0.3.16 app side: the forged-record harness; ledger P21 round 4 and X31 (the export-disk class); bench-log verify costs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 21:05:19 +00:00
parent b9f208f181
commit 2903134773
3 changed files with 432 additions and 97 deletions

View file

@ -1952,3 +1952,26 @@ genesis replay left behind, with `--igneum-exec-snapshot=peer`, sets the pair as
A's state with no hand action; its state root at 60 equals A's. `reorg.mjs` 18/18 in 262.1 s on the same binary; the exec
suite 20. Consequence: a PC or a box left with the tip-0 pair recovers on its own once a hand serves (the 5-minute
no-progress rule fires the ask even when the error text is new); until 0.3.14.1 the recovery file by hand is the way.
### 6 October 2026, 20:38Z to 21:03Z, proof verification on the consensus path (fork exec-sync-0313 da2d17ec)
The in-process SP1 verifier (sp1-sdk LightProver, the embedded keys) on a compressed shard proof of
block-58927-empty-reward (1,272,897 B), one core, including the LightProver setup each call:
| Machine | Profile | Verify | Command |
|---|---|---|---|
| M5 Max (this Mac) | release | 0.204 s, 0.232 s (two runs) | `cargo test --release -p igneum-exec nativeverify -- --nocapture` with the fixture |
| M5 Max | debug | 1.226 s | the same without `--release` |
| igneum-build-1 (Linux, the build box) | release | 0.400 s | `tools/build-remote.sh --no-fetch -- test --release -p igneum-exec nativeverify -- --nocapture` |
| EPYC 7K62, 2019 Zen 2 (rz-4090, earlier today) | release, through the host | 0.53 s | bench/proof-systems rows |
`node tools/exec-sync/forged.mjs` (fast-time simnet, `proving_consensus_verify_daa` 0 under the embedded ids): 8/8 in
23.6 s. The attacker A (`IGNEUM_TEST_SKIP_PROOF_RULE=1`, Trust pool) carried a forged shard record at block 27; B asked
A for the proof (22:03:31.270 local), held it 1 ms later, refused the block at 31.522 ("the proof bytes are not a bincode
SP1 proof"; the verify itself 0.000 s), never included it and paid nothing; before the forgery B followed A block for
block. An unmodified A refused its own carrying block ("block is known to be invalid", 20:45Z run).
Consequence per tier: the rule costs an honest node nothing on the hot path (a proof verified at relay time is a
cache hit); a cold proof costs a 2019 core 0.4 to 0.5 s and a current one 0.2 s, in parallel per record; a forger
loses its block and its peer. Until 0.3.16.1's finality-horizon skip, a fresh joiner fetches every carried proof in
its IBD window from its syncer (1.27 MB a record), so a syncer must still hold them.

File diff suppressed because it is too large Load diff

122
tools/exec-sync/forged.mjs Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env node
// Consensus proof verification harness (0.3.16, 6 October 2026; ledger P21): a forged proof record on a private
// fast-time simnet (ports 29770+, suffix 959; never the live devnet). The object pins the embedded program ids and
// sets proving_consensus_verify_daa 0. Cases, each asserted:
// 1. node A (the attacker: IGNEUM_TEST_SKIP_PROOF_RULE=1 turns its own body rule off, and IGNEUM_PROOF_VERIFY=trust
// makes its pool carry whatever it is given) mines; the harness signs a shard record for a mined block with random proof bytes
// that hash to proof_hash (the statement is the node's own, so only the proof is fake), submits it to A; A's next
// template carries it (igneum_getProofRecords shows it carried)
// 2. node B (peered, the same object, no external verifier either) refuses the carrying block: its log carries
// "IgneumInvalidProofRecord", its chain never includes that block (eth_getBlockByNumber at the carrier's height
// differs or B stays below it), and B pays nothing for the shard
// 3. known-finished: before the forgery, B followed A block for block (same hash at the same height)
// The positive leg (a real compressed proof verifies, a wrong statement or key is refused) is the unit test in
// igneum/exec/src/nativeverify.rs on a proof made by the host (IGNEUM_PROOF_FIXTURE); a simnet statement cannot be
// proven inside this harness's time.
// Usage: node tools/exec-sync/forged.mjs IGNEUM_EXEC_BIN=<dir with igneumd and igneum-miner>
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, openSync } from 'node:fs';
import { createHash, randomBytes } from 'node:crypto';
const ROOT = new URL('../../', import.meta.url).pathname;
const REL = process.env.IGNEUM_EXEC_BIN || `${ROOT}vendor/igneum-node/target-exec-sync/release`;
const IGNEUMD = `${REL}/igneumd`;
const MINER = `${REL}/igneum-miner`;
const TMP = '/tmp/igneum-exec-forged';
const BASE = 29770, SUFFIX = 959;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const MANIFEST = JSON.parse(readFileSync(`${ROOT}proving/igneum-prove/elf/manifest.json`, 'utf8'));
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const override = `${TMP}/override.json`;
let text = readFileSync(FILE, 'utf8').replace(/\s*"proving_v2_activation_daa":\s*\d+,?/, '').replace(/"skip_proof_of_work":\s*false/, '"skip_proof_of_work": true');
// the consensus verification switch at 0 under the embedded ids (the text edit keeps the u64::MAX values intact)
text = text.replace(/\n}\s*$/, `,\n "proving_consensus_verify_daa": 0,\n "proving_shard_program_id": "${MANIFEST.shard.program_id}",\n "proving_aggregator_id": "${MANIFEST.aggregator.program_id}"\n}\n`);
if (!/"proving_consensus_verify_daa": 0/.test(text)) throw new Error('override edit failed');
writeFileSync(override, text);
const t0 = Date.now();
const log = (m) => console.log(`${new Date().toISOString().slice(11, 23)} t=${((Date.now() - t0) / 1000).toFixed(1)}s ${m}`);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const hexn = (h) => Number(BigInt(h));
const started = [];
class Node {
constructor(i, connect = [], env = {}) { this.env = env; this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3; this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; }
get evm() { return `http://127.0.0.1:${this.evmPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc',
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes', ...this.connect.map(c => `--addpeer=${c}`)];
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_PROOF_VERIFY: 'trust', ...this.env } });
started.push(this.proc);
for (let k = 0; k < 120; k++) { try { await this.eth('eth_chainId'); return this; } catch { await sleep(500); } }
throw new Error(`node ${this.i} did not answer on ${this.evm}: ${this.logText().slice(-400)}`);
}
async stop() { try { this.proc.kill('SIGINT'); } catch { } for (let k = 0; k < 60; k++) { if (this.proc.exitCode !== null) return; await sleep(500); } try { this.proc.kill('SIGKILL'); } catch { } }
async eth(method, params = []) {
const r = await fetch(this.evm, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
const j = await r.json(); if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`); return j.result;
}
logText() { try { return readFileSync(this.logFile, 'utf8'); } catch { return ''; } }
}
function mine(node, label) {
const out = openSync(`${TMP}/miner-${label}.log`, 'a');
const p = spawn(MINER, ['vmine', `grpc://127.0.0.1:${node.grpcPort}`, '3600', '--label', label, '--share', '1', '--bps', '2', '--evm-address', '0x4343434343434343434343434343434343434343'], { stdio: ['ignore', out, out] });
started.push(p); return p;
}
const checks = [];
const check = (name, ok, detail) => { checks.push({ name, ok }); log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ': ' + JSON.stringify(detail).slice(0, 320) : ''}`); if (!ok) throw new Error(`check failed: ${name}`); };
async function waitTip(n, want, secs = 600) { for (let k = 0; k < secs * 2; k++) { const tip = hexn(await n.eth('eth_blockNumber')); if (tip >= want) return tip; await sleep(500); } throw new Error(`node ${n.i} did not reach ${want}`); }
const PAYOUT = '0x4343434343434343434343434343434343434343';
async function main() {
// A is the attacker: its node has the body rule switched off (the harness hook), so it carries the fake proof
// itself; an unmodified node refuses its own carrying block (seen 20:45Z: the forged record was never carried)
const a = await new Node(0, [], { IGNEUM_TEST_SKIP_PROOF_RULE: '1' }).start();
const pin = a.logText().match(/consensus proof verification from DAA score 0 \(shard program id (0x[0-9a-f]+)/);
check('A starts with consensus proof verification on under the embedded ids', !!pin && pin[1] === MANIFEST.shard.program_id, { line: pin && pin[0].slice(0, 120) });
const ma = mine(a, 'forger');
await waitTip(a, 20);
const b = await new Node(1, [`127.0.0.1:${a.p2pPort}`]).start();
const h0 = await waitTip(b, 24);
const [ra, rb] = await Promise.all([a.eth('eth_getBlockByNumber', ['0x' + h0.toString(16), false]), b.eth('eth_getBlockByNumber', ['0x' + h0.toString(16), false])]);
check('known-finished: B follows A block for block before the forgery', ra.hash === rb.hash, { height: h0 });
// the forged record: a mined block's shard 0, the node's own statement for the payout, random proof bytes
const target = hexn(await a.eth('eth_blockNumber')) - 2;
const plan = await a.eth('igneum_getShardPlan', ['0x' + target.toString(16), PAYOUT]);
const shard = plan.shards[0];
check('the shard plan reports the statement for the payout', typeof shard.statement === 'string' && shard.statement.length === 66, { block: target, statement: shard.statement && shard.statement.slice(0, 18) });
const fake = randomBytes(4096);
const proofHash = '0x' + createHash('sha256').update(fake).digest('hex');
const chain = `igneum-devnet-${SUFFIX}`;
const signed = spawnSync(MINER, ['sign-record', 'forger', chain, plan.hash.replace(/^0x/, ''), String(target), '0', PAYOUT, shard.statement, proofHash], { encoding: 'utf8' });
const rec = JSON.parse(signed.stdout.trim().split('\n').pop());
const sub = await a.eth('igneum_submitProofRecord', [{ record: rec.record, proof: '0x' + fake.toString('hex') }]);
check('A (trust mode) accepts the forged record into its pool', sub.accepted === true, sub);
// A carries it in a later block; B must refuse that block
let carrier = null;
for (let k = 0; k < 120 && !carrier; k++) {
const pr = await a.eth('igneum_getProofRecords', ['0x' + target.toString(16)]);
const c = (pr.carried || []).find(x => x.proofHash === proofHash || (x.record && x.record.proofHash === proofHash));
if (c) carrier = hexn(c.carrierNumber); else await sleep(500);
}
check('A carries the forged record in a later block', carrier !== null, { carrier });
// B: the rule fires; B never has A's carrier block
let bline = null;
for (let k = 0; k < 120 && !bline; k++) { bline = b.logText().split('\n').find(l => /IgneumInvalidProofRecord|carried proof record's proof does not verify/.test(l)) || null; if (!bline) await sleep(500); }
check('B refuses the carrying block: the consensus rule fires', !!bline, { line: bline && bline.slice(-220) });
await sleep(4000);
const tipB = hexn(await b.eth('eth_blockNumber'));
const carrierHashA = (await a.eth('eth_getBlockByNumber', ['0x' + carrier.toString(16), false])).hash;
let bHasCarrier = false;
if (tipB >= carrier) { const blk = await b.eth('eth_getBlockByNumber', ['0x' + carrier.toString(16), false]); bHasCarrier = blk && blk.hash === carrierHashA; }
check("B's chain never includes A's carrying block", !bHasCarrier, { tipB, carrier });
const paidB = (await b.eth('igneum_getProvingStatus'))['paidShards'];
check('B pays nothing for the forged shard', hexn(paidB || '0x0') === 0, { paidB });
try { ma.kill('SIGINT'); } catch { }
await a.stop(); await b.stop();
log(`RESULT forged-record harness: PASSED (${checks.length} checks) in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
}
main().then(() => cleanup(0)).catch(e => { log(`FAILED: ${e.message}`); cleanup(1); });
function cleanup(code) { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } setTimeout(() => { for (const p of started) { try { p.kill('SIGKILL'); } catch { } } process.exit(code); }, 3000); }