@carbonx3/cx3.js 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,10 @@
6
6
 
7
7
  The official Node.js SDK (**CX3js**) for [CarbonX3 Green Chain](https://carbonx3.com/).
8
8
 
9
- CX3js connects server-side applications to CarbonX3 Green Chain through the CX3Api gateway. It signs in as an OAuth2 application, signs transactions locally with secp256k1, binds contract ABIs to plain JavaScript methods and deploys the Green Chain token standards. Requests are REST + JSON with named parameters, not binary calldata.
9
+ - **Developer site:** [CX3Script](https://cx3script.com)
10
+ - **Documentation:** [cx3script.com/docs](https://cx3script.com/docs)
11
+
12
+ CX3js connects server-side applications to CarbonX3 Green Chain through the CX3Api gateway. It signs in as an OAuth2 application, signs transactions with secp256k1 (a key in your process, or your own signer), binds contract ABIs to plain JavaScript methods and deploys the Green Chain token standards. Requests are REST + JSON with named parameters, not binary calldata.
10
13
 
11
14
  > **Network status:** CarbonX3 Green Chain currently runs as a development network (devnet; `provider.getStatus()` returns its current chain id). Devnet CX3, tokens and contracts are test data with no monetary value and may be cleared when the devnet is reset.
12
15
 
@@ -14,6 +17,7 @@ CX3js connects server-side applications to CarbonX3 Green Chain through the CX3A
14
17
 
15
18
  - **OAuth2 application access**: client credentials from CX3WebUI; the access token is requested, cached and renewed for you.
16
19
  - **Local signing**: private keys stay in your process, and every write carries the wallet's secp256k1 signature.
20
+ - **External signers**: a wallet can sign through an HSM, a KMS, a hardware wallet or a custody service, so the key never enters your process.
17
21
  - **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
22
  - **Honest results**: a send or transfer the network refuses is reported as failed, and `"confirmed"` always comes with the block it is in.
19
23
  - **ABI-bound contracts**: `token.balanceOf(owner)` and `token.transfer(to, amount)`, with the ABI loaded from the chain.
@@ -87,27 +91,19 @@ ES modules work too: `import { Cx3Provider, Cx3Wallet } from "@carbonx3/cx3.js";
87
91
 
88
92
  ## Authentication
89
93
 
90
- **OAuth2 application (recommended).** The provider exchanges `clientId` and `clientSecret` at `POST /oauth/token` (client credentials grant) for a one-hour access token and sends it as `Authorization: Bearer …`. It renews the token a minute before it expires and, if the gateway answers `invalid_token`, fetches a fresh one and retries once. The secret is sent only to the token endpoint. `scope: ["read"]` requests fewer scopes than the application has. When the options are omitted, the provider reads `CX3_CLIENT_ID` and `CX3_CLIENT_SECRET`.
94
+ **OAuth2 application.** The provider exchanges `clientId` and `clientSecret` at `POST /oauth/token` (client credentials grant) for a one-hour access token and sends it as `Authorization: Bearer …`. It renews the token a minute before it expires and, if the gateway answers `invalid_token`, fetches a fresh one and retries once. The secret is sent only to the token endpoint. `scope: ["read"]` requests fewer scopes than the application has. When the options are omitted, the provider reads `CX3_CLIENT_ID` and `CX3_CLIENT_SECRET`.
91
95
 
92
96
  The access token identifies the application; every write is still signed by the wallet. A route outside the application's scopes answers `403 insufficient_scope`, and routes reserved for CarbonX3's own services answer `403 ROUTE_NOT_ALLOWED`. Each application has its own rate limit (300 requests per minute by default, `429 RATE_LIMITED`).
93
97
 
94
98
  `provider.bindApiKey(address)` (or `wallet.bindApiKey()`) confirms that a wallet is the one the application belongs to: `{ ok: true }`, otherwise `wallet_mismatch`.
95
99
 
96
- **Wallet API key (legacy).** A `cx3wk_…` key from the same settings page still works while applications move to OAuth2. Pass it as `apiKey` (or set `CX3_API_KEY`); it is sent as `X-Api-Key` and carries every scope of its wallet.
97
-
98
- ```javascript
99
- const provider = new Cx3Provider("https://cx3api.carbonx3.com", { apiKey: process.env.CX3_API_KEY });
100
- ```
101
-
102
100
  **Development node.** `new Cx3Provider(devNodeUrl, { devNode: true })` talks to a development node that runs without authentication (its `/api/v1` routes).
103
101
 
104
- **Credentials are required.** Without an application, an API key or `devNode: true`, the constructor throws `CREDENTIALS_REQUIRED`.
105
-
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
+ **Credentials are required.** Without an application or `devNode: true`, the constructor throws `CREDENTIALS_REQUIRED`. The application is the only credential: `headers` cannot carry `Authorization`.
107
103
 
108
104
  ## Keeping secrets out of logs
109
105
 
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:
106
+ A wallet's private key and a provider's client secret and access token are private fields of their objects. Nothing that prints, serializes or copies an object can reach them:
111
107
 
112
108
  ```javascript
113
109
  JSON.stringify(wallet); // {"address":"0x…","publicKey":"02…"}
@@ -118,7 +114,7 @@ const copy = { ...wallet }; // address, publicKey and provider; no key
118
114
  const key = wallet.exportPrivateKey(); // the one way to read the key back (for example to store a new wallet)
119
115
  ```
120
116
 
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.
117
+ `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.
122
118
 
123
119
  ## Reading chain data
124
120
 
@@ -164,6 +160,8 @@ Supported ABI shapes:
164
160
 
165
161
  `provider.getContract(address)` loads the ABI the chain calls the contract with: shape 2 for contracts compiled to CX3JS, shape 3 for CX3WASM. Built-in chain standards (ABI inputs `["params"]`) take one named object: `await lp.transfer({ to, amount })`. A contract handle created with a provider instead of a wallet can only read; its writes throw `NO_SIGNER`.
166
162
 
163
+ ABI methods are properties of a `Contract`, except the names JavaScript calls by itself (`then`, `catch`, `finally`, `toJSON`, `inspect`): a method with such a name is reached with `contract.call(name, params)` or `contract.send(name, params)`.
164
+
167
165
  ## Transactions and fees
168
166
 
169
167
  Gas is paid in CX3 to the validator that produces the block: gas used × gas price (devnet default 0.0001 CX3 per gas).
@@ -280,7 +278,7 @@ A private CX31, CX300, CX315 or CX3192 takes its CX3WebUI source as `sourceCode`
280
278
 
281
279
  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:
282
280
 
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.
281
+ - **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.
284
282
  - **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.
285
283
  - **Owner action:** `CX3_OWNER_V1|` + `JSON.stringify([chainId, genesisHash, action, address, owner, timestamp])`
286
284
  - **Site message:** `0x19` + `"CX3 Signed Message:\n"` + UTF-8 length + message (`signPersonalMessage`), the way the CX3Wallet extension and app sign `cx3_sign_message`
@@ -288,7 +286,30 @@ Digests are UTF-8 strings, hashed with SHA-256 and signed with secp256k1, byte-i
288
286
 
289
287
  BigInt values in params are converted to decimal strings before signing, and exactly those params are sent. Deploy, contract-send and owner signatures carry a timestamp and are accepted within ±5 minutes of the gateway's clock. A native transfer is admitted only within 60 seconds after its timestamp (the pool drops it after 60 seconds), and a block takes it only within 10 minutes of its timestamp. The genesis hash ties a native transfer to one network and one genesis: after a chain reset, a transfer signed before it no longer verifies. An address is `0x` + the last 20 bytes of `sha256(uncompressedPublicKey)`.
290
288
 
291
- The same building blocks are exported: `buildDeployDigest`, `buildSendDigest`, `buildOwnerActionDigest` (each takes the identity as its last argument), `buildTransferPayload`, `personalMessageBytes`, `signMessage` (signs a digest as given), `signPersonalMessage`, `privateKeyToAddress` and `createPrivateKey`.
289
+ The same building blocks are exported: `buildDeployDigest`, `buildSendDigest`, `buildOwnerActionDigest` (each takes the identity as its last argument), `buildTransferPayload`, `personalMessageBytes`, `signMessage` (signs a digest as given), `signPersonalMessage`, `privateKeyToAddress`, `publicKeyToAddress` and `createPrivateKey`.
290
+
291
+ `wallet.signPersonalMessage(text)` is for any text that comes from outside (a login challenge, a user's message). `wallet.signMessage` signs a text as it is and refuses one 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.
292
+
293
+ ### External signers
294
+
295
+ `Cx3Wallet.fromSigner()` makes a wallet whose key stays outside your process: in an HSM, a cloud KMS, a hardware wallet or a custody service. You give the public key and a `sign` function; everything else (`transferNative`, contract sends, deploys, `signPersonalMessage`) works as with a local key.
296
+
297
+ ```javascript
298
+ const wallet = Cx3Wallet.fromSigner({
299
+ publicKey: process.env.CX3_PUBLIC_KEY, // compressed (33 bytes) or uncompressed (65 bytes), hex or bytes
300
+ async sign(hash, info) {
301
+ // hash: the 32-byte SHA-256 the chain verifies (Uint8Array)
302
+ // info.kind: "transaction" | "personalMessage" | "message"; info.payload: what the hash is of
303
+ return myKms.signSecp256k1(hash); // r || s (64 bytes, hex or bytes) or { r, s }
304
+ }
305
+ }, provider);
306
+
307
+ await wallet.transferNative("0xRecipient…", 1.5);
308
+ ```
309
+
310
+ - `info.payload` is the exact text being signed (the typed digest or the transfer's JSON), so the signer can show it or check it against a policy before it signs.
311
+ - The SDK checks every answer against the wallet's public key before it is used: a signature of another key or over another hash throws `SIGNER_BAD_SIGNATURE` and nothing is sent. A signature with a high `s` is normalized to the low form the chain expects.
312
+ - An error thrown by `sign` surfaces as `SIGNER_FAILED`. `wallet.external` is `true`, and `exportPrivateKey()` throws `NO_PRIVATE_KEY`: there is no key to export.
292
313
 
293
314
  ## Error handling
294
315
 
@@ -307,13 +328,14 @@ try {
307
328
 
308
329
  | Code | Meaning |
309
330
  |------|---------|
310
- | `CREDENTIALS_REQUIRED` | No application, API key or `devNode` was given |
331
+ | `CREDENTIALS_REQUIRED` | No application or `devNode` was given |
311
332
  | `INVALID_CLIENT_CREDENTIALS` | The client ID or secret is not in the `cx3app_…` / `cx3sec_…` format |
312
333
  | `invalid_client` | The token endpoint refused the application (wrong secret, or revoked) |
313
334
  | `insufficient_scope`, `ROUTE_NOT_ALLOWED` | The route needs another scope, or is not open to applications (403) |
314
335
  | `RATE_LIMITED` | The application's rate limit was reached (429) |
315
336
  | `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 |
316
- | `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 |
337
+ | `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 |
338
+ | `INVALID_RECIPIENT` | `transferNative` was given a recipient that is not a full address (`0x` or `cx3:` and 40 hex digits); nothing is signed |
317
339
  | `GAS_LIMIT_TOO_LOW`, `GAS_LIMIT_TOO_HIGH` | A native transfer's gas limit is outside 21,000–100,000 |
318
340
  | `GAS_LIMIT_EXCEEDED` | A contract send's `gasLimit` is above the chain's maximum (400, `max` in the body) |
319
341
  | `INVALID_NONCE_ORDER` | A native transfer's nonce is not the next one (`expectedNonce` in the answer) |
@@ -331,44 +353,31 @@ try {
331
353
  | `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 |
332
354
  | `REQUEST_LOOKUP_UNAVAILABLE` | `getRequest` could not ask the chain (503): try again later, do not sign again |
333
355
  | `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 |
334
- | `BASIC_AUTH_UNSUPPORTED` | The `auth` option was used |
356
+ | `INVALID_SIGNER`, `INVALID_PUBLIC_KEY` | `Cx3Wallet.fromSigner` was given no `sign` function, or a public key that is not a secp256k1 point |
357
+ | `SIGNER_FAILED`, `SIGNER_BAD_SIGNATURE` | An external signer threw, or returned a signature that is not this wallet's over this hash; nothing is sent |
358
+ | `NO_PRIVATE_KEY` | `exportPrivateKey()` on a wallet that signs with an external signer |
335
359
  | `NO_CHAIN_IDENTITY` | The network's chain id or genesis hash is unavailable; a native transfer is not signed without them |
360
+ | `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`) |
361
+ | `INSECURE_BASE_URL` | Credentials would travel over plain `http` to another machine; use `https` (or `allowInsecure: true` on a network you trust) |
336
362
 
337
363
  ## Browser applications
338
364
 
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.
365
+ 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 or a private key in browser JavaScript.
340
366
 
341
367
  ## Environment variables
342
368
 
343
369
  | Variable | Used for |
344
370
  |----------|----------|
345
- | `CX3_CLIENT_ID`, `CX3_CLIENT_SECRET` | Application credentials when the options are omitted |
346
- | `CX3_API_KEY` | Legacy wallet API key |
371
+ | `CX3_CLIENT_ID`, `CX3_CLIENT_SECRET` | Application credentials when the options are omitted (not read with `devNode: true`) |
347
372
  | `CX3_NODE_URL` | Gateway URL for `getDefaultProvider()` |
348
373
 
349
374
  ## TypeScript
350
375
 
351
376
  Type declarations (`index.d.ts`) ship with the package.
352
377
 
353
- ## Upgrading from 0.2.x
354
-
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.
356
-
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.
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.
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.
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)`.
362
-
363
- ## Upgrading from 0.1.x
364
-
365
- CX3js is in beta (0.x), so a minor version may contain breaking changes. Version 0.2.0:
378
+ ## Versioning
366
379
 
367
- - **Credentials are required.** The provider talks to the CX3Api gateway as an OAuth2 application (or with a wallet API key) and throws `CREDENTIALS_REQUIRED` without one. A development node without authentication needs `devNode: true`; 0.1.x used node routes under `/api/v1` by default.
368
- - **HTTP Basic was removed.** The `auth` option throws `BASIC_AUTH_UNSUPPORTED`, and `headers` cannot carry `Authorization` or `X-Api-Key`.
369
- - **Node.js 20.19 or later** is required (`@noble/secp256k1` 3.x).
370
- - **Fixes:** BigInt params are signed and sent as decimal strings, and `transferNative` sends the gas fields it signs.
371
- - **New:** public chain reads (`getStatus`, `getTokens`, `getActivity`, `getDexPairs`, `getLpBalance`, `getWit`), private tokens and `isValidTokenSymbol`.
380
+ CX3js is in beta (0.x): a minor version may contain breaking changes, a patch version does not. Version 0.4.0 adds external signers (`Cx3Wallet.fromSigner`), makes the OAuth2 application the SDK's only credential.
372
381
 
373
382
  ## Documentation
374
383
 
package/index.d.ts CHANGED
@@ -29,15 +29,16 @@ export interface Cx3ProviderOptions {
29
29
  clientSecret?: string;
30
30
  /** ask for fewer scopes than the app has: "read", "write", "deploy" */
31
31
  scope?: string | Array<"read" | "write" | "deploy">;
32
- /** wallet API key (cx3wk_…), until apps move to OAuth2; env CX3_API_KEY */
33
- apiKey?: string;
34
- /** a development node that runs without auth (/api/v1), no credentials */
32
+ /** a development node that runs without auth (/api/v1); the credentials of the environment are not read for it */
35
33
  devNode?: boolean;
34
+ /** The network this provider is for (both together): an endpoint on another network is refused with CHAIN_IDENTITY_MISMATCH and nothing is signed */
35
+ chainId?: string;
36
+ genesisHash?: string;
37
+ /** Credentials over plain http to another machine throw INSECURE_BASE_URL unless this is true (localhost is always allowed) */
38
+ allowInsecure?: boolean;
36
39
  /** "" on the gateway, "/api/v1" on a development node */
37
40
  apiPrefix?: string;
38
- /** Not supported: throws BASIC_AUTH_UNSUPPORTED (HTTP Basic is the nodes' internal login) */
39
- auth?: never;
40
- /** extra headers; Authorization and X-Api-Key are dropped (use clientId / clientSecret or apiKey) */
41
+ /** extra headers; Authorization and X-Api-Key are dropped (the application is the only credential) */
41
42
  headers?: Record<string, string>;
42
43
  fetch?: typeof fetch;
43
44
  timeoutMs?: number;
@@ -73,14 +74,13 @@ export class Cx3Provider {
73
74
  devNode: boolean;
74
75
  oauth: boolean;
75
76
  clientId: string;
76
- apiKeyMode: "public" | "private" | "invalid";
77
- authMode: "oauth" | "apiKey" | "devNode";
78
- /** Throws CREDENTIALS_REQUIRED without clientId / clientSecret, apiKey or devNode */
77
+ authMode: "oauth" | "devNode";
78
+ /** Throws CREDENTIALS_REQUIRED without clientId / clientSecret or devNode */
79
79
  constructor(baseUrl: string, options?: Cx3ProviderOptions);
80
80
  /** Forget the held access token: the next request asks for a new one */
81
81
  clearAccessToken(): void;
82
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 };
83
+ toJSON(): { baseUrl: string; authMode: "oauth" | "devNode"; clientId?: string; scope?: string };
84
84
  /** The current OAuth2 access token (renewed a minute before it expires); null without an OAuth2 app */
85
85
  getAccessToken(force?: boolean): Promise<string | null>;
86
86
  request(path: string, opts?: { method?: string; body?: unknown; query?: Record<string, string | number> }): Promise<any>;
@@ -112,6 +112,7 @@ export class Cx3Provider {
112
112
  getBalance(address: string): Promise<any>;
113
113
  getNonce(address: string): Promise<number>;
114
114
  getPendingNonce(address: string): Promise<any>;
115
+ /** Is this wallet the one the application belongs to: { ok: true }, or wallet_mismatch */
115
116
  bindApiKey(address: string): Promise<any>;
116
117
  /** The gateway lists a private token only when the app's wallet is its owner or an authorized wallet */
117
118
  listContracts(opts?: { limit?: number; offset?: number; viewer?: string; owner?: string; standard?: string; type?: string; live?: boolean }): Promise<any>;
@@ -131,14 +132,32 @@ export class Cx3Provider {
131
132
  getContract(address: string, abi?: unknown, signer?: Cx3Wallet | null): Promise<Contract>;
132
133
  }
133
134
 
135
+ /** What an external signer is asked to sign: the kind of request and the exact text (or bytes) the hash is of */
136
+ export interface Cx3SignInfo {
137
+ kind: "transaction" | "personalMessage" | "message";
138
+ payload: string | Uint8Array;
139
+ }
140
+
141
+ /** A key kept outside the process (HSM, KMS, hardware wallet, custody service) */
142
+ export interface Cx3Signer {
143
+ /** secp256k1 public key, compressed (33 bytes) or uncompressed (65 bytes), hex or bytes */
144
+ publicKey: string | Uint8Array;
145
+ /** Signs the 32-byte SHA-256 `hash`; returns r || s (64 bytes, hex or bytes) or { r, s } (hex) */
146
+ sign(hash: Uint8Array, info: Cx3SignInfo): Promise<string | Uint8Array | { r: string; s: string }> | string | Uint8Array | { r: string; s: string };
147
+ }
148
+
134
149
  export class Cx3Wallet {
135
150
  address: string;
136
151
  publicKey: string;
137
152
  provider: Cx3Provider | null;
153
+ /** true for a wallet made with fromSigner (no private key in this process) */
154
+ external: boolean;
138
155
  constructor(privateKey: string | Uint8Array | Buffer, provider?: Cx3Provider | null);
139
156
  static createRandom(provider?: Cx3Provider | null): Cx3Wallet;
157
+ /** A wallet that signs through `signer`; every answer is checked against the public key (SIGNER_BAD_SIGNATURE) */
158
+ static fromSigner(signer: Cx3Signer, provider?: Cx3Provider | null): Cx3Wallet;
140
159
  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) */
160
+ /** The private key as given to the constructor; the wallet shows it nowhere else (not in JSON, logs or a spread). Throws NO_PRIVATE_KEY on an external-signer wallet */
142
161
  exportPrivateKey(): string | Uint8Array | Buffer;
143
162
  /** Address and public key only (JSON.stringify and util.inspect use it) */
144
163
  toJSON(): { address: string; publicKey: string };
@@ -165,15 +184,15 @@ export class Cx3Wallet {
165
184
  signSend(contractAddress: string, method: string, params?: object, opts?: Cx3ChainIdentity): Promise<any>;
166
185
  signOwnerAction(action: string, address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
167
186
  /** A private token's transfer / transferFrom / mint is checked first (PRIVATE_TOKEN_SENDER / _RECIPIENT); `privateCheck: false` skips it */
168
- sendTransaction(contractAddress: string, method: string, params?: object, opts?: { wait?: number; async?: boolean; privateCheck?: boolean; [k: string]: unknown }): Promise<TransactionResponse>;
187
+ sendTransaction(contractAddress: string, method: string, params?: object, opts?: { wait?: number; async?: boolean; privateCheck?: boolean; chainId?: string; genesisHash?: string; [k: string]: unknown }): Promise<TransactionResponse>;
169
188
  /** 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
189
  /** Sends an earlier send's signed request again, unchanged (no new signature): after SEND_OUTCOME_UNKNOWN or a lost answer */
171
190
  resendTransaction(signedRequest: Cx3SignedRequest, opts?: { wait?: number; async?: boolean }): Promise<TransactionResponse>;
172
- deployContract(fields: object): Promise<Cx3DeployResult>;
173
- pauseContract(address: string, owner?: string | null): Promise<any>;
174
- unpauseContract(address: string, owner?: string | null): Promise<any>;
175
- deleteContract(address: string, owner?: string | null): Promise<any>;
176
- /** Throws Cx3Error INVALID_AMOUNT (not greater than 0) or INVALID_AMOUNT_PRECISION (more than 6 decimals) before signing */
191
+ deployContract(fields: object, opts?: Cx3ChainIdentity): Promise<Cx3DeployResult>;
192
+ pauseContract(address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
193
+ unpauseContract(address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
194
+ deleteContract(address: string, owner?: string | null, opts?: Cx3ChainIdentity): Promise<any>;
195
+ /** 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) */
177
196
  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>;
178
197
  bindApiKey(address?: string): Promise<any>;
179
198
  }
@@ -290,7 +309,6 @@ export class TransactionResponse {
290
309
  export function getDefaultProvider(options?: Cx3ProviderOptions & { url?: string }): Cx3Provider;
291
310
  export function readCallValue(response: any): unknown;
292
311
  export function normalizeAddress(addr: string): string;
293
- export function walletApiKeyMode(key?: string | null): "public" | "private" | "invalid";
294
312
  /** BigInt → decimal string, recursively (params are signed and sent in this form) */
295
313
  export function toJsonSafe<T>(value: T): unknown;
296
314
  export function ensure0x(hex: string): string;
@@ -312,6 +330,8 @@ export function signPersonalMessage(message: string, privateKey: string | Uint8A
312
330
  export function buildTransferPayload(fields: object): string;
313
331
  export function signMessage(message: string | Uint8Array, privateKey: string | Uint8Array | Buffer): Promise<any>;
314
332
  export function privateKeyToAddress(privateKey: string | Uint8Array | Buffer): string;
333
+ /** The address of a secp256k1 public key (compressed or uncompressed, hex or bytes) */
334
+ export function publicKeyToAddress(publicKey: string | Uint8Array | Buffer): string;
315
335
  export function createPrivateKey(): string;
316
336
  export function normalizeAbi(abi: unknown): Record<string, any>;
317
337
  export function argsToParams(inputs: any[], args: any[]): object;
package/index.js CHANGED
@@ -15,7 +15,6 @@ const {
15
15
  normalizeAddress,
16
16
  ensure0x,
17
17
  strip0x,
18
- walletApiKeyMode,
19
18
  toJsonSafe
20
19
  } = require("./src/utils");
21
20
  const {
@@ -30,11 +29,12 @@ const {
30
29
  signMessage,
31
30
  signPersonalMessage,
32
31
  privateKeyToAddress,
32
+ publicKeyToAddress,
33
33
  createPrivateKey
34
34
  } = require("./src/crypto");
35
35
  const { normalizeAbi, argsToParams } = require("./src/abi");
36
36
 
37
- const VERSION = "0.3.10";
37
+ const VERSION = "0.4.0";
38
38
 
39
39
  /**
40
40
  * Convenience: provider from `options.url` or env `CX3_NODE_URL` — the CX3Api gateway
@@ -63,7 +63,6 @@ module.exports = {
63
63
  normalizeAddress,
64
64
  ensure0x,
65
65
  strip0x,
66
- walletApiKeyMode,
67
66
  toJsonSafe,
68
67
  buildDeployDigest,
69
68
  deployContentText,
@@ -74,6 +73,7 @@ module.exports = {
74
73
  signMessage,
75
74
  signPersonalMessage,
76
75
  privateKeyToAddress,
76
+ publicKeyToAddress,
77
77
  createPrivateKey,
78
78
  normalizeAbi,
79
79
  argsToParams,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@carbonx3/cx3.js",
3
- "version": "0.3.10",
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",
3
+ "version": "0.4.0",
4
+ "description": "Official Node.js SDK (CX3js) for CarbonX3 Green Chain: OAuth2 app access, local or external secp256k1 signing, ABI-bound contracts and token deploys over REST + JSON",
5
5
  "homepage": "https://cx3script.com",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -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/crypto.js CHANGED
@@ -73,6 +73,91 @@ async function signMessage(message, privateKeyInput) {
73
73
  };
74
74
  }
75
75
 
76
+ /** secp256k1 group order: a signature's s is kept in the lower half (the form the chain and the wallets produce). */
77
+ const CURVE_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
78
+
79
+ function bytesOf(input, what, code) {
80
+ if (input instanceof Uint8Array || Buffer.isBuffer(input)) return Buffer.from(input);
81
+ const hex = strip0x(String(input == null ? "" : input)).toLowerCase();
82
+ if (!hex || hex.length % 2 || !/^[0-9a-f]+$/.test(hex)) throw new Cx3Error(`Invalid ${what}`, code);
83
+ return Buffer.from(hex, "hex");
84
+ }
85
+
86
+ /** Both forms of a public key given compressed (33 bytes) or uncompressed (65 bytes), hex or bytes. */
87
+ function publicKeyForms(input) {
88
+ const bytes = bytesOf(input, "public key", "INVALID_PUBLIC_KEY");
89
+ if (bytes.length !== 33 && bytes.length !== 65) throw new Cx3Error("Invalid public key length", "INVALID_PUBLIC_KEY");
90
+ let point;
91
+ try {
92
+ point = secp.Point.fromBytes(bytes);
93
+ point.assertValidity();
94
+ } catch (_) {
95
+ throw new Cx3Error("Invalid public key", "INVALID_PUBLIC_KEY");
96
+ }
97
+ return { compressed: Buffer.from(point.toBytes(true)), uncompressed: Buffer.from(point.toBytes(false)) };
98
+ }
99
+
100
+ function publicKeyToAddress(publicKeyInput) {
101
+ return ensure0x(sha256(publicKeyForms(publicKeyInput).uncompressed).slice(-20).toString("hex"));
102
+ }
103
+
104
+ /** What a signer returned, as 64 bytes r || s with s in the lower half: 64 bytes (hex or bytes) or { r, s } (hex). */
105
+ function compactSignature(out) {
106
+ let bytes;
107
+ if (out && typeof out === "object" && !(out instanceof Uint8Array) && out.r != null && out.s != null) {
108
+ const part = (v) => strip0x(String(v)).toLowerCase().padStart(64, "0");
109
+ bytes = bytesOf(part(out.r) + part(out.s), "signature", "SIGNER_BAD_SIGNATURE");
110
+ } else {
111
+ bytes = bytesOf(out, "signature", "SIGNER_BAD_SIGNATURE");
112
+ }
113
+ if (bytes.length !== 64) throw new Cx3Error("The signer must return 64 bytes (r || s)", "SIGNER_BAD_SIGNATURE");
114
+ const r = BigInt("0x" + bytes.subarray(0, 32).toString("hex"));
115
+ let s = BigInt("0x" + bytes.subarray(32).toString("hex"));
116
+ if (r <= 0n || r >= CURVE_N || s <= 0n || s >= CURVE_N) throw new Cx3Error("Signature out of range", "SIGNER_BAD_SIGNATURE");
117
+ if (s > CURVE_N / 2n) s = CURVE_N - s;
118
+ return Buffer.from(r.toString(16).padStart(64, "0") + s.toString(16).padStart(64, "0"), "hex");
119
+ }
120
+
121
+ /**
122
+ * signMessage() with a signer that keeps the key elsewhere (an HSM, a KMS, a hardware wallet, a custody
123
+ * service). `signer.sign(hash, info)` gets the 32-byte sha256 the chain verifies and what it is the hash
124
+ * of; the answer is checked against the wallet's public key before anything is sent with it.
125
+ */
126
+ async function signWithSigner(message, signer, kind) {
127
+ const msgBytes = typeof message === "string" ? Buffer.from(message, "utf8") : Buffer.from(message);
128
+ const msgHash = sha256(msgBytes);
129
+ let out;
130
+ try {
131
+ out = await signer.sign(new Uint8Array(msgHash), { kind, payload: typeof message === "string" ? message : new Uint8Array(msgBytes) });
132
+ } catch (e) {
133
+ if (e instanceof Cx3Error) throw e;
134
+ throw new Cx3Error("The signer did not sign: " + String((e && e.message) || e).slice(0, 200), "SIGNER_FAILED");
135
+ }
136
+ const compact = compactSignature(out);
137
+ let recovery = -1;
138
+ for (const bit of [0, 1]) {
139
+ try {
140
+ const got = secp.recoverPublicKey(Buffer.concat([Buffer.from([bit]), compact]), msgHash, { prehash: false, isCompressed: false });
141
+ if (Buffer.from(got).equals(signer.uncompressed)) { recovery = bit; break; }
142
+ } catch (_) { /* not this bit */ }
143
+ }
144
+ // a signature of another key, or over another hash, never leaves the SDK
145
+ if (recovery < 0) throw new Cx3Error("The signer's signature is not this wallet's", "SIGNER_BAD_SIGNATURE");
146
+ const signature = compact.toString("hex");
147
+ const publicKey = signer.compressed.toString("hex");
148
+ return {
149
+ signature,
150
+ r: signature,
151
+ s: null,
152
+ v: recovery + 27,
153
+ publicKey,
154
+ senderPublicKey: publicKey,
155
+ publicKeyUncompressed: signer.uncompressed.toString("hex"),
156
+ hash: ensure0x(msgHash.toString("hex")),
157
+ derivedAddress: ensure0x(sha256(signer.uncompressed).slice(-20).toString("hex"))
158
+ };
159
+ }
160
+
76
161
  /**
77
162
  * Sign a site message the way the CX3Wallet extension and app do (cx3_sign_message):
78
163
  * sha256(0x19 "CX3 Signed Message:\n" + UTF-8 length + message). Such a signature never verifies as
@@ -103,5 +188,8 @@ module.exports = {
103
188
  privateKeyToAddress,
104
189
  signMessage,
105
190
  signPersonalMessage,
191
+ signWithSigner,
192
+ publicKeyForms,
193
+ publicKeyToAddress,
106
194
  createPrivateKey
107
195
  };
package/src/provider.js CHANGED
@@ -5,7 +5,7 @@
5
5
  "use strict";
6
6
 
7
7
  const { Cx3Error, Cx3HttpError, Cx3TxError } = require("./errors");
8
- const { readCallValue, sleep, normalizeAddress, walletApiKeyMode, toJsonSafe } = require("./utils");
8
+ const { readCallValue, sleep, normalizeAddress, toJsonSafe } = require("./utils");
9
9
  const { Contract } = require("./contract");
10
10
  const { normalizeGenesisHash } = require("./digest");
11
11
 
@@ -14,12 +14,11 @@ const { normalizeGenesisHash } = require("./digest");
14
14
  *
15
15
  * Apps sign in to the CX3Api gateway with an OAuth2 client (`clientId` + `clientSecret`, created in
16
16
  * CX3WebUI → Settings → API → Applications): the provider gets a one-hour access token at
17
- * `POST /oauth/token`, sends it as `Authorization: Bearer`, and renews it before it expires. A wallet
18
- * API key (`cx3wk_…`, `X-Api-Key`) still works during the move to applications. Without one of the
19
- * two the provider refuses to start (CREDENTIALS_REQUIRED): no app development without credentials.
17
+ * `POST /oauth/token`, sends it as `Authorization: Bearer`, and renews it before it expires. Without
18
+ * an application the provider refuses to start (CREDENTIALS_REQUIRED).
20
19
  * Gateway routes: `/contracts/:address/call`, `/contracts/:address/send`, `/wallet/...`, …
21
- * `devNode: true` talks to a development node that runs without auth (`/api/v1`). The SDK never sends
22
- * HTTP Basic (the nodes' internal login). Never talks to CX3VM directly.
20
+ * `devNode: true` talks to a development node that runs without auth (`/api/v1`). Never talks to
21
+ * CX3VM directly.
23
22
  */
24
23
 
25
24
  // node route → CX3Api route (the gateway forwards the same body to the node)
@@ -56,7 +55,7 @@ function boolVerdict(v) {
56
55
  return null;
57
56
  }
58
57
 
59
- // credentials come only from clientId / clientSecret or apiKey, never from an extra header
58
+ // credentials come only from clientId / clientSecret, never from an extra header
60
59
  function withoutCredentialHeaders(headers) {
61
60
  const out = {};
62
61
  for (const [k, v] of Object.entries(headers || {})) {
@@ -65,7 +64,16 @@ function withoutCredentialHeaders(headers) {
65
64
  return out;
66
65
  }
67
66
 
68
- const CLIENT_ID_RE = /^cx3app_[a-f0-9]{24}$/i;
67
+ // http to another machine (not localhost / 127.x / ::1)
68
+ function isRemotePlainHttp(baseUrl) {
69
+ let u;
70
+ try { u = new URL(baseUrl); } catch (_) { return false; }
71
+ if (u.protocol !== "http:") return false;
72
+ const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
73
+ return !(host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127(\.\d{1,3}){3}$/.test(host));
74
+ }
75
+
76
+ const CLIENT_ID_RE =/^cx3app_[a-f0-9]{24}$/i;
69
77
  const CLIENT_SECRET_RE = /^cx3sec_[a-f0-9]{64}$/i;
70
78
 
71
79
  // a dropped tx is failed for good after its finalAfter: wait this much past it (clock skew)
@@ -81,7 +89,6 @@ class Cx3Provider {
81
89
  // Credentials live in private fields: JSON.stringify, util.inspect, console.log, Object.keys and a
82
90
  // spread of the provider (or of anything that holds it) never show them.
83
91
  #clientSecret = "";
84
- #apiKey = "";
85
92
  #token = null; // { value, refreshAt, scope }
86
93
 
87
94
  /**
@@ -90,7 +97,6 @@ class Cx3Provider {
90
97
  * @param {string} [options.clientId] OAuth2 client id (`cx3app_…`; env CX3_CLIENT_ID)
91
98
  * @param {string} [options.clientSecret] OAuth2 client secret (`cx3sec_…`; env CX3_CLIENT_SECRET)
92
99
  * @param {string|string[]} [options.scope] ask for fewer scopes than the app has ("read", "write", "deploy")
93
- * @param {string} [options.apiKey] wallet API key (`cx3wk_…`; env CX3_API_KEY), until apps move to OAuth2
94
100
  * @param {boolean} [options.devNode] a development node without auth (`/api/v1`), no credentials
95
101
  * @param {string} [options.apiPrefix] ("" on the gateway, "/api/v1" on a development node)
96
102
  * @param {Record<string,string>} [options.headers] Extra headers (Authorization / X-Api-Key are dropped)
@@ -100,29 +106,40 @@ class Cx3Provider {
100
106
  constructor(baseUrl, options = {}) {
101
107
  if (!baseUrl) throw new Cx3Error("Cx3Provider requires baseUrl (CX3_NODE_URL)", "NO_BASE_URL");
102
108
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
103
- if (options.auth != null) {
104
- throw new Cx3Error(
105
- "HTTP Basic is the nodes' internal login and is not sent by the SDK: create an application in CX3WebUI (Settings → API) and pass clientId / clientSecret",
106
- "BASIC_AUTH_UNSUPPORTED"
107
- );
108
- }
109
- const env = (name) => (process.env[name] || "").trim();
109
+ this.devNode = options.devNode === true;
110
+ // a development node gets only what the caller passes: the gateway credentials of the environment never go to it
111
+ const env = (name) => (this.devNode ? "" : (process.env[name] || "").trim());
110
112
  this.clientId = String(options.clientId != null ? options.clientId : env("CX3_CLIENT_ID")).trim();
111
113
  this.#clientSecret = String(options.clientSecret != null ? options.clientSecret : env("CX3_CLIENT_SECRET")).trim();
112
114
  this.scope = Array.isArray(options.scope) ? options.scope.join(" ") : String(options.scope || "").trim();
113
- this.#apiKey = String(options.apiKey != null ? options.apiKey : env("CX3_API_KEY")).trim();
114
- this.devNode = options.devNode === true;
115
115
  this.oauth = !!(this.clientId || this.#clientSecret);
116
116
  if (this.oauth && (!CLIENT_ID_RE.test(this.clientId) || !CLIENT_SECRET_RE.test(this.#clientSecret))) {
117
117
  throw new Cx3Error("clientId must be cx3app_… and clientSecret cx3sec_… (CX3WebUI → Settings → API → Applications)", "INVALID_CLIENT_CREDENTIALS");
118
118
  }
119
- const hasKey = walletApiKeyMode(this.#apiKey) !== "public";
120
- if (!this.oauth && !hasKey && !this.devNode) {
119
+ if (!this.oauth && !this.devNode) {
121
120
  throw new Cx3Error(
122
121
  "App credentials required: create an application in CX3WebUI (Settings → API → Applications) and pass clientId / clientSecret (or set CX3_CLIENT_ID / CX3_CLIENT_SECRET)",
123
122
  "CREDENTIALS_REQUIRED"
124
123
  );
125
124
  }
125
+ // credentials over plain http can be read on the way: only this machine, or the caller's own word
126
+ if (this.oauth && options.allowInsecure !== true && isRemotePlainHttp(this.baseUrl)) {
127
+ throw new Cx3Error(
128
+ "Credentials are not sent over plain http: use an https URL (or allowInsecure: true on a network you trust)",
129
+ "INSECURE_BASE_URL"
130
+ );
131
+ }
132
+ // The network this provider is for: with both given, signatures are made for this network only and
133
+ // an endpoint that names another one is refused (CHAIN_IDENTITY_MISMATCH).
134
+ this.pinnedIdentity = null;
135
+ if (options.chainId != null || options.genesisHash != null) {
136
+ const chainId = String(options.chainId == null ? "" : options.chainId).trim();
137
+ const genesisHash = normalizeGenesisHash(options.genesisHash);
138
+ if (!chainId || !genesisHash) {
139
+ throw new Cx3Error("chainId and genesisHash are given together (genesisHash: 64 hex)", "NO_CHAIN_IDENTITY");
140
+ }
141
+ this.pinnedIdentity = { chainId, genesisHash };
142
+ }
126
143
  this.gateway = !this.devNode;
127
144
  this.apiPrefix = options.apiPrefix != null ? options.apiPrefix : (this.gateway ? "" : "/api/v1");
128
145
  this.extraHeaders = withoutCredentialHeaders(options.headers);
@@ -136,14 +153,9 @@ class Cx3Provider {
136
153
  }
137
154
  }
138
155
 
139
- get apiKeyMode() {
140
- return walletApiKeyMode(this.#apiKey);
141
- }
142
-
143
- /** "oauth" | "apiKey" | "devNode" */
156
+ /** "oauth" | "devNode" */
144
157
  get authMode() {
145
- if (this.devNode && !this.oauth && walletApiKeyMode(this.#apiKey) === "public") return "devNode";
146
- return this.oauth ? "oauth" : "apiKey";
158
+ return this.oauth ? "oauth" : "devNode";
147
159
  }
148
160
 
149
161
  /**
@@ -195,11 +207,7 @@ class Cx3Provider {
195
207
  "Content-Type": "application/json",
196
208
  ...this.extraHeaders
197
209
  };
198
- if (this.oauth) {
199
- headers.Authorization = `Bearer ${await this.getAccessToken(forceToken)}`;
200
- } else if (/^cx3wk_[a-f0-9]{48}$/i.test(this.#apiKey)) {
201
- headers["X-Api-Key"] = this.#apiKey;
202
- }
210
+ if (this.oauth) headers.Authorization = `Bearer ${await this.getAccessToken(forceToken)}`;
203
211
  return headers;
204
212
  }
205
213
 
@@ -224,9 +232,6 @@ class Cx3Provider {
224
232
  * @param {{ method?: string, body?: object, query?: Record<string,string|number> }} [opts]
225
233
  */
226
234
  async request(path, opts = {}) {
227
- if (!this.oauth && this.apiKeyMode === "invalid") {
228
- throw new Cx3Error("api_key_required", "api_key_required");
229
- }
230
235
  const method = opts.method || "GET";
231
236
  let route = path.startsWith("/") ? path : `/${path}`;
232
237
  if (this.gateway) route = gatewayPath(route, opts.body);
@@ -432,6 +437,14 @@ class Cx3Provider {
432
437
  const chainId = typeof r.chainId === "string" ? r.chainId.trim() : "";
433
438
  const genesisHash = normalizeGenesisHash(r.genesisHash);
434
439
  if (!chainId || !genesisHash) throw new Cx3Error("network identity unavailable", "NO_CHAIN_IDENTITY");
440
+ const pin = this.pinnedIdentity;
441
+ if (pin && (pin.chainId !== chainId || pin.genesisHash !== genesisHash)) {
442
+ throw new Cx3Error(
443
+ "The endpoint is on another network than the one this provider was made for: nothing is signed",
444
+ "CHAIN_IDENTITY_MISMATCH",
445
+ { expected: { ...pin }, got: { chainId, genesisHash } }
446
+ );
447
+ }
435
448
  this._chainIdentity = { at: Date.now(), value: { chainId, genesisHash } };
436
449
  return { chainId, genesisHash };
437
450
  }
@@ -528,34 +541,14 @@ class Cx3Provider {
528
541
  }
529
542
 
530
543
  /**
531
- * Check that this wallet is the one the credentials belong to (an app and a wallet API key are
532
- * both bound to one wallet): `{ ok: true }`, or `wallet_mismatch`.
533
- * OAuth2 app and cx3wk_ key → GET `/wallet/api-key/bind` (the gateway route; it has no `/whoami`);
534
- * a cx3wk_ key falls back to `/whoami` on 404 (a same-origin proxy that only forwards that).
535
- * A development node has no credentials: `{ ok: true, devNode: true }`.
544
+ * Check that this wallet is the one the application belongs to: `{ ok: true }`, or `wallet_mismatch`.
545
+ * A development node has no application: `{ ok: true, devNode: true }`.
536
546
  */
537
547
  async bindApiKey(address) {
538
548
  const addr = normalizeAddress(address);
539
549
  if (!addr) throw new Cx3Error("address required", "NO_ADDRESS");
540
- const query = { address: addr };
541
- if (this.oauth) return this._rejectEnvelope(await this.request("/wallet/api-key/bind", { query }));
542
- const mode = this.apiKeyMode;
543
- if (mode === "invalid") {
544
- throw new Cx3Error("api_key_required", "api_key_required");
545
- }
546
- if (mode === "public") {
547
- return { ok: true, devNode: true };
548
- }
549
- try {
550
- const json = await this.request("/wallet/api-key/bind", { query });
551
- return this._rejectEnvelope(json);
552
- } catch (e) {
553
- if (e && e.status === 404) {
554
- const json = await this.request("/whoami", { query });
555
- return this._rejectEnvelope(json);
556
- }
557
- throw e;
558
- }
550
+ if (!this.oauth) return { ok: true, devNode: true };
551
+ return this._rejectEnvelope(await this.request("/wallet/api-key/bind", { query: { address: addr } }));
559
552
  }
560
553
 
561
554
  /**
package/src/utils.js CHANGED
@@ -70,17 +70,6 @@ function toJsonSafe(value) {
70
70
  return value;
71
71
  }
72
72
 
73
- /**
74
- * Same rules as DappsExamples/CX3AssetDashboard `cx3_dapp_key_mode`:
75
- * empty → public (any wallet); valid cx3wk_ → private (that wallet); else invalid.
76
- */
77
- function walletApiKeyMode(key) {
78
- const n = String(key == null ? "" : key).trim();
79
- if (!n) return "public";
80
- if (/^cx3wk_[a-f0-9]{48}$/i.test(n)) return "private";
81
- return "invalid";
82
- }
83
-
84
73
  module.exports = {
85
74
  readCallValue,
86
75
  strip0x,
@@ -88,6 +77,5 @@ module.exports = {
88
77
  normalizeAddress,
89
78
  sleep,
90
79
  isPlainObject,
91
- toJsonSafe,
92
- walletApiKeyMode
80
+ toJsonSafe
93
81
  };
package/src/wallet.js CHANGED
@@ -9,12 +9,14 @@ const {
9
9
  privateKeyToPublicKey,
10
10
  signMessage,
11
11
  signPersonalMessage,
12
+ signWithSigner,
13
+ publicKeyForms,
12
14
  createPrivateKey,
13
15
  sha256
14
16
  } = require("./crypto");
15
17
  const { normalizeAddress, toJsonSafe } = require("./utils");
16
18
  const { Cx3Error } = require("./errors");
17
- const { buildDeployDigest, buildSendDigest, buildOwnerActionDigest, buildTransferPayload, stripNativeAddress, deployContentText } = require("./digest");
19
+ const { buildDeployDigest, buildSendDigest, buildOwnerActionDigest, buildTransferPayload, stripNativeAddress, deployContentText, personalMessageBytes } = require("./digest");
18
20
  const { TransactionResponse } = require("./transaction");
19
21
 
20
22
  /** Green Chain deploy rule: a token symbol is capital letters A-Z and digits 0-9 only. */
@@ -84,36 +86,69 @@ function withDeployWait(result, provider) {
84
86
  return result;
85
87
  }
86
88
 
89
+ /** A key that lives outside this process (Cx3Wallet.fromSigner): its public key and its sign function. */
90
+ class ExternalSigner {
91
+ constructor(spec) {
92
+ if (!spec || typeof spec.sign !== "function") throw new Cx3Error("signer.sign must be a function", "INVALID_SIGNER");
93
+ const forms = publicKeyForms(spec.publicKey);
94
+ this.compressed = forms.compressed;
95
+ this.uncompressed = forms.uncompressed;
96
+ this.sign = (hash, info) => spec.sign(hash, info);
97
+ }
98
+ }
99
+
87
100
  /**
88
- * Local secp256k1 wallet for Green Chain digests.
101
+ * secp256k1 wallet for Green Chain digests: a private key in this process, or a signer that keeps the
102
+ * key elsewhere (Cx3Wallet.fromSigner).
89
103
  */
90
104
  class Cx3Wallet {
91
105
  // The key is a private field: JSON.stringify, util.inspect, console.log, Object.keys and a spread of
92
106
  // the wallet (or of a result that holds it) never show it. It leaves only through exportPrivateKey().
93
- #privateKey;
107
+ #privateKey = null;
108
+ #signer = null;
94
109
 
95
110
  /**
96
111
  * @param {string|Uint8Array|Buffer} privateKey
97
112
  * @param {import('./provider').Cx3Provider|null} [provider]
98
113
  */
99
114
  constructor(privateKey, provider = null) {
115
+ this.provider = provider;
116
+ if (privateKey instanceof ExternalSigner) {
117
+ this.#signer = privateKey;
118
+ this.address = normalizeAddress("0x" + sha256(privateKey.uncompressed).slice(-20).toString("hex"));
119
+ this.publicKey = privateKey.compressed.toString("hex");
120
+ this.external = true;
121
+ return;
122
+ }
100
123
  if (!privateKey) throw new Cx3Error("privateKey required", "NO_PRIVATE_KEY");
101
124
  this.#privateKey = privateKey;
102
- this.provider = provider;
103
125
  this.address = privateKeyToAddress(privateKey);
104
126
  this.publicKey = privateKeyToPublicKey(privateKey, true).toString("hex");
127
+ this.external = false;
105
128
  }
106
129
 
107
130
  static createRandom(provider = null) {
108
131
  return new Cx3Wallet(createPrivateKey(), provider);
109
132
  }
110
133
 
134
+ /**
135
+ * A wallet whose key never enters this process (an HSM, a KMS, a hardware wallet, a custody service).
136
+ * `signer.publicKey`: the key's public key, compressed or uncompressed (hex or bytes).
137
+ * `signer.sign(hash, info)`: gets the 32-byte sha256 to sign and `info` ({ kind, payload }: what the
138
+ * hash is of, to show or to check a policy against) and returns r || s (64 bytes, hex or bytes) or
139
+ * { r, s }. The SDK checks the answer against the public key (SIGNER_BAD_SIGNATURE) before using it.
140
+ */
141
+ static fromSigner(signer, provider = null) {
142
+ return new Cx3Wallet(new ExternalSigner(signer), provider);
143
+ }
144
+
111
145
  connect(provider) {
112
- return new Cx3Wallet(this.#privateKey, provider);
146
+ return new Cx3Wallet(this.#signer || this.#privateKey, provider);
113
147
  }
114
148
 
115
149
  /** The private key as given to the constructor (a createRandom() wallet: the only way to save it). */
116
150
  exportPrivateKey() {
151
+ if (this.#signer) throw new Cx3Error("This wallet signs with an external signer and holds no private key", "NO_PRIVATE_KEY");
117
152
  return this.#privateKey;
118
153
  }
119
154
 
@@ -141,16 +176,19 @@ class Cx3Wallet {
141
176
  if (isChainPayload(message)) {
142
177
  throw new Cx3Error("This text is a chain transaction or call, not a message", "SIGN_REFUSED_TRANSACTION_PAYLOAD");
143
178
  }
179
+ if (this.#signer) return signWithSigner(message, this.#signer, "message");
144
180
  return signMessage(message, this.#privateKey);
145
181
  }
146
182
 
147
183
  /** The wallet's own typed digests and transfer payloads. */
148
184
  #signPayload(payload) {
185
+ if (this.#signer) return signWithSigner(payload, this.#signer, "transaction");
149
186
  return signMessage(payload, this.#privateKey);
150
187
  }
151
188
 
152
189
  /** Sign a site message the way the CX3Wallet extension and app do (with the site-message prefix). */
153
190
  async signPersonalMessage(message) {
191
+ if (this.#signer) return signWithSigner(personalMessageBytes(message), this.#signer, "personalMessage");
154
192
  return signPersonalMessage(message, this.#privateKey);
155
193
  }
156
194
 
@@ -235,7 +273,7 @@ class Cx3Wallet {
235
273
  if (opts.privateCheck !== false && typeof this.provider.checkPrivateTokenSend === "function") {
236
274
  await this.provider.checkPrivateTokenSend(contractAddress, method, params, this.address);
237
275
  }
238
- const signed = await this.signSend(contractAddress, method, params);
276
+ const signed = await this.signSend(contractAddress, method, params, opts);
239
277
  // everything the gateway needs to take the same signed call again: no key, safe to store
240
278
  const signedRequest = {
241
279
  contractAddress,
@@ -309,7 +347,7 @@ class Cx3Wallet {
309
347
  * (Cx3HttpError code DUPLICATE_TOKEN_SYMBOL, status 409). A dropped deploy (202, `dropped`,
310
348
  * `finalAfter`) is pending, not deployed: `result.wait()` follows it (withDeployWait).
311
349
  */
312
- async deployContract(fields) {
350
+ async deployContract(fields, opts = {}) {
313
351
  if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
314
352
  fields = toJsonSafe(fields || {});
315
353
  const ctor = fields.constructorArgs && typeof fields.constructorArgs === "object" && !Array.isArray(fields.constructorArgs) ? fields.constructorArgs : {};
@@ -329,7 +367,7 @@ class Cx3Wallet {
329
367
  ...fields,
330
368
  owner: fields.owner || this.address,
331
369
  contentHash
332
- });
370
+ }, opts);
333
371
  const body = {
334
372
  ...fields,
335
373
  owner: signed.owner,
@@ -347,9 +385,9 @@ class Cx3Wallet {
347
385
  return withDeployWait(result, this.provider);
348
386
  }
349
387
 
350
- async _ownerActionRequest(action, address, owner, method, path) {
388
+ async _ownerActionRequest(action, address, owner, method, path, opts = {}) {
351
389
  if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
352
- const signed = await this.signOwnerAction(action, address, owner);
390
+ const signed = await this.signOwnerAction(action, address, owner, opts);
353
391
  return this.provider.request(path, {
354
392
  method,
355
393
  body: {
@@ -361,33 +399,36 @@ class Cx3Wallet {
361
399
  });
362
400
  }
363
401
 
364
- async pauseContract(address, owner = null) {
402
+ async pauseContract(address, owner = null, opts = {}) {
365
403
  return this._ownerActionRequest(
366
404
  "pause",
367
405
  address,
368
406
  owner,
369
407
  "PUT",
370
- `/contracts/${encodeURIComponent(address)}/pause`
408
+ `/contracts/${encodeURIComponent(address)}/pause`,
409
+ opts
371
410
  );
372
411
  }
373
412
 
374
- async unpauseContract(address, owner = null) {
413
+ async unpauseContract(address, owner = null, opts = {}) {
375
414
  return this._ownerActionRequest(
376
415
  "unpause",
377
416
  address,
378
417
  owner,
379
418
  "PUT",
380
- `/contracts/${encodeURIComponent(address)}/unpause`
419
+ `/contracts/${encodeURIComponent(address)}/unpause`,
420
+ opts
381
421
  );
382
422
  }
383
423
 
384
- async deleteContract(address, owner = null) {
424
+ async deleteContract(address, owner = null, opts = {}) {
385
425
  return this._ownerActionRequest(
386
426
  "delete",
387
427
  address,
388
428
  owner,
389
429
  "DELETE",
390
- `/contracts/${encodeURIComponent(address)}`
430
+ `/contracts/${encodeURIComponent(address)}`,
431
+ opts
391
432
  );
392
433
  }
393
434
 
@@ -399,9 +440,18 @@ class Cx3Wallet {
399
440
  */
400
441
  async transferNative(to, amount, opts = {}) {
401
442
  if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
402
- const toAddress = String(to || "").trim();
403
- if (!toAddress) throw new Cx3Error("recipient required", "NO_RECIPIENT");
404
- const amt = typeof amount === "number" ? amount : Number(amount);
443
+ if (to == null || String(to).trim() === "") throw new Cx3Error("recipient required", "NO_RECIPIENT");
444
+ // a full address only (0x / cx3: + 40 hex): anything else is not signed
445
+ const toAddress = normalizeAddress(to);
446
+ if (!/^0x[0-9a-f]{40}$/.test(toAddress)) {
447
+ throw new Cx3Error("The recipient is not a CX3 address (0x + 40 hex)", "INVALID_RECIPIENT");
448
+ }
449
+ // a text amount is plain decimal digits: "0x10" or "1e3" would be read as another number
450
+ if (typeof amount === "string" && !/^\d{1,15}(\.\d+)?$/.test(amount.trim())) {
451
+ throw new Cx3Error("Invalid amount", "INVALID_AMOUNT");
452
+ }
453
+ if (typeof amount !== "number" && typeof amount !== "string") throw new Cx3Error("Invalid amount", "INVALID_AMOUNT");
454
+ const amt = typeof amount === "number" ? amount : Number(amount.trim());
405
455
  if (!Number.isFinite(amt) || amt <= 0) {
406
456
  throw new Cx3Error("Invalid amount", "INVALID_AMOUNT");
407
457
  }
@@ -463,6 +513,7 @@ class Cx3Wallet {
463
513
  return new TransactionResponse(result, this.provider, opts);
464
514
  }
465
515
 
516
+ /** Is this wallet the one the provider's application belongs to: `{ ok: true }`, or `wallet_mismatch`. */
466
517
  async bindApiKey(address) {
467
518
  if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
468
519
  return this.provider.bindApiKey(address || this.address);