fix(wallet): retry coin selection on NoChange to avoid dust/zero drai…#448
fix(wallet): retry coin selection on NoChange to avoid dust/zero drai…#448alienx5499 wants to merge 4 commits intobitcoindevkit:masterfrom
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #448 +/- ##
==========================================
+ Coverage 80.04% 80.23% +0.19%
==========================================
Files 24 24
Lines 5336 5362 +26
Branches 242 249 +7
==========================================
+ Hits 4271 4302 +31
+ Misses 987 981 -6
- Partials 78 79 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
|
||
| if !should_retry_for_dust_drain | ||
| || !matches!(&result.excess, Excess::NoChange { .. }) | ||
| || optional_remaining.is_empty() |
There was a problem hiding this comment.
The loop has both an outer || optional_remaining.is_empty() break condition and an inner let Some(w) = optional_remaining.pop() else { break result } guard. Since the outer check already breaks when the Vec is empty, the else { break result } branch of pop() is dead code. This is confusing — pick one:
if !should_retry_for_dust_drain || !matches!(&result.excess, Excess::NoChange { .. }) { break result; }
f083d6f to
55d050d
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes a Wallet::create_tx failure mode for drain-only transactions that include a foreign UTXO and use fee_absolute, where initial coin selection can yield Excess::NoChange (dust/zero remainder) and ultimately surface as CreateTxError::CoinSelection despite sufficient total funds.
Changes:
- Add a retry loop in
Wallet::create_txthat promotes optional wallet UTXOs to required inputs when coin selection returnsExcess::NoChangefor the drain-only +drain_tocase. - Add integration tests covering both dust remainder and zero remainder scenarios when spending a foreign P2A-style input with
fee_absoluteanddrain_to.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/wallet/mod.rs |
Adds a guarded coin-selection retry loop for the drain-only + Excess::NoChange path to avoid dust/zero drain outputs. |
tests/drain_to_dust_pull_utxo.rs |
Adds regression tests that reproduce the error and assert the builder pulls in an additional local UTXO to produce a valid drain output. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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); |
There was a problem hiding this comment.
This introduces a loop that clones required_for_attempt and optional_remaining on every create_tx call, even when should_retry_for_dust_drain is false (i.e., the common case). That can be a noticeable performance/memory regression for wallets with many UTXOs. Consider keeping the original single coin_select call for the non-retry path (move the vectors instead of cloning), and only entering the retry loop (with cloning) when the guard is true and the first attempt returns Excess::NoChange.
| // 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); | |
| // initial selection yields NoChange, promote an optional UTXO to required and retry; | |
| // 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 first_coin_selection = coin_selection | |
| .coin_select( | |
| required_utxos, | |
| optional_utxos, | |
| fee_rate, | |
| outgoing + fee_amount, | |
| &drain_script, | |
| rng, | |
| ) | |
| .map_err(CreateTxError::CoinSelection)?; | |
| let coin_selection = if should_retry_for_dust_drain | |
| && matches!(&first_coin_selection.excess, Excess::NoChange { .. }) | |
| { | |
| let mut required_for_attempt = first_coin_selection.selected.clone(); | |
| let mut optional_remaining = first_coin_selection.local_selected.clone(); | |
| loop { | |
| let Some(w) = optional_remaining.pop() else { | |
| break first_coin_selection; | |
| }; | |
| required_for_attempt.push(w); | |
| 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; | |
| } | |
| } | |
| } else { | |
| first_coin_selection |
| 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 |
There was a problem hiding this comment.
let coin_selection = loop { ... } shadows the coin_selection: Cs algorithm parameter, so the same identifier refers to two different concepts (algorithm vs selection result) within a small scope. This is easy to misread and makes future edits riskier; consider renaming one of them (e.g., keep the algorithm as coin_selector and the loop result as selection_result).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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); |
There was a problem hiding this comment.
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.
| 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)); | |
| } | |
| } |
| .coin_select( | ||
| required_for_attempt.clone(), | ||
| optional_remaining.clone(), |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if let Some(result) = last_successful_result { | ||
| break result; |
There was a problem hiding this comment.
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.
| 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; |
…_unchecked be710a8 chore(tests): replace deprecated FeeRate::from_sat_per_vb_unchecked (none34829) Pull request description: ### Description `bitcoin-units 0.1.3` deprecated `FeeRate::from_sat_per_vb_unchecked` in favor of `FeeRate::from_sat_per_vb_u32`. Cargo resolves any 0.1.x automatically, so fresh CI runs started failing on master (and all open PRs) once 0.1.3 was published. This patch swaps every call site over to the new name. The two functions compute the same value for any input that fits in `u32`; every existing call site passes a small literal (1, 3, 5, 10, 25, 50, 255, 454, 1000, 10_000), so no behavioural change. All usages are in test/fixture code (`src/wallet/coin_selection.rs` test module, `tests/wallet.rs`, `tests/build_fee_bump.rs`) — no production code path was using the deprecated function. ### Notes to the reviewers - Pure mechanical rename; no logic or type changes. - Unblocks CI on master and every open PR (e.g. #449, #442, #445, #448). ### Changelog notice N/A — internal test-only change. ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `just p` before pushing ACKs for top commit: ValuedMammal: ACK be710a8 Tree-SHA512: b2354fcf6e39228bc5796f92af4d3692fbdb750267c2c5a7d814fc5c84f4af64b746562fda7e01dac282ba04acb17ff7bf5c7ef0155a3d6f302974003baf0629
Description
Fixes #376.
Wallet::create_txcould returnCreateTxError::CoinSelectionfor drain-only transactions (drain_to, no explicit recipients) when spending a foreign input (e.g P2A) withfee_absolute.In that situation, the initial
coin_selectpass may select only required inputs and yieldExcess::NoChange(i.e. zero or dust remaining for the drain output). This results in a coin selection failure despite sufficient total funds, because the drain-only path cannot produce a non-dust output underExcess::NoChange.This change adds a retry for that scenario : optional wallet UTXOs are promoted to required inputs one at a time and
coin_selectis run again untilExcessis no longerNoChangeor there are no optional UTXOs left. That yields a valid (non-dust) drain output when possible.Scope: General coin selection behavior is unchanged. This only adds a retry for the
NoChange+ drain-only case. Optional UTXOs are taken in existing order (pop()), so no new selection policy is introduced.Notes to the reviewers
params.recipients.is_empty())drain_tois set(params.drain_wallet || !params.utxos.is_empty())(preservesNoRecipientsbehavior vs the empty-output path inmod.rs)manually_selected_onlytests/drain_to_dust_pull_utxo.rsinsert_txout,fee_absolute,drain_toChangelog notice
fix(wallet): retry coin selection on NoChange to avoid dust/zero drain outputs (#376)Checklists
All Submissions:
just pbefore pushingNew Features:
Bugfixes: