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.

9 min readUpdated 6 August 2026admin, pairs, markets, routing, geo, charts

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

  1. The canonical chainId:BASE/QUOTE key
  2. ACTIVE, INACTIVE, HIDDEN or DELISTED
  3. The one column that moves money
  4. 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.

POST/api/admin/dex/pair/discoverpermission: view.dex.pair
Asks the active market-data provider which pools hold both legs, deepest first. Read-only; nothing is written and no audit row is recorded.

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.
POST/api/admin/dex/pairpermission: create.dex.pair
Creates the market from two allowlisted tokens on one chain. The pair is created INACTIVE.

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.

PUT/api/admin/dex/pair/{id}permission: edit.dex.pair
Rebinds the pool, precisions, per-pair slippage and the rail flags. Chain, both legs and the symbol are immutable here.

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:

dexMinSlippageBpstype: numberdefault: 10
Floor a user may set, in basis points.
dexMaxSlippageBpstype: numberdefault: 500
Ceiling a user may set.
dexDefaultSlippageBpstype: numberdefault: 50
What a user gets before they touch anything.

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

PUT/api/admin/dex/pair/{id}/statuspermission: edit.dex.pair
Moves a market between INACTIVE, ACTIVE, HIDDEN and DELISTED.
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 DECIMAL column carried for display and SQL aggregation, never authoritative and never used to reconstruct a transfer;
  • per chain — rows are grouped by chainId and 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:

  • venuePolicy and restrictedCountries are 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.
  • status is 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 verified dexPool row 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.