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