Table of Contents
- Automatic Token Ingestion
- Overview
- Drain guard and monitoring
- The only input is an address
- Queue states
- Approval mode
- Processing one entry: plan + fetch + apply
- Abstract token resolution
- Outcomes
- Shared write boundary
- Token DB history
- Propagation
- Reading the interop transfer table
- CoinGecko: never called from
plan - CoinGecko symbol casing and punctuation
- Resolving CoinGecko symbol conflicts
- Address normalization
- What runs where
- What this replaces
- Future: persistent trace audit
Automatic Token Ingestion
This document describes how TokenDB is kept in sync automatically: how new deployed tokens are added, how they are linked to abstract tokens, and how conflicts and errors surface to humans.
Overview
A background loop in the token-backend service ticks every minute. Each tick does three things in order:
- Token relation ingestion. Materialize
TokenRelationrows from interop transfers inserted since the previous tick. This is a separate subsystem that deliberately does not use the queue below — see Token relations for how it works and, more importantly, why it is not part of this queue. - Pre-step. Scan the interop transfer table for transfers inserted since the previous tick and enqueue both token addresses from each transfer.
- Drain. Repeatedly take the next pending queue entry and process it until the queue is empty or the per-run safety cap is reached.
The steps run sequentially (never in parallel) so that logs stay separated and a failure is attributable to a single step. That’s the entire shape. Everything else is a detail of how a single entry gets processed.
Drain guard and monitoring
The drain has a hard per-run processing cap
(TOKEN_INGESTION_MAX_PROCESSED_PER_RUN, default 1000). This is a safety
guard against accidental propagation cycles: if processing one entry keeps
re-enqueueing the same connected set of tokens, one tick still terminates.
When the cap is reached, the loop peeks once more. If the queue is empty
exactly at the cap, the run is considered successful. If another pending
entry remains, the loop stops, leaves all remaining entries as pending, and
logs a warning with limitReached: true, processed,
remainingPending, maxProcessedPerRun, outcome counts, repeated-address
count, and duration. Every completed drain also logs the same summary at
info level with limitReached: false, so Elasticsearch can graph processed
entries and queue emptiness per run.
The only input is an address
The unit of work is a token address — the pair (chain, address). Not
a transfer, not a CoinGecko id, not a manual form submission. Whenever any
part of the system wants the ingestion process to reconsider a token, it
calls enqueue(address), which means exactly:
There is potentially new knowledge about this address. Please reprocess it.
The address might be brand new, already in TokenDB, or already have an abstract token. The queue does not pre-judge — it lets the processing logic figure out what, if anything, needs to change.
This framing is load-bearing: every external trigger collapses into the same act (enqueue). Conflicts in already-resolved tokens, propagation between linked tokens, manual retries — all of them work because the processor is one piece of code exercised by every trigger.
Queue states
The queue holds at most one row per (chain, address). States:
- pending — approved for processing; the drain loop only takes these.
- staged — discovered by the pre-step but not yet approved. The
automatic drain ignores these. A human can promote one to
pendingfrom the UI. Used only during rollout (see Approval mode). - conflict — disagreement was detected; left for a human to resolve. The CoinGecko-symbol variety can be resolved directly from the queue UI — see Resolving CoinGecko symbol conflicts.
- error — processing tried but could not fetch required data (e.g.
symbol,decimals,deploymentTimestamp). Left for a human.
Enqueueing an address that is already queued in any state is a no-op —
conflict and error are sticky until a human clears them. There is no
“could not resolve” state: if a tick cannot find an abstract token for an
address, the entry is simply removed. New knowledge later may re-enqueue
it and we try again from scratch.
Approval mode
For production rollout the pre-step enqueues discovered addresses as
staged by default. A researcher inspects each one and approves it from
the queue UI. Long-term, TOKEN_INGESTION_AUTOAPPROVE=true makes the loop
fully autonomous by enqueueing new addresses as pending, and the UI
focuses on conflict / error.
Processing one entry: plan + fetch + apply
The processor splits each tick into three phases:
plan(entry)— fast and local. Looks at TokenDB, walks the in-memory interop transfer index, and consults the in-memory CoinGecko coin map. No external calls: no RPC, no explorer, and no per-coin CoinGecko endpoints (getCoinDataById/getCoinMarketChartRange). Produces anIngestionTrace: an ordered list of decisionstepsplus a singleoutcome. The trace also carriesexistingDeployedToken— the result of the TokenDB lookupplanperforms for every entry — as a structured field, so consumers (such as the queue page’s already-in-TokenDB indicator) read it directly instead of scanning the human-readablesteps. When the outcome can’t be made terminal without an external call — either we’d insert a new token (needs RPC facts) or we’d materialize a new abstract from a CoinGecko coin we haven’t seen before (needs CoinGecko per-coin endpoints) — the outcome ispending, which carries the operation (insertorupdate) and the abstract intent (existingid, ornew-coingeckowith just the coin id and symbol).fetch(trace)— the only place external calls happen. Pass-through for every outcome exceptpending. Forpending,fetchmaterializes the new abstract record (when needed) via CoinGecko, then — foroperation: insert— calls the RPC/explorer fact fetcher. It either upgrades the outcome towrite(with a full deployed-token record), downgrades it toconflictwhen a newly materialized CoinGecko abstract has a different symbol than the deployed token, or downgrades it toerrorwhen CoinGecko data or required deployed-token facts cannot be fetched.apply(entry, trace)— writes only. Switches on the final outcome and does the corresponding TokenDB and queue mutations. Throws if it ever seespending(a signfetchwas skipped). For thewriteoutcome,applytranslates the trace intoCommand[]and funnels them through the sharedcommitTokenChangeswrite boundary, which is the same primitive that the user-drivenintent → plan → executepipeline uses. See Shared write boundary below.
process(entry) is the 4-line composition: plan then fetch then
apply.
The split is the entire shape of the implementation. It exists because:
- Fast queue-wide prediction. Because
planis RPC-free, the queue page can run it for every row on the visible page (inline insidegetPage) to populate a “Will do” column without paying RPC cost. - Dry-run for free. Calling
plan()+fetch()alone produces a full trace without touching the database. The queue page exposes a “Preview” button on every row that does exactly this. - The trace is its own audit log. Steps describe why a particular abstract token was chosen, which transfer evidence was used, whether CoinGecko hit, which facts were fetched, and what warnings arose. The outcome describes what would change. No separate logger threading through RPC/CoinGecko/explorer calls is needed.
- No
dryRun: booleanflag. The phase boundaries are the toggles.
The trace and outcome shapes are defined in
packages/token-backend/src/ingestion/IngestionTrace.ts.
That file is the canonical reference for what a trace contains — keep it
small and readable.
Abstract token resolution
Inside plan(), the abstract token for an address is resolved in three
strategies, tried in order:
- Non-swapping transfers. For every transfer involving this address
where the bridge type is
lockAndMintorburnAndMint, look up the other side in TokenDB and collect itsabstractTokenId. If there’s exactly one unique id, use it. If there are several, the outcome isconflict. Swap-based bridges are ignored here because the two sides are not the same asset. If the stored abstract on the existing deployed token disagrees with what transfers say, alsoconflict. - The token’s existing abstract. If the deployed token already exists in TokenDB and has an abstract assigned, reuse it.
- CoinGecko platform lookup. Search CoinGecko’s coin list for a coin
that lists this
(chain, address)as a platform address. If found and we already have anAbstractTokenwith thatcoingeckoId, reuse it. Otherwise build a newAbstractToken(withreviewed: false) from the coin’s data, but only commit the new abstract/deployed-token assignment if the deployed token symbol matches the new abstract token symbol. Since inserts learn the deployed token symbol from RPC/explorer facts, this mismatch check happens infetch; a mismatch becomesconflict, while a missing deployed token symbol remains anerror. The symbol comparison ignores casing and punctuation — see CoinGecko symbol casing and punctuation below.
If none of the three resolves anything, the outcome is skip and the
entry is removed. RPC/explorer fact fetching only runs in the fetch
phase, and only for pending-insert outcomes — there’s no point
fetching decimals for an address we’re going to drop or for one whose
existing record only needs its abstract pointer updated.
Outcomes
plan produces one of: skip, conflict, noop, write (update
with an already-existing abstract), or pending. fetch either passes
the outcome through or converts pending into write (insert or
update, possibly with a newly built CoinGecko abstract), conflict
(new CoinGecko abstract symbol differs from deployed token symbol), or error.
apply only ever sees the five terminal outcome kinds:
skip— no abstract resolvable, or address could not be normalized.applyremoves the queue entry. No write.conflict— disagreement detected.applymoves the entry to theconflictstate with a message.error— abstract resolution required CoinGecko data that could not be fetched, or the abstract resolved but the deployed-token facts are incomplete (missingsymbol,decimals, ordeploymentTimestamp).applymoves the entry toerrorwith a message.noop— token already exists with the resolved abstract; nothing to write.applyremoves the queue entry.write—applyinserts/updates the deployed token and, if needed, inserts a new abstract token in the same transaction. It then re-enqueues every neighbor token from the address’s transfers (propagation) and removes the queue entry.
Shared write boundary
This pipeline, the user-driven intent → plan → execute pipeline, and
token relation ingestion ultimately write to the
same TokenDB core tables (AbstractToken, DeployedToken, and
TokenRelation). To make sure all paths produce the same writes — and so
that future cross-cutting concerns like a persistent history table land
in exactly one place — they share a single primitive,
commitTokenChanges,
that takes a list of Commands and dispatches each to the matching
repository method.
commitTokenChanges is a pure router: every command arrives with whatever
fields it needs already populated, including any abstract-token
assignment proof. Each pipeline decides the proof at plan time:
- The user planner sets
abstractTokenAssignmentProof: { kind: 'manual', user: <email> }on anyAddDeployedTokenCommand/UpdateDeployedTokenCommandthat introduces or changesabstractTokenId, andnullwhen the assignment is cleared. - The ingestion planner sets the proof returned by abstract-token
resolution (
{ kind: 'coingecko' }or{ kind: 'non-swapping-transfer', transfer }) onto the deployed-token write produced by the same plan step. Forpendingoutcomes the proof is held on the pending variant and transferred onto the deployed-token write byfetch.
The plan-time stamp means the proof shows up in the diff the user sees before clicking Confirm in the UI, and in the ingestion preview dialog that renders predicted outcomes from the queue.
The proof is persisted on the DeployedToken.abstractTokenAssignmentProof
JSON column. Commands that don’t touch the assignment leave the column
alone. Setting abstractTokenId to null clears the proof.
AbstractTokenAssignmentProof today is one of:
{ kind: 'manual'; user }— written by user-driven plans.useris the email of whoever was logged in when the plan was confirmed.{ kind: 'coingecko' }— ingestion resolved the abstract from CoinGecko’s platform lookup. The CoinGecko id is already on the abstract token itself, so the proof carries no extra data.{ kind: 'non-swapping-transfer'; transfer }— ingestion resolved from non-swapping transfer evidence. The proof carries the full transfer row, not just an id, because the interop transfer table is a sliding 7-day window — by the time someone reviews the assignment, the row may already be gone. Because the proof is stored as JSON, BigInt raw amounts in that transfer are persisted as decimal strings.
The column itself is typed as JSON (unknown) at the repository layer
so old proofs continue to read even if the typed shape evolves; the
strong AbstractTokenAssignmentProof type is only used at plan time.
commitTokenChanges does not own its surrounding transaction — each
pipeline opens its own, because the user pipeline also needs to re-plan
inside that transaction while ingestion does not. The helper logs every
command it executes and records one row per executed command in the
TokenDbHistory table, so the audit trail
covers both pipelines uniformly.
Token DB history
Every executed command is recorded in TokenDbHistory. The shape is
deliberately small:
timestamp— when the command was applied.source—'manual'(user-driven plan) or'ingestion'.userEmail— set whensource = 'manual', otherwisenull.commandType— theCommand.typeliteral (e.g.AddDeployedTokenCommand), denormalized off the JSON for indexed filtering.command— the executedCommandstored verbatim as JSON.
The Command already carries everything an audit reader needs: Add*
commands hold the record being inserted, Update* commands hold both
existing (the row as planned against) and update (the patch), and
Delete* commands hold existing (the row about to be deleted). So
history needs no separate “before” / “after” columns — command is the
audit row. Re-rendering an Update* row as a diff is { existing, update }; an Add* row is record; a Delete* row is existing with a
deletion marker. DeleteAll* commands carry only type and are stored
as-is.
source is supplied as a third argument to commitTokenChanges. The
user-driven executePlan / planAndExecute pass
{ kind: 'manual', user } (the email is already required for proof
stamping); ingestion passes { kind: 'ingestion' }. The proofs introduced
in the previous slice continue to ride on individual commands and are
captured inside command — so anyone reading history sees both what
changed and why the abstract assignment was chosen.
Propagation
After a successful write, the processor re-enqueues the other side of
every transfer involving the just-written address. This is how the queue
drains the dependency graph: if A↔B started with both unknown, processing
B (via CoinGecko) writes B, propagation re-enqueues A, and the next
iteration resolves A from non-swapping transfer evidence (B is now in
TokenDB). noop outcomes do not propagate — that would cause
ping-pong cycles between two stable tokens.
Reading the interop transfer table
For each tick, the drain builds an in-memory index keyed by normalized
(chain, address) from a SQL aggregation over the interop transfer table:
one row per unique group of (src token, dst token, bridge-type evidence),
carrying the group’s transfer count and a sample transfer id. Each
processed entry looks up its own routes from this index — no per-entry DB
queries for transfer evidence. Aggregating in SQL keeps the index size
proportional to the number of distinct bridged token pairs, not to
transfer volume — the table retains ~7 days of transfers, and loading full
rows for all of them (as an earlier version of this index did) caused
out-of-memory crashes when retention grew from one day to seven.
The one consumer that needs a full transfer row — the
non-swapping-transfer assignment proof — fetches the group’s sample
transfer by primary key, and only for outcomes that persist the proof
(write and pending). Plans that end in noop or conflict — the
common steady-state outcomes — never pay the lookup.
The drain refresh happens immediately after the pre-step and immediately before processing the queue. Do not replace it with a stale cached read: tokens enqueued from newly inserted transfers must be planned against an index that can see those same transfers. The refreshed index is kept on the processor as the latest UI cache. Preview and queue-page prediction reuse that cached index, falling back to building and caching one on demand when the loop has not run yet.
The pre-step uses a separate insertion-order cursor
(interop-transfers:lastSerialId, stored in TokenDbSettings) to find
transfers added since the previous tick.
CoinGecko: never called from plan
CoinGecko calls cost money and are rate-limited. The client is configured
with a calls-per-minute limiter, and the processor builds a chain-keyed
map of all coin platforms once (per processor instance) and looks up
addresses in-memory after that. For new abstract tokens, getCoinDataById
and the listing-timestamp lookup are deferred to the fetch phase — they
are never called from plan, so populating the queue page’s “Will do”
column for hundreds of rows costs zero per-coin CoinGecko calls.
Per-coin calls in fetch are intentionally not cached. In the normal
auto-approve path, a new abstract is materialized once, and later tokens
with the same CoinGecko id reuse the stored abstract token from TokenDB.
The listing timestamp is optional; if the market-chart call fails,
ingestion keeps the abstract with a null listing timestamp.
The processor instance is hoisted to server startup so it lives for the whole server lifetime — that is, the coin map cache spans the whole session, not just one tick. The CoinGecko client itself rate-limits but does not cache; caching is the processor’s concern.
There is no “we tried this address and dropped it” record. A dropped address simply disappears from the queue. If audit history is needed later, the natural place to store it is alongside the deployed token itself — see Future: persistent audit.
CoinGecko symbol casing and punctuation
The two CoinGecko endpoints this pipeline calls — /coins/list
(used by plan to find the coin from (chain, address)) and
/coins/{id} (used by fetch to materialize a new abstract token) —
both return the symbol field lower-cased regardless of the token’s
actual casing (susde for sUSDe, wsteth for wstETH, susd for
sUSD). The name field preserves casing across both endpoints, but
it’s a full name rather than a symbol. The RPC call against the
deployed token returns the true casing, so RPC is the source of truth
for the symbol.
Beyond casing, CoinGecko symbols also routinely differ from the on-chain
symbol only in punctuation: $-prefixed meme coins ($PEPE vs PEPE,
in either direction), stray spaces, dots and similar. The production
conflict backlog showed such pairs make up roughly 40% of all
CoinGecko-symbol mismatches, and none of them describe a different
asset.
The fetch phase therefore treats symbols that match after lower-casing
and stripping everything that is not a Unicode letter or digit as the
same symbol rather than a conflict (two symbols that are all
punctuation never match — there is no comparable content). What ends up
on the new abstract token depends on how the raw values differed:
- Casing-only difference: the deployed-token casing is copied onto
the abstract record, and a
corrected-coingecko-symbol-casingstep is appended to the trace. - Punctuation difference: the deployed-token symbol is adopted
(it is on-chain truth and carries real casing) with edge whitespace
stripped — an invisible stray space on an abstract symbol would
spuriously conflict with clean deployments of the same asset later.
An
adopted-deployed-token-symbolstep is appended to the trace, and the CoinGecko spelling is recorded in the abstract token’scommentso the substitution stays traceable on the record itself. (The deployed-token record keeps its RPC symbol verbatim; only the abstract token symbol is curated. The manual resolution flow below trims its chosen symbol for the same reason.) - Genuine difference (e.g.
USDCvs.DAI,WKASvs.KAS): still aconflict.
All of this lives inside the existing fetch phase: that’s the moment we
already have both the materialized CoinGecko abstract and the
deployed-token symbol in hand, so no additional plumbing is needed. If
CoinGecko ever starts returning properly cased symbols, the casing
substitution becomes a no-op (the values already match) and can be
removed without touching the rest of the pipeline.
Resolving CoinGecko symbol conflicts
When the symbols genuinely differ, the entry lands in the sticky
conflict state as before — but this particular conflict kind is
resolvable from the queue UI. Production data shows the remaining
mismatches are dominated by wrapped/bridged variants (WKAS vs KAS,
aEthUSDT vs AUSDT) and CoinGecko-side renames (SAI vs DAI,
LUNC vs LUNA), where a researcher has to decide which spelling the
abstract token should carry; a small tail are CoinGecko platform-mapping
errors (a scam contract mapped onto a real coin) that must not be
written automatically — which is why this stays a human decision instead
of blindly preferring CoinGecko.
The conflict fires precisely when ingestion is about to create the abstract token — an existing abstract found by CoinGecko id is linked without any symbol comparison (see Abstract token resolution). So the resolution is not a special ingestion mode; it is simply: a human creates the abstract token, then the entry is retried. The re-plan finds the abstract by its CoinGecko id and links the deployed token through the ordinary existing-abstract path, and the conflict never fires again. No decision is transported through the pipeline, so there is nothing to go stale and nothing to guard — the pipeline keeps its core invariant of re-deriving everything from current state on every run, and queue entries keep carrying only status, never decisions.
The moving parts:
- The conflict outcome produced by
fetchcarries a structuredsymbolConflictfield (CoinGecko id + both symbols) next to the human-readable message, so the UI never parses message strings. For stored queue entries, thegetPageroute flags rows withresolvableSymbolConflict, derived from the fresh plan it already computes per row: the CoinGecko-symbol conflict is the only conflict kind that can fire while the plan wants to create a new abstract token from CoinGecko, sostate = conflictplus anew-coingeckopending plan identifies it — no message-format knowledge anywhere. Because the flag is derived from current evidence rather than the stored message, it also disappears on its own once the conflict stops applying (e.g. a sibling chain’s resolution already created the abstract token). - The queue page shows a Resolve button on flagged rows. The dialog
re-runs
plan+fetch(thepreviewroute) to get a fresh structured conflict and offers three choices: the CoinGecko symbol (upper-cased — CoinGecko loses casing), the deployed-token symbol, or a custom value (pre-filled with the CoinGecko symbol so fixing its casing is a two-keystroke edit). Because the abstract token is shared by every deployment of the coin, the dialog nudges towards a chain-neutral symbol (aWBTC, notaArbWBTC). If re-planning no longer produces this conflict — evidence changed while the entry sat in the queue, or a sibling chain’s entry was already resolved — the dialog says so and points at Preview / Retry instead. - Confirming reuses the ordinary manual write path: an
AddAbstractTokenIntent(plan.generate+plan.execute) creates the abstract token with the chosen symbol, the coin’s CoinGecko id, icon and listing timestamp (fetched via the samechecksroute the Add-abstract-token form uses), and acommentrecording both original symbols and the choice. Then the entry is retried. There is no dedicated resolution endpoint, and the ingestion pipeline knows nothing about resolutions. - The audit trail is the ordinary one: the manual insert lands in
TokenDbHistorywithsource = manual, the researcher’s email and the intent; the subsequent link write lands as a normalingestionrow with its trace log. The abstract token’scommentkeeps the decision visible on the record itself.
Failure modes degrade to visible, recoverable states instead of wrong writes. If the evidence changes before the retry runs (CoinGecko remapped the address or delisted the coin), the retry just reports the new reality and the manually created abstract sits unlinked — reviewable and deletable like any other token. Two researchers racing on sibling entries of the same coin can at worst create a spare abstract for that coin (the dialog previews on open, so a conflict that was already resolved shows as no-longer-resolvable instead of offering a second resolution); the spare is equally visible and deletable.
The queue page also has a Retry conflicts on this page bulk action
(retryMany): after the punctuation normalization above shipped, a large
share of the accumulated conflict backlog resolves automatically on
re-processing, and conflicts whose cause still holds simply come back
with a refreshed message. It is also the natural way to clear sibling
entries after one chain’s conflict was resolved — their re-plan now
finds the abstract token and links to it.
Address normalization
Interop transfer tokens are stored as bytes32; TokenDB stores 20-byte EVM addresses. Normalization:
- Lowercases everything.
- Cuts bytes32 → 20-byte for EVM addresses.
- Treats
0xandAddress32.ZEROas “no address” — these get dropped before entering the queue. - Keeps non-
0xliteral addresses (e.g."native") as-is for non-EVM chains.
What runs where
- Pre-step: reads from the interop database (
db). - Drain: reads from both
db(interop transfers) andtokenDb(TokenDB state, queue, settings). Writes go totokenDb. The drain always refreshes the transfer index fromdbimmediately before planning and updates the processor’s cached index for UI use. - Preview:
plan+fetch, called from thetokenIngestionQueue.previewtRPC route. Uses the processor’s cached transfer index, or builds and caches one on demand if no drain has warmed it yet. - Queue page predicted outcomes:
planonly, called once per row from inside thetokenIngestionQueue.getPagetRPC route. Uses the same cached transfer index/fallback path as preview. The same per-rowplancall also powers the row’s already-in-TokenDB indicator via the trace’sexistingDeployedToken.
What this replaces
The two cards on the legacy Token UI suggestions page.
Future: persistent trace audit
TokenDbHistory already gives a per-command audit trail of what
changed and who changed it. The next step, if needed, is persisting the
full IngestionTrace next to write events so the reasoning (every
decision step) is queryable too — useful when a researcher wants to
understand why a particular abstract was chosen long after the interop
transfer that justified it has rolled out of the 7-day window. The trace
already exists at apply time, so this is a low-cost follow-up.