The Pairs console
The curated market list column by column — per-pair venue policy, the chart binding, per-pair slippage and per-pair country blocks — and which of those the edit dialog can actually save.
Admin → Swap → Listings → Pairs, at /admin/dex/pair. Reaching the screen
needs access.dex.pair; the list route behind it (GET /api/admin/dex/pair)
checks view.dex.pair and every write checks edit.dex.pair.
A pair is a presentation decision, with one exception
The token catalogue decides what may be swapped. A pair decides what is surfaced as a market — charted, streamed on the websocket, and offered in the terminal's picker. Two allowlisted tokens with no pair row are still swappable, so nothing on this screen is a permission and nothing on it should be read as one. See Tokens for the gate that actually refuses.
The exception is venuePolicy. That column changes where the fill comes
from, which means it changes what the user receives and what you earn. It is
the only field here that moves money and it is why the column sits at the front
of the row rather than in the expanded detail.
The columns
- The canonical chainId:BASE/QUOTE key
- ACTIVE, INACTIVE, HIDDEN or DELISTED
- The one column that moves money
- Sweep output, not a live feed
Visible by default:
| Column | Field | What it tells you |
|---|---|---|
| Market | symbol / pair |
The canonical chainId:BASE/QUOTE key — e.g. 8453:WETH/USDC. Searchable, filterable, sortable. |
| Chain ID | chainId |
Numeric id from the chain registry. Filterable. |
| Status | status |
ACTIVE, INACTIVE, HIDDEN or DELISTED. Filterable. |
| Routing | venuePolicy |
Six values, below. DIRECT_ONLY is toned as a warning; everything else is neutral. |
| Liquidity | liquidityUsd |
Sweep output. Stale by design — see the last section. |
| Featured | isHot |
Puts the market on the terminal's featured rail. |
| Trending | isTrending |
The second rail flag. |
Open the row for the rest:
| Column | Field | What it tells you |
|---|---|---|
| Market data | marketDataSource |
INDEXER (default), ONCHAIN or NONE. ONCHAIN reads the pool's own Swap logs — see Market data. |
| Restricted countries | restrictedCountries |
ISO-3166-1 alpha-2 codes this one market may not be quoted in. |
| Default slippage | defaultSlippageBps |
Per-pair override of dexDefaultSlippageBps. Nullable. |
| Price decimals | pricePrecision |
Display only, default 8. Never used to round an amount sent to a router. |
| Amount decimals | amountPrecision |
Display only, default 8. Same caveat. |
The row also carries poolAddress, indexerId and poolId, which are the
binding fields; they have no column of their own and are covered below.
WETH/USDC exists on six chains. A bare symbol would let the chart and the
websocket subscribe to different markets, so the symbol is computed server-side
as chainId:BASE/QUOTE and is never accepted from a request. It is a unique
key (dexPairSymbolKey), and (chainId, baseTokenId, quoteTokenId) is a second
one — so creating a market that already exists returns a named 409 rather than a
bare 500.
Creating a market: discover, then bind
Create and delete are both off in the grid. A blank row would let an
operator invent a symbol the chart and the websocket then disagree about, and
deleting a row makes historic swaps that reference it unreadable — DELISTED is
the status for that.
Discovery exists so a pool is chosen from a list the indexer returned rather than transcribed off a block explorer. The failure mode of a wrong pool address is not an error — it is a market that charts a different pool's price forever.
The response carries the provider name, the current dexMinLiquidityUsd floor,
and each candidate's poolAddress, indexerId, liquidityUsd, dexName and a
belowFloor flag. Pools under the floor are flagged, not hidden: an empty
list reads as "this pair has no pools", which sends you hunting for a liquidity
problem that does not exist.
Three named refusals you can hit here:
- The active provider does not support pool discovery — bind the address by hand or switch provider.
- The provider does not index that chain.
- No provider is available at all, because it is unconfigured or cooling off (503). See Market data.
Four refusals, each named so you do not work through all four by hand: a leg that
is not allowlisted, two legs on different chains, a pool below
dexMinLiquidityUsd, and a market that already exists.
liquidityUsd is validated against dexMinLiquidityUsd on the way in, using
the figure discovery returned. The pair's stored liquidityUsd is sweep output
and may be hours old, so checking that instead would let a drained pool be
rebound. A null liquidity is accepted — the indexer honestly could not price
the pool, and refusing on "unknown" would make every freshly-indexed pool
unbindable.
venuePolicy — the one field that moves money
Per pair, the policy overrides the global dexVenuePolicy unless it says
INHERIT. An unrecognised value falls back to the global policy rather than to
anything this code could invent. Venue routing covers the global
setting, the race and the margin; this section is the per-pair half.
| Value | Behaviour |
|---|---|
INHERIT |
Use dexVenuePolicy. The default. |
AGGREGATOR_ONLY |
The direct venue is never even constructed. |
AGGREGATOR_PREFERRED |
Both race; the aggregator wins any tie and any sub-threshold margin. |
BEST_EXECUTION |
Both race; the net-output-plus-margin rule decides. |
DIRECT_ONLY_WHEN_UNQUOTED |
The global default. Direct is reached only when no aggregator quoted at all. |
DIRECT_ONLY |
Aggregators are not consulted. Per pair only. |
DIRECT_ONLY is accepted here and rejected at the global settings layer.
Per pair it is a legitimate statement — this is my token, my pool, do not bother
asking an aggregator. Globally it would route every pair, majors included,
through whatever pool happened to be bound, at a fill no user could see the cause
of. So the global enum does not contain the value at all, and a dexVenuePolicy
typed as DIRECT_ONLY degrades to the safe default rather than being honoured.
The user's fill comes from one pool with no aggregator competing for it, and a direct route earns you no integrator fee — a raw AMM router has no fee hook. See Why a direct pool earns no swap fee.
DIRECT_ONLY also requires a bound pool. The model refuses to save a
DIRECT_ONLY row whose poolId is null, because a policy with no pool refuses
every quote with NO_ROUTE — which reads as "this market is broken" rather than
"somebody set a policy and forgot the second half". INHERIT is deliberately not
checked: whether it can reach a direct venue depends on a global setting the
model cannot see.
The chart binding: indexerId, poolAddress, poolId
Three different facts, and they are three columns because they are not interchangeable.
| Field | What it is |
|---|---|
indexerId |
The provider-native pool id, for indexers that do not key on the address. Codex, for example, addresses a market as {pairAddress}:{networkId}. |
poolAddress |
The indexer's binding hint — what GeckoTerminal calls a pool. Lowercased on write, validated as an EVM address. |
poolId |
The verified dexPool row this pair may route through. This is the direct-venue binding, not the chart one. |
The market-refresh sweep resolves the chart source as
indexerId ?? poolAddress — it falls back from one to the other, so a pair
bound by address alone is still refreshed. A pair with neither is skipped
entirely and its statistics never move. GET /api/dex/chart uses the same
fallback and answers 404 Market has no chart source when both are absent, which
is a deliberately different message from Market not found.
Changing a leg is not an edit, it is a different market: the symbol would have to change with it, and the symbol keys the chart cache and every websocket subscription. Re-pointing it silently would hand the new market the old one's price history.
restrictedCountries — a per-market jurisdiction block
Separate from dexGeoBlockList, and deliberately not part of the platform geo
middleware. geoRestrictionGate is a synchronous uWS middleware whose rule
model is country -> action with no market dimension; an await inside it loses
the response object.
So this list is evaluated at the quote chokepoint instead, using the same
resolveNetworkLocation the platform gate uses — one resolver, so the two can
never disagree about where a request came from. A blocked request gets a 403
naming the market:
…is not available in your region. This is a restriction on this specific market, not on swapping generally — other pairs are unaffected.
Two behaviours worth knowing before you rely on it:
- It fails open on an unresolvable country. The platform gate has already run with your own failure policy; a second, stricter failure mode here would silently override that decision on one surface.
- An unparseable value reads as no restriction, not as a blanket block. A malformed blob is an operator typo, and turning one into a silent outage on a market that was working is the worse of the two failures.
Values are stored as JSON, upper-cased, and anything that is not exactly two letters is dropped on write.
Slippage: the per-pair number and the clamp around it
defaultSlippageBps on the row is the per-pair override of the global
dexDefaultSlippageBps (default 50). Whatever the user ends up requesting is
then clamped server-side at quote time:
The clamp is silent but recorded and echoed: the value actually used goes
into the dexQuote row and into the response, and complianceSnapshot carries
both slippageRequested and slippageClamped. The browser is not a trust
boundary here.
On a direct-pool route the ceiling tightens further to
min(dexMaxSlippageBps, dexDirectPoolMaxSlippageBps) — default 100 against the
global 500. On a thin pool a wide tolerance is a standing, publicly visible
invitation to sandwich for exactly that amount, because the attacker reads
amountOutMin straight out of the mempool calldata.
defaultSlippageBps is stored on the row and served on GET /api/dex/pair. In
this build the swap terminal seeds its own slippage from a client-side default of
50 bps (clamped 1–1500 in local storage) and does not read the pair's value, so
setting it changes the record and the API payload rather than what the ticket
opens at. The server-side clamp above applies either way. Confirm against your
own install before promising a per-market default to a customer.
Status, and what ACTIVE unlocks
| Status | Meaning |
|---|---|
INACTIVE |
Curated, not enabled. Every new pair starts here. |
ACTIVE |
On the rail, charted, and eligible for the market-refresh sweep. |
HIDDEN |
Quotable and chartable by deep link, off the rail. |
DELISTED |
Retired. The row stays so historic swaps remain readable. |
Status is not settable through the ordinary PUT — it has its own route, because
activating re-runs the allowlist check on both legs. A pair may sit
INACTIVE for weeks, and in the meantime one of its tokens can be denylisted,
have its screening verdict flip to BLOCKED, or be soft-deleted. Nothing on the
pair row records the token's current state, so activating without re-checking
would put that token in front of users through a market approved when it was
still fine.
Activating also requires a bound pool (indexerId or poolAddress). An active
market with no chart source renders on the rail and then 404s, which reads to a
user as a broken site rather than an unfinished setup.
Moving off ACTIVE is unconditional. It is your emergency stop and must
never be able to fail for a third party's reason.
ACTIVE is also the filter the refresh sweep uses: runDexMarketRefresh reads
dexPair where status = 'ACTIVE' and nothing else. HIDDEN markets are served
but not refreshed.
The statistics are sweep output, not a live feed
lastPrice, change24h, volume24hUsd and liquidityUsd are written by
runDexMarketRefresh, the Refresh DEX Market Data cron job. They are
denormalised so the rail can sort and render without one indexer call per row,
and they are:
- stale by construction — as old as the last successful sweep for that chain;
- lossy — every one is a
DECIMALcolumn carried for display and SQL aggregation, never authoritative and never used to reconstruct a transfer; - per chain — rows are grouped by
chainIdand each chain's chunks are attempted independently, so a provider with no data for one network costs that network's rows and nothing else.
A chunk that fails aborts the rest of that chain's chunks, not the sweep: whatever refused one chunk will refuse the rest, and each attempt is a metered call. The provider is then put into a cool-off by the shared error policy.
If these columns are not moving, the causes in order are: the pair is not
ACTIVE; it has neither indexerId nor poolAddress; the provider is cooling
off; or the cron job is not running. Market data, charts and the pool
indexer walks the whole chain.
What the edit dialog cannot save in this build
The Edit dialog groups fields as Visibility (status, isHot,
isTrending), Routing (venuePolicy, defaultSlippageBps) and
Presentation (pricePrecision, amountPrecision, restrictedCountries).
PUT /api/admin/dex/pair/{id} applies exactly eight fields — poolAddress,
indexerId, liquidityUsd, pricePrecision, amountPrecision,
defaultSlippageBps, isHot, isTrending — and ignores anything else in the
payload without an error. So in this build:
venuePolicyandrestrictedCountriesare rendered but not persisted by that route. The save reports success and the column is unchanged. Both are read at quote time, so a value already in the row is honoured — this is a write path gap, not a read one.statusis deliberately not settable there; use the status route above.- No admin route in this build writes
dexPair.poolId. That is the column the direct-venue resolver and the pool indexer both key on, so a pair cannot be bound to a verifieddexPoolrow from the console today.
Re-read the row after saving either field rather than assuming the dialog wrote it, and treat Direct pools as describing the intended flow rather than a console step that exists end to end.