aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/sp-worker.js69
-rw-r--r--src/wallet.js122
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.