Skip to content

Commit f083d6f

Browse files
committed
fix(wallet): retry coin selection on NoChange to avoid dust/zero drain outputs
1 parent fb7681a commit f083d6f

2 files changed

Lines changed: 111 additions & 10 deletions

File tree

src/wallet/mod.rs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,16 +1467,40 @@ impl Wallet {
14671467
}
14681468
};
14691469

1470-
let coin_selection = coin_selection
1471-
.coin_select(
1472-
required_utxos,
1473-
optional_utxos,
1474-
fee_rate,
1475-
outgoing + fee_amount,
1476-
&drain_script,
1477-
rng,
1478-
)
1479-
.map_err(CreateTxError::CoinSelection)?;
1470+
// Retry coin selection to avoid dust/zero drain outputs (see issue #376). If the
1471+
// selection yields NoChange the loop promotes an optional UTXO to required and retries;
1472+
// it exits when optional_remaining is exhausted or a viable drain output is found.
1473+
let should_retry_for_dust_drain = params.recipients.is_empty()
1474+
&& params.drain_to.is_some()
1475+
&& (params.drain_wallet || !params.utxos.is_empty())
1476+
&& !params.manually_selected_only;
1477+
1478+
let mut required_for_attempt = required_utxos;
1479+
let mut optional_remaining = optional_utxos;
1480+
let coin_selection = loop {
1481+
let result = coin_selection
1482+
.coin_select(
1483+
required_for_attempt.clone(),
1484+
optional_remaining.clone(),
1485+
fee_rate,
1486+
outgoing + fee_amount,
1487+
&drain_script,
1488+
rng,
1489+
)
1490+
.map_err(CreateTxError::CoinSelection)?;
1491+
1492+
if !should_retry_for_dust_drain
1493+
|| !matches!(&result.excess, Excess::NoChange { .. })
1494+
|| optional_remaining.is_empty()
1495+
{
1496+
break result;
1497+
}
1498+
1499+
let Some(w) = optional_remaining.pop() else {
1500+
break result;
1501+
};
1502+
required_for_attempt.push(w);
1503+
};
14801504

14811505
let excess = &coin_selection.excess;
14821506
tx.input = coin_selection

tests/drain_to_dust_pull_utxo.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
use bdk_wallet::test_utils::*;
2+
use bdk_wallet::KeychainKind;
3+
use bitcoin::{hashes::Hash, psbt, Amount, OutPoint, ScriptBuf, TxOut, Weight};
4+
5+
// Ensures coin selection pulls a local UTXO when drain-only selection would produce dust.
6+
#[test]
7+
fn test_drain_to_pulls_local_utxo_when_foreign_only_dust() {
8+
let (mut wallet, _) = get_funded_wallet_wpkh();
9+
let drain_spk = wallet
10+
.next_unused_address(KeychainKind::External)
11+
.script_pubkey();
12+
13+
let witness_utxo = TxOut {
14+
value: Amount::from_sat(500),
15+
script_pubkey: ScriptBuf::new_p2a(),
16+
};
17+
// Remember to include this as a "floating" txout in the wallet.
18+
let outpoint = OutPoint::new(Hash::hash(b"foreign-p2a-prev"), 1);
19+
wallet.insert_txout(outpoint, witness_utxo.clone());
20+
let satisfaction_weight = Weight::from_wu(71);
21+
let psbt_input = psbt::Input {
22+
witness_utxo: Some(witness_utxo),
23+
..Default::default()
24+
};
25+
26+
let mut tx_builder = wallet.build_tx();
27+
tx_builder
28+
.add_foreign_utxo(outpoint, psbt_input, satisfaction_weight)
29+
.unwrap()
30+
.only_witness_utxo()
31+
.fee_absolute(Amount::from_sat(400))
32+
.drain_to(drain_spk);
33+
34+
let psbt = tx_builder.finish().unwrap();
35+
let tx = psbt.unsigned_tx;
36+
assert!(tx.input.len() >= 2);
37+
assert!(!tx.output.is_empty());
38+
assert!(
39+
tx.input.iter().any(|txin| txin.previous_output == outpoint),
40+
"foreign_utxo should be in there"
41+
);
42+
}
43+
44+
// Foreign value equals fee: no satoshis left for a drain output until a wallet UTXO is included.
45+
#[test]
46+
fn test_drain_to_pulls_local_utxo_when_foreign_value_equals_fee() {
47+
let (mut wallet, _) = get_funded_wallet_wpkh();
48+
let drain_spk = wallet
49+
.next_unused_address(KeychainKind::External)
50+
.script_pubkey();
51+
52+
let witness_utxo = TxOut {
53+
value: Amount::from_sat(200),
54+
script_pubkey: ScriptBuf::new_p2a(),
55+
};
56+
let outpoint = OutPoint::new(Hash::hash(b"foreign-p2a-prev-200"), 1);
57+
wallet.insert_txout(outpoint, witness_utxo.clone());
58+
let satisfaction_weight = Weight::from_wu(71);
59+
let psbt_input = psbt::Input {
60+
witness_utxo: Some(witness_utxo),
61+
..Default::default()
62+
};
63+
64+
let mut tx_builder = wallet.build_tx();
65+
tx_builder
66+
.add_foreign_utxo(outpoint, psbt_input, satisfaction_weight)
67+
.unwrap()
68+
.only_witness_utxo()
69+
.fee_absolute(Amount::from_sat(200))
70+
.drain_to(drain_spk);
71+
72+
let psbt = tx_builder.finish().unwrap();
73+
let tx = psbt.unsigned_tx;
74+
assert!(tx.input.len() >= 2);
75+
assert!(!tx.output.is_empty());
76+
assert!(tx.input.iter().any(|txin| txin.previous_output == outpoint));
77+
}

0 commit comments

Comments
 (0)