@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.
@@ -4,7 +4,7 @@ var CURRENT_CIRCUIT_VERSION = 1;
4
4
  // src/protocol/memo/EncryptedMemo.ts
5
5
  import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
6
6
  import { randomBytes } from "@noble/ciphers/utils.js";
7
- import { packPoint, unpackPoint } from "@zk-kit/baby-jubjub";
7
+ import { packPoint } from "@zk-kit/baby-jubjub";
8
8
 
9
9
  // src/foundation/crypto/bjj-fast.ts
10
10
  import { edwards } from "@noble/curves/abstract/edwards.js";
@@ -32,6 +32,84 @@ function fastMulPoint(point, scalar) {
32
32
  return [x, y];
33
33
  }
34
34
 
35
+ // src/foundation/crypto/bjj.ts
36
+ import { mulPointEscalar, unpackPoint } from "@zk-kit/baby-jubjub";
37
+
38
+ // src/foundation/crypto/constants.ts
39
+ var BN254_R = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
40
+ var BABYJUB_SUBORDER = 2736030358979909402780800718157159386076813972158567259200215660948447373041n;
41
+
42
+ // src/foundation/crypto/bjj.ts
43
+ var BJJ_A = 168700n;
44
+ var BJJ_D = 168696n;
45
+ var BJJ_COFACTOR = 8n;
46
+ function _modpow(base, exp, mod) {
47
+ let result = 1n;
48
+ base = base % mod;
49
+ while (exp > 0n) {
50
+ if (exp & 1n) result = result * base % mod;
51
+ exp >>= 1n;
52
+ base = base * base % mod;
53
+ }
54
+ return result;
55
+ }
56
+ function _sqrtModP(y2) {
57
+ if (y2 === 0n) return 0n;
58
+ if (_modpow(y2, (BN254_R - 1n) / 2n, BN254_R) !== 1n) return null;
59
+ let s = 0n;
60
+ let q = BN254_R - 1n;
61
+ while ((q & 1n) === 0n) {
62
+ q >>= 1n;
63
+ s++;
64
+ }
65
+ if (s === 1n) return _modpow(y2, (BN254_R + 1n) / 4n, BN254_R);
66
+ let z = 2n;
67
+ while (_modpow(z, (BN254_R - 1n) / 2n, BN254_R) === 1n) z++;
68
+ let m = s;
69
+ let c = _modpow(z, q, BN254_R);
70
+ let t = _modpow(y2, q, BN254_R);
71
+ let r = _modpow(y2, (q + 1n) / 2n, BN254_R);
72
+ for (; ; ) {
73
+ if (t === 1n) return r;
74
+ let i = 1n;
75
+ let tmp = t * t % BN254_R;
76
+ while (tmp !== 1n) {
77
+ tmp = tmp * tmp % BN254_R;
78
+ i++;
79
+ }
80
+ const b = _modpow(c, 1n << m - i - 1n, BN254_R);
81
+ m = i;
82
+ c = b * b % BN254_R;
83
+ t = t * c % BN254_R;
84
+ r = r * b % BN254_R;
85
+ }
86
+ }
87
+ function recoverOwnerPkPoint(ax) {
88
+ const x2 = ax * ax % BN254_R;
89
+ const num = ((1n - BJJ_A * x2) % BN254_R + BN254_R) % BN254_R;
90
+ const den = ((1n - BJJ_D * x2) % BN254_R + BN254_R) % BN254_R;
91
+ if (den === 0n) return null;
92
+ const denInv = _modpow(den, BN254_R - 2n, BN254_R);
93
+ const y2 = num * denInv % BN254_R;
94
+ const y = _sqrtModP(y2);
95
+ if (y === null) return null;
96
+ const yAlt = BN254_R - y;
97
+ try {
98
+ const check = mulPointEscalar([ax, y], BABYJUB_SUBORDER);
99
+ return check[0] === 0n && check[1] === 1n ? [ax, y] : [ax, yAlt];
100
+ } catch {
101
+ return [ax, yAlt];
102
+ }
103
+ }
104
+ function unpackUsableViewingKey(packed) {
105
+ const point = unpackPoint(packed);
106
+ if (!point) return null;
107
+ if (point[0] === 0n && point[1] === 1n) return null;
108
+ const cleared = mulPointEscalar(point, BJJ_COFACTOR);
109
+ if (cleared[0] === 0n && cleared[1] === 1n) return null;
110
+ return point;
111
+ }
112
+
35
113
  // src/foundation/encoding/hex.ts
36
114
  function isHexOfLength(value, byteLen) {
37
115
  return typeof value === "string" && new RegExp(`^0x[0-9a-fA-F]{${byteLen * 2}}$`).test(value);
@@ -57,12 +135,23 @@ function ensureHexPrefix(hex) {
57
135
  return hex.startsWith("0x") ? hex : `0x${hex}`;
58
136
  }
59
137
  function hexToNumber(hex) {
60
- return parseInt(hex, 16);
138
+ const clean = /^0x/i.test(hex) ? hex.slice(2) : hex;
139
+ if (!/^[0-9a-fA-F]+$/.test(clean)) {
140
+ throw new Error(`Invalid hex quantity: "${hex}"`);
141
+ }
142
+ const value = Number(BigInt("0x" + clean));
143
+ if (!Number.isSafeInteger(value)) {
144
+ throw new Error(`Hex quantity exceeds safe integer range: "${hex}"`);
145
+ }
146
+ return value;
61
147
  }
62
148
  function hexToBigint(hex) {
63
149
  return BigInt(hex);
64
150
  }
65
151
  function scalarToHex(value) {
152
+ if (value < 0n || value >> 256n !== 0n) {
153
+ throw new Error(`scalarToHex: value must fit in 32 unsigned bytes, got ${value}`);
154
+ }
66
155
  return "0x" + value.toString(16).padStart(64, "0");
67
156
  }
68
157
 
@@ -122,16 +211,21 @@ function leHexToBigint(hex) {
122
211
  return bytesToBigintLE(fromHex(hex));
123
212
  }
124
213
 
125
- // src/foundation/crypto/constants.ts
126
- var BN254_R = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
127
- var BABYJUB_SUBORDER = 2736030358979909402780800718157159386076813972158567259200215660948447373041n;
128
-
129
214
  // src/protocol/memo/plaintext.ts
130
215
  import { sha256 } from "@noble/hashes/sha2.js";
131
216
  var KEY_DOMAIN = new TextEncoder().encode("orbinum-note-encryption-v1");
132
217
  var VIEW_TAG_DOMAIN = new TextEncoder().encode("orbinum-view-tag-v1");
133
218
  var MEMO_PLAINTEXT_SIZE = 120;
134
219
  function serializeMemo(value, ownerPk, blinding, assetId, sourcePk, circuitVersion) {
220
+ if (value < 0n || value >> 128n !== 0n) {
221
+ throw new Error(`serializeMemo: value must fit in 128 unsigned bits, got ${value}`);
222
+ }
223
+ if (!Number.isInteger(assetId) || assetId < 0 || assetId > 4294967295) {
224
+ throw new Error(`serializeMemo: assetId must be a u32, got ${assetId}`);
225
+ }
226
+ if (!Number.isInteger(circuitVersion) || circuitVersion < 0 || circuitVersion > 4294967295) {
227
+ throw new Error(`serializeMemo: circuitVersion must be a u32, got ${circuitVersion}`);
228
+ }
135
229
  const buf = new Uint8Array(MEMO_PLAINTEXT_SIZE);
136
230
  const view = new DataView(buf.buffer);
137
231
  view.setBigUint64(0, value & 0xffffffffffffffffn, true);
@@ -163,6 +257,9 @@ var CIPHERTEXT_SIZE = 136;
163
257
  var EPH_PK_SIZE = 32;
164
258
  var ENCRYPTED_MEMO_SIZE = NONCE_SIZE + CIPHERTEXT_SIZE + EPH_PK_SIZE;
165
259
  function bytesToBjjScalar(bytes) {
260
+ if (bytes.length !== 32) {
261
+ throw new Error(`bytesToBjjScalar: expected 32 bytes, got ${bytes.length}`);
262
+ }
166
263
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
167
264
  return BigInt("0x" + hex) % BABYJUB_SUBORDER || 1n;
168
265
  }
@@ -186,7 +283,7 @@ function parsePlaintext(nonce, ciphertextWithMac, encKey) {
186
283
  }
187
284
  var EncryptedMemo = {
188
285
  /**
189
- * Build and encrypt a memo for a note using ECDH (v2, 180 bytes).
286
+ * Build and encrypt a memo for a note using ECDH (180 bytes).
190
287
  *
191
288
  * @param value Note value in planck.
192
289
  * @param ownerPk 32-byte owner public key (LE).
@@ -226,7 +323,7 @@ var EncryptedMemo = {
226
323
  const ephPkPoint = fastMulBase(ephSkScalar);
227
324
  ephPkPackedBytes = bigintTo32Le(packPoint(ephPkPoint));
228
325
  const ivkPackedBigint = bytesToBigintLE(recipientIvkPacked);
229
- const ivkPoint = unpackPoint(ivkPackedBigint);
326
+ const ivkPoint = unpackUsableViewingKey(ivkPackedBigint);
230
327
  if (!ivkPoint)
231
328
  throw new Error("EncryptedMemo.encrypt: invalid recipient viewing public key");
232
329
  const sharedPoint = fastMulPoint(ivkPoint, ephSkScalar);
@@ -316,7 +413,7 @@ var EncryptedMemo = {
316
413
  if (ephPkPackedBigint === 0n) {
317
414
  return new Uint8Array(32);
318
415
  }
319
- const ephPkPoint = unpackPoint(ephPkPackedBigint);
416
+ const ephPkPoint = unpackUsableViewingKey(ephPkPackedBigint);
320
417
  if (!ephPkPoint) return null;
321
418
  const ivskScalar = bytesToBjjScalar(viewingSecretKey);
322
419
  const sharedPoint = fastMulPoint(ephPkPoint, ivskScalar);
@@ -326,9 +423,9 @@ var EncryptedMemo = {
326
423
  * Cheap view-tag check: does memo nonce[0] match the tag derived from
327
424
  * `sharedSecret`? One SHA256 + one byte compare — no AEAD work.
328
425
  *
329
- * Only meaningful for memos built with view tags (commitments at/after
330
- * the wallet's tagActivationLeaf): a legacy memo carries a random byte
331
- * there and would false-negative 255/256 of the time.
426
+ * Only meaningful for memos that carry a tag at or after
427
+ * `ScanKeys.viewTagActivationLeaf`. An older memo has a random byte there
428
+ * and would false-negative 255/256 of the time.
332
429
  */
333
430
  checkViewTag(memoBytes, sharedSecret) {
334
431
  if (memoBytes.length !== ENCRYPTED_MEMO_SIZE) return false;
@@ -354,80 +451,19 @@ var EncryptedMemo = {
354
451
  }
355
452
  };
356
453
 
357
- // src/protocol/memo/OutgoingBlob.ts
358
- import { chacha20poly1305 as chacha20poly13052 } from "@noble/ciphers/chacha.js";
359
- import { randomBytes as randomBytes2 } from "@noble/ciphers/utils.js";
360
- import { hkdf } from "@noble/hashes/hkdf.js";
361
- import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
362
- var OCK_DOMAIN = new TextEncoder().encode("orbinum-outgoing-cipher-v1");
363
- var NONCE_PREFIX = new TextEncoder().encode("OVK1");
364
- var NONCE_SUFFIX_SIZE = 8;
365
- var SHARED_SECRET_SIZE = 32;
366
- var MAC_SIZE = 16;
367
- var OVK_BLOB_SIZE = NONCE_SUFFIX_SIZE + SHARED_SECRET_SIZE + MAC_SIZE;
368
- function deriveOutgoingCipherKey(ovk, commitmentBytes, ephPkBytes) {
369
- if (commitmentBytes.length !== 32) {
370
- throw new Error(
371
- `OutgoingBlob: commitmentBytes must be 32 bytes, got ${commitmentBytes.length}`
372
- );
373
- }
374
- if (ephPkBytes.length !== 32) {
375
- throw new Error(`OutgoingBlob: ephPkBytes must be 32 bytes, got ${ephPkBytes.length}`);
376
- }
377
- const salt = new Uint8Array(64);
378
- salt.set(commitmentBytes, 0);
379
- salt.set(ephPkBytes, 32);
380
- return hkdf(sha2562, ovk, salt, OCK_DOMAIN, 32);
381
- }
382
- function buildNonce(suffix) {
383
- const nonce = new Uint8Array(12);
384
- nonce.set(NONCE_PREFIX, 0);
385
- nonce.set(suffix, 4);
386
- return nonce;
387
- }
388
- function sealOutgoingBlob(ovk, sharedSecret, commitmentBytes, ephPkBytes) {
389
- if (sharedSecret.length !== SHARED_SECRET_SIZE) {
390
- throw new Error(`OutgoingBlob: sharedSecret must be 32 bytes, got ${sharedSecret.length}`);
391
- }
392
- const ock = deriveOutgoingCipherKey(ovk, commitmentBytes, ephPkBytes);
393
- const suffix = randomBytes2(NONCE_SUFFIX_SIZE);
394
- const cipher = chacha20poly13052(ock, buildNonce(suffix));
395
- const sealed = cipher.encrypt(sharedSecret);
396
- const blob = new Uint8Array(OVK_BLOB_SIZE);
397
- blob.set(suffix, 0);
398
- blob.set(sealed, NONCE_SUFFIX_SIZE);
399
- return blob;
400
- }
401
- function openOutgoingBlob(ovk, blob, commitmentBytes, ephPkBytes) {
402
- if (blob.length !== OVK_BLOB_SIZE) return null;
403
- if (commitmentBytes.length !== 32 || ephPkBytes.length !== 32) return null;
404
- try {
405
- const ock = deriveOutgoingCipherKey(ovk, commitmentBytes, ephPkBytes);
406
- const suffix = blob.subarray(0, NONCE_SUFFIX_SIZE);
407
- const sealed = blob.subarray(NONCE_SUFFIX_SIZE);
408
- const cipher = chacha20poly13052(ock, buildNonce(suffix));
409
- return cipher.decrypt(sealed);
410
- } catch {
411
- return null;
412
- }
413
- }
414
- function randomOutgoingBlob() {
415
- return randomBytes2(OVK_BLOB_SIZE);
416
- }
417
-
418
454
  // src/foundation/crypto/stealth.ts
419
- import { sha256 as sha2563 } from "@noble/hashes/sha2.js";
420
- import { hkdf as hkdf2 } from "@noble/hashes/hkdf.js";
421
- import { mulPointEscalar, Base8, addPoint } from "@zk-kit/baby-jubjub";
455
+ import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
456
+ import { hkdf } from "@noble/hashes/hkdf.js";
457
+ import { mulPointEscalar as mulPointEscalar2, Base8, addPoint } from "@zk-kit/baby-jubjub";
422
458
  var STEALTH_INFO = new TextEncoder().encode("orbinum-stealth-v1");
423
459
  function deriveStealthScalar(sharedSecret, ownerPkBigint) {
424
460
  const salt = bigintTo32Le(ownerPkBigint);
425
- const stealthBytes = hkdf2(sha2563, sharedSecret, salt, STEALTH_INFO, 32);
461
+ const stealthBytes = hkdf(sha2562, sharedSecret, salt, STEALTH_INFO, 32);
426
462
  return bytesToBigintLE(stealthBytes) % BABYJUB_SUBORDER || 1n;
427
463
  }
428
464
  function deriveStealthOwnerPk(sharedSecret, ownerPkBigint, ownerPkPoint) {
429
465
  const stealthScalar = deriveStealthScalar(sharedSecret, ownerPkBigint);
430
- const stealthPt = addPoint(mulPointEscalar(Base8, stealthScalar), ownerPkPoint);
466
+ const stealthPt = addPoint(mulPointEscalar2(Base8, stealthScalar), ownerPkPoint);
431
467
  return stealthPt[0];
432
468
  }
433
469
  function deriveStealthSk(sharedSecret, ownerPkBigint, spendingKey) {
@@ -435,73 +471,20 @@ function deriveStealthSk(sharedSecret, ownerPkBigint, spendingKey) {
435
471
  return (stealthScalar + spendingKey) % BABYJUB_SUBORDER || 1n;
436
472
  }
437
473
 
438
- // src/foundation/crypto/bjj.ts
439
- import { mulPointEscalar as mulPointEscalar2 } from "@zk-kit/baby-jubjub";
440
- var BJJ_A = 168700n;
441
- var BJJ_D = 168696n;
442
- function _modpow(base, exp, mod) {
443
- let result = 1n;
444
- base = base % mod;
445
- while (exp > 0n) {
446
- if (exp & 1n) result = result * base % mod;
447
- exp >>= 1n;
448
- base = base * base % mod;
449
- }
450
- return result;
451
- }
452
- function _sqrtModP(y2) {
453
- if (y2 === 0n) return 0n;
454
- if (_modpow(y2, (BN254_R - 1n) / 2n, BN254_R) !== 1n) return null;
455
- let s = 0n;
456
- let q = BN254_R - 1n;
457
- while ((q & 1n) === 0n) {
458
- q >>= 1n;
459
- s++;
460
- }
461
- if (s === 1n) return _modpow(y2, (BN254_R + 1n) / 4n, BN254_R);
462
- let z = 2n;
463
- while (_modpow(z, (BN254_R - 1n) / 2n, BN254_R) === 1n) z++;
464
- let m = s;
465
- let c = _modpow(z, q, BN254_R);
466
- let t = _modpow(y2, q, BN254_R);
467
- let r = _modpow(y2, (q + 1n) / 2n, BN254_R);
468
- for (; ; ) {
469
- if (t === 1n) return r;
470
- let i = 1n;
471
- let tmp = t * t % BN254_R;
472
- while (tmp !== 1n) {
473
- tmp = tmp * tmp % BN254_R;
474
- i++;
475
- }
476
- const b = _modpow(c, 1n << m - i - 1n, BN254_R);
477
- m = i;
478
- c = b * b % BN254_R;
479
- t = t * c % BN254_R;
480
- r = r * b % BN254_R;
481
- }
482
- }
483
- function recoverOwnerPkPoint(ax) {
484
- const x2 = ax * ax % BN254_R;
485
- const num = ((1n - BJJ_A * x2) % BN254_R + BN254_R) % BN254_R;
486
- const den = ((1n - BJJ_D * x2) % BN254_R + BN254_R) % BN254_R;
487
- if (den === 0n) return null;
488
- const denInv = _modpow(den, BN254_R - 2n, BN254_R);
489
- const y2 = num * denInv % BN254_R;
490
- const y = _sqrtModP(y2);
491
- if (y === null) return null;
492
- const yAlt = BN254_R - y;
493
- try {
494
- const check = mulPointEscalar2([ax, y], BABYJUB_SUBORDER);
495
- return check[0] === 0n && check[1] === 1n ? [ax, y] : [ax, yAlt];
496
- } catch {
497
- return [ax, yAlt];
498
- }
474
+ // src/protocol/note/NoteBuilder.ts
475
+ import { mulPointEscalar as mulPointEscalar3 } from "@zk-kit/baby-jubjub";
476
+ import { randomBytes as randomBytes2 } from "@noble/ciphers/utils.js";
477
+ import { poseidon2, poseidon4 } from "poseidon-lite";
478
+
479
+ // src/foundation/crypto/blinding.ts
480
+ function randomBlinding() {
481
+ const buf = new Uint8Array(32);
482
+ crypto.getRandomValues(buf);
483
+ const reduced = bytesToBigintLE(buf) % BN254_R;
484
+ return reduced === 0n ? 1n : reduced;
499
485
  }
500
486
 
501
487
  // src/protocol/note/NoteBuilder.ts
502
- import { mulPointEscalar as mulPointEscalar3, unpackPoint as unpackPoint2 } from "@zk-kit/baby-jubjub";
503
- import { randomBytes as randomBytes3 } from "@noble/ciphers/utils.js";
504
- import { poseidon2, poseidon4 } from "poseidon-lite";
505
488
  var NoteBuilder = class {
506
489
  /**
507
490
  * Build a ZkNote from the given inputs.
@@ -509,7 +492,7 @@ var NoteBuilder = class {
509
492
  * @param input.value Amount in planck (required).
510
493
  * @param input.assetId Asset ID — default 0n (native ORB-Privacy).
511
494
  * @param input.ownerPk Sender's or recipient's global BabyJubJub Ax — default 0n.
512
- * @param input.blinding Random scalar — defaults to BigInt(Date.now()).
495
+ * @param input.blinding Random scalar — defaults to a CSPRNG draw.
513
496
  * @param input.spendingKey Secret key for nullifier — default 0n.
514
497
  * @param input.viewingPublicKey Recipient's 32-byte LE packed BJJ ivk. Triggers memo encryption.
515
498
  * @param input.recipientOwnerPk Recipient's global ownerPk. Required with viewingPublicKey
@@ -520,7 +503,7 @@ var NoteBuilder = class {
520
503
  const value = input.value;
521
504
  const assetId = input.assetId ?? 0n;
522
505
  const ownerPk = input.ownerPk ?? 0n;
523
- const blinding = input.blinding ?? BigInt(Date.now());
506
+ const blinding = input.blinding ?? randomBlinding();
524
507
  const spendingKey = input.spendingKey ?? 0n;
525
508
  const sourcePk = input.sourcePk ?? 0n;
526
509
  const circuitVersion = input.circuitVersion ?? CURRENT_CIRCUIT_VERSION;
@@ -529,9 +512,9 @@ var NoteBuilder = class {
529
512
  if (useStealth) {
530
513
  const recipientOwnerPk = input.recipientOwnerPk;
531
514
  const recipientIvkPacked = input.viewingPublicKey;
532
- const ephSk = input.ephSkOverride ?? randomBytes3(32);
515
+ const ephSk = input.ephSkOverride ?? randomBytes2(32);
533
516
  const ivkPackedBigint = bytesToBigintLE(recipientIvkPacked);
534
- const ivkPoint = unpackPoint2(ivkPackedBigint);
517
+ const ivkPoint = unpackUsableViewingKey(ivkPackedBigint);
535
518
  if (!ivkPoint)
536
519
  throw new Error("NoteBuilder.build: invalid recipient viewing public key");
537
520
  const ephSkScalar = BigInt(toHex(ephSk)) % BABYJUB_SUBORDER || 1n;
@@ -570,21 +553,6 @@ var NoteBuilder = class {
570
553
  throw new Error(
571
554
  `NoteBuilder.build: invariant violated \u2014 memo must be ${ENCRYPTED_MEMO_SIZE} bytes, got ${memo.length}`
572
555
  );
573
- let ovkBlob;
574
- if (input.outgoingViewingKey !== void 0) {
575
- const ephPkBytes = Uint8Array.from(memo.slice(148, 180));
576
- const sealed = sealOutgoingBlob(
577
- input.outgoingViewingKey,
578
- sharedSecret,
579
- stealthCommitmentBytes,
580
- ephPkBytes
581
- );
582
- if (sealed.length !== OVK_BLOB_SIZE)
583
- throw new Error(
584
- `NoteBuilder.build: invariant violated \u2014 ovkBlob must be ${OVK_BLOB_SIZE} bytes, got ${sealed.length}`
585
- );
586
- ovkBlob = Array.from(sealed);
587
- }
588
556
  return {
589
557
  value,
590
558
  assetId,
@@ -599,8 +567,7 @@ var NoteBuilder = class {
599
567
  commitmentHex: toHex(commitmentBytes2),
600
568
  nullifierHex: toHex(nullifierBytes2),
601
569
  memo,
602
- sourcePk,
603
- ...ovkBlob ? { ovkBlob } : {}
570
+ sourcePk
604
571
  };
605
572
  }
606
573
  const commitment = poseidon4([value, assetId, ownerPk, blinding]);
@@ -650,7 +617,7 @@ var NoteBuilder = class {
650
617
  * @param note The ZkNote whose fields populate the plaintext.
651
618
  * @param recipientIvkPacked 32-byte LE packed BJJ viewing public key of the recipient.
652
619
  * Pass `new Uint8Array(32)` (default) for a public/dummy memo.
653
- * @param sourcePk 32-byte counterparty BabyJubJub Ax.
620
+ * @param sourcePk 32-byte counterparty BabyJubJub Ax.
654
621
  * Pass `new Uint8Array(32)` (default) for no counterparty.
655
622
  */
656
623
  static buildMemo(note, recipientIvkPacked, sourcePk) {
@@ -730,18 +697,6 @@ function buildDummyTransferInput(assetId) {
730
697
  }
731
698
 
732
699
  // src/protocol/note/NoteDecryptor.ts
733
- function decryptAndVerifyPlaintext(memoBytes, commitmentBytes, sharedSecret, effectiveOwnerPk) {
734
- const plaintext = EncryptedMemo.decryptWithSharedSecret(
735
- memoBytes,
736
- commitmentBytes,
737
- sharedSecret
738
- );
739
- if (!plaintext) return null;
740
- const ownerPk = effectiveOwnerPk ?? plaintext.ownerPk;
741
- const recomputed = poseidon42([plaintext.value, plaintext.assetId, ownerPk, plaintext.blinding]);
742
- if (recomputed !== bytesToBigintLE(commitmentBytes)) return null;
743
- return plaintext;
744
- }
745
700
  function computeNullifier(commitment, spendingKey) {
746
701
  return poseidon22([commitment, spendingKey]);
747
702
  }
@@ -824,40 +779,6 @@ function tryDecryptNoteVerbose(commitment, viewingSecretKey, spendingKey, ownOwn
824
779
  }
825
780
  };
826
781
  }
827
- function tryRecoverOutgoing(hint, ovk, opts) {
828
- if (!hint.ovkBlob || !hint.encryptedMemo) return null;
829
- let commitmentBytes;
830
- let memoBytes;
831
- let blobBytes;
832
- try {
833
- commitmentBytes = fromHex(hint.commitmentHex);
834
- memoBytes = fromHex(hint.encryptedMemo);
835
- blobBytes = fromHex(hint.ovkBlob);
836
- } catch {
837
- return null;
838
- }
839
- if (memoBytes.length !== ENCRYPTED_MEMO_SIZE) return null;
840
- if (blobBytes.length !== OVK_BLOB_SIZE) return null;
841
- const ephPkBytes = memoBytes.subarray(148, 180);
842
- const sharedSecret = openOutgoingBlob(ovk, blobBytes, commitmentBytes, ephPkBytes);
843
- if (!sharedSecret) return null;
844
- const activation = opts?.viewTagActivationLeaf;
845
- if (activation !== void 0 && isValidLeafIndex(hint.leafIndex) && hint.leafIndex >= activation && !EncryptedMemo.checkViewTag(memoBytes, sharedSecret)) {
846
- return null;
847
- }
848
- const plaintext = decryptAndVerifyPlaintext(memoBytes, commitmentBytes, sharedSecret, null);
849
- if (!plaintext) return null;
850
- return {
851
- commitmentHex: hint.commitmentHex,
852
- ...isValidLeafIndex(hint.leafIndex) ? { leafIndex: hint.leafIndex } : {},
853
- value: plaintext.value,
854
- assetId: plaintext.assetId,
855
- recipientStealthPk: plaintext.ownerPk,
856
- blinding: plaintext.blinding,
857
- sourcePk: plaintext.sourcePk,
858
- circuitVersion: plaintext.circuitVersion
859
- };
860
- }
861
782
  function collectOutgoingFacts(hint) {
862
783
  if (!isHexOfLength(hint.commitmentHex, 32)) return null;
863
784
  if (!isHexOfLength(hint.encryptedMemo, ENCRYPTED_MEMO_SIZE)) return null;
@@ -868,6 +789,160 @@ function collectOutgoingFacts(hint) {
868
789
  };
869
790
  }
870
791
 
792
+ // src/protocol/eph/outgoingEph.ts
793
+ import { sha256 as sha2563 } from "@noble/hashes/sha2.js";
794
+
795
+ // src/protocol/eph/windowBounds.ts
796
+ var MAX_EPH_WINDOW = 1 << 20;
797
+
798
+ // src/foundation/crypto/keyGuards.ts
799
+ var SECRET_KEY_SIZE = 32;
800
+ function isUsableSecretKey(key) {
801
+ return key !== void 0 && key !== null && key.length === SECRET_KEY_SIZE && !key.every((b) => b === 0);
802
+ }
803
+ function assertSecretKeyBytes(key, label) {
804
+ if (key.length !== SECRET_KEY_SIZE) {
805
+ throw new Error(`${label} must be ${SECRET_KEY_SIZE} bytes, got ${key.length}`);
806
+ }
807
+ if (key.every((b) => b === 0)) {
808
+ throw new Error(`${label} is all zeros \u2014 that is not key material`);
809
+ }
810
+ }
811
+
812
+ // src/protocol/eph/outgoingEph.ts
813
+ import { packPoint as packPoint2, unpackPoint as unpackPoint2 } from "@zk-kit/baby-jubjub";
814
+ var OUTGOING_EPH_DOMAIN = new TextEncoder().encode("orbinum-outgoing-eph-v3");
815
+ function deriveOutgoingEphSk(outgoingViewingKey, index) {
816
+ if (!Number.isInteger(index) || index < 0 || index > 4294967295) {
817
+ throw new Error(`deriveOutgoingEphSk: index must be a u32, got ${index}`);
818
+ }
819
+ assertSecretKeyBytes(outgoingViewingKey, "deriveOutgoingEphSk: outgoingViewingKey");
820
+ const h = sha2563.create();
821
+ h.update(OUTGOING_EPH_DOMAIN);
822
+ h.update(outgoingViewingKey);
823
+ const idx = new Uint8Array(4);
824
+ new DataView(idx.buffer).setUint32(0, index >>> 0, true);
825
+ h.update(idx);
826
+ return h.digest();
827
+ }
828
+ function deriveOutgoingEphPk(outgoingViewingKey, index) {
829
+ const scalar = bytesToBjjScalar(deriveOutgoingEphSk(outgoingViewingKey, index));
830
+ return toHex(bigintTo32Le(packPoint2(fastMulBase(scalar)))).toLowerCase();
831
+ }
832
+ function outgoingEphWindow(outgoingViewingKey, from, count) {
833
+ const entries = [];
834
+ if (!Number.isInteger(from) || from < 0) {
835
+ throw new Error(`outgoingEphWindow: from must be a non-negative integer, got ${from}`);
836
+ }
837
+ if (!Number.isInteger(count) || count > MAX_EPH_WINDOW) {
838
+ throw new Error(
839
+ `outgoingEphWindow: count must be an integer <= ${MAX_EPH_WINDOW}, got ${count}`
840
+ );
841
+ }
842
+ for (let i = from; i < from + count; i++) {
843
+ entries.push({ index: i, ephPkHex: deriveOutgoingEphPk(outgoingViewingKey, i) });
844
+ }
845
+ return entries;
846
+ }
847
+ function deriveOutgoingSharedSecret(outgoingViewingKey, index, recipientIvkPacked) {
848
+ const ivkPoint = unpackPoint2(bytesToBigintLE(recipientIvkPacked));
849
+ if (!ivkPoint) throw new Error("deriveOutgoingSharedSecret: invalid viewing public key");
850
+ const scalar = bytesToBjjScalar(deriveOutgoingEphSk(outgoingViewingKey, index));
851
+ return bigintTo32Le(fastMulPoint(ivkPoint, scalar)[0]);
852
+ }
853
+ function reconstructOutgoingIndex(outgoingViewingKey, publishedEphPks, window) {
854
+ const published = /* @__PURE__ */ new Set();
855
+ for (const hex of publishedEphPks) published.add(hex.toLowerCase());
856
+ let highest = -1;
857
+ for (let i = 0; i < window; i++) {
858
+ if (published.has(deriveOutgoingEphPk(outgoingViewingKey, i))) highest = i;
859
+ }
860
+ return highest + 1;
861
+ }
862
+
863
+ // src/protocol/note/recoverSent.ts
864
+ function recoverSentFromSharedSecret(hint, outgoingSharedSecret, ephIndex) {
865
+ if (!isHexOfLength(hint.commitmentHex, 32)) return null;
866
+ if (!isHexOfLength(hint.encryptedMemo, ENCRYPTED_MEMO_SIZE)) return null;
867
+ try {
868
+ const plaintext = EncryptedMemo.decryptWithSharedSecret(
869
+ fromHex(hint.encryptedMemo),
870
+ fromHex(hint.commitmentHex),
871
+ outgoingSharedSecret
872
+ );
873
+ if (!plaintext) return null;
874
+ return {
875
+ commitmentHex: hint.commitmentHex,
876
+ ...isValidLeafIndex(hint.leafIndex) ? { leafIndex: hint.leafIndex } : {},
877
+ value: plaintext.value,
878
+ assetId: plaintext.assetId,
879
+ // On an outgoing note the memo's ownerPk IS the recipient's stealth
880
+ // owner — the builder wrote it that way.
881
+ recipientStealthPk: plaintext.ownerPk,
882
+ blinding: plaintext.blinding,
883
+ sourcePk: plaintext.sourcePk,
884
+ circuitVersion: plaintext.circuitVersion,
885
+ ephIndex
886
+ };
887
+ } catch {
888
+ return null;
889
+ }
890
+ }
891
+ function recoverSentNote(params) {
892
+ const { hint, outgoingViewingKey, ephIndex, recipientCandidates } = params;
893
+ if (!isHexOfLength(hint.commitmentHex, 32)) return null;
894
+ if (!isHexOfLength(hint.encryptedMemo, ENCRYPTED_MEMO_SIZE)) return null;
895
+ for (const candidate of recipientCandidates) {
896
+ let sharedSecret;
897
+ try {
898
+ sharedSecret = deriveOutgoingSharedSecret(outgoingViewingKey, ephIndex, candidate);
899
+ } catch {
900
+ continue;
901
+ }
902
+ const facts = recoverSentFromSharedSecret(hint, sharedSecret, ephIndex);
903
+ if (facts) return { ...facts, recipientIvk: candidate };
904
+ }
905
+ return null;
906
+ }
907
+
908
+ // src/protocol/note/recipientBook.ts
909
+ import { sha256 as sha2564 } from "@noble/hashes/sha2.js";
910
+ var RECIPIENT_BOOK_DOMAIN = new TextEncoder().encode("orbinum-recipient-book-v3");
911
+ var IVK_SIZE = 32;
912
+ function bookKeystream(outgoingViewingKey, paymentCommitmentHex) {
913
+ if (outgoingViewingKey.length !== 32) {
914
+ throw new Error(
915
+ `recipientBook: outgoingViewingKey must be 32 bytes, got ${outgoingViewingKey.length}`
916
+ );
917
+ }
918
+ if (outgoingViewingKey.every((b) => b === 0)) {
919
+ throw new Error(
920
+ "recipientBook: outgoingViewingKey is all zeros \u2014 the book would be public"
921
+ );
922
+ }
923
+ const normalised = paymentCommitmentHex.toLowerCase().replace(/^0x/, "");
924
+ const h = sha2564.create();
925
+ h.update(RECIPIENT_BOOK_DOMAIN);
926
+ h.update(outgoingViewingKey);
927
+ h.update(new TextEncoder().encode(normalised));
928
+ return h.digest();
929
+ }
930
+ function sealRecipientBookEntry(ivkPacked, outgoingViewingKey, paymentCommitmentHex) {
931
+ if (ivkPacked.length !== IVK_SIZE) return ivkPacked;
932
+ const keystream = bookKeystream(outgoingViewingKey, paymentCommitmentHex);
933
+ const out = new Uint8Array(IVK_SIZE);
934
+ for (let i = 0; i < IVK_SIZE; i++) out[i] = ivkPacked[i] ^ keystream[i];
935
+ return out;
936
+ }
937
+ function openRecipientBookEntry(sealedSourcePk, outgoingViewingKey, paymentCommitmentHex) {
938
+ if (sealedSourcePk < 0n || sealedSourcePk >> 256n !== 0n) return new Uint8Array(IVK_SIZE);
939
+ return sealRecipientBookEntry(
940
+ bigintTo32Le(sealedSourcePk),
941
+ outgoingViewingKey,
942
+ paymentCommitmentHex
943
+ );
944
+ }
945
+
871
946
  // src/protocol/note/NoteDisclosure.ts
872
947
  import { poseidon4 as poseidon43 } from "poseidon-lite";
873
948
 
@@ -953,29 +1028,41 @@ function decodeNoteDisclosureKey(key) {
953
1028
  }
954
1029
 
955
1030
  // src/protocol/eph/selfEph.ts
956
- import { sha256 as sha2564 } from "@noble/hashes/sha2.js";
957
- import { packPoint as packPoint2, unpackPoint as unpackPoint3 } from "@zk-kit/baby-jubjub";
958
- var SELF_EPH_DOMAIN = new TextEncoder().encode("orbinum-self-eph-v1");
959
- function deriveSelfEphSk(spendingKey, index) {
960
- const h = sha2564.create();
1031
+ import { sha256 as sha2565 } from "@noble/hashes/sha2.js";
1032
+ import { packPoint as packPoint3, unpackPoint as unpackPoint3 } from "@zk-kit/baby-jubjub";
1033
+ var SELF_EPH_DOMAIN = new TextEncoder().encode("orbinum-self-eph-v3");
1034
+ function deriveSelfEphSk(viewingSecretKey, index) {
1035
+ if (!Number.isInteger(index) || index < 0 || index > 4294967295) {
1036
+ throw new Error(`deriveSelfEphSk: index must be a u32, got ${index}`);
1037
+ }
1038
+ assertSecretKeyBytes(viewingSecretKey, "deriveSelfEphSk: viewingSecretKey");
1039
+ const h = sha2565.create();
961
1040
  h.update(SELF_EPH_DOMAIN);
962
- h.update(bigintTo32Le(spendingKey));
1041
+ h.update(viewingSecretKey);
963
1042
  const idx = new Uint8Array(4);
964
1043
  new DataView(idx.buffer).setUint32(0, index >>> 0, true);
965
1044
  h.update(idx);
966
1045
  return h.digest();
967
1046
  }
968
- function selfEphWindow(spendingKey, ivkPacked, from, count) {
1047
+ function selfEphWindow(viewingSecretKey, ivkPacked, from, count) {
969
1048
  const ivkPoint = unpackPoint3(bytesToBigintLE(ivkPacked));
970
1049
  if (!ivkPoint) throw new Error("selfEphWindow: invalid viewing public key");
971
1050
  const entries = [];
1051
+ if (!Number.isInteger(from) || from < 0) {
1052
+ throw new Error(`selfEphWindow: from must be a non-negative integer, got ${from}`);
1053
+ }
1054
+ if (!Number.isInteger(count) || count > MAX_EPH_WINDOW) {
1055
+ throw new Error(
1056
+ `selfEphWindow: count must be an integer <= ${MAX_EPH_WINDOW}, got ${count}`
1057
+ );
1058
+ }
972
1059
  for (let i = from; i < from + count; i++) {
973
- const scalar = bytesToBjjScalar(deriveSelfEphSk(spendingKey, i));
1060
+ const scalar = bytesToBjjScalar(deriveSelfEphSk(viewingSecretKey, i));
974
1061
  const ephPk = fastMulBase(scalar);
975
1062
  const sharedPoint = fastMulPoint(ivkPoint, scalar);
976
1063
  entries.push({
977
1064
  index: i,
978
- ephPkHex: toHex(bigintTo32Le(packPoint2(ephPk))),
1065
+ ephPkHex: toHex(bigintTo32Le(packPoint3(ephPk))),
979
1066
  sharedSecret: bigintTo32Le(sharedPoint[0])
980
1067
  });
981
1068
  }
@@ -983,17 +1070,22 @@ function selfEphWindow(spendingKey, ivkPacked, from, count) {
983
1070
  }
984
1071
 
985
1072
  // src/protocol/eph/pairwiseEph.ts
986
- import { sha256 as sha2565 } from "@noble/hashes/sha2.js";
987
- import { packPoint as packPoint3, unpackPoint as unpackPoint4 } from "@zk-kit/baby-jubjub";
1073
+ import { sha256 as sha2566 } from "@noble/hashes/sha2.js";
1074
+ import { packPoint as packPoint4, unpackPoint as unpackPoint4 } from "@zk-kit/baby-jubjub";
988
1075
  var PAIRWISE_EPH_DOMAIN = new TextEncoder().encode("orbinum-pairwise-eph-v1");
989
1076
  function derivePairwiseSharedSecret(myViewingSk, theirIvkPacked) {
990
- const theirPoint = unpackPoint4(bytesToBigintLE(theirIvkPacked));
1077
+ const theirPoint = unpackUsableViewingKey(bytesToBigintLE(theirIvkPacked));
991
1078
  if (!theirPoint) throw new Error("derivePairwiseSharedSecret: invalid viewing public key");
1079
+ assertSecretKeyBytes(myViewingSk, "derivePairwiseSharedSecret: myViewingSk");
992
1080
  const shared = fastMulPoint(theirPoint, bytesToBjjScalar(myViewingSk));
993
1081
  return bigintTo32Le(shared[0]);
994
1082
  }
995
1083
  function derivePairwiseEphSk(pairSecret, index) {
996
- const h = sha2565.create();
1084
+ if (!Number.isInteger(index) || index < 0 || index > 4294967295) {
1085
+ throw new Error(`derivePairwiseEphSk: index must be a u32, got ${index}`);
1086
+ }
1087
+ assertSecretKeyBytes(pairSecret, "derivePairwiseEphSk: pairSecret");
1088
+ const h = sha2566.create();
997
1089
  h.update(PAIRWISE_EPH_DOMAIN);
998
1090
  h.update(pairSecret);
999
1091
  const idx = new Uint8Array(4);
@@ -1005,13 +1097,21 @@ function pairwiseEphWindow(pairSecret, receiverIvkPacked, from, count) {
1005
1097
  const ivkPoint = unpackPoint4(bytesToBigintLE(receiverIvkPacked));
1006
1098
  if (!ivkPoint) throw new Error("pairwiseEphWindow: invalid viewing public key");
1007
1099
  const entries = [];
1100
+ if (!Number.isInteger(from) || from < 0) {
1101
+ throw new Error(`pairwiseEphWindow: from must be a non-negative integer, got ${from}`);
1102
+ }
1103
+ if (!Number.isInteger(count) || count > MAX_EPH_WINDOW) {
1104
+ throw new Error(
1105
+ `pairwiseEphWindow: count must be an integer <= ${MAX_EPH_WINDOW}, got ${count}`
1106
+ );
1107
+ }
1008
1108
  for (let i = from; i < from + count; i++) {
1009
1109
  const scalar = bytesToBjjScalar(derivePairwiseEphSk(pairSecret, i));
1010
1110
  const ephPk = fastMulBase(scalar);
1011
1111
  const sharedPoint = fastMulPoint(ivkPoint, scalar);
1012
1112
  entries.push({
1013
1113
  index: i,
1014
- ephPkHex: toHex(bigintTo32Le(packPoint3(ephPk))),
1114
+ ephPkHex: toHex(bigintTo32Le(packPoint4(ephPk))),
1015
1115
  sharedSecret: bigintTo32Le(sharedPoint[0])
1016
1116
  });
1017
1117
  }
@@ -1019,11 +1119,10 @@ function pairwiseEphWindow(pairSecret, receiverIvkPacked, from, count) {
1019
1119
  }
1020
1120
 
1021
1121
  // src/protocol/keys/PrivacyKeys.ts
1022
- import { hkdf as hkdf3 } from "@noble/hashes/hkdf.js";
1023
- import { sha256 as sha2566 } from "@noble/hashes/sha2.js";
1024
- import { packPoint as packPoint4 } from "@zk-kit/baby-jubjub";
1122
+ import { hkdf as hkdf2 } from "@noble/hashes/hkdf.js";
1123
+ import { sha256 as sha2567 } from "@noble/hashes/sha2.js";
1124
+ import { packPoint as packPoint5 } from "@zk-kit/baby-jubjub";
1025
1125
  var IVK_DOMAIN = new TextEncoder().encode("orbinum-ivk-v1");
1026
- var OVK_DOMAIN = new TextEncoder().encode("orbinum-ovk-v1");
1027
1126
  var KEY_VERSION = "v2";
1028
1127
  function deriveSpendingKeyFromMaster(masterBytes) {
1029
1128
  const skBigint = BigInt(toHex(masterBytes)) % BABYJUB_SUBORDER;
@@ -1031,12 +1130,26 @@ function deriveSpendingKeyFromMaster(masterBytes) {
1031
1130
  }
1032
1131
  function deriveViewingSecretKey(spendingKey) {
1033
1132
  const ikm = bigintTo32Le(spendingKey);
1034
- return hkdf3(sha2566, ikm, void 0, IVK_DOMAIN, 32);
1133
+ return hkdf2(sha2567, ikm, void 0, IVK_DOMAIN, 32);
1134
+ }
1135
+ var SPEND_V3_DOMAIN = new TextEncoder().encode("orbinum-spend-v3");
1136
+ var IVK_V3_DOMAIN = new TextEncoder().encode("orbinum-ivk-v3");
1137
+ var OVK_V3_DOMAIN = new TextEncoder().encode("orbinum-ovk-v3");
1138
+ var deriveBranch = (rootSecret, domain) => hkdf2(sha2567, rootSecret, void 0, domain, 32);
1139
+ function deriveSpendingKeyV3(rootSecret) {
1140
+ const scalar = BigInt(toHex(deriveBranch(rootSecret, SPEND_V3_DOMAIN))) % BABYJUB_SUBORDER;
1141
+ return scalar === 0n ? 1n : scalar;
1142
+ }
1143
+ function deriveViewingSecretKeyV3(rootSecret) {
1144
+ return deriveBranch(rootSecret, IVK_V3_DOMAIN);
1145
+ }
1146
+ function deriveOutgoingViewingKeyV3(rootSecret) {
1147
+ return deriveBranch(rootSecret, OVK_V3_DOMAIN);
1035
1148
  }
1036
1149
  function deriveViewingPublicKey(ivsk) {
1037
1150
  const ivskScalar = BigInt(toHex(ivsk)) % BABYJUB_SUBORDER || 1n;
1038
1151
  const ivkPoint = fastMulBase(ivskScalar);
1039
- const packed = packPoint4(ivkPoint);
1152
+ const packed = packPoint5(ivkPoint);
1040
1153
  return bigintTo32Le(packed);
1041
1154
  }
1042
1155
  function deriveOwnerPk(spendingKey) {
@@ -1047,19 +1160,22 @@ function deriveOwnerPk(spendingKey) {
1047
1160
  return 0n;
1048
1161
  }
1049
1162
  }
1050
- function deriveOutgoingViewingKey(masterBytes) {
1051
- return hkdf3(sha2566, masterBytes, void 0, OVK_DOMAIN, 32);
1052
- }
1053
1163
 
1054
1164
  // src/wallet/worker/kernel/types.ts
1055
1165
  var SELF_EPH_WINDOW = 1024;
1056
1166
  var PAIRWISE_EPH_WINDOW = 64;
1167
+ var OUTGOING_EPH_WINDOW = 64;
1057
1168
  var EMPTY_BATCH_RESULT = {
1058
1169
  notes: [],
1059
1170
  tagFiltered: 0,
1060
1171
  selfMatched: 0,
1061
1172
  pairwiseMatched: 0,
1062
- maxSelfEphIndex: null
1173
+ maxSelfEphIndex: null,
1174
+ maxOutgoingEphIndex: null,
1175
+ sentNotes: [],
1176
+ learnedRecipients: [],
1177
+ unmatchedSent: [],
1178
+ sealedBookEntries: []
1063
1179
  };
1064
1180
 
1065
1181
  // src/wallet/worker/kernel/ephWindow.ts
@@ -1067,23 +1183,21 @@ var cachedWindow = null;
1067
1183
  function clearKnownEphWindow() {
1068
1184
  cachedWindow = null;
1069
1185
  }
1070
- function bytesKey(bytes) {
1071
- let out = "";
1072
- for (const b of bytes) out += b.toString(16).padStart(2, "0");
1073
- return out;
1074
- }
1075
1186
  function getKnownEphWindow(keys) {
1076
1187
  const selfSize = keys.selfEphWindowSize ?? SELF_EPH_WINDOW;
1077
1188
  const pairSize = keys.pairwiseWindowSize ?? PAIRWISE_EPH_WINDOW;
1189
+ const outSize = keys.outgoingEphWindowSize ?? OUTGOING_EPH_WINDOW;
1078
1190
  const counterparties = keys.pairwiseCounterparties ?? [];
1079
- if (!keys.selfEph && counterparties.length === 0) return null;
1080
- const cacheKey = `${keys.spendingKey.toString(16)}:${keys.selfEph ? selfSize : 0}:${pairSize}:` + counterparties.map((c) => bytesKey(c)).join(",");
1191
+ const canScanOutgoing = keys.outgoingEph === true && isUsableSecretKey(keys.outgoingViewingKey);
1192
+ if (!keys.selfEph && !canScanOutgoing && counterparties.length === 0) return null;
1193
+ const cacheKey = `${keys.spendingKey.toString(16)}:${keys.selfEph ? selfSize : 0}:${pairSize}:${canScanOutgoing ? outSize : 0}:` + counterparties.map((c) => toHex(c)).join(",");
1081
1194
  if (cachedWindow?.cacheKey === cacheKey) return cachedWindow.window;
1082
1195
  try {
1083
1196
  const ivkPacked = deriveViewingPublicKey(keys.viewingKey);
1084
1197
  const byEphPk = /* @__PURE__ */ new Map();
1198
+ const outgoingByEphPk = /* @__PURE__ */ new Map();
1085
1199
  if (keys.selfEph) {
1086
- for (const e of selfEphWindow(keys.spendingKey, ivkPacked, 0, selfSize)) {
1200
+ for (const e of selfEphWindow(keys.viewingKey, ivkPacked, 0, selfSize)) {
1087
1201
  byEphPk.set(e.ephPkHex.toLowerCase(), {
1088
1202
  sharedSecret: e.sharedSecret,
1089
1203
  index: e.index,
@@ -1104,7 +1218,12 @@ function getKnownEphWindow(keys) {
1104
1218
  }
1105
1219
  }
1106
1220
  }
1107
- cachedWindow = { cacheKey, window: { byEphPk } };
1221
+ if (canScanOutgoing) {
1222
+ for (const e of outgoingEphWindow(keys.outgoingViewingKey, 0, outSize)) {
1223
+ outgoingByEphPk.set(e.ephPkHex.toLowerCase(), e.index);
1224
+ }
1225
+ }
1226
+ cachedWindow = { cacheKey, window: { byEphPk, outgoingByEphPk } };
1108
1227
  return cachedWindow.window;
1109
1228
  } catch {
1110
1229
  return null;
@@ -1113,21 +1232,32 @@ function getKnownEphWindow(keys) {
1113
1232
 
1114
1233
  // src/wallet/worker/kernel/decryptBatch.ts
1115
1234
  function hintEphPkHex(hint) {
1116
- if (hint.ephPkHex) return hint.ephPkHex.toLowerCase();
1117
1235
  const memo = hint.encryptedMemo;
1118
1236
  if (!memo || memo.length < 66) return null;
1119
1237
  return ("0x" + memo.slice(-64)).toLowerCase();
1120
1238
  }
1239
+ function sealedBookEntry(note) {
1240
+ if (note.sourcePk === void 0 || note.sourcePk === 0n) return null;
1241
+ return note.sourcePk;
1242
+ }
1121
1243
  function decryptHintBatch(hints, keys) {
1122
1244
  const activation = keys.viewTagActivationLeaf ?? null;
1123
1245
  const knownWindow = getKnownEphWindow(keys);
1246
+ const sentNotes = [];
1247
+ const learnedRecipients = /* @__PURE__ */ new Set();
1124
1248
  let tagFiltered = 0;
1125
1249
  let selfMatched = 0;
1126
1250
  let pairwiseMatched = 0;
1127
1251
  let maxSelfEphIndex = null;
1252
+ let maxOutgoingEphIndex = null;
1253
+ const sealedEntries = [];
1254
+ const pendingSent = [];
1255
+ const seenSent = /* @__PURE__ */ new Set();
1256
+ const unmatchedSent = [];
1128
1257
  const notes = hints.map((hint) => {
1129
1258
  try {
1130
- const known = knownWindow?.byEphPk.get(hintEphPkHex(hint) ?? "");
1259
+ const ephPkHex = hintEphPkHex(hint) ?? "";
1260
+ const known = knownWindow?.byEphPk.get(ephPkHex);
1131
1261
  if (known) {
1132
1262
  const result2 = tryDecryptNoteVerbose(
1133
1263
  hint,
@@ -1142,12 +1272,26 @@ function decryptHintBatch(hints, keys) {
1142
1272
  if (maxSelfEphIndex === null || known.index > maxSelfEphIndex) {
1143
1273
  maxSelfEphIndex = known.index;
1144
1274
  }
1275
+ const sealed = sealedBookEntry(result2.note);
1276
+ if (sealed !== null) sealedEntries.push(sealed);
1145
1277
  } else {
1146
1278
  pairwiseMatched++;
1147
1279
  }
1148
1280
  return result2.note;
1149
1281
  }
1150
1282
  }
1283
+ const outgoingIndex = knownWindow?.outgoingByEphPk.get(ephPkHex);
1284
+ if (outgoingIndex !== void 0) {
1285
+ if (maxOutgoingEphIndex === null || outgoingIndex > maxOutgoingEphIndex) {
1286
+ maxOutgoingEphIndex = outgoingIndex;
1287
+ }
1288
+ const key = hint.commitmentHex.toLowerCase();
1289
+ if (!seenSent.has(key)) {
1290
+ seenSent.add(key);
1291
+ pendingSent.push({ hint, ephIndex: outgoingIndex });
1292
+ }
1293
+ return null;
1294
+ }
1151
1295
  const viewTag = activation !== null && hint.leafIndex >= activation;
1152
1296
  const result = tryDecryptNoteVerbose(
1153
1297
  hint,
@@ -1162,7 +1306,44 @@ function decryptHintBatch(hints, keys) {
1162
1306
  return null;
1163
1307
  }
1164
1308
  });
1165
- return { notes, tagFiltered, selfMatched, pairwiseMatched, maxSelfEphIndex };
1309
+ const carried = keys.recipientCandidates ?? [];
1310
+ for (const { hint, ephIndex } of pendingSent) {
1311
+ const candidates = [
1312
+ ...sealedEntries.map(
1313
+ (e) => openRecipientBookEntry(e, keys.outgoingViewingKey, hint.commitmentHex)
1314
+ ),
1315
+ ...carried
1316
+ ];
1317
+ const sent = recoverSentNote({
1318
+ hint,
1319
+ outgoingViewingKey: keys.outgoingViewingKey,
1320
+ ephIndex,
1321
+ recipientCandidates: candidates
1322
+ });
1323
+ if (!sent) {
1324
+ unmatchedSent.push({ hint, ephIndex });
1325
+ continue;
1326
+ }
1327
+ const { recipientIvk, ...facts } = sent;
1328
+ learnedRecipients.add(toHex(recipientIvk));
1329
+ sentNotes.push({
1330
+ ...facts,
1331
+ counterpartyIvkHex: toHex(recipientIvk),
1332
+ encryptedMemo: hint.encryptedMemo ?? ""
1333
+ });
1334
+ }
1335
+ return {
1336
+ notes,
1337
+ tagFiltered,
1338
+ selfMatched,
1339
+ pairwiseMatched,
1340
+ maxSelfEphIndex,
1341
+ maxOutgoingEphIndex,
1342
+ sentNotes,
1343
+ learnedRecipients: [...learnedRecipients],
1344
+ unmatchedSent,
1345
+ sealedBookEntries: sealedEntries.map((e) => e.toString())
1346
+ };
1166
1347
  }
1167
1348
 
1168
1349
  // src/foundation/errors/abort.ts
@@ -1197,21 +1378,52 @@ function createMainThreadPool() {
1197
1378
  let selfMatched = 0;
1198
1379
  let pairwiseMatched = 0;
1199
1380
  let maxSelfEphIndex = null;
1381
+ let maxOutgoingEphIndex = null;
1382
+ const sentNotes = [];
1383
+ const unmatchedSent = [];
1384
+ const sealedBookEntries = /* @__PURE__ */ new Set();
1385
+ const learnedRecipients = new Set(
1386
+ (keys.recipientCandidates ?? []).map((c) => toHex(c))
1387
+ );
1200
1388
  for (let i = 0; i < hints.length; i += DECRYPT_YIELD_EVERY) {
1201
1389
  if (i > 0) {
1202
1390
  await yieldToBrowser();
1203
1391
  if (signal?.aborted) throw scanAbortError();
1204
1392
  }
1205
- const burst = decryptHintBatch(hints.slice(i, i + DECRYPT_YIELD_EVERY), keys);
1393
+ const burst = decryptHintBatch(hints.slice(i, i + DECRYPT_YIELD_EVERY), {
1394
+ ...keys,
1395
+ recipientCandidates: [...learnedRecipients].map((h) => fromHex(h))
1396
+ });
1206
1397
  notes.push(...burst.notes);
1207
1398
  tagFiltered += burst.tagFiltered;
1208
1399
  selfMatched += burst.selfMatched;
1209
1400
  pairwiseMatched += burst.pairwiseMatched;
1401
+ sentNotes.push(...burst.sentNotes ?? []);
1402
+ unmatchedSent.push(...burst.unmatchedSent ?? []);
1403
+ for (const e of burst.sealedBookEntries ?? []) sealedBookEntries.add(e);
1404
+ for (const r of burst.learnedRecipients ?? []) learnedRecipients.add(r);
1210
1405
  if (burst.maxSelfEphIndex !== null) {
1211
1406
  maxSelfEphIndex = Math.max(maxSelfEphIndex ?? -1, burst.maxSelfEphIndex);
1212
1407
  }
1408
+ if (burst.maxOutgoingEphIndex != null) {
1409
+ maxOutgoingEphIndex = Math.max(
1410
+ maxOutgoingEphIndex ?? -1,
1411
+ burst.maxOutgoingEphIndex
1412
+ );
1413
+ }
1213
1414
  }
1214
- return { notes, tagFiltered, selfMatched, pairwiseMatched, maxSelfEphIndex };
1415
+ return {
1416
+ notes,
1417
+ tagFiltered,
1418
+ selfMatched,
1419
+ pairwiseMatched,
1420
+ maxSelfEphIndex,
1421
+ maxOutgoingEphIndex,
1422
+ sentNotes,
1423
+ learnedRecipients: [...learnedRecipients],
1424
+ unmatchedSent,
1425
+ sealedBookEntries: [...sealedBookEntries]
1426
+ };
1215
1427
  },
1216
1428
  terminate() {
1217
1429
  clearKnownEphWindow();
@@ -1243,7 +1455,12 @@ function runOnWorker(worker, payload, signal) {
1243
1455
  tagFiltered: data.tagFiltered ?? 0,
1244
1456
  selfMatched: data.selfMatched ?? 0,
1245
1457
  pairwiseMatched: data.pairwiseMatched ?? 0,
1246
- maxSelfEphIndex: data.maxSelfEphIndex ?? null
1458
+ maxSelfEphIndex: data.maxSelfEphIndex ?? null,
1459
+ maxOutgoingEphIndex: data.maxOutgoingEphIndex ?? null,
1460
+ sentNotes: data.sentNotes ?? [],
1461
+ learnedRecipients: data.learnedRecipients ?? [],
1462
+ unmatchedSent: data.unmatchedSent ?? [],
1463
+ sealedBookEntries: data.sealedBookEntries ?? []
1247
1464
  });
1248
1465
  };
1249
1466
  worker.onerror = () => {
@@ -1270,9 +1487,19 @@ function mergeResults(results) {
1270
1487
  tagFiltered: results.reduce((sum, r) => sum + r.tagFiltered, 0),
1271
1488
  selfMatched: results.reduce((sum, r) => sum + r.selfMatched, 0),
1272
1489
  pairwiseMatched: results.reduce((sum, r) => sum + r.pairwiseMatched, 0),
1490
+ sentNotes: results.flatMap((r) => r.sentNotes ?? []),
1491
+ // Deduped: slices run in parallel, so the same change note cannot be
1492
+ // seen twice, but two different ones may name the same recipient.
1493
+ learnedRecipients: [...new Set(results.flatMap((r) => r.learnedRecipients ?? []))],
1494
+ unmatchedSent: results.flatMap((r) => r.unmatchedSent ?? []),
1495
+ sealedBookEntries: [...new Set(results.flatMap((r) => r.sealedBookEntries ?? []))],
1273
1496
  maxSelfEphIndex: results.reduce(
1274
1497
  (max, r) => r.maxSelfEphIndex === null ? max : Math.max(max ?? -1, r.maxSelfEphIndex),
1275
1498
  null
1499
+ ),
1500
+ maxOutgoingEphIndex: results.reduce(
1501
+ (max, r) => r.maxOutgoingEphIndex == null ? max : Math.max(max ?? -1, r.maxOutgoingEphIndex),
1502
+ null
1276
1503
  )
1277
1504
  };
1278
1505
  }
@@ -1339,10 +1566,14 @@ export {
1339
1566
  BN254_R,
1340
1567
  BABYJUB_SUBORDER,
1341
1568
  recoverOwnerPkPoint,
1569
+ unpackUsableViewingKey,
1570
+ isUsableSecretKey,
1571
+ assertSecretKeyBytes,
1342
1572
  fastMulBase,
1343
1573
  fastMulPoint,
1344
1574
  deriveStealthOwnerPk,
1345
1575
  deriveStealthSk,
1576
+ randomBlinding,
1346
1577
  scanAbortError,
1347
1578
  isAbortError,
1348
1579
  serializeMemo,
@@ -1350,11 +1581,6 @@ export {
1350
1581
  ENCRYPTED_MEMO_SIZE,
1351
1582
  bytesToBjjScalar,
1352
1583
  EncryptedMemo,
1353
- OVK_BLOB_SIZE,
1354
- deriveOutgoingCipherKey,
1355
- sealOutgoingBlob,
1356
- openOutgoingBlob,
1357
- randomOutgoingBlob,
1358
1584
  LEAVES_PER_TREE,
1359
1585
  isValidLeafIndex,
1360
1586
  treeIdOf,
@@ -1367,6 +1593,11 @@ export {
1367
1593
  derivePairwiseSharedSecret,
1368
1594
  derivePairwiseEphSk,
1369
1595
  pairwiseEphWindow,
1596
+ deriveOutgoingEphSk,
1597
+ deriveOutgoingEphPk,
1598
+ outgoingEphWindow,
1599
+ deriveOutgoingSharedSecret,
1600
+ reconstructOutgoingIndex,
1370
1601
  CURRENT_CIRCUIT_VERSION,
1371
1602
  NoteBuilder,
1372
1603
  computeNullifier,
@@ -1374,18 +1605,24 @@ export {
1374
1605
  computeNoteCommitment,
1375
1606
  tryDecryptNote,
1376
1607
  tryDecryptNoteVerbose,
1377
- tryRecoverOutgoing,
1378
1608
  collectOutgoingFacts,
1609
+ recoverSentFromSharedSecret,
1610
+ recoverSentNote,
1611
+ sealRecipientBookEntry,
1612
+ openRecipientBookEntry,
1379
1613
  createNoteDisclosureKey,
1380
1614
  decodeNoteDisclosureKey,
1381
1615
  KEY_VERSION,
1382
1616
  deriveSpendingKeyFromMaster,
1383
1617
  deriveViewingSecretKey,
1618
+ deriveSpendingKeyV3,
1619
+ deriveViewingSecretKeyV3,
1620
+ deriveOutgoingViewingKeyV3,
1384
1621
  deriveViewingPublicKey,
1385
1622
  deriveOwnerPk,
1386
- deriveOutgoingViewingKey,
1387
1623
  SELF_EPH_WINDOW,
1388
1624
  PAIRWISE_EPH_WINDOW,
1625
+ OUTGOING_EPH_WINDOW,
1389
1626
  EMPTY_BATCH_RESULT,
1390
1627
  clearKnownEphWindow,
1391
1628
  getKnownEphWindow,