@orbinum/sdk 1.3.0 → 1.4.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.d.mts CHANGED
@@ -240,8 +240,27 @@ declare function reserveSelfEphIndex(storage: NoteStorage): Promise<number>;
240
240
  * viewing public key. Registering the counterparty is a side effect worth
241
241
  * having: it makes the REVERSE direction cheap too, since their future payments
242
242
  * to this wallet become hash lookups instead of one trial ECDH per note.
243
+ *
244
+ * Returns `null` when this vault holds no history for that counterparty, and
245
+ * the caller must then use a random ephemeral. The reason is that "no history"
246
+ * has two causes this function cannot tell apart:
247
+ *
248
+ * - a genuine first payment, where index 0 is correct;
249
+ * - a counter that was LOST — a restored seed, a cleared IndexedDB, a wipe
250
+ * and rescan — where index 0 was already published and re-deriving it
251
+ * republishes that ephPk, linking the two notes in public.
252
+ *
253
+ * Unlike `selfEphCounter`, this one cannot be recovered: the index was
254
+ * published on a note encrypted toward someone else, so it never appears in
255
+ * this wallet's own scan, and asking a server whether a given ephPk exists
256
+ * would reveal which notes are ours. So the ambiguity is resolved the safe way
257
+ * — the caller degrades to random, costing the recipient one trial scan, and
258
+ * every later payment to the same counterparty takes the fast path again.
259
+ *
260
+ * The entry is still created: the NEXT payment has a counter, and registering
261
+ * the counterparty is what makes the reverse direction cheap.
243
262
  */
244
- declare function reservePairwiseIndex(storage: NoteStorage, ivkHex: string): Promise<number>;
263
+ declare function reservePairwiseIndex(storage: NoteStorage, ivkHex: string): Promise<number | null>;
245
264
 
246
265
  /**
247
266
  * Turning a note into a stored record and back.
@@ -4495,7 +4514,13 @@ declare function resolveSelfEphCeiling(params: {
4495
4514
  /**
4496
4515
  * Discovery window size for the next scan, given the persisted counter: at least
4497
4516
  * the default, rounded up so every index the wallet may already have used (plus
4498
- * the gap margin) falls inside the fast path.
4517
+ * the gap margin) falls inside the fast path, and never past `MAX_EPH_WINDOW`.
4518
+ *
4519
+ * The counter is read from a config that survives restores and hand-editing, so
4520
+ * a non-finite value is treated as "no history" rather than propagated: `NaN`
4521
+ * would make the builder's `i < from + count` false immediately and return an
4522
+ * EMPTY window, silently disabling the fast path, while `Infinity` would make
4523
+ * that same loop never terminate.
4499
4524
  */
4500
4525
  declare function windowSizeForCounter(counter: number): number;
4501
4526
 
@@ -4515,9 +4540,9 @@ declare function windowSizeForCounter(counter: number): number;
4515
4540
  *
4516
4541
  * The expensive part (one ECDH per memo) runs in the injected decrypt pool —
4517
4542
  * Web Workers when the host provides a factory, a yielded loop otherwise — while
4518
- * the cheap bookkeeping (cursor, on-chain set, counters) stays here. The next
4519
- * chunk/page is prefetched while the current one decrypts, overlapping network
4520
- * and CPU.
4543
+ * the cheap bookkeeping (cursor, on-chain set, counters) stays here. Both
4544
+ * transports download through the same PREFETCH-deep sliding window
4545
+ * (`runPrefetched`), overlapping network and CPU.
4521
4546
  */
4522
4547
 
4523
4548
  /**
@@ -4552,8 +4577,11 @@ interface ScanOutcome {
4552
4577
  onChainHexes: Set<string>;
4553
4578
  /** Highest leafIndex seen — the next incremental scan resumes after it. */
4554
4579
  maxLeafIndex: number | undefined;
4580
+ /** Hints walked this call, across both transports. */
4555
4581
  scanned: number;
4582
+ /** Notes recovered that the vault did not hold yet. */
4556
4583
  found: number;
4584
+ /** Hints carrying no memo or ephemeral key — nothing to decrypt. */
4557
4585
  noMemo: number;
4558
4586
  /**
4559
4587
  * Hints whose memo did not open with this wallet's keys.
@@ -4564,6 +4592,7 @@ interface ScanOutcome {
4564
4592
  * recovers nothing while this equals the whole pool has a key problem.
4565
4593
  */
4566
4594
  decryptFailed: number;
4595
+ /** Notes recovered that the vault already held. */
4567
4596
  alreadyPresent: number;
4568
4597
  /** Hints discarded by the view-tag fast filter (no AEAD decrypt attempted). */
4569
4598
  tagFiltered: number;
package/dist/index.d.ts CHANGED
@@ -240,8 +240,27 @@ declare function reserveSelfEphIndex(storage: NoteStorage): Promise<number>;
240
240
  * viewing public key. Registering the counterparty is a side effect worth
241
241
  * having: it makes the REVERSE direction cheap too, since their future payments
242
242
  * to this wallet become hash lookups instead of one trial ECDH per note.
243
+ *
244
+ * Returns `null` when this vault holds no history for that counterparty, and
245
+ * the caller must then use a random ephemeral. The reason is that "no history"
246
+ * has two causes this function cannot tell apart:
247
+ *
248
+ * - a genuine first payment, where index 0 is correct;
249
+ * - a counter that was LOST — a restored seed, a cleared IndexedDB, a wipe
250
+ * and rescan — where index 0 was already published and re-deriving it
251
+ * republishes that ephPk, linking the two notes in public.
252
+ *
253
+ * Unlike `selfEphCounter`, this one cannot be recovered: the index was
254
+ * published on a note encrypted toward someone else, so it never appears in
255
+ * this wallet's own scan, and asking a server whether a given ephPk exists
256
+ * would reveal which notes are ours. So the ambiguity is resolved the safe way
257
+ * — the caller degrades to random, costing the recipient one trial scan, and
258
+ * every later payment to the same counterparty takes the fast path again.
259
+ *
260
+ * The entry is still created: the NEXT payment has a counter, and registering
261
+ * the counterparty is what makes the reverse direction cheap.
243
262
  */
244
- declare function reservePairwiseIndex(storage: NoteStorage, ivkHex: string): Promise<number>;
263
+ declare function reservePairwiseIndex(storage: NoteStorage, ivkHex: string): Promise<number | null>;
245
264
 
246
265
  /**
247
266
  * Turning a note into a stored record and back.
@@ -4495,7 +4514,13 @@ declare function resolveSelfEphCeiling(params: {
4495
4514
  /**
4496
4515
  * Discovery window size for the next scan, given the persisted counter: at least
4497
4516
  * the default, rounded up so every index the wallet may already have used (plus
4498
- * the gap margin) falls inside the fast path.
4517
+ * the gap margin) falls inside the fast path, and never past `MAX_EPH_WINDOW`.
4518
+ *
4519
+ * The counter is read from a config that survives restores and hand-editing, so
4520
+ * a non-finite value is treated as "no history" rather than propagated: `NaN`
4521
+ * would make the builder's `i < from + count` false immediately and return an
4522
+ * EMPTY window, silently disabling the fast path, while `Infinity` would make
4523
+ * that same loop never terminate.
4499
4524
  */
4500
4525
  declare function windowSizeForCounter(counter: number): number;
4501
4526
 
@@ -4515,9 +4540,9 @@ declare function windowSizeForCounter(counter: number): number;
4515
4540
  *
4516
4541
  * The expensive part (one ECDH per memo) runs in the injected decrypt pool —
4517
4542
  * Web Workers when the host provides a factory, a yielded loop otherwise — while
4518
- * the cheap bookkeeping (cursor, on-chain set, counters) stays here. The next
4519
- * chunk/page is prefetched while the current one decrypts, overlapping network
4520
- * and CPU.
4543
+ * the cheap bookkeeping (cursor, on-chain set, counters) stays here. Both
4544
+ * transports download through the same PREFETCH-deep sliding window
4545
+ * (`runPrefetched`), overlapping network and CPU.
4521
4546
  */
4522
4547
 
4523
4548
  /**
@@ -4552,8 +4577,11 @@ interface ScanOutcome {
4552
4577
  onChainHexes: Set<string>;
4553
4578
  /** Highest leafIndex seen — the next incremental scan resumes after it. */
4554
4579
  maxLeafIndex: number | undefined;
4580
+ /** Hints walked this call, across both transports. */
4555
4581
  scanned: number;
4582
+ /** Notes recovered that the vault did not hold yet. */
4556
4583
  found: number;
4584
+ /** Hints carrying no memo or ephemeral key — nothing to decrypt. */
4557
4585
  noMemo: number;
4558
4586
  /**
4559
4587
  * Hints whose memo did not open with this wallet's keys.
@@ -4564,6 +4592,7 @@ interface ScanOutcome {
4564
4592
  * recovers nothing while this equals the whole pool has a key problem.
4565
4593
  */
4566
4594
  decryptFailed: number;
4595
+ /** Notes recovered that the vault already held. */
4567
4596
  alreadyPresent: number;
4568
4597
  /** Hints discarded by the view-tag fast filter (no AEAD decrypt attempted). */
4569
4598
  tagFiltered: number;
package/dist/index.js CHANGED
@@ -5414,13 +5414,29 @@ function mergeCounters(current, fallback) {
5414
5414
  ...parties !== void 0 ? { pairwiseCounterparties: parties } : {}
5415
5415
  };
5416
5416
  }
5417
- var higher = (a, b) => a === void 0 ? b : b === void 0 ? a : Math.max(a, b);
5417
+ var counterOrNone = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
5418
+ var higher = (a, b) => {
5419
+ const x = counterOrNone(a);
5420
+ const y = counterOrNone(b);
5421
+ return x === void 0 ? y : y === void 0 ? x : Math.max(x, y);
5422
+ };
5423
+ function usableEntry(entry) {
5424
+ if (typeof entry !== "object" || entry === null) return void 0;
5425
+ const { nextIndex, addedAt } = entry;
5426
+ const index = counterOrNone(nextIndex);
5427
+ if (index === void 0) return void 0;
5428
+ return { nextIndex: index, addedAt: counterOrNone(addedAt) ?? Date.now() };
5429
+ }
5418
5430
  function mergePairwise(current, fallback) {
5419
5431
  if (!current && !fallback) return void 0;
5420
- const merged = {
5421
- ...fallback ?? {}
5422
- };
5423
- for (const [key, entry] of Object.entries(current ?? {})) {
5432
+ const merged = {};
5433
+ for (const [key, entry] of Object.entries(fallback ?? {})) {
5434
+ const usable = usableEntry(entry);
5435
+ if (usable) merged[key] = usable;
5436
+ }
5437
+ for (const [key, raw] of Object.entries(current ?? {})) {
5438
+ const entry = usableEntry(raw);
5439
+ if (!entry) continue;
5424
5440
  const prev = merged[key];
5425
5441
  merged[key] = prev ? {
5426
5442
  nextIndex: Math.max(prev.nextIndex, entry.nextIndex),
@@ -5431,33 +5447,42 @@ function mergePairwise(current, fallback) {
5431
5447
  }
5432
5448
 
5433
5449
  // src/wallet/vault/storage/ephemeralIndex.ts
5450
+ var MAX_EPH_INDEX = 4294967294;
5451
+ function usableCounter(value) {
5452
+ if (typeof value !== "number" || !Number.isInteger(value)) return null;
5453
+ if (value < 0 || value > MAX_EPH_INDEX) return null;
5454
+ return value;
5455
+ }
5434
5456
  async function reserveSelfEphIndex(storage) {
5435
5457
  const updated = await storage.updateConfig((config) => ({
5436
5458
  ...config,
5437
- selfEphCounter: (config.selfEphCounter ?? 0) + 1,
5459
+ selfEphCounter: (usableCounter(config.selfEphCounter) ?? 0) + 1,
5438
5460
  updatedAt: Date.now()
5439
5461
  }));
5440
5462
  if (!updated) throw new Error("vault not initialized");
5441
- return (updated.selfEphCounter ?? 1) - 1;
5463
+ return (usableCounter(updated.selfEphCounter) ?? 1) - 1;
5442
5464
  }
5443
5465
  async function reservePairwiseIndex(storage, ivkHex) {
5444
5466
  const key = ivkHex.toLowerCase();
5445
5467
  const updated = await storage.updateConfig((config) => {
5446
- const current = config.pairwiseCounterparties?.[key];
5468
+ const stored = usableCounter(config.pairwiseCounterparties?.[key]?.nextIndex);
5469
+ const addedAt = config.pairwiseCounterparties?.[key]?.addedAt;
5447
5470
  return {
5448
5471
  ...config,
5449
5472
  pairwiseCounterparties: {
5450
5473
  ...config.pairwiseCounterparties,
5451
5474
  [key]: {
5452
- nextIndex: (current?.nextIndex ?? 0) + 1,
5453
- addedAt: current?.addedAt ?? Date.now()
5475
+ nextIndex: (stored ?? 0) + 1,
5476
+ addedAt: typeof addedAt === "number" ? addedAt : Date.now()
5454
5477
  }
5455
5478
  },
5456
5479
  updatedAt: Date.now()
5457
5480
  };
5458
5481
  });
5459
5482
  if (!updated) throw new Error("vault not initialized");
5460
- return (updated.pairwiseCounterparties?.[key]?.nextIndex ?? 1) - 1;
5483
+ const nextIndex = usableCounter(updated.pairwiseCounterparties?.[key]?.nextIndex);
5484
+ if (nextIndex === null || nextIndex <= 1) return null;
5485
+ return nextIndex - 1;
5461
5486
  }
5462
5487
 
5463
5488
  // src/wallet/vault/notes/record.ts
@@ -5880,6 +5905,7 @@ var VaultStore = class {
5880
5905
 
5881
5906
  // src/wallet/scanner/phases/collect.ts
5882
5907
  var PAGE_SIZE = 2500;
5908
+ var PREFETCH = 3;
5883
5909
  async function processHints(ctx, hints, total) {
5884
5910
  const { outcome } = ctx;
5885
5911
  const toDecrypt = [];
@@ -5927,6 +5953,24 @@ async function processHints(ctx, hints, total) {
5927
5953
  await ctx.onBatchDone?.(outcome.maxLeafIndex);
5928
5954
  ctx.onProgress?.({ scanned: outcome.scanned, total, found: outcome.found });
5929
5955
  }
5956
+ async function runPrefetched(count, fetchItem, signal, handle) {
5957
+ const inFlight = /* @__PURE__ */ new Map();
5958
+ const ensureFetching = (i) => {
5959
+ if (i < count && !inFlight.has(i)) inFlight.set(i, fetchItem(i));
5960
+ };
5961
+ try {
5962
+ for (let i = 0; i < count; i++) {
5963
+ if (signal?.aborted) throw scanAbortError();
5964
+ for (let n = i; n < i + PREFETCH; n++) ensureFetching(n);
5965
+ const item = await inFlight.get(i);
5966
+ inFlight.delete(i);
5967
+ await handle(item);
5968
+ }
5969
+ } finally {
5970
+ for (const pending of inFlight.values()) pending.catch(() => {
5971
+ });
5972
+ }
5973
+ }
5930
5974
  async function processSealedChunks(ctx, source, sinceLeafIndex) {
5931
5975
  if (!source.chunks) return null;
5932
5976
  const manifest = await source.chunks.manifest().catch(() => null);
@@ -5939,27 +5983,36 @@ async function processSealedChunks(ctx, source, sinceLeafIndex) {
5939
5983
  if (pending.length === 0) return null;
5940
5984
  const total = Math.max(manifest.total - startLeaf, 0);
5941
5985
  const fetchChunk = (i) => source.chunks.chunk(pending[i].idx, pending[i].digest);
5942
- let nextChunk = fetchChunk(0);
5943
- try {
5944
- for (let i = 0; i < pending.length; i++) {
5945
- if (ctx.signal?.aborted) throw scanAbortError();
5946
- const hints = await nextChunk;
5947
- nextChunk = i + 1 < pending.length ? fetchChunk(i + 1) : null;
5948
- await processHints(
5986
+ await runPrefetched(
5987
+ pending.length,
5988
+ fetchChunk,
5989
+ ctx.signal,
5990
+ (hints) => (
5991
+ // The first chunk may straddle the cursor drop already-scanned leaves.
5992
+ processHints(
5949
5993
  ctx,
5950
5994
  hints.filter((h) => (h.leafIndex ?? 0) >= startLeaf),
5951
5995
  total
5952
- );
5953
- }
5954
- } finally {
5955
- nextChunk?.catch(() => {
5956
- });
5957
- }
5996
+ )
5997
+ )
5998
+ );
5958
5999
  return manifest.lastSealedLeaf + 1;
5959
6000
  }
6001
+ async function processPaginatedTail(ctx, source, sinceLeafIndex) {
6002
+ const fetchPage = (page) => source.listHints({ page, limit: PAGE_SIZE, sinceLeafIndex });
6003
+ const firstPage = await fetchPage(1);
6004
+ const total = ctx.outcome.scanned + firstPage.pagination.total;
6005
+ const effectiveLimit = firstPage.pagination.limit || PAGE_SIZE;
6006
+ const totalPages = Math.max(Math.ceil(firstPage.pagination.total / effectiveLimit), 1);
6007
+ await runPrefetched(
6008
+ totalPages,
6009
+ (i) => i === 0 ? Promise.resolve(firstPage) : fetchPage(i + 1),
6010
+ ctx.signal,
6011
+ (page) => processHints(ctx, page.data, total)
6012
+ );
6013
+ }
5960
6014
  async function collectScanEntries(params) {
5961
- const { source, pool, keys, existingHexes, sinceLeafIndex, onProgress, signal, onPage } = params;
5962
- const { onBatchDone } = params;
6015
+ const { source, sinceLeafIndex, signal } = params;
5963
6016
  if (signal?.aborted) throw scanAbortError();
5964
6017
  const outcome = {
5965
6018
  scanEntries: [],
@@ -5977,14 +6030,14 @@ async function collectScanEntries(params) {
5977
6030
  };
5978
6031
  const ctx = {
5979
6032
  outcome,
5980
- pool,
5981
- keys,
5982
- existingHexes,
5983
- vaultHexes: params.vaultHexes ?? existingHexes,
5984
- onProgress,
6033
+ pool: params.pool,
6034
+ keys: params.keys,
6035
+ existingHexes: params.existingHexes,
6036
+ vaultHexes: params.vaultHexes ?? params.existingHexes,
6037
+ onProgress: params.onProgress,
5985
6038
  signal,
5986
- onPage,
5987
- onBatchDone
6039
+ onPage: params.onPage,
6040
+ onBatchDone: params.onBatchDone
5988
6041
  };
5989
6042
  let tailSince = sinceLeafIndex;
5990
6043
  try {
@@ -5995,33 +6048,7 @@ async function collectScanEntries(params) {
5995
6048
  params.onWarning?.("sealed-chunk path failed; falling back to paginated hints", err);
5996
6049
  if (outcome.maxLeafIndex !== void 0) tailSince = outcome.maxLeafIndex + 1;
5997
6050
  }
5998
- const fetchPage = (page) => source.listHints({ page, limit: PAGE_SIZE, sinceLeafIndex: tailSince });
5999
- const firstPage = await fetchPage(1);
6000
- const total = outcome.scanned + firstPage.pagination.total;
6001
- const effectiveLimit = firstPage.pagination.limit || PAGE_SIZE;
6002
- const totalPages = Math.ceil(firstPage.pagination.total / effectiveLimit);
6003
- const PREFETCH = 3;
6004
- const inFlight = /* @__PURE__ */ new Map();
6005
- const ensureFetching = (page) => {
6006
- if (page >= 2 && page <= totalPages && !inFlight.has(page)) {
6007
- inFlight.set(page, fetchPage(page));
6008
- }
6009
- };
6010
- try {
6011
- for (let p = 2; p < 2 + PREFETCH; p++) ensureFetching(p);
6012
- await processHints(ctx, firstPage.data, total);
6013
- for (let page = 2; page <= totalPages; page++) {
6014
- if (signal?.aborted) throw scanAbortError();
6015
- ensureFetching(page);
6016
- const current = await inFlight.get(page);
6017
- inFlight.delete(page);
6018
- for (let p = page + 1; p <= page + PREFETCH; p++) ensureFetching(p);
6019
- await processHints(ctx, current.data, total);
6020
- }
6021
- } finally {
6022
- for (const pending of inFlight.values()) pending.catch(() => {
6023
- });
6024
- }
6051
+ await processPaginatedTail(ctx, source, tailSince);
6025
6052
  return outcome;
6026
6053
  }
6027
6054
 
@@ -6233,7 +6260,7 @@ function gapMargin(windowSize) {
6233
6260
  }
6234
6261
  function resolveSelfEphCeiling(params) {
6235
6262
  const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
6236
- const windowSize = params.windowSize ?? SELF_EPH_WINDOW;
6263
+ const windowSize = params.windowSize !== void 0 && params.windowSize > 0 ? params.windowSize : SELF_EPH_WINDOW;
6237
6264
  if (scanMaxIndex === null) return null;
6238
6265
  if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
6239
6266
  const ownEphPks = /* @__PURE__ */ new Set();
@@ -6258,9 +6285,12 @@ function resolveSelfEphCeiling(params) {
6258
6285
  }
6259
6286
  return max;
6260
6287
  }
6288
+ var MAX_EPH_WINDOW = SELF_EPH_WINDOW * 64;
6261
6289
  function windowSizeForCounter(counter) {
6262
- const needed = counter + gapMargin(SELF_EPH_WINDOW);
6263
- return Math.max(SELF_EPH_WINDOW, Math.ceil(needed / SELF_EPH_WINDOW) * SELF_EPH_WINDOW);
6290
+ const used = Number.isFinite(counter) ? counter : 0;
6291
+ const needed = used + gapMargin(SELF_EPH_WINDOW);
6292
+ const rounded = Math.ceil(needed / SELF_EPH_WINDOW) * SELF_EPH_WINDOW;
6293
+ return Math.min(MAX_EPH_WINDOW, Math.max(SELF_EPH_WINDOW, rounded));
6264
6294
  }
6265
6295
 
6266
6296
  // src/wallet/scanner/phases/spentStatus.ts
@@ -6518,11 +6548,13 @@ async function buildZkNote(params, deps) {
6518
6548
  } else if (params.viewingPublicKey && storage && keys.viewingSecretKey) {
6519
6549
  try {
6520
6550
  const index = await reservePairwiseIndex(storage, toHex(params.viewingPublicKey));
6521
- const pairSecret = derivePairwiseSharedSecret(
6522
- keys.viewingSecretKey,
6523
- params.viewingPublicKey
6524
- );
6525
- ephSkOverride = derivePairwiseEphSk(pairSecret, index);
6551
+ if (index !== null) {
6552
+ const pairSecret = derivePairwiseSharedSecret(
6553
+ keys.viewingSecretKey,
6554
+ params.viewingPublicKey
6555
+ );
6556
+ ephSkOverride = derivePairwiseEphSk(pairSecret, index);
6557
+ }
6526
6558
  } catch {
6527
6559
  }
6528
6560
  }
package/dist/index.mjs CHANGED
@@ -4188,13 +4188,29 @@ function mergeCounters(current, fallback) {
4188
4188
  ...parties !== void 0 ? { pairwiseCounterparties: parties } : {}
4189
4189
  };
4190
4190
  }
4191
- var higher = (a, b) => a === void 0 ? b : b === void 0 ? a : Math.max(a, b);
4191
+ var counterOrNone = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
4192
+ var higher = (a, b) => {
4193
+ const x = counterOrNone(a);
4194
+ const y = counterOrNone(b);
4195
+ return x === void 0 ? y : y === void 0 ? x : Math.max(x, y);
4196
+ };
4197
+ function usableEntry(entry) {
4198
+ if (typeof entry !== "object" || entry === null) return void 0;
4199
+ const { nextIndex, addedAt } = entry;
4200
+ const index = counterOrNone(nextIndex);
4201
+ if (index === void 0) return void 0;
4202
+ return { nextIndex: index, addedAt: counterOrNone(addedAt) ?? Date.now() };
4203
+ }
4192
4204
  function mergePairwise(current, fallback) {
4193
4205
  if (!current && !fallback) return void 0;
4194
- const merged = {
4195
- ...fallback ?? {}
4196
- };
4197
- for (const [key, entry] of Object.entries(current ?? {})) {
4206
+ const merged = {};
4207
+ for (const [key, entry] of Object.entries(fallback ?? {})) {
4208
+ const usable = usableEntry(entry);
4209
+ if (usable) merged[key] = usable;
4210
+ }
4211
+ for (const [key, raw] of Object.entries(current ?? {})) {
4212
+ const entry = usableEntry(raw);
4213
+ if (!entry) continue;
4198
4214
  const prev = merged[key];
4199
4215
  merged[key] = prev ? {
4200
4216
  nextIndex: Math.max(prev.nextIndex, entry.nextIndex),
@@ -4205,33 +4221,42 @@ function mergePairwise(current, fallback) {
4205
4221
  }
4206
4222
 
4207
4223
  // src/wallet/vault/storage/ephemeralIndex.ts
4224
+ var MAX_EPH_INDEX = 4294967294;
4225
+ function usableCounter(value) {
4226
+ if (typeof value !== "number" || !Number.isInteger(value)) return null;
4227
+ if (value < 0 || value > MAX_EPH_INDEX) return null;
4228
+ return value;
4229
+ }
4208
4230
  async function reserveSelfEphIndex(storage) {
4209
4231
  const updated = await storage.updateConfig((config) => ({
4210
4232
  ...config,
4211
- selfEphCounter: (config.selfEphCounter ?? 0) + 1,
4233
+ selfEphCounter: (usableCounter(config.selfEphCounter) ?? 0) + 1,
4212
4234
  updatedAt: Date.now()
4213
4235
  }));
4214
4236
  if (!updated) throw new Error("vault not initialized");
4215
- return (updated.selfEphCounter ?? 1) - 1;
4237
+ return (usableCounter(updated.selfEphCounter) ?? 1) - 1;
4216
4238
  }
4217
4239
  async function reservePairwiseIndex(storage, ivkHex) {
4218
4240
  const key = ivkHex.toLowerCase();
4219
4241
  const updated = await storage.updateConfig((config) => {
4220
- const current = config.pairwiseCounterparties?.[key];
4242
+ const stored = usableCounter(config.pairwiseCounterparties?.[key]?.nextIndex);
4243
+ const addedAt = config.pairwiseCounterparties?.[key]?.addedAt;
4221
4244
  return {
4222
4245
  ...config,
4223
4246
  pairwiseCounterparties: {
4224
4247
  ...config.pairwiseCounterparties,
4225
4248
  [key]: {
4226
- nextIndex: (current?.nextIndex ?? 0) + 1,
4227
- addedAt: current?.addedAt ?? Date.now()
4249
+ nextIndex: (stored ?? 0) + 1,
4250
+ addedAt: typeof addedAt === "number" ? addedAt : Date.now()
4228
4251
  }
4229
4252
  },
4230
4253
  updatedAt: Date.now()
4231
4254
  };
4232
4255
  });
4233
4256
  if (!updated) throw new Error("vault not initialized");
4234
- return (updated.pairwiseCounterparties?.[key]?.nextIndex ?? 1) - 1;
4257
+ const nextIndex = usableCounter(updated.pairwiseCounterparties?.[key]?.nextIndex);
4258
+ if (nextIndex === null || nextIndex <= 1) return null;
4259
+ return nextIndex - 1;
4235
4260
  }
4236
4261
 
4237
4262
  // src/wallet/vault/notes/record.ts
@@ -4654,6 +4679,7 @@ var VaultStore = class {
4654
4679
 
4655
4680
  // src/wallet/scanner/phases/collect.ts
4656
4681
  var PAGE_SIZE = 2500;
4682
+ var PREFETCH = 3;
4657
4683
  async function processHints(ctx, hints, total) {
4658
4684
  const { outcome } = ctx;
4659
4685
  const toDecrypt = [];
@@ -4701,6 +4727,24 @@ async function processHints(ctx, hints, total) {
4701
4727
  await ctx.onBatchDone?.(outcome.maxLeafIndex);
4702
4728
  ctx.onProgress?.({ scanned: outcome.scanned, total, found: outcome.found });
4703
4729
  }
4730
+ async function runPrefetched(count, fetchItem, signal, handle) {
4731
+ const inFlight = /* @__PURE__ */ new Map();
4732
+ const ensureFetching = (i) => {
4733
+ if (i < count && !inFlight.has(i)) inFlight.set(i, fetchItem(i));
4734
+ };
4735
+ try {
4736
+ for (let i = 0; i < count; i++) {
4737
+ if (signal?.aborted) throw scanAbortError();
4738
+ for (let n = i; n < i + PREFETCH; n++) ensureFetching(n);
4739
+ const item = await inFlight.get(i);
4740
+ inFlight.delete(i);
4741
+ await handle(item);
4742
+ }
4743
+ } finally {
4744
+ for (const pending of inFlight.values()) pending.catch(() => {
4745
+ });
4746
+ }
4747
+ }
4704
4748
  async function processSealedChunks(ctx, source, sinceLeafIndex) {
4705
4749
  if (!source.chunks) return null;
4706
4750
  const manifest = await source.chunks.manifest().catch(() => null);
@@ -4713,27 +4757,36 @@ async function processSealedChunks(ctx, source, sinceLeafIndex) {
4713
4757
  if (pending.length === 0) return null;
4714
4758
  const total = Math.max(manifest.total - startLeaf, 0);
4715
4759
  const fetchChunk = (i) => source.chunks.chunk(pending[i].idx, pending[i].digest);
4716
- let nextChunk = fetchChunk(0);
4717
- try {
4718
- for (let i = 0; i < pending.length; i++) {
4719
- if (ctx.signal?.aborted) throw scanAbortError();
4720
- const hints = await nextChunk;
4721
- nextChunk = i + 1 < pending.length ? fetchChunk(i + 1) : null;
4722
- await processHints(
4760
+ await runPrefetched(
4761
+ pending.length,
4762
+ fetchChunk,
4763
+ ctx.signal,
4764
+ (hints) => (
4765
+ // The first chunk may straddle the cursor drop already-scanned leaves.
4766
+ processHints(
4723
4767
  ctx,
4724
4768
  hints.filter((h) => (h.leafIndex ?? 0) >= startLeaf),
4725
4769
  total
4726
- );
4727
- }
4728
- } finally {
4729
- nextChunk?.catch(() => {
4730
- });
4731
- }
4770
+ )
4771
+ )
4772
+ );
4732
4773
  return manifest.lastSealedLeaf + 1;
4733
4774
  }
4775
+ async function processPaginatedTail(ctx, source, sinceLeafIndex) {
4776
+ const fetchPage = (page) => source.listHints({ page, limit: PAGE_SIZE, sinceLeafIndex });
4777
+ const firstPage = await fetchPage(1);
4778
+ const total = ctx.outcome.scanned + firstPage.pagination.total;
4779
+ const effectiveLimit = firstPage.pagination.limit || PAGE_SIZE;
4780
+ const totalPages = Math.max(Math.ceil(firstPage.pagination.total / effectiveLimit), 1);
4781
+ await runPrefetched(
4782
+ totalPages,
4783
+ (i) => i === 0 ? Promise.resolve(firstPage) : fetchPage(i + 1),
4784
+ ctx.signal,
4785
+ (page) => processHints(ctx, page.data, total)
4786
+ );
4787
+ }
4734
4788
  async function collectScanEntries(params) {
4735
- const { source, pool, keys, existingHexes, sinceLeafIndex, onProgress, signal, onPage } = params;
4736
- const { onBatchDone } = params;
4789
+ const { source, sinceLeafIndex, signal } = params;
4737
4790
  if (signal?.aborted) throw scanAbortError();
4738
4791
  const outcome = {
4739
4792
  scanEntries: [],
@@ -4751,14 +4804,14 @@ async function collectScanEntries(params) {
4751
4804
  };
4752
4805
  const ctx = {
4753
4806
  outcome,
4754
- pool,
4755
- keys,
4756
- existingHexes,
4757
- vaultHexes: params.vaultHexes ?? existingHexes,
4758
- onProgress,
4807
+ pool: params.pool,
4808
+ keys: params.keys,
4809
+ existingHexes: params.existingHexes,
4810
+ vaultHexes: params.vaultHexes ?? params.existingHexes,
4811
+ onProgress: params.onProgress,
4759
4812
  signal,
4760
- onPage,
4761
- onBatchDone
4813
+ onPage: params.onPage,
4814
+ onBatchDone: params.onBatchDone
4762
4815
  };
4763
4816
  let tailSince = sinceLeafIndex;
4764
4817
  try {
@@ -4769,33 +4822,7 @@ async function collectScanEntries(params) {
4769
4822
  params.onWarning?.("sealed-chunk path failed; falling back to paginated hints", err);
4770
4823
  if (outcome.maxLeafIndex !== void 0) tailSince = outcome.maxLeafIndex + 1;
4771
4824
  }
4772
- const fetchPage = (page) => source.listHints({ page, limit: PAGE_SIZE, sinceLeafIndex: tailSince });
4773
- const firstPage = await fetchPage(1);
4774
- const total = outcome.scanned + firstPage.pagination.total;
4775
- const effectiveLimit = firstPage.pagination.limit || PAGE_SIZE;
4776
- const totalPages = Math.ceil(firstPage.pagination.total / effectiveLimit);
4777
- const PREFETCH = 3;
4778
- const inFlight = /* @__PURE__ */ new Map();
4779
- const ensureFetching = (page) => {
4780
- if (page >= 2 && page <= totalPages && !inFlight.has(page)) {
4781
- inFlight.set(page, fetchPage(page));
4782
- }
4783
- };
4784
- try {
4785
- for (let p = 2; p < 2 + PREFETCH; p++) ensureFetching(p);
4786
- await processHints(ctx, firstPage.data, total);
4787
- for (let page = 2; page <= totalPages; page++) {
4788
- if (signal?.aborted) throw scanAbortError();
4789
- ensureFetching(page);
4790
- const current = await inFlight.get(page);
4791
- inFlight.delete(page);
4792
- for (let p = page + 1; p <= page + PREFETCH; p++) ensureFetching(p);
4793
- await processHints(ctx, current.data, total);
4794
- }
4795
- } finally {
4796
- for (const pending of inFlight.values()) pending.catch(() => {
4797
- });
4798
- }
4825
+ await processPaginatedTail(ctx, source, tailSince);
4799
4826
  return outcome;
4800
4827
  }
4801
4828
 
@@ -4996,7 +5023,7 @@ function gapMargin(windowSize) {
4996
5023
  }
4997
5024
  function resolveSelfEphCeiling(params) {
4998
5025
  const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
4999
- const windowSize = params.windowSize ?? SELF_EPH_WINDOW;
5026
+ const windowSize = params.windowSize !== void 0 && params.windowSize > 0 ? params.windowSize : SELF_EPH_WINDOW;
5000
5027
  if (scanMaxIndex === null) return null;
5001
5028
  if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
5002
5029
  const ownEphPks = /* @__PURE__ */ new Set();
@@ -5021,9 +5048,12 @@ function resolveSelfEphCeiling(params) {
5021
5048
  }
5022
5049
  return max;
5023
5050
  }
5051
+ var MAX_EPH_WINDOW = SELF_EPH_WINDOW * 64;
5024
5052
  function windowSizeForCounter(counter) {
5025
- const needed = counter + gapMargin(SELF_EPH_WINDOW);
5026
- return Math.max(SELF_EPH_WINDOW, Math.ceil(needed / SELF_EPH_WINDOW) * SELF_EPH_WINDOW);
5053
+ const used = Number.isFinite(counter) ? counter : 0;
5054
+ const needed = used + gapMargin(SELF_EPH_WINDOW);
5055
+ const rounded = Math.ceil(needed / SELF_EPH_WINDOW) * SELF_EPH_WINDOW;
5056
+ return Math.min(MAX_EPH_WINDOW, Math.max(SELF_EPH_WINDOW, rounded));
5027
5057
  }
5028
5058
 
5029
5059
  // src/wallet/scanner/phases/spentStatus.ts
@@ -5281,11 +5311,13 @@ async function buildZkNote(params, deps) {
5281
5311
  } else if (params.viewingPublicKey && storage && keys.viewingSecretKey) {
5282
5312
  try {
5283
5313
  const index = await reservePairwiseIndex(storage, toHex(params.viewingPublicKey));
5284
- const pairSecret = derivePairwiseSharedSecret(
5285
- keys.viewingSecretKey,
5286
- params.viewingPublicKey
5287
- );
5288
- ephSkOverride = derivePairwiseEphSk(pairSecret, index);
5314
+ if (index !== null) {
5315
+ const pairSecret = derivePairwiseSharedSecret(
5316
+ keys.viewingSecretKey,
5317
+ params.viewingPublicKey
5318
+ );
5319
+ ephSkOverride = derivePairwiseEphSk(pairSecret, index);
5320
+ }
5289
5321
  } catch {
5290
5322
  }
5291
5323
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbinum/sdk",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Official TypeScript SDK for Orbinum.",
5
5
  "author": "Orbinum",
6
6
  "license": "MIT",