#!/usr/bin/env node /** * Full diff: Blockscout /api/v2/tokens vs curated token list (Chain 138). * * Outputs: * - missing_in_blockscout: in tokenlist but not in Blockscout response * - missing_in_tokenlist: in Blockscout but not in tokenlist * - metadata_mismatches: same address, different name/symbol/decimals (or null/0) * - source-of-truth recommendation per field * * Usage: * node diff-blockscout-vs-tokenlist.js * node diff-blockscout-vs-tokenlist.js --url "https://explorer.d-bis.org/api/v2/tokens" * node diff-blockscout-vs-tokenlist.js --file /path/to/blockscout-tokens.json * * Curated list: token-lists/lists/dbis-138.tokenlist.json (Chain 138 tokens). * ETH-USD (oracle) is not an ERC-20 supply token; it is expected to be missing from Blockscout. */ import { readFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CHAIN_ID = 138; const TOKENLIST_PATH = resolve(__dirname, '../lists/dbis-138.tokenlist.json'); function normAddr(addr) { return (addr || '').toLowerCase(); } function parseArgs() { const args = process.argv.slice(2); let url = null; let file = null; for (let i = 0; i < args.length; i++) { if (args[i] === '--url' && args[i + 1]) { url = args[i + 1]; i++; } else if (args[i] === '--file' && args[i + 1]) { file = args[i + 1]; i++; } } return { url, file }; } async function fetchAllBlockscoutTokens(baseUrl) { const items = []; let next = null; const base = baseUrl.replace(/\?.*$/, ''); while (true) { const qs = next ? new URLSearchParams({ page_size: 100, ...next }) : new URLSearchParams({ page: 1, page_size: 100 }); const res = await fetch(`${base}?${qs}`); if (!res.ok) throw new Error(`Blockscout ${res.status}`); const data = await res.json(); const list = data.items ?? data.data ?? (Array.isArray(data) ? data : []); items.push(...list); next = data.next_page_params ?? null; if (!next) break; } return items; } function loadTokenList() { const raw = readFileSync(TOKENLIST_PATH, 'utf8'); const data = JSON.parse(raw); const tokens = (data.tokens || []).filter((t) => t.chainId === CHAIN_ID); return tokens.map((t) => ({ address: normAddr(t.address), name: t.name ?? null, symbol: t.symbol ?? null, decimals: t.decimals != null ? Number(t.decimals) : null, logoURI: t.logoURI ?? null, })); } function loadBlockscoutFromFile(path) { const raw = readFileSync(path, 'utf8'); const data = JSON.parse(raw); const list = data.items ?? data.data ?? (Array.isArray(data) ? data : []); return list.map((t) => ({ address: normAddr(t.address ?? t.hash), name: t.name ?? null, symbol: t.symbol ?? null, decimals: t.decimals != null && t.decimals !== '' ? Number(t.decimals) : null, })); } function runDiff(tokenlist, blockscout) { const byAddr = (arr) => Object.fromEntries(arr.map((t) => [t.address, t])); const listMap = byAddr(tokenlist); const scoutMap = byAddr(blockscout); const missing_in_blockscout = tokenlist .filter((t) => !scoutMap[t.address]) .map((t) => ({ address: t.address, symbol: t.symbol, name: t.name, note: t.symbol === 'ETH-USD' ? 'Oracle; not ERC-20 supply token' : null })); const missing_in_tokenlist = blockscout .filter((t) => !listMap[t.address]) .map((t) => ({ address: t.address, symbol: t.symbol, name: t.name, decimals: t.decimals })); const metadata_mismatches = []; for (const addr of Object.keys(listMap)) { const list = listMap[addr]; const scout = scoutMap[addr]; if (!scout) continue; const mismatches = []; if (list.name !== scout.name && (scout.name != null || list.name != null)) mismatches.push({ field: 'name', tokenlist: list.name, blockscout: scout.name }); if (list.symbol !== scout.symbol && (scout.symbol != null || list.symbol != null)) mismatches.push({ field: 'symbol', tokenlist: list.symbol, blockscout: scout.symbol }); if (list.decimals !== scout.decimals && (scout.decimals != null || list.decimals != null)) mismatches.push({ field: 'decimals', tokenlist: list.decimals, blockscout: scout.decimals }); if (mismatches.length) metadata_mismatches.push({ address: addr, symbol: list.symbol ?? scout.symbol, mismatches }); } return { missing_in_blockscout, missing_in_tokenlist, metadata_mismatches }; } function sourceOfTruthRecommendation(diff) { return { address: 'Token list (dbis-138.tokenlist.json) and CONTRACT_ADDRESSES_REFERENCE; Blockscout is on-chain index.', symbol: 'Token list; use Explorer UI override only when Blockscout returns null (e.g. WETH9).', name: 'Token list; same as symbol.', decimals: 'Token list; use override when Blockscout returns 0 or null.', logo: 'Token list logoURI.', }; } function main() { const { url, file } = parseArgs(); const baseUrl = url || 'https://explorer.d-bis.org/api/v2/tokens'; (async () => { let blockscout; if (file) { blockscout = loadBlockscoutFromFile(file); console.error(`Loaded ${blockscout.length} tokens from file: ${file}`); } else { try { blockscout = await fetchAllBlockscoutTokens(baseUrl); console.error(`Fetched ${blockscout.length} tokens from ${baseUrl}`); } catch (e) { console.error('Fetch failed:', e.message); console.error('Use --file path/to/blockscout-tokens.json with a saved snapshot.'); process.exit(1); } } const tokenlist = loadTokenList(); console.error(`Loaded ${tokenlist.length} Chain ${CHAIN_ID} tokens from ${TOKENLIST_PATH}`); const diff = runDiff(tokenlist, blockscout); const recommendation = sourceOfTruthRecommendation(diff); const out = { chainId: CHAIN_ID, tokenlist_path: TOKENLIST_PATH, blockscout_source: file || baseUrl, missing_in_blockscout: diff.missing_in_blockscout, missing_in_tokenlist: diff.missing_in_tokenlist, metadata_mismatches: diff.metadata_mismatches, source_of_truth: recommendation, }; console.log(JSON.stringify(out, null, 2)); })().catch((e) => { console.error(e); process.exit(1); }); } main();