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.
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 restartA 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.
-
dexEnabled— the master switch. Off means every/api/dex/*route refuses. Super Admin only, changed in system settings. -
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 asCHAIN_DISABLED. -
The chain row's
statuson Chains. Also refused asCHAIN_DISABLED, and worth checking a second time — a chain can switch itself off at boot; see the last section on this page. -
A server RPC resolves. The Server RPC column shows
row,env,ecoornone.nonemeans every server-side read of that chain 503s with the variable to set named in the message. See Environment variables. -
Aggregator coverage. At least one enabled adapter must both cover the chain and have its credential set.
credentialConfiguredon 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. CheckdexChain.aggregatorSupporthas not switched it off for this chain specifically. -
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.
-
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.
-
Press Test on Requirements. The checklist is what you configured. The probe is what answers.
- 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.
- 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 toCONFIRMED— 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_ERRORon one adapter and nothing else — that vendor's credential or endpoint. A dead key looks like 100%PROVIDER_ERRORon 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 withdexQuoteDedupMsanddexPriceCacheMs.TIMEOUT— the adapter did not answer withindexQuoteTimeoutMs(default 4000). Raising it trades a slower page for fewer lost candidates;latencyMson 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.
-
Set
APP_DEX_RPC_ETHEREUMto a chain-1 endpoint andAPP_DEX_RPC_BSCto a chain-56 endpoint in.env— or the Server RPC override on each chain row in Admin → Swap → Chains. -
Restart the backend. The
CRITICALand 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.) -
Switch the chain on again in Admin → Swap → Chains, and press Test on
/admin/dex/requirementsto 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
CONFIRMEDswap 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
REVERTEDswap'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.