Skip to content

fix(wallet): retry coin selection on NoChange to avoid dust/zero drai…#448

Open
alienx5499 wants to merge 4 commits intobitcoindevkit:masterfrom
alienx5499:fix/376-p2a-drain-coinselection
Open

fix(wallet): retry coin selection on NoChange to avoid dust/zero drai…#448
alienx5499 wants to merge 4 commits intobitcoindevkit:masterfrom
alienx5499:fix/376-p2a-drain-coinselection

Conversation

@alienx5499
Copy link
Copy Markdown

Description

Fixes #376.

Wallet::create_tx could return CreateTxError::CoinSelection for drain-only transactions (drain_to, no explicit recipients) when spending a foreign input (e.g P2A) with fee_absolute.

In that situation, the initial coin_select pass may select only required inputs and yield Excess::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 under Excess::NoChange.

This change adds a retry for that scenario : optional wallet UTXOs are promoted to required inputs one at a time and coin_select is run again until Excess is no longer NoChange or 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

  • Guards: Retry is gated on:
    • no explicit recipients (params.recipients.is_empty())
    • drain_to is set
    • (params.drain_wallet || !params.utxos.is_empty()) (preserves NoRecipients behavior vs the empty-output path in mod.rs)
    • not manually_selected_only
  • Tests: tests/drain_to_dust_pull_utxo.rs
    • Foreign P2A-style UTXOs via insert_txout, fee_absolute, drain_to
    • Covers dust remainder (500 sat value, 400 sat fee) and zero remainder (200 sat value, 200 sat fee)
    • Asserts at least 2 inputs (foreign + local) non-empty outputs, foreign outpoint present in inputs

Changelog notice

fix(wallet): retry coin selection on NoChange to avoid dust/zero drain outputs (#376)

Checklists

All Submissions:

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 18, 2026

Codecov Report

❌ Patch coverage is 91.66667% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.23%. Comparing base (fb7681a) to head (f083d6f).
⚠️ Report is 10 commits behind head on master.

Files with missing lines Patch % Lines
src/wallet/mod.rs 91.66% 1 Missing and 1 partial ⚠️
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     
Flag Coverage Δ
rust 80.23% <91.66%> (+0.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/wallet/mod.rs Outdated

if !should_retry_for_dust_drain
|| !matches!(&result.excess, Excess::NoChange { .. })
|| optional_remaining.is_empty()
Copy link
Copy Markdown

@nikunjkumar05 nikunjkumar05 Apr 19, 2026

Choose a reason for hiding this comment

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

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; }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch!
Thanks!

@alienx5499 alienx5499 force-pushed the fix/376-p2a-drain-coinselection branch from f083d6f to 55d050d Compare April 19, 2026 11:01
@ValuedMammal ValuedMammal added the wontfix This will not be worked on label Apr 23, 2026
@ValuedMammal ValuedMammal requested a review from Copilot April 23, 2026 01:34
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_tx that promotes optional wallet UTXOs to required inputs when coin selection returns Excess::NoChange for the drain-only + drain_to case.
  • Add integration tests covering both dust remainder and zero remainder scenarios when spending a foreign P2A-style input with fee_absolute and drain_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.

Comment thread src/wallet/mod.rs Outdated
Comment on lines +1471 to +1499
// 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);
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.

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
Comment thread src/wallet/mod.rs Outdated
Comment on lines 1478 to 1503
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
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.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread src/wallet/mod.rs Outdated
Comment on lines +1481 to +1500
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);
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.
Comment thread src/wallet/mod.rs Outdated
Comment on lines +1483 to +1485
.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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread src/wallet/mod.rs Outdated
Comment on lines +1504 to +1505
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.
ValuedMammal added a commit that referenced this pull request Apr 23, 2026
…_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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wontfix This will not be worked on

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Spending P2A Output with a Non-Zero Value can Return CreateTxError::CoinSelection

4 participants