igneum/tools/proving-v1/net.mjs
2026-10-06 08:32:42 +00:00

263 lines
19 KiB
JavaScript

#!/usr/bin/env node
// Proving v1 (spec 7.8, docs/plans/proving-v1.md step 4) on a private 3-node fast-time test network: ports 29950 and
// up, network igneum-devnet-956, data under /tmp/igneum-proving-v1, infra/fast-time's 60x profile with
// skip_proof_of_work, proving v0 at DAA 60 and proving v1 at DAA 120 (4 blocks a segment, unproven after 60 DAA, a
// tenth of the pool credit to the aggregator). Every node runs in trust mode (IGNEUM_PROOF_VERIFY=trust): the rule is
// what is under test, not SP1, so the proof bytes are placeholders and the statement is the node's own native block
// statement (igneum_getSegmentStatement), signed with igneum-miner sign-segment-record.
//
// Cases, each timed and asserted (the CLAUDE.md rule: a gate is trusted only after one known-finished and one
// known-failed case):
// 1. known-finished: the first v1 segment's record (a fresh chain, chain_len 4) relays, verifies, is carried and
// pays the aggregator's share (4 x 10% of the credits) to the payout address on every node
// 2. the chain rule: the second segment refuses a fresh-chain record while the first is proven, and accepts the
// continuing one (chain_len 8)
// 3. known-failed: the third segment gets no record; after its deadline it is unproven, a late record for it is
// refused, and the fourth segment's fresh-chain record is accepted and paid (the chain restarts)
// 4. the v0 side after the switch: a shard's shardWei is 90% of its block's share
// Never touches the live devnet (26610/26611, 26640/28640), the proving-v0 harness (29800+) or the C4 runs (29900+).
//
// node tools/proving-v1/net.mjs [--secs 1200] [--v1 120] [--segment 4] [--unproven 60]
// IGNEUM_PV1_BIN=<dir with igneumd and igneum-miner> (default vendor/igneum-node/target-pv1/release)
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { connectRpc } from '../finality-attacks/lib/rpc.mjs';
import { privateKeyToAccount } from '../prove-fixtures/node_modules/viem/_esm/accounts/index.js';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const REL = process.env.IGNEUM_PV1_BIN || `${ROOT}vendor/igneum-node/target-pv1/release`;
const IGNEUMD = `${REL}/igneumd`;
const MINER = `${REL}/igneum-miner`;
const TMP = '/tmp/igneum-proving-v1';
const BASE = 29950, SUFFIX = 956, CHAIN_NAME = `igneum-devnet-${SUFFIX}`;
const args = process.argv.slice(2);
const flag = (name, dflt) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] !== undefined ? +args[i + 1] : dflt; };
const SECS = flag('--secs', 1200);
const V0 = 60, V1 = flag('--v1', 120), SEG = flag('--segment', 4), UNPROVEN = flag('--unproven', 60), SHARE_BPS = 1000;
// --fresh-rule <daa>: the fresh-record rule switch (6 October 2026); default never (the 0.3.11 rule), so case 3 keeps
// its known-failed line; with --fresh-rule 0 a fresh record after a PENDING segment is accepted (the new rule) and
// case 2's refusal after a PROVEN one still stands
const FRESH_RULE = process.argv.includes('--fresh-rule') ? Number(process.argv[process.argv.indexOf('--fresh-rule') + 1]) : null;
const started = [];
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), `t=${since()}s`, ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const hexn = (v) => Number(BigInt(v));
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
const funded = privateKeyToAccount('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d');
const PAYOUT = '0x4343434343434343434343434343434343434343';
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const override = `${TMP}/override.json`;
let overrideText = readFileSync(FILE, 'utf8')
.replace(/"proving_v0_activation_daa":\s*\d+/, `"proving_v0_activation_daa": ${V0}`)
.replace(/"proving_v1_activation_daa":\s*\d+/, `"proving_v1_activation_daa": ${V1}`)
.replace(/"proving_v1_segment_blocks":\s*\d+/, `"proving_v1_segment_blocks": ${SEG}`)
.replace(/"proving_v1_unproven_daa":\s*\d+/, `"proving_v1_unproven_daa": ${UNPROVEN}`)
.replace(/"proving_v1_aggregator_share_bps":\s*\d+/, `"proving_v1_aggregator_share_bps": ${SHARE_BPS}`)
.replace(/"proving_v1_fresh_rule_daa":\s*\d+/, FRESH_RULE === null ? '"proving_v1_fresh_rule_daa": 18446744073709551615' : `"proving_v1_fresh_rule_daa": ${FRESH_RULE}`)
.replace(/"skip_proof_of_work":\s*(true|false)/, '"skip_proof_of_work": true');
for (const re of [/"skip_proof_of_work": true/, new RegExp(`"proving_v1_activation_daa": ${V1}`), new RegExp(`"proving_v1_segment_blocks": ${SEG}`), new RegExp(`"proving_v1_unproven_daa": ${UNPROVEN}`)]) if (!re.test(overrideText)) throw new Error(`override edit failed: ${re}`);
writeFileSync(override, overrideText);
class Node {
constructor(i, connect = [], 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.env = env; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
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'];
if (this.connect.length) a.push(...this.connect.map(c => `--connect=${c}`)); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, ...this.env } });
started.push(this.proc);
await sleep(800);
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} evm ${this.evmPort} p2p ${this.p2pPort} env ${JSON.stringify(this.env)}`);
return this;
}
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}: ${j.error.message}`);
return j.result;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
}
function miner(argv, name) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(MINER, argv, { stdio: ['ignore', out, out] });
started.push(p);
return p;
}
async function stopAll() {
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
const report = { v0: V0, v1: V1, segment: SEG, unproven: UNPROVEN, share_bps: SHARE_BPS, steps: {}, checks: [] };
const step = (k, v) => { report.steps[k] = v; log(`STEP ${k}: ${JSON.stringify(v)}`); };
const check = (name, ok, detail) => { report.checks.push({ name, ok, detail }); log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ': ' + JSON.stringify(detail) : ''}`); if (!ok) throw new Error(`check failed: ${name} ${JSON.stringify(detail)}`); };
function run(cmd, argv, env = {}) {
const r = spawnSync(cmd, argv, { encoding: 'utf8', maxBuffer: 1 << 28, env: { ...process.env, ...env } });
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
}
const sha256 = (buf) => '0x' + createHash('sha256').update(buf).digest('hex');
async function waitDaa(n, want, label) {
let daa = 0;
while (daa < want) { await sleep(1000); const s = await n.eth('igneum_getProvingStatus'); daa = hexn(s.tipDaa); }
log(`${label}: daa ${daa}`);
return daa;
}
async function waitExecuted(n, number) {
for (let k = 0; k < 600; k++) { const tip = hexn(await n.eth('eth_blockNumber')); if (tip >= number) return tip; await sleep(500); }
throw new Error(`block ${number} not executed in 300 s`);
}
// signs a segment record over the given public values with v0's key; the proof bytes are a placeholder (trust mode)
function signSegment(first, last, hash, pv, tag) {
const proof = Buffer.from(`igneum-proving-v1-harness-${tag}-${first}-${last}`);
const sg = run(MINER, ['sign-segment-record', 'v0', CHAIN_NAME, String(first), String(last), hash, PAYOUT, pv, sha256(proof)]);
if (sg.code !== 0) throw new Error(`sign-segment-record failed: ${sg.out}`);
const signed = JSON.parse(sg.out.trim().split('\n').pop());
return { record: signed.record, proof: '0x' + proof.toString('hex'), keyHash: signed.keyHash, statement: signed.statement };
}
async function waitSegmentPaid(n, first, secs = 240) {
const deadline = Date.now() + secs * 1000;
while (Date.now() < deadline) {
const r = await n.eth('igneum_getSegmentRecords', ['0x' + first.toString(16)]);
if (r.paid) return r;
await sleep(1000);
}
throw new Error(`segment ${first} not paid within ${secs} s on n${n.i}`);
}
try {
const n0 = await new Node(0, [], { IGNEUM_PROOF_VERIFY: 'trust' }).start();
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`], { IGNEUM_PROOF_VERIFY: 'trust' }).start();
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`, `127.0.0.1:${n1.p2pPort}`], { IGNEUM_PROOF_VERIFY: 'trust' }).start();
const nodes = [n0, n1, n2];
log(`node 0 says: ${n0.grepLog(/proving v1/).join(' | ') || '(no proving v1 line)'}`);
nodes.forEach((n, i) => miner(['vmine', n.grpc, String(SECS), '--label', `v${i}`, '--share', String(1 / 3), '--bps', '1', ...(i === 0 ? ['--evm-address', funded.address] : [])], `vmine-v${i}`));
const keyHashes = [];
for (let i = 0; i < 3; i++) {
for (let k = 0; k < 50 && !keyHashes[i]; k++) { const m = (() => { try { return readFileSync(`${TMP}/vmine-v${i}.log`, 'utf8').match(/key=([0-9a-f]{64})/); } catch { return null; } })(); if (m) keyHashes[i] = '0x' + m[1]; else await sleep(200); }
}
log(`vote keys: ${keyHashes.join(' ')}`);
const st0 = await n0.eth('igneum_getProvingStatus');
step('status', { v0: st0.activationDaa, v1: st0.v1 });
check('the node carries the v1 parameters', hexn(st0.v1.activationDaa) === V1 && hexn(st0.v1.segmentBlocks) === SEG && hexn(st0.v1.unprovenDaa) === UNPROVEN && hexn(st0.v1.aggregatorShareBps) === SHARE_BPS, st0.v1);
// the v1 start: the first chain block at DAA >= V1
await waitDaa(n0, V1 + 8, 'past the v1 activation');
let st = await n0.eth('igneum_getProvingStatus');
const S = hexn(st.v1.start);
step('v1_start', { start: S, tipDaa: hexn(st.tipDaa) });
check('every node agrees on the v1 start', (await Promise.all(nodes.map(n => n.eth('igneum_getProvingStatus')))).every(x => hexn(x.v1.start) === S), S);
// ---- case 1: the first segment, a fresh chain, carried and paid
const seg0 = [S, S + SEG - 1];
await waitExecuted(n0, seg0[1] + 1);
const stmt0 = await n0.eth('igneum_getSegmentStatement', ['0x' + seg0[0].toString(16)]);
check('segment 0 is aligned at the start and pending', hexn(stmt0.first) === seg0[0] && hexn(stmt0.last) === seg0[1] && stmt0.status.status === 'pending', { first: stmt0.first, last: stmt0.last, status: stmt0.status });
check('the native statement is the same on every node', (await Promise.all(nodes.map(n => n.eth('igneum_getSegmentStatement', ['0x' + seg0[0].toString(16)])))).every(x => x.publicValuesFresh === stmt0.publicValuesFresh), stmt0.publicValuesFresh.slice(0, 24));
const expectedWei0 = stmt0.blocks.reduce((a, b) => a + BigInt(b.aggregatorWei), 0n);
const creditSum0 = stmt0.blocks.reduce((a, b) => a + BigInt(b.poolCreditWei), 0n);
check('the aggregator share is a tenth of the segment credits', expectedWei0 === creditSum0 / 10n, { expectedWei0: expectedWei0.toString(), creditSum0: creditSum0.toString() });
const lastHash0 = stmt0.blocks[stmt0.blocks.length - 1].hash;
const rec0 = signSegment(seg0[0], seg0[1], lastHash0, stmt0.publicValuesFresh, 'seg0');
const tSubmit0 = Date.now();
const sub0 = await n1.eth('igneum_submitSegmentRecord', [{ record: rec0.record, proof: rec0.proof }]);
check('known-finished: segment 0 record accepted by n1', sub0.accepted === true && sub0.new === true, sub0);
const paid0 = await waitSegmentPaid(n0, seg0[0]);
const paidAt0 = (Date.now() - tSubmit0) / 1000;
check('known-finished: segment 0 paid on n0 (relayed over p2p, verified in trust mode, carried)', BigInt(paid0.paid.wei) === expectedWei0 && paid0.paid.payout.toLowerCase() === PAYOUT, { paid: paid0.paid, secs: paidAt0 });
await sleep(3000);
const agree0 = await Promise.all(nodes.map(n => n.eth('igneum_getSegmentRecords', ['0x' + seg0[0].toString(16)]).then(r => r.paid && r.paid.wei)));
check('every node paid segment 0 the same', agree0.every(w => w && BigInt(w) === expectedWei0), agree0);
const bal0 = BigInt(await n0.eth('eth_getBalance', [PAYOUT, 'latest']));
check('the payout address holds the aggregator share', bal0 === expectedWei0, { balance: bal0.toString() });
step('case1', { segment: seg0, wei: expectedWei0.toString(), paidSecs: paidAt0, carrier: paid0.paid.carrierNumber });
// ---- case 2: the chain rule on segment 1
const seg1 = [S + SEG, S + 2 * SEG - 1];
await waitExecuted(n0, seg1[1] + 1);
const stmt1 = await n0.eth('igneum_getSegmentStatement', ['0x' + seg1[0].toString(16)]);
check('segment 1 names segment 0 as its proven previous', stmt1.previous && hexn(stmt1.previous.first) === seg0[0] && hexn(stmt1.previous.chainLen) === SEG, stmt1.previous);
const lastHash1 = stmt1.blocks[stmt1.blocks.length - 1].hash;
const fresh1 = signSegment(seg1[0], seg1[1], lastHash1, stmt1.publicValuesFresh, 'seg1-fresh');
const subFresh = await n0.eth('igneum_submitSegmentRecord', [{ record: fresh1.record, proof: fresh1.proof }]);
check('chain rule: a fresh-chain record for segment 1 is refused while segment 0 is proven', subFresh.accepted === false && /does not chain to segment/.test(subFresh.reason), subFresh);
const cont1 = signSegment(seg1[0], seg1[1], lastHash1, stmt1.publicValuesContinuing, 'seg1-cont');
const subCont = await n2.eth('igneum_submitSegmentRecord', [{ record: cont1.record, proof: cont1.proof }]);
check('chain rule: the continuing record (chain_len 8) is accepted', subCont.accepted === true, subCont);
const paid1 = await waitSegmentPaid(n0, seg1[0]);
check('segment 1 paid with chain_len 8', hexn(paid1.paid.chainLen) === 2 * SEG, paid1.paid);
step('case2', { segment: seg1, refused: subFresh.reason, paid: paid1.paid });
// ---- case 3: segment 2 left unproven; segment 3 restarts the chain
const seg2 = [S + 2 * SEG, S + 3 * SEG - 1];
const seg3 = [S + 3 * SEG, S + 4 * SEG - 1];
await waitExecuted(n0, seg3[1] + 1);
const stmt2 = await n0.eth('igneum_getSegmentStatement', ['0x' + seg2[0].toString(16)]);
const deadline2 = hexn(stmt2.status.deadline_daa);
check('segment 2 is pending with a deadline', stmt2.status.status === 'pending' && deadline2 > 0, stmt2.status);
const stmt3early = await n0.eth('igneum_getSegmentStatement', ['0x' + seg3[0].toString(16)]);
const lastHash3 = stmt3early.blocks[stmt3early.blocks.length - 1].hash;
const fresh3 = signSegment(seg3[0], seg3[1], lastHash3, stmt3early.publicValuesFresh, 'seg3-fresh');
const subEarly = await n0.eth('igneum_submitSegmentRecord', [{ record: fresh3.record, proof: fresh3.proof }]);
if (FRESH_RULE === null) {
check('known-failed: segment 3 cannot start a fresh chain while segment 2 is pending', subEarly.accepted === false && /pending until DAA/.test(subEarly.reason), subEarly);
} else {
check('fresh-record rule: segment 3 starts a fresh chain while segment 2 is pending', subEarly.accepted === true, subEarly);
const stmt3now = await n0.eth('igneum_getSegmentStatement', ['0x' + seg3[0].toString(16)]);
check('fresh-record rule: the statement reports the fresh record admissible', stmt3now.freshAdmissible === true, { freshAdmissible: stmt3now.freshAdmissible });
}
await waitDaa(n0, deadline2 + 2, 'past segment 2 deadline');
const stmt2late = await n0.eth('igneum_getSegmentStatement', ['0x' + seg2[0].toString(16)]);
check('known-failed: segment 2 is unproven after its deadline', stmt2late.status.status === 'unproven', stmt2late.status);
const lastHash2 = stmt2late.blocks[stmt2late.blocks.length - 1].hash;
const late2 = signSegment(seg2[0], seg2[1], lastHash2, stmt2late.publicValuesContinuing || stmt2late.publicValuesFresh, 'seg2-late');
const subLate = await n0.eth('igneum_submitSegmentRecord', [{ record: late2.record, proof: late2.proof }]);
check('known-failed: a late record for segment 2 pays nothing (refused as unproven)', subLate.accepted === false && /unproven/.test(subLate.reason), subLate);
const subRestart = await n1.eth('igneum_submitSegmentRecord', [{ record: fresh3.record, proof: fresh3.proof }]);
if (FRESH_RULE === null) {
check('segment 3 restarts the chain with a fresh-chain record after the unproven segment', subRestart.accepted === true, subRestart);
} else {
// under the fresh-record rule the record was accepted while segment 2 was still pending (above) and is carried
// by now: the second offer is a duplicate or a paid segment, never a chain-rule refusal
check('fresh-record rule: the second offer of segment 3\'s record is a duplicate, not a chain-rule refusal', subRestart.accepted === true || !/does not chain/.test(subRestart.reason || ''), subRestart);
}
const paid3 = await waitSegmentPaid(n0, seg3[0]);
check('segment 3 paid with chain_len 4', hexn(paid3.paid.chainLen) === SEG, paid3.paid);
st = await n0.eth('igneum_getProvingStatus');
check('the status counts proven and unproven segments', st.v1.segmentsInWindow.proven >= 3 && st.v1.segmentsInWindow.unproven >= 1, st.v1.segmentsInWindow);
step('case3', { unproven: seg2, restarted: seg3, status: st.v1 });
// ---- case 4: the shard side after the switch
const work = await n0.eth('igneum_getAssignedShards', [[keyHashes[0]], 40]);
const w = work.find(x => hexn(x.number) >= S);
check('a v1 shard is paid 90% of its block share (one shard a block here)', w && BigInt(w.shardWei) === BigInt(w.poolCreditWei) - BigInt(w.poolCreditWei) / 10n, w && { number: w.number, shardWei: w.shardWei, credit: w.poolCreditWei });
const before = work.find(x => hexn(x.number) < S);
if (before) check('a pre-v1 shard keeps the whole share', BigInt(before.shardWei) === BigInt(before.poolCreditWei), { number: before.number });
report.ok = report.checks.every(c => c.ok);
log(`RESULT proving v1 harness: ${report.ok ? 'PASSED' : 'FAILED'} (${report.checks.length} checks) in ${since()} s`);
} catch (e) {
report.error = e.message;
log(`FAILED: ${e.message}`);
} finally {
writeFileSync(`${TMP}/report.json`, JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
await stopAll();
process.exit(report.ok ? 0 : 1);
}