From 3adfe5d89a8f456862dc31c6e905f77368fd29b0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 22 May 2026 01:19:08 -0500 Subject: [PATCH] Send to and from BIP-352 silent payment addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HD wallet can already derive its own sp1q… receive address and detect incoming silent payments via the BlindBit indexer, but the Send dialog only handled bare Bitcoin addresses (or npubs / nprofiles, which routed through nostrPubkeyToBitcoinAddress). Silent-payment funds were stuck: they showed in the balance but the dialog gated them with a "spending isn't supported yet" notice. Now the dialog handles both ends: - Recipient resolution in parseHdRecipient accepts sp1… (mainnet, v0) alongside bc1…, npub1…, and nprofile1…. The send mutation decodes the address, derives the per-transaction P_k locally from the selected inputs' BIP-341-tweaked private keys, and writes it as a regular P2TR output. The on-chain transaction looks like any other Taproot spend; the BIP-352 ECDH happens entirely off-chain. - The coin selector now mixes BIP-86 UTXOs with SP UTXOs from the NIP-78 storage doc. SP inputs are signed by computing d_k = b_spend + t_k and writing tapKeySig directly, bypassing @scure/btc-signer's automatic TapTweak (which would re-tweak the already-on-chain P_k and produce an invalid signature). - The "silent-payment-only balance" warning is gone — those funds are now spendable. The privacy disclaimer still appears for bare bc1… addresses but is suppressed for sp1… recipients, since the whole point of silent payments is that the on-chain output is fresh and unlinkable. src/lib/hdwallet/sp/sender.ts contains the BIP-352 sender math (address bech32m decode, outpoint serialisation, ECDH, P_k derivation) ported from Ditto and adapted to noble-curves v2. src/lib/hdwallet/sp/spend.ts holds the spend-side helpers (b_spend derivation, d_k = b_spend + t_k, manual Schnorr signing for SP inputs). Both are covered by the BIP-352 canonical taproot-only test vectors plus a sender↔receiver round-trip check that the same (b_spend, t_k) the scanner persists really does produce the P_k the spender signs against. --- src/components/HDSendBitcoinDialog.tsx | 163 +++-- src/lib/hdwallet/sp/sender.test.ts | 190 ++++++ src/lib/hdwallet/sp/sender.ts | 475 ++++++++++++++ src/lib/hdwallet/sp/spend.test.ts | 139 ++++ src/lib/hdwallet/sp/spend.ts | 218 +++++++ src/lib/hdwallet/transaction.ts | 600 +++++++++++++++--- src/test/fixtures/bip352_taproot_vectors.json | 60 ++ 7 files changed, 1683 insertions(+), 162 deletions(-) create mode 100644 src/lib/hdwallet/sp/sender.test.ts create mode 100644 src/lib/hdwallet/sp/sender.ts create mode 100644 src/lib/hdwallet/sp/spend.test.ts create mode 100644 src/lib/hdwallet/sp/spend.ts create mode 100644 src/test/fixtures/bip352_taproot_vectors.json diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx index 089776c4..3941e15c 100644 --- a/src/components/HDSendBitcoinDialog.tsx +++ b/src/components/HDSendBitcoinDialog.tsx @@ -35,7 +35,6 @@ import { isLargeAmount, nostrPubkeyToBitcoinAddress, satsToUSD, - validateBitcoinAddress, } from '@/lib/bitcoin'; import { broadcastBlockbookTx, @@ -43,9 +42,12 @@ import { fetchFeeRates, } from '@/lib/hdwallet/blockbook'; import { - buildHdUnsignedPsbt, + buildHdSpendPsbt, finalizeHdPsbt, + type HdInput, + type HdSpendableSpUtxo, type HdSpendableUtxo, + parseHdRecipient, previewHdFee, signHdPsbt, } from '@/lib/hdwallet/transaction'; @@ -93,17 +95,31 @@ function getUniqueFeeSpeeds(rates: BlockbookFeeRates | undefined): FeeSpeed[] { // --------------------------------------------------------------------------- interface ResolvedRecipient { - /** Final P2TR/P2WPKH/etc. address used as the PSBT output. */ + /** + * Final P2TR/P2WPKH/etc. address used as the PSBT output. + * + * For silent-payment (`sp1…`) recipients this is the original `sp1…` + * string — the real on-chain `P_k` is derived at build time, after coin + * selection. The dialog never displays this value directly when + * `kind === 'sp'`; it's kept here so {@link buildHdSpendPsbt} can route + * by recipient kind. + */ address: string; /** Optional Nostr pubkey when the recipient was an npub/nprofile. */ pubkey?: string; /** Raw text the user typed (for re-display). */ raw: string; + /** + * Recipient kind. `'address'` for bare Bitcoin addresses (including + * Nostr-derived ones); `'sp'` for BIP-352 silent-payment addresses. + */ + kind: 'address' | 'sp'; } /** * Parse the recipient input as one of: * - bare Bitcoin address (mainnet, any standard type) + * - silent-payment address (`sp1…`, mainnet, v0) * - npub1… → P2TR derived from the Nostr pubkey * - nprofile1… → P2TR derived from the encoded pubkey * @@ -114,9 +130,13 @@ function resolveRecipient(input: string): ResolvedRecipient | null { const trimmed = input.trim(); if (!trimmed) return null; - // Try bare Bitcoin address first — common case. - if (validateBitcoinAddress(trimmed)) { - return { address: trimmed, raw: trimmed }; + // Try bare Bitcoin / silent-payment via the unified parser. + const parsed = parseHdRecipient(trimmed); + if (parsed) { + if (parsed.kind === 'address') { + return { address: parsed.address, raw: trimmed, kind: 'address' }; + } + return { address: parsed.spAddress, raw: trimmed, kind: 'sp' }; } // Try NIP-19 npub / nprofile. @@ -125,10 +145,10 @@ function resolveRecipient(input: string): ResolvedRecipient | null { const decoded = nip19.decode(trimmed); if (decoded.type === 'npub') { const address = nostrPubkeyToBitcoinAddress(decoded.data); - if (address) return { address, pubkey: decoded.data, raw: trimmed }; + if (address) return { address, pubkey: decoded.data, raw: trimmed, kind: 'address' }; } else if (decoded.type === 'nprofile') { const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey); - if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed }; + if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed, kind: 'address' }; } } catch { // fall through @@ -165,7 +185,12 @@ interface SendResult { */ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoinDialogProps) { const availability = useHdWalletAccess(); - const { scan, silentPaymentBalance, refetch: refetchWallet } = useHdWallet(); + const { + scan, + silentPaymentBalance, + silentPaymentStorage, + refetch: refetchWallet, + } = useHdWallet(); const { config } = useAppContext(); const { blockbookBaseUrl } = config; const { toast } = useToast(); @@ -201,16 +226,34 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin }, [feeRates, feeSpeed]); // ── Owned UTXO set ─────────────────────────────────────────── - const ownedUtxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]); - const totalBalance = useMemo(() => ownedUtxos.reduce((s, u) => s + u.value, 0), [ownedUtxos]); - - // Silent-payment UTXOs are scanned and displayed on /wallet but cannot - // yet be spent by this dialog: the BIP-352 receive output uses a tweaked - // private key not derivable from the BIP-86 (chain, index) pair the PSBT - // signer expects. Detect the case where the wallet's _only_ funds are SP - // funds so we can surface a clear explanation instead of leaving the user - // with a silently-disabled Send button. - const onlyHasSpFunds = totalBalance === 0 && silentPaymentBalance > 0; + // + // Combines BIP-86 UTXOs scanned from Blockbook with silent-payment UTXOs + // discovered by the BIP-352 scanner and persisted via NIP-78. Both can + // fund a send; the PSBT builder dispatches per-input. + const bip86Utxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]); + const spUtxos: HdSpendableSpUtxo[] = useMemo( + () => + (silentPaymentStorage?.utxos ?? []).map((u) => ({ + txid: u.txid, + vout: u.vout, + value: u.value, + tweakHex: u.tweak, + k: u.k, + height: u.height, + })), + [silentPaymentStorage], + ); + const ownedInputs: HdInput[] = useMemo( + () => [ + ...bip86Utxos.map((utxo) => ({ kind: 'bip86', utxo })), + ...spUtxos.map((utxo) => ({ kind: 'sp', utxo })), + ], + [bip86Utxos, spUtxos], + ); + const totalBalance = useMemo( + () => bip86Utxos.reduce((s, u) => s + u.value, 0) + silentPaymentBalance, + [bip86Utxos, silentPaymentBalance], + ); // ── USD → sats ─────────────────────────────────────────────── const amountSats = useMemo(() => { @@ -222,21 +265,21 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin // ── Fee estimate (matches the actual coin selection) ──────── // - // Crucially we do NOT use `ownedUtxos.length` as the input count: an HD + // Crucially we do NOT use `ownedInputs.length` as the input count: an HD // wallet typically has many UTXOs across many addresses, but a real send // only consumes the minimal set the coin selector picks. Using the full // count would over-estimate fees by 10x or more on an active wallet, and // would also make the UI think we're insufficient when we're not. const estimatedFeeSats = useMemo(() => { - if (!ownedUtxos.length || !currentFeeRate || !amountSats) return 0; - return previewHdFee(ownedUtxos, amountSats, currentFeeRate); - }, [ownedUtxos, currentFeeRate, amountSats]); + if (!ownedInputs.length || !currentFeeRate || !amountSats) return 0; + return previewHdFee(ownedInputs, amountSats, currentFeeRate); + }, [ownedInputs, currentFeeRate, amountSats]); const totalSats = amountSats + estimatedFeeSats; // `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`. // Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine. const selectionFailed = - amountSats > 0 && !!currentFeeRate && ownedUtxos.length > 0 && estimatedFeeSats === 0; + amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0; const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance); const showBalance = insufficient || (amountSats > 0 && totalBalance === 0); @@ -244,7 +287,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin // user has manually overridden. useEffect(() => { if (feeSpeedUserChanged.current) return; - if (!ownedUtxos.length || !feeRates || amountSats <= 0) return; + if (!ownedInputs.length || !feeRates || amountSats <= 0) return; const uniqueSpeeds = getUniqueFeeSpeeds(feeRates); const threshold = amountSats * 0.4; @@ -252,11 +295,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1]; for (const speed of uniqueSpeeds) { const rate = getRateForSpeed(feeRates, speed); - const fee = previewHdFee(ownedUtxos, amountSats, rate); + const fee = previewHdFee(ownedInputs, amountSats, rate); if (fee > 0 && fee <= threshold) { target = speed; break; } } setFeeSpeed((prev) => (prev === target ? prev : target)); - }, [amountSats, feeRates, ownedUtxos, totalBalance]); + }, [amountSats, feeRates, ownedInputs, totalBalance]); const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => { feeSpeedUserChanged.current = true; @@ -266,7 +309,12 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin // ── Two-tap arm + raw-address disclaimer ───────────────────── const isLarge = isLargeAmount(totalSats, btcPrice); - const isRawAddress = !!recipient && !recipient.pubkey; + // SP recipients (`sp1…`) produce a fresh, unlinkable Taproot output per + // payment — they do NOT have the privacy concern of a reused on-chain + // address. The public disclaimer is only needed for bare BTC addresses + // typed in directly (no Nostr identity attached, no SP). + const isRawAddress = + !!recipient && recipient.kind === 'address' && !recipient.pubkey; const [confirmArmed, setConfirmArmed] = useState(false); const [acknowledgedPublic, setAcknowledgedPublic] = useState(false); @@ -304,8 +352,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin if (availability.status !== 'available') { throw new Error('HD wallet is not available for this login type.'); } - if (!recipient) throw new Error('Enter a Bitcoin address or npub.'); - if (!ownedUtxos.length) throw new Error('No spendable Bitcoin in this wallet.'); + if (!recipient) throw new Error('Enter a Bitcoin address, sp1… address, or npub.'); + if (!ownedInputs.length) throw new Error('No spendable Bitcoin in this wallet.'); if (!feeRates) throw new Error('Fee rates not loaded.'); if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself."); if (amountSats <= 0) throw new Error('Enter an amount.'); @@ -315,17 +363,26 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0; setProgress('building'); - const built = buildHdUnsignedPsbt( - availability.account, - ownedUtxos, - recipient.address, + const built = buildHdSpendPsbt({ + account: availability.account, + inputs: ownedInputs, + recipient: + recipient.kind === 'sp' + ? { kind: 'sp', spAddress: recipient.address } + : { kind: 'address', address: recipient.address }, amountSats, - rate, + feeRate: rate, nextChangeIndex, - ); + nsecBytes: availability.nsecBytes, + }); setProgress('signing'); - const signedHex = signHdPsbt(built.psbtHex, built.inputDerivations, availability.account); + const signedHex = signHdPsbt( + built.psbtHex, + built.inputDescriptors, + availability.account, + availability.nsecBytes, + ); const txHex = finalizeHdPsbt(signedHex); setProgress('broadcasting'); @@ -337,6 +394,10 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin notificationSuccess(); setSuccess(result); queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] }); + // SP storage is keyed by `(txid, vout)`; the next scan will mark + // spent UTXOs as gone. Invalidate the doc query so the optimistic + // copy is dropped in favour of the relay copy on the next scan. + queryClient.invalidateQueries({ queryKey: ['hdwallet-sp-doc'] }); void refetchWallet(); }, onError: (err) => { @@ -350,11 +411,11 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin if (availability.status !== 'available') { setError('HD wallet is not available for this login type.'); return; } - if (!recipient) { setError('Enter a Bitcoin address or npub.'); return; } + if (!recipient) { setError('Enter a Bitcoin address, sp1… address, or npub.'); return; } if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; } if (!btcPrice) { setError('Waiting for BTC price…'); return; } if (amountSats <= 0) { setError('Enter an amount.'); return; } - if (!ownedUtxos.length) { setError("You don't have any Bitcoin yet."); return; } + if (!ownedInputs.length) { setError("You don't have any Bitcoin yet."); return; } if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; } if (isRawAddress && !acknowledgedPublic) { setError('Acknowledge the privacy warning before sending.'); return; @@ -366,7 +427,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin recipient, btcPrice, amountSats, - ownedUtxos.length, + ownedInputs.length, insufficient, isRawAddress, acknowledgedPublic, @@ -411,7 +472,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin !btcPrice || amountSats <= 0 || insufficient || - !ownedUtxos.length || + !ownedInputs.length || (isRawAddress && !acknowledgedPublic); // ── Render ─────────────────────────────────────────────────── @@ -501,14 +562,16 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin id="hd-recipient-input" value={recipientInput} onChange={(e) => setRecipientInput(e.target.value)} - placeholder="bc1… or npub…" + placeholder="bc1…, sp1…, or npub…" autoComplete="off" spellCheck={false} className="font-mono text-sm" /> {recipient && (

- {recipient.pubkey ? ( + {recipient.kind === 'sp' ? ( + <>Sending via a silent payment — the recipient gets a fresh, unlinkable on-chain address. + ) : recipient.pubkey ? ( <>Sending to a Nostr user's on-chain address. ) : ( <>Sending to a raw Bitcoin address. @@ -584,20 +647,6 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin )} - {/* Silent-payment-only balance: the PSBT signer can't yet spend - BIP-352 outputs, so explain why Send is disabled instead of - leaving the button greyed out with no feedback. */} - {onlyHasSpFunds && ( - - - - Your balance is held in silent-payment outputs. Spending - them isn't supported yet — receive on-chain bitcoin to - your regular receive address to send. - - - )} - {/* Send button */}