This repository is the authoritative, auditable source for the token metadata written to Ekubo's database. The database synchronization command reads only committed files from this repository; it never downloads third-party token lists.
curated-tokens.jsonis the human-maintained, highest-precedence input. Its document can contain both curated token rows and curated bridge mappings.tokens.jsonis the complete generated document consumed by the database sync. Its top-leveltokensandbridge_relationshipsarrays keep both kinds of authoritative metadata in one reviewable file.token-list.schema.jsonis the JSON Schema for both curated and generated token-list documents, including nullable rawtotal_supplyandcirculating_supplyvalues.token-sources.jsonrecords the winning source for every generated token.image-sources.jsonrecords upstream-to-Cloudflare logo migrations so repeated runs do not upload the same image again.coingecko-markets.jsoncaches the circulating and total supply CoinGecko reports for every referenced coin, alongside the rotation cursor described below.src/sources.tsis the reviewable catalog of every remote token source.
bridge_relationships links a token to the same asset on another chain. Each
link is directed and keyed by its source token and destination chain, so a
token has at most one counterpart per chain. The database sync writes all of
these fields; the five original ones are what the API's bridgeInfos was built
on, and the rest are additive metadata:
| Field | Meaning |
|---|---|
source_chain_id, source_token_address |
The token the link starts from. |
dest_chain_id, dest_token_address |
Its counterpart. Both tokens are always rows in the generated list. |
source_bridge_address |
The bridge or escrow on the source chain when the source names one, else null. It is informational; relationship says what the link is. |
relationship |
canonical_bridge: minted by the destination chain's official bridge (OP Stack standard bridge, Arbitrum gateway, StarkGate). native_issuer: the issuer's own deployment on both chains (Circle's USDC). interoperability: a version linked by an interoperability protocol such as LayerZero OFT, Wormhole NTT, or Chainlink CCIP, which is not a canonical bridge copy. unknown: the source links the two but does not say how. |
bridge_name |
The bridge, issuer, or protocol, when known. |
source_name, source_url |
The source that claimed the link and where it was read. |
inferred_reverse |
true when the source stated only the opposite direction and the link mirrors it. |
Links come from these sources. The first source to claim a key wins, and a later source that names a different counterpart is logged as a conflict instead of overwriting it:
- Curated links in
curated-tokens.json. - Circle's published USDC and EURC contract tables and Tether's supported
protocols page (
native_issuer). - Native ether on rollups that bridge it from Ethereum, and the OP Stack
WETH predeploy that wraps it (
canonical_bridge), fromCANONICAL_ETH_ROLLUPSinsrc/sources.ts. - StarkGate's bridged-token list, the Superchain token list, Offchain Labs'
Arbitrum gateway lists, and Ava Labs' Avalanche Bridge resources
(
canonical_bridge). - Mappings read from the bridge contracts into
bridge-reads.json(canonical_bridge): the Arbitrum One and Robinhood Chain gateways (the token'sl1Address(), confirmed by the Ethereum gateway router), the Polygon PoSRootChainManager, and the OP Stack standard bridge (the token'sremoteToken()with the bridge predeploy as itsbridge()). An Ethereum token claimed by two listed tokens on one chain is left out as ambiguous. The daily build reads this snapshot without an RPC endpoint;bun run read-bridgesrefreshes it. - The USDT0 Network deployments API for USDT0 and XAUT0, LayerZero OFTs
backed by USDT and XAUT locked on Ethereum (
interoperability). - The
bridgeInfoextension of the Uniswap default list (unknown, because Uniswap links the asset people mean on each chain whether it is an issuer deployment, a bridge copy, a custodial peg, or an OFT). - The Monad list's
crossChainAddresses, labelled by the protocol it names (interoperability, elseunknown).
A shared symbol, name, or CoinGecko ID never creates a link. A link is kept only when both tokens are in the list, never links a chain to itself, and is mirrored when the reverse key is free. A source that cannot be read keeps its previous links. Each run logs what it dropped, the conflicts, the links still without a reciprocal, and any chain where two tokens claim the same canonical or issuer relationship to one counterpart (a canonical copy beside an issuer deployment, such as USDC.e beside USDC, is expected and not reported).
bun run update-bridges regenerates only the links in tokens.json from these
sources, without database, CoinGecko, or Cloudflare credentials.
tokens.json is the only source of links in the database. The sync upserts
every link and then deletes the links the API serves (those whose source token
has a token row) whose source token and destination chain are no longer in
tokens.json, printing each deleted row so it can be
restored. Rows whose source token has no token row are never served and are
left alone. If that would delete more than 20 percent of the served links, or
the list has no links at all, it deletes nothing, still applies the upserts,
and fails so the workflow reports it.
Token address zero is a chain's native currency, the asset its gas is paid in.
normalizeTokenAddress folds 0x0, 0xEeee…eEEeE, and 0x455448 into that
one address, so a chain has exactly one native row. NATIVE_CURRENCIES in
src/sources.ts names the asset for every chain a list is fetched for,
curated-tokens.json carries the row itself rather than inheriting it from a
third-party list, and validation fails if a curated row is missing or if any
source names a different asset at address zero — ETH on Berachain, say. Tempo
is deliberately absent: it has no native currency, and gas there is paid in
stablecoins.
This list is mainnets only. Ekubo removed testnet support across the interface,
the indexer and the quoter service, so a testnet row here would describe a chain
nothing in the stack can serve. NATIVE_CURRENCIES therefore carries no testnet,
and validateNativeCurrencies makes that binding in both directions for
curated-tokens.json: a curated row at address zero on a chain the list does not
name fails, and so does a named chain with no curated row.
Generation uses first-source-wins precedence:
- Ekubo's curated tokens.
- Tokens registered through Ekubo's onchain token registry.
- The standard token lists in their declared order in
src/sources.ts. - CoinGecko Pro token lists for explicitly mapped production chains.
- AVNU's Starknet lists.
Because the first source wins, a wrong field in an upstream list is corrected
with a curated row rather than by reordering sources. Upstream lists do
sometimes attach another project's logo to a token. The Uniswap default list,
for example, gives Velodrome Finance's VELO on Optimism
(0x9560e827aF36c94D2Ac33a39bCE1Fe78631088Db) the CoinGecko image of the
unrelated Velo token on BNB Smart Chain, although CoinGecko itself maps that
address to Velodrome. The curated row points at Velodrome's own logo, committed
under assets/, and a test pins the two tokens to different logos. A comparison
of every generated logo with CoinGecko's image for the same chain and address
found three more such tokens from the 1inch list (OneRing on Fantom, and BNBx
and Binance Beacon ETH on BNB Smart Chain); they are curated with CoinGecko's
image.
The explicit CoinGecko Pro mappings cover every chain the Ekubo wallet MCP
server configures on first run, because that wallet vendors tokens.json as
its compiled-in default token list and a missing chain leaves it showing bare
addresses on an approval screen. WALLET_DEFAULT_NETWORK_CHAIN_IDS in
src/sources.ts pins that set, and a test fails if one of those chains loses
its source.
The updater starts from the curated inputs each time. Removed remote tokens do not linger merely because they appeared in an older generated file. Git commits show every metadata, provenance, bridge, and hosted-logo change.
After token discovery, the updater combines CoinGecko IDs declared by an input
source with exact chain-and-contract mappings from
/coins/list?include_platform=true, then reads
total_supply and circulating_supply from batched /coins/markets requests.
The reviewed platform mappings in src/sources.ts are checked against
CoinGecko's /asset_platforms response before enrichment. Ambiguous contract
mappings are skipped. Existing curated or onchain-registered total supply wins;
CoinGecko fills missing totals and circulating supply. CoinGecko reports supply
in whole-token units, so the updater converts it to an integer in the token's
indivisible units before writing JSON.
Reading every referenced coin's supply on every run was this repository's
largest source of CoinGecko requests, so supplies are cached in
coingecko-markets.json instead. A run refreshes the coins that are new to the
cache plus one rotation slice of the rest, which covers every coin within
rotation_slots runs — a week at the daily schedule — for a small fixed cost
per run. The cursor lives in the artifact so the schedule survives across runs.
A coin CoinGecko returns nothing for is cached as empty rather than dropped, so
it is not re-requested until its slot comes round again, and coins no longer
referenced by any token are pruned. Deleting the file is safe: the next run
rebuilds it in one full pass.
Supply is best-effort market metadata, not an onchain accounting invariant.
CoinGecko may report a global economic supply for an asset deployed on multiple
chains, and circulating supply can change between updates. A missing,
invalid, ambiguous, or unavailable value remains null. Consumers compute FDV
and market cap as usd_price * raw_supply / 10 ^ token_decimals and should
preserve the distinction between unknown and zero.
jurisdiction-policy/ekubo-token-jurisdictions-v2.json is the token
jurisdiction policy decided on EKU-853 (CLO, 2026-10-06). The country lists are
a minimum product-policy floor, not a legal determination. The MCP server
(src/jurisdiction-policy.json) and the interface
(src/util/common/jurisdictionPolicy.json) vendor it byte-for-byte, and all
three repositories pin its SHA-256 in a test.
On Robinhood Chain (4663) every address falls into one of three groups:
rhj_stock_token: Robinhood Assets (Jersey) Stock Tokens, restricted for buying and selling alike in AE, BY, CA, CH, CU, GB, IR, KP, MM, RU, SD, SG, SS, SY, UA, US and VE, and when the country is unknown.non_class: ETH, WETH, USDG and STONX, verified not to be Stock Tokens. Adding an address here needs on-chain verification and CTO sign-off.- anything else is
unknown, and consumers hold it in every country.
Every entry carries provenance (source, ref, observed_at). Regenerate
with bun run jurisdiction-policy --observed-at <ISO-8601> --issuer <rhj-assets.json> --curated curated-tokens.json --curated-commit <sha>. Class
membership only grows and keeps each entry's first observation; removing an
address needs a CLO reclassification, not a list edit. bun run validate fails when
curated-tokens.json has a chain-4663 row in neither group, and the daily
Jurisdiction policy registry check workflow fails when the issuer registry
(https://api.robinhood.com/rhj/assets) lists an address the policy does not
classify.
Update authoritative token list runs daily, on a push to main that changes
an input, and through workflow_dispatch. It fetches all declared sources, reads onchain
registrations through a read-only database connection, hosts every available
external logo in Cloudflare Images, validates the result, and commits changed
generated files to main. JSON Schema validation runs before a generated
document can be committed.
Sync authoritative token list to database is a separate workflow. It runs
after a human push that changes the list, after a successful update workflow,
every 30 minutes as a reconciliation guard, and through workflow_dispatch.
The sync validates and then upserts both collections from tokens.json in one
transaction, in which it also removes links and token rows no longer in the list.
Token rows are pruned only where the token picker shows them by default
(visibility priority 0 or higher) and only on chains the list covers. Hidden
rows (priority below 0) predate the reviewed list and still name tokens in pool
statistics, so they are left alone, as are rows on chains absent from the list.
Each deleted row is printed in full so it can be restored, and its price
history is deleted with it because that history references the row; the price
worker rebuilds prices if the token is listed again. If a sync would delete
more than 20 percent of the visible rows in scope, or more than 20 percent and
more than 100 rows on any one chain, it deletes no token rows, still applies
the upserts, and fails so the workflow reports it.
The workflow_run trigger is intentional: pushes made with GitHub's automation
token do not trigger a second ordinary push workflow.
Configure these repository secrets:
-
PG_CONNECTION_STRING: the single Postgres connection used to readlatest_token_registrations_viewwhile generating the list and to write the committed list during database synchronization. -
CLOUDFLARE_ACCOUNT_ID: Cloudflare account containing Ekubo Images. -
CLOUDFLARE_API_TOKEN: token with Cloudflare Images Write permission. -
COINGECKO_API_KEY: CoinGecko Pro API key. The updater sends it only in thex-cg-pro-api-keyrequest header. It is used for token lists, exact contract mapping, and batched market-supply enrichment.A token-list source that cannot be downloaded after every retry does not fail the run. The updater skips the source — a dead standard list, a missing AVNU or bridge page, or the CoinGecko Pro token lists — and folds the previous
tokens.jsonback in, so the outage degrades the list to the last known good rows instead of deleting the tokens the missing source contributed. A skipped run logsSkipping <source> …; that line means the published list is stale for that source. A CoinGecko429that survives every retry is additionally reported asCoinGecko is rate limited (429): SKIPPING …, which means the published list is stale, so check the key's plan and quota when it appears. The CoinGecko supply refresh still skips only on429; every other supply failure still fails the run.
Configure these repository variables:
CLOUDFLARE_IMAGES_DELIVERY_HASH: the account delivery hash used inhttps://imagedelivery.net/<hash>/....CLOUDFLARE_IMAGES_VARIANT: the public token-logo variant; defaults tologo.CLOUDFLARE_IMAGES_REQUEST_INTERVAL_MS: minimum delay between Cloudflare Images API requests when the batch API is unavailable; defaults to300milliseconds to stay below the global API limit.
Cloudflare uploads use a deterministic custom ID derived from the upstream URL.
That makes retries idempotent and lets tokens on multiple chains share a hosted
image. The updater checks that deterministic delivery URL before uploading,
uses Cloudflare's batch upload API when available, paces its fallback API
requests, and honors rate-limit retry headers. If Cloudflare cannot fetch an
upstream URL, the runner downloads the image and uploads its bytes. IPFS image
sources are resolved through an HTTP gateway. Every generated URL is normalized
to the configured variant. A failed refresh retains the token's previously
hosted image; otherwise an unreachable logo is recorded as null without
failing the entire token-list update.
CoinGecko enrichment first checks /asset_platforms against the explicit
platform-to-chain mappings in src/sources.ts, then downloads each mapped
chain's standard token list from /token_lists/{asset_platform_id}/all.json.
The updater rejects a source if CoinGecko changes the mapping or returns a token
for another chain. Supply enrichment uses one complete /coins/list request
and bounded batches of up to 200 coin IDs per /coins/markets request; it never
makes one request per token. Requests are sequential and use the shared retry
and rate-limit handling.
Alchemy's ERC-20 Token API is intentionally not used for this enrichment. Its token metadata response provides name, symbol, decimals, and logo, while its other fungible-token endpoints are wallet balance and allowance queries. It does not currently provide ERC-20 circulating supply, total supply, or holder count. NFT owner endpoints are not applicable to fungible tokens.
The indexer migration that adds erc20_tokens.circulating_supply must be
applied before this repository's database sync is deployed. The sync writes
both supply columns and deliberately fails instead of silently dropping the new
field when the target schema is stale.
Likewise, indexer migration 00136_erc20_bridge_link_provenance, which adds the
link metadata columns to erc20_tokens_bridge_relationships, must be applied
before the sync that writes them is deployed.
Install and validate:
bun install
bun run format:check
bun run lint
bun run typecheck
bun test
bun run validateRegenerate the list by exporting the workflow environment variables and running:
bun run update-tokensFor source-processing development without a token-registry database, pass
--without-registrations directly:
bun scripts/update-tokens.ts --without-registrationsSynchronize the committed list by setting PG_CONNECTION_STRING and running
bun run sync-database.