Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions clientParser.js
Original file line number Diff line number Diff line change
@@ -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();
}
45 changes: 45 additions & 0 deletions clientRegistry.js
Original file line number Diff line number Diff line change
@@ -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;
}
116 changes: 116 additions & 0 deletions clientsView.js
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +15 to +16
*
* 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
};
}
5 changes: 5 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +85 to +87
64 changes: 58 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`);
});
Comment on lines +88 to +92
}

/**
Expand Down Expand Up @@ -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
};
});

Expand All @@ -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 };
});

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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
*/
Expand Down Expand Up @@ -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)'
},
Expand Down
Loading
Loading