Exchange Engine 6.5.3
Latest18 September 2026
This release has upgrade notes. Read them before updating — they describe behaviour changes that need your attention.
Ecosystem v6.5.3
Release Date: September 18, 2026 Tags: WITHDRAWALS, UTXO, BITCOIN, BATCHING, DEPOSITS, ORDERS, MATCHING-ENGINE, RECONCILIATION, CANCELLATIONS, CUSTODY, TRANSFERS, CHAIN ADDONS
Overview
A token withdrawal whose transaction reverted on-chain ended as COMPLETED, and the customer was never refunded. The check that waits for confirmation read a reverted receipt as "not mined yet". The broadcast hash then blocked the refund, and the recovery sweep promoted the row to COMPLETED without checking the chain. The customer was debited and received nothing. Their withdrawal looked successful and carried a real transaction hash. A revert is now recognised as soon as its receipt is read, and the withdrawal is marked FAILED and refunded.
An order could be matched before the money behind it was locked. Placement wrote the order first and took the funds second. A fill in that gap could be paid out of the hold on another of the customer's orders. Placement now locks the funds before the order exists.
The inOrder reconciler's --apply unlocked money that was backing live orders.
It left the fee out of every open BUY's expected hold, and it read the wallets after
the order scan. Both mistakes made it release too much. The corrected script ships in
Core v6.7.9. If you have run --apply before, read Upgrade Notes.
A cancellation could report funds as returned when the wallet had not released them. Copy trading freed a follower's budget based on that figure. Cancels now report what the wallet actually released, and they log an error when it falls short.
No BTC withdrawal could be built against a Bitcoin Core node that does not run
-txindex. With BTC_NODE=node, the node could not return the confirmed parent
transactions that a withdrawal signs against. The live deposit monitor could not read a
confirmed deposit either, and the pending-deposit check could never confirm one. Both
now read through the node's wallet RPC. Withdrawals also fall back to the other
explorer backends when the node cannot answer.
BTC, LTC, DOGE and DASH withdrawals can now be grouped into one transaction. A new "Group withdrawals" mode sends everyone who withdraws inside the batching window in one transaction, with up to 20 recipients. It needs a batching window set in the backend environment first.
Requires Core v6.7.9. This release loads the Solana, Tron, TON and Monero chain services through a helper that ships in Core v6.7.9. On Core v6.7.8, every withdrawal on those chains fails before anything is sent and is marked FAILED and refunded. Several repairs in this note also live in Core files and arrive only with Core v6.7.9.
Update Instructions
pnpm updatorUpdate Core to 6.7.9 first, or in the same maintenance window. This release calls a shared chain-service helper that lives in Core and does not exist on Core v6.7.8. See Upgrade Notes.
The withdrawal queue, order placement, the Bitcoin node provider and the custody figures all run on the server and need the backend restart. The new Batching setting on the Ecosystem settings page is part of the browser bundle and needs the frontend rebuild.
- To use withdrawal batching, set
UTXO_BATCH_WINDOW_MSand restart the backend. Neither batching mode does anything without it. See Upgrade Notes. - If you have ever run
pnpm reconcile:eco-inorder --apply, take Core v6.7.9 and run it again without--apply. See Upgrade Notes. - If you run the Solana, TON, Tron or Monero chain addons, take Core v6.7.9 before, or together with, any chain package built from this release.
Upgrade Notes
Take Core v6.7.9 with this release
- This release calls a shared helper that lives in Core and does not exist on Core
v6.7.8. On Core v6.7.8, every Solana, Tron, TON and Monero withdrawal fails before
anything is sent. It is marked FAILED and refunded, with a reason saying
chainServiceInstanceis not a function. The Solana chain-table fetch, the transaction-history fetcher and native-coin settlements on those chains fail the same way. - Solana, TON, Tron and Monero chain addon packages built from this release load two shared helpers from their new Core location. Update Core before, or together with, any of them. On Core v6.7.8 that chain service does not load, and withdrawals on that chain fail before anything is sent.
- The following repairs ship in Core files, even though only an Ecosystem install can
reach them. They arrive only with Core v6.7.9:
- the corrected expected hold and read order in the
inOrderreconciler (backend/scripts/reconcile-eco-inorder.mjs); - recording a transfer into an ecosystem wallet under the receiving wallet's own asset, not the asset the customer sent;
- refusing to save the batching mode through System → Settings;
- the clear "chain addon is not installed" message on the Pool Backing console's chain reads.
- the corrected expected hold and read order in the
Withdrawal batching does nothing until you set a window
- Neither mode does anything until a batching window is set. The window is the backend
environment variable
UTXO_BATCH_WINDOW_MS. It defaults to 0 and is not in.env.example. - Set it to the longest wait you are willing to give customers (for example
60000for one minute) and restart the backend, because the value is read only at startup. - Then pick the mode under Ecosystem → Settings → Withdrawals → Batching.
- If Group withdrawals is selected with no window set, the backend writes a one-time warning to its WITHDRAW log the next time a BTC, LTC, DOGE or DASH withdrawal comes through.
If you have run the inOrder reconciler with --apply before
- Update Core to 6.7.9, because the script ships in Core.
- Run
pnpm reconcile:eco-inorderagain without--apply. A wallet whose open BUY orders lost their fee share may now appear under 'UNDER-locked'. The tool reports those wallets but never changes them. - Run
--applyonly while the wallets it will touch are not trading.
Order rows that are already corrupted stay until you clean them up
- This update does not remove rows that are already corrupted, because the cancel path never deletes ledger rows.
- On the admin Ecosystem orders page (
/admin/finance/order/ecosystem), click Cleanup Corrupted Orders, then Scan First, then Clean Up Orders. - The button scans at most 10,000 orders.
POST /api/admin/ecosystem/order/cleanupaccepts alimitof up to 100,000.scanTruncated: truein its response means the scan stopped before the end of the table. - A corrupted row has no symbol left to identify its open-orders index row. The cleanup
therefore cannot remove that index row, and the engine loads it as a live resting
order at every boot. If any rows were deleted, run
pnpm eco:index:repairand then restart the backend, as the cleanup's own log line says.
Added
Choose how BTC, LTC, DOGE and DASH withdrawals share one on-chain transaction
- Added a Bitcoin-style Withdrawal Batching setting under Ecosystem → Settings → Withdrawals → Batching, with two modes. Send promptly is what the platform has always done, and it stays the default. A waiting withdrawal is released as soon as a second one arrives, and both go at once. That keeps the first customer's wait short, but it can only ever pair withdrawals two at a time, however long the batching window is. On a live install with a 60-second window, three customers withdrew within three seconds of each other and got two transactions: a pair, then the third on its own after waiting out a new window.
- The Group withdrawals mode holds new arrivals together until the first one's window runs out. Everyone who withdraws inside that window then goes in one transaction, with up to 20 recipients. The transaction uses one set of inputs and one change output for all of them, instead of one per pair. The group leaves when the earliest member's window runs out, so nobody waits longer than their own window, however many join behind them. It leaves early once 20 are waiting, because a 21st cannot fit in the transaction anyway. Measured on a live install at the same fee rate, the same three withdrawals cost 317 satoshis as one transaction against 541 as two — about 40% less, and the saving grows with every recipient added.
- Spot custody sweeps are still never batched with anything, in either direction. The
mode is read fresh on every withdrawal, so a change saved in the console applies to
the next withdrawal without a restart. Neither mode does anything until
UTXO_BATCH_WINDOW_MSis set. See Upgrade Notes.
Changed
The batching mode can only be changed from the Ecosystem console
- Changed the general System → Settings save so that it refuses
ecosystemUtxoBatchModewith a 403. The message names Ecosystem → Settings → Withdrawals → Batching as the place to change it. The general endpoint does not check values. If it accepted a mode the withdrawal queue does not recognise, the queue would treat it as the default and quietly ignore what the saved setting says. The System Settings page has no field for this key, and a full save that leaves the value unchanged still goes through, so nothing else on that page is affected. This guard ships in a Core file, so you need Core v6.7.9 to get it.
A placement refused by a busy wallet now says the wallet is busy, not that the server failed
- Changed what a placement returns when the wallet is busy. The hold now runs
before anything is written, so a refusal from the wallet's own queue reaches the
caller unchanged. It is a 503 "The wallet is busy ... Please retry." (code
WALLET_BUSY), which the Hummingbot endpoints already turn into -1001. Before, the refusal came after an order row had been written and deleted again. It was flattened into a 500 "Failed to update wallet balance. Order rolled back.", and that was what callers normally saw. TheECO_PREHOLD_ADMISSIONearly check is off unless set. Even when it is on, it only refuses a queue that is already full, and it never catches a hold that waited out the 30-second deadline. An out-of-funds refusal keeps its 4xx and its shortfall figures. It and the other hold refusals now end "No order was created." instead of "Order rolled back."
Calls to a Bitcoin Core node now give up after 15 seconds
- Changed every RPC call to the node, with
BTC_NODE=node, to give up after 15 seconds. Before, a call had no time limit of its own. Some of these calls, such as reading a withdrawal's parent transactions, are made with the pool's UTXO rows locked. A node that accepted a connection and then stopped answering could hold those locks for minutes and stall every withdrawal queued behind it. The limit covers every call to the node, including the full UTXO-set scan (scantxoutset) that some balance reads fall back to. If your node needs longer, setBTC_NODE_TIMEOUT_MS(in milliseconds).
The two helpers shared by the chain withdrawal handlers now live in Core (from v6.7.9)
- Changed where two helpers live. Ecosystem v6.5.0 shipped them into
backend/src/blockchains/.withdrawal-outcome.tsdecides whether a failed Solana, TON or Tron broadcast is marked FAILED and refunded or held as TIMEOUT for manual review. It also decides when a received Monero transfer can be spent.network-fee-tracker.tsrecords the network fee that a TON, SOL or native-TRX withdrawal burns from the customer's own address. Chain addons are dropped into that folder, so the files reached only installs that had bought Ecosystem. The Staking addon, which does not require Ecosystem, needs them too. - From Core v6.7.9 they live in
backend/src/utils/and every install gets them. The code inside is byte for byte what v6.5.2 carried. Nothing in the Ecosystem addon itself calls them. The callers are the Solana, TON, Tron and Monero chain services and the Staking addon's on-chain engines. The old paths stay as forwarders until every chain addon has shipped a rebuilt package, so chain packages built before the move still find the helpers. See Upgrade Notes.
Fixed
A token withdrawal showed COMPLETED with a real transaction hash, but nothing arrived at the customer's address and their balance was never given back
An EVM token withdrawal whose transaction was mined and then reverted burned gas and moved nothing. Native-coin EVM withdrawals were not affected. They wait for two confirmations before the hash is stored, and that wait already rejected a revert, whereas the token movers return as soon as the transaction is broadcast. The confirmation check recognised only a receipt that said "succeeded". It treated a receipt that said "reverted" the same as "no receipt yet". So it waited ten more five-second rounds for a verdict that could never change, then threw a plain "Transaction failed". By then the row already carried the broadcast hash, and every other guard reads a hash as "the money has left". The queue's failure handler therefore refused to mark the row FAILED or refund it, and the row stayed PROCESSING. The next recovery sweep saw PROCESSING with a hash and promoted it straight to COMPLETED without checking the chain. The customer was debited, received nothing and was not refunded. The withdrawal read as successful, with a hash that an explorer shows moving nothing.
- Fixed so that a reverted receipt is treated as final as soon as it is read. The confirmation check stops at once and logs the revert instead of spending fifty seconds on it, and the recovery sweep now checks the chain before it promotes anything. A receipt that says succeeded completes as before. A receipt that says reverted is marked FAILED and refunded. The reverted hash is kept in the row's metadata, and the description reads "Transaction reverted on-chain (…); nothing was transferred." A row with no receipt at all is left for the next sweep rather than completed unmined, and a node that cannot be reached is never read as a revert. Chains the sweep cannot check this way (UTXO, Solana, TON, Monero, Tron) keep their previous behaviour, so nothing is stranded. The refund is idempotent, so a second pass cannot pay twice. Admins get an in-app notification for every revert.
An order could be matched before the money behind it was locked
Placement wrote the order first and took the funds second. The order and its
open-orders index row were written together, with status OPEN. The matching engine
picks orders up from that index when it re-reads it, and it never checks whether an
order is funded. So an order could be matched before its wallet hold existed. The hold
waits in the per-wallet queue, which allows up to 30 seconds by default, so the gap was
longest under exactly the load that slows a hold down. A fill in that gap was checked
against the wallet-wide inOrder balance. If the customer had any other resting order
in that currency, the fill was paid out of that other order's hold. If they had none,
the fill was refused. If the process died inside the gap, the order stayed OPEN with
nothing held.
- Fixed so that placement takes the hold before the order row exists. Both steps
run inside the same placement budget, so a placement refused at the budget limit still
changes nothing. If the write then fails with a validation refusal (a 400, where no
row can have been written), the hold is released. If the write fails ambiguously, the
hold is kept on purpose and an error is logged naming
pnpm reconcile:eco-inorder --user <id>. A database coordinator timeout on a write that may still apply is an example. Releasing in that case could leave an OPEN order with no funds behind it. The worst case is now a hold with no order, which the reconciler can return. Before, the worst case was a fill paid from another order's money, which nothing could undo.
The inOrder reconciler's --apply unlocked money that was backing live orders
There were two separate defects, and both made the tool release too much. First, the
expected hold for an open BUY was cost × remaining ÷ amount, leaving out the fee.
Placement holds cost + fee, and a cancel refunds (cost + fee) × remaining ÷ amount. So
every open BUY showed a surplus equal to its share of the fee, and --apply unlocked
that amount from under a live order. The error scales with the fee rate and adds up
across every open BUY on a wallet. Second, the tool scanned the OPEN orders before it
read the wallets. An order placed between the two reads was counted in inOrder but not
in the expected total, so its whole hold showed as surplus. Several new error logs in
this release send you to this script, so it had to be right.
- Fixed the expected hold for a BUY to include the fee. It uses the same integer arithmetic as the cancel path, so the two cannot disagree even by a wei. The wallets are now read before the order scan, so an order placed during the scan makes the surplus look smaller, not larger. The reverse case remains: an order that fills or is cancelled during the scan still makes a surplus look larger than it is. Dry-run mode prints the corrected figures. The script ships in Core, so this needs Core v6.7.9. See Upgrade Notes.
A cancellation said it had returned funds the wallet had not released
A cancel refund can only return what inOrder actually holds. If a concurrent fill has
already paid part of it out, the wallet releases less, or nothing, and logs that it did
not credit the surplus. The ecosystem cancel routine that the other cancel paths share
ignored this and reported the amount it had asked for as released. It passes that
figure back to the code that called it. The engine's market-order expiry logs the
figure as "released", and copy trading frees a follower's allocation from it. So a
follower's budget could be freed for money that never came back.
- Fixed the shared cancel routine to report the amount the wallet actually
released. Four paths now log an error when a release comes up short: that routine,
the single-order cancel route, cancel-all, and the order teardown that runs when an
admin deletes an ecosystem market. Each error names the order, the amount released
against the amount asked, and
pnpm reconcile:eco-inorder. The wallet's own log line for this case is now an error, not a warning. The order is still marked CANCELED. The missing amount has either already reached the customer through a concurrent fill or is stranded ininOrder. Refusing the cancel would block correct cancellations in the common harmless case.
No BTC withdrawal could be built against a Bitcoin Core node that does not run -txindex
Signing a pooled input needs the full parent transaction. That transaction was fetched
from the single configured backend, with five tries three seconds apart. A Bitcoin Core
node without -txindex cannot return a confirmed parent at all, so all five tries
failed the same way. The withdrawal, or UTXO consolidation, failed with them. Retrying
the same backend could never have helped.
- Fixed so that with
BTC_NODE=nodethe node is asked for a parent three ways. It tries the wallet RPC first, which needs no-txindexfor an address the platform watches, then the plain call, then the call scoped to the block the wallet named. If the node still cannot answer, the other explorer backends for the chain are asked in turn. This fallback applies whichever backend you have configured. Whatever comes back is hashed and refused unless it really is the transaction that was asked for. A backend pointed at the wrong network or a fork therefore cannot supply bytes to sign against. These reads run with the pool's UTXO rows locked, so the platform stops trying after about 45 seconds. When a mempool.space mirror answers "not found", the platform now asks the other mirrors in the ring before accepting that answer. A genuine failure now names each backend it tried and the reason each one was refused.
With BTC_NODE=node, the live deposit monitor could not read a confirmed BTC deposit, and the pending-deposit check could never confirm one
The Bitcoin Core provider read a transaction with getrawtransaction. A node without
-txindex answers that call only while the transaction is still in the mempool, so a
confirmed deposit could not be read at all. The provider also fetched every input's
parent in one batch, and a single unreadable parent threw the whole transaction away.
For a deposit that is the ordinary case, because the parent belongs to the sender.
Either way the provider returned nothing. The live deposit monitor behind the
customer's deposit page then logged "Failed to process confirmed transaction" and
retried on every poll without crediting. The pending-deposit check could never confirm
the deposit, so it stayed PENDING. The 60-second BTC deposit scanner reads the node by a
different route and still credited these deposits.
- Fixed so that the provider reads through the node's wallet RPC. Without
-txindex, that RPC still answers for any transaction paying a watched address. An input whose parent cannot be read is now left unresolved, and the rest of the transaction is still used.
With a Bitcoin Core node, a BTC deposit could be recorded with a huge negative fee
The network fee written onto a UTXO deposit was worked out as inputs minus outputs.
With BTC_NODE=node, an input's parent transaction belongs to whoever paid the
customer. It is not in the platform's watch-only wallet, and once it is confirmed a
node without -txindex will not serve it. The deposit scanner kept such inputs at a
value of zero rather than drop the deposit. The subtraction then came out as minus the
whole transaction's output value, and that figure was stored as the fee on the
customer's deposit record. On a staging install, a 0.00387035 BTC deposit recorded a fee
of -27.77806163. Installs reading BTC from mempool.space or BlockCypher were not
affected, because those backends return the input values.
- Fixed so that the fee is worked out only when every input's parent was actually read. When one could not be read, the deposit records a fee of 0 rather than a made-up figure, and the fee can no longer come out negative. The amount credited was never affected, because the sender pays a deposit's network fee and the platform records it for information only. Deposits recorded before this update keep the fee they were given.
Addresses the platform's records showed as empty, or as holding half the amount, were ranked first when the engine looked for another address to pay a withdrawal
When the withdrawing user's own address cannot pay, the engine shortlists other addresses. It treats each one as holding its recorded balance minus the amount already lent out of it. The lent figure was read from the private ledger without a floor, and on a live install it can be negative. The internal transfer path writes the ledger with a negative amount when funds leave a wallet, but it never lowers the per-address record these queries read. Subtracting a negative adds. So an address recorded as holding 9.89 was ranked as holding 19.79, and an address recorded as empty qualified for a whole unit. Both queries that pick the paying address had this defect. Both disagreed with the coverage figures on the Ecosystem Custody page, which already refuse to count a negative ledger as extra cover. Each shortlisted address is checked on-chain before anything is spent from it, so the engine skipped a phantom address rather than paying from it. A phantom still ranked ahead of addresses that really held the coins. It cost an extra node read and took one of the five shortlist places. With enough phantoms, a funded address was never reached, and the withdrawal fell back to the master wallet or found no source at all.
- Fixed both queries that pick the paying address to treat a lent figure below zero as zero. One is the single-row picker and the other is the shortlist that the token mover checks on-chain. A bookkeeping discrepancy can no longer be read as evidence of spendable coins. The floor works one way only. It can lower what the engine believes an address holds but never raise it. A correctly recorded (non-negative) row is completely unaffected.
An order that no cancel could retire, and corrupted rows in the order list
When a fill was written, the order-ledger half of the write set only five columns: filled, remaining, status, updated time and trades. In ScyllaDB that write creates the row if it does not exist. So a fill against an order with no ledger row created one. The new row had those five columns and status OPEN, and nothing in symbol, side, type, price, amount, cost, fee, fee currency or wallet type. The open-orders index half of the same write already wrote the whole order, so one fill repaired the index and corrupted the ledger. The shared ecosystem cancel routine then read the empty symbol without checking it. Every attempt to cancel such a row failed with "Cannot read properties of null (reading 'split')".
- Fixed the ledger write to set the whole order: symbol, side, type, time in force, price, amount, cost, fee, fee currency and wallet type. A fill against a missing row now creates a complete order that can be cancelled. On a healthy row, the write sets the same values it already holds. The cancel routine now recognises a row with no usable symbol. It logs the row as a corrupted ledger row and answers "not found" instead of throwing. This update does not remove rows that are already corrupted. See Upgrade Notes.
A market buy placed on a process without the matching engine counted the customer's own sell orders as depth
When pricing a market order, placement leaves the customer's own resting orders out of
the depth it counts, because the matching engine never fills two orders from the same
customer against each other. That list came only from the matching engine's in-memory
book. A backend process that does not hold the matching-engine lease therefore left out
nothing. This affects installs where orders reach such a process. The usual case is the
dedicated trading process enabled (ECO_TRADING_ENABLED) while /api/ecosystem/order
or /api/hb/ still reach the web backend instead of the trading port. Orders go to the
process that runs the engine in the default single-backend layout. They also go there
in a trading split whose proxy sends those prefixes to the trading process. On an
affected process, a market buy could count the customer's own resting sells as
liquidity and be accepted and funded. It then filled only against other customers'
orders, and the rest was cancelled. That process never raised the 422 that explains
this ("N price level(s) are your own resting orders, which cannot fill against each
other").
- Fixed so that, when the engine is not in the process, placement reads the customer's own resting orders from the open-orders index, which any process can read. It removes them from the depth used to price the order. A market buy that other customers' orders cannot fill is now refused before anything is held. The refusal is the 422 that says how many price levels are the customer's own. The same netting applies to the market-sell price and to fill-or-kill depth checks. The self-match check still runs as before. If the index read fails, pricing works exactly as it did.
Solana, Tron, TON and Monero withdrawals and ecosystem screens failed with a raw JavaScript error instead of saying which chain addon is missing
Solana, Tron, TON and Monero support comes as separate chain addons. The loaders that fetch those chain services return nothing when the addon is not there. Several ecosystem paths used that result without checking it:
- all four non-EVM branches of the withdrawal queue
- the Solana entry in the chain table
- all four non-EVM branches of the transaction-history fetcher
- the native-coin settlement mover
- every chain read in the Pool Backing console
On an install missing one of those addons, a withdrawal on that chain ended FAILED and refunded with the reason "Transaction failed: Cannot read properties of null (reading 'getInstance')". The other paths showed a JavaScript property error, or a module path from the build directory such as "Cannot find module '…/dist/src/blockchains/sol'". The settlement mover had a written refusal, "the chain service is not available on this install". It could never fire for a missing addon, because the module error was thrown one line earlier. On the withdrawal queue, the chain table and the history fetcher, an addon that is installed but broken gave exactly the same message as one that was never bought. That message sends an operator off to buy something they already own.
- Fixed so that all of these load the chain service through one shared helper. When the addon is absent, it answers "The SOL chain addon is not installed on this server." When the addon is present but its module failed to load, it says so and quotes the load error, because the two need different fixes. A withdrawal refused this way still ends FAILED and refunded with nothing sent, but its reason now names the missing addon. The settlement mover's refusal is now the 503 it was always written to give. It is still raised before anything is signed. The Monero and TON entries in the chain table already answered this way and are unchanged. The transaction-history fetcher still reports its usual 500, now carrying the clear message. The Pool Backing console's chain reads are part of Core and get this change with Core v6.7.9. The shared helper itself ships in Core v6.7.9, and this release does not work without it. See Upgrade Notes.
After a transfer that changed currency, the Ecosystem Custody page showed the asset the customer sent losing cover
On a transfer into an ecosystem wallet, the transfer route passed the sender's currency
to the step that credits the wallet. That step wrote it straight to the private ledger
(ecosystem_private_ledger). The private ledger holds one row per wallet, address
index, currency, chain and network, and each row describes what that one wallet holds.
So the currency can only ever be the receiving wallet's own. A transfer out of a SPOT,
FIAT or FUTURES wallet into an ecosystem wallet in a different asset has two different
currencies. The receiving asset's row was never written, and a row appeared under the
asset the customer sent instead. The Ecosystem Custody page subtracts private-ledger
rows from an asset's recorded cover, per currency and across chains. So it deducted
cover from an asset that had not moved. On one install this was enough to report an
asset covered many times over as "Unbacked".
- Fixed so that a credit into an ecosystem wallet is recorded in that wallet's own asset. The currency now comes from the receiving wallet itself, not from whatever the caller passed. That covers all three places in the transfer route that credit an ecosystem wallet, and any future caller gets the same currency. The receiving asset's private-ledger row is written, and no row is created against the asset the customer sent. The repair applies to transfers made from now on. Rows already written under the wrong asset stay as they are. The transfer route is a Core file, so this repair arrives with Core v6.7.9.
The Ecosystem Custody page reported an asset "Unbacked / 0%" while its coins were sitting on another chain
The page summed every row of the private ledger and subtracted the total from the asset's recorded holdings. The withdrawal path does not deduct those rows the same way. The queries that choose which address pays a withdrawal start from the custody address records. They match ledger rows to those records by wallet, chain and currency. A ledger row with no custody address behind it is never matched and never reduces a payout by a single unit. Those unreachable rows still reduced the reported cover. The per-asset figure is summed across chains, so rows stranded on a chain where the asset has no custody at all dragged a covered asset down to "Unbacked / 0%".
- Fixed the page's private-ledger deduction to count only rows that have a custody address behind them on the same wallet, chain and asset. These are the rows the withdrawal engine can see when it picks an address. An address that matches more than once cannot multiply the sum.