Repository navigation
key-wallet: coin selection rejects zero/dust-change sends with a misleading InsufficientFunds (available > required) #911
Description
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,000
required: 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.
Detailed discussion
- Confirmed present on
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.- Repro, with exact figures from a real dash-evo-tool mainnet run: a 10,000,000-duff UTXO,
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). - Downstream symptom: dash-evo-tool's own "Max send" button (which purposely computes an amount that leaves zero explicit change) is the one control most likely to trip this every time — see Send fails with InsufficientFunds when change would be zero — reported
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
- key-wallet: TransactionBuilder build/build_signed return the size-based fee, not the fee the tx actually pays (dust-drop under-report) #871 (closed via fix(key-wallet): return the fee the tx actually pays from build/build_signed #872) —
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. - Send fails with InsufficientFunds when change would be zero — reported
availableexceedsrequired, andMaxreliably produces the failing amount dash-evo-tool#909 — the original user report from the DET wallet UI that surfaced this. - platform-wallet: confirmed UTXO height never mirrored back from core_transactions — asset-lock funding fails with "No UTXOs available" after restart platform#4178 — a separate complaint raised alongside fix(key-wallet): out-of-order UTXO spend recorded nowhere — phantom unspent balance (#649) #909 ("UTXO height never backfilled from
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
Activity