@carbonx3/cx3.js 0.3.9 → 0.3.11

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 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);
@@ -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` with the code in `tx.error`, and `tx.wait()` rejects with a `Cx3TxError` of that code.
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");
@@ -252,7 +313,8 @@ try {
252
313
  | `insufficient_scope`, `ROUTE_NOT_ALLOWED` | The route needs another scope, or is not open to applications (403) |
253
314
  | `RATE_LIMITED` | The application's rate limit was reached (429) |
254
315
  | `INSUFFICIENT_BALANCE`, `GAS_PRICE_TOO_LOW` | A native transfer's sender cannot pay the amount plus gas, or the gas price is below the chain floor |
255
- | `INVALID_AMOUNT`, `INVALID_AMOUNT_PRECISION` | A native amount is not greater than 0, or has more than 6 decimal places (CX3 has 6); `transferNative` checks both before signing |
316
+ | `INVALID_AMOUNT`, `INVALID_AMOUNT_PRECISION` | A native amount is not greater than 0, is a text that is not plain decimal digits (`"0x10"`, `"1e3"`), or has more than 6 decimal places (CX3 has 6); `transferNative` checks before signing |
317
+ | `INVALID_RECIPIENT` | `transferNative` was given a recipient that is not a full address (`0x` or `cx3:` and 40 hex digits); nothing is signed |
256
318
  | `GAS_LIMIT_TOO_LOW`, `GAS_LIMIT_TOO_HIGH` | A native transfer's gas limit is outside 21,000–100,000 |
257
319
  | `GAS_LIMIT_EXCEEDED` | A contract send's `gasLimit` is above the chain's maximum (400, `max` in the body) |
258
320
  | `INVALID_NONCE_ORDER` | A native transfer's nonce is not the next one (`expectedNonce` in the answer) |
@@ -261,22 +323,29 @@ try {
261
323
  | `PRIVATE_TOKEN_SENDER`, `PRIVATE_TOKEN_RECIPIENT`, `PRIVATE_TOKEN` | Private token rules (see above) |
262
324
  | `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
325
  | `TX_FAILED` | The transaction failed for good (a dropped send only after its `finalAfter`) |
326
+ | `TX_REFUSED` | `tx.error` of an answer that neither reports success nor names a transaction or a reason |
327
+ | `NO_TX_HASH` | `tx.wait()` was called on an answer that names no transaction: there is nothing to follow |
328
+ | `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 |
329
+ | `INVALID_SIGNED_REQUEST` | `resendTransaction` was given something that is not this wallet's signed request |
330
+ | `SIGN_REFUSED_TRANSACTION_PAYLOAD` | `wallet.signMessage` was given a text the chain accepts as a transaction or call; sign outside messages with `signPersonalMessage` |
264
331
  | `TX_TRACK_TIMEOUT` | `tx.wait()` stopped while the transaction was still pending: not a failure, do not sign it again |
265
332
  | `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
333
  | `REQUEST_LOOKUP_UNAVAILABLE` | `getRequest` could not ask the chain (503): try again later, do not sign again |
267
334
  | `LEDGER_UNAVAILABLE`, `LEDGER_FULL` | The gateway's `/dapp/v1` send could not record the request (503): it was not sent, and the same signed body may be sent again later, without a new signature |
268
335
  | `BASIC_AUTH_UNSUPPORTED` | The `auth` option was used |
269
336
  | `NO_CHAIN_IDENTITY` | The network's chain id or genesis hash is unavailable; a native transfer is not signed without them |
337
+ | `CHAIN_IDENTITY_MISMATCH` | The provider was made for one network (`chainId` and `genesisHash` options) and the endpoint names another; nothing is signed (`err.details.expected`, `err.details.got`) |
338
+ | `INSECURE_BASE_URL` | Credentials would travel over plain `http` to another machine; use `https` (or `allowInsecure: true` on a network you trust) |
270
339
 
271
340
  ## Browser applications
272
341
 
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.9.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.
342
+ 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.11.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
343
 
275
344
  ## Environment variables
276
345
 
277
346
  | Variable | Used for |
278
347
  |----------|----------|
279
- | `CX3_CLIENT_ID`, `CX3_CLIENT_SECRET` | Application credentials when the options are omitted |
348
+ | `CX3_CLIENT_ID`, `CX3_CLIENT_SECRET` | Application credentials when the options are omitted (not read with `devNode: true`) |
280
349
  | `CX3_API_KEY` | Legacy wallet API key |
281
350
  | `CX3_NODE_URL` | Gateway URL for `getDefaultProvider()` |
282
351
 
@@ -286,12 +355,13 @@ Type declarations (`index.d.ts`) ship with the package.
286
355
 
287
356
  ## Upgrading from 0.2.x
288
357
 
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.9.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`).
358
+ 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. In 0.3.11, `transferNative` refuses a recipient that is not a full address (`INVALID_RECIPIENT`) and a text amount that is not plain decimal digits; `sendTransaction`, `deployContract` and the owner actions sign for `opts.chainId` and `opts.genesisHash` when both are given; a provider can be made for one network (`chainId` and `genesisHash` options, `CHAIN_IDENTITY_MISMATCH`); credentials are not sent over plain `http` to another machine (`INSECURE_BASE_URL`); with `devNode: true` the credentials of the environment are not read; and `@noble/secp256k1` is pinned to one exact version.
290
359
 
291
360
  - **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
361
  - **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
362
  - **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); `signMessage` still signs a digest as it is.
363
+ - **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.
364
+ - **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
365
 
296
366
  ## Upgrading from 0.1.x
297
367
 
package/index.d.ts CHANGED
@@ -31,8 +31,13 @@ export interface Cx3ProviderOptions {
31
31
  scope?: string | Array<"read" | "write" | "deploy">;
32
32
  /** wallet API key (cx3wk_…), until apps move to OAuth2; env CX3_API_KEY */
33
33
  apiKey?: string;
34
- /** a development node that runs without auth (/api/v1), no credentials */
34
+ /** a development node that runs without auth (/api/v1); the credentials of the environment are not read for it */
35
35
  devNode?: boolean;
36
+ /** The network this provider is for (both together): an endpoint on another network is refused with CHAIN_IDENTITY_MISMATCH and nothing is signed */
37
+ chainId?: string;
38
+ genesisHash?: string;
39
+ /** Credentials over plain http to another machine throw INSECURE_BASE_URL unless this is true (localhost is always allowed) */
40
+ allowInsecure?: boolean;
36
41
  /** "" on the gateway, "/api/v1" on a development node */
37
42
  apiPrefix?: string;
38
43
  /** Not supported: throws BASIC_AUTH_UNSUPPORTED (HTTP Basic is the nodes' internal login) */
@@ -77,6 +82,10 @@ export class Cx3Provider {
77
82
  authMode: "oauth" | "apiKey" | "devNode";
78
83
  /** Throws CREDENTIALS_REQUIRED without clientId / clientSecret, apiKey or devNode */
79
84
  constructor(baseUrl: string, options?: Cx3ProviderOptions);
85
+ /** Forget the held access token: the next request asks for a new one */
86
+ clearAccessToken(): void;
87
+ /** Where the provider points and how it signs in; never a credential (JSON.stringify and util.inspect use it) */
88
+ toJSON(): { baseUrl: string; authMode: "oauth" | "apiKey" | "devNode"; clientId?: string; scope?: string };
80
89
  /** The current OAuth2 access token (renewed a minute before it expires); null without an OAuth2 app */
81
90
  getAccessToken(force?: boolean): Promise<string | null>;
82
91
  request(path: string, opts?: { method?: string; body?: unknown; query?: Record<string, string | number> }): Promise<any>;
@@ -134,7 +143,12 @@ export class Cx3Wallet {
134
143
  constructor(privateKey: string | Uint8Array | Buffer, provider?: Cx3Provider | null);
135
144
  static createRandom(provider?: Cx3Provider | null): Cx3Wallet;
136
145
  connect(provider: Cx3Provider): Cx3Wallet;
146
+ /** The private key as given to the constructor; the wallet shows it nowhere else (not in JSON, logs or a spread) */
147
+ exportPrivateKey(): string | Uint8Array | Buffer;
148
+ /** Address and public key only (JSON.stringify and util.inspect use it) */
149
+ toJSON(): { address: string; publicKey: string };
137
150
  getAddress(): string;
151
+ /** 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
152
  signMessage(message: string | Uint8Array): Promise<{
139
153
  signature: string;
140
154
  r: string;
@@ -156,17 +170,32 @@ export class Cx3Wallet {
156
170
  signSend(contractAddress: string, method: string, params?: object, opts?: Cx3ChainIdentity): Promise<any>;
157
171
  signOwnerAction(action: string, address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
158
172
  /** A private token's transfer / transferFrom / mint is checked first (PRIVATE_TOKEN_SENDER / _RECIPIENT); `privateCheck: false` skips it */
159
- sendTransaction(contractAddress: string, method: string, params?: object, opts?: { wait?: number; async?: boolean; privateCheck?: boolean; [k: string]: unknown }): Promise<TransactionResponse>;
173
+ sendTransaction(contractAddress: string, method: string, params?: object, opts?: { wait?: number; async?: boolean; privateCheck?: boolean; chainId?: string; genesisHash?: string; [k: string]: unknown }): Promise<TransactionResponse>;
160
174
  /** 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. */
161
- deployContract(fields: object): Promise<Cx3DeployResult>;
162
- pauseContract(address: string, owner?: string | null): Promise<any>;
163
- unpauseContract(address: string, owner?: string | null): Promise<any>;
164
- deleteContract(address: string, owner?: string | null): Promise<any>;
165
- /** Throws Cx3Error INVALID_AMOUNT (not greater than 0) or INVALID_AMOUNT_PRECISION (more than 6 decimals) before signing */
175
+ /** Sends an earlier send's signed request again, unchanged (no new signature): after SEND_OUTCOME_UNKNOWN or a lost answer */
176
+ resendTransaction(signedRequest: Cx3SignedRequest, opts?: { wait?: number; async?: boolean }): Promise<TransactionResponse>;
177
+ deployContract(fields: object, opts?: Cx3ChainIdentity): Promise<Cx3DeployResult>;
178
+ pauseContract(address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
179
+ unpauseContract(address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
180
+ deleteContract(address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
181
+ /** Before signing, throws Cx3Error INVALID_RECIPIENT (not 0x / cx3: + 40 hex), INVALID_AMOUNT (not greater than 0, or a text that is not plain decimal digits) or INVALID_AMOUNT_PRECISION (more than 6 decimals) */
166
182
  transferNative(to: string, amount: number | string, opts?: { nonce?: number; timestamp?: number; data?: unknown; gasLimit?: number; gasPrice?: number; chainId?: string; genesisHash?: string; wait?: number }): Promise<TransactionResponse>;
167
183
  bindApiKey(address?: string): Promise<any>;
168
184
  }
169
185
 
186
+ /** A signed contract call as it was sent: no key in it, safe to store; `requestId` is sha256 of its CX3_CALL_V1 digest */
187
+ export interface Cx3SignedRequest {
188
+ contractAddress: string;
189
+ method: string;
190
+ params: object;
191
+ fromAddress: string;
192
+ signature: string;
193
+ senderPublicKey: string;
194
+ timestamp: number;
195
+ requestId: string;
196
+ }
197
+
198
+ /** ABI methods become properties, except names JavaScript calls by itself (then, catch, finally, toJSON, inspect): reach those with call() / send() */
170
199
  export class Contract {
171
200
  address: string;
172
201
  target: string | null;
@@ -242,7 +271,10 @@ export class TransactionResponse {
242
271
  blockHeight?: number;
243
272
  contractAddress: string | null;
244
273
  async: boolean;
245
- /** "pending" for async, TX_TIMEOUT (queued) and dropped answers; "confirmed" / "failed" for sync ones */
274
+ /**
275
+ * "pending" for async, TX_TIMEOUT (queued) and dropped answers and for a transfer accepted but in no block yet;
276
+ * "confirmed" only with a blockHeight; "failed" for a refused one (also the node's HTTP 200 `{ error }` answer)
277
+ */
246
278
  status: string;
247
279
  /** trimmed from a block: not final, another validator may still include it until finalAfter */
248
280
  dropped: boolean;
@@ -250,9 +282,13 @@ export class TransactionResponse {
250
282
  finalAfter: number | null;
251
283
  /** the signed request's id (lookup: provider.getRequest) */
252
284
  requestId: string | null;
285
+ /** contract sends: the signed call as sent (not enumerable, not in JSON); wallet.resendTransaction takes it */
286
+ signedRequest?: Cx3SignedRequest;
253
287
  hint: string | null;
254
288
  error: string | null;
255
- /** follows pending / dropped answers (until finalAfter by default); rejects only on a final failure */
289
+ /** the answer without the provider (JSON.stringify uses it) */
290
+ toJSON(): Record<string, unknown>;
291
+ /** 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 */
256
292
  wait(opts?: { timeoutMs?: number; intervalMs?: number; onPending?: (status: any) => void; onConfirmed?: (status: any) => void }): Promise<any>;
257
293
  }
258
294
 
package/index.js CHANGED
@@ -34,7 +34,7 @@ const {
34
34
  } = require("./src/crypto");
35
35
  const { normalizeAbi, argsToParams } = require("./src/abi");
36
36
 
37
- const VERSION = "0.3.9";
37
+ const VERSION = "0.3.11";
38
38
 
39
39
  /**
40
40
  * Convenience: provider from `options.url` or env `CX3_NODE_URL` — the CX3Api gateway
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carbonx3/cx3.js",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
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": {
@@ -40,7 +40,7 @@
40
40
  "node": ">=20.19.0"
41
41
  },
42
42
  "dependencies": {
43
- "@noble/secp256k1": "^3.2.0"
43
+ "@noble/secp256k1": "3.2.0"
44
44
  },
45
45
  "exports": {
46
46
  ".": {
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
- if (name in this) continue;
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/provider.js CHANGED
@@ -65,7 +65,16 @@ function withoutCredentialHeaders(headers) {
65
65
  return out;
66
66
  }
67
67
 
68
- const CLIENT_ID_RE = /^cx3app_[a-f0-9]{24}$/i;
68
+ // http to another machine (not localhost / 127.x / ::1)
69
+ function isRemotePlainHttp(baseUrl) {
70
+ let u;
71
+ try { u = new URL(baseUrl); } catch (_) { return false; }
72
+ if (u.protocol !== "http:") return false;
73
+ const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
74
+ return !(host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127(\.\d{1,3}){3}$/.test(host));
75
+ }
76
+
77
+ const CLIENT_ID_RE =/^cx3app_[a-f0-9]{24}$/i;
69
78
  const CLIENT_SECRET_RE = /^cx3sec_[a-f0-9]{64}$/i;
70
79
 
71
80
  // a dropped tx is failed for good after its finalAfter: wait this much past it (clock skew)
@@ -78,6 +87,12 @@ function isTransientError(e) {
78
87
  }
79
88
 
80
89
  class Cx3Provider {
90
+ // Credentials live in private fields: JSON.stringify, util.inspect, console.log, Object.keys and a
91
+ // spread of the provider (or of anything that holds it) never show them.
92
+ #clientSecret = "";
93
+ #apiKey = "";
94
+ #token = null; // { value, refreshAt, scope }
95
+
81
96
  /**
82
97
  * @param {string} baseUrl CX3Api gateway URL (`CX3_NODE_URL`, e.g. https://cx3api.carbonx3.com), or a development node with `devNode`
83
98
  * @param {object} [options]
@@ -100,29 +115,47 @@ class Cx3Provider {
100
115
  "BASIC_AUTH_UNSUPPORTED"
101
116
  );
102
117
  }
103
- const env = (name) => (process.env[name] || "").trim();
118
+ this.devNode = options.devNode === true;
119
+ // a development node gets only what the caller passes: the gateway credentials of the environment never go to it
120
+ const env = (name) => (this.devNode ? "" : (process.env[name] || "").trim());
104
121
  this.clientId = String(options.clientId != null ? options.clientId : env("CX3_CLIENT_ID")).trim();
105
- this._clientSecret = String(options.clientSecret != null ? options.clientSecret : env("CX3_CLIENT_SECRET")).trim();
122
+ this.#clientSecret = String(options.clientSecret != null ? options.clientSecret : env("CX3_CLIENT_SECRET")).trim();
106
123
  this.scope = Array.isArray(options.scope) ? options.scope.join(" ") : String(options.scope || "").trim();
107
- this.apiKey = String(options.apiKey != null ? options.apiKey : env("CX3_API_KEY")).trim();
108
- this.devNode = options.devNode === true;
109
- this.oauth = !!(this.clientId || this._clientSecret);
110
- if (this.oauth && (!CLIENT_ID_RE.test(this.clientId) || !CLIENT_SECRET_RE.test(this._clientSecret))) {
124
+ this.#apiKey = String(options.apiKey != null ? options.apiKey : env("CX3_API_KEY")).trim();
125
+ this.oauth = !!(this.clientId || this.#clientSecret);
126
+ if (this.oauth && (!CLIENT_ID_RE.test(this.clientId) || !CLIENT_SECRET_RE.test(this.#clientSecret))) {
111
127
  throw new Cx3Error("clientId must be cx3app_… and clientSecret cx3sec_… (CX3WebUI → Settings → API → Applications)", "INVALID_CLIENT_CREDENTIALS");
112
128
  }
113
- const hasKey = walletApiKeyMode(this.apiKey) !== "public";
129
+ const hasKey = walletApiKeyMode(this.#apiKey) !== "public";
114
130
  if (!this.oauth && !hasKey && !this.devNode) {
115
131
  throw new Cx3Error(
116
132
  "App credentials required: create an application in CX3WebUI (Settings → API → Applications) and pass clientId / clientSecret (or set CX3_CLIENT_ID / CX3_CLIENT_SECRET)",
117
133
  "CREDENTIALS_REQUIRED"
118
134
  );
119
135
  }
136
+ // credentials over plain http can be read on the way: only this machine, or the caller's own word
137
+ if ((this.oauth || hasKey) && options.allowInsecure !== true && isRemotePlainHttp(this.baseUrl)) {
138
+ throw new Cx3Error(
139
+ "Credentials are not sent over plain http: use an https URL (or allowInsecure: true on a network you trust)",
140
+ "INSECURE_BASE_URL"
141
+ );
142
+ }
143
+ // The network this provider is for: with both given, signatures are made for this network only and
144
+ // an endpoint that names another one is refused (CHAIN_IDENTITY_MISMATCH).
145
+ this.pinnedIdentity = null;
146
+ if (options.chainId != null || options.genesisHash != null) {
147
+ const chainId = String(options.chainId == null ? "" : options.chainId).trim();
148
+ const genesisHash = normalizeGenesisHash(options.genesisHash);
149
+ if (!chainId || !genesisHash) {
150
+ throw new Cx3Error("chainId and genesisHash are given together (genesisHash: 64 hex)", "NO_CHAIN_IDENTITY");
151
+ }
152
+ this.pinnedIdentity = { chainId, genesisHash };
153
+ }
120
154
  this.gateway = !this.devNode;
121
155
  this.apiPrefix = options.apiPrefix != null ? options.apiPrefix : (this.gateway ? "" : "/api/v1");
122
156
  this.extraHeaders = withoutCredentialHeaders(options.headers);
123
157
  this.fetchImpl = options.fetch || globalThis.fetch;
124
158
  this.timeoutMs = options.timeoutMs || 60000;
125
- this._token = null; // { value, refreshAt, scope }
126
159
  this._tokenPromise = null;
127
160
  // token address → { private, owners } from its registry record (set at deploy)
128
161
  this._tokenRecords = new Map();
@@ -132,12 +165,12 @@ class Cx3Provider {
132
165
  }
133
166
 
134
167
  get apiKeyMode() {
135
- return walletApiKeyMode(this.apiKey);
168
+ return walletApiKeyMode(this.#apiKey);
136
169
  }
137
170
 
138
171
  /** "oauth" | "apiKey" | "devNode" */
139
172
  get authMode() {
140
- if (this.devNode && !this.oauth && walletApiKeyMode(this.apiKey) === "public") return "devNode";
173
+ if (this.devNode && !this.oauth && walletApiKeyMode(this.#apiKey) === "public") return "devNode";
141
174
  return this.oauth ? "oauth" : "apiKey";
142
175
  }
143
176
 
@@ -147,7 +180,7 @@ class Cx3Provider {
147
180
  */
148
181
  async getAccessToken(force = false) {
149
182
  if (!this.oauth) return null;
150
- if (!force && this._token && Date.now() < this._token.refreshAt) return this._token.value;
183
+ if (!force && this.#token && Date.now() < this.#token.refreshAt) return this.#token.value;
151
184
  if (!this._tokenPromise) {
152
185
  this._tokenPromise = this._fetchToken().finally(() => { this._tokenPromise = null; });
153
186
  }
@@ -155,7 +188,7 @@ class Cx3Provider {
155
188
  }
156
189
 
157
190
  async _fetchToken() {
158
- const form = new URLSearchParams({ grant_type: "client_credentials", client_id: this.clientId, client_secret: this._clientSecret });
191
+ const form = new URLSearchParams({ grant_type: "client_credentials", client_id: this.clientId, client_secret: this.#clientSecret });
159
192
  if (this.scope) form.set("scope", this.scope);
160
193
  const { res, json } = await this._fetchJson(`${this.baseUrl}/oauth/token`, {
161
194
  method: "POST",
@@ -167,8 +200,22 @@ class Cx3Provider {
167
200
  throw new Cx3HttpError((json && json.error_description) || code, res.status, { ...(json || {}), error: code });
168
201
  }
169
202
  const ttl = Number(json.expires_in) > 0 ? Number(json.expires_in) : 3600;
170
- this._token = { value: json.access_token, refreshAt: Date.now() + Math.max(30, ttl - 60) * 1000, scope: json.scope || "" };
171
- return this._token.value;
203
+ this.#token = { value: json.access_token, refreshAt: Date.now() + Math.max(30, ttl - 60) * 1000, scope: json.scope || "" };
204
+ return this.#token.value;
205
+ }
206
+
207
+ /** Forget the held access token: the next request asks for a new one. */
208
+ clearAccessToken() {
209
+ this.#token = null;
210
+ }
211
+
212
+ /** What a log or a JSON answer may show of a provider: where it points and how it signs in, no credential. */
213
+ toJSON() {
214
+ return { baseUrl: this.baseUrl, authMode: this.authMode, clientId: this.clientId || undefined, scope: this.scope || undefined };
215
+ }
216
+
217
+ [Symbol.for("nodejs.util.inspect.custom")]() {
218
+ return `Cx3Provider ${JSON.stringify(this.toJSON())}`;
172
219
  }
173
220
 
174
221
  async _headers(forceToken = false) {
@@ -178,8 +225,8 @@ class Cx3Provider {
178
225
  };
179
226
  if (this.oauth) {
180
227
  headers.Authorization = `Bearer ${await this.getAccessToken(forceToken)}`;
181
- } else if (/^cx3wk_[a-f0-9]{48}$/i.test(this.apiKey)) {
182
- headers["X-Api-Key"] = this.apiKey;
228
+ } else if (/^cx3wk_[a-f0-9]{48}$/i.test(this.#apiKey)) {
229
+ headers["X-Api-Key"] = this.#apiKey;
183
230
  }
184
231
  return headers;
185
232
  }
@@ -413,6 +460,14 @@ class Cx3Provider {
413
460
  const chainId = typeof r.chainId === "string" ? r.chainId.trim() : "";
414
461
  const genesisHash = normalizeGenesisHash(r.genesisHash);
415
462
  if (!chainId || !genesisHash) throw new Cx3Error("network identity unavailable", "NO_CHAIN_IDENTITY");
463
+ const pin = this.pinnedIdentity;
464
+ if (pin && (pin.chainId !== chainId || pin.genesisHash !== genesisHash)) {
465
+ throw new Cx3Error(
466
+ "The endpoint is on another network than the one this provider was made for: nothing is signed",
467
+ "CHAIN_IDENTITY_MISMATCH",
468
+ { expected: { ...pin }, got: { chainId, genesisHash } }
469
+ );
470
+ }
416
471
  this._chainIdentity = { at: Date.now(), value: { chainId, genesisHash } };
417
472
  return { chainId, genesisHash };
418
473
  }
@@ -17,13 +17,19 @@ class TransactionResponse {
17
17
  */
18
18
  constructor(result, provider = null, opts = {}) {
19
19
  this.raw = result || {};
20
- this.provider = provider;
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
- this.success = this.raw.success !== false || queued;
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
- this.status = queued ? "pending" : (this.raw.status || (this.async ? "pending" : (this.success ? "confirmed" : "failed")));
36
- this.error = queued ? null : (this.raw.error || this.raw.message || null);
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
- return this.raw;
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
@@ -24,6 +24,33 @@ function isValidTokenSymbol(symbol) {
24
24
  return TOKEN_SYMBOL_RE.test(String(symbol == null ? "" : symbol));
25
25
  }
26
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
+
27
54
  /**
28
55
  * A deploy result keeps the gateway's fields and gets `dropped`, `finalAfter` and `wait()`. A deploy
29
56
  * the node trimmed from its block (202 dropped, status "pending") is not deployed yet: another
@@ -61,13 +88,17 @@ function withDeployWait(result, provider) {
61
88
  * Local secp256k1 wallet for Green Chain digests.
62
89
  */
63
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
+
64
95
  /**
65
96
  * @param {string|Uint8Array|Buffer} privateKey
66
97
  * @param {import('./provider').Cx3Provider|null} [provider]
67
98
  */
68
99
  constructor(privateKey, provider = null) {
69
100
  if (!privateKey) throw new Cx3Error("privateKey required", "NO_PRIVATE_KEY");
70
- this._privateKey = privateKey;
101
+ this.#privateKey = privateKey;
71
102
  this.provider = provider;
72
103
  this.address = privateKeyToAddress(privateKey);
73
104
  this.publicKey = privateKeyToPublicKey(privateKey, true).toString("hex");
@@ -78,21 +109,49 @@ class Cx3Wallet {
78
109
  }
79
110
 
80
111
  connect(provider) {
81
- return new Cx3Wallet(this._privateKey, provider);
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())}`;
82
127
  }
83
128
 
84
129
  getAddress() {
85
130
  return this.address;
86
131
  }
87
132
 
88
- /** Sign raw bytes / a digest string as given (sha256 + secp256k1): the typed digests use this. */
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
+ */
89
140
  async signMessage(message) {
90
- return signMessage(message, this._privateKey);
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);
91
150
  }
92
151
 
93
152
  /** Sign a site message the way the CX3Wallet extension and app do (with the site-message prefix). */
94
153
  async signPersonalMessage(message) {
95
- return signPersonalMessage(message, this._privateKey);
154
+ return signPersonalMessage(message, this.#privateKey);
96
155
  }
97
156
 
98
157
  /** The network identity typed digests bind: `opts.chainId` + `opts.genesisHash`, else the provider's. */
@@ -121,7 +180,7 @@ class Cx3Wallet {
121
180
  contentHash,
122
181
  timestamp
123
182
  }, await this._identity(opts));
124
- const signed = await this.signMessage(digest);
183
+ const signed = await this.#signPayload(digest);
125
184
  return {
126
185
  ...signed,
127
186
  owner,
@@ -143,7 +202,7 @@ class Cx3Wallet {
143
202
  const timestamp = Date.now();
144
203
  const safeParams = toJsonSafe(params || {});
145
204
  const digest = buildSendDigest(contractAddress, method, safeParams, fromAddress, timestamp, await this._identity(opts));
146
- const signed = await this.signMessage(digest);
205
+ const signed = await this.#signPayload(digest);
147
206
  return {
148
207
  ...signed,
149
208
  fromAddress,
@@ -157,7 +216,7 @@ class Cx3Wallet {
157
216
  const own = normalizeAddress(owner || this.address);
158
217
  const timestamp = Date.now();
159
218
  const digest = buildOwnerActionDigest(action, address, own, timestamp, await this._identity(opts));
160
- const signed = await this.signMessage(digest);
219
+ const signed = await this.#signPayload(digest);
161
220
  return {
162
221
  ...signed,
163
222
  owner: own,
@@ -176,20 +235,72 @@ class Cx3Wallet {
176
235
  if (opts.privateCheck !== false && typeof this.provider.checkPrivateTokenSend === "function") {
177
236
  await this.provider.checkPrivateTokenSend(contractAddress, method, params, this.address);
178
237
  }
179
- const signed = await this.signSend(contractAddress, method, params);
180
- const body = {
238
+ const signed = await this.signSend(contractAddress, method, params, opts);
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,
181
244
  fromAddress: signed.fromAddress,
182
245
  signature: signed.signature,
183
246
  senderPublicKey: signed.senderPublicKey,
184
- timestamp: signed.timestamp
247
+ timestamp: signed.timestamp,
248
+ requestId: requestIdOf(signed.digest)
185
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) {
186
271
  const asyncMode = opts.wait === 0 || opts.async === true;
187
- // send exactly the params that were signed (BigInt already as strings)
188
- const result = await this.provider.send(contractAddress, method, signed.params, {
189
- ...body,
190
- wait: asyncMode ? 0 : undefined
191
- });
192
- return new TransactionResponse(result, this.provider, opts);
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;
193
304
  }
194
305
 
195
306
  /**
@@ -198,7 +309,7 @@ class Cx3Wallet {
198
309
  * (Cx3HttpError code DUPLICATE_TOKEN_SYMBOL, status 409). A dropped deploy (202, `dropped`,
199
310
  * `finalAfter`) is pending, not deployed: `result.wait()` follows it (withDeployWait).
200
311
  */
201
- async deployContract(fields) {
312
+ async deployContract(fields, opts = {}) {
202
313
  if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
203
314
  fields = toJsonSafe(fields || {});
204
315
  const ctor = fields.constructorArgs && typeof fields.constructorArgs === "object" && !Array.isArray(fields.constructorArgs) ? fields.constructorArgs : {};
@@ -218,7 +329,7 @@ class Cx3Wallet {
218
329
  ...fields,
219
330
  owner: fields.owner || this.address,
220
331
  contentHash
221
- });
332
+ }, opts);
222
333
  const body = {
223
334
  ...fields,
224
335
  owner: signed.owner,
@@ -236,9 +347,9 @@ class Cx3Wallet {
236
347
  return withDeployWait(result, this.provider);
237
348
  }
238
349
 
239
- async _ownerActionRequest(action, address, owner, method, path) {
350
+ async _ownerActionRequest(action, address, owner, method, path, opts = {}) {
240
351
  if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
241
- const signed = await this.signOwnerAction(action, address, owner);
352
+ const signed = await this.signOwnerAction(action, address, owner, opts);
242
353
  return this.provider.request(path, {
243
354
  method,
244
355
  body: {
@@ -250,33 +361,36 @@ class Cx3Wallet {
250
361
  });
251
362
  }
252
363
 
253
- async pauseContract(address, owner = null) {
364
+ async pauseContract(address, owner = null, opts = {}) {
254
365
  return this._ownerActionRequest(
255
366
  "pause",
256
367
  address,
257
368
  owner,
258
369
  "PUT",
259
- `/contracts/${encodeURIComponent(address)}/pause`
370
+ `/contracts/${encodeURIComponent(address)}/pause`,
371
+ opts
260
372
  );
261
373
  }
262
374
 
263
- async unpauseContract(address, owner = null) {
375
+ async unpauseContract(address, owner = null, opts = {}) {
264
376
  return this._ownerActionRequest(
265
377
  "unpause",
266
378
  address,
267
379
  owner,
268
380
  "PUT",
269
- `/contracts/${encodeURIComponent(address)}/unpause`
381
+ `/contracts/${encodeURIComponent(address)}/unpause`,
382
+ opts
270
383
  );
271
384
  }
272
385
 
273
- async deleteContract(address, owner = null) {
386
+ async deleteContract(address, owner = null, opts = {}) {
274
387
  return this._ownerActionRequest(
275
388
  "delete",
276
389
  address,
277
390
  owner,
278
391
  "DELETE",
279
- `/contracts/${encodeURIComponent(address)}`
392
+ `/contracts/${encodeURIComponent(address)}`,
393
+ opts
280
394
  );
281
395
  }
282
396
 
@@ -288,9 +402,18 @@ class Cx3Wallet {
288
402
  */
289
403
  async transferNative(to, amount, opts = {}) {
290
404
  if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
291
- const toAddress = String(to || "").trim();
292
- if (!toAddress) throw new Cx3Error("recipient required", "NO_RECIPIENT");
293
- const amt = typeof amount === "number" ? amount : Number(amount);
405
+ if (to == null || String(to).trim() === "") throw new Cx3Error("recipient required", "NO_RECIPIENT");
406
+ // a full address only (0x / cx3: + 40 hex): anything else is not signed
407
+ const toAddress = normalizeAddress(to);
408
+ if (!/^0x[0-9a-f]{40}$/.test(toAddress)) {
409
+ throw new Cx3Error("The recipient is not a CX3 address (0x + 40 hex)", "INVALID_RECIPIENT");
410
+ }
411
+ // a text amount is plain decimal digits: "0x10" or "1e3" would be read as another number
412
+ if (typeof amount === "string" && !/^\d{1,15}(\.\d+)?$/.test(amount.trim())) {
413
+ throw new Cx3Error("Invalid amount", "INVALID_AMOUNT");
414
+ }
415
+ if (typeof amount !== "number" && typeof amount !== "string") throw new Cx3Error("Invalid amount", "INVALID_AMOUNT");
416
+ const amt = typeof amount === "number" ? amount : Number(amount.trim());
294
417
  if (!Number.isFinite(amt) || amt <= 0) {
295
418
  throw new Cx3Error("Invalid amount", "INVALID_AMOUNT");
296
419
  }
@@ -327,7 +450,7 @@ class Cx3Wallet {
327
450
  chainId: identity.chainId,
328
451
  genesisHash: identity.genesisHash
329
452
  });
330
- const signed = await this.signMessage(payload);
453
+ const signed = await this.#signPayload(payload);
331
454
  const body = {
332
455
  type: "TRANSFER",
333
456
  fromAddress: stripNativeAddress(this.address),