#!/usr/bin/env node // Proving v0 end to end on a private 3-node test network (spec 7.7, docs/plans/proving-v0.md): ports 29800 and up, // network igneum-devnet-955, data under /tmp/igneum-proving-v0, infra/fast-time's 60x profile with // skip_proof_of_work and proving_v0_activation_daa set. Three voting vmine producers (the proving build's // igneum-miner) share 1 block/s; v0 names a funded EVM address. Node 0 verifies SP1 proofs with the real host // (IGNEUM_PROOF_VERIFIER); nodes 1 and 2 run in trust mode, so the relay and the verifier are both exercised. // // Steps, each timed: wait for the activation DAA, send one transfer, export the chain and cut the fixture with // igneum-prove-export, check the exporter's plan against the node's igneum_getShardPlan, prove the shard on the // CPU with igneum-prove-host --mode compressed, sign the record with igneum-miner sign-record (v0's vote key), // submit it to node 1, watch node 0 verify it, watch a block carry it, and check the payout address's balance. // Never touches the live devnet (26610/26611, 26640/28640) or the fast-time simnet (29500+). // // node tools/proving-v0/run.mjs [--secs 1500] [--activation 60] [--empty] (--empty proves the newest empty segment instead) // node tools/proving-v0/run.mjs --network-only --secs 3600 (just the 3 nodes and the voters, for the app's prover loop; node 1 // p2p 127.0.0.1:29811 is the peer to give the app) import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs'; 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_PROVING_BIN || `${ROOT}vendor/igneum-node-proving/target/release`; const IGNEUMD = `${REL}/igneumd`; const MINER = `${REL}/igneum-miner`; const PROVE = process.env.IGNEUM_PROVE_BIN || `${ROOT}proving/igneum-prove/target/release`; const HOST = `${PROVE}/igneum-prove-host`; const EXPORT = `${PROVE}/igneum-prove-export`; const TMP = '/tmp/igneum-proving-v0'; const BASE = 29800, SUFFIX = 955, CHAIN_NAME = `igneum-devnet-${SUFFIX}`, CHAIN_ID = 4463; 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', 1500); const ACTIVATION = flag('--activation', 60); const EMPTY = args.includes('--empty'); const NETWORK_ONLY = args.includes('--network-only'); 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)); for (const b of [IGNEUMD, MINER, HOST, EXPORT]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); } // the funded account: v0's payout address (a throwaway key from tools/prove-fixtures/gen.mjs) const funded = privateKeyToAccount('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); const PAYOUT = '0x4242424242424242424242424242424242424242'; rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); const override = `${TMP}/override.json`; // text edits, not JSON.parse: the file carries u64::MAX values that JavaScript numbers cannot hold const overrideText = readFileSync(FILE, 'utf8') .replace(/"proving_v0_activation_daa":\s*\d+/, `"proving_v0_activation_daa": ${ACTIVATION}`) .replace(/"skip_proof_of_work":\s*(true|false)/, '"skip_proof_of_work": true'); if (!/"skip_proof_of_work": true/.test(overrideText) || !new RegExp(`"proving_v0_activation_daa": ${ACTIVATION}`).test(overrideText)) throw new Error('override edit failed'); 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 = { activation: ACTIVATION, steps: {} }; const step = (k, v) => { report.steps[k] = v; log(`STEP ${k}: ${JSON.stringify(v)}`); }; function run(cmd, argv, env = {}) { const t = Date.now(); const r = spawnSync(cmd, argv, { encoding: 'utf8', maxBuffer: 1 << 28, env: { ...process.env, ...env } }); return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), secs: (Date.now() - t) / 1000 }; } try { const n0 = await new Node(0, [], { IGNEUM_PROOF_VERIFIER: HOST, SP1_PROVER: 'cpu' }).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 v0|proving v0/).join(' | ') || '(no proving line)'}`); // three voters sharing 1 block/s; v0 pays the funded account 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 status0 = await n0.eth('igneum_getProvingStatus'); log(`proving status n0: ${JSON.stringify(status0)}`); step('status', { activationDaa: status0.activationDaa, verifier: status0.verifier }); if (NETWORK_ONLY) { log(`network only: 3 nodes up for ${SECS} s; peer for the app: 127.0.0.1:${n1.p2pPort}; EVM RPC n0 ${n0.evm}`); while (Date.now() - t0 < SECS * 1000) { await sleep(15000); const s = await n0.eth('igneum_getProvingStatus').catch(() => null); if (s) log(`daa ${parseInt(s.tipDaa, 16)} active=${s.active} pool=${JSON.stringify(s.pool)} paidShards=${s.paidShards}`); } report.ok = true; throw new Error('network-only run finished'); } // 1. wait for the activation DAA (plus a margin: the sortition window needs dust blocks per key) let daa = 0; while (daa < ACTIVATION + 5) { await sleep(2000); const s = await n0.eth('igneum_getProvingStatus'); daa = parseInt(s.tipDaa, 16); if (Math.round(+since()) % 10 === 0) log(`daa ${daa} active=${s.active} pool=${JSON.stringify(s.pool)}`); } step('activation', { daa, secs: +since() }); // 2. a transfer from the funded account, so a segment has one transaction const balance = BigInt(await n0.eth('eth_getBalance', [funded.address, 'latest'])); log(`funded ${funded.address} balance ${balance} wei`); if (balance === 0n) throw new Error('the funded account has no rewards yet'); const nonce = parseInt(await n0.eth('eth_getTransactionCount', [funded.address, 'latest']), 16); const raw = await funded.signTransaction({ type: 'eip1559', chainId: CHAIN_ID, nonce, to: '0x1111111111111111111111111111111111111111', value: 1_000_000_000_000_000n, gas: 21000n, maxFeePerGas: 20_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n }); const txHash = await n0.eth('eth_sendRawTransaction', [raw]); let receipt = null; for (let k = 0; k < 120 && !receipt; k++) { await sleep(1000); receipt = await n0.eth('eth_getTransactionReceipt', [txHash]); } if (!receipt) throw new Error('the transfer was not executed in 120 s'); const txNumber = parseInt(receipt.blockNumber, 16); step('transfer', { txHash, number: txNumber, secs: +since() }); // 3. the work list of v0's key, and the shard to prove await sleep(1500); const work = await n0.eth('igneum_getAssignedShards', [[keyHashes[0]], 100]); const mine = work.filter(w => w.assigned); log(`assigned shards for v0 in the last 100 blocks: ${mine.length} of ${work.length} listed (${mine.slice(0, 5).map(w => `#${parseInt(w.number, 16)}/${w.shard} txs ${w.txCount}`).join(', ')} ...)`); let target = EMPTY ? mine.find(w => w.txCount === 0) : mine.find(w => parseInt(w.number, 16) === txNumber); if (!target) { log(`v0 is not assigned to block ${txNumber} (or no empty shard); taking the newest assigned shard`); target = mine[0]; } if (!target) throw new Error('v0 has no assigned shard'); const number = parseInt(target.number, 16); const plan = await n0.eth('igneum_getShardPlan', [target.number]); const plan1 = await n1.eth('igneum_getShardPlan', [target.number]); if (JSON.stringify(plan.shards) !== JSON.stringify(plan1.shards)) throw new Error('nodes 0 and 1 disagree on the shard plan'); step('plan', { number, hash: plan.hash, shards: plan.shards.length, eligibleKeys: plan.eligibleKeys, windowBlocks: plan.windowBlocks, assignees: plan.shards[target.shard].assignees.length, pgas: parseInt(plan.shards[target.shard].pgas, 16), shardWei: target.shardWei, sameOnNode1: true }); // 4. export and cut the fixture; the exporter's plan must be the node's const seq = await n0.eth('igneum_exportSegments', ['0x0', target.number]); writeFileSync(`${TMP}/seq.json`, JSON.stringify(seq)); const fixture = `${TMP}/block-${number}.json`; const ex = run(EXPORT, [`${TMP}/seq.json`, String(number), fixture, '--source', `proving-v0 test network block ${number}`]); writeFileSync(`${TMP}/export.log`, ex.out); if (ex.code !== 0) throw new Error(`exporter failed (${ex.code}): ${ex.out.split('\n').slice(-5).join(' | ')}`); const fx = JSON.parse(readFileSync(fixture, 'utf8')); const fs0 = fx.plan.shards[target.shard], ns0 = plan.shards[target.shard]; const same = fx.plan.shards.length === plan.shards.length && fs0.pre_root === ns0.preRoot && fs0.post_root === ns0.postRoot && fs0.link_in === ns0.linkIn && fs0.link_out === ns0.linkOut && fs0.receipts_root === ns0.receiptsRoot && fs0.pgas_used === parseInt(ns0.pgas, 16); step('export', { secs: ex.secs, exporterShards: fx.plan.shards.length, matchesNode: same, preRoot: fs0.pre_root, postRoot: fs0.post_root }); if (!same) throw new Error(`the exporter's plan differs from the node's: ${JSON.stringify({ fs0, ns0 })}`); // 5. prove the shard on the CPU (execute, then compressed) log(`proving block ${number} shard ${target.shard} on the CPU (SP1_PROVER=cpu); this takes minutes on a loaded Mac`); const results = `${TMP}/results-${number}-${target.shard}.json`; const pr = run(HOST, [fixture, '--mode', 'compressed', '--shard', String(target.shard), '--prover', PAYOUT, '--out', results], { SP1_PROVER: 'cpu', RUST_LOG: 'off' }); writeFileSync(`${TMP}/prove.log`, pr.out); if (pr.code !== 0) throw new Error(`prover failed (${pr.code}): ${pr.out.split('\n').filter(l => /RESULT|error|Error/.test(l)).slice(-6).join(' | ')}`); const res = JSON.parse(readFileSync(results, 'utf8')); step('prove', { secs: pr.secs, cycles: res.cycles, executeSeconds: res.execute_seconds, compressedSeconds: res.compressed_prove_seconds, verifySeconds: res.compressed_verify_seconds, proofBytes: res.compressed_proof_bytes, statement: res.statement, proofSha256: res.proof_sha256 }); // 6. sign the record with v0's vote key and submit it to node 1 (trust mode), with the proof bytes const sg = run(MINER, ['sign-record', 'v0', CHAIN_NAME, plan.hash, String(number), String(target.shard), PAYOUT, res.statement, res.proof_sha256]); if (sg.code !== 0) throw new Error(`sign-record failed: ${sg.out}`); const signed = JSON.parse(sg.out.trim().split('\n').pop()); if (signed.keyHash !== keyHashes[0].slice(2)) throw new Error(`sign-record key ${signed.keyHash} is not v0's ${keyHashes[0]}`); const proofHex = '0x' + readFileSync(res.proof_file).toString('hex'); const sub = await n1.eth('igneum_submitProofRecord', [{ record: signed.record, proof: proofHex }]); step('submit', { to: 'n1', ...sub, secs: +since() }); if (!sub.accepted) throw new Error(`record refused: ${sub.reason}`); // 7. node 0 receives it over p2p and verifies the SP1 proof; then a block carries it and the segment pays let verified = null, paid = null, carriedBy = null, relayedAt = null, verifiedAt = null, paidAt = null; const deadline = Date.now() + 600_000; while (Date.now() < deadline && !(paid && verified)) { await sleep(1000); const r0 = await n0.eth('igneum_getProofRecords', [target.number]); const e0 = r0.pool.find(e => e.shard === target.shard); if (e0 && relayedAt == null) { relayedAt = +since(); log(`n0 holds the record over p2p (verified=${e0.verified})`); } if (e0 && e0.verified != null && verified == null) { verified = e0.verified; verifiedAt = +since(); log(`n0 verifier says ${e0.verified}: ${e0.note}`); } const paidRow = r0.paid && r0.paid[target.shard]; if (paidRow && paid == null) { paid = paidRow; paidAt = +since(); carriedBy = (r0.carried.find(c => c.valid) || {}).carrier; log(`PAID on n0: ${JSON.stringify(paidRow)} carried by ${carriedBy}`); } if (Math.round(+since()) % 15 === 0) log(`waiting: pool ${JSON.stringify((e0 || {}).verified)} included ${(e0 || {}).includedIn || 'no'} paid ${paid ? 'yes' : 'no'}`); } step('relay_verify_pay', { relayedAt, verified, verifiedAt, paidAt, carriedBy, paid }); if (!verified) throw new Error('node 0 did not verify the proof'); if (!paid) throw new Error('no block carried the record within 10 minutes'); // every node agrees on the payment, and the payout address holds the shard's part await sleep(3000); const agree = []; for (const n of nodes) { const r = await n.eth('igneum_getProofRecords', [target.number]); agree.push(r.paid && r.paid[target.shard] ? r.paid[target.shard].wei : null); } const bal = BigInt(await n0.eth('eth_getBalance', [PAYOUT, 'latest'])); const pool = await n0.eth('igneum_getProvingStatus'); step('payout', { paidWeiPerNode: agree, payoutBalanceWei: bal.toString(), shardWei: BigInt(target.shardWei).toString(), balanceEqualsShardWei: bal === BigInt(target.shardWei), poolBalanceWei: BigInt(pool.poolBalanceWei).toString(), paidShards: pool.paidShards }); report.ok = bal === BigInt(target.shardWei) && agree.every(a => a === agree[0] && a != null); log(`RESULT proving v0 end to end: ${report.ok ? 'PASSED' : 'FAILED'} 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); }