chains: read each chain from that chain, and list the ones this network runs

/chains showed fifteen rows on every network, from a hardcoded table. Four of
them — T, R, I, O — are registered on no network at all; the node answers
"there is no ID with alias: T". A fifth, M, runs on devnet alone. And every
/chains/<slug> page printed the same four numbers: the primary network's
validator count, its total stake, its chain count, its threshold. Nothing on
Q-Chain's page was about Q-Chain. Fifteen pages, one set of facts.

The network already knows its own answer. platform.getBlockchains is the chain
list, and it is per-network for free: mainnet and testnet report nine chains,
devnet reports ten including M. That is what /chains lists now, joined with
configs/app/primaryChains.ts for presentation only — a chain the network
reports and we have no metadata for still gets a row, because the network is
the source of truth and our table is not.

Three facts per chain, each read from that chain:

  * the blockchain ID, from the registry — the thing that actually tells one
    chain from another, and it was already on screen while the numbers above
    it were not
  * whether the node has bootstrapped it, from info.isBootstrapped, which the
    gateway routes for every chain. This is how Q-Chain's page now says
    Syncing where the other nine say Bootstrapped: Q has not come up on any of
    the three networks and is the sole reason all three report healthy:false
  * its head, in its own dialect — eth_blockNumber for C, platform.getHeight
    for P, xvm.getHeight for X. Not avm: that service is gone from this build,
    and asking it returns "can't find service" rather than a height

Validators are the P-Chain's model, so they render on the P-Chain and nowhere
else. The head is labelled for the model it comes from — the X-Chain's is an
accepted height, not a block height.

Where a chain has no head to read, it says so. The gateway routes exactly P, X
and C on all three networks; D, A, B, Z, G, K and Q are registered and
bootstrapped on the node but their RPC 404s from a browser. Proof it is the
route and not the chain: C-Chain's own blockchain ID, /v1/bc/25td8att…/rpc,
404s while /v1/bc/C/rpc returns a height — same chain, same node. Those pages
read "No public RPC" and say plainly that the chain is registered and running
but not published. A zero there would have read as a measurement.

One proxy replaces the P-only one. /v1/pchain hardcoded /v1/bc/P; /v1/node/
<endpoint> resolves the endpoint against the primary-chain table and never
treats it as a URL. A chain the gateway does not route comes back 200 carrying
a JSON-RPC error rather than 502 — nothing is broken, and 502 put a red failed
request in the console of eight of the ten chain pages.

Deleted: CHAIN_INDEXER_URLS and the hook that read it — fifteen hostnames of
the shape api-indexer-<x>chain.lux.network, none of which resolve. Fifteen of
fifteen NXDOMAIN. It fetched them on every chain page and rendered the null.

Measured on mainnet, 1440x900 and 390x844, fresh context per URL: 20 of 20
chain/screen rows agree with RPC on both status and head — C-Chain 1,107,826
against a live head bracketed at 1,107,822..1,107,829, P and X at 0, seven
reading No public RPC, Q Syncing while the rest are Bootstrapped. /chains
lists the same ten chains the P-Chain reports, and 10 of 10 row links reach a
real page. Zero 4xx or 5xx from our own origin.
This commit is contained in:
2026-08-08 07:32:01 -07:00
parent 16c8d71a90
commit 87aa0fa367
17 changed files with 440 additions and 289 deletions
+4
View File
@@ -0,0 +1,4 @@
// Per-chain facts for the primary-network chain pages.
export { useChainFacts } from './useChainFacts';
export type { ChainFacts, Head } from './useChainFacts';
+111
View File
@@ -0,0 +1,111 @@
// What is true about ONE primary-network chain, read from that chain.
//
// Every /chains/<slug> page used to print the same four numbers — the
// network-wide validator count, total stake, chain count and threshold — so
// Q-Chain and Z-Chain and K-Chain were the same page with the title swapped.
// None of it was about the chain you opened.
//
// Three facts are per-chain and real:
// * the blockchain ID, from the P-Chain registry — unique, and the thing that
// actually distinguishes one chain from another
// * whether the node has bootstrapped it, from info.isBootstrapped, which the
// gateway routes for every chain
// * its head, from its own RPC, in whatever dialect it speaks
//
// The last one is only available where the gateway routes the chain. Today that
// is P, X and C on all three networks; the rest are registered and bootstrapped
// but answer 404 from the browser's only reachable origin. That is reported as
// `unreachable`, never as a height of zero — a chain whose head we cannot read and
// a chain sitting at genesis are different states and must not render alike.
import { useQuery } from '@tanstack/react-query';
import type { PrimaryVm } from 'configs/app/primaryChains';
const STALE_MS = 15_000;
export type Head =
{ readonly kind: 'height'; readonly height: number } |
{ readonly kind: 'unreachable' } |
{ readonly kind: 'unknown' };
export interface ChainFacts {
readonly blockchainId: string | null;
readonly bootstrapped: boolean | null;
readonly head: Head;
readonly chainId: number | null;
}
async function rpc(endpoint: string, method: string, params: unknown): Promise<unknown> {
const res = await fetch(`/v1/node/${ endpoint }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
if (!res.ok) {
return null;
}
const body = await res.json() as { result?: unknown };
return body.result ?? null;
}
// Each dialect names its head differently, and asking the wrong one gets a
// "can't find service" error rather than an answer — X speaks xvm, not avm, and
// not platform.
async function readHead(vm: PrimaryVm): Promise<Head> {
try {
if (vm.view === 'evm' || vm.view === 'dex') {
const hex = await rpc(vm.slug, 'eth_blockNumber', []) as string | null;
return hex ? { kind: 'height', height: parseInt(hex, 16) } : { kind: 'unreachable' };
}
const method = vm.view === 'platform' ? 'platform.getHeight' : 'xvm.getHeight';
const out = await rpc(vm.slug, method, {}) as { height?: string } | null;
return out?.height !== undefined ?
{ kind: 'height', height: Number(out.height) } :
{ kind: 'unreachable' };
} catch {
return { kind: 'unreachable' };
}
}
async function readChainId(vm: PrimaryVm): Promise<number | null> {
if (vm.view !== 'evm' && vm.view !== 'dex') {
return null;
}
const hex = await rpc(vm.slug, 'eth_chainId', []) as string | null;
return hex ? parseInt(hex, 16) : null;
}
export function useChainFacts(vm: PrimaryVm | undefined, blockchainId: string | null): {
readonly facts: ChainFacts;
readonly isLoading: boolean;
} {
const query = useQuery({
queryKey: [ 'chain:facts', vm?.slug ],
enabled: Boolean(vm),
staleTime: STALE_MS,
refetchInterval: STALE_MS,
queryFn: async(): Promise<Omit<ChainFacts, 'blockchainId'>> => {
if (!vm) {
return { bootstrapped: null, head: { kind: 'unknown' }, chainId: null };
}
const alias = vm.slug.charAt(0).toUpperCase();
const [ boot, head, chainId ] = await Promise.all([
rpc('info', 'info.isBootstrapped', { chain: alias }) as Promise<{ isBootstrapped?: boolean } | null>,
readHead(vm),
readChainId(vm),
]);
return { bootstrapped: boot?.isBootstrapped ?? null, head, chainId };
},
});
return {
facts: {
blockchainId,
bootstrapped: query.data?.bootstrapped ?? null,
head: query.data?.head ?? { kind: 'unknown' },
chainId: query.data?.chainId ?? vm?.chainId ?? null,
},
isLoading: query.isLoading,
};
}
+2 -2
View File
@@ -1,6 +1,6 @@
// React Query hook for platform.getBlockchains.
// Returns the list of all blockchains registered on the P-chain.
// Uses the server-side /v1/pchain proxy to bypass CORS.
// Uses the server-side /v1/node/p-chain proxy to bypass CORS.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
@@ -12,7 +12,7 @@ const BLOCKCHAINS_QUERY_KEY = 'pchain:blockchains' as const;
const EMPTY_BLOCKCHAINS: ReadonlyArray<PChainBlockchain> = [];
async function fetchBlockchains(): Promise<ReadonlyArray<PChainBlockchain>> {
const res = await fetch('/v1/pchain', {
const res = await fetch('/v1/node/p-chain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+1 -1
View File
@@ -15,7 +15,7 @@ async function fetchChainHeights(): Promise<{ pChain: number; cChain: number }>
const [ pRes, cRes ] = await Promise.allSettled([
// P-chain: use server-side proxy to bypass CORS
fetch('/v1/pchain', {
fetch('/v1/node/p-chain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'platform.getHeight', params: {}, id: 1 }),
+2 -2
View File
@@ -1,6 +1,6 @@
// React Query hook for platform.getCurrentValidators.
// Returns the validator list and aggregated network statistics.
// Uses the server-side /v1/pchain proxy to bypass CORS.
// Uses the server-side /v1/node/p-chain proxy to bypass CORS.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
@@ -63,7 +63,7 @@ export interface UseCurrentValidatorsResult {
}
async function fetchCurrentValidators(): Promise<UseCurrentValidatorsResult> {
const res = await fetch('/v1/pchain', {
const res = await fetch('/v1/node/p-chain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+2 -2
View File
@@ -3,7 +3,7 @@
// a sovereign L1 is a Network. The node's only wire method is
// `platform.getNets` (vms/platformvm/service.go: GetNets/APINet) — the legacy
// upstream method name does not exist here and returns JSON-RPC -32000.
// Uses the server-side /v1/pchain proxy to bypass CORS.
// Uses the server-side /v1/node/p-chain proxy to bypass CORS.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
@@ -15,7 +15,7 @@ const NETS_QUERY_KEY = 'pchain:nets' as const;
const EMPTY_NETS: ReadonlyArray<PChainNet> = [];
async function fetchNets(): Promise<ReadonlyArray<PChainNet>> {
const res = await fetch('/v1/pchain', {
const res = await fetch('/v1/node/p-chain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+1 -1
View File
@@ -38,7 +38,7 @@ export interface NetworkValidators {
}
async function fetchCount(chainName: string): Promise<number> {
const res = await fetch(`/v1/pchain?chain=${ encodeURIComponent(chainName) }`, {
const res = await fetch(`/v1/node/p-chain?chain=${ encodeURIComponent(chainName) }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+1 -1
View File
@@ -100,7 +100,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/v1/csrf': 'Regular page',
'/v1/healthz': 'Regular page',
'/v1/config': 'Regular page',
'/v1/pchain': 'Regular page',
'/v1/node/[endpoint]': 'Regular page',
};
export default function getPageOgType(pathname: Route['pathname']) {
+1 -1
View File
@@ -103,7 +103,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/v1/csrf': DEFAULT_TEMPLATE,
'/v1/healthz': DEFAULT_TEMPLATE,
'/v1/config': DEFAULT_TEMPLATE,
'/v1/pchain': DEFAULT_TEMPLATE,
'/v1/node/[endpoint]': DEFAULT_TEMPLATE,
};
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
+1 -1
View File
@@ -105,7 +105,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/v1/csrf': '%network_name% node API CSRF token',
'/v1/healthz': '%network_name% node API health check',
'/v1/config': '%network_name% node API app config',
'/v1/pchain': '%network_name% node API P-chain proxy',
'/v1/node/[endpoint]': '%network_name% node API JSON-RPC proxy',
};
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
+1 -1
View File
@@ -98,7 +98,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/v1/csrf': 'Node API: CSRF token',
'/v1/healthz': 'Node API: Health check',
'/v1/config': 'Node API: App config',
'/v1/pchain': 'Node API: P-chain proxy',
'/v1/node/[endpoint]': 'Node API: JSON-RPC proxy',
};
export default function getPageType(pathname: Route['pathname']) {
+2 -4
View File
@@ -19,20 +19,19 @@ declare module "nextjs-routes" {
| DynamicRoute<"/address/[hash]", { "hash": string }>
| StaticRoute<"/advanced-filter">
| StaticRoute<"/ai">
| StaticRoute<"/api/ai">
| StaticRoute<"/api/config">
| StaticRoute<"/api/csrf">
| StaticRoute<"/api/healthz">
| StaticRoute<"/api/log">
| StaticRoute<"/api/metrics">
| StaticRoute<"/api/monitoring/invalid-api-schema">
| StaticRoute<"/api/pchain">
| DynamicRoute<"/api/node/[endpoint]", { "endpoint": string }>
| StaticRoute<"/api/proxy">
| DynamicRoute<"/api/tokens/[hash]/instances/[id]/media-type", { "hash": string; "id": string }>
| StaticRoute<"/api-docs">
| DynamicRoute<"/apps/[id]", { "id": string }>
| StaticRoute<"/apps">
| StaticRoute<"/auth/callback">
| StaticRoute<"/auth/profile">
| DynamicRoute<"/batches/[number]", { "number": string }>
| DynamicRoute<"/batches/celestia/[height]/[commitment]", { "height": string; "commitment": string }>
| StaticRoute<"/batches">
@@ -71,7 +70,6 @@ declare module "nextjs-routes" {
| StaticRoute<"/">
| StaticRoute<"/internal-txs">
| StaticRoute<"/interop-messages">
| StaticRoute<"/login">
| StaticRoute<"/mud-worlds">
| DynamicRoute<"/name-services/clusters/[name]", { "name": string }>
| DynamicRoute<"/name-services/domains/[name]", { "name": string }>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@luxfi/explore",
"version": "1.1.29",
"version": "1.1.30",
"private": false,
"homepage": "https://github.com/luxfi/explore#readme",
"engines": {
+118
View File
@@ -0,0 +1,118 @@
// JSON-RPC pass-through to one endpoint on our own node.
//
// The browser cannot call the node directly: the gateway answers 404 to the
// OPTIONS preflight for /v1/bc/*, so a cross-origin RPC fails before it is
// sent. Forwarding from the server side has no preflight.
//
// `endpoint` is resolved against a fixed table and never used as a URL. An
// arbitrary value here would make this an open request forwarder for anything
// the server can reach.
//
// Replaces /v1/pchain, which hardcoded /v1/bc/P. The P-Chain is not the only
// chain a page needs to ask about — every primary-network chain has its own
// identity and its own liveness, and reading them was the whole reason each
// chain page showed the same network-wide validator count.
import type { NextApiRequest, NextApiResponse } from 'next';
import { CHAINS, getCurrentChain } from 'configs/app/chainRegistry';
import { PRIMARY_VMS } from 'configs/app/primaryChains';
import { getEnvValue } from 'configs/app/utils';
const TIMEOUT_MS = 10_000;
// endpoint -> path on the node. `info` is the node's own API; every other
// entry is a primary-network chain, addressed by its single-letter alias.
// EVM chains answer under /rpc, the rest at the bare chain path.
const PATHS: Readonly<Record<string, string>> = {
info: '/v1/info',
...Object.fromEntries(PRIMARY_VMS.map((vm) => [
vm.slug,
`/v1/bc/${ vm.slug.charAt(0).toUpperCase() }${ vm.view === 'evm' || vm.view === 'dex' ? '/rpc' : '' }`,
])),
};
// `?chain=<name>` reads another network's node — every sovereign L1 runs its
// own. The name is resolved against the registry, never used as a URL.
function getChainBase(name: string): string | undefined {
const { network } = getCurrentChain();
return CHAINS.find((c) => c.name === name && c.network === network)?.nodeApiUrl;
}
// The node's API origin, derived from the chain RPC URL. That URL points at a
// specific EVM chain (canonical `…/v1/bc/C/rpc`), so it must be reduced to
// scheme+host — concatenating onto the full RPC path produced a malformed URL,
// an HTML 404, and "Unexpected non-whitespace character after JSON" on every
// non-C-chain brand.
function getApiBase(): string {
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL') ?? '';
try {
return rpcUrl ? new URL(rpcUrl).origin : '';
} catch {
return '';
}
}
const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void> => {
if (req.method !== 'POST') {
res.status(405).json({ error: 'Method not allowed' });
return;
}
const endpoint = String(req.query.endpoint ?? '').toLowerCase();
const path = PATHS[endpoint];
if (!path) {
res.status(404).json({ error: `Unknown node endpoint "${ endpoint }"` });
return;
}
const chain = req.query.chain;
if (typeof chain === 'string' && !getChainBase(chain)) {
res.status(404).json({ error: `No node API is registered for chain "${ chain }"` });
return;
}
const base = typeof chain === 'string' ? getChainBase(chain) : getApiBase();
if (!base) {
res.status(500).json({ error: 'NEXT_PUBLIC_NETWORK_RPC_URL not configured' });
return;
}
try {
const response = await fetch(base + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
// Valid RPC yields JSON; a chain the gateway does not route yields its HTML
// 404. Most primary chains are registered on the P-Chain and bootstrapped
// on the node but unreachable, so that is a normal answer about the network,
// not a failure of this request — it comes back 200 carrying a JSON-RPC
// error, the way JSON-RPC reports an unavailable method. Answering 502
// instead put a red failed request in the console of eight of the ten
// chain pages and claimed something was broken when nothing was.
const raw = await response.text();
try {
res.status(200).json(JSON.parse(raw));
} catch {
res.status(200).json({
jsonrpc: '2.0',
id: null,
error: {
code: -32601,
message: `${ path } is not routed by the gateway (HTTP ${ response.status })`,
},
});
}
} catch (error) {
// A timeout or a DNS failure IS a transport fault, and stays one.
res.status(502).json({
error: 'unreachable',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};
export default handler;
-87
View File
@@ -1,87 +0,0 @@
// Server-side proxy for P-chain JSON-RPC calls.
// Bypasses CORS issues with the KrakenD gateway which returns 404 on OPTIONS preflight for /v1/bc/P.
import type { NextApiRequest, NextApiResponse } from 'next';
import { CHAINS, getCurrentChain } from 'configs/app/chainRegistry';
import { getEnvValue } from 'configs/app/utils';
const TIMEOUT_MS = 10_000;
// `?chain=<name>` reads another network's P-Chain — every sovereign L1 runs its
// own, so a cross-network validator count needs one hop per chain. The name is
// resolved against the registry and never used as a URL: an arbitrary origin
// here would make this endpoint a request forwarder for anything on our network.
function getChainBase(name: string): string | undefined {
const { network } = getCurrentChain();
return CHAINS.find((c) => c.name === name && c.network === network)?.nodeApiUrl;
}
// Derive the node's API origin from the chain RPC URL. The RPC URL points at a
// specific EVM chain (canonical `…/v1/bc/C/rpc`); the P-chain
// lives at `<origin>/v1/bc/P`, so we must reduce to the scheme+host origin and
// never naively concatenate onto the full RPC path (which produced a malformed
// URL → HTML 404 → "Unexpected non-whitespace character after JSON" for every
// non-C-chain brand).
function getApiBase(): string {
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL') ?? '';
if (!rpcUrl) {
return '';
}
try {
return new URL(rpcUrl).origin;
} catch {
return '';
}
}
const handler = async(req: NextApiRequest, res: NextApiResponse): Promise<void> => {
if (req.method !== 'POST') {
res.status(405).json({ error: 'Method not allowed' });
return;
}
const chain = req.query.chain;
if (typeof chain === 'string' && !getChainBase(chain)) {
res.status(404).json({ error: `No node API is registered for chain "${ chain }"` });
return;
}
const base = typeof chain === 'string' ? getChainBase(chain) : getApiBase();
if (!base) {
res.status(500).json({ error: 'NEXT_PUBLIC_NETWORK_RPC_URL not configured' });
return;
}
try {
const response = await fetch(`${ base }/v1/bc/P`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
// The node returns JSON for valid P-chain RPC; a wrong URL or gateway
// error yields HTML. Read as text and parse defensively so the client gets a
// clear error rather than an opaque "non-whitespace character" parse crash.
const raw = await response.text();
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
res.status(502).json({
error: 'P-chain request failed',
message: `Upstream returned non-JSON (HTTP ${ response.status }) from ${ base }/v1/bc/P`,
});
return;
}
res.status(200).json(data);
} catch (error) {
res.status(502).json({
error: 'P-chain request failed',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};
export default handler;
+150 -175
View File
@@ -1,13 +1,14 @@
import { Skeleton } from '@luxfi/ui/skeleton';
import { Tag } from '@luxfi/ui/tag';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import type { PrimaryVm } from 'configs/app/primaryChains';
import { getPrimaryVm } from 'configs/app/primaryChains';
import { useBlockchains, useCurrentValidators, useNets } from 'lib/api/pchain';
import type { Head } from 'lib/api/chain';
import { useChainFacts } from 'lib/api/chain';
import { useBlockchains, useCurrentValidators } from 'lib/api/pchain';
import type { PChainBlockchain, PChainValidator } from 'lib/api/pchain';
import { cn } from 'lib/utils/cn';
import DexPage from 'ui/dex/DexPage';
@@ -51,56 +52,6 @@ const KNOWN_VM_IDS: Readonly<Record<string, string>> = {
rWhpuQPF1kb72esV2momhMuTYGkEb1oL29pt2EBXWsBY6MALT: 'PVM',
};
// DAG chain indexer API URLs — one endpoint per primary network chain so
// ChainDetailPage can fetch live stats (block height, vertex count, etc.)
// for any chain the user opens. Missing entries here mean the detail page
// falls back to P-Chain blockchain metadata only, with no realtime stats.
const CHAIN_INDEXER_URLS: Readonly<Record<string, string>> = {
'c-chain': 'https://api-indexer.lux.network',
'p-chain': 'https://api-indexer-pchain.lux.network',
'x-chain': 'https://api-indexer-xchain.lux.network',
'a-chain': 'https://api-indexer-achain.lux.network',
'b-chain': 'https://api-indexer-bchain.lux.network',
'd-chain': 'https://api-indexer-dchain.lux.network',
'g-chain': 'https://api-indexer-gchain.lux.network',
'i-chain': 'https://api-indexer-ichain.lux.network',
'k-chain': 'https://api-indexer-kchain.lux.network',
'm-chain': 'https://api-indexer-mchain.lux.network',
'o-chain': 'https://api-indexer-ochain.lux.network',
'q-chain': 'https://api-indexer-qchain.lux.network',
'r-chain': 'https://api-indexer-rchain.lux.network',
't-chain': 'https://api-indexer-tchain.lux.network',
'z-chain': 'https://api-indexer-zchain.lux.network',
};
interface IndexerStats {
readonly chain_stats: Record<string, unknown>;
readonly dag_stats?: {
readonly total_vertices: number;
readonly total_edges: number;
readonly accepted_vertices: number;
readonly pending_vertices: number;
readonly chain_type: string;
readonly last_updated: string;
};
}
function useChainIndexerStats(slug: string): { stats: IndexerStats | null; isLoading: boolean } {
const indexerUrl = CHAIN_INDEXER_URLS[slug];
const query = useQuery({
queryKey: [ 'chainIndexerStats', slug ],
queryFn: async() => {
if (!indexerUrl) return null;
const res = await fetch(`${ indexerUrl }/stats`);
if (!res.ok) return null;
return await res.json() as IndexerStats;
},
staleTime: 30_000,
enabled: Boolean(indexerUrl),
});
return { stats: query.data ?? null, isLoading: query.isLoading };
}
function formatStake(nanoLux: bigint): string {
const lux = Number(nanoLux) / Math.pow(10, LUX_DECIMALS);
if (lux >= 1_000_000) return `${ (lux / 1_000_000).toFixed(2) }M`;
@@ -113,6 +64,49 @@ function truncateId(id: string, len: number = 16): string {
return `${ id.slice(0, 8) }...${ id.slice(-6) }`;
}
// A chain that we cannot reach and a chain sitting at genesis are different
// states. Rendering the first as 0 is the fabricated zero — it reads as a
// measurement and is not one.
function headValue(head: Head): string {
switch (head.kind) {
case 'height': return head.height.toLocaleString();
case 'unreachable': return 'No public RPC';
default: return 'Unknown';
}
}
// Each VM names its head after its own data model. Calling the X-Chain's a
// "block height" is the same category error as giving it a gas column.
function headLabel(vm: PrimaryVm | undefined): string {
switch (vm?.view) {
case 'platform': return 'P-Chain Height';
case 'utxo': return 'Accepted Height';
default: return 'Block Height';
}
}
function bootstrapLabel(b: boolean | null): string {
if (b === null) return 'Unknown';
return b ? 'Bootstrapped' : 'Syncing';
}
interface StatCardProps {
readonly label: string;
readonly value: string;
readonly isLoading?: boolean;
}
const StatCard = ({ label, value, isLoading = false }: StatCardProps) => (
<div className="p-4 border border-[var(--color-border-divider)] rounded-lg bg-[var(--color-gray-50)] dark:bg-[var(--color-whiteAlpha-50)]">
<span className="block text-xs text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider mb-1">
{ label }
</span>
<Skeleton loading={ isLoading }>
<span className="text-xl font-bold text-[var(--color-text-primary)]">{ value }</span>
</Skeleton>
</div>
);
interface InfoRowProps {
readonly label: string;
readonly value: string;
@@ -180,10 +174,8 @@ const ChainDetailPage = () => {
const router = useRouter();
const slug = (router.query.slug as string || '').toLowerCase();
const { blockchains, isLoading: chainsLoading } = useBlockchains();
const { blockchains } = useBlockchains();
const { validators, isLoading: validatorsLoading } = useCurrentValidators();
const { nets } = useNets();
const { stats: indexerStats } = useChainIndexerStats(slug);
const resolvedChain = React.useMemo<{
blockchain: PChainBlockchain | undefined;
@@ -217,12 +209,22 @@ const ChainDetailPage = () => {
const vmName = KNOWN_VM_IDS[vmId] ?? (vmId ? truncateId(vmId) : 'Unknown');
const chainId = L1_EVM_CHAIN_IDS[slug];
const explorerUrl = EXPLORER_URLS[slug];
const isLoading = chainsLoading || validatorsLoading;
const net = React.useMemo(
() => nets.find((n) => n.id === netId),
[ nets, netId ],
const { facts, isLoading: factsLoading } = useChainFacts(
resolvedChain.meta,
resolvedChain.blockchain?.id ?? null,
);
const isPlatform = resolvedChain.meta?.view === 'platform';
// The P-Chain is the registry, so it never lists itself. Every other primary
// chain has to appear in this network's own answer to be on this network:
// T, R, I, O are in our metadata table and registered nowhere, and M runs on
// devnet alone.
const onThisNetwork = isPlatform ||
!resolvedChain.isPrimary ||
blockchains.some((c) => c.name.toLowerCase() === slug);
const scopeLabel = facts.chainId != null ? 'EVM Chain ID' : 'Network';
const networkScope = resolvedChain.isPrimary ? 'Primary' : 'Sovereign L1';
const scopeValue = facts.chainId != null ? String(facts.chainId) : networkScope;
const netChains = React.useMemo(
() => netId ? blockchains.filter((c) => c.netID === netId) : [],
@@ -260,47 +262,33 @@ const ChainDetailPage = () => {
</span>
</div>
{ !onThisNetwork && (
<div className="p-4 mb-6 border border-[var(--color-border-divider)] rounded-lg">
<span className="text-sm text-[var(--color-text-secondary)]">
{ chainName } is not registered on { config.chain.name || 'this network' }.
Nothing below describes it.
</span>
</div>
) }
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<div className="p-4 border border-[var(--color-border-divider)] rounded-lg bg-[var(--color-gray-50)] dark:bg-[var(--color-whiteAlpha-50)]">
<span className="block text-xs text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider mb-1">
Validators
</span>
<Skeleton loading={ isLoading }>
<span className="text-xl font-bold text-[var(--color-text-primary)]">
{ validators.length }
</span>
</Skeleton>
</div>
<div className="p-4 border border-[var(--color-border-divider)] rounded-lg bg-[var(--color-gray-50)] dark:bg-[var(--color-whiteAlpha-50)]">
<span className="block text-xs text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider mb-1">
Total Stake
</span>
<Skeleton loading={ isLoading }>
<span className="text-xl font-bold text-[var(--color-text-primary)]">
{ formatStake(totalStake) } { config.chain.currency.symbol || 'LUX' }
</span>
</Skeleton>
</div>
<div className="p-4 border border-[var(--color-border-divider)] rounded-lg bg-[var(--color-gray-50)] dark:bg-[var(--color-whiteAlpha-50)]">
<span className="block text-xs text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider mb-1">
Chains on Network
</span>
<Skeleton loading={ isLoading }>
<span className="text-xl font-bold text-[var(--color-text-primary)]">
{ netChains.length }
</span>
</Skeleton>
</div>
<div className="p-4 border border-[var(--color-border-divider)] rounded-lg bg-[var(--color-gray-50)] dark:bg-[var(--color-whiteAlpha-50)]">
<span className="block text-xs text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider mb-1">
Threshold
</span>
<Skeleton loading={ isLoading }>
<span className="text-xl font-bold text-[var(--color-text-primary)]">
{ net?.threshold ?? '-' }
</span>
</Skeleton>
</div>
<StatCard label="Status" value={ bootstrapLabel(facts.bootstrapped) } isLoading={ factsLoading }/>
<StatCard label={ headLabel(resolvedChain.meta) } value={ headValue(facts.head) } isLoading={ factsLoading }/>
{ isPlatform ? (
<>
<StatCard label="Validators" value={ String(validators.length) } isLoading={ validatorsLoading }/>
<StatCard
label="Total Stake"
value={ `${ formatStake(totalStake) } ${ config.chain.currency.symbol || 'LUX' }` }
isLoading={ validatorsLoading }
/>
</>
) : (
<>
<StatCard label="VM" value={ vmName }/>
<StatCard label={ scopeLabel } value={ scopeValue }/>
</>
) }
</div>
<div className="mb-6">
@@ -320,39 +308,6 @@ const ChainDetailPage = () => {
</div>
</div>
{ indexerStats && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
Indexer Stats
</span>
{ indexerStats.dag_stats && (
<Tag size="sm" variant="subtle">
{ indexerStats.dag_stats.chain_type.toUpperCase() }
</Tag>
) }
</div>
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
{ indexerStats.dag_stats && (
<>
<InfoRow label="Total Vertices" value={ String(indexerStats.dag_stats.total_vertices) }/>
<InfoRow label="Total Edges" value={ String(indexerStats.dag_stats.total_edges) }/>
<InfoRow label="Accepted Vertices" value={ String(indexerStats.dag_stats.accepted_vertices) }/>
<InfoRow label="Pending Vertices" value={ String(indexerStats.dag_stats.pending_vertices) }/>
<InfoRow label="Last Updated" value={ indexerStats.dag_stats.last_updated }/>
</>
) }
{ Object.entries(indexerStats.chain_stats).map(([ key, value ]) => (
<InfoRow
key={ key }
label={ key.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') }
value={ typeof value === 'object' ? JSON.stringify(value) : String(value) }
/>
)) }
</div>
</div>
) }
{ netChains.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
@@ -408,54 +363,74 @@ const ChainDetailPage = () => {
</div>
) }
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
Validators
</span>
<Skeleton loading={ validatorsLoading }>
<Tag size="sm" variant="subtle">{ validators.length }</Tag>
</Skeleton>
</div>
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
<div className="hidden lg:flex px-4 py-2 gap-4 border-b border-[var(--color-border-divider)] items-center">
<div className="w-10 shrink-0 text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
#
</div>
<div className="flex-[2] text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
Node ID
</div>
<div className="flex-1 text-right text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
Stake
</div>
<div className="w-20 text-right shrink-0 text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
Uptime
</div>
<div className="w-10 shrink-0 text-center text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
{ '\u2022' }
</div>
{ /* Validators are the P-Chain's data model, not every chain's. Listing
the same primary-network validator set under Q-Chain and Z-Chain and
K-Chain is what made all fifteen pages read alike. */ }
{ isPlatform && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
Validators
</span>
<Skeleton loading={ validatorsLoading }>
<Tag size="sm" variant="subtle">{ validators.length }</Tag>
</Skeleton>
</div>
{ validatorsLoading && (
<div className="px-4 py-6">
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px"/>
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
<div className="hidden lg:flex px-4 py-2 gap-4 border-b border-[var(--color-border-divider)] items-center">
<div className="w-10 shrink-0 text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
#
</div>
<div className="flex-[2] text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
Node ID
</div>
<div className="flex-1 text-right text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
Stake
</div>
<div className="w-20 text-right shrink-0 text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
Uptime
</div>
<div className="w-10 shrink-0 text-center text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider">
{ '\u2022' }
</div>
</div>
) }
{ !validatorsLoading && validators.length === 0 && (
<div className="px-4 py-8 text-center text-[var(--color-text-secondary)] text-sm">
No validators found
</div>
) }
{ validatorsLoading && (
<div className="px-4 py-6">
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px"/>
</div>
) }
{ !validatorsLoading && validators.map((v, i) => (
<ValidatorRow key={ v.nodeID } validator={ v } index={ i }/>
)) }
{ !validatorsLoading && validators.length === 0 && (
<div className="px-4 py-8 text-center text-[var(--color-text-secondary)] text-sm">
No validators found
</div>
) }
{ !validatorsLoading && validators.map((v, i) => (
<ValidatorRow key={ v.nodeID } validator={ v } index={ i }/>
)) }
</div>
</div>
</div>
) }
{ /* Say it plainly. The chain is registered on the P-Chain and running on
the node, but the gateway routes only P, X and C, so no head can be
read from a browser. An empty panel here would read as "nothing
happening on this chain", which is a different and false claim. */ }
{ facts.head.kind === 'unreachable' && (
<div className="mb-6 p-4 border border-[var(--color-border-divider)] rounded-lg">
<span className="text-sm text-[var(--color-text-secondary)]">
{ chainName } is registered on the primary network and
{ facts.bootstrapped === true ? ' bootstrapped on the node' : ' not yet bootstrapped' }
, but its RPC is not published on { config.chain.name || 'this network' }&apos;s public gateway,
so its height and contents cannot be read here.
</span>
</div>
) }
</PrimaryNetworkGuard>
);
};
+42 -10
View File
@@ -17,9 +17,13 @@ import ChainRow from './ChainRow';
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY' as const;
// Primary-network VM chains come from the single source of truth in
// configs/app/primaryChains.ts (mirrored from the node registry), so this page
// and the chain-detail page can never drift apart.
// Which primary chains EXIST is the network's answer, read live from
// platform.getBlockchains; configs/app/primaryChains.ts only says how to
// present one. Listing the hardcoded set gave every network the same fifteen
// rows, four of which (T, R, I, O) are registered on no network at all — the
// node answers "there is no ID with alias: T" — and one of which (M) exists
// only on devnet. The P-Chain is added by hand because it is the registry, so
// it never appears in its own list.
const L1_EVM_CHAIN_IDS: Readonly<Record<string, number>> = {
Zoo: 200200,
@@ -106,6 +110,21 @@ const ChainsPage = () => {
return blockchains.filter((chain) => chain.netID !== PRIMARY_NETWORK_ID);
}, [ blockchains ]);
// The primary chains this network actually runs, in the registry's order,
// each paired with its presentation metadata. A chain the network reports
// but we have no metadata for still gets a row — the network is the source
// of truth, not our table.
const primaryChains = React.useMemo(() => {
const rows = blockchains
.filter((chain) => chain.netID === PRIMARY_NETWORK_ID)
.map((chain) => {
const slug = chain.name.toLowerCase();
return { chain, meta: PRIMARY_VMS.find((vm) => vm.slug === slug) };
});
const pChain = PRIMARY_VMS.find((vm) => vm.slug === 'p-chain');
return pChain ? [ { chain: undefined, meta: pChain }, ...rows ] : rows;
}, [ blockchains ]);
const handlePrimaryClick = React.useCallback(() => {
setActiveTab(TAB_IDS.primary);
}, []);
@@ -143,15 +162,28 @@ const ChainsPage = () => {
{ activeTab === TAB_IDS.primary && (
<div className="border border-[var(--color-border-divider)] rounded-md overflow-hidden">
<TableHeader showNetId={ false }/>
{ PRIMARY_VMS.map((chain) => (
{ isLoading && (
<div className="px-4 py-6">
<Skeleton loading={ true } h="20px" mb={ 3 }/>
<Skeleton loading={ true } h="20px" mb={ 3 }/>
<Skeleton loading={ true } h="20px"/>
</div>
) }
{ !isLoading && primaryChains.length === 0 && (
<div className="px-4 py-8 text-center text-[var(--color-text-secondary)] text-sm">
The P-Chain did not answer, so this network&apos;s chain list is unknown
</div>
) }
{ !isLoading && primaryChains.map(({ chain, meta }) => (
<ChainRow
key={ chain.name }
name={ chain.name }
fullName={ chain.fullName }
vmLabel={ chain.vm }
chainId={ chain.chainId }
key={ meta?.slug ?? chain?.id }
name={ meta?.name ?? chain?.name ?? '' }
fullName={ meta?.fullName }
blockchainId={ chain?.id }
vmLabel={ meta?.vm ?? 'Unknown' }
chainId={ meta?.chainId ?? null }
isActive
href={ `/chains/${ chain.slug }` }
href={ `/chains/${ meta?.slug ?? (chain?.name ?? '').toLowerCase() }` }
/>
)) }
</div>