Files
explore/pages/api/node/[endpoint].spec.ts
zeekay 0ac8927c3d The P-Chain is read through typed GETs, from the P-Chain that secures the chain
luxd serves no P-Chain JSON-RPC, so every platform.* read the explorer sent
answered 404 and the validators, chains and heights widgets were empty on Lux
and Zoo. Each read is now GET /v1/chain/P/ops/{validators,blockchains,height},
coded against the node's OpenAPI 3.1 document, through GET
/v1/node/p-chain?op=<op>. The proxy's fixed table allows those three ops and one
NodeID parameter; a POST there answers 405.

ChainEntry.pChain is where the P-Chain that secures a chain is read: Lux reads
its own; Zoo, an L2 carried by Lux's validator set, reads Lux's and never its
own node's; Hanzo, on its own committee until hanzod joins as an L2, has none.
P-Chain UI names whose it is ("Lux primary network validators securing Zoo
Network") and prices stake in LUX. The cross-network validator sum is gone: it
counted Lux's validators again under Zoo.

The top bar shows a link only where its page has content: Chains, Bridge and
DEX on the Lux C-Chain host (their pages now 404 elsewhere), Validators where a
P-Chain secures the chain, Stats where there is a P-Chain panel or a stats
service. A P-Chain at genesis renders height #0 rather than nothing. The chain
page's uptime is no longer multiplied by 100.

Claude-Session: https://claude.ai/code/session_011Ge9UnfkEK37Lapx5nfPQK
2026-09-23 20:04:11 -07:00

103 lines
4.0 KiB
TypeScript

// The node proxy forwards a P-Chain read to the P-Chain that secures the
// requesting host's chain, and to nothing else: Zoo reads Lux's, never its own
// node's; Hanzo, which no P-Chain secures, is refused. Every op and parameter
// comes from a fixed table.
import type { NextApiRequest, NextApiResponse } from 'next';
import { beforeEach, describe, expect, it } from 'vitest';
import handler from './[endpoint]';
interface Answer {
status?: number;
body?: unknown;
}
async function call(host: string, query: Record<string, string>, method = 'GET'): Promise<Answer> {
const answer: Answer = {};
const res = {
status(code: number) {
answer.status = code;
return res;
},
json(body: unknown) {
answer.body = body;
return res;
},
};
const req = { method, query: { endpoint: 'p-chain', ...query }, headers: { host }, body: undefined };
await handler(req as unknown as NextApiRequest, res as unknown as NextApiResponse);
return answer;
}
function forwarded(): Array<string> {
return fetchMock.mock.calls.map(([ input ]) => String(input));
}
describe('GET /v1/node/p-chain', () => {
beforeEach(() => {
fetchMock.resetMocks();
fetchMock.mockResponse(JSON.stringify({ height: '0' }));
});
it.each([
[ 'explore.lux.network', 'https://api.lux.network' ],
[ 'explore.zoo.network', 'https://api.lux.network' ],
[ 'explore.zoo.ngo', 'https://api.lux.network' ],
[ 'explore.lux-test.network', 'https://api.lux-test.network' ],
[ 'explore.zoo-test.network', 'https://api.lux-test.network' ],
])('%s reads the P-Chain at %s', async(host, origin) => {
const answer = await call(host, { op: 'height' });
expect(answer).toEqual({ status: 200, body: { height: '0' } });
expect(forwarded()).toEqual([ `${ origin }/v1/chain/P/ops/height` ]);
});
it('never reads a Zoo node\'s own P-Chain', async() => {
await call('explore.zoo.network', { op: 'validators' });
expect(forwarded().join(' ')).not.toContain('zoo');
});
it.each([ 'explore.hanzo.network', 'explore.hanzo.ai' ])('%s has no P-Chain and forwards nothing', async(host) => {
const answer = await call(host, { op: 'validators' });
expect(answer.status).toBe(404);
expect(forwarded()).toEqual([]);
});
it('forwards one NodeID and drops every parameter the op does not take', async() => {
const nodeID = 'NodeID-DwsrqSkPoE3pXWrUt9nkJ5yBycwRQ246X';
await call('explore.lux.network', { op: 'validators', nodeIDs: nodeID, netID: 'x', url: 'https://evil.example' });
expect(forwarded()).toEqual([ `https://api.lux.network/v1/chain/P/ops/validators?nodeIDs=${ nodeID }` ]);
});
it.each([ 'NodeID-../../info', 'https://evil.example', 'NodeID-0OIl' ])('refuses nodeIDs=%s', async(nodeIDs) => {
const answer = await call('explore.lux.network', { op: 'validators', nodeIDs });
expect(answer.status).toBe(400);
expect(forwarded()).toEqual([]);
});
it.each([ 'balance', 'tx', '../info', '' ])('refuses op=%s', async(op) => {
const answer = await call('explore.lux.network', { op });
expect(answer.status).toBe(404);
expect(forwarded()).toEqual([]);
});
it('refuses JSON-RPC: the P-Chain answers GETs only', async() => {
const answer = await call('explore.lux.network', { op: 'height' }, 'POST');
expect(answer.status).toBe(405);
expect(forwarded()).toEqual([]);
});
it('passes the node\'s status and problem document through', async() => {
fetchMock.mockResponse(JSON.stringify({ type: 'about:blank', status: 503, detail: 'bootstrapping' }), { status: 503 });
const answer = await call('explore.lux.network', { op: 'height' });
expect(answer).toEqual({ status: 503, body: { type: 'about:blank', status: 503, detail: 'bootstrapping' } });
});
it('reports a gateway page that is not JSON as unreadable', async() => {
fetchMock.mockResponse('404 page not found', { status: 404 });
const answer = await call('explore.lux.network', { op: 'height' });
expect(answer.status).toBe(502);
});
});