From 42dd039ddb1a4f9181b5c5272f0b8605b485b2e8 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Sat, 15 Aug 2026 22:15:54 +0300 Subject: [PATCH] Execution Kernel Stage 2: the claim-chain gap detector, with lag as a first-class metric Execution proofs are an audit layer OVER the chain, never under it. For that layer to mean anything, the claim chain must be continuous, and this tool measures three properties from the live chain: linkage (each window's prevStateRoot equals the previous window's postStateRoot and the chain's real root before the first), no gaps or overlaps in the tiling, and anchoring (each postStateRoot equals the header.stateRoot the chain actually published, so the proof proves the committed state and not a parallel one). It reports proving LAG as a first-class number, because lag is the cost curve of proving and hiding it is the failure. Run and measured 2026-08-15 against rpc.aere.network: an 8-window synthetic chain built from live state roots verifies clean; four negative controls (gap, overlap, linkage, anchor) each make the detector go red, and the anchor plant is isolated on the last window so it trips the anchoring rule specifically, not linkage. Read-only, installs nothing. Points at a claims file when a public proof endpoint exists; the deprecated aggregator V1 stays fenced. --- execution-kernel/claim-chain-detector.mjs | 147 ++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 execution-kernel/claim-chain-detector.mjs 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); });