Repository Public
1// Reads research/candidates.json and writes packages/core/src/tokens.ts. A deployment is kept only when ALL of this holds;2// anything else is dropped and reported in research/dropped.txt — never silently kept:3// 1. first source — the issuer's (or, for a bridged representation, the network's / bridge's) own document states the address:4// research/evidence.json, produced by scripts/check-sources.mts, has an entry for this exact address;5// 2. second, independent source — CoinGecko lists the same address for the coin id named by the candidate (research/cg.json,6// refreshed by scripts/fetch-cg.mts), and that coin's symbol is the expected one;7// EXTENSION (only when CoinGecko lists no address on that network, and only for OP-Stack networks): the canonical bridge's own8// token mapping read on-chain — the L2 token names the L1 token and the Standard Bridge, and the L1 token is this asset's9// Ethereum deployment, itself accepted through 1 + 2 + 3. Recorded per token as `second: 'op-bridge'`;10// 3. the chain itself — on BOTH RPC endpoints of the network: contract code exists, symbol() is the expected one, decimals()11// agree (also with CoinGecko's when it gives them), totalSupply() > 0. Solana: an initialized SPL mint owned by a token12// program, supply > 0, no transfer fee, no transfer hook, transferable. TRON: same as EVM through the java-tron HTTP API.13// node packages/core/scripts/gen-tokens.mts (run from the repository root)14import { readFileSync, writeFileSync } from 'node:fs';15import { decodeAbiParameters, encodeFunctionData, erc20Abi, getAddress, hexToBigInt, hexToNumber, hexToString, parseAbi } from 'viem';16import { tronAddressBytes } from '../src/plugins/tron.ts';1718/** the same two endpoints per network as packages/core/src/networks.ts */19const RPC: Record<string, string[]> = {20 ethereum: ['https://ethereum-rpc.publicnode.com', 'https://eth.drpc.org'], base: ['https://mainnet.base.org', 'https://base-rpc.publicnode.com'],21 arbitrum: ['https://arb1.arbitrum.io/rpc', 'https://arbitrum-one-rpc.publicnode.com'], polygon: ['https://polygon-bor-rpc.publicnode.com', 'https://polygon.drpc.org'],22 optimism: ['https://mainnet.optimism.io', 'https://optimism-rpc.publicnode.com'], bsc: ['https://bsc-dataseed.bnbchain.org', 'https://bsc-rpc.publicnode.com'],23 avalanche: ['https://api.avax.network/ext/bc/C/rpc', 'https://avalanche-c-chain-rpc.publicnode.com'], hyperevm: ['https://rpc.hyperliquid.xyz/evm', 'https://hyperliquid.drpc.org'],24 solana: ['https://api.mainnet-beta.solana.com', 'https://solana-rpc.publicnode.com'],25 tron: ['https://api.trongrid.io', 'https://tron-rpc.publicnode.com'] /* java-tron HTTP API, not JSON-RPC */ };26const CG: Record<string, string> = { ethereum: 'ethereum', base: 'base', arbitrum: 'arbitrum-one', polygon: 'polygon-pos', optimism: 'optimistic-ethereum', bsc: 'binance-smart-chain', avalanche: 'avalanche', hyperevm: 'hyperevm', solana: 'solana', tron: 'tron' };27const TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', TOKEN22 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb';28const OP_STANDARD_BRIDGE = '0x4200000000000000000000000000000000000010', OP_STACK = new Set(['optimism', 'base']);29const bridgeAbi = parseAbi(['function l1Token() view returns (address)', 'function remoteToken() view returns (address)', 'function l2Bridge() view returns (address)', 'function bridge() view returns (address)']);30const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));3132async function rpc(url: string, method: string, params: unknown[]): Promise<any> {33 for (let k = 0; k < 5; k++) {34 try {35 const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(15000) });36 const j = await r.json() as any;37 if (j.error) throw Object.assign(new Error(j.error.message), { rpcError: true, revert: /revert/i.test(j.error.message ?? '') });38 if (j.result === undefined) throw new Error('no result (rate limit?)'); // a throttled endpoint must never read as "no code"39 return j.result;40 } catch (e) { if (k === 4 || (e as any).revert) throw e; await sleep(1500 * (k + 1)); }41 }42}43/** TRON full-node HTTP API: read-only contract call, returns the ABI-encoded result. Fails on a revert or a missing contract. */44async function tronCall(url: string, contract: string, selector: string): Promise<`0x${string}`> {45 for (let k = 0; k < 5; k++) {46 try {47 const r = await fetch(url + '/wallet/triggerconstantcontract', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(15000),48 body: JSON.stringify({ owner_address: contract, contract_address: contract, function_selector: selector, parameter: '', visible: true }) });49 const j = await r.json() as any;50 if (j.result?.result !== true || j.result?.message || !j.constant_result?.[0]) throw new Error(`constant call ${selector} failed`);51 return `0x${j.constant_result[0]}`;52 } catch (e) { if (k === 4) throw e; await sleep(1500 * (k + 1)); }53 }54 throw new Error('unreachable');55}56const call = (url: string, to: string, data: string) => rpc(url, 'eth_call', [{ to, data }, 'latest']);57const str = (raw: `0x${string}`): string => { try { return decodeAbiParameters([{ type: 'string' }], raw)[0]; } catch { return hexToString(raw, { size: 32 }); } }; // string, or bytes32 for a few old tokens58/** what one endpoint says about an EVM token */59async function readEvm(url: string, addr: string): Promise<{ sym: string; dec: number }> {60 const code = await rpc(url, 'eth_getCode', [addr, 'latest']);61 if (!code || code === '0x') throw new Error('no contract code');62 const dec = hexToNumber(await call(url, addr, encodeFunctionData({ abi: erc20Abi, functionName: 'decimals' })));63 const sym = str(await call(url, addr, encodeFunctionData({ abi: erc20Abi, functionName: 'symbol' })));64 if (hexToBigInt(await call(url, addr, encodeFunctionData({ abi: erc20Abi, functionName: 'totalSupply' }))) <= 0n) throw new Error('total supply is zero');65 return { sym, dec };66}67async function readTron(url: string, addr: string): Promise<{ sym: string; dec: number }> {68 const info = await (await fetch(url + '/wallet/getcontract', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: addr, visible: true }), signal: AbortSignal.timeout(15000) })).json() as any;69 // a contract deployed by another contract has no creation `bytecode` in this answer, only the hash of its runtime code70 if (info.contract_address !== addr || !(info.bytecode || /^[0-9a-f]{64}$/.test(info.code_hash ?? ''))) throw new Error('no contract code');71 const dec = hexToNumber(await tronCall(url, addr, 'decimals()')), sym = str(await tronCall(url, addr, 'symbol()'));72 if (hexToBigInt(await tronCall(url, addr, 'totalSupply()')) <= 0n) throw new Error('total supply is zero');73 return { sym, dec };74}75async function readSol(url: string, mint: string): Promise<{ dec: number; program: 'token' | 'token2022'; ext: string[] }> {76 const a = (await rpc(url, 'getAccountInfo', [mint, { encoding: 'jsonParsed', commitment: 'finalized' }])).value;77 if (!a || a.data?.parsed?.type !== 'mint' || ![TOKEN, TOKEN22].includes(a.owner)) throw new Error('not an SPL mint');78 const info = a.data.parsed.info, exts: any[] = info.extensions ?? [], ext = exts.map((e) => e.extension);79 if (!info.isInitialized || BigInt(info.supply) <= 0n) throw new Error('mint not initialized or supply is zero');80 // Token-2022 mints with a transfer hook or a non-zero transfer fee change what "transfer" means: refuse them81 const fee = exts.find((e) => e.extension === 'transferFeeConfig')?.state;82 if (exts.find((e) => e.extension === 'transferHook')?.state?.programId) throw new Error('mint has an active transfer hook');83 if (fee && (fee.newerTransferFee?.transferFeeBasisPoints || fee.olderTransferFee?.transferFeeBasisPoints)) throw new Error('mint charges a transfer fee');84 if (ext.includes('nonTransferable')) throw new Error('non-transferable mint');85 return { dec: info.decimals, program: a.owner === TOKEN22 ? 'token2022' : 'token', ext };86}87/** both endpoints must answer and say the same thing */88async function both<T>(net: string, f: (url: string) => Promise<T>): Promise<T> {89 const [a, b] = await Promise.all(RPC[net]!.map((u) => f(u).catch((e) => { throw new Error(`${new URL(u).host}: ${(e as Error).message}`); })));90 if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`the two endpoints disagree: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`);91 return a!;92}9394const cand = JSON.parse(readFileSync('research/candidates.json', 'utf8'));95const cg: Record<string, any> = JSON.parse(readFileSync('research/cg.json', 'utf8'));96const evidence: Record<string, { address: string; url: string; via: string }> = JSON.parse(readFileSync('research/evidence.json', 'utf8'));97type Dep = { address: string; decimals: number; program?: string; onchainSymbol?: string; name?: string; second?: string };98type Row = { symbol: string; network: string; address: string; decimals: number; onchainSymbol: string | null; first: string; firstVia: string; second: string };99const symbols = cand.tokens.map((t: any) => t.symbol);100if (new Set(symbols).size !== symbols.length) throw new Error('a symbol appears twice in candidates.json');101const results = new Map<string, { dep?: Dep; row?: Row; drop?: string }>();102103async function one(t: any, net: string, raw: any, ethOk: () => Dep | undefined): Promise<{ dep: Dep; row: Row }> {104 const d = typeof raw === 'string' ? { a: raw } : raw, tag = `${t.symbol}@${net}`, evm = net !== 'solana' && net !== 'tron';105 const address: string = evm ? getAddress(d.a.toLowerCase()) : d.a;106 const same = (x: string) => (evm ? x.toLowerCase() === address.toLowerCase() : x === address);107 // 1. first source108 const ev = evidence[tag];109 if (!ev || !same(ev.address)) throw new Error('first source: the address was not found in the issuer / network document (no evidence entry)');110 // 2. second source111 const id: string | undefined = d.cg ?? t.cg, entry = id ? cg[id] : undefined, listed = entry?.platforms?.[CG[net]!];112 let second: string, cgDec: number | null = null;113 if (listed?.contract_address) {114 if (!same(listed.contract_address)) throw new Error(`second source differs: CoinGecko ${id} lists ${listed.contract_address}`);115 const okSym = [t.symbol, ...(t.alias ?? []), d.onchain, d.cgSymbol].filter(Boolean).map((s: string) => s.toUpperCase());116 if (!okSym.includes(String(entry.symbol).toUpperCase())) throw new Error(`second source is another coin: CoinGecko ${id} has symbol "${entry.symbol}"`);117 second = `coingecko:${id}`; cgDec = typeof listed.decimal_place === 'number' ? listed.decimal_place : null;118 } else if (d.second?.type === 'op-bridge' && OP_STACK.has(net)) {119 const l1 = ethOk();120 if (!l1 || l1.address.toLowerCase() !== String(d.second.l1).toLowerCase()) throw new Error('bridge mapping: the L1 token is not this asset\'s accepted Ethereum deployment');121 const got = await both(net, async (u) => {122 const addr = async (fns: ('l1Token' | 'remoteToken' | 'l2Bridge' | 'bridge')[]) => { for (const fn of fns) { try { return decodeAbiParameters([{ type: 'address' }], await call(u, address, encodeFunctionData({ abi: bridgeAbi, functionName: fn })))[0].toLowerCase(); } catch { /* next */ } } throw new Error('token does not expose its bridge mapping'); };123 return { l1: await addr(['l1Token', 'remoteToken']), bridge: await addr(['l2Bridge', 'bridge']) };124 });125 if (got.l1 !== l1.address.toLowerCase() || got.bridge !== OP_STANDARD_BRIDGE) throw new Error(`bridge mapping differs: L1 token ${got.l1}, bridge ${got.bridge}`);126 second = 'op-bridge';127 } else throw new Error(id ? `no second source: CoinGecko ${id} lists no address on this network` : 'no second source');128 // 3. the chain, on both endpoints129 let dep: Dep, onchain: string | null = null;130 if (net === 'solana') {131 const m = await both(net, (u) => readSol(u, address));132 dep = { address, decimals: m.dec, ...(m.program === 'token2022' ? { program: 'token2022' } : {}) };133 } else {134 const m = await both(net, (u) => (net === 'tron' ? (tronAddressBytes(address), readTron(u, address)) : readEvm(u, address)));135 const ok = d.onchain ? m.sym === d.onchain : [t.symbol, ...(t.alias ?? [])].some((s: string) => s.toUpperCase() === m.sym.toUpperCase());136 if (!ok) throw new Error(`on-chain symbol is "${m.sym}"`);137 onchain = m.sym;138 dep = { address, decimals: m.dec, ...(m.sym !== t.symbol ? { onchainSymbol: m.sym } : {}) };139 }140 if (cgDec !== null && cgDec !== dep.decimals) throw new Error(`decimals differ: chain ${dep.decimals}, CoinGecko ${cgDec}`);141 if (d.name) dep.name = d.name;142 if (second !== `coingecko:${id}`) dep.second = second;143 return { dep, row: { symbol: t.symbol, network: net, address, decimals: dep.decimals, onchainSymbol: onchain, first: ev.url, firstVia: ev.via, second } };144}145// one worker per network (public endpoints are rate-limited); Ethereum first, because bridge mappings refer to it146const order = ['ethereum', ...Object.keys(RPC).filter((n) => n !== 'ethereum')];147async function runNet(net: string): Promise<void> {148 for (const t of cand.tokens) {149 const raw = t.d[net]; if (raw === undefined) continue;150 const tag = `${t.symbol}@${net}`;151 try { results.set(tag, await one(t, net, raw, () => results.get(`${t.symbol}@ethereum`)?.dep)); console.log('ok ', tag); }152 catch (e) { results.set(tag, { drop: (e as Error).message }); console.log('DROP', tag, (e as Error).message); }153 await sleep(net === 'tron' || net === 'solana' ? 900 : 150);154 }155}156await runNet('ethereum');157await Promise.all(order.slice(1).map(runNet));158159const out: any[] = [], dropped: string[] = [], rows: Row[] = [], seen = new Map<string, string>();160for (const t of cand.tokens) {161 const deployments: Record<string, Dep> = {};162 for (const net of Object.keys(t.d)) {163 if (!(net in RPC)) { dropped.push(`${t.symbol}@${net}: unknown network`); continue; }164 const tag = `${t.symbol}@${net}`, r = results.get(tag)!;165 if (r.drop) { dropped.push(`${tag}: ${r.drop}`); continue; }166 const key = `${net}:${r.dep!.address.toLowerCase()}`;167 if (seen.has(key)) { dropped.push(`${tag}: same contract as ${seen.get(key)}`); continue; }168 seen.set(key, tag); deployments[net] = r.dep!; rows.push(r.row!);169 }170 if (!Object.keys(deployments).length) continue;171 const counts = new Map<number, number>(); for (const d of Object.values(deployments)) counts.set(d.decimals, (counts.get(d.decimals) ?? 0) + 1);172 const main = [...counts].sort((a, b) => b[1] - a[1])[0]![0];173 out.push({ ...t, decimals: main, deployments });174}175const q = (s: string) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;176const body = out.map((t) => ` // ${t.source || 'sources per deployment: research/candidates.json'}\n { symbol: ${q(t.symbol)}, name: ${q(t.name)}, decimals: ${t.decimals},${t.issuer ? ` issuer: ${q(t.issuer)},` : ''}${t.flags ? ` flags: ${q(t.flags)},` : ''} deployments: {\n${Object.entries(t.deployments as Record<string, Dep>).map(([n, d]) =>177 ` ${n}: { address: ${q(d.address)}${d.decimals !== t.decimals ? `, decimals: ${d.decimals}` : ''}${d.program ? `, program: ${q(d.program)}` : ''}${d.onchainSymbol ? `, onchainSymbol: ${q(d.onchainSymbol)}` : ''}${d.name ? `, name: ${q(d.name)}` : ''}${d.second ? `, second: ${q(d.second)}` : ''} },`).join('\n')}\n } },`).join('\n');178writeFileSync('packages/core/src/tokens.ts', `// GENERATED by scripts/gen-tokens.mts on ${new Date().toISOString().slice(0, 10)} from research/candidates.json — do not edit by hand.179// Inclusion rule (docs/TOKENS.md): the issuer's or network's own document states the address (research/evidence.json), a second180// independent source agrees (CoinGecko's contract list; \`second\` names the source when it is another one), and the contract's own181// symbol()/decimals()/totalSupply() (EVM, TRON) or mint owner program/decimals/supply (Solana) match on both RPC endpoints.182// The API re-checks the on-chain part at every start.183export interface TokenDef {184 symbol: string; name: string; decimals: number;185 /** who issues the asset (different issuers never share a symbol on one network) */186 issuer?: string;187 /** behaviour worth knowing before holding it (freeze / pause powers, bridge representation…) */188 flags?: string;189 /** network id -> deployment. \`decimals\` overrides when a chain differs. \`onchainSymbol\`: what the contract itself returns when it is not the ticker.190 * \`name\`: this deployment is another representation than the token's main one (bridged, exchange-pegged). \`second\`: second source when not CoinGecko. */191 deployments: Record<string, { address: string; decimals?: number; program?: 'token' | 'token2022'; onchainSymbol?: string; name?: string; second?: 'op-bridge' }>;192}193export const TOKENS: TokenDef[] = [194${body}195];196`);197for (const x of cand.excluded ?? []) dropped.push(`${x.symbol}: EXCLUDED — ${x.why}`);198writeFileSync('research/dropped.txt', dropped.join('\n') + '\n');199writeFileSync('research/accepted.json', JSON.stringify(rows, null, 1) + '\n');200console.log(`\nwrote ${out.length} tokens, ${rows.length} deployments; dropped ${dropped.length - (cand.excluded?.length ?? 0)} deployments, ${cand.excluded?.length ?? 0} assets excluded on purpose`);201