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.
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.
When status: true is sent it does exactly three things:
- Reads
lic/<productId>.licfrom disk for the row being enabled. 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 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 leavesproviderset, so the nextstartExchange()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) callsremoveExchange(name). That always dropsnamefrom the instance cache, but it nullsproviderandexchangeonlyif (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. backendandcronare separate processes by default —production.config.jsstartsbackendwithCRON_MODE=offandcronwithCRON_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.
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.
-
Stop new orders, as far as the platform lets you. Set
status = falseon every row in Admin → Finance → Markets (/admin/finance/exchange/market). A disabled market drops out of/api/exchange/marketand out of the ticker WebSocket — both filter onstatus: 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
currencyandpairand refuses only when the row or itsmetadatais missing; it never readsmarket.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 globalspotStatussetting, and no admin screen writes it — the Enable Spot Trading toggle on/admin/trading/settingswrites 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. -
Clear every OPEN order. Filter
/admin/finance/order/exchangeonstatus = 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. ThereferenceIdcolumn is the order id at the old exchange; once the connection points elsewhere, that id resolves to nothing. -
Finish pending spot deposits. A
DEPOSITtransaction sitting atPENDINGis waiting for the verifier to match the customer's txid againstfetchDepositson the active exchange. Coins that arrived at a Binance address will never appear in KuCoin's deposit list. -
Clear the withdrawal queue. Approve or reject everything pending before the payout path changes underneath it.
-
Move the float. Covered below. It is the long pole.
Making the switch
-
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.
-
Put the new credentials in
.env. The names are built at runtime from the provider'snamecolumn, 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. -
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.
-
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.
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.
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.
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.
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.
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.
/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:
- Total what you owe per currency. That is the sum of
balanceplusinOrderacrosswalletrows wheretype = 'SPOT', grouped bycurrency. There is no report for it; read it from the database. - 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}/withdrawareapproveandreject,{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. - Keep the old API key and its
.envvariables 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. - 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,walletortransactionrow 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
exchangetable'sversionandtypecolumns are seeded and displayed but play no part in the switch.
Related
- Installing the Binance provider — the same steps in the order they run on a fresh install
- Connection, rate limits and bans — what the cached connection does once it exists
- Currencies, markets and charts — the two imports in detail
- Troubleshooting — the messages each failure produces