diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 19:38:30 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 19:38:30 -0700 |
| commit | 348a57004f94dd0f58c4e70769e65ca7ea3f7619 (patch) | |
| tree | 5f8cd2930abd9c1e05bb7c18e0bec239780281ef /src/features/sp-wallet.js | |
| parent | 9196f4804851a87c8356418b1c4cade6e87c54ba (diff) | |
| download | coinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.tar.gz coinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.zip | |
Simplify: one Lightning path, one balance, fewer features
Dropped: silent payments (feature, wallet chains, scan worker, indexer
config), Boltz submarine/reverse swaps (feature + swap.js + tools), the
third-party ark swap bridge (config, client flows, settings card — the
ASP's own Lightning send is the one path now), and the Consolidate
coins settings action.
The NWC pouch is gone with them: background answering now spends the
WHOLE ark balance. The IndexedDB record mirrors the real manager state
plus the chain-3 keys for its live coins and a change-index window; the
worker pays through the ASP exactly like the open app, and the app
folds the worker's spends, change, and key allocations back in on
connect (additive merge + reconcile against the server). A leftover
chain-6 pouch from the short-lived pouch era is swept home
automatically using the keys stored in its own record. The stated
tradeoff: same-origin script access now reaches the ark balance, not a
carve-out — the ark balance is walking-around money, the on-chain
wallet's keys are never mirrored, and per-connection NWC budgets remain
the enforced ceiling.
Bundle: 726KB -> 602KB.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/features/sp-wallet.js')
| -rw-r--r-- | src/features/sp-wallet.js | 544 |
1 files changed, 0 insertions, 544 deletions
diff --git a/src/features/sp-wallet.js b/src/features/sp-wallet.js deleted file mode 100644 index f461d27..0000000 --- a/src/features/sp-wallet.js +++ /dev/null @@ -1,544 +0,0 @@ -// Silent payments (BIP-352) wallet engine — key derivation, indexer scanning -// (worker-accelerated with an inline fallback), the live indexer WebSocket, -// SP outputs as spendable coins, sp1 send preparation, and per-output-key -// signing. Installed onto the core wallet by the sp feature, wired through the -// wallet's generic seams so a build without sp ships none of this. - -import * as btc from '@scure/btc-signer'; -import { hex } from '@scure/base'; -import { schnorr } from '@noble/curves/secp256k1'; -import { HDKey } from '@scure/bip32'; -import { mnemonicToSeedSync } from '@scure/bip39'; -import { - isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, - silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, - silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas, -} from '../silentpay.js'; -import { spIndexerUrl } from '../api.js'; -import { utxoId, absWsUrl, NETS } from '../wallet.js'; - -const SP_DUST_LIMIT = 0; - -export function installSpWallet(wallet) { - if (wallet.scanSilentPayments) return; // already installed - wallet.spUtxos = []; - wallet.lastSpScan = 0; // highest block height scanned for silent payments - wallet.spScanning = false; - - Object.assign(wallet, { - // BIP-352 scan/spend keys, derived from the master seed (m/352' paths) — so - // they need the mnemonic (or a depth-0 master xprv); watch-only and account- - // level imported keys can't do silent payments. Cached per account. - silentPaymentKeys() { - if (this.watchOnly) return null; - const coin = NETS[this.netName].coin; - const ck = `sp|${this.netName}|${this.mnemonic}|${this.passphrase}|${this.xprv}`; - if (this._spKeys && this._spKeysKey === ck) return this._spKeys; - let master = null; - if (this.mnemonic) master = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase)); - 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; - }, - - // 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)) return; - // Timed out. TERMINATE the worker rather than let it keep grinding the - // orphaned request in the background — otherwise the caller's inline - // fallback runs the SAME work on the main thread and both threads peg. - // _ensureSpWorker() respawns a fresh worker (and re-sends keys) on the - // next call, so this only costs us the current chunk. With the tweak - // budget above, calls stay sub-second and this should never fire. - try { this._spWorker && this._spWorker.terminate(); } catch {} - this._spWorker = null; - for (const p of this._spPending.values()) p.rej(new Error('sp worker timeout')); - this._spPending.clear(); - }, 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(); - return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null; - }, - - // Is SP receiving available here? (we can derive keys AND an indexer is set) - silentPaymentsAvailable() { - return !!(this.silentPaymentKeys() && spIndexerUrl(this.netName)); - }, - - // Total received via silent payments: { confirmed, pending, count } over the - // unspent SP outputs we've found. - spBalance() { - let confirmed = 0, pending = 0; - for (const u of this.spUtxos) { if (u.confirmed) confirmed += u.value; else pending += u.value; } - return { confirmed, pending, count: this.spUtxos.length }; - }, - - // Received silent payments as history rows (one per tx; multiple outputs to us - // sum into one received entry) so they appear in history like normal payments. - spTxRows() { - const byTx = new Map(); - for (const u of this.spUtxos) { - const e = byTx.get(u.txid) || { txid: u.txid, net: 0, confirmed: true, firstSeen: u.firstSeen || Date.now() }; - e.net += u.value; - if (!u.confirmed) e.confirmed = false; - e.firstSeen = Math.min(e.firstSeen, u.firstSeen || e.firstSeen); - byTx.set(u.txid, e); - } - return [...byTx.values()].map((e) => ({ - txid: e.txid, net: e.net, fee: 0, vsize: 0, sp: true, - confirmed: e.confirmed, - blockTime: e.confirmed ? Math.floor(e.firstSeen / 1000) : 0, - blockHeight: 0, - firstSeen: e.firstSeen, - })); - }, - - // The taproot address for an x-only output key (for backend UTXO lookups). - _spAddress(xonly) { - return btc.Address(this.netCfg.net).encode(btc.OutScript.decode(btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(xonly) }))); - }, - - // Scan the configured SP indexer from the last-scanned height to the tip, find - // outputs paying our scan/spend keys, and verify each is unspent via the chain - // backend (the indexer only does discovery). Updates this.spUtxos. - async scanSilentPayments({ rescan = false, silent = false } = {}) { - const keys = this.silentPaymentKeys(); - const indexer = spIndexerUrl(this.netName); - if (!keys || !indexer || this.offline) return { unavailable: true }; - if (this._spScanBusy) return { busy: true }; - // Fresh wallet, never scanned: set the watermark to the current tip instead of - // scanning all history (nothing predates the wallet). New blocks + mempool - // arrive via the live push from here on. - if (this._spFresh && !this.lastSpScan && !rescan) { - this._spFresh = false; - try { this.lastSpScan = (await (await fetch(`${indexer}/height`)).json()).height || 0; this.saveCache(); } catch {} - return { fresh: true }; - } - // Two tabs on the same wallet must not both grind the catch-up: a - // localStorage lease (refreshed per chunk) lets the second tab yield. - const leaseKey = this._cacheKey() + ':spscan-lease'; - try { - if (!rescan && Date.now() - (parseInt(localStorage.getItem(leaseKey), 10) || 0) < 30000) { - return { busy: true }; - } - localStorage.setItem(leaseKey, String(Date.now())); - } catch {} - this._spScanBusy = true; - // Only a manual scan shows the visible "Scanning…" state; auto-scans are - // invisible (setting spScanning for them left the card stuck on "Scanning…"). - if (!silent) { this.spScanning = true; this.emit(); } - try { - if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } - const from = (this.lastSpScan || 0) + 1; - // Catch-up: per block we get tweaks + a Bloom filter (no outputs). Derive a - // candidate key per tweak, test the filter, and only fetch the full block on - // a hit — so most blocks cost just the candidate math, no download/scan. - const res = await (await fetch(`${indexer}/scan/${from}?dustLimit=${SP_DUST_LIMIT}`)).json(); - const tip = res.tip || 0; - const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); - const found = []; - const blocks = res.blocks || []; - // Process in chunks bounded by TWEAK count, not block count. The cost is - // one EC point-multiply per tweak, and mainnet blocks are wildly uneven - // (a few to 2500+ tweaks each). A fixed 100-block chunk could be ~25k - // tweaks — seconds of work that blew the 30s worker timeout, which then - // dropped the same chunk onto the main thread while the orphaned worker - // kept grinding it (both threads pegged). Budgeting by tweaks keeps every - // worker call sub-second regardless of block density; the watermark still - // advances per chunk so interruptions resume instead of restarting. - const TWEAK_BUDGET = 2000; - for (let c = 0; c < blocks.length; ) { - // grow the slice until it holds ~TWEAK_BUDGET tweaks (always ≥1 block, - // so a single dense block still goes as its own chunk). - let end = c, tw = 0; - do { tw += (blocks[end].tweaks || []).length; end++; } while (end < blocks.length && tw < TWEAK_BUDGET); - const slice = blocks.slice(c, end); - c = end; - const hits = await this._spHitBlocks(slice); - for (const block of slice) { - if (!hits.has(block.height)) continue; - const blk = await (await fetch(`${indexer}/block/${block.height}`)).json(); - 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); - } - } - const maxH = slice[slice.length - 1].height; - if (maxH > (this.lastSpScan || 0)) { this.lastSpScan = maxH; this.saveCache(); } // checkpoint - try { localStorage.setItem(leaseKey, String(Date.now())); } catch {} // keep the lease - await new Promise((r) => setTimeout(r, 0)); // let the UI breathe - } - if (tip > (this.lastSpScan || 0)) this.lastSpScan = tip; - // Verify newly-found outputs are unspent + get confirmation status. - for (const f of found) { - const address = this._spAddress(f.xonly); - try { - const us = await this.api.addressUtxos(address); - const u = us.find((x) => x.txid === f.txid && x.vout === f.vout); - if (u) this.spUtxos.push({ ...f, address, confirmed: !!(u.status && u.status.confirmed), firstSeen: Date.now() }); - } catch {} - } - await this._refreshSpUtxos(); - this._recomputeBalanceFromChains(); // fold found SP value into the displayed balance - this.saveCache(); - return { found: found.length, scanned: tip }; - } finally { - this._spScanBusy = false; - this.spScanning = false; - this.emit(); // always refresh (balance + clears any "Scanning…") - } - }, - - // Re-check tracked SP outputs against the backend: drop spent ones, refresh - // confirmation status. - async _refreshSpUtxos() { - const next = []; - for (const u of this.spUtxos) { - try { - const us = await this.api.addressUtxos(u.address); - const live = us.find((x) => x.txid === u.txid && x.vout === u.vout); - if (live) next.push({ ...u, confirmed: !!(live.status && live.status.confirmed) }); - } catch { next.push(u); } // backend hiccup: keep as-is - } - this.spUtxos = next; - }, - - // Live silent-payment push: subscribe to the indexer's WebSocket so each new - // block's SP items are scanned on arrival instead of polling. Catches up via a - // /scan on connect (covers anything missed while disconnected) and reconnects. - _spConnect() { - if (this.offline) return; - const base = spIndexerUrl(this.netName); - if (!base || !this.silentPaymentKeys()) return; - const url = base.replace(/^http/, 'ws') + '/ws'; - if (this._spWs && this._spWsUrl === url) return; - this._spDisconnect(); - this._spWsUrl = url; - let ws; - try { ws = new WebSocket(absWsUrl(url)); } catch { return; } - this._spWs = ws; - ws.onopen = () => { - this.spLive = true; - this.scanSilentPayments({ silent: true }).catch(() => {}); // confirmed catch-up - // Pending (mempool) catch-up. - fetch(`${base}/mempool`).then((r) => r.json()).then((d) => this._scanSpMempool(d.items || [])).catch(() => {}); - }; - ws.onmessage = (ev) => { - let msg; - try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data)); } catch { return; } - if (!msg || !Array.isArray(msg.items)) return; - if (msg.type === 'block') this._scanSpItems(msg.items, msg.height).catch(() => {}); - else if (msg.type === 'mempool') this._scanSpMempool(msg.items).catch(() => {}); - }; - ws.onerror = () => { try { ws.close(); } catch {} }; - ws.onclose = () => { - if (this._spWs !== ws) return; - this.spLive = false; this._spWs = null; - if (!this.offline) this._spReconnect = setTimeout(() => this._spConnect(), 3000); - }; - }, - - _spDisconnect() { - clearTimeout(this._spReconnect); - this.spLive = false; - const ws = this._spWs; - this._spWs = null; this._spWsUrl = null; - if (ws) { try { ws.onclose = null; ws.close(); } catch {} } - }, - - // Scan a confirmed block's pushed items and upsert ours into spUtxos as - // confirmed (a previously-pending mempool entry is promoted to confirmed). - async _scanSpItems(items, height) { - const keys = this.silentPaymentKeys(); - if (!keys) return; - let changed = false; - 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(); - this.saveCache(); - if (changed) this.emit(); - }, - - // Scan the current mempool's pushed items and rebuild our pending (unconfirmed) - // 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) { - // 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 = []; - 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. - const confNow = this.spUtxos.filter((u) => u.confirmed); - const confNowIds = new Set(confNow.map((u) => `${u.txid}:${u.vout}`)); - const oldPendingIds = new Set(this.spUtxos.filter((u) => !u.confirmed).map((u) => `${u.txid}:${u.vout}`)); - const freshPending = pending.filter((p) => !confNowIds.has(`${p.txid}:${p.vout}`)); - // Only emit when our pending set actually changed — the mempool churns - // constantly, and a no-op emit would re-render and steal input focus. - const changed = freshPending.length !== oldPendingIds.size || freshPending.some((p) => !oldPendingIds.has(`${p.txid}:${p.vout}`)); - this.spUtxos = [...confNow, ...freshPending]; - if (changed) { this._recomputeBalanceFromChains(); this.saveCache(); this.emit(); } - } - } finally { - this._spMempoolBusy = false; - } - }, - - // Mark summary outputs that fund a silent payment with the sp1… address the - // user actually typed (the on-chain output is a derived one-time taproot addr). - _tagSilentOutputs(summary, spOuts) { - for (const s of spOuts) { - if (!s.derivedAddress) continue; - const o = summary.outputs.find((o) => o.address === s.derivedAddress && !o.silent); - if (o) o.silent = s.address; - } - }, - - // Replace each placeholder silent-payment output in a freshly-selected (still - // unsigned) tx with its real one-time taproot script, derived from the tx's - // actual inputs per BIP-352. - _applySilentPayments(tx, spOuts) { - const byOutpoint = new Map(); - for (const u of this.utxos) byOutpoint.set(utxoId(u), { u, sp: false }); - for (const u of this.spUtxos) byOutpoint.set(utxoId(u), { u, sp: true }); - const spKeys = this.silentPaymentKeys(); - const inputs = []; - for (let i = 0; i < tx.inputsLength; i++) { - const inp = tx.getInput(i); - const e = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`); - if (!e) throw new Error('Missing key for a silent payment input.'); - // Our own SP coins can fund a silent payment too — they're taproot inputs - // whose key is d = spend_priv + t_k (committed to with even Y by the script). - if (e.sp) { - if (!spKeys) throw new Error('Missing keys for a silent payment input.'); - inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(e.u.tweak)), taproot: true }); - } else { - inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: this.node(e.u.chain, e.u.index).privateKey }); - } - } - const scripts = silentPaymentScripts(inputs, spOuts.map((s) => ({ scan: s.scan, spend: s.spend }))); - spOuts.forEach((s, k) => { - const ph = hex.encode(s.placeholder); - const idx = tx.outputs.findIndex((o) => hex.encode(o.script) === ph); - if (idx < 0) throw new Error('Silent payment output not found after selection.'); - tx.updateOutput(idx, { script: scripts[k], amount: tx.outputs[idx].amount }, true); - s.derivedAddress = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(scripts[k])); - }); - }, - }); - - // -- seam registrations ----------------------------------------------- - wallet.registerBalanceExtra(() => wallet.spBalance()); - wallet.registerHistoryProvider(() => wallet.spTxRows()); - wallet.registerScanHook(() => wallet.scanSilentPayments({ silent: true }).catch(() => {})); - wallet.registerRealtimeHook({ - start: () => wallet._spConnect(), // live push of silent-payment receipts - stop: () => wallet._spDisconnect(), - // WS is primary; the heartbeat poll is the fallback when it isn't live. - poll: () => { if (!wallet.spLive) wallet.scanSilentPayments({ silent: true }).catch(() => {}); }, - }); - wallet.registerLoadHook(({ spFresh }) => { - // A freshly-generated wallet can't have received silent payments before it - // existed, so its SP watermark starts at the current tip (no history scan). - wallet._spFresh = !!spFresh; - 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) => { - 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 - // replaced, invalidating our spend). - wallet.registerInputProvider(({ coinIds }) => { - const confirmedSp = wallet.spUtxos.filter((u) => u.confirmed); - const pool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp; - return pool.map((u) => ({ - txid: u.txid, - index: u.vout, - sequence: 0xfffffffd, - witnessUtxo: { script: btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(u.xonly) }), amount: BigInt(u.value) }, - // Present so the fee estimator sizes a key-path (64-byte) witness; the - // value is unused (SP inputs are signed raw below, via tapKeySig). - tapInternalKey: hex.decode(u.xonly), - })); - }); - // sp1… recipients: the real taproot output commits to the final input set, - // so buildTx selects against a placeholder and swaps the derived script in. - wallet.registerSendPreparer(() => { - const spOuts = []; // { placeholder, scan, spend, address } - return { - match: (addr) => isSilentPaymentAddress(addr), - prepare(addr) { - if (wallet.watchOnly) throw new Error('Silent payments require a wallet with keys.'); - const { scan, spend } = decodeSilentPaymentAddress(addr); - const placeholder = silentPaymentPlaceholder(spOuts.length); - spOuts.push({ placeholder, scan, spend, address: addr }); - return placeholder; - }, - apply(tx) { if (spOuts.length) wallet._applySilentPayments(tx, spOuts); }, - tag(summary) { wallet._tagSilentOutputs(summary, spOuts); }, - }; - }); - // Received SP outputs sign with d = spend_priv + t_k (raw output key — - // no BIP-341 tweak, so btc-signer's auto key-path signing doesn't apply). - wallet.registerInputSigner((tx, i, id, { prevScripts, amounts }) => { - const u = wallet.spUtxos.find((x) => utxoId(x) === id); - if (!u) return false; - const d = silentPaymentOutputPrivKey(wallet.silentPaymentKeys().spendPriv, hex.decode(u.tweak)); - const hash = tx.preimageWitnessV1(i, prevScripts, btc.SigHash.DEFAULT, amounts); - tx.updateInput(i, { tapKeySig: schnorr.sign(hash, d) }, true); - return true; - }); -} |
