@wishknish/knishio-client-js 0.8.2 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishknish/knishio-client-js",
3
- "version": "0.8.2",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "productName": "Knish.IO Javascript SDK Client",
6
6
  "description": "JavaScript implementation of the Knish.IO SDK to consume Knish.IO GraphQL APIs.",
@@ -65,22 +65,18 @@
65
65
  "wonka": "^6.3.5"
66
66
  },
67
67
  "devDependencies": {
68
- "@jest/globals": "^29.7.0",
68
+ "@jest/globals": "^30.4.1",
69
69
  "@rollup/plugin-babel": "^6.1.0",
70
70
  "@rollup/plugin-commonjs": "^28.0.9",
71
71
  "@rollup/plugin-node-resolve": "^16.0.3",
72
+ "@swc/core": "^1.15.43",
73
+ "@swc/jest": "^0.2.39",
72
74
  "buffer": "^6.0.3",
73
- "esbuild-jest": "^0.5.0",
74
- "eslint": "^8.57.1",
75
- "eslint-config-standard": "^17.1.0",
76
- "eslint-plugin-import": "^2.32.0",
77
- "eslint-plugin-n": "^16.6.2",
78
- "eslint-plugin-promise": "^6.6.0",
79
- "eslint-plugin-vue": "^9.33.0",
80
- "esmock": "^2.7.3",
81
- "jest": "^29.7.0",
75
+ "eslint": "^9.39.0",
76
+ "jest": "^30.4.2",
77
+ "neostandard": "^0.13.0",
82
78
  "rollup": "^4.57.1",
83
- "vite": "^6.4.1"
79
+ "vite": "^7.3.5"
84
80
  },
85
81
  "browserslist": [
86
82
  "> 1%",
@@ -89,7 +85,7 @@
89
85
  ],
90
86
  "scripts": {
91
87
  "build": "vite build --config build/vite.config.mjs",
92
- "lint": "eslint --ext .js src",
88
+ "lint": "eslint src",
93
89
  "test": "jest",
94
90
  "test:coverage": "jest --coverage",
95
91
  "selftest": "npm run build && node tests/scripts/self-test.js",
@@ -88,7 +88,6 @@ import ActiveWalletSubscribe from './subscribe/ActiveWalletSubscribe.js'
88
88
  import ActiveSessionSubscribe from './subscribe/ActiveSessionSubscribe.js'
89
89
  import MutationActiveSession from './mutation/MutationActiveSession.js'
90
90
  import QueryActiveSession from './query/QueryActiveSession.js'
91
- import QueryUserActivity from './query/QueryUserActivity.js'
92
91
  import QueryToken from './query/QueryToken.js'
93
92
  import BatchIdException from './exception/BatchIdException.js'
94
93
  import AuthorizationRejectedException from './exception/AuthorizationRejectedException.js'
@@ -1216,50 +1215,6 @@ export default class KnishIOClient {
1216
1215
  })
1217
1216
  }
1218
1217
 
1219
- /**
1220
- * Queries user activity based on the provided parameters.
1221
- *
1222
- * @param {string} bundleHash - The bundle hash.
1223
- * @param {string} metaType - The meta type.
1224
- * @param {string} metaId - The meta ID.
1225
- * @param {string} ipAddress - The IP address.
1226
- * @param {string} browser - The browser.
1227
- * @param {string} osCpu - The operating system and CPU.
1228
- * @param {string} resolution - The screen resolution.
1229
- * @param {string} timeZone - The time zone.
1230
- * @param {string} countBy - The count by parameter.
1231
- * @param {string} interval - The interval parameter.
1232
- *
1233
- * @returns {Promise<ResponseQueryUserActivity>} The result of the query.
1234
- */
1235
- async queryUserActivity ({
1236
- bundleHash,
1237
- metaType,
1238
- metaId,
1239
- ipAddress,
1240
- browser,
1241
- osCpu,
1242
- resolution,
1243
- timeZone,
1244
- countBy,
1245
- interval
1246
- }) {
1247
- const query = this.createQuery(QueryUserActivity)
1248
-
1249
- return await this.executeQuery(query, {
1250
- bundleHash,
1251
- metaType,
1252
- metaId,
1253
- ipAddress,
1254
- browser,
1255
- osCpu,
1256
- resolution,
1257
- timeZone,
1258
- countBy,
1259
- interval
1260
- })
1261
- }
1262
-
1263
1218
  /**
1264
1219
  * Builds and executes a molecule to declare an active session for the given MetaType
1265
1220
  *
@@ -1966,6 +1921,109 @@ export default class KnishIOClient {
1966
1921
  return await this.executeQuery(query)
1967
1922
  }
1968
1923
 
1924
+ /**
1925
+ * Transfers tokens from one source wallet to MULTIPLE recipients in a single molecule.
1926
+ *
1927
+ * One molecule funds N recipients (WP line 544: "utilizing the sender's Wallet to fund multiple
1928
+ * recipients with one transaction"). Each recipient receives its own amount and, for stackable
1929
+ * tokens, its own subset of token units. The source is fully drained and the unsent change
1930
+ * returns via the remainder atom (UTXO), so the V-atoms conserve. Single-recipient transfers
1931
+ * should keep using transferToken(); this is its N-recipient generalization.
1932
+ *
1933
+ * @param {Object} options - The transfer options.
1934
+ * @param {string} options.token - The token to transfer.
1935
+ * @param {Array} options.recipients - [{ bundleHash, amount?, units?, batchId? }] per recipient.
1936
+ * @param {Object} [options.sourceWallet=null] - The source wallet; queried if not provided.
1937
+ *
1938
+ * @returns {Promise} - A Promise that resolves to the transaction result.
1939
+ *
1940
+ * @throws {StackableUnitAmountException} - If a recipient provides both amount and units.
1941
+ * @throws {TransferBalanceException} - If the source wallet does not have enough balance.
1942
+ */
1943
+ async transferTokens ({
1944
+ token,
1945
+ recipients,
1946
+ sourceWallet = null
1947
+ }) {
1948
+ // Per-recipient amount: units.length for stackable, else the explicit amount
1949
+ const amounts = recipients.map(recipient => {
1950
+ const units = recipient.units || []
1951
+ if (units.length > 0) {
1952
+ // Can't move stackable units AND provide amount
1953
+ if (recipient.amount > 0) {
1954
+ throw new StackableUnitAmountException()
1955
+ }
1956
+ return units.length
1957
+ }
1958
+ return recipient.amount || 0
1959
+ })
1960
+ const total = amounts.reduce((sum, value) => sum + value, 0)
1961
+
1962
+ // Get a source wallet (loads its token units)
1963
+ if (sourceWallet === null) {
1964
+ sourceWallet = await this.querySourceWallet({
1965
+ token,
1966
+ amount: total
1967
+ })
1968
+ }
1969
+
1970
+ // Do you have enough tokens?
1971
+ if (sourceWallet === null || Decimal.cmp(sourceWallet.balance, total) < 0) {
1972
+ throw new TransferBalanceException()
1973
+ }
1974
+
1975
+ // Build a shadow recipient wallet per recipient, each with its own batch ID
1976
+ const recipientWallets = recipients.map(recipient => {
1977
+ const recipientWallet = Wallet.create({
1978
+ bundle: recipient.bundleHash,
1979
+ token
1980
+ })
1981
+
1982
+ // Compute the batch ID for the recipient (typically used by stackable tokens)
1983
+ if (recipient.batchId !== undefined && recipient.batchId !== null) {
1984
+ recipientWallet.batchId = recipient.batchId
1985
+ } else {
1986
+ recipientWallet.initBatchId({
1987
+ sourceWallet
1988
+ })
1989
+ }
1990
+
1991
+ return recipientWallet
1992
+ })
1993
+
1994
+ // Create a remainder from the source wallet
1995
+ const remainderWallet = sourceWallet.createRemainder(this.getSecret())
1996
+
1997
+ // --- Token units splitting (N-way): source keeps the union, each recipient its subset,
1998
+ // remainder the kept units
1999
+ sourceWallet.splitUnitsMulti(
2000
+ recipients.map(recipient => recipient.units || []),
2001
+ recipientWallets,
2002
+ remainderWallet
2003
+ )
2004
+ // ---
2005
+
2006
+ // Build the molecule itself
2007
+ const molecule = await this.createMolecule({
2008
+ sourceWallet,
2009
+ remainderWallet
2010
+ })
2011
+ /**
2012
+ * @type {MutationTransferTokens}
2013
+ */
2014
+ const query = await this.createMoleculeMutation({
2015
+ mutationClass: MutationTransferTokens,
2016
+ molecule
2017
+ })
2018
+
2019
+ query.fillMoleculeMulti({
2020
+ recipientWallets,
2021
+ amounts
2022
+ })
2023
+
2024
+ return await this.executeQuery(query)
2025
+ }
2026
+
1969
2027
  /**
1970
2028
  * Deposits buffer token into the source wallet.
1971
2029
  *
@@ -2343,7 +2401,15 @@ export default class KnishIOClient {
2343
2401
  molecule
2344
2402
  })
2345
2403
 
2346
- query.fillMolecule({ meta: { encrypt: (encrypt ? 'true' : 'false') } })
2404
+ // PQ-transport Phase E (cycle 163): convey the AUTH source wallet's ML-KEM768 public key as a
2405
+ // SIGNED `walletPubkey` meta on the U-atom (fillMolecule → initAuthorization → sign), so the
2406
+ // validator can encrypt CipherHash responses back to THIS wallet (the one that decrypts them).
2407
+ // Signed → tamper-proof. Only when present (PQ-capable wallet).
2408
+ const authMeta = { encrypt: (encrypt ? 'true' : 'false') }
2409
+ if (wallet.pubkey) {
2410
+ authMeta.walletPubkey = wallet.pubkey
2411
+ }
2412
+ query.fillMolecule({ meta: authMeta })
2347
2413
  /**
2348
2414
  * @type {ResponseRequestAuthorization}
2349
2415
  */
package/src/Molecule.js CHANGED
@@ -577,6 +577,11 @@ export default class Molecule {
577
577
  recipientWallet,
578
578
  amount
579
579
  }) {
580
+ // Canonical token transfer (incl. stackable): a UTXO-style FULL-balance debit + remainder.
581
+ // The source wallet is fully drained (-balance) and the change returns via the remainder atom,
582
+ // so the 3 V-atoms conserve (sum == 0) and the validator's stackable full-drain guard accepts.
583
+ // Per-unit (stackable) movement is layered by calling Wallet.splitUnits(units, remainder,
584
+ // recipient) BEFORE this — there is no separate partial-debit transfer mode.
580
585
  if (this.sourceWallet.balance - amount < 0) {
581
586
  throw new BalanceInsufficientException()
582
587
  }
@@ -608,54 +613,55 @@ export default class Molecule {
608
613
  }
609
614
 
610
615
  /**
611
- * Creates a stackable V-isotope transfer with 3 atoms:
612
- * source debit, recipient credit, remainder.
613
- * Propagates batchId from source wallet.
616
+ * Multi-recipient value transfer (WP line 544: one molecule funds N recipients).
614
617
  *
615
- * @param {Wallet} recipientWallet - wallet receiving the tokens
616
- * @param {number} amount - amount to transfer
618
+ * Builds 1 source atom (full-balance debit, carrying the SENT union of all units) + one atom
619
+ * per recipient (its amount + its own units, walletBundle-bonded) + 1 remainder atom (the KEPT
620
+ * change). Conservation: -balance + Σ amounts + (balance - Σ amounts) == 0.
621
+ *
622
+ * Per-unit (stackable) routing is layered by calling Wallet.splitUnitsMulti(...) BEFORE this —
623
+ * it sets the source's tokenUnits to the union, each recipient's to its subset, and the
624
+ * remainder's to the kept units. The single-recipient initValue() is the N=1 special case.
625
+ *
626
+ * @param {Wallet[]} recipientWallets
627
+ * @param {number[]} amounts - parallel to recipientWallets
617
628
  * @return {Molecule}
618
629
  */
619
- addStackableTransfer ({
620
- recipientWallet,
621
- amount
630
+ initValues ({
631
+ recipientWallets,
632
+ amounts
622
633
  }) {
623
- if (amount <= 0) {
624
- throw new NegativeAmountException('Molecule::addStackableTransfer() - Amount must be positive!')
625
- }
634
+ const total = amounts.reduce((sum, value) => sum + Number(value), 0)
626
635
 
627
- if (this.sourceWallet.balance - amount < 0) {
636
+ if (this.sourceWallet.balance - total < 0) {
628
637
  throw new BalanceInsufficientException()
629
638
  }
630
639
 
631
- const batchId = this.sourceWallet.batchId || generateBatchId({})
632
-
633
- // Source debit atom
640
+ // Source atom: debit the full balance (UTXO); carries the SENT union of all units
634
641
  this.addAtom(Atom.create({
635
642
  isotope: 'V',
636
643
  wallet: this.sourceWallet,
637
- value: -amount,
638
- batchId
644
+ value: -this.sourceWallet.balance
639
645
  }))
640
646
 
641
- // Recipient credit atom
642
- this.addAtom(Atom.create({
643
- isotope: 'V',
644
- wallet: recipientWallet,
645
- value: amount,
646
- metaType: 'walletBundle',
647
- metaId: recipientWallet.bundle,
648
- batchId: generateBatchId({})
649
- }))
647
+ // One atom per recipient: its amount + its own units, bonded to the recipient bundle
648
+ recipientWallets.forEach((recipientWallet, index) => {
649
+ this.addAtom(Atom.create({
650
+ isotope: 'V',
651
+ wallet: recipientWallet,
652
+ value: amounts[index],
653
+ metaType: 'walletBundle',
654
+ metaId: recipientWallet.bundle
655
+ }))
656
+ })
650
657
 
651
- // Remainder atom
658
+ // Remainder atom: the change back to the sender; carries the KEPT units
652
659
  this.addAtom(Atom.create({
653
660
  isotope: 'V',
654
661
  wallet: this.remainderWallet,
655
- value: this.sourceWallet.balance - amount,
662
+ value: this.sourceWallet.balance - total,
656
663
  metaType: 'walletBundle',
657
- metaId: this.remainderWallet.bundle,
658
- batchId
664
+ metaId: this.remainderWallet.bundle
659
665
  }))
660
666
 
661
667
  return this
package/src/Wallet.js CHANGED
@@ -398,6 +398,47 @@ export default class Wallet {
398
398
  remainderWallet.tokenUnits = remainderTokenUnits
399
399
  }
400
400
 
401
+ /**
402
+ * Split token units across MULTIPLE recipients (WP line 544).
403
+ *
404
+ * The source retains the SENT union (all units leaving), each recipient gets its own subset,
405
+ * and the remainder gets the KEPT units (those not assigned to any recipient). Mirrors
406
+ * splitUnits() but N-way: the source's tokenUnits become the union (not a single recipient's
407
+ * subset) so the source atom carries the full SENT set the validator reads as the authority.
408
+ *
409
+ * @param {array[]} recipientUnitLists - array of unit-id arrays, parallel to recipientWallets
410
+ * @param {Wallet[]} recipientWallets
411
+ * @param {Wallet} remainderWallet
412
+ */
413
+ splitUnitsMulti (
414
+ recipientUnitLists,
415
+ recipientWallets,
416
+ remainderWallet
417
+ ) {
418
+ // The union of all unit ids leaving the source
419
+ const sentIds = new Set()
420
+ recipientUnitLists.forEach(unitList => {
421
+ unitList.forEach(id => sentIds.add(id))
422
+ })
423
+
424
+ // Nothing to split (fungible transfer) — leave token units untouched
425
+ if (sentIds.size === 0) {
426
+ return
427
+ }
428
+
429
+ // Each recipient gets its own subset of the source's token units
430
+ recipientWallets.forEach((recipientWallet, index) => {
431
+ const ids = recipientUnitLists[index]
432
+ recipientWallet.tokenUnits = this.tokenUnits.filter(tokenUnit => ids.includes(tokenUnit.id))
433
+ })
434
+
435
+ // The remainder keeps everything not sent to any recipient
436
+ remainderWallet.tokenUnits = this.tokenUnits.filter(tokenUnit => !sentIds.has(tokenUnit.id))
437
+
438
+ // The source carries the SENT union (the ownership authority the validator reads)
439
+ this.tokenUnits = this.tokenUnits.filter(tokenUnit => sentIds.has(tokenUnit.id))
440
+ }
441
+
401
442
  /**
402
443
  * Create a remainder wallet from the source one
403
444
  *
@@ -445,6 +486,18 @@ export default class Wallet {
445
486
  const messageString = JSON.stringify(message)
446
487
  const messageUint8 = new TextEncoder().encode(messageString)
447
488
  const deserializedPubkey = this.deserializeKey(recipientPubkey)
489
+ // ML-KEM-768 public keys are exactly 1184 bytes. A wrong-length key here almost always means the
490
+ // node did not advertise an ML-KEM public key in its auth `key` field (e.g. a validator predating
491
+ // the PQ-transport build). Fail with an actionable message rather than the crypto lib's cryptic
492
+ // `"publicKey" expected Uint8Array of length 1184, got length=N` assertion.
493
+ const ML_KEM_768_PUBLIC_KEY_BYTES = 1184
494
+ if (deserializedPubkey.length !== ML_KEM_768_PUBLIC_KEY_BYTES) {
495
+ throw new Error(
496
+ `KnishIO: cannot ML-KEM-encrypt — recipient public key is ${deserializedPubkey.length} bytes, ` +
497
+ `expected ${ML_KEM_768_PUBLIC_KEY_BYTES} (ML-KEM-768). The node likely did not advertise an ML-KEM ` +
498
+ 'public key (upgrade the validator to a PQ-transport build), or authenticate with { encrypt: false }.'
499
+ )
500
+ }
448
501
  const { cipherText, sharedSecret } = MlKEM768.encapsulate(deserializedPubkey)
449
502
  const encryptedMessage = await this.encryptWithSharedSecret(messageUint8, sharedSecret)
450
503
  return {
@@ -454,6 +507,17 @@ export default class Wallet {
454
507
  }
455
508
 
456
509
  async decryptMessage (encryptedData) {
510
+ const decryptedString = await this._mlkemDecryptToString(encryptedData)
511
+ return decryptedString === null ? null : JSON.parse(decryptedString)
512
+ }
513
+
514
+ /**
515
+ * ML-KEM768 decapsulate + AES-256-GCM decrypt → the RAW decrypted UTF-8 string
516
+ * (no JSON.parse). Shared by {@link decryptMessage} (which JSON.parses the result)
517
+ * and the PQ CipherHash transport ({@link decryptMyMessageML768}, which needs the raw
518
+ * response JSON text). PQ-transport Phase E (cycle 163).
519
+ */
520
+ async _mlkemDecryptToString (encryptedData) {
457
521
  const { cipherText, encryptedMessage } = encryptedData
458
522
 
459
523
  let sharedSecret
@@ -486,9 +550,8 @@ export default class Wallet {
486
550
  return null
487
551
  }
488
552
 
489
- let decryptedString
490
553
  try {
491
- decryptedString = new TextDecoder().decode(decryptedUint8)
554
+ return new TextDecoder().decode(decryptedUint8)
492
555
  } catch (e) {
493
556
  console.warn('Wallet::decryptMessage() - Decoding failed', e)
494
557
  console.info('Wallet::decryptMessage() - my public key', this.pubkey)
@@ -497,8 +560,40 @@ export default class Wallet {
497
560
  console.info('Wallet::decryptMessage() - decrypted Uint8Array', decryptedUint8)
498
561
  return null
499
562
  }
563
+ }
564
+
565
+ /**
566
+ * Multi-recipient map key for a public key: `Base64_standard(SHAKE256(pubkey_utf8, 8 bytes))`
567
+ * — matches the validator's `hash_share` and the other SDKs' `hashShare`/`shortHash`.
568
+ * `shake256(pubkey, 64)` = 64 bits = 8 bytes, hex; hex-decode → standard base64. PQ Phase E.
569
+ */
570
+ hashShare (pubkey) {
571
+ const hex = shake256(pubkey, 64)
572
+ const bytes = Uint8Array.from(hex.match(/.{2}/g).map(b => parseInt(b, 16)))
573
+ return this.serializeKey(bytes)
574
+ }
500
575
 
501
- return JSON.parse(decryptedString)
576
+ /**
577
+ * Post-quantum (ML-KEM768) `CipherHash` request envelope: a stringified single-recipient
578
+ * map `{ "<hashShare(recipientPubkey)>": {cipherText, encryptedMessage} }` (object-valued,
579
+ * via {@link encryptMessage}). Matches the Rust validator's CipherHash handler. PQ Phase E.
580
+ */
581
+ async encryptStringML768 (message, recipientPubkey) {
582
+ const envelope = await this.encryptMessage(message, recipientPubkey)
583
+ return JSON.stringify({ [this.hashShare(recipientPubkey)]: envelope })
584
+ }
585
+
586
+ /**
587
+ * Decrypt a `CipherHash` response map addressed to THIS wallet's ML-KEM pubkey
588
+ * (`hashShare(this.pubkey)`) → the RAW decrypted GraphQL response JSON text (NOT JSON.parsed;
589
+ * it replaces the HTTP response body for the normal parser). `null` if no entry / decrypt fails.
590
+ */
591
+ async decryptMyMessageML768 (map) {
592
+ const envelope = map[this.hashShare(this.pubkey)]
593
+ if (!envelope) {
594
+ return null
595
+ }
596
+ return this._mlkemDecryptToString(envelope)
502
597
  }
503
598
 
504
599
  async encryptWithSharedSecret (message, sharedSecret) {
package/src/index.js CHANGED
@@ -91,7 +91,6 @@ import QueryMetaTypeViaAtom from './query/QueryMetaTypeViaAtom.js'
91
91
  import QueryMetaTypeViaMolecule from './query/QueryMetaTypeViaMolecule.js'
92
92
  import QueryPolicy from './query/QueryPolicy.js'
93
93
  import QueryToken from './query/QueryToken.js'
94
- import QueryUserActivity from './query/QueryUserActivity.js'
95
94
  import QueryWalletBundle from './query/QueryWalletBundle.js'
96
95
  import QueryWalletList from './query/QueryWalletList.js'
97
96
 
@@ -142,7 +141,6 @@ import ResponsePeering from './response/ResponsePeering.js'
142
141
  import ResponsePolicy from './response/ResponsePolicy.js'
143
142
  import ResponseProposeMolecule from './response/ResponseProposeMolecule.js'
144
143
  import ResponseQueryActiveSession from './response/ResponseQueryActiveSession.js'
145
- import ResponseQueryUserActivity from './response/ResponseQueryUserActivity.js'
146
144
  import ResponseRequestAuthorization from './response/ResponseRequestAuthorization.js'
147
145
  import ResponseRequestAuthorizationGuest from './response/ResponseRequestAuthorizationGuest.js'
148
146
  import ResponseRequestTokens from './response/ResponseRequestTokens.js'
@@ -253,7 +251,6 @@ export {
253
251
  QueryMetaTypeViaMolecule,
254
252
  QueryPolicy,
255
253
  QueryToken,
256
- QueryUserActivity,
257
254
  QueryWalletBundle,
258
255
  QueryWalletList,
259
256
 
@@ -298,7 +295,6 @@ export {
298
295
  ResponsePolicy,
299
296
  ResponseProposeMolecule,
300
297
  ResponseQueryActiveSession,
301
- ResponseQueryUserActivity,
302
298
  ResponseRequestAuthorization,
303
299
  ResponseRequestAuthorizationGuest,
304
300
  ResponseRequestTokens,
@@ -7,6 +7,25 @@ import {
7
7
  import { createClient as createWSClient } from 'graphql-ws'
8
8
  import { pipe, map, subscribe } from 'wonka'
9
9
 
10
+ // PQ-transport Phase E (cycle 163): the post-quantum CipherHash wrapper query. The validator
11
+ // intercepts the `CipherHash` op, decrypts `$Hash`, executes the inner request, and returns the
12
+ // encrypted response in `{ data: { CipherHash: { hash } } }`.
13
+ const CIPHER_HASH_QUERY = 'query ( $Hash: String! ) { CipherHash ( Hash: $Hash ) { hash } }'
14
+
15
+ /**
16
+ * Light parse of a GraphQL request body's operation type + root field name, for the CipherHash
17
+ * bypass decision (no full GraphQL parse needed). Operation type = the first query/mutation/
18
+ * subscription keyword (default 'query' for an anonymous `{ ... }`); root field = the first
19
+ * identifier inside the top-level selection set.
20
+ */
21
+ function parseOperation (query) {
22
+ const typeMatch = (query || '').match(/\b(query|mutation|subscription)\b/i)
23
+ const type = typeMatch ? typeMatch[1].toLowerCase() : 'query'
24
+ const braceIdx = (query || '').indexOf('{')
25
+ const nameMatch = braceIdx >= 0 ? query.slice(braceIdx + 1).match(/[A-Za-z_][A-Za-z0-9_]*/) : null
26
+ return { type, name: nameMatch ? nameMatch[0] : '' }
27
+ }
28
+
10
29
  class UrqlClientWrapper {
11
30
  constructor ({ serverUri, socket = null, encrypt = false }) {
12
31
  this.$__client = this.createUrqlClient({ serverUri, socket, encrypt })
@@ -44,6 +63,10 @@ class UrqlClientWrapper {
44
63
  return createClient({
45
64
  url: serverUri,
46
65
  exchanges,
66
+ // PQ-transport Phase E: when encryption is on, route fetch through the CipherHash
67
+ // wrapper (encrypt the request body to the validator's ML-KEM pubkey, decrypt the
68
+ // response). Undefined → urql uses the global fetch (plaintext).
69
+ ...(encrypt ? { fetch: (input, init) => this.cipherFetch(input, init) } : {}),
47
70
  fetchOptions: () => ({
48
71
  headers: {
49
72
  'X-Auth-Token': this.$__authToken
@@ -54,6 +77,71 @@ class UrqlClientWrapper {
54
77
  })
55
78
  }
56
79
 
80
+ /**
81
+ * Whether an outgoing GraphQL request body should be wrapped in CipherHash. Bypass (plaintext):
82
+ * introspection `__schema`, `ContinuId`, the `AccessToken` mutation, and the U-isotope
83
+ * `ProposeMolecule` (auth bootstrap — the key exchange itself can't be encrypted). Mirrors the
84
+ * Kotlin/validator bypass set.
85
+ */
86
+ shouldEncrypt (body) {
87
+ let parsed
88
+ try {
89
+ parsed = JSON.parse(body)
90
+ } catch (e) {
91
+ return false
92
+ }
93
+ const { type, name } = parseOperation(parsed.query)
94
+ if (type === 'query' && (name === '__schema' || name === 'ContinuId')) return false
95
+ if (type === 'mutation' && name === 'AccessToken') return false
96
+ if (type === 'mutation' && name === 'ProposeMolecule') {
97
+ const isotope = parsed.variables && parsed.variables.molecule &&
98
+ parsed.variables.molecule.atoms && parsed.variables.molecule.atoms[0] &&
99
+ parsed.variables.molecule.atoms[0].isotope
100
+ if (isotope === 'U') return false
101
+ }
102
+ return true
103
+ }
104
+
105
+ /**
106
+ * Custom `fetch` that wraps a GraphQL request in the ML-KEM CipherHash envelope and decrypts the
107
+ * response (PQ-transport Phase E). Operates on the raw POST body (mirrors Kotlin's
108
+ * encryptBody/decryptBody). Reads the CURRENT client wallet + validator pubkey from auth.
109
+ */
110
+ async cipherFetch (input, init) {
111
+ const wallet = this.getWallet()
112
+ const serverPubkey = this.getPubKey()
113
+ let encryptedRequest = false
114
+ let requestInit = init
115
+
116
+ if (wallet && serverPubkey && init && typeof init.body === 'string' && this.shouldEncrypt(init.body)) {
117
+ const hashVar = await wallet.encryptStringML768(init.body, serverPubkey)
118
+ requestInit = { ...init, body: JSON.stringify({ query: CIPHER_HASH_QUERY, variables: { Hash: hashVar } }) }
119
+ encryptedRequest = true
120
+ }
121
+
122
+ const response = await fetch(input, requestInit)
123
+ if (!encryptedRequest) {
124
+ return response
125
+ }
126
+
127
+ // Decrypt the CipherHash response back to the inner GraphQL response JSON.
128
+ const text = await response.text()
129
+ const init2 = { status: response.status, statusText: response.statusText, headers: response.headers }
130
+ let parsed
131
+ try {
132
+ parsed = JSON.parse(text)
133
+ } catch (e) {
134
+ return new Response(text, init2)
135
+ }
136
+ const hash = parsed && parsed.data && parsed.data.CipherHash && parsed.data.CipherHash.hash
137
+ if (typeof hash !== 'string') {
138
+ // Plaintext (e.g. a validator-side error response) — pass through unchanged.
139
+ return new Response(text, init2)
140
+ }
141
+ const decrypted = await wallet.decryptMyMessageML768(JSON.parse(hash))
142
+ return new Response(decrypted != null ? decrypted : text, init2)
143
+ }
144
+
57
145
  setAuthData ({ token, pubkey, wallet }) {
58
146
  this.$__authToken = token
59
147
  this.$__pubkey = pubkey
@@ -70,6 +70,24 @@ export default class MutationTransferTokens extends MutationProposeMolecule {
70
70
  this.$__molecule.check(this.$__molecule.sourceWallet)
71
71
  }
72
72
 
73
+ /**
74
+ * Fills the Molecule for a MULTI-recipient transfer (WP line 544)
75
+ *
76
+ * @param {Wallet[]} recipientWallets
77
+ * @param {number[]} amounts - parallel to recipientWallets
78
+ */
79
+ fillMoleculeMulti ({
80
+ recipientWallets,
81
+ amounts
82
+ }) {
83
+ this.$__molecule.initValues({
84
+ recipientWallets,
85
+ amounts
86
+ })
87
+ this.$__molecule.sign({})
88
+ this.$__molecule.check(this.$__molecule.sourceWallet)
89
+ }
90
+
73
91
  /**
74
92
  * Builds a Response object out of a JSON string
75
93
  *