From 6dc7f9f89a071e43de77bf6dd82cf8d4012ce8c3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 31 Jul 2026 00:15:59 -0700 Subject: Sync silent-payment scan across devices so the catch-up runs once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SP state (lastSpScan watermark + found spUtxos) was in the nostr snapshot but, unlike Ark/gifts, its cache extension wasn't mergeAlways — so it only applied via the newest full snapshot at load time, last-writer-wins. The watermark could regress (a device behind on SP, or mid-rescan, publishing a newer snapshot would reset another device's watermark), so every device tended to re-grind the ~1M-tweak backlog independently. Make it a commutative merge like Ark: - mergeAlways: true, so it merges live and from older snapshots too. - lastSpScan takes the MAX (monotonic high-water mark, never regresses) — this is what makes the block catch-up run once across all devices. - spUtxos is an additive union by outpoint; a coin we already track is never mutated, and spend detection stays each device's own job (_refreshSpUtxos drops backend-spent coins), so stale merged-in coins self-heal on refresh. - Anti-entropy: republish our superset when we know more, guarded so it stops once devices converge. A manual rescan still works — it forces from=1 synchronously (no await before capturing it), so a concurrent max-merge can't sabotage the re-scan. Verified with a merge unit test driving the real extension (max/union/converge). Co-Authored-By: Claude Opus 4.8 --- src/features/sp-wallet.js | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/features/sp-wallet.js b/src/features/sp-wallet.js index 2ac3e70..f461d27 100644 --- a/src/features/sp-wallet.js +++ b/src/features/sp-wallet.js @@ -460,9 +460,43 @@ export function installSpWallet(wallet) { wallet.spUtxos = []; wallet.lastSpScan = 0; }); + // Ride the encrypted sync snapshot so the (expensive) SP block catch-up runs + // ONCE across all devices instead of on each. Like Ark, this is a commutative + // merge (mergeAlways) — applied even for snapshots older than local state — so + // whichever device scans furthest carries the rest. wallet.registerCacheExtension({ + mergeAlways: true, save: () => ({ spUtxos: wallet.spUtxos, lastSpScan: wallet.lastSpScan }), - load: (d) => { wallet.spUtxos = d.spUtxos || []; wallet.lastSpScan = d.lastSpScan || 0; }, + load: (d) => { + let grew = false; + // Watermark is a HIGH-water mark: adopt the furthest height any device + // reached, never regress. This is what stops every device re-grinding the + // ~1M-tweak backlog. (A manual rescan forces from=1 synchronously, so it + // still re-scans everything regardless of this.) + const remoteScan = d.lastSpScan || 0; + if (remoteScan > (wallet.lastSpScan || 0)) wallet.lastSpScan = remoteScan; + else if ((wallet.lastSpScan || 0) > remoteScan) grew = true; // our watermark is ahead + // Additive union of found coins by outpoint — bring in ones we lack, but + // NEVER mutate a coin we already track. Spend detection is each device's + // own job (_refreshSpUtxos drops coins the backend reports spent), so a + // stale coin merged in from another device self-heals on the next refresh + // rather than needing spent-state to propagate. + if (Array.isArray(d.spUtxos)) { + const have = new Set(wallet.spUtxos.map((u) => `${u.txid}:${u.vout}`)); + let added = false; + for (const ru of d.spUtxos) { + if (!ru || ru.txid == null || ru.vout == null) continue; + const id = `${ru.txid}:${ru.vout}`; + if (!have.has(id)) { wallet.spUtxos.push(ru); have.add(id); added = true; } + } + if (wallet.spUtxos.length > d.spUtxos.length) grew = true; // we hold coins it lacked + if (added) wallet._recomputeBalanceFromChains(); + } + // Anti-entropy: if we ended up knowing more than this snapshot did, push + // our superset back so the shared slot converges. Guarded, so once every + // device agrees it stops (matches the Ark merge). + if (grew) { try { wallet.saveCache(); } catch {} } + }, }); // Confirmed SP coins are spendable like any other coin — including to fund a // silent payment. Unconfirmed ones are excluded (the sender's tx could be -- cgit v1.2.3