@carbonx3/cx3.js 0.3.8 → 0.3.10
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 +75 -8
- package/index.d.ts +39 -3
- package/index.js +3 -1
- package/package.json +1 -1
- package/src/contract.js +6 -1
- package/src/digest.js +44 -1
- package/src/provider.js +33 -14
- package/src/transaction.js +20 -5
- package/src/wallet.js +153 -22
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ CX3js connects server-side applications to CarbonX3 Green Chain through the CX3A
|
|
|
14
14
|
|
|
15
15
|
- **OAuth2 application access**: client credentials from CX3WebUI; the access token is requested, cached and renewed for you.
|
|
16
16
|
- **Local signing**: private keys stay in your process, and every write carries the wallet's secp256k1 signature.
|
|
17
|
+
- **Secrets stay out of logs**: a wallet, a provider or a result that is logged or sent as JSON never contains the private key, the client secret or the access token.
|
|
18
|
+
- **Honest results**: a send or transfer the network refuses is reported as failed, and `"confirmed"` always comes with the block it is in.
|
|
17
19
|
- **ABI-bound contracts**: `token.balanceOf(owner)` and `token.transfer(to, amount)`, with the ABI loaded from the chain.
|
|
18
20
|
- **Token deploys**: CX30, CX31, CX300, CX315 and CX3192, plus the green standards CX337–CX341.
|
|
19
21
|
- **Private tokens**: deploy them, and check authorized wallets before anything is signed.
|
|
@@ -66,10 +68,13 @@ async function main() {
|
|
|
66
68
|
|
|
67
69
|
// Wallet-signed token transfer (BigInt or decimal string)
|
|
68
70
|
const tx = await token.transfer("0xRecipient…", 10n ** 18n);
|
|
69
|
-
await tx.wait();
|
|
71
|
+
await tx.wait(); // resolves when it is in a block, rejects when it failed
|
|
70
72
|
|
|
71
|
-
// Native CX3 transfer
|
|
72
|
-
await wallet.transferNative("0xRecipient…", 1.5);
|
|
73
|
+
// Native CX3 transfer: check the answer, then wait for the block
|
|
74
|
+
const pay = await wallet.transferNative("0xRecipient…", 1.5);
|
|
75
|
+
if (!pay.success) throw new Error(`Transfer refused: ${pay.error}`); // for example INSUFFICIENT_BALANCE
|
|
76
|
+
const receipt = await pay.wait();
|
|
77
|
+
console.log(pay.txHash, receipt.blockHeight);
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
main().catch((err) => {
|
|
@@ -100,6 +105,21 @@ const provider = new Cx3Provider("https://cx3api.carbonx3.com", { apiKey: proces
|
|
|
100
105
|
|
|
101
106
|
**No HTTP Basic.** HTTP Basic is the internal login between Green Chain nodes and CarbonX3 services, not an application credential, so the SDK never sends it: the `auth` option throws `BASIC_AUTH_UNSUPPORTED`, and `headers` cannot carry `Authorization` or `X-Api-Key`.
|
|
102
107
|
|
|
108
|
+
## Keeping secrets out of logs
|
|
109
|
+
|
|
110
|
+
A wallet's private key and a provider's client secret, API key and access token are private fields of their objects. Nothing that prints, serializes or copies an object can reach them:
|
|
111
|
+
|
|
112
|
+
```javascript
|
|
113
|
+
JSON.stringify(wallet); // {"address":"0x…","publicKey":"02…"}
|
|
114
|
+
console.log(provider); // Cx3Provider {"baseUrl":"https://cx3api.carbonx3.com","authMode":"oauth","clientId":"cx3app_…"}
|
|
115
|
+
res.json(await token.transfer(to, amount)); // the result's own fields; no provider, no wallet
|
|
116
|
+
const copy = { ...wallet }; // address, publicKey and provider; no key
|
|
117
|
+
|
|
118
|
+
const key = wallet.exportPrivateKey(); // the one way to read the key back (for example to store a new wallet)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`Cx3Wallet.createRandom()` makes a new key in memory only: call `exportPrivateKey()` and store it safely, or the wallet is gone when the process ends. `provider.clearAccessToken()` forgets the held access token, so the next request asks for a new one. Since 0.3.10 there is no `wallet._privateKey`, `provider.apiKey` or `provider._clientSecret` to read.
|
|
122
|
+
|
|
103
123
|
## Reading chain data
|
|
104
124
|
|
|
105
125
|
Public chain data, served by the gateway to every application (a development node answers `DAPP_ONLY`):
|
|
@@ -153,14 +173,55 @@ Gas is paid in CX3 to the validator that produces the block: gas used × gas pri
|
|
|
153
173
|
|
|
154
174
|
Too little balance for a native transfer fails with `INSUFFICIENT_BALANCE`. A native transfer's gas limit and gas price are `0` (the chain's defaults: 21,000 and 0.0001) or 21,000–100,000 and at least the chain floor of 0.0001 (`GAS_LIMIT_TOO_LOW`, `GAS_LIMIT_TOO_HIGH`, `GAS_PRICE_TOO_LOW`), and its amount is greater than 0 with at most 6 decimal places, because CX3 has 6. `transferNative` checks the amount before signing (`INVALID_AMOUNT`, `INVALID_AMOUNT_PRECISION`).
|
|
155
175
|
|
|
176
|
+
### The result of a send or transfer
|
|
177
|
+
|
|
178
|
+
`token.transfer(…)`, `wallet.sendTransaction(…)` and `wallet.transferNative(…)` return a `TransactionResponse`:
|
|
179
|
+
|
|
180
|
+
| Field | Meaning |
|
|
181
|
+
|-------|---------|
|
|
182
|
+
| `success` | `true` only when the answer says so or names the transaction (or the request it queued) and carries no error; `false` for a refused one |
|
|
183
|
+
| `status` | `"confirmed"` (always together with `blockHeight`), `"pending"` (accepted, in no block yet; also queued and dropped sends) or `"failed"` |
|
|
184
|
+
| `error` | The network's code for a refused send or transfer (for example `INSUFFICIENT_BALANCE`, `INVALID_NONCE_ORDER`); `null` otherwise |
|
|
185
|
+
| `txHash` | The transaction hash, or `null` when the answer names none |
|
|
186
|
+
| `blockHeight` | The block the transaction is in, once known |
|
|
187
|
+
| `requestId`, `dropped`, `finalAfter`, `hint` | For contract sends: see below |
|
|
188
|
+
| `raw` | The gateway's answer as it came |
|
|
189
|
+
|
|
190
|
+
The network answers a refused native transfer with HTTP 200 and `{ error, state }`, so nothing is thrown at that point: read `tx.success`, or call `tx.wait()`, which rejects with the same code.
|
|
191
|
+
|
|
192
|
+
```javascript
|
|
193
|
+
const tx = await wallet.transferNative(to, 25);
|
|
194
|
+
if (!tx.success) {
|
|
195
|
+
console.error("Refused:", tx.error); // nothing was sent; fix the cause before signing again
|
|
196
|
+
} else {
|
|
197
|
+
const receipt = await tx.wait(); // "pending" until it is in a block
|
|
198
|
+
console.log("Paid in block", receipt.blockHeight, tx.txHash);
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Record a payment as made only after `tx.wait()` resolved (or `tx.status === "confirmed"`), never on `success` alone.
|
|
203
|
+
|
|
156
204
|
### Waiting for a transaction
|
|
157
205
|
|
|
158
206
|
A contract send waits for its block by default (the node waits up to 25 seconds); with `{ wait: 0 }` it returns at once. `tx.wait()` polls the transaction status until it is confirmed or failed for good:
|
|
159
207
|
|
|
208
|
+
- **A refused answer rejects.** `tx.wait()` of a result with `success: false` rejects with a `Cx3TxError` carrying `tx.error`. When the answer names no transaction at all, it rejects with `NO_TX_HASH`: there is nothing to follow.
|
|
160
209
|
- **Pending is not failed.** `tx.status` is `"pending"` for `{ wait: 0 }`, for a queued send (HTTP 202 `queued: true`, code `TX_TIMEOUT`: not in a block within the node's wait) and for a dropped send. `tx.success` stays `true`, and `tx.wait()` follows the transaction. A queued or dropped deploy is `status: "pending"` too: the token factory gives `result.contract` only after `result.wait()` sees it confirmed.
|
|
161
210
|
- **Dropped sends.** A send the node trimmed from its block comes back as HTTP 202 with `tx.dropped === true` and `tx.finalAfter` (ms epoch, about 12 minutes after the signed timestamp). Until then another validator may still include it, so by default `tx.wait()` keeps waiting until `finalAfter` plus 30 seconds. After that the status says `failed`, and `tx.wait()` rejects with `TX_FAILED`.
|
|
162
211
|
- **Only a final failure rejects with `TX_FAILED`.** `TX_TRACK_TIMEOUT` (your own `timeoutMs`, otherwise 60 seconds or until `finalAfter` for a dropped send) means the transaction is still pending: its `receipt` has `status: "pending"`, `dropped` and `finalAfter`. A network error, a 429 or a 5xx answer while polling does not end the wait.
|
|
163
212
|
- **Never sign the same call again while it can still run.** A new signature is a second call. Every send answer carries `tx.requestId`, and `provider.getRequest(tx.requestId, wallet.address)` tells what became of it: `executed`, `pending`, `submitted` or `unknown`.
|
|
213
|
+
- **When the answer is lost, the error still names the request.** A contract send that gets no answer (connection lost, timeout) rejects with `SEND_OUTCOME_UNKNOWN`. That error, like every other error of a send, carries `err.requestId` and `err.signedRequest`. Look the request up, or send the same signed request again with `wallet.resendTransaction(err.signedRequest)`: the gateway knows it by its id, so it cannot run twice. The signed request holds no key and can be stored.
|
|
214
|
+
|
|
215
|
+
```javascript
|
|
216
|
+
let tx;
|
|
217
|
+
try {
|
|
218
|
+
tx = await wallet.sendTransaction(token, "transfer", { to, amount: "1000" });
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (err.code !== "SEND_OUTCOME_UNKNOWN") throw err;
|
|
221
|
+
tx = await wallet.resendTransaction(err.signedRequest); // same signature, same call
|
|
222
|
+
}
|
|
223
|
+
await tx.wait();
|
|
224
|
+
```
|
|
164
225
|
|
|
165
226
|
```javascript
|
|
166
227
|
const tx = await token.transfer(to, amount);
|
|
@@ -219,7 +280,7 @@ A private CX31, CX300, CX315 or CX3192 takes its CX3WebUI source as `sourceCode`
|
|
|
219
280
|
|
|
220
281
|
Digests are UTF-8 strings, hashed with SHA-256 and signed with secp256k1, byte-identical to what the Green Chain node verifies. Each kind starts with its own prefix (signing domains), so a signature of one kind never verifies as another; `chainId` and `genesisHash` are the network's (`provider.getChainIdentity()`, fetched by the wallet methods themselves) and addresses are lower-case `0x` + 40 hex:
|
|
221
282
|
|
|
222
|
-
- **Deploy:** `
|
|
283
|
+
- **Deploy:** `CX3_DEPLOY_V2|` + `JSON.stringify([chainId, genesisHash, owner, standard, name, symbol, decimals, initialSupply, contentHash, timestamp])`. `contentHash` is `Cx3Wallet.deployContentHash(request)`: the SHA-256 of the code, the constructor arguments, the target and the template switches of the request, so the signature is good for that content only. `deployContract` computes it, signs it and sends it. `signDeploy` without a `contentHash` signs the older `CX3_DEPLOY_V1` (the same list without it), which the network still accepts.
|
|
223
284
|
- **Contract send:** `CX3_CALL_V1|` + `JSON.stringify([chainId, genesisHash, contractAddress, method, params, fromAddress, timestamp])`. The chain checks this signature again inside the relayed transaction: a call runs as your wallet only with it.
|
|
224
285
|
- **Owner action:** `CX3_OWNER_V1|` + `JSON.stringify([chainId, genesisHash, action, address, owner, timestamp])`
|
|
225
286
|
- **Site message:** `0x19` + `"CX3 Signed Message:\n"` + UTF-8 length + message (`signPersonalMessage`), the way the CX3Wallet extension and app sign `cx3_sign_message`
|
|
@@ -231,7 +292,7 @@ The same building blocks are exported: `buildDeployDigest`, `buildSendDigest`, `
|
|
|
231
292
|
|
|
232
293
|
## Error handling
|
|
233
294
|
|
|
234
|
-
Every error is a `Cx3Error` with a `code`. `Cx3HttpError` adds the HTTP `status` and the response `body` (with `requestId` for a contract send); `Cx3TxError` adds the transaction `receipt`. A send or native transfer that the node refuses with HTTP 200 returns `tx.success === false`
|
|
295
|
+
Every error is a `Cx3Error` with a `code`. `Cx3HttpError` adds the HTTP `status` and the response `body` (with `requestId` for a contract send); `Cx3TxError` adds the transaction `receipt`. A send or native transfer that the node refuses with HTTP 200 does not throw: it returns `tx.success === false`, `tx.status === "failed"` and the code in `tx.error`, and `tx.wait()` rejects with a `Cx3TxError` of that code. Errors and results can be logged as they are; they carry no key, secret or token.
|
|
235
296
|
|
|
236
297
|
```javascript
|
|
237
298
|
const { Cx3HttpError } = require("@carbonx3/cx3.js");
|
|
@@ -261,6 +322,11 @@ try {
|
|
|
261
322
|
| `PRIVATE_TOKEN_SENDER`, `PRIVATE_TOKEN_RECIPIENT`, `PRIVATE_TOKEN` | Private token rules (see above) |
|
|
262
323
|
| `TX_TIMEOUT` | The node's 25-second wait ended before a block: queued, not final. A send answer with this code is pending and `tx.wait()` follows it; without a tx hash the outcome is open (look up its `requestId`) |
|
|
263
324
|
| `TX_FAILED` | The transaction failed for good (a dropped send only after its `finalAfter`) |
|
|
325
|
+
| `TX_REFUSED` | `tx.error` of an answer that neither reports success nor names a transaction or a reason |
|
|
326
|
+
| `NO_TX_HASH` | `tx.wait()` was called on an answer that names no transaction: there is nothing to follow |
|
|
327
|
+
| `SEND_OUTCOME_UNKNOWN` | A contract send got no answer; the call may still run. Use `err.requestId` / `err.signedRequest` (look up or `resendTransaction`), never a new signature |
|
|
328
|
+
| `INVALID_SIGNED_REQUEST` | `resendTransaction` was given something that is not this wallet's signed request |
|
|
329
|
+
| `SIGN_REFUSED_TRANSACTION_PAYLOAD` | `wallet.signMessage` was given a text the chain accepts as a transaction or call; sign outside messages with `signPersonalMessage` |
|
|
264
330
|
| `TX_TRACK_TIMEOUT` | `tx.wait()` stopped while the transaction was still pending: not a failure, do not sign it again |
|
|
265
331
|
| `REPLAYED_REQUEST` | This signed request was already received or already ran (409); the answer does not say how it ended. Look it up with `provider.getRequest(err.body.requestId, wallet.address)` and never sign it again |
|
|
266
332
|
| `REQUEST_LOOKUP_UNAVAILABLE` | `getRequest` could not ask the chain (503): try again later, do not sign again |
|
|
@@ -270,7 +336,7 @@ try {
|
|
|
270
336
|
|
|
271
337
|
## Browser applications
|
|
272
338
|
|
|
273
|
-
This package is for Node.js servers. Web pages use the **CX3 IIFE** (`window.cx3`) together with the CX3Wallet extension: [`@carbonx3/cx3-iife`](https://www.npmjs.com/package/@carbonx3/cx3-iife) on npm and jsDelivr, or [download it](https://cx3script.com/download/cx3-0.2.
|
|
339
|
+
This package is for Node.js servers. Web pages use the **CX3 IIFE** (`window.cx3`) together with the CX3Wallet extension: [`@carbonx3/cx3-iife`](https://www.npmjs.com/package/@carbonx3/cx3-iife) on npm and jsDelivr, or [download it](https://cx3script.com/download/cx3-0.2.10.min.js); see the [CX3 IIFE guide](https://cx3script.com/docs?doc=cx3iife). Keep the application's credentials on your server behind a same-origin proxy, and never put a client secret, an API key or a private key in browser JavaScript.
|
|
274
340
|
|
|
275
341
|
## Environment variables
|
|
276
342
|
|
|
@@ -286,12 +352,13 @@ Type declarations (`index.d.ts`) ship with the package.
|
|
|
286
352
|
|
|
287
353
|
## Upgrading from 0.2.x
|
|
288
354
|
|
|
289
|
-
Version 0.3.0 signs native transfers for the network's new signing format (0.3.1 to 0.3.4 change only this README, the file headers and comments, not the code). In 0.3.5, `bindApiKey` with a wallet API key asks the gateway's `/wallet/api-key/bind` (before, it asked `/whoami`, which the gateway refuses, so it always failed). In 0.3.6, `wait()` follows a send or deploy the gateway reports as not final (`dropped`) until its `finalAfter` instead of failing after 60 s, a `TX_TIMEOUT` answer with a hash is pending, `getRequest()` looks up a signed request, and `transferNative` refuses more than 6 decimals. 0.3.7 changes only this README: a native transfer's gas price floor is 0.0001 (the 0.3.6 README said 0.000001). 0.3.8 changes only this README too: the CX3 IIFE download link names `cx3-0.2.
|
|
355
|
+
Version 0.3.0 signs native transfers for the network's new signing format (0.3.1 to 0.3.4 change only this README, the file headers and comments, not the code). In 0.3.5, `bindApiKey` with a wallet API key asks the gateway's `/wallet/api-key/bind` (before, it asked `/whoami`, which the gateway refuses, so it always failed). In 0.3.6, `wait()` follows a send or deploy the gateway reports as not final (`dropped`) until its `finalAfter` instead of failing after 60 s, a `TX_TIMEOUT` answer with a hash is pending, `getRequest()` looks up a signed request, and `transferNative` refuses more than 6 decimals. 0.3.7 changes only this README: a native transfer's gas price floor is 0.0001 (the 0.3.6 README said 0.000001). 0.3.8 changes only this README too: the CX3 IIFE download link names `cx3-0.2.10.min.js`. In 0.3.9, `deployContract` signs `CX3_DEPLOY_V2`: the code and the constructor arguments of the request are under the signature (`Cx3Wallet.deployContentHash`, `signDeploy({ contentHash })`, `deployContentText`). In 0.3.10, a refused native transfer (HTTP 200 with `error`) is `success: false` / `status: "failed"` and `wait()` rejects (before, it was reported as confirmed); an accepted transfer without a block is `"pending"`; `wait()` rejects with `NO_TX_HASH` when the answer names no transaction; the private key, client secret, API key and access token are private fields (`wallet._privateKey`, `provider.apiKey` and `provider._clientSecret` are gone; use `wallet.exportPrivateKey()`), and results no longer carry the provider into JSON or logs.
|
|
290
356
|
|
|
291
357
|
- **The genesis hash is signed.** `transferNative` asks the network for its chain id and genesis hash (`provider.getChainIdentity()`) unless both are given as `opts.chainId` and `opts.genesisHash`. 0.2.x transfers no longer verify on the chain.
|
|
292
358
|
- **No fallback chain id.** `buildTransferPayload` throws `NO_CHAIN_IDENTITY` without `chainId` and `genesisHash`; the `CX3_CHAIN_ID` variable and the `chainIdBind` option are gone.
|
|
293
359
|
- **Typed digests.** Contract sends, deploys and owner actions sign `CX3_CALL_V1` / `CX3_DEPLOY_V1` / `CX3_OWNER_V1` digests bound to the network (see Signing); the `|`-joined digests of 0.2.x are refused. `signSend`, `signDeploy` and `signOwnerAction` take an optional `{ chainId, genesisHash }`, else they ask the provider (kept for a minute). The `build*Digest` helpers need the identity as their last argument.
|
|
294
|
-
- **Site messages.** `signPersonalMessage` signs the way the wallets sign `cx3_sign_message` (with the `0x19` prefix)
|
|
360
|
+
- **Site messages.** `signPersonalMessage` signs the way the wallets sign `cx3_sign_message` (with the `0x19` prefix). Use it for any text that comes from outside (a login challenge, a user's message). `wallet.signMessage` signs a text as it is, without the prefix, and refuses a text the chain would accept as a transfer, contract call, deploy or owner action (`SIGN_REFUSED_TRANSACTION_PAYLOAD`), so a service that signs messages for its users cannot be made to sign a payment. The exported `signMessage(message, privateKey)` function is the low-level form without that check.
|
|
361
|
+
- **Contract method names.** ABI methods are properties of a `Contract`, except the names JavaScript calls by itself (`then`, `catch`, `finally`, `toJSON`, `inspect`): a contract with such a method is reached with `contract.call(name, params)` or `contract.send(name, params)`.
|
|
295
362
|
|
|
296
363
|
## Upgrading from 0.1.x
|
|
297
364
|
|
package/index.d.ts
CHANGED
|
@@ -77,6 +77,10 @@ export class Cx3Provider {
|
|
|
77
77
|
authMode: "oauth" | "apiKey" | "devNode";
|
|
78
78
|
/** Throws CREDENTIALS_REQUIRED without clientId / clientSecret, apiKey or devNode */
|
|
79
79
|
constructor(baseUrl: string, options?: Cx3ProviderOptions);
|
|
80
|
+
/** Forget the held access token: the next request asks for a new one */
|
|
81
|
+
clearAccessToken(): void;
|
|
82
|
+
/** Where the provider points and how it signs in; never a credential (JSON.stringify and util.inspect use it) */
|
|
83
|
+
toJSON(): { baseUrl: string; authMode: "oauth" | "apiKey" | "devNode"; clientId?: string; scope?: string };
|
|
80
84
|
/** The current OAuth2 access token (renewed a minute before it expires); null without an OAuth2 app */
|
|
81
85
|
getAccessToken(force?: boolean): Promise<string | null>;
|
|
82
86
|
request(path: string, opts?: { method?: string; body?: unknown; query?: Record<string, string | number> }): Promise<any>;
|
|
@@ -134,7 +138,12 @@ export class Cx3Wallet {
|
|
|
134
138
|
constructor(privateKey: string | Uint8Array | Buffer, provider?: Cx3Provider | null);
|
|
135
139
|
static createRandom(provider?: Cx3Provider | null): Cx3Wallet;
|
|
136
140
|
connect(provider: Cx3Provider): Cx3Wallet;
|
|
141
|
+
/** The private key as given to the constructor; the wallet shows it nowhere else (not in JSON, logs or a spread) */
|
|
142
|
+
exportPrivateKey(): string | Uint8Array | Buffer;
|
|
143
|
+
/** Address and public key only (JSON.stringify and util.inspect use it) */
|
|
144
|
+
toJSON(): { address: string; publicKey: string };
|
|
137
145
|
getAddress(): string;
|
|
146
|
+
/** Signs a text / bytes as given (no site-message prefix). Refuses a chain payload (typed digest, transaction JSON) with SIGN_REFUSED_TRANSACTION_PAYLOAD: use signPersonalMessage for a message from outside */
|
|
138
147
|
signMessage(message: string | Uint8Array): Promise<{
|
|
139
148
|
signature: string;
|
|
140
149
|
r: string;
|
|
@@ -149,12 +158,17 @@ export class Cx3Wallet {
|
|
|
149
158
|
/** A site message the way the CX3Wallet extension and app sign it (0x19 "CX3 Signed Message:\n" prefix) */
|
|
150
159
|
signPersonalMessage(message: string): Promise<any>;
|
|
151
160
|
/** Typed digests (CX3_DEPLOY_V1 / CX3_CALL_V1 / CX3_OWNER_V1) bound to the network's chainId + genesisHash (from the provider unless given in opts) */
|
|
161
|
+
/** With fields.contentHash (Cx3Wallet.deployContentHash(request)) the digest is CX3_DEPLOY_V2; the answer carries digestVersion 2 | 1 */
|
|
152
162
|
signDeploy(fields: object, opts?: Cx3ChainIdentity): Promise<any>;
|
|
163
|
+
/** sha256 (hex) of the code, constructor arguments, target and template switches of a deploy request */
|
|
164
|
+
static deployContentHash(request: object): string;
|
|
153
165
|
signSend(contractAddress: string, method: string, params?: object, opts?: Cx3ChainIdentity): Promise<any>;
|
|
154
166
|
signOwnerAction(action: string, address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
|
|
155
167
|
/** A private token's transfer / transferFrom / mint is checked first (PRIVATE_TOKEN_SENDER / _RECIPIENT); `privateCheck: false` skips it */
|
|
156
168
|
sendTransaction(contractAddress: string, method: string, params?: object, opts?: { wait?: number; async?: boolean; privateCheck?: boolean; [k: string]: unknown }): Promise<TransactionResponse>;
|
|
157
169
|
/** Throws Cx3Error INVALID_TOKEN_SYMBOL (before signing) for a symbol outside A-Z / 0-9; the chain answers DUPLICATE_TOKEN_SYMBOL (409) for a symbol in use. A dropped deploy is pending: result.wait() follows it. */
|
|
170
|
+
/** Sends an earlier send's signed request again, unchanged (no new signature): after SEND_OUTCOME_UNKNOWN or a lost answer */
|
|
171
|
+
resendTransaction(signedRequest: Cx3SignedRequest, opts?: { wait?: number; async?: boolean }): Promise<TransactionResponse>;
|
|
158
172
|
deployContract(fields: object): Promise<Cx3DeployResult>;
|
|
159
173
|
pauseContract(address: string, owner?: string | null): Promise<any>;
|
|
160
174
|
unpauseContract(address: string, owner?: string | null): Promise<any>;
|
|
@@ -164,6 +178,19 @@ export class Cx3Wallet {
|
|
|
164
178
|
bindApiKey(address?: string): Promise<any>;
|
|
165
179
|
}
|
|
166
180
|
|
|
181
|
+
/** A signed contract call as it was sent: no key in it, safe to store; `requestId` is sha256 of its CX3_CALL_V1 digest */
|
|
182
|
+
export interface Cx3SignedRequest {
|
|
183
|
+
contractAddress: string;
|
|
184
|
+
method: string;
|
|
185
|
+
params: object;
|
|
186
|
+
fromAddress: string;
|
|
187
|
+
signature: string;
|
|
188
|
+
senderPublicKey: string;
|
|
189
|
+
timestamp: number;
|
|
190
|
+
requestId: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** ABI methods become properties, except names JavaScript calls by itself (then, catch, finally, toJSON, inspect): reach those with call() / send() */
|
|
167
194
|
export class Contract {
|
|
168
195
|
address: string;
|
|
169
196
|
target: string | null;
|
|
@@ -239,7 +266,10 @@ export class TransactionResponse {
|
|
|
239
266
|
blockHeight?: number;
|
|
240
267
|
contractAddress: string | null;
|
|
241
268
|
async: boolean;
|
|
242
|
-
/**
|
|
269
|
+
/**
|
|
270
|
+
* "pending" for async, TX_TIMEOUT (queued) and dropped answers and for a transfer accepted but in no block yet;
|
|
271
|
+
* "confirmed" only with a blockHeight; "failed" for a refused one (also the node's HTTP 200 `{ error }` answer)
|
|
272
|
+
*/
|
|
243
273
|
status: string;
|
|
244
274
|
/** trimmed from a block: not final, another validator may still include it until finalAfter */
|
|
245
275
|
dropped: boolean;
|
|
@@ -247,9 +277,13 @@ export class TransactionResponse {
|
|
|
247
277
|
finalAfter: number | null;
|
|
248
278
|
/** the signed request's id (lookup: provider.getRequest) */
|
|
249
279
|
requestId: string | null;
|
|
280
|
+
/** contract sends: the signed call as sent (not enumerable, not in JSON); wallet.resendTransaction takes it */
|
|
281
|
+
signedRequest?: Cx3SignedRequest;
|
|
250
282
|
hint: string | null;
|
|
251
283
|
error: string | null;
|
|
252
|
-
/**
|
|
284
|
+
/** the answer without the provider (JSON.stringify uses it) */
|
|
285
|
+
toJSON(): Record<string, unknown>;
|
|
286
|
+
/** follows pending / dropped answers (until finalAfter by default); rejects on a refused answer, on a final failure, and with NO_TX_HASH when the answer names no transaction */
|
|
253
287
|
wait(opts?: { timeoutMs?: number; intervalMs?: number; onPending?: (status: any) => void; onConfirmed?: (status: any) => void }): Promise<any>;
|
|
254
288
|
}
|
|
255
289
|
|
|
@@ -263,8 +297,10 @@ export function ensure0x(hex: string): string;
|
|
|
263
297
|
export function strip0x(hex: string): string;
|
|
264
298
|
/** The network a typed digest or a native transfer is bound to (provider.getChainIdentity()) */
|
|
265
299
|
export interface Cx3ChainIdentity { chainId?: string; genesisHash?: string }
|
|
266
|
-
/**
|
|
300
|
+
/** With fields.contentHash: CX3_DEPLOY_V2|[chainId, genesisHash, owner, standard, name, symbol, decimals, initialSupply, contentHash, timestamp]; without: CX3_DEPLOY_V1 (the same list without it) */
|
|
267
301
|
export function buildDeployDigest(fields: object, identity: Cx3ChainIdentity): string;
|
|
302
|
+
/** The text whose sha256 (hex) is a deploy request's contentHash */
|
|
303
|
+
export function deployContentText(request: object): string;
|
|
268
304
|
/** CX3_CALL_V1|[chainId, genesisHash, contract, method, params, from, timestamp] */
|
|
269
305
|
export function buildSendDigest(contractAddress: string, method: string, params: object, fromAddress: string, timestamp: number, identity: Cx3ChainIdentity): string;
|
|
270
306
|
/** CX3_OWNER_V1|[chainId, genesisHash, action, address, owner, timestamp] */
|
package/index.js
CHANGED
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
} = require("./src/utils");
|
|
21
21
|
const {
|
|
22
22
|
buildDeployDigest,
|
|
23
|
+
deployContentText,
|
|
23
24
|
buildSendDigest,
|
|
24
25
|
buildOwnerActionDigest,
|
|
25
26
|
buildTransferPayload,
|
|
@@ -33,7 +34,7 @@ const {
|
|
|
33
34
|
} = require("./src/crypto");
|
|
34
35
|
const { normalizeAbi, argsToParams } = require("./src/abi");
|
|
35
36
|
|
|
36
|
-
const VERSION = "0.3.
|
|
37
|
+
const VERSION = "0.3.10";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Convenience: provider from `options.url` or env `CX3_NODE_URL` — the CX3Api gateway
|
|
@@ -65,6 +66,7 @@ module.exports = {
|
|
|
65
66
|
walletApiKeyMode,
|
|
66
67
|
toJsonSafe,
|
|
67
68
|
buildDeployDigest,
|
|
69
|
+
deployContentText,
|
|
68
70
|
buildSendDigest,
|
|
69
71
|
buildOwnerActionDigest,
|
|
70
72
|
buildTransferPayload,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carbonx3/cx3.js",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"description": "Official Node.js SDK (CX3js) for CarbonX3 Green Chain: OAuth2 app access, local secp256k1 signing, ABI-bound contracts and token deploys over REST + JSON",
|
|
5
5
|
"homepage": "https://cx3script.com",
|
|
6
6
|
"publishConfig": {
|
package/src/contract.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
const { normalizeAbi, argsToParams } = require("./abi");
|
|
8
8
|
const { Cx3Error } = require("./errors");
|
|
9
9
|
|
|
10
|
+
const RESERVED_METHOD_NAMES = new Set(["then", "catch", "finally", "toJSON", "inspect", "__proto__"]);
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* Contract handle with ABI-bound methods.
|
|
12
14
|
*
|
|
@@ -32,7 +34,10 @@ class Contract {
|
|
|
32
34
|
|
|
33
35
|
const self = this;
|
|
34
36
|
for (const name of Object.keys(this.interface)) {
|
|
35
|
-
|
|
37
|
+
// A method named like something JavaScript calls by itself never becomes a property: `await contract`
|
|
38
|
+
// calls `then`, JSON.stringify calls `toJSON`, and either would send a signed call nobody asked
|
|
39
|
+
// for. Such a method stays reachable as contract.call(name, …) / contract.send(name, …).
|
|
40
|
+
if (name in this || RESERVED_METHOD_NAMES.has(name)) continue;
|
|
36
41
|
this[name] = async function (...args) {
|
|
37
42
|
return self._invoke(name, args);
|
|
38
43
|
};
|
package/src/digest.js
CHANGED
|
@@ -34,7 +34,48 @@ function requireIdentity(identity) {
|
|
|
34
34
|
return [chainId, genesisHash];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/** JSON with object keys in order, undefined as null (the node's signingDomains.canonicalJson). */
|
|
38
|
+
function canonicalJson(v) {
|
|
39
|
+
if (v === undefined || v === null || typeof v === "function" || typeof v === "symbol") return "null";
|
|
40
|
+
if (typeof v === "bigint") return JSON.stringify(v.toString());
|
|
41
|
+
if (typeof v !== "object") return JSON.stringify(v) === undefined ? "null" : JSON.stringify(v);
|
|
42
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
|
|
43
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The text whose sha256 (hex) is a deploy request's contentHash — what the request deploys beyond the
|
|
48
|
+
* CX3_DEPLOY_V1 fields: the code as sent, the constructor arguments, the target, the template switches.
|
|
49
|
+
* Byte-identical to the node's signingDomains.deployContentHash input.
|
|
50
|
+
*/
|
|
51
|
+
function deployContentText(body) {
|
|
52
|
+
const b = body && typeof body === "object" ? body : {};
|
|
53
|
+
const flag = (v) => (v === undefined || v === null ? null : (v === true || v === "true"));
|
|
54
|
+
return canonicalJson({
|
|
55
|
+
sourceCode: String(b.sourceCode || b.bytecode || ""),
|
|
56
|
+
constructorArgs: b.constructorArgs === undefined ? null : b.constructorArgs,
|
|
57
|
+
target: String(b.target || ""),
|
|
58
|
+
mintable: flag(b.mintable),
|
|
59
|
+
burnable: flag(b.burnable),
|
|
60
|
+
pausable: flag(b.pausable),
|
|
61
|
+
totalSupply: b.totalSupply === undefined || b.totalSupply === null ? null : String(b.totalSupply)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* With `contentHash` (64 hex, sha256 of deployContentText) the digest is CX3_DEPLOY_V2: the code and
|
|
67
|
+
* the constructor arguments are under the signature too. Without it, CX3_DEPLOY_V1.
|
|
68
|
+
*/
|
|
69
|
+
function buildDeployDigest({ owner, standard, name, symbol, decimals, initialSupply, contentHash, timestamp }, identity) {
|
|
70
|
+
const hash = String(contentHash == null ? "" : contentHash).toLowerCase();
|
|
71
|
+
if (hash) {
|
|
72
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) throw new Cx3Error("contentHash must be 64 hex characters", "INVALID_CONTENT_HASH");
|
|
73
|
+
return `CX3_DEPLOY_V2|${JSON.stringify([
|
|
74
|
+
...requireIdentity(identity), normalizeDigestAddress(owner), String(standard || ""), String(name || ""),
|
|
75
|
+
String(symbol || ""), decimals == null ? 18 : Number(decimals),
|
|
76
|
+
initialSupply == null ? "0" : String(initialSupply), hash, Number(timestamp)
|
|
77
|
+
])}`;
|
|
78
|
+
}
|
|
38
79
|
return `CX3_DEPLOY_V1|${JSON.stringify([
|
|
39
80
|
...requireIdentity(identity), normalizeDigestAddress(owner), String(standard || ""), String(name || ""),
|
|
40
81
|
String(symbol || ""), decimals == null ? 18 : Number(decimals),
|
|
@@ -109,6 +150,8 @@ function buildTransferPayload(fields = {}) {
|
|
|
109
150
|
}
|
|
110
151
|
|
|
111
152
|
module.exports = {
|
|
153
|
+
canonicalJson,
|
|
154
|
+
deployContentText,
|
|
112
155
|
buildDeployDigest,
|
|
113
156
|
buildSendDigest,
|
|
114
157
|
buildOwnerActionDigest,
|
package/src/provider.js
CHANGED
|
@@ -78,6 +78,12 @@ function isTransientError(e) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
class Cx3Provider {
|
|
81
|
+
// Credentials live in private fields: JSON.stringify, util.inspect, console.log, Object.keys and a
|
|
82
|
+
// spread of the provider (or of anything that holds it) never show them.
|
|
83
|
+
#clientSecret = "";
|
|
84
|
+
#apiKey = "";
|
|
85
|
+
#token = null; // { value, refreshAt, scope }
|
|
86
|
+
|
|
81
87
|
/**
|
|
82
88
|
* @param {string} baseUrl CX3Api gateway URL (`CX3_NODE_URL`, e.g. https://cx3api.carbonx3.com), or a development node with `devNode`
|
|
83
89
|
* @param {object} [options]
|
|
@@ -102,15 +108,15 @@ class Cx3Provider {
|
|
|
102
108
|
}
|
|
103
109
|
const env = (name) => (process.env[name] || "").trim();
|
|
104
110
|
this.clientId = String(options.clientId != null ? options.clientId : env("CX3_CLIENT_ID")).trim();
|
|
105
|
-
this
|
|
111
|
+
this.#clientSecret = String(options.clientSecret != null ? options.clientSecret : env("CX3_CLIENT_SECRET")).trim();
|
|
106
112
|
this.scope = Array.isArray(options.scope) ? options.scope.join(" ") : String(options.scope || "").trim();
|
|
107
|
-
this
|
|
113
|
+
this.#apiKey = String(options.apiKey != null ? options.apiKey : env("CX3_API_KEY")).trim();
|
|
108
114
|
this.devNode = options.devNode === true;
|
|
109
|
-
this.oauth = !!(this.clientId || this
|
|
110
|
-
if (this.oauth && (!CLIENT_ID_RE.test(this.clientId) || !CLIENT_SECRET_RE.test(this
|
|
115
|
+
this.oauth = !!(this.clientId || this.#clientSecret);
|
|
116
|
+
if (this.oauth && (!CLIENT_ID_RE.test(this.clientId) || !CLIENT_SECRET_RE.test(this.#clientSecret))) {
|
|
111
117
|
throw new Cx3Error("clientId must be cx3app_… and clientSecret cx3sec_… (CX3WebUI → Settings → API → Applications)", "INVALID_CLIENT_CREDENTIALS");
|
|
112
118
|
}
|
|
113
|
-
const hasKey = walletApiKeyMode(this
|
|
119
|
+
const hasKey = walletApiKeyMode(this.#apiKey) !== "public";
|
|
114
120
|
if (!this.oauth && !hasKey && !this.devNode) {
|
|
115
121
|
throw new Cx3Error(
|
|
116
122
|
"App credentials required: create an application in CX3WebUI (Settings → API → Applications) and pass clientId / clientSecret (or set CX3_CLIENT_ID / CX3_CLIENT_SECRET)",
|
|
@@ -122,7 +128,6 @@ class Cx3Provider {
|
|
|
122
128
|
this.extraHeaders = withoutCredentialHeaders(options.headers);
|
|
123
129
|
this.fetchImpl = options.fetch || globalThis.fetch;
|
|
124
130
|
this.timeoutMs = options.timeoutMs || 60000;
|
|
125
|
-
this._token = null; // { value, refreshAt, scope }
|
|
126
131
|
this._tokenPromise = null;
|
|
127
132
|
// token address → { private, owners } from its registry record (set at deploy)
|
|
128
133
|
this._tokenRecords = new Map();
|
|
@@ -132,12 +137,12 @@ class Cx3Provider {
|
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
get apiKeyMode() {
|
|
135
|
-
return walletApiKeyMode(this
|
|
140
|
+
return walletApiKeyMode(this.#apiKey);
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
/** "oauth" | "apiKey" | "devNode" */
|
|
139
144
|
get authMode() {
|
|
140
|
-
if (this.devNode && !this.oauth && walletApiKeyMode(this
|
|
145
|
+
if (this.devNode && !this.oauth && walletApiKeyMode(this.#apiKey) === "public") return "devNode";
|
|
141
146
|
return this.oauth ? "oauth" : "apiKey";
|
|
142
147
|
}
|
|
143
148
|
|
|
@@ -147,7 +152,7 @@ class Cx3Provider {
|
|
|
147
152
|
*/
|
|
148
153
|
async getAccessToken(force = false) {
|
|
149
154
|
if (!this.oauth) return null;
|
|
150
|
-
if (!force && this
|
|
155
|
+
if (!force && this.#token && Date.now() < this.#token.refreshAt) return this.#token.value;
|
|
151
156
|
if (!this._tokenPromise) {
|
|
152
157
|
this._tokenPromise = this._fetchToken().finally(() => { this._tokenPromise = null; });
|
|
153
158
|
}
|
|
@@ -155,7 +160,7 @@ class Cx3Provider {
|
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
async _fetchToken() {
|
|
158
|
-
const form = new URLSearchParams({ grant_type: "client_credentials", client_id: this.clientId, client_secret: this
|
|
163
|
+
const form = new URLSearchParams({ grant_type: "client_credentials", client_id: this.clientId, client_secret: this.#clientSecret });
|
|
159
164
|
if (this.scope) form.set("scope", this.scope);
|
|
160
165
|
const { res, json } = await this._fetchJson(`${this.baseUrl}/oauth/token`, {
|
|
161
166
|
method: "POST",
|
|
@@ -167,8 +172,22 @@ class Cx3Provider {
|
|
|
167
172
|
throw new Cx3HttpError((json && json.error_description) || code, res.status, { ...(json || {}), error: code });
|
|
168
173
|
}
|
|
169
174
|
const ttl = Number(json.expires_in) > 0 ? Number(json.expires_in) : 3600;
|
|
170
|
-
this
|
|
171
|
-
return this.
|
|
175
|
+
this.#token = { value: json.access_token, refreshAt: Date.now() + Math.max(30, ttl - 60) * 1000, scope: json.scope || "" };
|
|
176
|
+
return this.#token.value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Forget the held access token: the next request asks for a new one. */
|
|
180
|
+
clearAccessToken() {
|
|
181
|
+
this.#token = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What a log or a JSON answer may show of a provider: where it points and how it signs in, no credential. */
|
|
185
|
+
toJSON() {
|
|
186
|
+
return { baseUrl: this.baseUrl, authMode: this.authMode, clientId: this.clientId || undefined, scope: this.scope || undefined };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
190
|
+
return `Cx3Provider ${JSON.stringify(this.toJSON())}`;
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
async _headers(forceToken = false) {
|
|
@@ -178,8 +197,8 @@ class Cx3Provider {
|
|
|
178
197
|
};
|
|
179
198
|
if (this.oauth) {
|
|
180
199
|
headers.Authorization = `Bearer ${await this.getAccessToken(forceToken)}`;
|
|
181
|
-
} else if (/^cx3wk_[a-f0-9]{48}$/i.test(this
|
|
182
|
-
headers["X-Api-Key"] = this
|
|
200
|
+
} else if (/^cx3wk_[a-f0-9]{48}$/i.test(this.#apiKey)) {
|
|
201
|
+
headers["X-Api-Key"] = this.#apiKey;
|
|
183
202
|
}
|
|
184
203
|
return headers;
|
|
185
204
|
}
|
package/src/transaction.js
CHANGED
|
@@ -17,13 +17,19 @@ class TransactionResponse {
|
|
|
17
17
|
*/
|
|
18
18
|
constructor(result, provider = null, opts = {}) {
|
|
19
19
|
this.raw = result || {};
|
|
20
|
-
|
|
20
|
+
// not enumerable: a result that is logged, spread or sent as JSON does not drag the provider along
|
|
21
|
+
Object.defineProperty(this, "provider", { value: provider, enumerable: false, writable: true, configurable: true });
|
|
21
22
|
this.txHash = this.raw.txHash || this.raw.hash || null;
|
|
22
23
|
this.hash = this.txHash;
|
|
23
24
|
// TX_TIMEOUT with a hash: queued, not final within the node's sync wait (25 s), so pending:
|
|
24
25
|
// follow it with wait(), never sign the same call again
|
|
25
26
|
const queued = this.raw.success === false && this.raw.error === "TX_TIMEOUT" && !!this.txHash;
|
|
26
|
-
|
|
27
|
+
// The node refuses a native transfer with HTTP 200 and { error, state }, without a `success` field
|
|
28
|
+
// (review 3, 2026-11-12): an answer is a success only when it says so, or when it names the
|
|
29
|
+
// transaction (hash) or the request it queued, and carries no error.
|
|
30
|
+
const named = !!this.txHash || !!this.raw.requestId || this.raw.async === true;
|
|
31
|
+
const refused = this.raw.success === false || (this.raw.success !== true && (!!this.raw.error || !named));
|
|
32
|
+
this.success = queued || !refused;
|
|
27
33
|
this.blockHeight = this.raw.blockHeight;
|
|
28
34
|
this.contractAddress = this.raw.contractAddress || null;
|
|
29
35
|
this.async = !!this.raw.async || opts.wait === 0;
|
|
@@ -32,8 +38,16 @@ class TransactionResponse {
|
|
|
32
38
|
this.finalAfter = Number(this.raw.finalAfter) > 0 ? Number(this.raw.finalAfter) : null;
|
|
33
39
|
this.requestId = this.raw.requestId || null;
|
|
34
40
|
this.hint = this.raw.hint || null;
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
// "confirmed" only with the block it is in: an accepted transfer that is still in the pool is pending
|
|
42
|
+
const own = !this.success ? "failed" : (this.async || this.blockHeight == null ? "pending" : "confirmed");
|
|
43
|
+
this.status = queued ? "pending" : (this.success ? (this.raw.status || own) : "failed");
|
|
44
|
+
this.error = queued ? null : (this.raw.error || this.raw.message || (this.success ? null : "TX_REFUSED"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The answer without the provider (what JSON.stringify and a log show). */
|
|
48
|
+
toJSON() {
|
|
49
|
+
const { raw, ...own } = this;
|
|
50
|
+
return { ...own, raw };
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
/**
|
|
@@ -54,7 +68,8 @@ class TransactionResponse {
|
|
|
54
68
|
};
|
|
55
69
|
}
|
|
56
70
|
if (!this.txHash) {
|
|
57
|
-
|
|
71
|
+
// nothing to follow: resolving here would report a transaction nobody can name as done
|
|
72
|
+
throw new Cx3TxError("The answer names no transaction to wait for", "NO_TX_HASH", this.raw);
|
|
58
73
|
}
|
|
59
74
|
if (!this.provider) {
|
|
60
75
|
throw new Cx3TxError("No provider to wait on transaction", "NO_PROVIDER", this.raw);
|
package/src/wallet.js
CHANGED
|
@@ -9,11 +9,12 @@ const {
|
|
|
9
9
|
privateKeyToPublicKey,
|
|
10
10
|
signMessage,
|
|
11
11
|
signPersonalMessage,
|
|
12
|
-
createPrivateKey
|
|
12
|
+
createPrivateKey,
|
|
13
|
+
sha256
|
|
13
14
|
} = require("./crypto");
|
|
14
15
|
const { normalizeAddress, toJsonSafe } = require("./utils");
|
|
15
16
|
const { Cx3Error } = require("./errors");
|
|
16
|
-
const { buildDeployDigest, buildSendDigest, buildOwnerActionDigest, buildTransferPayload, stripNativeAddress } = require("./digest");
|
|
17
|
+
const { buildDeployDigest, buildSendDigest, buildOwnerActionDigest, buildTransferPayload, stripNativeAddress, deployContentText } = require("./digest");
|
|
17
18
|
const { TransactionResponse } = require("./transaction");
|
|
18
19
|
|
|
19
20
|
/** Green Chain deploy rule: a token symbol is capital letters A-Z and digits 0-9 only. */
|
|
@@ -23,6 +24,33 @@ function isValidTokenSymbol(symbol) {
|
|
|
23
24
|
return TOKEN_SYMBOL_RE.test(String(symbol == null ? "" : symbol));
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A text the chain verifies as its own: a typed digest (CX3_CALL_V1|…, CX3_DEPLOY_V1|…, CX3_OWNER_V1|…)
|
|
29
|
+
* or a transaction's canonical JSON payload. The same rule as the CX3Wallet extension's sign request.
|
|
30
|
+
*/
|
|
31
|
+
function isChainPayload(message) {
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = typeof message === "string" ? message : Buffer.from(message).toString("utf8");
|
|
35
|
+
} catch (_) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (/^CX3_[A-Z0-9]+_V\d+\|/.test(text)) return true;
|
|
39
|
+
let obj = null;
|
|
40
|
+
try {
|
|
41
|
+
obj = JSON.parse(text);
|
|
42
|
+
} catch (_) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return false;
|
|
46
|
+
return "fromAddress" in obj || ("type" in obj && "nonce" in obj);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Request id of a signed contract send: sha256 (hex) of its CX3_CALL_V1 digest, as the gateway names it. */
|
|
50
|
+
function requestIdOf(digest) {
|
|
51
|
+
return Buffer.from(sha256(Buffer.from(String(digest), "utf8"))).toString("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
/**
|
|
27
55
|
* A deploy result keeps the gateway's fields and gets `dropped`, `finalAfter` and `wait()`. A deploy
|
|
28
56
|
* the node trimmed from its block (202 dropped, status "pending") is not deployed yet: another
|
|
@@ -60,13 +88,17 @@ function withDeployWait(result, provider) {
|
|
|
60
88
|
* Local secp256k1 wallet for Green Chain digests.
|
|
61
89
|
*/
|
|
62
90
|
class Cx3Wallet {
|
|
91
|
+
// The key is a private field: JSON.stringify, util.inspect, console.log, Object.keys and a spread of
|
|
92
|
+
// the wallet (or of a result that holds it) never show it. It leaves only through exportPrivateKey().
|
|
93
|
+
#privateKey;
|
|
94
|
+
|
|
63
95
|
/**
|
|
64
96
|
* @param {string|Uint8Array|Buffer} privateKey
|
|
65
97
|
* @param {import('./provider').Cx3Provider|null} [provider]
|
|
66
98
|
*/
|
|
67
99
|
constructor(privateKey, provider = null) {
|
|
68
100
|
if (!privateKey) throw new Cx3Error("privateKey required", "NO_PRIVATE_KEY");
|
|
69
|
-
this
|
|
101
|
+
this.#privateKey = privateKey;
|
|
70
102
|
this.provider = provider;
|
|
71
103
|
this.address = privateKeyToAddress(privateKey);
|
|
72
104
|
this.publicKey = privateKeyToPublicKey(privateKey, true).toString("hex");
|
|
@@ -77,21 +109,49 @@ class Cx3Wallet {
|
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
connect(provider) {
|
|
80
|
-
return new Cx3Wallet(this
|
|
112
|
+
return new Cx3Wallet(this.#privateKey, provider);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The private key as given to the constructor (a createRandom() wallet: the only way to save it). */
|
|
116
|
+
exportPrivateKey() {
|
|
117
|
+
return this.#privateKey;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What a log or a JSON answer may show of a wallet: its address and public key. */
|
|
121
|
+
toJSON() {
|
|
122
|
+
return { address: this.address, publicKey: this.publicKey };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
126
|
+
return `Cx3Wallet ${JSON.stringify(this.toJSON())}`;
|
|
81
127
|
}
|
|
82
128
|
|
|
83
129
|
getAddress() {
|
|
84
130
|
return this.address;
|
|
85
131
|
}
|
|
86
132
|
|
|
87
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Sign raw bytes / a text as given (sha256 + secp256k1), without the site-message prefix.
|
|
135
|
+
* A text the chain would accept as a transaction, contract call, deploy or owner action is refused
|
|
136
|
+
* (SIGN_REFUSED_TRANSACTION_PAYLOAD): a service that signs "any message" for its users would otherwise
|
|
137
|
+
* hand out transfer signatures. Those have their own methods (transferNative, signSend, signDeploy,
|
|
138
|
+
* signOwnerAction); for a message from outside, use signPersonalMessage.
|
|
139
|
+
*/
|
|
88
140
|
async signMessage(message) {
|
|
89
|
-
|
|
141
|
+
if (isChainPayload(message)) {
|
|
142
|
+
throw new Cx3Error("This text is a chain transaction or call, not a message", "SIGN_REFUSED_TRANSACTION_PAYLOAD");
|
|
143
|
+
}
|
|
144
|
+
return signMessage(message, this.#privateKey);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The wallet's own typed digests and transfer payloads. */
|
|
148
|
+
#signPayload(payload) {
|
|
149
|
+
return signMessage(payload, this.#privateKey);
|
|
90
150
|
}
|
|
91
151
|
|
|
92
152
|
/** Sign a site message the way the CX3Wallet extension and app do (with the site-message prefix). */
|
|
93
153
|
async signPersonalMessage(message) {
|
|
94
|
-
return signPersonalMessage(message, this
|
|
154
|
+
return signPersonalMessage(message, this.#privateKey);
|
|
95
155
|
}
|
|
96
156
|
|
|
97
157
|
/** The network identity typed digests bind: `opts.chainId` + `opts.genesisHash`, else the provider's. */
|
|
@@ -101,9 +161,15 @@ class Cx3Wallet {
|
|
|
101
161
|
return this.provider.getChainIdentity();
|
|
102
162
|
}
|
|
103
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Sign a deploy request. With `fields.contentHash` (Cx3Wallet.deployContentHash(request)) the digest is
|
|
166
|
+
* CX3_DEPLOY_V2: the code and the constructor arguments are under the signature, and the request
|
|
167
|
+
* must carry the same `contentHash`. Without it, CX3_DEPLOY_V1.
|
|
168
|
+
*/
|
|
104
169
|
async signDeploy(fields, opts = {}) {
|
|
105
170
|
const owner = normalizeAddress(fields.owner || this.address);
|
|
106
171
|
const timestamp = fields.timestamp || Date.now();
|
|
172
|
+
const contentHash = fields.contentHash ? String(fields.contentHash).toLowerCase() : undefined;
|
|
107
173
|
const digest = buildDeployDigest({
|
|
108
174
|
owner,
|
|
109
175
|
standard: fields.standard,
|
|
@@ -111,23 +177,32 @@ class Cx3Wallet {
|
|
|
111
177
|
symbol: fields.symbol,
|
|
112
178
|
decimals: fields.decimals,
|
|
113
179
|
initialSupply: fields.initialSupply,
|
|
180
|
+
contentHash,
|
|
114
181
|
timestamp
|
|
115
182
|
}, await this._identity(opts));
|
|
116
|
-
const signed = await this
|
|
183
|
+
const signed = await this.#signPayload(digest);
|
|
117
184
|
return {
|
|
118
185
|
...signed,
|
|
119
186
|
owner,
|
|
120
187
|
timestamp,
|
|
121
|
-
digest
|
|
188
|
+
digest,
|
|
189
|
+
digestVersion: contentHash ? 2 : 1,
|
|
190
|
+
...(contentHash ? { contentHash } : {})
|
|
122
191
|
};
|
|
123
192
|
}
|
|
124
193
|
|
|
194
|
+
/** sha256 (hex) of what a deploy request deploys beyond the named fields — the node's deployContentHash. */
|
|
195
|
+
static deployContentHash(request) {
|
|
196
|
+
const asSent = JSON.parse(JSON.stringify(toJsonSafe(request || {})));
|
|
197
|
+
return Buffer.from(sha256(deployContentText(asSent))).toString("hex");
|
|
198
|
+
}
|
|
199
|
+
|
|
125
200
|
async signSend(contractAddress, method, params, opts = {}) {
|
|
126
201
|
const fromAddress = this.address;
|
|
127
202
|
const timestamp = Date.now();
|
|
128
203
|
const safeParams = toJsonSafe(params || {});
|
|
129
204
|
const digest = buildSendDigest(contractAddress, method, safeParams, fromAddress, timestamp, await this._identity(opts));
|
|
130
|
-
const signed = await this
|
|
205
|
+
const signed = await this.#signPayload(digest);
|
|
131
206
|
return {
|
|
132
207
|
...signed,
|
|
133
208
|
fromAddress,
|
|
@@ -141,7 +216,7 @@ class Cx3Wallet {
|
|
|
141
216
|
const own = normalizeAddress(owner || this.address);
|
|
142
217
|
const timestamp = Date.now();
|
|
143
218
|
const digest = buildOwnerActionDigest(action, address, own, timestamp, await this._identity(opts));
|
|
144
|
-
const signed = await this
|
|
219
|
+
const signed = await this.#signPayload(digest);
|
|
145
220
|
return {
|
|
146
221
|
...signed,
|
|
147
222
|
owner: own,
|
|
@@ -161,19 +236,71 @@ class Cx3Wallet {
|
|
|
161
236
|
await this.provider.checkPrivateTokenSend(contractAddress, method, params, this.address);
|
|
162
237
|
}
|
|
163
238
|
const signed = await this.signSend(contractAddress, method, params);
|
|
164
|
-
|
|
239
|
+
// everything the gateway needs to take the same signed call again: no key, safe to store
|
|
240
|
+
const signedRequest = {
|
|
241
|
+
contractAddress,
|
|
242
|
+
method,
|
|
243
|
+
params: signed.params,
|
|
165
244
|
fromAddress: signed.fromAddress,
|
|
166
245
|
signature: signed.signature,
|
|
167
246
|
senderPublicKey: signed.senderPublicKey,
|
|
168
|
-
timestamp: signed.timestamp
|
|
247
|
+
timestamp: signed.timestamp,
|
|
248
|
+
requestId: requestIdOf(signed.digest)
|
|
169
249
|
};
|
|
250
|
+
return this.#postSigned(signedRequest, opts);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Send a signed contract call again, exactly as it was signed: the `signedRequest` of an earlier
|
|
255
|
+
* answer or of a SEND_OUTCOME_UNKNOWN error. The gateway knows a signed request by its id, so the
|
|
256
|
+
* call cannot run twice; a new signature would be a second call.
|
|
257
|
+
*/
|
|
258
|
+
async resendTransaction(signedRequest, opts = {}) {
|
|
259
|
+
if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
|
|
260
|
+
const r = signedRequest || {};
|
|
261
|
+
if (!r.signature || !r.senderPublicKey || !r.timestamp || !r.method || !r.contractAddress) {
|
|
262
|
+
throw new Cx3Error("signedRequest of an earlier send required", "INVALID_SIGNED_REQUEST");
|
|
263
|
+
}
|
|
264
|
+
if (normalizeAddress(r.fromAddress) !== normalizeAddress(this.address)) {
|
|
265
|
+
throw new Cx3Error("This request was signed by another wallet", "INVALID_SIGNED_REQUEST");
|
|
266
|
+
}
|
|
267
|
+
return this.#postSigned({ ...r }, opts);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async #postSigned(signedRequest, opts) {
|
|
170
271
|
const asyncMode = opts.wait === 0 || opts.async === true;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
272
|
+
let result;
|
|
273
|
+
try {
|
|
274
|
+
// send exactly the params that were signed (BigInt already as strings)
|
|
275
|
+
result = await this.provider.send(signedRequest.contractAddress, signedRequest.method, signedRequest.params, {
|
|
276
|
+
fromAddress: signedRequest.fromAddress,
|
|
277
|
+
signature: signedRequest.signature,
|
|
278
|
+
senderPublicKey: signedRequest.senderPublicKey,
|
|
279
|
+
timestamp: signedRequest.timestamp,
|
|
280
|
+
wait: asyncMode ? 0 : undefined
|
|
281
|
+
});
|
|
282
|
+
} catch (e) {
|
|
283
|
+
// The caller gets the request's name and its signed form with every failure, so a lost answer is
|
|
284
|
+
// looked up (provider.getRequest) or sent again as it is (resendTransaction), never signed anew.
|
|
285
|
+
if (e instanceof Cx3Error) {
|
|
286
|
+
e.requestId = signedRequest.requestId;
|
|
287
|
+
e.signedRequest = signedRequest;
|
|
288
|
+
throw e;
|
|
289
|
+
}
|
|
290
|
+
const open = new Cx3Error(
|
|
291
|
+
"No answer to the send: the call may still run. Look it up or send the same signed request again; do not sign a new one.",
|
|
292
|
+
"SEND_OUTCOME_UNKNOWN",
|
|
293
|
+
{ requestId: signedRequest.requestId, cause: String((e && e.message) || e) }
|
|
294
|
+
);
|
|
295
|
+
open.requestId = signedRequest.requestId;
|
|
296
|
+
open.signedRequest = signedRequest;
|
|
297
|
+
throw open;
|
|
298
|
+
}
|
|
299
|
+
const tx = new TransactionResponse(result, this.provider, opts);
|
|
300
|
+
if (!tx.requestId) tx.requestId = signedRequest.requestId;
|
|
301
|
+
// not enumerable: the answer logs and serializes as before
|
|
302
|
+
Object.defineProperty(tx, "signedRequest", { value: signedRequest, enumerable: false, configurable: true, writable: true });
|
|
303
|
+
return tx;
|
|
177
304
|
}
|
|
178
305
|
|
|
179
306
|
/**
|
|
@@ -196,9 +323,12 @@ class Cx3Wallet {
|
|
|
196
323
|
);
|
|
197
324
|
}
|
|
198
325
|
}
|
|
326
|
+
// CX3_DEPLOY_V2: the code and the constructor arguments of this request are signed too
|
|
327
|
+
const contentHash = Cx3Wallet.deployContentHash(fields);
|
|
199
328
|
const signed = await this.signDeploy({
|
|
200
329
|
...fields,
|
|
201
|
-
owner: fields.owner || this.address
|
|
330
|
+
owner: fields.owner || this.address,
|
|
331
|
+
contentHash
|
|
202
332
|
});
|
|
203
333
|
const body = {
|
|
204
334
|
...fields,
|
|
@@ -207,7 +337,8 @@ class Cx3Wallet {
|
|
|
207
337
|
wallet: fields.wallet || signed.owner,
|
|
208
338
|
timestamp: signed.timestamp,
|
|
209
339
|
signature: signed.signature,
|
|
210
|
-
senderPublicKey: signed.senderPublicKey
|
|
340
|
+
senderPublicKey: signed.senderPublicKey,
|
|
341
|
+
contentHash
|
|
211
342
|
};
|
|
212
343
|
const result = await this.provider.request("/deployContract", {
|
|
213
344
|
method: "POST",
|
|
@@ -307,7 +438,7 @@ class Cx3Wallet {
|
|
|
307
438
|
chainId: identity.chainId,
|
|
308
439
|
genesisHash: identity.genesisHash
|
|
309
440
|
});
|
|
310
|
-
const signed = await this
|
|
441
|
+
const signed = await this.#signPayload(payload);
|
|
311
442
|
const body = {
|
|
312
443
|
type: "TRANSFER",
|
|
313
444
|
fromAddress: stripNativeAddress(this.address),
|