#!/usr/bin/env node /** * Enhanced Token List Validator * Validates token lists against the Uniswap Token Lists JSON schema * Based on: https://github.com/Uniswap/token-lists * Uses: @uniswap/token-lists package for schema and types * * Enhanced with: * - EIP-55 checksum validation * - Duplicate detection * - Logo URL validation * - Chain ID strict validation * - Semantic versioning validation */ import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, resolve } from 'path'; import { ethers } from 'ethers'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Required chain ID (optional - if not set, will validate all tokens have same chainId) // Can be overridden via --chain-id flag let REQUIRED_CHAIN_ID = null; /** * Get schema from @uniswap/token-lists package * Falls back to fetching from URL if package not available */ async function getSchema() { try { // Try to import schema from @uniswap/token-lists package const tokenLists = await import('@uniswap/token-lists'); if (tokenLists.schema) { console.log('āœ… Using schema from @uniswap/token-lists package\n'); return tokenLists.schema; } } catch (error) { console.log('āš ļø @uniswap/token-lists package not available, fetching schema from URL...\n'); } // Fallback: fetch schema from Uniswap try { const SCHEMA_URL = 'https://uniswap.org/tokenlist.schema.json'; const response = await fetch(SCHEMA_URL); if (!response.ok) { throw new Error(`Failed to fetch schema: ${response.statusText}`); } return await response.json(); } catch (error) { console.error('Error fetching schema:', error.message); console.error('Falling back to basic validation...'); return null; } } // Validate EIP-55 checksum function isChecksummed(address) { try { return ethers.isAddress(address) && address === ethers.getAddress(address); } catch { return false; } } // Basic validation with enhanced checks function enhancedValidation(tokenList) { const errors = []; const warnings = []; const seenAddresses = new Set(); const seenSymbols = new Map(); // chainId -> Set of symbols let detectedChainId = null; // Required fields if (!tokenList.name || typeof tokenList.name !== 'string') { errors.push('Missing or invalid "name" field'); } if (!tokenList.version) { errors.push('Missing "version" field'); } else { if (typeof tokenList.version.major !== 'number') { errors.push('version.major must be a number'); } if (typeof tokenList.version.minor !== 'number') { errors.push('version.minor must be a number'); } if (typeof tokenList.version.patch !== 'number') { errors.push('version.patch must be a number'); } } if (!tokenList.tokens || !Array.isArray(tokenList.tokens)) { errors.push('Missing or invalid "tokens" array'); return { errors, warnings, valid: false }; } // Detect chain ID from first token if not specified if (tokenList.tokens.length > 0 && tokenList.tokens[0].chainId) { detectedChainId = tokenList.tokens[0].chainId; } // Validate each token tokenList.tokens.forEach((token, index) => { const prefix = `Token[${index}]`; // Required token fields if (typeof token.chainId !== 'number') { errors.push(`${prefix}: Missing or invalid "chainId"`); } else { // Chain ID consistency check if (detectedChainId === null) { detectedChainId = token.chainId; } else if (token.chainId !== detectedChainId) { errors.push(`${prefix}: chainId mismatch - expected ${detectedChainId}, got ${token.chainId}`); } // Strict chain ID validation (if REQUIRED_CHAIN_ID is set) if (REQUIRED_CHAIN_ID !== null && token.chainId !== REQUIRED_CHAIN_ID) { errors.push(`${prefix}: chainId must be ${REQUIRED_CHAIN_ID}, got ${token.chainId}`); } } if (!token.address || typeof token.address !== 'string') { errors.push(`${prefix}: Missing or invalid "address"`); } else { // Validate Ethereum address format if (!/^0x[a-fA-F0-9]{40}$/.test(token.address)) { errors.push(`${prefix}: Invalid Ethereum address format: ${token.address}`); } else { // EIP-55 checksum validation if (!isChecksummed(token.address)) { errors.push(`${prefix}: Address not EIP-55 checksummed: ${token.address}`); } // Duplicate address detection const addressLower = token.address.toLowerCase(); if (seenAddresses.has(addressLower)) { errors.push(`${prefix}: Duplicate address: ${token.address}`); } seenAddresses.add(addressLower); } } if (!token.name || typeof token.name !== 'string') { errors.push(`${prefix}: Missing or invalid "name"`); } if (!token.symbol || typeof token.symbol !== 'string') { errors.push(`${prefix}: Missing or invalid "symbol"`); } else { // Symbol uniqueness per chainId const chainId = token.chainId || 0; if (!seenSymbols.has(chainId)) { seenSymbols.set(chainId, new Set()); } const symbolSet = seenSymbols.get(chainId); if (symbolSet.has(token.symbol.toUpperCase())) { warnings.push(`${prefix}: Duplicate symbol "${token.symbol}" on chainId ${chainId}`); } symbolSet.add(token.symbol.toUpperCase()); } if (typeof token.decimals !== 'number' || token.decimals < 0 || token.decimals > 255) { errors.push(`${prefix}: Invalid "decimals" (must be 0-255), got ${token.decimals}`); } // Optional fields (warnings) if (!token.logoURI) { warnings.push(`${prefix}: Missing "logoURI" (optional but recommended)`); } else if (typeof token.logoURI !== 'string') { warnings.push(`${prefix}: Invalid "logoURI" type`); } else if (!token.logoURI.startsWith('http://') && !token.logoURI.startsWith('https://') && !token.logoURI.startsWith('ipfs://')) { warnings.push(`${prefix}: Invalid "logoURI" format (should be HTTP/HTTPS/IPFS URL): ${token.logoURI}`); } else if (!token.logoURI.startsWith('https://') && !token.logoURI.startsWith('ipfs://')) { warnings.push(`${prefix}: logoURI should use HTTPS (not HTTP): ${token.logoURI}`); } }); return { errors, warnings, valid: errors.length === 0 }; } async function validateTokenList(filePath) { console.log(`\nšŸ” Validating token list: ${filePath}\n`); // Read token list file let tokenList; try { const fileContent = readFileSync(filePath, 'utf-8'); tokenList = JSON.parse(fileContent); } catch (error) { console.error('āŒ Error reading or parsing token list file:'); console.error(` ${error.message}`); process.exit(1); } // Get schema from @uniswap/token-lists package or fetch from URL const schema = await getSchema(); let validationResult; if (schema) { // Use AJV if available, otherwise fall back to enhanced validation try { // Try to use dynamic import for ajv (if installed) const { default: Ajv } = await import('ajv'); const addFormats = (await import('ajv-formats')).default; const ajv = new Ajv({ allErrors: true, verbose: true }); addFormats(ajv); const validate = ajv.compile(schema); const valid = validate(tokenList); if (valid) { // Schema validation passed, now run enhanced checks validationResult = enhancedValidation(tokenList); } else { const schemaErrors = validate.errors?.map(err => { const path = err.instancePath || err.schemaPath || ''; return `${path}: ${err.message}`; }) || []; const enhanced = enhancedValidation(tokenList); validationResult = { errors: [...schemaErrors, ...enhanced.errors], warnings: enhanced.warnings, valid: false }; } } catch (importError) { // AJV not available, use enhanced validation console.log('āš ļø AJV not available, using enhanced validation'); validationResult = enhancedValidation(tokenList); } } else { // Schema fetch failed, use enhanced validation validationResult = enhancedValidation(tokenList); } // Display results if (validationResult.valid) { console.log('āœ… Token list is valid!\n'); // Display token list info console.log('šŸ“‹ Token List Info:'); console.log(` Name: ${tokenList.name}`); if (tokenList.version) { console.log(` Version: ${tokenList.version.major}.${tokenList.version.minor}.${tokenList.version.patch}`); } if (tokenList.timestamp) { console.log(` Timestamp: ${tokenList.timestamp}`); } console.log(` Tokens: ${tokenList.tokens.length}`); console.log(''); // List tokens console.log('šŸŖ™ Tokens:'); tokenList.tokens.forEach((token, index) => { console.log(` ${index + 1}. ${token.symbol} (${token.name})`); console.log(` Address: ${token.address}`); console.log(` Chain ID: ${token.chainId}`); console.log(` Decimals: ${token.decimals}`); if (token.logoURI) { console.log(` Logo: ${token.logoURI}`); } console.log(''); }); if (validationResult.warnings.length > 0) { console.log('āš ļø Warnings:'); validationResult.warnings.forEach(warning => { console.log(` - ${warning}`); }); console.log(''); } process.exit(0); } else { console.error('āŒ Token list validation failed!\n'); if (validationResult.errors.length > 0) { console.error('Errors:'); validationResult.errors.forEach(error => { console.error(` āŒ ${error}`); }); console.log(''); } if (validationResult.warnings.length > 0) { console.log('Warnings:'); validationResult.warnings.forEach(warning => { console.log(` āš ļø ${warning}`); }); console.log(''); } process.exit(1); } } // Main const args = process.argv.slice(2); const filePath = args.find(arg => !arg.startsWith('--')) || resolve(__dirname, '../lists/dbis-138.tokenlist.json'); const chainIdArg = args.find(arg => arg.startsWith('--chain-id=')); if (chainIdArg) { REQUIRED_CHAIN_ID = parseInt(chainIdArg.split('=')[1], 10); } if (!filePath) { console.error('Usage: node validate-token-list.js [path/to/token-list.json] [--chain-id=138]'); process.exit(1); } validateTokenList(filePath).catch(error => { console.error('Unexpected error:', error); process.exit(1); });