SPB Git

Repository Public

gen-tokens.mts 17.1 KB 201 lines typescript

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
Files main
Menu

Theme