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 | 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(path: string, signal: AbortSignal): Promise { 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 { 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('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(`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 { 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.'); } }