@carbonx3/cx3.js 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/provider.js CHANGED
@@ -1,34 +1,118 @@
1
1
  "use strict";
2
2
 
3
3
  const { Cx3Error, Cx3HttpError, Cx3TxError } = require("./errors");
4
- const { readCallValue, sleep, normalizeAddress, walletApiKeyMode } = require("./utils");
4
+ const { readCallValue, sleep, normalizeAddress, walletApiKeyMode, toJsonSafe } = require("./utils");
5
5
  const { Contract } = require("./contract");
6
+ const { normalizeGenesisHash } = require("./digest");
6
7
 
7
8
  /**
8
9
  * Green Chain HTTP provider.
9
10
  *
10
- * Talks to `{baseUrl}/api/v1/...` — never CX3VM directly.
11
+ * Apps sign in to the CX3Api gateway with an OAuth2 client (`clientId` + `clientSecret`, created in
12
+ * CX3WebUI → Settings → API → Applications): the provider gets a one-hour access token at
13
+ * `POST /oauth/token`, sends it as `Authorization: Bearer`, and renews it before it expires. A wallet
14
+ * API key (`cx3wk_…`, `X-Api-Key`) still works during the move to applications. Without one of the
15
+ * two the provider refuses to start (CREDENTIALS_REQUIRED): no app development without credentials.
16
+ * Gateway routes: `/contracts/:address/call`, `/contracts/:address/send`, `/wallet/...`, …
17
+ * `devNode: true` talks to a development node that runs without auth (`/api/v1`). The SDK never sends
18
+ * HTTP Basic (the nodes' internal login). Never talks to CX3VM directly.
11
19
  */
20
+
21
+ // node route → CX3Api route (the gateway forwards the same body to the node)
22
+ function gatewayPath(path, body) {
23
+ const addr = body && (body.contractAddress || body.address);
24
+ if (path === "/contract/call" && addr) return `/contracts/${encodeURIComponent(addr)}/call`;
25
+ if (path === "/contract/send" && addr) return `/contracts/${encodeURIComponent(addr)}/send`;
26
+ if (path === "/deployContract") return "/contracts/deploy";
27
+ if (path === "/getBalanceAndNonce") return "/wallet/getBalanceAndNonce";
28
+ if (path === "/getNonce") return "/wallet/getNonce";
29
+ if (path === "/getContracts") return "/contracts/list";
30
+ const info = /^\/contract\/([^/]+)$/.exec(path);
31
+ if (info) return `/contracts/${info[1]}`;
32
+ return path;
33
+ }
34
+
35
+ function appendQuery(url, query) {
36
+ if (!query) return url;
37
+ const qs = new URLSearchParams();
38
+ for (const [k, v] of Object.entries(query)) {
39
+ if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
40
+ }
41
+ const s = qs.toString();
42
+ return s ? url + (url.includes("?") ? "&" : "?") + s : url;
43
+ }
44
+
45
+ // a private token moves only between its owner and its authorized wallets (the templates' guards)
46
+ const PRIVATE_GUARDED_METHODS = new Set(["transfer", "transferFrom", "mint"]);
47
+
48
+ // a bool view answers true / false on CX3JS and "true" / "false" on CX3WASM
49
+ function boolVerdict(v) {
50
+ if (v === true || v === 1 || v === "1" || v === "true") return true;
51
+ if (v === false || v === 0 || v === "0" || v === "false") return false;
52
+ return null;
53
+ }
54
+
55
+ // credentials come only from clientId / clientSecret or apiKey, never from an extra header
56
+ function withoutCredentialHeaders(headers) {
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(headers || {})) {
59
+ if (!/^(authorization|x-api-key)$/i.test(k)) out[k] = v;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ const CLIENT_ID_RE = /^cx3app_[a-f0-9]{24}$/i;
65
+ const CLIENT_SECRET_RE = /^cx3sec_[a-f0-9]{64}$/i;
66
+
12
67
  class Cx3Provider {
13
68
  /**
14
- * @param {string} baseUrl Green Chain node URL (`CX3_NODE_URL`)
69
+ * @param {string} baseUrl CX3Api gateway URL (`CX3_NODE_URL`, e.g. https://cx3api.carbonx3.com), or a development node with `devNode`
15
70
  * @param {object} [options]
16
- * @param {string} [options.apiPrefix="/api/v1"]
17
- * @param {{ user: string, pass: string }} [options.auth] Basic auth (legacy node → Green Chain)
18
- * @param {string} [options.apiKey] Wallet API key (`cx3wk_…`) for CX3Api `X-Api-Key`
19
- * @param {Record<string,string>} [options.headers] Extra headers
71
+ * @param {string} [options.clientId] OAuth2 client id (`cx3app_…`; env CX3_CLIENT_ID)
72
+ * @param {string} [options.clientSecret] OAuth2 client secret (`cx3sec_…`; env CX3_CLIENT_SECRET)
73
+ * @param {string|string[]} [options.scope] ask for fewer scopes than the app has ("read", "write", "deploy")
74
+ * @param {string} [options.apiKey] wallet API key (`cx3wk_…`; env CX3_API_KEY), until apps move to OAuth2
75
+ * @param {boolean} [options.devNode] a development node without auth (`/api/v1`), no credentials
76
+ * @param {string} [options.apiPrefix] ("" on the gateway, "/api/v1" on a development node)
77
+ * @param {Record<string,string>} [options.headers] Extra headers (Authorization / X-Api-Key are dropped)
20
78
  * @param {typeof fetch} [options.fetch] Custom fetch (tests)
21
79
  * @param {number} [options.timeoutMs]
22
80
  */
23
81
  constructor(baseUrl, options = {}) {
24
82
  if (!baseUrl) throw new Cx3Error("Cx3Provider requires baseUrl (CX3_NODE_URL)", "NO_BASE_URL");
25
83
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
26
- this.apiPrefix = options.apiPrefix != null ? options.apiPrefix : "/api/v1";
27
- this.auth = options.auth || null;
28
- this.apiKey = String(options.apiKey != null ? options.apiKey : (process.env.CX3_API_KEY || "")).trim();
29
- this.extraHeaders = options.headers || {};
84
+ if (options.auth != null) {
85
+ throw new Cx3Error(
86
+ "HTTP Basic is the nodes' internal login and is not sent by the SDK: create an application in CX3WebUI (Settings → API) and pass clientId / clientSecret",
87
+ "BASIC_AUTH_UNSUPPORTED"
88
+ );
89
+ }
90
+ const env = (name) => (process.env[name] || "").trim();
91
+ this.clientId = String(options.clientId != null ? options.clientId : env("CX3_CLIENT_ID")).trim();
92
+ this._clientSecret = String(options.clientSecret != null ? options.clientSecret : env("CX3_CLIENT_SECRET")).trim();
93
+ this.scope = Array.isArray(options.scope) ? options.scope.join(" ") : String(options.scope || "").trim();
94
+ this.apiKey = String(options.apiKey != null ? options.apiKey : env("CX3_API_KEY")).trim();
95
+ this.devNode = options.devNode === true;
96
+ this.oauth = !!(this.clientId || this._clientSecret);
97
+ if (this.oauth && (!CLIENT_ID_RE.test(this.clientId) || !CLIENT_SECRET_RE.test(this._clientSecret))) {
98
+ throw new Cx3Error("clientId must be cx3app_… and clientSecret cx3sec_… (CX3WebUI → Settings → API → Applications)", "INVALID_CLIENT_CREDENTIALS");
99
+ }
100
+ const hasKey = walletApiKeyMode(this.apiKey) !== "public";
101
+ if (!this.oauth && !hasKey && !this.devNode) {
102
+ throw new Cx3Error(
103
+ "App credentials required: create an application in CX3WebUI (Settings → API → Applications) and pass clientId / clientSecret (or set CX3_CLIENT_ID / CX3_CLIENT_SECRET)",
104
+ "CREDENTIALS_REQUIRED"
105
+ );
106
+ }
107
+ this.gateway = !this.devNode;
108
+ this.apiPrefix = options.apiPrefix != null ? options.apiPrefix : (this.gateway ? "" : "/api/v1");
109
+ this.extraHeaders = withoutCredentialHeaders(options.headers);
30
110
  this.fetchImpl = options.fetch || globalThis.fetch;
31
111
  this.timeoutMs = options.timeoutMs || 60000;
112
+ this._token = null; // { value, refreshAt, scope }
113
+ this._tokenPromise = null;
114
+ // token address → { private, owners } from its registry record (set at deploy)
115
+ this._tokenRecords = new Map();
32
116
  if (typeof this.fetchImpl !== "function") {
33
117
  throw new Cx3Error("fetch is not available — use Node 18+ or pass options.fetch", "NO_FETCH");
34
118
  }
@@ -38,24 +122,51 @@ class Cx3Provider {
38
122
  return walletApiKeyMode(this.apiKey);
39
123
  }
40
124
 
41
- _headers() {
125
+ /** "oauth" | "apiKey" | "devNode" */
126
+ get authMode() {
127
+ if (this.devNode && !this.oauth && walletApiKeyMode(this.apiKey) === "public") return "devNode";
128
+ return this.oauth ? "oauth" : "apiKey";
129
+ }
130
+
131
+ /**
132
+ * The current access token (OAuth2 apps): fetched once, shared by concurrent calls, renewed a
133
+ * minute before it expires or when the gateway says it is no longer valid.
134
+ */
135
+ async getAccessToken(force = false) {
136
+ if (!this.oauth) return null;
137
+ if (!force && this._token && Date.now() < this._token.refreshAt) return this._token.value;
138
+ if (!this._tokenPromise) {
139
+ this._tokenPromise = this._fetchToken().finally(() => { this._tokenPromise = null; });
140
+ }
141
+ return this._tokenPromise;
142
+ }
143
+
144
+ async _fetchToken() {
145
+ const form = new URLSearchParams({ grant_type: "client_credentials", client_id: this.clientId, client_secret: this._clientSecret });
146
+ if (this.scope) form.set("scope", this.scope);
147
+ const { res, json } = await this._fetchJson(`${this.baseUrl}/oauth/token`, {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
150
+ body: form.toString()
151
+ });
152
+ if (!res.ok || !json || !json.access_token) {
153
+ const code = (json && json.error) || "OAUTH_TOKEN_FAILED";
154
+ throw new Cx3HttpError((json && json.error_description) || code, res.status, { ...(json || {}), error: code });
155
+ }
156
+ const ttl = Number(json.expires_in) > 0 ? Number(json.expires_in) : 3600;
157
+ this._token = { value: json.access_token, refreshAt: Date.now() + Math.max(30, ttl - 60) * 1000, scope: json.scope || "" };
158
+ return this._token.value;
159
+ }
160
+
161
+ async _headers(forceToken = false) {
42
162
  const headers = {
43
163
  "Content-Type": "application/json",
44
164
  ...this.extraHeaders
45
165
  };
46
- if (/^cx3wk_[a-f0-9]{48}$/i.test(this.apiKey)) {
166
+ if (this.oauth) {
167
+ headers.Authorization = `Bearer ${await this.getAccessToken(forceToken)}`;
168
+ } else if (/^cx3wk_[a-f0-9]{48}$/i.test(this.apiKey)) {
47
169
  headers["X-Api-Key"] = this.apiKey;
48
- return headers;
49
- }
50
- if (this.auth && this.auth.user && this.auth.pass) {
51
- const token = Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64");
52
- headers.Authorization = `Basic ${token}`;
53
- } else {
54
- const user = process.env.CX3_INTERNAL_AUTH_USER;
55
- const pass = process.env.CX3_INTERNAL_AUTH_PASS;
56
- if (user && pass) {
57
- headers.Authorization = `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`;
58
- }
59
170
  }
60
171
  return headers;
61
172
  }
@@ -81,56 +192,218 @@ class Cx3Provider {
81
192
  * @param {{ method?: string, body?: object, query?: Record<string,string|number> }} [opts]
82
193
  */
83
194
  async request(path, opts = {}) {
84
- if (this.apiKeyMode === "invalid") {
195
+ if (!this.oauth && this.apiKeyMode === "invalid") {
85
196
  throw new Cx3Error("api_key_required", "api_key_required");
86
197
  }
87
198
  const method = opts.method || "GET";
88
- let url = `${this.baseUrl}${this.apiPrefix}${path.startsWith("/") ? path : `/${path}`}`;
89
- if (opts.query) {
90
- const qs = new URLSearchParams();
91
- for (const [k, v] of Object.entries(opts.query)) {
92
- if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
93
- }
94
- const s = qs.toString();
95
- if (s) url += (url.includes("?") ? "&" : "?") + s;
199
+ let route = path.startsWith("/") ? path : `/${path}`;
200
+ if (this.gateway) route = gatewayPath(route, opts.body);
201
+ const url = appendQuery(`${this.baseUrl}${this.apiPrefix}${route}`, opts.query);
202
+ const body = opts.body !== undefined ? JSON.stringify(opts.body) : undefined;
203
+ let { res, json } = await this._fetchJson(url, { method, headers: await this._headers(), body });
204
+ // the token expired early or the gateway restarted with a new key: one fresh token, one retry
205
+ if (this.oauth && res.status === 401 && json && json.error === "invalid_token") {
206
+ ({ res, json } = await this._fetchJson(url, { method, headers: await this._headers(true), body }));
96
207
  }
208
+ if (!res.ok) {
209
+ throw new Cx3HttpError(
210
+ json.error || json.message || `HTTP ${res.status}`,
211
+ res.status,
212
+ json
213
+ );
214
+ }
215
+ return json;
216
+ }
97
217
 
218
+ /** fetch with the provider timeout; the body is parsed as JSON ({} when it is not). */
219
+ async _fetchJson(url, init) {
98
220
  const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
99
221
  const timer = controller
100
222
  ? setTimeout(() => controller.abort(), this.timeoutMs)
101
223
  : null;
102
-
103
224
  try {
104
- const res = await this.fetchImpl(url, {
105
- method,
106
- headers: this._headers(),
107
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
108
- signal: controller ? controller.signal : undefined
109
- });
225
+ const res = await this.fetchImpl(url, { ...init, signal: controller ? controller.signal : undefined });
110
226
  const json = await res.json().catch(() => ({}));
111
- if (!res.ok) {
112
- throw new Cx3HttpError(
113
- json.error || json.message || `HTTP ${res.status}`,
114
- res.status,
115
- json
116
- );
117
- }
118
- return json;
227
+ return { res, json };
119
228
  } finally {
120
229
  if (timer) clearTimeout(timer);
121
230
  }
122
231
  }
123
232
 
233
+ /**
234
+ * GET on the gateway's public read group (`/dapp/v1{route}`: wallet tokens, activity, CarbonFi
235
+ * pools, WIT). These are public chain data, read without credentials; a development node has no
236
+ * such group (DAPP_ONLY).
237
+ */
238
+ async _dappRead(route, query) {
239
+ if (!this.gateway) {
240
+ throw new Cx3Error(`${route} is served by the CX3Api gateway; a node does not serve it`, "DAPP_ONLY");
241
+ }
242
+ const headers = { "Content-Type": "application/json", ...this.extraHeaders };
243
+ const { res, json } = await this._fetchJson(appendQuery(`${this.baseUrl}/dapp/v1${route}`, query), { method: "GET", headers });
244
+ if (!res.ok) {
245
+ throw new Cx3HttpError(json.error || json.message || `HTTP ${res.status}`, res.status, json);
246
+ }
247
+ return json;
248
+ }
249
+
250
+ /**
251
+ * Every registry token with this wallet's balance in one read (`/dapp/v1/accounts/:address/tokens`):
252
+ * `{ tokens: [{ address, name, symbol, decimals, standard, balance, mine, private?, authorized? }], total, unread }`.
253
+ * A private token is listed only for its owner, its authorized wallets and its holders.
254
+ */
255
+ async getTokens(address) {
256
+ const addr = normalizeAddress(address);
257
+ if (!addr) throw new Cx3Error("address required", "NO_ADDRESS");
258
+ return this._dappRead(`/accounts/${encodeURIComponent(addr)}/tokens`);
259
+ }
260
+
261
+ /**
262
+ * Wallet history, newest first, without block rewards (`/dapp/v1/accounts/:address/activity`).
263
+ * Next page: `{ before: previousPage.nextBefore }`; `nextBefore` is null on the last page.
264
+ */
265
+ async getActivity(address, opts = {}) {
266
+ const addr = normalizeAddress(address);
267
+ if (!addr) throw new Cx3Error("address required", "NO_ADDRESS");
268
+ return this._dappRead(`/accounts/${encodeURIComponent(addr)}/activity`, { limit: opts.limit, before: opts.before });
269
+ }
270
+
271
+ /** CarbonFi pools (`/dapp/v1/dex/pairs`): `{ pairs }`. */
272
+ async getDexPairs() {
273
+ return this._dappRead("/dex/pairs");
274
+ }
275
+
276
+ /** A wallet's LP token balance in one CarbonFi pool: `{ balance, totalSupply, lpContractAddress, onChain }`. */
277
+ async getLpBalance(pairId, address) {
278
+ const id = String(pairId || "").replace(/^0x/i, "");
279
+ if (!/^[0-9a-f]{40}$/i.test(id)) throw new Cx3Error("pair id must be 40 hex characters", "INVALID_PAIR_ID");
280
+ const addr = normalizeAddress(address);
281
+ if (!addr) throw new Cx3Error("address required", "NO_ADDRESS");
282
+ return this._dappRead(`/dex/pairs/${id.toLowerCase()}/lp/${encodeURIComponent(addr)}`);
283
+ }
284
+
124
285
  async getContractInfo(address) {
125
286
  const addr = encodeURIComponent(address);
126
287
  return this.request(`/contract/${addr}`);
127
288
  }
128
289
 
290
+ /**
291
+ * Is this a private token (CX3WebUI "Private" deploy, or `Cx3TokenFactory` with
292
+ * `visibility: "private"`)? Read from its registry record (`metadata.visibility`), which the
293
+ * deploy sets for good, so the answer is kept per address.
294
+ */
295
+ async isPrivateToken(address) {
296
+ return (await this._tokenRecord(address)).private;
297
+ }
298
+
299
+ async _tokenRecord(address) {
300
+ const key = normalizeAddress(address);
301
+ if (this._tokenRecords.has(key)) return this._tokenRecords.get(key);
302
+ const info = await this.getContractInfo(address);
303
+ const c = info && info.contract;
304
+ if (!c || typeof c !== "object") throw new Cx3Error("contract record not available", "CONTRACT_NOT_FOUND");
305
+ const rec = {
306
+ private: !!(c.metadata && typeof c.metadata === "object" && c.metadata.visibility === "private"),
307
+ owners: [c.owner, c.deployer].map(normalizeAddress).filter((a) => /^0x[0-9a-f]{40}$/.test(a))
308
+ };
309
+ this._tokenRecords.set(key, rec);
310
+ return rec;
311
+ }
312
+
313
+ /**
314
+ * May this wallet hold and move the token? The owner always may (the templates' guards say
315
+ * `owner || whitelist[a]`); any other wallet when the token's `isWhitelisted` view says so (the
316
+ * authorized list of a private token, the whitelist of a public one). Meaningful for a token with
317
+ * such a list: the node answers false for a token without one.
318
+ * @returns {Promise<boolean|null>} null = the node did not answer
319
+ */
320
+ async isTokenAuthorized(token, wallet) {
321
+ const a = normalizeAddress(wallet);
322
+ if (!/^0x[0-9a-f]{40}$/.test(a)) throw new Cx3Error("wallet address required", "NO_ADDRESS");
323
+ try {
324
+ if ((await this._tokenRecord(token)).owners.includes(a)) return true;
325
+ } catch (_) {
326
+ // no registry record: the view decides
327
+ }
328
+ try {
329
+ // the templates name the parameter addr or account; the node takes the one its ABI has
330
+ const r = await this.call(token, "isWhitelisted", { addr: a, account: a, address: a });
331
+ if (!r || r.success === false) return null;
332
+ return boolVerdict(r.value);
333
+ } catch (_) {
334
+ return null;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Before a signed transfer / transferFrom / mint of a private token: both ends must be the owner or
340
+ * authorized wallets, otherwise the block refuses it. Throws PRIVATE_TOKEN_SENDER or
341
+ * PRIVATE_TOKEN_RECIPIENT; an unreadable record or view gives no verdict (the contract decides).
342
+ */
343
+ async checkPrivateTokenSend(contractAddress, method, params, from) {
344
+ if (!PRIVATE_GUARDED_METHODS.has(method)) return;
345
+ let priv = false;
346
+ try {
347
+ priv = await this.isPrivateToken(contractAddress);
348
+ } catch (_) {
349
+ return;
350
+ }
351
+ if (!priv) return;
352
+ const p = params && typeof params === "object" ? params : {};
353
+ const sender = method === "transferFrom" ? p.from : from;
354
+ const recipient = p.to != null ? p.to : p.recipient;
355
+ if (sender && (await this.isTokenAuthorized(contractAddress, sender)) === false) {
356
+ throw new Cx3Error("The sender is not an authorized wallet of this private token", "PRIVATE_TOKEN_SENDER",
357
+ { token: contractAddress, wallet: normalizeAddress(sender) });
358
+ }
359
+ if (recipient && (await this.isTokenAuthorized(contractAddress, recipient)) === false) {
360
+ throw new Cx3Error("The recipient is not an authorized wallet of this private token", "PRIVATE_TOKEN_RECIPIENT",
361
+ { token: contractAddress, wallet: normalizeAddress(recipient) });
362
+ }
363
+ }
364
+
129
365
  async getAbi(address) {
130
366
  const addr = encodeURIComponent(address);
131
367
  return this.request(`/contracts/${addr}/abi`);
132
368
  }
133
369
 
370
+ /**
371
+ * WIT interface of a CX3WASM contract: `{ target, wit }`.
372
+ * On the gateway: `/dapp/v1/contracts/:address/wit` (404 WIT_NOT_AVAILABLE for CX3JS; the
373
+ * source itself is never served to apps); a development node: `witCode` of the source record.
374
+ */
375
+ async getWit(address) {
376
+ const addr = encodeURIComponent(address);
377
+ if (this.gateway) return this._dappRead(`/contracts/${addr}/wit`);
378
+ const src = await this.request(`/contracts/${addr}/source`);
379
+ return { success: src.success !== false, address, target: src.target || null, wit: src.witCode || "" };
380
+ }
381
+
382
+ /** Chain head `{ height, hash, timestamp, chainId? }` (chainId from the gateway). */
383
+ async getStatus() {
384
+ if (this.gateway) return this._rejectEnvelope(await this._dappRead("/status"));
385
+ const blk = this._rejectEnvelope(await this.request("/getLastBlock"));
386
+ return { success: true, height: blk.index != null ? blk.index : blk.height, hash: blk.hash || null, timestamp: blk.timestamp || null };
387
+ }
388
+
389
+ /**
390
+ * `{ chainId, genesisHash }`: the network identity a native transfer signs over (the gateway's
391
+ * /status, a development node's /chain/identity). Throws NO_CHAIN_IDENTITY when either is missing.
392
+ */
393
+ async getChainIdentity() {
394
+ // changes only with a new genesis: kept for a minute (one request per signature otherwise)
395
+ const hit = this._chainIdentity;
396
+ if (hit && Date.now() - hit.at < 60000) return { ...hit.value };
397
+ const r = this.gateway
398
+ ? this._rejectEnvelope(await this._dappRead("/status"))
399
+ : this._rejectEnvelope(await this.request("/chain/identity"));
400
+ const chainId = typeof r.chainId === "string" ? r.chainId.trim() : "";
401
+ const genesisHash = normalizeGenesisHash(r.genesisHash);
402
+ if (!chainId || !genesisHash) throw new Cx3Error("network identity unavailable", "NO_CHAIN_IDENTITY");
403
+ this._chainIdentity = { at: Date.now(), value: { chainId, genesisHash } };
404
+ return { chainId, genesisHash };
405
+ }
406
+
134
407
  async getEvents(address, typeOrOpts = "", opts = {}) {
135
408
  const extra = (typeOrOpts && typeof typeOrOpts === "object")
136
409
  ? typeOrOpts
@@ -223,21 +496,23 @@ class Cx3Provider {
223
496
  }
224
497
 
225
498
  /**
226
- * Bind / verify the presented cx3wk_ API key against a wallet address.
227
- * Empty key → public (any wallet), `{ ok: true, public: true }` — same as dApp whoami.
228
- * Valid cx3wk_ → private: GET `/whoami` then CX3Api `/wallet/api-key/bind` on 404.
499
+ * Check that this wallet is the one the credentials belong to (an app and a wallet API key are
500
+ * both bound to one wallet): `{ ok: true }`, or `wallet_mismatch`.
501
+ * OAuth2 app → GET `/wallet/api-key/bind`; cx3wk_ key → GET `/whoami`, then `/wallet/api-key/bind` on 404.
502
+ * A development node has no credentials: `{ ok: true, devNode: true }`.
229
503
  */
230
504
  async bindApiKey(address) {
231
505
  const addr = normalizeAddress(address);
232
506
  if (!addr) throw new Cx3Error("address required", "NO_ADDRESS");
507
+ const query = { address: addr };
508
+ if (this.oauth) return this._rejectEnvelope(await this.request("/wallet/api-key/bind", { query }));
233
509
  const mode = this.apiKeyMode;
234
510
  if (mode === "invalid") {
235
511
  throw new Cx3Error("api_key_required", "api_key_required");
236
512
  }
237
513
  if (mode === "public") {
238
- return { ok: true, public: true };
514
+ return { ok: true, devNode: true };
239
515
  }
240
- const query = { address: addr };
241
516
  try {
242
517
  const json = await this.request("/whoami", { query });
243
518
  return this._rejectEnvelope(json);
@@ -250,8 +525,18 @@ class Cx3Provider {
250
525
  }
251
526
  }
252
527
 
253
- async listContracts({ limit = 50, offset = 0 } = {}) {
254
- return this.request("/getContracts", { query: { limit, offset } });
528
+ /**
529
+ * Registry list. The gateway shows a private token only when the app's wallet is its owner or an
530
+ * authorized wallet; `owner`, `standard`, `type` filter and `live` adds supply / paused.
531
+ */
532
+ async listContracts({ limit = 50, offset = 0, viewer, owner, standard, type, live } = {}) {
533
+ const query = { limit, offset };
534
+ if (viewer) query.viewer = normalizeAddress(viewer);
535
+ if (owner) query.owner = normalizeAddress(owner);
536
+ if (standard) query.standard = standard;
537
+ if (type) query.type = type;
538
+ if (live) query.live = 1;
539
+ return this.request("/getContracts", { query });
255
540
  }
256
541
 
257
542
  /**
@@ -261,7 +546,7 @@ class Cx3Provider {
261
546
  const body = {
262
547
  contractAddress,
263
548
  method,
264
- params: params || {}
549
+ params: toJsonSafe(params || {})
265
550
  };
266
551
  if (sender) body.fromAddress = normalizeAddress(sender);
267
552
  const json = await this.request("/contract/call", { method: "POST", body });
@@ -279,7 +564,7 @@ class Cx3Provider {
279
564
  const body = {
280
565
  contractAddress,
281
566
  method,
282
- params: params || {},
567
+ params: toJsonSafe(params || {}),
283
568
  fromAddress: normalizeAddress(extras.fromAddress || extras.sender || ""),
284
569
  ...extras
285
570
  };
@@ -297,9 +582,9 @@ class Cx3Provider {
297
582
  try {
298
583
  return await this.request(`/tx/${hash}/status`);
299
584
  } catch (e) {
300
- if (e.status === 404) {
585
+ if (e.status === 404 && !this.gateway) {
301
586
  const url = `${this.baseUrl}/tx/${hash}/status`;
302
- const res = await this.fetchImpl(url, { headers: this._headers() });
587
+ const res = await this.fetchImpl(url, { headers: await this._headers() });
303
588
  const json = await res.json().catch(() => ({}));
304
589
  if (!res.ok) throw new Cx3HttpError(json.error || `HTTP ${res.status}`, res.status, json);
305
590
  return json;
package/src/utils.js CHANGED
@@ -48,6 +48,24 @@ function isPlainObject(v) {
48
48
  return v !== null && typeof v === "object" && !Array.isArray(v);
49
49
  }
50
50
 
51
+ /**
52
+ * BigInt → decimal string, recursively, so uint256 amounts can be passed as
53
+ * BigInt. Runs BEFORE signing: the node rebuilds the digest from the JSON it
54
+ * receives, so the signed params and the sent params must be the same values.
55
+ */
56
+ function toJsonSafe(value) {
57
+ if (typeof value === "bigint") return value.toString();
58
+ if (Array.isArray(value)) return value.map(toJsonSafe);
59
+ // only literal objects: Date / Buffer keep their own toJSON
60
+ const proto = value !== null && typeof value === "object" ? Object.getPrototypeOf(value) : undefined;
61
+ if (proto === Object.prototype || proto === null) {
62
+ const out = {};
63
+ for (const [k, v] of Object.entries(value)) out[k] = toJsonSafe(v);
64
+ return out;
65
+ }
66
+ return value;
67
+ }
68
+
51
69
  /**
52
70
  * Same rules as AppSmokeTest `cx3_dapp_key_mode`:
53
71
  * empty → public (any wallet); valid cx3wk_ → private (that wallet); else invalid.
@@ -66,5 +84,6 @@ module.exports = {
66
84
  normalizeAddress,
67
85
  sleep,
68
86
  isPlainObject,
87
+ toJsonSafe,
69
88
  walletApiKeyMode
70
89
  };