Exchange Engine 6.5.0
Latest4 September 2026
This release has upgrade notes. Read them before updating — they describe behaviour changes that need your attention.
Ecosystem v6.5.0
Release Date: September 4, 2026 Tags: CUSTODY, DEPOSITS, WITHDRAWALS, EIP-7702, FEES, SETTINGS, SECURITY, BITCOIN, TRON, ECOSYSTEM, PERFORMANCE, MATCHING-ENGINE, SHARDS, STOP-ORDERS, OPERATIONS
Overview
The custody release. Every token on an Ethereum-style chain now deposits to the customer's own permanent address — including the tokens that cannot delegate their network fee, which until now went to a small pool of shared custodial contracts, one locked per deposit session. That pool was your concurrency ceiling, the reason the deposit screen counted down, the reason customers saw "All custodial wallets are currently in use", and the reason you kept deploying contracts. It is gone, and so is a defect it carried: the same deposit could be credited to two customers.
What replaces it is one mechanism for every token. The platform already holds
the private key of every per-user deposit address; a token that supports
permit was moved out of that address with the master wallet paying the gas,
and a token that does not had no way out of a customer's own address — which
is why those tokens were routed to the shared contracts, and withdrawn from
there by the master wallet, in the first place. Now every token can leave a
customer's address, three ways: one sponsored EIP-7702 transaction where the
chain accepts it, permit where the token supports it, and a just-in-time gas
top-up everywhere else. By default the customer pays the network fee in the
token they withdraw — quoted at initiation from the gas price and the token's
market price, shown on the withdrawal form, with the surplus refunded after
confirmation. A token with no market price is charged the flat network fee set
on it (or nothing, with a warning) and is not reconciled; the charge can be
switched off in Admin → Ecosystem → Settings.
Two settings decide the most, and neither has a fixed default. Address model: keep a separate address for every currency wallet on every chain, or give each customer one key — the same address on every Ethereum-style chain, for every address issued from then on. Custodial mode: let the contracts you already deployed keep serving withdrawals until they are empty, sweep them to the master wallet, or switch them off. Both are set for you on the first boot from what your database already holds, so an upgraded install keeps its address model and its custodial contracts until you decide otherwise. Upgrade Notes walks through it; the Custody migration guide is the long form.
The same audit then read the non-EVM paths, and the parts of what it found
that live in this extension ship here too: the withdrawal queue's crash
recovery never recorded a UTXO batch's change output, so the customer whose
address received it was later credited the change as a deposit out of pool
funds; the queue booked the full platform fee on UTXO withdrawals while the pool
paid the network; the TRON alternative-source selector accepted a wallet's
platform balance as evidence of coins at its Tron address; the Bitcoin
node-mode scanner credited one output per transaction and recorded no UTXO rows;
and the user-to-user transfer route had no KYC gate. The chain handlers
themselves ship with their own addons in the same window: Monero 6.1.8,
Solana 6.1.3, TON 6.1.0 and Tron 6.1.5, each of which now parks a transfer
that may be on-chain as TIMEOUT instead of refunding it. The two helper
modules those handlers share, backend/src/blockchains/withdrawal-outcome.ts
and network-fee-tracker.ts, ship with this extension.
Two changes reach the trading screen. The ticker on a market whose candles are written by the AI market maker rather than by fills froze at whatever it held when the backend last started — last price, 24-hour high, low, volume and turnover, all of them, for as long as the process lived. And the order book now reports how much of the depth it is showing was posted by the platform's own market maker rather than by customers: the public market-data feed has always drawn that distinction and the trading screen never did.
A late fix worth reading before you update: a customer could not buy a coin they had never held if that coin's deposits were misconfigured, because opening a wallet ran the deposit-address rule. On an affected install this refused the order outright with a message about deposits. Alongside it, two paths that could strand held funds are closed — a cancellation that could not open the wallet, and an admin market deletion, which kept the fee of every resting buy order and could mint currency if a fill landed mid-sweep. Money may already be stranded on your install; see Upgrade Notes.
Requires Core v6.7.6.
It is also the release that ships the shard tier, switched off. The
matching engine can now run as several shard processes, each holding its
own markets, behind door processes that forward placements and cancels to
the shard that owns the symbol. Nothing about it turns on by itself: with ECO_DOOR, ECO_SHARDS, the two
symbol lists and the signed map file all unset, the engine is the single process
it was — a symbol list left behind is what keeps it off a market, not the door.
The cutover can move one market at a time — shadowed and watched before it is
enforced — or the whole venue in one step, and the engine health route carries
a capacity advisor: on a running shard it names the constraint that binds it
and the headroom left, and on an install that has not turned the tier on it
answers insufficient-data, because a single-process engine keeps no rate
window — whether your install would gain anything is measured by running the
perf lane against a clone, not read off the route. What this hardware could
and could not prove is in Highlights.
One correction here touches stops that are already resting on your install.
The stop-order table was written by two clocks — a stop's placement and its
final status by the backend's, its cancel and trigger claims by the database's
— and when the two disagreed a stop could be left reading CANCELLING or
TRIGGERING indefinitely, with its reservation already released and the money
right, until a restart's recovery tidied it. Every write to that table is now
stamped by one clock. Rows written before the update keep their old stamp, and
there is a script to name any stop that can still refuse its final write until
real time passes that stamp — see Upgrade Notes.
Update Instructions
pnpm updatorUpdate Core first: this release depends on a wallet-table index that Core 6.7.6
relaxes on boot, on the deposit and withdrawal screens that ship with it, and
on its admin reject door, which refuses to refund the TIMEOUT rows the chain
addons now write.
- No table is added and nothing runs by hand. Two settings rows are written on the first boot; see Upgrade Notes.
- Grant the three new permission keys (
access.,view.andedit.ecosystem.settings) under Admin → Users → Roles & Permissions → User Roles to the admin roles that manage the extension — until then only a Super Admin sees Admin → Ecosystem → Settings. - Update the Monero, Solana, TON and Tron addons you run to the versions named above in the same window; they import helpers this release adds.
- Core 6.7.6's
ecosystem_custodyspot deposit mode needs this release: the hook that turns a Funding deposit into a sweep to the exchange lives here. Nothing to configure; the mode is chosen in Core's settings. - Core 6.7.6's pool-backing engine also sends native coin and the Solana,
Tron, TON and Monero assets through
sendNativeFromCustodyin this release's settlement mover, which calls cores the chain addons carve out of their handlers — update those addons in the same window. - Core 6.7.6's pool-backing engine moves coins through this release's movers
and pooled UTXO spend, and its treasury is an ordinary Funding wallet under
a system account (
pool-backing@treasury.invalid) that appears in your wallet lists once the engine first needs it. Nothing to run; see the Core note and the Pool backing page in the operator guide. - The shard tier needs Core 6.7.6 for the door's own lease rule: a process
that forwards to shards is refused the single-process matching lease unless
some markets are still its own. On an earlier Core a door could arm a second
engine beside the shards. Do not set
ECO_DOORon any process until the core is updated.
Highlights
The shard tier, and what this hardware could prove
The single-process engine is CPU-bound in one Node process at roughly 70 to 170 accepted placements per second on the reference box, and the trading process of Core 6.7.6 moves that process off the web tier without raising the ceiling. The shard tier is the next step: the engine split by market across processes, each with its own write-ahead log, its own lease and its own ledger batcher, reached through doors that hold the customer connections and forward by symbol. A shard writes the ledger of record and its own log; everything a customer reads about an order is written afterwards by a projector that lags the shard by a few milliseconds.
Two parity lanes are the reason it can be described here at all. A corpus of 32 scenarios — fills, partial fills, stops, OCO, self-match, fee rounding, the market maker's own orders, copy trading — is replayed through a real shard and graded against the fixtures the single-process engine produced, and a second lane records every wire response and the frames its four order-path sockets carry. The corpus's first run found five money-path and contract defects in a shard tier whose component tests were all green: it collected no platform fee, wrote no per-order trade history, admitted a market buy the book could not fill, answered 404 where the route answers 400, and stamped two fills of one cycle with one millisecond. All are fixed. The corpus reproduces all 32 scenarios through a real shard, in both fee modes; the wire lane matches every committed fixture except the ones a door changes by construction — the lease fields of the engine health bodies, a follower-only cancel that a door completes, and the trade history the shard now sends where the fixture recorded none — each expected and listed, and none of them money.
What the box could measure — 80 accepted operations a second in the place-and-cancel rows, and 40 accepted placements a second producing about 20 fills in the rows taken under fills, both under 1% of the plan's target, which needs machines with cores to spare:
| monolith | one shard | two shards, same quote | two shards, different quotes | |
|---|---|---|---|---|
| CPU per accepted request, door only (place and cancel, net of idle) | — | 2.1 ms | 2.1 ms | not measured |
| CPU per accepted request, all processes (place and cancel, net of idle) | 5.2 ms | 4.2 ms | 4.9 ms | not measured |
| CPU per accepted request, all processes (under fills) | 13.4 ms | 9.7 ms | 11.5 ms | 12.1 ms |
| POST p99 under fills, 20 fills/s | 644 ms | 45 ms | 44 ms | 45 ms |
| InnoDB row-lock waits under fills, 80 s | 2 | 1 | 601 | 3 |
The first row is the door constant, never measured before because nothing did the door's job without also matching. The fourth is where the tier earns its keep: settlement and fee collection are the expensive part, and moving them out of the request process takes p99 from 644 ms to 45 ms at the same load. The last is the one placement rule that matters — every shard that fills a symbol quoted in one currency credits the same platform fee wallet row, so two shards on two USDT markets collide on one fill in three where two shards on USDT and USDC do not. It cost no latency at that rate; it scales with shards per quote currency; keep same-quote markets on one shard unless one shard cannot carry that quote.
What it does not prove: anything at the target mix. Contention is rate-dependent, the per-shard constants were measured on shared cores, and the load proof itself is not achievable on a single developer machine. Treat the numbers as the shape of the cost, not as a capacity figure.
Upgrade Notes
A cancelled or fired stop order could be left in its in-between state
The stop-order table was written by two clocks: a plain write is stamped by
the backend's clock and a conditional one by the database's, and when they
disagreed — by up to 467 ms on the reference box, drifting and changing sign —
the order of two writes to one row was decided by the skew rather than by the
order they were made in. With the database's clock ahead, the stop's final
status write lost to the claim it was meant to follow: a cancelled stop stayed
CANCELLING with its money already returned, and a fired stop stayed
TRIGGERING beside the live order it had placed, until the next restart marked
them CANCELLED and FAILED. With the backend's clock ahead the placement's
own PENDING cell could outlive a claim that followed it, so a cancel could be
told it had applied while the row still read PENDING until its final write
landed. Every write the stop-order code makes to that table is now conditional,
so all of them are stamped by one clock. A ScyllaDB restore from the admin still
writes restored stop rows with the backend's stamp; the doctor below names any
that leaves at risk.
- Fixed — a stop's cancel, trigger and status writes can no longer be
ordered by clock skew. The backend logs a
STOP_ORDERwarning naming any stop whose final status write finds it in a state other than the one just claimed, and when that state is not a terminal one it logs an error and re-issues the write without the expectation, so a stop whose reservation has already been released is never left armed by a skipped write. - Rows written before this release keep the stamp they were written with, and
one stamped ahead of the database's clock can still refuse a terminal write
until real time passes it — a window only as long as the backend's clock was
ahead of the database's, so a stop resting across the update is at risk only
if that gap outlasted the restart. The stop-order doctor names any such
row —
node backend/scripts/stop-order-doctor.mjs(Core 6.7.6) — reading every non-terminal stop, comparing its stamp with the database's own clock and naming each one still at risk and the moment it stops being. It writes nothing. If a customer needs one cancelled sooner, cancel it and confirm it reached a terminal state.
Held funds this release stops stranding may already be stranded, so check
Three defects fixed here locked customer money in the held balance where nothing could return it. The fixes stop it happening again; they do not find what has already happened, because in two of the three cases the records that would identify it were deleted at the time.
- Review any market you have deleted while it had resting buy orders. Each of those orders left its fee locked in the owner's held balance, and its order record is gone. Comparing each customer's held balance against their open orders is the only way to find it now.
- Review orders that were cancelled but released nothing. A cancellation that could not open the wallet still cancelled the order and logged that it released nothing, then said the customer's funds "need an operator". Those lines are your list.
- If you run Bitcoin, Litecoin, Dogecoin or Dash on a test network, or any chain whose network name has more than one spelling, open its diagnostics page after updating. The token-network check now runs for every chain and will tell you if rows are hidden; on an affected install this was invisible and made the currency untradeable as well as undepositable. Core 6.7.6 explains the rule that changed.
What the first boot decides for you
Two settings have no fixed default because the right answer depends on what the install already holds. On the first boot the backend reads the database once and writes whichever of the two rows is missing:
ecosystemAddressModelbecomesper_currencyif any deposit address has ever been issued (awallet_datarow exists), otherwiseper_user. An upgraded install therefore keeps issuing one address per currency wallet per chain, exactly as before, until you switch.ecosystemCustodialModebecomesdrainif any custodial contract row exists, otherwiseoff. Indrain, the contracts you deployed remain the last source a withdrawal can draw on, the Custodial Wallets screen stays, and each row gains a Sweep to master wallet action for admins who can edit custodial wallets. Inoff, every custodial route refuses and the menu entry disappears. Creating a new contract is refused in either mode.
Open Admin → Ecosystem → Settings once after the update and read the four tabs. Nothing on them needs changing for the platform to run.
Deposits: a legacy wallet is repaired the next time it is opened
A customer's wallet for a non-permit token used to hold no address of its own — only a balance. The next time such a wallet is opened, the chain gets a real address and keeps the balance the entry was carrying (a chain whose master wallet is missing or disabled is skipped with a warning, as it always was for permit tokens). Deposit sessions registered before the update, which bound a wallet to a shared contract for up to seventy-two hours, are dropped by the background scanner on its first pass after the update, so the old contracts are not watched from then on. A customer who kept a contract address from an earlier session and sends to it lands funds in a contract you control, not in their wallet — a support credit, not a loss, but tell them to copy the address fresh.
Withdrawals: what the first one on each chain does
While the token mover is Automatic (the default) or EIP-7702 only, the
first token withdrawal on each chain that leaves a customer's address deploys a
small EcosystemDelegate contract from that chain's master wallet — one
deploy's gas, recorded under Admin → Ecosystem → Settings → Diagnostics. It
is deployed again only if that chain's master wallet is rotated. A withdrawal
paid from the master wallet's own balance or from a legacy custodial contract
deploys nothing, and the permit-only and just-in-time-gas-only movers never
do. The delegate goes up before the chain is asked to accept an EIP-7702
transaction, so a chain that refuses one still pays that single deploy; the
refusal is remembered for twenty-four hours and the chain is served by permit
(tokens that implement it) or the two-transaction just-in-time gas mover
instead — that too shows under Diagnostics. Keep the master wallet funded with
native coin on every chain, as before: it still pays the gas of every token
withdrawal.
The network fee is charged to the customer
ecosystemChargeNetworkFee is on by default. A token withdrawal on an
Ethereum-style chain is quoted the gas of its transactions plus a buffer
(ecosystemNetworkFeeBufferPercent, twenty percent), converted through the
platform's USD rates and charged in the withdrawn token on top of the platform
fee; the receipts are read after confirmation and the surplus refunded at the
rate the quote used. The withdrawal form shows the estimate before the customer
confirms and includes it in the total, and the maximum-withdrawable figure
subtracts the same quote. The master wallet itself is not topped back up with
native coin: what the customer pays stays in the withdrawal's fee, and the
delegate deploy is not charged to anyone.
- A token with no USD price, or a chain whose native coin has none, cannot be
converted. Set the token's Network fee fallback (
fee.network, in token units) on the token form; it is charged as a flat fee, without the after-confirmation refund, and is also what is charged when the chain's gas price cannot be read. A token with neither is withdrawn with the network charge skipped and a warning in the log. - Native-coin withdrawals are unchanged: the full amount is sent and the actual gas is debited from the wallet after confirmation, on top of the amount, as it always has been.
If you want the contracts gone today
Sweep every token on every custodial contract from its row menu — one token
symbol per sweep, and you need to know which tokens each contract holds, since
the screen lists the contracts but not their balances. A sweep that finds
nothing refuses with "This contract holds no off
under Admin → Ecosystem → Settings. Swept balances sit in the master wallet,
which the withdrawal path now uses as a source. Leaving the mode on drain is
equally safe, but the contracts are drawn on only when no customer address and
the master wallet can cover a withdrawal, so they may take a long time to empty,
or never empty on their own.
TIMEOUT rows need an operator, and there will be more of them
A TIMEOUT row means the transaction was broadcast and its outcome could not
be read. Neither the queue nor Core 6.7.6's admin reject door will refund it.
Look the address up on the chain's explorer: if the transfer landed, set the
row complete with its hash; if it did not, the reconciliation watchdog resolves
TRC-20 rows on its own, and the rest can be rejected once the transaction's
expiry has passed and the explorer shows nothing.
Bitcoin node-mode deposits credited before this release may be short
A deposit that paid the same address in more than one output was credited for the first output only. Compare recent node-mode deposits against the explorer and credit the difference by hand. The UTXO rows those deposits should have produced are not recreated on a schedule: the pool resyncs a wallet's address from the node only when a withdrawal from that wallet finds the pool short, so until then the coins are spendable on-chain but invisible to the withdrawal pool.
Switching Ecosystem off no longer strands a debited withdrawal
A withdrawal row is created in the same database transaction that debits the customer's balance, so a PENDING row is money that has left a customer and not reached the chain. Two scheduled watchdogs are what re-broadcast those or refund them — and switching this extension off stopped both. The boot-time sweep was gated on the same switch, so restarting did not recover them either: the debit simply stayed.
- Both watchdogs now keep running while the extension is off, and are registered again after a restart.
- Disabling now tells you how many withdrawals are in that state first. It is a
warning, not a lock —
acknowledgeInFlightproceeds anyway. - Your customers' own deposit and withdrawal pages stay open while this extension is disabled, because only the admin routes are gated. They can go on filing requests into a product you have switched off, and you lose the console you would watch them from. That is worth knowing before you use the switch as a maintenance lever — it is not one.
Added
A door and its shards
The engine, split across processes by market. A shard owns a set of
symbols, matches them in memory, settles through its own ledger batcher and
writes its own write-ahead log; a door is an ordinary backend process with
ECO_DOOR on, which forwards every placement and cancel on a sharded symbol
to the shard that owns it and never matches a symbol the tier owns. Which
shard owns a symbol is
decided by a map every process shares — by hash, or by a signed file — and a
process that would disagree with the others about it refuses to arm rather
than pick a side. Placements through the door wait for the shard's own log to
reach disk before they are acknowledged. Everything here is off until you set
it, and rollback is unsetting it — all of it: ECO_DOOR, ECO_SHARDS,
ECO_SHARD_SYMBOLS, ECO_SHARD_SHADOW_SYMBOLS and ECO_SHARD_MAP_FILE. The
last three are read by the single-process engine itself, door or no door, and
the two lists pull opposite ways. An ownership list left behind keeps that
engine off exactly the markets it names and refuses every cancel on them, with
nothing serving them. A shadow list left behind does it to everything else: the
engine stands down from every market the list does not name, and cancels are
refused across the venue except on the symbols still being watched. A signed map
file left behind acts as the ownership list for the symbols it names, and one
whose shard count is not 1 stops the engine starting at all. ECO_WAL_DIR set on the ordinary backend opens the
leader's own fee journal under it, which is the one thing here a shard is not
needed for.
- Added
ECO_SHARDSandECO_SHARD_ID: how many shards divide the venue and which one this process is. A shard is started from the same build as the backend (ECO_SHARDS=4 ECO_SHARD_ID=2 ECO_WAL_DIR=... node backend/dist/shard.js,shard-<id>under PM2); it opens no HTTP port and runs no cron. An id at or above the count refuses to start and says so. - Added
ECO_WAL_DIR, required by a shard: the root of its write-ahead log, withECO_WAL_SNAPSHOT_EVERYfor how often it compacts. A shard that restarts replays its log and re-submits any fill or release the ledger had not yet confirmed; its fee journal lives under the same root, one per shard. - Added
ECO_SHARD_PORT_BASE(4300): shard N listens on base + N on the loopback, which is where a door reaches it.ECO_SHARD_TICK_MS(10) is the shard's matching tick. - Added
ECO_PROJECTOR, off by default: the shard writes the order rows, the open-orders index, the aggregated book, the tape and the candles that customers and the Hummingbot endpoints read, from its own cycle events. Without it those rows are not written on a sharded symbol, so it is part of running a shard, not an optimisation. - Added
ECO_SHARD_MAP_FILEandECO_SHARD_MAP_SECRET: a signed map that pins symbols to shards explicitly (for moving one market between shards, or keeping a quote currency together). A file whose signature, shard count or ownership list disagrees with the environment is refused. - Added to the engine health route: a
shardsblock with each reachable shard's own health — resident orders, fills, cycle time, its batcher and fee counters, its log's durability floor — and anownershipblock saying which symbols this process believes the tier owns. Compareownershipacross every process before moving anything. - Added a reconciler on the shard that compares its book against the projection and the ledger on an interval and reports drift in its health.
Moving one market at a time
The cutover is three stages, each reached by a restart and never by a message; the two that move a symbol's ownership are done under a quiesce. A shadow stage, where a shard receives a copy of every placement on a symbol the single-process engine still matches, and of the cancels the backend makes on a customer's behalf or hands across processes (the OCO limit leg a firing stop retires, an OCO target leg rolled back because its stop leg could not be placed, a copy-trading close or teardown, a trading bot's cancel, a cancel another process queued for the matching engine) — a cancel a customer sends to the process that is itself the matcher is not copied, so the shadow keeps an order the live engine has retired and its next pass counts that as a difference — and reports how many consecutive passes its book agreed with the live one. An enforce stage, where that one symbol is the shard's and every other symbol is still the engine's. And the whole venue. The two engines are never both matching one symbol, by two different mechanisms. Where a list or a shadow list exists, the single-process engine abstains from exactly the symbols the tier owns and the door forwards exactly those. Where neither does — the whole venue in one step — nothing is abstained from per symbol and the door is not a candidate for the matching lease at all, so its own engine never arms. A shard given a symbol in both the owned and the shadow lists refuses to arm; a door or the single-process engine given the same pair reads the symbol as shadowed, so nothing forwards it and the engine keeps matching it.
- Added
ECO_SHARD_SYMBOLS: the symbols the shard tier owns. Unset is the whole venue; the literalnoneis nothing (the shadow stage); a list is exactly those markets, everywhere else staying with the single-process engine. Deliberately not a setting the kill switch can reach, because "off" for an ownership list would mean the whole venue. - Added
ECO_SHARD_SHADOW_SYMBOLS: the symbols a door copies to a shadow — a shard started withECO_SHARD_SHADOW=1, which matches the copy and writes nothing at all: no ledger, no projection, no fee. It takes its own lease and listens in its own port range (ECO_SHADOW_PORT_BASE, else the shard base plus 100, or plus the shard count when that is larger) so it can run beside a real tier, and compares its book with the live engine's everyECO_SHADOW_RECONCILE_MS(60 s). Its health carries a verdict,cleanPasses, that resets to zero on any comparison pass that finds a difference from the live book, a wallet locking less in the ledger than the shadow holds against it, or a check that could not be made. SetECO_SHARD_SYMBOLS=nonebeside the shadow list for the shadow stage: a shadow list on its own leaves the rest of the venue the tier's, which is a later stage and not this one. - Added to the health route's
ownershipblock:matcherAbstainsFrom, the symbols this process's own engine leaves alone, and the shadow copy counters.
Fees on a shard, and the one row every shard shares
A shard collects the platform fee after its settlement commits, outside the tick, one fee per fill, and journals each fee before its body runs and again once it has landed, so a shard that dies while a fee is in flight replays exactly that fee when it next arms. The record is written after the settlement commits, and the journal buffers that record for up to twenty milliseconds rather than waiting for it to reach disk, so a crash inside that window still loses the tick's fees. It is never a customer's money, and the window has no runbook until a ledger-sourced fee sweep exists. Two things the measurement above forced into this release: every shard now has its own journal directory (two shards on one host used to fight over one, and the loser ran with no journal), and a shard actually replays its journal on arm, which the comment promised and nothing did. Both are inside this unreleased tier and no install ever ran without them.
- Added
ECO_LEDGER_FEE_MODE=batched, off by default: one transaction per tick per quote currency on the shard instead of one per fill, at most sixteen fees to a transaction so no single hold on the fee row runs long, each fee still the same body inside a savepoint so the ledger rows are identical, the stage taken off the matching cycle. It pays only when a tick carries several fees — above about 50 fills a second on one shard's own quote currency — and measured on a quote split across two shards it is worse than per-fill, so the health route will not recommend it for a shard the split names. The shard processes read it at boot; give the process that serves the health route the same line, or its advice keeps offering the mode for a shard already running it. - Added to shard health:
feeJournal(dir,pending,abandoned,lastSeq,durableSeq,disabled),feeBatchMsLast,feeBatchMsMax,feeBatchMsP99,feeBacklog,feeBackpressureWaits, a one-minute ratewindowandcycleMsP99. - Added
sameQuoteSplitto the health route'sownershipblock, naming any quote currency the map spreads across more than one shard — read over every market with status on, because a shard holds books only for the symbols it owns and so can never see one — and one warning line the first time the route is read with such a split in it, once per process. Each shard's advice carries the entries that name it.
The health route says whether to shard
advice on the engine health route is a capacity advisor: for this process
and for each reachable shard, a ranked list — stay, split-trading, shard,
add-shards, fewer-shards, batch-fees, colocate-quote, or
insufficient-data — each with the constraint it is measured against and
the reasons, and, wherever the process measured them, the headroom left on that
constraint and the evidence: colocate-quote carries no headroom, and
insufficient-data carries neither headroom nor evidence. summary is the line to read first. It
advises and never acts: which process owns which symbol changes only by
restart, because a process re-deciding that at runtime is two matchers over
one book. Its thresholds are fractions of measured constants — about 70
placements a second for one process, a fee hold of about 4.2 ms per fill that
caps a shard's per-fill fee stage near 238 fills a second, and the same-quote
row-lock figures — or of the ledger tick the process is configured with, and
every piece of advice carries the numbers it was computed from, so you can
disagree with a threshold and still use the evidence. It answers insufficient-data for a single-process engine,
which keeps no rate window, rather than guess; the perf lane, which measures
that window itself, gives the same process a real answer.
- Added
adviceto the engine health route Core 6.7.6 introduces (/api/admin/ecosystem/engine/health); the same advisor is carried as anadviceblock in every perf-driver run's JSON report and printed as one line on its console.
Cancelling on a sharded market
The order rows a customer reads are a projection that lands a few milliseconds after the shard has the order, and a cancel sent straight off a placement's acknowledgement can arrive first. The ecosystem cancel route therefore checks for a stop order first (one keyed read of the customer's own partition, because a stop rests outside every shard) and then hands the cancel to the shard before it reads the order row; when only some markets are sharded, it first looks up the order's market from the placement hint or, failing that, from the row, so a shard's order and a local one cannot be confused. The shard's answer is the answer: cancelled, or not held — and for an order no shard holds the route checks the order's own row before answering: Order is not open (400, inside a reply that begins Failed to cancel order:) for one that has filled or been cancelled, Order not found (404, worded the same way) for one that never existed, and a retryable 503 for an open order its shard has not loaded yet, so a bot keeps tracking it and asks again. The Hummingbot route reads the projection first and, when the row has not landed yet, forwards on the placement hint alone so the shard can answer for it; Hummingbot Connector 6.1.7 describes it in its own note.
- Added — a cancel-all on a sharded venue fans out to every shard, each of which cancels the customer's orders it holds; one that cannot reach a shard counts that shard once among the failures it reports — it cannot know how many orders the shard held — so the reply says orders could not be cancelled and are still open, never a clean sweep, and the log names the shard, while the cancels the other shards completed stand.
One withdrawal path for every token
- Added —
EcosystemDelegate, an EIP-7702 delegate deployed once per chain by the master wallet on first use (and again only if the master wallet is rotated). A withdrawal from a customer's address becomes one transaction sent by the master, carrying an authorization signed by the customer's key; the address never needs native coin and is never topped up. - Added — the just-in-time gas mover: the master sends the address exactly the native coin one transfer needs, the address's own key signs the transfer at the same gas price, and the dust that remains is reused by the next withdrawal. Native coin the customer is credited for at that address is reserved and never spent as gas. Works on every EVM chain, including custom chains.
- Changed — one source order for every token withdrawal, replacing the
separate PERMIT and NO_PERMIT branches; each candidate is checked on-chain
before it is used: the customer's own address, another customer's address
(the borrow is then recorded in the private ledger), the master wallet's own
balance — new as a source — and, in
drainmode, a legacy custodial contract. The withdrawal's destination is never chosen as the address that pays. - Added — the withdrawal row records which source paid and which mover ran
(
metadata.move), and the network fee as quoted (metadata.networkFee); when the quote was priced, the fee as measured from the receipts and as refunded are written to the same key after confirmation.
Settings
- Added — Admin → Ecosystem → Settings, with Addresses, Withdrawals,
Custodial wallets and Diagnostics tabs, behind
access.ecosystem.settings;GET/PUT /api/admin/ecosystem/settingsbehindview./edit.ecosystem.settings. - Added —
ecosystemAddressModel(per_currency|per_user),ecosystemCustodialMode(drain|off),ecosystemEvmMover(auto|eip7702|permit|jit_gas),ecosystemChargeNetworkFeeandecosystemNetworkFeeBufferPercent. - Added —
POST /api/admin/ecosystem/wallet/custodial/{id}/sweep, the per-row Sweep to master wallet action; refused while the mode isoff. - Added — an optional Network fee fallback (
fee.network) on the token form, used only when the fee cannot be priced: the token or the chain's native coin has no USD price, or the chain's gas price cannot be read.
The platform's own movements
- Added —
utils/settlement-move.ts: the platform sending a token from custody to the exchange for pool backing, through the same source order and movers a customer withdrawal uses (the treasury's own address, another address holding surplus, the master wallet), with its own bookkeeping — a lending address is debited and the private ledger records the loan exactly as for a customer withdrawal, but nobody's record is credited, because the coins left custody. It writes notransactionrow and never touches the withdrawal queue; a crash between the receipt and the bookkeeping is replayed from the settlement's own record. - Added —
sendPooledUtxoinutils/utxo.ts: one spend from the pooled inputs to one address, the expected hash handed to the caller before broadcast, change to the largest input's owner as today, no batching window, no email. The batch withdrawal now runs on the same core (buildAndBroadcastPooledSpend) with its member writes passed in as hooks at the positions they always ran; a test pins the batch's row sequence byte-for-byte.
A Spot deposit to the customer's own address
- Added —
utils/spot-custody.ts: when a Funding deposit credits a wallet that has an openecosystem_custodySpot intent for that chain, the hook quotes the network fee, refuses below the exchange's or the Ecosystem withdrawal minimum (the coins stay in Funding; the customer is told), and otherwise creates a sweep — a withdrawal from the customer's Funding wallet to the exchange's deposit address throughstoreWithdrawaland the ordinary queue, with the platform fee overridden to zero, the intent named in its metadata, no confirmation email and no profit booked. When the sweep broadcasts, Core claims its hash on the exchange for the customer; when it fails, the queue's refund applies and the sweep is retried once after ten minutes before the intent is failed with the console's resweep door. - Added —
storeSweepWithdrawalon the withdraw route, a thin wrapper that carries those flags into the same database transaction; the queue reads them (sweepIntentIdOf,emailSuppressed) on its success and failure paths. A sweep is never batched with other UTXO withdrawals: one hash must mean one customer, so the batch collector returns a sweep alone and never parks it waiting for companions. - Changed — the deposit credit path (
storeAndBroadcastTransaction) and the Bitcoin node-mode scanner call the hook after a successful credit, best effort: a hook failure never fails the credit.
The platform's own movements on native chains
- Added —
sendNativeFromCustodyinutils/settlement-move.ts: one send from the treasury's own address on native EVM, SOL, SPL, TRON, TRC20, TON (with a memo) or Monero, falling back to the master wallet where the master can hold the asset and only down to the per-chain reserve Core'spoolBackingMasterReservesets, never spending a customer's address. The hash is handed to the caller the moment it exists — before the confirmation wait — and a TON send hands over its payload first, because TON has no hash until the chain shows the message. Gas and fees are reported in the chain's native asset for Core to book. - Added —
sendNativeFromCustody's source checks read balances through readers the chain addons ship.getSplTokenBalance(Solana 6.1.3) andreadTrc20Balance(Tron 6.1.5) read the treasury's own address only: the master wallet holds no SPL or TRC20 tokens and only pays their gas, and its native reserve is checked inside the addon's send core (in lamports or sun) rather than read here.getWalletBalances(Monero 6.1.8, per wallet file) reads both the treasury's file andmaster_wallet. Each throws on an outage instead of answering zero, so an unreachable node refuses the leg rather than choosing an empty source. Core's custody reads do not use them.
Shared chain helpers
- Added —
backend/src/blockchains/withdrawal-outcome.ts: the pure broadcast-outcome classification the TON, TRON, Solana and Monero handlers share (which node refusals are failures, which are unknown, when a deposit is spendable), andnetwork-fee-tracker.ts, the tracker debit for a network fee burned from a customer's own address.
Tests
- Added — an on-chain suite that runs the three movers against a local Prague-hardfork node: a USDT-shaped token with no return value leaves a zero-balance address in one sponsored transaction; native and token deposits still land on a delegated address, including a native send with a fixed 21,000 gas limit; the just-in-time top-up is exactly the shortfall, reuses dust, and leaves a credited native balance untouched; a node without EIP-7702 is recognised and nothing moves.
- Added — three suites for the platform's own movements: the EVM settlement move over a recorder (source order, bookkeeping per source kind, the not-broadcast classification), the pooled UTXO spend with real signing beside the one-member batch (both write orders pinned), and the change guard on the deposit credit.
- Added — a MySQL-backed suite proving two currency wallets of one customer can hold the same address, that the reverse lookup finds the owner, prefers the withdrawn currency and finds a secondary-chain address, and that the wallet index is no longer unique.
The order book says which depth is the platform's own
Levels the AI market maker posts carry a time-to-live and levels backed by a real resting order do not — the distinction the matching engine's reconciler already relies on, and which had never been carried out of storage. The aggregated book read now selects it, so a customer sizing an order against the ladder can be told what share of what they are looking at is the house's.
- Added a maker-depth subset to the aggregated order book, cut to exactly the price levels the frame carries, so a marker can never name a level the client was not sent. It rides beside the book rather than widening each level, because this same read serves the Hummingbot order-book and ticker endpoints and their levels are consumed as pairs.
- Added the distinction as an explicit absence where it is not known. A frame produced by the matching engine's own push path holds a post-write book with no provenance in it and omits the field, and a consumer must read that as not known rather than as none of this is synthetic.
The trading screen that renders it ships with Core v6.7.6; the market maker that posts the depth is AI Market Maker v6.2.5.
Fail-fast on the order path
The bounds this release adds inside the engine are environment variables, none of which needs a value; each is described with the number it was set from in the Core environment reference, alongside the shed gate and wallet-gate bounds that ship with Core 6.7.6.
- Added
ECO_CLAIM_TIMEOUT_MS, the cancel claim's bound on the engine lock, andECO_PREHOLD_ADMISSION, the pre-hold check, both described under Fixed. - Added
ECO_CANCEL_DRAIN_PER_TICK: on an install running more than one backend process, a cancellation of an order placed through a process that does not run the engine is served by the leader's once-a-second drain, which used to serve every due request in the tick that found it. The bound serves at most that many per tick and leaves the rest queued untouched, no attempt recorded, for the following ticks; every ten seconds the log printsServed N cross-process cancel(s) this tick (ECO_CANCEL_DRAIN_PER_TICK=L); M wait for the next tick.The drain can also serve a bounded tick through one batch claim per market when the engine hands it one; in this release it serves them one at a time, still bounded.0, the default, is the previous behaviour. - Added
ECO_SCYLLA_BUDGET_PLACEandECO_SCYLLA_BUDGET_CANCEL: the ScyllaDB client gained a per-process gate on how many placements or cancellations may run ScyllaDB work at once, refusing with 503 andRetry-After: 1at the bound. Both are live in this release: the order write runs inside the placement gate, so a refusal lands before any row exists, and both user cancel doors and every caller of the shared cancel helper run their refund and status write inside the cancel gate, with the claim restored on a refusal.0, the default, only counts. - Added
WS_MARKET_CACHE_MS: the market feed answers "is this an enabled market" on SUBSCRIBE from one list of enabled markets refreshed on that interval, one refresh in flight at a time, falling back to the row read for a subscription whose refresh failed. A market an administrator disables is honoured on the next refresh.0, the default, is one read per SUBSCRIBE as before. The frame shapes are unchanged; the market feed also marks its book, ticker, candle and trade frames as snapshots so Core's broker can skip them for a socket that has stopped reading.
The matching engine in a process of its own
Core 6.7.6 adds an opt-in, ECO_TRADING_ENABLED, that starts a second
backend process for the engine; this release is where the engine learns who
may hold it.
- Added a rule for who may hold the engine: a process that declares
ECO_PROCESS_ROLE=webis refused theecosystem-matchinglease before any store is consulted, exactly as aCRON_MODE=onlyprocess is, and the engine health route reports it ascandidate: false,blockedBy: "policy". A process that declarestradingis a candidate. A process that declares nothing keeps the rule it had, so an install without the opt-in matches where it matched before. - Changed where the AI Market Maker and the trading bots run: they follow the same candidacy, so on the split layout they run in the trading process with no setting of their own. Their refusal notices on a web-role process still name "the dedicated cron process"; the behaviour is right, the wording predates the role. Copy trading's live path needs no rule: its queue is fed by placements, which only the trading process receives, so the copies are produced there and the web process's queue stays empty.
- Four path prefixes move to the trading process, and the reverse proxy is
what moves them:
/api/ecosystem/order,/api/ecosystem/market,/api/ecosystem/tickerand all of/api/hb/. The wallet, deposit, withdraw, token and chart routes and every admin screen stay on the web process. A trading app that PM2 restarts on the same host reclaims the lease on its way up, so that outage is its boot time; a follower trading process is promoted on its first poll after the row is free, which is what remains of the TTL plus up to one poll interval plus the hydrate: plan for 25 to 45 seconds at the 20-second TTL. The drill of record, two same-host handovers, measured promotion at 19.3 and 19.4 seconds and the first accepted order at 19.7 and 20.1 seconds, with every fill exactly once. During the gap the four prefixes answer 502 at the proxy and everything else keeps working; the web process is never promoted. The Operations page carries the layout, the lease rule and what to watch.
The engine's flat-cost flags, off by default and confined to a canary
Measured on the reference box, the cost of a placement or a cancel grew with the depth of the book it landed on: every cycle walked both sides of every market, every placement made the next cycle visit every market, a cancel waited for a cycle before it answered, every order placed on another process made the leader re-read a market's whole resident window under the engine lock, the five-minute reconciliation held that lock across the entire book, and the self-match guard paged five thousand rows of the customer's history per placement. Each of those is replaced behind a flag of its own, and every flag is off until you set it: with none of them set the engine is byte for byte the one that shipped before, and with any of them on the fills, the ledger rows and the wire responses are the same: each flag ships with a test that runs the engine both ways and compares the fills, the rows and the responses, and the 32-scenario wire corpus is graded with the flags off and on as the gate. The flags, the canary and the order to turn them on are in the Core environment reference; what the health route and the log say about them is in the Core monitoring guide.
- Added
ECO_ENGINE_CANARY_SYMBOLS, the markets every flag below is confined to (empty is every market), andECO_ENGINE_SELF_CHECK, which inlogmode compares each of the v2 book's mirror structures against the engine's own list at every place the engine changes them and reports a divergence before it can price anything; after three it disables itself and says so. Money-affecting flags also read as off, without a restart, whenever the process cannot hear a kill over the settings bus, which includes its own boot window. - Added
ECO_ENGINE_BOOK_V2: a second structure beside the engine's list of resident orders (an id map, a per-user open set, a per-price aggregate and the two sides as a tree of price levels in the walk's own order), kept in step at every mutation site, answering depth reads, duplicate checks, cancel claims and the placement's self-match guard. About 200 bytes of heap per resident order; the walk still runs over the list in this release. - Added
ECO_CYCLE_DIRTY_ONLYandECO_CYCLE_FULL_PASS_MS(5,000 ms): a cycle visits only the markets something changed since the last one, with a full pass on the interval as the backstop. - Added
ECO_MATCH_EARLY_BREAK: a cycle that will fill nothing stops at the first non-crossing pair once both cursors are past their side's market orders, placed and tested so the fill sequence is identical either way. A zero-fill cycle at 50,000 resting orders dropped from 33.8 to 23.4 ms. - Added
ECO_CANCEL_AWAITS_CYCLE, on by default: set to0, a completed cancellation schedules the matching cycle it triggers instead of waiting for it, the refund still sized from the engine's claim; cancel-all keeps its awaited cycle, and a kill lands on the awaited path. - Added
ECO_NUDGE_CARRIES_KEY,ECO_WINDOW_BAND_READandECO_RESYNC_READ_OUTSIDE_LOCK: the leader reads one ledger row for a keyed nudge instead of a window, refills a windowed side from the band beyond its boundary instead of the whole window, and takes the resync's ScyllaDB read before the engine lock and merges it only if nothing moved the market meanwhile. The keyed nudge's consumer is built and its producer is not: the placement path still publishes the bare market symbol in this release, so the first of the three changes nothing yet. - Added
ECO_RECONCILE_FROM_AGGREGATE: the five-minute reconciliation copies a canary market's per-price aggregate under one short hold of the lock, compares and plans off it, and writes each repair under a hold that checks the market has not moved since the copy; the same rows with the same amounts as the old sweep, which keeps running for every market outside the canary. - Changed
ECO_SCYLLA_BUDGET_PLACEandECO_SCYLLA_BUDGET_CANCELfrom reserved to live: the order write runs inside the placement gate, so a refusal at the bound lands before any row exists, and both user cancel doors and every caller of the shared cancel helper run their refund and status write inside the cancel gate, with the claim restored on a refusal.0, the default, only counts.
Cancel-all in batches
A customer or a bot cancelling every open order paid about 18.5 ms of leader time per order: one engine claim for all of them, then one wallet transaction and one ScyllaDB read-modify-write of the price level per order.
- Added
ECO_CANCEL_ALL_BATCH, off by default. Set to N (the plan's working value is 50), the single claim stays and the refunds are grouped per wallet: one wallet transaction per N orders with a database savepoint per order, so one refused order rolls back only itself; one ScyllaDB batch per price level carrying the sum of the per-order decrements; one matching cycle per touched market. Every batch reads the engine lease's epoch under lock and compares it with the epoch read at the start of the run, so a run that straddles another process's promotion stops with its remaining orders reported failed and their claims restored. The rows, the idempotency keys, the amounts and the response are identical to the per-order loop's; what changes is the commit envelope, so a deadlock inside a batch fails that batch's orders together, each restored to the book and reported, and the wallet's row lock is held for the batch (150 to 500 ms at 50) rather than per order. - The same value turns the Hummingbot batch cancel (
DELETE /api/hb/order, up to 200 per call) onto the batched core, with a fall-back to the per-order path for the whole call if the core refuses at the claim, and the response shape unchanged. The IOC sweep still cancels remainders one at a time in this release.
Holds and fills through the ledger batcher
Core 6.7.6 ships a ledger batcher that commits many ledger operations in one MySQL transaction per tick, writing exactly the rows the wallet verbs write. This release is where the order path uses it.
- Added
ECO_LEDGER_BATCHER,offby default.holds: a placement's hold, which opened a transaction of its own, is submitted to the process's batcher and committed with every other hold of the same tick; the placement still waits for that commit before the order becomes matchable, a refused hold raises the same error with the same figures, and a replayed key still answers as a duplicate.all: additionally each fill's legs, and the AI market maker's pool update when a bot is one side, are one group of the next tick, applied whole or not at all under the same row locks the verbs take, and refused whole if any leg is. Any other value, including1, reads asoff. Three differences are recorded as allowlisted: a refused leg discards its sibling legs where each leg used to be its own transaction, a batch-level failure fails every operation of that tick at once (each placement rolling its order back and rendering as the 500 a deadlock renders today), and a wallet-gate deadline hit inside a batch renders as that 500 rather than the gate's 503. - Added the fence: the leader bumps the engine lease's
epochon every arm and installs it in the batcher, so a deposed leader's next tick aborts and its batcher refuses everything until the process re-arms; on a lease never claimed through MySQL there is no epoch row and the batcher commits unfenced, logged once. The bump runs on every arm whether or not the batcher is on; see the Core Upgrade Notes for the column it needs. - The kill switch and a settings bus that cannot deliver one turn the mode
off without a restart. Keep
innodb_flush_log_at_trx_commit=1while it is on.
Changed
Where a deposit lands
- Changed — every token on an EVM chain deposits to the customer's own
address. A token's contract type (
PERMIT,NO_PERMIT,NATIVE) no longer decides where a deposit goes, only which movers a withdrawal may use. - Changed — under
per_user, the first EVM address a customer is issued becomes their one key; every later currency and chain reuses it, so the address is identical on Ethereum, BSC, Polygon and every custom chain. Switching the model never changes an address already issued. Issuance is serialised per customer within a process so two wallets opened together share the key. - Changed — a token deposit is credited from the receipt's
Transferevent for the token contract, not from the transaction's calldata. A transfer made by a contract wallet, a router, an exchange batcher or a sponsored call carries other calldata entirely and was not credited before; several transfers to the same address in one transaction are summed. - Changed —
GET /api/ecosystem/wallet/{currency}acceptscontractTypefor older clients and ignores it;GET /api/ecosystem/deposit/unlockstill requires a signed-in caller and then always answers 200 and does nothing. The deposit socket ignores any address a client sends and watches the wallet's own. - Changed — a native-coin transfer sent by an EVM chain's master wallet is
never credited as a customer deposit, and a transfer whose sender cannot be
checked is left for the next poll rather than credited. Under
per_userthe native-coin wallet shares its address with the token wallets, and a gas top-up would otherwise have read as a deposit of the very coin the platform had just spent. Token transfers and deposits on non-EVM chains are not filtered by sender. - Changed — the background scanner drops a deposit watch whose address is no longer the wallet's own on that chain, and a zero-value native transfer is never treated as a deposit.
Withdrawals
- Changed — a UTXO deposit whose outputs are the change of a pool-backing settlement spend is refused as a deposit (409) the same way a withdrawal's own change is, so a lost change record cannot credit a customer with the platform's coins.
- Changed — for a token withdrawal on an EVM chain, the platform's
withdrawal fee row (
transaction.fee) is the platform fee plus the quoted network fee, rewritten to the network fee actually retained once the receipts are read; the profit booked at completion reads the row again, so it is the retained figure, never the refunded surplus. - Changed — when another customer's address pays a withdrawal, the record of what sits at each address moves with the coins: the paying row goes down, the withdrawing customer's row is restored, and the private ledger records the amount as lent to the platform.
- Changed — an EVM destination address is normalised to its checksummed spelling before anything decides whether it belongs to a platform customer.
- Changed —
POST /api/admin/ecosystem/wallet/custodialrefuses with an explanation; new custodial contracts are not created in either mode. The other custodial routes refuse while the mode isoff. - Changed — the user-to-user transfer route checks the
transfer_walletsKYC feature and refuses a suspended or banned sender or recipient before anything else, as the core transfer route already did. - Removed — the custodial address lock, the one-hour expiry sweep, the release route's behaviour, and the deposit-page countdown that drove them.
Documentation
- Changed — the Ecosystem overview and install pages, the Deposit wallets, Master wallet, Tokens and markets and Retiring assets guides, the admin console and operations pages, the Chain families and data-model references, the troubleshooting page, three of the customer help pages (Deposit addresses, Withdrawing on-chain, Ecosystem problems) and the master-wallet runbook describe the new model.
- Added — the Custody migration guide.
- Added the Operations page's "Two backend processes" section: the
layout
ECO_TRADING_ENABLEDstarts, which four prefixes move, the lease rule, the failover window and what to watch. - Added the engine flags, the canary procedure,
ECO_CANCEL_ALL_BATCHandECO_LEDGER_BATCHERto the Core environment reference, a section on what the health route and the log say about them to the Core monitoring guide, and the self-match guard's one observable difference to the order desk page.
The self-match guard can read the engine's own book
The guard that refuses an order which would cross the customer's own resting order read the newest 5,000 rows of the customer's whole order history from ScyllaDB on every placement and filtered them to the market afterwards, a single-partition read that took 3 to 7 seconds on a 7,207-row partition in one recorded incident.
- Changed so that with
ECO_ENGINE_BOOK_V2on, on the process that holds the engine, for a market whose open orders are all resident, the guard reads the engine's per-user set and the history read is not made. Orders mid-cancellation are folded in so a cancel-and-replace is refused for the same seconds as before. The one case the two sources answer differently is recorded rather than hidden: anOPENorder older than the customer's newest 5,000 rows across every market was invisible to the page and is visible to the engine, so a placement against it is now refused where it used to be accepted; the new source is strictly the stricter one. The order desk page describes the case for support. Every other case, and every process, market or flag state that does not qualify, takes the history read exactly as before.
Fixed
A shared custodial address could credit one deposit to two customers
A custodial contract was handed to one deposit session at a time, and the lock was the only thing attributing a deposit to a customer. The credit path released the lock the moment a deposit landed, while the background scanner kept the previous holder's wallet bound to that address for seventy-two hours, rescanning three thousand blocks every two minutes; the next holder's deposit was seen by both, and the duplicate check keys on the transaction and the wallet, not the address. Under a multi-process deployment the lock lived in each process's memory, so two customers could be handed the same address at once.
- Fixed — by removal: no address is shared, so no deposit can be attributed to more than one wallet.
Cancelling a storm of orders parked a database connection per refund
The cancel refund releases the order's hold inside a transaction of its own, opened — and its pool connection taken — before the wallet row was reached. A bot cancelling its ladder every tick therefore held one connection per refund waiting on the same wallet row, and the pool those connections came from is the one every other query in the process shares. The site slowed with the bot, on pages that never touch a wallet.
- Fixed — the refund queues for its wallet's turn in memory and takes a connection only when it is next, through the wallet serialisation added in Core 6.7.6, which this release requires.
A cancellation waited behind the whole matching cycle, without limit
A cancel claims its order under the same lock the matching cycle holds, so on a market deep enough for a cycle to take a while every cancel that arrived during it sat behind the whole cycle, and behind every cancel already queued, with nothing to say when it would be answered. Measured with the engine's own harness: the claim itself is 1.7 milliseconds at 50,000 resting orders and 3.7 at 100,000, but the wait in front of it was the cycle's, and a cycle that stalled took every pending cancel with it.
- Fixed with a bound on the wait,
ECO_CLAIM_TIMEOUT_MS. A claim that has waited that long is refused with 503 andRetry-After: 1, and the message says what is true:The matching engine is busy: <operation> waited N ms for the engine lock. Nothing was changed and the order is still open; retry.The refused claim never runs, writes no marker and leaves the order where it was; its place in the lock's queue is released when the cycle ahead of it finishes, so an abandoned wait can never hold the engine. The bound lives inside the engine's lock, not around it, for exactly that reason. The cycle, resync and eviction paths are never bounded.0, the default, leaves the claim path byte for byte as before. - A Hummingbot cancel-all reports a refused claim per order under
failed[].reasonwith a 200, as it reports every per-order failure; the session cancel-all propagates the 503.
A refused hold still cost the order its ScyllaDB writes and a rollback
When the wallet gate refuses a hold, the placement has already written the order to ScyllaDB and must delete it again, so a burst of orders on one wallet that the gate was going to refuse anyway still paid two ScyllaDB writes each and rolled back one by one.
- Fixed behind
ECO_PREHOLD_ADMISSION: immediately before the order is written, the placement asks the wallet gate whether the hold it is about to take would be refused at entry, keyed on the wallet the order spends, and refuses with the gate's ownWALLET_BUSY(503) there instead. No row is written and nothing is rolled back. In this release the check consults the connection-slot bound (WALLET_QUEUE_MAX_SLOT); the per-wallet bound is still applied by the hold itself, after the row is written, as before. Off by default, and off the placement path is untouched. Requires Core 6.7.6 for the bounds it consults.
Permit withdrawals refused as "contact support" on a master wallet that held gas
The pre-flight check before a permit withdrawal compared the master wallet's
balance of the token against the gas cost in wei. A master wallet holding
plenty of native coin and none of the token — the normal state — failed the
check, and the customer was told "Withdrawal failed, Please contact support
team."
- Fixed — the check reads the master wallet's native balance, and the refusal names the balance it found and what to fund.
A withdrawal to another customer's secondary-chain address left the platform
The reverse lookup that turns a withdrawal into an internal transfer indexes
each wallet's first address and falls back to a text scan for the others. The
scan was issued through the ORM against a JSON column, which quoted the pattern
— LIKE '"%address%"' — so it could never match a properly stored address map
(only a row in the older double-encoded form). A withdrawal to another
customer's Polygon or Arbitrum address therefore went on-chain, paid gas, and
was credited back by the deposit monitor rather than settling internally.
- Fixed — the scan is a
JSON_SEARCHexact match on the address map, with the text scan kept for rows written in the older double-encoded form. The lookup lives in its own module and prefers the owner's wallet in the withdrawn currency.
A lowercase copy of a platform address was treated as external
Deposit addresses are stored in their checksummed, mixed-case spelling and the internal-transfer lookup compared exact strings, while address validation accepted a lowercase address as valid. A customer who pasted a lowercase copy of another customer's address — or of their own — was treated as withdrawing to an outside address: the coins left on-chain to an address the platform holds the key for, and a native self-send was never credited back because the deposit-side duplicate check did not distinguish the withdrawal row from a deposit.
- Fixed — an EVM destination is checksummed before the lookup, the lookup compares EVM addresses case-insensitively, the native deposit check matches only deposit rows, and the destination is never chosen as the address that pays a withdrawal.
An alternative-sourced withdrawal debited the wrong record
When a PERMIT withdrawal was paid from another customer's address, the
follow-up that lowers the platform's record of that address found its target
with a query for the first wallet_data row on the chain and currency — not the
row that had paid — and posted two private-ledger entries: a debit against
whichever row that query returned and a credit against the row that paid. When
the two were the same row the entries cancelled and the loan went unrecorded;
when they were not, the debit landed on an address that had paid nothing.
- Fixed — the settlement targets the row that paid, by id, and posts one ledger entry for the amount lent.
The TRON alternative-source selector chose addresses that held nothing
The selector accepted a wallet's canonical platform balance as evidence of coins at its Tron address. A wallet row is one per currency across every chain, so a USDT holder whose coins sat on Ethereum qualified as a Tron source with nothing at the address; the master wallet paid to fund it with energy, the transfer reverted, the customer was refunded, and the same empty candidate was chosen again on every retry.
- Fixed — the selector reads the per-chain tracker only and excludes the candidates a withdrawal has already tried. Tron 6.1.5 pairs it with a live on-chain balance check before any gas is spent.
A UTXO withdrawal recovered from a crash minted its change
The queue's recovery for a withdrawal that broadcast and then crashed before committing marked the spent inputs but never recorded the change output. Change is paid to a customer address, so that customer's monitor later saw a transaction paying their address with no record of it and credited the change as a deposit, out of pool funds.
- Fixed — recovery decodes the transaction's outputs, finds the one paying a platform address and records it as change; and an output the UTXO resync imported from the node is treated as platform-produced when its transaction is a platform withdrawal.
UTXO fee accounting
- Fixed — a batch's real network fee is apportioned to its members and
recorded on each row, and the platform profit booked is
platformFee − networkFee, floored at zero with the shortfall logged. The full platform fee was booked while the pool paid the network, so custody shrank by the network fee on every withdrawal whose platform fee was below it.
The Bitcoin node-mode scanner credited one output per transaction
In BTC_NODE=node mode the scanner read the node's transaction list, which
carries one entry per output, credited the first entry it met for a
transaction and skipped the rest as already seen, and passed no outputs on, so
no UTXO rows were recorded and the coins were invisible to the withdrawal pool.
- Fixed — the scanner aggregates entries by transaction, fetches the full transaction from the node as the API providers already did, credits every output paying the address, and records the UTXO rows. The job itself is carried by Core; the node provider's transaction fetch is in this extension.
The ticker froze at boot on a market whose candles no fill writes
The in-memory daily candle every ticker figure is derived from is advanced, on the process that runs the matcher, only when a real fill updates it. That is a complete mechanism for a market whose candles are written by fills. It is not one for a market whose price series is published by the AI market maker, which writes candles directly and never touches the matcher's memory — and which does so every ten seconds on a market with no real trades at all.
On those markets the ticker held its boot value for the life of the process, and it reads as a dead market: a last price that has not moved in a day, a 24-hour range that never changes, zero volume, and — because the order book is republished from the current price — a header price sitting below the entire book.
- Fixed — the process that runs the matcher re-reads the stored candles on the same minute timer the read-only followers already used, taking a stored candle only when it is genuinely newer than the one held in memory. Between a fill updating memory and its write landing in storage there is a window where memory is the fresher of the two, and the followers' wholesale re-read would have taken the pre-fill row back over it.
A customer could not buy a coin whose deposits were misconfigured
Opening a wallet and issuing a deposit address are different acts, and one function did both. Creating a wallet row for a currency ran the deposit-address rule first and refused outright when no chain of that currency could be monitored — and that same function is the fallback both wallet doors use when a customer does not yet have a wallet in the currency at all.
So a customer who had never held a coin could not do anything that needed a balance in it. A newly registered customer who deposited USDT and placed a buy for Bitcoin on the order book was refused with "No enabled tokens found for BTC" — a message about Bitcoin deposits, on an order that never touches an address. A trade reads the balance and the held amount and nothing else.
It only ever hit customers who had not held that coin before, because both doors look the row up before they create one. Anyone already holding Bitcoin kept trading normally, which is why the platform looked healthy from the inside and why the fault appeared to come and go.
The same refusal reached an affiliate reward paid into an Ecosystem wallet, an internal transfer to a recipient who had never held the currency, the balance check that sizes a stop order's reservation, copy-trading credits, and the market maker's pool funding.
- Fixed — a wallet is now opened without issuing an address when there is nothing to issue, and the row simply carries no address. Nothing to issue was never a reason to refuse the wallet.
- Fixed — the refusal stays where an address is actually handed to a customer. Issuing a deposit address on a chain no monitor can watch is still refused, before the address exists, because that refusal is what stops customer funds being sent somewhere nothing is listening.
Cancelling an order could take the money it was meant to give back
When cancelling an order, the platform releases the held funds and then marks the order cancelled. If the wallet could not be opened it cancelled anyway and released nothing, on the reasoning that a wallet which cannot be opened never held anything.
That is true only when the wallet does not exist. Several failures happen after the wallet row has already been read — a wallet whose address list is empty, a stored address that will not parse, or an ordinary database timeout — and in those cases the funds are real and still held. The cancellation was written to a different store from the balance, so it landed: the order became CANCELLED while the money stayed locked, and every route that could have returned it was then closed. A retry answers "not open", the refund path stops on the same test, the admin wallet editor will not edit a held amount, and no job recalculates it. The engine's own log recorded it as a successful release of zero.
- Fixed — the platform now checks whether the wallet row exists before deciding. A wallet that was never there still cancels and releases nothing, which is the deleted-customer case this was written for. A wallet that exists and cannot be opened refuses the cancellation instead: the order stays open, stays funded, and the cancellation can be retried once the cause is cleared.
- Fixed — a check that cannot answer is treated as "the wallet exists". The two outcomes are not equally recoverable: refusing leaves the order retryable, while guessing wrong locks the money permanently.
Deleting a market kept part of every resting buy order
Placing a buy order holds the cost of the order and its fee. Cancelling one gives both back. Deleting a market gave back the cost alone, so every resting buy order swept by an admin market deletion left its fee locked in the customer's held balance — and the order records are deleted in the same operation, so nothing afterwards could identify that money, let alone return it.
The same refund was also the only one in the extension that could give back more than was held. Both delete routes stop the matching engine only after the sweep has finished, so orders keep filling while it runs; a fill that consumed part of an order between the read and the refund left the refund oversized, and without the release-only flag the excess was credited as new balance rather than ignored. Widening the refund to include the fee widens that excess, so both are corrected together.
- Fixed — a market deletion refunds the cost and the fee, prorated by the unfilled portion, matching what placement held and what a normal cancellation returns.
- Fixed — that refund can now only release funds that are actually held. It can no longer create currency when a fill lands mid-sweep.
- Fixed — one order that cannot be refunded no longer abandons the rest. The market row is destroyed before the sweep finishes, so a failure part-way through left every remaining order holding its funds against a market that no longer existed, and neither delete route could be run again because both stop on the missing market. Each failure is now logged with its order and the fact that its funds are still held, and the sweep continues.
A sell order for a wallet that does not exist reported a broken venue
The sell branch of order placement read the wallet's balance before checking whether the wallet was there, so the intended "wallet not found" answer was unreachable and the customer got a generic failure with an internal error message in it. The buy branch had always been in the right order.
- Fixed — the check runs before the balance is read, so the customer is told which wallet is missing.
The check that explains a vanished currency never ran for Bitcoin
The chain diagnostics include a check whose whole purpose is to explain a currency that has simply disappeared from the deposit list. It ran for four chains — Solana, Tron, TON and Monero — and for nothing else. Bitcoin, Litecoin, Dogecoin, Dash and every EVM chain never reached it.
That is why the failure described in Core 6.7.6 could hide in plain sight: on an affected server every Bitcoin diagnostic was green, because the block explorer really was reachable, while Bitcoin had silently gone from the customer deposit list and could not be traded. The only signals were log lines that reach no screen.
- Fixed — the check now runs for every chain, so a hidden currency is reported on the chain's own diagnostics page instead of being discovered when an order fails.
- Fixed — the "Active tokens on current network" figure counted rows whose network was spelled exactly like your configured value, which disagreed with the rule that decides eligibility. A Bitcoin install could read a healthy count while every row was in fact ineligible, or the reverse. It now counts the rows the platform would actually accept.
- Fixed — the Bitcoin-family report counted enabled tokens on any network under a heading that says "on current network", so an operator could not see a network mismatch from that figure at all.