@quantovainc/qcore 0.1.29 → 0.1.31

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
@@ -91,7 +91,7 @@ Quantova is post quantum from the ground. There is no elliptic curve anywhere in
91
91
 
92
92
  3. The address. A Quantova address is the Bech32m Q1 string that renders the SHA3 256 hash of the scheme byte together with the whole module lattice public key of one thousand nine hundred fifty two bytes. The entire public key is bound into the address. Nothing is truncated to a twenty byte hash and there is no key recovery from a signature, so one address names exactly one post quantum key.
93
93
 
94
- 4. The transaction. A transaction body carries the sender, the nonce, the meter limit, the fee, and the call. The bytes a signature stands over are the SHA3 256 hash of the canonical body followed by a fixed Quantova transaction domain tag, so a transaction signature can never be replayed as another kind of signed message. QCore.js assembles the body, signs the digest inside the compiled core, and returns the wrapper bytes and the transaction id ready for the gateway.
94
+ 4. The transaction. A transaction body carries the sender, the nonce, the meter limit, the fee, and the call. The bytes a signature stands over are the SHA3 256 hash of the canonical body, and a fixed Quantova transaction domain tag is bound in as the post quantum signing context, so a transaction signature can never be replayed as another kind of signed message. QCore.js assembles the body, signs the digest inside the compiled core, and returns the wrapper bytes and the transaction id ready for the gateway.
95
95
 
96
96
  5. The payable call. `core.signPayableCall` carries a native value and an explicit chain id alongside the sender, the nonce, the meter limit, the fee, and the call, so a call to a contract can move value in the same signed body a transfer does, and the same signature can be pinned to one network. The sender and the target never enter that signature as the rendered Q1 string, since a display convention can change over time. Each is parsed back to its raw thirty two byte payload first, and that payload, never the string, is what the preimage carries, so the signature stays bound to the account and never to how an address happened to be printed.
97
97
 
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.31",
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
@@ -2,7 +2,7 @@
2
2
  "name": "qcore-js",
3
3
  "type": "module",
4
4
  "description": "The JavaScript binding for the Quantova client core, generated over QCore with wasm-bindgen. The core does the key derivation, the post quantum signing, and the wire, the page does the fetch.",
5
- "version": "0.1.29",
5
+ "version": "0.1.31",
6
6
  "license": "Apache-2.0 OR MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qcore-js",
3
3
  "description": "The JavaScript binding for the Quantova client core, generated over QCore with wasm-bindgen. The core does the key derivation, the post quantum signing, and the wire, the page does the fetch.",
4
- "version": "0.1.29",
4
+ "version": "0.1.31",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -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
 
@@ -252,7 +275,22 @@ function makeClient(core) {
252
275
 
253
276
  nodeInfo() { return this._call('node_info', '{}'); }
254
277
  head() { return this._call('head', '{}'); }
255
- account(address) { return this._call('get_account', core.account_body(address)); }
278
+ async account(address) {
279
+ const acct = await this._call('get_account', core.account_body(address));
280
+ if (acct && acct.address != null && acct.address !== address) {
281
+ throw new Error(`the gateway answered for ${acct.address} when asked about ${address}, refusing to trust it`);
282
+ }
283
+ return acct;
284
+ }
285
+ _checkedNonce(reported, expected) {
286
+ const n = accountNonce(reported);
287
+ if (expected != null) {
288
+ const e = BigInt(expected);
289
+ if (n < e) throw new Error(`the gateway reported nonce ${n} below the expected ${e}; refusing so a replayed lower nonce cannot force a second payment`);
290
+ if (n > e + 16n) throw new Error(`the gateway reported nonce ${n} far above the expected ${e}; refusing`);
291
+ }
292
+ return n;
293
+ }
256
294
  transaction(txId) { return this._call('get_transaction', core.transaction_body(txId)); }
257
295
  block(height) { return this._call('get_block', core.block_by_height_body(BigInt(height))); }
258
296
  submit(txHex) { return this._call('submit_transaction', core.submit_body(txHex)); }
@@ -262,7 +300,7 @@ function makeClient(core) {
262
300
 
263
301
  address(seedHex, index) { return core.address(seedHex, accountIndex(index)); }
264
302
 
265
- async transfer(seedHex, index, to, amount, maxFeeQuon) {
303
+ async transfer(seedHex, index, to, amount, maxFeeQuon, expectedNonce) {
266
304
  if (!core.valid_address(to)) throw new Error('the recipient is not a q1 address');
267
305
  checkAmount(amount);
268
306
  const ceiling = feeCeiling(maxFeeQuon);
@@ -277,8 +315,9 @@ function makeClient(core) {
277
315
  const from = core.address(seedHex, accountIndex(index));
278
316
  const acct = await this.account(from);
279
317
  if (!acct || acct.nonce == null) throw new Error('the gateway did not report a nonce');
318
+ const nonce = this._checkedNonce(acct.nonce, expectedNonce);
280
319
  const signed = JSON.parse(
281
- core.sign_transfer(seedHex, accountIndex(index), to, String(amount), accountNonce(acct.nonce), String(fee), chainId)
320
+ core.sign_transfer(seedHex, accountIndex(index), to, String(amount), nonce, String(fee), chainId)
282
321
  );
283
322
  const outcome = await this.submit(signed.tx_hex);
284
323
  return { signed, outcome };
@@ -378,7 +417,7 @@ function makeClient(core) {
378
417
  return BigInt(core.storageValue(JSON.stringify(resp), core.scalarSlotKey(BigInt(slot))));
379
418
  }
380
419
 
381
- async callSignedOrder(callerSeedHex, callerIndex, contract, selectorHex, orderSpec, ownerSeedHex, ownerIndex, meterLimit, maxFeeQuon) {
420
+ async callSignedOrder(callerSeedHex, callerIndex, contract, selectorHex, orderSpec, ownerSeedHex, ownerIndex, meterLimit, maxFeeQuon, expectedOrderNonce) {
382
421
  if (!core.valid_address(contract)) throw new Error('the contract is not a q1 address');
383
422
  const ceiling = feeCeiling(maxFeeQuon);
384
423
  const info = await this.nodeInfo();
@@ -390,7 +429,18 @@ function makeClient(core) {
390
429
  throw new Error(`the gateway fee ${fee} is above the maximum you allowed ${maxFeeQuon}, refusing to sign`);
391
430
  }
392
431
  const signer = core.orderSigner(ownerSeedHex, accountIndex(ownerIndex));
432
+ // The owner authorisation carries no deadline, so this nonce is the only thing
433
+ // that stops it being replayed. Reading it from the gateway hands the party the
434
+ // signature protects against the one term that expires it: answer with a future
435
+ // nonce and the reply is a valid order banked until the contract counter reaches
436
+ // it. Pass expectedOrderNonce to bind what you meant to sign; without it you are
437
+ // trusting the endpoint with the owner key.
393
438
  const nonce = await this.contractNonce(contract, signer);
439
+ if (expectedOrderNonce != null && BigInt(expectedOrderNonce) !== nonce) {
440
+ throw new Error(
441
+ `the gateway reported order nonce ${nonce} but you expected ${expectedOrderNonce}, refusing to sign`
442
+ );
443
+ }
394
444
  const order = JSON.parse(core.buildTypedOrderCall(
395
445
  chainId,
396
446
  contract,
@@ -410,7 +460,7 @@ function makeClient(core) {
410
460
  core.sign_call(callerSeedHex, accountIndex(callerIndex), contract, order.call_args, accountNonce(acct.nonce), BigInt(meterLimit), String(fee), chainId)
411
461
  );
412
462
  const outcome = await this.submit(signed.tx_hex);
413
- return { order, signed, outcome };
463
+ return { order, signed, outcome, orderNonce: nonce };
414
464
  }
415
465
  };
416
466
  }