@wishknish/knishio-client-ts 0.8.3 → 0.9.1

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.
Files changed (35) hide show
  1. package/dist/index.cjs +399 -258
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +37 -16
  4. package/dist/index.d.ts +37 -16
  5. package/dist/index.iife.js +3700 -602
  6. package/dist/index.iife.js.map +1 -1
  7. package/dist/index.js +421 -280
  8. package/dist/index.js.map +1 -1
  9. package/package.json +18 -20
  10. package/src/KnishIOClient.ts +135 -53
  11. package/src/constants.ts +1 -2
  12. package/src/core/Molecule.ts +53 -0
  13. package/src/core/Wallet.ts +96 -3
  14. package/src/libraries/Decimal.ts +1 -1
  15. package/src/libraries/GraphQLClient.ts +83 -0
  16. package/src/libraries/Hex.ts +4 -3
  17. package/src/libraries/UrqlClientWrapper.ts +13 -13
  18. package/src/libraries/crypto.ts +4 -4
  19. package/src/mutation/MutationClaimShadowWallet.ts +3 -1
  20. package/src/mutation/MutationTransferTokens.ts +18 -0
  21. package/src/query/QueryActiveSession.ts +0 -1
  22. package/src/query/QueryPolicy.ts +0 -1
  23. package/src/response/ResponseAuthorizationGuest.ts +2 -2
  24. package/src/response/ResponseBalance.ts +9 -6
  25. package/src/response/ResponseLinkIdentifier.ts +1 -1
  26. package/src/response/ResponseRequestAuthorizationGuest.ts +2 -2
  27. package/src/subscribe/Subscribe.ts +3 -2
  28. package/src/types/assertions.ts +1 -6
  29. package/src/types/client.ts +12 -19
  30. package/src/types/guards.ts +2 -3
  31. package/src/validation/UNIVERSAL_CONFIGURATION_INTERFACES.ts +3 -5
  32. package/src/validation/ValidationService.ts +6 -15
  33. package/src/versions/HashAtom.ts +11 -9
  34. package/src/query/QueryUserActivity.ts +0 -153
  35. package/src/response/ResponseQueryUserActivity.ts +0 -89
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishknish/knishio-client-ts",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "productName": "Knish.IO TypeScript SDK Client",
5
5
  "description": "TypeScript implementation of the Knish.IO SDK to consume Knish.IO GraphQL APIs with enhanced type safety.",
6
6
  "license": "GPL-3.0-or-later",
@@ -71,23 +71,19 @@
71
71
  },
72
72
  "devDependencies": {
73
73
  "@types/isomorphic-fetch": "^0.0.39",
74
- "@types/jest": "^30.0.0",
75
74
  "@types/jssha": "^3.0.0",
76
- "@types/node": "^22.8.1",
77
- "@types/pg": "^8.10.0",
78
- "@typescript-eslint/eslint-plugin": "^8.15.0",
79
- "@typescript-eslint/parser": "^8.15.0",
80
- "eslint": "^9.16.0",
81
- "jest": "^30.2.0",
82
- "pg": "^8.11.0",
83
- "prettier": "^3.4.2",
84
- "ts-jest": "^29.4.6",
85
- "tsup": "^8.3.5",
86
- "tsx": "^4.20.5",
87
- "typescript": "^5.6.3",
88
- "vite": "^6.3.5",
89
- "vite-plugin-dts": "^4.2.3",
90
- "vitest": "^2.1.8"
75
+ "@types/node": "^22",
76
+ "@typescript-eslint/eslint-plugin": "^8.63.0",
77
+ "@typescript-eslint/parser": "^8.63.0",
78
+ "@vitest/coverage-v8": "^4.1.10",
79
+ "eslint": "^9",
80
+ "prettier": "^3",
81
+ "tsup": "^8.5.1",
82
+ "tsx": "^4.23.0",
83
+ "typescript": "^5",
84
+ "vite": "^7",
85
+ "vite-plugin-dts": "^5.0.3",
86
+ "vitest": "^4.1.10"
91
87
  },
92
88
  "browserslist": [
93
89
  "> 1%",
@@ -100,10 +96,12 @@
100
96
  "build:test": "tsup src/index.ts --format esm,cjs,iife --tsconfig tsconfig.build.json --clean",
101
97
  "build:dev": "tsup src/index.ts --format esm,cjs --dts --sourcemap --watch",
102
98
  "typecheck": "tsc --noEmit",
103
- "lint": "eslint src --ext .ts,.tsx --fix",
99
+ "lint": "eslint src",
100
+ "lint:fix": "eslint src --fix",
104
101
  "format": "prettier --write \"src/**/*.{ts,tsx}\"",
105
- "test": "vitest",
106
- "test:coverage": "vitest --coverage",
102
+ "test": "vitest run",
103
+ "test:watch": "vitest",
104
+ "test:coverage": "vitest run --coverage",
107
105
  "test:ui": "vitest --ui",
108
106
  "dev": "vite",
109
107
  "prepublishOnly": "npm run build",
@@ -50,7 +50,8 @@ import Decimal from '@/libraries/Decimal'
50
50
  import GraphQLClient from '@/libraries/GraphQLClient'
51
51
  import {
52
52
  generateBundleHash,
53
- generateSecret
53
+ generateSecret,
54
+ generateBatchId
54
55
  } from '@/libraries/crypto'
55
56
  import Molecule from '@/core/Molecule'
56
57
  import Wallet from '@/core/Wallet'
@@ -80,7 +81,6 @@ import QueryBatchHistory from '@/query/QueryBatchHistory'
80
81
  import QueryAtom from '@/query/QueryAtom'
81
82
  import QueryPolicy from '@/query/QueryPolicy'
82
83
  import QueryActiveSession from '@/query/QueryActiveSession'
83
- import QueryUserActivity from '@/query/QueryUserActivity'
84
84
  import QueryToken from '@/query/QueryToken'
85
85
  import QueryMetaTypeViaAtom from '@/query/QueryMetaTypeViaAtom'
86
86
  import QueryMetaTypeViaMolecule from '@/query/QueryMetaTypeViaMolecule'
@@ -121,6 +121,7 @@ import {
121
121
  TransferBalanceException,
122
122
  WalletShadowException,
123
123
  StackableUnitAmountException,
124
+ StackableUnitDecimalsException,
124
125
  AuthorizationRejectedException,
125
126
  MolecularHashMismatchException,
126
127
  SignatureMismatchException,
@@ -137,7 +138,8 @@ import type {
137
138
  MetaId,
138
139
  BatchId,
139
140
  MetaFilter,
140
- RequestPolicy
141
+ RequestPolicy,
142
+ TransferRecipientInput
141
143
  } from '@/types'
142
144
 
143
145
  /**
@@ -801,6 +803,96 @@ export default class KnishIOClient {
801
803
  return await this.executeQuery(query)
802
804
  }
803
805
 
806
+ /**
807
+ * Fund N recipients from a single source in ONE molecule (multi-recipient sibling of
808
+ * transferToken). Each recipient gets its own subset of stackable units (or a fungible
809
+ * amount); a remainder returns the rest to the sender. Conserves:
810
+ * -balance + Σamounts + (balance - Σ) === 0.
811
+ *
812
+ * @param token - token slug
813
+ * @param recipients - destinations (bundleHash + units OR amount + optional batchId)
814
+ * @param sourceWallet - optional pre-resolved source (defaults to the client's own wallet)
815
+ */
816
+ async transferTokens({
817
+ token,
818
+ recipients,
819
+ sourceWallet = null
820
+ }: {
821
+ token: string
822
+ recipients: TransferRecipientInput[]
823
+ sourceWallet?: Wallet | null
824
+ }): Promise<Response | null> {
825
+ // Per-recipient amount: stackable -> unit count; fungible -> explicit amount (never both)
826
+ const amounts: number[] = recipients.map(recipient => {
827
+ const unitCount = recipient.units ? recipient.units.length : 0
828
+ if (unitCount > 0) {
829
+ if (recipient.amount !== null && recipient.amount !== undefined && recipient.amount > 0) {
830
+ throw new StackableUnitAmountException()
831
+ }
832
+ return unitCount
833
+ }
834
+ return recipient.amount ?? 0
835
+ })
836
+
837
+ const total = amounts.reduce((sum, amount) => sum + amount, 0)
838
+
839
+ // Resolve the source (queryBalance now carries tokenUnits, so the split below sees real units)
840
+ if (sourceWallet === null) {
841
+ sourceWallet = await this.querySourceWallet({
842
+ token,
843
+ amount: total
844
+ })
845
+ }
846
+
847
+ if (sourceWallet === null || Decimal.cmp(Number(sourceWallet.balance), total) < 0) {
848
+ throw new TransferBalanceException()
849
+ }
850
+
851
+ // Build a shadow recipient wallet per destination + assign a distinct batch id
852
+ const recipientWallets: Wallet[] = recipients.map(recipient => {
853
+ const recipientWallet = Wallet.create({
854
+ bundle: recipient.bundleHash,
855
+ token
856
+ })
857
+ if (recipient.batchId !== null && recipient.batchId !== undefined) {
858
+ recipientWallet.batchId = recipient.batchId
859
+ } else {
860
+ recipientWallet.initBatchId({ sourceWallet: sourceWallet as Wallet })
861
+ }
862
+ return recipientWallet
863
+ })
864
+
865
+ // Canonical remainder (carries the source's token/characters; reuses the source batch id)
866
+ const remainderWallet = sourceWallet.createRemainder(this.getSecret())
867
+
868
+ // Stackable (NFT): partition the source's tokenUnits across source (SENT union), each
869
+ // recipient (its subset), and remainder (KEPT) BEFORE the molecule is built. No-op for fungible.
870
+ if (recipients.some(recipient => recipient.units && recipient.units.length > 0)) {
871
+ sourceWallet.splitUnitsMulti(
872
+ recipients.map(recipient => recipient.units ?? []),
873
+ recipientWallets,
874
+ remainderWallet
875
+ )
876
+ }
877
+
878
+ const molecule = await this.createMolecule({
879
+ sourceWallet,
880
+ remainderWallet
881
+ })
882
+
883
+ const query = await this.createMoleculeMutation({
884
+ mutationClass: MutationTransferTokens,
885
+ molecule
886
+ })
887
+
888
+ query.fillMoleculeMulti({
889
+ recipientWallets,
890
+ amounts
891
+ })
892
+
893
+ return await this.executeQuery(query)
894
+ }
895
+
804
896
  /**
805
897
  * Request authorization token (guest or profile)
806
898
  *
@@ -1436,51 +1528,6 @@ export default class KnishIOClient {
1436
1528
  }) as Promise<Response>
1437
1529
  }
1438
1530
 
1439
- /**
1440
- * Query user activity
1441
- */
1442
- async queryUserActivity({
1443
- bundleHash,
1444
- metaType,
1445
- metaId,
1446
- ipAddress = null,
1447
- browser = null,
1448
- osCpu = null,
1449
- resolution = null,
1450
- timeZone = null,
1451
- countBy = null,
1452
- interval = null
1453
- }: {
1454
- bundleHash: BundleHash | string
1455
- metaType: MetaType | string
1456
- metaId: MetaId | string
1457
- ipAddress?: string | null
1458
- browser?: string | null
1459
- osCpu?: string | null
1460
- resolution?: string | null
1461
- timeZone?: string | null
1462
- countBy?: string | null
1463
- interval?: string | null
1464
- }): Promise<Response> {
1465
- const query = this.createQuery(QueryUserActivity)
1466
-
1467
- const variables: Record<string, any> = {
1468
- bundleHash,
1469
- metaType,
1470
- metaId
1471
- }
1472
-
1473
- if (ipAddress !== null) variables.ipAddress = ipAddress
1474
- if (browser !== null) variables.browser = browser
1475
- if (osCpu !== null) variables.osCpu = osCpu
1476
- if (resolution !== null) variables.resolution = resolution
1477
- if (timeZone !== null) variables.timeZone = timeZone
1478
- if (countBy !== null) variables.countBy = countBy
1479
- if (interval !== null) variables.interval = interval
1480
-
1481
- return this.executeQuery(query, variables) as Promise<Response>
1482
- }
1483
-
1484
1531
  /**
1485
1532
  * Query token information
1486
1533
  */
@@ -1620,7 +1667,7 @@ export default class KnishIOClient {
1620
1667
  amount = null,
1621
1668
  meta = null,
1622
1669
  batchId = null,
1623
- units: _units = null
1670
+ units = null
1624
1671
  }: {
1625
1672
  token: TokenSlug | string
1626
1673
  amount?: number | string | null
@@ -1630,6 +1677,33 @@ export default class KnishIOClient {
1630
1677
  }): Promise<Response> {
1631
1678
  this.log('info', `KnishIOClient::createToken() - Creating token ${token}...`)
1632
1679
 
1680
+ const tokenMeta: Record<string, any> = meta || {}
1681
+ const fungibility = tokenMeta.fungibility
1682
+ let resolvedAmount: number | string = amount ?? 0
1683
+
1684
+ // For a stackable token, ensure a batch ID is present (mirror JS createToken)
1685
+ if (fungibility === 'stackable') {
1686
+ tokenMeta.batchId = batchId || generateBatchId({})
1687
+ }
1688
+
1689
+ // Token-unit initialization for stackable / nonfungible tokens: the unit IDs ARE the supply.
1690
+ // Meta values are sent as strings (the validator's MetaItemInput.value is a GraphQL String).
1691
+ if (
1692
+ (fungibility === 'stackable' || fungibility === 'nonfungible' || fungibility === 'non-fungible') &&
1693
+ units && units.length > 0
1694
+ ) {
1695
+ if (Number(tokenMeta.decimals ?? 0) > 0) {
1696
+ throw new StackableUnitDecimalsException()
1697
+ }
1698
+ if (Number(amount ?? 0) > 0) {
1699
+ throw new StackableUnitAmountException()
1700
+ }
1701
+ resolvedAmount = units.length
1702
+ tokenMeta.splittable = '1'
1703
+ tokenMeta.decimals = '0'
1704
+ tokenMeta.tokenUnits = JSON.stringify(units)
1705
+ }
1706
+
1633
1707
  // Create the molecule mutation
1634
1708
  const mutation = await this.createMoleculeMutation({ mutationClass: MutationCreateToken })
1635
1709
 
@@ -1644,8 +1718,8 @@ export default class KnishIOClient {
1644
1718
  // Initialize the create token mutation
1645
1719
  await mutation.fillMolecule({
1646
1720
  recipientWallet,
1647
- amount: amount ?? 0,
1648
- meta
1721
+ amount: resolvedAmount,
1722
+ meta: tokenMeta
1649
1723
  })
1650
1724
 
1651
1725
  // Execute the mutation
@@ -2333,9 +2407,17 @@ export default class KnishIOClient {
2333
2407
  molecule
2334
2408
  })
2335
2409
 
2336
- // Initialize the profile auth mutation
2410
+ // Initialize the profile auth mutation.
2411
+ // PQ-transport Phase E: convey the AUTH source wallet's ML-KEM768 public key as a SIGNED
2412
+ // walletPubkey U-atom meta, so the validator can encrypt CipherHash responses back to THIS
2413
+ // wallet (the one that decrypts them). Signed → MITM can't swap the target. Conveyed
2414
+ // unconditionally (parity with JS/Kotlin/PHP), so even a plaintext auth primes enc_pubkey.
2415
+ const authMeta: Record<string, string> = { encrypt: encrypt ? 'true' : 'false' }
2416
+ if (wallet.pubkey) {
2417
+ authMeta.walletPubkey = wallet.pubkey
2418
+ }
2337
2419
  mutation.fillMolecule({
2338
- meta: { encrypt: encrypt ? 'true' : 'false' }
2420
+ meta: authMeta
2339
2421
  })
2340
2422
 
2341
2423
  // Execute the mutation
package/src/constants.ts CHANGED
@@ -231,8 +231,7 @@ export const QUERY_TYPES = [
231
231
  'Batch',
232
232
  'ActiveSession',
233
233
  'Policy',
234
- 'Token',
235
- 'UserActivity'
234
+ 'Token'
236
235
  ] as const
237
236
 
238
237
  export type QueryType = typeof QUERY_TYPES[number]
@@ -319,6 +319,59 @@ export default class Molecule {
319
319
  return this
320
320
  }
321
321
 
322
+ /**
323
+ * Initialize a MULTI-recipient V-type molecule: one source debits its FULL balance to fund
324
+ * N recipients (each its own amount + stackable units) plus a remainder back to the sender.
325
+ * Multi-recipient sibling of initValue (WP line 544: fund multiple recipients with one
326
+ * transaction). recipientWallets is parallel to amounts.
327
+ */
328
+ initValues({
329
+ recipientWallets,
330
+ amounts
331
+ }: {
332
+ recipientWallets: Wallet[]
333
+ amounts: number[]
334
+ }): Molecule {
335
+ if (!this.sourceWallet) {
336
+ throw new Error('Source wallet required for value transfer')
337
+ }
338
+
339
+ const total = amounts.reduce((sum, amount) => sum + amount, 0)
340
+
341
+ if (Number(this.sourceWallet.balance) - total < 0) {
342
+ throw new BalanceInsufficientException()
343
+ }
344
+
345
+ // Source atom: debit the ENTIRE balance (UTXO drain); carries the SENT union of token units
346
+ this.addAtom(Atom.create({
347
+ isotope: 'V',
348
+ wallet: this.sourceWallet,
349
+ value: -Number(this.sourceWallet.balance)
350
+ }))
351
+
352
+ // One atom per recipient: +amount_i, walletBundle -> recipient bundle, its own SENT units
353
+ recipientWallets.forEach((recipientWallet, i) => {
354
+ this.addAtom(Atom.create({
355
+ isotope: 'V',
356
+ wallet: recipientWallet,
357
+ value: amounts[i]!,
358
+ metaType: 'walletBundle',
359
+ metaId: recipientWallet.bundle!
360
+ }))
361
+ })
362
+
363
+ // Remainder atom: +(balance - total), walletBundle -> sender bundle, KEPT units
364
+ this.addAtom(Atom.create({
365
+ isotope: 'V',
366
+ wallet: this.remainderWallet!,
367
+ value: Number(this.sourceWallet.balance) - total,
368
+ metaType: 'walletBundle',
369
+ metaId: this.remainderWallet!.bundle!
370
+ }))
371
+
372
+ return this
373
+ }
374
+
322
375
  /**
323
376
  * Sign the molecule with one-time signature
324
377
  * Matches JavaScript SDK sign method
@@ -349,6 +349,42 @@ export default class Wallet {
349
349
  remainderWallet.tokenUnits = remainderTokenUnits
350
350
  }
351
351
 
352
+ /**
353
+ * Split token units across MULTIPLE recipients (N-way sibling of splitUnits).
354
+ *
355
+ * The source retains the SENT union (all units leaving), each recipient gets its own
356
+ * subset, and the remainder keeps the KEPT units (those not assigned to any recipient).
357
+ * recipientUnitLists is parallel to recipientWallets. No-op when no units are sent.
358
+ *
359
+ * @param recipientUnitLists - per-recipient arrays of token unit IDs (parallel to recipientWallets)
360
+ * @param recipientWallets - destination wallets
361
+ * @param remainderWallet - wallet to receive the KEPT units
362
+ */
363
+ splitUnitsMulti(
364
+ recipientUnitLists: string[][],
365
+ recipientWallets: Wallet[],
366
+ remainderWallet: Wallet
367
+ ): void {
368
+ const sentIds = new Set<string>(recipientUnitLists.flat())
369
+
370
+ // Nothing to split (fungible transfer) — leave token units untouched
371
+ if (sentIds.size === 0) {
372
+ return
373
+ }
374
+
375
+ // Each recipient gets its own subset of the source's token units
376
+ recipientWallets.forEach((recipientWallet, i) => {
377
+ const ids = recipientUnitLists[i] ?? []
378
+ recipientWallet.tokenUnits = this.tokenUnits.filter(tokenUnit => ids.includes(tokenUnit.id))
379
+ })
380
+
381
+ // The remainder keeps everything not sent to any recipient (KEPT)
382
+ remainderWallet.tokenUnits = this.tokenUnits.filter(tokenUnit => !sentIds.has(tokenUnit.id))
383
+
384
+ // The source carries the SENT union (the ownership authority the validator reads)
385
+ this.tokenUnits = this.tokenUnits.filter(tokenUnit => sentIds.has(tokenUnit.id))
386
+ }
387
+
352
388
  /**
353
389
  * Get token units data
354
390
  * Stub for compatibility
@@ -401,6 +437,18 @@ export default class Wallet {
401
437
  const messageString = JSON.stringify(message)
402
438
  const messageUint8 = new TextEncoder().encode(messageString)
403
439
  const deserializedPubkey = this.deserializeKey(recipientPubkey)
440
+ // ML-KEM-768 public keys are exactly 1184 bytes. A wrong-length key here almost always means the
441
+ // node did not advertise an ML-KEM public key in its auth `key` field (e.g. a validator predating
442
+ // the PQ-transport build). Fail with an actionable message rather than @noble's cryptic
443
+ // `"publicKey" expected Uint8Array of length 1184, got length=N` assertion.
444
+ const ML_KEM_768_PUBLIC_KEY_BYTES = 1184
445
+ if (deserializedPubkey.length !== ML_KEM_768_PUBLIC_KEY_BYTES) {
446
+ throw new Error(
447
+ `KnishIO: cannot ML-KEM-encrypt — recipient public key is ${deserializedPubkey.length} bytes, ` +
448
+ `expected ${ML_KEM_768_PUBLIC_KEY_BYTES} (ML-KEM-768). The node likely did not advertise an ML-KEM ` +
449
+ `public key (upgrade the validator to a PQ-transport build), or authenticate with { encrypt: false }.`
450
+ )
451
+ }
404
452
  const { cipherText, sharedSecret } = ml_kem768.encapsulate(deserializedPubkey)
405
453
  const encryptedMessage = await this.encryptWithSharedSecret(messageUint8, sharedSecret)
406
454
  return {
@@ -410,6 +458,16 @@ export default class Wallet {
410
458
  }
411
459
 
412
460
  async decryptMessage(encryptedData: { cipherText: string; encryptedMessage: string }): Promise<any> {
461
+ const decryptedString = await this._mlkemDecryptToString(encryptedData)
462
+ return decryptedString === null ? null : JSON.parse(decryptedString)
463
+ }
464
+
465
+ /**
466
+ * ML-KEM768 decapsulate + AES-256-GCM decrypt → the RAW decrypted UTF-8 string (no JSON.parse).
467
+ * Shared by {@link decryptMessage} (which JSON.parses the result) and the PQ CipherHash transport
468
+ * ({@link decryptMyMessageML768}, which needs the raw response JSON text). PQ-transport Phase E.
469
+ */
470
+ async _mlkemDecryptToString(encryptedData: { cipherText: string; encryptedMessage: string }): Promise<string | null> {
413
471
  const { cipherText, encryptedMessage } = encryptedData
414
472
  let sharedSecret
415
473
  try {
@@ -438,9 +496,8 @@ export default class Wallet {
438
496
  console.info('Wallet::decryptMessage() - deserialized encrypted message', deserializedEncryptedMessage)
439
497
  return null
440
498
  }
441
- let decryptedString
442
499
  try {
443
- decryptedString = new TextDecoder().decode(decryptedUint8)
500
+ return new TextDecoder().decode(decryptedUint8)
444
501
  } catch (e) {
445
502
  console.warn('Wallet::decryptMessage() - Decoding failed', e)
446
503
  console.info('Wallet::decryptMessage() - my public key', this.pubkey)
@@ -449,7 +506,43 @@ export default class Wallet {
449
506
  console.info('Wallet::decryptMessage() - decrypted Uint8Array', decryptedUint8)
450
507
  return null
451
508
  }
452
- return JSON.parse(decryptedString)
509
+ }
510
+
511
+ /**
512
+ * Canonical cross-SDK hashShare for a public key: standard base64 of SHAKE256(pubkey_utf8, 8 bytes)
513
+ * — byte-matches the validator's hash_share and the JS/Kotlin/PHP hashShare. `shake256(pubkey, 64)`
514
+ * = 64 bits = 8 bytes (hex) → hex-decode → standard base64 via serializeKey. PQ-transport Phase E.
515
+ */
516
+ hashShare(pubkey: string): string {
517
+ const hex = shake256(pubkey, 64)
518
+ const bytes = new Uint8Array(hex.length / 2)
519
+ for (let i = 0; i < bytes.length; i++) {
520
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16)
521
+ }
522
+ return this.serializeKey(bytes)
523
+ }
524
+
525
+ /**
526
+ * Post-quantum (ML-KEM768) CipherHash request envelope: a stringified single-recipient map
527
+ * `{ "<hashShare(recipientPubkey)>": {cipherText, encryptedMessage} }` (object-valued, via
528
+ * {@link encryptMessage}). Matches the Rust validator's CipherHash handler. PQ-transport Phase E.
529
+ */
530
+ async encryptStringML768(message: any, recipientPubkey: string): Promise<string> {
531
+ const envelope = await this.encryptMessage(message, recipientPubkey)
532
+ return JSON.stringify({ [this.hashShare(recipientPubkey)]: envelope })
533
+ }
534
+
535
+ /**
536
+ * Decrypt a CipherHash response map addressed to THIS wallet's ML-KEM pubkey
537
+ * (`hashShare(this.pubkey)`) → the RAW decrypted GraphQL response JSON text (NOT JSON.parsed;
538
+ * it replaces the HTTP response body for the normal parser). `null` if no entry / decrypt fails.
539
+ */
540
+ async decryptMyMessageML768(map: Record<string, { cipherText: string; encryptedMessage: string }>): Promise<string | null> {
541
+ const envelope = map[this.hashShare(this.pubkey)]
542
+ if (!envelope) {
543
+ return null
544
+ }
545
+ return this._mlkemDecryptToString(envelope)
453
546
  }
454
547
 
455
548
  // =============================================================================
@@ -72,7 +72,7 @@ export default class Decimal {
72
72
  * @param debug Enable debug logging
73
73
  * @returns 0 if equal, 1 if value1 > value2, -1 if value1 < value2
74
74
  */
75
- static cmp(value1: number, value2: number, debug: boolean = false): number {
75
+ static cmp(value1: number, value2: number, _debug: boolean = false): number {
76
76
  const val1 = Decimal.val(value1) * MULTIPLIER
77
77
  const val2 = Decimal.val(value2) * MULTIPLIER
78
78
 
@@ -62,6 +62,22 @@ import type {
62
62
  } from '@/types'
63
63
  import type Wallet from '@/core/Wallet'
64
64
 
65
+ const CIPHER_HASH_QUERY = 'query ( $Hash: String! ) { CipherHash ( Hash: $Hash ) { hash } }'
66
+
67
+ /**
68
+ * Light parse of a GraphQL request body's operation type + root field name, for the CipherHash
69
+ * bypass decision (no full GraphQL parse needed). Operation type = the first query/mutation/
70
+ * subscription keyword (default 'query' for an anonymous `{ ... }`); root field = the first
71
+ * identifier inside the top-level selection set.
72
+ */
73
+ function parseOperation(query: string): { type: string; name: string } {
74
+ const typeMatch = (query || '').match(/\b(query|mutation|subscription)\b/i)
75
+ const type = typeMatch?.[1]?.toLowerCase() ?? 'query'
76
+ const braceIdx = (query || '').indexOf('{')
77
+ const nameMatch = braceIdx >= 0 ? query.slice(braceIdx + 1).match(/[A-Za-z_][A-Za-z0-9_]*/) : null
78
+ return { type, name: nameMatch?.[0] ?? '' }
79
+ }
80
+
65
81
  /**
66
82
  * GraphQL client wrapper for KnishIO SDK
67
83
  * Provides a unified interface for GraphQL operations
@@ -125,6 +141,10 @@ export default class GraphQLClient implements IGraphQLClient {
125
141
  return createClient({
126
142
  url: serverUri,
127
143
  exchanges,
144
+ // PQ-transport Phase E: when encryption is on, route fetch through the CipherHash wrapper
145
+ // (encrypt the request body to the validator's ML-KEM pubkey, decrypt the response).
146
+ // Omitted → urql uses the global fetch (plaintext).
147
+ ...(this.cipherLink ? { fetch: ((input: any, init: any) => this.cipherFetch(input, init)) as typeof fetch } : {}),
128
148
  fetchOptions: () => ({
129
149
  headers: {
130
150
  'X-Auth-Token': this.$__authToken
@@ -135,6 +155,69 @@ export default class GraphQLClient implements IGraphQLClient {
135
155
  })
136
156
  }
137
157
 
158
+ /**
159
+ * Whether an outgoing GraphQL request body should be wrapped in CipherHash. Bypass (plaintext):
160
+ * introspection `__schema`, `ContinuId`, the `AccessToken` mutation, and the U-isotope
161
+ * `ProposeMolecule` (auth bootstrap — the key exchange itself can't be encrypted). Mirrors the
162
+ * Kotlin/PHP/validator bypass set.
163
+ */
164
+ private shouldEncrypt(body: string): boolean {
165
+ let parsed: any
166
+ try {
167
+ parsed = JSON.parse(body)
168
+ } catch (e) {
169
+ return false
170
+ }
171
+ const { type, name } = parseOperation(parsed.query)
172
+ if (type === 'query' && (name === '__schema' || name === 'ContinuId')) return false
173
+ if (type === 'mutation' && name === 'AccessToken') return false
174
+ if (type === 'mutation' && name === 'ProposeMolecule') {
175
+ const isotope = parsed.variables?.molecule?.atoms?.[0]?.isotope
176
+ if (isotope === 'U') return false
177
+ }
178
+ return true
179
+ }
180
+
181
+ /**
182
+ * Custom `fetch` that wraps a GraphQL request in the ML-KEM CipherHash envelope and decrypts the
183
+ * response (PQ-transport Phase E). Operates on the raw POST body (mirrors the JS/Kotlin transform).
184
+ * Reads the CURRENT client wallet + validator pubkey from auth.
185
+ */
186
+ private async cipherFetch(input: any, init: any): Promise<Response> {
187
+ const wallet = this.$__wallet
188
+ const serverPubkey = this.$__pubkey
189
+ let encryptedRequest = false
190
+ let requestInit = init
191
+
192
+ if (wallet && serverPubkey && init && typeof init.body === 'string' && this.shouldEncrypt(init.body)) {
193
+ const hashVar = await wallet.encryptStringML768(init.body, serverPubkey)
194
+ requestInit = { ...init, body: JSON.stringify({ query: CIPHER_HASH_QUERY, variables: { Hash: hashVar } }) }
195
+ encryptedRequest = true
196
+ }
197
+
198
+ const response = await fetch(input, requestInit)
199
+ if (!encryptedRequest) {
200
+ return response
201
+ }
202
+
203
+ // Decrypt the CipherHash response back to the inner GraphQL response JSON.
204
+ const text = await response.text()
205
+ const init2 = { status: response.status, statusText: response.statusText, headers: response.headers }
206
+ let parsed: any
207
+ try {
208
+ parsed = JSON.parse(text)
209
+ } catch (e) {
210
+ return new Response(text, init2)
211
+ }
212
+ const hash = parsed?.data?.CipherHash?.hash
213
+ if (typeof hash !== 'string') {
214
+ // Plaintext (e.g. a validator-side error response) — pass through unchanged.
215
+ return new Response(text, init2)
216
+ }
217
+ const decrypted = await wallet!.decryptMyMessageML768(JSON.parse(hash))
218
+ return new Response(decrypted != null ? decrypted : text, init2)
219
+ }
220
+
138
221
  setAuthData({
139
222
  token,
140
223
  pubkey,
@@ -72,7 +72,8 @@ export default class Hex {
72
72
  ? ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']
73
73
  : ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']
74
74
 
75
- return set[Math.floor(val / 16)] + set[val % 16]
75
+ // indices are provably in-range (val is a byte; /16 and %16 are 0..15 for a 16-elem set)
76
+ return set[Math.floor(val / 16)]! + set[val % 16]!
76
77
  }
77
78
 
78
79
  const opts: Required<HexOptions> = Object.assign(
@@ -97,7 +98,7 @@ export default class Hex {
97
98
  let column = 0
98
99
 
99
100
  for (let i = 0; i < arrayLike.length; ++i) {
100
- str += numberToHex(arrayLike[i], opts.uppercase)
101
+ str += numberToHex(arrayLike[i]!, opts.uppercase)
101
102
 
102
103
  if (i === arrayLike.length - 1) {
103
104
  break
@@ -135,7 +136,7 @@ export default class Hex {
135
136
  let curr = -1
136
137
 
137
138
  for (let i = 0; i < target.length; ++i) {
138
- const c = target[i]
139
+ const c = target[i]!
139
140
  const val = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'].indexOf(c)
140
141
 
141
142
  if (val === -1) {