@carbonx3/cx3.js 0.3.11 → 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,7 +328,7 @@ 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) |
@@ -332,46 +353,31 @@ try {
332
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 |
333
354
  | `REQUEST_LOOKUP_UNAVAILABLE` | `getRequest` could not ask the chain (503): try again later, do not sign again |
334
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 |
335
- | `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 |
336
359
  | `NO_CHAIN_IDENTITY` | The network's chain id or genesis hash is unavailable; a native transfer is not signed without them |
337
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`) |
338
361
  | `INSECURE_BASE_URL` | Credentials would travel over plain `http` to another machine; use `https` (or `allowInsecure: true` on a network you trust) |
339
362
 
340
363
  ## Browser applications
341
364
 
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.
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.
343
366
 
344
367
  ## Environment variables
345
368
 
346
369
  | Variable | Used for |
347
370
  |----------|----------|
348
371
  | `CX3_CLIENT_ID`, `CX3_CLIENT_SECRET` | Application credentials when the options are omitted (not read with `devNode: true`) |
349
- | `CX3_API_KEY` | Legacy wallet API key |
350
372
  | `CX3_NODE_URL` | Gateway URL for `getDefaultProvider()` |
351
373
 
352
374
  ## TypeScript
353
375
 
354
376
  Type declarations (`index.d.ts`) ship with the package.
355
377
 
356
- ## Upgrading from 0.2.x
357
-
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.
359
-
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.
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.
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.
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)`.
365
-
366
- ## Upgrading from 0.1.x
367
-
368
- CX3js is in beta (0.x), so a minor version may contain breaking changes. Version 0.2.0:
378
+ ## Versioning
369
379
 
370
- - **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.
371
- - **HTTP Basic was removed.** The `auth` option throws `BASIC_AUTH_UNSUPPORTED`, and `headers` cannot carry `Authorization` or `X-Api-Key`.
372
- - **Node.js 20.19 or later** is required (`@noble/secp256k1` 3.x).
373
- - **Fixes:** BigInt params are signed and sent as decimal strings, and `transferNative` sends the gas fields it signs.
374
- - **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.
375
381
 
376
382
  ## Documentation
377
383
 
package/index.d.ts CHANGED
@@ -29,8 +29,6 @@ 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
32
  /** a development node that runs without auth (/api/v1); the credentials of the environment are not read for it */
35
33
  devNode?: boolean;
36
34
  /** The network this provider is for (both together): an endpoint on another network is refused with CHAIN_IDENTITY_MISMATCH and nothing is signed */
@@ -40,9 +38,7 @@ export interface Cx3ProviderOptions {
40
38
  allowInsecure?: boolean;
41
39
  /** "" on the gateway, "/api/v1" on a development node */
42
40
  apiPrefix?: string;
43
- /** Not supported: throws BASIC_AUTH_UNSUPPORTED (HTTP Basic is the nodes' internal login) */
44
- auth?: never;
45
- /** 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) */
46
42
  headers?: Record<string, string>;
47
43
  fetch?: typeof fetch;
48
44
  timeoutMs?: number;
@@ -78,14 +74,13 @@ export class Cx3Provider {
78
74
  devNode: boolean;
79
75
  oauth: boolean;
80
76
  clientId: string;
81
- apiKeyMode: "public" | "private" | "invalid";
82
- authMode: "oauth" | "apiKey" | "devNode";
83
- /** Throws CREDENTIALS_REQUIRED without clientId / clientSecret, apiKey or devNode */
77
+ authMode: "oauth" | "devNode";
78
+ /** Throws CREDENTIALS_REQUIRED without clientId / clientSecret or devNode */
84
79
  constructor(baseUrl: string, options?: Cx3ProviderOptions);
85
80
  /** Forget the held access token: the next request asks for a new one */
86
81
  clearAccessToken(): void;
87
82
  /** 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 };
83
+ toJSON(): { baseUrl: string; authMode: "oauth" | "devNode"; clientId?: string; scope?: string };
89
84
  /** The current OAuth2 access token (renewed a minute before it expires); null without an OAuth2 app */
90
85
  getAccessToken(force?: boolean): Promise<string | null>;
91
86
  request(path: string, opts?: { method?: string; body?: unknown; query?: Record<string, string | number> }): Promise<any>;
@@ -117,6 +112,7 @@ export class Cx3Provider {
117
112
  getBalance(address: string): Promise<any>;
118
113
  getNonce(address: string): Promise<number>;
119
114
  getPendingNonce(address: string): Promise<any>;
115
+ /** Is this wallet the one the application belongs to: { ok: true }, or wallet_mismatch */
120
116
  bindApiKey(address: string): Promise<any>;
121
117
  /** The gateway lists a private token only when the app's wallet is its owner or an authorized wallet */
122
118
  listContracts(opts?: { limit?: number; offset?: number; viewer?: string; owner?: string; standard?: string; type?: string; live?: boolean }): Promise<any>;
@@ -136,14 +132,32 @@ export class Cx3Provider {
136
132
  getContract(address: string, abi?: unknown, signer?: Cx3Wallet | null): Promise<Contract>;
137
133
  }
138
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
+
139
149
  export class Cx3Wallet {
140
150
  address: string;
141
151
  publicKey: string;
142
152
  provider: Cx3Provider | null;
153
+ /** true for a wallet made with fromSigner (no private key in this process) */
154
+ external: boolean;
143
155
  constructor(privateKey: string | Uint8Array | Buffer, provider?: Cx3Provider | null);
144
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;
145
159
  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) */
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 */
147
161
  exportPrivateKey(): string | Uint8Array | Buffer;
148
162
  /** Address and public key only (JSON.stringify and util.inspect use it) */
149
163
  toJSON(): { address: string; publicKey: string };
@@ -295,7 +309,6 @@ export class TransactionResponse {
295
309
  export function getDefaultProvider(options?: Cx3ProviderOptions & { url?: string }): Cx3Provider;
296
310
  export function readCallValue(response: any): unknown;
297
311
  export function normalizeAddress(addr: string): string;
298
- export function walletApiKeyMode(key?: string | null): "public" | "private" | "invalid";
299
312
  /** BigInt → decimal string, recursively (params are signed and sent in this form) */
300
313
  export function toJsonSafe<T>(value: T): unknown;
301
314
  export function ensure0x(hex: string): string;
@@ -317,6 +330,8 @@ export function signPersonalMessage(message: string, privateKey: string | Uint8A
317
330
  export function buildTransferPayload(fields: object): string;
318
331
  export function signMessage(message: string | Uint8Array, privateKey: string | Uint8Array | Buffer): Promise<any>;
319
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;
320
335
  export function createPrivateKey(): string;
321
336
  export function normalizeAbi(abi: unknown): Record<string, any>;
322
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.11";
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.11",
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"
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 || {})) {
@@ -90,7 +89,6 @@ class Cx3Provider {
90
89
  // Credentials live in private fields: JSON.stringify, util.inspect, console.log, Object.keys and a
91
90
  // spread of the provider (or of anything that holds it) never show them.
92
91
  #clientSecret = "";
93
- #apiKey = "";
94
92
  #token = null; // { value, refreshAt, scope }
95
93
 
96
94
  /**
@@ -99,7 +97,6 @@ class Cx3Provider {
99
97
  * @param {string} [options.clientId] OAuth2 client id (`cx3app_…`; env CX3_CLIENT_ID)
100
98
  * @param {string} [options.clientSecret] OAuth2 client secret (`cx3sec_…`; env CX3_CLIENT_SECRET)
101
99
  * @param {string|string[]} [options.scope] ask for fewer scopes than the app has ("read", "write", "deploy")
102
- * @param {string} [options.apiKey] wallet API key (`cx3wk_…`; env CX3_API_KEY), until apps move to OAuth2
103
100
  * @param {boolean} [options.devNode] a development node without auth (`/api/v1`), no credentials
104
101
  * @param {string} [options.apiPrefix] ("" on the gateway, "/api/v1" on a development node)
105
102
  * @param {Record<string,string>} [options.headers] Extra headers (Authorization / X-Api-Key are dropped)
@@ -109,32 +106,24 @@ class Cx3Provider {
109
106
  constructor(baseUrl, options = {}) {
110
107
  if (!baseUrl) throw new Cx3Error("Cx3Provider requires baseUrl (CX3_NODE_URL)", "NO_BASE_URL");
111
108
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
112
- if (options.auth != null) {
113
- throw new Cx3Error(
114
- "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",
115
- "BASIC_AUTH_UNSUPPORTED"
116
- );
117
- }
118
109
  this.devNode = options.devNode === true;
119
110
  // a development node gets only what the caller passes: the gateway credentials of the environment never go to it
120
111
  const env = (name) => (this.devNode ? "" : (process.env[name] || "").trim());
121
112
  this.clientId = String(options.clientId != null ? options.clientId : env("CX3_CLIENT_ID")).trim();
122
113
  this.#clientSecret = String(options.clientSecret != null ? options.clientSecret : env("CX3_CLIENT_SECRET")).trim();
123
114
  this.scope = Array.isArray(options.scope) ? options.scope.join(" ") : String(options.scope || "").trim();
124
- this.#apiKey = String(options.apiKey != null ? options.apiKey : env("CX3_API_KEY")).trim();
125
115
  this.oauth = !!(this.clientId || this.#clientSecret);
126
116
  if (this.oauth && (!CLIENT_ID_RE.test(this.clientId) || !CLIENT_SECRET_RE.test(this.#clientSecret))) {
127
117
  throw new Cx3Error("clientId must be cx3app_… and clientSecret cx3sec_… (CX3WebUI → Settings → API → Applications)", "INVALID_CLIENT_CREDENTIALS");
128
118
  }
129
- const hasKey = walletApiKeyMode(this.#apiKey) !== "public";
130
- if (!this.oauth && !hasKey && !this.devNode) {
119
+ if (!this.oauth && !this.devNode) {
131
120
  throw new Cx3Error(
132
121
  "App credentials required: create an application in CX3WebUI (Settings → API → Applications) and pass clientId / clientSecret (or set CX3_CLIENT_ID / CX3_CLIENT_SECRET)",
133
122
  "CREDENTIALS_REQUIRED"
134
123
  );
135
124
  }
136
125
  // 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)) {
126
+ if (this.oauth && options.allowInsecure !== true && isRemotePlainHttp(this.baseUrl)) {
138
127
  throw new Cx3Error(
139
128
  "Credentials are not sent over plain http: use an https URL (or allowInsecure: true on a network you trust)",
140
129
  "INSECURE_BASE_URL"
@@ -164,14 +153,9 @@ class Cx3Provider {
164
153
  }
165
154
  }
166
155
 
167
- get apiKeyMode() {
168
- return walletApiKeyMode(this.#apiKey);
169
- }
170
-
171
- /** "oauth" | "apiKey" | "devNode" */
156
+ /** "oauth" | "devNode" */
172
157
  get authMode() {
173
- if (this.devNode && !this.oauth && walletApiKeyMode(this.#apiKey) === "public") return "devNode";
174
- return this.oauth ? "oauth" : "apiKey";
158
+ return this.oauth ? "oauth" : "devNode";
175
159
  }
176
160
 
177
161
  /**
@@ -223,11 +207,7 @@ class Cx3Provider {
223
207
  "Content-Type": "application/json",
224
208
  ...this.extraHeaders
225
209
  };
226
- if (this.oauth) {
227
- headers.Authorization = `Bearer ${await this.getAccessToken(forceToken)}`;
228
- } else if (/^cx3wk_[a-f0-9]{48}$/i.test(this.#apiKey)) {
229
- headers["X-Api-Key"] = this.#apiKey;
230
- }
210
+ if (this.oauth) headers.Authorization = `Bearer ${await this.getAccessToken(forceToken)}`;
231
211
  return headers;
232
212
  }
233
213
 
@@ -252,9 +232,6 @@ class Cx3Provider {
252
232
  * @param {{ method?: string, body?: object, query?: Record<string,string|number> }} [opts]
253
233
  */
254
234
  async request(path, opts = {}) {
255
- if (!this.oauth && this.apiKeyMode === "invalid") {
256
- throw new Cx3Error("api_key_required", "api_key_required");
257
- }
258
235
  const method = opts.method || "GET";
259
236
  let route = path.startsWith("/") ? path : `/${path}`;
260
237
  if (this.gateway) route = gatewayPath(route, opts.body);
@@ -564,34 +541,14 @@ class Cx3Provider {
564
541
  }
565
542
 
566
543
  /**
567
- * Check that this wallet is the one the credentials belong to (an app and a wallet API key are
568
- * both bound to one wallet): `{ ok: true }`, or `wallet_mismatch`.
569
- * OAuth2 app and cx3wk_ key → GET `/wallet/api-key/bind` (the gateway route; it has no `/whoami`);
570
- * a cx3wk_ key falls back to `/whoami` on 404 (a same-origin proxy that only forwards that).
571
- * 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 }`.
572
546
  */
573
547
  async bindApiKey(address) {
574
548
  const addr = normalizeAddress(address);
575
549
  if (!addr) throw new Cx3Error("address required", "NO_ADDRESS");
576
- const query = { address: addr };
577
- if (this.oauth) return this._rejectEnvelope(await this.request("/wallet/api-key/bind", { query }));
578
- const mode = this.apiKeyMode;
579
- if (mode === "invalid") {
580
- throw new Cx3Error("api_key_required", "api_key_required");
581
- }
582
- if (mode === "public") {
583
- return { ok: true, devNode: true };
584
- }
585
- try {
586
- const json = await this.request("/wallet/api-key/bind", { query });
587
- return this._rejectEnvelope(json);
588
- } catch (e) {
589
- if (e && e.status === 404) {
590
- const json = await this.request("/whoami", { query });
591
- return this._rejectEnvelope(json);
592
- }
593
- throw e;
594
- }
550
+ if (!this.oauth) return { ok: true, devNode: true };
551
+ return this._rejectEnvelope(await this.request("/wallet/api-key/bind", { query: { address: addr } }));
595
552
  }
596
553
 
597
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
 
@@ -475,6 +513,7 @@ class Cx3Wallet {
475
513
  return new TransactionResponse(result, this.provider, opts);
476
514
  }
477
515
 
516
+ /** Is this wallet the one the provider's application belongs to: `{ ok: true }`, or `wallet_mismatch`. */
478
517
  async bindApiKey(address) {
479
518
  if (!this.provider) throw new Cx3Error("Wallet has no provider", "NO_PROVIDER");
480
519
  return this.provider.bindApiKey(address || this.address);