From ac4ed32f22f74cd248f0f8881f240e2caaf7c9c2 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Wed, 23 Sep 2026 13:30:28 +0100 Subject: [PATCH] feat(wallet): add TxBuilder::avoid_reuse to skip reused-address UTXOs - Add an opt-in TxBuilder method that keeps automatic coin selection from spending UTXOs sitting in an address the wallet has already spent from. Once a wallet spends from an address, that address becomes publicly linked to the wallet. An adversary can exploit this by sending coins to it and hoping the wallet later merges them into a payment thereby linking their UTXOs. `avoid_reuse` defends against this by excluding such coins from selection. Details: - Semantics follow Bitcoin Core's avoid_reuse wallet flag (bitcoin/bitcoin#13756): an address is avoided only once it has been spent from. Coins on a never-spent address stay selectable. - Detection uses the wallet's canonical history (Wallet::list_output), so outputs from replaced transactions (e.g. RBF) are not counted as reuse. - Implemented via the existing unspendable set, so avoided coins can still be spent when selected explicitly with TxBuilder::add_utxo. - Opt-in and off by default; existing behavior is unchanged. Modeled as a per-transaction TxBuilder option rather than a persisted wallet flag. - Tests cover three cases: a never-spent address is kept, a spent-from address is excluded (spendable via explicit selection), and a fee-bumped incoming payment is not mistaken for reuse. Fixes bitcoin/bitcoin#13756 item in #28. --- src/wallet/tx_builder.rs | 224 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 1 deletion(-) diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index c3b73079..d6a33997 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -619,6 +619,46 @@ impl<'a, Cs> TxBuilder<'a, Cs> { self.exclude_below_confirmations(1) } + /// Avoid spending UTXOs that sit on an address the wallet has already spent from. + /// + /// When called, every currently-unspent UTXO whose address (script pubkey) the wallet has + /// previously spent from is added to the "unspendable" list (see [`TxBuilder::unspendable`]), + /// so automatic coin selection will not pick it. This improves privacy by thwarting *forced + /// address reuse*: once you spend from an address it becomes publicly linked to you, and an + /// adversary can then send coins to it hoping your wallet later merges them into a payment, + /// linking your UTXOs together. + /// + /// Only addresses the wallet has *spent from* are avoided. Detection uses the wallet's + /// canonical history ([`Wallet::list_output`]). + /// + /// Coins on an avoided address can still be spent by selecting them explicitly with + /// [`TxBuilder::add_utxo`], as manual selection overrides the unspendable list. + /// + /// This inspects the wallet's current state, so call it after syncing. Chaining it with other + /// filtering methods yields the union of all excluded outpoints. + /// + /// This mirrors Bitcoin Core's `avoid_reuse` wallet flag (`bitcoin/bitcoin#13756`). + pub fn avoid_reuse(&mut self) -> &mut Self { + let outputs = self.wallet.list_output().collect::>(); + + // Addresses the wallet has already spent from, keyed by `(keychain, derivation index)` + // which maps 1:1 to a script pubkey. + let spent_from: HashSet<(KeychainKind, u32)> = outputs + .iter() + .filter(|output| output.is_spent) + .map(|output| (output.keychain, output.derivation_index)) + .collect(); + + // Exclude unspent outputs from any of those addresses. + for output in &outputs { + if !output.is_spent && spent_from.contains(&(output.keychain, output.derivation_index)) + { + self.params.unspendable.insert(output.outpoint); + } + } + self + } + /// Sign with a specific sig hash /// /// **Use this option very carefully** @@ -1027,9 +1067,11 @@ mod test { } use crate::test_utils::*; - use bitcoin::TxOut; + use alloc::string::ToString; use bitcoin::consensus::deserialize; use bitcoin::hex::FromHex; + use bitcoin::{Address, Network, TxOut}; + use core::str::FromStr; use super::*; #[test] @@ -1570,4 +1612,184 @@ mod test { matches!(&builder.params.utxos[0].utxo, Utxo::Local(output) if output.outpoint == outpoint) ); } + + // An address that received more than once and never spent from must NOT be excluded. + #[test] + fn test_avoid_reuse_keeps_never_spent_addresses() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc.to_string(), change_desc.to_string()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + let first_op = receive_output_to_address( + &mut wallet, + addr.clone(), + Amount::from_sat(100_000), + ReceiveTo::Mempool(0), + ); + let second_op = receive_output_to_address( + &mut wallet, + addr, + Amount::from_sat(546), + ReceiveTo::Mempool(0), + ); + + let recipient = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .unwrap() + .assume_checked(); + + // Since the address was never spent from, both received outputs remain selectable. + let mut builder = wallet.build_tx(); + builder + .drain_wallet() + .drain_to(recipient.script_pubkey()) + .avoid_reuse(); + let psbt = builder.finish().unwrap(); + let selected: Vec = psbt + .unsigned_tx + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!(selected.contains(&first_op)); + assert!(selected.contains(&second_op)); + } + + // Once the wallet spends from an address, coins later sent to it must be excluded from + // automatic selection (but still spendable when selected explicitly). + #[test] + fn test_avoid_reuse_excludes_spent_from_address() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc.to_string(), change_desc.to_string()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let spent_addr = wallet.reveal_next_address(KeychainKind::External).address; + let fresh_addr = wallet.reveal_next_address(KeychainKind::External).address; + let recipient = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .unwrap() + .assume_checked(); + + // Receive on `spent_addr`, then spend that output so the address becomes spent-from. + let first_op = receive_output_to_address( + &mut wallet, + spent_addr.clone(), + Amount::from_sat(50_000), + ReceiveTo::Mempool(0), + ); + let spend_tx = Transaction { + input: vec![TxIn { + previous_output: first_op, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: recipient.script_pubkey(), + }], + ..new_tx(0) + }; + insert_tx(&mut wallet, spend_tx); + + // Attacker sends to the now-used address; a fresh address also receives coins. + let peppered_op = receive_output_to_address( + &mut wallet, + spent_addr, + Amount::from_sat(30_000), + ReceiveTo::Mempool(0), + ); + let fresh_op = receive_output_to_address( + &mut wallet, + fresh_addr, + Amount::from_sat(50_000), + ReceiveTo::Mempool(0), + ); + + // avoid_reuse: selection uses the fresh coin, never the peppered one. + let mut builder = wallet.build_tx(); + builder + .add_recipient(recipient.script_pubkey(), Amount::from_sat(20_000)) + .avoid_reuse(); + let psbt = builder.finish().unwrap(); + let selected: Vec = psbt + .unsigned_tx + .input + .iter() + .map(|i| i.previous_output) + .collect(); + assert!(selected.contains(&fresh_op)); + assert!(!selected.contains(&peppered_op)); + + // Needing more than the fresh coin holds fails, since the peppered coin is off-limits. + let mut builder = wallet.build_tx(); + builder + .add_recipient(recipient.script_pubkey(), Amount::from_sat(60_000)) + .avoid_reuse(); + assert!(matches!( + builder.finish(), + Err(CreateTxError::CoinSelection(_)) + )); + + // can still be spent when selected explicitly. + let mut builder = wallet.build_tx(); + builder.add_recipient(recipient.script_pubkey(), Amount::from_sat(60_000)); + builder.avoid_reuse(); + builder.add_utxo(peppered_op).unwrap(); + assert!(builder.finish().is_ok()); + } + + // A fee-bumped incoming payment must not be mistaken for address reuse. + #[test] + fn test_avoid_reuse_ignores_replaced_payments() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc.to_string(), change_desc.to_string()) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let addr = wallet.reveal_next_address(KeychainKind::External).address; + + // Both versions of the payment spend the same parent outpoint, so they conflict (RBF). + let shared_input = OutPoint { + txid: new_tx(0).compute_txid(), + vout: 0, + }; + let payment = |sequence: Sequence| Transaction { + input: vec![TxIn { + previous_output: shared_input, + sequence, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: addr.script_pubkey(), + }], + ..new_tx(0) + }; + + let original = payment(Sequence::ENABLE_RBF_NO_LOCKTIME); + let replacement = payment(Sequence(0xFFFF_FFFE)); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + + insert_tx(&mut wallet, original); + insert_seen_at(&mut wallet, original_txid, 1_000); + insert_tx(&mut wallet, replacement); + insert_seen_at(&mut wallet, replacement_txid, 2_000); + + // Canonically there is a single output on the address, never spent from. + assert_eq!(wallet.list_unspent().count(), 1); + + let recipient = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .unwrap() + .assume_checked(); + let mut builder = wallet.build_tx(); + builder + .add_recipient(recipient.script_pubkey(), Amount::from_sat(10_000)) + .avoid_reuse(); + // The replaced payment is not mistaken for reuse; the coin stays spendable. + assert!(builder.finish().is_ok()); + } }