Improvements
This commit is contained in:
Executable
+99
@@ -0,0 +1,99 @@
|
||||
import { NETWORK, type CanonicalPage, type TestnetBlock, type TestnetSnapshot, type TestnetSummary } from './testnet.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 = fetch) { this.fetcher = fetcher; }
|
||||
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);
|
||||
if (s.network !== NETWORK || 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.');
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export type TestnetSnapshot = {
|
||||
blocks: TestnetBlock[]; transfers: TestnetTransaction[]; pendingTransfers: TestnetTransaction[];
|
||||
peers: number; algorithm: 'RandomX'; maturity: number;
|
||||
};
|
||||
export type TestnetSummary = Omit<TestnetSnapshot, 'blocks' | 'transfers'> & { height: number; revision: number };
|
||||
export type CanonicalPage = { network: typeof NETWORK; chainId: string; tip: string; height: number;
|
||||
offset: number; next: number | null; blocks: TestnetBlock[] };
|
||||
export type TestnetDeck = {
|
||||
network: typeof NETWORK; chainId: string; address: string; tip: string;
|
||||
offset: number; limit: number; total: number; next: number | null;
|
||||
|
||||
Reference in New Issue
Block a user