aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-25 20:41:20 -0700
committerAdam Soltys <[email protected]>2026-06-25 20:41:20 -0700
commitaa044138337c947b26eea910db57f2d5b743b5b8 (patch)
tree0a0497e3cf6fc8198041d53913cc326ce86112c6 /src/wallet.js
parent1edb9ac5723f2d5fda5093437b2e375f98af3777 (diff)
downloadcoinosv3-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]>
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js73
1 files changed, 66 insertions, 7 deletions
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