aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js12
-rw-r--r--src/i18n.js1
-rw-r--r--src/silentpay.js111
-rw-r--r--src/wallet.js73
4 files changed, 188 insertions, 9 deletions
diff --git a/src/app.js b/src/app.js
index 21aea3d..d7fbc4e 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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