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.
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.
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
LOCKED → SPENT 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:
PROCESSINGwith a transaction hash — already broadcast. It is promoted toCOMPLETEDwithout re-broadcasting, and its locked inputs are promoted toSPENT. Skipping that promotion is what would leave funds locked forever.PROCESSINGwith no hash but a persisted pre-broadcast intent (txHashPending) — the chain is checked. Found on-chain, it completes and its inputs are settledSPENT. Not found, and older than 30 minutes, it is reverted toPENDINGfor 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.
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
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:
- 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. - 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: CONSOLIDATIONand 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 undermempoolorblockcypherdoes nothing at all.BTC_ZMQ_RAWTXis 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).
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:
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.
Related
- Supported blockchains — provider selection and the
<CHAIN>_NODEfallbacks - Chain families — how UTXO differs from the account-model chains
- Deposit wallets and custody — detection, crediting and the withdrawal queue
- Admin console — the screens named here