diff --git a/src/wallet/coin_selection.rs b/src/wallet/coin_selection.rs index a6618185..ff642476 100644 --- a/src/wallet/coin_selection.rs +++ b/src/wallet/coin_selection.rs @@ -705,16 +705,17 @@ impl CoinSelectionAlgorithm for SingleRandomDraw { } fn calculate_cs_result( - mut selected_utxos: Vec, - mut required_utxos: Vec, + selected_utxos: Vec, + required_utxos: Vec, excess: Excess, ) -> CoinSelectionResult { - selected_utxos.append(&mut required_utxos); - let fee_amount = selected_utxos.iter().map(|u| u.fee).sum(); - let selected = selected_utxos + let mut selected = required_utxos; + selected.extend(selected_utxos); + let fee_amount = selected.iter().map(|u| u.fee).sum(); + let selected = selected .into_iter() - .map(|u| u.weighted_utxo.utxo) - .collect::>(); + .map(|output_group| output_group.weighted_utxo.utxo) + .collect(); CoinSelectionResult { selected, diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index 9ad794f8..4166483f 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -943,6 +943,7 @@ mod test { }; } + use crate::test_utils::*; use bitcoin::consensus::deserialize; use bitcoin::hex::FromHex; use bitcoin::TxOut; @@ -1168,7 +1169,6 @@ mod test { #[test] fn test_exclude_unconfirmed() { - use crate::test_utils::*; use bdk_chain::BlockId; use bitcoin::{hashes::Hash, BlockHash, Network}; @@ -1258,7 +1258,6 @@ mod test { #[test] fn test_build_fee_bump_remove_change_output_single_desc() { - use crate::test_utils::*; use bdk_chain::BlockId; use bitcoin::{hashes::Hash, BlockHash, Network}; @@ -1304,8 +1303,6 @@ mod test { #[test] fn duplicated_utxos_in_add_utxos_are_only_added_once() { - use crate::test_utils::get_funded_wallet_wpkh; - let (mut wallet, _) = get_funded_wallet_wpkh(); let utxo = wallet.list_unspent().next().unwrap(); let op = utxo.outpoint; @@ -1318,7 +1315,6 @@ mod test { #[test] fn not_duplicated_utxos_in_required_list() { - use crate::test_utils::get_funded_wallet_wpkh; let (mut wallet1, _) = get_funded_wallet_wpkh(); let utxo1 @ LocalOutput { outpoint, .. } = wallet1.list_unspent().next().unwrap(); let mut builder = wallet1.build_tx(); @@ -1333,10 +1329,54 @@ mod test { assert_eq!(vec![fake_weighted_utxo], builder.params.utxos); } + // This test demonstrates that `add_utxo` only considers the final insertion. #[test] - fn not_duplicated_foreign_utxos_with_same_outpoint_but_different_weight() { - use crate::test_utils::{get_funded_wallet_single, get_funded_wallet_wpkh, get_test_wpkh}; + fn test_add_utxo_final_outpoint_retained() { + // Create empty wallet + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(bdk_wallet::bitcoin::Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + let outpoint_0 = receive_output( + &mut wallet, + Amount::from_sat(35_000), + ReceiveTo::Mempool(50), + ); + let outpoint_1 = receive_output( + &mut wallet, + Amount::from_sat(25_200), + ReceiveTo::Mempool(100), + ); + + let send_to = wallet.next_unused_address(KeychainKind::External).address; + let mut tx_builder = wallet.build_tx(); + tx_builder + .add_utxo(outpoint_0) + .unwrap() + .add_utxo(outpoint_1) + .unwrap() + .add_utxo(outpoint_0) + .unwrap() + .add_recipient(send_to.script_pubkey(), Amount::from_sat(60_000)) + .fee_rate(FeeRate::from_sat_per_vb(1).unwrap()) + .ordering(crate::TxOrdering::Untouched); + let psbt = tx_builder.finish().unwrap(); + + assert_eq!( + psbt.unsigned_tx + .input + .iter() + .map(|txin| txin.previous_output) + .collect::>(), + vec![outpoint_1, outpoint_0], + "Last outpoint added should be retained" + ); + } + #[test] + fn not_duplicated_foreign_utxos_with_same_outpoint_but_different_weight() { // Use two different wallets to avoid adding local UTXOs let (wallet1, txid1) = get_funded_wallet_wpkh(); let (mut wallet2, txid2) = get_funded_wallet_single(get_test_wpkh()); @@ -1392,7 +1432,6 @@ mod test { // Test that local outputs have precedence over utxos added via `add_foreign_utxo` #[test] fn test_local_utxos_have_precedence_over_foreign_utxos() { - use crate::test_utils::get_funded_wallet_wpkh; let (mut wallet, _) = get_funded_wallet_wpkh(); let utxo = wallet.list_unspent().next().unwrap(); diff --git a/tests/wallet.rs b/tests/wallet.rs index c779c0a4..e532dbf1 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -3026,3 +3026,55 @@ fn test_tx_ordering_untouched_preserves_insertion_ordering() { // Check vout is sorted by recipient insertion order assert!(txouts == vec![400, 300, 500]); } + +// BnB coin selection should find a solution using the optional UTXO. +// This demonstrates that `calculate_cs_result` correctly orders required UTXOs before selected +// ones. +#[test] +fn test_tx_ordering_untouched_preserves_insertion_ordering_bnb_success() { + // Create empty wallet + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(bdk_wallet::bitcoin::Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Set up UTXOs with specific values so BnB can find an exact match (avoiding change). + // - outpoint_0 (required): 35,000 sat - not enough alone + // - outpoint_1 (optional): 25,200 sat + // - Target: 60,000 sat + // - Expected fee: 200 sat + + let outpoint_0 = receive_output( + &mut wallet, + Amount::from_sat(35_000), + ReceiveTo::Mempool(50), + ); + let outpoint_1 = receive_output( + &mut wallet, + Amount::from_sat(25_200), + ReceiveTo::Mempool(100), + ); + + let send_to = wallet.next_unused_address(KeychainKind::External).address; + let mut tx_builder = wallet.build_tx(); + tx_builder + .add_utxo(outpoint_0) + .unwrap() + .add_recipient(send_to.script_pubkey(), Amount::from_sat(60_000)) + .fee_rate(FeeRate::from_sat_per_vb(1).unwrap()) + .ordering(bdk_wallet::TxOrdering::Untouched); + let psbt = tx_builder.finish().unwrap(); + + // Verify that both UTXOs are selected in the correct order: + // required (outpoint_0) should appear before optional (outpoint_1) + assert_eq!( + psbt.unsigned_tx + .input + .iter() + .map(|txin| txin.previous_output) + .collect::>(), + vec![outpoint_0, outpoint_1], + "UTXOs should be ordered with required first, then selected" + ); +}