UTXO Coin Selection in Production

Maciej Lewandowski · · Field notes from custody & wallet infrastructure

On account-based chains a balance is a number. On Bitcoin it is a set of unspent outputs, and each withdrawal is a small optimization problem: which coins do you spend, what does it cost, what does it leak, and what mess does it leave for the next withdrawal. Coin selection is where those trade-offs stop being theoretical.

The shape of a withdrawal

To withdraw 0.5 BTC you pick a subset of your UTXOs summing to at least 0.5 plus fees, where the fee depends on how many inputs you picked (each input adds bytes, each byte costs the current feerate). Then you produce a change output for the remainder and accept that your choice changed the shape of your wallet for each future withdrawal. "Subtract 0.5 from balance" hides all of that. Pick greedily and you buy three distinct production problems: fees, dust, and privacy.

UTXO set (your "balance") 1.20 ✓ 0.30 ✓ 0.008 dust 0.50 reserved transaction inputs: 1.20 + 0.30 withdraw 1.40 1.400 → recipient 0.098 → fresh change 0.002 → fee dust: excluded · reserved: held by another in-flight withdrawal

Fees: inputs are the expensive part

Each input costs bytes, and bytes cost money. A withdrawal funded by fifty small deposits can cost multiples of one funded by a single large UTXO, and at high feerates small UTXOs become economically unspendable: the fee to consume them exceeds their value.

One way to save bytes is to avoid change altogether. Branch-and-bound style selection, which Bitcoin Core popularized, looks for input sets that hit the target without a change output. No change means fewer bytes, lower fees and one less output to track. Keep it in the toolbox, but with a busy wallet the hit rate is modest, so build the rest of the system as if change is the normal case.

The other lever is consolidation. Fragmentation is deposit-driven, since each client deposit adds a UTXO. Teams that keep their wallets cheap to operate sweep small outputs into larger ones during low-fee windows, as scheduled maintenance. Teams that skip this meet the problem during a fee spike, when consolidation costs the most and withdrawals are at their most expensive.

Dust

Dust is any output too small to be worth spending. It arrives two ways: as change you created yourself, and as deposits you did not ask for. You need a policy for both.

For your own change, the rule is short: if the would-be change output falls below the dust threshold, fold it into the fee and write a satoshi-perfect ledger entry explaining the difference. Keep the dust output instead and you have created a liability with a maintenance cost.

Dust you receive is a different animal, because dusting is also a tracking technique. An attacker sends tiny amounts to many addresses and watches which ones you later spend together, clustering your wallets. Decide the stance up front: quarantine incoming dust by default, exclude it from selection, and if you consolidate it at all, do it through a path where you accept the linkage.

Privacy

When you spend UTXOs together you link them in the public record for good: the common-input-ownership heuristic is the bread and butter of chain analytics. An institution that ignores this broadcasts its own operational graph, which addresses belong together, how funds flow between them, and roughly when.

Send change to a fresh address each time; HD derivation exists for this. Reuse addresses and you turn your transaction history into a public dashboard of your flows. Consolidation makes an even louder statement: merging hundreds of UTXOs into one output stamps them with common ownership for anyone watching. That trade is acceptable when you chose it as part of a scheduled sweep, and expensive when a careless selection made it for you.

Concurrent withdrawals

Real custody systems process withdrawals in parallel, and a UTXO can fund exactly one transaction. Skip reservation and two concurrent withdrawals will select overlapping inputs; the network rejects the loser as a double-spend attempt, monitoring flags it, and it sits in the audit trail looking far worse than it was.

Treat UTXOs like inventory: selection places a hold, broadcast consumes it, failure releases it, and crash recovery resolves in-flight holds by checking what reached the chain. If a wallet design never mentions holds, releases or crash recovery, its coin selection is unfinished.

Two operational corollaries follow. First, reserve fee-bumping capacity: commit all of your confirmed UTXOs to pending withdrawals and you have nothing left to CPFP a stuck transaction with, at the moment you need it most. Second, batching (one transaction, many withdrawal outputs) is a huge fee win, but it couples customers: one stuck batch is forty stuck withdrawals with one shared fee-bump decision. Batch, and cap the blast radius when you do.

What held up in production

We ended up reporting spendable balance from selectable UTXOs rather than from the raw sum, because the difference (dust, unconfirmed, reserved) is real. Selection knew the feerate before choosing inputs, not after. Change below the dust threshold went into the fee, with a ledger entry explaining it, and each transaction sent change to a fresh HD-derived address.

Incoming dust sat in quarantine, excluded from selection by default. Consolidation ran as scheduled maintenance in low-fee windows, with the privacy linkage accepted as part of the decision. UTXO reservation shipped with holds, releases and crash recovery, and we tested the recovery path rather than assuming it. We kept spare UTXOs for CPFP instead of committing the whole wallet to in-flight spends, and we batched withdrawals with a bounded blast radius.

Coin selection looks like an algorithm question. In production it is an inventory management problem, a cost problem, a privacy problem and a concurrency problem wearing the same trench coat. Design for all four.