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.

17 min readUpdated 6 August 2026migration, provider-switch, ipv4, re-import, restart

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.

Enables or disables a provider. Refuses with 403 and licenseRequired when no licence file exists

Sending status: true does three things and nothing else:

  1. Reads lic/<productId>.lic from disk for the row being enabled — 54510301.lic for XT. No file, 403 with licenseRequired: true, and nothing is written.
  2. Opens a transaction and runs UPDATE exchange SET status = false for every row whose id is not this one.
  3. Sets status = true on 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 leaves provider set — so the next startExchange() 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) calls removeExchange(name). That always drops name from the instance cache, but it nulls provider and exchange only if (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.
  • backend and cron are separate processes. production.config.js starts backend with CRON_MODE=off and cron with CRON_MODE=only. Both build their own connection. Both must be restarted.

Why Verify Credentials tells you everything is fine

Returns the enabled provider, re-syncs its licence flag from disk, and runs a live credential check
Round-trips the saved credentials against the exchange with a throwaway connection

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.

  1. Stop new orders, as far as the platform lets you. Set status = false on 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/market and 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 by currency and pair and never reads market.status. Watch the OPEN order count in step 2 rather than assuming the book is frozen.

  2. Clear every OPEN order. Filter /admin/finance/order/exchange on status = OPEN. Each row's referenceId is the order id at the old exchange; once the connection points at XT that id resolves to nothing.

  3. Finish pending spot deposits. A DEPOSIT transaction at PENDING is waiting for the verifier to match its txid against the active exchange's fetchDeposits. Coins that arrived at a KuCoin address will never appear in XT's deposit list.

  4. 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.

  5. Move the float. Covered at the end of this page. It is the long pole.

Making the switch

  1. 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.

  2. Put the XT credentials in .env. The names are built at runtime from the provider's name column, uppercased:

    APP_XT_API_KEY="..."
    APP_XT_API_SECRET="..."

    XT takes a key and a secret. There is no passphrase — APP_XT_API_PASSPHRASE is read by the shared loader and discarded, because XT declares password: false. Leave the old provider's variables in place until the migration is finished; the old key is what lets you move the float.

  3. 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.

  4. 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.

  5. Restart both processes. This is the step that actually switches the platform. Nothing before it changed a single running connection.

    pm2 restart backend cron
  6. 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:frontend

Two 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.

Reads the active provider's currency catalogue. Without confirm=true it returns the plan only

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.

Reads the active provider's market list. Delisted markets that still carry OPEN orders are never removed

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.

Deletes cached candles from Redis and from disk

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.

The live balance of the active exchange account, per asset, split into available and in-order

/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:

  1. Total what you owe per currency: balance + inOrder across wallet rows where type = 'SPOT', grouped by currency. There is no report for it; read it from the database.
  2. 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}/withdraw are approve and reject, {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.
  3. Keep the old API key and its .env variables until every asset has landed. Some assets will need a network XT does not support, which means selling and re-buying.
  4. 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, wallet or transaction row 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 exchange table's version and type columns 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.