@orbinum/sdk 1.2.0 → 1.3.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
@@ -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
@@ -4600,6 +4600,15 @@ interface CollectScanEntriesParams {
4600
4600
  note: ZkNote;
4601
4601
  isNew: boolean;
4602
4602
  }>) => Promise<void>) | undefined;
4603
+ /**
4604
+ * Awaited after each fully processed chunk/page — AFTER `onPage`, so the
4605
+ * batch's notes are already in the caller's hands — with the highest valid
4606
+ * leaf seen so far. Fires for every batch, including ones with no owned
4607
+ * notes: that is the common case, and the checkpoint must advance past
4608
+ * other people's leaves too. Lets callers persist the scan cursor
4609
+ * incrementally so an aborted scan resumes instead of restarting.
4610
+ */
4611
+ onBatchDone?: ((maxLeafIndex: number | undefined) => Promise<void>) | undefined;
4603
4612
  /**
4604
4613
  * Bounds `outcome.onChainHexes` to commitments the wallet actually holds.
4605
4614
  * Pass the pre-scan snapshot, since `existingHexes` grows during the scan.
@@ -4678,14 +4687,16 @@ declare function resolveSpentStatus(params: {
4678
4687
  * casing between runs cannot split one nullifier into two entries.
4679
4688
  */
4680
4689
 
4681
- interface ResolveSpentSetParams {
4690
+ interface OpenSpentSetParams {
4682
4691
  source: NullifierSource;
4683
4692
  cache: NullifierCache;
4684
- /** The wallet's own nullifiers, lowercase hex. */
4685
- ownNullifiers: Set<string>;
4686
4693
  signal?: AbortSignal | undefined;
4687
4694
  onWarning?: ((message: string, cause?: unknown) => void) | undefined;
4688
4695
  }
4696
+ interface ResolveSpentSetParams extends OpenSpentSetParams {
4697
+ /** The wallet's own nullifiers, lowercase hex. */
4698
+ ownNullifiers: Set<string>;
4699
+ }
4689
4700
  /**
4690
4701
  * Returns ONLY the spent members of `ownNullifiers` as a Map hex → spend details
4691
4702
  * (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
@@ -4600,6 +4600,15 @@ interface CollectScanEntriesParams {
4600
4600
  note: ZkNote;
4601
4601
  isNew: boolean;
4602
4602
  }>) => Promise<void>) | undefined;
4603
+ /**
4604
+ * Awaited after each fully processed chunk/page — AFTER `onPage`, so the
4605
+ * batch's notes are already in the caller's hands — with the highest valid
4606
+ * leaf seen so far. Fires for every batch, including ones with no owned
4607
+ * notes: that is the common case, and the checkpoint must advance past
4608
+ * other people's leaves too. Lets callers persist the scan cursor
4609
+ * incrementally so an aborted scan resumes instead of restarting.
4610
+ */
4611
+ onBatchDone?: ((maxLeafIndex: number | undefined) => Promise<void>) | undefined;
4603
4612
  /**
4604
4613
  * Bounds `outcome.onChainHexes` to commitments the wallet actually holds.
4605
4614
  * Pass the pre-scan snapshot, since `existingHexes` grows during the scan.
@@ -4678,14 +4687,16 @@ declare function resolveSpentStatus(params: {
4678
4687
  * casing between runs cannot split one nullifier into two entries.
4679
4688
  */
4680
4689
 
4681
- interface ResolveSpentSetParams {
4690
+ interface OpenSpentSetParams {
4682
4691
  source: NullifierSource;
4683
4692
  cache: NullifierCache;
4684
- /** The wallet's own nullifiers, lowercase hex. */
4685
- ownNullifiers: Set<string>;
4686
4693
  signal?: AbortSignal | undefined;
4687
4694
  onWarning?: ((message: string, cause?: unknown) => void) | undefined;
4688
4695
  }
4696
+ interface ResolveSpentSetParams extends OpenSpentSetParams {
4697
+ /** The wallet's own nullifiers, lowercase hex. */
4698
+ ownNullifiers: Set<string>;
4699
+ }
4689
4700
  /**
4690
4701
  * Returns ONLY the spent members of `ownNullifiers` as a Map hex → spend details
4691
4702
  * (block timestamp + spending tx hash, each null when the row carried none).
package/dist/index.js CHANGED
@@ -5924,6 +5924,7 @@ async function processHints(ctx, hints, total) {
5924
5924
  pageEntries.push({ note, isNew });
5925
5925
  }
5926
5926
  if (pageEntries.length > 0) await ctx.onPage?.(pageEntries);
5927
+ await ctx.onBatchDone?.(outcome.maxLeafIndex);
5927
5928
  ctx.onProgress?.({ scanned: outcome.scanned, total, found: outcome.found });
5928
5929
  }
5929
5930
  async function processSealedChunks(ctx, source, sinceLeafIndex) {
@@ -5958,6 +5959,7 @@ async function processSealedChunks(ctx, source, sinceLeafIndex) {
5958
5959
  }
5959
5960
  async function collectScanEntries(params) {
5960
5961
  const { source, pool, keys, existingHexes, sinceLeafIndex, onProgress, signal, onPage } = params;
5962
+ const { onBatchDone } = params;
5961
5963
  if (signal?.aborted) throw scanAbortError();
5962
5964
  const outcome = {
5963
5965
  scanEntries: [],
@@ -5981,7 +5983,8 @@ async function collectScanEntries(params) {
5981
5983
  vaultHexes: params.vaultHexes ?? existingHexes,
5982
5984
  onProgress,
5983
5985
  signal,
5984
- onPage
5986
+ onPage,
5987
+ onBatchDone
5985
5988
  };
5986
5989
  let tailSince = sinceLeafIndex;
5987
5990
  try {
@@ -6022,63 +6025,12 @@ async function collectScanEntries(params) {
6022
6025
  return outcome;
6023
6026
  }
6024
6027
 
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
6028
  // src/wallet/scanner/phases/spentSet.ts
6077
6029
  function normalizeNullifierHex(hex) {
6078
6030
  return hex.toLowerCase();
6079
6031
  }
6080
- async function resolveSpentSet(params) {
6081
- const { source, cache, ownNullifiers, signal } = params;
6032
+ async function openSpentSet(params) {
6033
+ const { source, cache, signal } = params;
6082
6034
  const throwIfAborted = () => {
6083
6035
  if (signal?.aborted) throw scanAbortError();
6084
6036
  };
@@ -6087,20 +6039,29 @@ async function resolveSpentSet(params) {
6087
6039
  await syncNullifierCache(params, manifest, throwIfAborted);
6088
6040
  throwIfAborted();
6089
6041
  const tail = await getConsistentTail(params, throwIfAborted);
6090
- const stored = await cache.getSpentNullifiers([...ownNullifiers]);
6091
6042
  const tailMap = new Map(
6092
6043
  tail.data.map((h, i) => [
6093
6044
  normalizeNullifierHex(h),
6094
6045
  { spentAt: tail.timestampsMs?.[i] ?? null, txHash: tail.txHashes?.[i] ?? null }
6095
6046
  ])
6096
6047
  );
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;
6048
+ return {
6049
+ async query(ownNullifiers) {
6050
+ if (ownNullifiers.size === 0) return /* @__PURE__ */ new Map();
6051
+ const stored = await cache.getSpentNullifiers([...ownNullifiers]);
6052
+ const spent = /* @__PURE__ */ new Map();
6053
+ const unknown = { spentAt: null, txHash: null };
6054
+ for (const h of ownNullifiers) {
6055
+ if (stored.has(h)) spent.set(h, stored.get(h) ?? unknown);
6056
+ else if (tailMap.has(h)) spent.set(h, tailMap.get(h) ?? unknown);
6057
+ }
6058
+ return spent;
6059
+ }
6060
+ };
6061
+ }
6062
+ async function resolveSpentSet(params) {
6063
+ const handle = await openSpentSet(params);
6064
+ return handle.query(params.ownNullifiers);
6104
6065
  }
6105
6066
  async function syncNullifierCache(params, manifest, throwIfAborted) {
6106
6067
  const { source, cache } = params;
@@ -6150,40 +6111,6 @@ async function getConsistentTail(params, throwIfAborted) {
6150
6111
  throw new Error("nullifier tail inconsistent with local chunks after retry");
6151
6112
  }
6152
6113
 
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
6114
  // src/wallet/scanner/phases/persist.ts
6188
6115
  function purgeIsTrustworthy(hintsScanned, purgeableNotes) {
6189
6116
  if (purgeableNotes === 0) return true;
@@ -6242,6 +6169,134 @@ async function persistCursor(storage, maxLeafIndex, isIncremental) {
6242
6169
  });
6243
6170
  }
6244
6171
 
6172
+ // src/wallet/scanner/phases/checkpoint.ts
6173
+ function createScanCheckpoint(params) {
6174
+ const { vault, storage, nullifiers, isIncremental, signal, onWarning } = params;
6175
+ const throwIfAborted = () => {
6176
+ if (signal?.aborted) throw scanAbortError();
6177
+ };
6178
+ let spentSetPromise = null;
6179
+ const getSpentSet = () => spentSetPromise ??= openSpentSet({
6180
+ source: nullifiers,
6181
+ cache: storage,
6182
+ signal,
6183
+ onWarning
6184
+ }).catch(() => null);
6185
+ return {
6186
+ async savePageNotes(entries) {
6187
+ throwIfAborted();
6188
+ const fresh = entries.filter((e) => e.isNew);
6189
+ if (fresh.length === 0) return;
6190
+ const own = new Set(fresh.map((e) => e.note.nullifierHex.toLowerCase()));
6191
+ const spentSet = await getSpentSet();
6192
+ const spent = spentSet ? await spentSet.query(own).catch(() => /* @__PURE__ */ new Map()) : /* @__PURE__ */ new Map();
6193
+ throwIfAborted();
6194
+ await vault.saveMany(
6195
+ fresh.map(({ note }) => {
6196
+ const spend = spent.get(note.nullifierHex.toLowerCase());
6197
+ return {
6198
+ note: spend ? stampSpentTxHash(note, spend.txHash) : note,
6199
+ noteStatus: {
6200
+ spent: spend !== void 0,
6201
+ spentAt: spend?.spentAt ?? null
6202
+ }
6203
+ };
6204
+ })
6205
+ );
6206
+ },
6207
+ async advanceCursor(maxLeafIndex) {
6208
+ if (maxLeafIndex === void 0) return;
6209
+ throwIfAborted();
6210
+ await persistCursor(storage, maxLeafIndex, isIncremental);
6211
+ }
6212
+ };
6213
+ }
6214
+
6215
+ // src/wallet/worker/kernel/types.ts
6216
+ var SELF_EPH_WINDOW = 1024;
6217
+ var PAIRWISE_EPH_WINDOW = 64;
6218
+ var EMPTY_BATCH_RESULT = {
6219
+ notes: [],
6220
+ tagFiltered: 0,
6221
+ selfMatched: 0,
6222
+ pairwiseMatched: 0,
6223
+ maxSelfEphIndex: null
6224
+ };
6225
+
6226
+ // src/wallet/scanner/selfEphGap.ts
6227
+ function noteEphPkHex(note) {
6228
+ if (!Array.isArray(note.memo) || note.memo.length < 32) return null;
6229
+ return toHex(note.memo.slice(-32)).toLowerCase();
6230
+ }
6231
+ function gapMargin(windowSize) {
6232
+ return Math.max(1, Math.floor(windowSize / 16));
6233
+ }
6234
+ function resolveSelfEphCeiling(params) {
6235
+ const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
6236
+ const windowSize = params.windowSize ?? SELF_EPH_WINDOW;
6237
+ if (scanMaxIndex === null) return null;
6238
+ if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
6239
+ const ownEphPks = /* @__PURE__ */ new Set();
6240
+ for (const note of notes) {
6241
+ const hex = noteEphPkHex(note);
6242
+ if (hex) ownEphPks.add(hex);
6243
+ }
6244
+ const ivkPacked = deriveViewingPublicKey(viewingKey);
6245
+ let max = scanMaxIndex;
6246
+ let from = windowSize;
6247
+ for (; ; ) {
6248
+ const slice = selfEphWindow(spendingKey, ivkPacked, from, windowSize);
6249
+ let matched = false;
6250
+ for (const entry of slice) {
6251
+ if (ownEphPks.has(entry.ephPkHex.toLowerCase())) {
6252
+ matched = true;
6253
+ if (entry.index > max) max = entry.index;
6254
+ }
6255
+ }
6256
+ if (!matched) break;
6257
+ from += windowSize;
6258
+ }
6259
+ return max;
6260
+ }
6261
+ 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);
6264
+ }
6265
+
6266
+ // src/wallet/scanner/phases/spentStatus.ts
6267
+ function collectNullifiersToQuery(scanEntries, onChainHexes, vaultNotes) {
6268
+ const syncedCommitments = new Set(scanEntries.map((e) => e.note.commitmentHex));
6269
+ const nullifiers = /* @__PURE__ */ new Set();
6270
+ for (const { note } of scanEntries) {
6271
+ nullifiers.add(note.nullifierHex.toLowerCase());
6272
+ }
6273
+ for (const vaultNote of vaultNotes) {
6274
+ if (syncedCommitments.has(vaultNote.commitmentHex)) continue;
6275
+ if (!onChainHexes.has(vaultNote.commitmentHex)) continue;
6276
+ if (vaultNote.nullifierHex) nullifiers.add(vaultNote.nullifierHex.toLowerCase());
6277
+ }
6278
+ return nullifiers;
6279
+ }
6280
+ async function resolveSpentStatus(params) {
6281
+ if (params.nullifiers.size === 0) return /* @__PURE__ */ new Map();
6282
+ try {
6283
+ return await resolveSpentSet({
6284
+ source: params.source,
6285
+ cache: params.cache,
6286
+ ownNullifiers: params.nullifiers,
6287
+ signal: params.signal,
6288
+ onWarning: params.onWarning
6289
+ });
6290
+ } catch (err) {
6291
+ if (isAbortError(err)) throw err;
6292
+ params.onWarning?.(
6293
+ "could not resolve spent-nullifier status (feed unavailable or inconsistent); spent status left unverified this pass",
6294
+ err
6295
+ );
6296
+ return /* @__PURE__ */ new Map();
6297
+ }
6298
+ }
6299
+
6245
6300
  // src/wallet/scanner/pipeline.ts
6246
6301
  async function runScan(params) {
6247
6302
  const { vault, storage, hints, nullifiers, pool, keys, sinceLeafIndex, signal, onWarning } = params;
@@ -6260,6 +6315,14 @@ async function runScan(params) {
6260
6315
  }
6261
6316
  }
6262
6317
  );
6318
+ const checkpoint = createScanCheckpoint({
6319
+ vault,
6320
+ storage,
6321
+ nullifiers,
6322
+ isIncremental,
6323
+ signal,
6324
+ onWarning
6325
+ });
6263
6326
  const scan = await collectScanEntries({
6264
6327
  source: hints,
6265
6328
  pool,
@@ -6280,7 +6343,9 @@ async function runScan(params) {
6280
6343
  // consumer probes it with a vault commitment, so keeping the whole pool
6281
6344
  // only bought heap.
6282
6345
  vaultHexes: preScanHexes,
6283
- onWarning
6346
+ onWarning,
6347
+ onPage: (entries) => checkpoint.savePageNotes(entries),
6348
+ onBatchDone: (maxLeafIndex) => checkpoint.advanceCursor(maxLeafIndex)
6284
6349
  });
6285
6350
  const nullifiersToQuery = collectNullifiersToQuery(
6286
6351
  scan.scanEntries,
package/dist/index.mjs CHANGED
@@ -4698,6 +4698,7 @@ async function processHints(ctx, hints, total) {
4698
4698
  pageEntries.push({ note, isNew });
4699
4699
  }
4700
4700
  if (pageEntries.length > 0) await ctx.onPage?.(pageEntries);
4701
+ await ctx.onBatchDone?.(outcome.maxLeafIndex);
4701
4702
  ctx.onProgress?.({ scanned: outcome.scanned, total, found: outcome.found });
4702
4703
  }
4703
4704
  async function processSealedChunks(ctx, source, sinceLeafIndex) {
@@ -4732,6 +4733,7 @@ async function processSealedChunks(ctx, source, sinceLeafIndex) {
4732
4733
  }
4733
4734
  async function collectScanEntries(params) {
4734
4735
  const { source, pool, keys, existingHexes, sinceLeafIndex, onProgress, signal, onPage } = params;
4736
+ const { onBatchDone } = params;
4735
4737
  if (signal?.aborted) throw scanAbortError();
4736
4738
  const outcome = {
4737
4739
  scanEntries: [],
@@ -4755,7 +4757,8 @@ async function collectScanEntries(params) {
4755
4757
  vaultHexes: params.vaultHexes ?? existingHexes,
4756
4758
  onProgress,
4757
4759
  signal,
4758
- onPage
4760
+ onPage,
4761
+ onBatchDone
4759
4762
  };
4760
4763
  let tailSince = sinceLeafIndex;
4761
4764
  try {
@@ -4796,52 +4799,12 @@ async function collectScanEntries(params) {
4796
4799
  return outcome;
4797
4800
  }
4798
4801
 
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
4802
  // src/wallet/scanner/phases/spentSet.ts
4840
4803
  function normalizeNullifierHex(hex) {
4841
4804
  return hex.toLowerCase();
4842
4805
  }
4843
- async function resolveSpentSet(params) {
4844
- const { source, cache, ownNullifiers, signal } = params;
4806
+ async function openSpentSet(params) {
4807
+ const { source, cache, signal } = params;
4845
4808
  const throwIfAborted = () => {
4846
4809
  if (signal?.aborted) throw scanAbortError();
4847
4810
  };
@@ -4850,20 +4813,29 @@ async function resolveSpentSet(params) {
4850
4813
  await syncNullifierCache(params, manifest, throwIfAborted);
4851
4814
  throwIfAborted();
4852
4815
  const tail = await getConsistentTail(params, throwIfAborted);
4853
- const stored = await cache.getSpentNullifiers([...ownNullifiers]);
4854
4816
  const tailMap = new Map(
4855
4817
  tail.data.map((h, i) => [
4856
4818
  normalizeNullifierHex(h),
4857
4819
  { spentAt: tail.timestampsMs?.[i] ?? null, txHash: tail.txHashes?.[i] ?? null }
4858
4820
  ])
4859
4821
  );
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;
4822
+ return {
4823
+ async query(ownNullifiers) {
4824
+ if (ownNullifiers.size === 0) return /* @__PURE__ */ new Map();
4825
+ const stored = await cache.getSpentNullifiers([...ownNullifiers]);
4826
+ const spent = /* @__PURE__ */ new Map();
4827
+ const unknown = { spentAt: null, txHash: null };
4828
+ for (const h of ownNullifiers) {
4829
+ if (stored.has(h)) spent.set(h, stored.get(h) ?? unknown);
4830
+ else if (tailMap.has(h)) spent.set(h, tailMap.get(h) ?? unknown);
4831
+ }
4832
+ return spent;
4833
+ }
4834
+ };
4835
+ }
4836
+ async function resolveSpentSet(params) {
4837
+ const handle = await openSpentSet(params);
4838
+ return handle.query(params.ownNullifiers);
4867
4839
  }
4868
4840
  async function syncNullifierCache(params, manifest, throwIfAborted) {
4869
4841
  const { source, cache } = params;
@@ -4913,40 +4885,6 @@ async function getConsistentTail(params, throwIfAborted) {
4913
4885
  throw new Error("nullifier tail inconsistent with local chunks after retry");
4914
4886
  }
4915
4887
 
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
4888
  // src/wallet/scanner/phases/persist.ts
4951
4889
  function purgeIsTrustworthy(hintsScanned, purgeableNotes) {
4952
4890
  if (purgeableNotes === 0) return true;
@@ -5005,6 +4943,123 @@ async function persistCursor(storage, maxLeafIndex, isIncremental) {
5005
4943
  });
5006
4944
  }
5007
4945
 
4946
+ // src/wallet/scanner/phases/checkpoint.ts
4947
+ function createScanCheckpoint(params) {
4948
+ const { vault, storage, nullifiers, isIncremental, signal, onWarning } = params;
4949
+ const throwIfAborted = () => {
4950
+ if (signal?.aborted) throw scanAbortError();
4951
+ };
4952
+ let spentSetPromise = null;
4953
+ const getSpentSet = () => spentSetPromise ??= openSpentSet({
4954
+ source: nullifiers,
4955
+ cache: storage,
4956
+ signal,
4957
+ onWarning
4958
+ }).catch(() => null);
4959
+ return {
4960
+ async savePageNotes(entries) {
4961
+ throwIfAborted();
4962
+ const fresh = entries.filter((e) => e.isNew);
4963
+ if (fresh.length === 0) return;
4964
+ const own = new Set(fresh.map((e) => e.note.nullifierHex.toLowerCase()));
4965
+ const spentSet = await getSpentSet();
4966
+ const spent = spentSet ? await spentSet.query(own).catch(() => /* @__PURE__ */ new Map()) : /* @__PURE__ */ new Map();
4967
+ throwIfAborted();
4968
+ await vault.saveMany(
4969
+ fresh.map(({ note }) => {
4970
+ const spend = spent.get(note.nullifierHex.toLowerCase());
4971
+ return {
4972
+ note: spend ? stampSpentTxHash(note, spend.txHash) : note,
4973
+ noteStatus: {
4974
+ spent: spend !== void 0,
4975
+ spentAt: spend?.spentAt ?? null
4976
+ }
4977
+ };
4978
+ })
4979
+ );
4980
+ },
4981
+ async advanceCursor(maxLeafIndex) {
4982
+ if (maxLeafIndex === void 0) return;
4983
+ throwIfAborted();
4984
+ await persistCursor(storage, maxLeafIndex, isIncremental);
4985
+ }
4986
+ };
4987
+ }
4988
+
4989
+ // src/wallet/scanner/selfEphGap.ts
4990
+ function noteEphPkHex(note) {
4991
+ if (!Array.isArray(note.memo) || note.memo.length < 32) return null;
4992
+ return toHex(note.memo.slice(-32)).toLowerCase();
4993
+ }
4994
+ function gapMargin(windowSize) {
4995
+ return Math.max(1, Math.floor(windowSize / 16));
4996
+ }
4997
+ function resolveSelfEphCeiling(params) {
4998
+ const { notes, spendingKey, viewingKey, scanMaxIndex } = params;
4999
+ const windowSize = params.windowSize ?? SELF_EPH_WINDOW;
5000
+ if (scanMaxIndex === null) return null;
5001
+ if (scanMaxIndex < windowSize - gapMargin(windowSize)) return scanMaxIndex;
5002
+ const ownEphPks = /* @__PURE__ */ new Set();
5003
+ for (const note of notes) {
5004
+ const hex = noteEphPkHex(note);
5005
+ if (hex) ownEphPks.add(hex);
5006
+ }
5007
+ const ivkPacked = deriveViewingPublicKey(viewingKey);
5008
+ let max = scanMaxIndex;
5009
+ let from = windowSize;
5010
+ for (; ; ) {
5011
+ const slice = selfEphWindow(spendingKey, ivkPacked, from, windowSize);
5012
+ let matched = false;
5013
+ for (const entry of slice) {
5014
+ if (ownEphPks.has(entry.ephPkHex.toLowerCase())) {
5015
+ matched = true;
5016
+ if (entry.index > max) max = entry.index;
5017
+ }
5018
+ }
5019
+ if (!matched) break;
5020
+ from += windowSize;
5021
+ }
5022
+ return max;
5023
+ }
5024
+ 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);
5027
+ }
5028
+
5029
+ // src/wallet/scanner/phases/spentStatus.ts
5030
+ function collectNullifiersToQuery(scanEntries, onChainHexes, vaultNotes) {
5031
+ const syncedCommitments = new Set(scanEntries.map((e) => e.note.commitmentHex));
5032
+ const nullifiers = /* @__PURE__ */ new Set();
5033
+ for (const { note } of scanEntries) {
5034
+ nullifiers.add(note.nullifierHex.toLowerCase());
5035
+ }
5036
+ for (const vaultNote of vaultNotes) {
5037
+ if (syncedCommitments.has(vaultNote.commitmentHex)) continue;
5038
+ if (!onChainHexes.has(vaultNote.commitmentHex)) continue;
5039
+ if (vaultNote.nullifierHex) nullifiers.add(vaultNote.nullifierHex.toLowerCase());
5040
+ }
5041
+ return nullifiers;
5042
+ }
5043
+ async function resolveSpentStatus(params) {
5044
+ if (params.nullifiers.size === 0) return /* @__PURE__ */ new Map();
5045
+ try {
5046
+ return await resolveSpentSet({
5047
+ source: params.source,
5048
+ cache: params.cache,
5049
+ ownNullifiers: params.nullifiers,
5050
+ signal: params.signal,
5051
+ onWarning: params.onWarning
5052
+ });
5053
+ } catch (err) {
5054
+ if (isAbortError(err)) throw err;
5055
+ params.onWarning?.(
5056
+ "could not resolve spent-nullifier status (feed unavailable or inconsistent); spent status left unverified this pass",
5057
+ err
5058
+ );
5059
+ return /* @__PURE__ */ new Map();
5060
+ }
5061
+ }
5062
+
5008
5063
  // src/wallet/scanner/pipeline.ts
5009
5064
  async function runScan(params) {
5010
5065
  const { vault, storage, hints, nullifiers, pool, keys, sinceLeafIndex, signal, onWarning } = params;
@@ -5023,6 +5078,14 @@ async function runScan(params) {
5023
5078
  }
5024
5079
  }
5025
5080
  );
5081
+ const checkpoint = createScanCheckpoint({
5082
+ vault,
5083
+ storage,
5084
+ nullifiers,
5085
+ isIncremental,
5086
+ signal,
5087
+ onWarning
5088
+ });
5026
5089
  const scan = await collectScanEntries({
5027
5090
  source: hints,
5028
5091
  pool,
@@ -5043,7 +5106,9 @@ async function runScan(params) {
5043
5106
  // consumer probes it with a vault commitment, so keeping the whole pool
5044
5107
  // only bought heap.
5045
5108
  vaultHexes: preScanHexes,
5046
- onWarning
5109
+ onWarning,
5110
+ onPage: (entries) => checkpoint.savePageNotes(entries),
5111
+ onBatchDone: (maxLeafIndex) => checkpoint.advanceCursor(maxLeafIndex)
5047
5112
  });
5048
5113
  const nullifiersToQuery = collectNullifiersToQuery(
5049
5114
  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.0",
4
4
  "description": "Official TypeScript SDK for Orbinum.",
5
5
  "author": "Orbinum",
6
6
  "license": "MIT",