The Chains console
Every per-chain setup decision — the switch, the fee recipient, both RPC fields, confirmation depth and the zero-fee acknowledgement — and why a field left blank is not the same as a field left alone.
/admin/dex/chain. This is the screen you touch on every install and on every
new network, and it is the only place several irreversible decisions are made —
where your revenue lands, and which contract addresses this platform will ask a
user to sign against.
You configure chains here; you do not create them
Rows are seeded from code. The registry (DEX_CHAIN_STATIC) supplies the
chain id, slug, name, native currency, wrapped native, explorer URL and the
default confirmation depth, and it is also what the wallet adapter builds its
network definition from. So the console has no create and no delete: it
edits the operator's half of a row that already exists.
That is enforced rather than merely absent. Switching on a row whose chainId
is not in the registry is refused by name — the quote path only ever iterates
the registry, so such a row would read "on" in this console and be invisible to
every user.
Fifteen chains across four virtual machines are seeded, every one of them
status: false. Nothing is offered until you switch it on.
The six columns you can write
PUT /api/admin/dex/chain/{id} writes exactly these, plus one derived stamp.
Everything else on the row is a protocol fact.
| Field | Form label | Rules |
|---|---|---|
status |
Enabled | Refused when the chain id is not in the code registry |
requiredConfirmations |
Confirmations | Whole number, 1–200. Seeded per chain; meaningless on Solana and TON, where a slot is final or it is not |
feeRecipient |
Fee recipient | Validated per virtual machine — see below. THE fee recipient for this chain; there is no global fallback |
zeroFeeAcknowledged |
Run at zero fee | The only escape from the refusal rule |
publicRpcUrl |
Public RPC | http/https/ws/wss, ≤ 500 characters. Ships to the browser |
rpcUrlOverride |
Server RPC | Same validation. Write-only — never returned |
feeRecipientUpdatedAt is derived: it is stamped only when the address
actually changes, not on every save, because the question it exists to answer
is "when did our revenue destination move".
chainId is immutable and a payload that tries to change it is refused rather
than ignored.
Absent is not empty
The admin table posts the entire form back on every save. For a field you
can see, that is harmless. rpcUrlOverride is the field you cannot see — it
is never serialised by the list endpoint, because an Alchemy or Infura URL
carries the API key in its path.
So the handler distinguishes two cases that look identical in an ordinary update:
| Payload | Effect |
|---|---|
| Key absent from the request | The stored value is left alone |
Key present and "" or null |
The column is cleared |
Which means: editing a confirmation depth and pressing Save does not wipe
your server RPC. Deliberately clearing the box does. Nothing surfaces the
difference afterwards — the console still reports rpcConfigured from the
environment fallback, and swaps simply start failing.
The rule is applied to all three nullable text columns, but only
rpcUrlOverride is unreadable. publicRpcUrl and feeRecipient come back from
the list, so their boxes are populated and an empty box genuinely means empty.
Two RPC fields, and only one of them is safe to key
publicRpcUrl is served to every visitor's browser. rpcUrlOverride is used by
the server and is never returned by any route. They are two columns rather than
one field with a checkbox for exactly this reason: one field is how a keyed URL
ends up in a JavaScript bundle.
Server RPC: four values, and each names a different owner
The Server RPC column does not show a URL. It shows where the endpoint came from, and the four values mean different things operationally:
rpcSource |
Meaning |
|---|---|
row |
The override on this row. You set it here, for swaps |
env |
APP_DEX_RPC_<SLUG>. Set for this addon, for this chain |
eco |
Borrowed from the ecosystem addon's <KEY>_<NETWORK>_RPC. Its network follows <KEY>_NETWORK, which is not this addon's setting |
none |
No endpoint resolves. Every server-side read of this chain 503s |
eco is kept separate from env because the difference is who owns the value.
A chain running on eco can be moved to a testnet by an edit made for deposit
scanning — and the boot cross-check will then force this chain's status to
false and log CRITICAL, because pricing on one network while the user signs
for another is not recoverable after the fact. If a chain will not stay enabled,
that is the first thing to check. See
Environment variables.
Fee recipient validation is per virtual machine
The same field, two genuinely different rules, because the same normalisation is correct on one VM and destructive on the other.
EVM — checksummed, and the zero address is refused
The address is run through EIP-55. An all-lowercase address has no checksum to verify and is accepted; a mixed-case address whose checksum does not match is refused, which is the typo class worth catching. The zero address is refused by name — the aggregator would pay every integrator fee to a burn address.
Stored lowercase, rendered checksummed. The comparisons that identify your own fee in a receipt are byte comparisons against lowercase log topics, so lowercase is the canonical form.
Solana — base58, stored verbatim
A Solana address must be base58 (no 0, O, I or l) and decode to 32
bytes. It is stored exactly as entered.
Case is data on Solana. .toLowerCase() applied to a base58 pubkey produces
another perfectly valid pubkey for a different account — the row saves, the
fee accrues, and it accrues somewhere nobody holds a key for. Nothing downstream
can detect it. That is why the EVM normalisation is not applied here.
Solana, again: the address is the OWNER, not the destination
On Solana this field names the wallet that owns the fee accounts. An SPL token is held in a token account, one per mint, and each must exist before fees in that token can arrive. A perfectly valid recipient can collect nothing.
Wallets goes and looks, mint by mint, and offers the unsigned transaction that creates the missing ones.
Fee status: will this chain earn?
- Do not move on while any enabled chain reads Refusing
A separate column from Enabled, answering a separate question. It is
computed from the row and your configured dexFeeBps, in the same order the
fee resolver uses.
| Chip | Meaning |
|---|---|
| Earning | A fee is set and a valid, non-zero recipient exists |
| Refusing | A fee is set and the recipient is missing, malformed or the zero address. Swaps on this chain are refused entirely — it does not quote at zero |
| No fee | Either dexFeeBps is 0, or you ticked Run at zero fee here |
The chip names the one missing item and the single next action. It is not sortable or filterable: it is computed in the serialiser, so there is no column behind it.
Ticking zeroFeeAcknowledged silences a correctly configured 30 bps on that
chain without touching the fee setting or the recipient. It is the only way to
switch the refusal off, it is the right answer for a partner chain you run at
cost, and it is written into the audit trail in words —
zeroFeeAcknowledged false -> true (this chain now charges NO platform fee) —
so an auditor reading the trail six months later does not have to open the
resolver to learn where the revenue went.
See Fees and revenue for why refusing is the correct behaviour and a silent zero is not.
Per-chain, per-provider: aggregatorSupport
dexChain.aggregatorSupport is a JSON object keyed by the adapter's name —
"0x", "1inch", "kyberswap", "lifi", "odos", "jupiter", "sunswap",
"stonfi" — with a boolean each. It is the per-chain override on top of the
provider's own switch.
Only false takes an adapter away. A key that is absent means "not
configured", which is ON — a freshly seeded chain row carries no flags at all,
and defaulting those to off would make a correct install look broken.
The router allowlist, and the addition mechanism
dexEnforceRouterAllowlist (default on) refuses any quote whose to or
whose approval spender is not on the allowlist. That allowlist is the static
list in code, plus every registered AMM deployment, plus
dexChain.metadata.extraRouters[] — the operator-supplied additions on this
chain's row.
An entry is an object and must name its kind:
{
"extraRouters": [
{
"router": "0x…",
"kind": "AGGREGATOR",
"allowanceTargets": ["0x…"],
"aggregator": "0x",
"label": "0x Settler (Base), release 2026-08"
}
]
}kind is AGGREGATOR or AMM_ROUTER and an entry that omits it is dropped
rather than defaulted — defaulting to aggregator would let a typo hand a bare
AMM router the trust an aggregator quote carries. An omitted
allowanceTargets means the router spends for itself. A malformed blob degrades
to "no extra routers" rather than taking the quote path down.
Its address rotates with every release, so pinning one in code guarantees a
stale constant. extraRouters exists for exactly this case: when a vendor
rotates a router and quoting stops, add the new address here rather than turning
dexEnforceRouterAllowlist off. The allowlist is the only thing that reads the
router and the approval spender before a user signs.
aggregatorSupport, metadata.extraRouters and the Odos referral columns are
returned by GET /api/admin/dex/chain, but the chain edit form and the PUT
handler write only the six columns listed above. Until an editor exists, these
three are read-only from the console, and the practical workaround for an
aggregator you cannot configure per chain is to switch that provider off on
Admin → Swap → Providers.
Odos: the referral code that has to be registered on chain
Odos is the one aggregator whose integrator fee is not a request parameter. It is a referral code registered on chain, per chain, in a transaction the operator signs. Three columns on this row hold the result:
| Column | Holds |
|---|---|
odosReferralCode |
The number registered on chain |
odosReferralTxHash |
The registration transaction. Unique across the table |
odosReferralVerifiedAt |
Set only after that receipt was read back |
Odos does not reject an unregistered code. It prices the route, returns calldata, the swap succeeds — and the fee goes nowhere. No error, no warning, no field in the response that differs. Healthy swaps, a revenue line that never moves.
So the adapter refuses to quote whenever a platform fee is configured and
this chain's code is not verified. verified is bound to
odosReferralVerifiedAt, not to the code being non-null: a number typed into a
form and never registered looks identical to a good one.
It refuses only when dexFeeBps is above zero. At zero nothing is being
charged, so an unregistered code costs nothing and blocking a working venue
would be the same mistake in the other direction.
Because this build has no writer for those three columns, Odos will refuse on
every chain once you set a platform fee. If Odos is switched on and quoting
stopped the moment you raised dexFeeBps, that is the cause — switch the Odos
provider off until the registration can be recorded.
Order of operations
-
Set the fee recipient first, on every chain you intend to enable, and check the rendered address is in that network's own format.
-
Set the RPC. Server RPC (or
APP_DEX_RPC_<SLUG>) for the platform; Public RPC only if you want browser-side reads, and only a keyless endpoint. -
Switch the chain on.
-
Read the Fee status column. Do not move on while any enabled chain says Refusing.
-
Raise
dexFeeBpslast. Doing it before step 1 takes every chain down until you catch up — and fees on trades that have already settled cannot be recovered, because the fee is baked into calldata the user already signed.
When a chain still will not serve, Requirements names the missing piece and probes the network rather than trusting the configuration.