diff --git a/execution-kernel/claim-chain-detector.mjs b/execution-kernel/claim-chain-detector.mjs new file mode 100644 index 0000000..cc4482b --- /dev/null +++ b/execution-kernel/claim-chain-detector.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +'use strict'; +/* + * claim-chain-detector.mjs - Execution Kernel Stage 2: the claim-chain gap detector. + * + * The kernel treats execution proofs as an audit layer OVER the chain, never under it + * (finality stays QBFT plus Falcon). Each ExecutionClaim covers a window [h1,h2] and carries + * prevStateRoot, postStateRoot, txRoot, vkey, proof. For the layer to mean anything, three + * properties must hold continuously, and this tool measures all three from the live chain: + * + * 1. LINKAGE - claim[n].prevStateRoot == claim[n-1].postStateRoot, and the first claim's + * prevStateRoot equals the chain's state root at h1-1. No forged jump. + * 2. NO GAPS - windows tile the covered range with no hole and no overlap. + * 3. ANCHORING - each claim's postStateRoot equals the header.stateRoot the chain published + * at that height. The proof proves the state the real chain committed, not a + * parallel state. + * + * And it reports LAG as a first-class metric: how far the newest claim trails the chain head. + * Lag is not a failure, it is the cost curve of proving. Hiding it is the failure. + * + * There is no on-chain proof registry with real traffic yet (aggregator V1 is deprecated and + * fenced), so claims are read from a claims file (--claims path) whose shape mirrors the + * on-chain ProvenBlock record. When a public claim endpoint exists, point --claims at it. + * A SYNTHETIC mode (--synthesize N) builds a correct claim chain of N windows from the live + * header state roots, so the detector's own logic can be exercised, and NEGATIVE CONTROLS + * (--inject) plant exactly one defect and require the detector to go red. + * + * Read-only. Only eth_getBlockByNumber / eth_blockNumber against a public RPC. + * + * node claim-chain-detector.mjs --synthesize 8 --window 4 + * node claim-chain-detector.mjs --synthesize 8 --window 4 --inject gap + * node claim-chain-detector.mjs --synthesize 8 --window 4 --inject linkage + * node claim-chain-detector.mjs --synthesize 8 --window 4 --inject anchor + * node claim-chain-detector.mjs --claims claims.json + */ +import fs from 'node:fs'; + +const RPC = process.env.AERE_RPC || 'https://rpc.aere.network'; +const args = process.argv.slice(2); +const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 && i + 1 < args.length ? args[i + 1] : def; }; +const WINDOW = parseInt(opt('--window', '4'), 10); +const SYNTH = opt('--synthesize', null); +const CLAIMS_FILE = opt('--claims', null); +const INJECT = opt('--inject', null); +const SAFETY = parseInt(opt('--safety', '6'), 10); + +let rpcCalls = 0; +async function rpc(method, params) { + rpcCalls++; + const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json', connection: 'close' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(20000) }); + if (!r.ok) throw new Error(method + ' -> HTTP ' + r.status); + const j = await r.json(); + if (j.error) throw new Error(method + ' -> ' + JSON.stringify(j.error)); + return j.result; +} +const stateRootAt = async (h) => { + const b = await rpc('eth_getBlockByNumber', ['0x' + h.toString(16), false]); + return b ? b.stateRoot : null; +}; + +// Build a correct claim chain of `count` windows of `WINDOW` blocks each, ending near head. +async function synthesizeChain(count) { + const head = parseInt(await rpc('eth_blockNumber', []), 16) - SAFETY; + const h2 = head; + const h1 = h2 - count * WINDOW + 1; + const claims = []; + for (let w = 0; w < count; w++) { + const a = h1 + w * WINDOW; + const b = a + WINDOW - 1; + claims.push({ + h1: a, h2: b, + prevStateRoot: await stateRootAt(a - 1), + postStateRoot: await stateRootAt(b), + chainId: 2800, + }); + } + return { firstPrevAt: h1 - 1, firstPrev: await stateRootAt(h1 - 1), claims }; +} + +function plant(chain, kind) { + const c = chain.claims; + const mid = Math.floor(c.length / 2); + if (kind === 'gap') { c[mid].h1 += 1; return 'window ' + mid + ' start shifted +1 (a one-block hole appears before it)'; } + if (kind === 'overlap') { c[mid].h1 -= 1; return 'window ' + mid + ' start shifted -1 (it overlaps the previous window)'; } + if (kind === 'linkage') { c[mid].prevStateRoot = '0x' + 'ba'.repeat(32); return 'window ' + mid + ' prevStateRoot corrupted (chain link broken)'; } + if (kind === 'anchor') { + // corrupt the LAST window's postStateRoot: no next claim links to it, so this is caught + // ONLY by the anchoring check (postStateRoot == header.stateRoot), not by linkage. + const last = c.length - 1; + c[last].postStateRoot = '0x' + 'ad'.repeat(32); + return 'last window postStateRoot != header.stateRoot (proves a parallel state, isolated from linkage)'; + } + throw new Error('unknown inject: ' + kind); +} + +(async () => { + console.log('Execution Kernel Stage 2: claim-chain gap detector'); + console.log('RPC ' + RPC + ', window ' + WINDOW + '\n'); + + let chain; + if (SYNTH) chain = await synthesizeChain(parseInt(SYNTH, 10)); + else if (CLAIMS_FILE) chain = JSON.parse(fs.readFileSync(CLAIMS_FILE, 'utf8')); + else { console.error('give --synthesize N or --claims path'); process.exit(2); } + + let plantedNote = null; + if (INJECT) { plantedNote = plant(chain, INJECT); console.log('NEGATIVE CONTROL: planted ' + INJECT + ' -> ' + plantedNote + '\n'); } + + const problems = []; + const c = chain.claims; + + // 1. linkage: first claim vs chain, then claim to claim + if (chain.firstPrev && c[0].prevStateRoot !== chain.firstPrev) + problems.push('LINKAGE: first claim prevStateRoot != chain state root at ' + chain.firstPrevAt); + for (let i = 1; i < c.length; i++) + if (c[i].prevStateRoot !== c[i - 1].postStateRoot) + problems.push('LINKAGE: claim[' + i + '].prevStateRoot != claim[' + (i - 1) + '].postStateRoot'); + + // 2. gaps / overlaps + for (let i = 1; i < c.length; i++) { + if (c[i].h1 > c[i - 1].h2 + 1) problems.push('GAP: hole between block ' + c[i - 1].h2 + ' and ' + c[i].h1); + if (c[i].h1 <= c[i - 1].h2) problems.push('OVERLAP: window ' + i + ' starts at ' + c[i].h1 + ', inside window ' + (i - 1)); + } + + // 3. anchoring: each postStateRoot == header.stateRoot at h2 + for (let i = 0; i < c.length; i++) { + const real = await stateRootAt(c[i].h2); + if (real && c[i].postStateRoot !== real) + problems.push('ANCHOR: claim[' + i + '] postStateRoot != header.stateRoot at ' + c[i].h2); + } + + // lag as a first-class metric + const head = parseInt(await rpc('eth_blockNumber', []), 16); + const newest = c[c.length - 1].h2; + const lag = head - newest; + console.log('claims: ' + c.length + ' windows, covering blocks ' + c[0].h1 + '..' + newest); + console.log('LAG (first-class metric): newest claim trails head by ' + lag + ' blocks (' + (lag * 0.53).toFixed(1) + ' s at 0.53 s/block)'); + console.log('RPC calls: ' + rpcCalls + '\n'); + + if (INJECT) { + if (problems.length) { console.log('NEGATIVE CONTROL PASSED: detector went red -> ' + problems[0]); process.exit(0); } + console.log('NEGATIVE CONTROL FAILED: the planted ' + INJECT + ' was NOT caught. The detector is theatre.'); process.exit(1); + } + if (problems.length) { console.log('RESULT: BROKEN CLAIM CHAIN'); problems.forEach((p) => console.log(' * ' + p)); process.exit(1); } + console.log('RESULT: claim chain is continuous, linked, and anchored across ' + c.length + ' windows. Lag published above.'); + process.exit(0); +})().catch((e) => { console.error('ERROR: ' + e.message); process.exit(2); });