You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
TL;DR: A send that would exactly drain a wallet's UTXOs (or leave only dust change) is rejected by CoinSelector/TransactionBuilder even though the wallet holds enough funds to cover it, and the resulting InsufficientFunds error reports figures that don't make sense together (available bigger than required).
User story
As a wallet integrator (e.g. dash-evo-tool), I want a send that would leave zero or dust change to fold the remainder into the fee and build successfully, so that a "Max send" / drain-style transaction always succeeds instead of failing every time.
As a wallet integrator, I want SelectionError::InsufficientFunds to report available/required as directly comparable quantities, so the error can be surfaced to the end user without misdirecting them toward the wrong cause (e.g. "check your balance" when the balance is actually sufficient).
Scenario
Base flow
A wallet holds a single UTXO of 10,000,000 duffs (0.1 DASH). The caller computes the maximum sendable amount as balance minus a properly no-change-sized fee estimate (192 bytes for a 1-input/1-output tx at 1 duff/byte, plus safety margin ⇒ 220 duffs), giving a send amount of 9,999,780 duffs. It calls TransactionBuilder with SelectionStrategy::LargestFirst, that one UTXO as input, one output of 9,999,780 duffs, and a change address set (the normal call shape — the caller has no way to know in advance that no change will end up being needed).
Actual behavior
select_coins_with_size's pre-check (total_available < target_amount, coin_selection.rs:157-160) passes fine (10,000,000 ≥ 9,999,780). But accumulate_coins_with_size (coin_selection.rs:291-345) is handed a base_size from TransactionBuilder::calculate_base_size() (transaction_builder.rs:170-193) that always reserves room for a change output whenever change_addr.is_some() — regardless of whether change will actually survive. That inflates required_amount = target_amount + estimated_fee (line 312) to more than the single UTXO can cover (10,000,006 > 10,000,000), so the loop never satisfies its own bar and falls through to the terminal:
Err(SelectionError::InsufficientFunds{available: total_value,// 10,000,000required: target_amount,// 9,999,780 — the bare requested OUTPUT amount, fee already dropped})
So the error compares available (10,000,000) to required (9,999,780) and reads backwards — available > required — even though the actual constraint that failed was fee-inclusive (10,000,006 needed).
Expected behavior
When a UTXO set can cover the target amount plus a correctly-sized no-change fee, but not "target + fee assuming change", the builder should retry (or fold the leftover into the fee directly) rather than reject outright — the function already has this exact fallback logic a few lines later (change_amount < self.dust_threshold ⇒ fold into fee, line 318-323) — it's just unreachable here because the entry bar (required_amount) was computed with the wrong (change-inclusive) size assumption. Separately, SelectionError::InsufficientFunds.required should always be a quantity directly comparable to available (i.e. should include the fee actually being tested), never a bare target_amount.
select_coins_with_size (line ~155-162 across versions): pre-check compares total_available to bare target_amount — fine on its own, not part of the bug.
accumulate_coins_with_size (line ~291-345): the per-step required_amount uses a base_size sized for 2 outputs (destination + change) whenever a change address is configured, even before it's known whether the accumulated inputs will actually need one.
Repro, with exact figures from a real dash-evo-tool mainnet run: a 10,000,000-duff UTXO, LargestFirst strategy, FeeRate::normal() (1 duff/byte), 1 output of 9,999,780 duffs (balance minus a correctly-computed no-change 192-byte-tx fee of 220 duffs) reproducibly returns InsufficientFunds { available: 10000000, required: 9999780 } instead of building a 1-output, no-change transaction (true no-change fee ≈ 220-226 duffs, well within the available balance).
TL;DR: A send that would exactly drain a wallet's UTXOs (or leave only dust change) is rejected by
CoinSelector/TransactionBuildereven though the wallet holds enough funds to cover it, and the resultingInsufficientFundserror reports figures that don't make sense together (availablebigger thanrequired).User story
As a wallet integrator (e.g. dash-evo-tool), I want a send that would leave zero or dust change to fold the remainder into the fee and build successfully, so that a "Max send" / drain-style transaction always succeeds instead of failing every time.
As a wallet integrator, I want
SelectionError::InsufficientFundsto reportavailable/requiredas directly comparable quantities, so the error can be surfaced to the end user without misdirecting them toward the wrong cause (e.g. "check your balance" when the balance is actually sufficient).Scenario
Base flow
A wallet holds a single UTXO of 10,000,000 duffs (0.1 DASH). The caller computes the maximum sendable amount as balance minus a properly no-change-sized fee estimate (192 bytes for a 1-input/1-output tx at 1 duff/byte, plus safety margin ⇒ 220 duffs), giving a send amount of 9,999,780 duffs. It calls
TransactionBuilderwithSelectionStrategy::LargestFirst, that one UTXO as input, one output of 9,999,780 duffs, and a change address set (the normal call shape — the caller has no way to know in advance that no change will end up being needed).Actual behavior
select_coins_with_size's pre-check (total_available < target_amount, coin_selection.rs:157-160) passes fine (10,000,000 ≥ 9,999,780). Butaccumulate_coins_with_size(coin_selection.rs:291-345) is handed abase_sizefromTransactionBuilder::calculate_base_size()(transaction_builder.rs:170-193) that always reserves room for a change output wheneverchange_addr.is_some()— regardless of whether change will actually survive. That inflatesrequired_amount = target_amount + estimated_fee(line 312) to more than the single UTXO can cover (10,000,006 > 10,000,000), so the loop never satisfies its own bar and falls through to the terminal:(coin_selection.rs:341-344)
So the error compares
available(10,000,000) torequired(9,999,780) and reads backwards —available > required— even though the actual constraint that failed was fee-inclusive (10,000,006 needed).Expected behavior
When a UTXO set can cover the target amount plus a correctly-sized no-change fee, but not "target + fee assuming change", the builder should retry (or fold the leftover into the fee directly) rather than reject outright — the function already has this exact fallback logic a few lines later (
change_amount < self.dust_threshold ⇒ fold into fee, line 318-323) — it's just unreachable here because the entry bar (required_amount) was computed with the wrong (change-inclusive) size assumption. Separately,SelectionError::InsufficientFunds.requiredshould always be a quantity directly comparable toavailable(i.e. should include the fee actually being tested), never a baretarget_amount.Detailed discussion
dev@19690d3(2026-07-15) —coin_selection.rsandtransaction_builder.rsare byte-identical to the last commits that touched them (03096dd0, feat(key-wallet): support for more account types + strategy to consume all utxo available #823 for coin_selection.rs; unchanged since), so this is not something a subsequent PR already fixed.select_coins_with_size(line ~155-162 across versions): pre-check comparestotal_availableto baretarget_amount— fine on its own, not part of the bug.accumulate_coins_with_size(line ~291-345): the per-steprequired_amountuses abase_sizesized for 2 outputs (destination + change) whenever a change address is configured, even before it's known whether the accumulated inputs will actually need one.LargestFirststrategy,FeeRate::normal()(1 duff/byte), 1 output of 9,999,780 duffs (balance minus a correctly-computed no-change 192-byte-tx fee of 220 duffs) reproducibly returnsInsufficientFunds { available: 10000000, required: 9999780 }instead of building a 1-output, no-change transaction (true no-change fee ≈ 220-226 duffs, well within the available balance).availableexceedsrequired, andMaxreliably produces the failing amount dash-evo-tool#909. That repo has a committed-but-unmerged offline repro test (wallet_backend::payments::tests::core_max_send_with_single_utxo_builds_without_change) exercising this exactTransactionBuilder/CoinSelectorAPI path and asserting the send should succeed.Prior work
TransactionBuilder::build/build_signedunder-reported the paid fee on a successful dust-drop. Related area (dust folded into fee) but a different specific defect: that issue was about fee misreporting after a successful build; this issue is about the build being spuriously rejected before it ever gets there, plus the resulting error's misleading numbers.availableexceedsrequired, andMaxreliably produces the failing amount dash-evo-tool#909 — the original user report from the DET wallet UI that surfaced this.core_transactions"); confirmed to be a distinct bug inplatform-wallet-storage'score_state.rs, unrelated to this coin-selection issue.🤖 Co-authored by Claudius the Magnificent AI Agent