aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-27 17:35:03 -0700
committerAdam Soltys <[email protected]>2026-06-27 17:35:03 -0700
commit700efba34e34a8282a7853cbbdaa0f7de21e218c (patch)
tree5e8b57cfdb6c23453206f865a5051db8fba2fede
parent56a2736be51d2f35d68ca88b477a948efe3398ce (diff)
downloadcoinosv3-700efba34e34a8282a7853cbbdaa0f7de21e218c.tar.gz
coinosv3-700efba34e34a8282a7853cbbdaa0f7de21e218c.zip
Make silent-payment receives first-class in the UI
- One Receive card with an Address ⇄ Silent payment toggle (no separate card, no BIP-352 badge, no scan-status line or Rescan button — discovery is automatic). - SP receipts appear in History like normal payments: wallet.history merges BIP84 txs with per-tx SP receive rows (spTxRows), newest first; the detail view and pagination use it. SP outputs carry a firstSeen timestamp. - Receive description simplified to 'A reusable address that can't be tracked.' - Removed the '· Silent payment' tag from the Send recipient line. Verified on regtest: a received SP shows in history and balance with no manual scan. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--src/app.js75
-rw-r--r--src/i18n.js4
-rw-r--r--src/wallet.js42
3 files changed, 65 insertions, 56 deletions
diff --git a/src/app.js b/src/app.js
index 727e3cc..3a8f5fd 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2224,55 +2224,28 @@ function receiveTab() {
);
}
+ // One card with a toggle between the standard address and the silent-payment
+ // address (shown only when SP is available).
const fresh = wallet.freshReceive();
const spAddr = wallet.silentPaymentsAvailable() ? wallet.silentPaymentAddress() : null;
+ const sp = spAddr && ui.receiveType === 'sp';
+ const addr = sp ? spAddr : fresh.address;
return h(
'div',
- { 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')),
- h('div', { class: 'row between', style: 'align-items:center' },
- h('span', { class: 'small muted' },
- wallet.spScanning
- ? t('spScanning')
- : bal.count
- ? t('spReceived', { amount: fmtAmount(bal.confirmed + bal.pending) + ' ' + unitLabel(), n: bal.count })
- : t('spWatching')),
- h('button', { class: 'linklike small', disabled: wallet.spScanning, onClick: doSpScan }, t('spRescan'))
- )
+ { class: 'card col', style: 'align-items:center;gap:14px' },
+ spAddr
+ ? h('div', { class: 'seg', style: 'display:flex;width:100%' },
+ h('button', { type: 'button', class: (!sp ? 'active ' : '') + 'grow', onClick: () => { ui.receiveType = 'address'; render(); } }, t('receiveAddressTab')),
+ h('button', { type: 'button', class: (sp ? 'active ' : '') + 'grow', onClick: () => { ui.receiveType = 'sp'; render(); } }, t('receiveSpTab'))
+ )
+ : null,
+ h('div', { html: qrSvg(addr) }),
+ h('div', { class: 'addr-box' + (sp ? ' break' : ''), style: 'width:100%' + (sp ? ';font-size:12px' : '') }, addr),
+ copyBtn(addr, t('copyAddress')),
+ sp ? h('p', { class: 'small muted', style: 'margin:0;text-align:center' }, t('spReceiveDesc')) : null
);
}
-// Auto-scanning runs on the poll loop; this is a manual full rescan.
-function doSpScan() {
- if (wallet.spScanning || wallet.offline) return;
- wallet.scanSilentPayments({ rescan: true }).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() {
@@ -2609,7 +2582,6 @@ function recipientRow(s, r, i) {
const syncCheck = () => {
const a = r.address.trim();
const nodes = addrVerifyNodes(a);
- if (a && isSilentPaymentAddress(a)) nodes.push(h('span', { class: 'small faint', style: 'margin-left:6px' }, '· ' + t('silentPaymentNote')));
check.replaceChildren(...nodes);
check.style.display = a ? '' : 'none';
};
@@ -3037,14 +3009,15 @@ function giftsAllView(gifts) {
function historyTab() {
if (ui.bump) return bumpView();
+ const txs = wallet.history; // BIP84 txs + silent-payment receipts, newest first
if (ui.txDetail) {
- const tx = wallet.txs.find((x) => x.txid === ui.txDetail);
+ const tx = txs.find((x) => x.txid === ui.txDetail);
if (tx) return txDetailView(tx);
ui.txDetail = null;
}
if (wallet.offline)
return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('historyOffline')));
- if ((wallet.scanning && !wallet.loaded) || (wallet.historyLoading && !wallet.txs.length))
+ if ((wallet.scanning && !wallet.loaded) || (wallet.historyLoading && !txs.length))
return h(
'div',
{ class: 'card center col', style: 'align-items:center;gap:10px' },
@@ -3055,15 +3028,15 @@ function historyTab() {
// on-chain history; they aren't transactions until claimed or revoked.
const gifts = wallet.loaded ? wallet.outstandingGifts() : [];
if (ui.giftsAll && gifts.length) return giftsAllView(gifts);
- if (!wallet.txs.length && !gifts.length)
+ if (!txs.length && !gifts.length)
return h('div', { class: 'card' }, h('p', { class: 'muted center', style: 'margin:0' }, t('noTxYet')));
// Show at most 3 gifts inline; the rest live behind "View all". Transactions
// paginate 10 at a time.
const giftsHead = gifts.slice(0, 3);
- const txPages = Math.ceil(wallet.txs.length / PAGE_SIZE);
+ const txPages = Math.ceil(txs.length / PAGE_SIZE);
const txPage = Math.min(ui.txPage, Math.max(0, txPages - 1));
- const txSlice = wallet.txs.slice(txPage * PAGE_SIZE, txPage * PAGE_SIZE + PAGE_SIZE);
+ const txSlice = txs.slice(txPage * PAGE_SIZE, txPage * PAGE_SIZE + PAGE_SIZE);
return h(
'div',
{ class: 'card' },
@@ -3074,7 +3047,7 @@ function historyTab() {
gifts.length > 3
? h('button', { class: 'btn-sm btn-block', style: 'margin-top:8px', onClick: () => { ui.giftsAll = true; ui.giftsPage = 0; ui.revokeId = null; render(); } }, t('viewAllGifts', { n: gifts.length }))
: null,
- pager(txPage, wallet.txs.length, (p) => { ui.txPage = p; render(); }),
+ pager(txPage, txs.length, (p) => { ui.txPage = p; render(); }),
wallet.historyLoading
? h(
'div',
@@ -3093,8 +3066,8 @@ function historyTab() {
async function openTx(txid) {
ui.txDetail = txid;
render();
- const tx = wallet.txs.find((t) => t.txid === txid);
- if (!tx || tx.fee || wallet.offline) return;
+ const tx = wallet.history.find((t) => t.txid === txid);
+ if (!tx || tx.sp || tx.fee || wallet.offline) return;
try {
const full = await wallet.api.getTx(txid);
if (!full || ui.txDetail !== txid) return;
diff --git a/src/i18n.js b/src/i18n.js
index f6a94c0..adfa4e8 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -302,7 +302,9 @@ const STR = {
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.',
+ spReceiveDesc: "A reusable address that can't be tracked.",
+ receiveAddressTab: 'Address',
+ receiveSpTab: 'Silent payment',
spScan: 'Scan for silent payments',
spScanning: 'Scanning…',
spWatching: 'Watching for payments',
diff --git a/src/wallet.js b/src/wallet.js
index 7b4c8ae..2ea9dad 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -304,6 +304,38 @@ export class Wallet {
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()];
+ 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);
+ return (b.firstSeen || 0) - (a.firstSeen || 0);
+ });
+ 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) })));
@@ -357,7 +389,7 @@ export class Wallet {
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) });
+ if (u) this.spUtxos.push({ ...f, address, confirmed: !!(u.status && u.status.confirmed), firstSeen: Date.now() });
} catch {}
}
await this._refreshSpUtxos();
@@ -440,7 +472,7 @@ export class Wallet {
const o = it.outputs.find((o) => o.xonly === m.output);
const existing = this.spUtxos.find((u) => u.txid === it.txid && u.vout === o.vout);
if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } }
- else { this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true }); changed = true; }
+ else { this.spUtxos.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: true, firstSeen: Date.now() }); changed = true; }
}
}
if (height && height > this.lastSpScan) this.lastSpScan = height;
@@ -457,13 +489,15 @@ export class Wallet {
if (!keys) return;
const confirmed = this.spUtxos.filter((u) => u.confirmed);
const confirmedIds = new Set(confirmed.map((u) => `${u.txid}:${u.vout}`));
+ const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen]));
const pending = [];
for (const it of 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);
- if (confirmedIds.has(`${it.txid}:${o.vout}`)) continue; // already confirmed
- pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false });
+ const id = `${it.txid}:${o.vout}`;
+ if (confirmedIds.has(id)) continue; // already confirmed
+ pending.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak, address: this._spAddress(m.output), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() });
}
}
const prevPending = this.spUtxos.filter((u) => !u.confirmed).length;