diff options
| author | Adam Soltys <[email protected]> | 2026-06-27 17:35:03 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-27 17:35:03 -0700 |
| commit | 700efba34e34a8282a7853cbbdaa0f7de21e218c (patch) | |
| tree | 5e8b57cfdb6c23453206f865a5051db8fba2fede /src | |
| parent | 56a2736be51d2f35d68ca88b477a948efe3398ce (diff) | |
| download | coinosv3-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]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 75 | ||||
| -rw-r--r-- | src/i18n.js | 4 | ||||
| -rw-r--r-- | src/wallet.js | 42 |
3 files changed, 65 insertions, 56 deletions
@@ -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; |
