diff options
| author | Adam Soltys <[email protected]> | 2026-06-27 15:01:06 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-27 15:01:06 -0700 |
| commit | 68edd804f1696aa412da2357a469c013f0a3e92d (patch) | |
| tree | 133289228e51d140b174f6e2f095d03b23d8a917 | |
| parent | eeceebb09878a544b18926a3cd164e581b0c9869 (diff) | |
| download | coinosv3-68edd804f1696aa412da2357a469c013f0a3e92d.tar.gz coinosv3-68edd804f1696aa412da2357a469c013f0a3e92d.zip | |
Wire silent-payment receiving into the wallet (scan + balance + UI)
wallet.js: scanSilentPayments() pulls the indexer's /scan range, matches outputs
on-device, and verifies each is unspent via the chain backend; spUtxos + balance
are tracked in their own bucket (one-time taproot keys), persisted in the cache.
silentPaymentsAvailable()/spBalance() expose state.
app.js: the Receive tab shows the reusable sp address (QR + copy), a 'Scan for
silent payments' button, and the found amount; Settings gains a per-network SP
indexer picker (under Data source) with custom/self-host support.
sp-indexer.js: added /scan/{from} so a client fetches a whole range in one
request instead of one fetch per block.
Receiving verified end-to-end on regtest (scan finds the output, 49,999,800 sat
confirmed). Spending SP outputs is the remaining piece.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| -rw-r--r-- | src/app.js | 72 | ||||
| -rw-r--r-- | src/i18n.js | 9 | ||||
| -rw-r--r-- | src/wallet.js | 96 | ||||
| -rw-r--r-- | tools/sp-indexer.js | 12 |
4 files changed, 183 insertions, 6 deletions
@@ -8,7 +8,7 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; -import { getExplorerConfig, setExplorerConfig, explorerPresets, getDataMode, setDataMode, electrumPresets, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, NETWORKS } from './api.js'; +import { getExplorerConfig, setExplorerConfig, explorerPresets, getDataMode, setDataMode, electrumPresets, getElectrumServerConfig, setElectrumServerConfig, getNetwork, setNetwork, NETWORKS, getSpIndexerConfig, setSpIndexerConfig, spIndexerPresets } from './api.js'; import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { @@ -1307,6 +1307,7 @@ function settingsTab() { ), networkCard(), explorerCard(), + spIndexerCard(), wallet.watchOnly || !wallet.mnemonic ? null : syncCard() ); } @@ -2224,12 +2225,73 @@ function receiveTab() { } const fresh = wallet.freshReceive(); + const spAddr = wallet.silentPaymentsAvailable() ? wallet.silentPaymentAddress() : null; return h( 'div', - { class: 'card col', style: 'align-items:center;gap:14px' }, - h('div', { html: qrSvg(fresh.address) }), - h('div', { class: 'addr-box', style: 'width:100%' }, fresh.address), - copyBtn(fresh.address, t('copyAddress')) + { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, + h('div', { html: qrSvg(fresh.address) }), + h('div', { class: 'addr-box', style: 'width:100%' }, fresh.address), + copyBtn(fresh.address, t('copyAddress')) + ), + spAddr ? silentPaymentReceiveCard(spAddr) : null + ); +} + +// Reusable silent-payment receive address (sp1…/tsp1…) + on-device scan. The +// address is static and reusable; senders derive a unique on-chain output each +// time, so there's no address reuse and nothing links the payments. +function silentPaymentReceiveCard(spAddr) { + const bal = wallet.spBalance(); + return h( + 'div', + { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'row between' }, + h('h3', { style: 'margin:0' }, t('spReceiveTitle')), + h('span', { class: 'small muted' }, 'BIP-352') + ), + h('p', { class: 'small muted', style: 'margin:0' }, t('spReceiveDesc')), + h('div', { style: 'align-self:center' }, h('div', { html: qrSvg(spAddr) })), + h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, spAddr), + copyBtn(spAddr, t('copyAddress')), + bal.count + ? h('div', { class: 'small', style: 'text-align:center' }, + t('spReceived', { amount: fmtAmount(bal.confirmed + bal.pending) + ' ' + unitLabel(), n: bal.count })) + : null, + h('button', { class: 'btn-block', disabled: wallet.spScanning, onClick: doSpScan }, + wallet.spScanning ? t('spScanning') : t('spScan')) + ); +} + +function doSpScan() { + if (wallet.spScanning || wallet.offline) return; + wallet.scanSilentPayments().catch(() => {}).finally(render); + render(); +} + +// Silent-payment tweak indexer picker (per network) — the endpoint used to scan +// for received silent payments. Only shown for wallets that can derive SP keys. +function spIndexerCard() { + if (!wallet.silentPaymentKeys || !wallet.silentPaymentKeys()) return null; + const net = getNetwork(); + const cfg = getSpIndexerConfig(net); + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('spIndexerTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('spIndexerDesc')), + h('select', { onChange: (e) => { setSpIndexerConfig({ server: e.target.value, url: cfg.url }, net); render(); } }, + spIndexerPresets(net).map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label))), + cfg.server === 'custom' + ? h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('spIndexerUrl')), + h('input', { + type: 'text', class: 'mono-input', placeholder: 'https://your-indexer:8888', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: cfg.url, + onChange: (e) => { setSpIndexerConfig({ server: 'custom', url: e.target.value.trim() }, net); render(); }, + }), + h('div', { class: 'small faint' }, t('spIndexerHint'))) + : null ); } diff --git a/src/i18n.js b/src/i18n.js index 2fe840d..5f029a3 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -301,6 +301,15 @@ const STR = { electrumUrl: 'Electrum server (wss://)', electrumUrlHint: 'A WebSocket Electrum server, e.g. wss://your-node:50004', silentPaymentNote: 'Silent payment', + spReceiveTitle: 'Silent payment address', + spReceiveDesc: 'A reusable address that leaks nothing on-chain — each sender derives a unique, unlinkable output. Scanning finds payments on-device using the indexer set under Data source.', + spScan: 'Scan for silent payments', + spScanning: 'Scanning…', + spReceived: 'Found {n} silent payment output(s): {amount}', + spIndexerTitle: 'Silent payment indexer', + spIndexerDesc: 'Where to scan for received silent payments. It only serves public tweak data — your scan key stays on-device — but a public one can see which blocks hold your outputs, so self-host for full privacy.', + spIndexerUrl: 'Indexer URL', + spIndexerHint: 'A BIP-352 tweak indexer (e.g. your own).', deviceSync: 'Device sync', deviceSyncDesc: 'Keep this wallet in sync across your devices by storing an encrypted copy on a Nostr relay — only a device with your seed can read it. Turn it off to keep everything on this device.', syncAcross: 'Sync across devices', diff --git a/src/wallet.js b/src/wallet.js index 219b0f5..092a005 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -21,7 +21,7 @@ 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 } from './api.js'; +import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; import { NostrSync, getSyncConfig } from './nostr.js'; import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan } from './silentpay.js'; @@ -102,6 +102,12 @@ 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 @@ -178,6 +184,8 @@ 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; @@ -277,6 +285,88 @@ export class Wallet { 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 }; + } + + // 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 } = {}) { + const keys = this.silentPaymentKeys(); + const indexer = spIndexerUrl(this.netName); + if (!keys || !indexer || this.offline) return { unavailable: true }; + if (this.spScanning) return { busy: true }; + this.spScanning = true; + this.emit(); + try { + if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } + const from = (this.lastSpScan || 0) + 1; + const res = await (await fetch(`${indexer}/scan/${from}`)).json(); + const tip = res.tip || 0; + const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); + const found = []; + for (const it of res.items || []) { + const matches = silentPaymentScan({ + scanPriv: keys.scanPriv, spendPub: keys.spendPub, + tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly), + }); + for (const m of matches) { + const o = it.outputs.find((o) => o.xonly === m.output); + const id = `${it.txid}:${o.vout}`; + if (have.has(id)) continue; + have.add(id); + found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); + } + } + 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) }); + } catch {} + } + await this._refreshSpUtxos(); + this.saveCache(); + this.emit(); + return { found: found.length, scanned: tip }; + } finally { + this.spScanning = false; + this.emit(); + } + } + + // 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; + } + node(chain, index) { // Relative derivation from the account node (chain/index are non-hardened). return this.account().deriveChild(chain).deriveChild(index); @@ -1818,6 +1908,8 @@ export class Wallet { nextReceiveIndex: this.nextReceiveIndex, nextChangeIndex: this.nextChangeIndex, feeRates: this.feeRates, + spUtxos: this.spUtxos, + lastSpScan: this.lastSpScan, }; } @@ -1830,6 +1922,8 @@ 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; this.addrMap = new Map(); for (const a of [...this.receive, ...this.change]) { this.addrMap.set(a.address, { chain: a.chain, index: a.index }); diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js index 12f76f7..d573ab9 100644 --- a/tools/sp-indexer.js +++ b/tools/sp-indexer.js @@ -109,6 +109,18 @@ Bun.serve({ const b = blocks.get(Number(m[1])); return json(b ? b.items.map((i) => i.tweak) : []); } + // All SP-eligible tx items from height `from` to the tip, in one response — + // each tagged with its height. Lets a client scan a range without one fetch + // per block. (Regtest-scale; mainnet would page or use BIP-158 filters.) + if ((m = p.match(/^\/scan\/(\d+)$/))) { + const from = Number(m[1]); + const items = []; + for (let h = from; h <= indexed; h++) { + const b = blocks.get(h); + if (b) for (const it of b.items) items.push({ height: h, ...it }); + } + return json({ tip: indexed, items }); + } return json({ error: 'not found' }, 404); }, }); |
