@orbinum/sdk 2.0.0 → 3.0.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/dist/index.mjs CHANGED
@@ -15,10 +15,11 @@ import {
15
15
  LEAVES_PER_TREE,
16
16
  MAX_WORKERS,
17
17
  NoteBuilder,
18
- OVK_BLOB_SIZE,
18
+ OUTGOING_EPH_WINDOW,
19
19
  PAIRWISE_EPH_WINDOW,
20
20
  SELF_EPH_WINDOW,
21
21
  WORKER_CRASHED,
22
+ assertSecretKeyBytes,
22
23
  base64UrlDecode,
23
24
  base64UrlEncode,
24
25
  bigintTo32Be,
@@ -40,18 +41,22 @@ import {
40
41
  createWorkerPool,
41
42
  decodeNoteDisclosureKey,
42
43
  decryptHintBatch,
43
- deriveOutgoingCipherKey,
44
- deriveOutgoingViewingKey,
44
+ deriveOutgoingEphPk,
45
+ deriveOutgoingEphSk,
46
+ deriveOutgoingSharedSecret,
47
+ deriveOutgoingViewingKeyV3,
45
48
  deriveOwnerPk,
46
49
  derivePairwiseEphSk,
47
50
  derivePairwiseSharedSecret,
48
51
  deriveSelfEphSk,
49
52
  deriveSpendingKeyFromMaster,
53
+ deriveSpendingKeyV3,
50
54
  deriveStealthOwnerPk,
51
55
  deriveStealthSk,
52
56
  deriveViewTag,
53
57
  deriveViewingPublicKey,
54
58
  deriveViewingSecretKey,
59
+ deriveViewingSecretKeyV3,
55
60
  ensureHexPrefix,
56
61
  fastMulBase,
57
62
  fastMulPoint,
@@ -62,15 +67,20 @@ import {
62
67
  isAbortError,
63
68
  isHexOfLength,
64
69
  isSpendable,
70
+ isUsableSecretKey,
65
71
  isValidLeafIndex,
66
72
  leHexToBigint,
67
- openOutgoingBlob,
73
+ openRecipientBookEntry,
74
+ outgoingEphWindow,
68
75
  pairwiseEphWindow,
69
- randomOutgoingBlob,
76
+ randomBlinding,
77
+ reconstructOutgoingIndex,
70
78
  recoverOwnerPkPoint,
79
+ recoverSentFromSharedSecret,
80
+ recoverSentNote,
71
81
  scalarToHex,
72
82
  scanAbortError,
73
- sealOutgoingBlob,
83
+ sealRecipientBookEntry,
74
84
  selectNotes,
75
85
  selfEphWindow,
76
86
  serializeMemo,
@@ -78,8 +88,8 @@ import {
78
88
  treeIdOf,
79
89
  tryDecryptNote,
80
90
  tryDecryptNoteVerbose,
81
- tryRecoverOutgoing
82
- } from "./chunk-VYKKBXOE.mjs";
91
+ unpackUsableViewingKey
92
+ } from "./chunk-54HFH63J.mjs";
83
93
 
84
94
  // src/foundation/encoding/base64.ts
85
95
  var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@@ -119,14 +129,6 @@ function fromBase64(b64) {
119
129
  return new Uint8Array(bytes);
120
130
  }
121
131
 
122
- // src/foundation/crypto/blinding.ts
123
- function randomBlinding() {
124
- const buf = new Uint8Array(32);
125
- crypto.getRandomValues(buf);
126
- const reduced = bytesToBigintLE(buf) % BN254_R;
127
- return reduced === 0n ? 1n : reduced;
128
- }
129
-
130
132
  // src/foundation/text/format.ts
131
133
  var LOCALE_DECIMAL_SEP = new Intl.NumberFormat(void 0).formatToParts(1.1).find((p) => p.type === "decimal")?.value ?? ".";
132
134
  function formatIntegerLocale(intPart) {
@@ -234,9 +236,6 @@ function parseEvmAddress(addr) {
234
236
  const withPrefix = addr.startsWith("0x") ? addr : `0x${addr}`;
235
237
  return isEvmAddress(withPrefix) ? withPrefix.toLowerCase() : null;
236
238
  }
237
- function isSs58(addr) {
238
- return !addr.startsWith("0x") && addr.length >= 46 && addr.length <= 50;
239
- }
240
239
  function isEvmAddress(addr) {
241
240
  return /^0x[0-9a-fA-F]{40}$/.test(addr);
242
241
  }
@@ -366,11 +365,10 @@ function addressToAccountIdHex(addr) {
366
365
  function addressToFieldElement(address) {
367
366
  const accountIdHex = addressToAccountIdHex(address);
368
367
  if (!accountIdHex) throw new Error(`Cannot resolve address to AccountId32: ${address}`);
369
- const hex = accountIdHex.slice(2);
370
- const bytes = new Uint8Array(32);
371
- for (let i = 0; i < 32; i++) {
372
- bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
368
+ if (!isHexOfLength(accountIdHex, 32)) {
369
+ throw new Error(`AccountId32 must be 32 bytes of hex: ${accountIdHex}`);
373
370
  }
371
+ const bytes = fromHex(accountIdHex);
374
372
  return bytesToBigintLE(bytes) % BN254_R;
375
373
  }
376
374
 
@@ -379,7 +377,7 @@ import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
379
377
  import { randomBytes } from "@noble/ciphers/utils.js";
380
378
  import { hkdf } from "@noble/hashes/hkdf.js";
381
379
  import { sha256 } from "@noble/hashes/sha2.js";
382
- import { packPoint, unpackPoint } from "@zk-kit/baby-jubjub";
380
+ import { packPoint } from "@zk-kit/baby-jubjub";
383
381
  var SLIP_DOMAIN = new TextEncoder().encode("orbinum-payment-slip-v1");
384
382
  var NONCE_PREFIX = new TextEncoder().encode("SLP1");
385
383
  var EPH_PK_SIZE = 32;
@@ -400,7 +398,7 @@ function sealPaymentSlip(recipientIvkPacked, fields) {
400
398
  `PaymentSlip: recipientIvk must be 32 bytes, got ${recipientIvkPacked.length}`
401
399
  );
402
400
  }
403
- const ivkPoint = unpackPoint(bytesToBigintLE(recipientIvkPacked));
401
+ const ivkPoint = unpackUsableViewingKey(bytesToBigintLE(recipientIvkPacked));
404
402
  if (!ivkPoint) throw new Error("PaymentSlip: invalid recipient viewing public key");
405
403
  const ephSkScalar = bytesToBjjScalar(randomBytes(32));
406
404
  const ephPkPacked = bigintTo32Le(packPoint(fastMulBase(ephSkScalar)));
@@ -422,7 +420,7 @@ function openPaymentSlip(recipientIvsk, envelope) {
422
420
  if (envelope.length > MAX_SLIP_ENVELOPE_SIZE) return null;
423
421
  try {
424
422
  const ephPkPacked = envelope.subarray(0, EPH_PK_SIZE);
425
- const ephPkPoint = unpackPoint(bytesToBigintLE(ephPkPacked));
423
+ const ephPkPoint = unpackUsableViewingKey(bytesToBigintLE(ephPkPacked));
426
424
  if (!ephPkPoint) return null;
427
425
  const ivskScalar = bytesToBjjScalar(recipientIvsk);
428
426
  const sharedSecret = bigintTo32Le(fastMulPoint(ephPkPoint, ivskScalar)[0]);
@@ -466,14 +464,7 @@ function decodePaymentSlip(text) {
466
464
  const checksum = rest.slice(sep + 1);
467
465
  if (slipChecksum(payload) !== checksum) return null;
468
466
  try {
469
- const hex = base64UrlDecode(payload);
470
- const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
471
- if (clean.length % 2 !== 0) return null;
472
- const bytes = new Uint8Array(clean.length / 2);
473
- for (let i = 0; i < bytes.length; i++) {
474
- bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
475
- }
476
- return bytes;
467
+ return fromHex(base64UrlDecode(payload));
477
468
  } catch {
478
469
  return null;
479
470
  }
@@ -578,8 +569,17 @@ ${canonicalAccountId(address)}`;
578
569
 
579
570
  // src/protocol/keys/PrivacyKeyManager.ts
580
571
  import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
581
- function privacyAddressChecksum(ownerPkHex, ivkHex) {
582
- const body = `orbpriv2:${ownerPkHex}:${ivkHex}`;
572
+ var SCHEME_BY_VERSION = { v2: "orbpriv2", v3: "orbpriv3" };
573
+ function usableAddressFields(ownerPkHex, ivkHex) {
574
+ const ownerPk = ownerPkHex.toLowerCase();
575
+ const ivk = ivkHex.toLowerCase();
576
+ if (!isHexOfLength(ownerPk, 32) || !isHexOfLength(ivk, 32)) return null;
577
+ if (/^0x0+$/.test(ownerPk) || /^0x0+$/.test(ivk)) return null;
578
+ if (!unpackUsableViewingKey(bytesToBigintLE(fromHex(ivk)))) return null;
579
+ return { ownerPkHex: ownerPk, viewingPublicKeyHex: ivk };
580
+ }
581
+ function privacyAddressChecksum(scheme, ownerPkHex, ivkHex) {
582
+ const body = `${scheme}:${ownerPkHex}:${ivkHex}`;
583
583
  const digest = sha2562(new TextEncoder().encode(body));
584
584
  return toHex(digest.slice(0, 4)).slice(2);
585
585
  }
@@ -589,30 +589,29 @@ var PrivacyKeyManager = class {
589
589
  masterBytes: null,
590
590
  viewingSecretKey: null,
591
591
  viewingPublicKeyPacked: null,
592
- ownerPk: null,
593
- outgoingViewingKey: null
592
+ ownerPk: null
594
593
  };
595
594
  /**
596
595
  * Load a spending key and its corresponding master bytes into the in-memory session.
597
596
  * Derives viewingSecretKey, viewingPublicKeyPacked, and ownerPk immediately.
598
597
  * Replaces any previously loaded key.
599
598
  *
600
- * @param spendingKey Circuit scalar: BigInt(masterBytes) % BABYJUB_SUBORDER, clamped to [1, ∞).
601
- * @param masterBytes Raw 32-byte HKDF output before modular reduction. Used to derive
602
- * the stable vault key (HKDF(masterBytes, info="orbinum-vault-key-v1")).
599
+ * @param _legacySpendingKey IGNORED. Kept so v2-era callers still compile;
600
+ * the scalar is re-derived from `masterBytes`.
601
+ * @param masterBytes Raw 32-byte HKDF output. The root every v3 branch and
602
+ * the stable vault key hang off.
603
603
  */
604
- async load(spendingKey, masterBytes) {
605
- const viewingSecretKey = deriveViewingSecretKey(spendingKey);
604
+ async load(_legacySpendingKey, masterBytes) {
605
+ const spendingKey = deriveSpendingKeyV3(masterBytes);
606
+ const viewingSecretKey = deriveViewingSecretKeyV3(masterBytes);
606
607
  const viewingPublicKeyPacked = deriveViewingPublicKey(viewingSecretKey);
607
608
  const ownerPk = deriveOwnerPk(spendingKey);
608
- const outgoingViewingKey = deriveOutgoingViewingKey(masterBytes);
609
609
  this._state = {
610
610
  spendingKey,
611
611
  masterBytes,
612
612
  viewingSecretKey,
613
613
  viewingPublicKeyPacked,
614
- ownerPk,
615
- outgoingViewingKey
614
+ ownerPk
616
615
  };
617
616
  }
618
617
  /** Clear all key material from memory. Call on vault lock / sign-out. */
@@ -622,8 +621,7 @@ var PrivacyKeyManager = class {
622
621
  masterBytes: null,
623
622
  viewingSecretKey: null,
624
623
  viewingPublicKeyPacked: null,
625
- ownerPk: null,
626
- outgoingViewingKey: null
624
+ ownerPk: null
627
625
  };
628
626
  }
629
627
  /** Returns true if a spending key has been loaded. */
@@ -661,6 +659,24 @@ var PrivacyKeyManager = class {
661
659
  }
662
660
  return this._state.viewingPublicKeyPacked;
663
661
  }
662
+ /**
663
+ * The OUTGOING viewing key (ovk) — reads what this wallet SENT.
664
+ *
665
+ * Seeds the outgoing ephemeral sequence and opens the recipient book, so a
666
+ * holder can enumerate every payment the wallet made and name who received
667
+ * it. That makes it strictly more sensitive than the incoming viewing key,
668
+ * which yields amounts and no counterparties: hand it out only where the
669
+ * payment graph is meant to travel with it.
670
+ *
671
+ * A scan without it still finds every note the wallet OWNS and simply
672
+ * reconstructs no sent history — the watch-only case.
673
+ *
674
+ * SECURITY: never expose this in an address or a network request.
675
+ * Throws if not loaded.
676
+ */
677
+ getOutgoingViewingKey() {
678
+ return deriveOutgoingViewingKeyV3(this.getMasterBytes());
679
+ }
664
680
  /** Returns the BabyJubJub owner public key (x-coordinate). Throws if not loaded. */
665
681
  getOwnerPk() {
666
682
  if (this._state.ownerPk === null) {
@@ -668,16 +684,6 @@ var PrivacyKeyManager = class {
668
684
  }
669
685
  return this._state.ownerPk;
670
686
  }
671
- /**
672
- * Returns the 32-byte outgoing viewing key (ovk). Throws if not loaded.
673
- * Used to seal/open the outgoing blob that lets the sender recover a transfer.
674
- */
675
- getOutgoingViewingKey() {
676
- if (this._state.outgoingViewingKey === null) {
677
- throw new Error("PrivacyKeyManager: no key loaded. Call load() first.");
678
- }
679
- return this._state.outgoingViewingKey;
680
- }
681
687
  /** Returns the spending key as a 32-byte little-endian Uint8Array. Throws if not loaded. */
682
688
  getSpendingKeyBytes() {
683
689
  return bigintTo32Le(this.getSpendingKey());
@@ -706,7 +712,7 @@ var PrivacyKeyManager = class {
706
712
  * Exports a shareable privacy address encoding the owner public key and
707
713
  * viewing PUBLIC key of the currently loaded identity.
708
714
  *
709
- * Format: `orbpriv2:{ownerPk_hex}:{viewingPublicKey_hex}:{checksum}`
715
+ * Format: `orbpriv3:{ownerPk_hex}:{viewingPublicKey_hex}:{checksum}`
710
716
  *
711
717
  * The recipient uses this address so the sender can:
712
718
  * 1. Embed `ownerPk` in the note commitment (Poseidon4 input).
@@ -721,44 +727,64 @@ var PrivacyKeyManager = class {
721
727
  *
722
728
  * Throws if no key is loaded.
723
729
  */
724
- encodePrivacyAddress() {
730
+ encodePrivacyAddress(version = "v3") {
725
731
  const ownerPk = this.getOwnerPk();
726
732
  const ivkPacked = this.getViewingPublicKeyPacked();
727
733
  const ownerPkHex = scalarToHex(ownerPk);
728
734
  const ivkHex = toHex(ivkPacked);
729
- const checksum = privacyAddressChecksum(ownerPkHex, ivkHex);
730
- return `orbpriv2:${ownerPkHex}:${ivkHex}:${checksum}`;
735
+ const scheme = SCHEME_BY_VERSION[version];
736
+ const checksum = privacyAddressChecksum(scheme, ownerPkHex, ivkHex);
737
+ return `${scheme}:${ownerPkHex}:${ivkHex}:${checksum}`;
731
738
  }
732
739
  /**
733
740
  * Decode a privacy address into `{ ownerPkHex, viewingPublicKeyHex }`, or
734
741
  * `null` if it does not parse.
735
742
  *
736
- * Accepts both:
737
- * - `orbpriv2:{ownerPk}:{ivk}:{checksum}` — checksum verified; a mismatch
738
- * (corrupted paste) returns null.
739
- * - `orbpriv1:{ownerPk}:{ivk}` — legacy, no checksum. Still read so addresses
740
- * shared before v2 keep resolving; only v2 is emitted.
743
+ * Reads every scheme ever emitted; only `orbpriv3` is written.
744
+ *
745
+ * - `orbpriv3` / `orbpriv2` — checksum verified; a mismatch (corrupted
746
+ * paste, or a relabelled prefix) returns null.
747
+ * - `orbpriv1` legacy, predates the checksum entirely. The FIELDS are
748
+ * validated exactly as above, since this branch has no integrity check
749
+ * of its own.
750
+ *
751
+ * Reading an old address is not the same as supporting an old identity. An
752
+ * address is an owner key and a viewing key; paying one works whatever
753
+ * scheme derived it, and whoever shares it may not have upgraded yet.
754
+ * Refusing them here would break incoming payments for no gain.
741
755
  */
742
756
  static decodePrivacyAddress(address) {
743
757
  const parts = address.split(":");
744
- if (parts[0] === "orbpriv2") {
758
+ for (const version of ["v2", "v3"]) {
759
+ if (parts[0] !== SCHEME_BY_VERSION[version]) continue;
745
760
  if (parts.length !== 4) return null;
746
761
  const [, ownerPkHex, viewingPublicKeyHex, checksum] = parts;
747
762
  if (!ownerPkHex || !viewingPublicKeyHex || !checksum) return null;
748
- if (privacyAddressChecksum(ownerPkHex, viewingPublicKeyHex) !== checksum) return null;
749
- return { ownerPkHex, viewingPublicKeyHex };
763
+ if (privacyAddressChecksum(
764
+ SCHEME_BY_VERSION[version],
765
+ ownerPkHex,
766
+ viewingPublicKeyHex
767
+ ) !== checksum) {
768
+ return null;
769
+ }
770
+ const fields = usableAddressFields(ownerPkHex, viewingPublicKeyHex);
771
+ return fields ? { ...fields, scheme: SCHEME_BY_VERSION[version] } : null;
750
772
  }
751
773
  if (parts[0] === "orbpriv1") {
752
774
  if (parts.length !== 3) return null;
753
775
  const [, ownerPkHex, viewingPublicKeyHex] = parts;
754
776
  if (!ownerPkHex || !viewingPublicKeyHex) return null;
755
- return { ownerPkHex, viewingPublicKeyHex };
777
+ const fields = usableAddressFields(ownerPkHex, viewingPublicKeyHex);
778
+ return fields ? { ...fields, scheme: "orbpriv1" } : null;
756
779
  }
757
780
  return null;
758
781
  }
759
782
  /**
760
783
  * Load keys from a cached "mk:0x{masterBytes_hex}" string produced by exportHex().
761
- * Throws if the format is invalid or masterBytes length is not 32 bytes.
784
+ *
785
+ * Throws on a bad prefix, on non-hex content, and on a master that is not
786
+ * 32 usable bytes — an all-zero one included, since every wallet would
787
+ * derive the same identity from it.
762
788
  */
763
789
  async importFromHex(hex) {
764
790
  if (!hex.startsWith("mk:")) {
@@ -766,15 +792,9 @@ var PrivacyKeyManager = class {
766
792
  'PrivacyKeyManager: invalid cache format. Expected "mk:0x{masterBytes_hex}".'
767
793
  );
768
794
  }
769
- const raw = hex.slice(3);
770
- const h = raw.startsWith("0x") ? raw.slice(2) : raw;
771
- const masterBytes = new Uint8Array((h.match(/.{2}/g) ?? []).map((b) => parseInt(b, 16)));
772
- if (masterBytes.length !== 32) {
773
- throw new Error("PrivacyKeyManager: invalid master bytes \u2014 expected 32 bytes.");
774
- }
775
- const masterBigint = BigInt("0x" + h);
776
- const sk = masterBigint % BABYJUB_SUBORDER || 1n;
777
- await this.load(sk, masterBytes);
795
+ const masterBytes = fromHex(hex.slice(3));
796
+ assertSecretKeyBytes(masterBytes, "PrivacyKeyManager: master");
797
+ await this.load(0n, masterBytes);
778
798
  }
779
799
  };
780
800
 
@@ -1095,6 +1115,25 @@ function wsUrlToHttp(wsUrl) {
1095
1115
  }
1096
1116
 
1097
1117
  // src/chain/substrate/SubstrateClient.ts
1118
+ var TIMESTAMP_PALLET = 1;
1119
+ function decodeCompact(bytes, offset) {
1120
+ const first = bytes[offset];
1121
+ if (first === void 0) return null;
1122
+ const mode = first & 3;
1123
+ if (mode === 0) return first >>> 2;
1124
+ if (mode === 1) {
1125
+ const b1 = bytes[offset + 1];
1126
+ return b1 === void 0 ? null : (first >>> 2 | b1 << 6) >>> 0;
1127
+ }
1128
+ const width = mode === 2 ? 4 : (first >>> 2) + 5;
1129
+ if (offset + width > bytes.length) return null;
1130
+ let value = 0n;
1131
+ const start = mode === 2 ? offset : offset + 1;
1132
+ const end = mode === 2 ? offset + 4 : offset + width;
1133
+ for (let i = end - 1; i >= start; i--) value = value << 8n | BigInt(bytes[i]);
1134
+ if (mode === 2) value >>= 2n;
1135
+ return value > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(value);
1136
+ }
1098
1137
  var SubstrateClient = class _SubstrateClient {
1099
1138
  constructor(_papi, _httpUrl, _owned) {
1100
1139
  this._papi = _papi;
@@ -1292,25 +1331,17 @@ var SubstrateClient = class _SubstrateClient {
1292
1331
  }
1293
1332
  }
1294
1333
  if (!timestampMs) {
1295
- const tsHex = extrinsics.find((hex) => {
1334
+ const ts = extrinsics.reduce((found, hex) => {
1335
+ if (found !== null) return found;
1296
1336
  try {
1297
1337
  const b = fromHex(hex);
1298
- return b.length > 6 && b[4] === 3 && b[5] === 0;
1338
+ if (b.length < 5 || b[2] !== TIMESTAMP_PALLET || b[3] !== 0) return null;
1339
+ return decodeCompact(b, 4);
1299
1340
  } catch {
1300
- return false;
1341
+ return null;
1301
1342
  }
1302
- });
1303
- if (tsHex) {
1304
- try {
1305
- const b = fromHex(tsHex);
1306
- const view = new DataView(b.buffer, b.byteOffset + 6, 8);
1307
- const lo = view.getUint32(0, true);
1308
- const hi = view.getUint32(4, true);
1309
- const ms = lo + hi * 4294967296;
1310
- if (ms > 0) timestampMs = ms;
1311
- } catch {
1312
- }
1313
- }
1343
+ }, null);
1344
+ if (ts !== null && ts > 0) timestampMs = ts;
1314
1345
  }
1315
1346
  const author = _SubstrateClient.extractAuthorFromLogs(header.digest.logs, ss58Prefix);
1316
1347
  return { header, extrinsics, timestampMs, author };
@@ -1580,7 +1611,10 @@ var EvmClient = class {
1580
1611
  }
1581
1612
  /**
1582
1613
  * Performs multiple JSON-RPC calls in a single HTTP request (batch).
1583
- * Results are returned in the same order as `calls`, as a typed tuple.
1614
+ *
1615
+ * Results come back in the order of `calls`, as a typed tuple. A call the
1616
+ * server answered with an error, or did not answer at all, lands as `null`
1617
+ * in its own slot — never shifting the ones after it.
1584
1618
  */
1585
1619
  async batchRequest(calls) {
1586
1620
  const body = calls.map((c, i) => ({
@@ -1592,8 +1626,8 @@ var EvmClient = class {
1592
1626
  const res = await postJsonWithRetry(this.rpcUrl, JSON.stringify(body));
1593
1627
  if (!res.ok) throw new Error(`EVM HTTP ${res.status}: ${res.statusText}`);
1594
1628
  const arr = await res.json();
1595
- arr.sort((a, b) => (a.id ?? 0) - (b.id ?? 0));
1596
- return arr.map((r) => r.result ?? null);
1629
+ const byId = new Map(arr.map((r) => [r.id, r]));
1630
+ return calls.map((_, i) => byId.get(i + 1)?.result ?? null);
1597
1631
  }
1598
1632
  // ─── Convenience wrappers ─────────────────────────────────────────────────
1599
1633
  /** Returns the native token balance (in wei) for an EVM address. */
@@ -1639,7 +1673,10 @@ var EvmClient = class {
1639
1673
  if (from) txObj["from"] = from;
1640
1674
  return this.request("eth_call", [txObj, "latest"]);
1641
1675
  }
1642
- /** Estimates the gas required for a transaction. Returns the estimate in wei as a `bigint`. */
1676
+ /**
1677
+ * Estimates a transaction's gas, in GAS UNITS — not wei. Multiply by
1678
+ * `getGasPrice()` for a cost.
1679
+ */
1643
1680
  async estimateGas(params) {
1644
1681
  const hex = await this.request("eth_estimateGas", [params]);
1645
1682
  return hexToBigint(hex);
@@ -1890,7 +1927,7 @@ var EvmExplorer = class _EvmExplorer {
1890
1927
  // --- Address ---
1891
1928
  /**
1892
1929
  * Returns aggregated on-chain data for an EVM address: balance, nonce,
1893
- * bytecode (truncated), and up to 50 recent logs from the last 1 000 blocks.
1930
+ * bytecode (truncated), and up to 50 logs it EMITTED in the last 1 001 blocks.
1894
1931
  */
1895
1932
  async getAddressInfo(address) {
1896
1933
  const latest = await this.evm.getBlockNumber().catch(() => 0);
@@ -1973,7 +2010,7 @@ var EvmExplorer = class _EvmExplorer {
1973
2010
  };
1974
2011
  }
1975
2012
  /**
1976
- * Returns ERC-20 `Transfer` events for `address` from the last 1 000 blocks.
2013
+ * Returns ERC-20 `Transfer` events for `address` from the last 1 001 blocks.
1977
2014
  * When `holderAddress` is provided, restricts results to transfers sent or received by that address.
1978
2015
  */
1979
2016
  async getTokenTransfers(address, holderAddress) {
@@ -2200,7 +2237,11 @@ var ShieldedPoolModule = class {
2200
2237
  * Withdraws tokens from the shielded pool to a public address.
2201
2238
  * Submits as an UNSIGNED (gasless) transaction — fee is embedded in the ZK proof.
2202
2239
  * Pass a `signer` to fall back to signed submission (e.g. for testing).
2203
- * Extrinsic: shieldedPool.unshield(proof, merkleRoot, nullifier, assetId, amount, recipient, fee, changeCommitment, changeEncryptedMemo, relayer, circuitVersion)
2240
+ * Extrinsic: shieldedPool.unshield(proof, merkleRoot, nullifier, assetId, amount, recipient, fee, changeCommitment, changeEncryptedMemo, circuitVersion)
2241
+ *
2242
+ * The relay fee recipient is NOT a parameter: the chain takes it from the
2243
+ * dispatch origin. Submitting unsigned credits the block author; submitting
2244
+ * through the EVM precompile credits whoever signed that transaction.
2204
2245
  */
2205
2246
  async unshield(params, signer, options) {
2206
2247
  const entry = resolveTx(this.substrate.unsafe, "ShieldedPool", "unshield");
@@ -2224,8 +2265,6 @@ var ShieldedPoolModule = class {
2224
2265
  fee: params.fee ?? 0n,
2225
2266
  change_commitment: changeCommitment,
2226
2267
  change_encrypted_memo: changeEncryptedMemo,
2227
- relayer: void 0,
2228
- // Option<H160> — None for direct Substrate submissions
2229
2268
  circuit_version: params.circuitVersion
2230
2269
  });
2231
2270
  if (signer) {
@@ -2237,7 +2276,11 @@ var ShieldedPoolModule = class {
2237
2276
  * Performs a private (shielded) transfer between two notes.
2238
2277
  * Submits as an UNSIGNED (gasless) transaction — fee is embedded in the ZK proof.
2239
2278
  * Pass a `signer` to fall back to signed submission (e.g. for testing).
2240
- * Extrinsic: shieldedPool.privateTransfer(proof, merkleRoot, nullifiers, commitments, memos, assetId, fee, relayer, circuitVersion)
2279
+ * Extrinsic: shieldedPool.privateTransfer(proof, merkleRoot, nullifiers, commitments, memos, assetId, fee, circuitVersion)
2280
+ *
2281
+ * The relay fee recipient is NOT a parameter: the chain takes it from the
2282
+ * dispatch origin. Submitting unsigned credits the block author; submitting
2283
+ * through the EVM precompile credits whoever signed that transaction.
2241
2284
  */
2242
2285
  async privateTransfer(params, signer, options) {
2243
2286
  const nullifiers = params.inputs.map((inp) => inp.nullifier);
@@ -2258,8 +2301,6 @@ var ShieldedPoolModule = class {
2258
2301
  encrypted_memos: memos,
2259
2302
  asset_id: params.assetId,
2260
2303
  fee: params.fee ?? 0n,
2261
- relayer: void 0,
2262
- // Option<H160> — None for direct Substrate submissions
2263
2304
  circuit_version: params.circuitVersion
2264
2305
  });
2265
2306
  if (signer) {
@@ -2342,7 +2383,13 @@ var PrivacyModule = class {
2342
2383
  async getMerkleRoot() {
2343
2384
  return this.substrate.request("privacy_getMerkleRoot", []);
2344
2385
  }
2345
- /** Returns the Merkle proof for the given leaf index or commitment hex. */
2386
+ /**
2387
+ * Returns the Merkle proof for a LEAF INDEX.
2388
+ *
2389
+ * The node declares this parameter `u32`, so a commitment hex is rejected
2390
+ * by deserialisation before any lookup runs — `getMerkleProofByCommitment`
2391
+ * is the one that takes a string.
2392
+ */
2346
2393
  async getMerkleProof(leafIndex) {
2347
2394
  const raw = await this.substrate.request("privacy_getMerkleProof", [
2348
2395
  leafIndex
@@ -2776,7 +2823,9 @@ var ShieldedPoolPrecompile = class {
2776
2823
  * hidden by design, so any address (a relayer included) can submit a valid
2777
2824
  * proof.
2778
2825
  *
2779
- * Extrinsic: `shieldedPool.privateTransfer(proof, merkleRoot, nullifiers, commitments, memos)`
2826
+ * Extrinsic: `shieldedPool.privateTransfer(proof, merkleRoot, nullifiers,
2827
+ * commitments, memos, assetId, fee, circuitVersion)` — eight arguments; see
2828
+ * `buildPrivateTransferCalldata` for the encoding order.
2780
2829
  */
2781
2830
  async privateTransfer(params, signer) {
2782
2831
  return signer({ to: this.addr, data: buildPrivateTransferCalldata(params) });
@@ -2788,7 +2837,14 @@ var ShieldedPoolPrecompile = class {
2788
2837
  * `params.recipientAddress` must be a 0x-prefixed AccountId32. To send to an
2789
2838
  * EVM address, derive it first with `evmToImplicitSubstrate(evmAddr)`.
2790
2839
  *
2791
- * Extrinsic: `shieldedPool.unshield(proof, merkleRoot, nullifier, assetId, amount, recipient)`
2840
+ * Extrinsic: `shieldedPool.unshield(proof, merkleRoot, nullifier, assetId,
2841
+ * amount, recipient, fee, changeCommitment, changeEncryptedMemo,
2842
+ * circuitVersion)` — ten arguments; see `buildUnshieldCalldata`.
2843
+ *
2844
+ * **The relay fee goes to whoever `signer` is.** The chain takes the recipient
2845
+ * from `msg.sender`, not from calldata, so the account behind this signer is
2846
+ * the one credited — and it is also the one paying gas. Relaying on someone
2847
+ * else's behalf and being paid for it is the same act here.
2792
2848
  */
2793
2849
  async unshield(params, signer) {
2794
2850
  return signer({ to: this.addr, data: buildUnshieldCalldata(params) });
@@ -2807,7 +2863,9 @@ var ShieldedPoolPrecompile = class {
2807
2863
  * The `msg.sender` address is the validator identity, and must match the
2808
2864
  * one with pending fees.
2809
2865
  *
2810
- * Extrinsic: `shieldedPool.claim_shielded_fees(commitment, amount, assetId, memo, proof, publicSignals)`
2866
+ * Extrinsic: `shieldedPool.claim_shielded_fees(commitment, amount, assetId,
2867
+ * memo, proof, publicSignals, circuitVersion)` — seven arguments; see
2868
+ * `buildClaimShieldedFeesCalldata`.
2811
2869
  */
2812
2870
  async claimShieldedFees(params, signer) {
2813
2871
  return signer({
@@ -2821,7 +2879,12 @@ var ShieldedPoolPrecompile = class {
2821
2879
  // AccountId32, so estimating from a different address measures a different
2822
2880
  // call.
2823
2881
  async estimateShieldGas(params, from) {
2824
- return this.evm.estimateGas({ from, to: this.addr, data: buildShieldCalldata(params) });
2882
+ return this.evm.estimateGas({
2883
+ from,
2884
+ to: this.addr,
2885
+ data: buildShieldCalldata(params),
2886
+ value: `0x${params.amount.toString(16)}`
2887
+ });
2825
2888
  }
2826
2889
  async estimatePrivateTransferGas(params, from) {
2827
2890
  return this.evm.estimateGas({
@@ -3276,9 +3339,13 @@ var OrbinumClientProvider = class _OrbinumClientProvider {
3276
3339
  // ─── Reconnection ───────────────────────────────────────────────────────
3277
3340
  /**
3278
3341
  * Schedules the next connection attempt using exponential backoff
3279
- * (capped at `reconnectMaxMs`) with full jitter, then transitions to
3280
- * `'reconnecting'`. Jitter (a random fraction of the delay) spreads out
3281
- * reconnects so many clients don't retry in lockstep after a shared outage.
3342
+ * (capped at `reconnectMaxMs`) with EQUAL jitter, then transitions to
3343
+ * `'reconnecting'`.
3344
+ *
3345
+ * The delay lands in `[capped/2, capped]` — half fixed, half random. That
3346
+ * spreads a shared outage's reconnects over a `capped/2` window instead of
3347
+ * having every client retry in lockstep, while keeping a floor so the first
3348
+ * attempts do not all pile up near zero the way full jitter allows.
3282
3349
  */
3283
3350
  scheduleReconnect() {
3284
3351
  if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
@@ -3670,6 +3737,16 @@ function mapZkEventData(method, data) {
3670
3737
  index: get(4, "leaf_index") || get(4, "index")
3671
3738
  };
3672
3739
  }
3740
+ if (m === "nullifiersspent") {
3741
+ return { nullifiers: get(0, "nullifiers") };
3742
+ }
3743
+ if (m === "commitmentsinserted") {
3744
+ return {
3745
+ commitments: get(0, "commitments"),
3746
+ memos: get(1, "encrypted_memos") || get(1, "memos"),
3747
+ indices: get(2, "leaf_indices") || get(2, "indices")
3748
+ };
3749
+ }
3673
3750
  if (m === "privatetransfer") {
3674
3751
  return {
3675
3752
  nullifiers: get(0, "nullifiers"),
@@ -3711,12 +3788,12 @@ function mapZkEventData(method, data) {
3711
3788
  if (m_norm === "log") {
3712
3789
  return { log: get(0, "log") };
3713
3790
  }
3714
- if (m_norm === "created" || m_norm === "createdfailed" || m_norm === "executed" || m_norm === "executedfailed") {
3791
+ if (m_norm === "created" || m_norm === "createdfailed" || m_norm === "executedfailed") {
3715
3792
  return { address: get(0, "address") };
3716
3793
  }
3717
3794
  if (m_norm === "executed") {
3718
3795
  return {
3719
- from: get(0, "from"),
3796
+ from: get(0, "from") ?? get(0, "address"),
3720
3797
  to: get(1, "to"),
3721
3798
  transaction_hash: get(2, "transaction_hash"),
3722
3799
  exit_reason: get(3, "exit_reason")
@@ -3863,10 +3940,10 @@ var ERROR_KINDS = {
3863
3940
  CommitmentAlreadyExists: "shape",
3864
3941
  UnknownMerkleRoot: "stale-proof",
3865
3942
  // Amounts and fees
3866
- AmountTooSmall: "amount",
3867
3943
  InvalidAmount: "amount",
3868
3944
  InsufficientPoolBalance: "amount",
3869
3945
  FeeTooLow: "amount",
3946
+ InsufficientPendingFees: "amount",
3870
3947
  // Proofs
3871
3948
  InvalidProof: "proof",
3872
3949
  ProofVerificationFailed: "proof",
@@ -3875,12 +3952,14 @@ var ERROR_KINDS = {
3875
3952
  InvalidAssetId: "asset",
3876
3953
  AssetNotVerified: "asset",
3877
3954
  AssetIdMismatch: "asset",
3955
+ AssetIdAlreadyExists: "asset",
3878
3956
  // Request shape
3879
3957
  InvalidMemoSize: "shape",
3880
3958
  MemoCommitmentMismatch: "shape",
3881
3959
  TooManyInputsOrOutputs: "shape",
3882
3960
  InvalidRecipient: "shape",
3883
3961
  EmptyBatch: "shape",
3962
+ FeeRecipientUnavailable: "shape",
3884
3963
  // Capacity
3885
3964
  MerkleTreeFull: "capacity",
3886
3965
  // Balances pallet, reached through the same path
@@ -3935,6 +4014,7 @@ function methodOf(fnSig) {
3935
4014
  if (fnSig.startsWith("unshield(")) return "unshield";
3936
4015
  if (fnSig.startsWith("privateTransfer(")) return "privateTransfer";
3937
4016
  if (fnSig.startsWith("shieldBatch(")) return "shieldBatch";
4017
+ if (fnSig.startsWith("claimShieldedFees(")) return "claimShieldedFees";
3938
4018
  if (fnSig.startsWith("shield(")) return "shield";
3939
4019
  return null;
3940
4020
  }
@@ -4238,6 +4318,7 @@ function buildConfig(existing, chainFingerprint) {
4238
4318
  updatedAt: now,
4239
4319
  ...chainFingerprint ? { chainFingerprint } : {},
4240
4320
  ...existing?.selfEphCounter !== void 0 ? { selfEphCounter: existing.selfEphCounter } : {},
4321
+ ...existing?.outgoingEphCounter !== void 0 ? { outgoingEphCounter: existing.outgoingEphCounter } : {},
4241
4322
  ...existing?.pairwiseCounterparties !== void 0 ? { pairwiseCounterparties: existing.pairwiseCounterparties } : {}
4242
4323
  };
4243
4324
  }
@@ -4249,6 +4330,7 @@ async function writeConfig(storage, fallback, chainFingerprint) {
4249
4330
  }
4250
4331
  function mergeCounters(current, fallback) {
4251
4332
  const selfEphCounter = higher(current.selfEphCounter, fallback?.selfEphCounter);
4333
+ const outgoingEphCounter = higher(current.outgoingEphCounter, fallback?.outgoingEphCounter);
4252
4334
  const parties = mergePairwise(current.pairwiseCounterparties, fallback?.pairwiseCounterparties);
4253
4335
  return {
4254
4336
  createdAt: current.createdAt ?? fallback?.createdAt,
@@ -4256,6 +4338,7 @@ function mergeCounters(current, fallback) {
4256
4338
  // is exactOptionalPropertyTypes, so an explicit undefined is a type
4257
4339
  // error AND would round-trip through structured clone as a present key.
4258
4340
  ...selfEphCounter !== void 0 ? { selfEphCounter } : {},
4341
+ ...outgoingEphCounter !== void 0 ? { outgoingEphCounter } : {},
4259
4342
  ...parties !== void 0 ? { pairwiseCounterparties: parties } : {}
4260
4343
  };
4261
4344
  }
@@ -4293,6 +4376,7 @@ function mergePairwise(current, fallback) {
4293
4376
 
4294
4377
  // src/wallet/vault/storage/ephemeralIndex.ts
4295
4378
  var MAX_EPH_INDEX = 4294967294;
4379
+ var RESTORE_SAFETY_GAP = 8;
4296
4380
  function usableCounter(value) {
4297
4381
  if (typeof value !== "number" || !Number.isInteger(value)) return null;
4298
4382
  if (value < 0 || value > MAX_EPH_INDEX) return null;
@@ -4307,27 +4391,51 @@ async function reserveSelfEphIndex(storage) {
4307
4391
  if (!updated) throw new Error("vault not initialized");
4308
4392
  return (usableCounter(updated.selfEphCounter) ?? 1) - 1;
4309
4393
  }
4310
- async function reservePairwiseIndex(storage, ivkHex) {
4394
+ async function reserveOutgoingIndex(storage, fromChain) {
4395
+ const recovered = usableCounter(fromChain);
4396
+ let exhausted = false;
4397
+ const updated = await storage.updateConfig((config) => {
4398
+ const raw = config.outgoingEphCounter;
4399
+ if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > MAX_EPH_INDEX) {
4400
+ exhausted = true;
4401
+ return config;
4402
+ }
4403
+ exhausted = false;
4404
+ const stored = usableCounter(raw) ?? 0;
4405
+ const floor = recovered !== null && recovered > stored ? Math.min(recovered + RESTORE_SAFETY_GAP, MAX_EPH_INDEX) : stored;
4406
+ return {
4407
+ ...config,
4408
+ outgoingEphCounter: floor + 1,
4409
+ updatedAt: Date.now()
4410
+ };
4411
+ });
4412
+ if (!updated) throw new Error("vault not initialized");
4413
+ if (exhausted || typeof updated.outgoingEphCounter === "number" && Number.isSafeInteger(updated.outgoingEphCounter) && updated.outgoingEphCounter > MAX_EPH_INDEX) {
4414
+ throw new Error("outgoing ephemeral sequence exhausted");
4415
+ }
4416
+ const committed = usableCounter(updated.outgoingEphCounter);
4417
+ if (committed === null || committed - 1 >= MAX_EPH_INDEX) {
4418
+ throw new Error("outgoing ephemeral sequence exhausted");
4419
+ }
4420
+ return committed - 1;
4421
+ }
4422
+ async function registerPairwiseCounterparty(storage, ivkHex) {
4311
4423
  const key = ivkHex.toLowerCase();
4312
4424
  const updated = await storage.updateConfig((config) => {
4313
- const stored = usableCounter(config.pairwiseCounterparties?.[key]?.nextIndex);
4314
- const addedAt = config.pairwiseCounterparties?.[key]?.addedAt;
4425
+ const existing = config.pairwiseCounterparties?.[key];
4315
4426
  return {
4316
4427
  ...config,
4317
4428
  pairwiseCounterparties: {
4318
4429
  ...config.pairwiseCounterparties,
4319
4430
  [key]: {
4320
- nextIndex: (stored ?? 0) + 1,
4321
- addedAt: typeof addedAt === "number" ? addedAt : Date.now()
4431
+ nextIndex: usableCounter(existing?.nextIndex) ?? 0,
4432
+ addedAt: typeof existing?.addedAt === "number" ? existing.addedAt : Date.now()
4322
4433
  }
4323
4434
  },
4324
4435
  updatedAt: Date.now()
4325
4436
  };
4326
4437
  });
4327
4438
  if (!updated) throw new Error("vault not initialized");
4328
- const nextIndex = usableCounter(updated.pairwiseCounterparties?.[key]?.nextIndex);
4329
- if (nextIndex === null || nextIndex <= 1) return null;
4330
- return nextIndex - 1;
4331
4439
  }
4332
4440
 
4333
4441
  // src/wallet/vault/notes/record.ts
@@ -4762,19 +4870,44 @@ async function processHints(ctx, hints, total) {
4762
4870
  }
4763
4871
  }
4764
4872
  outcome.scanned++;
4765
- if (!hint.encryptedMemo || !hint.ephPkHex) {
4873
+ if (!hint.encryptedMemo) {
4766
4874
  outcome.noMemo++;
4767
4875
  continue;
4768
4876
  }
4769
4877
  toDecrypt.push(hint);
4770
4878
  }
4771
- const { notes, tagFiltered, selfMatched, pairwiseMatched, maxSelfEphIndex } = await ctx.pool.decryptBatch(toDecrypt, ctx.keys, ctx.signal);
4879
+ const {
4880
+ notes,
4881
+ tagFiltered,
4882
+ selfMatched,
4883
+ pairwiseMatched,
4884
+ maxSelfEphIndex,
4885
+ maxOutgoingEphIndex,
4886
+ sentNotes,
4887
+ learnedRecipients,
4888
+ unmatchedSent,
4889
+ sealedBookEntries
4890
+ } = await ctx.pool.decryptBatch(
4891
+ toDecrypt,
4892
+ { ...ctx.keys, recipientCandidates: [...outcome.learnedRecipients].map(fromHex) },
4893
+ ctx.signal
4894
+ );
4772
4895
  outcome.tagFiltered += tagFiltered;
4773
4896
  outcome.selfDiscovered += selfMatched;
4774
4897
  outcome.pairwiseDiscovered += pairwiseMatched;
4898
+ outcome.sentNotes.push(...sentNotes ?? []);
4899
+ outcome.unmatchedSent.push(...unmatchedSent ?? []);
4900
+ for (const e of sealedBookEntries ?? []) outcome.sealedBookEntries.add(e);
4901
+ for (const r of learnedRecipients ?? []) outcome.learnedRecipients.add(r);
4775
4902
  if (maxSelfEphIndex !== null) {
4776
4903
  outcome.maxSelfEphIndex = Math.max(outcome.maxSelfEphIndex ?? -1, maxSelfEphIndex);
4777
4904
  }
4905
+ if (maxOutgoingEphIndex != null) {
4906
+ outcome.maxOutgoingEphIndex = Math.max(
4907
+ outcome.maxOutgoingEphIndex ?? -1,
4908
+ maxOutgoingEphIndex
4909
+ );
4910
+ }
4778
4911
  const pageEntries = [];
4779
4912
  for (const [i, decrypted] of notes.entries()) {
4780
4913
  const hint = toDecrypt[i];
@@ -4871,7 +5004,12 @@ async function collectScanEntries(params) {
4871
5004
  tagFiltered: 0,
4872
5005
  selfDiscovered: 0,
4873
5006
  pairwiseDiscovered: 0,
4874
- maxSelfEphIndex: null
5007
+ sentNotes: [],
5008
+ unmatchedSent: [],
5009
+ sealedBookEntries: /* @__PURE__ */ new Set(),
5010
+ learnedRecipients: new Set((params.keys.recipientCandidates ?? []).map((c) => toHex(c))),
5011
+ maxSelfEphIndex: null,
5012
+ maxOutgoingEphIndex: null
4875
5013
  };
4876
5014
  const ctx = {
4877
5015
  outcome,
@@ -5093,7 +5231,7 @@ function gapMargin(windowSize) {
5093
5231
  return Math.max(1, Math.floor(windowSize / 16));
5094
5232
  }
5095
5233
  function resolveSelfEphCeiling(params) {
5096
- const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
5234
+ const { notes, viewingKey, scanMaxIndex } = params;
5097
5235
  const windowSize = params.windowSize !== void 0 && params.windowSize > 0 ? params.windowSize : SELF_EPH_WINDOW;
5098
5236
  if (scanMaxIndex === null) return null;
5099
5237
  if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
@@ -5106,7 +5244,7 @@ function resolveSelfEphCeiling(params) {
5106
5244
  let max = scanMaxIndex;
5107
5245
  let from = windowSize;
5108
5246
  for (; ; ) {
5109
- const slice = selfEphWindow(spendingKey, ivkPacked, from, windowSize);
5247
+ const slice = selfEphWindow(viewingKey, ivkPacked, from, windowSize);
5110
5248
  let matched = false;
5111
5249
  for (const entry of slice) {
5112
5250
  if (ownEphPks.has(entry.ephPkHex.toLowerCase())) {
@@ -5162,6 +5300,8 @@ async function resolveSpentStatus(params) {
5162
5300
  }
5163
5301
 
5164
5302
  // src/wallet/scanner/pipeline.ts
5303
+ var OUTGOING_RESCAN_GAP = 8;
5304
+ var RETRY_BOOK_LIMIT = 64;
5165
5305
  async function runScan(params) {
5166
5306
  const { vault, storage, hints, nullifiers, pool, keys, sinceLeafIndex, signal, onWarning } = params;
5167
5307
  const isIncremental = sinceLeafIndex !== void 0;
@@ -5169,6 +5309,7 @@ async function runScan(params) {
5169
5309
  const preScanHexes = new Set(existingHexes);
5170
5310
  const config = await storage.getConfig().catch(() => null);
5171
5311
  const selfEphWindowSize = windowSizeForCounter(config?.selfEphCounter ?? 0);
5312
+ const outgoingEphWindowSize = windowSizeForCounter(config?.outgoingEphCounter ?? 0);
5172
5313
  const pairwiseCounterparties = Object.keys(config?.pairwiseCounterparties ?? {}).flatMap(
5173
5314
  (hex) => {
5174
5315
  try {
@@ -5197,7 +5338,12 @@ async function runScan(params) {
5197
5338
  // worth it on full scans and restores, waste on small incremental ticks.
5198
5339
  selfEph: !isIncremental,
5199
5340
  selfEphWindowSize,
5200
- pairwiseCounterparties
5341
+ pairwiseCounterparties,
5342
+ // Sent-note discovery rides the same full-scan/restore budget as
5343
+ // self discovery: on an incremental tick the history is already
5344
+ // there, and the window would be paid for nothing.
5345
+ outgoingEph: !isIncremental,
5346
+ outgoingEphWindowSize
5201
5347
  },
5202
5348
  existingHexes,
5203
5349
  sinceLeafIndex,
@@ -5211,6 +5357,49 @@ async function runScan(params) {
5211
5357
  onPage: (entries) => checkpoint.savePageNotes(entries),
5212
5358
  onBatchDone: (maxLeafIndex) => checkpoint.advanceCursor(maxLeafIndex)
5213
5359
  });
5360
+ const ovk = keys.outgoingViewingKey;
5361
+ if (scan.unmatchedSent.length > 0 && ovk) {
5362
+ const proven = [...scan.learnedRecipients].map(fromHex);
5363
+ const keptEntries = [...scan.sealedBookEntries].slice(-RETRY_BOOK_LIMIT);
5364
+ const sealed = [];
5365
+ for (const entry of keptEntries) {
5366
+ try {
5367
+ sealed.push(BigInt(entry));
5368
+ } catch {
5369
+ }
5370
+ }
5371
+ const retries = scan.unmatchedSent.slice(-RETRY_BOOK_LIMIT);
5372
+ if (retries.length < scan.unmatchedSent.length) {
5373
+ onWarning?.(
5374
+ `sent-note retry capped at ${retries.length} of ${scan.unmatchedSent.length}; the rest resolve on the next full scan`
5375
+ );
5376
+ }
5377
+ if (keptEntries.length < scan.sealedBookEntries.size) {
5378
+ onWarning?.(
5379
+ `recipient book capped at ${keptEntries.length} of ${scan.sealedBookEntries.size} entries; older payments resolve on the next full scan`
5380
+ );
5381
+ }
5382
+ for (const { hint, ephIndex } of retries) {
5383
+ if (signal?.aborted) throw scanAbortError();
5384
+ const candidates = [
5385
+ ...sealed.map((e) => openRecipientBookEntry(e, ovk, hint.commitmentHex)),
5386
+ ...proven
5387
+ ];
5388
+ const sent = recoverSentNote({
5389
+ hint,
5390
+ outgoingViewingKey: ovk,
5391
+ ephIndex,
5392
+ recipientCandidates: candidates
5393
+ });
5394
+ if (!sent) continue;
5395
+ const { recipientIvk, ...facts } = sent;
5396
+ scan.sentNotes.push({
5397
+ ...facts,
5398
+ counterpartyIvkHex: toHex(recipientIvk),
5399
+ encryptedMemo: hint.encryptedMemo ?? ""
5400
+ });
5401
+ }
5402
+ }
5214
5403
  const nullifiersToQuery = collectNullifiersToQuery(
5215
5404
  scan.scanEntries,
5216
5405
  scan.onChainHexes,
@@ -5240,7 +5429,6 @@ async function runScan(params) {
5240
5429
  try {
5241
5430
  ceiling = resolveSelfEphCeiling({
5242
5431
  notes: vault.getAll(),
5243
- spendingKey: keys.spendingKey,
5244
5432
  viewingKey: keys.viewingKey,
5245
5433
  scanMaxIndex: scan.maxSelfEphIndex,
5246
5434
  windowSize: selfEphWindowSize
@@ -5250,6 +5438,13 @@ async function runScan(params) {
5250
5438
  await bumpSelfEphCounterTo(storage, ceiling + 1).catch(() => {
5251
5439
  });
5252
5440
  }
5441
+ if (scan.maxOutgoingEphIndex !== null) {
5442
+ await bumpOutgoingCounterTo(
5443
+ storage,
5444
+ scan.maxOutgoingEphIndex + 1 + OUTGOING_RESCAN_GAP
5445
+ ).catch(() => {
5446
+ });
5447
+ }
5253
5448
  if (scan.decryptFailed > 0 && scan.found === 0 && scan.alreadyPresent === 0) {
5254
5449
  onWarning?.(
5255
5450
  `no notes recovered from ${scan.decryptFailed} hint(s) with memos; if this wallet should hold notes, check the keys it was unlocked with`
@@ -5261,7 +5456,13 @@ async function runScan(params) {
5261
5456
  decryptFailed: scan.decryptFailed,
5262
5457
  alreadyPresent: scan.alreadyPresent,
5263
5458
  purged,
5264
- incremental: isIncremental
5459
+ incremental: isIncremental,
5460
+ sentNotes: scan.sentNotes,
5461
+ discovery: {
5462
+ self: scan.selfDiscovered,
5463
+ pairwise: scan.pairwiseDiscovered,
5464
+ tagFiltered: scan.tagFiltered
5465
+ }
5265
5466
  };
5266
5467
  }
5267
5468
  async function bumpSelfEphCounterTo(storage, next) {
@@ -5269,6 +5470,11 @@ async function bumpSelfEphCounterTo(storage, next) {
5269
5470
  (config) => (config.selfEphCounter ?? 0) >= next ? config : { ...config, selfEphCounter: next, updatedAt: Date.now() }
5270
5471
  );
5271
5472
  }
5473
+ async function bumpOutgoingCounterTo(storage, next) {
5474
+ await storage.updateConfig(
5475
+ (config) => (config.outgoingEphCounter ?? 0) >= next ? config : { ...config, outgoingEphCounter: next, updatedAt: Date.now() }
5476
+ );
5477
+ }
5272
5478
 
5273
5479
  // src/wallet/scanner/history/extrinsicFacts.ts
5274
5480
  var UNKNOWN = { fee: null, success: true };
@@ -5407,10 +5613,8 @@ function regeneratePaymentSlip(facts, recipientIvkPacked, txHash) {
5407
5613
  commitmentHex: facts.commitmentHex,
5408
5614
  encryptedMemo: facts.encryptedMemo,
5409
5615
  ...facts.leafIndex !== void 0 ? { leafIndex: facts.leafIndex } : {},
5410
- // Rendered as an explorer link by the recipient, where an unconstrained
5616
+ // The recipient renders this as an explorer link, so an unconstrained
5411
5617
  // string is a URL injection wearing the authority of a decrypted slip.
5412
- // Dropped rather than fatal: the slip still rebuilds the note, which is
5413
- // the part that matters.
5414
5618
  ...isHexOfLength(txHash, 32) ? { txHash } : {}
5415
5619
  });
5416
5620
  return encodePaymentSlip(envelope);
@@ -5435,15 +5639,37 @@ async function loadExistingRecords(vault) {
5435
5639
  function recordKey(transfer) {
5436
5640
  return transfer.hash ?? `${transfer.blockNumber}-${transfer.extrinsicIndex ?? 0}`;
5437
5641
  }
5642
+ function sentByCommitment(sentNotes) {
5643
+ return new Map(sentNotes.map((s) => [s.commitmentHex.toLowerCase(), s]));
5644
+ }
5645
+ function reissuedSlip(sent, hash) {
5646
+ try {
5647
+ const facts = {
5648
+ commitmentHex: sent.commitmentHex,
5649
+ encryptedMemo: sent.encryptedMemo,
5650
+ ...sent.leafIndex !== void 0 ? { leafIndex: sent.leafIndex } : {}
5651
+ };
5652
+ return {
5653
+ paymentSlip: regeneratePaymentSlip(
5654
+ facts,
5655
+ fromHex(sent.counterpartyIvkHex),
5656
+ hash ?? void 0
5657
+ )
5658
+ };
5659
+ } catch {
5660
+ return {};
5661
+ }
5662
+ }
5438
5663
  async function reconstructOutgoingTxRecords(deps) {
5439
5664
  const { vault, transfers } = deps;
5440
5665
  const now = deps.now ?? Date.now;
5441
5666
  const allNotes = vault.getAll();
5442
5667
  const spentNotes = allNotes.filter((n) => n.spent);
5443
5668
  if (spentNotes.length === 0) return;
5669
+ const sentCommitments = (deps.sentNotes ?? []).map((s) => s.commitmentHex);
5444
5670
  const [outgoingTransfers, commitmentTransfers] = await Promise.all([
5445
5671
  transfers.byNullifiers(spentNotes.map((n) => n.nullifierHex)),
5446
- transfers.byCommitments(allNotes.map((n) => n.commitmentHex))
5672
+ transfers.byCommitments([...allNotes.map((n) => n.commitmentHex), ...sentCommitments])
5447
5673
  ]);
5448
5674
  if (outgoingTransfers.length === 0) return;
5449
5675
  const noteByNullifier = new Map(spentNotes.map((n) => [n.nullifierHex, n]));
@@ -5452,64 +5678,85 @@ async function reconstructOutgoingTxRecords(deps) {
5452
5678
  commitmentTransfers.map((ct) => [extrinsicKey(ct), ct.matchedCommitments ?? []])
5453
5679
  );
5454
5680
  const existingByKey = await loadExistingRecords(vault);
5681
+ const sentIndex = sentByCommitment(deps.sentNotes ?? []);
5682
+ const recoveryEnabled = deps.sentNotes !== void 0;
5683
+ const sentFor = (row) => (commitmentsByExtrinsic.get(extrinsicKey(row)) ?? []).map((hex) => sentIndex.get(hex.toLowerCase())).find((s) => s !== void 0);
5455
5684
  for (const transfer of outgoingTransfers) {
5456
5685
  const existing = existingByKey.get(recordKey(transfer));
5457
- if (existing?.recipientPkHex && existing.recipientPkHex !== ZERO_PK) continue;
5686
+ const known = existing?.recipientPkHex && existing.recipientPkHex !== ZERO_PK;
5687
+ if (known && existing?.paymentSlip) continue;
5458
5688
  const inputNotes = (transfer.matchedNullifiers ?? []).map((h) => noteByNullifier.get(h)).filter((n) => n !== void 0);
5459
5689
  if (inputNotes.length === 0) continue;
5460
5690
  const changeNote = selectDescribingNoteByCommitment(
5461
5691
  commitmentsByExtrinsic.get(extrinsicKey(transfer)) ?? [],
5462
5692
  noteByCommitment
5463
5693
  );
5464
- const recipientPkHex = toPkHex(changeNote?.sourcePk);
5694
+ const sentForRow = sentFor(transfer);
5695
+ const recipientPkHex = sentForRow ? toPkHex(sentForRow.recipientStealthPk) : recoveryEnabled ? ZERO_PK : toPkHex(changeNote?.sourcePk);
5465
5696
  if (existing) {
5466
- if (recipientPkHex === ZERO_PK) continue;
5467
- await vault.saveTxRecord({ ...existing, recipientPkHex });
5697
+ const patch = {
5698
+ // Only when it ADDS something: this runs over the whole
5699
+ // outgoing history on every scan, and rewriting what is already
5700
+ // there would make each one a write.
5701
+ ...!known && recipientPkHex !== ZERO_PK ? { recipientPkHex } : {},
5702
+ ...sentForRow && !existing.paymentSlip ? reissuedSlip(sentForRow, transfer.hash) : {}
5703
+ };
5704
+ if (Object.keys(patch).length > 0) {
5705
+ await vault.saveTxRecord({ ...existing, ...patch });
5706
+ }
5468
5707
  continue;
5469
5708
  }
5470
5709
  const { fee, success } = await fetchExtrinsicFacts(deps.txFacts, transfer.hash);
5471
5710
  const totalInputValue = inputNotes.reduce((sum, n) => sum + n.value, 0n);
5472
- const transferAmount = totalInputValue - (changeNote?.value ?? 0n) - (fee ?? 0n);
5711
+ const derivedAmount = totalInputValue - (changeNote?.value ?? 0n) - (fee ?? 0n);
5712
+ const transferAmount = sentForRow?.value ?? derivedAmount;
5473
5713
  if (transferAmount <= 0n) continue;
5474
5714
  const record = {
5475
5715
  id: recordKey(transfer),
5476
5716
  type: "private_transfer",
5477
5717
  blockNumber: transfer.blockNumber,
5478
5718
  hash: transfer.hash ?? "",
5479
- assetId: inputNotes[0].assetId.toString(),
5719
+ assetId: (sentForRow?.assetId ?? inputNotes[0].assetId).toString(),
5480
5720
  amount: transferAmount.toString(),
5481
5721
  recipientPkHex,
5482
5722
  status: success ? "success" : "failed",
5483
- ...fee !== null ? { feePlanck: fee.toString() } : { amountApproximate: true },
5484
- timestampMs: transfer.timestampMs ?? now()
5723
+ ...fee !== null ? { feePlanck: fee.toString() } : {},
5724
+ ...fee === null && !sentForRow ? { amountApproximate: true } : {},
5725
+ timestampMs: transfer.timestampMs ?? now(),
5726
+ // Best-effort: a slip that cannot be sealed costs the convenience of
5727
+ // handing it over again, never the history row.
5728
+ ...sentForRow ? reissuedSlip(sentForRow, transfer.hash) : {}
5485
5729
  };
5486
5730
  await vault.saveTxRecord(record);
5487
5731
  }
5488
5732
  }
5489
5733
 
5490
5734
  // src/wallet/ops/notes/buildNote.ts
5491
- async function buildZkNote(params, deps) {
5735
+ async function buildZkNoteWithIndex(params, deps) {
5492
5736
  const { keys, storage } = deps;
5493
5737
  const ownerPk = params.ownerPk ?? keys.ownerPk;
5494
5738
  const spendingKey = params.spendingKey ?? keys.spendingKey;
5495
5739
  const viewingPublicKey = params.viewingPublicKey ?? keys.viewingPublicKey;
5496
5740
  const isSelf = params.viewingPublicKey === void 0 && params.recipientOwnerPk === void 0;
5497
5741
  let ephSkOverride;
5498
- if (isSelf && storage) {
5742
+ let outgoingIndex;
5743
+ if (isSelf && storage && keys.viewingSecretKey) {
5499
5744
  try {
5500
- ephSkOverride = deriveSelfEphSk(spendingKey, await reserveSelfEphIndex(storage));
5745
+ ephSkOverride = deriveSelfEphSk(
5746
+ keys.viewingSecretKey,
5747
+ await reserveSelfEphIndex(storage)
5748
+ );
5501
5749
  } catch {
5502
5750
  }
5503
- } else if (params.viewingPublicKey && storage && keys.viewingSecretKey) {
5751
+ } else if (params.viewingPublicKey && storage && keys.outgoingViewingKey) {
5504
5752
  try {
5505
- const index = await reservePairwiseIndex(storage, toHex(params.viewingPublicKey));
5506
- if (index !== null) {
5507
- const pairSecret = derivePairwiseSharedSecret(
5508
- keys.viewingSecretKey,
5509
- params.viewingPublicKey
5510
- );
5511
- ephSkOverride = derivePairwiseEphSk(pairSecret, index);
5512
- }
5753
+ const index = await reserveOutgoingIndex(storage, params.outgoingIndexFromChain);
5754
+ ephSkOverride = deriveOutgoingEphSk(keys.outgoingViewingKey, index);
5755
+ outgoingIndex = index;
5756
+ } catch {
5757
+ }
5758
+ try {
5759
+ await registerPairwiseCounterparty(storage, toHex(params.viewingPublicKey));
5513
5760
  } catch {
5514
5761
  }
5515
5762
  }
@@ -5529,7 +5776,13 @@ async function buildZkNote(params, deps) {
5529
5776
  },
5530
5777
  ...ephSkOverride !== void 0 && { ephSkOverride }
5531
5778
  });
5532
- return stampCreatedAt(note, Date.now());
5779
+ return {
5780
+ note: stampCreatedAt(note, Date.now()),
5781
+ ...outgoingIndex !== void 0 ? { outgoingIndex } : {}
5782
+ };
5783
+ }
5784
+ async function buildZkNote(params, deps) {
5785
+ return (await buildZkNoteWithIndex(params, deps)).note;
5533
5786
  }
5534
5787
 
5535
5788
  // src/wallet/ops/notes/selfStealthNote.ts
@@ -5548,89 +5801,6 @@ function recoverSelfStealthNote(note, keys) {
5548
5801
  return recovered;
5549
5802
  }
5550
5803
 
5551
- // src/wallet/ops/notes/noteTransfer.ts
5552
- var NOTE_TRANSFER_URI_SCHEME = "orbinum://notes/v1/";
5553
- var QR_PAGE_MAX_CHARS = 1800;
5554
- function noteToTransferEntry(note) {
5555
- return {
5556
- c: note.commitmentHex,
5557
- n: note.nullifierHex,
5558
- val: note.value.toString(),
5559
- aid: note.assetId.toString(),
5560
- opk: scalarToHex(note.ownerPk),
5561
- bld: scalarToHex(note.blinding),
5562
- sk: scalarToHex(note.spendingKey)
5563
- };
5564
- }
5565
- function encodeNoteTransferPages(notes, options = {}) {
5566
- const now = options.now ?? Date.now;
5567
- const maxChars = options.maxChars ?? QR_PAGE_MAX_CHARS;
5568
- const entries = notes.map(noteToTransferEntry);
5569
- const batches = [];
5570
- let batch = [];
5571
- for (const entry of entries) {
5572
- batch.push(entry);
5573
- const probe = { v: 1, ts: 0, p: 0, pt: 1, notes: batch };
5574
- const size = NOTE_TRANSFER_URI_SCHEME.length + base64UrlEncode(JSON.stringify(probe)).length;
5575
- if (size > maxChars && batch.length > 1) {
5576
- batch.pop();
5577
- batches.push(batch);
5578
- batch = [entry];
5579
- }
5580
- }
5581
- if (batch.length > 0) batches.push(batch);
5582
- const ts = now();
5583
- return batches.map((notesInPage, i) => {
5584
- const payload = {
5585
- v: 1,
5586
- ts,
5587
- p: i,
5588
- pt: batches.length,
5589
- notes: notesInPage
5590
- };
5591
- return NOTE_TRANSFER_URI_SCHEME + base64UrlEncode(JSON.stringify(payload));
5592
- });
5593
- }
5594
- function decodeNoteTransferPage(uri) {
5595
- if (!uri.startsWith(NOTE_TRANSFER_URI_SCHEME)) {
5596
- throw new Error("Not an Orbinum note-transfer code.");
5597
- }
5598
- let payload;
5599
- try {
5600
- payload = JSON.parse(base64UrlDecode(uri.slice(NOTE_TRANSFER_URI_SCHEME.length)));
5601
- } catch {
5602
- throw new Error("Note-transfer code is corrupt.");
5603
- }
5604
- const p = payload;
5605
- if (p.v !== 1) {
5606
- throw new Error(`Unsupported note-transfer version: ${String(p.v)}.`);
5607
- }
5608
- if (typeof p.p !== "number" || typeof p.pt !== "number" || !Array.isArray(p.notes) || p.notes.some((n) => !isEntry(n))) {
5609
- throw new Error("Note-transfer code is missing required fields.");
5610
- }
5611
- return p;
5612
- }
5613
- function isEntry(value) {
5614
- if (typeof value !== "object" || value === null) return false;
5615
- const e = value;
5616
- return ["c", "n", "val", "aid", "opk", "bld", "sk"].every(
5617
- (k) => typeof e[k] === "string" && e[k].length > 0
5618
- );
5619
- }
5620
- function assembleNoteTransfer(pages) {
5621
- if (pages.length === 0) throw new Error("No pages scanned.");
5622
- const total = pages[0].pt;
5623
- if (pages.some((p) => p.pt !== total)) {
5624
- throw new Error("Pages come from different batches.");
5625
- }
5626
- const seen = new Set(pages.map((p) => p.p));
5627
- if (seen.size !== total) {
5628
- const missing = [...Array(total).keys()].filter((i) => !seen.has(i));
5629
- throw new Error(`Missing page(s): ${missing.map((i) => i + 1).join(", ")} of ${total}.`);
5630
- }
5631
- return [...pages].sort((a, b) => a.p - b.p).flatMap((p) => p.notes);
5632
- }
5633
-
5634
5804
  // src/wallet/ops/notes/noteBackup.ts
5635
5805
  var NOTE_BACKUP_VERSION = 1;
5636
5806
  function noteToBackupEntry(note) {
@@ -5665,12 +5835,13 @@ function decodeNoteBackup(json) {
5665
5835
  if (p.v !== NOTE_BACKUP_VERSION) {
5666
5836
  throw new Error(`Unsupported note-backup version: ${String(p.v)}.`);
5667
5837
  }
5668
- if (!Array.isArray(p.notes) || p.notes.some((n) => !isEntry2(n))) {
5838
+ if (!Array.isArray(p.notes) || p.notes.some((n) => !isEntry(n))) {
5669
5839
  throw new Error("Note backup is missing required fields.");
5670
5840
  }
5671
5841
  return p.notes;
5672
5842
  }
5673
5843
  function importNotesFromBackup(entries, keys) {
5844
+ assertSecretKeyBytes(keys.viewingSecretKey, "importNotesFromBackup: viewingSecretKey");
5674
5845
  const out = [];
5675
5846
  for (const entry of entries) {
5676
5847
  const commitment = {
@@ -5694,12 +5865,14 @@ function importNotesFromBackup(entries, keys) {
5694
5865
  }
5695
5866
  return out;
5696
5867
  }
5697
- function isEntry2(value) {
5868
+ function isEntry(value) {
5698
5869
  if (typeof value !== "object" || value === null) return false;
5699
5870
  const e = value;
5700
- if (typeof e["commitmentHex"] !== "string" || e["commitmentHex"].length === 0) return false;
5701
- if (typeof e["encryptedMemo"] !== "string" || e["encryptedMemo"].length === 0) return false;
5702
- if ("leafIndex" in e && e["leafIndex"] !== void 0 && typeof e["leafIndex"] !== "number") {
5871
+ if (!isHexOfLength(e["commitmentHex"], 32)) return false;
5872
+ if (!isHexOfLength(e["encryptedMemo"], ENCRYPTED_MEMO_SIZE)) return false;
5873
+ if (e["leafIndex"] !== void 0 && !isValidLeafIndex(e["leafIndex"])) return false;
5874
+ if (e["spent"] !== void 0 && typeof e["spent"] !== "boolean") return false;
5875
+ if (e["spentAt"] !== void 0 && e["spentAt"] !== null && typeof e["spentAt"] !== "number") {
5703
5876
  return false;
5704
5877
  }
5705
5878
  return true;
@@ -5707,6 +5880,7 @@ function isEntry2(value) {
5707
5880
 
5708
5881
  // src/wallet/ops/notes/paymentSlipImport.ts
5709
5882
  function importPaymentSlip(slip, keys) {
5883
+ assertSecretKeyBytes(keys.viewingSecretKey, "importPaymentSlip: viewingSecretKey");
5710
5884
  const envelope = typeof slip === "string" ? decodePaymentSlip(slip) : slip;
5711
5885
  if (!envelope) return null;
5712
5886
  const fields = openPaymentSlip(keys.viewingSecretKey, envelope);
@@ -5835,10 +6009,7 @@ async function transferNotes(deps, params, onProgress) {
5835
6009
  const merkleRoot = merkleA.root;
5836
6010
  onProgress?.("building-output-notes");
5837
6011
  const effectiveSenderPk = senderPk ?? noteA.ownerPk;
5838
- const senderViewingPublicKey = deriveViewingPublicKey(
5839
- deriveViewingSecretKey(noteA.spendingKey)
5840
- );
5841
- const recipientNote = await deps.buildNote({
6012
+ const { note: recipientNote, outgoingIndex } = await deps.buildNote({
5842
6013
  value: transferAmount,
5843
6014
  assetId: noteA.assetId,
5844
6015
  ownerPk: recipientPk,
@@ -5848,13 +6019,17 @@ async function transferNotes(deps, params, onProgress) {
5848
6019
  // With a viewing key present this activates stealth derivation.
5849
6020
  recipientOwnerPk: recipientPk
5850
6021
  });
5851
- const changeNote = await deps.buildNote({
6022
+ const changeSourcePk = outgoingIndex !== void 0 && recipientViewingPublicKey !== void 0 && deps.outgoingViewingKey !== void 0 ? bytesToBigintLE(
6023
+ sealRecipientBookEntry(
6024
+ recipientViewingPublicKey,
6025
+ deps.outgoingViewingKey,
6026
+ recipientNote.commitmentHex
6027
+ )
6028
+ ) : recipientNote.ownerPk;
6029
+ const { note: changeNote } = await deps.buildNote({
5852
6030
  value: changeValue,
5853
6031
  assetId: noteA.assetId,
5854
- ownerPk: effectiveSenderPk,
5855
- spendingKey: noteA.spendingKey,
5856
- sourcePk: recipientNote.ownerPk,
5857
- viewingPublicKey: senderViewingPublicKey
6032
+ sourcePk: changeSourcePk
5858
6033
  });
5859
6034
  onProgress?.("generating-zk");
5860
6035
  const noteToInput = (note, proof) => ({
@@ -5971,7 +6146,7 @@ async function unshieldNote(deps, params, onProgress) {
5971
6146
  if (changeValue > 0n) {
5972
6147
  const { ownerPk, spendingKey } = deps.stealthKeys;
5973
6148
  changeBlinding = randomBlinding();
5974
- changeNote = await deps.buildNote({
6149
+ changeNote = (await deps.buildNote({
5975
6150
  value: changeValue,
5976
6151
  assetId: note.assetId,
5977
6152
  ownerPk,
@@ -5979,7 +6154,7 @@ async function unshieldNote(deps, params, onProgress) {
5979
6154
  spendingKey,
5980
6155
  // Activates stealth: the commitment covers a one-time ownerPk.
5981
6156
  recipientOwnerPk: ownerPk
5982
- });
6157
+ })).note;
5983
6158
  }
5984
6159
  const { provider, version: circuitVersion } = await deps.resolver.resolve(
5985
6160
  CircuitType6.Unshield,
@@ -6038,7 +6213,7 @@ async function unshieldNote(deps, params, onProgress) {
6038
6213
  import { CircuitType as CircuitType7 } from "@orbinum/proof-generator";
6039
6214
  async function claimFees(deps, { assetId, amount, signer }, onStep) {
6040
6215
  onStep?.("building-note");
6041
- const note = await deps.buildNote({ value: amount, assetId: BigInt(assetId) });
6216
+ const { note } = await deps.buildNote({ value: amount, assetId: BigInt(assetId) });
6042
6217
  const { provider } = await deps.resolver.resolve(CircuitType7.ValueProof, note.circuitVersion);
6043
6218
  onStep?.("generating-proof");
6044
6219
  const proofOutput = await generateFeeClaimProof(
@@ -6265,8 +6440,9 @@ var KEY_PREFIX = "orbinum_sk_";
6265
6440
  function isEnvelope(value) {
6266
6441
  return typeof value === "object" && value !== null && value.v === 1 && typeof value.iv === "string" && typeof value.ct === "string";
6267
6442
  }
6268
- function sessionCacheKey(address, chainId) {
6269
- return `${KEY_PREFIX}${chainId}_${canonicalAccountId(address)}`;
6443
+ function sessionCacheKey(address, chainId, version = "v3") {
6444
+ const marker = `${version}_`;
6445
+ return `${KEY_PREFIX}${marker}${chainId}_${canonicalAccountId(address)}`;
6270
6446
  }
6271
6447
  async function hasCachedSession(store, address, chainId) {
6272
6448
  return await store.get(sessionCacheKey(address, chainId)) !== null;
@@ -6302,9 +6478,55 @@ async function clearSession(store, address, chainId) {
6302
6478
 
6303
6479
  // src/wallet/identity/vaultName.ts
6304
6480
  var PREFIX = "orbinum-vault";
6305
- function vaultStorageName(address, chainFingerprint) {
6481
+ var versionMarker = (version) => `${version}-`;
6482
+ var VERSION_MARKERS = ["v3-"];
6483
+ function assertNoVersionMarker(component, label) {
6484
+ for (const marker of VERSION_MARKERS) {
6485
+ if (component.startsWith(marker)) {
6486
+ throw new Error(
6487
+ `vaultStorageName: ${label} must not start with "${marker}" \u2014 it would collide with another identity version`
6488
+ );
6489
+ }
6490
+ }
6491
+ }
6492
+ function vaultStorageName(address, chainFingerprint, version = "v3") {
6306
6493
  const account = canonicalAccountId(address);
6307
- return chainFingerprint ? `${PREFIX}-${chainFingerprint.toLowerCase()}-${account}` : `${PREFIX}-${account}`;
6494
+ assertNoVersionMarker(account, "account");
6495
+ const marker = versionMarker(version);
6496
+ if (chainFingerprint) assertNoVersionMarker(chainFingerprint.toLowerCase(), "chainFingerprint");
6497
+ return chainFingerprint ? `${PREFIX}-${marker}${chainFingerprint.toLowerCase()}-${account}` : `${PREFIX}-${marker}${account}`;
6498
+ }
6499
+
6500
+ // src/wallet/identity/walletIdentity.ts
6501
+ function deriveIdentity(rootSecret, version) {
6502
+ if (rootSecret.length !== 32) {
6503
+ throw new Error(`deriveIdentity: rootSecret must be 32 bytes, got ${rootSecret.length}`);
6504
+ }
6505
+ if (rootSecret.every((b) => b === 0)) {
6506
+ throw new Error("deriveIdentity: rootSecret is all zeros \u2014 every wallet would share it");
6507
+ }
6508
+ if (version !== "v3") {
6509
+ throw new Error(`deriveIdentity: unknown identity version ${String(version)}`);
6510
+ }
6511
+ const spendingKey = deriveSpendingKeyV3(rootSecret);
6512
+ const viewingSecretKey = deriveViewingSecretKeyV3(rootSecret);
6513
+ return {
6514
+ version,
6515
+ spendingKey,
6516
+ ownerPk: deriveOwnerPk(spendingKey),
6517
+ viewingSecretKey,
6518
+ viewingPublicKey: deriveViewingPublicKey(viewingSecretKey),
6519
+ outgoingViewingKey: deriveOutgoingViewingKeyV3(rootSecret)
6520
+ };
6521
+ }
6522
+ function exportViewingCredential(identity, options = {}) {
6523
+ return {
6524
+ version: identity.version,
6525
+ ownerPk: identity.ownerPk,
6526
+ viewingSecretKey: Uint8Array.from(identity.viewingSecretKey),
6527
+ viewingPublicKey: Uint8Array.from(identity.viewingPublicKey),
6528
+ ...options.includeOutgoing && identity.outgoingViewingKey ? { outgoingViewingKey: Uint8Array.from(identity.outgoingViewingKey) } : {}
6529
+ };
6308
6530
  }
6309
6531
 
6310
6532
  // src/wallet/identity/injectedExtensions.ts
@@ -6342,25 +6564,36 @@ var OrbinumWallet = class {
6342
6564
  session;
6343
6565
  notes;
6344
6566
  vaultStore;
6345
- /** Set at unlock; every key below is derived from it. */
6346
- spendingKey = null;
6567
+ /**
6568
+ * The identity this wallet is unlocked as, or null.
6569
+ *
6570
+ * Held as a bundle rather than a single scalar because v3 keys are SIBLINGS:
6571
+ * the spending key no longer produces the viewing key, so there is nothing
6572
+ * left to derive the others from once unlock returns. Keeping the root out
6573
+ * of this object is deliberate — the branches are what the wallet needs, and
6574
+ * the root is the thing worth not retaining.
6575
+ */
6576
+ identity = null;
6347
6577
  /** The underlying store, for operations the facade does not cover. */
6348
6578
  get vault() {
6349
6579
  return this.vaultStore;
6350
6580
  }
6351
6581
  get unlocked() {
6352
- return this.session.unlocked && this.spendingKey !== null;
6582
+ return this.session.unlocked && this.identity !== null;
6353
6583
  }
6354
6584
  /**
6355
6585
  * Opens the wallet from the master key bytes a `SpendingKeyRequest`
6356
6586
  * signature produces — the same value `PrivacyKeyManager.getMasterBytes()`
6357
6587
  * returns.
6358
6588
  *
6359
- * Master bytes rather than the spending-key scalar, deliberately: the scalar
6360
- * is the master reduced modulo the curve order, so deriving the vault key
6361
- * from it would tie the encrypted vault to the CURRENT modulus. The master
6362
- * is pre-reduction, which keeps a stored vault readable across a modulus
6363
- * change. Everything else viewing keys, ownerPk — comes from the scalar.
6589
+ * Master bytes rather than the spending-key scalar, deliberately: the master
6590
+ * is the root EVERY branch hangs off spending key, viewing key, outgoing
6591
+ * viewing key and vault key are each their own HKDF of it, none derived
6592
+ * from another. Only `ownerPk` comes from the spending scalar, because the
6593
+ * circuit defines it that way.
6594
+ *
6595
+ * It is also what keeps a stored vault readable across a modulus change:
6596
+ * the vault key is derived pre-reduction.
6364
6597
  *
6365
6598
  * Reports whether the stored vault was RESET: a different chain, a different
6366
6599
  * key, or notes the chain no longer recognises. When it was, the wallet
@@ -6374,14 +6607,12 @@ var OrbinumWallet = class {
6374
6607
  * against a vault that was never read.
6375
6608
  */
6376
6609
  async unlock(masterBytes, options = {}) {
6377
- if (masterBytes.length !== 32) {
6378
- throw new Error(`Master key must be 32 bytes, got ${masterBytes.length}.`);
6379
- }
6610
+ assertSecretKeyBytes(masterBytes, "Master key");
6380
6611
  this.session.open(
6381
6612
  await deriveVaultKey(masterBytes),
6382
6613
  await deriveVaultBlindKey(masterBytes)
6383
6614
  );
6384
- this.spendingKey = deriveSpendingKeyFromMaster(masterBytes);
6615
+ this.identity = deriveIdentity(masterBytes, this.config.identityVersion ?? "v3");
6385
6616
  try {
6386
6617
  return await this.vaultStore.unlock(this.session.cryptoKey, options);
6387
6618
  } catch (err) {
@@ -6392,8 +6623,12 @@ var OrbinumWallet = class {
6392
6623
  /** Drops the in-memory keys. The stored vault is untouched. */
6393
6624
  lock() {
6394
6625
  this.session.lock();
6395
- this.spendingKey = null;
6626
+ this.identity = null;
6396
6627
  this.notes.set([]);
6628
+ try {
6629
+ this.config.pool.terminate();
6630
+ } catch {
6631
+ }
6397
6632
  }
6398
6633
  /** The notes currently held. Empty while locked. */
6399
6634
  getNotes() {
@@ -6405,11 +6640,8 @@ var OrbinumWallet = class {
6405
6640
  }
6406
6641
  /** This wallet's privacy identity: the keys a payer needs to address it. */
6407
6642
  privacyKeys() {
6408
- const spendingKey = this.requireKey();
6409
- return {
6410
- ownerPk: deriveOwnerPk(spendingKey),
6411
- viewingPublicKey: deriveViewingPublicKey(deriveViewingSecretKey(spendingKey))
6412
- };
6643
+ const { ownerPk, viewingPublicKey } = this.requireIdentity();
6644
+ return { ownerPk, viewingPublicKey };
6413
6645
  }
6414
6646
  /**
6415
6647
  * Walks the feed, recovers this wallet's notes, and persists them with their
@@ -6419,15 +6651,21 @@ var OrbinumWallet = class {
6419
6651
  * `sinceLeafIndex` for a full scan, which is what a restore needs.
6420
6652
  */
6421
6653
  async scan(options = {}) {
6422
- const spendingKey = this.requireKey();
6423
- const viewingKey = deriveViewingSecretKey(spendingKey);
6654
+ const identity = this.requireIdentity();
6424
6655
  return runScan({
6425
6656
  vault: this.vaultStore,
6426
6657
  storage: this.config.storage,
6427
6658
  hints: this.config.hints,
6428
6659
  nullifiers: this.config.nullifiers,
6429
6660
  pool: this.config.pool,
6430
- keys: { viewingKey, spendingKey, ownerPk: deriveOwnerPk(spendingKey) },
6661
+ keys: {
6662
+ viewingKey: identity.viewingSecretKey,
6663
+ spendingKey: identity.spendingKey,
6664
+ ownerPk: identity.ownerPk,
6665
+ // Absent on a watch-only identity: the scan then finds every
6666
+ // note the wallet owns and reconstructs no payment history.
6667
+ ...identity.outgoingViewingKey ? { outgoingViewingKey: identity.outgoingViewingKey } : {}
6668
+ },
6431
6669
  sinceLeafIndex: options.sinceLeafIndex,
6432
6670
  viewTagActivationLeaf: this.config.viewTagActivationLeaf,
6433
6671
  onProgress: options.onProgress,
@@ -6449,13 +6687,14 @@ var OrbinumWallet = class {
6449
6687
  * reusing an index would republish an ephPk and link the two notes.
6450
6688
  */
6451
6689
  async buildNote(params) {
6452
- const spendingKey = this.requireKey();
6690
+ const identity = this.requireIdentity();
6453
6691
  return buildZkNote(params, {
6454
6692
  keys: {
6455
- ownerPk: deriveOwnerPk(spendingKey),
6456
- spendingKey,
6457
- viewingPublicKey: deriveViewingPublicKey(deriveViewingSecretKey(spendingKey)),
6458
- viewingSecretKey: deriveViewingSecretKey(spendingKey)
6693
+ ownerPk: identity.ownerPk,
6694
+ spendingKey: identity.spendingKey,
6695
+ viewingPublicKey: identity.viewingPublicKey,
6696
+ viewingSecretKey: identity.viewingSecretKey,
6697
+ ...identity.outgoingViewingKey ? { outgoingViewingKey: identity.outgoingViewingKey } : {}
6459
6698
  },
6460
6699
  storage: this.config.storage
6461
6700
  });
@@ -6472,11 +6711,12 @@ var OrbinumWallet = class {
6472
6711
  * Treat the result as secret: it can spend every note this wallet holds.
6473
6712
  */
6474
6713
  spendKeys() {
6475
- const spendingKey = this.requireKey();
6714
+ const identity = this.requireIdentity();
6476
6715
  return {
6477
- spendingKey,
6478
- viewingSecretKey: deriveViewingSecretKey(spendingKey),
6479
- ownerPk: deriveOwnerPk(spendingKey)
6716
+ spendingKey: identity.spendingKey,
6717
+ viewingSecretKey: identity.viewingSecretKey,
6718
+ ownerPk: identity.ownerPk,
6719
+ ...identity.outgoingViewingKey ? { outgoingViewingKey: identity.outgoingViewingKey } : {}
6480
6720
  };
6481
6721
  }
6482
6722
  /**
@@ -6498,7 +6738,20 @@ var OrbinumWallet = class {
6498
6738
  * verifying-key rotation, and the failure surfaces much later.
6499
6739
  */
6500
6740
  buildOutputNote = async (params) => {
6501
- return this.buildNote({ ...params, circuitVersion: await this.circuitVersion() });
6741
+ const identity = this.requireIdentity();
6742
+ return buildZkNoteWithIndex(
6743
+ { ...params, circuitVersion: await this.circuitVersion() },
6744
+ {
6745
+ keys: {
6746
+ ownerPk: identity.ownerPk,
6747
+ spendingKey: identity.spendingKey,
6748
+ viewingPublicKey: identity.viewingPublicKey,
6749
+ viewingSecretKey: identity.viewingSecretKey,
6750
+ ...identity.outgoingViewingKey ? { outgoingViewingKey: identity.outgoingViewingKey } : {}
6751
+ },
6752
+ storage: this.config.storage
6753
+ }
6754
+ );
6502
6755
  };
6503
6756
  /** The circuit version a new note should carry. */
6504
6757
  async circuitVersion() {
@@ -6510,11 +6763,11 @@ var OrbinumWallet = class {
6510
6763
  }
6511
6764
  return chainActiveCircuitVersion(this.config.zkVerifier);
6512
6765
  }
6513
- requireKey() {
6514
- if (this.spendingKey === null) {
6766
+ requireIdentity() {
6767
+ if (this.identity === null) {
6515
6768
  throw new Error("Wallet is locked. Call unlock() before using it.");
6516
6769
  }
6517
- return this.spendingKey;
6770
+ return this.identity;
6518
6771
  }
6519
6772
  };
6520
6773
  export {
@@ -6545,9 +6798,8 @@ export {
6545
6798
  NATIVE_ASSET_ID,
6546
6799
  NOTE_BACKUP_VERSION,
6547
6800
  NOTE_BIGINT_FIELDS,
6548
- NOTE_TRANSFER_URI_SCHEME,
6549
6801
  NoteBuilder,
6550
- OVK_BLOB_SIZE,
6802
+ OUTGOING_EPH_WINDOW,
6551
6803
  OrbinumClient,
6552
6804
  OrbinumClientProvider,
6553
6805
  OrbinumWallet,
@@ -6557,7 +6809,6 @@ export {
6557
6809
  PRECOMPILE_ADDR,
6558
6810
  PrivacyKeyManager,
6559
6811
  PrivacyModule,
6560
- QR_PAGE_MAX_CHARS,
6561
6812
  RECOVERED_TX_RESULT,
6562
6813
  RelayerStatusModule,
6563
6814
  RpcV2Module,
@@ -6582,7 +6833,7 @@ export {
6582
6833
  addressToFieldElement,
6583
6834
  applyBatch,
6584
6835
  applyNoteStatus,
6585
- assembleNoteTransfer,
6836
+ assertSecretKeyBytes,
6586
6837
  base58,
6587
6838
  base64UrlDecode,
6588
6839
  base64UrlEncode,
@@ -6595,6 +6846,7 @@ export {
6595
6846
  buildShieldBatchOperations,
6596
6847
  buildShieldParams,
6597
6848
  buildZkNote,
6849
+ buildZkNoteWithIndex,
6598
6850
  bytesToBigintLE,
6599
6851
  bytesToBjjScalar,
6600
6852
  cacheSession,
@@ -6624,15 +6876,17 @@ export {
6624
6876
  createWorkerPool,
6625
6877
  decodeNoteBackup,
6626
6878
  decodeNoteDisclosureKey,
6627
- decodeNoteTransferPage,
6628
6879
  decodePaymentSlip,
6629
6880
  decodePrecompileCalldata,
6630
6881
  decryptHintBatch,
6631
6882
  decryptJson,
6632
6883
  decryptNoteRecord,
6884
+ deriveIdentity,
6633
6885
  deriveMasterKeyBytes,
6634
- deriveOutgoingCipherKey,
6635
- deriveOutgoingViewingKey,
6886
+ deriveOutgoingEphPk,
6887
+ deriveOutgoingEphSk,
6888
+ deriveOutgoingSharedSecret,
6889
+ deriveOutgoingViewingKeyV3,
6636
6890
  deriveOwnerPk,
6637
6891
  derivePairwiseEphSk,
6638
6892
  derivePairwiseSharedSecret,
@@ -6641,6 +6895,7 @@ export {
6641
6895
  deriveSpendingKeyFromSignature,
6642
6896
  deriveSpendingKeyMessageV2,
6643
6897
  deriveSpendingKeyTypedData,
6898
+ deriveSpendingKeyV3,
6644
6899
  deriveStealthOwnerPk,
6645
6900
  deriveStealthSk,
6646
6901
  deriveVaultBlindKey,
@@ -6648,9 +6903,9 @@ export {
6648
6903
  deriveViewTag,
6649
6904
  deriveViewingPublicKey,
6650
6905
  deriveViewingSecretKey,
6906
+ deriveViewingSecretKeyV3,
6651
6907
  detectCommitmentMismatch,
6652
6908
  encodeNoteBackup,
6653
- encodeNoteTransferPages,
6654
6909
  encodePaymentSlip,
6655
6910
  encryptJson,
6656
6911
  encryptNote,
@@ -6660,6 +6915,7 @@ export {
6660
6915
  evmToImplicitSubstrate,
6661
6916
  evmToMappedAccountHex,
6662
6917
  evmToSubstrate,
6918
+ exportViewingCredential,
6663
6919
  extractPalletError,
6664
6920
  failed,
6665
6921
  fastMulBase,
@@ -6700,9 +6956,9 @@ export {
6700
6956
  isNativeAsset,
6701
6957
  isNoteSelfConsistent,
6702
6958
  isSpendable,
6703
- isSs58,
6704
6959
  isSubstrateAddress,
6705
6960
  isUnifiedAddress,
6961
+ isUsableSecretKey,
6706
6962
  isValidLeafIndex,
6707
6963
  leHexToBigint,
6708
6964
  mapExtrinsicArgs,
@@ -6719,10 +6975,10 @@ export {
6719
6975
  noteMatchesCommitment,
6720
6976
  noteOrigin,
6721
6977
  noteSpentTxHash,
6722
- noteToTransferEntry,
6723
6978
  noteTxKind,
6724
- openOutgoingBlob,
6725
6979
  openPaymentSlip,
6980
+ openRecipientBookEntry,
6981
+ outgoingEphWindow,
6726
6982
  outranks,
6727
6983
  pairwiseEphWindow,
6728
6984
  palletErrorKind,
@@ -6733,15 +6989,18 @@ export {
6733
6989
  planTransfer,
6734
6990
  planUnshield,
6735
6991
  randomBlinding,
6736
- randomOutgoingBlob,
6992
+ reconstructOutgoingIndex,
6737
6993
  reconstructOutgoingTxRecords,
6738
6994
  recoverOwnerPkPoint,
6739
6995
  recoverSelfStealthNote,
6996
+ recoverSentFromSharedSecret,
6997
+ recoverSentNote,
6740
6998
  refuseIfAlreadySpent,
6741
6999
  regeneratePaymentSlip,
7000
+ registerPairwiseCounterparty,
6742
7001
  removeByCommitment,
6743
7002
  requireSessionKeys,
6744
- reservePairwiseIndex,
7003
+ reserveOutgoingIndex,
6745
7004
  reserveSelfEphIndex,
6746
7005
  resolveSelfEphCeiling,
6747
7006
  resolveSpentSet,
@@ -6750,8 +7009,8 @@ export {
6750
7009
  runScan,
6751
7010
  scalarToHex,
6752
7011
  scanAbortError,
6753
- sealOutgoingBlob,
6754
7012
  sealPaymentSlip,
7013
+ sealRecipientBookEntry,
6755
7014
  selectDescribingNote,
6756
7015
  selectDescribingNoteByCommitment,
6757
7016
  selectGhosts,
@@ -6777,10 +7036,10 @@ export {
6777
7036
  truncateMiddle,
6778
7037
  tryDecryptNote,
6779
7038
  tryDecryptNoteVerbose,
6780
- tryRecoverOutgoing,
6781
7039
  txLandedAfterError,
6782
7040
  u128,
6783
7041
  u64,
7042
+ unpackUsableViewingKey,
6784
7043
  unshieldNote,
6785
7044
  upsertNote,
6786
7045
  vaultReplacer,