The token catalogue: import, probe, screen, allowlist

The gate on the whole product — three independent axes a token must clear, how importing a list works, why on-chain decimals are verified, and what the nightly re-screen does and does not touch.

9 min readUpdated 6 August 2026admin, tokens, allowlist, screening, listing

/admin/dex/token. Until a token is allowlisted here, nothing quotes it. dexAllowlistMode is ALLOWLIST and it is the only value this build honours — the allowlist module implements that behaviour and deliberately does not read the setting, so there is no configuration that widens the gate. A fresh install with every chain green and every provider keyed still serves no swaps until this screen has been used.

A row in ecosystemToken makes the platform issue a custodial deposit address, and reading a wallet for such a currency mints and encrypts a private key as a side effect. This addon holds no keys, so it has its own catalogue. ecosystemTokenId is a soft link for operators who list the same asset in both places, and it is never used to resolve a wallet.

Three axes, and a refusal always names which one

A token must clear all three. They are separate columns on purpose: collapsing them means an automated screening downgrade silently overrides an operator's decision, and when a token is blocked you have to be told which of the three did it.

Axis Column Values Who decides
1 status on / off You. The emergency stop
2 listing PENDING · ALLOWLISTED · DENYLISTED You. The curation decision
3 riskLevel SAFE · CAUTION · BLOCKED The screener

The quote path runs one ladder, and every rung has its own refusal reason:

Reason What it means Your action
CHAIN_UNKNOWN Not a chain this build registers Nothing — the request is wrong
CHAIN_DISABLED The chain row is off, or dexEnabledChains excludes it Chains
TOKEN_NOT_LISTED No catalogue row for that address on that chain Add the token
TOKEN_DISABLED Axis 1 — status is off Switch it on
TOKEN_PENDING Axis 2 — nobody has reviewed it yet Allowlist it
TOKEN_DENYLISTED Axis 2 — somebody reviewed it and said no Deliberate
TOKEN_DIRECT_POOL_ONLY Quotable only through a bound pool, and direct pools are off See below
SCREENING_BLOCKED Axis 3 — the screener said BLOCKED Investigate, then decide
SCREENING_STALE The verdict is older than dexScreeningMaxAgeDays and dexScreeningFailOpen is off Re-screen

Screening refusals only apply when dexScreeningEnabled is on, and SCREENING_STALE only fires for an operator who explicitly chose fail-closed.

The columns on the console

  1. Axis 1 — the operator switch
  2. Axis 2 — PENDING, ALLOWLISTED or DENYLISTED
  3. Axis 3 — the screener; risk flags show on hover
  4. Chain and address are the asset's identity and are immutable
Column Notes
symbol / name Rendered as one compound column, searchable
chainId The catalogue is chain-major; the same asset on two chains is two rows
status Axis 1
listing Axis 2
riskLevel Axis 3. The risk flags show on hover
address Checksummed for display on EVM; base58 and base64 shown verbatim
decimals Load-bearing — see below
isNative Exactly one row per chain
directPoolOnly Quotable through a bound pool only
verifiedSource MANUAL · TOKENLIST · AGGREGATOR — how the row entered
riskFlags JSON slugs: honeypot, high_sell_tax, proxy_upgradeable, low_liquidity
riskCheckedAt When the verdict was produced. Drives the staleness rule
sortOrder Position in the user's token picker
notes Yours

The edit form writes curation (status, listing) and presentation (sortOrder, logoUrl, notes).

chainId and address are immutable. Together they are the asset's identity — the unique index, the lookup the quote path performs, and what every dexSwap row already written refers to through this row. Editing either turns a historic swap in USDC into a swap in whatever was typed. A new asset is a new row.

The dialog also offers riskLevel and directPoolOnly, and PUT /api/admin/dex/token/{id} reads neither. The only keys it accepts are symbol, name, decimals, logoUrl, coingeckoId, status, listing, verifiedSource, ecosystemTokenId, sortOrder and notes — everything else the form posts is discarded silently, so those two save without an error and change nothing.

riskLevel is the screener's column and nothing else writes it: a screening run sets it, on create, on POST /api/admin/dex/token/{id}/screen, and on the nightly re-screen. A human who disagrees with a verdict says so through listing, which nothing automated touches. directPoolOnly is written by nothing in this build — see below.

Importing a token list

POST/api/admin/dex/token/importpermission: create.dex.token
Loads a Uniswap-standard list into the catalogue for one chain

The source is either an inline tokens array or an https URL. Plain http is refused — a list an attacker can rewrite in transit is an attacker's list — and the document must be a Uniswap-standard { tokens: [...] } or a bare array.

Field Meaning
chainId Required. Only entries carrying this chain id are considered
url https URL of the list
tokens Inline array, used instead of url
maxTokens Rows considered in one call. Default 200, hard ceiling 1000

A list carries its own chain id per entry, so importing an Ethereum list into Base yields nothing and says so.

Every inserted row lands listing: PENDING, status: false and riskCheckedAt: null, and none of them is quotable. A token list is a third party's suggestion; treating it as an approval would let an upstream list maintainer add a token to your tradable surface without you ever seeing it.

The same rule makes re-import safe. A duplicate (chainId, address) updates display fields only — symbol, name, logo, CoinGecko id. listing, status, riskLevel and decimals are never touched, so a token you denylisted is not un-denylisted by the next sync.

The response counts inserted, updatedExisting, skippedDuplicates, refused and unverified, and repeats the rule in words so nobody reads "imported 180 tokens" as "180 tokens are now tradable".

The importer, the probe, the on-demand screen and the delete are API routes with no control on the token console yet. The console does the curation half — switching, listing and presentation — and the routes above are reachable through the API with the permissions named on each one.

Decimals are verified against the chain

dexRequireOnChainDecimalstype: booleandefault: true
Read decimals() from the chain and refuse a row that disagrees with what you were given.

Every raw-to-display conversion in this addon scales by decimals. A row that claims 18 for a 6-decimal token shows a balance a million times too large and asks the user to approve a spend a million times larger than the number on their screen. Once they sign, nothing on this platform can undo it.

That is why the check refuses rather than warns.

Where the check runs:

  • On import — each candidate's contract is read. A row whose on-chain decimals() disagrees with the list is counted in refused and never inserted. A row the RPC could not answer for is counted in unverified and is inserted, with the reason recorded in metadata.probe.failed, because an unreachable endpoint is your problem rather than the token's.
  • On create and on update — whenever the saved row would end up ALLOWLISTED. The check is run against the resulting state, not the diff, so both ways in are covered: allowlisting a row whose decimals were never verified, and editing decimals on a row that is already allowlisted.
  • On switching an allowlisted token back on — because "disabled" is exactly where a token sits while its decimals are disputed.

Two carve-outs. The native pseudo-asset has no contract, so its decimals come from the code registry. A row that is not being allowlisted is not probed at all — PENDING and DENYLISTED rows are not quotable, so an unreachable RPC must never stop you parking or blocking a token.

dexTokenProbeTimeoutMs (default 6000) bounds each probe.

The per-token routes

POST/api/admin/dex/tokenpermission: create.dex.token
Adds one token. Probes decimals when the row would be ALLOWLISTED
POST/api/admin/dex/token/{id}/probepermission: edit.dex.token
Reads decimals(), symbol() and name() and reports whether they agree with the row
POST/api/admin/dex/token/{id}/screenpermission: edit.dex.token
Asks the configured screener for a fresh verdict now
PUT/api/admin/dex/token/{id}/statuspermission: edit.dex.token
Flips axis 1, the operator on/off switch
DELETE/api/admin/dex/token/{id}permission: delete.dex.token
Soft-deletes the row

Probe is a diagnostic and is not audited. It refreshes metadata.probe and is explicitly not allowed to change decimals, listing or status — reconciling a disagreement is your decision, made through the edit, where it is audited. It reports an RPC failure rather than refusing, because "your endpoint is down" is one of the answers you asked for.

Screen records a verdict and nothing else. It returns 200 with "nothing is configured" when no screener is set, rather than an error that reads like the token is broken.

Status off is unconditional — the emergency stop must never fail for an RPC reason. Status on, for an allowlisted row, re-runs the decimals probe.

Delete is soft. The row survives so historic swaps still render their assets, and it stops being quotable immediately (the quote path queries without soft-deleted rows, so it answers TOKEN_NOT_LISTED). A token still bound to a pair is refused before the delete rather than left to fail at the foreign key. Delisting is normally DENYLISTED, not a delete.

The native asset

The chain's native token (ETH, BNB, POL, SOL, TRX, TON) is stored as a real row using the aggregator sentinel address:

0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee

Every major aggregator uses it, and storing it verbatim keeps the (chainId, address) unique index meaningful — exactly one native row per chain. The quote path resolves the sentinel by the isNative flag rather than by the address, so an install that seeded its native row under a different sentinel still finds the one row.

directPoolOnly

A token flagged directPoolOnly has no aggregator that will quote it — that is what the flag means. It is quotable only through a pool bound in Direct pools, and while dexDirectPoolsEnabled is off (the default) it is unquotable by anything.

It is refused under its own name, TOKEN_DIRECT_POOL_ONLY, rather than folded into a generic "no route available" — the operator action is "enable direct pools", not "enable the token", and those are different screens.

directPoolOnly is read by the quote path and shown on the console, but no route writes it: the edit route does not accept it, POST /api/admin/dex/token does not accept it, and neither the importer, the probe nor the screener touches it. The column defaults to off, so on a stock install no row carries it and TOKEN_DIRECT_POOL_ONLY never fires. A row that does carry it was set outside the product, directly in the database.

The nightly re-screen

dexTokenRescreen runs every 24 hours and re-screens dexScreeningSliceSize rows per pass, oldest riskCheckedAt first, so a large catalogue is fully covered within ceil(n / slice) runs. A keyset cursor advances past every row it visited — including ones the provider could not answer for — so a handful of dead contracts cannot sit at the front of the queue and starve everything behind them.

The cron never touches listing or status. A token that turns BLOCKED overnight is refused by the screening rung of the ladder while dexScreeningEnabled is on — but the row still reads allowlisted and switched on, and nothing delists it for you.

That is deliberate: letting a third party's opinion take a curated market off sale with no operator in the loop is a worse failure than the one it prevents. It does mean a BLOCKED verdict is an item of work, not a resolution. Filter the catalogue by riskLevel = BLOCKED after any screening change and decide each one.

Screening also runs on write — every row an import inserts is screened once, after all the inserts rather than between them, and a screening failure never fails the import.

A first pass, in order

  1. Confirm the chain is enabled and quoting on Chains. A token on a dark chain teaches you nothing.

  2. Import a reputable list for that chain, or add the majors by hand. Expect everything to land PENDING and disabled.

  3. Read the refused list. A decimals disagreement is the one finding in an import that always deserves a look before anything is allowlisted.

  4. Turn screening on if you intend to use it — dexScreeningEnabled plus dexScreeningProvider in Settings — and let the first pass run before you curate.

  5. Allowlist deliberately, a handful at a time, and set status on. Both axes, or the token is still not quotable.

  6. Set sortOrder on the ones users should see first.