@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/README.md +229 -51
- package/index.d.ts +88 -15
- package/index.js +9 -5
- package/package.json +13 -6
- package/src/abi.js +8 -1
- package/src/contract.js +5 -0
- package/src/crypto.js +6 -4
- package/src/digest.js +4 -10
- package/src/factory.js +105 -1
- package/src/provider.js +325 -59
- package/src/utils.js +19 -0
- package/src/wallet.js +38 -6
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
|
-
*
|
|
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
|
|
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.
|
|
17
|
-
* @param {
|
|
18
|
-
* @param {string} [options.
|
|
19
|
-
* @param {
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
89
|
-
if (opts.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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,
|
|
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
|
-
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|