Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
60 changes: 47 additions & 13 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1467,19 +1467,53 @@ 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 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(),
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.

Potential performance issue: the retry loop clones required_for_attempt and optional_remaining on each iteration, and may rerun coin selection many times when the wallet has a large UTXO set. Consider adding a cheap guard like !optional_utxos.is_empty() (or an upper bound on retries) to avoid extra allocations/work in cases where no retry can succeed (e.g., no optional UTXOs).

Copilot uses AI. Check for mistakes.
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);
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 this retry loop, each coin_select attempt is fallible; returning immediately on Err(InsufficientFunds) (via map_err later in this chain) can change behavior vs the previous successful Ok(… Excess::NoChange …) attempt and can also prevent trying other optionals. Consider keeping the last successful result and breaking/falling back to it if a later retry errors, instead of propagating the error from the retry attempt.

Suggested change
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);
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;
}
return Err(CreateTxError::CoinSelection(err));
}
}

Copilot uses AI. Check for mistakes.
}
} 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 +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) {
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