diff options
| author | Adam Soltys <[email protected]> | 2026-06-25 20:41:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-25 20:41:20 -0700 |
| commit | aa044138337c947b26eea910db57f2d5b743b5b8 (patch) | |
| tree | 0a0497e3cf6fc8198041d53913cc326ce86112c6 | |
| parent | 1edb9ac5723f2d5fda5093437b2e375f98af3777 (diff) | |
| download | coinosv3-aa044138337c947b26eea910db57f2d5b743b5b8.tar.gz coinosv3-aa044138337c947b26eea910db57f2d5b743b5b8.zip | |
Add Silent Payments (BIP-352) sending
Pay sp1… addresses: the wallet derives the one-time taproot output from the
recipient's address + the transaction's own input keys (ECDH), so payments are
unlinkable on-chain with no interaction. New src/silentpay.js implements the
sender derivation + bech32m address decode, validated against the official
BIP-352 test vectors (all P2WPKH-input / single-recipient cases pass; the rest
exercise input types Hal never spends).
buildTx detects sp1… recipients, selects coins against a same-size placeholder
taproot output, then swaps in the script derived from the final input set
(updateOutput). Works for normal sends, multi-recipient, and send-max. The
review screen shows the sp1… address with a 'Silent payment' label (the on-chain
output is a derived one-time taproot address). End-to-end signing verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| -rw-r--r-- | src/app.js | 12 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/silentpay.js | 111 | ||||
| -rw-r--r-- | src/wallet.js | 73 |
4 files changed, 188 insertions, 9 deletions
@@ -9,6 +9,7 @@ import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS, getRealtimeEnabled, setRealtimeEnabled } from './api.js'; +import { isSilentPaymentAddress } from './silentpay.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { fmtBtc, @@ -2155,7 +2156,9 @@ function recipientRow(s, r, i) { const check = h('div', { class: 'addr-check' }); const syncCheck = () => { const a = r.address.trim(); - check.replaceChildren(...addrVerifyNodes(a)); + 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'; }; @@ -2382,7 +2385,12 @@ function reviewView() { { class: 'summary col', style: 'gap:0' }, ...outs.map((o) => h('div', { class: 'line' }, - h('span', { class: 'k mono break' }, shortAddr(o.address, 14, 8)), + o.silent + ? h('span', { class: 'k col', style: 'gap:2px;align-items:flex-start' }, + h('span', { class: 'mono break' }, shortAddr(o.silent, 12, 8)), + h('span', { class: 'small faint' }, t('silentPaymentNote')) + ) + : h('span', { class: 'k mono break' }, shortAddr(o.address, 14, 8)), h('span', { class: 'v' }, fmtAmount(o.amount), ' ', unitTag()) ) ), diff --git a/src/i18n.js b/src/i18n.js index 0004b83..8ed57f3 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -259,6 +259,7 @@ const STR = { explorerDesc: 'The server this wallet queries for balances and history. Point it at your own node for maximum privacy.', explorerUrl: 'Server URL', explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api', + silentPaymentNote: 'Silent payment', instantNotif: 'Instant notifications', instantNotifDesc: 'Uses coinos to push payments the instant they arrive. Turn off to poll the explorer above instead — a bit slower, but your addresses are never sent to coinos.', deviceSync: 'Device sync', diff --git a/src/silentpay.js b/src/silentpay.js new file mode 100644 index 0000000..0700999 --- /dev/null +++ b/src/silentpay.js @@ -0,0 +1,111 @@ +// Silent Payments (BIP-352) — sending side only. +// +// A silent payment address (sp1…) is a static, reusable address the receiver +// publishes once. The sender derives a unique one-time taproot output for each +// payment from the receiver's address plus the sender's own input keys (ECDH), +// so there's no on-chain link between payments and no address reuse — without +// any interaction with the receiver. +// +// This module only does the SENDER half (deriving the output). Receiving needs +// chain scanning (a per-tx ECDH tweak), which a light client can't do without a +// dedicated index server — out of scope here. +// +// Validated against the official BIP-352 send test vectors. We only spend +// compressed P2WPKH inputs, which keeps the eligible-input rules trivial: each +// input contributes its private key as-is (no taproot even-Y negation, no +// uncompressed-key or P2SH edge cases). + +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils'; +import { bech32m } from '@scure/base'; + +const Point = secp256k1.ProjectivePoint; +const G = Point.BASE; +const N = secp256k1.CURVE.n; + +const modN = (x) => ((x % N) + N) % N; +const toInt = (bytes) => BigInt('0x' + bytesToHex(bytes)); +// tagged hash: sha256(sha256(tag) || sha256(tag) || msg) +const tagged = (tag, msg) => { + const t = sha256(new TextEncoder().encode(tag)); + return sha256(concatBytes(t, t, msg)); +}; +const le32 = (n) => { + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, n >>> 0, true); + return b; +}; +const be32 = (n) => { + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, n >>> 0, false); + return b; +}; +// A taproot scriptPubKey for a 32-byte x-only key: OP_1 (0x51) PUSH32 (0x20) key. +const taprootScript = (xonly) => concatBytes(Uint8Array.of(0x51, 0x20), xonly); + +export function isSilentPaymentAddress(addr) { + if (typeof addr !== 'string') return false; + const a = addr.toLowerCase(); + if (!a.startsWith('sp1') && !a.startsWith('tsp1')) return false; + try { + decodeSilentPaymentAddress(addr); + return true; + } catch { + return false; + } +} + +// Decode sp1…/tsp1… → { scan, spend } (33-byte compressed pubkeys each) + net. +export function decodeSilentPaymentAddress(addr) { + const { prefix, words } = bech32m.decode(addr.toLowerCase(), 1023); + if (prefix !== 'sp' && prefix !== 'tsp') throw new Error('Not a silent payment address.'); + if (words[0] !== 0) throw new Error('Unsupported silent payment version.'); + const data = bech32m.fromWords(words.slice(1)); + if (data.length !== 66) throw new Error('Malformed silent payment address.'); + return { scan: data.slice(0, 33), spend: data.slice(33, 66), testnet: prefix === 'tsp' }; +} + +// Derive the one-time taproot output scripts for a set of silent-payment outputs +// that this transaction's inputs are funding. +// inputs: [{ txid (display hex), vout, priv (Uint8Array 32) }] — ALL eligible +// (P2WPKH) inputs of the tx; the derivation commits to every one. +// outputs: [{ scan, spend }] — one entry per desired output (repeat the same +// recipient to pay it multiple times; the k counter is handled here). +// Returns a taproot scriptPubKey (Uint8Array) for each entry, in order. +export function silentPaymentScripts(inputs, outputs) { + if (!inputs.length) throw new Error('Silent payment needs at least one input.'); + + // a = Σ input private keys (mod n); A = a·G + let a = 0n; + for (const i of inputs) a = modN(a + toInt(i.priv)); + if (a === 0n) throw new Error('Silent payment input key sum is zero.'); + const A = G.multiply(a); + + // input_hash = H(smallest_outpoint || A); outpoint = txid(wire order) || vout(LE) + let smallest = null; + for (const i of inputs) { + const op = concatBytes(hexToBytes(i.txid).reverse(), le32(i.vout)); + if (!smallest || bytesToHex(op) < bytesToHex(smallest)) smallest = op; + } + const inputHash = modN(toInt(tagged('BIP0352/Inputs', concatBytes(smallest, A.toRawBytes(true))))); + + const counters = new Map(); // recipient → next k + return outputs.map(({ scan, spend }) => { + const rkey = bytesToHex(scan) + bytesToHex(spend); + const k = counters.get(rkey) || 0; + counters.set(rkey, k + 1); + // ecdh = input_hash·a·B_scan ; t_k = H(ecdh || ser32(k)) ; P = B_spend + t_k·G + const ecdh = Point.fromHex(scan).multiply(modN(inputHash * a)).toRawBytes(true); + const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k))))); + const Pout = Point.fromHex(spend).add(G.multiply(tk)); + return taprootScript(Pout.toRawBytes(true).slice(1)); // x-only + }); +} + +// A unique, valid placeholder taproot scriptPubKey used to size a silent-payment +// output during coin selection, before the real script can be derived (which +// needs the final input set). Swapped out for the real one afterward. +export function silentPaymentPlaceholder(index) { + return taprootScript(sha256(concatBytes(new TextEncoder().encode('halsp-placeholder'), be32(index)))); +} diff --git a/src/wallet.js b/src/wallet.js index fb9e49e..5941353 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -23,6 +23,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, wsUrl } from './api.js'; import { NostrSync, getSyncConfig } from './nostr.js'; +import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder } from './silentpay.js'; // No look-ahead: stop scanning a chain at the first unused address. This wallet // only ever exposes ONE unused address at a time (freshReceive = first unused; @@ -767,12 +768,26 @@ export class Wallet { const changeAddress = this.freshChange().address; const feePerByte = BigInt(Math.max(1, Math.round(feeRate))); + // Silent payment (sp1…) recipients: the real taproot output can only be + // derived once coin selection fixes the input set (it commits to every + // input), so we select against a same-size placeholder taproot output and + // swap in the derived script afterward. + const spOuts = []; // { placeholder, scan, spend } + if (recipients.some((r) => isSilentPaymentAddress(r.address)) && this.watchOnly) + throw new Error('Silent payments require a wallet with keys.'); + if (sendMax) { if (recipients.length !== 1) throw new Error('Send-max requires exactly one recipient.'); // Drain everything: use the recipient as the "change" address with no // fixed outputs, so the estimator sends (total − exact fee) to them. - const dest = recipients[0].address; + let dest = recipients[0].address; + if (isSilentPaymentAddress(dest)) { + const { scan, spend } = decodeSilentPaymentAddress(dest); + const placeholder = silentPaymentPlaceholder(0); + spOuts.push({ placeholder, scan, spend, address: dest }); + dest = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(placeholder)); + } const sel = btc.selectUTXO(inputs, [], 'all', { changeAddress: dest, feePerByte, @@ -781,13 +796,21 @@ export class Wallet { bip69: true, }); if (!sel || !sel.tx) throw new Error('Fee exceeds balance.'); - return summarize(sel, this.netCfg.net); + if (spOuts.length) this._applySilentPayments(sel.tx, spOuts); + const sweepSummary = summarize(sel, this.netCfg.net); + this._tagSilentOutputs(sweepSummary, spOuts); + return sweepSummary; } - const outputs = recipients.map((r) => ({ - address: r.address, - amount: BigInt(r.amount), - })); + const outputs = recipients.map((r) => { + if (isSilentPaymentAddress(r.address)) { + const { scan, spend } = decodeSilentPaymentAddress(r.address); + const placeholder = silentPaymentPlaceholder(spOuts.length); + spOuts.push({ placeholder, scan, spend, address: r.address }); + return { script: placeholder, amount: BigInt(r.amount) }; + } + return { address: r.address, amount: BigInt(r.amount) }; + }); const strategy = coinIds ? 'all' : 'default'; const sel = btc.selectUTXO(inputs, outputs, strategy, { changeAddress, @@ -798,7 +821,43 @@ export class Wallet { }); if (!sel || !sel.tx) throw new Error('Insufficient funds for amount + fee.'); - return summarize(sel, this.netCfg.net); + if (spOuts.length) this._applySilentPayments(sel.tx, spOuts); + const summary = summarize(sel, this.netCfg.net); + this._tagSilentOutputs(summary, spOuts); + return summary; + } + + // 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); + const inputs = []; + for (let i = 0; i < tx.inputsLength; i++) { + const inp = tx.getInput(i); + const u = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`); + if (!u) throw new Error('Missing key for a silent payment input.'); + inputs.push({ txid: u.txid, vout: u.vout, priv: this.node(u.chain, 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])); + }); } // True if a built tx spends any of our still-unconfirmed coins — meaning it |
