diff --git a/clientParser.js b/clientParser.js new file mode 100644 index 0000000..ce78d41 --- /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(); +} + +/** + * 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 new file mode 100644 index 0000000..ce8c128 --- /dev/null +++ b/clientRegistry.js @@ -0,0 +1,45 @@ +// Curated registry of known blockchain client software. +// 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 + 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/clientsView.js b/clientsView.js new file mode 100644 index 0000000..43fb8bb --- /dev/null +++ b/clientsView.js @@ -0,0 +1,116 @@ +import { getRpcMonitoringResults } from './dataService.js'; +import { parseClientVersion } from './clientParser.js'; + +/** + * Aggregate parsed client software across working RPC endpoints. + * + * 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: + * { + * 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 { results } = getRpcMonitoringResults(); + const working = 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(summary => summary && summary.clients.length > 0); +} + +/** + * Build a per-chain client summary from a list of endpoint results. + * Parses `clientVersion` lazily via parseClientVersion. Non-working entries + * 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) { + const client = parseClientVersion(r.clientVersion); + if (!client) { + unknownNodes++; + continue; + } + + const key = client.name; + let bucket = byClient.get(key); + if (!bucket) { + bucket = { + name: client.name, + repo: client.repo, + language: client.language, + website: client.website, + layer: client.layer, + known: client.known, + nodeCount: 0, + _versions: new Map() + }; + byClient.set(key, bucket); + } + bucket.nodeCount++; + + const v = 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 + }; +} 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 fd3c1da..11d7e50 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,8 @@ import { basename, resolve, dirname, join } from 'node:path'; import { fileURLToPath as toFilePath } from 'node:url'; import pkg from './package.json' with { type: 'json' }; import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations, countChainsByTag, getRpcMonitoringResults, getRpcMonitoringStatus, startRpcHealthCheck } from './dataService.js'; +import { getClientsByChain, summarizeChainClients } from './clientsView.js'; +import { getPricesForChains, getPriceForChain, prefetchAllPrices } from './priceService.js'; import { PORT, HOST, BODY_LIMIT, MAX_PARAM_LENGTH, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, @@ -83,6 +85,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}`); + }); } /** @@ -114,9 +121,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 }; }); @@ -134,7 +148,8 @@ export async function buildApp(options = {}) { return sendError(reply, 404, 'Chain not found'); } - return chain; + const price = await getPriceForChain(chainId); + return { ...chain, price }; }); /** @@ -405,8 +420,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, @@ -415,10 +434,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 = getRpcMonitoringResults(); + 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 */ @@ -476,6 +526,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 2e3cd11..c709b5e 100644 --- a/mcp-tools.js +++ b/mcp-tools.js @@ -14,6 +14,8 @@ import { getRpcMonitoringResults, getRpcMonitoringStatus, } from './dataService.js'; +import { getClientsByChain } from './clientsView.js'; +import { getPricesForChains, getPriceForChain } from './priceService.js'; /** * Get the list of MCP tool definitions (schemas) @@ -174,6 +176,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.', + }, + }, + }, + }, ]; } @@ -199,15 +214,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'); @@ -216,7 +237,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) { @@ -441,6 +463,28 @@ function handleGetRpcMonitorById(args) { return { content: [{ type: 'text', text: lines.join('\n') }] }; } +function handleGetClients(args) { + if (args.chainId === undefined) { + const results = getRpcMonitoringResults(); + 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 = { @@ -457,6 +501,7 @@ const toolHandlers = { traverse_relations: handleTraverseRelations, get_rpc_monitor: handleGetRpcMonitor, get_rpc_monitor_by_id: handleGetRpcMonitorById, + get_clients: handleGetClients, }; /** @@ -471,7 +516,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..3de0f83 --- /dev/null +++ b/priceService.js @@ -0,0 +1,219 @@ +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 { 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, ok: pendingOk }) => ({ id, usd: map.get(id), ok: pendingOk }))); + } else { + toFetch.push(id); + } + } + + 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); + 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 { + 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, ok: batchOk }; + })(); + + for (const id of toFetch) { + inflight.set(id, batchPromise); + } + try { + 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)); + } + } finally { + for (const id of toFetch) inflight.delete(id); + } + } + + 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 { map: result, ok }; +} + +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 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. + } +} + +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 09b6cf6..2c723dc 100644 --- a/public/app.js +++ b/public/app.js @@ -586,7 +586,6 @@ function showNodeHeader(node, data) { : 'None'; } - // Status badge const statusBadge = document.getElementById('chainStatusBadge'); if (statusBadge) { if (data.status) { diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js index 0b9d7db..87af4cc 100644 --- a/tests/integration/api.test.js +++ b/tests/integration/api.test.js @@ -7,6 +7,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'); @@ -248,6 +266,73 @@ vi.mock('../../dataService.js', async () => { }; }); +vi.mock('../../clientsView.js', () => ({ + 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', () => { let app; @@ -373,6 +458,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', () => { @@ -420,6 +524,19 @@ describe('API Endpoints', () => { const data = JSON.parse(response.payload); expect(data).toHaveProperty('error', 'Invalid chain ID'); }); + + 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', () => { @@ -855,6 +972,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/clientsView.test.js b/tests/unit/clientsView.test.js new file mode 100644 index 0000000..05f64dc --- /dev/null +++ b/tests/unit/clientsView.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../dataService.js', () => ({ + getRpcMonitoringResults: vi.fn() +})); + +import { getRpcMonitoringResults } from '../../dataService.js'; +import { getClientsByChain, summarizeChainClients } from '../../clientsView.js'; + +function makeResult(chainId, chainName, url, clientVersion, status = 'working') { + return { chainId, chainName, url, clientVersion, status, blockNumber: 1, latencyMs: 10, error: null }; +} + +function withResults(results) { + vi.mocked(getRpcMonitoringResults).mockReturnValue({ + lastUpdated: '2026-05-13T00:00:00Z', + totalEndpoints: results.length, + testedEndpoints: results.length, + workingEndpoints: results.filter(r => r.status === 'working').length, + failedEndpoints: results.filter(r => r.status !== 'working').length, + results + }); +} + +describe('clientsView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getClientsByChain(chainId)', () => { + it('aggregates clients across working endpoints for a chain', () => { + withResults([ + makeResult(1, 'Ethereum', 'https://rpc1', 'Geth/v1.14.5/linux/go1.22'), + makeResult(1, 'Ethereum', 'https://rpc2', 'Geth/v1.14.5/linux/go1.22'), + makeResult(1, 'Ethereum', 'https://rpc3', 'erigon/v2.60.0/linux/go1.22') + ]); + + const summary = getClientsByChain(1); + expect(summary).toMatchObject({ + chainId: 1, + chainName: 'Ethereum', + totalNodes: 3, + unknownNodes: 0 + }); + expect(summary.clients).toHaveLength(2); + expect(summary.clients[0]).toMatchObject({ name: 'geth', nodeCount: 2 }); + expect(summary.clients[0].versions).toEqual([ + { version: 'v1.14.5', nodeCount: 2 } + ]); + expect(summary.clients[1]).toMatchObject({ name: 'erigon', nodeCount: 1 }); + }); + + it('returns null when chain has no monitoring data', () => { + withResults([ + makeResult(1, 'Ethereum', 'https://eth', 'Geth/v1.14.5') + ]); + expect(getClientsByChain(99999)).toBeNull(); + }); + + it('ignores failed endpoints', () => { + withResults([ + makeResult(1, 'Ethereum', 'https://ok', 'Geth/v1.14.5'), + makeResult(1, 'Ethereum', 'https://bad', 'Geth/v1.14.5', 'failed') + ]); + const summary = getClientsByChain(1); + expect(summary.totalNodes).toBe(1); + }); + + it('counts endpoints with no parseable client as unknownNodes', () => { + withResults([ + makeResult(1, 'Ethereum', 'https://a', null), + makeResult(1, 'Ethereum', 'https://b', 'Geth/v1.14.5') + ]); + 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'); + }); + }); + + describe('getClientsByChain() across chains', () => { + it('returns one summary per chain when chainId is omitted', () => { + withResults([ + makeResult(1, 'Ethereum', 'https://eth', 'Geth/v1.14.5'), + makeResult(137, 'Polygon', 'https://polygon', 'bor/v1.3.0') + ]); + 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'); + }); + + it('returns empty array when no working endpoints exist', () => { + withResults([]); + expect(getClientsByChain()).toEqual([]); + }); + }); + + describe('summarizeChainClients', () => { + it('sorts versions inside a client by nodeCount descending', () => { + const summary = summarizeChainClients([ + makeResult(1, 'Ethereum', 'https://a', 'Geth/v1.14.5'), + makeResult(1, 'Ethereum', 'https://b', 'Geth/v1.14.5'), + makeResult(1, 'Ethereum', 'https://c', 'Geth/v1.14.4') + ]); + expect(summary.clients[0].versions).toEqual([ + { version: 'v1.14.5', nodeCount: 2 }, + { version: 'v1.14.4', nodeCount: 1 } + ]); + }); + + it('returns null when no working endpoints are supplied', () => { + expect(summarizeChainClients([])).toBeNull(); + expect(summarizeChainClients([ + makeResult(1, 'Ethereum', 'https://a', 'Geth', 'failed') + ])).toBeNull(); + }); + }); +}); diff --git a/tests/unit/mcp-tools.test.js b/tests/unit/mcp-tools.test.js index c804caa..0908b23 100644 --- a/tests/unit/mcp-tools.test.js +++ b/tests/unit/mcp-tools.test.js @@ -50,7 +50,26 @@ vi.mock('../../dataService.js', () => ({ })), })); +vi.mock('../../clientsView.js', () => ({ + getClientsByChain: vi.fn(() => null), + summarizeChainClients: 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 clientsView from '../../clientsView.js'; +import * as priceService from '../../priceService.js'; import { getToolDefinitions, handleToolCall } from '../../mcp-tools.js'; describe('MCP Tools - Shared Module', () => { @@ -105,10 +124,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', () => { @@ -127,6 +146,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', () => { @@ -202,6 +222,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', () => { @@ -238,6 +283,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', () => { @@ -698,6 +767,62 @@ describe('MCP Tools - Shared Module', () => { }); }); + describe('get_clients', () => { + it('returns aggregated clients across all chains when chainId omitted', async () => { + vi.mocked(clientsView.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(clientsView.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(clientsView.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..0b73c99 --- /dev/null +++ b/tests/unit/priceService.test.js @@ -0,0 +1,221 @@ +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 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, + 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(); + }); + }); +});