@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
JavaScript
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
};
}
}