From 1e9ddb457e7b0b4691bc9ae8371f1e34ec418dad Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:05:19 +0000 Subject: [PATCH] 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 --- docs/bench-log.md | 23 +++++++ docs/fud-ledger.md | 9 +++ tools/exec-sync/forged.mjs | 122 +++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 tools/exec-sync/forged.mjs diff --git a/docs/bench-log.md b/docs/bench-log.md index 630b3d2dd..131a6eccc 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -2292,3 +2292,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. diff --git a/docs/fud-ledger.md b/docs/fud-ledger.md index 8e162a2c9..20727733c 100644 --- a/docs/fud-ledger.md +++ b/docs/fud-ledger.md @@ -1677,6 +1677,8 @@ Answer: Correct for v0, and by design for now. The consensus check on a carried Round 2 (5 October 2026, night): the public text now carries the v0 fact, labelled Open: `site/litepaper.html`, Proving, "How a block gets proven": "Open: in proving v0 the SP1 proof itself is checked off the consensus path, by every block producer before it carries a record, and the native-execution check is what consensus enforces; whether the SP1 verifier moves inside consensus is a decision before the public testnet." Status unchanged: decision owner the project lead, decisions item 11. +Round 4 (6 October 2026, 21:0x UTC, the 0.3.16 exec work): Fixed on a branch, pending merge. Fork `exec-sync-0313` da2d17ec puts the proof check on the consensus path: from `proving_consensus_verify_daa` (never by default; with `proving_shard_program_id` and `proving_aggregator_id` in the digest once set) body validation in context verifies every carried record's proof in-process (sp1-sdk's LightProver, the keys embedded from `proving/igneum-prove/elf`) against the pinned program id and the record's statement; a failing proof invalidates the block (`RuleError::IgneumInvalidProofRecord`, the relaying peer disconnected); a proof not held is fetched from the relaying peer (`IgneumRequestProofRecords`, payload 78) and the block retried once, never marked. Measured: 0.204 to 0.232 s a proof on one M5 Max core, 0.400 s on igneum-build-1, release, in-process; the harness `tools/exec-sync/forged.mjs` 8/8 in 23.6 s (an attacker with the rule off carries a forged record; the honest peer asks for the proof, refuses the block in 0.000 s of verify, pays nothing). A node whose embedded keys are not the pinned ids refuses to start. Open for 0.3.16.1: the finality-horizon skip (a block a certified checkpoint covers needs no proof check), so a fresh joiner's syncer serves proofs only for the unfinalised window. + ### P22. The rewards and payouts are inputs to the shard proof, not outputs "The shard guest takes the segment's rewards and the prover payouts as data and commits the post-root after them. A host can feed any list and the proof still verifies." @@ -2194,6 +2196,13 @@ Same rule as the count above. 167 entries (P23 added by round 2). | Open, found tonight, fix in round 3 | 1 | P23 | | Another agent's tonight | 1 | C4 | +## Status updates, 6 October 2026 (21:0x UTC, the 0.3.16 exec work) + +### X31. The prover's segment exports filled the disk under the node +The fleet's provers (the kit's box-prover.py) exported the chain with `igneum_exportSegments [0, last]` for every segment, 50 to 500 MB a file, and nothing pruned them: between about 13:00Z and 20:44Z on 6 October 2026 the exports reached 42 GB on the hub (586 segment dirs on a 60 GB disk, 100 percent) and 3 to 46 GB on every standing box (p1-4090 and p2-4070-1 at 100 percent with 45 and 46 GB; p1-4070 97 percent, p2-3090-4 94 percent, p1-3080 87 percent, p1-a5000 81 percent, p2-4090-3 79 percent, p2-3090-3 75 percent, p1-5090 74 percent, p2-3090-2 55 percent, p2-3090-1 38 percent, p2-4090-1b 30 percent, the 8x rig 28 GB), 4 to 6 GB an hour a box. The hub's node died three times: 19:58:08Z and 20:01:55Z on the sync KeyNotFound (a peer's gap sync below retention, another class), 20:42:31Z on "No space left on device"; the supervisor restarted it at 19:59:11Z, 20:02:36Z and 20:44:38Z. + +Status: Fixed (6 October 2026, 21:0x UTC). At the source: the 0.3.14 export is `[first-1, last]` with the account dump, a few hundred KB a segment, not 50 to 500 MB. In the kit: gpu-fleet f9ad70e (every standing box): a 20 GB and 168 h cap on the export dir enforced before every export, the export deleted the moment its record is accepted or paid, no export under 10 percent free disk (logged); the supervisor's 20-minute prune stays as the belt. In the app: exec-app-0314 4159818 and proving-v2 717148d (`app/igneum-app/src/provingdir.rs`): the same caps on `/proving` (IGNEUM_PROVING_DIR_MAX_GB 20, IGNEUM_PROVING_DIR_MAX_DAYS 7, which leave 80 GB of a 100 GB box to the node and the system), a segment's directory deleted on submission, the free-disk floor before each export with the skip on the Prove page; the cap is a pure function with a unit test. Consequence per tier: a home miner's app never grows its proving dir past 20 GB or a week, and stops exporting (not mining) when the disk is under 10 percent free; a box or a rig the same through the kit. + ## Count by status, 6 October 2026 (17:30 UTC, round 3 merged and the owner's decisions applied) Same rule as the counts above. 167 entries. diff --git a/tools/exec-sync/forged.mjs b/tools/exec-sync/forged.mjs new file mode 100644 index 000000000..2604d8a22 --- /dev/null +++ b/tools/exec-sync/forged.mjs @@ -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= +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); }