From 12cc2ffceaf6b3a8af723939f44b62f9748f1e8a Mon Sep 17 00:00:00 2001 From: Johnaverse Date: Tue, 12 May 2026 08:49:13 -0400 Subject: [PATCH 1/3] Add native-token USD prices and execution-client fingerprinting Two new product features sharing the existing RPC monitor pipeline: - Native-token prices: `/chains` and `/chains/:id` now include a `price: { usd, updatedAt } | null` field sourced from CoinGecko. New `priceService.js` keys its cache by CoinGecko coinId (so sibling chains like all ETH-L2s share a single entry), coalesces concurrent fetches, applies a 3s timeout per request, negatively caches missing IDs with a short TTL, and is prefetched on startup so the first `/chains` call doesn't pay a round-trip on the hot path. - Client diversity: each working RPC endpoint's `web3_clientVersion` is parsed into structured metadata (name, version, repo, language, layer). New `/clients` and `/clients/:id` endpoints expose per-chain client summaries with node counts and version breakdowns. `/rpc-monitor/:id` also gains a `clients` array. Both surfaces are exposed as MCP tools (`get_clients`) and the get_chains/get_chain_by_id MCP responses are price-enriched. Adds env vars PRICE_CACHE_TTL_MS, PRICE_NEGATIVE_CACHE_TTL_MS, PRICE_FETCH_TIMEOUT_MS. Includes unit + integration tests covering parser edge cases, cache/coalescing behavior, and the new routes. --- clientParser.js | 80 ++++++++++++ clientRegistry.js | 44 +++++++ config.js | 5 + index.js | 65 ++++++++-- mcp-tools.js | 56 ++++++++- priceService.js | 212 ++++++++++++++++++++++++++++++++ public/app.js | 67 +++++----- rpcMonitor.js | 108 ++++++++++++++++ tests/integration/api.test.js | 175 +++++++++++++++++++++++++- tests/unit/clientParser.test.js | 141 +++++++++++++++++++++ tests/unit/mcp-tools.test.js | 124 ++++++++++++++++++- tests/unit/priceService.test.js | 189 ++++++++++++++++++++++++++++ tests/unit/rpcMonitor.test.js | 195 ++++++++++++++++++++++++++++- 13 files changed, 1415 insertions(+), 46 deletions(-) create mode 100644 clientParser.js create mode 100644 clientRegistry.js create mode 100644 priceService.js create mode 100644 tests/unit/clientParser.test.js create mode 100644 tests/unit/priceService.test.js diff --git a/clientParser.js b/clientParser.js new file mode 100644 index 0000000..b6f8ad7 --- /dev/null +++ b/clientParser.js @@ -0,0 +1,80 @@ +import { lookupClient } from './clientRegistry.js'; + +/** + * Parse a `web3_clientVersion` response into structured client metadata. + * + * Client strings follow a loose `name/version/os/runtime` convention: + * - "Geth/v1.14.5-stable-xxx/linux-amd64/go1.22.5" + * - "erigon/v2.60.0/linux-amd64/go1.22.5" + * - "besu/v24.5.1/linux-x86_64/openjdk-java-21" + * - "Nethermind/v1.26.0+xyz" + * - "reth/v1.0.0-xxx" + * + * Returns null for empty / sentinel values ("unavailable", "") so callers can + * distinguish "no data" from "data we couldn't recognize". + * + * @param {string|null|undefined} raw + * @returns {{ + * raw: string, + * name: string, + * version: string|null, + * os: string|null, + * runtime: string|null, + * repo: string|null, + * language: string|null, + * website: string|null, + * layer: string|null, + * known: boolean + * } | null} + */ +export function parseClientVersion(raw) { + if (typeof raw !== 'string') return null; + + const trimmed = raw.trim(); + if (!trimmed || trimmed.toLowerCase() === 'unavailable') return null; + + // Split on '/' but DO NOT collapse empty segments — doing so would shift + // later segments into earlier slots (e.g. "geth//linux" would mis-report + // "linux" as the version). Preserve positional meaning instead. + const parts = trimmed.split('/').map(p => p.trim()); + const nameSegment = parts[0]; + if (!nameSegment) return null; + + const name = normalizeName(nameSegment); + const version = parts[1] ? normalizeVersion(parts[1]) : null; + const os = parts[2] || null; + const runtime = parts[3] || null; + + const meta = lookupClient(name); + + return { + raw: trimmed, + name, + version, + os, + runtime, + repo: meta?.repo ?? null, + language: meta?.language ?? null, + website: meta?.website ?? null, + layer: meta?.layer ?? null, + known: meta !== null + }; +} + +/** + * Normalize a client name segment to the lowercase form used as registry key. + * Strips surrounding whitespace and any trailing build suffix after a space. + */ +function normalizeName(segment) { + return segment.split(/\s+/)[0].toLowerCase(); +} + +/** + * Extract the semver portion of a version segment, preserving an optional `v` + * prefix and pre-release/build metadata. Some clients emit extra descriptors + * (e.g. "v1.26.0+commit.abc"); we keep them intact — the raw string is still + * available via `raw`. + */ +function normalizeVersion(segment) { + return segment.trim(); +} diff --git a/clientRegistry.js b/clientRegistry.js new file mode 100644 index 0000000..e1c703f --- /dev/null +++ b/clientRegistry.js @@ -0,0 +1,44 @@ +// Curated registry of known blockchain client software. +// Key is the lowercased, hyphen-free client name as it appears in +// `web3_clientVersion` responses. Add new entries as they're observed in +// production — unknown names still round-trip through the parser with `repo: null`. + +export const CLIENT_REGISTRY = { + // Ethereum execution clients + geth: { repo: 'ethereum/go-ethereum', language: 'Go', website: 'https://geth.ethereum.org', layer: 'execution' }, + erigon: { repo: 'erigontech/erigon', language: 'Go', website: 'https://erigon.tech', layer: 'execution' }, + nethermind: { repo: 'NethermindEth/nethermind', language: 'C#', website: 'https://nethermind.io', layer: 'execution' }, + besu: { repo: 'hyperledger/besu', language: 'Java', website: 'https://besu.hyperledger.org', layer: 'execution' }, + reth: { repo: 'paradigmxyz/reth', language: 'Rust', website: 'https://reth.rs', layer: 'execution' }, + + // Ethereum consensus (beacon) clients — mostly reported via a different RPC surface, + // but listed here so beacon endpoints can be resolved when discovered. + lighthouse: { repo: 'sigp/lighthouse', language: 'Rust', website: 'https://lighthouse.sigmaprime.io', layer: 'consensus' }, + prysm: { repo: 'OffchainLabs/prysm', language: 'Go', website: 'https://prysmaticlabs.com', layer: 'consensus' }, + teku: { repo: 'Consensys/teku', language: 'Java', website: 'https://consensys.io/teku', layer: 'consensus' }, + nimbus: { repo: 'status-im/nimbus-eth2', language: 'Nim', website: 'https://nimbus.guide', layer: 'consensus' }, + lodestar: { repo: 'ChainSafe/lodestar', language: 'TypeScript', website: 'https://lodestar.chainsafe.io', layer: 'consensus' }, + + // L2 / alt-EVM clients + bor: { repo: 'maticnetwork/bor', language: 'Go', website: 'https://polygon.technology', layer: 'execution' }, + 'op-geth': { repo: 'ethereum-optimism/op-geth', language: 'Go', website: 'https://optimism.io', layer: 'execution' }, + // op-reth ships as a binary inside the reth monorepo; no separate repo. + 'op-reth': { repo: 'paradigmxyz/reth', language: 'Rust', website: 'https://reth.rs', layer: 'execution' }, + + // Non-EVM chains that still respond to JSON-RPC health probes + parity: { repo: 'openethereum/openethereum', language: 'Rust', website: null, layer: 'execution', deprecated: true }, + openethereum: { repo: 'openethereum/openethereum', language: 'Rust', website: null, layer: 'execution', deprecated: true } +}; + +/** + * Look up registry metadata for a parsed client name. + * Matches are case-insensitive; returns null when the name is unknown. + * + * @param {string} name Normalized client name (e.g. "geth", "erigon") + * @returns {{ repo: string|null, language: string|null, website: string|null, layer: string|null, deprecated?: boolean }|null} + */ +export function lookupClient(name) { + if (!name || typeof name !== 'string') return null; + const key = name.toLowerCase(); + return CLIENT_REGISTRY[key] ?? null; +} diff --git a/config.js b/config.js index 93abf01..3b2124f 100644 --- a/config.js +++ b/config.js @@ -80,3 +80,8 @@ export const CORS_ORIGIN = parseStringEnv('CORS_ORIGIN', '*'); // Proxy (optional) export const PROXY_URL = parseStringEnv('PROXY_URL', ''); export const PROXY_ENABLED = PROXY_URL !== ''; + +// Price cache +export const PRICE_CACHE_TTL_MS = parseIntEnv('PRICE_CACHE_TTL_MS', 3600000); +export const PRICE_NEGATIVE_CACHE_TTL_MS = parseIntEnv('PRICE_NEGATIVE_CACHE_TTL_MS', 300000); +export const PRICE_FETCH_TIMEOUT_MS = parseIntEnv('PRICE_FETCH_TIMEOUT_MS', 3000); diff --git a/index.js b/index.js index 0d92267..155bb73 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,8 @@ import helmet from '@fastify/helmet'; import { readFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations } from './dataService.js'; -import { getMonitoringResults, getMonitoringStatus, startRpcHealthCheck } from './rpcMonitor.js'; +import { getMonitoringResults, getMonitoringStatus, startRpcHealthCheck, getClientsByChain, summarizeChainClients } from './rpcMonitor.js'; +import { getPricesForChains, getPriceForChain, prefetchAllPrices } from './priceService.js'; import { PORT, HOST, BODY_LIMIT, MAX_PARAM_LENGTH, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, @@ -70,6 +71,11 @@ export async function buildApp(options = {}) { } }); startRpcHealthCheck(); + // Warm the price cache in the background so the first /chains request + // doesn't pay a CoinGecko round-trip. Failures are silent. + prefetchAllPrices().catch(err => { + console.warn(`Initial price prefetch failed: ${err.message}`); + }); } /** @@ -101,9 +107,16 @@ export async function buildApp(options = {}) { chains = chains.filter(chain => chain.tags?.includes(tag)); } + const chainIds = chains.map(c => c.chainId); + const priceMap = await getPricesForChains(chainIds); + const enrichedChains = chains.map(chain => ({ + ...chain, + price: priceMap.get(chain.chainId) ?? null + })); + return { - count: chains.length, - chains + count: enrichedChains.length, + chains: enrichedChains }; }); @@ -121,7 +134,8 @@ export async function buildApp(options = {}) { return sendError(reply, 404, 'Chain not found'); } - return chain; + const price = await getPriceForChain(chainId); + return { ...chain, price }; }); /** @@ -392,8 +406,12 @@ export async function buildApp(options = {}) { return sendError(reply, 404, 'No monitoring results found for this chain'); } - const workingCount = chainResults.filter(r => r.status === 'working').length; - const failedCount = chainResults.filter(r => r.status === 'failed').length; + let workingCount = 0; + let failedCount = 0; + for (const r of chainResults) { + if (r.status === 'working') workingCount++; + else if (r.status === 'failed') failedCount++; + } return { chainId, @@ -402,10 +420,41 @@ export async function buildApp(options = {}) { workingEndpoints: workingCount, failedEndpoints: failedCount, lastUpdated: results.lastUpdated, - endpoints: chainResults + endpoints: chainResults, + clients: summarizeChainClients(chainResults)?.clients ?? [] + }; + }); + + /** + * Get aggregated client software across all chains + */ + fastify.get('/clients', async () => { + const results = getMonitoringResults(); + const chains = getClientsByChain(); + return { + lastUpdated: results.lastUpdated, + count: chains.length, + chains }; }); + /** + * Get client software for a specific chain + */ + fastify.get('/clients/:id', async (request, reply) => { + const chainId = parseIntParam(request.params.id); + if (chainId === null) { + return sendError(reply, 400, 'Invalid chain ID'); + } + + const summary = getClientsByChain(chainId); + if (!summary) { + return sendError(reply, 404, 'No client data found for this chain'); + } + + return summary; + }); + /** * Get aggregate stats */ @@ -467,6 +516,8 @@ export async function buildApp(options = {}) { '/keywords': 'Get extracted keywords (blockchain names, network names, client names, etc.)', '/rpc-monitor': 'Get RPC endpoint monitoring results', '/rpc-monitor/:id': 'Get RPC monitoring results for a specific chain by ID', + '/clients': 'Get aggregated client software (name, version, GitHub repo) across all chains', + '/clients/:id': 'Get client software running on a specific chain by ID', '/stats': 'Get aggregate stats (chain counts, RPC health percentage)', '/relations/:id/graph?depth=N': 'BFS graph traversal of chain relations (default depth: 2)' }, diff --git a/mcp-tools.js b/mcp-tools.js index a720e48..d5725a5 100644 --- a/mcp-tools.js +++ b/mcp-tools.js @@ -11,7 +11,8 @@ import { validateChainData, traverseRelations, } from './dataService.js'; -import { getMonitoringResults, getMonitoringStatus } from './rpcMonitor.js'; +import { getMonitoringResults, getMonitoringStatus, getClientsByChain } from './rpcMonitor.js'; +import { getPricesForChains, getPriceForChain } from './priceService.js'; /** * Get the list of MCP tool definitions (schemas) @@ -172,6 +173,19 @@ export function getToolDefinitions() { required: ['chainId'], }, }, + { + name: 'get_clients', + description: 'Get execution client software (name, version, GitHub repo, language) running on a chain, aggregated from live RPC endpoints. Omit chainId to get a summary across all chains.', + inputSchema: { + type: 'object', + properties: { + chainId: { + type: 'number', + description: 'Optional chain ID. If provided, returns clients for that chain only. If omitted, returns a summary across all chains with monitoring data.', + }, + }, + }, + }, ]; } @@ -197,15 +211,21 @@ function isValidChainId(chainId) { // --- Individual tool handlers --- -function handleGetChains(args) { +async function handleGetChains(args) { let chains = getAllChains(); if (args.tag) { chains = chains.filter((chain) => chain.tags?.includes(args.tag)); } - return textResponse({ count: chains.length, chains }); + const chainIds = chains.map((c) => c.chainId); + const priceMap = await getPricesForChains(chainIds); + const enrichedChains = chains.map((chain) => ({ + ...chain, + price: priceMap.get(chain.chainId) ?? null, + })); + return textResponse({ count: enrichedChains.length, chains: enrichedChains }); } -function handleGetChainById(args) { +async function handleGetChainById(args) { const { chainId } = args; if (!isValidChainId(chainId)) { return errorResponse('Invalid chain ID'); @@ -214,7 +234,8 @@ function handleGetChainById(args) { if (!chain) { return errorResponse('Chain not found'); } - return textResponse(chain); + const price = await getPriceForChain(chainId); + return textResponse({ ...chain, price }); } function handleSearchChains(args) { @@ -443,6 +464,28 @@ function handleGetRpcMonitorById(args) { return { content: [{ type: 'text', text: lines.join('\n') }] }; } +function handleGetClients(args) { + if (args.chainId === undefined) { + const results = getMonitoringResults(); + const chains = getClientsByChain(); + return textResponse({ + lastUpdated: results.lastUpdated, + count: chains.length, + chains, + }); + } + + const { chainId } = args; + if (!isValidChainId(chainId)) { + return errorResponse('Invalid chain ID'); + } + const summary = getClientsByChain(chainId); + if (!summary) { + return errorResponse('No client data found for this chain'); + } + return textResponse(summary); +} + // --- Dispatch map --- const toolHandlers = { @@ -459,6 +502,7 @@ const toolHandlers = { traverse_relations: handleTraverseRelations, get_rpc_monitor: handleGetRpcMonitor, get_rpc_monitor_by_id: handleGetRpcMonitorById, + get_clients: handleGetClients, }; /** @@ -473,7 +517,7 @@ export async function handleToolCall(name, args) { if (!handler) { return errorResponse(`Unknown tool: ${name}`); } - return handler(args); + return await handler(args); } catch (error) { return errorResponse('Internal error', error.message); } diff --git a/priceService.js b/priceService.js new file mode 100644 index 0000000..f6e94dd --- /dev/null +++ b/priceService.js @@ -0,0 +1,212 @@ +import { proxyFetch } from './fetchUtil.js'; +import { + PRICE_CACHE_TTL_MS, + PRICE_NEGATIVE_CACHE_TTL_MS, + PRICE_FETCH_TIMEOUT_MS, +} from './config.js'; + +const CHAIN_ID_TO_COINGECKO_ID = { + 1: 'ethereum', + 10: 'ethereum', + 25: 'crypto-com-chain', + 56: 'binancecoin', + 66: 'oec-token', + 100: 'xdai', + 137: 'matic-network', + 250: 'fantom', + 288: 'ethereum', + 324: 'ethereum', + 1088: 'metis-token', + 1284: 'moonbeam', + 1285: 'moonriver', + 2222: 'kava', + 5000: 'mantle', + 7700: 'canto', + 8217: 'kaia', + 8453: 'ethereum', + 9001: 'evmos', + 42161: 'ethereum', + 42170: 'ethereum', + 42220: 'celo', + 43114: 'avalanche-2', + 59144: 'ethereum', + 81457: 'ethereum', + 534352: 'ethereum', + 1313161554: 'ethereum', + 1666600000: 'harmony', +}; + +const COINGECKO_PRICE_URL = 'https://api.coingecko.com/api/v3/simple/price'; + +// Cache keyed by coinId so sibling chains share a single entry naturally. +// Value: { usd: number, updatedAt: string } for hits, +// { usd: null, updatedAt: string } for negative entries (short TTL). +const priceCache = new Map(); + +// Coalesce concurrent fetches: one in-flight promise per coinId. +const inflight = new Map(); + +export function getCoinGeckoId(chainId) { + return CHAIN_ID_TO_COINGECKO_ID[chainId] ?? null; +} + +function isFresh(entry) { + if (!entry) return false; + const age = Date.now() - new Date(entry.updatedAt).getTime(); + const ttl = entry.usd === null ? PRICE_NEGATIVE_CACHE_TTL_MS : PRICE_CACHE_TTL_MS; + return age <= ttl; +} + +function getCachedByCoinId(coinId) { + const entry = priceCache.get(coinId); + return isFresh(entry) ? entry : null; +} + +function toPublic(entry) { + if (!entry || entry.usd === null) return null; + return { usd: entry.usd, updatedAt: entry.updatedAt }; +} + +async function fetchWithTimeout(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PRICE_FETCH_TIMEOUT_MS); + try { + return await proxyFetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function fetchCoinIds(coinIds) { + if (coinIds.length === 0) return new Map(); + + // Coalesce: for each coinId, reuse an in-flight promise if one exists. + // Otherwise schedule the missing IDs in a single batched request. + const result = new Map(); + const toFetch = []; + const waiters = []; + + for (const id of coinIds) { + const pending = inflight.get(id); + if (pending) { + waiters.push(pending.then(map => [id, map.get(id)])); + } else { + toFetch.push(id); + } + } + + if (toFetch.length > 0) { + const batchPromise = (async () => { + const map = new Map(); + const url = `${COINGECKO_PRICE_URL}?ids=${toFetch.join(',')}&vs_currencies=usd`; + try { + const response = await fetchWithTimeout(url); + if (response.ok) { + const data = await response.json(); + for (const [id, prices] of Object.entries(data)) { + if (typeof prices?.usd === 'number') map.set(id, prices.usd); + } + } else { + console.warn(`CoinGecko price fetch failed: HTTP ${response.status}`); + } + } catch (err) { + console.warn(`CoinGecko price fetch error: ${err.message}`); + } + return map; + })(); + + for (const id of toFetch) { + inflight.set(id, batchPromise); + } + try { + const map = await batchPromise; + for (const id of toFetch) { + if (map.has(id)) result.set(id, map.get(id)); + } + } finally { + for (const id of toFetch) inflight.delete(id); + } + } + + for (const [id, usd] of await Promise.all(waiters.map(p => p))) { + if (usd !== undefined) result.set(id, usd); + } + + return result; +} + +function recordResults(coinIds, fetched) { + const updatedAt = new Date().toISOString(); + for (const id of coinIds) { + if (fetched.has(id)) { + priceCache.set(id, { usd: fetched.get(id), updatedAt }); + } else { + // Negative cache: short TTL so we don't hammer CoinGecko on every request + // when an ID is missing or temporarily unavailable. + priceCache.set(id, { usd: null, updatedAt }); + } + } +} + +export async function getPriceForChain(chainId) { + const coinId = getCoinGeckoId(chainId); + if (!coinId) return null; + + const cached = getCachedByCoinId(coinId); + if (cached) return toPublic(cached); + + const fetched = await fetchCoinIds([coinId]); + recordResults([coinId], fetched); + return toPublic(priceCache.get(coinId)); +} + +export async function getPricesForChains(chainIds) { + const result = new Map(); + const wantedCoinIds = new Set(); + const chainToCoin = new Map(); + + for (const chainId of chainIds) { + const coinId = getCoinGeckoId(chainId); + if (!coinId) { + result.set(chainId, null); + continue; + } + chainToCoin.set(chainId, coinId); + const cached = getCachedByCoinId(coinId); + if (cached) { + result.set(chainId, toPublic(cached)); + } else { + wantedCoinIds.add(coinId); + } + } + + if (wantedCoinIds.size > 0) { + const coinIds = [...wantedCoinIds]; + const fetched = await fetchCoinIds(coinIds); + recordResults(coinIds, fetched); + } + + for (const [chainId, coinId] of chainToCoin) { + if (result.has(chainId)) continue; + result.set(chainId, toPublic(priceCache.get(coinId))); + } + + return result; +} + +/** + * Warm the cache for all chainIds with a known CoinGecko mapping. + * Intended to be called once after data load so the first /chains request + * doesn't pay a CoinGecko round-trip on the hot path. Failures are silent — + * a cold cache falls back to per-request fetching with the same timeout. + */ +export async function prefetchAllPrices() { + const coinIds = [...new Set(Object.values(CHAIN_ID_TO_COINGECKO_ID))]; + const fetched = await fetchCoinIds(coinIds); + recordResults(coinIds, fetched); +} + +export function clearPriceCache() { + priceCache.clear(); + inflight.clear(); +} diff --git a/public/app.js b/public/app.js index 0b5837b..d74452b 100644 --- a/public/app.js +++ b/public/app.js @@ -583,18 +583,7 @@ function getStatusClass(status) { return ''; } -function showNodeDetails(node) { - const panel = document.getElementById('detailsPanel'); - const data = node.data; - - const iconElem = document.getElementById('chainIcon'); - iconElem.textContent = node.name ? node.name.charAt(0).toUpperCase() : '?'; - iconElem.style.background = `linear-gradient(135deg, ${node.color}, ${node.color}33)`; - - document.getElementById('chainName').textContent = node.name || 'Unknown Chain'; - document.getElementById('chainIdBadge').textContent = `ID: ${data.chainId}`; - - // Status badge +function showStatusBadge(data) { const statusBadge = document.getElementById('chainStatusBadge'); if (data.status) { statusBadge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1); @@ -603,7 +592,9 @@ function showNodeDetails(node) { } else { statusBadge.style.display = 'none'; } +} +function showTagsBadge(data) { const tagsElem = document.getElementById('chainTags'); if (data.tags?.length > 0) { tagsElem.textContent = data.tags.join(', '); @@ -611,6 +602,40 @@ function showNodeDetails(node) { } else { tagsElem.style.display = 'none'; } +} + +function showWebsite(data) { + const webElem = document.getElementById('chainWebsite'); + if (data.infoURL) { + try { + const a = document.createElement('a'); + a.href = data.infoURL; + a.target = "_blank"; + a.rel = "noopener"; + a.textContent = new URL(data.infoURL).hostname; + webElem.textContent = ''; + webElem.appendChild(a); + } catch { + webElem.textContent = data.infoURL; + } + } else { + webElem.textContent = 'None available'; + } +} + +function showNodeDetails(node) { + const panel = document.getElementById('detailsPanel'); + const data = node.data; + + const iconElem = document.getElementById('chainIcon'); + iconElem.textContent = node.name ? node.name.charAt(0).toUpperCase() : '?'; + iconElem.style.background = `linear-gradient(135deg, ${node.color}, ${node.color}33)`; + + document.getElementById('chainName').textContent = node.name || 'Unknown Chain'; + document.getElementById('chainIdBadge').textContent = `ID: ${data.chainId}`; + + showStatusBadge(data); + showTagsBadge(data); const curElem = document.getElementById('chainCurrency'); curElem.textContent = data.nativeCurrency @@ -637,23 +662,7 @@ function showNodeDetails(node) { showRpcEndpoints(data); showExplorers(data); - - const webElem = document.getElementById('chainWebsite'); - if (data.infoURL) { - try { - const a = document.createElement('a'); - a.href = data.infoURL; - a.target = "_blank"; - a.rel = "noopener"; - a.textContent = new URL(data.infoURL).hostname; - webElem.textContent = ''; - webElem.appendChild(a); - } catch { - webElem.textContent = data.infoURL; - } - } else { - webElem.textContent = 'None available'; - } + showWebsite(data); panel.classList.remove('hidden'); } diff --git a/rpcMonitor.js b/rpcMonitor.js index ff8d97e..b7bdb3a 100644 --- a/rpcMonitor.js +++ b/rpcMonitor.js @@ -1,6 +1,7 @@ import { getAllEndpoints } from './dataService.js'; import { MAX_ENDPOINTS_PER_CHAIN, RPC_CHECK_CONCURRENCY } from './config.js'; import { jsonRpcCall } from './rpcUtil.js'; +import { parseClientVersion } from './clientParser.js'; // Store monitoring results in memory let monitoringResults = { @@ -62,6 +63,7 @@ async function testRpcEndpoint(url) { url: url, status: 'unknown', clientVersion: null, + client: null, blockNumber: null, latencyMs: null, error: null, @@ -75,6 +77,7 @@ async function testRpcEndpoint(url) { try { const clientVersion = await jsonRpcCall(url, 'web3_clientVersion'); result.clientVersion = clientVersion; + result.client = parseClientVersion(clientVersion); } catch (clientVersionError) { console.debug(`web3_clientVersion not supported for ${url}: ${clientVersionError.message}`); result.clientVersion = 'unavailable'; @@ -232,6 +235,111 @@ export function getMonitoringResults() { return monitoringResults; } +/** + * Aggregate parsed client software across working RPC endpoints. + * + * When `chainId` is provided, returns a single summary object for that chain + * (or null if no monitoring data exists for it). When omitted, returns an + * array of summaries — one per chain with at least one working endpoint that + * reported a parseable `client`. + * + * Each summary: + * { + * chainId, chainName, + * totalNodes, // working endpoints considered + * unknownNodes, // working endpoints whose client didn't parse + * clients: [ + * { name, repo, language, website, layer, known, + * nodeCount, versions: [{ version, nodeCount }, ...] } + * ] + * } + * + * @param {number} [chainId] + * @returns {object | object[] | null} + */ +export function getClientsByChain(chainId) { + const working = monitoringResults.results.filter(r => r.status === 'working'); + + if (chainId !== undefined) { + const chainResults = working.filter(r => r.chainId === chainId); + if (chainResults.length === 0) return null; + return summarizeChainClients(chainResults); + } + + const byChain = new Map(); + for (const r of working) { + if (!byChain.has(r.chainId)) byChain.set(r.chainId, []); + byChain.get(r.chainId).push(r); + } + + return Array.from(byChain.values()) + .map(summarizeChainClients) + .filter(Boolean); +} + +/** + * Build a per-chain client summary from a list of endpoint results. + * Non-working endpoints are ignored. Assumes all entries share the same chainId. + * Returns null if no working endpoints were supplied. + */ +export function summarizeChainClients(chainResults) { + chainResults = chainResults.filter(r => r.status === 'working'); + if (chainResults.length === 0) return null; + const { chainId, chainName } = chainResults[0]; + const byClient = new Map(); + let unknownNodes = 0; + + for (const r of chainResults) { + if (!r.client) { + unknownNodes++; + continue; + } + + const key = r.client.name; + let bucket = byClient.get(key); + if (!bucket) { + bucket = { + name: r.client.name, + repo: r.client.repo, + language: r.client.language, + website: r.client.website, + layer: r.client.layer, + known: r.client.known, + nodeCount: 0, + _versions: new Map() + }; + byClient.set(key, bucket); + } + bucket.nodeCount++; + + const v = r.client.version ?? 'unknown'; + bucket._versions.set(v, (bucket._versions.get(v) ?? 0) + 1); + } + + const clients = Array.from(byClient.values()) + .map(c => ({ + name: c.name, + repo: c.repo, + language: c.language, + website: c.website, + layer: c.layer, + known: c.known, + nodeCount: c.nodeCount, + versions: Array.from(c._versions.entries()) + .map(([version, nodeCount]) => ({ version, nodeCount })) + .sort((a, b) => b.nodeCount - a.nodeCount) + })) + .sort((a, b) => b.nodeCount - a.nodeCount); + + return { + chainId, + chainName, + totalNodes: chainResults.length, + unknownNodes, + clients + }; +} + /** * Get monitoring status */ diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js index 9e2ecd9..1145b65 100644 --- a/tests/integration/api.test.js +++ b/tests/integration/api.test.js @@ -8,6 +8,24 @@ vi.mock('node:fs/promises', () => ({ readFile: vi.fn() })); +// Mock priceService +vi.mock('../../priceService.js', () => ({ + getPricesForChains: vi.fn(async (chainIds) => { + const map = new Map(); + for (const id of chainIds) { + map.set(id, id === 1 ? { usd: 2000.5, updatedAt: '2026-05-01T00:00:00.000Z' } : null); + } + return map; + }), + getPriceForChain: vi.fn(async (chainId) => { + if (chainId === 1) return { usd: 2000.5, updatedAt: '2026-05-01T00:00:00.000Z' }; + return null; + }), + getCoinGeckoId: vi.fn(() => null), + clearPriceCache: vi.fn(), + prefetchAllPrices: vi.fn(async () => {}), +})); + // Mock the modules before importing vi.mock('../../dataService.js', async () => { const actual = await vi.importActual('../../dataService.js'); @@ -247,7 +265,71 @@ vi.mock('../../rpcMonitor.js', () => ({ isMonitoring: false, lastUpdated: new Date().toISOString() })), - startRpcHealthCheck: vi.fn() + startRpcHealthCheck: vi.fn(), + getClientsByChain: vi.fn((chainId) => { + const samples = { + 1: { + chainId: 1, + chainName: 'Ethereum Mainnet', + totalNodes: 2, + unknownNodes: 0, + clients: [ + { + name: 'geth', + repo: 'ethereum/go-ethereum', + language: 'Go', + website: 'https://geth.ethereum.org', + layer: 'execution', + known: true, + nodeCount: 2, + versions: [{ version: 'v1.14.5', nodeCount: 2 }] + } + ] + }, + 137: { + chainId: 137, + chainName: 'Polygon', + totalNodes: 1, + unknownNodes: 0, + clients: [ + { + name: 'bor', + repo: 'maticnetwork/bor', + language: 'Go', + website: 'https://polygon.technology', + layer: 'execution', + known: true, + nodeCount: 1, + versions: [{ version: 'v1.3.0', nodeCount: 1 }] + } + ] + } + }; + if (chainId === undefined) return Object.values(samples); + return samples[chainId] ?? null; + }), + summarizeChainClients: vi.fn((chainResults) => { + if (!chainResults || chainResults.length === 0) return null; + const chainId = chainResults[0].chainId; + return { + chainId, + chainName: chainResults[0].chainName, + totalNodes: chainResults.length, + unknownNodes: 0, + clients: [ + { + name: 'geth', + repo: 'ethereum/go-ethereum', + language: 'Go', + website: 'https://geth.ethereum.org', + layer: 'execution', + known: true, + nodeCount: chainResults.length, + versions: [{ version: 'v1.14.5', nodeCount: chainResults.length }] + } + ] + }; + }) })); describe('API Endpoints', () => { @@ -375,6 +457,25 @@ describe('API Endpoints', () => { expect(data).toHaveProperty('error'); expect(data.error).toContain('Invalid tag'); }); + + it('should include price field on each chain', async () => { + const response = await app.inject({ + method: 'GET', + url: '/chains' + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data.chains.length > 0).toBe(true); + data.chains.forEach(chain => { + expect(chain).toHaveProperty('price'); + }); + // Ethereum should have a real price, others should be null + const eth = data.chains.find(c => c.chainId === 1); + expect(eth.price).toEqual({ usd: 2000.5, updatedAt: '2026-05-01T00:00:00.000Z' }); + const polygon = data.chains.find(c => c.chainId === 137); + expect(polygon.price).toBeNull(); + }); }); describe('GET /chains/:id', () => { @@ -424,6 +525,19 @@ describe('API Endpoints', () => { const data = JSON.parse(response.payload); expect(data).toHaveProperty('chainId', 1); }); + + it('should include price field when known chain', async () => { + const response = await app.inject({ + method: 'GET', + url: '/chains/1' + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data).toHaveProperty('price'); + expect(data.price).toEqual({ usd: 2000.5, updatedAt: '2026-05-01T00:00:00.000Z' }); + }); + }); describe('GET /search', () => { @@ -848,6 +962,65 @@ describe('API Endpoints', () => { const data = JSON.parse(response.payload); expect(data).toHaveProperty('error', 'No monitoring results found for this chain'); }); + + it('should include clients summary in the response', async () => { + const response = await app.inject({ + method: 'GET', + url: '/rpc-monitor/1' + }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data).toHaveProperty('clients'); + expect(Array.isArray(data.clients)).toBe(true); + expect(data.clients[0]).toMatchObject({ name: 'geth', repo: 'ethereum/go-ethereum' }); + }); + }); + + describe('GET /clients', () => { + it('returns aggregated clients across all chains', async () => { + const response = await app.inject({ method: 'GET', url: '/clients' }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data).toHaveProperty('count', 2); + expect(data).toHaveProperty('chains'); + expect(Array.isArray(data.chains)).toBe(true); + expect(data.chains[0]).toHaveProperty('clients'); + }); + }); + + describe('GET /clients/:id', () => { + it('returns client summary for a known chain', async () => { + const response = await app.inject({ method: 'GET', url: '/clients/1' }); + + expect(response.statusCode).toBe(200); + const data = JSON.parse(response.payload); + expect(data).toMatchObject({ + chainId: 1, + chainName: 'Ethereum Mainnet', + totalNodes: 2 + }); + expect(data.clients[0]).toMatchObject({ + name: 'geth', + repo: 'ethereum/go-ethereum', + nodeCount: 2 + }); + }); + + it('returns 400 for invalid chain ID', async () => { + const response = await app.inject({ method: 'GET', url: '/clients/not-a-number' }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload)).toHaveProperty('error', 'Invalid chain ID'); + }); + + it('returns 404 when no client data exists for chain', async () => { + const response = await app.inject({ method: 'GET', url: '/clients/999999' }); + + expect(response.statusCode).toBe(404); + expect(JSON.parse(response.payload)).toHaveProperty('error', 'No client data found for this chain'); + }); }); describe('GET /validate', () => { diff --git a/tests/unit/clientParser.test.js b/tests/unit/clientParser.test.js new file mode 100644 index 0000000..eb301dd --- /dev/null +++ b/tests/unit/clientParser.test.js @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { parseClientVersion } from '../../clientParser.js'; + +describe('parseClientVersion', () => { + describe('null / empty inputs', () => { + it('returns null for null', () => { + expect(parseClientVersion(null)).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(parseClientVersion(undefined)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseClientVersion('')).toBeNull(); + }); + + it('returns null for whitespace-only string', () => { + expect(parseClientVersion(' ')).toBeNull(); + }); + + it('returns null for the "unavailable" sentinel', () => { + expect(parseClientVersion('unavailable')).toBeNull(); + expect(parseClientVersion('UNAVAILABLE')).toBeNull(); + }); + + it('returns null for non-string values', () => { + expect(parseClientVersion(42)).toBeNull(); + expect(parseClientVersion({})).toBeNull(); + expect(parseClientVersion([])).toBeNull(); + }); + }); + + describe('known clients', () => { + it('parses a full Geth version string', () => { + const result = parseClientVersion('Geth/v1.14.5-stable-xxx/linux-amd64/go1.22.5'); + expect(result).toMatchObject({ + name: 'geth', + version: 'v1.14.5-stable-xxx', + os: 'linux-amd64', + runtime: 'go1.22.5', + repo: 'ethereum/go-ethereum', + language: 'Go', + layer: 'execution', + known: true + }); + expect(result.raw).toBe('Geth/v1.14.5-stable-xxx/linux-amd64/go1.22.5'); + }); + + it('parses erigon', () => { + const result = parseClientVersion('erigon/v2.60.0/linux-amd64/go1.22.5'); + expect(result.name).toBe('erigon'); + expect(result.version).toBe('v2.60.0'); + expect(result.repo).toBe('erigontech/erigon'); + expect(result.known).toBe(true); + }); + + it('parses besu', () => { + const result = parseClientVersion('besu/v24.5.1/linux-x86_64/openjdk-java-21'); + expect(result.name).toBe('besu'); + expect(result.repo).toBe('hyperledger/besu'); + expect(result.language).toBe('Java'); + }); + + it('parses nethermind with build metadata in version', () => { + const result = parseClientVersion('Nethermind/v1.26.0+commit.abc123'); + expect(result.name).toBe('nethermind'); + expect(result.version).toBe('v1.26.0+commit.abc123'); + expect(result.os).toBeNull(); + expect(result.runtime).toBeNull(); + expect(result.known).toBe(true); + }); + + it('parses reth', () => { + const result = parseClientVersion('reth/v1.0.0-rc.1/x86_64-unknown-linux-gnu'); + expect(result.name).toBe('reth'); + expect(result.repo).toBe('paradigmxyz/reth'); + }); + + it('parses Polygon bor', () => { + const result = parseClientVersion('bor/v1.3.0/linux-amd64/go1.21.0'); + expect(result.name).toBe('bor'); + expect(result.repo).toBe('maticnetwork/bor'); + }); + }); + + describe('unknown clients', () => { + it('still parses structure and marks known=false', () => { + const result = parseClientVersion('some-new-client/v0.1.0/linux/rust1.75'); + expect(result).toMatchObject({ + name: 'some-new-client', + version: 'v0.1.0', + os: 'linux', + runtime: 'rust1.75', + repo: null, + language: null, + website: null, + layer: null, + known: false + }); + }); + + it('handles a single-segment unknown client', () => { + const result = parseClientVersion('mystery-node'); + expect(result).toMatchObject({ + name: 'mystery-node', + version: null, + os: null, + runtime: null, + known: false + }); + }); + }); + + describe('edge cases', () => { + it('normalizes name case', () => { + expect(parseClientVersion('GETH/v1.14.0').name).toBe('geth'); + expect(parseClientVersion('Erigon/v2.60.0').name).toBe('erigon'); + }); + + it('trims surrounding whitespace', () => { + const result = parseClientVersion(' geth/v1.14.0 '); + expect(result.name).toBe('geth'); + expect(result.version).toBe('v1.14.0'); + }); + + it('preserves segment positions on doubled slashes (version stays null)', () => { + // Doubled slashes must NOT shift later segments into the version slot — + // that would silently mis-label OS strings as versions. + const result = parseClientVersion('geth//linux-amd64'); + expect(result.name).toBe('geth'); + expect(result.version).toBeNull(); + expect(result.os).toBe('linux-amd64'); + }); + + it('strips trailing whitespace suffix from name segment', () => { + const result = parseClientVersion('geth node/v1.14.0'); + expect(result.name).toBe('geth'); + }); + }); +}); diff --git a/tests/unit/mcp-tools.test.js b/tests/unit/mcp-tools.test.js index cb36d67..a2e7f4a 100644 --- a/tests/unit/mcp-tools.test.js +++ b/tests/unit/mcp-tools.test.js @@ -51,10 +51,24 @@ vi.mock('../../rpcMonitor.js', () => ({ isMonitoring: false, lastUpdated: null, })), + getClientsByChain: vi.fn(() => null), +})); + +// Mock priceService before importing +vi.mock('../../priceService.js', () => ({ + getPricesForChains: vi.fn(async (chainIds) => { + const map = new Map(); + for (const id of chainIds) map.set(id, null); + return map; + }), + getPriceForChain: vi.fn(async () => null), + getCoinGeckoId: vi.fn(() => null), + clearPriceCache: vi.fn(), })); import * as dataService from '../../dataService.js'; import * as rpcMonitor from '../../rpcMonitor.js'; +import * as priceService from '../../priceService.js'; import { getToolDefinitions, handleToolCall } from '../../mcp-tools.js'; describe('MCP Tools - Shared Module', () => { @@ -109,10 +123,10 @@ describe('MCP Tools - Shared Module', () => { }); describe('getToolDefinitions', () => { - it('should return an array of 13 tools', () => { + it('should return an array of 14 tools', () => { const tools = getToolDefinitions(); expect(Array.isArray(tools)).toBe(true); - expect(tools.length).toBe(13); + expect(tools.length).toBe(14); }); it('should include all expected tool names', () => { @@ -131,6 +145,7 @@ describe('MCP Tools - Shared Module', () => { expect(names).toContain('traverse_relations'); expect(names).toContain('get_rpc_monitor'); expect(names).toContain('get_rpc_monitor_by_id'); + expect(names).toContain('get_clients'); }); it('should require chainId for traverse_relations', () => { @@ -206,6 +221,31 @@ describe('MCP Tools - Shared Module', () => { expect(data.count).toBe(0); expect(data.chains).toEqual([]); }); + + it('should include price field on each chain', async () => { + vi.mocked(dataService.getAllChains).mockReturnValue([ + { chainId: 1, name: 'Ethereum', tags: [] }, + ]); + vi.mocked(priceService.getPricesForChains).mockResolvedValue( + new Map([[1, { usd: 2000.5, updatedAt: '2026-05-01T00:00:00.000Z' }]]) + ); + const result = await handleToolCall('get_chains', {}); + const data = JSON.parse(result.content[0].text); + expect(data.chains[0]).toHaveProperty('price'); + expect(data.chains[0].price).toEqual({ usd: 2000.5, updatedAt: '2026-05-01T00:00:00.000Z' }); + }); + + it('should set price: null for unknown chains', async () => { + vi.mocked(dataService.getAllChains).mockReturnValue([ + { chainId: 99999, name: 'Unknown Chain', tags: [] }, + ]); + vi.mocked(priceService.getPricesForChains).mockResolvedValue( + new Map([[99999, null]]) + ); + const result = await handleToolCall('get_chains', {}); + const data = JSON.parse(result.content[0].text); + expect(data.chains[0].price).toBeNull(); + }); }); describe('handleToolCall - get_chain_by_id', () => { @@ -242,6 +282,30 @@ describe('MCP Tools - Shared Module', () => { const data = JSON.parse(result.content[0].text); expect(data.error).toBe('Chain not found'); }); + + it('should include price when CoinGecko returns data', async () => { + vi.mocked(dataService.getChainById).mockReturnValue({ + chainId: 1, name: 'Ethereum', nativeCurrency: { symbol: 'ETH' }, + }); + vi.mocked(priceService.getPriceForChain).mockResolvedValue({ + usd: 2000.5, updatedAt: '2026-05-01T00:00:00.000Z', + }); + const result = await handleToolCall('get_chain_by_id', { chainId: 1 }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.price).toEqual({ usd: 2000.5, updatedAt: '2026-05-01T00:00:00.000Z' }); + }); + + it('should set price: null when CoinGecko fetch fails', async () => { + vi.mocked(dataService.getChainById).mockReturnValue({ + chainId: 1, name: 'Ethereum', nativeCurrency: { symbol: 'ETH' }, + }); + vi.mocked(priceService.getPriceForChain).mockResolvedValue(null); + const result = await handleToolCall('get_chain_by_id', { chainId: 1 }); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.price).toBeNull(); + }); }); describe('handleToolCall - search_chains', () => { @@ -702,6 +766,62 @@ describe('MCP Tools - Shared Module', () => { }); }); + describe('get_clients', () => { + it('returns aggregated clients across all chains when chainId omitted', async () => { + vi.mocked(rpcMonitor.getClientsByChain).mockImplementation((chainId) => { + if (chainId === undefined) { + return [ + { + chainId: 1, + chainName: 'Ethereum', + totalNodes: 2, + unknownNodes: 0, + clients: [{ name: 'geth', repo: 'ethereum/go-ethereum', nodeCount: 2, versions: [], known: true }] + } + ]; + } + return null; + }); + + const result = await handleToolCall('get_clients', {}); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0].text); + expect(data.count).toBe(1); + expect(data.chains[0].chainId).toBe(1); + }); + + it('returns summary for a specific chain', async () => { + vi.mocked(rpcMonitor.getClientsByChain).mockReturnValue({ + chainId: 1, + chainName: 'Ethereum', + totalNodes: 1, + unknownNodes: 0, + clients: [{ name: 'geth', repo: 'ethereum/go-ethereum', nodeCount: 1, versions: [], known: true }] + }); + + const result = await handleToolCall('get_clients', { chainId: 1 }); + const data = JSON.parse(result.content[0].text); + expect(data.chainId).toBe(1); + expect(data.clients[0].name).toBe('geth'); + }); + + it('returns error for invalid chain ID', async () => { + const result = await handleToolCall('get_clients', { chainId: 'not-a-number' }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('Invalid chain ID'); + }); + + it('returns error when no client data exists for chain', async () => { + vi.mocked(rpcMonitor.getClientsByChain).mockReturnValue(null); + + const result = await handleToolCall('get_clients', { chainId: 99999 }); + expect(result.isError).toBe(true); + const data = JSON.parse(result.content[0].text); + expect(data.error).toBe('No client data found for this chain'); + }); + }); + describe('handleToolCall - error handling', () => { it('should return error for unknown tool', async () => { const result = await handleToolCall('unknown_tool', {}); diff --git a/tests/unit/priceService.test.js b/tests/unit/priceService.test.js new file mode 100644 index 0000000..871399a --- /dev/null +++ b/tests/unit/priceService.test.js @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../config.js', () => ({ + PRICE_CACHE_TTL_MS: 3600000, + PRICE_NEGATIVE_CACHE_TTL_MS: 300000, + PRICE_FETCH_TIMEOUT_MS: 3000, + PROXY_URL: '', + PROXY_ENABLED: false, +})); + +vi.mock('../../fetchUtil.js', () => ({ + proxyFetch: vi.fn(), +})); + +import * as fetchUtil from '../../fetchUtil.js'; +import { + getPriceForChain, + getPricesForChains, + getCoinGeckoId, + clearPriceCache, +} from '../../priceService.js'; + +describe('priceService', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearPriceCache(); + }); + + describe('getCoinGeckoId', () => { + it('should return ethereum for chainId 1', () => { + expect(getCoinGeckoId(1)).toBe('ethereum'); + }); + + it('should return null for unknown chain', () => { + expect(getCoinGeckoId(99999)).toBeNull(); + }); + + it('should return ethereum for Base (8453)', () => { + expect(getCoinGeckoId(8453)).toBe('ethereum'); + }); + + it('should return matic-network for Polygon (137)', () => { + expect(getCoinGeckoId(137)).toBe('matic-network'); + }); + }); + + describe('getPriceForChain', () => { + it('should return null for unknown chain without fetching', async () => { + const result = await getPriceForChain(99999); + expect(result).toBeNull(); + expect(fetchUtil.proxyFetch).not.toHaveBeenCalled(); + }); + + it('should fetch and return price for known chain', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: true, + json: async () => ({ ethereum: { usd: 2000.5 } }), + }); + const result = await getPriceForChain(1); + expect(result).toMatchObject({ usd: 2000.5 }); + expect(result.updatedAt).toBeDefined(); + expect(result.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('should return null gracefully on CoinGecko HTTP error', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: false, + status: 429, + }); + const result = await getPriceForChain(1); + expect(result).toBeNull(); + }); + + it('should return null gracefully on network error', async () => { + vi.mocked(fetchUtil.proxyFetch).mockRejectedValue( + new Error('ECONNREFUSED') + ); + const result = await getPriceForChain(1); + expect(result).toBeNull(); + }); + + it('should use TTL cache on second call', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: true, + json: async () => ({ ethereum: { usd: 2000.5 } }), + }); + const first = await getPriceForChain(1); + const second = await getPriceForChain(1); + expect(first).toEqual(second); + expect(fetchUtil.proxyFetch).toHaveBeenCalledTimes(1); + }); + + it('should reuse sibling cache for L2 chains sharing ETH coinId', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: true, + json: async () => ({ ethereum: { usd: 2000.5 } }), + }); + // Fetch Ethereum (chainId 1) + const eth = await getPriceForChain(1); + // Fetch Optimism (chainId 10) — same coinId 'ethereum' + const opt = await getPriceForChain(10); + // Should NOT have made a second network call + expect(fetchUtil.proxyFetch).toHaveBeenCalledTimes(1); + expect(eth.usd).toBe(opt.usd); + expect(eth.updatedAt).toBe(opt.updatedAt); + }); + }); + + describe('getPricesForChains', () => { + it('should batch all unique coinIds into one request', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: true, + json: async () => ({ + ethereum: { usd: 2000.5 }, + 'matic-network': { usd: 0.8 }, + }), + }); + const result = await getPricesForChains([1, 137, 10]); // 10 shares ETH with 1 + expect(fetchUtil.proxyFetch).toHaveBeenCalledTimes(1); + // Verify the URL contains both ids (not three) + const url = vi.mocked(fetchUtil.proxyFetch).mock.calls[0][0]; + expect(url).toContain('ethereum'); + expect(url).toContain('matic-network'); + expect(result.get(1)).toMatchObject({ usd: 2000.5 }); + expect(result.get(137)).toMatchObject({ usd: 0.8 }); + expect(result.get(10)).toMatchObject({ usd: 2000.5 }); // sibling reuse + }); + + it('should return null for unknown chain IDs', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + const result = await getPricesForChains([99999]); + expect(result.get(99999)).toBeNull(); + expect(fetchUtil.proxyFetch).not.toHaveBeenCalled(); // no coinId, no fetch + }); + + it('should return null for all chains on CoinGecko failure', async () => { + vi.mocked(fetchUtil.proxyFetch).mockRejectedValue(new Error('timeout')); + const result = await getPricesForChains([1, 137]); + expect(result.get(1)).toBeNull(); + expect(result.get(137)).toBeNull(); + }); + + it('should handle mixed known and unknown chains', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: true, + json: async () => ({ ethereum: { usd: 2000.5 } }), + }); + const result = await getPricesForChains([1, 99999, 137]); + expect(result.get(1)).toMatchObject({ usd: 2000.5 }); + expect(result.get(99999)).toBeNull(); + expect(result.get(137)).toBeNull(); // no price for this one + }); + + it('should deduplicate batch requests for sibling chains', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: true, + json: async () => ({ ethereum: { usd: 2000.5 } }), + }); + // Request multiple L2s that all use ETH + const result = await getPricesForChains([1, 10, 42161, 8453]); + // Should only call CoinGecko once for "ethereum" + expect(fetchUtil.proxyFetch).toHaveBeenCalledTimes(1); + const url = vi.mocked(fetchUtil.proxyFetch).mock.calls[0][0]; + // URL should contain ids parameter with only "ethereum" once + expect(url).toContain('ids=ethereum'); + // All chains should have the same price + expect(result.get(1)?.usd).toBe(2000.5); + expect(result.get(10)?.usd).toBe(2000.5); + expect(result.get(42161)?.usd).toBe(2000.5); + expect(result.get(8453)?.usd).toBe(2000.5); + }); + + it('should handle partial CoinGecko response gracefully', async () => { + vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ + ok: true, + json: async () => ({ + ethereum: { usd: 2000.5 }, + // matic-network is missing + }), + }); + const result = await getPricesForChains([1, 137]); + expect(result.get(1)).toMatchObject({ usd: 2000.5 }); + expect(result.get(137)).toBeNull(); + }); + }); +}); diff --git a/tests/unit/rpcMonitor.test.js b/tests/unit/rpcMonitor.test.js index 0619a56..82d8d07 100644 --- a/tests/unit/rpcMonitor.test.js +++ b/tests/unit/rpcMonitor.test.js @@ -37,7 +37,7 @@ vi.mock('../../dataService.js', () => ({ ]) })); -import { getMonitoringResults, getMonitoringStatus, startRpcHealthCheck, startMonitoring } from '../../rpcMonitor.js'; +import { getMonitoringResults, getMonitoringStatus, startRpcHealthCheck, startMonitoring, getClientsByChain } from '../../rpcMonitor.js'; import { getAllEndpoints } from '../../dataService.js'; describe('RPC Monitor', () => { @@ -507,4 +507,197 @@ describe('RPC Monitor', () => { consoleErrorSpy.mockRestore(); }); }); + + describe('Parsed client field', () => { + it('populates result.client for a recognized client version', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(getAllEndpoints).mockReturnValue([ + { chainId: 1, name: 'Ethereum', rpc: ['https://eth.rpc.com'] } + ]); + + vi.mocked(jsonRpcCall) + .mockResolvedValueOnce('Geth/v1.14.5-stable-xxx/linux-amd64/go1.22.5') + .mockResolvedValueOnce('0x12345'); + + await startMonitoring(); + + const results = getMonitoringResults(); + const ethResults = results.results.filter(r => r.chainId === 1); + expect(ethResults[0].client).toMatchObject({ + name: 'geth', + version: 'v1.14.5-stable-xxx', + repo: 'ethereum/go-ethereum', + known: true + }); + + consoleSpy.mockRestore(); + }); + + it('populates result.client with known=false for an unknown client', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(getAllEndpoints).mockReturnValue([ + { chainId: 999, name: 'Mystery Chain', rpc: ['https://mystery.rpc.com'] } + ]); + + vi.mocked(jsonRpcCall) + .mockResolvedValueOnce('some-new-client/v0.1.0') + .mockResolvedValueOnce('0x1'); + + await startMonitoring(); + + const results = getMonitoringResults(); + const mysteryResults = results.results.filter(r => r.chainId === 999); + expect(mysteryResults[0].client).toMatchObject({ + name: 'some-new-client', + version: 'v0.1.0', + repo: null, + known: false + }); + + consoleSpy.mockRestore(); + }); + + it('aggregates clients across working endpoints for a chain', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(getAllEndpoints).mockReturnValue([ + { + chainId: 1, + name: 'Ethereum', + rpc: [ + 'https://rpc1.example.com', + 'https://rpc2.example.com', + 'https://rpc3.example.com' + ] + } + ]); + + // rpc1: geth v1.14.5, rpc2: geth v1.14.5 (same version), rpc3: erigon v2.60.0 + vi.mocked(jsonRpcCall) + .mockResolvedValueOnce('Geth/v1.14.5/linux/go1.22') + .mockResolvedValueOnce('0x1') + .mockResolvedValueOnce('Geth/v1.14.5/linux/go1.22') + .mockResolvedValueOnce('0x1') + .mockResolvedValueOnce('erigon/v2.60.0/linux/go1.22') + .mockResolvedValueOnce('0x1'); + + await startMonitoring(); + + const summary = getClientsByChain(1); + expect(summary).toMatchObject({ + chainId: 1, + chainName: 'Ethereum', + totalNodes: 3, + unknownNodes: 0 + }); + expect(summary.clients).toHaveLength(2); + + // Sorted by nodeCount descending — geth should be first with 2 nodes + expect(summary.clients[0]).toMatchObject({ + name: 'geth', + repo: 'ethereum/go-ethereum', + nodeCount: 2 + }); + expect(summary.clients[0].versions).toEqual([ + { version: 'v1.14.5', nodeCount: 2 } + ]); + expect(summary.clients[1]).toMatchObject({ + name: 'erigon', + nodeCount: 1 + }); + + consoleSpy.mockRestore(); + }); + + it('returns null from getClientsByChain when chain has no monitoring data', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(getAllEndpoints).mockReturnValue([ + { chainId: 1, name: 'Ethereum', rpc: ['https://eth.rpc.com'] } + ]); + vi.mocked(jsonRpcCall).mockResolvedValue('0x1'); + await startMonitoring(); + + expect(getClientsByChain(99999)).toBeNull(); + + consoleSpy.mockRestore(); + }); + + it('returns an array across all chains when chainId is omitted', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(getAllEndpoints).mockReturnValue([ + { chainId: 1, name: 'Ethereum', rpc: ['https://eth.rpc.com'] }, + { chainId: 137, name: 'Polygon', rpc: ['https://polygon.rpc.com'] } + ]); + + // URL-keyed mock: chains test concurrently so sequential mocks would interleave + vi.mocked(jsonRpcCall).mockImplementation(async (url, method) => { + if (method === 'web3_clientVersion') { + if (url.includes('eth.rpc.com')) return 'Geth/v1.14.5'; + if (url.includes('polygon.rpc.com')) return 'bor/v1.3.0'; + } + return '0x1'; + }); + + await startMonitoring(); + + const all = getClientsByChain(); + expect(Array.isArray(all)).toBe(true); + expect(all).toHaveLength(2); + const byId = Object.fromEntries(all.map(c => [c.chainId, c])); + expect(byId[1].clients[0].name).toBe('geth'); + expect(byId[137].clients[0].name).toBe('bor'); + + consoleSpy.mockRestore(); + }); + + it('counts endpoints with no parsed client as unknownNodes', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(getAllEndpoints).mockReturnValue([ + { chainId: 1, name: 'Ethereum', rpc: ['https://a.rpc.com', 'https://b.rpc.com'] } + ]); + + // a: client version unavailable, b: known geth + vi.mocked(jsonRpcCall) + .mockRejectedValueOnce(new Error('not supported')) + .mockResolvedValueOnce('0x1') + .mockResolvedValueOnce('Geth/v1.14.5') + .mockResolvedValueOnce('0x1'); + + await startMonitoring(); + + const summary = getClientsByChain(1); + expect(summary.totalNodes).toBe(2); + expect(summary.unknownNodes).toBe(1); + expect(summary.clients).toHaveLength(1); + expect(summary.clients[0].name).toBe('geth'); + + consoleSpy.mockRestore(); + }); + + it('sets result.client to null when web3_clientVersion is unavailable', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(getAllEndpoints).mockReturnValue([ + { chainId: 1, name: 'Ethereum', rpc: ['https://eth.rpc.com'] } + ]); + + vi.mocked(jsonRpcCall) + .mockRejectedValueOnce(new Error('Method not supported')) + .mockResolvedValueOnce('0x1'); + + await startMonitoring(); + + const results = getMonitoringResults(); + const ethResults = results.results.filter(r => r.chainId === 1); + expect(ethResults[0].clientVersion).toBe('unavailable'); + expect(ethResults[0].client).toBeNull(); + + consoleSpy.mockRestore(); + }); + }); }); From 036eef157f2eb9a54cef64fe4d510bda3882b2fa Mon Sep 17 00:00:00 2001 From: Johnaverse <110527930+Johnaverse@users.noreply.github.com> Date: Tue, 12 May 2026 08:52:35 -0400 Subject: [PATCH 2/3] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/unit/rpcMonitor.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/unit/rpcMonitor.test.js b/tests/unit/rpcMonitor.test.js index 82d8d07..3c22ded 100644 --- a/tests/unit/rpcMonitor.test.js +++ b/tests/unit/rpcMonitor.test.js @@ -636,8 +636,14 @@ describe('RPC Monitor', () => { // URL-keyed mock: chains test concurrently so sequential mocks would interleave vi.mocked(jsonRpcCall).mockImplementation(async (url, method) => { if (method === 'web3_clientVersion') { - if (url.includes('eth.rpc.com')) return 'Geth/v1.14.5'; - if (url.includes('polygon.rpc.com')) return 'bor/v1.3.0'; + let hostname = ''; + try { + hostname = new URL(url).hostname; + } catch { + hostname = ''; + } + if (hostname === 'eth.rpc.com') return 'Geth/v1.14.5'; + if (hostname === 'polygon.rpc.com') return 'bor/v1.3.0'; } return '0x1'; }); From 444d1c104b12f89d284fef1f3fab284e9e80d1d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 00:25:10 +0000 Subject: [PATCH 3/3] Address Copilot review on PR #40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit priceService: distinguish "upstream failed" from "id missing". On HTTP / network error the cache is no longer poisoned with negative entries — retries can immediately re-fetch instead of waiting for PRICE_NEGATIVE_CACHE_TTL_MS. Two new tests cover the retry path after both network and HTTP failures. clientRegistry: drop misleading "hyphen-free" claim from the header comment — registry already contains op-geth and op-reth. clientParser: rewrite normalizeVersion docstring to describe what the function actually does (trim) rather than implying semver extraction. clientsView: getClientsByChain() (no-arg form) now filters out chains where every working endpoint had an unparseable client, so /clients stays a directory of known software. Single-chain form unchanged so callers can still see unknownNodes. --- clientParser.js | 8 ++++---- clientRegistry.js | 7 ++++--- clientsView.js | 15 +++++++++++---- priceService.js | 27 +++++++++++++++++---------- tests/unit/priceService.test.js | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/clientParser.js b/clientParser.js index b6f8ad7..ce78d41 100644 --- a/clientParser.js +++ b/clientParser.js @@ -70,10 +70,10 @@ function normalizeName(segment) { } /** - * Extract the semver portion of a version segment, preserving an optional `v` - * prefix and pre-release/build metadata. Some clients emit extra descriptors - * (e.g. "v1.26.0+commit.abc"); we keep them intact — the raw string is still - * available via `raw`. + * Trim the version segment. We intentionally keep build metadata, pre-release + * tags, and any other suffix the client emits (e.g. "v1.26.0+commit.abc") + * so the version string aggregates downstream as the client author meant it. + * The raw input is still available via `raw` for callers that need it. */ function normalizeVersion(segment) { return segment.trim(); diff --git a/clientRegistry.js b/clientRegistry.js index e1c703f..ce8c128 100644 --- a/clientRegistry.js +++ b/clientRegistry.js @@ -1,7 +1,8 @@ // Curated registry of known blockchain client software. -// Key is the lowercased, hyphen-free client name as it appears in -// `web3_clientVersion` responses. Add new entries as they're observed in -// production — unknown names still round-trip through the parser with `repo: null`. +// Key is the lowercased client name as it appears in `web3_clientVersion` +// responses (hyphens preserved — see `op-geth`, `op-reth`). Add new entries +// as they're observed in production — unknown names still round-trip +// through the parser with `repo: null`. export const CLIENT_REGISTRY = { // Ethereum execution clients diff --git a/clientsView.js b/clientsView.js index b03b2f9..43fb8bb 100644 --- a/clientsView.js +++ b/clientsView.js @@ -4,9 +4,16 @@ import { parseClientVersion } from './clientParser.js'; /** * Aggregate parsed client software across working RPC endpoints. * - * When `chainId` is provided, returns a single summary object for that chain - * (or null if no monitoring data exists for it). When omitted, returns an - * array of summaries — one per chain with at least one working endpoint. + * When `chainId` is provided, returns a single summary for that chain + * (or null if no monitoring data exists for it). The single-chain form + * returns the summary even if every working endpoint reported an + * unparseable client (so the caller can still see `totalNodes` / + * `unknownNodes`). + * + * When `chainId` is omitted, returns an array of summaries — one per + * chain with at least one parseable client. Chains where every working + * endpoint failed to parse are excluded from the list so `/clients` + * stays a directory of *known* client software. * * Each summary: * { @@ -40,7 +47,7 @@ export function getClientsByChain(chainId) { return Array.from(byChain.values()) .map(summarizeChainClients) - .filter(Boolean); + .filter(summary => summary && summary.clients.length > 0); } /** diff --git a/priceService.js b/priceService.js index f6e94dd..3de0f83 100644 --- a/priceService.js +++ b/priceService.js @@ -78,18 +78,19 @@ async function fetchWithTimeout(url) { } async function fetchCoinIds(coinIds) { - if (coinIds.length === 0) return new Map(); + if (coinIds.length === 0) return { map: new Map(), ok: true }; // Coalesce: for each coinId, reuse an in-flight promise if one exists. // Otherwise schedule the missing IDs in a single batched request. const result = new Map(); + let ok = true; const toFetch = []; const waiters = []; for (const id of coinIds) { const pending = inflight.get(id); if (pending) { - waiters.push(pending.then(map => [id, map.get(id)])); + waiters.push(pending.then(({ map, ok: pendingOk }) => ({ id, usd: map.get(id), ok: pendingOk }))); } else { toFetch.push(id); } @@ -98,6 +99,7 @@ async function fetchCoinIds(coinIds) { if (toFetch.length > 0) { const batchPromise = (async () => { const map = new Map(); + let batchOk = true; const url = `${COINGECKO_PRICE_URL}?ids=${toFetch.join(',')}&vs_currencies=usd`; try { const response = await fetchWithTimeout(url); @@ -107,19 +109,22 @@ async function fetchCoinIds(coinIds) { if (typeof prices?.usd === 'number') map.set(id, prices.usd); } } else { + batchOk = false; console.warn(`CoinGecko price fetch failed: HTTP ${response.status}`); } } catch (err) { + batchOk = false; console.warn(`CoinGecko price fetch error: ${err.message}`); } - return map; + return { map, ok: batchOk }; })(); for (const id of toFetch) { inflight.set(id, batchPromise); } try { - const map = await batchPromise; + const { map, ok: batchOk } = await batchPromise; + if (!batchOk) ok = false; for (const id of toFetch) { if (map.has(id)) result.set(id, map.get(id)); } @@ -128,23 +133,25 @@ async function fetchCoinIds(coinIds) { } } - for (const [id, usd] of await Promise.all(waiters.map(p => p))) { + for (const { id, usd, ok: waiterOk } of await Promise.all(waiters.map(p => p))) { + if (!waiterOk) ok = false; if (usd !== undefined) result.set(id, usd); } - return result; + return { map: result, ok }; } -function recordResults(coinIds, fetched) { +function recordResults(coinIds, { map: fetched, ok }) { const updatedAt = new Date().toISOString(); for (const id of coinIds) { if (fetched.has(id)) { priceCache.set(id, { usd: fetched.get(id), updatedAt }); - } else { - // Negative cache: short TTL so we don't hammer CoinGecko on every request - // when an ID is missing or temporarily unavailable. + } else if (ok) { + // Upstream succeeded but didn't return this id — it's genuinely missing. + // Negative-cache with the short TTL so we don't hammer CoinGecko. priceCache.set(id, { usd: null, updatedAt }); } + // else: upstream failed; leave any prior entry intact so retries can succeed. } } diff --git a/tests/unit/priceService.test.js b/tests/unit/priceService.test.js index 871399a..0b73c99 100644 --- a/tests/unit/priceService.test.js +++ b/tests/unit/priceService.test.js @@ -79,6 +79,38 @@ describe('priceService', () => { expect(result).toBeNull(); }); + it('should retry upstream on next call after a fetch failure', async () => { + vi.mocked(fetchUtil.proxyFetch) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ ethereum: { usd: 2500.0 } }), + }); + + const first = await getPriceForChain(1); + expect(first).toBeNull(); + + const second = await getPriceForChain(1); + expect(second).toMatchObject({ usd: 2500.0 }); + expect(fetchUtil.proxyFetch).toHaveBeenCalledTimes(2); + }); + + it('should retry upstream on next call after an HTTP error', async () => { + vi.mocked(fetchUtil.proxyFetch) + .mockResolvedValueOnce({ ok: false, status: 500 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ ethereum: { usd: 1800.0 } }), + }); + + const first = await getPriceForChain(1); + expect(first).toBeNull(); + + const second = await getPriceForChain(1); + expect(second).toMatchObject({ usd: 1800.0 }); + expect(fetchUtil.proxyFetch).toHaveBeenCalledTimes(2); + }); + it('should use TTL cache on second call', async () => { vi.mocked(fetchUtil.proxyFetch).mockResolvedValue({ ok: true,