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.
/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
- Axis 1 — the operator switch
- Axis 2 — PENDING, ALLOWLISTED or DENYLISTED
- Axis 3 — the screener; risk flags show on hover
- 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
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
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 inrefusedand never inserted. A row the RPC could not answer for is counted inunverifiedand is inserted, with the reason recorded inmetadata.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
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:
0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeEvery 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
-
Confirm the chain is enabled and quoting on Chains. A token on a dark chain teaches you nothing.
-
Import a reputable list for that chain, or add the majors by hand. Expect everything to land
PENDINGand disabled. -
Read the
refusedlist. A decimals disagreement is the one finding in an import that always deserves a look before anything is allowlisted. -
Turn screening on if you intend to use it —
dexScreeningEnabledplusdexScreeningProviderin Settings — and let the first pass run before you curate. -
Allowlist deliberately, a handful at a time, and set
statuson. Both axes, or the token is still not quotable. -
Set
sortOrderon the ones users should see first.