aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-27 23:17:56 -0700
committerAdam Soltys <[email protected]>2026-06-27 23:17:56 -0700
commitdf58295848c7675e6501148da2a988b918811a2c (patch)
treeb06dbd67d086ace52ed9c7b3639bbfe4579d8b1e /src/wallet.js
parent77a14981edc9b65bfce52a2e97ec99e6069e9c77 (diff)
downloadcoinosv3-df58295848c7675e6501148da2a988b918811a2c.tar.gz
coinosv3-df58295848c7675e6501148da2a988b918811a2c.zip
Fix SP scanning jank: memoize by tweak + yield + coalesce
Profiling (CDP) showed the toggle lag/high CPU was _scanSpMempool re-scanning the ENTIRE mempool item set on every WS push (the mempool churns constantly), redoing an EC point-multiplication per tweak on the main thread — ~1s blocks. - Memoize silentPaymentScan/silentPaymentCandidate by tweak (outcome is fixed), cleared when the SP keys change, capped to bound memory. Steady-state CPU from re-scanning dropped from ~1s/push to ~0 (measured 18ms over 9s idle). - Yield to the event loop during the catch-up block loop and the mempool scan, so a large first/cold scan (mainnet) doesn't freeze the UI. - Coalesce mempool scans (latest-wins, never two at once) with a race guard so a block confirming mid-scan can't duplicate an outpoint. Measured after: receive toggle 8/8 switches, avg 5ms / max 9ms (was 1-2s). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js94
1 files changed, 73 insertions, 21 deletions
diff --git a/src/wallet.js b/src/wallet.js
index 2ea9dad..2f9cca5 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -282,9 +282,37 @@ export class Wallet {
else if (this.xprv) { const n = HDKey.fromExtendedKey(this.xprv); if (n.depth === 0) master = n; }
this._spKeys = master ? deriveSilentPaymentKeys(master, coin) : null;
this._spKeysKey = ck;
+ this._spScanCache = new Map(); // per-tweak scan results are keyed to these keys
+ this._spCandCache = new Map();
return this._spKeys;
}
+ // Scan one tweak's outputs for our payments, memoized by tweak — the EC
+ // point-multiplication is the cost, and a tweak's outcome never changes, so
+ // repeated mempool pushes (which re-send the same tweaks) stay cheap.
+ _spScan(tweakHex, outputs) {
+ const keys = this.silentPaymentKeys(); // resets the caches if keys changed
+ if (!keys) return [];
+ let m = this._spScanCache.get(tweakHex);
+ if (m) return m;
+ if (this._spScanCache.size > 100000) this._spScanCache.clear(); // bound memory over long sessions
+ m = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex), outputs });
+ this._spScanCache.set(tweakHex, m);
+ return m;
+ }
+
+ // Our candidate key for a tweak (for the Bloom pre-filter), memoized likewise.
+ _spCandidate(tweakHex) {
+ const keys = this.silentPaymentKeys();
+ if (!keys) return null;
+ let c = this._spCandCache.get(tweakHex);
+ if (c) return c;
+ if (this._spCandCache.size > 100000) this._spCandCache.clear(); // bound memory over long sessions
+ c = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex) });
+ this._spCandCache.set(tweakHex, c);
+ return c;
+ }
+
// Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP.
silentPaymentAddress() {
const k = this.silentPaymentKeys();
@@ -363,16 +391,20 @@ export class Wallet {
const tip = res.tip || 0;
const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`));
const found = [];
+ let scanned = 0;
for (const block of res.blocks || []) {
+ // Yield to the event loop periodically — the candidate math is an EC
+ // multiply per tweak and a big catch-up would otherwise freeze the UI.
+ if (++scanned % 8 === 0) await new Promise((r) => setTimeout(r, 0));
let hit = false;
for (const tw of block.tweaks) {
- const cand = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tw) });
+ const cand = this._spCandidate(tw);
if (bloomHas(block.filter, cand)) { hit = true; break; }
}
if (!hit) continue;
const blk = await (await fetch(`${indexer}/block/${block.height}`)).json();
for (const it of blk.items || []) {
- const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) });
+ const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly));
for (const m of matches) {
const o = it.outputs.find((o) => o.xonly === m.output);
const id = `${it.txid}:${o.vout}`;
@@ -467,7 +499,7 @@ export class Wallet {
if (!keys) return;
let changed = false;
for (const it of items) {
- const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) });
+ const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly));
for (const m of matches) {
const o = it.outputs.find((o) => o.xonly === m.output);
const existing = this.spUtxos.find((u) => u.txid === it.txid && u.vout === o.vout);
@@ -485,26 +517,46 @@ export class Wallet {
// SP set — so payments show the moment they're broadcast. Confirmed entries are
// kept; the unconfirmed set is replaced, which also drops vanished/RBF'd ones.
async _scanSpMempool(items) {
- const keys = this.silentPaymentKeys();
- if (!keys) return;
- const confirmed = this.spUtxos.filter((u) => u.confirmed);
- const confirmedIds = new Set(confirmed.map((u) => `${u.txid}:${u.vout}`));
- const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen]));
- const pending = [];
- for (const it of items) {
- const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) });
- for (const m of matches) {
- const o = it.outputs.find((o) => o.xonly === m.output);
- const id = `${it.txid}:${o.vout}`;
- if (confirmedIds.has(id)) continue; // already confirmed
- pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() });
+ // Coalesce: pushes arrive faster than a cold (mainnet) scan completes, so
+ // keep only the latest set and never run two scans at once.
+ this._spMempoolLatest = items;
+ if (this._spMempoolBusy) return;
+ this._spMempoolBusy = true;
+ try {
+ if (!this.silentPaymentKeys()) return;
+ while (this._spMempoolLatest) {
+ const cur = this._spMempoolLatest;
+ this._spMempoolLatest = null;
+ const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen]));
+ const confirmedIds = new Set(this.spUtxos.filter((u) => u.confirmed).map((u) => `${u.txid}:${u.vout}`));
+ const pending = [];
+ let i = 0;
+ for (const it of cur) {
+ // Yield periodically so a large first scan doesn't block the UI (cached
+ // tweaks are cheap, so steady-state pushes barely pause here).
+ if (++i % 50 === 0) await new Promise((r) => setTimeout(r, 0));
+ const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly));
+ for (const m of matches) {
+ const o = it.outputs.find((o) => o.xonly === m.output);
+ const id = `${it.txid}:${o.vout}`;
+ if (confirmedIds.has(id)) continue;
+ pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() });
+ }
+ }
+ // Re-read confirmed (a block push may have landed during a yield) and drop
+ // any pending that just confirmed, so an outpoint never appears twice.
+ const confNow = this.spUtxos.filter((u) => u.confirmed);
+ const confNowIds = new Set(confNow.map((u) => `${u.txid}:${u.vout}`));
+ const prevPending = this.spUtxos.length - confNow.length;
+ const freshPending = pending.filter((p) => !confNowIds.has(`${p.txid}:${p.vout}`));
+ this.spUtxos = [...confNow, ...freshPending];
+ if (freshPending.length !== prevPending || freshPending.length) this._recomputeBalanceFromChains();
+ this.saveCache();
+ this.emit();
}
+ } finally {
+ this._spMempoolBusy = false;
}
- const prevPending = this.spUtxos.filter((u) => !u.confirmed).length;
- this.spUtxos = [...confirmed, ...pending];
- if (pending.length !== prevPending || pending.length) this._recomputeBalanceFromChains();
- this.saveCache();
- this.emit();
}
node(chain, index) {