Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 56 additions & 13 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1467,19 +1467,62 @@ 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 {
break result;
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the retry loop, if coin_select errors after promoting an optional UTXO, the code breaks out by returning last_successful_result (which is necessarily an Excess::NoChange result) rather than continuing to try other optional UTXOs. This can cause false failures when the last optional popped has negative effective value (BnB explicitly filters these from optional_utxos), since promoting it to required can trigger InsufficientFunds even though another optional UTXO could have resolved the NoChange dust case. Consider treating a failed promoted UTXO as “skipped” (remove it from required_for_attempt and continue with remaining optionals), and only return an error once all candidates are exhausted.

Suggested change
if let Some(result) = last_successful_result {
break result;
if let Some(result) = last_successful_result.take() {
required_for_attempt.pop();
if optional_remaining.is_empty() {
break result;
}
last_successful_result = Some(result);
continue;

Copilot uses AI. Check for mistakes.
}
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 {
Expand Down Expand Up @@ -1534,7 +1577,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) {
Expand Down
77 changes: 77 additions & 0 deletions tests/drain_to_dust_pull_utxo.rs
Original file line number Diff line number Diff line change
@@ -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));
}
Loading