aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 00:15:59 -0700
committerAdam Soltys <[email protected]>2026-07-31 00:15:59 -0700
commit6dc7f9f89a071e43de77bf6dd82cf8d4012ce8c3 (patch)
tree342e92453a8554e18e5c3fbab5dfcac90e65b252 /src/features
parentc264f12ded66f60ab137d05a6e2449f17c98ad68 (diff)
downloadcoinosv3-6dc7f9f89a071e43de77bf6dd82cf8d4012ce8c3.tar.gz
coinosv3-6dc7f9f89a071e43de77bf6dd82cf8d4012ce8c3.zip
Sync silent-payment scan across devices so the catch-up runs once
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 <[email protected]>
Diffstat (limited to 'src/features')
-rw-r--r--src/features/sp-wallet.js36
1 files changed, 35 insertions, 1 deletions
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