Switching the active exchange provider
Moving spot off KuCoin onto Binance or XT, or onto KuCoin from another provider — the restart that actually applies it, the passphrase asymmetry, and the float and orders nobody moves for you.
Exactly one row in the exchange table may have status = true, and the admin
panel makes changing it a single switch. That switch is honest about one thing
only: which row carries the flag. The running connection, the imported markets
and currencies, open orders, pending deposits, the candle cache and — most
importantly — the coins themselves are all unaffected and all still point at the
exchange you just left.
Your customers' SPOT balances are ledger rows. The assets backing them sit in your KuCoin account. Flipping the provider does not move an asset, does not close an order, does not retire a deposit address 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 control lives 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. No file,403withlicenseRequired: true, nothing written. KuCoin's product id is37179816. - Opens a transaction and runs
UPDATE exchange SET status = falsefor every other row. - Sets
status = trueon this one and commits.
It never touches exchange_market, exchange_currency, exchange_order,
wallet, the chart cache or the running connection, and it notifies no process
that anything changed.
One route in the codebase would. POST /api/admin/finance/exchange/provider/{productId}/activate calls
saveLicense(productId, envatoUsername), which sets status: false on every
other provider and status: true on the one just licensed, alongside
licenseStatus and the Envato username. No admin screen calls it. It is
orphaned.
What the panel actually does: the Activate License buttons on an exchange's
Extensions page send you to Admin → System → License
(/admin/system/license?productId=…), which posts to
/api/admin/system/license/activate. That writes lic/<productId>.lic and
refreshes the licence caches in the process that served the request. It never
opens the exchange table, so the active row stays exactly where it was.
So licensing a Binance or XT product "just to have it ready" is safe — KuCoin keeps serving. The status toggle above is the only control that moves the flag.
The trap: the KuCoin connection is memoised for the life of the process
ExchangeManager is a process-lifetime singleton. Two private fields 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 built for that name | removeExchange(name) under the same condition, or stopExchange() |
startExchange() — the call every spot path makes to get a connection — returns
this.exchange before it looks at anything else. There is no timestamp, no
version check, no invalidation hook. A process that built a KuCoin client before
you flipped the switch keeps handing that same KuCoin client to order placement,
the ticker poller, the deposit verifier, the withdrawal approver and the admin
finance screens for as long as it lives.
Three follow-on details:
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 conditional. 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). Saving the row for the provider you have just switched to fails that guard; it is saving the row for KuCoin, the provider the process is still memoising, that clears both. And either way it only runs inside the process that served that HTTP request. backendandcronare separate processes.production.config.jsstartsbackendwithCRON_MODE=offandcronwithCRON_MODE=only. Each builds its own connection. Both must be restarted.
Why Verify Credentials tells you everything is fine
Opening Admin → Finance → Exchange (/admin/finance/exchange) runs the
active-provider check, and the Verify Credentials button runs the same
routine on demand.
Both build a brand-new, throwaway ccxt instance from
APP_<PROVIDER>_API_KEY, _API_SECRET and _API_PASSPHRASE, load markets, call
fetchBalance and close it. They never look at the cached instance and never
replace it.
That produces 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 KuCoin. A green verify proves your .env is right. It proves
nothing about what the running processes are doing.
The passphrase asymmetry
KuCoin authenticates with three values. Binance and XT authenticate with two. That is not symmetric, and the two directions fail differently.
password. Required by ccxt for KuCoin; has no counterpart on Binance or XT.Switching away from KuCoin. APP_KUCOIN_API_PASSPHRASE becomes inert.
Nothing reads it, because the variable names are built at runtime as
APP_${provider.toUpperCase()}_API_* from the provider's name column. Leaving
it in .env is harmless; deleting it is harmless. Keep it until the migration is
finished — you may need the KuCoin key to move the float, and re-creating a
passphrase is impossible (KuCoin never shows it again).
Switching to KuCoin. Forgetting it is the most expensive mistake on this page, because the platform's own credential guard checks only the key and the secret. What happens next depends on which way it is wrong:
| State | What happens |
|---|---|
| Absent or empty | ccxt raises before any network call — kucoin requires "password" credential. The manager catches it, retries three times five seconds apart, then returns nothing and enters a 30-minute cooldown. Each caller then words it differently: order placement answers 503 Exchange service is currently unavailable, a customer cancel 503 Service currently unavailable, the balance screen 500 Exchange or provider not available, and only the two admin imports say "Failed to start exchange provider: kucoin". The crons say nothing to a screen at all — they log "Exchange unavailable; skipping…" and no-op |
| Present but wrong | The client is built and market loading fails on the signed call. The manager then replaces it with an unauthenticated instance and logs "Falling back to an unauthenticated kucoin instance…". Markets, tickers and charts keep working. Balances, deposit addresses and withdrawals do not |
| Correct but added after the restart | Credentials are read once, when the client is first built. Nothing changes until you restart |
The middle row is the dangerous one: nothing on screen says anything is wrong,
and the Platform Health panel on the admin dashboard (/admin — there is no
Health screen under System) reports "kucoin configured" because its exchange
check reads only APP_KUCOIN_API_KEY and APP_KUCOIN_API_SECRET and never the
passphrase. Troubleshooting has the grep that separates
the three.
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 and metadata.precision, .limits, .maker, .taker |
orders are validated and priced against KuCoin's rules on a different exchange |
exchange_currency rows |
unchanged — currency, name, precision, fee, status |
assets KuCoin listed and the new provider does not stay listed |
| Deposit and withdrawal networks | read live from the active provider per request, never 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 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 KuCoin |
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 |
KuCoin 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 KuCoin still mutes the new provider until its TTL expires |
Deposit addresses stay live, and stop being credited
This one has no row in the table above because there is no row anywhere. Spot deposit addresses are not stored. They are fetched from the active exchange per request, through a five-method cascade, and handed to the customer.
Every address your platform has ever shown a customer is a KuCoin address. It
does not expire when you switch. Coins sent to it still arrive — in your KuCoin
account — and the platform will never credit them, because both the live
verifier and processSpotPendingDeposits call fetchDeposits on the active
exchange, and the new exchange has never heard of that deposit.
Customers bookmark these. Some wallets and exchanges save them as address-book entries. Expect deposits to a retired KuCoin address for months, and plan to sweep them by hand:
- Keep the KuCoin account and its API key open after the switch.
- Watch KuCoin's own deposit history for arrivals.
- Credit each one with Adjust Balance on
/admin/finance/wallet— theADDdirection, with a description that names the txid — and move the coins to the new exchange.
Before you switch: settle the book
Do this while KuCoin 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.
Two code paths move money out of inOrder and back, and neither is an admin
action: the customer's own cancel, and the reconciliation cron, which releases
the hold when the exchange reports the order CANCELED, EXPIRED or REJECTED
or reports a partial fill. Both have to resolve the order on the active
exchange first, which is exactly what stops working after the switch. See
The spot desk.
-
Stop new orders as far as the platform lets you. Set
status = falseon every row in Admin → Finance → Exchange → Market (/admin/finance/exchange/market). A disabled market drops out of/api/exchange/marketand out of the ticker stream, so it disappears from the trade screen.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. The one server-side refusal is the globalspotStatussetting, and no admin screen in this build writes it. 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. Each of those rows must reach a terminal status before the switch — the customer cancels it, or it fills. ThereferenceIdcolumn is the order id at KuCoin; once the connection points elsewhere, that id resolves to nothing. -
Finish pending spot deposits. A
DEPOSITtransaction atPENDINGis waiting for the verifier to match the customer's txid againstfetchDepositson the active exchange. Coins that arrived at a KuCoin address will never appear in Binance's deposit list. -
Clear the withdrawal queue. Approve or reject everything on
/admin/finance/withdraw/logbefore the payout path changes underneath it. Remember that a KuCoin approval also runs amain → tradetransfer, so leave enough float in the Main account to drain the queue. -
Move the float. Covered below. It is the long pole.
Making the switch
-
Activate the new provider's licence on Admin → System → Extensions → Exchanges, which hands you to Admin → System → License to enter the purchase code. This writes
lic/<productId>.licand nothing else — the active row does not move, so KuCoin is still the enabled provider. Do it first anyway: the toggle in step 3 refuses with403andlicenseRequireduntil that file exists. -
Put the new credentials in the project root
.env. The names are built at runtime from the provider'snamecolumn, uppercased:APP_BINANCE_API_KEY="..." APP_BINANCE_API_SECRET="..."Leave the
APP_KUCOIN_*variables in place, passphrase included. They are what let you move the float and sweep late deposits. -
Flip the toggle. On Admin → System → Extensions → Exchanges, enable the new provider. This is the step that runs the transaction, and it is the only one that does: from this moment the database says the new provider is live even though nothing is connected to it. Confirm KuCoin now reads inactive.
-
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. -
Update
NEXT_PUBLIC_EXCHANGEand rebuild the frontend. It is compiled in, not read at runtime."kuc"selects KuCoin's order-book depth ladder and chart provider;"bin"is Binance's. Leaving it wrong makes the browser ask for depths the new exchange will not serve.
After the restart: the two imports
Both are preview-first — the first press computes a plan and writes nothing, and
confirm=true applies it. Read the delete counts. On a provider switch they will
be large, and they describe real rows.
1. Spot currencies, first
Admin → Finance → Currency → Spot (/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 withdrawal route answers 404 Currency not found
before it will do anything.
Check the preview's deleteSample against what your customers actually hold
before confirming.
The import creates new currencies with status: false, refreshes name,
precision and fee on ones that already exist, and deliberately leaves
status alone. A confirmed import then kicks the price cron.
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 → Exchange → Market (/admin/finance/exchange/market), then
Import.
The import only creates rows for symbols it does not already have. For a
symbol 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 untouched and keeps KuCoin's
decimal places, minimum cost and fee rates forever. Those are the numbers your
platform validates customer orders against before forwarding them, so the first
symptom is orders the new exchange 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 enabled on the markets screen.
The import refuses to remove a delisted market that still carries OPEN orders
and reports the count as keptForOpenOrders. Those rows survive the switch on
purpose — each open order is holding inOrder against a customer's balance.
The per-row delete button has no such guard. With the market row gone the
reconciler cannot resolve the fee rate, logs "Market data not found for
<symbol>" under SPOT_RECON and abandons the order permanently, hold
included.
So after a switch you will have a residue of KuCoin-era markets the import would
not remove. They are not junk — they are the markets your unsettleable orders
live on. Settle those orders from /admin/finance/order/exchange first, then
re-import to clear the markets.
Newly imported markets arrive with status: false. Nothing is visible to a
customer until you enable it — and enabling needs edit.ecosystem.market, not
edit.exchange.market. See
Currencies, markets and charts.
3. Rebuild the chart cache
Candles carry no provider in their path, so KuCoin's history is still being
served as the new exchange's. Admin → Finance → Exchange → Chart
(/admin/finance/exchange/chart) has a Clean action.
Clean the pairs you carry, then rebuild. Do not lower the rateLimit in
chart_cache to speed a large build up — that is the reliable way to earn the
ban marker that silences the whole spot stack.
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.
processPendingSpotOrders runs every 60 seconds, 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 KuCoin. 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 on. It repeats 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 releases the hold, because releasing it 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:
- Switch back to KuCoin temporarily, restart, and let the orders settle. This is the only clean one.
- Reconcile each customer by hand against KuCoin's own trade history and correct
their
walletrows with Adjust Balance, accepting that theinOrderhold 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 branch from the resolved provider name, and the branches are not equivalent:
| Provider | What approval does |
|---|---|
kucoin |
an internal transfer from main to trade first, then withdraw with { chain }, then re-reads the withdrawal for the real fee |
binance, okx |
withdraw(currency, amount, address, memo, { network: chain }), then maps the returned status |
| 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 with 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 customer-initiated withdrawal route (used when auto-approval is on) has a
wider set — kucoin, binance, kraken, okx and xt — and refuses anything
else with "Withdrawal method not currently available." The two lists disagree,
so test both paths on whatever you switch to.
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 landing in the new account, while the assets backing your existing ledger are still at KuCoin.
/admin/finance/exchange/balance calls fetchBalance on whichever exchange is
active. It is the real account, and it is not compared to anything — no screen
sums your customers' SPOT wallet.balance per currency and puts the two side by
side.
ccxt maps its default account type to KuCoin's trade account, so the figures
you have been reading on that screen were never your whole KuCoin holding.
Anything parked in Main — including everything the withdrawal path's
main → trade transfers left behind — was invisible there.
When you total what to move off KuCoin, read both accounts in KuCoin's own interface. The platform will under-report.
Moving the float is a manual, per-asset job:
- Total what you owe per currency: the sum of
balanceplusinOrderacrosswalletrows wheretype = 'SPOT', grouped bycurrency. There is no report for it; read it from the database. - Withdraw each asset from KuCoin to the new exchange, from the exchanges' own
interfaces, with their own 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 simply executes a customer's already-pending request. Approving one to shift float would send a customer's money, not yours. - Keep the KuCoin key and its
.envvariables until every asset has landed. Transfers 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 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.
Switching back to KuCoin
The same procedure in reverse, with the same restart, plus three things specific to this direction:
- Set all three
APP_KUCOIN_*variables, passphrase included, before the restart. See the asymmetry section above for what each way of getting it wrong looks like. - Expect the market import to drop contract symbols. ccxt loads KuCoin's
spot, swap and futures markets together; anything whose
spotflag is not true and any symbol containing a colon (BTC/USDT:USDT) is skipped on purpose. One contract symbol inexchange_marketbreaks multi-symbol calls with "kucoin symbols must be of the same type" and takes the whole ticker batch with it. - Fund the Main account. Withdrawals transfer
main → tradebefore they send. A float that lives entirely in Trade fails every payout at step one.
There is no snapshot of the previous market and currency state to restore. The rows are now the other provider's, and the same staleness applies going back.
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 way to tell is the row's date against the date you switched. - The
exchangetable'sversionandtypecolumns are seeded and displayed but play no part in the switch. - There is no export or archive of the pre-switch
exchange_marketandexchange_currencyrows. If you want them, take a database backup before you import.
Related
- Install and activate — the same steps in the order a fresh install runs them
- The spot desk — the order and withdrawal screens this page keeps sending you to
- Currencies, markets and charts — both imports in detail
- Deposits and withdrawals — the Main/Trade split and the payout sequence
- Reference — every variable, endpoint and cron
- Troubleshooting — the passphrase, the cooldown and the ban marker