Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
76 changes: 63 additions & 13 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
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));
}