UNPKG

@polymerdao/mcp-polymer

Version:

A Model Context Protocol (MCP) server for blockchain event verification using Polymer's Prove API.

379 lines (378 loc) 15.6 kB
import axios from 'axios'; const CACHE_DURATION = 60 * 60 * 1000; // 1 hour export class ChainlistClient { constructor() { this.chainsCache = null; this.chainlistCache = null; this.rpcCache = new Map(); this.rpcStatusCache = new Map(); // Blocked domains/patterns for RPC endpoints to prevent SSRF attacks this.BLOCKED_RPC_PATTERNS = [ // Block localhost and private networks /^localhost$/i, /^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^169\.254\./, // Link-local /^224\./, // Multicast /^0\.0\.0\.0$/, /^::1$/, // IPv6 localhost // Block suspicious domains /\.local$/i, /\.internal$/i, /\.corp$/i, ]; } getChainName(chainName) { if (chainName.toLowerCase() === 'eth') { return 'ethereum'; } return chainName; } validateRpcUrl(url) { try { // First check raw URL for path traversal attempts before URL normalization if (url.includes('..') || url.includes('%2e%2e') || // URL encoded .. url.includes('%2E%2E')) { // URL encoded .. (uppercase) return false; } const parsedUrl = new URL(url); // Must use HTTPS to prevent protocol downgrade attacks if (parsedUrl.protocol !== 'https:') { return false; } // Check if hostname matches any blocked patterns const hostname = parsedUrl.hostname; const isBlocked = this.BLOCKED_RPC_PATTERNS.some(pattern => pattern.test(hostname)); if (isBlocked) { console.warn(`RPC URL rejected - blocked hostname: ${hostname}`); return false; } // Additional path validation after URL parsing - check for double slashes const path = parsedUrl.pathname; if (path.includes('//') || path.includes('%2f%2f') || // URL encoded // path.includes('%2F%2F')) { // URL encoded // (uppercase) return false; } return true; } catch (error) { // Invalid URL format return false; } } async fetchChains() { if (this.chainsCache && (Date.now() - this.chainsCache.timestamp < CACHE_DURATION)) { return this.chainsCache.data; } const response = await axios.get('https://chainid.network/chains.json'); const chains = response.data; this.chainsCache = { data: chains, timestamp: Date.now() }; return chains; } async fetchChainlistData() { if (this.chainlistCache && (Date.now() - this.chainlistCache.timestamp < CACHE_DURATION)) { return this.chainlistCache.data; } try { const response = await axios.get('https://chainlist.org/rpcs.json'); const chains = response.data; this.chainlistCache = { data: chains, timestamp: Date.now() }; return chains; } catch (error) { // Fallback to chainid.network if chainlist.org fails const fallbackChains = await this.fetchChains(); return fallbackChains.map(chain => ({ ...chain, chain: chain.shortName.toUpperCase() })); } } async getChainIdAndRpc(chainName, environment) { if (environment !== 'mainnet') { throw new Error(`Environment '${environment}' is not supported. Only 'mainnet' is supported.`); } const effectiveChainName = this.getChainName(chainName); const cacheKey = `${effectiveChainName}-${environment}`; const cached = this.rpcCache.get(cacheKey); if (cached && (Date.now() - cached.timestamp < CACHE_DURATION)) { return cached.data; } const rpcs = await this.getChainRpcs(chainName, environment); if (rpcs.length === 0) { throw new Error(`Could not resolve chain '${chainName}' for environment '${environment}'`); } // Get chain info from the first RPC entry const chainInfo = rpcs[0]; // Filter out RPCs that require API keys (contain placeholders) or known problematic ones const publicRpcs = rpcs.filter(rpc => !rpc.rpc_url.includes('${') && !rpc.rpc_url.includes('YOUR_API_KEY') && !rpc.rpc_url.includes('api.mycryptoapi.com') // Known to have issues with large blocks ); // Prefer known reliable public RPCs const reliableRpc = publicRpcs.find(rpc => rpc.rpc_url.includes('cloudflare-eth.com') || rpc.rpc_url.includes('ethereum-rpc.publicnode.com') || // Fixed: was ethereum.publicnode.com rpc.rpc_url.includes('ethereum.publicnode.com') || rpc.rpc_url.includes('rpc.ankr.com') || rpc.rpc_url.includes('eth.llamarpc.com') || rpc.rpc_url.includes('eth-mainnet.public.blastapi.io') || rpc.rpc_url.includes('api.zan.top/node/v1/eth/mainnet/public')); const rpc = reliableRpc || publicRpcs[0] || rpcs[0]; // Prefer reliable RPCs, then any public RPC, fallback to first RPC if (!rpc) { throw new Error(`No RPC found for chain '${chainName}' in environment '${environment}'`); } const data = { chainId: chainInfo.chain_id, rpcUrl: rpc.rpc_url }; this.rpcCache.set(cacheKey, { data, timestamp: Date.now() }); return data; } async getChainRpcs(chainName, environment) { if (environment !== 'mainnet') { return []; } const effectiveChainName = this.getChainName(chainName); const chains = await this.fetchChainlistData(); // Try multiple matching strategies let chain = chains.find(c => c.name.toLowerCase() === effectiveChainName.toLowerCase()); // If no exact match, try matching by shortName if (!chain) { chain = chains.find(c => c.shortName.toLowerCase() === effectiveChainName.toLowerCase()); } // If still no match, try partial matching for common chains if (!chain) { const lowerName = effectiveChainName.toLowerCase(); if (lowerName === 'ethereum') { chain = chains.find(c => c.name.toLowerCase() === 'ethereum mainnet' || c.chainId === 1); } else if (lowerName === 'polygon') { chain = chains.find(c => c.name.toLowerCase() === 'polygon mainnet' || c.chainId === 137); } else if (lowerName === 'optimism' || lowerName === 'op') { chain = chains.find(c => c.chainId === 10 || c.name.toLowerCase().includes('op mainnet')); } else if (lowerName === 'arbitrum' || lowerName === 'arb') { chain = chains.find(c => c.chainId === 42161 || c.name.toLowerCase().includes('arbitrum one')); } else if (lowerName === 'avalanche' || lowerName === 'avax') { chain = chains.find(c => c.chainId === 43114 || c.name.toLowerCase().includes('avalanche')); } else if (lowerName === 'bsc' || lowerName === 'binance') { chain = chains.find(c => c.chainId === 56 || c.name.toLowerCase().includes('binance smart chain')); } else { // Try partial name matching chain = chains.find(c => c.name.toLowerCase().includes(lowerName)); } } if (!chain) { return []; } const rpcs = chain.rpc .map(rpcEntry => { let rpcUrl; let tracking; let isOpenSource; if (typeof rpcEntry === 'string') { rpcUrl = rpcEntry; } else { rpcUrl = rpcEntry.url; tracking = rpcEntry.tracking; isOpenSource = rpcEntry.isOpenSource; } if (rpcUrl.startsWith('http')) { // Validate RPC URL for security if (!this.validateRpcUrl(rpcUrl)) { return null; // Skip invalid/unsafe URLs } const rpcInfo = { chain_id: chain.chainId, chain_name: chain.name, short_name: chain.shortName, rpc_url: rpcUrl, environment, tracking: tracking, isOpenSource, status: this.rpcStatusCache.get(rpcUrl)?.status || 'unknown', lastError: this.rpcStatusCache.get(rpcUrl)?.lastError, lastChecked: this.rpcStatusCache.get(rpcUrl)?.lastChecked, }; return rpcInfo; } return null; }) .filter((rpc) => rpc !== null); // Filter out permanently failed RPCs and sort by preference return rpcs .filter(rpc => rpc.status !== 'failed') .sort((a, b) => { // Prioritize working RPCs first if (a.status === 'working' && b.status !== 'working') return -1; if (b.status === 'working' && a.status !== 'working') return 1; // Among working RPCs, sort by most recent success if (a.status === 'working' && b.status === 'working') { return (b.lastSuccess || 0) - (a.lastSuccess || 0); } // For non-working RPCs, prioritize by tracking and open source if (a.tracking === 'none' && b.tracking !== 'none') return -1; if (b.tracking === 'none' && a.tracking !== 'none') return 1; if (a.isOpenSource && !b.isOpenSource) return -1; if (b.isOpenSource && !a.isOpenSource) return 1; return 0; }); } getCacheStats() { return { size: this.rpcCache.size, keys: Array.from(this.rpcCache.keys()), }; } getRpcStatusCacheStats() { const failed = []; const working = []; this.rpcStatusCache.forEach((info, url) => { if (info.status === 'failed') { failed.push(url); } else if (info.status === 'working') { working.push(url); } }); return { size: this.rpcStatusCache.size, failed, working }; } clearCache() { this.rpcCache.clear(); this.chainsCache = null; this.chainlistCache = null; this.rpcStatusCache.clear(); } markRpcStatus(rpcUrl, status, error) { const existing = this.rpcStatusCache.get(rpcUrl) || { chain_id: 0, chain_name: '', short_name: '', rpc_url: rpcUrl, environment: 'mainnet' }; this.rpcStatusCache.set(rpcUrl, { ...existing, status, lastError: error, lastChecked: Date.now(), lastSuccess: status === 'working' ? Date.now() : existing.lastSuccess }); } isAuthError(error) { const networkError = error; const message = (networkError?.message && typeof networkError.message === 'string') ? networkError.message.toLowerCase() : ''; const statusCode = networkError?.response?.status; return (statusCode === 401 || statusCode === 403 || message.includes('unauthorized') || message.includes('forbidden') || message.includes('api key') || message.includes('invalid key') || message.includes('authentication')); } isConnectionError(error) { const networkError = error; const message = (networkError?.message && typeof networkError.message === 'string') ? networkError.message.toLowerCase() : ''; const code = networkError?.code; return (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || message.includes('network error') || message.includes('connect timeout') || message.includes('dns') || message.includes('enotfound') || message.includes('econnrefused')); } isRpcError(error) { const networkError = error; const message = (networkError?.message && typeof networkError.message === 'string') ? networkError.message.toLowerCase() : ''; // Check for RPC errors that indicate incompatibility return (message.includes('invalid params') || message.includes('invalid parameters') || message.includes('method not found') || message.includes('parse error') || message.includes('internal error') || message.includes('rpc error')); } async executeWithRetry(chainName, method, params, rpcCall) { const rpcs = await this.getChainRpcs(chainName, 'mainnet'); const attemptedRpcs = []; const failedRpcs = []; if (rpcs.length === 0) { return { success: false, error: `No RPCs available for chain: ${chainName}`, attemptedRpcs, failedRpcs }; } // Try all available RPCs until one succeeds for (let i = 0; i < rpcs.length; i++) { const rpc = rpcs[i]; attemptedRpcs.push(rpc.rpc_url); try { const result = await rpcCall(rpc.rpc_url); this.markRpcStatus(rpc.rpc_url, 'working'); return { success: true, data: result, usedRpc: rpc.rpc_url, attemptedRpcs, failedRpcs }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); // Mark as permanently failed for auth/DNS/RPC issues if (this.isAuthError(error) || this.isConnectionError(error) || this.isRpcError(error)) { this.markRpcStatus(rpc.rpc_url, 'failed', errorMessage); failedRpcs.push(rpc.rpc_url); } // Continue to next RPC if (i === rpcs.length - 1) { return { success: false, error: `All ${rpcs.length} RPC attempts failed. Last error: ${errorMessage}`, attemptedRpcs, failedRpcs }; } } } return { success: false, error: 'Unexpected retry loop exit', attemptedRpcs, failedRpcs }; } async getChainInfoById(chainId, _environment) { const chains = await this.fetchChains(); const chain = chains.find(c => c.chainId === chainId); if (!chain) { throw new Error(`Chain with ID ${chainId} not found`); } return { name: chain.name, shortName: chain.shortName }; } }