@quantovainc/qcore 0.1.29 → 0.1.30

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/index.d.ts CHANGED
@@ -162,6 +162,8 @@ export class Network {
162
162
  export interface ClientOptions {
163
163
  acknowledgeMainnet?: boolean;
164
164
  network?: Network;
165
+ /** Bind signatures to this chain name; a gateway reporting any other is refused. */
166
+ expectedChainId?: string;
165
167
  }
166
168
 
167
169
  export class Client {
@@ -228,6 +230,7 @@ export class Client {
228
230
  ownerIndex: number,
229
231
  meterLimit: number | bigint,
230
232
  maxFeeQuon: string | bigint,
233
+ expectedOrderNonce?: bigint | number | string
231
234
  ): Promise<SignedOrderResult>;
232
235
  }
233
236
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@quantovainc/qcore",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Quantova's post quantum client core for JavaScript. Signs with ML-DSA-65 and builds Q1 addresses.",
5
5
  "scripts": {
6
6
  "build": "bash scripts/build.sh",
7
- "test": "node test-conformance.js && node test-sign-validate.js && node test-transport.js && node test-fee-cap.js && node test-network.js"
7
+ "test": "node test-types.js && node test-conformance.js && node test-order-nonce.js && node test-chain-binding.js && node test-sign-validate.js && node test-transport.js && node test-fee-cap.js && node test-network.js"
8
8
  },
9
9
  "keywords": [
10
10
  "post-quantum",
@@ -83,5 +83,8 @@
83
83
  ],
84
84
  "engines": {
85
85
  "node": ">=18"
86
+ },
87
+ "devDependencies": {
88
+ "typescript": "^5.6.0"
86
89
  }
87
90
  }
package/pkg/package.json CHANGED
@@ -13,8 +13,8 @@
13
13
  "qcore_js.js",
14
14
  "qcore_js_bg.js",
15
15
  "qcore_js.d.ts",
16
- "LICENSE-APACHE",
17
- "LICENSE-MIT"
16
+ "LICENSE-MIT",
17
+ "LICENSE-APACHE"
18
18
  ],
19
19
  "main": "qcore_js.js",
20
20
  "types": "qcore_js.d.ts",
Binary file
@@ -11,8 +11,8 @@
11
11
  "qcore_js_bg.wasm",
12
12
  "qcore_js.js",
13
13
  "qcore_js.d.ts",
14
- "LICENSE-APACHE",
15
- "LICENSE-MIT"
14
+ "LICENSE-MIT",
15
+ "LICENSE-APACHE"
16
16
  ],
17
17
  "main": "qcore_js.js",
18
18
  "types": "qcore_js.d.ts"
Binary file
package/shared.js CHANGED
@@ -198,6 +198,17 @@ function makeClient(core) {
198
198
  this.network = opts.network instanceof Network ? opts.network : Network.forUrl(base);
199
199
  }
200
200
  this.base = requireSafeTransport(base).replace(/\/$/, '');
201
+ // The chain name the gateway reports is hashed into the signature preimage of
202
+ // every signing path, so whoever names it decides which network a signature is
203
+ // valid on. `Network.forUrl` leaves chainId null, which is the default for
204
+ // `new Client(url)`, and the configured check is skipped when it is null, so by
205
+ // default the endpoint chose. Bind it with expectedChainId, or rely on the pin,
206
+ // which at least refuses a gateway that changes its mind mid session.
207
+ this.expectedChainId =
208
+ typeof opts.expectedChainId === 'string' && opts.expectedChainId.length > 0
209
+ ? opts.expectedChainId
210
+ : null;
211
+ this._pinnedChainName = null;
201
212
  }
202
213
 
203
214
  _guardMainnet() {
@@ -212,13 +223,25 @@ function makeClient(core) {
212
223
  if (!name) throw new Error('the gateway did not report a chain id to bind the signature to');
213
224
  if (typeof name !== 'string') throw new Error('the gateway reported a chain id that is not a string, refusing to bind a signature to it');
214
225
  const id = BigInt(core.chainIdFromName(name));
215
- const configured = this.network && this.network.chainId;
226
+ const configured =
227
+ this.expectedChainId || (this.network && this.network.chainId) || null;
216
228
  if (configured && name !== configured) {
217
229
  throw new Error(`the gateway reports chain ${name} but this client is configured for ${configured}; refusing to sign a transaction that would be valid on a network you did not choose`);
218
230
  }
219
231
  if (!this.acknowledgeMainnet && this._isMainnetId(id)) {
220
232
  throw new Error(`the gateway reports the mainnet chain ${name}; refusing to sign a mainnet transaction without acknowledgeMainnet`);
221
233
  }
234
+ // Pin on first ACCEPTED use, after every refusal above. With nothing configured
235
+ // the endpoint names the chain, and the least this can do is refuse one that
236
+ // answers differently later in the same session, which is how a signature ends
237
+ // up bound to a network the caller never chose while every earlier call looked
238
+ // entirely normal. Pinning a name we refused to sign for would be wrong: it was
239
+ // never a chain this client accepted.
240
+ if (this._pinnedChainName === null) {
241
+ this._pinnedChainName = name;
242
+ } else if (this._pinnedChainName !== name) {
243
+ throw new Error(`the gateway reported chain ${this._pinnedChainName} earlier and now reports ${name}; refusing to sign, the endpoint is not naming one network`);
244
+ }
222
245
  return id;
223
246
  }
224
247
 
@@ -378,7 +401,7 @@ function makeClient(core) {
378
401
  return BigInt(core.storageValue(JSON.stringify(resp), core.scalarSlotKey(BigInt(slot))));
379
402
  }
380
403
 
381
- async callSignedOrder(callerSeedHex, callerIndex, contract, selectorHex, orderSpec, ownerSeedHex, ownerIndex, meterLimit, maxFeeQuon) {
404
+ async callSignedOrder(callerSeedHex, callerIndex, contract, selectorHex, orderSpec, ownerSeedHex, ownerIndex, meterLimit, maxFeeQuon, expectedOrderNonce) {
382
405
  if (!core.valid_address(contract)) throw new Error('the contract is not a q1 address');
383
406
  const ceiling = feeCeiling(maxFeeQuon);
384
407
  const info = await this.nodeInfo();
@@ -390,7 +413,18 @@ function makeClient(core) {
390
413
  throw new Error(`the gateway fee ${fee} is above the maximum you allowed ${maxFeeQuon}, refusing to sign`);
391
414
  }
392
415
  const signer = core.orderSigner(ownerSeedHex, accountIndex(ownerIndex));
416
+ // The owner authorisation carries no deadline, so this nonce is the only thing
417
+ // that stops it being replayed. Reading it from the gateway hands the party the
418
+ // signature protects against the one term that expires it: answer with a future
419
+ // nonce and the reply is a valid order banked until the contract counter reaches
420
+ // it. Pass expectedOrderNonce to bind what you meant to sign; without it you are
421
+ // trusting the endpoint with the owner key.
393
422
  const nonce = await this.contractNonce(contract, signer);
423
+ if (expectedOrderNonce != null && BigInt(expectedOrderNonce) !== nonce) {
424
+ throw new Error(
425
+ `the gateway reported order nonce ${nonce} but you expected ${expectedOrderNonce}, refusing to sign`
426
+ );
427
+ }
394
428
  const order = JSON.parse(core.buildTypedOrderCall(
395
429
  chainId,
396
430
  contract,
@@ -410,7 +444,7 @@ function makeClient(core) {
410
444
  core.sign_call(callerSeedHex, accountIndex(callerIndex), contract, order.call_args, accountNonce(acct.nonce), BigInt(meterLimit), String(fee), chainId)
411
445
  );
412
446
  const outcome = await this.submit(signed.tx_hex);
413
- return { order, signed, outcome };
447
+ return { order, signed, outcome, orderNonce: nonce };
414
448
  }
415
449
  };
416
450
  }