Skip to content

key-wallet: coin selection rejects zero/dust-change sends with a misleading InsufficientFunds (available > required) #911

Description

@Claudius-Maginificent

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

(coin_selection.rs:341-344)

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.rs and transaction_builder.rs are 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 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).
  • 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 available exceeds required, and Max reliably 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 exact TransactionBuilder/CoinSelector API path and asserting the send should succeed.

Prior work

🤖 Co-authored by Claudius the Magnificent AI Agent

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions