Running the UTXO chains

Bitcoin, Litecoin, Dogecoin and Dash in operation — the unspent-output screen, LOCKED reservations, PSBT batching, the per-chain cooldown, fee tiers, fragmentation and the ZMQ realtime path.

9 min readUpdated 6 August 2026utxo, bitcoin, withdrawals, psbt, fees, zmq

Bitcoin, Litecoin, Dogecoin and Dash do not behave like the account-model chains. There is no balance to send from — there is a set of unspent outputs, each of which must be spent whole, and every withdrawal is a transaction assembled from some of them. Almost everything that goes wrong on these four chains is a consequence of that one difference.

This page covers the operational surface: what the UTXO screen shows, what the withdrawal path does with the set, and the four settings that change its behaviour.

The spending pool

The custodial ledger — wallet.balance in MySQL — is the source of truth for what each customer owns, and it is checked and debited under a row lock before a withdrawal is queued.

On-chain, it works differently. Every deposit address for a currency forms one spending pool. A withdrawal may be funded by outputs sitting on other customers' deposit addresses, each input signed with its own wallet's key inside a single PSBT. That is deliberate: balances acquired through internal trades and transfers move the ledger only, never the chain, so scoping coin selection per-user would make them unwithdrawable.

A single transaction is capped at 100 inputs.

The unspent-output screen

Admin → Ecosystem → Wallets → UTXO, at /admin/ecosystem/utxo.

Lists tracked unspent outputs

The screen is view-only: the table declares view access and nothing else, so there is no create, edit or delete row action on it. (Create and status-change endpoints do exist behind create.ecosystem.utxo and edit.ecosystem.utxo, but nothing in the panel calls them, and hand-editing a status is how you produce a double-spend or a permanently unspendable output.) Treat this as a diagnostic screen: you open it to answer "why did that withdrawal refuse" or "why is this payout stuck".

Column What it is
Wallet The ECO wallet the output belongs to, shown by currency
Amount The output's value, to 8 decimal places
Status UNSPENT, LOCKED or SPENT
Index The output index within its transaction
Script The locking script (scriptPubKey)
Created At When the platform recorded the row

Opening a row shows the fields the table has no column for, and they are the ones that matter: Transaction ID (an output is a transaction id plus an index — without it the row identifies nothing), Wallet ID, Origin, and, when the output is reserved, Locked by transaction.

origin records where the output came from, and it is how you tell customer money from platform bookkeeping:

Origin Meaning
DEPOSIT A real incoming customer deposit
CHANGE Change the platform produced from its own withdrawal
CONSOLIDATION Output of an automatic consolidation sweep
SYNC Backfilled by reconciling an address against the chain

The list column renders status with a boolean badge — anything non-empty paints green as "Available". A LOCKED and a SPENT output therefore both look available in the table. Open the row to see the actual three-state value; the view dialog reads it correctly and colours LOCKED amber.

LOCKED means reserved, not gone

LOCKED is the state between "this input was signed into a broadcast" and "that broadcast is settled". Locked inputs are excluded from selection, so a concurrent withdrawal cannot double-spend them.

Every locked row is tagged with the broadcast's transaction id in lockedTxId. That tag is what makes the promotion safe: promoteLockedUtxosToSpent moves LOCKEDSPENT for that txid only, so two withdrawals in flight at once can never promote each other's inputs. Pooled selection means one broadcast's inputs can span many wallets, which is exactly why the promotion cannot be scoped by wallet.

A row stuck in LOCKED means a withdrawal that was signed and never settled. Boot-time recovery is what resolves that, and it runs before the withdrawal queue takes new work:

  • PROCESSING with a transaction hash — already broadcast. It is promoted to COMPLETED without re-broadcasting, and its locked inputs are promoted to SPENT. Skipping that promotion is what would leave funds locked forever.
  • PROCESSING with no hash but a persisted pre-broadcast intent (txHashPending) — the chain is checked. Found on-chain, it completes and its inputs are settled SPENT. Not found, and older than 30 minutes, it is reverted to PENDING for a genuine retry.
  • PENDING — simply re-enqueued.

UTXO chains are the only family where recovery performs that verification. Tron's TRC20 handler does persist a pre-broadcast intent too — TRON transaction ids are deterministic before broadcast, so it writes txHashPending between signing and sending — but the recovery branch above is gated on the four UTXO chains and never checks it. Every other path writes the hash only after a successful broadcast: EVM, Solana, TON, Monero and Tron's native TRX withdrawal. So a stale PROCESSING row on any non-UTXO chain is left alone for a human rather than risk a double payout, Tron included.

PSBT batching

One on-chain transaction can pay many recipients, and it pays one network fee however many that is. The queue exploits that.

Milliseconds to park a lone UTXO withdrawal so later withdrawals on the same chain and currency can join its transaction. 0 sends immediately.

At 0 — the default — nothing is delayed, but withdrawals that happen to be queued together are still batched together. Set it to a non-zero value and a withdrawal with no companion is parked until createdAt + window, still PENDING, with a log line naming the wait:

Parking BTC withdrawal <id> for 60s to allow batching (UTXO_BATCH_WINDOW_MS)

A companion arriving during the window releases the parked one early so both ride the same transaction. A batch takes at most 20 recipients (MAX_BATCH_RECIPIENTS); members must share the chain and the currency.

The trade-off is plain: a longer window means fewer transactions and less fee per customer, and a slower withdrawal for the first person in the window. Sixty seconds on a busy Bitcoin desk is a large saving; sixty seconds on a quiet one is sixty seconds of a customer watching a spinner for no benefit.

Failure is isolated per member. One member that cannot be paid is failed and refunded on its own and the rest are retried; a whole-batch failure with more than one member retries each member individually.

The per-chain cooldown

Before processing any withdrawal, the queue enforces a 5-second minimum gap between withdrawals on the same chain (CHAIN_COOLDOWN_MS, a constant — there is no setting for it). It exists to keep explorer and RPC providers from rate-limiting the platform with 429s.

The ceiling that follows is twelve on-chain transactions per chain per minute, which is a real constraint on a busy desk — and the reason batching matters. With UTXO_BATCH_WINDOW_MS at zero and withdrawals arriving one at a time, twelve per minute is also twelve customers per minute. With batching on, twelve transactions can carry up to 240 recipients.

Chains are independent of one another: the cooldown is tracked per chain, so Bitcoin and Litecoin do not queue behind each other.

Fee rate selection

Fee tier used for Bitcoin withdrawals. Vocabulary differs per provider; an unrecognised value silently falls back to the provider's default.

The valid values depend on which provider BTC_NODE selects, and the two vocabularies share not one word:

Provider path Accepted tiers Default
mempool (esplora) fastestFee, halfHourFee, hourFee, economyFee, minimumFee halfHourFee
blockcypher (fees read from blockchain.info) regular, priority regular

Setting priority on the mempool provider, or fastestFee on the BlockCypher path, does not error — the key is absent from the response and the provider's default is used instead. The chain diagnostics catch this: the Requirements test runs a Fee rate tier "<tier>" resolves check and lists the tiers the provider actually returned when it fails.

This variable is Bitcoin-only in effect as well as in name. Litecoin, Dogecoin and Dash read BlockCypher's medium_fee_per_kb and have no tier selection.

If the fee lookup fails for any reason, the provider returns 1 sat/vByte and the withdrawal is built and broadcast at that rate. It will not fail; it may simply never confirm. If UTXO withdrawals start sitting unconfirmed in the mempool, check the fee source before anything else.

"Not economical"

Before a UTXO withdrawal is allowed to debit anything, the platform walks the pool largest-output-first, adding inputs until the requested amount plus the estimated network fee is covered. If it never gets there, the request is refused with 400 and one of two messages:

  • Available UTXOs are too small to cover network fees. Total pooled value: X BTC, estimated fee: Y BTC. — the whole pool is worth less than it costs to spend.
  • Insufficient pooled on-chain liquidity. Maximum possible withdrawal right now: X BTC. — there are coins, but not enough after fees, and the message tells you the ceiling.

This is a statement about fragmentation, not about the customer's balance. Their ledger balance is checked separately, and it can be perfectly adequate while the pool behind it is a thousand dust outputs.

Read it on the UTXO screen: filter to the currency and look at the amounts. Many tiny UNSPENT rows is fragmentation. A handful of large ones and the refusal means the pool genuinely is short.

The platform tries to fix this itself before giving up. Two things happen first:

  1. Reconciliation. The wallet's own address is re-read from the chain once, backfilling any confirmed output that has no database row — these appear with origin: SYNC. If anything was recovered, the economics are recomputed.
  2. Automatic consolidation. If the pool's average output is worth less than three times what it costs to spend one, or if there are ten or more outputs at all, a consolidation sweep runs at up to 10 sat/byte and the economics are recomputed again. Its output is recorded with origin: CONSOLIDATION and is spendable immediately, unconfirmed.

Only after both is the withdrawal refused, and the message then reads Even after consolidation: <reason>.

Realtime detection over ZMQ

Running a self-hosted Bitcoin Core node unlocks zero-confirmation deposit detection. Two conditions, both required:

  • BTC_NODE=node — ZMQ is initialised from the Bitcoin node provider only. Setting it under mempool or blockcypher does nothing at all.
  • BTC_ZMQ_RAWTX is set — its presence is the on/off switch for the whole ZMQ service. There is no separate enable flag.

The diagnostics warn about exactly the mismatch people hit: BTC_ZMQ_RAWTX is set but ZMQ only initializes when BTC_NODE=node — realtime detection is currently off.

Three sockets are opened: rawtx (instant detection), rawblock (confirmation updates) and hashtx (a lightweight monitoring stream).

ZMQ rawtx publisher endpoint, matching bitcoin.conf zmqpubrawtx. Presence enables the ZMQ service; only read when BTC_NODE=node.
ZMQ rawblock publisher endpoint, matching zmqpubrawblock.
ZMQ hashtx publisher endpoint, matching zmqpubhashtx.

It is read into the service's configuration and its socket is never connected. The Requirements screen flags it as dead. Remove it from .env rather than wondering why block notifications do not arrive on it.

If the native ZMQ addon is not present in the install, the service is constructed and stays inert — no sockets, no error, and deposits keep arriving through polling. That is the intended degradation, not a fault.

The Bitcoin deposit scanner

Bitcoin has a deposit scanner of its own, in addition to the per-session monitor and the background sweep. It is registered as the btcDepositScanner job at Admin → System → Cron with a 60-second period.

start() arms the scanner's own 60-second interval. Deregistering the cron entry leaves that interval running, which would mean deposits still being credited after Ecosystem was turned off. The job therefore registers a teardown that is invoked when the extension is disabled, and that teardown is what actually stops it.

If you disable the extension and still see Bitcoin deposit crediting in the logs, the process did not pick up the change. Restart it.

The background sweep named above is not UTXO-specific. It is the backgroundDepositScanner job, which re-scans recently-active deposit addresses on every chain — registered when a customer opens a deposit page, kept for 72 hours — on one shared cadence:

How often the background scanner re-scans each recently-active deposit address, on any chain.

UTXO_CHECK_INTERVAL_MS is listed on the Requirements screen and does not drive that sweep. It tunes a separate monitorDeposits() loop that is gated behind ENABLE_DEPOSIT_MONITORING=true (off by default) and is not started from any current code path, so changing it has no effect on deposit detection.