Files
2026-10-29 11:45:51 -04:00

106 lines
6.3 KiB
TypeScript
Executable File

import { NETWORK, type CanonicalPage, type TestnetBlock, type TestnetSnapshot, type TestnetSummary } from './testnet.ts';
import { expectedChainIdentity } from './chain-identity.ts';
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
const MAX_HISTORY_BYTES = 64 * 1024 * 1024;
const MAX_CLIENT_BLOCKS = 10000;
const hex = /^[a-f0-9]{64}$/;
/** Bounded transport and continuity checks, NOT independent consensus/SPV verification. */
export class StateClient {
private cache: { chainId: string; blocks: TestnetBlock[]; bytes: number } | undefined;
private inFlight: Promise<TestnetSnapshot> | undefined;
private readonly fetcher: typeof fetch;
constructor(fetcher: typeof fetch = globalThis.fetch) {
// Browser Web APIs require the Window/global receiver, not this StateClient instance.
this.fetcher = fetcher.bind(globalThis);
}
async json<T>(path: string, signal: AbortSignal): Promise<T> {
const response = await this.fetcher(`/api/${path}`, { cache: 'no-store', redirect: 'error', signal });
if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !response.body) throw new Error('Node must return JSON.');
const announced = response.headers.get('content-length');
if (announced !== null && (!/^\d+$/.test(announced) || Number(announced) > MAX_PAGE_BYTES)) {
await response.body.cancel(); throw new Error('Node response exceeds client page limit.');
}
const reader = response.body.getReader(), chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const item = await reader.read(); if (item.done) break;
bytes += item.value.byteLength;
if (bytes > MAX_PAGE_BYTES) throw new Error('Node response exceeds client page limit.');
chunks.push(item.value);
}
} finally { await reader.cancel(); reader.releaseLock(); }
const joined = new Uint8Array(bytes); let offset = 0;
for (const chunk of chunks) { joined.set(chunk, offset); offset += chunk.byteLength; }
const data = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joined));
if (!response.ok) throw Object.assign(new Error(typeof data.error === 'string' ? data.error : 'Node RPC failed.'), { status: response.status });
return data;
}
load(): Promise<TestnetSnapshot> {
if (this.inFlight) return this.inFlight;
const result = this.loadFresh();
this.inFlight = result;
void result.finally(() => { if (this.inFlight === result) this.inFlight = undefined; }).catch(() => {});
return result;
}
private async summary(signal: AbortSignal) {
const s = await this.json<TestnetSummary>('summary', signal);
const expected = await expectedChainIdentity();
if (s.network !== NETWORK || s.chainId !== expected.chainId || s.genesis !== expected.genesis ||
s.algorithm !== 'RandomX' || !hex.test(s.chainId) || !hex.test(s.tip) || !hex.test(s.genesis) ||
!Number.isSafeInteger(s.height) || s.height < 0 || !Array.isArray(s.pendingTransfers) || s.pendingTransfers.length > 256 ||
!/^\d{1,256}$/.test(s.cumulativeWork)) throw new Error('Incompatible node summary.');
if (s.height > MAX_CLIENT_BLOCKS) throw new Error('This node exceeds the browser history limit (10,000 blocks). A paged explorer/wallet is required; no partial ownership state will be shown.');
return s;
}
private async page(s: TestnetSummary, offset: number, signal: AbortSignal, limit = 16) {
const p = await this.json<CanonicalPage>(`canonical?tip=${s.tip}&offset=${offset}&limit=${limit}`, signal);
const count = Math.min(limit, s.height - offset), next = offset + count < s.height ? offset + count : null;
if (p.network !== s.network || p.chainId !== s.chainId || p.tip !== s.tip || p.height !== s.height || p.offset !== offset ||
p.next !== next || !Array.isArray(p.blocks) || p.blocks.length !== count) throw new Error('Invalid canonical pagination.');
for (let i = 0; i < p.blocks.length; i++) {
const b = p.blocks[i];
if (b.network !== s.network || b.chainId !== s.chainId || b.height !== offset + i + 1 || !hex.test(b.hash) ||
!Array.isArray(b.transactions) || b.transactions.length > 64) throw new Error('Invalid canonical block page.');
}
return p;
}
private async loadFresh(): Promise<TestnetSnapshot> {
const signal = AbortSignal.timeout(30000);
for (let attempt = 0; attempt < 2; attempt++) {
try {
const s = await this.summary(signal);
let blocks: TestnetBlock[] = [], bytes = 0;
if (this.cache?.chainId === s.chainId && this.cache.blocks.length <= s.height) {
const cached = this.cache;
if (!cached.blocks.length || cached.blocks.at(-1)!.hash === s.tip ||
(await this.page(s, cached.blocks.length - 1, signal, 1)).blocks[0]?.hash === cached.blocks.at(-1)!.hash) {
blocks = cached.blocks.slice(); bytes = cached.bytes;
}
}
while (blocks.length < s.height) {
const p = await this.page(s, blocks.length, signal);
const encodedBytes = new TextEncoder().encode(JSON.stringify(p.blocks)).byteLength;
bytes += encodedBytes;
if (bytes > MAX_HISTORY_BYTES) throw new Error('Browser history exceeds 64 MiB; no incomplete ownership state will be used.');
for (const b of p.blocks) {
if (b.previousHash !== (blocks.at(-1)?.hash ?? s.genesis)) throw new Error('Canonical block continuity mismatch.');
blocks.push(b);
}
}
if ((blocks.at(-1)?.hash ?? s.genesis) !== s.tip) throw new Error('Canonical tip does not match loaded history.');
const final = await this.summary(signal);
if (final.chainId !== s.chainId || final.tip !== s.tip || final.height !== s.height) throw Object.assign(new Error('Canonical tip changed during loading.'), { status: 409 });
this.cache = { chainId: s.chainId, blocks, bytes };
const { height: _height, revision: _revision, ...metadata } = final;
return { ...metadata, blocks: blocks.slice(), transfers: blocks.flatMap(b => b.transactions) };
} catch (error) {
if (attempt === 0 && (error as { status?: number }).status === 409) { this.cache = undefined; continue; }
throw error;
}
}
throw new Error('Could not load a stable canonical chain.');
}
}