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 --- 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 ++++++++++++ 5 files changed, 620 insertions(+), 3 deletions(-) create mode 100644 src/features/sp-wallet.js create mode 100644 src/features/sync.js (limited to 'src/features') 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()]; + }, + }; +} -- cgit v1.2.3