From 55d050d7f26a68c459c5688bd50de1c89751a6ad Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Sat, 18 Apr 2026 05:02:35 +0530 Subject: [PATCH 1/4] fix(wallet): retry coin selection on NoChange to avoid dust/zero drain outputs --- src/wallet/mod.rs | 41 ++++++++++++----- tests/drain_to_dust_pull_utxo.rs | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 tests/drain_to_dust_pull_utxo.rs diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b4eb7709..17a8ef1e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1467,16 +1467,37 @@ 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()) + && !params.manually_selected_only; + + let mut required_for_attempt = required_utxos; + let mut optional_remaining = optional_utxos; + let coin_selection = loop { + let result = coin_selection + .coin_select( + required_for_attempt.clone(), + optional_remaining.clone(), + fee_rate, + outgoing + fee_amount, + &drain_script, + rng, + ) + .map_err(CreateTxError::CoinSelection)?; + + if !should_retry_for_dust_drain || !matches!(&result.excess, Excess::NoChange { .. }) { + break result; + } + + let Some(w) = optional_remaining.pop() else { + break result; + }; + required_for_attempt.push(w); + }; let excess = &coin_selection.excess; tx.input = coin_selection 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)); +} From 98f32a818251962024cb4521a3257412ca2306c3 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Thu, 23 Apr 2026 17:10:05 +0530 Subject: [PATCH 2/4] refactor(wallet): split dust drain retry path and rename selection_result for clarity --- src/wallet/mod.rs | 51 +++++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 17a8ef1e..d47f186a 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1475,32 +1475,45 @@ impl Wallet { && (params.drain_wallet || !params.utxos.is_empty()) && !params.manually_selected_only; - let mut required_for_attempt = required_utxos; - let mut optional_remaining = optional_utxos; - let coin_selection = loop { - let result = coin_selection + let selection_result = if should_retry_for_dust_drain { + let mut required_for_attempt = required_utxos; + let mut optional_remaining = optional_utxos; + loop { + let result = coin_selection + .coin_select( + required_for_attempt.clone(), + optional_remaining.clone(), + fee_rate, + outgoing + fee_amount, + &drain_script, + rng, + ) + .map_err(CreateTxError::CoinSelection)?; + + if !matches!(&result.excess, Excess::NoChange { .. }) { + break result; + } + + let Some(w) = optional_remaining.pop() else { + break result; + }; + required_for_attempt.push(w); + } + } else { + coin_selection .coin_select( - required_for_attempt.clone(), - optional_remaining.clone(), + required_utxos, + optional_utxos, fee_rate, outgoing + fee_amount, &drain_script, rng, ) - .map_err(CreateTxError::CoinSelection)?; - - if !should_retry_for_dust_drain || !matches!(&result.excess, Excess::NoChange { .. }) { - break result; - } - - let Some(w) = optional_remaining.pop() else { - break result; - }; - required_for_attempt.push(w); + .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 { @@ -1555,7 +1568,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) { From 235d336e8968f34e53067f5732c787be48b66d6a Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Thu, 23 Apr 2026 17:32:11 +0530 Subject: [PATCH 3/4] fix(wallet): keep last successful NoChange result in dust-drain retry path --- src/wallet/mod.rs | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d47f186a..4b146bf2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1473,31 +1473,40 @@ impl Wallet { 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 { - let result = coin_selection - .coin_select( - required_for_attempt.clone(), - optional_remaining.clone(), - fee_rate, - outgoing + fee_amount, - &drain_script, - rng, - ) - .map_err(CreateTxError::CoinSelection)?; - - if !matches!(&result.excess, Excess::NoChange { .. }) { - break result; - } + 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; - }; - required_for_attempt.push(w); + 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 { + break result; + } + return Err(CreateTxError::CoinSelection(err)); + } + } } } else { coin_selection From 11ab2d8fd0066bda2455e71e3a5488c2750498f6 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Thu, 23 Apr 2026 17:59:01 +0530 Subject: [PATCH 4/4] fix(wallet): continue dust-drain retries after failed optional UTXO promotion --- src/wallet/mod.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4b146bf2..2ed1663f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1501,8 +1501,15 @@ impl Wallet { required_for_attempt.push(w); } Err(err) => { - if let Some(result) = last_successful_result { - break result; + 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)); }