The exchange provider is degraded — the silent half-failure

When exchange credentials fail the platform silently falls back to an unauthenticated instance — charts keep working while spot deposits and withdrawals stop. How to spot it, prove it and fix it.

9 min readUpdated 6 August 2026exchange, spot, credentials, deposits, runbook

The exchange provider is the account this platform holds at Binance, KuCoin or XT. Spot prices, markets and charts come from its public API; deposit addresses, deposit and withdraw networks, and balances come from its authenticated one.

When the credentials fail, the platform does not stop. It logs one warning and falls back to an unauthenticated instance so the read-only half keeps working. That is a deliberate trade — a half-dead provider is better than a dead site — but it means the failure is invisible from the outside: charts render, the market list populates, prices tick, and every spot deposit and withdrawal quietly stops.

Do not diagnose it by asking "is the exchange up". It is up. Ask whether the platform is authenticated to it. The one screen that answers that is /admin/finance/exchange/balance — a balance cannot be fetched without valid credentials.

Symptoms

What works What does not
Charts and candles Spot deposit: no address, no network list
The market list Spot withdrawal: fails at the payout step
Live prices and tickers Spot order placement: 500 Unable to process order
The exchange balance screen
Anything that reads deposit or withdraw networks

Order placement is not in the working half, even though the quote that precedes it is. POST /api/exchange/order reads the ticker from the public API, then calls createOrder and fetchOrder — both authenticated — so on the fallback instance ccxt throws and the route answers 500 Unable to process order: <message>. The customer sees a book they can watch and cannot trade on.

Customers see it as a deposit screen that errors rather than showing an address, and a withdrawal that is debited and then refunded. The API answers a 503 with:

Unable to load currency data from binance. The exchange API credentials may be
missing or invalid.

That message is now explicit. It used to read "Currency not found", because ccxt returns an empty map — not an error — from fetchCurrencies() when the provider serves it from an authenticated endpoint and no usable credentials are present. Binance's sapi/capital/config/getall is the common case, and an empty map is exactly what the unauthenticated fallback instance produces.

Where the fallback happens

In backend/src/utils/exchange.ts, during initialisation:

  1. Credentials are read from the environment by name, built at runtime from the provider chosen in the admin: APP_${PROVIDER}_API_KEY, APP_${PROVIDER}_API_SECRET, plus APP_${PROVIDER}_API_PASSPHRASE where the provider needs one. Missing or empty either of the first two logs API credentials for <provider> are missing. and gives up for this attempt.
  2. checkRequiredCredentials() runs. If it fails, the instance is closed and immediately replaced with a credential-less one — before markets are even loaded — logging API credentials for <provider> are invalid.
  3. Markets are loaded. If loadMarkets() throws for a reason that is not a rate limit or a clock skew, the instance is closed and replaced with a credential-less one, which is then primed with its own loadMarkets() so public data is actually usable. The warning is:
Falling back to an unauthenticated binance instance — authenticated data
(deposit/withdraw networks, balances) will be unavailable until valid API
credentials are configured.

That line is the whole diagnosis. Grep for it first:

pm2 logs backend --lines 500 | grep -iE "EXCHANGE|CURRENCY"

Once built, the instance is held in a process-level cache and every later call returns it. Nothing evicts it on a credential change. On top of that, after three failed initialisation attempts the manager backs off for 30 minutes and returns null rather than trying again — so correcting the keys and waiting looks exactly like the fix not working.

Restart the backend after any credential change. Credentials are read from the environment, not from the settings table, so there is no cache-clear button that would do instead.

The hub screen

Admin → Finance → Trading Infrastructure → Exchange Providers (/admin/finance/exchange), gated on access.exchange.

It shows the one active provider — enabling a provider deactivates every other one, so there is exactly one at a time — with three badges (version, Active / Inactive, Licensed / Unlicensed) and three tabs:

Tab Holds
Overview The provider description
Regions Supported and restricted countries, from the provider's own listing
Settings Status, licence and version tiles, plus the proxy form

Two buttons live in the header: Verify Credentials, and Activate License when the provider is unlicensed. The result of a verification is rendered as a full-width alert below the tabs, and on failure it prints the exact environment variable names for this provider — built literally from the provider name, because they are read verbatim by the backend.

Activating or deactivating a provider happens on Admin → System → Extensions (/admin/system/extension?type=exchange), which is where the Back button and the no-provider empty state both send you. A provider cannot be enabled without an activated licence file (lic/<productId>.lic); the attempt answers 403 with licenseRequired: true.

Four screens with no menu entry

None of these appear in the navigation. They are reachable only from the Quick actions grid at the bottom of the hub page.

Screen Path Permission For
Balances /admin/finance/exchange/balance view.exchange.balance This is the credential proof. Per-asset available / in-order / total
Markets /admin/finance/exchange/market view.exchange.market The tradable pairs imported from the provider
Charts /admin/finance/exchange/chart view.exchange.chart The candle cache: per-market counts, sizes, gaps
Fees /admin/finance/exchange/fee view.exchange.fee Trading fee configuration

Those keys are seeded separately from the hub's access.exchange, so a role that can open /admin/finance/exchange does not automatically get any of the four. On Markets the gate is the table's own access.exchange.market / view.exchange.market plus the list route's view.exchange.market — the page itself carries no route-level permission entry.

Balances and Fees are greyed out on the hub with "Please verify credentials first" whenever the most recent verification failed — so if those two cards are disabled, you already have your answer.

The balance route fetches a live balance and maps it to asset, available, inOrder and total, dropping assets with nothing in them. Two of its three failures are typed, which is the useful part:

Response Means
401 Authentication error: please check your API credentials. The credentials are wrong or revoked
503 Network error: unable to reach the exchange. Egress, DNS or the proxy
500 Failed to retrieve exchange balance Everything else, including "no provider is active" and the manager's 30-minute backoff

Only ccxt.AuthenticationError and ccxt.NetworkError get their own status. The "no exchange" case is raised inside the route's own try as Exchange or provider not available, and because that is an ordinary Error the trailing else catches it and rewrites it — so that string never reaches you. A bare 500 here means "look at the log", not "the credentials are fine".

Verifying credentials

Verify Credentials calls POST /api/admin/finance/exchange/provider/{productId}/verify (edit.exchange). It builds a fresh instance — it does not consult the cached one — and does two things in order: loadMarkets(), then fetchBalance(). Only a successful balance fetch counts as valid.

It is deliberately excluded from the admin audit trail: it round-trips a saved key to see whether the exchange still accepts it, and saving the key is the audited action.

Read the message it returns, not just the red or green:

Message Means Fix
API credentials are missing from environment variables The key or secret is absent or empty in .env. The test never reached the exchange. Add the keys, restart
Invalid API credentials. Please check your API key and secret. The exchange rejected them. Wrong, revoked, or for the wrong account. Reissue at the exchange
Insufficient API permissions. Please check your API key permissions. The key exists but lacks a scope this platform needs. Re-enable the required permissions on the key
Access denied: Your server's location is blocked by this exchange. HTTP 451 / eligibility. Configure a proxy on the Settings tab
Network error. Please check your internet connection and try again. DNS or egress from the server. Firewall, DNS, or a broken proxy
Rate limit exceeded. Please wait a moment and try again. Too many attempts. Wait
Server time synchronization failed. Clock skew the auto-retry could not absorb. Fix NTP on the server
Exchange service is temporarily unavailable. The provider is genuinely down. Wait

Missing and invalid are different faults. The first never left your server; the second is a conversation the exchange refused. Only the first is fixed by editing .env.

Clock skew is retried automatically up to twice with a fresh time sync before it is reported, so seeing this message means two syncs failed.

Proxy configuration

The Settings tab takes a proxy URL in http://, https://, socks4:// or socks5:// form (with optional user:pass@). Leave it empty to disable. Without one the platform forces an IPv4 agent — some exchanges cannot whitelist IPv6.

The page's own guidance, and it is right: test before you save. The test uses a separate connection, so a proxy that fails the test will not have been committed.

When the URL cannot be parsed, the agent factory logs

Invalid proxy URL: <the url>

and returns null. The exchange instance is then built with no agent at all, so it connects directly — and if the reason you configured a proxy was a geoblock, the exchange refuses the connection and you get an authentication or eligibility error that reads like bad keys.

If verification started failing right after a proxy change, grep for Invalid proxy URL before you touch a single credential.

Saving the proxy writes to the provider row (PUT /api/admin/finance/exchange/provider/{productId}), which also clears that provider from the manager's cache — so a proxy change, unlike a credential change, does take effect on the next call.

The health check will not tell you

The Exchange Provider entry in GET /api/admin/system/health/batch checks the database for an active provider row and then checks that the two environment variables are present. It never talks to the exchange.

Health status Fires when
unconfigured No provider row has status: true
warning<provider> enabled but API credentials missing The row is active and APP_<PROVIDER>_API_KEY or _API_SECRET is unset
warningCould not verify exchange provider The check itself threw
up<provider> configured A row is active and both variables exist

Every one of those is critical: false, so none of them turns the system health screen red, and up means only "two strings exist in the environment". A key that is present and revoked reports up forever. Treat this entry as a configuration check, never as a liveness one.

The currency cache

cacheExchangeCurrencies runs hourly and warms the Redis exchangeCurrencies key with a 120-second TTL, so the key is expired for most of the hour and GET /api/exchange/currency reads the database instead. That is by design: the blob carries price, which processCurrenciesPrices rewrites every two minutes, and a longer TTL would let a cached price disagree with the row.

Two consequences worth knowing:

  • The cached blob is the platform's own list of enabled currencies, from the exchangeCurrency table. A stale one is at most two minutes behind, and the endpoints fall back to the database on a miss but never re-populate the key themselves. This job stopping does not break the currency list.
  • The deposit screen's networks do not come from this cache. They come from a live authenticated fetchCurrencies() call against the provider, which is precisely the call that returns {} on a degraded instance. So a currency can be listed and enabled everywhere and still offer no depositable network.

If the deposit screen shows a currency but no chain to deposit on, you are in this runbook, not looking at a cache problem.

Fix it

  1. Prove the diagnosis. Open /admin/finance/exchange/balance. A populated list means the credentials are fine and your problem is elsewhere. A 401 means this runbook.

  2. Find the fallback line in the log.

    pm2 logs backend --lines 500 | grep -iE "Falling back to an unauthenticated|credentials for|Invalid proxy URL"
  3. Check the variable names for the active provider. They are built from the provider name, so they never appear literally in the code — a typo produces no error beyond a single log line.

    Provider Variables
    Binance APP_BINANCE_API_KEY, APP_BINANCE_API_SECRET
    KuCoin APP_KUCOIN_API_KEY, APP_KUCOIN_API_SECRET, APP_KUCOIN_API_PASSPHRASE
    XT APP_XT_API_KEY, APP_XT_API_SECRET

    Those three are the providers the seeder ships. The lookup is generic — APP_<NAME>_API_KEY uppercased from the provider's name — so any other provider follows the same shape.

  4. Correct .env, then restart the backend. Not optional: the degraded instance is cached for the life of the process, and the 30-minute backoff makes a correct fix look like a failed one.

    pm2 restart backend
    pm2 restart cron        # the scheduler builds its own instance

    Restart cron too. processCurrenciesPrices, processPendingSpotOrders and processPendingWithdrawals all run there against their own cached exchange instance, so a web-only restart leaves the scheduler still degraded.

  5. Verify on the screen, not in the log. /admin/finance/exchangeVerify Credentials → expect "API credentials are valid and connection successful". Then open Balances and confirm assets appear.

  6. Check what the outage cost. Spot withdrawals that failed at the payout step were debited and are reconciled by reconcileSpotWithdrawals (every 5 minutes); anything still PROCESSING with no reference is decidable on Admin → Finance → Withdrawals (/admin/finance/withdraw/log), which accepts an Approve or Reject on PENDING and PROCESSING rows.

Things that are not this fault

  • Blank charts with working deposits. That is the candle cache or ScyllaDB, not credentials. See the Charts are empty entry in Troubleshooting.
  • Everything on-chain failing while spot is fine. Ecosystem withdrawals do not touch the exchange at all — see Ecosystem payouts have stopped.
  • Nothing scheduled happening anywhere. Check the scheduler console before assuming a provider problem.
  • NEXT_PUBLIC_EXCHANGE is a frontend variable — the first three letters of the provider alias, read by the TradingView chart component and the market-data socket. It is inlined into the browser bundle at build time, so changing it requires pnpm build:frontend, and it has no effect on the backend's provider choice, which comes from the active exchange row.