diff options
| author | Adam Soltys <[email protected]> | 2026-06-28 03:21:07 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-28 03:21:07 -0700 |
| commit | 935adfe4a28cedd17f97ea3a6be7ccbafe6c7cec (patch) | |
| tree | b4618b068e379e7feedd80b2a623433ed240018e /src | |
| parent | e8edfd88b14ad07f5f97f5cb1d5d603d825a413d (diff) | |
| download | coinosv3-935adfe4a28cedd17f97ea3a6be7ccbafe6c7cec.tar.gz coinosv3-935adfe4a28cedd17f97ea3a6be7ccbafe6c7cec.zip | |
Run silent-payment scanning in a Web Worker
The EC crypto (candidate derivation + full scan) now runs in a worker, off the
main thread, so even a large mainnet scan can't jank the UI at all. The worker is
bundled separately and embedded as base64 (single-file app), spawned from a Blob
URL; it holds the keys + per-tweak cache. If workers are unavailable or the bundle
is missing, scanning falls back to the main thread (still cached + yielding).
scanSilentPayments / _scanSpItems / _scanSpMempool now go through _spHitBlocks and
_spScanItems, which dispatch to the worker (or the fallback). +27KB to index.html
for the worker's crypto bundle.
Verified via CDP: worker runs keys/candidates/scan correctly in-browser; a real
reload + mainnet scan threw 0 exceptions and the SP address renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/sp-worker.js | 69 | ||||
| -rw-r--r-- | src/wallet.js | 122 |
2 files changed, 152 insertions, 39 deletions
diff --git a/src/sp-worker.js b/src/sp-worker.js new file mode 100644 index 0000000..59a1769 --- /dev/null +++ b/src/sp-worker.js @@ -0,0 +1,69 @@ +// Web Worker: the EC-heavy silent-payment crypto, kept off the main thread so a +// large scan never janks the UI. Memoizes per tweak (a tweak's outcome is fixed), +// so repeated mempool pushes — which re-send the same tweaks — stay cheap. +// +// Protocol (request/response by id): +// { id, op:'keys', scanPriv, spendPub } → { id, ok:true } +// { id, op:'candidates', blocks:[{height,tweaks,filter}] } → { id, hits:[height] } +// { id, op:'scan', items:[{txid,tweak,outputs}] } → { id, found:[utxo] } + +import { hex } from '@scure/base'; +import { silentPaymentScan, silentPaymentCandidate, bloomHas } from './silentpay.js'; + +let scanPriv = null; +let spendPub = null; +const scanCache = new Map(); // tweakHex -> matches +const candCache = new Map(); // tweakHex -> candidate x-only hex + +self.onmessage = (e) => { + const { id, op } = e.data; + try { + if (op === 'keys') { + scanPriv = e.data.scanPriv; + spendPub = e.data.spendPub; + scanCache.clear(); + candCache.clear(); + self.postMessage({ id, ok: true }); + return; + } + if (op === 'candidates') { + // Return the heights of blocks whose Bloom filter could contain one of our + // outputs (derive a candidate per tweak, test the filter, stop on first hit). + const hits = []; + for (const b of e.data.blocks) { + for (const tw of b.tweaks) { + let c = candCache.get(tw); + if (!c) { + if (candCache.size > 100000) candCache.clear(); + c = silentPaymentCandidate({ scanPriv, spendPub, tweak: hex.decode(tw) }); + candCache.set(tw, c); + } + if (bloomHas(b.filter, c)) { hits.push(b.height); break; } + } + } + self.postMessage({ id, hits }); + return; + } + if (op === 'scan') { + // Full scan: return resolved utxos {txid,vout,value,xonly,tweak} for ours. + const found = []; + for (const it of e.data.items) { + let m = scanCache.get(it.tweak); + if (!m) { + if (scanCache.size > 100000) scanCache.clear(); + m = silentPaymentScan({ scanPriv, spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + scanCache.set(it.tweak, m); + } + for (const mm of m) { + const o = it.outputs.find((x) => x.xonly === mm.output); + if (o) found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: mm.output, tweak: mm.tweak }); + } + } + self.postMessage({ id, found }); + return; + } + self.postMessage({ id, error: 'unknown op ' + op }); + } catch (err) { + self.postMessage({ id, error: String((err && err.message) || err) }); + } +}; diff --git a/src/wallet.js b/src/wallet.js index 497b60b..006441c 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -316,6 +316,74 @@ export class Wallet { return c; } + // Spawn (once) the scanning worker and keep its keys current. Returns null if + // workers are unavailable or the embedded bundle is missing — callers then fall + // back to scanning on the main thread (still cached + yielding). + _ensureSpWorker() { + if (this._spWorkerFailed || typeof Worker === 'undefined') return null; + const keys = this.silentPaymentKeys(); + if (!keys) return null; + if (!this._spWorker) { + try { + const b64 = globalThis.__SP_WORKER__; + if (!b64) { this._spWorkerFailed = true; return null; } + const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); + const w = new Worker(URL.createObjectURL(new Blob([bytes], { type: 'text/javascript' }))); + this._spPending = new Map(); + this._spReqId = 0; + w.onmessage = (e) => { const p = this._spPending.get(e.data.id); if (p) { this._spPending.delete(e.data.id); e.data.error ? p.rej(new Error(e.data.error)) : p.res(e.data); } }; + w.onerror = () => { this._spWorkerFailed = true; try { w.terminate(); } catch {} this._spWorker = null; for (const p of this._spPending.values()) p.rej(new Error('sp worker error')); this._spPending.clear(); }; + this._spWorker = w; + this._spWorkerKeysSig = null; + } catch { this._spWorkerFailed = true; return null; } + } + if (this._spWorkerKeysSig !== this._spKeysKey) { + this._spWorkerKeysSig = this._spKeysKey; + this._spWorker.postMessage({ id: ++this._spReqId, op: 'keys', scanPriv: keys.scanPriv, spendPub: keys.spendPub }); + } + return this._spWorker; + } + + _spCall(op, data) { + return new Promise((res, rej) => { + const id = ++this._spReqId; + this._spPending.set(id, { res, rej }); + this._spWorker.postMessage({ id, op, ...data }); + setTimeout(() => { if (this._spPending.has(id)) { this._spPending.delete(id); rej(new Error('sp worker timeout')); } }, 30000); + }); + } + + // Block heights whose Bloom filter might hold one of our outputs (worker if + // available, else a cached + yielding main-thread loop). + async _spHitBlocks(blocks) { + if (!blocks.length) return new Set(); + const w = this._ensureSpWorker(); + if (w) { try { return new Set((await this._spCall('candidates', { blocks })).hits || []); } catch {} } + const hits = new Set(); + let i = 0; + for (const b of blocks) { + if (++i % 8 === 0) await new Promise((r) => setTimeout(r, 0)); + for (const tw of b.tweaks) { if (bloomHas(b.filter, this._spCandidate(tw))) { hits.add(b.height); break; } } + } + return hits; + } + + // Full-scan items → our resolved utxos {txid,vout,value,xonly,tweak} (worker if + // available, else a cached + yielding main-thread loop). + async _spScanItems(items) { + if (!items.length) return []; + const w = this._ensureSpWorker(); + if (w) { try { return (await this._spCall('scan', { items })).found || []; } catch {} } + const found = []; + let i = 0; + for (const it of items) { + 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((x) => x.xonly === m.output); if (o) found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); } + } + return found; + } + // Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP. silentPaymentAddress() { const k = this.silentPaymentKeys(); @@ -402,27 +470,15 @@ 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; + const hits = await this._spHitBlocks(res.blocks || []); 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 = this._spCandidate(tw); - if (bloomHas(block.filter, cand)) { hit = true; break; } - } - if (!hit) continue; + if (!hits.has(block.height)) continue; const blk = await (await fetch(`${indexer}/block/${block.height}`)).json(); - for (const it of blk.items || []) { - 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 (have.has(id)) continue; - have.add(id); - found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); - } + for (const f of await this._spScanItems(blk.items || [])) { + const id = `${f.txid}:${f.vout}`; + if (have.has(id)) continue; + have.add(id); + found.push(f); } } if (tip > this.lastSpScan) this.lastSpScan = tip; @@ -509,14 +565,10 @@ export class Wallet { const keys = this.silentPaymentKeys(); if (!keys) return; let changed = false; - for (const it of items) { - 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); - if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } } - else { this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true, firstSeen: Date.now() }); changed = true; } - } + for (const f of await this._spScanItems(items)) { + const existing = this.spUtxos.find((u) => u.txid === f.txid && u.vout === f.vout); + if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } } + else { this.spUtxos.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: true, firstSeen: Date.now() }); changed = true; } } if (height && height > this.lastSpScan) this.lastSpScan = height; if (changed) this._recomputeBalanceFromChains(); @@ -541,18 +593,10 @@ export class Wallet { 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() }); - } + for (const f of await this._spScanItems(cur)) { + const id = `${f.txid}:${f.vout}`; + if (confirmedIds.has(id)) continue; + pending.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), 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. |
