From cc2c05d2a1b7a9601eb3cb0df998483099e590cc Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 4 Jul 2026 12:35:15 -0700 Subject: Features phase 2b: silent payments + nostr sync leave the core wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wallet.js drops from 2,374 to ~1,970 lines, holding only BIP84 on-chain logic plus generic feature seams. New seams added this pass: registerBalanceExtra extra confirmed/pending buckets registerInputProvider extra spendable inputs for buildTx registerSendPreparer destinations needing post-selection derivation registerInputSigner non-BIP84 input signing registerHistoryProvider / registerScanHook / registerRealtimeHook / registerLoadHook / registerCacheExtension / registerCacheSavedHook src/features/sp-wallet.js: the whole BIP-352 engine — key derivation, worker-accelerated indexer scanning, the live indexer WebSocket, SP outputs as spendable inputs, sp1 send preparation, and per-output-key signing — installs via those seams (installSpWallet). src/features/sync.js: nostr identity + encrypted cross-device state sync plus its Settings card, installed via load/cache-saved/realtime hooks. profileChip/nostrProfile move here-adjacent (into gifts, the only other nostr consumer), so core app.js no longer imports nostr at all. buildTx/sign are now feature-agnostic (input providers + preparers + signers); _recomputeBalance* use the balance-extra registry, so a core build with no SP no longer calls a missing spBalance() (that regression dropped the minimal build into offline mode — caught by the smoke test). New coverage: tools/sp-test.js drives two browser "devices" through an SP send, indexer discovery, and an SP-input spend on the ark regtest chain (needs tools/sp-indexer.js pointed at bitcoind:18543). Minimal smoke now also asserts no SP/nostr code ships. Minimal profile: 314KB, down from 412KB (full: 580KB). All suites green: minimal smoke, sp-test, ark UI, gift lifecycle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy --- build.js | 2 +- src/app.js | 74 +----- src/features/gifts.js | 32 ++- src/features/index.js | 3 +- src/features/sp-wallet.js | 467 +++++++++++++++++++++++++++++++++++++ src/features/sp.js | 2 + src/features/sync.js | 119 ++++++++++ src/wallet.js | 581 ++++++++-------------------------------------- tools/minimal-smoke.js | 7 +- tools/sp-test.js | 143 ++++++++++++ 10 files changed, 866 insertions(+), 564 deletions(-) create mode 100644 src/features/sp-wallet.js create mode 100644 src/features/sync.js create mode 100644 tools/sp-test.js diff --git a/build.js b/build.js index a40a949..5165669 100644 --- a/build.js +++ b/build.js @@ -141,7 +141,7 @@ export async function buildSpWorker({ minify = true } = {}) { // on-chain-only wallet. A build plugin swaps src/features/index.js for a // generated module that only imports the enabled features, so a disabled // feature's code (and its network endpoints) never enters the bundle. -const ALL_FEATURES = { gifts: 'giftsFeature', swaps: 'swapsFeature', ark: 'arkFeature', sp: 'spFeature' }; +const ALL_FEATURES = { gifts: 'giftsFeature', swaps: 'swapsFeature', ark: 'arkFeature', sp: 'spFeature', sync: 'syncFeature' }; export function enabledFeatures(spec = process.env.HAL_FEATURES) { if (spec == null) return Object.keys(ALL_FEATURES); diff --git a/src/app.js b/src/app.js index a7fed0f..2730211 100644 --- a/src/app.js +++ b/src/app.js @@ -7,7 +7,6 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; -import { getSyncConfig, setSyncConfig, parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from './nostr.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; import { buildFeatures } from './features/index.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; @@ -416,29 +415,6 @@ function download(filename, text, mime = 'application/json') { setTimeout(() => URL.revokeObjectURL(url), 1000); } -// --- nostr profiles (avatar + name) — cached, lazily fetched, re-renders ---- -const _profileCache = new Map(); // pubkeyHex -> profile | null | 'loading' -function nostrProfile(pkHex) { - if (_profileCache.has(pkHex)) return _profileCache.get(pkHex); - _profileCache.set(pkHex, 'loading'); - fetchNostrProfile(pkHex).then((p) => { _profileCache.set(pkHex, p || null); render(); }).catch(() => { _profileCache.set(pkHex, null); render(); }); - return 'loading'; -} -// A row showing the recipient's avatar + name (or a shortened npub fallback). -function profileChip(pkHex, { size = 30 } = {}) { - const p = nostrProfile(pkHex); - const npub = npubOf(pkHex) || pkHex; - const short = npub.slice(0, 12) + '…' + npub.slice(-4); - const avatar = (pic) => - pic - ? h('img', { src: pic, style: `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;flex:0 0 auto`, onError: (e) => { e.target.style.visibility = 'hidden'; } }) - : h('div', { style: `width:${size}px;height:${size}px;border-radius:50%;background:#9993;flex:0 0 auto` }); - if (p === 'loading') return h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, short)); - const name = (p && p.name) || short; - return h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, - avatar(p && p.picture), - h('span', { class: p && p.name ? '' : 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, name)); -} function copyBtn(text, label = t('copy')) { return h('button', { class: 'btn-sm', onClick: () => copy(text) }, label); @@ -853,7 +829,7 @@ async function activateAccount(acc, opts = {}) { // scan runs ONLY when we have neither (e.g. a seed imported on a fresh device // with no synced state); that's what discovers the used addresses. Otherwise // the socket + frontier poll keep us current with no refresh-time burst. - const hadNostr = wallet.watchOnly ? false : await wallet.syncFromNostr(); + const hadNostr = wallet.watchOnly || !wallet.syncFromNostr ? false : await wallet.syncFromNostr(); if (!hadCache && !hadNostr) { await wallet.scan({ silent: false }); } @@ -1454,7 +1430,7 @@ function settingsTab() { ...featureAll('settingsCards'), consolidateCard(), explorerCard(), - wallet.watchOnly || !wallet.mnemonic ? null : syncCard() + null ); } @@ -1560,48 +1536,6 @@ function explorerCard() { ); } -// Cross-device sync settings: toggle + editable relay list (default coinos). -function syncCard() { - const cfg = getSyncConfig(); - const setEnabled = (enabled) => { - if (enabled === cfg.enabled) return; - setSyncConfig({ enabled, relays: cfg.relays }); - render(); - // On enable, pull anything newer from the relays, then push our copy up. - if (enabled && !wallet.offline) { - wallet.syncFromNostr().catch(() => {}).finally(() => wallet.saveCache()); - } - }; - return h( - 'div', - { class: 'card col' }, - h('h3', {}, t('deviceSync')), - h('p', { class: 'small muted', style: 'margin:0' }, t('deviceSyncDesc')), - h('div', { class: 'row between' }, - h('span', { class: 'lab', style: 'margin:0' }, t('syncAcross')), - h('div', { class: 'seg' }, - h('button', { type: 'button', class: cfg.enabled ? 'active' : '', onClick: () => setEnabled(true) }, t('syncOn')), - h('button', { type: 'button', class: !cfg.enabled ? 'active' : '', onClick: () => setEnabled(false) }, t('syncOff')) - ) - ), - cfg.enabled - ? h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('relaysLabel')), - h('textarea', { - placeholder: 'wss://relay.example.com', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - style: 'min-height:64px', - value: cfg.relays.join('\n'), - onInput: (e) => { - const relays = e.target.value.split('\n').map((s) => s.trim()).filter(Boolean); - setSyncConfig({ enabled: true, relays }); - }, - }), - h('div', { class: 'small faint' }, t('relaysHint')) - ) - : null - ); -} // Language selector. Changing it persists the choice, flips text direction for // RTL languages, and re-renders the whole app in the new language. @@ -1859,7 +1793,7 @@ function accountSettingsScreen() { : h('button', { class: 'btn-block', onClick: () => { ui.pubkeyShown = true; render(); } }, t('showPublicKey')) ), // Nostr address — share it so others can send you locked gifts. - (a.id === activeId && wallet.nostrNpub()) + (a.id === activeId && wallet.nostrNpub && wallet.nostrNpub()) ? h('div', { class: 'card col' }, h('h3', {}, t('nostrKeyTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('nostrKeyDesc')), @@ -2969,7 +2903,7 @@ async function importSnapshotFile(e) { const ctx = { h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal, fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, toggleUnit, download, - brandHeader, profileChip, activeAccount, setAccounts: (list) => { accounts = list; }, + brandHeader, activeAccount, setAccounts: (list) => { accounts = list; }, getAccounts: () => accounts, claimTargets, enterWallet, activateAccount, commitAccount, }; diff --git a/src/features/gifts.js b/src/features/gifts.js index 9562b9f..b17f5da 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -13,7 +13,7 @@ export function giftsFeature(ctx) { const { h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, openExternal, fmtAmount, unitLabel, unitTag, parseAmount, getUnit, toggleUnit, download, - brandHeader, profileChip, activeAccount, setAccounts, getAccounts, claimTargets, + brandHeader, activeAccount, setAccounts, getAccounts, claimTargets, enterWallet, activateAccount, commitAccount, } = ctx; installGiftWallet(wallet); // gift primitives live outside the core wallet @@ -441,7 +441,11 @@ export function giftsFeature(ctx) { ui.giftCode = blob; ui.giftLocked = true; ui.giftClaimCode = claimCode; ui.giftDmStatus = 'sending'; wallet.recordGift({ code: blob, locked: true, amount, claimCode, outpoints: g.reserved }); const dmText = t('giftDmText', { amount: fmtAmount(amount) + ' ' + unitLabel(), link: giftUrl(), code: claimCode }); - wallet.sendNostrDM(pk, dmText).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); + if (wallet.sendNostrDM) { + wallet.sendNostrDM(pk, dmText).then((ok) => { ui.giftDmStatus = ok ? 'sent' : 'failed'; render(); }).catch(() => { ui.giftDmStatus = 'failed'; render(); }); + } else { + ui.giftDmStatus = 'failed'; // no sync/nostr feature in this build — the claim code fallback shows + } } function giftCard() { @@ -699,6 +703,30 @@ export function giftsFeature(ctx) { render(); } + // --- nostr profiles (avatar + name) — cached, lazily fetched, re-renders ---- + const _profileCache = new Map(); // pubkeyHex -> profile | null | 'loading' + function nostrProfile(pkHex) { + if (_profileCache.has(pkHex)) return _profileCache.get(pkHex); + _profileCache.set(pkHex, 'loading'); + fetchNostrProfile(pkHex).then((p) => { _profileCache.set(pkHex, p || null); render(); }).catch(() => { _profileCache.set(pkHex, null); render(); }); + return 'loading'; + } + // A row showing the recipient's avatar + name (or a shortened npub fallback). + function profileChip(pkHex, { size = 30 } = {}) { + const p = nostrProfile(pkHex); + const npub = npubOf(pkHex) || pkHex; + const short = npub.slice(0, 12) + '…' + npub.slice(-4); + const avatar = (pic) => + pic + ? h('img', { src: pic, style: `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;flex:0 0 auto`, onError: (e) => { e.target.style.visibility = 'hidden'; } }) + : h('div', { style: `width:${size}px;height:${size}px;border-radius:50%;background:#9993;flex:0 0 auto` }); + if (p === 'loading') return h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, short)); + const name = (p && p.name) || short; + return h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(p && p.picture), + h('span', { class: p && p.name ? '' : 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, name)); + } + // "Payment received!" takeover right after a gift claim (no scan wait). function claimCelebration() { // A gift was just claimed into this wallet — celebrate immediately (the diff --git a/src/features/index.js b/src/features/index.js index 590020a..e63e816 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -8,9 +8,10 @@ import { giftsFeature } from './gifts.js'; import { swapsFeature } from './swaps.js'; import { arkFeature } from './ark.js'; import { spFeature } from './sp.js'; +import { syncFeature } from './sync.js'; // NB order is meaningful where hooks stack: gifts' receive takeover and // balance line come before ark's, matching the pre-plugin layout. export function buildFeatures(ctx) { - return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), spFeature(ctx)]; + return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), spFeature(ctx), syncFeature(ctx)]; } diff --git a/src/features/sp-wallet.js b/src/features/sp-wallet.js new file mode 100644 index 0000000..c2d1cdb --- /dev/null +++ b/src/features/sp-wallet.js @@ -0,0 +1,467 @@ +// 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)) { 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(); + 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 }; + } + 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 hits = await this._spHitBlocks(res.blocks || []); + for (const block of res.blocks || []) { + 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); + } + } + if (tip > this.lastSpScan) 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; + }); + wallet.registerCacheExtension({ + save: () => ({ spUtxos: wallet.spUtxos, lastSpScan: wallet.lastSpScan }), + load: (d) => { wallet.spUtxos = d.spUtxos || []; wallet.lastSpScan = d.lastSpScan || 0; }, + }); + // 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; + }); +} diff --git a/src/features/sp.js b/src/features/sp.js index 0960cdf..5c55457 100644 --- a/src/features/sp.js +++ b/src/features/sp.js @@ -3,12 +3,14 @@ // in wallet.js/silentpay.js and ships with the core wallet for now.) import { isSilentPaymentAddress } from '../silentpay.js'; +import { installSpWallet } from './sp-wallet.js'; import { getNetwork, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from '../api.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; export function spFeature(ctx) { const { h, ui, render, wallet, copyBtn } = ctx; + installSpWallet(wallet); // the BIP-352 engine lives outside the core wallet function spIndexerCard() { if (!wallet.silentPaymentKeys || !wallet.silentPaymentKeys()) return null; diff --git a/src/features/sync.js b/src/features/sync.js new file mode 100644 index 0000000..8cbd9f3 --- /dev/null +++ b/src/features/sync.js @@ -0,0 +1,119 @@ +// Cross-device sync feature — encrypted wallet state on Nostr relays +// (NIP-44 to self, replaceable kind 30078) plus the wallet's nostr identity +// (used e.g. to DM locked-gift claim codes). Installed onto the core wallet; +// a build without sync ships none of it and never talks to a relay. + +import { NostrSync, getSyncConfig, setSyncConfig, npubOf } from '../nostr.js'; +import { t } from '../i18n.js'; + +export function installSyncWallet(wallet) { + if (wallet.syncFromNostr) return; // already installed + wallet.nostr = new NostrSync(); + wallet._nostrPubTimer = null; + + Object.assign(wallet, { + // Our nostr identity (used to DM a locked gift's claim code to the recipient). + nostrPubkey() { return (this.nostr && this.nostr.pk) || null; }, + + nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; }, + + // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey. + async sendNostrDM(recipientPkHex, text) { + return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false; + }, + + // Pull the latest state from Nostr; apply it if it's newer than what we have. + // Returns true if state was applied (so the caller can skip a full scan). + async syncFromNostr() { + const sync = getSyncConfig(); + if (this.offline || !sync.enabled) return false; + this.nostr.setRelays(sync.relays); + let remote; + try { + remote = await this.nostr.fetch(); + } catch { + return false; + } + if (!remote) return false; + if ((remote.savedAt || 0) > (this._savedAt || 0)) { + this._applySnapshot(remote); + this.saveCache(); // mirror into localStorage + this.emit(); + return true; + } + // Our local copy is newer (or equal) — push it up so the relay catches up. + if ((this._savedAt || 0) > (remote.savedAt || 0)) this.saveCache(); + return true; // remote existed, so no full scan needed + }, + }); + + // identity follows the open wallet + wallet.registerLoadHook(() => { + if (wallet.mnemonic) wallet.nostr.load(wallet.mnemonic, wallet.passphrase); + else wallet.nostr.unload(); + }); + // push every saved snapshot to the relays (debounced) unless sync is off + wallet.registerCacheSavedHook((snap) => { + const sync = getSyncConfig(); + if (wallet.offline || !sync.enabled) return; + wallet.nostr.setRelays(sync.relays); + clearTimeout(wallet._nostrPubTimer); + wallet._nostrPubTimer = setTimeout(() => wallet.nostr.publish(snap), 2500); + }); + wallet.registerRealtimeHook({ stop: () => clearTimeout(wallet._nostrPubTimer) }); +} + +export function syncFeature(ctx) { + const { h, ui, render, wallet } = ctx; + installSyncWallet(wallet); + + // Cross-device sync settings: toggle + editable relay list (default coinos). + function syncCard() { + const cfg = getSyncConfig(); + const setEnabled = (enabled) => { + if (enabled === cfg.enabled) return; + setSyncConfig({ enabled, relays: cfg.relays }); + render(); + // On enable, pull anything newer from the relays, then push our copy up. + if (enabled && !wallet.offline) { + wallet.syncFromNostr().catch(() => {}).finally(() => wallet.saveCache()); + } + }; + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('deviceSync')), + h('p', { class: 'small muted', style: 'margin:0' }, t('deviceSyncDesc')), + h('div', { class: 'row between' }, + h('span', { class: 'lab', style: 'margin:0' }, t('syncAcross')), + h('div', { class: 'seg' }, + h('button', { type: 'button', class: cfg.enabled ? 'active' : '', onClick: () => setEnabled(true) }, t('syncOn')), + h('button', { type: 'button', class: !cfg.enabled ? 'active' : '', onClick: () => setEnabled(false) }, t('syncOff')) + ) + ), + cfg.enabled + ? h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('relaysLabel')), + h('textarea', { + placeholder: 'wss://relay.example.com', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + style: 'min-height:64px', + value: cfg.relays.join('\n'), + onInput: (e) => { + const relays = e.target.value.split('\n').map((s) => s.trim()).filter(Boolean); + setSyncConfig({ enabled: true, relays }); + }, + }), + h('div', { class: 'small faint' }, t('relaysHint')) + ) + : null + ); + } + + return { + id: 'sync', + settingsCards() { + return wallet.watchOnly || !wallet.mnemonic ? [] : [syncCard()]; + }, + }; +} diff --git a/src/wallet.js b/src/wallet.js index 403bcd8..647db99 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -21,17 +21,15 @@ import * as btc from '@scure/btc-signer'; import { p2wpkh } from '@scure/btc-signer/payment'; import { concatBytes } from '@scure/btc-signer/utils.js'; -import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; +import { Api, pool, getBackend, explorerWeb, electrumCandidates } from './api.js'; import { ElectrumApi } from './electrum.js'; -import { NostrSync, getSyncConfig, npubOf } from './nostr.js'; -import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; // Resolve a same-origin '/path' WebSocket URL to an absolute ws(s):// URL against // the page. The regtest backends are proxied through the dev server as relative // paths (e.g. '/electrum') so the app works from a phone over the LAN/Tailscale; // absolute URLs (real deployments, tests) pass through untouched. -function absWsUrl(u) { +export function absWsUrl(u) { if (u && u[0] === '/' && typeof location !== 'undefined') { return (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + u; } @@ -53,9 +51,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Silent-payment catch-up dust limit (sats): the indexer drops tweaks for txs // whose taproot outputs are all below this, cutting the candidate math on a big // scan. 0 = receive everything (default, so no small payment is ever missed). -const SP_DUST_LIMIT = 0; -const NETS = { +export const NETS = { mainnet: { net: btc.NETWORK, coin: 0 }, testnet: { net: btc.TEST_NETWORK, coin: 1 }, // mutinynet is a signet — same address format / version bytes / coin type as @@ -108,6 +105,19 @@ export class Wallet { // Feature-registered providers of coin ids to exclude from spending and // the spendable balance (e.g. coins reserved behind unclaimed gift links). this._coinLocks = []; + // Further optional-feature registries (see the seams section below): extra + // balance buckets, extra spendable inputs, send-address preparers, input + // signers, history rows, scan/realtime/load hooks, cache extensions. + this._balanceExtras = []; + this._inputProviders = []; + this._sendPreparers = []; + this._inputSigners = []; + this._historyProviders = []; + this._scanHooks = []; + this._realtimeHooks = []; + this._loadHooks = []; + this._cacheExtensions = []; + this._cacheSavedHooks = []; this.api = new Api('mainnet'); this.offline = false; @@ -122,13 +132,6 @@ export class Wallet { this.feeRates = null; this.confirmed = 0; this.pending = 0; - // Silent-payment (BIP-352) receiving: outputs we found by scanning the - // indexer. Kept in their own bucket (one-time taproot keys, not BIP84 - // addresses); { txid, vout, value, confirmed, tweak, xonly, address }. - this.spUtxos = []; - this.lastSpScan = 0; // highest block height scanned for silent payments - this.spScanning = false; - this.scanning = false; this.loaded = false; // true once a scan/snapshot has populated balances once this.historyLoading = false; // history is still being fetched in the background @@ -154,10 +157,7 @@ export class Wallet { this._lastMsg = 0; // time of last WS message (incl. pong) this._wakeHooked = false; - // Encrypted cross-device state sync over Nostr. - this.nostr = new NostrSync(); this._savedAt = 0; - this._nostrPubTimer = null; this._subs = new Set(); } @@ -176,7 +176,7 @@ export class Wallet { this.stopRealtime(); // A freshly-generated wallet can't have received silent payments before it // existed, so we start its SP watermark at the current tip (no history scan). - this._spFresh = spFresh; + this.mnemonic = (mnemonic || '').trim().replace(/\s+/g, ' '); this.passphrase = passphrase; this.xpub = xpub || ''; @@ -193,8 +193,7 @@ export class Wallet { this._reclaimed = null; // gift coins freed for spending but link still live this._savedAt = 0; try { - if (this.mnemonic) this.nostr.load(this.mnemonic, this.passphrase); - else this.nostr.unload(); + for (const fn of this._loadHooks) { try { fn({ spFresh, netName, offline }); } catch {} } } catch {} this.reset(); } @@ -207,8 +206,6 @@ export class Wallet { this.txs = []; this.confirmed = 0; this.pending = 0; - this.spUtxos = []; - this.lastSpScan = 0; this.loaded = false; this.nextReceiveIndex = 0; this.nextChangeIndex = 0; @@ -286,166 +283,26 @@ export class Wallet { return this.account().publicExtendedKey; } - // 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)) { 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(); - return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null; - } + + + // True if addr is a valid on-chain address for this wallet's network. isOnchainAddress(addr) { try { btc.Address(this.netCfg.net).decode((addr || '').trim()); return true; } catch { return false; } } - // 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, - })); - } + // History for display: BIP84 txs + silent-payment receipts, newest first. (SP // receipts pay one-time taproot keys, never our BIP84 addresses, so no overlap.) get history() { - const rows = [...this.txs, ...this.spTxRows()]; + const rows = [...this.txs, ...this._historyProviders.flatMap((fn) => fn())]; rows.sort((a, b) => { if (a.confirmed !== b.confirmed) return a.confirmed ? 1 : -1; // pending first if (a.confirmed) return (b.blockTime || 0) - (a.blockTime || 0); @@ -454,185 +311,12 @@ export class Wallet { return rows; } - // 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 }; - } - 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 hits = await this._spHitBlocks(res.blocks || []); - for (const block of res.blocks || []) { - 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); - } - } - if (tip > this.lastSpScan) 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; - } - } + + node(chain, index) { // Relative derivation from the account node (chain/index are non-hardened). @@ -995,7 +679,7 @@ export class Wallet { } this.loaded = true; this.saveCache(); - this.scanSilentPayments({ silent: true }).catch(() => {}); // fold in any silent-payment receipts + for (const fn of this._scanHooks) { try { fn({ silent: true }); } catch {} } } finally { this._refreshing = false; this.historyLoading = false; // clear even if an earlier step threw @@ -1147,14 +831,21 @@ export class Wallet { if (res.has(utxoId(u))) return s; if (u.confirmed || u.chain === 1) return s + u.value; return s; - }, 0) + this.spBalance().confirmed; // confirmed silent-payment receipts are spendable + }, 0) + this._balanceExtras.reduce((n, fn) => n + (fn().confirmed || 0), 0); } // Unconfirmed incoming receives (not our change, not gift-locked) — shown as a // separate pending line, kept out of the spendable headline. Includes pending // (mempool) silent-payment receipts. get pendingIncoming() { const res = this.lockedCoinIds(); - return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0) + this.spBalance().pending; + return this.utxos.reduce((s, u) => s + ((!u.confirmed && u.chain === 0 && !res.has(utxoId(u))) ? u.value : 0), 0) + + this._balanceExtras.reduce((n, fn) => n + (fn().pending || 0), 0); + } + + _balanceExtra() { + let confirmed = 0, pending = 0; + for (const fn of this._balanceExtras) { const b = fn(); confirmed += b.confirmed || 0; pending += b.pending || 0; } + return { confirmed, pending }; } _recomputeBalanceFromChains() { @@ -1164,15 +855,15 @@ export class Wallet { c += a.confirmed || 0; p += a.pending || 0; } - const sp = this.spBalance(); - this.confirmed = c + sp.confirmed; - this.pending = p + sp.pending; + const ex = this._balanceExtra(); + this.confirmed = c + ex.confirmed; + this.pending = p + ex.pending; } _recomputeBalanceFromUtxos() { - const sp = this.spBalance(); - this.confirmed = this.utxos.reduce((s, u) => s + (u.confirmed ? u.value : 0), 0) + sp.confirmed; - this.pending = this.utxos.reduce((s, u) => s + (u.confirmed ? 0 : u.value), 0) + sp.pending; + const ex = this._balanceExtra(); + this.confirmed = this.utxos.reduce((s, u) => s + (u.confirmed ? u.value : 0), 0) + ex.confirmed; + this.pending = this.utxos.reduce((s, u) => s + (u.confirmed ? 0 : u.value), 0) + ex.pending; } freshReceive() { @@ -1190,16 +881,10 @@ export class Wallet { const pool_ = coinIds ? this.utxos.filter((u) => coinIds.includes(utxoId(u))) : (() => { const locked = this.lockedCoinIds(); return this.utxos.filter((u) => !locked.has(utxoId(u))); })(); // skip locked coins (gift reservations, ...) - // Received silent-payment outputs are spendable too — one-time taproot keys, - // signed by sign() with the per-output key. Excluded when paying an sp1… - // recipient (funding an SP send from a taproot input needs BIP-352 even-Y - // key handling we don't do yet), so those sends use BIP84 coins. - // 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). - const confirmedSp = this.spUtxos.filter((u) => u.confirmed); - const spPool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp.slice(); - if (!pool_.length && !spPool.length) throw new Error('No spendable coins selected.'); + // Feature coins (e.g. received silent-payment outputs) join the pool as + // ready-made inputs; their signing is handled by registered input signers. + const extraInputs = this._inputProviders.flatMap((fn) => fn({ coinIds })); + if (!pool_.length && !extraInputs.length) throw new Error('No spendable coins selected.'); const inputs = [ ...pool_.map((u) => { @@ -1213,27 +898,17 @@ export class Wallet { witnessUtxo: { script: pay.script, amount: BigInt(u.value) }, }; }), - ...spPool.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 outputs are signed raw in sign(), via tapKeySig). - tapInternalKey: hex.decode(u.xonly), - })), + ...extraInputs, ]; const changeAddress = this.freshChange().address; const feePerByte = BigInt(Math.max(1, Math.round(feeRate))); - // Silent payment (sp1…) recipients: the real taproot output can only be - // derived once coin selection fixes the input set (it commits to every - // input), so we select against a same-size placeholder taproot output and - // swap in the derived script afterward. - const spOuts = []; // { placeholder, scan, spend } - if (recipients.some((r) => isSilentPaymentAddress(r.address)) && this.watchOnly) - throw new Error('Silent payments require a wallet with keys.'); + // Send-address preparers (e.g. silent payments): destinations whose real + // output can only be derived once coin selection fixes the input set select + // against a placeholder and swap the derived script in afterward. + const prepSessions = this._sendPreparers.map((fn) => fn(this)); + const sessionFor = (addr) => prepSessions.find((s) => s.match(addr)); if (sendMax) { if (recipients.length !== 1) @@ -1241,12 +916,8 @@ export class Wallet { // Drain everything: use the recipient as the "change" address with no // fixed outputs, so the estimator sends (total − exact fee) to them. let dest = recipients[0].address; - if (isSilentPaymentAddress(dest)) { - const { scan, spend } = decodeSilentPaymentAddress(dest); - const placeholder = silentPaymentPlaceholder(0); - spOuts.push({ placeholder, scan, spend, address: dest }); - dest = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(placeholder)); - } + const ds = sessionFor(dest); + if (ds) dest = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(ds.prepare(dest))); const sel = btc.selectUTXO(inputs, [], 'all', { changeAddress: dest, feePerByte, @@ -1256,19 +927,15 @@ export class Wallet { disableScriptCheck: true, }); if (!sel || !sel.tx) throw new Error('Fee exceeds balance.'); - if (spOuts.length) this._applySilentPayments(sel.tx, spOuts); + for (const s of prepSessions) s.apply(sel.tx); const sweepSummary = summarize(sel, this.netCfg.net); - this._tagSilentOutputs(sweepSummary, spOuts); + for (const s of prepSessions) s.tag(sweepSummary); return sweepSummary; } const outputs = recipients.map((r) => { - if (isSilentPaymentAddress(r.address)) { - const { scan, spend } = decodeSilentPaymentAddress(r.address); - const placeholder = silentPaymentPlaceholder(spOuts.length); - spOuts.push({ placeholder, scan, spend, address: r.address }); - return { script: placeholder, amount: BigInt(r.amount) }; - } + const s = sessionFor(r.address); + if (s) return { script: s.prepare(r.address), amount: BigInt(r.amount) }; return { address: r.address, amount: BigInt(r.amount) }; }); const strategy = coinIds ? 'all' : 'default'; @@ -1285,53 +952,13 @@ export class Wallet { }); if (!sel || !sel.tx) throw new Error('Insufficient funds for amount + fee.'); - if (spOuts.length) this._applySilentPayments(sel.tx, spOuts); + for (const s of prepSessions) s.apply(sel.tx); const summary = summarize(sel, this.netCfg.net); - this._tagSilentOutputs(summary, spOuts); + for (const s of prepSessions) s.tag(summary); return summary; } - // 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])); - }); - } // True if a built tx spends any of our still-unconfirmed coins — meaning it // can't confirm until that parent (ancestor) transaction confirms first. @@ -1353,9 +980,7 @@ export class Wallet { if (this.watchOnly) throw new Error('Watch-only wallet — no keys to sign with.'); const bip84 = new Map(); for (const u of this.utxos) bip84.set(utxoId(u), u); - const sp = new Map(); - for (const u of this.spUtxos) sp.set(utxoId(u), u); - // Prevout scripts + amounts for any taproot (SP) sighash. + // Prevout scripts + amounts, for any signer that needs a taproot sighash. const prevScripts = []; const amounts = []; for (let i = 0; i < tx.inputsLength; i++) { @@ -1363,18 +988,15 @@ export class Wallet { prevScripts.push(w.script); amounts.push(w.amount); } - const spKeys = sp.size ? this.silentPaymentKeys() : null; + const signCtx = { prevScripts, amounts }; for (let i = 0; i < tx.inputsLength; i++) { const inp = tx.getInput(i); const id = `${hex.encode(inp.txid)}:${inp.index}`; if (bip84.has(id)) { const u = bip84.get(id); tx.signIdx(this.node(u.chain, u.index).privateKey, i); - } else if (sp.has(id)) { - const u = sp.get(id); - const d = silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(u.tweak)); - const hash = tx.preimageWitnessV1(i, prevScripts, btc.SigHash.DEFAULT, amounts); - tx.updateInput(i, { tapKeySig: schnorr.sign(hash, d) }, true); + } else if (this._inputSigners.some((fn) => fn(tx, i, id, signCtx))) { + // signed by a feature (e.g. a received silent-payment output) } else { throw new Error(`Cannot find key for input ${id}`); } @@ -1508,6 +1130,29 @@ export class Wallet { // Coin locks: features contribute sets of coin ids that spending and the // balance treat as unavailable. registerCoinLock(fn) { this._coinLocks.push(fn); } + // Extra balance buckets: fn() -> { confirmed, pending } folded into the + // spendable / pending headline figures. + registerBalanceExtra(fn) { this._balanceExtras.push(fn); } + // Extra spendable inputs for buildTx: fn({ coinIds }) -> scure input objects. + registerInputProvider(fn) { this._inputProviders.push(fn); } + // Send-address preparers: fn(wallet) -> per-build session with + // match(addr) / prepare(addr) -> placeholderScript / apply(tx) / tag(summary), + // for destinations whose real output can only be derived after coin selection. + registerSendPreparer(fn) { this._sendPreparers.push(fn); } + // Input signers: fn(tx, i, id, { prevScripts, amounts }) -> true if signed. + registerInputSigner(fn) { this._inputSigners.push(fn); } + // Extra history rows (merged + sorted with on-chain txs). + registerHistoryProvider(fn) { this._historyProviders.push(fn); } + // Called at the end of every scan/refresh pass. + registerScanHook(fn) { this._scanHooks.push(fn); } + // start()/stop() with the realtime backend; poll() on its heartbeat. + registerRealtimeHook(h) { this._realtimeHooks.push(h); } + // Called from load() with its options (wallet switch / (re)open). + registerLoadHook(fn) { this._loadHooks.push(fn); } + // save() -> object merged into the cache snapshot; load(d) restores. + registerCacheExtension(e) { this._cacheExtensions.push(e); } + // Called with every saved state snapshot (e.g. to push it to sync relays). + registerCacheSavedHook(fn) { this._cacheSavedHooks.push(fn); } lockedCoinIds() { const all = new Set(); for (const fn of this._coinLocks) { try { for (const id of fn()) all.add(id); } catch {} } @@ -1549,13 +1194,6 @@ export class Wallet { - // Our nostr identity (used to DM a locked gift's claim code to the recipient). - nostrPubkey() { return (this.nostr && this.nostr.pk) || null; } - nostrNpub() { const pk = this.nostrPubkey(); return pk ? npubOf(pk) : null; } - // Send a nostr DM (e.g. a locked gift's claim code) to a recipient pubkey. - async sendNostrDM(recipientPkHex, text) { - return this.nostr && this.nostr.sk ? this.nostr.sendDM(recipientPkHex, text) : false; - } // --- realtime (mempool.space WebSocket) --------------------------------- @@ -1798,11 +1436,9 @@ export class Wallet { if (Date.now() - (this._lastPoll || 0) < due) return; this._lastPoll = Date.now(); this.refreshLive(); - // SP receipts arrive via the indexer WebSocket (_spConnect); only poll for - // them as a fallback when that socket isn't live. - if (!this.spLive) this.scanSilentPayments({ silent: true }).catch(() => {}); + for (const h of this._realtimeHooks) { try { h.poll && h.poll(); } catch {} } }, 1000); - this._spConnect(); // live push of silent-payment receipts + for (const h of this._realtimeHooks) { try { h.start && h.start(); } catch {} } // No periodic full re-scan: the WS (and the frontier poll above) keep the // balance current, and refreshLive's _reconcileHeld catches spends of held // coins. The only thing neither covers is a deposit to a reused old address, @@ -2065,7 +1701,7 @@ export class Wallet { stopRealtime() { this._wsWant = false; this.live = false; - this._spDisconnect(); + for (const h of this._realtimeHooks) { try { h.stop && h.stop(); } catch {} } this._rejectPending('realtime stopped'); // fail any in-flight Electrum data calls this._callQueue = []; clearTimeout(this._wsRetry); @@ -2073,7 +1709,6 @@ export class Wallet { clearInterval(this._pollTimer); clearInterval(this._deepTimer); clearInterval(this._hbTimer); - clearTimeout(this._nostrPubTimer); if (this._ws) { try { this._ws.onclose = null; @@ -2125,8 +1760,7 @@ export class Wallet { nextReceiveIndex: this.nextReceiveIndex, nextChangeIndex: this.nextChangeIndex, feeRates: this.feeRates, - spUtxos: this.spUtxos, - lastSpScan: this.lastSpScan, + ...Object.assign({}, ...this._cacheExtensions.map((e) => { try { return e.save(); } catch { return {}; } })), }; } @@ -2139,8 +1773,7 @@ export class Wallet { this.nextReceiveIndex = d.nextReceiveIndex || 0; this.nextChangeIndex = d.nextChangeIndex || 0; this.feeRates = d.feeRates || this.feeRates; - this.spUtxos = d.spUtxos || []; - this.lastSpScan = d.lastSpScan || 0; + for (const e of this._cacheExtensions) { try { e.load(d); } catch {} } this.addrMap = new Map(); for (const a of [...this.receive, ...this.change]) { this.addrMap.set(a.address, { chain: a.chain, index: a.index }); @@ -2167,14 +1800,7 @@ export class Wallet { const xk = this._xpubCacheKey(); if (xk !== this._cacheKey()) localStorage.setItem(xk, JSON.stringify(snap)); // watch-only mirror } catch {} - // Push to the configured relays too (debounced), so other devices get the - // update — unless cross-device sync is turned off. - const sync = getSyncConfig(); - if (!this.offline && sync.enabled) { - this.nostr.setRelays(sync.relays); - clearTimeout(this._nostrPubTimer); - this._nostrPubTimer = setTimeout(() => this.nostr.publish(snap), 2500); - } + for (const fn of this._cacheSavedHooks) { try { fn(snap); } catch {} } } restoreCache() { @@ -2192,29 +1818,6 @@ export class Wallet { } } - // Pull the latest state from Nostr; apply it if it's newer than what we have. - // Returns true if state was applied (so the caller can skip a full scan). - async syncFromNostr() { - const sync = getSyncConfig(); - if (this.offline || !sync.enabled) return false; - this.nostr.setRelays(sync.relays); - let remote; - try { - remote = await this.nostr.fetch(); - } catch { - return false; - } - if (!remote) return false; - if ((remote.savedAt || 0) > (this._savedAt || 0)) { - this._applySnapshot(remote); - this.saveCache(); // mirror into localStorage - this.emit(); - return true; - } - // Our local copy is newer (or equal) — push it up so the relay catches up. - if ((this._savedAt || 0) > (remote.savedAt || 0)) this.saveCache(); - return true; // remote existed, so no full scan needed - } // --- offline snapshot --------------------------------------------------- // Exported on the ONLINE device. Contains no secrets — just what an offline diff --git a/tools/minimal-smoke.js b/tools/minimal-smoke.js index ab31619..1e80d67 100644 --- a/tools/minimal-smoke.js +++ b/tools/minimal-smoke.js @@ -28,6 +28,8 @@ check('meaningfully smaller than full', html.length < full.length * 0.9); check('no ark protocol code', !html.includes('Ark VTXO mailbox authorization')); check('no swap protocol code', !html.includes('swap/reverse')); check('no musig code', !html.includes('MuSig/noncecoef')); +check('no silent-payment engine', !html.includes('BIP0352') && !html.includes('__SP_WORKER__')); +check('no nostr sync code', !html.includes('30078') && !html.includes('relay.coinos.io')); const server = Bun.serve({ port: 5198, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); @@ -79,6 +81,7 @@ try { check('no swap provider card', !settings.includes('Swap provider')); check('no ark card', !/⚔ Ark/.test(settings)); check('no sp indexer card', !settings.includes('Silent payment indexer')); + check('no sync card', !settings.toLowerCase().includes('sync')); console.log('\n[3] core receive still works'); await clickText('.tabs button', 'Receive'); @@ -87,7 +90,9 @@ try { check('onchain address shown', /^bcrt1/.test(addr || ''), (addr || '').slice(0, 16) + '…'); sh(`${BCLI} sendtoaddress ${addr} 0.0004`); sh(`${BCLI} generatetoaddress 1 $(${BCLI} getnewaddress) >/dev/null`); - check('payment lands', await waitText('40,000', 30000)); + const landed = await waitText('40,000', 30000); + if (!landed) console.log(' [balance area]', (await bodyText()).slice(0, 200).replace(/\n+/g, ' | ')); + check('payment lands', landed); check('no page errors', errs.length === 0, errs.join(' | ')); console.log(ok ? '\n✅ SUCCESS: minimal profile is a working on-chain-only wallet' diff --git a/tools/sp-test.js b/tools/sp-test.js new file mode 100644 index 0000000..d602d0f --- /dev/null +++ b/tools/sp-test.js @@ -0,0 +1,143 @@ +// Silent-payments e2e on regtest, through the real UI (two isolated browser +// contexts = two devices): +// +// wallet B shows its sp1 address -> wallet A (funded on-chain) pays it -> +// B's indexer scan finds the payment -> B SPENDS the SP coin (exercises the +// BIP-352 per-output key signing path) -> miner address receives it. +// +// Needs the ark-regtest stack + the SP indexer: +// COOKIE=$(cat ~/ark-regtest/bitcoind/regtest/.cookie); \ +// CORE_RPC=http://127.0.0.1:18543 RPC_USER=${COOKIE%%:*} RPC_PASS=${COOKIE#*:} \ +// SP_PORT=8899 START_HEIGHT= bun tools/sp-indexer.js +// +// Usage: bun tools/sp-test.js + +import { spawn, execSync } from 'node:child_process'; +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +const PORT = 5199; +const APP = `http://localhost:${PORT}`; +const BCLI = `bitcoin-cli -regtest -datadir=${process.env.HOME}/ark-regtest/bitcoind -rpcport=18543 -rpcwallet=miner`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); +const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`); + +let ok = true; +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!cond) ok = false; +}; + +const dev = spawn('bun', ['run', 'dev'], { cwd: process.env.HOME + '/halwallet', env: { ...process.env, PORT: String(PORT) }, stdio: 'ignore' }); +for (let i = 0; i < 30; i++) { try { if ((await fetch(APP)).ok) break; } catch {} await sleep(500); } + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); + +async function newWalletPage() { + const ctx = await browser.createBrowserContext(); // isolated storage = its own device + const page = await ctx.newPage(); + page.on('pageerror', (e) => console.log(' [pageerror]', e.message)); + await page.goto(APP, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' })); + localStorage.setItem('btc-wallet-sp-indexer:regtest', JSON.stringify({ server: 'custom', url: 'http://localhost:8899' })); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + const clickText = (sel, text) => page.evaluate((s, x) => { + const el = [...document.querySelectorAll(s)].find((e) => e.textContent.trim().toLowerCase().includes(x.toLowerCase())); + if (el) { el.click(); return true; } + return false; + }, sel, text); + const bodyText = () => page.evaluate(() => document.body.innerText); + const waitText = async (text, ms = 20000) => { + for (let i = 0; i < ms / 300; i++) { + if ((await bodyText()).toLowerCase().includes(text.toLowerCase())) return true; + await sleep(300); + } + return false; + }; + await clickText('button', 'Import existing'); + await sleep(200); + await page.type('textarea', generateMnemonic(wordlist)); + await clickText('button', 'Open wallet'); + await waitText('receive'); + return { page, clickText, bodyText, waitText }; +} + +try { + console.log('\n[1] two wallets'); + const A = await newWalletPage(); + const B = await newWalletPage(); + + // B: silent-payment address + await B.page.select('select', 'sp'); + await sleep(400); + const spAddr = await B.page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim()); + check('B shows sp1 address', /^t?sp1[a-z0-9]+$/.test(spAddr || ''), (spAddr || '').slice(0, 20) + '…'); + + // A: fund on-chain + const aAddr = await A.page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim()); + sh(`${BCLI} sendtoaddress ${aAddr} 0.001`); + mine(1); + check('A funded', await A.waitText('100,000', 30000)); + if (await A.page.evaluate(() => document.body.innerText.includes('Payment received'))) { + await A.clickText('.card', 'Payment received'); + await sleep(300); + } + + console.log('\n[2] A pays B\'s silent-payment address'); + await A.clickText('.tabs button', 'Send'); + await sleep(300); + await A.page.type('.mono-input', spAddr); + await sleep(500); // destReady render + await A.page.type('input[type="number"]', '30000'); + await A.clickText('button.btn-primary', 'Review transaction'); + check('review shows SP note', await A.waitText('Silent payment', 8000)); + await A.clickText('button.btn-primary', 'Sign & broadcast'); + const bok = await A.waitText('Transaction ID', 15000); + if (!bok) console.log(' [A after send]', (await A.bodyText()).slice(0, 320).replace(/\n+/g, ' | ')); + check('broadcast ok', bok); + mine(1); + + console.log('\n[3] B discovers the payment'); + const found = await (async () => { + for (let i = 0; i < 25; i++) { + if ((await B.bodyText()).includes('30,000')) return true; + await sleep(2000); + } + return false; + })(); + check('B sees 30,000 sats via SP scan', found, (await B.bodyText()).match(/([\d,]+)\s*\n?\s*sats/)?.[1]); + + console.log('\n[4] B spends the SP coin (BIP-352 per-output key signing)'); + if (await B.page.evaluate(() => document.body.innerText.includes('Payment received'))) { + await B.clickText('.card', 'Payment received'); + await sleep(300); + } + mine(1); // confirm the SP receive; unconfirmed SP coins aren't spendable + await B.waitText('30,000', 15000); + await sleep(2000); + const minerAddr = sh(`${BCLI} getnewaddress`); + await B.clickText('.tabs button', 'Send'); + await sleep(300); + await B.page.type('.mono-input', minerAddr); + await sleep(500); + await B.page.type('input[type="number"]', '20000'); + await B.clickText('button.btn-primary', 'Review transaction'); + await sleep(800); + await B.clickText('button.btn-primary', 'Sign & broadcast'); + const spent = await B.waitText('Transaction ID', 15000); + check('SP coin spend broadcast', spent, spent ? '' : (await B.bodyText()).slice(0, 200)); + mine(1); + + console.log(ok ? '\n✅ SUCCESS: SP send, indexer discovery, and SP-input spend all work' + : '\n❌ some checks failed'); +} finally { + await browser.close(); + dev.kill(); +} +process.exit(ok ? 0 : 1); -- cgit v1.2.3