Troubleshooting Swap

The symptoms operators actually get tickets about — a page that is a 500, no quote, a stuck swap, REVERTED, REORGED, fees that never appear, a chain that will not stay on — each with the ordered check that finds the cause.

18 min readUpdated 1 September 2026troubleshooting, quotes, swaps, fees, rpc, providers, testnet, walletconnect

Every section here starts at the symptom you are handed and ends at the screen that answers it. Where a check is cheap and rules out a lot, it comes first.

/admin/dex/requirements reports, per chain, exactly what is still missing, and its Test button talks to the network rather than trusting your configuration. It answers even while the master switch is off, because that switch is one of the things it reports. Most of the ladders below are a longer way of reading that screen. See Requirements.

"The Swap page shows Something went wrong!"

The whole page is the platform's 500 card — "Something went wrong! An error occurred while loading the page", with Try Again and Go Home — the moment /dex/swap opens. Every other page works, the admin Swap console included — except its pool detail screen, which loads the same wallet stack. Check Profile → Wallet (/user/profile?tab=wallet): it shows the same card.

That pairing is the tell. Both screens load the wallet stack — wagmi and the Reown modal — on demand, and the stack could not start; the same build fails the same way on the other wallet surfaces (wallet sign-in, the four NFT flows, the admin pool detail page). On builds from Core 6.6.2 through 6.7.4 the cause was almost always a frontend built with no NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: the variable was not in .env.example, the frontend build did not check for it, and the wallet code threw the moment a browser loaded it. Nothing in the backend log corresponds to it, because the backend was never asked anything — the only trace is NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID is not defined in the browser console. (Core 6.6.1 and earlier compiled in a hard-coded fallback id instead — a different failure: the picker worked and only signing returned 500.)

Set the variable — a free project id from cloud.reown.com — then rebuild the frontend and restart:

grep NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID .env
pnpm build:frontend
pnpm restart

A restart alone changes nothing: the value is compiled into the browser bundle. From Core 6.7.5 the same misconfiguration no longer takes the page down — the terminal and the profile tab render a "Wallet connection is not set up yet" card instead, an admin viewing it is shown the variable to set, and pnpm build:frontend prints a warning while the value is empty. Where the id comes from and how to restrict it to your domain is in Wallet Connect → Install.

"Swap is not in the user menu"

The Swap entry under Trading in the user navigation is gated by one thing: the addon's Enabled switch in Admin → System → Extension Manager (install step 3). It is not gated by dexEnabled, by a chain being on, or by any permission — with the extension on and dexEnabled off, the entry is there and the page refuses; with the extension off, the entry is gone and so is the addon's API.

The switch takes effect without a restart, and the navigation is rebuilt from the settings fetch on every page load — so an ordinary reload picks it up. Local storage only bridges the first paint until that fetch lands; it cannot hold the menu stale past a reload that reaches the server.

"No quote on chain X"

Work down. Each rung is either fine or it is your answer.

  1. dexEnabled — the master switch. Off means every /api/dex/* route refuses. Super Admin only, changed in system settings.

  2. dexEnabledChains — a JSON array of chain ids. Empty means no narrowing; non-empty makes it an allowlist intersected with the chain rows, so it can only ever take chains away. A chain missing from a non-empty list is refused as CHAIN_DISABLED.

  3. The chain row's status on Chains. Also refused as CHAIN_DISABLED, and worth checking a second time — a chain can switch itself off at boot; see the last section on this page.

  4. A server RPC resolves. The Server RPC column shows row, env, eco or none. none means every server-side read of that chain 503s with the variable to set named in the message. See Environment variables.

  5. Aggregator coverage. At least one enabled adapter must both cover the chain and have its credential set. credentialConfigured on Admin → Swap → Providers is the boolean that answers this; an adapter switched on with an unset variable is green on every other screen and 502s on every quote. Check dexChain.aggregatorSupport has not switched it off for this chain specifically.

  6. The fee-recipient refusal. If the Fee status column says Refusing, this chain serves no quotes at all — it does not quote at zero. Set a recipient or tick Run at zero fee. See Fees and revenue.

  7. The token allowlist. If other pairs on the chain quote and one does not, it is a token, not the chain. Both legs must clear all three axes; see Tokens for the refusal-reason table.

  8. Press Test on Requirements. The checklist is what you configured. The probe is what answers.

    1. Press Test on the chain that will not quote

It stops calldata being built, not prices being served — quotes still render. If prices appear and nothing can be signed, that is the switch. It is the one to reach for during an incident precisely because the page does not vanish.

"A user cannot swap at all"

Four user-side gates, and the first one is the one that catches almost everyone.

The quote route refuses with 403 "Link this wallet to your account before swapping" unless the connected taker address has been proved — a providerUser row with provider WALLET, which is what Sign-In With Ethereum writes.

Connecting and linking are different things, and this is the distinction support tickets come in about. Connecting attaches a wallet to the browser session. Linking proves the person holds its key, by signing a free message that moves no funds and costs no gas. A user can connect and still be refused, which reads like a broken button unless they know there are two steps.

The Swap terminal now resolves it in place: with the requirement on and the connected address unproved, the ticket's primary button reads Link wallet, is enabled, and runs the signature without leaving the page. The same link can be made from Profile → Wallet.

Set dexRequireWalletLink to false in Admin → Swap → Settings if you do not want that step at all. Turning it off does not disable anything else — quotes, swaps and approvals all continue to work.

Linking is not Ethereum-only. Each virtual machine proves ownership its own way, and the Swap ticket runs the right one for whichever chain the user is on:

Network What the user does What it costs
Ethereum and other EVM chains Signs a Sign-In With Ethereum message Nothing
Solana Signs a short plain-text statement Nothing
TRON Signs a short plain-text statement in TronLink Nothing
TON Reconnects once, and the wallet returns a ton_proof Nothing

None of these is a transaction. Nothing is broadcast, no gas or energy is spent, and no permission is granted — every one of them is a signature that proves the person holds the key.

TON reconnects, and that is the protocol rather than a quirk. A TON address is the hash of a contract, not of a key, so a bare signature cannot say which account produced it. TON Connect's answer, ton_proof, is part of the connection request — so a wallet that is already connected has to reconnect to produce one. The user sees the TON Connect modal once, at the moment they asked to link.

TRON needs a wallet that supports TIP-191 message signing (signMessageV2). TronLink, OKX Wallet and Bitget all do. A wallet that does not is told so directly rather than producing a signature that would fail verification with no visible cause.

Gate Default Symptom Where
dexRequireWalletLink on The ticket says Link wallet until the address is proved. Works on every chain Swap settings
dexKycRequired off 403 for unverified users only. Super Admin only System settings
Geo 403 for some countries See below
dexRiskWarningEnabled on A disclosure the user must acknowledge. Does not refuse Swap settings

The Swap settings page carries dexGeoBlockList under Compliance → Jurisdiction, and it defaults to empty. The quote and swap routes enforce geography through the platform's geo policy on the SWAP action — assertGeoAllowed("SWAP", …) — not through that list, and no consumer of dexGeoBlockList exists in the enforcement path.

So a user reporting an unexpected 403 by country is being refused by the platform geo rules, and filling in dexGeoBlockList will not by itself restrict anybody. Configure the platform's geo restrictions for the SWAP action, and read Compliance limit 5 before claiming a jurisdiction policy you have not configured.

"The swap is stuck"

Open /admin/dex/swap, find the row, and read two columns: status and statusReason. The reason is what separates cases that look identical.

  1. One of seven states; the tooltip is the status reason
status · reason What happened Recoverable?
PENDING · (none yet) Broadcast, not yet seen in a block Wait
PENDING · seen once, nonce free We saw it, it is absent now, and the sender's nonce has not moved. It can still be mined Wait — deliberately not dropped
MINED · mined In a block, not yet deep enough for requiredConfirmations Wait
DROPPED · not-seen Never seen at all, and older than dexDropAfterMs The user re-submits
DROPPED · nonce-consumed Seen once, gone, and the sender has moved past its nonce — something else consumed it Nothing to re-send
REPLACED The user's wallet sped it up or cancelled it. The replacement is being followed Nothing to do
CONFIRMED · reorg-clear Re-read once past the reorg window and unchanged Done

dexDropAfterMs (default 1800000, 30 minutes) is only used for the not-seen case — a transaction we have never seen, where age is the only evidence available. It is deliberately long. Where we did see the transaction, age is not used at all: the rule needs two independent facts, because a node that has simply forgotten a mempool entry looks identical to one that never had it.

statusHistory on the row is an append-only list of every transition with its reason, and it is the only thing that can answer "my swap says dropped" after status has been overwritten.

The confirmation sweep runs every 30 seconds. Every state and every reason is set out in full on Swap history.

"It says REVERTED"

The transaction reached the network, ran, and failed. The tokens were not swapped and are still in the user's wallet; the network fee was spent, because the network charges for the attempt.

Nothing on this platform can reverse or refund it. The fee went to the network, not to you. The usual causes are the price moving past the user's slippage tolerance, the deadline passing before it was mined, or a token that takes a cut on transfer the trade did not account for. The answer is a fresh quote.

Reverted swaps that cluster on one pair are worth a look at the token's riskFlags — a high_sell_tax or honeypot flag is the same finding arriving from a different direction.

"It says REORGED"

The block that carried the swap is no longer part of the canonical chain. The sweep re-reads every confirmed swap exactly once, requiredConfirmations × 2 blocks past its confirming block, and compares both the height and the block hash it recorded. A different hash at the same height is the ordinary shape of a reorg.

What follows automatically:

  • The swap moves to REORGED, which is terminal. It does not climb back to CONFIRMED — a re-broadcast arrives as a new row with its own hash.
  • A negative reversal row is written to the fee accrual ledger, pointing at the accrual it negates. One reversal per accrual is a database constraint, not a convention. The revenue is un-booked.

If a chain produces reorged swaps repeatedly, raise its requiredConfirmations on Chains. Polygon is seeded at 20 and Linea at 10 for exactly this reason — an L2 with a re-ordering sequencer needs more confirmations, not fewer.

"Fees accrued but nothing shows in Finance → Profit"

This is correct behaviour, not a bug.

The fee is collected on chain, by the aggregator, into an address you control — never into a platform wallet. The platform holds no key for it and cannot move it. Revenue reaches your profit report only after you sweep the funds yourself and record the sweep, at which point a background job waits for confirmations and writes an adminProfit row of type DEX_SWAP.

A large Accrued figure with nothing in Finance → Profit is the system working. The full path, including the three ways a sweep can fail to credit, is Fees and revenue.

"Fees are accruing on Solana but the balance is zero"

An SPL token is not held by a wallet. It is held by a token account owned by that wallet, one per mint, and each must exist before anything can arrive.

So a perfectly valid Solana fee recipient collects nothing until the token account for that mint exists — and nothing about the address itself reveals it. Swaps succeed, users are happy, the ledger accrues, and the money is not there.

/admin/dex/wallet goes and looks, mint by mint, and offers the unsigned transaction that creates the missing accounts for you to sign. Check it after enabling Solana and again after listing any new SPL token. See Wallets.

"Quotes were fine yesterday"

The quote log at /admin/dex/quote is the evidence, and its two chip columns answer different questions:

Column Question Values
outcome Did we produce a price at all? OK · NO_ROUTE · PROVIDER_ERROR · RATE_LIMITED · TIMEOUT
status What became of the price we produced? OPEN · USED · EXPIRED · CANCELLED

Failed attempts are persisted precisely so "I could never get a price" is answerable. Read the mix:

  • PROVIDER_ERROR on one adapter and nothing else — that vendor's credential or endpoint. A dead key looks like 100% PROVIDER_ERROR on one adapter and is invisible everywhere else in the admin.
  • RATE_LIMITED — you are over that vendor's quota. There is no cool-off and no circuit breaker in this build: every enabled adapter is asked on every quote, and a rate-limited one simply loses the race. Add a key where the variable is optional (Jupiter, LI.FI, TronGrid, toncenter), or reduce load with dexQuoteDedupMs and dexPriceCacheMs.
  • TIMEOUT — the adapter did not answer within dexQuoteTimeoutMs (default 4000). Raising it trades a slower page for fewer lost candidates; latencyMs on the same rows tells you whether the ceiling is the problem.
  • NO_ROUTE — the vendors answered and none of them could route that pair. Not a fault.

The Overview console at /admin/dex shows the same breakdown for the last 24 hours, per aggregator.

"The chart is blank, or the pair list is stale"

Two different systems, and they fail independently of each other and of swapping.

The chart and the live ticker come from a market-data provider — dexMarketProvider, GeckoTerminal by default — and not from an aggregator. Set dexMarketProviderFallback: a chart that stops drawing is the most visible failure on the page and has nothing to do with whether swaps still work. dexChartCacheTtlSec (default 86400) is how long candles are cached, so a provider outage shows up on the chart later than it shows up on the ticker.

The timeframe menu is shorter than on the spot terminal. By design: it offers only the intervals the market's data source serves natively — seven on GeckoTerminal, nine on Codex, 1m alone on an ONCHAIN pair — because a derived interval is a chart that empties out when scrolled back. The chart opens on 1h wherever the source serves it. See Which timeframes the chart offers.

The pair list's statistics — last price, 24h change, volume, liquidity — are denormalised columns on dexPair refreshed by the runDexMarketRefresh cron, which runs every 5 minutes. If those figures are stale while the chart is fine, the cron is not running: check Admin → System → Cron. The sweep never broadcasts, so it degrades freshness and nothing else. Rows are grouped by chain and each chain is attempted independently, so a provider with no data for one network costs that network's rows only.

"Quoting stopped after a vendor upgrade"

The most likely cause is a rotated router address failing the allowlist.

dexEnforceRouterAllowlist (default on) refuses any quote whose to or whose approval spender the platform does not recognise. The refusal message names the address. Both fields arrive in the aggregator's own response and go straight to the user's wallet, and this setting is the only thing that reads them before a user signs.

Turning dexEnforceRouterAllowlist off removes the check on every chain and every vendor, permanently, to solve one rotated constant. Add the new router to that chain's metadata.extraRouters[] instead — the mechanism exists for exactly this case, which is also why 0x v2's Settler is deliberately absent from the static list.

Note that the current build has no console field for extraRouters; see Chains for the shape of an entry and what that means today.

"A chain will not stay enabled"

You switch a chain on, and after the next restart it is off again.

That is the boot chain-id cross-check. When a chain has no endpoint of its own and falls back to the ecosystem addon's <KEY>_<NETWORK>_RPC, and the ecosystem's active network is a different chain id, the platform logs CRITICAL and sets dexChain.status to false. The alternative is pricing against one network while the user signs for another, which is not recoverable after the fact.

Fix it by giving the chain an endpoint this addon owns — APP_DEX_RPC_<SLUG> or the Server RPC override on the chain row — after which the ecosystem's network is no longer on that chain's path and the check leaves it alone. The shipped .env has FTM_NETWORK="testnet", CELO_NETWORK="alfajores" and TON_NETWORK="testnet", so Fantom, Celo and TON are the usual candidates; an install running its ecosystem on Sepolia and BSC testnet gets the same two lines for Ethereum and BNB Smart Chain. See Environment variables.

Then switch the chain back on in Admin → Swap → Chains. The check only ever turns a chain off — it logs DISABLED <slug> for swaps — and nothing turns it back on once the endpoint is in place. A chain that is already off at boot logs <slug> cannot be enabled for swaps at warn instead; nothing blocks the switch itself, but until the endpoint exists the same check will turn the chain off again at the next restart.

"Does Swap work on a testnet?"

No, and there is no setting that would make it. Every venue quotes mainnet liquidity — 0x, 1inch, KyberSwap and LI.FI on the EVM chains, Jupiter on Solana, SunSwap on TRON, STON.fi on TON — and the chain registry carries mainnet ids only: 1, 10, 25, 30, 56, 137, 250, 8453, 42161, 42220, 43114 and 59144, plus Solana, TRON and TON. There is no Sepolia, no BSC testnet, and a chain's id is a protocol fact, not a configuration value.

The boot message that prompts this question reads:

CRITICAL: chain id mismatch for ETH. The DEX registry says 1 (Ethereum) but the
ecosystem's active network "sepolia" is chain 11155111, and ethereum has no RPC
of its own — so it would price against sepolia and the user would sign for
chain 1. DISABLED ethereum for swaps. Set APP_DEX_RPC_ETHEREUM to a chain-1
endpoint (or the chain's RPC override in Admin → Swap → Chains) to run swaps
here independently of the ecosystem.

It is not asking you to move Swap to Sepolia. It is saying that the ecosystem addon scans deposits on Sepolia (ETH_NETWORK="sepolia"), that Swap had borrowed the ecosystem's RPC because it had none of its own, and that it switched Ethereum off rather than quote on one network and sign on another. The two addons are meant to run on different networks: leave the ecosystem on its testnet, give Swap a mainnet endpoint of its own, and they stop sharing anything.

  1. Set APP_DEX_RPC_ETHEREUM to a chain-1 endpoint and APP_DEX_RPC_BSC to a chain-56 endpoint in .env — or the Server RPC override on each chain row in Admin → Swap → Chains.

  2. Restart the backend. The CRITICAL and warn lines stop — that absence is the visible confirmation. (The chain is still reported, as "Left as it is — ethereum resolves its RPC from APP_DEX_RPC_ETHEREUM, not from the ecosystem", but at debug level, which the default log level hides.)

  3. Switch the chain on again in Admin → Swap → Chains, and press Test on /admin/dex/requirements to confirm the endpoint answers with the right chain id.

To try the flow without risking much, use the cheapest chain you have enabled with a small amount — install step 11 is a 1-USDC swap for exactly this reason — or set dexQuoteOnly while you test, which shows real quotes and refuses every signature.

"Odos quotes stopped the moment I set a fee"

Odos is the one aggregator whose integrator fee is a referral code registered on chain, per chain, rather than a request parameter. An unregistered code is accepted silently and pays nothing forever, so the adapter refuses to quote when a platform fee is configured and the chain's code is not verified.

It refuses only when dexFeeBps is above zero, which is why raising the fee is what makes it appear. In this build there is no console writer for the referral columns, so the practical answer is to switch the Odos provider off until the registration can be recorded. See Chains.

When to escalate rather than dig

Some states are final by construction and no amount of admin work changes them:

  • A CONFIRMED swap that delivered something the user did not expect. There is no admin action that unwinds a settled swap — the funds were never yours to move.
  • A REVERTED swap's gas.
  • A fee that was baked into calldata a user already signed on a chain that was running at zero. It cannot be collected retrospectively.

The most an admin can do is stop offering new swaps: dexQuoteOnly for prices without execution, a chain's status for one network, or dexEnabled for the whole surface including its websocket streams. All three are instant and none needs a deploy.