Troubleshooting

Symptom-first diagnosis for Ecosystem — trading 503s, deposits that never credit, withdrawals stuck pending, chains that look healthy but are not, and order books that disagree with the orders behind them.

17 min readUpdated 3 September 2026troubleshooting, diagnostics, deposits, withdrawals, scylladb, eip-7702, network-fee

Start at /admin/ecosystem. The overview answers three questions — can we pay people, is anything stuck, is a chain degraded — and most of what follows is reachable from a link on that page.

  1. Can we pay people — assets short of cover

If a specific chain is involved, run its diagnostics before anything else: Admin → Ecosystem → Blockchains → Requirements → select the chain → run the test. It names the missing variable rather than making you guess, and it reports readiness per platform flow rather than per endpoint.

Fast triage

Symptom Most likely cause
Every trading endpoint returns 503 ScyllaDB unreachable, or SCYLLA_ENABLED="false"
"Encryption key is not set" on any wallet action Vault locked — no passphrase set, and nobody unlocked it after the last restart
Orders are accepted but never fill The matching engine is not in the process serving the order
Deposits detected but never credited The verification job is not running, or confirmation depth is not reached
Withdrawals sit at PENDING Vault locked, master wallet missing or disabled, or the queue lost the row
One chain works, another does nothing Wrong <CHAIN>_NETWORK value — the RPC key it reads does not exist
Health says a chain is Up, but nothing works The ARBIRUM typo, or a badge satisfied by a key that is merely present — the overview only checks that, never that the endpoint answers
A token withdrawal is refused with "No funded source for … on …" Nothing the engine may pay from holds enough of the token on-chain — the customer's address, other customers' addresses, the master wallet, or a legacy custodial contract while draining
Token withdrawals on one chain take two transactions and more gas than expected The chain refused an EIP-7702 transaction and is listed under Settings → Diagnostics; the just-in-time-gas fallback is in use for 24 hours
A token withdrawal charged no network fee; the log says "network fee skipped" The token or the chain's native coin has no USD price, and the token has no fee.network fallback
Token withdrawals fail, native ones succeed The master wallet is out of native coin
A price level shows in the book with no order behind it Order book divergence — run the rebuild script
Funds locked in inOrder with no open order Hold residue — run the reconcile script
The Ecosystem admin menu is missing Permissions not granted, or the backend was not restarted after granting

Diagnosis in detail

Trading endpoints return 503 but wallets work

This is the designed behaviour when ScyllaDB is unavailable. Wallets, deposits and withdrawals do not depend on Scylla; orders, the book and candles do.

Check three things in order. Is SCYLLA_ENABLED set to "false"? Is the node running (systemctl status scylla-server)? Does the backend log show ScyllaDB unreachable at … at boot?

The backend probes the first contact point with a short TCP check before it retries, so an absent node costs a warning rather than a minute of exponential backoff. If the port is open but CQL is not yet serving, you will instead see connection retries with doubling delays and then "Max retries reached".

Also check SCYLLA_DATACENTER. It must match the datacenter name the node reports, not a name you chose, and a mismatch fails the connection rather than degrading it.

"Encryption key is not set"

The vault is locked. Either ENCRYPTION_KEY_PASSPHRASE is not set and nobody has unlocked it since the last restart, or ENCRYPTED_ENCRYPTION_KEY is missing or malformed.

Open /admin/ecosystem. The masthead shows Initiate Vault when locked — enter the passphrase there. If the badge is green and you still see this error, the process serving your request is not the one that was unlocked: on a multi-process deployment each process holds its own decrypted key in memory.

Check the format too. ENCRYPTED_ENCRYPTION_KEY must be exactly four colon-separated hex parts. Three parts, or a truncated copy-paste, will not decrypt.

Deposits are detected but never credited

Crediting is done by one job — verifyPendingEcoDeposits, every 60 seconds. Check Admin → System → Cron that it is registered and running. If the whole addon's job group is absent, the extension is disabled or the cron process did not pick up the change.

If the job is running, the transaction has not reached confirmation depth. Required depth is 3 for Bitcoin, 6 for Litecoin, Dogecoin and Dash, and 12 by default for anything that does not specify its own.

If neither, look for the pending record. Between detection and crediting the transaction lives in Redis, not MySQL. A Redis flush in that window loses the pending record — the coins are still at the user's address and will be found again on the next scan of it, but nothing will happen until that scan runs.

Finally, check that the token is active and its network matches the chain's configured network. A token whose network no longer matches is skipped when addresses are issued.

A deposit went to an address the user no longer sees

Deposit addresses are generated for the network configured at the time. If BTC_NETWORK was flipped after users had addresses, those addresses belong to a different chain and are invalid — they must be regenerated, and anything sent to them is on the old network.

The same applies less dramatically to Solana: SOL_NETWORK set to anything other than mainnet or testnet silently falls through to devnet, and SOLANA_NETWORK is a separate key that only stamps metadata. If the two disagree, the stored record lies about which cluster the address is on.

Older Tron wallets are a special case that self-heals: a Tron entry holding a 0x… EVM address predates proper Tron derivation and is regenerated automatically on the next fetch. Seeing that in the logs is the fix working, not a fault.

Withdrawals sit at PENDING and never move

Work through these in order.

Is the vault unlocked? Signing needs key material.

Does the chain have a master wallet, and is it enabled? The diagnostics downgrade withdrawal readiness to failed for either condition, and a disabled wallet behaves exactly like a missing one.

Did the queue lose the row? The queue is in-memory and the debit is durable, so a restart between the two strands the row. Three recovery passes exist — one at boot with no age filter, a watchdog every 5 minutes for rows older than 3 minutes, and a legacy 30-minute pass. If a row is older than that and still PENDING, it is in the set the recovery deliberately refuses to touch, which is exactly the count the admin overview breaks out.

Is it a token withdrawal on a chain with no gas? The master wallet pays the gas on-chain, in the chain's native asset, for every token mover — and for a token moved out of a draining legacy custodial contract too. The customer repays it in the withdrawn token afterwards, but the coin has to be there first. Native withdrawals still succeed in this state, which is why the failure looks selective.

A PROCESSING row that already carries a transaction hash is never re-broadcast — recovery promotes it to COMPLETED, because the funds have irreversibly left. That is correct, not a bug.

A token withdrawal is refused with "No funded source for … on …"

The full message names the amount, the token and the chain, then lists what was checked: the customer's own address, other customers' addresses, the master wallet, and — while the custodial mode is drain — the legacy custodial contracts. Every candidate is checked on-chain before it is chosen, so this is the engine saying that nothing it is allowed to pay from actually holds the token, whatever the platform's own records say. The customer is not debited.

Two causes account for nearly all of them.

Legacy credit sitting in a custodial contract, with the mode set to off. A wallet whose balance was earned under the old shared-deposit model has its coins in a contract, not at any address. With ecosystemCustodialMode on off those contracts are not a source. Open Admin → Ecosystem → Settings → Custodial wallets: if contracts still exist, either switch back to drain or sweep each contract to the master wallet, after which the master wallet is a source in either mode.

A recorded balance the chain does not back. wallet_data.balance says an address holds the token and the explorer says it does not — a deposit credited from a transaction that was later reorganised, or an address that was paid away from outside the platform. Compare the address on the explorer, then check the private ledger at Admin → Ecosystem → Wallets → Ledgers for that wallet. That is a coverage problem, and the overview will be reporting the asset as short of cover.

A chain is listed under Settings → Diagnostics as refusing EIP-7702

Admin → Ecosystem → Settings → DiagnosticsChains refusing EIP-7702 lists every chain whose node rejected a type-4 transaction, with the time it happened. The log carries the same event under ECOSYSTEM: <CHAIN>:<network> refused an EIP-7702 transaction; using the fallback mover for 24h.

This is not a fault. The chain is either not past the Pectra upgrade, or its RPC provider does not relay type-4 transactions yet. For 24 hours every token withdrawal on that chain uses the fallback — permit for a PERMIT token, otherwise just-in-time gas, which is two transactions and costs the master wallet more — and then the next withdrawal probes again. The entry clears itself the first time a type-4 transaction is accepted.

Only the vocabulary of a "transaction type not supported" refusal is treated this way. A revert, an insufficient-funds error or a nonce clash is reported as the failure it is and never puts a chain on this list — so a chain that keeps failing withdrawals without appearing here has a different problem, and the log line under ECO_WITHDRAW names it.

If you know a chain will never accept type-4 transactions and want to skip the daily probe, set ecosystemEvmMover to jit_gas under Settings → Withdrawals. That applies to every chain, so it is the right answer only for an install whose chains are all in that position.

A token withdrawal charged no network fee

With ecosystemChargeNetworkFee on, a token withdrawal's estimated gas is converted into the withdrawn token through the platform's USD rates and charged to the customer. When either side of that conversion has no price, the engine logs under ECO_WITHDRAW:

No USD price for <symbol> on <chain>; network fee skipped — set fee.network on the <currency> token or list <native> on a market

and sends the withdrawal anyway, with the master wallet absorbing the gas. A token that cannot be withdrawn is a worse outcome than one unrecovered fee, so this is a warning rather than a refusal — but every such withdrawal costs you the gas.

Two fixes, and the message names both. Set the token's fee.network on its edit screen at Admin → Ecosystem → Trading → Tokens — a flat amount in token units that is charged whenever the priced estimate is unavailable. Or give the unpriced side a price: list the chain's native coin, or the token, on a market. A Gas price unavailable on <chain> line in the same place is the RPC failing to quote a fee at all; that is the chain's RPC, not pricing, and the diagnostics for that chain will show it.

A chain reports healthy but nothing works on it

Three known shapes of this.

The Arbitrum typo. ARBIRUM_MAINNET_RPC, missing the second T, is read by the admin balance endpoint and the legacy health check but never by the real provider path. Set only that and health says Up while deposits and withdrawals are broken. Set ARBITRUM_MAINNET_RPC.

Keys that used to be display-only. SOL_<NETWORK>_RPC and XMR_<NETWORK>_RPC (each with a _FALLBACK sibling) are read by the services now — a comma-separated list forming a failover pool, replacing the public cluster endpoint and XMR_DAEMON_RPC_URL respectively. The pool does not walk the list in order: it prefers an endpoint it has never tried, then the fastest one measured, so any entry can serve any query and one bad daemon in the list is not harmless. The Monero diagnostics therefore probe every entry. The overview badge still only checks that the key is present, which proves nothing about reachability; the Solana test probes the public cluster endpoint.

Explorer coverage gaps. Etherscan's free tier is paid-only for BSC, OP Mainnet, Base and Avalanche, and Fantom, Cronos and HECO are not on V2 at all. Only three of those have a keyless instance the provider chain falls back to on its own — OP Mainnet and Base on hosted Blockscout, Avalanche on Routescan. BSC, Fantom, Cronos, HECO and Polygon Amoy have neither a hosted Blockscout instance nor Routescan coverage, so on those chains no keyless provider is appended to the order. BSC is the one where a key is the normal answer for a production install: set NODEREAL_API_KEY for mainnet, which is free for BSC, or MORALIS_API_KEY / COVALENT_API_KEY for testnet, the only two that index chain 97. Without one, transaction history and native-deposit detection fall back to an RPC block scan, which is slower and only walks forward from now.

Provider initialisation fails with "Chain ID not found"

<CHAIN>_NETWORK holds a value that is not one of that chain's valid network names, so the <CHAIN>_<NETWORK>_RPC key it derives does not exist.

The classic is Polygon: its mainnet is called matic, not mainnet. Also check that the RPC you configured actually serves the chain ID the network expects — the diagnostics report a mismatch explicitly.

Remember that a fresh value needs a restart. Provider instances are constructed at module load and nothing re-reads .env.

Orders are accepted but never match

The matching engine boots inside the backend process, and exactly one process may own it. Order placement puts the order in the serving process's in-memory queue, so if the wrong process holds the lease, orders rest forever.

A dedicated cron process is refused the lease structurally, precisely to prevent this. If you are running a custom process layout, check the engine_lease table and the boot logs for a message about another process holding the lease.

The same rule is why the AI Market Maker must run in the engine's process: its bots enqueue into that same in-memory queue, and an order enqueued anywhere else is refused.

"Market metadata missing precision details"

The market has no precision.amount and precision.price configured, and order placement refuses rather than guessing. Edit the market and set both, along with the amount, price and cost limits.

This is the usual cause of a newly created market that accepts nothing — the market row is created before its metadata is filled in.

A level in the book is not in anyone's open orders, and Cancel All will not remove it

The symptom is specific: the book displays depth at a price, the customer's Open Orders tab does not list it, and both cancelling it individually and Cancel All leave it exactly where it was. It usually appears after a restart.

Check the order index first — not the book. Every list in the UI reads the orders ledger, so an order the ledger has already cancelled or deleted is correctly absent from all of them. The matching engine reads a different table: open_orders_by_market, the book-ordered index. A row left there with no OPEN order behind it is loaded at every boot as a live resting order, and the five-minute reconciler then rebuilds the aggregated level to match it. The depth is real to the engine and invisible to every cancel door the customer has.

pnpm eco:index:check                       # read-only; names the markets that disagree

A market reported with STALE=n is this case. The repair:

pnpm eco:index:repair                      # removes the stale rows

Then restart the backend. The engine is still holding those orders in memory, and until it reloads it will keep the level standing.

From 6.6.4 the server does this for itself: it compares both counts at boot, and one market an hour while it runs, and rebuilds any market whose index holds more rows than the ledger has open orders — before a single order is loaded, so one restart clears both the index and the depth it was producing. eco:index:check stays the way to confirm it, and the only way to see the state on a version that does not yet self-heal.

Two things this is NOT, and both look identical from the trade page: a genuine ghost level (below), and funds still held for an order that no longer exists (further down).

The order book shows levels with no orders behind them

Three ways the aggregated book can diverge from the orders table: a level with no backing open order (ghost), an open order with no level (missing), and a level whose amount does not equal the sum of remaining at that price (mismatch).

pnpm rebuild:eco-orderbook                                 # report
node backend/scripts/rebuild-eco-orderbook.mjs ETH/USDT --execute

Run pnpm eco:index:check before this one. This script rebuilds the book from the orders ledger, which is the right authority — but the running engine rebuilds it from what it loaded out of open_orders_by_market, so if that index is holding rows the ledger disowns, the level you delete here comes straight back. Repair the index, restart, then rebuild the book if anything is still wrong.

It is non-destructive: orders, trades, candles and user funds are untouched. It also reports ecosystem levels stranded in the futures keyspace by an older defect where unqualified writes landed in the wrong keyspace.

Restart the backend afterwards. The engine holds the book in process memory and a repaired table plus a stale process disagree immediately.

Symbols with an active AI market maker are skipped by default — that book is maintained without real order rows, so rebuilding from orders would wipe its quotes. Pass --include-ai to override, having stopped the market maker first.

The backend's own five-minute sweep is more discriminating than this script, and from 6.6.4 more so again. It identifies the market maker's own levels individually and reconciles only the rest, so an AI market is normally swept without touching its quotes. Where it cannot tell them apart — a market whose maker is configured ACTIVE while none of the levels are recognisably its own — it now waits for two consecutive quiet sweeps and then treats the book as customer depth, on the grounds that a maker which is quoting rewrites its quotes continuously. Before 6.6.4 it gave up on such a market permanently, which is how an unbacked level there became impossible to remove.

If you keep a maker configured ACTIVE while deliberately not quoting, its old ladder is now cleaned up like any other unbacked depth. Stop the market maker for that market if you want the ladder left alone. The transition is logged once per market rather than on every sweep, so Reconciling <symbol>: in the backend log is where to look.

Funds are locked in inOrder with no open order

Hold residue. Report it, then release it:

pnpm reconcile:eco-inorder                 # report
node backend/scripts/reconcile-eco-inorder.mjs --apply
node backend/scripts/reconcile-eco-inorder.mjs --user <userId> --apply

It recomputes the correct hold from the user's open orders and releases only the positive difference. It never debits a balance and never raises a hold, so it cannot over-credit. An under-locked wallet is reported rather than fixed — that is pnpm fix:eco-orders.

Restart the backend after applying, so cached wallet rows refresh.

The Bitcoin Core RPC check fails while bitcoin-cli works

The row prints the socket error and the address the backend actually used — connect ECONNREFUSED 127.0.0.1:8332, a 401, a 403 — and that address is the whole story. bitcoin-cli reads bitcoin.conf: the network, rpcport= and the .cookie file. The backend reads .env: BTC_NODE_HOST, BTC_NODE_PORT, BTC_NODE_USER, BTC_NODE_PASSWORD. A CLI that answers proves the node is up, not that .env points at it.

The row says What is wrong
ECONNREFUSED …:8332 with BTC_NETWORK naming a test network The backend is knocking on the mainnet port. Bitcoin Core serves testnet4 on 48332, testnet3 on 18332 and signet on 38332 — and off mainnet a top-level rpcport= is ignored, so only an rpcport= inside that chain's own [testnet4] / [test] / [signet] section changes it. Set BTC_NODE_PORT to whatever the node actually listens on. The requirements report warns about this combination before you run the test
401 Unauthorized The node rejected BTC_NODE_USER / BTC_NODE_PASSWORD. bitcoin-cli authenticates from bitcoin.conf or the cookie file, neither of which the backend reads — .env must carry the rpcuser= / rpcpassword= pair (or an rpcauth= user) the node accepts
403 Forbidden Either rpcallowip= does not admit the address the backend connects from — loopback is always admitted, so this means a non-loopback path: another host, a Docker bridge, the box's own LAN IP — or rpcwhitelist= is in force. If the backend's RPC user has its own rpcwhitelist= line, add the call to it; if it has none, add a line or set rpcwhitelistdefault=0
Nothing at all, until the row times out Not a warming-up node — that answers instantly with Loading block index…. A timeout is a firewall dropping packets, the wrong host, or a node stalled under a heavy reindex
Node runs "test" but BTC_NETWORK=testnet4 The node is on testnet3. testnet is an alias of testnet4 on this platform; either move the node or set BTC_NETWORK=testnet3

The fallback to mempool.space is decided once and cached, so a red row means one of two different situations and the page cannot tell you which.

  • The node was already failing when this backend process first needed a BTC provider. The factory fell back to mempool.space, cached that choice for the life of the process, and deposits and withdrawals ride Esplora — which is why nothing else looks broken. What you lose is ZMQ realtime detection and node-side address tracking.
  • The node failed after that. The factory never re-evaluates: the runtime is still on the cached node provider, whose calls now fail silently (they return empty results rather than raising), so deposits stop being detected.

Either way the repair ends the same: fix the cause, then restart the backend — nothing returns to the node, or moves off it, without a restart. The estimatesmartfee and ZMQ rows are skipped rather than failed whenever getblockchaininfo itself fails — transport, auth or an RPC error — so one cause shows as one red row. A node that answers with the wrong chain is a different case: that row fails while the other two still run.

A UTXO withdrawal is refused as "not economical"

The available unspent outputs would cost more in network fees to spend than the amount being withdrawn — usually many small inputs from repeated small deposits.

Check Admin → Ecosystem → Wallets → UTXO for that wallet. If the inputs are genuinely fragmented, the withdrawal needs to be larger or the wallet needs consolidating. If inputs show as LOCKED against a transaction that never completed, that is a stuck payout rather than a fee problem — check the withdrawal queue.

Scylla logs BusyConnectionError

The driver caps in-flight requests per connection and refuses beyond it. The write path that used to cause this is bounded now, so seeing it today usually means genuine concurrency on an under-provisioned pool.

SCYLLA_LOCAL_CONNECTIONS tunes it, accepting 1 to 32 and defaulting to 4. A shared two-core VPS wants fewer; a dedicated cluster more. A bigger pool is headroom, not a fix — if it recurs immediately after raising it, something is issuing unbounded writes and the pool size is not the problem.

The Ecosystem admin menu is missing after enabling the extension

Two causes, usually both.

Permission gates ship strict and fail closed, so a newly enabled addon is reachable by Super Admin only until an operator grants its keys per role in Admin → Roles. The addon enforces twenty-eight keys on its routes; access.ecosystem is the one that opens the console.

And the route gate is held in memory, so a grant does not take effect until the API process restarts.

If the menu is missing for a Super Admin too, the extension is not actually enabled — check Admin → System → Extensions and confirm the licence validated.

Logs worth grepping

pm2 logs backend --lines 500 | grep -E "SCYLLA|ECOSYSTEM|ECO_ENGINE"
pm2 logs backend --lines 500 | grep -E "DEPOSIT|DEPOSIT_SCAN"
pm2 logs backend --lines 500 | grep -E "WITHDRAW|ECO_WITHDRAW"
pm2 logs backend --lines 500 | grep -E "ENCRYPT|WALLET"

ADMIN_ECO is the module for administrative actions and appears in the audit trail rather than only in the console.

When to escalate

Stop and get help rather than experimenting if any of these are true:

  • The vault passphrase is lost or ENCRYPTED_ENCRYPTION_KEY was replaced. There is no recovery and every further write makes reconciliation harder.
  • The coverage panel reports an asset as unbacked and you cannot account for the difference from the private ledger.
  • A withdrawal is PROCESSING with a transaction hash that does not exist on-chain.
  • ScyllaDB has been restored from a snapshot older than MySQL — the two stores now disagree about which orders exist, and pnpm eco:index:check will tell you by how much.