@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 +24 -9
- package/dist/index.d.ts +24 -9
- package/dist/index.js +210 -143
- package/dist/index.mjs +199 -132
- package/package.json +1 -1
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:
|
|
4507
|
-
*
|
|
4508
|
-
*
|
|
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.
|
|
4519
|
-
*
|
|
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
|
|
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:
|
|
4507
|
-
*
|
|
4508
|
-
*
|
|
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.
|
|
4519
|
-
*
|
|
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
|
|
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
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
6081
|
-
const { source, cache,
|
|
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
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
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
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
4844
|
-
const { source, cache,
|
|
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
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
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,
|