igneum/tools/proving-v0/run.mjs

238 lines
17 KiB
JavaScript

#!/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);
}