- Refresh pnpm-lock.yaml / workspace after prior merge - Add Chain 138 info hub SPA (info-defi-oracle-138) - Token list and validation script tweaks; path_b report; Hyperledger proxmox install notes - HYBX implementation roadmap and routing graph data model Note: transaction-composer is a nested git repo — convert to submodule before tracking. Made-with: Cursor
380 lines
12 KiB
JavaScript
Executable File
380 lines
12 KiB
JavaScript
Executable File
#!/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('ℹ️ Using fallback token list schema source\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 infos = [];
|
||
const seenAddresses = new Set();
|
||
const seenSymbols = new Map(); // chainId -> Map<symbol, token[]>
|
||
let detectedChainId = null;
|
||
|
||
function isAllowedGruVersionDuplicate(existingToken, nextToken) {
|
||
if (!existingToken?.extensions || !nextToken?.extensions) return false;
|
||
|
||
const existingVersion = existingToken.extensions.gruVersion;
|
||
const nextVersion = nextToken.extensions.gruVersion;
|
||
const existingCurrencyCode = existingToken.extensions.currencyCode;
|
||
const nextCurrencyCode = nextToken.extensions.currencyCode;
|
||
|
||
if (typeof existingVersion !== 'string' || typeof nextVersion !== 'string') {
|
||
return false;
|
||
}
|
||
|
||
if (existingVersion === nextVersion) {
|
||
return false;
|
||
}
|
||
|
||
if (typeof existingCurrencyCode !== 'string' || typeof nextCurrencyCode !== 'string') {
|
||
return false;
|
||
}
|
||
|
||
if (existingCurrencyCode !== nextCurrencyCode) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 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 Map());
|
||
}
|
||
const symbolMap = seenSymbols.get(chainId);
|
||
const symbolKey = token.symbol.toUpperCase();
|
||
const existingTokens = symbolMap.get(symbolKey) || [];
|
||
if (existingTokens.length > 0) {
|
||
const duplicateAllowed = existingTokens.every(existingToken =>
|
||
isAllowedGruVersionDuplicate(existingToken, token)
|
||
);
|
||
|
||
if (duplicateAllowed) {
|
||
infos.push(
|
||
`${prefix}: Allowed staged GRU duplicate symbol "${token.symbol}" on chainId ${chainId} ` +
|
||
`(${existingTokens.map(existingToken => existingToken.extensions?.gruVersion).join(', ')} -> ${token.extensions?.gruVersion})`
|
||
);
|
||
} else {
|
||
warnings.push(`${prefix}: Duplicate symbol "${token.symbol}" on chainId ${chainId}`);
|
||
}
|
||
}
|
||
existingTokens.push(token);
|
||
symbolMap.set(symbolKey, existingTokens);
|
||
}
|
||
|
||
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, infos, 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,
|
||
infos: enhanced.infos,
|
||
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('');
|
||
}
|
||
|
||
if (validationResult.infos.length > 0) {
|
||
console.log('ℹ️ Notes:');
|
||
validationResult.infos.forEach(info => {
|
||
console.log(` - ${info}`);
|
||
});
|
||
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('');
|
||
}
|
||
|
||
if (validationResult.infos.length > 0) {
|
||
console.log('Notes:');
|
||
validationResult.infos.forEach(info => {
|
||
console.log(` ℹ️ ${info}`);
|
||
});
|
||
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);
|
||
});
|