From 5ffff14c42bb7869c94ce6728976c9c6da1f61f6 Mon Sep 17 00:00:00 2001 From: Muhammet Selim Ferah Date: Sat, 28 Mar 2026 00:07:14 +0000 Subject: [PATCH] fix: resolve multiple registry issues - Fix index.js: read chain.json instead of metadata.json (critical bug) - Fix _TEMPLATE categories to use uppercase (DEFI, GAMING) matching CI rules - Fix validate script to be self-contained (was pointing to sibling repo) - Add validate-all.js for standalone local validation - Upgrade publish.yml actions to v4 for consistency - Add enrichment output files to .gitignore - Deduplicate validation logic in validate-pr.yml CI workflow - Add TypeScript type definitions (types.d.ts) - Add JSON Schema for chain.json (chain.schema.json) - Fix enrichment script idempotency for logoUri Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 4 +- .github/workflows/validate-pr.yml | 144 +++++-------------- .gitignore | 5 + chain.schema.json | 198 ++++++++++++++++++++++++++ data/_TEMPLATE/chain.json | 2 +- index.js | 4 +- package.json | 5 +- scripts/enrich-registry.js | 4 +- scripts/validate-all.js | 222 ++++++++++++++++++++++++++++++ types.d.ts | 92 +++++++++++++ 10 files changed, 561 insertions(+), 119 deletions(-) create mode 100644 chain.schema.json create mode 100644 scripts/validate-all.js create mode 100644 types.d.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ffe46ec..2ca4718 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,10 +18,10 @@ jobs: contents: read steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '18' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index c9f77ae..f9569dc 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -382,6 +382,10 @@ jobs: print(f" - {warning}") else: print("All URLs are reachable") + + # Save warnings for the report step + with open('url-warnings.json', 'w') as f: + json.dump(warnings, f) EOF - name: Validate RPC endpoints @@ -457,6 +461,10 @@ jobs: print(f" - {warning}") else: print("All RPC endpoints are responding") + + # Save warnings for the report step + with open('rpc-warnings.json', 'w') as f: + json.dump(warnings, f) EOF - name: Check content quality @@ -509,6 +517,10 @@ jobs: print(f" ... and {len(warnings) - 20} more warnings") else: print("Content quality checks passed") + + # Save warnings for the report step + with open('quality-warnings.json', 'w') as f: + json.dump(warnings, f) EOF - name: Get changed files @@ -535,127 +547,35 @@ jobs: echo "Generating validation summary report..." python3 << 'EOF' import json - import urllib.request - import urllib.error - from pathlib import Path - import time import os - import subprocess - - # Collect all validation results - url_warnings = [] - rpc_warnings = [] - quality_warnings = [] - - def check_url(url, timeout=5): - if not url or url.strip() == '': - return False, "Empty URL" - try: - req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) - with urllib.request.urlopen(req, timeout=timeout) as response: - return response.status == 200, f"OK" - except Exception as e: - return False, str(e)[:50] + from pathlib import Path - def check_rpc(rpc_url, timeout=5): - if not rpc_url or rpc_url.strip() == '': - return False, "Empty" + def load_warnings(filename): try: - data = json.dumps({ - "jsonrpc": "2.0", - "method": "eth_blockNumber", - "params": [], - "id": 1 - }).encode('utf-8') - req = urllib.request.Request(rpc_url, data=data, headers={'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0'}) - with urllib.request.urlopen(req, timeout=timeout) as response: - result = json.loads(response.read().decode('utf-8')) - return 'result' in result or 'error' in result, "OK" - except Exception as e: - return False, str(e)[:50] + with open(filename) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return [] + + # Load warnings saved by earlier validation steps + url_warnings = load_warnings('url-warnings.json') + rpc_warnings = load_warnings('rpc-warnings.json') + quality_warnings = load_warnings('quality-warnings.json') + + # Count total chains + chain_count = sum( + 1 for p in Path('data').iterdir() + if p.is_dir() and not p.name.startswith('.') and not p.name.startswith('_') + and (p / 'chain.json').exists() + ) - # Get changed files from git diff (only for PRs) - changed_files = [] is_pr = os.environ.get('GITHUB_EVENT_NAME') == 'pull_request' + scope = "in registry" if not is_pr else "in this PR" - if is_pr: - try: - base_ref = os.environ.get('GITHUB_BASE_REF', 'main') - result = subprocess.run( - ['git', 'diff', '--name-only', f'origin/{base_ref}...HEAD'], - capture_output=True, - text=True - ) - changed_files = [ - f.strip() for f in result.stdout.split('\n') - if 'data/' in f and 'chain.json' in f and '_TEMPLATE' not in f - ] - except Exception as e: - print(f"Could not get changed files: {e}") - changed_files = [] - - # If no changed files or not a PR, check first 10 chains - chain_count = 0 - files_to_check = [] - - if changed_files: - files_to_check = [Path(f) for f in changed_files if Path(f).exists()] - print(f"Checking {len(files_to_check)} changed chain(s) in this PR") - else: - # Fallback: check first 10 chains - for chain_json in Path('data').rglob('chain.json'): - if '_TEMPLATE' not in str(chain_json) and chain_count < 10: - files_to_check.append(chain_json) - chain_count += 1 - print(f"Checking first {len(files_to_check)} chains (no PR changes detected)") - - chain_count = len(files_to_check) - - # Check each file - for chain_json in files_to_check: - try: - with open(chain_json) as f: - data = json.load(f) - - folder = chain_json.parent.name - - # URL checks - if data.get('website'): - is_valid, msg = check_url(data['website']) - if not is_valid: - url_warnings.append(f"{folder}: Website issue - {msg}") - - if data.get('logo'): - is_valid, msg = check_url(data['logo']) - if not is_valid: - url_warnings.append(f"{folder}: Logo issue - {msg}") - - # Quality checks - desc = data.get('description', '').strip() - if len(desc) < 20: - quality_warnings.append(f"{folder}: Short description") - - if not data.get('chains') or len(data.get('chains', [])) == 0: - quality_warnings.append(f"{folder}: No blockchains") - - # RPC checks - for chain in data.get('chains', [])[:1]: - for rpc_url in chain.get('rpcUrls', [])[:1]: - if rpc_url: - is_valid, msg = check_rpc(rpc_url) - if not is_valid: - rpc_warnings.append(f"{folder}: RPC issue") - - time.sleep(0.3) - except: - pass - - # Create summary report - scope = "in this PR" if changed_files else "(sample)" report = "## Validation Report\n\n" report += "Status: All required validations passed\n\n" report += "### Summary\n" - report += f"- Chains checked {scope}: {chain_count}\n" + report += f"- Total chains in registry: {chain_count}\n" report += f"- URL warnings: {len(url_warnings)}\n" report += f"- RPC warnings: {len(rpc_warnings)}\n" report += f"- Quality warnings: {len(quality_warnings)}\n\n" diff --git a/.gitignore b/.gitignore index 24f2a7e..4a8a488 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,11 @@ yarn-error.log* dist/ build/ +# Enrichment script outputs +enrichment-report.json +enrichment-errors.log +validation-report.md + # Environment files .env .env.local diff --git a/chain.schema.json b/chain.schema.json new file mode 100644 index 0000000..f971282 --- /dev/null +++ b/chain.schema.json @@ -0,0 +1,198 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Avalanche L1 Chain Registry Entry", + "description": "Schema for chain.json files in the l1-registry", + "type": "object", + "required": [ + "subnetId", + "network", + "categories", + "name", + "description", + "logo", + "website", + "socials", + "chains" + ], + "properties": { + "subnetId": { + "type": "string", + "minLength": 1, + "description": "Unique Avalanche subnet identifier" + }, + "isL1": { + "type": "boolean", + "description": "Whether this subnet has been converted to an L1" + }, + "network": { + "type": "string", + "enum": ["mainnet", "fuji"], + "description": "Network environment" + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z0-9]+$" + }, + "minItems": 1, + "description": "Classification categories (uppercase, e.g., DEFI, GAMING)" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Official chain name" + }, + "description": { + "type": "string", + "minLength": 20, + "maxLength": 500, + "description": "Brief description of the chain (20-500 characters)" + }, + "logo": { + "type": "string", + "description": "URL to the chain's logo image" + }, + "website": { + "type": "string", + "format": "uri", + "description": "Official website URL" + }, + "socials": { + "type": "array", + "items": { + "$ref": "#/definitions/Social" + }, + "description": "Social media links" + }, + "chains": { + "type": "array", + "items": { + "$ref": "#/definitions/Blockchain" + }, + "minItems": 1, + "description": "Blockchains within this subnet" + } + }, + "additionalProperties": false, + "definitions": { + "Social": { + "type": "object", + "required": ["name", "url"], + "properties": { + "name": { + "type": "string", + "enum": [ + "twitter", + "discord", + "telegram", + "github", + "medium", + "linkedin", + "youtube", + "reddit" + ], + "description": "Social platform name" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL to the social media profile" + } + }, + "additionalProperties": false + }, + "NativeToken": { + "type": "object", + "required": ["symbol", "name", "decimals"], + "properties": { + "symbol": { + "type": "string", + "minLength": 1, + "description": "Token ticker symbol (e.g., AVAX, BEAM)" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Full token name" + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 36, + "description": "Number of decimal places" + }, + "logoUri": { + "type": "string", + "description": "URL to the token logo image" + } + }, + "additionalProperties": false + }, + "Blockchain": { + "type": "object", + "required": [ + "blockchainId", + "name", + "evmChainId", + "vmName", + "vmId", + "rpcUrls" + ], + "properties": { + "blockchainId": { + "type": "string", + "minLength": 1, + "description": "Unique Avalanche blockchain identifier" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable blockchain name" + }, + "description": { + "type": "string", + "description": "Description of the blockchain" + }, + "evmChainId": { + "type": "integer", + "minimum": 1, + "description": "EVM-compatible chain ID (EIP-155)" + }, + "vmName": { + "type": "string", + "minLength": 1, + "description": "Virtual machine name (e.g., EVM, SubnetEVM)" + }, + "vmId": { + "type": "string", + "minLength": 1, + "description": "Virtual machine identifier" + }, + "sybilResistanceType": { + "type": "string", + "enum": ["Proof of Stake", "Proof of Authority"], + "description": "Consensus mechanism type" + }, + "explorerUrl": { + "type": "string", + "format": "uri", + "description": "Block explorer URL" + }, + "rpcUrls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "minItems": 1, + "description": "Array of JSON-RPC endpoint URLs" + }, + "nativeToken": { + "$ref": "#/definitions/NativeToken" + } + }, + "additionalProperties": false + } + } +} diff --git a/data/_TEMPLATE/chain.json b/data/_TEMPLATE/chain.json index fe3e175..0309264 100644 --- a/data/_TEMPLATE/chain.json +++ b/data/_TEMPLATE/chain.json @@ -2,7 +2,7 @@ "subnetId": "subnet-id-here", "isL1": false, "network": "mainnet", - "categories": ["DeFi", "Gaming"], + "categories": ["DEFI", "GAMING"], "name": "Chain Name", "description": "Chain description here", "logo": "https://example.com/logo.png", diff --git a/index.js b/index.js index 680a485..9b71480 100644 --- a/index.js +++ b/index.js @@ -18,7 +18,7 @@ function getDataPath() { */ function loadChain(chainFolder) { try { - const chainPath = path.join(DATA_PATH, chainFolder, 'metadata.json'); + const chainPath = path.join(DATA_PATH, chainFolder, 'chain.json'); if (fs.existsSync(chainPath)) { const data = fs.readFileSync(chainPath, 'utf8'); return JSON.parse(data); @@ -41,7 +41,7 @@ function getAllChains() { for (const dir of dirs) { if (dir.isDirectory()) { - const chainPath = path.join(DATA_PATH, dir.name, 'metadata.json'); + const chainPath = path.join(DATA_PATH, dir.name, 'chain.json'); if (fs.existsSync(chainPath)) { try { const data = fs.readFileSync(chainPath, 'utf8'); diff --git a/package.json b/package.json index 44f0def..e3327ee 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,18 @@ "version": "1.2.8", "description": "Community-maintained registry of Avalanche L1 chains", "main": "index.js", + "types": "types.d.ts", "files": [ "data/**/*", "scripts/**/*", "index.js", + "types.d.ts", + "chain.schema.json", "README.md", "LICENSE" ], "scripts": { - "validate": "node ../L1Beat-backend/src/scripts/validateChainsRegistry.js", + "validate": "node scripts/validate-all.js", "validate:single": "./scripts/validate-single.sh" }, "repository": { diff --git a/scripts/enrich-registry.js b/scripts/enrich-registry.js index 3cecc3e..8f2d1fc 100644 --- a/scripts/enrich-registry.js +++ b/scripts/enrich-registry.js @@ -142,7 +142,9 @@ async function enrichChain(folder) { chain.nativeToken = {}; } - if (!chain.nativeToken.logoUri) { + if (!chain.nativeToken.logoUri && chain.nativeToken.logoUri !== '') { + // Only enrich if logoUri has never been set (undefined/null). + // An empty string means it was previously checked and no logo was found. const logoUri = findLogoUriForBlockchain(chain.blockchainId); if (logoUri) { chain.nativeToken.logoUri = logoUri; diff --git a/scripts/validate-all.js b/scripts/validate-all.js new file mode 100644 index 0000000..8deac56 --- /dev/null +++ b/scripts/validate-all.js @@ -0,0 +1,222 @@ +const fs = require('fs'); +const path = require('path'); + +const DATA_PATH = path.join(__dirname, '..', 'data'); + +const REQUIRED_FIELDS = ['subnetId', 'network', 'categories', 'name', 'description', 'logo', 'website', 'socials', 'chains']; +const REQUIRED_CHAIN_FIELDS = ['blockchainId', 'name', 'evmChainId', 'vmName', 'vmId', 'rpcUrls']; + +const TEMPLATE_STRINGS = [ + 'subnet-id-here', 'blockchain-id-here', 'chain-slug', + 'vm-id-here', 'rpc-url-here', 'Chain Name', 'My Chain', + 'Describe your', 'https://your-', 'TEMPLATE', +]; + +const URL_PATTERN = /^https?:\/\/[^\s/$?.#][^\s]*$/; + +function hasTemplateValue(obj, objPath) { + if (typeof obj === 'string') { + for (const t of TEMPLATE_STRINGS) { + if (obj.toLowerCase().includes(t.toLowerCase())) { + return `template placeholder at '${objPath}': '${obj}'`; + } + } + } else if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + const r = hasTemplateValue(obj[i], `${objPath}[${i}]`); + if (r) return r; + } + } else if (obj && typeof obj === 'object') { + for (const [k, v] of Object.entries(obj)) { + const r = hasTemplateValue(v, `${objPath}.${k}`); + if (r) return r; + } + } + return null; +} + +function validateChain(folder, data) { + const errors = []; + const warnings = []; + + // Required fields + for (const field of REQUIRED_FIELDS) { + if (!(field in data)) { + errors.push(`Missing required field: ${field}`); + } + } + + // Network value + if (data.network && !['mainnet', 'fuji'].includes(data.network)) { + errors.push("network must be 'mainnet' or 'fuji'"); + } + + // Category casing + for (const cat of (data.categories || [])) { + if (typeof cat === 'string' && cat !== cat.toUpperCase()) { + errors.push(`category '${cat}' must be uppercase - use '${cat.toUpperCase()}'`); + } + } + + // Template values + const templateResult = hasTemplateValue(data, folder); + if (templateResult) { + errors.push(templateResult); + } + + // URL format validation + for (const field of ['logo', 'website']) { + const url = data[field] || ''; + if (url && !URL_PATTERN.test(url)) { + errors.push(`'${field}' is not a valid URL: '${url}'`); + } + } + for (const social of (data.socials || [])) { + const url = social.url || ''; + if (url && !URL_PATTERN.test(url)) { + errors.push(`social url is not a valid URL: '${url}'`); + } + } + + // Chain-level validation + if (data.chains && Array.isArray(data.chains)) { + for (let idx = 0; idx < data.chains.length; idx++) { + const chain = data.chains[idx]; + + for (const field of REQUIRED_CHAIN_FIELDS) { + if (!(field in chain)) { + errors.push(`chains[${idx}] missing field: ${field}`); + } + } + + for (const rpc of (chain.rpcUrls || [])) { + if (rpc && !URL_PATTERN.test(rpc)) { + errors.push(`rpcUrl is not a valid URL: '${rpc}'`); + } + } + + const nt = chain.nativeToken; + if (nt && typeof nt === 'object') { + for (const field of ['symbol', 'name', 'decimals']) { + if (nt[field] === undefined || nt[field] === null || nt[field] === '') { + warnings.push(`chains[${idx}].nativeToken.${field} is empty`); + } + } + const logoUri = nt.logoUri || ''; + if (logoUri && !URL_PATTERN.test(logoUri)) { + errors.push(`nativeToken.logoUri is not a valid URL: '${logoUri}'`); + } + if (!logoUri) { + warnings.push(`chains[${idx}].nativeToken.logoUri is empty`); + } + } + + if (chain.evmChainId === null || chain.evmChainId === undefined) { + warnings.push(`chains[${idx}].evmChainId is null`); + } + } + } + + return { errors, warnings }; +} + +function main() { + const dirs = fs.readdirSync(DATA_PATH, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.startsWith('.') && !d.name.startsWith('_')); + + const folderPattern = /^[a-z0-9-]+$/; + let totalErrors = 0; + let totalWarnings = 0; + let totalChains = 0; + + // Uniqueness checks + const seenSubnetIds = {}; + const seenEvmChainIds = {}; + + console.log(`Validating ${dirs.length} chains...\n`); + + for (const dir of dirs) { + const folder = dir.name; + const chainJsonPath = path.join(DATA_PATH, folder, 'chain.json'); + const readmePath = path.join(DATA_PATH, folder, 'README.md'); + + // Folder naming + if (!folderPattern.test(folder)) { + console.error(` ERROR ${folder}: folder name must be lowercase and hyphenated`); + totalErrors++; + continue; + } + + // File existence + if (!fs.existsSync(chainJsonPath)) { + console.error(` ERROR ${folder}: Missing chain.json`); + totalErrors++; + continue; + } + if (!fs.existsSync(readmePath)) { + console.error(` ERROR ${folder}: Missing README.md`); + totalErrors++; + } + + let data; + try { + data = JSON.parse(fs.readFileSync(chainJsonPath, 'utf8')); + } catch (e) { + console.error(` ERROR ${folder}: Invalid JSON - ${e.message}`); + totalErrors++; + continue; + } + + // Uniqueness: subnetId + const sid = data.subnetId || ''; + if (sid) { + if (seenSubnetIds[sid]) { + console.error(` ERROR ${folder}: duplicate subnetId '${sid}' already used by '${seenSubnetIds[sid]}'`); + totalErrors++; + } else { + seenSubnetIds[sid] = folder; + } + } + + // Uniqueness: evmChainId + for (const chain of (data.chains || [])) { + const eid = chain.evmChainId; + if (eid !== null && eid !== undefined) { + const key = String(eid); + if (seenEvmChainIds[key]) { + console.error(` ERROR ${folder}: evmChainId ${eid} already used by '${seenEvmChainIds[key]}'`); + totalErrors++; + } else { + seenEvmChainIds[key] = folder; + } + } + } + + const { errors, warnings } = validateChain(folder, data); + + for (const err of errors) { + console.error(` ERROR ${folder}: ${err}`); + } + for (const warn of warnings) { + console.warn(` WARN ${folder}: ${warn}`); + } + + totalErrors += errors.length; + totalWarnings += warnings.length; + totalChains++; + } + + console.log(`\n${'='.join ? '=' : '━'.repeat(40)}`); + console.log(`Validated ${totalChains} chains`); + console.log(` Errors: ${totalErrors}`); + console.log(` Warnings: ${totalWarnings}`); + + if (totalErrors > 0) { + console.error(`\nValidation failed with ${totalErrors} error(s).`); + process.exit(1); + } else { + console.log('\nAll validations passed.'); + } +} + +main(); diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..b46c098 --- /dev/null +++ b/types.d.ts @@ -0,0 +1,92 @@ +/** + * Type definitions for l1beat-l1-registry + */ + +export interface Social { + /** Social platform name (e.g., "twitter", "discord", "telegram") */ + name: string; + /** URL to the social media profile or invite */ + url: string; +} + +export interface NativeToken { + /** Token ticker symbol (e.g., "AVAX", "BEAM") */ + symbol: string; + /** Full token name */ + name: string; + /** Number of decimal places the token uses */ + decimals: number; + /** URL to the token logo image */ + logoUri?: string; +} + +export interface Blockchain { + /** Unique Avalanche blockchain identifier */ + blockchainId: string; + /** Human-readable blockchain name */ + name: string; + /** Description of the blockchain */ + description?: string; + /** EVM-compatible chain ID (EIP-155) */ + evmChainId: number; + /** Virtual machine name (e.g., "EVM", "SubnetEVM") */ + vmName: string; + /** Virtual machine identifier */ + vmId: string; + /** Consensus mechanism type */ + sybilResistanceType?: "Proof of Stake" | "Proof of Authority"; + /** Block explorer URL */ + explorerUrl?: string; + /** Array of JSON-RPC endpoint URLs */ + rpcUrls: string[]; + /** Native gas/utility token of the blockchain */ + nativeToken?: NativeToken; +} + +export interface ChainData { + /** Unique Avalanche subnet identifier */ + subnetId: string; + /** Whether this subnet has been converted to an L1 */ + isL1?: boolean; + /** Network environment */ + network: "mainnet" | "fuji"; + /** Classification categories (uppercase, e.g., "DEFI", "GAMING") */ + categories: string[]; + /** Official chain name */ + name: string; + /** Brief description of the chain */ + description: string; + /** URL to the chain's logo image */ + logo: string; + /** Official website URL */ + website: string; + /** Social media links */ + socials: Social[]; + /** Blockchains within this subnet */ + chains: Blockchain[]; +} + +/** Absolute path to the data directory */ +export const dataPath: string; + +/** Get the data directory path for l1-registry */ +export function getDataPath(): string; + +/** + * Load a single chain by folder name + * @param chainFolder - Chain folder name (e.g., "dexalot", "beam") + * @returns Chain metadata object or null if not found + */ +export function loadChain(chainFolder: string): ChainData | null; + +/** + * Load all available chains + * @returns Array of chain metadata objects + */ +export function getAllChains(): ChainData[]; + +/** + * Get list of all chain folder names + * @returns Array of chain folder names + */ +export function getChainNames(): string[];