aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-27 15:01:06 -0700
committerAdam Soltys <[email protected]>2026-06-27 15:01:06 -0700
commit68edd804f1696aa412da2357a469c013f0a3e92d (patch)
tree133289228e51d140b174f6e2f095d03b23d8a917
parenteeceebb09878a544b18926a3cd164e581b0c9869 (diff)
downloadcoinosv3-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.js72
-rw-r--r--src/i18n.js9
-rw-r--r--src/wallet.js96
-rw-r--r--tools/sp-indexer.js12
4 files changed, 183 insertions, 6 deletions
diff --git a/src/app.js b/src/app.js
index caddeb5..daf9782 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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);
},
});