Switching the active exchange provider
Moving spot onto or off XT — what the toggle actually writes, the restart that applies it, the IPv4 allowlist that decides whether XT answers, and the re-imports that follow.
Exactly one row in the exchange table carries status = true, and the admin
panel makes changing it a single switch. That switch is honest about one thing
only: which row is flagged. The running connection, the market and currency
tables, open orders, pending deposits and the coins themselves are all
unaffected and stay pointing at the exchange you just left.
Your customers' SPOT balances are ledger rows. The assets backing them sit in whichever exchange account held them. Flipping the provider does not move an asset, does not close an order and does not tell you the shortfall. Read this page end to end before you touch the toggle, and do it with trading paused.
What the switch actually writes
The on/off control is on Admin → System → Extensions, filtered to exchanges
(/admin/system/extension?type=exchange) — not on the exchange hub.
Sending status: true does three things and nothing else:
- Reads
lic/<productId>.licfrom disk for the row being enabled —54510301.licfor XT. No file,403withlicenseRequired: true, and nothing is written. - Opens a transaction and runs
UPDATE exchange SET status = falsefor every row whoseidis not this one. - Sets
status = trueon this one, and commits.
That is the whole handler. It never touches exchange_market,
exchange_currency, exchange_order, wallet, the chart cache or the running
connection, and it never tells a process that anything changed.
POST /api/admin/finance/exchange/provider/{productId}/activate shares no code
with the toggle — it calls saveLicense(productId, envatoUsername), which runs
its own transaction keyed on productId. The effect is the same shape: on
success it sets status: false on every other provider and status: true on
the one you just licensed, along with licenseStatus and username.
So activating the XT licence "just to have it ready" silently takes your current provider out of service. If you are buying XT for later, expect to switch back deliberately afterwards.
The trap: the connection is memoised for the life of the process
ExchangeManager is a process-lifetime singleton holding two private fields
that both survive the switch:
| Field | Holds | Cleared by |
|---|---|---|
provider |
the provider name, read once from the exchange table |
removeExchange(name), and only when name is the one this process is holding |
exchange |
the live ccxt instance for that name | removeExchange(name) under the same condition; stopExchange() |
startExchange() — what every spot path calls to get a connection — returns the
cached instance before it looks at anything else. There is no timestamp on it,
no version check and no invalidation hook. A process that built a KuCoin
connection before you flipped the switch keeps handing that same KuCoin
connection to order placement, the ticker stream, the deposit verifier and the
admin finance screens for as long as it lives.
Three consequences:
stopExchange()is not enough. The ticker and market WebSocket services call it when the exchange errors. It closes the instance and drops it from the cache, but leavesproviderset — so the nextstartExchange()rebuilds a connection to the old exchange under the old name.- The one eviction that works is per-process. Saving the provider row
(
PUT /api/admin/finance/exchange/provider/{productId}, the proxy field on the exchange hub's Settings tab) callsremoveExchange(name). That always dropsnamefrom the instance cache, but it nullsproviderandexchangeonlyif (this.provider === provider)— so saving the row for the provider you switched to does not clear them; saving the row for the old one does. Either way it runs only inside the process that served that request. backendandcronare separate processes.production.config.jsstartsbackendwithCRON_MODE=offandcronwithCRON_MODE=only. Both build their own connection. Both must be restarted.
Why Verify Credentials tells you everything is fine
Both build a brand-new, throwaway ccxt instance from
APP_<PROVIDER>_API_KEY / _API_SECRET / _API_PASSPHRASE, load markets, call
fetchBalance, and close it. Neither looks at the cached instance and neither
replaces it.
That produces the most misleading state this product can be in: the exchange hub
reports XT, licensed, "API credentials are valid and connection successful" —
while every customer order in the same minute is still being placed on the old
exchange. A green verify proves your .env is right. It proves nothing about
what the running processes are doing.
What the switch leaves behind
Nothing in this table is touched, migrated or flagged.
| What | State after the switch | Consequence |
|---|---|---|
exchange_market rows |
unchanged — symbols, metadata.precision, metadata.limits, metadata.maker, metadata.taker |
orders are validated and priced against the old exchange's rules |
exchange_currency rows |
unchanged — currency, name, precision, fee, status |
listed currencies XT does not carry stay listed |
| Deposit and withdrawal networks | read live from the active provider at request time, never stored | this half follows the switch on its own, and immediately disagrees with the currency rows |
exchange_order rows at OPEN |
unchanged, still holding funds in wallet.inOrder |
see below — these become unsettleable |
wallet rows of type SPOT |
unchanged, balance and inOrder intact |
the ledger still says the customer owns coins; the coins are at the old exchange |
transaction rows of type DEPOSIT at PENDING |
unchanged | the verifier now polls XT for a txid that landed elsewhere |
| Chart cache | data/chart/<base>/<quote>/<interval>.json.gz and Redis ohlcv:<symbol>:<interval> — no provider in either path |
the old exchange's candles are served as XT's history |
exchange:ban_status in Redis |
one global key, not per provider | a rate-limit ban earned on the old provider still mutes XT until its TTL expires |
Before you switch: settle the book
Do this while the old provider is still active. Most of it is impossible afterwards.
/admin/finance/order/exchange is read-only — canCreate, canEdit and
canDelete are all false. Adjust Balance on /admin/finance/wallet moves
balance by ADD or SUBTRACT and does not touch inOrder. The only two
paths that return held funds are the customer's own cancel and the
reconciliation cron, and both must first resolve the order on the active
exchange — which is exactly what stops working after the switch.
-
Stop new orders, as far as the platform lets you. Set
status = falseon every row on the markets screen (/admin/finance/exchange/market). It has no menu entry of its own — reach it from the Markets card on Admin → Finance → Trading Infrastructure → Exchange Providers. A disabled market drops out of/api/exchange/marketand out of the ticker WebSocket, so it disappears from the trade screen. That is presentation, not enforcement — the spot order-create route looks the market up bycurrencyandpairand never readsmarket.status. Watch the OPEN order count in step 2 rather than assuming the book is frozen. -
Clear every OPEN order. Filter
/admin/finance/order/exchangeonstatus = OPEN. Each row'sreferenceIdis the order id at the old exchange; once the connection points at XT that id resolves to nothing. -
Finish pending spot deposits. A
DEPOSITtransaction atPENDINGis waiting for the verifier to match its txid against the active exchange'sfetchDeposits. Coins that arrived at a KuCoin address will never appear in XT's deposit list. -
Clear the withdrawal queue. Approve or reject everything pending before the payout path changes underneath it. Read the XT-specific warning in The spot desk first — the admin Approve path has no XT branch, so anything left in that queue cannot be paid out after the switch.
-
Move the float. Covered at the end of this page. It is the long pole.
Making the switch
-
Activate the XT licence on Admin → System → Extensions → Exchanges. Remember that activation itself flips the active row — from this moment the database says XT is live even though nothing is connected to it.
-
Put the XT credentials in
.env. The names are built at runtime from the provider'snamecolumn, uppercased:APP_XT_API_KEY="..." APP_XT_API_SECRET="..."XT takes a key and a secret. There is no passphrase —
APP_XT_API_PASSPHRASEis read by the shared loader and discarded, because XT declarespassword: false. Leave the old provider's variables in place until the migration is finished; the old key is what lets you move the float. -
Allowlist the server's egress IPv4 on the XT key. See the section below. Do this before the restart, not after — every signed call fails without it, and the failure reads as a bad secret.
-
Confirm the toggle. On Admin → System → Extensions → Exchanges, XT should read active and the old provider inactive. If you switched by toggle rather than by activation, this is the step that runs the transaction.
-
Restart both processes. This is the step that actually switches the platform. Nothing before it changed a single running connection.
pm2 restart backend cron -
Verify credentials on
/admin/finance/exchange. Now — and only now — a green result describes the connection your customers are using.
XT's allowlist is IPv4-only, and the platform pins to IPv4 for it
This is the trap that catches most XT switches, and it produces an error message that points at the wrong thing.
// Force IPv4 for exchanges like XT.com that don't support IPv6 whitelisting
const httpsAgentIPv4 = new Agent({ family: 4, keepAlive: true, timeout: 30000 });Every XT connection the platform builds uses that agent unless a proxy URL is
set on the provider row, in which case the proxy agent replaces it and the
family: 4 pin is gone entirely.
XT's API-key IP allowlist accepts IPv4 addresses. A dual-stack server that presents an IPv6 source address is refused on every signed call, and the refusal looks exactly like an invalid key.
Three rules follow:
-
Allowlist the server's public egress IPv4 — the address XT sees, not the address in your DNS. A domain's A record is not the answer, and a NAT gateway or egress proxy in front of the box changes it. Settle it from the app server itself:
curl -4 https://api.ipify.org -
If a proxy is configured on the provider row, allowlist the proxy's address instead, and use an IPv4-capable proxy. With a proxy set, the platform's IPv4 pin no longer applies.
-
Re-allowlist before any address change — a migration, a new load balancer, a floating IP reassignment. The key breaks silently.
XT also geo-blocks a list of jurisdictions, which is about the server's location rather than your customers'. A server in a restricted region gets HTTP 451 on every call, and the platform recognises that case specifically: "Access denied: Your server's location is blocked by this exchange." Full detail, including the proxy path and its test button, is in API keys and network access.
NEXT_PUBLIC_EXCHANGE does not switch anything
The example .env carries NEXT_PUBLIC_EXCHANGE="bin" with a comment listing
five exchange aliases, which reads like the provider switch. It is not one. No
backend code reads it, and the active provider is the exchange row with
status = true and nothing else.
It is a frontend build-time variable — inlined at build, so changing it in
.env does nothing until the frontend is rebuilt:
pnpm build:frontendTwo frontend modules read it, and only one of them acts on it:
| Reader | What it does with the value |
|---|---|
services/market-data-ws.ts |
Sizes the order-book depth the browser requests. xt selects XT's ladder — 5 / 10 / 20 / 50 by tick size, the only depths XT serves. Anything else asks for Binance's 40 / 80 / 160 / 320 |
components/blocks/tradingview-chart |
Maps bin to binance, kuc to kucoin and everything else to binance, stores the result in component state, and never reads it again |
So the real consequence of leaving it at bin on an XT install is one thing:
the browser asks XT for order-book depths it does not serve. Set it to "xt"
and rebuild. Setting it will not switch providers, and it is not a substitute
for step 4 above.
After the restart: the two imports
Both are preview-first. The first press computes a plan and writes nothing; a second, explicit confirmation applies it. Read the delete counts — on a provider switch they are large, and they describe real rows.
1. Spot currencies
Admin → Finance → Currency Management → Cryptocurrencies
(/admin/finance/currency/spot), then Import.
Currencies XT does not list are deleted outright. The exchange_currency row
goes; the customer's SPOT wallet row for that currency does not, and their
balance stays on it. What breaks is every path that looks the currency up — the
spot deposit route answers 404 Currency not found before it will create a
deposit transaction, and the withdrawal route refuses for the same reason.
Check the preview's deleteSample against what your customers actually hold
before you confirm.
For currencies that already exist the import refreshes name, precision and
fee and deliberately leaves status alone — enabling a currency is your
decision, not XT's. Afterwards, act on the missing currencies alert at the
top of the screen: a pair whose base or quote is not enabled will not trade, and
a provider switch is the most reliable way to create a batch of them.
2. Markets
Admin → Finance → Trading Infrastructure → Exchange Providers, then the
Markets card (/admin/finance/exchange/market) — the screen is not in the
nav — and Import Markets.
The import only creates rows for symbols it does not already have. For a
symbol already in exchange_market it writes nothing — no precision, no limits,
no maker or taker rate.
BTC/USDT exists on every provider, so it survives the import untouched and
keeps the old exchange's decimal places, minimum cost and fee rates forever.
Those are the numbers the platform validates customer orders against before
forwarding them to XT, so the first symptom is orders XT rejects for a precision
or minimum-notional violation your platform thought was fine.
To genuinely refresh a market, delete the row and let the import recreate it, or
edit its metadata by hand. Both are available on the markets screen — but
clear the market's open orders first, because the per-row delete has no
open-order guard and an order whose market row is gone can never settle.
Every newly imported market arrives with status: false. Nothing is visible to
a customer until you enable it.
The swap symbols a previous provider left behind
The market import takes a symbol only when the market is flagged spot and its symbol contains no colon:
// ccxt loads swap/futures markets alongside spot for several exchanges
// (kucoin, okx, ...); importing them poisons exchange_market with
// symbols like BTC/USDT:USDT that break multi-symbol spot calls.
if (market.spot !== true && market.type !== "spot") continue;
if (typeof symbol !== "string" || symbol.includes(":")) continue;That guard protects the import you are about to run. It does not
retroactively clean an install that was migrated from a provider whose import
took them: those BTC/USDT:USDT rows are already in exchange_market and they
stay there until something removes them.
A confirmed market import is what removes them — XT does not list them, so they
land in the delete plan like any other delisted symbol. The exception is the one
that matters: a poisoned symbol still carrying an OPEN order is kept, and
reported in the plan's keptForOpenOrders count. Clear those orders and
re-import, or the row survives every import you run.
Until it is gone, an enabled poisoned row is skipped by the ticker stream — which checks each symbol against the active exchange's own market list and logs "Ticker stream skipping N symbol(s) not listed as spot on the active exchange" — so the symptom is a market that appears in the admin list and never produces a price.
3. The chart cache
Candles carry no provider in their path, so the old exchange's history is still
being served as XT's. Admin → Finance → Exchange Providers → Charts
(/admin/finance/exchange/chart) has a Clean action that removes both the
Redis keys and the gzipped files, per symbol and interval.
Clean the pairs you carry, then rebuild — see Currencies and markets for the cost of a large build and how to keep it under XT's rate limit.
Memos: what a carried-over asset loses
XT publishes no memo metadata. The XT branch of the currency standardiser writes
withdrawMemo: false on every network it builds, and that value is not
persisted anywhere — exchange_currency has no column for it. Whether a
customer is asked for a destination tag is decided instead by a hard-coded list
of tickers in the withdrawal form: XRP, XLM, EOS, ATOM, HBAR, plus
BNB on BEP2.
If your old provider's data happened to carry memo information and you have a memo-requiring asset outside those six cases listed, the switch removes any chance of that information existing at all. The withdrawal form asks for an address and nothing else, and the customer's coins arrive at the destination unattributed.
After the switch, go through every memo or destination-tag asset you list. Send one yourself on the smallest amount the network allows and confirm it credits at the far end. If the form does not collect the tag, delist the asset rather than hoping. Recovering an untagged deposit is the receiving exchange's discretion, not yours.
The same applies in the deposit direction, and neither the imported data nor any admin screen will warn you. Say it in the currency's own description.
Orders that survive the switch
If any order was still OPEN when the connection changed, this is what happens,
and it is silent.
The reconciler sweeps every exchangeOrder row with status = 'OPEN' and a
non-null referenceId and calls fetchOrder(referenceId, symbol) on the
currently active exchange. That id was minted elsewhere. XT answers with an
error, the cron catches it, logs
Failed to reconcile spot order <id> (ref <referenceId>) under SPOT_RECON,
and moves on. It repeats on every tick, forever. The customer's own cancel does
the same lookup and fails the same way.
There is no supported recovery, because releasing the hold correctly means
deciding whether the order filled — and the exchange that knows is no longer the
one the platform talks to. The honest options are to switch back temporarily,
restart, and let the orders settle; or to reconcile each customer by hand
against the old exchange's trade history and correct their wallet rows with
Adjust Balance, accepting that the inOrder hold will still be there.
That is why step 2 of the pre-switch checklist is not optional.
The float
No screen performs this part.
Every SPOT balance on your platform is a claim against one exchange account. After the switch the platform pays withdrawals from the XT account and credits deposits that land in XT, while the assets backing your existing ledger are still in the old one.
/admin/finance/exchange/balance calls fetchBalance on whichever exchange is
active and lists every asset with a non-zero available or in-order figure. It is
the real account, and it is compared to nothing — no screen sums your customers'
SPOT wallet.balance per currency and puts the two side by side.
Moving the float is manual and per-asset:
- Total what you owe per currency:
balance + inOrderacrosswalletrows wheretype = 'SPOT', grouped bycurrency. There is no report for it; read it from the database. - Withdraw each asset from the old exchange into XT, from the exchanges' own
interfaces, using their networks and fees. The platform gives you nothing to
do this with — there is no admin-initiated withdrawal. The only routes under
/api/admin/finance/wallet/{id}/withdrawareapproveandreject,{id}is a transaction id, and approve executes a customer's already-pending request. Approving one to shift float would send a customer's money. - Keep the old API key and its
.envvariables until every asset has landed. Some assets will need a network XT does not support, which means selling and re-buying. - Only then compare XT's balance screen against the totals from step 1, currency by currency.
Between the switch and the float landing, your customers' ledger says they own coins the active exchange does not hold. Nothing warns you. The shortfall surfaces as the first withdrawal XT refuses for insufficient balance — which the platform then refunds, leaving the request looking like a transient failure rather than an empty account.
Keep withdrawals closed until the float is in place.
Switching away from XT
The same procedure in reverse, with the same restart, and one addition: the market and currency rows are now XT's, so the same staleness applies going the other way. There is no snapshot of the previous state to restore.
Toggling XT off without enabling anything else is a legitimate intermediate
state — it is the right place to be while you re-import for a different provider
— but do not leave it there with customer balances outstanding. With no active
provider, startExchange() returns null: orders return 503, deposits are never
verified, withdrawals cannot be initiated and prices stop updating.
What we could not determine
- Nothing records which provider an
exchange_order,walletortransactionrow belongs to. There is no column, no metadata key and no audit entry saying "this order was placed on KuCoin". After a switch the only signal is the row's creation date against the date you switched. - The
exchangetable'sversionandtypecolumns are seeded and displayed but play no part in the switch. - We could not establish any supported way to hand-mark a spot withdrawal as paid without calling the exchange, which is what the admin Approve path would need to be usable on XT.
Related
- The spot desk — orders, the withdrawal gap on XT, and the five crons
- Install and activate — the same steps in the order they run on a fresh install
- API keys and network access — IPv4, allowlisting, geo-blocking and the proxy
- Currencies and markets — the two imports in detail
- Reference — every variable, endpoint, permission and cron
- Troubleshooting — the messages each failure produces