@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.
@@ -23,6 +23,7 @@ __export(worker_exports, {
23
23
  DECRYPT_YIELD_EVERY: () => DECRYPT_YIELD_EVERY,
24
24
  EMPTY_BATCH_RESULT: () => EMPTY_BATCH_RESULT,
25
25
  MAX_WORKERS: () => MAX_WORKERS,
26
+ OUTGOING_EPH_WINDOW: () => OUTGOING_EPH_WINDOW,
26
27
  PAIRWISE_EPH_WINDOW: () => PAIRWISE_EPH_WINDOW,
27
28
  SELF_EPH_WINDOW: () => SELF_EPH_WINDOW,
28
29
  WORKER_CRASHED: () => WORKER_CRASHED,
@@ -38,7 +39,7 @@ module.exports = __toCommonJS(worker_exports);
38
39
  // src/protocol/memo/EncryptedMemo.ts
39
40
  var import_chacha = require("@noble/ciphers/chacha.js");
40
41
  var import_utils = require("@noble/ciphers/utils.js");
41
- var import_baby_jubjub = require("@zk-kit/baby-jubjub");
42
+ var import_baby_jubjub2 = require("@zk-kit/baby-jubjub");
42
43
 
43
44
  // src/foundation/crypto/bjj-fast.ts
44
45
  var import_edwards = require("@noble/curves/abstract/edwards.js");
@@ -66,7 +67,88 @@ function fastMulPoint(point, scalar) {
66
67
  return [x, y];
67
68
  }
68
69
 
70
+ // src/foundation/crypto/bjj.ts
71
+ var import_baby_jubjub = require("@zk-kit/baby-jubjub");
72
+
73
+ // src/foundation/crypto/constants.ts
74
+ var BN254_R = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
75
+ var BABYJUB_SUBORDER = 2736030358979909402780800718157159386076813972158567259200215660948447373041n;
76
+
77
+ // src/foundation/crypto/bjj.ts
78
+ var BJJ_A = 168700n;
79
+ var BJJ_D = 168696n;
80
+ var BJJ_COFACTOR = 8n;
81
+ function _modpow(base, exp, mod) {
82
+ let result = 1n;
83
+ base = base % mod;
84
+ while (exp > 0n) {
85
+ if (exp & 1n) result = result * base % mod;
86
+ exp >>= 1n;
87
+ base = base * base % mod;
88
+ }
89
+ return result;
90
+ }
91
+ function _sqrtModP(y2) {
92
+ if (y2 === 0n) return 0n;
93
+ if (_modpow(y2, (BN254_R - 1n) / 2n, BN254_R) !== 1n) return null;
94
+ let s = 0n;
95
+ let q = BN254_R - 1n;
96
+ while ((q & 1n) === 0n) {
97
+ q >>= 1n;
98
+ s++;
99
+ }
100
+ if (s === 1n) return _modpow(y2, (BN254_R + 1n) / 4n, BN254_R);
101
+ let z = 2n;
102
+ while (_modpow(z, (BN254_R - 1n) / 2n, BN254_R) === 1n) z++;
103
+ let m = s;
104
+ let c = _modpow(z, q, BN254_R);
105
+ let t = _modpow(y2, q, BN254_R);
106
+ let r = _modpow(y2, (q + 1n) / 2n, BN254_R);
107
+ for (; ; ) {
108
+ if (t === 1n) return r;
109
+ let i = 1n;
110
+ let tmp = t * t % BN254_R;
111
+ while (tmp !== 1n) {
112
+ tmp = tmp * tmp % BN254_R;
113
+ i++;
114
+ }
115
+ const b = _modpow(c, 1n << m - i - 1n, BN254_R);
116
+ m = i;
117
+ c = b * b % BN254_R;
118
+ t = t * c % BN254_R;
119
+ r = r * b % BN254_R;
120
+ }
121
+ }
122
+ function recoverOwnerPkPoint(ax) {
123
+ const x2 = ax * ax % BN254_R;
124
+ const num = ((1n - BJJ_A * x2) % BN254_R + BN254_R) % BN254_R;
125
+ const den = ((1n - BJJ_D * x2) % BN254_R + BN254_R) % BN254_R;
126
+ if (den === 0n) return null;
127
+ const denInv = _modpow(den, BN254_R - 2n, BN254_R);
128
+ const y2 = num * denInv % BN254_R;
129
+ const y = _sqrtModP(y2);
130
+ if (y === null) return null;
131
+ const yAlt = BN254_R - y;
132
+ try {
133
+ const check = (0, import_baby_jubjub.mulPointEscalar)([ax, y], BABYJUB_SUBORDER);
134
+ return check[0] === 0n && check[1] === 1n ? [ax, y] : [ax, yAlt];
135
+ } catch {
136
+ return [ax, yAlt];
137
+ }
138
+ }
139
+ function unpackUsableViewingKey(packed) {
140
+ const point = (0, import_baby_jubjub.unpackPoint)(packed);
141
+ if (!point) return null;
142
+ if (point[0] === 0n && point[1] === 1n) return null;
143
+ const cleared = (0, import_baby_jubjub.mulPointEscalar)(point, BJJ_COFACTOR);
144
+ if (cleared[0] === 0n && cleared[1] === 1n) return null;
145
+ return point;
146
+ }
147
+
69
148
  // src/foundation/encoding/hex.ts
149
+ function isHexOfLength(value, byteLen) {
150
+ return typeof value === "string" && new RegExp(`^0x[0-9a-fA-F]{${byteLen * 2}}$`).test(value);
151
+ }
70
152
  function toHex(bytes) {
71
153
  return "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
72
154
  }
@@ -109,16 +191,21 @@ function bytesToBigintLE(bytes) {
109
191
  return result;
110
192
  }
111
193
 
112
- // src/foundation/crypto/constants.ts
113
- var BN254_R = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
114
- var BABYJUB_SUBORDER = 2736030358979909402780800718157159386076813972158567259200215660948447373041n;
115
-
116
194
  // src/protocol/memo/plaintext.ts
117
195
  var import_sha2 = require("@noble/hashes/sha2.js");
118
196
  var KEY_DOMAIN = new TextEncoder().encode("orbinum-note-encryption-v1");
119
197
  var VIEW_TAG_DOMAIN = new TextEncoder().encode("orbinum-view-tag-v1");
120
198
  var MEMO_PLAINTEXT_SIZE = 120;
121
199
  function serializeMemo(value, ownerPk, blinding, assetId, sourcePk, circuitVersion) {
200
+ if (value < 0n || value >> 128n !== 0n) {
201
+ throw new Error(`serializeMemo: value must fit in 128 unsigned bits, got ${value}`);
202
+ }
203
+ if (!Number.isInteger(assetId) || assetId < 0 || assetId > 4294967295) {
204
+ throw new Error(`serializeMemo: assetId must be a u32, got ${assetId}`);
205
+ }
206
+ if (!Number.isInteger(circuitVersion) || circuitVersion < 0 || circuitVersion > 4294967295) {
207
+ throw new Error(`serializeMemo: circuitVersion must be a u32, got ${circuitVersion}`);
208
+ }
122
209
  const buf = new Uint8Array(MEMO_PLAINTEXT_SIZE);
123
210
  const view = new DataView(buf.buffer);
124
211
  view.setBigUint64(0, value & 0xffffffffffffffffn, true);
@@ -150,6 +237,9 @@ var CIPHERTEXT_SIZE = 136;
150
237
  var EPH_PK_SIZE = 32;
151
238
  var ENCRYPTED_MEMO_SIZE = NONCE_SIZE + CIPHERTEXT_SIZE + EPH_PK_SIZE;
152
239
  function bytesToBjjScalar(bytes) {
240
+ if (bytes.length !== 32) {
241
+ throw new Error(`bytesToBjjScalar: expected 32 bytes, got ${bytes.length}`);
242
+ }
153
243
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
154
244
  return BigInt("0x" + hex) % BABYJUB_SUBORDER || 1n;
155
245
  }
@@ -173,7 +263,7 @@ function parsePlaintext(nonce, ciphertextWithMac, encKey) {
173
263
  }
174
264
  var EncryptedMemo = {
175
265
  /**
176
- * Build and encrypt a memo for a note using ECDH (v2, 180 bytes).
266
+ * Build and encrypt a memo for a note using ECDH (180 bytes).
177
267
  *
178
268
  * @param value Note value in planck.
179
269
  * @param ownerPk 32-byte owner public key (LE).
@@ -211,9 +301,9 @@ var EncryptedMemo = {
211
301
  throw new Error("EncryptedMemo.encrypt: ephSkOverride must be 32 bytes");
212
302
  const ephSkScalar = bytesToBjjScalar(ephSkBytes);
213
303
  const ephPkPoint = fastMulBase(ephSkScalar);
214
- ephPkPackedBytes = bigintTo32Le((0, import_baby_jubjub.packPoint)(ephPkPoint));
304
+ ephPkPackedBytes = bigintTo32Le((0, import_baby_jubjub2.packPoint)(ephPkPoint));
215
305
  const ivkPackedBigint = bytesToBigintLE(recipientIvkPacked);
216
- const ivkPoint = (0, import_baby_jubjub.unpackPoint)(ivkPackedBigint);
306
+ const ivkPoint = unpackUsableViewingKey(ivkPackedBigint);
217
307
  if (!ivkPoint)
218
308
  throw new Error("EncryptedMemo.encrypt: invalid recipient viewing public key");
219
309
  const sharedPoint = fastMulPoint(ivkPoint, ephSkScalar);
@@ -303,7 +393,7 @@ var EncryptedMemo = {
303
393
  if (ephPkPackedBigint === 0n) {
304
394
  return new Uint8Array(32);
305
395
  }
306
- const ephPkPoint = (0, import_baby_jubjub.unpackPoint)(ephPkPackedBigint);
396
+ const ephPkPoint = unpackUsableViewingKey(ephPkPackedBigint);
307
397
  if (!ephPkPoint) return null;
308
398
  const ivskScalar = bytesToBjjScalar(viewingSecretKey);
309
399
  const sharedPoint = fastMulPoint(ephPkPoint, ivskScalar);
@@ -313,9 +403,9 @@ var EncryptedMemo = {
313
403
  * Cheap view-tag check: does memo nonce[0] match the tag derived from
314
404
  * `sharedSecret`? One SHA256 + one byte compare — no AEAD work.
315
405
  *
316
- * Only meaningful for memos built with view tags (commitments at/after
317
- * the wallet's tagActivationLeaf): a legacy memo carries a random byte
318
- * there and would false-negative 255/256 of the time.
406
+ * Only meaningful for memos that carry a tag at or after
407
+ * `ScanKeys.viewTagActivationLeaf`. An older memo has a random byte there
408
+ * and would false-negative 255/256 of the time.
319
409
  */
320
410
  checkViewTag(memoBytes, sharedSecret) {
321
411
  if (memoBytes.length !== ENCRYPTED_MEMO_SIZE) return false;
@@ -344,7 +434,7 @@ var EncryptedMemo = {
344
434
  // src/foundation/crypto/stealth.ts
345
435
  var import_sha22 = require("@noble/hashes/sha2.js");
346
436
  var import_hkdf = require("@noble/hashes/hkdf.js");
347
- var import_baby_jubjub2 = require("@zk-kit/baby-jubjub");
437
+ var import_baby_jubjub3 = require("@zk-kit/baby-jubjub");
348
438
  var STEALTH_INFO = new TextEncoder().encode("orbinum-stealth-v1");
349
439
  function deriveStealthScalar(sharedSecret, ownerPkBigint) {
350
440
  const salt = bigintTo32Le(ownerPkBigint);
@@ -353,7 +443,7 @@ function deriveStealthScalar(sharedSecret, ownerPkBigint) {
353
443
  }
354
444
  function deriveStealthOwnerPk(sharedSecret, ownerPkBigint, ownerPkPoint) {
355
445
  const stealthScalar = deriveStealthScalar(sharedSecret, ownerPkBigint);
356
- const stealthPt = (0, import_baby_jubjub2.addPoint)((0, import_baby_jubjub2.mulPointEscalar)(import_baby_jubjub2.Base8, stealthScalar), ownerPkPoint);
446
+ const stealthPt = (0, import_baby_jubjub3.addPoint)((0, import_baby_jubjub3.mulPointEscalar)(import_baby_jubjub3.Base8, stealthScalar), ownerPkPoint);
357
447
  return stealthPt[0];
358
448
  }
359
449
  function deriveStealthSk(sharedSecret, ownerPkBigint, spendingKey) {
@@ -361,69 +451,6 @@ function deriveStealthSk(sharedSecret, ownerPkBigint, spendingKey) {
361
451
  return (stealthScalar + spendingKey) % BABYJUB_SUBORDER || 1n;
362
452
  }
363
453
 
364
- // src/foundation/crypto/bjj.ts
365
- var import_baby_jubjub3 = require("@zk-kit/baby-jubjub");
366
- var BJJ_A = 168700n;
367
- var BJJ_D = 168696n;
368
- function _modpow(base, exp, mod) {
369
- let result = 1n;
370
- base = base % mod;
371
- while (exp > 0n) {
372
- if (exp & 1n) result = result * base % mod;
373
- exp >>= 1n;
374
- base = base * base % mod;
375
- }
376
- return result;
377
- }
378
- function _sqrtModP(y2) {
379
- if (y2 === 0n) return 0n;
380
- if (_modpow(y2, (BN254_R - 1n) / 2n, BN254_R) !== 1n) return null;
381
- let s = 0n;
382
- let q = BN254_R - 1n;
383
- while ((q & 1n) === 0n) {
384
- q >>= 1n;
385
- s++;
386
- }
387
- if (s === 1n) return _modpow(y2, (BN254_R + 1n) / 4n, BN254_R);
388
- let z = 2n;
389
- while (_modpow(z, (BN254_R - 1n) / 2n, BN254_R) === 1n) z++;
390
- let m = s;
391
- let c = _modpow(z, q, BN254_R);
392
- let t = _modpow(y2, q, BN254_R);
393
- let r = _modpow(y2, (q + 1n) / 2n, BN254_R);
394
- for (; ; ) {
395
- if (t === 1n) return r;
396
- let i = 1n;
397
- let tmp = t * t % BN254_R;
398
- while (tmp !== 1n) {
399
- tmp = tmp * tmp % BN254_R;
400
- i++;
401
- }
402
- const b = _modpow(c, 1n << m - i - 1n, BN254_R);
403
- m = i;
404
- c = b * b % BN254_R;
405
- t = t * c % BN254_R;
406
- r = r * b % BN254_R;
407
- }
408
- }
409
- function recoverOwnerPkPoint(ax) {
410
- const x2 = ax * ax % BN254_R;
411
- const num = ((1n - BJJ_A * x2) % BN254_R + BN254_R) % BN254_R;
412
- const den = ((1n - BJJ_D * x2) % BN254_R + BN254_R) % BN254_R;
413
- if (den === 0n) return null;
414
- const denInv = _modpow(den, BN254_R - 2n, BN254_R);
415
- const y2 = num * denInv % BN254_R;
416
- const y = _sqrtModP(y2);
417
- if (y === null) return null;
418
- const yAlt = BN254_R - y;
419
- try {
420
- const check = (0, import_baby_jubjub3.mulPointEscalar)([ax, y], BABYJUB_SUBORDER);
421
- return check[0] === 0n && check[1] === 1n ? [ax, y] : [ax, yAlt];
422
- } catch {
423
- return [ax, yAlt];
424
- }
425
- }
426
-
427
454
  // src/protocol/note/NoteDecryptor.ts
428
455
  var import_poseidon_lite = require("poseidon-lite");
429
456
 
@@ -506,30 +533,187 @@ function tryDecryptNoteVerbose(commitment, viewingSecretKey, spendingKey, ownOwn
506
533
  };
507
534
  }
508
535
 
509
- // src/protocol/eph/selfEph.ts
536
+ // src/protocol/eph/outgoingEph.ts
510
537
  var import_sha23 = require("@noble/hashes/sha2.js");
538
+
539
+ // src/protocol/eph/windowBounds.ts
540
+ var MAX_EPH_WINDOW = 1 << 20;
541
+
542
+ // src/foundation/crypto/keyGuards.ts
543
+ var SECRET_KEY_SIZE = 32;
544
+ function isUsableSecretKey(key) {
545
+ return key !== void 0 && key !== null && key.length === SECRET_KEY_SIZE && !key.every((b) => b === 0);
546
+ }
547
+ function assertSecretKeyBytes(key, label) {
548
+ if (key.length !== SECRET_KEY_SIZE) {
549
+ throw new Error(`${label} must be ${SECRET_KEY_SIZE} bytes, got ${key.length}`);
550
+ }
551
+ if (key.every((b) => b === 0)) {
552
+ throw new Error(`${label} is all zeros \u2014 that is not key material`);
553
+ }
554
+ }
555
+
556
+ // src/protocol/eph/outgoingEph.ts
511
557
  var import_baby_jubjub4 = require("@zk-kit/baby-jubjub");
512
- var SELF_EPH_DOMAIN = new TextEncoder().encode("orbinum-self-eph-v1");
513
- function deriveSelfEphSk(spendingKey, index) {
558
+ var OUTGOING_EPH_DOMAIN = new TextEncoder().encode("orbinum-outgoing-eph-v3");
559
+ function deriveOutgoingEphSk(outgoingViewingKey, index) {
560
+ if (!Number.isInteger(index) || index < 0 || index > 4294967295) {
561
+ throw new Error(`deriveOutgoingEphSk: index must be a u32, got ${index}`);
562
+ }
563
+ assertSecretKeyBytes(outgoingViewingKey, "deriveOutgoingEphSk: outgoingViewingKey");
514
564
  const h = import_sha23.sha256.create();
565
+ h.update(OUTGOING_EPH_DOMAIN);
566
+ h.update(outgoingViewingKey);
567
+ const idx = new Uint8Array(4);
568
+ new DataView(idx.buffer).setUint32(0, index >>> 0, true);
569
+ h.update(idx);
570
+ return h.digest();
571
+ }
572
+ function deriveOutgoingEphPk(outgoingViewingKey, index) {
573
+ const scalar = bytesToBjjScalar(deriveOutgoingEphSk(outgoingViewingKey, index));
574
+ return toHex(bigintTo32Le((0, import_baby_jubjub4.packPoint)(fastMulBase(scalar)))).toLowerCase();
575
+ }
576
+ function outgoingEphWindow(outgoingViewingKey, from, count) {
577
+ const entries = [];
578
+ if (!Number.isInteger(from) || from < 0) {
579
+ throw new Error(`outgoingEphWindow: from must be a non-negative integer, got ${from}`);
580
+ }
581
+ if (!Number.isInteger(count) || count > MAX_EPH_WINDOW) {
582
+ throw new Error(
583
+ `outgoingEphWindow: count must be an integer <= ${MAX_EPH_WINDOW}, got ${count}`
584
+ );
585
+ }
586
+ for (let i = from; i < from + count; i++) {
587
+ entries.push({ index: i, ephPkHex: deriveOutgoingEphPk(outgoingViewingKey, i) });
588
+ }
589
+ return entries;
590
+ }
591
+ function deriveOutgoingSharedSecret(outgoingViewingKey, index, recipientIvkPacked) {
592
+ const ivkPoint = (0, import_baby_jubjub4.unpackPoint)(bytesToBigintLE(recipientIvkPacked));
593
+ if (!ivkPoint) throw new Error("deriveOutgoingSharedSecret: invalid viewing public key");
594
+ const scalar = bytesToBjjScalar(deriveOutgoingEphSk(outgoingViewingKey, index));
595
+ return bigintTo32Le(fastMulPoint(ivkPoint, scalar)[0]);
596
+ }
597
+
598
+ // src/protocol/note/recoverSent.ts
599
+ function recoverSentFromSharedSecret(hint, outgoingSharedSecret, ephIndex) {
600
+ if (!isHexOfLength(hint.commitmentHex, 32)) return null;
601
+ if (!isHexOfLength(hint.encryptedMemo, ENCRYPTED_MEMO_SIZE)) return null;
602
+ try {
603
+ const plaintext = EncryptedMemo.decryptWithSharedSecret(
604
+ fromHex(hint.encryptedMemo),
605
+ fromHex(hint.commitmentHex),
606
+ outgoingSharedSecret
607
+ );
608
+ if (!plaintext) return null;
609
+ return {
610
+ commitmentHex: hint.commitmentHex,
611
+ ...isValidLeafIndex(hint.leafIndex) ? { leafIndex: hint.leafIndex } : {},
612
+ value: plaintext.value,
613
+ assetId: plaintext.assetId,
614
+ // On an outgoing note the memo's ownerPk IS the recipient's stealth
615
+ // owner — the builder wrote it that way.
616
+ recipientStealthPk: plaintext.ownerPk,
617
+ blinding: plaintext.blinding,
618
+ sourcePk: plaintext.sourcePk,
619
+ circuitVersion: plaintext.circuitVersion,
620
+ ephIndex
621
+ };
622
+ } catch {
623
+ return null;
624
+ }
625
+ }
626
+ function recoverSentNote(params) {
627
+ const { hint, outgoingViewingKey, ephIndex, recipientCandidates } = params;
628
+ if (!isHexOfLength(hint.commitmentHex, 32)) return null;
629
+ if (!isHexOfLength(hint.encryptedMemo, ENCRYPTED_MEMO_SIZE)) return null;
630
+ for (const candidate of recipientCandidates) {
631
+ let sharedSecret;
632
+ try {
633
+ sharedSecret = deriveOutgoingSharedSecret(outgoingViewingKey, ephIndex, candidate);
634
+ } catch {
635
+ continue;
636
+ }
637
+ const facts = recoverSentFromSharedSecret(hint, sharedSecret, ephIndex);
638
+ if (facts) return { ...facts, recipientIvk: candidate };
639
+ }
640
+ return null;
641
+ }
642
+
643
+ // src/protocol/note/recipientBook.ts
644
+ var import_sha24 = require("@noble/hashes/sha2.js");
645
+ var RECIPIENT_BOOK_DOMAIN = new TextEncoder().encode("orbinum-recipient-book-v3");
646
+ var IVK_SIZE = 32;
647
+ function bookKeystream(outgoingViewingKey, paymentCommitmentHex) {
648
+ if (outgoingViewingKey.length !== 32) {
649
+ throw new Error(
650
+ `recipientBook: outgoingViewingKey must be 32 bytes, got ${outgoingViewingKey.length}`
651
+ );
652
+ }
653
+ if (outgoingViewingKey.every((b) => b === 0)) {
654
+ throw new Error(
655
+ "recipientBook: outgoingViewingKey is all zeros \u2014 the book would be public"
656
+ );
657
+ }
658
+ const normalised = paymentCommitmentHex.toLowerCase().replace(/^0x/, "");
659
+ const h = import_sha24.sha256.create();
660
+ h.update(RECIPIENT_BOOK_DOMAIN);
661
+ h.update(outgoingViewingKey);
662
+ h.update(new TextEncoder().encode(normalised));
663
+ return h.digest();
664
+ }
665
+ function sealRecipientBookEntry(ivkPacked, outgoingViewingKey, paymentCommitmentHex) {
666
+ if (ivkPacked.length !== IVK_SIZE) return ivkPacked;
667
+ const keystream = bookKeystream(outgoingViewingKey, paymentCommitmentHex);
668
+ const out = new Uint8Array(IVK_SIZE);
669
+ for (let i = 0; i < IVK_SIZE; i++) out[i] = ivkPacked[i] ^ keystream[i];
670
+ return out;
671
+ }
672
+ function openRecipientBookEntry(sealedSourcePk, outgoingViewingKey, paymentCommitmentHex) {
673
+ if (sealedSourcePk < 0n || sealedSourcePk >> 256n !== 0n) return new Uint8Array(IVK_SIZE);
674
+ return sealRecipientBookEntry(
675
+ bigintTo32Le(sealedSourcePk),
676
+ outgoingViewingKey,
677
+ paymentCommitmentHex
678
+ );
679
+ }
680
+
681
+ // src/protocol/eph/selfEph.ts
682
+ var import_sha25 = require("@noble/hashes/sha2.js");
683
+ var import_baby_jubjub5 = require("@zk-kit/baby-jubjub");
684
+ var SELF_EPH_DOMAIN = new TextEncoder().encode("orbinum-self-eph-v3");
685
+ function deriveSelfEphSk(viewingSecretKey, index) {
686
+ if (!Number.isInteger(index) || index < 0 || index > 4294967295) {
687
+ throw new Error(`deriveSelfEphSk: index must be a u32, got ${index}`);
688
+ }
689
+ assertSecretKeyBytes(viewingSecretKey, "deriveSelfEphSk: viewingSecretKey");
690
+ const h = import_sha25.sha256.create();
515
691
  h.update(SELF_EPH_DOMAIN);
516
- h.update(bigintTo32Le(spendingKey));
692
+ h.update(viewingSecretKey);
517
693
  const idx = new Uint8Array(4);
518
694
  new DataView(idx.buffer).setUint32(0, index >>> 0, true);
519
695
  h.update(idx);
520
696
  return h.digest();
521
697
  }
522
- function selfEphWindow(spendingKey, ivkPacked, from, count) {
523
- const ivkPoint = (0, import_baby_jubjub4.unpackPoint)(bytesToBigintLE(ivkPacked));
698
+ function selfEphWindow(viewingSecretKey, ivkPacked, from, count) {
699
+ const ivkPoint = (0, import_baby_jubjub5.unpackPoint)(bytesToBigintLE(ivkPacked));
524
700
  if (!ivkPoint) throw new Error("selfEphWindow: invalid viewing public key");
525
701
  const entries = [];
702
+ if (!Number.isInteger(from) || from < 0) {
703
+ throw new Error(`selfEphWindow: from must be a non-negative integer, got ${from}`);
704
+ }
705
+ if (!Number.isInteger(count) || count > MAX_EPH_WINDOW) {
706
+ throw new Error(
707
+ `selfEphWindow: count must be an integer <= ${MAX_EPH_WINDOW}, got ${count}`
708
+ );
709
+ }
526
710
  for (let i = from; i < from + count; i++) {
527
- const scalar = bytesToBjjScalar(deriveSelfEphSk(spendingKey, i));
711
+ const scalar = bytesToBjjScalar(deriveSelfEphSk(viewingSecretKey, i));
528
712
  const ephPk = fastMulBase(scalar);
529
713
  const sharedPoint = fastMulPoint(ivkPoint, scalar);
530
714
  entries.push({
531
715
  index: i,
532
- ephPkHex: toHex(bigintTo32Le((0, import_baby_jubjub4.packPoint)(ephPk))),
716
+ ephPkHex: toHex(bigintTo32Le((0, import_baby_jubjub5.packPoint)(ephPk))),
533
717
  sharedSecret: bigintTo32Le(sharedPoint[0])
534
718
  });
535
719
  }
@@ -537,17 +721,22 @@ function selfEphWindow(spendingKey, ivkPacked, from, count) {
537
721
  }
538
722
 
539
723
  // src/protocol/eph/pairwiseEph.ts
540
- var import_sha24 = require("@noble/hashes/sha2.js");
541
- var import_baby_jubjub5 = require("@zk-kit/baby-jubjub");
724
+ var import_sha26 = require("@noble/hashes/sha2.js");
725
+ var import_baby_jubjub6 = require("@zk-kit/baby-jubjub");
542
726
  var PAIRWISE_EPH_DOMAIN = new TextEncoder().encode("orbinum-pairwise-eph-v1");
543
727
  function derivePairwiseSharedSecret(myViewingSk, theirIvkPacked) {
544
- const theirPoint = (0, import_baby_jubjub5.unpackPoint)(bytesToBigintLE(theirIvkPacked));
728
+ const theirPoint = unpackUsableViewingKey(bytesToBigintLE(theirIvkPacked));
545
729
  if (!theirPoint) throw new Error("derivePairwiseSharedSecret: invalid viewing public key");
730
+ assertSecretKeyBytes(myViewingSk, "derivePairwiseSharedSecret: myViewingSk");
546
731
  const shared = fastMulPoint(theirPoint, bytesToBjjScalar(myViewingSk));
547
732
  return bigintTo32Le(shared[0]);
548
733
  }
549
734
  function derivePairwiseEphSk(pairSecret, index) {
550
- const h = import_sha24.sha256.create();
735
+ if (!Number.isInteger(index) || index < 0 || index > 4294967295) {
736
+ throw new Error(`derivePairwiseEphSk: index must be a u32, got ${index}`);
737
+ }
738
+ assertSecretKeyBytes(pairSecret, "derivePairwiseEphSk: pairSecret");
739
+ const h = import_sha26.sha256.create();
551
740
  h.update(PAIRWISE_EPH_DOMAIN);
552
741
  h.update(pairSecret);
553
742
  const idx = new Uint8Array(4);
@@ -556,16 +745,24 @@ function derivePairwiseEphSk(pairSecret, index) {
556
745
  return h.digest();
557
746
  }
558
747
  function pairwiseEphWindow(pairSecret, receiverIvkPacked, from, count) {
559
- const ivkPoint = (0, import_baby_jubjub5.unpackPoint)(bytesToBigintLE(receiverIvkPacked));
748
+ const ivkPoint = (0, import_baby_jubjub6.unpackPoint)(bytesToBigintLE(receiverIvkPacked));
560
749
  if (!ivkPoint) throw new Error("pairwiseEphWindow: invalid viewing public key");
561
750
  const entries = [];
751
+ if (!Number.isInteger(from) || from < 0) {
752
+ throw new Error(`pairwiseEphWindow: from must be a non-negative integer, got ${from}`);
753
+ }
754
+ if (!Number.isInteger(count) || count > MAX_EPH_WINDOW) {
755
+ throw new Error(
756
+ `pairwiseEphWindow: count must be an integer <= ${MAX_EPH_WINDOW}, got ${count}`
757
+ );
758
+ }
562
759
  for (let i = from; i < from + count; i++) {
563
760
  const scalar = bytesToBjjScalar(derivePairwiseEphSk(pairSecret, i));
564
761
  const ephPk = fastMulBase(scalar);
565
762
  const sharedPoint = fastMulPoint(ivkPoint, scalar);
566
763
  entries.push({
567
764
  index: i,
568
- ephPkHex: toHex(bigintTo32Le((0, import_baby_jubjub5.packPoint)(ephPk))),
765
+ ephPkHex: toHex(bigintTo32Le((0, import_baby_jubjub6.packPoint)(ephPk))),
569
766
  sharedSecret: bigintTo32Le(sharedPoint[0])
570
767
  });
571
768
  }
@@ -574,26 +771,34 @@ function pairwiseEphWindow(pairSecret, receiverIvkPacked, from, count) {
574
771
 
575
772
  // src/protocol/keys/PrivacyKeys.ts
576
773
  var import_hkdf2 = require("@noble/hashes/hkdf.js");
577
- var import_sha25 = require("@noble/hashes/sha2.js");
578
- var import_baby_jubjub6 = require("@zk-kit/baby-jubjub");
774
+ var import_sha27 = require("@noble/hashes/sha2.js");
775
+ var import_baby_jubjub7 = require("@zk-kit/baby-jubjub");
579
776
  var IVK_DOMAIN = new TextEncoder().encode("orbinum-ivk-v1");
580
- var OVK_DOMAIN = new TextEncoder().encode("orbinum-ovk-v1");
777
+ var SPEND_V3_DOMAIN = new TextEncoder().encode("orbinum-spend-v3");
778
+ var IVK_V3_DOMAIN = new TextEncoder().encode("orbinum-ivk-v3");
779
+ var OVK_V3_DOMAIN = new TextEncoder().encode("orbinum-ovk-v3");
581
780
  function deriveViewingPublicKey(ivsk) {
582
781
  const ivskScalar = BigInt(toHex(ivsk)) % BABYJUB_SUBORDER || 1n;
583
782
  const ivkPoint = fastMulBase(ivskScalar);
584
- const packed = (0, import_baby_jubjub6.packPoint)(ivkPoint);
783
+ const packed = (0, import_baby_jubjub7.packPoint)(ivkPoint);
585
784
  return bigintTo32Le(packed);
586
785
  }
587
786
 
588
787
  // src/wallet/worker/kernel/types.ts
589
788
  var SELF_EPH_WINDOW = 1024;
590
789
  var PAIRWISE_EPH_WINDOW = 64;
790
+ var OUTGOING_EPH_WINDOW = 64;
591
791
  var EMPTY_BATCH_RESULT = {
592
792
  notes: [],
593
793
  tagFiltered: 0,
594
794
  selfMatched: 0,
595
795
  pairwiseMatched: 0,
596
- maxSelfEphIndex: null
796
+ maxSelfEphIndex: null,
797
+ maxOutgoingEphIndex: null,
798
+ sentNotes: [],
799
+ learnedRecipients: [],
800
+ unmatchedSent: [],
801
+ sealedBookEntries: []
597
802
  };
598
803
 
599
804
  // src/wallet/worker/kernel/ephWindow.ts
@@ -601,23 +806,21 @@ var cachedWindow = null;
601
806
  function clearKnownEphWindow() {
602
807
  cachedWindow = null;
603
808
  }
604
- function bytesKey(bytes) {
605
- let out = "";
606
- for (const b of bytes) out += b.toString(16).padStart(2, "0");
607
- return out;
608
- }
609
809
  function getKnownEphWindow(keys) {
610
810
  const selfSize = keys.selfEphWindowSize ?? SELF_EPH_WINDOW;
611
811
  const pairSize = keys.pairwiseWindowSize ?? PAIRWISE_EPH_WINDOW;
812
+ const outSize = keys.outgoingEphWindowSize ?? OUTGOING_EPH_WINDOW;
612
813
  const counterparties = keys.pairwiseCounterparties ?? [];
613
- if (!keys.selfEph && counterparties.length === 0) return null;
614
- const cacheKey = `${keys.spendingKey.toString(16)}:${keys.selfEph ? selfSize : 0}:${pairSize}:` + counterparties.map((c) => bytesKey(c)).join(",");
814
+ const canScanOutgoing = keys.outgoingEph === true && isUsableSecretKey(keys.outgoingViewingKey);
815
+ if (!keys.selfEph && !canScanOutgoing && counterparties.length === 0) return null;
816
+ const cacheKey = `${keys.spendingKey.toString(16)}:${keys.selfEph ? selfSize : 0}:${pairSize}:${canScanOutgoing ? outSize : 0}:` + counterparties.map((c) => toHex(c)).join(",");
615
817
  if (cachedWindow?.cacheKey === cacheKey) return cachedWindow.window;
616
818
  try {
617
819
  const ivkPacked = deriveViewingPublicKey(keys.viewingKey);
618
820
  const byEphPk = /* @__PURE__ */ new Map();
821
+ const outgoingByEphPk = /* @__PURE__ */ new Map();
619
822
  if (keys.selfEph) {
620
- for (const e of selfEphWindow(keys.spendingKey, ivkPacked, 0, selfSize)) {
823
+ for (const e of selfEphWindow(keys.viewingKey, ivkPacked, 0, selfSize)) {
621
824
  byEphPk.set(e.ephPkHex.toLowerCase(), {
622
825
  sharedSecret: e.sharedSecret,
623
826
  index: e.index,
@@ -638,7 +841,12 @@ function getKnownEphWindow(keys) {
638
841
  }
639
842
  }
640
843
  }
641
- cachedWindow = { cacheKey, window: { byEphPk } };
844
+ if (canScanOutgoing) {
845
+ for (const e of outgoingEphWindow(keys.outgoingViewingKey, 0, outSize)) {
846
+ outgoingByEphPk.set(e.ephPkHex.toLowerCase(), e.index);
847
+ }
848
+ }
849
+ cachedWindow = { cacheKey, window: { byEphPk, outgoingByEphPk } };
642
850
  return cachedWindow.window;
643
851
  } catch {
644
852
  return null;
@@ -647,21 +855,32 @@ function getKnownEphWindow(keys) {
647
855
 
648
856
  // src/wallet/worker/kernel/decryptBatch.ts
649
857
  function hintEphPkHex(hint) {
650
- if (hint.ephPkHex) return hint.ephPkHex.toLowerCase();
651
858
  const memo = hint.encryptedMemo;
652
859
  if (!memo || memo.length < 66) return null;
653
860
  return ("0x" + memo.slice(-64)).toLowerCase();
654
861
  }
862
+ function sealedBookEntry(note) {
863
+ if (note.sourcePk === void 0 || note.sourcePk === 0n) return null;
864
+ return note.sourcePk;
865
+ }
655
866
  function decryptHintBatch(hints, keys) {
656
867
  const activation = keys.viewTagActivationLeaf ?? null;
657
868
  const knownWindow = getKnownEphWindow(keys);
869
+ const sentNotes = [];
870
+ const learnedRecipients = /* @__PURE__ */ new Set();
658
871
  let tagFiltered = 0;
659
872
  let selfMatched = 0;
660
873
  let pairwiseMatched = 0;
661
874
  let maxSelfEphIndex = null;
875
+ let maxOutgoingEphIndex = null;
876
+ const sealedEntries = [];
877
+ const pendingSent = [];
878
+ const seenSent = /* @__PURE__ */ new Set();
879
+ const unmatchedSent = [];
662
880
  const notes = hints.map((hint) => {
663
881
  try {
664
- const known = knownWindow?.byEphPk.get(hintEphPkHex(hint) ?? "");
882
+ const ephPkHex = hintEphPkHex(hint) ?? "";
883
+ const known = knownWindow?.byEphPk.get(ephPkHex);
665
884
  if (known) {
666
885
  const result2 = tryDecryptNoteVerbose(
667
886
  hint,
@@ -676,12 +895,26 @@ function decryptHintBatch(hints, keys) {
676
895
  if (maxSelfEphIndex === null || known.index > maxSelfEphIndex) {
677
896
  maxSelfEphIndex = known.index;
678
897
  }
898
+ const sealed = sealedBookEntry(result2.note);
899
+ if (sealed !== null) sealedEntries.push(sealed);
679
900
  } else {
680
901
  pairwiseMatched++;
681
902
  }
682
903
  return result2.note;
683
904
  }
684
905
  }
906
+ const outgoingIndex = knownWindow?.outgoingByEphPk.get(ephPkHex);
907
+ if (outgoingIndex !== void 0) {
908
+ if (maxOutgoingEphIndex === null || outgoingIndex > maxOutgoingEphIndex) {
909
+ maxOutgoingEphIndex = outgoingIndex;
910
+ }
911
+ const key = hint.commitmentHex.toLowerCase();
912
+ if (!seenSent.has(key)) {
913
+ seenSent.add(key);
914
+ pendingSent.push({ hint, ephIndex: outgoingIndex });
915
+ }
916
+ return null;
917
+ }
685
918
  const viewTag = activation !== null && hint.leafIndex >= activation;
686
919
  const result = tryDecryptNoteVerbose(
687
920
  hint,
@@ -696,7 +929,44 @@ function decryptHintBatch(hints, keys) {
696
929
  return null;
697
930
  }
698
931
  });
699
- return { notes, tagFiltered, selfMatched, pairwiseMatched, maxSelfEphIndex };
932
+ const carried = keys.recipientCandidates ?? [];
933
+ for (const { hint, ephIndex } of pendingSent) {
934
+ const candidates = [
935
+ ...sealedEntries.map(
936
+ (e) => openRecipientBookEntry(e, keys.outgoingViewingKey, hint.commitmentHex)
937
+ ),
938
+ ...carried
939
+ ];
940
+ const sent = recoverSentNote({
941
+ hint,
942
+ outgoingViewingKey: keys.outgoingViewingKey,
943
+ ephIndex,
944
+ recipientCandidates: candidates
945
+ });
946
+ if (!sent) {
947
+ unmatchedSent.push({ hint, ephIndex });
948
+ continue;
949
+ }
950
+ const { recipientIvk, ...facts } = sent;
951
+ learnedRecipients.add(toHex(recipientIvk));
952
+ sentNotes.push({
953
+ ...facts,
954
+ counterpartyIvkHex: toHex(recipientIvk),
955
+ encryptedMemo: hint.encryptedMemo ?? ""
956
+ });
957
+ }
958
+ return {
959
+ notes,
960
+ tagFiltered,
961
+ selfMatched,
962
+ pairwiseMatched,
963
+ maxSelfEphIndex,
964
+ maxOutgoingEphIndex,
965
+ sentNotes,
966
+ learnedRecipients: [...learnedRecipients],
967
+ unmatchedSent,
968
+ sealedBookEntries: sealedEntries.map((e) => e.toString())
969
+ };
700
970
  }
701
971
 
702
972
  // src/foundation/errors/abort.ts
@@ -728,21 +998,52 @@ function createMainThreadPool() {
728
998
  let selfMatched = 0;
729
999
  let pairwiseMatched = 0;
730
1000
  let maxSelfEphIndex = null;
1001
+ let maxOutgoingEphIndex = null;
1002
+ const sentNotes = [];
1003
+ const unmatchedSent = [];
1004
+ const sealedBookEntries = /* @__PURE__ */ new Set();
1005
+ const learnedRecipients = new Set(
1006
+ (keys.recipientCandidates ?? []).map((c) => toHex(c))
1007
+ );
731
1008
  for (let i = 0; i < hints.length; i += DECRYPT_YIELD_EVERY) {
732
1009
  if (i > 0) {
733
1010
  await yieldToBrowser();
734
1011
  if (signal?.aborted) throw scanAbortError();
735
1012
  }
736
- const burst = decryptHintBatch(hints.slice(i, i + DECRYPT_YIELD_EVERY), keys);
1013
+ const burst = decryptHintBatch(hints.slice(i, i + DECRYPT_YIELD_EVERY), {
1014
+ ...keys,
1015
+ recipientCandidates: [...learnedRecipients].map((h) => fromHex(h))
1016
+ });
737
1017
  notes.push(...burst.notes);
738
1018
  tagFiltered += burst.tagFiltered;
739
1019
  selfMatched += burst.selfMatched;
740
1020
  pairwiseMatched += burst.pairwiseMatched;
1021
+ sentNotes.push(...burst.sentNotes ?? []);
1022
+ unmatchedSent.push(...burst.unmatchedSent ?? []);
1023
+ for (const e of burst.sealedBookEntries ?? []) sealedBookEntries.add(e);
1024
+ for (const r of burst.learnedRecipients ?? []) learnedRecipients.add(r);
741
1025
  if (burst.maxSelfEphIndex !== null) {
742
1026
  maxSelfEphIndex = Math.max(maxSelfEphIndex ?? -1, burst.maxSelfEphIndex);
743
1027
  }
1028
+ if (burst.maxOutgoingEphIndex != null) {
1029
+ maxOutgoingEphIndex = Math.max(
1030
+ maxOutgoingEphIndex ?? -1,
1031
+ burst.maxOutgoingEphIndex
1032
+ );
1033
+ }
744
1034
  }
745
- return { notes, tagFiltered, selfMatched, pairwiseMatched, maxSelfEphIndex };
1035
+ return {
1036
+ notes,
1037
+ tagFiltered,
1038
+ selfMatched,
1039
+ pairwiseMatched,
1040
+ maxSelfEphIndex,
1041
+ maxOutgoingEphIndex,
1042
+ sentNotes,
1043
+ learnedRecipients: [...learnedRecipients],
1044
+ unmatchedSent,
1045
+ sealedBookEntries: [...sealedBookEntries]
1046
+ };
746
1047
  },
747
1048
  terminate() {
748
1049
  clearKnownEphWindow();
@@ -774,7 +1075,12 @@ function runOnWorker(worker, payload, signal) {
774
1075
  tagFiltered: data.tagFiltered ?? 0,
775
1076
  selfMatched: data.selfMatched ?? 0,
776
1077
  pairwiseMatched: data.pairwiseMatched ?? 0,
777
- maxSelfEphIndex: data.maxSelfEphIndex ?? null
1078
+ maxSelfEphIndex: data.maxSelfEphIndex ?? null,
1079
+ maxOutgoingEphIndex: data.maxOutgoingEphIndex ?? null,
1080
+ sentNotes: data.sentNotes ?? [],
1081
+ learnedRecipients: data.learnedRecipients ?? [],
1082
+ unmatchedSent: data.unmatchedSent ?? [],
1083
+ sealedBookEntries: data.sealedBookEntries ?? []
778
1084
  });
779
1085
  };
780
1086
  worker.onerror = () => {
@@ -801,9 +1107,19 @@ function mergeResults(results) {
801
1107
  tagFiltered: results.reduce((sum, r) => sum + r.tagFiltered, 0),
802
1108
  selfMatched: results.reduce((sum, r) => sum + r.selfMatched, 0),
803
1109
  pairwiseMatched: results.reduce((sum, r) => sum + r.pairwiseMatched, 0),
1110
+ sentNotes: results.flatMap((r) => r.sentNotes ?? []),
1111
+ // Deduped: slices run in parallel, so the same change note cannot be
1112
+ // seen twice, but two different ones may name the same recipient.
1113
+ learnedRecipients: [...new Set(results.flatMap((r) => r.learnedRecipients ?? []))],
1114
+ unmatchedSent: results.flatMap((r) => r.unmatchedSent ?? []),
1115
+ sealedBookEntries: [...new Set(results.flatMap((r) => r.sealedBookEntries ?? []))],
804
1116
  maxSelfEphIndex: results.reduce(
805
1117
  (max, r) => r.maxSelfEphIndex === null ? max : Math.max(max ?? -1, r.maxSelfEphIndex),
806
1118
  null
1119
+ ),
1120
+ maxOutgoingEphIndex: results.reduce(
1121
+ (max, r) => r.maxOutgoingEphIndex == null ? max : Math.max(max ?? -1, r.maxOutgoingEphIndex),
1122
+ null
807
1123
  )
808
1124
  };
809
1125
  }
@@ -855,6 +1171,7 @@ function createDecryptPool(options) {
855
1171
  DECRYPT_YIELD_EVERY,
856
1172
  EMPTY_BATCH_RESULT,
857
1173
  MAX_WORKERS,
1174
+ OUTGOING_EPH_WINDOW,
858
1175
  PAIRWISE_EPH_WINDOW,
859
1176
  SELF_EPH_WINDOW,
860
1177
  WORKER_CRASHED,