@orbinum/sdk 2.1.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;
1299
- } catch {
1300
- return false;
1301
- }
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;
1338
+ if (b.length < 5 || b[2] !== TIMESTAMP_PALLET || b[3] !== 0) return null;
1339
+ return decodeCompact(b, 4);
1311
1340
  } catch {
1341
+ return null;
1312
1342
  }
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) {
@@ -2346,7 +2383,13 @@ var PrivacyModule = class {
2346
2383
  async getMerkleRoot() {
2347
2384
  return this.substrate.request("privacy_getMerkleRoot", []);
2348
2385
  }
2349
- /** 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
+ */
2350
2393
  async getMerkleProof(leafIndex) {
2351
2394
  const raw = await this.substrate.request("privacy_getMerkleProof", [
2352
2395
  leafIndex
@@ -3296,9 +3339,13 @@ var OrbinumClientProvider = class _OrbinumClientProvider {
3296
3339
  // ─── Reconnection ───────────────────────────────────────────────────────
3297
3340
  /**
3298
3341
  * Schedules the next connection attempt using exponential backoff
3299
- * (capped at `reconnectMaxMs`) with full jitter, then transitions to
3300
- * `'reconnecting'`. Jitter (a random fraction of the delay) spreads out
3301
- * 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.
3302
3349
  */
3303
3350
  scheduleReconnect() {
3304
3351
  if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
@@ -3690,6 +3737,16 @@ function mapZkEventData(method, data) {
3690
3737
  index: get(4, "leaf_index") || get(4, "index")
3691
3738
  };
3692
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
+ }
3693
3750
  if (m === "privatetransfer") {
3694
3751
  return {
3695
3752
  nullifiers: get(0, "nullifiers"),
@@ -3731,12 +3788,12 @@ function mapZkEventData(method, data) {
3731
3788
  if (m_norm === "log") {
3732
3789
  return { log: get(0, "log") };
3733
3790
  }
3734
- if (m_norm === "created" || m_norm === "createdfailed" || m_norm === "executed" || m_norm === "executedfailed") {
3791
+ if (m_norm === "created" || m_norm === "createdfailed" || m_norm === "executedfailed") {
3735
3792
  return { address: get(0, "address") };
3736
3793
  }
3737
3794
  if (m_norm === "executed") {
3738
3795
  return {
3739
- from: get(0, "from"),
3796
+ from: get(0, "from") ?? get(0, "address"),
3740
3797
  to: get(1, "to"),
3741
3798
  transaction_hash: get(2, "transaction_hash"),
3742
3799
  exit_reason: get(3, "exit_reason")
@@ -3883,10 +3940,10 @@ var ERROR_KINDS = {
3883
3940
  CommitmentAlreadyExists: "shape",
3884
3941
  UnknownMerkleRoot: "stale-proof",
3885
3942
  // Amounts and fees
3886
- AmountTooSmall: "amount",
3887
3943
  InvalidAmount: "amount",
3888
3944
  InsufficientPoolBalance: "amount",
3889
3945
  FeeTooLow: "amount",
3946
+ InsufficientPendingFees: "amount",
3890
3947
  // Proofs
3891
3948
  InvalidProof: "proof",
3892
3949
  ProofVerificationFailed: "proof",
@@ -3895,12 +3952,14 @@ var ERROR_KINDS = {
3895
3952
  InvalidAssetId: "asset",
3896
3953
  AssetNotVerified: "asset",
3897
3954
  AssetIdMismatch: "asset",
3955
+ AssetIdAlreadyExists: "asset",
3898
3956
  // Request shape
3899
3957
  InvalidMemoSize: "shape",
3900
3958
  MemoCommitmentMismatch: "shape",
3901
3959
  TooManyInputsOrOutputs: "shape",
3902
3960
  InvalidRecipient: "shape",
3903
3961
  EmptyBatch: "shape",
3962
+ FeeRecipientUnavailable: "shape",
3904
3963
  // Capacity
3905
3964
  MerkleTreeFull: "capacity",
3906
3965
  // Balances pallet, reached through the same path
@@ -4259,6 +4318,7 @@ function buildConfig(existing, chainFingerprint) {
4259
4318
  updatedAt: now,
4260
4319
  ...chainFingerprint ? { chainFingerprint } : {},
4261
4320
  ...existing?.selfEphCounter !== void 0 ? { selfEphCounter: existing.selfEphCounter } : {},
4321
+ ...existing?.outgoingEphCounter !== void 0 ? { outgoingEphCounter: existing.outgoingEphCounter } : {},
4262
4322
  ...existing?.pairwiseCounterparties !== void 0 ? { pairwiseCounterparties: existing.pairwiseCounterparties } : {}
4263
4323
  };
4264
4324
  }
@@ -4270,6 +4330,7 @@ async function writeConfig(storage, fallback, chainFingerprint) {
4270
4330
  }
4271
4331
  function mergeCounters(current, fallback) {
4272
4332
  const selfEphCounter = higher(current.selfEphCounter, fallback?.selfEphCounter);
4333
+ const outgoingEphCounter = higher(current.outgoingEphCounter, fallback?.outgoingEphCounter);
4273
4334
  const parties = mergePairwise(current.pairwiseCounterparties, fallback?.pairwiseCounterparties);
4274
4335
  return {
4275
4336
  createdAt: current.createdAt ?? fallback?.createdAt,
@@ -4277,6 +4338,7 @@ function mergeCounters(current, fallback) {
4277
4338
  // is exactOptionalPropertyTypes, so an explicit undefined is a type
4278
4339
  // error AND would round-trip through structured clone as a present key.
4279
4340
  ...selfEphCounter !== void 0 ? { selfEphCounter } : {},
4341
+ ...outgoingEphCounter !== void 0 ? { outgoingEphCounter } : {},
4280
4342
  ...parties !== void 0 ? { pairwiseCounterparties: parties } : {}
4281
4343
  };
4282
4344
  }
@@ -4314,6 +4376,7 @@ function mergePairwise(current, fallback) {
4314
4376
 
4315
4377
  // src/wallet/vault/storage/ephemeralIndex.ts
4316
4378
  var MAX_EPH_INDEX = 4294967294;
4379
+ var RESTORE_SAFETY_GAP = 8;
4317
4380
  function usableCounter(value) {
4318
4381
  if (typeof value !== "number" || !Number.isInteger(value)) return null;
4319
4382
  if (value < 0 || value > MAX_EPH_INDEX) return null;
@@ -4328,27 +4391,51 @@ async function reserveSelfEphIndex(storage) {
4328
4391
  if (!updated) throw new Error("vault not initialized");
4329
4392
  return (usableCounter(updated.selfEphCounter) ?? 1) - 1;
4330
4393
  }
4331
- 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) {
4332
4423
  const key = ivkHex.toLowerCase();
4333
4424
  const updated = await storage.updateConfig((config) => {
4334
- const stored = usableCounter(config.pairwiseCounterparties?.[key]?.nextIndex);
4335
- const addedAt = config.pairwiseCounterparties?.[key]?.addedAt;
4425
+ const existing = config.pairwiseCounterparties?.[key];
4336
4426
  return {
4337
4427
  ...config,
4338
4428
  pairwiseCounterparties: {
4339
4429
  ...config.pairwiseCounterparties,
4340
4430
  [key]: {
4341
- nextIndex: (stored ?? 0) + 1,
4342
- addedAt: typeof addedAt === "number" ? addedAt : Date.now()
4431
+ nextIndex: usableCounter(existing?.nextIndex) ?? 0,
4432
+ addedAt: typeof existing?.addedAt === "number" ? existing.addedAt : Date.now()
4343
4433
  }
4344
4434
  },
4345
4435
  updatedAt: Date.now()
4346
4436
  };
4347
4437
  });
4348
4438
  if (!updated) throw new Error("vault not initialized");
4349
- const nextIndex = usableCounter(updated.pairwiseCounterparties?.[key]?.nextIndex);
4350
- if (nextIndex === null || nextIndex <= 1) return null;
4351
- return nextIndex - 1;
4352
4439
  }
4353
4440
 
4354
4441
  // src/wallet/vault/notes/record.ts
@@ -4783,19 +4870,44 @@ async function processHints(ctx, hints, total) {
4783
4870
  }
4784
4871
  }
4785
4872
  outcome.scanned++;
4786
- if (!hint.encryptedMemo || !hint.ephPkHex) {
4873
+ if (!hint.encryptedMemo) {
4787
4874
  outcome.noMemo++;
4788
4875
  continue;
4789
4876
  }
4790
4877
  toDecrypt.push(hint);
4791
4878
  }
4792
- 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
+ );
4793
4895
  outcome.tagFiltered += tagFiltered;
4794
4896
  outcome.selfDiscovered += selfMatched;
4795
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);
4796
4902
  if (maxSelfEphIndex !== null) {
4797
4903
  outcome.maxSelfEphIndex = Math.max(outcome.maxSelfEphIndex ?? -1, maxSelfEphIndex);
4798
4904
  }
4905
+ if (maxOutgoingEphIndex != null) {
4906
+ outcome.maxOutgoingEphIndex = Math.max(
4907
+ outcome.maxOutgoingEphIndex ?? -1,
4908
+ maxOutgoingEphIndex
4909
+ );
4910
+ }
4799
4911
  const pageEntries = [];
4800
4912
  for (const [i, decrypted] of notes.entries()) {
4801
4913
  const hint = toDecrypt[i];
@@ -4892,7 +5004,12 @@ async function collectScanEntries(params) {
4892
5004
  tagFiltered: 0,
4893
5005
  selfDiscovered: 0,
4894
5006
  pairwiseDiscovered: 0,
4895
- 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
4896
5013
  };
4897
5014
  const ctx = {
4898
5015
  outcome,
@@ -5114,7 +5231,7 @@ function gapMargin(windowSize) {
5114
5231
  return Math.max(1, Math.floor(windowSize / 16));
5115
5232
  }
5116
5233
  function resolveSelfEphCeiling(params) {
5117
- const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
5234
+ const { notes, viewingKey, scanMaxIndex } = params;
5118
5235
  const windowSize = params.windowSize !== void 0 && params.windowSize > 0 ? params.windowSize : SELF_EPH_WINDOW;
5119
5236
  if (scanMaxIndex === null) return null;
5120
5237
  if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
@@ -5127,7 +5244,7 @@ function resolveSelfEphCeiling(params) {
5127
5244
  let max = scanMaxIndex;
5128
5245
  let from = windowSize;
5129
5246
  for (; ; ) {
5130
- const slice = selfEphWindow(spendingKey, ivkPacked, from, windowSize);
5247
+ const slice = selfEphWindow(viewingKey, ivkPacked, from, windowSize);
5131
5248
  let matched = false;
5132
5249
  for (const entry of slice) {
5133
5250
  if (ownEphPks.has(entry.ephPkHex.toLowerCase())) {
@@ -5183,6 +5300,8 @@ async function resolveSpentStatus(params) {
5183
5300
  }
5184
5301
 
5185
5302
  // src/wallet/scanner/pipeline.ts
5303
+ var OUTGOING_RESCAN_GAP = 8;
5304
+ var RETRY_BOOK_LIMIT = 64;
5186
5305
  async function runScan(params) {
5187
5306
  const { vault, storage, hints, nullifiers, pool, keys, sinceLeafIndex, signal, onWarning } = params;
5188
5307
  const isIncremental = sinceLeafIndex !== void 0;
@@ -5190,6 +5309,7 @@ async function runScan(params) {
5190
5309
  const preScanHexes = new Set(existingHexes);
5191
5310
  const config = await storage.getConfig().catch(() => null);
5192
5311
  const selfEphWindowSize = windowSizeForCounter(config?.selfEphCounter ?? 0);
5312
+ const outgoingEphWindowSize = windowSizeForCounter(config?.outgoingEphCounter ?? 0);
5193
5313
  const pairwiseCounterparties = Object.keys(config?.pairwiseCounterparties ?? {}).flatMap(
5194
5314
  (hex) => {
5195
5315
  try {
@@ -5218,7 +5338,12 @@ async function runScan(params) {
5218
5338
  // worth it on full scans and restores, waste on small incremental ticks.
5219
5339
  selfEph: !isIncremental,
5220
5340
  selfEphWindowSize,
5221
- 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
5222
5347
  },
5223
5348
  existingHexes,
5224
5349
  sinceLeafIndex,
@@ -5232,6 +5357,49 @@ async function runScan(params) {
5232
5357
  onPage: (entries) => checkpoint.savePageNotes(entries),
5233
5358
  onBatchDone: (maxLeafIndex) => checkpoint.advanceCursor(maxLeafIndex)
5234
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
+ }
5235
5403
  const nullifiersToQuery = collectNullifiersToQuery(
5236
5404
  scan.scanEntries,
5237
5405
  scan.onChainHexes,
@@ -5261,7 +5429,6 @@ async function runScan(params) {
5261
5429
  try {
5262
5430
  ceiling = resolveSelfEphCeiling({
5263
5431
  notes: vault.getAll(),
5264
- spendingKey: keys.spendingKey,
5265
5432
  viewingKey: keys.viewingKey,
5266
5433
  scanMaxIndex: scan.maxSelfEphIndex,
5267
5434
  windowSize: selfEphWindowSize
@@ -5271,6 +5438,13 @@ async function runScan(params) {
5271
5438
  await bumpSelfEphCounterTo(storage, ceiling + 1).catch(() => {
5272
5439
  });
5273
5440
  }
5441
+ if (scan.maxOutgoingEphIndex !== null) {
5442
+ await bumpOutgoingCounterTo(
5443
+ storage,
5444
+ scan.maxOutgoingEphIndex + 1 + OUTGOING_RESCAN_GAP
5445
+ ).catch(() => {
5446
+ });
5447
+ }
5274
5448
  if (scan.decryptFailed > 0 && scan.found === 0 && scan.alreadyPresent === 0) {
5275
5449
  onWarning?.(
5276
5450
  `no notes recovered from ${scan.decryptFailed} hint(s) with memos; if this wallet should hold notes, check the keys it was unlocked with`
@@ -5282,7 +5456,13 @@ async function runScan(params) {
5282
5456
  decryptFailed: scan.decryptFailed,
5283
5457
  alreadyPresent: scan.alreadyPresent,
5284
5458
  purged,
5285
- incremental: isIncremental
5459
+ incremental: isIncremental,
5460
+ sentNotes: scan.sentNotes,
5461
+ discovery: {
5462
+ self: scan.selfDiscovered,
5463
+ pairwise: scan.pairwiseDiscovered,
5464
+ tagFiltered: scan.tagFiltered
5465
+ }
5286
5466
  };
5287
5467
  }
5288
5468
  async function bumpSelfEphCounterTo(storage, next) {
@@ -5290,6 +5470,11 @@ async function bumpSelfEphCounterTo(storage, next) {
5290
5470
  (config) => (config.selfEphCounter ?? 0) >= next ? config : { ...config, selfEphCounter: next, updatedAt: Date.now() }
5291
5471
  );
5292
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
+ }
5293
5478
 
5294
5479
  // src/wallet/scanner/history/extrinsicFacts.ts
5295
5480
  var UNKNOWN = { fee: null, success: true };
@@ -5428,10 +5613,8 @@ function regeneratePaymentSlip(facts, recipientIvkPacked, txHash) {
5428
5613
  commitmentHex: facts.commitmentHex,
5429
5614
  encryptedMemo: facts.encryptedMemo,
5430
5615
  ...facts.leafIndex !== void 0 ? { leafIndex: facts.leafIndex } : {},
5431
- // Rendered as an explorer link by the recipient, where an unconstrained
5616
+ // The recipient renders this as an explorer link, so an unconstrained
5432
5617
  // string is a URL injection wearing the authority of a decrypted slip.
5433
- // Dropped rather than fatal: the slip still rebuilds the note, which is
5434
- // the part that matters.
5435
5618
  ...isHexOfLength(txHash, 32) ? { txHash } : {}
5436
5619
  });
5437
5620
  return encodePaymentSlip(envelope);
@@ -5456,15 +5639,37 @@ async function loadExistingRecords(vault) {
5456
5639
  function recordKey(transfer) {
5457
5640
  return transfer.hash ?? `${transfer.blockNumber}-${transfer.extrinsicIndex ?? 0}`;
5458
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
+ }
5459
5663
  async function reconstructOutgoingTxRecords(deps) {
5460
5664
  const { vault, transfers } = deps;
5461
5665
  const now = deps.now ?? Date.now;
5462
5666
  const allNotes = vault.getAll();
5463
5667
  const spentNotes = allNotes.filter((n) => n.spent);
5464
5668
  if (spentNotes.length === 0) return;
5669
+ const sentCommitments = (deps.sentNotes ?? []).map((s) => s.commitmentHex);
5465
5670
  const [outgoingTransfers, commitmentTransfers] = await Promise.all([
5466
5671
  transfers.byNullifiers(spentNotes.map((n) => n.nullifierHex)),
5467
- transfers.byCommitments(allNotes.map((n) => n.commitmentHex))
5672
+ transfers.byCommitments([...allNotes.map((n) => n.commitmentHex), ...sentCommitments])
5468
5673
  ]);
5469
5674
  if (outgoingTransfers.length === 0) return;
5470
5675
  const noteByNullifier = new Map(spentNotes.map((n) => [n.nullifierHex, n]));
@@ -5473,64 +5678,85 @@ async function reconstructOutgoingTxRecords(deps) {
5473
5678
  commitmentTransfers.map((ct) => [extrinsicKey(ct), ct.matchedCommitments ?? []])
5474
5679
  );
5475
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);
5476
5684
  for (const transfer of outgoingTransfers) {
5477
5685
  const existing = existingByKey.get(recordKey(transfer));
5478
- if (existing?.recipientPkHex && existing.recipientPkHex !== ZERO_PK) continue;
5686
+ const known = existing?.recipientPkHex && existing.recipientPkHex !== ZERO_PK;
5687
+ if (known && existing?.paymentSlip) continue;
5479
5688
  const inputNotes = (transfer.matchedNullifiers ?? []).map((h) => noteByNullifier.get(h)).filter((n) => n !== void 0);
5480
5689
  if (inputNotes.length === 0) continue;
5481
5690
  const changeNote = selectDescribingNoteByCommitment(
5482
5691
  commitmentsByExtrinsic.get(extrinsicKey(transfer)) ?? [],
5483
5692
  noteByCommitment
5484
5693
  );
5485
- const recipientPkHex = toPkHex(changeNote?.sourcePk);
5694
+ const sentForRow = sentFor(transfer);
5695
+ const recipientPkHex = sentForRow ? toPkHex(sentForRow.recipientStealthPk) : recoveryEnabled ? ZERO_PK : toPkHex(changeNote?.sourcePk);
5486
5696
  if (existing) {
5487
- if (recipientPkHex === ZERO_PK) continue;
5488
- 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
+ }
5489
5707
  continue;
5490
5708
  }
5491
5709
  const { fee, success } = await fetchExtrinsicFacts(deps.txFacts, transfer.hash);
5492
5710
  const totalInputValue = inputNotes.reduce((sum, n) => sum + n.value, 0n);
5493
- const transferAmount = totalInputValue - (changeNote?.value ?? 0n) - (fee ?? 0n);
5711
+ const derivedAmount = totalInputValue - (changeNote?.value ?? 0n) - (fee ?? 0n);
5712
+ const transferAmount = sentForRow?.value ?? derivedAmount;
5494
5713
  if (transferAmount <= 0n) continue;
5495
5714
  const record = {
5496
5715
  id: recordKey(transfer),
5497
5716
  type: "private_transfer",
5498
5717
  blockNumber: transfer.blockNumber,
5499
5718
  hash: transfer.hash ?? "",
5500
- assetId: inputNotes[0].assetId.toString(),
5719
+ assetId: (sentForRow?.assetId ?? inputNotes[0].assetId).toString(),
5501
5720
  amount: transferAmount.toString(),
5502
5721
  recipientPkHex,
5503
5722
  status: success ? "success" : "failed",
5504
- ...fee !== null ? { feePlanck: fee.toString() } : { amountApproximate: true },
5505
- 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) : {}
5506
5729
  };
5507
5730
  await vault.saveTxRecord(record);
5508
5731
  }
5509
5732
  }
5510
5733
 
5511
5734
  // src/wallet/ops/notes/buildNote.ts
5512
- async function buildZkNote(params, deps) {
5735
+ async function buildZkNoteWithIndex(params, deps) {
5513
5736
  const { keys, storage } = deps;
5514
5737
  const ownerPk = params.ownerPk ?? keys.ownerPk;
5515
5738
  const spendingKey = params.spendingKey ?? keys.spendingKey;
5516
5739
  const viewingPublicKey = params.viewingPublicKey ?? keys.viewingPublicKey;
5517
5740
  const isSelf = params.viewingPublicKey === void 0 && params.recipientOwnerPk === void 0;
5518
5741
  let ephSkOverride;
5519
- if (isSelf && storage) {
5742
+ let outgoingIndex;
5743
+ if (isSelf && storage && keys.viewingSecretKey) {
5520
5744
  try {
5521
- ephSkOverride = deriveSelfEphSk(spendingKey, await reserveSelfEphIndex(storage));
5745
+ ephSkOverride = deriveSelfEphSk(
5746
+ keys.viewingSecretKey,
5747
+ await reserveSelfEphIndex(storage)
5748
+ );
5522
5749
  } catch {
5523
5750
  }
5524
- } else if (params.viewingPublicKey && storage && keys.viewingSecretKey) {
5751
+ } else if (params.viewingPublicKey && storage && keys.outgoingViewingKey) {
5525
5752
  try {
5526
- const index = await reservePairwiseIndex(storage, toHex(params.viewingPublicKey));
5527
- if (index !== null) {
5528
- const pairSecret = derivePairwiseSharedSecret(
5529
- keys.viewingSecretKey,
5530
- params.viewingPublicKey
5531
- );
5532
- ephSkOverride = derivePairwiseEphSk(pairSecret, index);
5533
- }
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));
5534
5760
  } catch {
5535
5761
  }
5536
5762
  }
@@ -5550,7 +5776,13 @@ async function buildZkNote(params, deps) {
5550
5776
  },
5551
5777
  ...ephSkOverride !== void 0 && { ephSkOverride }
5552
5778
  });
5553
- 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;
5554
5786
  }
5555
5787
 
5556
5788
  // src/wallet/ops/notes/selfStealthNote.ts
@@ -5569,89 +5801,6 @@ function recoverSelfStealthNote(note, keys) {
5569
5801
  return recovered;
5570
5802
  }
5571
5803
 
5572
- // src/wallet/ops/notes/noteTransfer.ts
5573
- var NOTE_TRANSFER_URI_SCHEME = "orbinum://notes/v1/";
5574
- var QR_PAGE_MAX_CHARS = 1800;
5575
- function noteToTransferEntry(note) {
5576
- return {
5577
- c: note.commitmentHex,
5578
- n: note.nullifierHex,
5579
- val: note.value.toString(),
5580
- aid: note.assetId.toString(),
5581
- opk: scalarToHex(note.ownerPk),
5582
- bld: scalarToHex(note.blinding),
5583
- sk: scalarToHex(note.spendingKey)
5584
- };
5585
- }
5586
- function encodeNoteTransferPages(notes, options = {}) {
5587
- const now = options.now ?? Date.now;
5588
- const maxChars = options.maxChars ?? QR_PAGE_MAX_CHARS;
5589
- const entries = notes.map(noteToTransferEntry);
5590
- const batches = [];
5591
- let batch = [];
5592
- for (const entry of entries) {
5593
- batch.push(entry);
5594
- const probe = { v: 1, ts: 0, p: 0, pt: 1, notes: batch };
5595
- const size = NOTE_TRANSFER_URI_SCHEME.length + base64UrlEncode(JSON.stringify(probe)).length;
5596
- if (size > maxChars && batch.length > 1) {
5597
- batch.pop();
5598
- batches.push(batch);
5599
- batch = [entry];
5600
- }
5601
- }
5602
- if (batch.length > 0) batches.push(batch);
5603
- const ts = now();
5604
- return batches.map((notesInPage, i) => {
5605
- const payload = {
5606
- v: 1,
5607
- ts,
5608
- p: i,
5609
- pt: batches.length,
5610
- notes: notesInPage
5611
- };
5612
- return NOTE_TRANSFER_URI_SCHEME + base64UrlEncode(JSON.stringify(payload));
5613
- });
5614
- }
5615
- function decodeNoteTransferPage(uri) {
5616
- if (!uri.startsWith(NOTE_TRANSFER_URI_SCHEME)) {
5617
- throw new Error("Not an Orbinum note-transfer code.");
5618
- }
5619
- let payload;
5620
- try {
5621
- payload = JSON.parse(base64UrlDecode(uri.slice(NOTE_TRANSFER_URI_SCHEME.length)));
5622
- } catch {
5623
- throw new Error("Note-transfer code is corrupt.");
5624
- }
5625
- const p = payload;
5626
- if (p.v !== 1) {
5627
- throw new Error(`Unsupported note-transfer version: ${String(p.v)}.`);
5628
- }
5629
- if (typeof p.p !== "number" || typeof p.pt !== "number" || !Array.isArray(p.notes) || p.notes.some((n) => !isEntry(n))) {
5630
- throw new Error("Note-transfer code is missing required fields.");
5631
- }
5632
- return p;
5633
- }
5634
- function isEntry(value) {
5635
- if (typeof value !== "object" || value === null) return false;
5636
- const e = value;
5637
- return ["c", "n", "val", "aid", "opk", "bld", "sk"].every(
5638
- (k) => typeof e[k] === "string" && e[k].length > 0
5639
- );
5640
- }
5641
- function assembleNoteTransfer(pages) {
5642
- if (pages.length === 0) throw new Error("No pages scanned.");
5643
- const total = pages[0].pt;
5644
- if (pages.some((p) => p.pt !== total)) {
5645
- throw new Error("Pages come from different batches.");
5646
- }
5647
- const seen = new Set(pages.map((p) => p.p));
5648
- if (seen.size !== total) {
5649
- const missing = [...Array(total).keys()].filter((i) => !seen.has(i));
5650
- throw new Error(`Missing page(s): ${missing.map((i) => i + 1).join(", ")} of ${total}.`);
5651
- }
5652
- return [...pages].sort((a, b) => a.p - b.p).flatMap((p) => p.notes);
5653
- }
5654
-
5655
5804
  // src/wallet/ops/notes/noteBackup.ts
5656
5805
  var NOTE_BACKUP_VERSION = 1;
5657
5806
  function noteToBackupEntry(note) {
@@ -5686,12 +5835,13 @@ function decodeNoteBackup(json) {
5686
5835
  if (p.v !== NOTE_BACKUP_VERSION) {
5687
5836
  throw new Error(`Unsupported note-backup version: ${String(p.v)}.`);
5688
5837
  }
5689
- if (!Array.isArray(p.notes) || p.notes.some((n) => !isEntry2(n))) {
5838
+ if (!Array.isArray(p.notes) || p.notes.some((n) => !isEntry(n))) {
5690
5839
  throw new Error("Note backup is missing required fields.");
5691
5840
  }
5692
5841
  return p.notes;
5693
5842
  }
5694
5843
  function importNotesFromBackup(entries, keys) {
5844
+ assertSecretKeyBytes(keys.viewingSecretKey, "importNotesFromBackup: viewingSecretKey");
5695
5845
  const out = [];
5696
5846
  for (const entry of entries) {
5697
5847
  const commitment = {
@@ -5715,12 +5865,14 @@ function importNotesFromBackup(entries, keys) {
5715
5865
  }
5716
5866
  return out;
5717
5867
  }
5718
- function isEntry2(value) {
5868
+ function isEntry(value) {
5719
5869
  if (typeof value !== "object" || value === null) return false;
5720
5870
  const e = value;
5721
- if (typeof e["commitmentHex"] !== "string" || e["commitmentHex"].length === 0) return false;
5722
- if (typeof e["encryptedMemo"] !== "string" || e["encryptedMemo"].length === 0) return false;
5723
- 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") {
5724
5876
  return false;
5725
5877
  }
5726
5878
  return true;
@@ -5728,6 +5880,7 @@ function isEntry2(value) {
5728
5880
 
5729
5881
  // src/wallet/ops/notes/paymentSlipImport.ts
5730
5882
  function importPaymentSlip(slip, keys) {
5883
+ assertSecretKeyBytes(keys.viewingSecretKey, "importPaymentSlip: viewingSecretKey");
5731
5884
  const envelope = typeof slip === "string" ? decodePaymentSlip(slip) : slip;
5732
5885
  if (!envelope) return null;
5733
5886
  const fields = openPaymentSlip(keys.viewingSecretKey, envelope);
@@ -5856,10 +6009,7 @@ async function transferNotes(deps, params, onProgress) {
5856
6009
  const merkleRoot = merkleA.root;
5857
6010
  onProgress?.("building-output-notes");
5858
6011
  const effectiveSenderPk = senderPk ?? noteA.ownerPk;
5859
- const senderViewingPublicKey = deriveViewingPublicKey(
5860
- deriveViewingSecretKey(noteA.spendingKey)
5861
- );
5862
- const recipientNote = await deps.buildNote({
6012
+ const { note: recipientNote, outgoingIndex } = await deps.buildNote({
5863
6013
  value: transferAmount,
5864
6014
  assetId: noteA.assetId,
5865
6015
  ownerPk: recipientPk,
@@ -5869,13 +6019,17 @@ async function transferNotes(deps, params, onProgress) {
5869
6019
  // With a viewing key present this activates stealth derivation.
5870
6020
  recipientOwnerPk: recipientPk
5871
6021
  });
5872
- 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({
5873
6030
  value: changeValue,
5874
6031
  assetId: noteA.assetId,
5875
- ownerPk: effectiveSenderPk,
5876
- spendingKey: noteA.spendingKey,
5877
- sourcePk: recipientNote.ownerPk,
5878
- viewingPublicKey: senderViewingPublicKey
6032
+ sourcePk: changeSourcePk
5879
6033
  });
5880
6034
  onProgress?.("generating-zk");
5881
6035
  const noteToInput = (note, proof) => ({
@@ -5992,7 +6146,7 @@ async function unshieldNote(deps, params, onProgress) {
5992
6146
  if (changeValue > 0n) {
5993
6147
  const { ownerPk, spendingKey } = deps.stealthKeys;
5994
6148
  changeBlinding = randomBlinding();
5995
- changeNote = await deps.buildNote({
6149
+ changeNote = (await deps.buildNote({
5996
6150
  value: changeValue,
5997
6151
  assetId: note.assetId,
5998
6152
  ownerPk,
@@ -6000,7 +6154,7 @@ async function unshieldNote(deps, params, onProgress) {
6000
6154
  spendingKey,
6001
6155
  // Activates stealth: the commitment covers a one-time ownerPk.
6002
6156
  recipientOwnerPk: ownerPk
6003
- });
6157
+ })).note;
6004
6158
  }
6005
6159
  const { provider, version: circuitVersion } = await deps.resolver.resolve(
6006
6160
  CircuitType6.Unshield,
@@ -6286,8 +6440,9 @@ var KEY_PREFIX = "orbinum_sk_";
6286
6440
  function isEnvelope(value) {
6287
6441
  return typeof value === "object" && value !== null && value.v === 1 && typeof value.iv === "string" && typeof value.ct === "string";
6288
6442
  }
6289
- function sessionCacheKey(address, chainId) {
6290
- 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)}`;
6291
6446
  }
6292
6447
  async function hasCachedSession(store, address, chainId) {
6293
6448
  return await store.get(sessionCacheKey(address, chainId)) !== null;
@@ -6323,9 +6478,55 @@ async function clearSession(store, address, chainId) {
6323
6478
 
6324
6479
  // src/wallet/identity/vaultName.ts
6325
6480
  var PREFIX = "orbinum-vault";
6326
- 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") {
6327
6493
  const account = canonicalAccountId(address);
6328
- 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
+ };
6329
6530
  }
6330
6531
 
6331
6532
  // src/wallet/identity/injectedExtensions.ts
@@ -6363,25 +6564,36 @@ var OrbinumWallet = class {
6363
6564
  session;
6364
6565
  notes;
6365
6566
  vaultStore;
6366
- /** Set at unlock; every key below is derived from it. */
6367
- 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;
6368
6577
  /** The underlying store, for operations the facade does not cover. */
6369
6578
  get vault() {
6370
6579
  return this.vaultStore;
6371
6580
  }
6372
6581
  get unlocked() {
6373
- return this.session.unlocked && this.spendingKey !== null;
6582
+ return this.session.unlocked && this.identity !== null;
6374
6583
  }
6375
6584
  /**
6376
6585
  * Opens the wallet from the master key bytes a `SpendingKeyRequest`
6377
6586
  * signature produces — the same value `PrivacyKeyManager.getMasterBytes()`
6378
6587
  * returns.
6379
6588
  *
6380
- * Master bytes rather than the spending-key scalar, deliberately: the scalar
6381
- * is the master reduced modulo the curve order, so deriving the vault key
6382
- * from it would tie the encrypted vault to the CURRENT modulus. The master
6383
- * is pre-reduction, which keeps a stored vault readable across a modulus
6384
- * 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.
6385
6597
  *
6386
6598
  * Reports whether the stored vault was RESET: a different chain, a different
6387
6599
  * key, or notes the chain no longer recognises. When it was, the wallet
@@ -6395,14 +6607,12 @@ var OrbinumWallet = class {
6395
6607
  * against a vault that was never read.
6396
6608
  */
6397
6609
  async unlock(masterBytes, options = {}) {
6398
- if (masterBytes.length !== 32) {
6399
- throw new Error(`Master key must be 32 bytes, got ${masterBytes.length}.`);
6400
- }
6610
+ assertSecretKeyBytes(masterBytes, "Master key");
6401
6611
  this.session.open(
6402
6612
  await deriveVaultKey(masterBytes),
6403
6613
  await deriveVaultBlindKey(masterBytes)
6404
6614
  );
6405
- this.spendingKey = deriveSpendingKeyFromMaster(masterBytes);
6615
+ this.identity = deriveIdentity(masterBytes, this.config.identityVersion ?? "v3");
6406
6616
  try {
6407
6617
  return await this.vaultStore.unlock(this.session.cryptoKey, options);
6408
6618
  } catch (err) {
@@ -6413,8 +6623,12 @@ var OrbinumWallet = class {
6413
6623
  /** Drops the in-memory keys. The stored vault is untouched. */
6414
6624
  lock() {
6415
6625
  this.session.lock();
6416
- this.spendingKey = null;
6626
+ this.identity = null;
6417
6627
  this.notes.set([]);
6628
+ try {
6629
+ this.config.pool.terminate();
6630
+ } catch {
6631
+ }
6418
6632
  }
6419
6633
  /** The notes currently held. Empty while locked. */
6420
6634
  getNotes() {
@@ -6426,11 +6640,8 @@ var OrbinumWallet = class {
6426
6640
  }
6427
6641
  /** This wallet's privacy identity: the keys a payer needs to address it. */
6428
6642
  privacyKeys() {
6429
- const spendingKey = this.requireKey();
6430
- return {
6431
- ownerPk: deriveOwnerPk(spendingKey),
6432
- viewingPublicKey: deriveViewingPublicKey(deriveViewingSecretKey(spendingKey))
6433
- };
6643
+ const { ownerPk, viewingPublicKey } = this.requireIdentity();
6644
+ return { ownerPk, viewingPublicKey };
6434
6645
  }
6435
6646
  /**
6436
6647
  * Walks the feed, recovers this wallet's notes, and persists them with their
@@ -6440,15 +6651,21 @@ var OrbinumWallet = class {
6440
6651
  * `sinceLeafIndex` for a full scan, which is what a restore needs.
6441
6652
  */
6442
6653
  async scan(options = {}) {
6443
- const spendingKey = this.requireKey();
6444
- const viewingKey = deriveViewingSecretKey(spendingKey);
6654
+ const identity = this.requireIdentity();
6445
6655
  return runScan({
6446
6656
  vault: this.vaultStore,
6447
6657
  storage: this.config.storage,
6448
6658
  hints: this.config.hints,
6449
6659
  nullifiers: this.config.nullifiers,
6450
6660
  pool: this.config.pool,
6451
- 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
+ },
6452
6669
  sinceLeafIndex: options.sinceLeafIndex,
6453
6670
  viewTagActivationLeaf: this.config.viewTagActivationLeaf,
6454
6671
  onProgress: options.onProgress,
@@ -6470,13 +6687,14 @@ var OrbinumWallet = class {
6470
6687
  * reusing an index would republish an ephPk and link the two notes.
6471
6688
  */
6472
6689
  async buildNote(params) {
6473
- const spendingKey = this.requireKey();
6690
+ const identity = this.requireIdentity();
6474
6691
  return buildZkNote(params, {
6475
6692
  keys: {
6476
- ownerPk: deriveOwnerPk(spendingKey),
6477
- spendingKey,
6478
- viewingPublicKey: deriveViewingPublicKey(deriveViewingSecretKey(spendingKey)),
6479
- 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 } : {}
6480
6698
  },
6481
6699
  storage: this.config.storage
6482
6700
  });
@@ -6493,11 +6711,12 @@ var OrbinumWallet = class {
6493
6711
  * Treat the result as secret: it can spend every note this wallet holds.
6494
6712
  */
6495
6713
  spendKeys() {
6496
- const spendingKey = this.requireKey();
6714
+ const identity = this.requireIdentity();
6497
6715
  return {
6498
- spendingKey,
6499
- viewingSecretKey: deriveViewingSecretKey(spendingKey),
6500
- ownerPk: deriveOwnerPk(spendingKey)
6716
+ spendingKey: identity.spendingKey,
6717
+ viewingSecretKey: identity.viewingSecretKey,
6718
+ ownerPk: identity.ownerPk,
6719
+ ...identity.outgoingViewingKey ? { outgoingViewingKey: identity.outgoingViewingKey } : {}
6501
6720
  };
6502
6721
  }
6503
6722
  /**
@@ -6519,7 +6738,20 @@ var OrbinumWallet = class {
6519
6738
  * verifying-key rotation, and the failure surfaces much later.
6520
6739
  */
6521
6740
  buildOutputNote = async (params) => {
6522
- 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
+ );
6523
6755
  };
6524
6756
  /** The circuit version a new note should carry. */
6525
6757
  async circuitVersion() {
@@ -6531,11 +6763,11 @@ var OrbinumWallet = class {
6531
6763
  }
6532
6764
  return chainActiveCircuitVersion(this.config.zkVerifier);
6533
6765
  }
6534
- requireKey() {
6535
- if (this.spendingKey === null) {
6766
+ requireIdentity() {
6767
+ if (this.identity === null) {
6536
6768
  throw new Error("Wallet is locked. Call unlock() before using it.");
6537
6769
  }
6538
- return this.spendingKey;
6770
+ return this.identity;
6539
6771
  }
6540
6772
  };
6541
6773
  export {
@@ -6566,9 +6798,8 @@ export {
6566
6798
  NATIVE_ASSET_ID,
6567
6799
  NOTE_BACKUP_VERSION,
6568
6800
  NOTE_BIGINT_FIELDS,
6569
- NOTE_TRANSFER_URI_SCHEME,
6570
6801
  NoteBuilder,
6571
- OVK_BLOB_SIZE,
6802
+ OUTGOING_EPH_WINDOW,
6572
6803
  OrbinumClient,
6573
6804
  OrbinumClientProvider,
6574
6805
  OrbinumWallet,
@@ -6578,7 +6809,6 @@ export {
6578
6809
  PRECOMPILE_ADDR,
6579
6810
  PrivacyKeyManager,
6580
6811
  PrivacyModule,
6581
- QR_PAGE_MAX_CHARS,
6582
6812
  RECOVERED_TX_RESULT,
6583
6813
  RelayerStatusModule,
6584
6814
  RpcV2Module,
@@ -6603,7 +6833,7 @@ export {
6603
6833
  addressToFieldElement,
6604
6834
  applyBatch,
6605
6835
  applyNoteStatus,
6606
- assembleNoteTransfer,
6836
+ assertSecretKeyBytes,
6607
6837
  base58,
6608
6838
  base64UrlDecode,
6609
6839
  base64UrlEncode,
@@ -6616,6 +6846,7 @@ export {
6616
6846
  buildShieldBatchOperations,
6617
6847
  buildShieldParams,
6618
6848
  buildZkNote,
6849
+ buildZkNoteWithIndex,
6619
6850
  bytesToBigintLE,
6620
6851
  bytesToBjjScalar,
6621
6852
  cacheSession,
@@ -6645,15 +6876,17 @@ export {
6645
6876
  createWorkerPool,
6646
6877
  decodeNoteBackup,
6647
6878
  decodeNoteDisclosureKey,
6648
- decodeNoteTransferPage,
6649
6879
  decodePaymentSlip,
6650
6880
  decodePrecompileCalldata,
6651
6881
  decryptHintBatch,
6652
6882
  decryptJson,
6653
6883
  decryptNoteRecord,
6884
+ deriveIdentity,
6654
6885
  deriveMasterKeyBytes,
6655
- deriveOutgoingCipherKey,
6656
- deriveOutgoingViewingKey,
6886
+ deriveOutgoingEphPk,
6887
+ deriveOutgoingEphSk,
6888
+ deriveOutgoingSharedSecret,
6889
+ deriveOutgoingViewingKeyV3,
6657
6890
  deriveOwnerPk,
6658
6891
  derivePairwiseEphSk,
6659
6892
  derivePairwiseSharedSecret,
@@ -6662,6 +6895,7 @@ export {
6662
6895
  deriveSpendingKeyFromSignature,
6663
6896
  deriveSpendingKeyMessageV2,
6664
6897
  deriveSpendingKeyTypedData,
6898
+ deriveSpendingKeyV3,
6665
6899
  deriveStealthOwnerPk,
6666
6900
  deriveStealthSk,
6667
6901
  deriveVaultBlindKey,
@@ -6669,9 +6903,9 @@ export {
6669
6903
  deriveViewTag,
6670
6904
  deriveViewingPublicKey,
6671
6905
  deriveViewingSecretKey,
6906
+ deriveViewingSecretKeyV3,
6672
6907
  detectCommitmentMismatch,
6673
6908
  encodeNoteBackup,
6674
- encodeNoteTransferPages,
6675
6909
  encodePaymentSlip,
6676
6910
  encryptJson,
6677
6911
  encryptNote,
@@ -6681,6 +6915,7 @@ export {
6681
6915
  evmToImplicitSubstrate,
6682
6916
  evmToMappedAccountHex,
6683
6917
  evmToSubstrate,
6918
+ exportViewingCredential,
6684
6919
  extractPalletError,
6685
6920
  failed,
6686
6921
  fastMulBase,
@@ -6721,9 +6956,9 @@ export {
6721
6956
  isNativeAsset,
6722
6957
  isNoteSelfConsistent,
6723
6958
  isSpendable,
6724
- isSs58,
6725
6959
  isSubstrateAddress,
6726
6960
  isUnifiedAddress,
6961
+ isUsableSecretKey,
6727
6962
  isValidLeafIndex,
6728
6963
  leHexToBigint,
6729
6964
  mapExtrinsicArgs,
@@ -6740,10 +6975,10 @@ export {
6740
6975
  noteMatchesCommitment,
6741
6976
  noteOrigin,
6742
6977
  noteSpentTxHash,
6743
- noteToTransferEntry,
6744
6978
  noteTxKind,
6745
- openOutgoingBlob,
6746
6979
  openPaymentSlip,
6980
+ openRecipientBookEntry,
6981
+ outgoingEphWindow,
6747
6982
  outranks,
6748
6983
  pairwiseEphWindow,
6749
6984
  palletErrorKind,
@@ -6754,15 +6989,18 @@ export {
6754
6989
  planTransfer,
6755
6990
  planUnshield,
6756
6991
  randomBlinding,
6757
- randomOutgoingBlob,
6992
+ reconstructOutgoingIndex,
6758
6993
  reconstructOutgoingTxRecords,
6759
6994
  recoverOwnerPkPoint,
6760
6995
  recoverSelfStealthNote,
6996
+ recoverSentFromSharedSecret,
6997
+ recoverSentNote,
6761
6998
  refuseIfAlreadySpent,
6762
6999
  regeneratePaymentSlip,
7000
+ registerPairwiseCounterparty,
6763
7001
  removeByCommitment,
6764
7002
  requireSessionKeys,
6765
- reservePairwiseIndex,
7003
+ reserveOutgoingIndex,
6766
7004
  reserveSelfEphIndex,
6767
7005
  resolveSelfEphCeiling,
6768
7006
  resolveSpentSet,
@@ -6771,8 +7009,8 @@ export {
6771
7009
  runScan,
6772
7010
  scalarToHex,
6773
7011
  scanAbortError,
6774
- sealOutgoingBlob,
6775
7012
  sealPaymentSlip,
7013
+ sealRecipientBookEntry,
6776
7014
  selectDescribingNote,
6777
7015
  selectDescribingNoteByCommitment,
6778
7016
  selectGhosts,
@@ -6798,10 +7036,10 @@ export {
6798
7036
  truncateMiddle,
6799
7037
  tryDecryptNote,
6800
7038
  tryDecryptNoteVerbose,
6801
- tryRecoverOutgoing,
6802
7039
  txLandedAfterError,
6803
7040
  u128,
6804
7041
  u64,
7042
+ unpackUsableViewingKey,
6805
7043
  unshieldNote,
6806
7044
  upsertNote,
6807
7045
  vaultReplacer,