@orbinum/sdk 1.2.0 → 1.3.1

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
@@ -4503,9 +4503,9 @@ declare function windowSizeForCounter(counter: number): number;
4503
4503
  * Phase 1 — walk the hint feed and trial-decrypt every memo against the wallet's
4504
4504
  * keys.
4505
4505
  *
4506
- * Pure collection: no vault writes and no spent-status resolution, both of which
4507
- * are later phases. Nothing here persists, so the note list a user is looking at
4508
- * stays stable for the whole scan.
4506
+ * Pure collection: this phase itself writes nothing persistence happens only
4507
+ * through the injected `onPage`/`onBatchDone` callbacks, so the caller decides
4508
+ * what (if anything) is checkpointed while the scan runs.
4509
4509
  *
4510
4510
  * Transport, in order of preference:
4511
4511
  * 1. Sealed chunks — the immutable bulk of the feed, digest-addressed and
@@ -4515,9 +4515,9 @@ declare function windowSizeForCounter(counter: number): number;
4515
4515
  *
4516
4516
  * The expensive part (one ECDH per memo) runs in the injected decrypt pool —
4517
4517
  * 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.
4518
+ * the cheap bookkeeping (cursor, on-chain set, counters) stays here. Both
4519
+ * transports download through the same PREFETCH-deep sliding window
4520
+ * (`runPrefetched`), overlapping network and CPU.
4521
4521
  */
4522
4522
 
4523
4523
  /**
@@ -4552,8 +4552,11 @@ interface ScanOutcome {
4552
4552
  onChainHexes: Set<string>;
4553
4553
  /** Highest leafIndex seen — the next incremental scan resumes after it. */
4554
4554
  maxLeafIndex: number | undefined;
4555
+ /** Hints walked this call, across both transports. */
4555
4556
  scanned: number;
4557
+ /** Notes recovered that the vault did not hold yet. */
4556
4558
  found: number;
4559
+ /** Hints carrying no memo or ephemeral key — nothing to decrypt. */
4557
4560
  noMemo: number;
4558
4561
  /**
4559
4562
  * Hints whose memo did not open with this wallet's keys.
@@ -4564,6 +4567,7 @@ interface ScanOutcome {
4564
4567
  * recovers nothing while this equals the whole pool has a key problem.
4565
4568
  */
4566
4569
  decryptFailed: number;
4570
+ /** Notes recovered that the vault already held. */
4567
4571
  alreadyPresent: number;
4568
4572
  /** Hints discarded by the view-tag fast filter (no AEAD decrypt attempted). */
4569
4573
  tagFiltered: number;
@@ -4600,6 +4604,15 @@ interface CollectScanEntriesParams {
4600
4604
  note: ZkNote;
4601
4605
  isNew: boolean;
4602
4606
  }>) => Promise<void>) | undefined;
4607
+ /**
4608
+ * Awaited after each fully processed chunk/page — AFTER `onPage`, so the
4609
+ * batch's notes are already in the caller's hands — with the highest valid
4610
+ * leaf seen so far. Fires for every batch, including ones with no owned
4611
+ * notes: that is the common case, and the checkpoint must advance past
4612
+ * other people's leaves too. Lets callers persist the scan cursor
4613
+ * incrementally so an aborted scan resumes instead of restarting.
4614
+ */
4615
+ onBatchDone?: ((maxLeafIndex: number | undefined) => Promise<void>) | undefined;
4603
4616
  /**
4604
4617
  * Bounds `outcome.onChainHexes` to commitments the wallet actually holds.
4605
4618
  * Pass the pre-scan snapshot, since `existingHexes` grows during the scan.
@@ -4678,14 +4691,16 @@ declare function resolveSpentStatus(params: {
4678
4691
  * casing between runs cannot split one nullifier into two entries.
4679
4692
  */
4680
4693
 
4681
- interface ResolveSpentSetParams {
4694
+ interface OpenSpentSetParams {
4682
4695
  source: NullifierSource;
4683
4696
  cache: NullifierCache;
4684
- /** The wallet's own nullifiers, lowercase hex. */
4685
- ownNullifiers: Set<string>;
4686
4697
  signal?: AbortSignal | undefined;
4687
4698
  onWarning?: ((message: string, cause?: unknown) => void) | undefined;
4688
4699
  }
4700
+ interface ResolveSpentSetParams extends OpenSpentSetParams {
4701
+ /** The wallet's own nullifiers, lowercase hex. */
4702
+ ownNullifiers: Set<string>;
4703
+ }
4689
4704
  /**
4690
4705
  * Returns ONLY the spent members of `ownNullifiers` as a Map hex → spend details
4691
4706
  * (block timestamp + spending tx hash, each null when the row carried none).
package/dist/index.d.ts CHANGED
@@ -4503,9 +4503,9 @@ declare function windowSizeForCounter(counter: number): number;
4503
4503
  * Phase 1 — walk the hint feed and trial-decrypt every memo against the wallet's
4504
4504
  * keys.
4505
4505
  *
4506
- * Pure collection: no vault writes and no spent-status resolution, both of which
4507
- * are later phases. Nothing here persists, so the note list a user is looking at
4508
- * stays stable for the whole scan.
4506
+ * Pure collection: this phase itself writes nothing persistence happens only
4507
+ * through the injected `onPage`/`onBatchDone` callbacks, so the caller decides
4508
+ * what (if anything) is checkpointed while the scan runs.
4509
4509
  *
4510
4510
  * Transport, in order of preference:
4511
4511
  * 1. Sealed chunks — the immutable bulk of the feed, digest-addressed and
@@ -4515,9 +4515,9 @@ declare function windowSizeForCounter(counter: number): number;
4515
4515
  *
4516
4516
  * The expensive part (one ECDH per memo) runs in the injected decrypt pool —
4517
4517
  * 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.
4518
+ * the cheap bookkeeping (cursor, on-chain set, counters) stays here. Both
4519
+ * transports download through the same PREFETCH-deep sliding window
4520
+ * (`runPrefetched`), overlapping network and CPU.
4521
4521
  */
4522
4522
 
4523
4523
  /**
@@ -4552,8 +4552,11 @@ interface ScanOutcome {
4552
4552
  onChainHexes: Set<string>;
4553
4553
  /** Highest leafIndex seen — the next incremental scan resumes after it. */
4554
4554
  maxLeafIndex: number | undefined;
4555
+ /** Hints walked this call, across both transports. */
4555
4556
  scanned: number;
4557
+ /** Notes recovered that the vault did not hold yet. */
4556
4558
  found: number;
4559
+ /** Hints carrying no memo or ephemeral key — nothing to decrypt. */
4557
4560
  noMemo: number;
4558
4561
  /**
4559
4562
  * Hints whose memo did not open with this wallet's keys.
@@ -4564,6 +4567,7 @@ interface ScanOutcome {
4564
4567
  * recovers nothing while this equals the whole pool has a key problem.
4565
4568
  */
4566
4569
  decryptFailed: number;
4570
+ /** Notes recovered that the vault already held. */
4567
4571
  alreadyPresent: number;
4568
4572
  /** Hints discarded by the view-tag fast filter (no AEAD decrypt attempted). */
4569
4573
  tagFiltered: number;
@@ -4600,6 +4604,15 @@ interface CollectScanEntriesParams {
4600
4604
  note: ZkNote;
4601
4605
  isNew: boolean;
4602
4606
  }>) => Promise<void>) | undefined;
4607
+ /**
4608
+ * Awaited after each fully processed chunk/page — AFTER `onPage`, so the
4609
+ * batch's notes are already in the caller's hands — with the highest valid
4610
+ * leaf seen so far. Fires for every batch, including ones with no owned
4611
+ * notes: that is the common case, and the checkpoint must advance past
4612
+ * other people's leaves too. Lets callers persist the scan cursor
4613
+ * incrementally so an aborted scan resumes instead of restarting.
4614
+ */
4615
+ onBatchDone?: ((maxLeafIndex: number | undefined) => Promise<void>) | undefined;
4603
4616
  /**
4604
4617
  * Bounds `outcome.onChainHexes` to commitments the wallet actually holds.
4605
4618
  * Pass the pre-scan snapshot, since `existingHexes` grows during the scan.
@@ -4678,14 +4691,16 @@ declare function resolveSpentStatus(params: {
4678
4691
  * casing between runs cannot split one nullifier into two entries.
4679
4692
  */
4680
4693
 
4681
- interface ResolveSpentSetParams {
4694
+ interface OpenSpentSetParams {
4682
4695
  source: NullifierSource;
4683
4696
  cache: NullifierCache;
4684
- /** The wallet's own nullifiers, lowercase hex. */
4685
- ownNullifiers: Set<string>;
4686
4697
  signal?: AbortSignal | undefined;
4687
4698
  onWarning?: ((message: string, cause?: unknown) => void) | undefined;
4688
4699
  }
4700
+ interface ResolveSpentSetParams extends OpenSpentSetParams {
4701
+ /** The wallet's own nullifiers, lowercase hex. */
4702
+ ownNullifiers: Set<string>;
4703
+ }
4689
4704
  /**
4690
4705
  * Returns ONLY the spent members of `ownNullifiers` as a Map hex → spend details
4691
4706
  * (block timestamp + spending tx hash, each null when the row carried none).
package/dist/index.js CHANGED
@@ -5880,6 +5880,7 @@ var VaultStore = class {
5880
5880
 
5881
5881
  // src/wallet/scanner/phases/collect.ts
5882
5882
  var PAGE_SIZE = 2500;
5883
+ var PREFETCH = 3;
5883
5884
  async function processHints(ctx, hints, total) {
5884
5885
  const { outcome } = ctx;
5885
5886
  const toDecrypt = [];
@@ -5924,8 +5925,27 @@ async function processHints(ctx, hints, total) {
5924
5925
  pageEntries.push({ note, isNew });
5925
5926
  }
5926
5927
  if (pageEntries.length > 0) await ctx.onPage?.(pageEntries);
5928
+ await ctx.onBatchDone?.(outcome.maxLeafIndex);
5927
5929
  ctx.onProgress?.({ scanned: outcome.scanned, total, found: outcome.found });
5928
5930
  }
5931
+ async function runPrefetched(count, fetchItem, signal, handle) {
5932
+ const inFlight = /* @__PURE__ */ new Map();
5933
+ const ensureFetching = (i) => {
5934
+ if (i < count && !inFlight.has(i)) inFlight.set(i, fetchItem(i));
5935
+ };
5936
+ try {
5937
+ for (let i = 0; i < count; i++) {
5938
+ if (signal?.aborted) throw scanAbortError();
5939
+ for (let n = i; n < i + PREFETCH; n++) ensureFetching(n);
5940
+ const item = await inFlight.get(i);
5941
+ inFlight.delete(i);
5942
+ await handle(item);
5943
+ }
5944
+ } finally {
5945
+ for (const pending of inFlight.values()) pending.catch(() => {
5946
+ });
5947
+ }
5948
+ }
5929
5949
  async function processSealedChunks(ctx, source, sinceLeafIndex) {
5930
5950
  if (!source.chunks) return null;
5931
5951
  const manifest = await source.chunks.manifest().catch(() => null);
@@ -5938,26 +5958,36 @@ async function processSealedChunks(ctx, source, sinceLeafIndex) {
5938
5958
  if (pending.length === 0) return null;
5939
5959
  const total = Math.max(manifest.total - startLeaf, 0);
5940
5960
  const fetchChunk = (i) => source.chunks.chunk(pending[i].idx, pending[i].digest);
5941
- let nextChunk = fetchChunk(0);
5942
- try {
5943
- for (let i = 0; i < pending.length; i++) {
5944
- if (ctx.signal?.aborted) throw scanAbortError();
5945
- const hints = await nextChunk;
5946
- nextChunk = i + 1 < pending.length ? fetchChunk(i + 1) : null;
5947
- await processHints(
5961
+ await runPrefetched(
5962
+ pending.length,
5963
+ fetchChunk,
5964
+ ctx.signal,
5965
+ (hints) => (
5966
+ // The first chunk may straddle the cursor drop already-scanned leaves.
5967
+ processHints(
5948
5968
  ctx,
5949
5969
  hints.filter((h) => (h.leafIndex ?? 0) >= startLeaf),
5950
5970
  total
5951
- );
5952
- }
5953
- } finally {
5954
- nextChunk?.catch(() => {
5955
- });
5956
- }
5971
+ )
5972
+ )
5973
+ );
5957
5974
  return manifest.lastSealedLeaf + 1;
5958
5975
  }
5976
+ async function processPaginatedTail(ctx, source, sinceLeafIndex) {
5977
+ const fetchPage = (page) => source.listHints({ page, limit: PAGE_SIZE, sinceLeafIndex });
5978
+ const firstPage = await fetchPage(1);
5979
+ const total = ctx.outcome.scanned + firstPage.pagination.total;
5980
+ const effectiveLimit = firstPage.pagination.limit || PAGE_SIZE;
5981
+ const totalPages = Math.max(Math.ceil(firstPage.pagination.total / effectiveLimit), 1);
5982
+ await runPrefetched(
5983
+ totalPages,
5984
+ (i) => i === 0 ? Promise.resolve(firstPage) : fetchPage(i + 1),
5985
+ ctx.signal,
5986
+ (page) => processHints(ctx, page.data, total)
5987
+ );
5988
+ }
5959
5989
  async function collectScanEntries(params) {
5960
- const { source, pool, keys, existingHexes, sinceLeafIndex, onProgress, signal, onPage } = params;
5990
+ const { source, sinceLeafIndex, signal } = params;
5961
5991
  if (signal?.aborted) throw scanAbortError();
5962
5992
  const outcome = {
5963
5993
  scanEntries: [],
@@ -5975,13 +6005,14 @@ async function collectScanEntries(params) {
5975
6005
  };
5976
6006
  const ctx = {
5977
6007
  outcome,
5978
- pool,
5979
- keys,
5980
- existingHexes,
5981
- vaultHexes: params.vaultHexes ?? existingHexes,
5982
- onProgress,
6008
+ pool: params.pool,
6009
+ keys: params.keys,
6010
+ existingHexes: params.existingHexes,
6011
+ vaultHexes: params.vaultHexes ?? params.existingHexes,
6012
+ onProgress: params.onProgress,
5983
6013
  signal,
5984
- onPage
6014
+ onPage: params.onPage,
6015
+ onBatchDone: params.onBatchDone
5985
6016
  };
5986
6017
  let tailSince = sinceLeafIndex;
5987
6018
  try {
@@ -5992,93 +6023,16 @@ async function collectScanEntries(params) {
5992
6023
  params.onWarning?.("sealed-chunk path failed; falling back to paginated hints", err);
5993
6024
  if (outcome.maxLeafIndex !== void 0) tailSince = outcome.maxLeafIndex + 1;
5994
6025
  }
5995
- const fetchPage = (page) => source.listHints({ page, limit: PAGE_SIZE, sinceLeafIndex: tailSince });
5996
- const firstPage = await fetchPage(1);
5997
- const total = outcome.scanned + firstPage.pagination.total;
5998
- const effectiveLimit = firstPage.pagination.limit || PAGE_SIZE;
5999
- const totalPages = Math.ceil(firstPage.pagination.total / effectiveLimit);
6000
- const PREFETCH = 3;
6001
- const inFlight = /* @__PURE__ */ new Map();
6002
- const ensureFetching = (page) => {
6003
- if (page >= 2 && page <= totalPages && !inFlight.has(page)) {
6004
- inFlight.set(page, fetchPage(page));
6005
- }
6006
- };
6007
- try {
6008
- for (let p = 2; p < 2 + PREFETCH; p++) ensureFetching(p);
6009
- await processHints(ctx, firstPage.data, total);
6010
- for (let page = 2; page <= totalPages; page++) {
6011
- if (signal?.aborted) throw scanAbortError();
6012
- ensureFetching(page);
6013
- const current = await inFlight.get(page);
6014
- inFlight.delete(page);
6015
- for (let p = page + 1; p <= page + PREFETCH; p++) ensureFetching(p);
6016
- await processHints(ctx, current.data, total);
6017
- }
6018
- } finally {
6019
- for (const pending of inFlight.values()) pending.catch(() => {
6020
- });
6021
- }
6026
+ await processPaginatedTail(ctx, source, tailSince);
6022
6027
  return outcome;
6023
6028
  }
6024
6029
 
6025
- // src/wallet/worker/kernel/types.ts
6026
- var SELF_EPH_WINDOW = 1024;
6027
- var PAIRWISE_EPH_WINDOW = 64;
6028
- var EMPTY_BATCH_RESULT = {
6029
- notes: [],
6030
- tagFiltered: 0,
6031
- selfMatched: 0,
6032
- pairwiseMatched: 0,
6033
- maxSelfEphIndex: null
6034
- };
6035
-
6036
- // src/wallet/scanner/selfEphGap.ts
6037
- function noteEphPkHex(note) {
6038
- if (!Array.isArray(note.memo) || note.memo.length < 32) return null;
6039
- return toHex(note.memo.slice(-32)).toLowerCase();
6040
- }
6041
- function gapMargin(windowSize) {
6042
- return Math.max(1, Math.floor(windowSize / 16));
6043
- }
6044
- function resolveSelfEphCeiling(params) {
6045
- const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
6046
- const windowSize = params.windowSize ?? SELF_EPH_WINDOW;
6047
- if (scanMaxIndex === null) return null;
6048
- if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
6049
- const ownEphPks = /* @__PURE__ */ new Set();
6050
- for (const note of notes) {
6051
- const hex = noteEphPkHex(note);
6052
- if (hex) ownEphPks.add(hex);
6053
- }
6054
- const ivkPacked = deriveViewingPublicKey(viewingKey);
6055
- let max = scanMaxIndex;
6056
- let from = windowSize;
6057
- for (; ; ) {
6058
- const slice = selfEphWindow(spendingKey, ivkPacked, from, windowSize);
6059
- let matched = false;
6060
- for (const entry of slice) {
6061
- if (ownEphPks.has(entry.ephPkHex.toLowerCase())) {
6062
- matched = true;
6063
- if (entry.index > max) max = entry.index;
6064
- }
6065
- }
6066
- if (!matched) break;
6067
- from += windowSize;
6068
- }
6069
- return max;
6070
- }
6071
- function windowSizeForCounter(counter) {
6072
- const needed = counter + gapMargin(SELF_EPH_WINDOW);
6073
- return Math.max(SELF_EPH_WINDOW, Math.ceil(needed / SELF_EPH_WINDOW) * SELF_EPH_WINDOW);
6074
- }
6075
-
6076
6030
  // src/wallet/scanner/phases/spentSet.ts
6077
6031
  function normalizeNullifierHex(hex) {
6078
6032
  return hex.toLowerCase();
6079
6033
  }
6080
- async function resolveSpentSet(params) {
6081
- const { source, cache, ownNullifiers, signal } = params;
6034
+ async function openSpentSet(params) {
6035
+ const { source, cache, signal } = params;
6082
6036
  const throwIfAborted = () => {
6083
6037
  if (signal?.aborted) throw scanAbortError();
6084
6038
  };
@@ -6087,20 +6041,29 @@ async function resolveSpentSet(params) {
6087
6041
  await syncNullifierCache(params, manifest, throwIfAborted);
6088
6042
  throwIfAborted();
6089
6043
  const tail = await getConsistentTail(params, throwIfAborted);
6090
- const stored = await cache.getSpentNullifiers([...ownNullifiers]);
6091
6044
  const tailMap = new Map(
6092
6045
  tail.data.map((h, i) => [
6093
6046
  normalizeNullifierHex(h),
6094
6047
  { spentAt: tail.timestampsMs?.[i] ?? null, txHash: tail.txHashes?.[i] ?? null }
6095
6048
  ])
6096
6049
  );
6097
- const spent = /* @__PURE__ */ new Map();
6098
- const unknown = { spentAt: null, txHash: null };
6099
- for (const h of ownNullifiers) {
6100
- if (stored.has(h)) spent.set(h, stored.get(h) ?? unknown);
6101
- else if (tailMap.has(h)) spent.set(h, tailMap.get(h) ?? unknown);
6102
- }
6103
- return spent;
6050
+ return {
6051
+ async query(ownNullifiers) {
6052
+ if (ownNullifiers.size === 0) return /* @__PURE__ */ new Map();
6053
+ const stored = await cache.getSpentNullifiers([...ownNullifiers]);
6054
+ const spent = /* @__PURE__ */ new Map();
6055
+ const unknown = { spentAt: null, txHash: null };
6056
+ for (const h of ownNullifiers) {
6057
+ if (stored.has(h)) spent.set(h, stored.get(h) ?? unknown);
6058
+ else if (tailMap.has(h)) spent.set(h, tailMap.get(h) ?? unknown);
6059
+ }
6060
+ return spent;
6061
+ }
6062
+ };
6063
+ }
6064
+ async function resolveSpentSet(params) {
6065
+ const handle = await openSpentSet(params);
6066
+ return handle.query(params.ownNullifiers);
6104
6067
  }
6105
6068
  async function syncNullifierCache(params, manifest, throwIfAborted) {
6106
6069
  const { source, cache } = params;
@@ -6150,40 +6113,6 @@ async function getConsistentTail(params, throwIfAborted) {
6150
6113
  throw new Error("nullifier tail inconsistent with local chunks after retry");
6151
6114
  }
6152
6115
 
6153
- // src/wallet/scanner/phases/spentStatus.ts
6154
- function collectNullifiersToQuery(scanEntries, onChainHexes, vaultNotes) {
6155
- const syncedCommitments = new Set(scanEntries.map((e) => e.note.commitmentHex));
6156
- const nullifiers = /* @__PURE__ */ new Set();
6157
- for (const { note } of scanEntries) {
6158
- nullifiers.add(note.nullifierHex.toLowerCase());
6159
- }
6160
- for (const vaultNote of vaultNotes) {
6161
- if (syncedCommitments.has(vaultNote.commitmentHex)) continue;
6162
- if (!onChainHexes.has(vaultNote.commitmentHex)) continue;
6163
- if (vaultNote.nullifierHex) nullifiers.add(vaultNote.nullifierHex.toLowerCase());
6164
- }
6165
- return nullifiers;
6166
- }
6167
- async function resolveSpentStatus(params) {
6168
- if (params.nullifiers.size === 0) return /* @__PURE__ */ new Map();
6169
- try {
6170
- return await resolveSpentSet({
6171
- source: params.source,
6172
- cache: params.cache,
6173
- ownNullifiers: params.nullifiers,
6174
- signal: params.signal,
6175
- onWarning: params.onWarning
6176
- });
6177
- } catch (err) {
6178
- if (isAbortError(err)) throw err;
6179
- params.onWarning?.(
6180
- "could not resolve spent-nullifier status (feed unavailable or inconsistent); spent status left unverified this pass",
6181
- err
6182
- );
6183
- return /* @__PURE__ */ new Map();
6184
- }
6185
- }
6186
-
6187
6116
  // src/wallet/scanner/phases/persist.ts
6188
6117
  function purgeIsTrustworthy(hintsScanned, purgeableNotes) {
6189
6118
  if (purgeableNotes === 0) return true;
@@ -6242,6 +6171,134 @@ async function persistCursor(storage, maxLeafIndex, isIncremental) {
6242
6171
  });
6243
6172
  }
6244
6173
 
6174
+ // src/wallet/scanner/phases/checkpoint.ts
6175
+ function createScanCheckpoint(params) {
6176
+ const { vault, storage, nullifiers, isIncremental, signal, onWarning } = params;
6177
+ const throwIfAborted = () => {
6178
+ if (signal?.aborted) throw scanAbortError();
6179
+ };
6180
+ let spentSetPromise = null;
6181
+ const getSpentSet = () => spentSetPromise ??= openSpentSet({
6182
+ source: nullifiers,
6183
+ cache: storage,
6184
+ signal,
6185
+ onWarning
6186
+ }).catch(() => null);
6187
+ return {
6188
+ async savePageNotes(entries) {
6189
+ throwIfAborted();
6190
+ const fresh = entries.filter((e) => e.isNew);
6191
+ if (fresh.length === 0) return;
6192
+ const own = new Set(fresh.map((e) => e.note.nullifierHex.toLowerCase()));
6193
+ const spentSet = await getSpentSet();
6194
+ const spent = spentSet ? await spentSet.query(own).catch(() => /* @__PURE__ */ new Map()) : /* @__PURE__ */ new Map();
6195
+ throwIfAborted();
6196
+ await vault.saveMany(
6197
+ fresh.map(({ note }) => {
6198
+ const spend = spent.get(note.nullifierHex.toLowerCase());
6199
+ return {
6200
+ note: spend ? stampSpentTxHash(note, spend.txHash) : note,
6201
+ noteStatus: {
6202
+ spent: spend !== void 0,
6203
+ spentAt: spend?.spentAt ?? null
6204
+ }
6205
+ };
6206
+ })
6207
+ );
6208
+ },
6209
+ async advanceCursor(maxLeafIndex) {
6210
+ if (maxLeafIndex === void 0) return;
6211
+ throwIfAborted();
6212
+ await persistCursor(storage, maxLeafIndex, isIncremental);
6213
+ }
6214
+ };
6215
+ }
6216
+
6217
+ // src/wallet/worker/kernel/types.ts
6218
+ var SELF_EPH_WINDOW = 1024;
6219
+ var PAIRWISE_EPH_WINDOW = 64;
6220
+ var EMPTY_BATCH_RESULT = {
6221
+ notes: [],
6222
+ tagFiltered: 0,
6223
+ selfMatched: 0,
6224
+ pairwiseMatched: 0,
6225
+ maxSelfEphIndex: null
6226
+ };
6227
+
6228
+ // src/wallet/scanner/selfEphGap.ts
6229
+ function noteEphPkHex(note) {
6230
+ if (!Array.isArray(note.memo) || note.memo.length < 32) return null;
6231
+ return toHex(note.memo.slice(-32)).toLowerCase();
6232
+ }
6233
+ function gapMargin(windowSize) {
6234
+ return Math.max(1, Math.floor(windowSize / 16));
6235
+ }
6236
+ function resolveSelfEphCeiling(params) {
6237
+ const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
6238
+ const windowSize = params.windowSize ?? SELF_EPH_WINDOW;
6239
+ if (scanMaxIndex === null) return null;
6240
+ if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
6241
+ const ownEphPks = /* @__PURE__ */ new Set();
6242
+ for (const note of notes) {
6243
+ const hex = noteEphPkHex(note);
6244
+ if (hex) ownEphPks.add(hex);
6245
+ }
6246
+ const ivkPacked = deriveViewingPublicKey(viewingKey);
6247
+ let max = scanMaxIndex;
6248
+ let from = windowSize;
6249
+ for (; ; ) {
6250
+ const slice = selfEphWindow(spendingKey, ivkPacked, from, windowSize);
6251
+ let matched = false;
6252
+ for (const entry of slice) {
6253
+ if (ownEphPks.has(entry.ephPkHex.toLowerCase())) {
6254
+ matched = true;
6255
+ if (entry.index > max) max = entry.index;
6256
+ }
6257
+ }
6258
+ if (!matched) break;
6259
+ from += windowSize;
6260
+ }
6261
+ return max;
6262
+ }
6263
+ function windowSizeForCounter(counter) {
6264
+ const needed = counter + gapMargin(SELF_EPH_WINDOW);
6265
+ return Math.max(SELF_EPH_WINDOW, Math.ceil(needed / SELF_EPH_WINDOW) * SELF_EPH_WINDOW);
6266
+ }
6267
+
6268
+ // src/wallet/scanner/phases/spentStatus.ts
6269
+ function collectNullifiersToQuery(scanEntries, onChainHexes, vaultNotes) {
6270
+ const syncedCommitments = new Set(scanEntries.map((e) => e.note.commitmentHex));
6271
+ const nullifiers = /* @__PURE__ */ new Set();
6272
+ for (const { note } of scanEntries) {
6273
+ nullifiers.add(note.nullifierHex.toLowerCase());
6274
+ }
6275
+ for (const vaultNote of vaultNotes) {
6276
+ if (syncedCommitments.has(vaultNote.commitmentHex)) continue;
6277
+ if (!onChainHexes.has(vaultNote.commitmentHex)) continue;
6278
+ if (vaultNote.nullifierHex) nullifiers.add(vaultNote.nullifierHex.toLowerCase());
6279
+ }
6280
+ return nullifiers;
6281
+ }
6282
+ async function resolveSpentStatus(params) {
6283
+ if (params.nullifiers.size === 0) return /* @__PURE__ */ new Map();
6284
+ try {
6285
+ return await resolveSpentSet({
6286
+ source: params.source,
6287
+ cache: params.cache,
6288
+ ownNullifiers: params.nullifiers,
6289
+ signal: params.signal,
6290
+ onWarning: params.onWarning
6291
+ });
6292
+ } catch (err) {
6293
+ if (isAbortError(err)) throw err;
6294
+ params.onWarning?.(
6295
+ "could not resolve spent-nullifier status (feed unavailable or inconsistent); spent status left unverified this pass",
6296
+ err
6297
+ );
6298
+ return /* @__PURE__ */ new Map();
6299
+ }
6300
+ }
6301
+
6245
6302
  // src/wallet/scanner/pipeline.ts
6246
6303
  async function runScan(params) {
6247
6304
  const { vault, storage, hints, nullifiers, pool, keys, sinceLeafIndex, signal, onWarning } = params;
@@ -6260,6 +6317,14 @@ async function runScan(params) {
6260
6317
  }
6261
6318
  }
6262
6319
  );
6320
+ const checkpoint = createScanCheckpoint({
6321
+ vault,
6322
+ storage,
6323
+ nullifiers,
6324
+ isIncremental,
6325
+ signal,
6326
+ onWarning
6327
+ });
6263
6328
  const scan = await collectScanEntries({
6264
6329
  source: hints,
6265
6330
  pool,
@@ -6280,7 +6345,9 @@ async function runScan(params) {
6280
6345
  // consumer probes it with a vault commitment, so keeping the whole pool
6281
6346
  // only bought heap.
6282
6347
  vaultHexes: preScanHexes,
6283
- onWarning
6348
+ onWarning,
6349
+ onPage: (entries) => checkpoint.savePageNotes(entries),
6350
+ onBatchDone: (maxLeafIndex) => checkpoint.advanceCursor(maxLeafIndex)
6284
6351
  });
6285
6352
  const nullifiersToQuery = collectNullifiersToQuery(
6286
6353
  scan.scanEntries,
package/dist/index.mjs CHANGED
@@ -4654,6 +4654,7 @@ var VaultStore = class {
4654
4654
 
4655
4655
  // src/wallet/scanner/phases/collect.ts
4656
4656
  var PAGE_SIZE = 2500;
4657
+ var PREFETCH = 3;
4657
4658
  async function processHints(ctx, hints, total) {
4658
4659
  const { outcome } = ctx;
4659
4660
  const toDecrypt = [];
@@ -4698,8 +4699,27 @@ async function processHints(ctx, hints, total) {
4698
4699
  pageEntries.push({ note, isNew });
4699
4700
  }
4700
4701
  if (pageEntries.length > 0) await ctx.onPage?.(pageEntries);
4702
+ await ctx.onBatchDone?.(outcome.maxLeafIndex);
4701
4703
  ctx.onProgress?.({ scanned: outcome.scanned, total, found: outcome.found });
4702
4704
  }
4705
+ async function runPrefetched(count, fetchItem, signal, handle) {
4706
+ const inFlight = /* @__PURE__ */ new Map();
4707
+ const ensureFetching = (i) => {
4708
+ if (i < count && !inFlight.has(i)) inFlight.set(i, fetchItem(i));
4709
+ };
4710
+ try {
4711
+ for (let i = 0; i < count; i++) {
4712
+ if (signal?.aborted) throw scanAbortError();
4713
+ for (let n = i; n < i + PREFETCH; n++) ensureFetching(n);
4714
+ const item = await inFlight.get(i);
4715
+ inFlight.delete(i);
4716
+ await handle(item);
4717
+ }
4718
+ } finally {
4719
+ for (const pending of inFlight.values()) pending.catch(() => {
4720
+ });
4721
+ }
4722
+ }
4703
4723
  async function processSealedChunks(ctx, source, sinceLeafIndex) {
4704
4724
  if (!source.chunks) return null;
4705
4725
  const manifest = await source.chunks.manifest().catch(() => null);
@@ -4712,26 +4732,36 @@ async function processSealedChunks(ctx, source, sinceLeafIndex) {
4712
4732
  if (pending.length === 0) return null;
4713
4733
  const total = Math.max(manifest.total - startLeaf, 0);
4714
4734
  const fetchChunk = (i) => source.chunks.chunk(pending[i].idx, pending[i].digest);
4715
- let nextChunk = fetchChunk(0);
4716
- try {
4717
- for (let i = 0; i < pending.length; i++) {
4718
- if (ctx.signal?.aborted) throw scanAbortError();
4719
- const hints = await nextChunk;
4720
- nextChunk = i + 1 < pending.length ? fetchChunk(i + 1) : null;
4721
- await processHints(
4735
+ await runPrefetched(
4736
+ pending.length,
4737
+ fetchChunk,
4738
+ ctx.signal,
4739
+ (hints) => (
4740
+ // The first chunk may straddle the cursor drop already-scanned leaves.
4741
+ processHints(
4722
4742
  ctx,
4723
4743
  hints.filter((h) => (h.leafIndex ?? 0) >= startLeaf),
4724
4744
  total
4725
- );
4726
- }
4727
- } finally {
4728
- nextChunk?.catch(() => {
4729
- });
4730
- }
4745
+ )
4746
+ )
4747
+ );
4731
4748
  return manifest.lastSealedLeaf + 1;
4732
4749
  }
4750
+ async function processPaginatedTail(ctx, source, sinceLeafIndex) {
4751
+ const fetchPage = (page) => source.listHints({ page, limit: PAGE_SIZE, sinceLeafIndex });
4752
+ const firstPage = await fetchPage(1);
4753
+ const total = ctx.outcome.scanned + firstPage.pagination.total;
4754
+ const effectiveLimit = firstPage.pagination.limit || PAGE_SIZE;
4755
+ const totalPages = Math.max(Math.ceil(firstPage.pagination.total / effectiveLimit), 1);
4756
+ await runPrefetched(
4757
+ totalPages,
4758
+ (i) => i === 0 ? Promise.resolve(firstPage) : fetchPage(i + 1),
4759
+ ctx.signal,
4760
+ (page) => processHints(ctx, page.data, total)
4761
+ );
4762
+ }
4733
4763
  async function collectScanEntries(params) {
4734
- const { source, pool, keys, existingHexes, sinceLeafIndex, onProgress, signal, onPage } = params;
4764
+ const { source, sinceLeafIndex, signal } = params;
4735
4765
  if (signal?.aborted) throw scanAbortError();
4736
4766
  const outcome = {
4737
4767
  scanEntries: [],
@@ -4749,13 +4779,14 @@ async function collectScanEntries(params) {
4749
4779
  };
4750
4780
  const ctx = {
4751
4781
  outcome,
4752
- pool,
4753
- keys,
4754
- existingHexes,
4755
- vaultHexes: params.vaultHexes ?? existingHexes,
4756
- onProgress,
4782
+ pool: params.pool,
4783
+ keys: params.keys,
4784
+ existingHexes: params.existingHexes,
4785
+ vaultHexes: params.vaultHexes ?? params.existingHexes,
4786
+ onProgress: params.onProgress,
4757
4787
  signal,
4758
- onPage
4788
+ onPage: params.onPage,
4789
+ onBatchDone: params.onBatchDone
4759
4790
  };
4760
4791
  let tailSince = sinceLeafIndex;
4761
4792
  try {
@@ -4766,82 +4797,16 @@ async function collectScanEntries(params) {
4766
4797
  params.onWarning?.("sealed-chunk path failed; falling back to paginated hints", err);
4767
4798
  if (outcome.maxLeafIndex !== void 0) tailSince = outcome.maxLeafIndex + 1;
4768
4799
  }
4769
- const fetchPage = (page) => source.listHints({ page, limit: PAGE_SIZE, sinceLeafIndex: tailSince });
4770
- const firstPage = await fetchPage(1);
4771
- const total = outcome.scanned + firstPage.pagination.total;
4772
- const effectiveLimit = firstPage.pagination.limit || PAGE_SIZE;
4773
- const totalPages = Math.ceil(firstPage.pagination.total / effectiveLimit);
4774
- const PREFETCH = 3;
4775
- const inFlight = /* @__PURE__ */ new Map();
4776
- const ensureFetching = (page) => {
4777
- if (page >= 2 && page <= totalPages && !inFlight.has(page)) {
4778
- inFlight.set(page, fetchPage(page));
4779
- }
4780
- };
4781
- try {
4782
- for (let p = 2; p < 2 + PREFETCH; p++) ensureFetching(p);
4783
- await processHints(ctx, firstPage.data, total);
4784
- for (let page = 2; page <= totalPages; page++) {
4785
- if (signal?.aborted) throw scanAbortError();
4786
- ensureFetching(page);
4787
- const current = await inFlight.get(page);
4788
- inFlight.delete(page);
4789
- for (let p = page + 1; p <= page + PREFETCH; p++) ensureFetching(p);
4790
- await processHints(ctx, current.data, total);
4791
- }
4792
- } finally {
4793
- for (const pending of inFlight.values()) pending.catch(() => {
4794
- });
4795
- }
4800
+ await processPaginatedTail(ctx, source, tailSince);
4796
4801
  return outcome;
4797
4802
  }
4798
4803
 
4799
- // src/wallet/scanner/selfEphGap.ts
4800
- function noteEphPkHex(note) {
4801
- if (!Array.isArray(note.memo) || note.memo.length < 32) return null;
4802
- return toHex(note.memo.slice(-32)).toLowerCase();
4803
- }
4804
- function gapMargin(windowSize) {
4805
- return Math.max(1, Math.floor(windowSize / 16));
4806
- }
4807
- function resolveSelfEphCeiling(params) {
4808
- const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
4809
- const windowSize = params.windowSize ?? SELF_EPH_WINDOW;
4810
- if (scanMaxIndex === null) return null;
4811
- if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
4812
- const ownEphPks = /* @__PURE__ */ new Set();
4813
- for (const note of notes) {
4814
- const hex = noteEphPkHex(note);
4815
- if (hex) ownEphPks.add(hex);
4816
- }
4817
- const ivkPacked = deriveViewingPublicKey(viewingKey);
4818
- let max = scanMaxIndex;
4819
- let from = windowSize;
4820
- for (; ; ) {
4821
- const slice = selfEphWindow(spendingKey, ivkPacked, from, windowSize);
4822
- let matched = false;
4823
- for (const entry of slice) {
4824
- if (ownEphPks.has(entry.ephPkHex.toLowerCase())) {
4825
- matched = true;
4826
- if (entry.index > max) max = entry.index;
4827
- }
4828
- }
4829
- if (!matched) break;
4830
- from += windowSize;
4831
- }
4832
- return max;
4833
- }
4834
- function windowSizeForCounter(counter) {
4835
- const needed = counter + gapMargin(SELF_EPH_WINDOW);
4836
- return Math.max(SELF_EPH_WINDOW, Math.ceil(needed / SELF_EPH_WINDOW) * SELF_EPH_WINDOW);
4837
- }
4838
-
4839
4804
  // src/wallet/scanner/phases/spentSet.ts
4840
4805
  function normalizeNullifierHex(hex) {
4841
4806
  return hex.toLowerCase();
4842
4807
  }
4843
- async function resolveSpentSet(params) {
4844
- const { source, cache, ownNullifiers, signal } = params;
4808
+ async function openSpentSet(params) {
4809
+ const { source, cache, signal } = params;
4845
4810
  const throwIfAborted = () => {
4846
4811
  if (signal?.aborted) throw scanAbortError();
4847
4812
  };
@@ -4850,20 +4815,29 @@ async function resolveSpentSet(params) {
4850
4815
  await syncNullifierCache(params, manifest, throwIfAborted);
4851
4816
  throwIfAborted();
4852
4817
  const tail = await getConsistentTail(params, throwIfAborted);
4853
- const stored = await cache.getSpentNullifiers([...ownNullifiers]);
4854
4818
  const tailMap = new Map(
4855
4819
  tail.data.map((h, i) => [
4856
4820
  normalizeNullifierHex(h),
4857
4821
  { spentAt: tail.timestampsMs?.[i] ?? null, txHash: tail.txHashes?.[i] ?? null }
4858
4822
  ])
4859
4823
  );
4860
- const spent = /* @__PURE__ */ new Map();
4861
- const unknown = { spentAt: null, txHash: null };
4862
- for (const h of ownNullifiers) {
4863
- if (stored.has(h)) spent.set(h, stored.get(h) ?? unknown);
4864
- else if (tailMap.has(h)) spent.set(h, tailMap.get(h) ?? unknown);
4865
- }
4866
- return spent;
4824
+ return {
4825
+ async query(ownNullifiers) {
4826
+ if (ownNullifiers.size === 0) return /* @__PURE__ */ new Map();
4827
+ const stored = await cache.getSpentNullifiers([...ownNullifiers]);
4828
+ const spent = /* @__PURE__ */ new Map();
4829
+ const unknown = { spentAt: null, txHash: null };
4830
+ for (const h of ownNullifiers) {
4831
+ if (stored.has(h)) spent.set(h, stored.get(h) ?? unknown);
4832
+ else if (tailMap.has(h)) spent.set(h, tailMap.get(h) ?? unknown);
4833
+ }
4834
+ return spent;
4835
+ }
4836
+ };
4837
+ }
4838
+ async function resolveSpentSet(params) {
4839
+ const handle = await openSpentSet(params);
4840
+ return handle.query(params.ownNullifiers);
4867
4841
  }
4868
4842
  async function syncNullifierCache(params, manifest, throwIfAborted) {
4869
4843
  const { source, cache } = params;
@@ -4913,40 +4887,6 @@ async function getConsistentTail(params, throwIfAborted) {
4913
4887
  throw new Error("nullifier tail inconsistent with local chunks after retry");
4914
4888
  }
4915
4889
 
4916
- // src/wallet/scanner/phases/spentStatus.ts
4917
- function collectNullifiersToQuery(scanEntries, onChainHexes, vaultNotes) {
4918
- const syncedCommitments = new Set(scanEntries.map((e) => e.note.commitmentHex));
4919
- const nullifiers = /* @__PURE__ */ new Set();
4920
- for (const { note } of scanEntries) {
4921
- nullifiers.add(note.nullifierHex.toLowerCase());
4922
- }
4923
- for (const vaultNote of vaultNotes) {
4924
- if (syncedCommitments.has(vaultNote.commitmentHex)) continue;
4925
- if (!onChainHexes.has(vaultNote.commitmentHex)) continue;
4926
- if (vaultNote.nullifierHex) nullifiers.add(vaultNote.nullifierHex.toLowerCase());
4927
- }
4928
- return nullifiers;
4929
- }
4930
- async function resolveSpentStatus(params) {
4931
- if (params.nullifiers.size === 0) return /* @__PURE__ */ new Map();
4932
- try {
4933
- return await resolveSpentSet({
4934
- source: params.source,
4935
- cache: params.cache,
4936
- ownNullifiers: params.nullifiers,
4937
- signal: params.signal,
4938
- onWarning: params.onWarning
4939
- });
4940
- } catch (err) {
4941
- if (isAbortError(err)) throw err;
4942
- params.onWarning?.(
4943
- "could not resolve spent-nullifier status (feed unavailable or inconsistent); spent status left unverified this pass",
4944
- err
4945
- );
4946
- return /* @__PURE__ */ new Map();
4947
- }
4948
- }
4949
-
4950
4890
  // src/wallet/scanner/phases/persist.ts
4951
4891
  function purgeIsTrustworthy(hintsScanned, purgeableNotes) {
4952
4892
  if (purgeableNotes === 0) return true;
@@ -5005,6 +4945,123 @@ async function persistCursor(storage, maxLeafIndex, isIncremental) {
5005
4945
  });
5006
4946
  }
5007
4947
 
4948
+ // src/wallet/scanner/phases/checkpoint.ts
4949
+ function createScanCheckpoint(params) {
4950
+ const { vault, storage, nullifiers, isIncremental, signal, onWarning } = params;
4951
+ const throwIfAborted = () => {
4952
+ if (signal?.aborted) throw scanAbortError();
4953
+ };
4954
+ let spentSetPromise = null;
4955
+ const getSpentSet = () => spentSetPromise ??= openSpentSet({
4956
+ source: nullifiers,
4957
+ cache: storage,
4958
+ signal,
4959
+ onWarning
4960
+ }).catch(() => null);
4961
+ return {
4962
+ async savePageNotes(entries) {
4963
+ throwIfAborted();
4964
+ const fresh = entries.filter((e) => e.isNew);
4965
+ if (fresh.length === 0) return;
4966
+ const own = new Set(fresh.map((e) => e.note.nullifierHex.toLowerCase()));
4967
+ const spentSet = await getSpentSet();
4968
+ const spent = spentSet ? await spentSet.query(own).catch(() => /* @__PURE__ */ new Map()) : /* @__PURE__ */ new Map();
4969
+ throwIfAborted();
4970
+ await vault.saveMany(
4971
+ fresh.map(({ note }) => {
4972
+ const spend = spent.get(note.nullifierHex.toLowerCase());
4973
+ return {
4974
+ note: spend ? stampSpentTxHash(note, spend.txHash) : note,
4975
+ noteStatus: {
4976
+ spent: spend !== void 0,
4977
+ spentAt: spend?.spentAt ?? null
4978
+ }
4979
+ };
4980
+ })
4981
+ );
4982
+ },
4983
+ async advanceCursor(maxLeafIndex) {
4984
+ if (maxLeafIndex === void 0) return;
4985
+ throwIfAborted();
4986
+ await persistCursor(storage, maxLeafIndex, isIncremental);
4987
+ }
4988
+ };
4989
+ }
4990
+
4991
+ // src/wallet/scanner/selfEphGap.ts
4992
+ function noteEphPkHex(note) {
4993
+ if (!Array.isArray(note.memo) || note.memo.length < 32) return null;
4994
+ return toHex(note.memo.slice(-32)).toLowerCase();
4995
+ }
4996
+ function gapMargin(windowSize) {
4997
+ return Math.max(1, Math.floor(windowSize / 16));
4998
+ }
4999
+ function resolveSelfEphCeiling(params) {
5000
+ const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
5001
+ const windowSize = params.windowSize ?? SELF_EPH_WINDOW;
5002
+ if (scanMaxIndex === null) return null;
5003
+ if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
5004
+ const ownEphPks = /* @__PURE__ */ new Set();
5005
+ for (const note of notes) {
5006
+ const hex = noteEphPkHex(note);
5007
+ if (hex) ownEphPks.add(hex);
5008
+ }
5009
+ const ivkPacked = deriveViewingPublicKey(viewingKey);
5010
+ let max = scanMaxIndex;
5011
+ let from = windowSize;
5012
+ for (; ; ) {
5013
+ const slice = selfEphWindow(spendingKey, ivkPacked, from, windowSize);
5014
+ let matched = false;
5015
+ for (const entry of slice) {
5016
+ if (ownEphPks.has(entry.ephPkHex.toLowerCase())) {
5017
+ matched = true;
5018
+ if (entry.index > max) max = entry.index;
5019
+ }
5020
+ }
5021
+ if (!matched) break;
5022
+ from += windowSize;
5023
+ }
5024
+ return max;
5025
+ }
5026
+ function windowSizeForCounter(counter) {
5027
+ const needed = counter + gapMargin(SELF_EPH_WINDOW);
5028
+ return Math.max(SELF_EPH_WINDOW, Math.ceil(needed / SELF_EPH_WINDOW) * SELF_EPH_WINDOW);
5029
+ }
5030
+
5031
+ // src/wallet/scanner/phases/spentStatus.ts
5032
+ function collectNullifiersToQuery(scanEntries, onChainHexes, vaultNotes) {
5033
+ const syncedCommitments = new Set(scanEntries.map((e) => e.note.commitmentHex));
5034
+ const nullifiers = /* @__PURE__ */ new Set();
5035
+ for (const { note } of scanEntries) {
5036
+ nullifiers.add(note.nullifierHex.toLowerCase());
5037
+ }
5038
+ for (const vaultNote of vaultNotes) {
5039
+ if (syncedCommitments.has(vaultNote.commitmentHex)) continue;
5040
+ if (!onChainHexes.has(vaultNote.commitmentHex)) continue;
5041
+ if (vaultNote.nullifierHex) nullifiers.add(vaultNote.nullifierHex.toLowerCase());
5042
+ }
5043
+ return nullifiers;
5044
+ }
5045
+ async function resolveSpentStatus(params) {
5046
+ if (params.nullifiers.size === 0) return /* @__PURE__ */ new Map();
5047
+ try {
5048
+ return await resolveSpentSet({
5049
+ source: params.source,
5050
+ cache: params.cache,
5051
+ ownNullifiers: params.nullifiers,
5052
+ signal: params.signal,
5053
+ onWarning: params.onWarning
5054
+ });
5055
+ } catch (err) {
5056
+ if (isAbortError(err)) throw err;
5057
+ params.onWarning?.(
5058
+ "could not resolve spent-nullifier status (feed unavailable or inconsistent); spent status left unverified this pass",
5059
+ err
5060
+ );
5061
+ return /* @__PURE__ */ new Map();
5062
+ }
5063
+ }
5064
+
5008
5065
  // src/wallet/scanner/pipeline.ts
5009
5066
  async function runScan(params) {
5010
5067
  const { vault, storage, hints, nullifiers, pool, keys, sinceLeafIndex, signal, onWarning } = params;
@@ -5023,6 +5080,14 @@ async function runScan(params) {
5023
5080
  }
5024
5081
  }
5025
5082
  );
5083
+ const checkpoint = createScanCheckpoint({
5084
+ vault,
5085
+ storage,
5086
+ nullifiers,
5087
+ isIncremental,
5088
+ signal,
5089
+ onWarning
5090
+ });
5026
5091
  const scan = await collectScanEntries({
5027
5092
  source: hints,
5028
5093
  pool,
@@ -5043,7 +5108,9 @@ async function runScan(params) {
5043
5108
  // consumer probes it with a vault commitment, so keeping the whole pool
5044
5109
  // only bought heap.
5045
5110
  vaultHexes: preScanHexes,
5046
- onWarning
5111
+ onWarning,
5112
+ onPage: (entries) => checkpoint.savePageNotes(entries),
5113
+ onBatchDone: (maxLeafIndex) => checkpoint.advanceCursor(maxLeafIndex)
5047
5114
  });
5048
5115
  const nullifiersToQuery = collectNullifiersToQuery(
5049
5116
  scan.scanEntries,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbinum/sdk",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Official TypeScript SDK for Orbinum.",
5
5
  "author": "Orbinum",
6
6
  "license": "MIT",