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.

9 min readUpdated 6 August 2026admin, chains, rpc, fees, routers

/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?

  1. 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

  1. Set the fee recipient first, on every chain you intend to enable, and check the rendered address is in that network's own format.

  2. 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.

  3. Switch the chain on.

  4. Read the Fee status column. Do not move on while any enabled chain says Refusing.

  5. Raise dexFeeBps last. 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.