Switching the active exchange provider

Moving spot off Binance onto another provider — what the toggle changes, what it leaves stale, the restart that actually applies it, and the orders and float nobody moves for you.

15 min readUpdated 6 August 2026migration, provider-switch, open-orders, float, restart

The platform resolves exactly one row in the exchange table with status = true, and the admin panel makes changing it a single switch. That switch is honest about one thing only: which row is flagged active. Everything downstream of it — the running connection, the market and currency tables, open orders, pending deposits, and the coins themselves — is unaffected and stays pointing at the exchange you just left.

Your customers' SPOT balances are ledger rows. The assets backing them sit in the Binance account. 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 does

The on/off control lives 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.

When status: true is sent it does exactly three things:

  1. Reads lic/<productId>.lic from disk for the row being enabled. 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 notifies 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, though: on success it sets status: false on every other provider and status: true on the one you just licensed, alongside licenseStatus and username.

So activating a KuCoin or XT licence "just to have it ready" silently takes Binance out of service. If you are buying a second provider 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. It holds two private fields that both survive the switch:

Field What it holds Cleared by
provider the provider name read once from the exchange table removeExchange(name), and only when name is the one the process is holding
exchange the live ccxt instance for that name removeExchange(name) under the same condition, stopExchange()

startExchange() — the function every spot path calls to get a connection — returns this.exchange before it looks at anything else. There is no timestamp on it, no version check and no invalidation hook. If the process built a Binance connection before you flipped the switch, it will keep handing that same Binance connection to order placement, the ticker stream, the deposit verifier, the withdrawal approver and the admin finance screens for as long as the process lives.

Three follow-on details that catch people out:

  • stopExchange() is not enough. The ticker and market WebSocket services call it when the exchange errors. It closes and evicts the instance 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, and only for the provider that process is holding. 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). Save the row for the provider you have just switched to and that guard is false, so neither field is cleared — it is saving the row for the old provider, the one the process is still memoising, that empties both. Either way it only runs inside the process that served that HTTP request. The cron process has its own singleton and its own memoised Binance connection, and nothing in an admin request reaches it.
  • backend and cron are separate processes by default — production.config.js starts backend with CRON_MODE=off and cron with CRON_MODE=only. Both build their own connection. Both have to be restarted.

Why Verify Credentials tells you everything is fine

Opening Admin → Finance → Exchange Providers (/admin/finance/exchange) runs the active-provider check, and pressing Verify Credentials runs the same routine directly.

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_<NEWPROVIDER>_API_KEY / _API_SECRET / _API_PASSPHRASE, load markets, call fetchBalance and close it. They never look at the cached instance and never replace it.

The consequence is the single most misleading state this product can be in: the exchange hub reports the new provider, licensed, "API credentials are valid and connection successful" — while every customer order in the same minute is still being placed on Binance. A green verify proves your .env is right. It proves nothing about what the running processes are doing.

The one screen that reads the database row rather than the cache is the health tile on the admin dashboard (/admin), which checks the enabled provider's name against process.env and reports "<provider> enabled but API credentials missing" when the new variables are absent.

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 your platform validates and prices orders against the old exchange's rules
exchange_currency rows unchanged — currency, name, precision, fee, status listed currencies the new provider does not carry stay listed
Deposit and withdrawal networks read live from the active provider at request time, not stored this half follows the switch on its own, and immediately disagrees with the currency rows
exchange_order rows with status = 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, status PENDING unchanged the verifier now polls the wrong exchange for the customer's txid
Chart cache data/chart/<base>/<quote>/<interval>.json.gz and Redis ohlcv:<symbol>:<interval> — no provider in either path old candles are served as the new exchange's history
exchange:ban_status in Redis one global key, not per provider a rate-limit ban earned on Binance still mutes the new provider 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 a read-only screen — canCreate, canEdit and canDelete are all false. It lists exchange orders, filters them by status, and does nothing else.

The Adjust Balance row action on /admin/finance/wallet moves balance by ADD or SUBTRACT. It does not touch inOrder. Two spot code paths move money out of inOrder and back to balance, and neither of them is an admin action: the customer's own cancel, and the reconciliation cron, which calls the same walletService.release when the exchange reports the order CANCELED, EXPIRED or REJECTED, or reports a partial fill. Both first have to 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 in Admin → Finance → Markets (/admin/finance/exchange/market). A disabled market drops out of /api/exchange/market and out of the ticker WebSocket — both filter on status: true — so it disappears from the trade screen and stops being streamed and priced.

    That is presentation, not enforcement. The spot order-create route looks the market up by currency and pair and refuses only when the row or its metadata is missing; it never reads market.status. A client that already holds the symbol can still post an order against a disabled market. The one server-side check that refuses a new spot order outright is the global spotStatus setting, and no admin screen writes it — the Enable Spot Trading toggle on /admin/trading/settings writes a different key, spotWallets, which this route does not read. So 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. Every one of those rows has to reach a terminal status before the switch — the customer cancels it from the trade screen, or it fills. The referenceId column is the order id at the old exchange; once the connection points elsewhere, that id resolves to nothing.

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

  4. Clear the withdrawal queue. Approve or reject everything pending before the payout path changes underneath it.

  5. Move the float. Covered below. It is the long pole.

Making the switch

  1. Activate the new provider's licence on Admin → System → Extensions → Exchanges. Remember that activation itself flips the active row — from this moment the database says the new provider is live even though nothing is connected to it.

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

    APP_KUCOIN_API_KEY="..."
    APP_KUCOIN_API_SECRET="..."
    APP_KUCOIN_API_PASSPHRASE="..."

    Binance and XT take a key and secret only; KuCoin also issues a passphrase. Leave the old APP_BINANCE_* variables in place until the migration is finished — the old key is what lets you move the float.

  3. Confirm the toggle. On Admin → System → Extensions → Exchanges, the new provider should read active and the old one inactive. If you switched by toggle rather than by activation, this is the step that runs the transaction.

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

    pm2 restart backend cron
  5. Verify credentials on /admin/finance/exchange. Now — and only now — a green result describes the connection your customers are using.

After the restart: the two imports

Both are preview-first: the first press computes a plan and writes nothing, and a second explicit confirmation applies it. Read the delete counts. On a provider switch they will be large, and they are describing 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 the new provider 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.

Check the preview's deleteSample against what your customers actually hold before confirming.

The import refreshes name, precision and fee on currencies that already exist and deliberately leaves status alone — enabling a currency is your decision, not the exchange's. A confirmed import then refreshes currency prices immediately.

Afterwards, act on the missing currencies alert at the top of the screen.

Lists currencies used by enabled markets that are not themselves enabled.

A pair whose base or quote currency 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 → Markets (/admin/finance/exchange/market), then 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 that is already in exchange_market it writes nothing at all — 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 your platform validates customer orders against before it forwards them, so the first symptom is orders the new exchange rejects for a precision or minimum-notional violation that your platform thought was fine.

To actually refresh a market you have to remove the row and let the import recreate it, or edit metadata by hand on the markets screen. Both edit and delete are enabled there.

The import refuses to delete a delisted market that still carries OPEN orders, and reports how many it kept. The per-row delete button does not: it removes the row immediately.

The order reconciler resolves fee rates by looking up the exchange_market row for the order's symbol. With the row gone it logs "Market data not found for <symbol>" and abandons that order, permanently, with the hold still sitting in the customer's inOrder. Clear the orders first, every time.

Every newly imported market arrives with status: false. Nothing is visible to a customer until you enable it.

3. The chart cache

Candles carry no provider in their path, so the old exchange's history is still being served. 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, markets and charts for the cost of a large build and how to keep it under the rate limit.

Orders that survive the switch

If any order was still OPEN when the connection changed, this is what happens to it, and it is worth understanding because the failure is silent.

The reconciliation cron 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 by Binance. The new exchange answers with an error, the cron catches it, logs Failed to reconcile spot order <id> (ref <referenceId>) under SPOT_RECON, and moves to the next row. 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. Nothing in the admin panel will release the hold, because releasing it correctly means deciding whether the order filled — and the exchange that knows the answer is no longer the one the platform talks to. If you find yourself here, the honest options are to switch back to the old provider temporarily, restart, and let the orders settle; or to reconcile each customer by hand against the old exchange's own 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.

Payouts branch on the provider name

The withdrawal approval path chooses its code branch from the resolved provider name, and the branches are not equivalent:

Provider What approval does
binance, okx withdraw(currency, amount, address, memo, { network: chain }), then maps the returned status
kucoin an internal transfer from the main account to trade first, then withdraw with { chain }, then re-reads the withdrawal to pick up the real fee
anything else nothing

The default: branch is empty. With no withdraw response the handler treats the payout as rejected and credits the customer back — so on a provider that has no branch, every SPOT withdrawal approval fails and refunds, cleanly but permanently, until code exists for it.

xt is one of the three seeded providers and is not in that switch. If you are moving to XT, verify a small withdrawal end to end before you tell customers payouts are open.

The float

This is the part no screen performs.

Every SPOT balance on your platform is a claim against one exchange account. After the switch the platform pays withdrawals from the new account and credits deposits that land in the new account, 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. It is not compared to anything — no screen sums your customers' SPOT wallet.balance per currency and puts the two side by side.

Moving the float is a manual, per-asset job:

  1. Total what you owe per currency. That is the sum of balance plus 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 to the new one, from the exchanges' own interfaces, using their own networks and fees. The platform gives you nothing to do it with: there is no admin-initiated withdrawal at all. The only routes under /api/admin/finance/wallet/{id}/withdraw are approve and reject, {id} is a transaction id rather than a wallet id, and approve simply executes a customer's already-PENDING request against the active exchange — the customer was debited when they created it. Approving one to shift float would send a customer's money, not yours.
  3. Keep the old API key and its .env variables until every asset has landed. Transfers between exchanges take confirmations, and some assets will need a network the new exchange does not support, which means selling and re-buying.
  4. Only then compare the new account'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 that the active exchange does not hold. Nothing warns you. The shortfall surfaces as the first approved withdrawal the exchange refuses for insufficient balance — which the platform then refunds to the customer, leaving the request looking like a transient failure rather than an empty account.

Keep withdrawals closed until the float is in place. See Deposits and withdrawals.

Switching back

The same procedure in reverse, with the same restart, and one addition: the market and currency rows are now the new provider's, so the same staleness applies going the other way. There is no snapshot of the previous state to restore.

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 that says "this order was placed on Binance". After a switch the only way to tell is the date it was created against the date you switched.
  • The exchange table's version and type columns are seeded and displayed but play no part in the switch.