diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b4eb7709..2ed1663f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1467,19 +1467,69 @@ impl Wallet { } }; - let coin_selection = coin_selection - .coin_select( - required_utxos, - optional_utxos, - fee_rate, - outgoing + fee_amount, - &drain_script, - rng, - ) - .map_err(CreateTxError::CoinSelection)?; + // Retry coin selection to avoid dust/zero drain outputs (see issue #376). If the + // selection yields NoChange the loop promotes an optional UTXO to required and retries; + // it exits when optional_remaining is exhausted or a viable drain output is found. + let should_retry_for_dust_drain = params.recipients.is_empty() + && params.drain_to.is_some() + && (params.drain_wallet || !params.utxos.is_empty()) + && !optional_utxos.is_empty() + && !params.manually_selected_only; + + let selection_result = if should_retry_for_dust_drain { + let mut required_for_attempt = required_utxos; + let mut optional_remaining = optional_utxos; + let mut last_successful_result = None; + loop { + match coin_selection.coin_select( + required_for_attempt.clone(), + optional_remaining.clone(), + fee_rate, + outgoing + fee_amount, + &drain_script, + rng, + ) { + Ok(result) => { + if !matches!(&result.excess, Excess::NoChange { .. }) { + break result; + } + + let Some(w) = optional_remaining.pop() else { + break result; + }; + last_successful_result = Some(result); + required_for_attempt.push(w); + } + Err(err) => { + if let Some(result) = last_successful_result.take() { + // The last promoted optional UTXO made selection fail. + // Drop it and keep trying remaining optionals. + required_for_attempt.pop(); + if optional_remaining.is_empty() { + break result; + } + last_successful_result = Some(result); + continue; + } + return Err(CreateTxError::CoinSelection(err)); + } + } + } + } else { + coin_selection + .coin_select( + required_utxos, + optional_utxos, + fee_rate, + outgoing + fee_amount, + &drain_script, + rng, + ) + .map_err(CreateTxError::CoinSelection)? + }; - let excess = &coin_selection.excess; - tx.input = coin_selection + let excess = &selection_result.excess; + tx.input = selection_result .selected .iter() .map(|u| bitcoin::TxIn { @@ -1534,7 +1584,7 @@ impl Wallet { // Sort inputs/outputs according to the chosen algorithm. params.ordering.sort_tx_with_aux_rand(&mut tx, rng); - let psbt = self.complete_transaction(tx, coin_selection.selected, params)?; + let psbt = self.complete_transaction(tx, selection_result.selected, params)?; // Recording changes to the change keychain. if let (Excess::Change { .. }, Some((keychain, index))) = (excess, drain_index) { diff --git a/tests/drain_to_dust_pull_utxo.rs b/tests/drain_to_dust_pull_utxo.rs new file mode 100644 index 00000000..8293235e --- /dev/null +++ b/tests/drain_to_dust_pull_utxo.rs @@ -0,0 +1,77 @@ +use bdk_wallet::test_utils::*; +use bdk_wallet::KeychainKind; +use bitcoin::{hashes::Hash, psbt, Amount, OutPoint, ScriptBuf, TxOut, Weight}; + +// Ensures coin selection pulls a local UTXO when drain-only selection would produce dust. +#[test] +fn test_drain_to_pulls_local_utxo_when_foreign_only_dust() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let drain_spk = wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(); + + let witness_utxo = TxOut { + value: Amount::from_sat(500), + script_pubkey: ScriptBuf::new_p2a(), + }; + // Remember to include this as a "floating" txout in the wallet. + let outpoint = OutPoint::new(Hash::hash(b"foreign-p2a-prev"), 1); + wallet.insert_txout(outpoint, witness_utxo.clone()); + let satisfaction_weight = Weight::from_wu(71); + let psbt_input = psbt::Input { + witness_utxo: Some(witness_utxo), + ..Default::default() + }; + + let mut tx_builder = wallet.build_tx(); + tx_builder + .add_foreign_utxo(outpoint, psbt_input, satisfaction_weight) + .unwrap() + .only_witness_utxo() + .fee_absolute(Amount::from_sat(400)) + .drain_to(drain_spk); + + let psbt = tx_builder.finish().unwrap(); + let tx = psbt.unsigned_tx; + assert!(tx.input.len() >= 2); + assert!(!tx.output.is_empty()); + assert!( + tx.input.iter().any(|txin| txin.previous_output == outpoint), + "foreign_utxo should be in there" + ); +} + +// Foreign value equals fee: no satoshis left for a drain output until a wallet UTXO is included. +#[test] +fn test_drain_to_pulls_local_utxo_when_foreign_value_equals_fee() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let drain_spk = wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(); + + let witness_utxo = TxOut { + value: Amount::from_sat(200), + script_pubkey: ScriptBuf::new_p2a(), + }; + let outpoint = OutPoint::new(Hash::hash(b"foreign-p2a-prev-200"), 1); + wallet.insert_txout(outpoint, witness_utxo.clone()); + let satisfaction_weight = Weight::from_wu(71); + let psbt_input = psbt::Input { + witness_utxo: Some(witness_utxo), + ..Default::default() + }; + + let mut tx_builder = wallet.build_tx(); + tx_builder + .add_foreign_utxo(outpoint, psbt_input, satisfaction_weight) + .unwrap() + .only_witness_utxo() + .fee_absolute(Amount::from_sat(200)) + .drain_to(drain_spk); + + let psbt = tx_builder.finish().unwrap(); + let tx = psbt.unsigned_tx; + assert!(tx.input.len() >= 2); + assert!(!tx.output.is_empty()); + assert!(tx.input.iter().any(|txin| txin.previous_output == outpoint)); +}