331 lines
20 KiB
JavaScript
Executable file
331 lines
20 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
// Era VDF lane (7 October 2026), the F7 re-roll harness against the REAL era cut with the era VDF on and off
|
|
// (docs/plans/cryptanalysis.md 4.2 row F7; the attack lane's tools/attack/f7-era/reroll.mjs attacked the epoch cut
|
|
// because the era cut was a chain constant; the node now takes `pow_era_blocks` and `pow_era_lead` from the override
|
|
// file, so a fast-time network crosses an era every two minutes and the cut rule under test is the era's own).
|
|
//
|
|
// The network: three nodes on infra/fast-time/override-60x.json with skip_proof_of_work, era 120 DAA and lead 20
|
|
// (cuts at S = 120 n - 20, one per two minutes), own ports 30100 and up, own devnet suffix 1010, data under
|
|
// /tmp/igneum-fast-time-era-vdf. Two honest virtual miners share 1 block/s on nodes 0 and 1; the adversary on node 2
|
|
// holds a block A built on the tip at DAA score S - 1 (the cut block sits there) and tries to make its own block the
|
|
// era's cut block, steering the era seed to a value it knows.
|
|
//
|
|
// --vdf off the stand-in: E_n is the cut block's hash, so the draw of a candidate block is known the instant the
|
|
// block is built; the adversary publishes A at once. Known-pass: re-rolls fire (seed == hash(A)).
|
|
// --vdf on the era VDF (spec 04 section 4.4) at a fast T: E_n is the VDF output over the cut block's day, so the
|
|
// adversary must run the delay over its candidate input before it knows the draw of any choice; it runs
|
|
// the node's own evaluator (igneum-miner vdf eval, the same T as the network's) and publishes A when it
|
|
// ends. Known-fail: no re-roll (A arrives after the honest block interval, the input it evaluated is not
|
|
// the chain's, the seed it precomputed is not the chain's seed).
|
|
//
|
|
// A re-roll with the VDF off: the era's reported seed is hash(A). A re-roll with the VDF on: the seed the adversary
|
|
// precomputed for its candidate is the era's reported seed (the known-draw re-roll), or A became the cut block (a
|
|
// steer, which without the known draw is a coin flip the VDF makes blind). The gate is 0 known-draw re-rolls.
|
|
//
|
|
// node reroll.mjs --vdf on|off [--scheme 0|1] [--t N | --delay-secs 5] [--cuts 6] [--era-blocks 120] [--era-lead 20]
|
|
// [--secs 1500] [--genesis-bits 0x1d100000] [--tag name]
|
|
// IGNEUMD and IGNEUM_MINER name the binaries (default: this lane's release build on igneum-build-1).
|
|
// IGNEUM_EV_TMP and IGNEUM_EV_OUT name the data dir and the log dir.
|
|
|
|
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 { Miner, voteKeyHashFor } from '../harness/lib/miner.mjs';
|
|
import { submitReport } from '../harness/lib/rpc.mjs';
|
|
import { devAddress } from '../harness/lib/address.mjs';
|
|
|
|
const ROOT = new URL('../../', import.meta.url).pathname;
|
|
const FILE = process.env.IGNEUM_OVERRIDE_60X || `${ROOT}infra/fast-time/override-60x.json`;
|
|
const BIN = '/srv/builds/igneum-wt-era-vdf/vendor/igneum-node-ev/target/release';
|
|
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
|
|
const MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
|
|
const TMP = process.env.IGNEUM_EV_TMP || '/tmp/igneum-fast-time-era-vdf';
|
|
const OUT = process.env.IGNEUM_EV_OUT || TMP;
|
|
const BASE = 30100, SUFFIX = 1010;
|
|
const args = process.argv.slice(2);
|
|
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? +args[i + 1] : dflt; };
|
|
const sflag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
|
|
const VDF = sflag('vdf', null);
|
|
const SCHEME = flag('scheme', 0);
|
|
const DELAY_SECS = flag('delay-secs', 5);
|
|
let T = flag('t', 0);
|
|
const CUTS = flag('cuts', 6);
|
|
const ERA = flag('era-blocks', 120);
|
|
const LEAD = flag('era-lead', 20);
|
|
const SECS = flag('secs', 1500);
|
|
const GENESIS_BITS = flag('genesis-bits', 0x1d100000);
|
|
const TAG = sflag('tag', `vdf-${VDF}-s${SCHEME}`);
|
|
if (!['on', 'off'].includes(VDF)) { console.error('usage: --vdf on|off [--scheme 0|1] [--t N | --delay-secs S] [--cuts N] [--era-blocks N] [--era-lead N]'); process.exit(2); }
|
|
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
|
const started = [];
|
|
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
|
|
// ---- the era draw in JS (spec 01 sections 1.3.1, 1.3.2, 1.13.1), checked against the Rust census (attack-f7) ----
|
|
const M64 = (1n << 64n) - 1n;
|
|
function fnvSalt0(bytes) {
|
|
let h = 0xcbf29ce484222325n;
|
|
for (const x of bytes) { h = ((h ^ BigInt(x)) * 0x100000001b3n) & M64; }
|
|
h ^= h >> 33n; h = (h * 0xff51afd7ed558ccdn) & M64; h ^= h >> 33n;
|
|
return h;
|
|
}
|
|
class SplitMix { constructor(s) { this.s = s & M64; } next() { this.s = (this.s + 0x9E3779B97F4A7C15n) & M64; let z = this.s; z = ((z ^ (z >> 30n)) * 0xBF58476D1CE4E5B9n) & M64; z = ((z ^ (z >> 27n)) * 0x94D049BB133111EBn) & M64; return z ^ (z >> 31n); } below(n) { return this.next() % BigInt(n); } }
|
|
function eraDraw(eraBytes) {
|
|
const seed = fnvSalt0([...Buffer.from('igneum-era/', 'utf8'), ...eraBytes]);
|
|
const s = new SplitMix(seed);
|
|
s.below(1);
|
|
const M = Number((s.next() & 0xffffffffn) | 1n) >>> 0;
|
|
const R = 1 + Number(s.below(31));
|
|
const r = [s.next(), s.next(), s.next(), s.next()];
|
|
const c = []; for (let i = 0; i < 16; i++) c.push(i);
|
|
for (let i = 0; i < 4; i++) { const n = c.length - i; const j = i + Number(r[i] % BigInt(n)); [c[i], c[j]] = [c[j], c[i]]; }
|
|
return { M, R, pos: c.slice(0, 4).sort((a, b) => a - b) };
|
|
}
|
|
function selfCheck() {
|
|
const e = eraDraw(Buffer.from('b62532bc9bb83b386be48f9df264bad3f8afb70fbb0db775b18f687b2da7f8e2', 'hex'));
|
|
const ok = e.M === 0x558c0543 && e.R === 4 && e.pos.join() === '0,1,2,3';
|
|
log(`draw self-check against the Rust census: M ${e.M.toString(16)} R ${e.R} pos [${e.pos}] -> ${ok ? 'OK' : 'MISMATCH'}`);
|
|
if (!ok) process.exit(3);
|
|
}
|
|
const drawOf = (hex) => { const d = eraDraw(Buffer.from(hex, 'hex')); return `M ${d.M.toString(16)} R ${d.R} pos [${d.pos}]`; };
|
|
|
|
// ---- the evaluator (the node's own code through igneum-miner vdf) ----
|
|
function bench(secs) {
|
|
const r = spawnSync(MINER, ['vdf', 'bench', '--secs', String(secs), '--scheme', String(SCHEME)], { encoding: 'utf8' });
|
|
if (r.status !== 0) { log(`bench failed: ${r.stderr}`); process.exit(3); }
|
|
const m = /= ([0-9]+) (squarings|hashes)\/s/.exec(r.stdout);
|
|
log(`bench: ${r.stdout.trim().split('\n').join(' | ')}`);
|
|
return m ? +m[1] : 0;
|
|
}
|
|
// asynchronous, so the honest virtual miners in this process keep mining while the adversary computes (a
|
|
// synchronous spawn froze the chain for the length of the evaluation, which is not the attack)
|
|
function adversaryEval(inputHex) {
|
|
const t0 = Date.now();
|
|
return new Promise((resolve) => {
|
|
const r = spawn(MINER, ['vdf', 'eval', '--input', inputHex, '--t', String(T), '--scheme', String(SCHEME), '--threads', '2'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = '', err = '';
|
|
r.stdout.on('data', (d) => { out += d; });
|
|
r.stderr.on('data', (d) => { err += d; });
|
|
r.on('close', (status) => {
|
|
const secs = (Date.now() - t0) / 1000;
|
|
if (status !== 0) return resolve({ secs, seed: null, error: err.slice(0, 200) });
|
|
try { const j = JSON.parse(out.trim().split('\n').pop()); resolve({ secs, seed: j.seed, proof_bytes: j.proof.length / 2 }); } catch (e) { resolve({ secs, seed: null, error: String(e) }); }
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- network ----
|
|
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); mkdirSync(OUT, { recursive: true });
|
|
const baseText = readFileSync(FILE, 'utf8');
|
|
function mergeOverrideText(text, fields) {
|
|
let out = text;
|
|
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
|
|
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
|
|
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
|
|
}
|
|
selfCheck();
|
|
if (VDF === 'on' && T === 0) {
|
|
const rate = bench(3);
|
|
T = Math.max(1, Math.floor(rate * DELAY_SECS));
|
|
log(`T ${T} for a ${DELAY_SECS} s delay at ${rate}/s on one core of this box (scheme ${SCHEME})`);
|
|
}
|
|
const fields = { genesis_bits: GENESIS_BITS, skip_proof_of_work: true, pow_era_blocks: ERA, pow_era_lead: LEAD };
|
|
if (VDF === 'on') Object.assign(fields, { era_vdf_activation_daa: 0, vdf_scheme: SCHEME, era_vdf_t: T });
|
|
else Object.assign(fields, { era_vdf_activation_daa: '18446744073709551615' });
|
|
const override = `${TMP}/override.json`;
|
|
writeFileSync(override, mergeOverrideText(baseText, fields));
|
|
|
|
class Node {
|
|
constructor(i, connect = []) {
|
|
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2;
|
|
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
|
|
}
|
|
get json() { return `ws://127.0.0.1:${this.jsonPort}`; }
|
|
async start() {
|
|
mkdirSync(this.dir, { recursive: true });
|
|
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
|
|
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`,
|
|
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
|
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
|
|
const out = openSync(this.logFile, 'a');
|
|
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
|
|
started.push(this.proc);
|
|
writeFileSync(`${OUT}/pids-${TAG}.txt`, started.map(p => p.pid).join('\n') + '\n');
|
|
await sleep(1200);
|
|
this.rpc = await connectRpc(this.json);
|
|
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}`);
|
|
return this;
|
|
}
|
|
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
|
}
|
|
async function stopAll() {
|
|
for (const m of miners) { try { m.stop(); } catch { } }
|
|
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); });
|
|
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
|
|
|
const t0 = Date.now();
|
|
const n0 = await new Node(0).start();
|
|
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`]).start();
|
|
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`]).start();
|
|
const nodes = [n0, n1, n2];
|
|
log(`n0: ${n0.grepLog(/Era VDF/).map(l => l.replace(/^.*?Era VDF/, 'Era VDF')).join(' | ') || '(no era VDF line)'}; cuts at S = ${ERA} n - ${LEAD}`);
|
|
|
|
const miners = [];
|
|
for (const [n, label] of [[n0, 'honest-a'], [n1, 'honest-b']]) {
|
|
const m = new Miner({ node: n, share: 0.5, bps: 1, label });
|
|
await m.start(); miners.push(m);
|
|
}
|
|
const advRpc = n2.rpc;
|
|
const advAddr = devAddress('era-vdf-adversary');
|
|
const advKey = voteKeyHashFor('era-vdf-adversary');
|
|
|
|
async function dagInfo(n) { return n.rpc.call('getBlockDagInfo'); }
|
|
async function getBlock(n, hash) { const r = await n.rpc.call('getBlock', { hash, includeTransactions: false }); return r.block || r; }
|
|
const hdr = (b) => b.header || {};
|
|
const vd = (b) => b.verboseData || b.verbose_data || {};
|
|
const daaOf = (b) => +(hdr(b).daaScore ?? hdr(b).daa_score);
|
|
const spOf = (b) => vd(b).selectedParentHash ?? vd(b).selected_parent_hash;
|
|
const hashOf = (b) => vd(b).hash;
|
|
async function powEpoch(n) { try { const t = await n.rpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] }); return t.powEpoch || t.pow_epoch || {}; } catch { return {}; } }
|
|
async function virtualDaa(n) { return +((await powEpoch(n)).virtualDaaScore || 0); }
|
|
// the node's seed_below: from the sink down the selected parents to the first block with DAA score under `score`
|
|
async function chainBlockBelow(n, score) {
|
|
const d = await dagInfo(n);
|
|
let cur = d.sink;
|
|
for (let k = 0; k < 4096; k++) {
|
|
const b = await getBlock(n, cur);
|
|
if (daaOf(b) < score) return b;
|
|
const sp = spOf(b);
|
|
if (!sp || sp === cur) return b;
|
|
cur = sp;
|
|
}
|
|
return null;
|
|
}
|
|
// A by its nonce: from the submitting node first, reading from A's own selected parent (getBlocks from a block far
|
|
// below the tip answers a short window that misses A), then from n0 the same way, then from the settled anchor
|
|
async function findAdversaryHash(tries, nonce) {
|
|
// the node logs every proof-of-work check with the block's hash, DAA score and nonce: the one line that names A
|
|
// whatever its place in the DAG (a side block getBlocks never lists)
|
|
const re = new RegExp('PoW rejected ([0-9a-f]{64}) by [a-z0-9-]+ \\(daa ([0-9]+), nonce 0x' + nonce.toString(16) + '\\)');
|
|
for (const n of [n0, n2, n1]) {
|
|
for (const line of n.grepLog(re)) { const m = re.exec(line); if (m) return { hash: m[1], daa: +m[2] }; }
|
|
}
|
|
for (const [n, lowHash] of tries) {
|
|
if (!lowHash) continue;
|
|
try {
|
|
const r = await n.rpc.call('getBlocks', { lowHash, includeBlocks: true, includeTransactions: false });
|
|
for (const b of (r.blocks || [])) if (String(hdr(b).nonce) === String(nonce)) return { hash: hashOf(b), daa: daaOf(b) };
|
|
} catch { }
|
|
}
|
|
return { hash: null, daa: null };
|
|
}
|
|
// the era's reported seed for era e, once the node has it (the VDF's record, or the stand-in at once)
|
|
async function reportedEraSeed(n, e, waitMs) {
|
|
const t1 = Date.now();
|
|
let states = [];
|
|
while (Date.now() - t1 < waitMs) {
|
|
const pe = await powEpoch(n);
|
|
if (+pe.eraIndex === e) {
|
|
states.push(+pe.eraVdfState);
|
|
if (pe.eraSeed) return { seed: String(pe.eraSeed), input: pe.eraVdfInput ? String(pe.eraVdfInput) : null, state: +pe.eraVdfState, states, waited: (Date.now() - t1) / 1000 };
|
|
}
|
|
await sleep(250);
|
|
}
|
|
return { seed: null, input: null, state: null, states, waited: (Date.now() - t1) / 1000 };
|
|
}
|
|
|
|
async function attackCut(e) {
|
|
const score = e * ERA - LEAD;
|
|
const target = score - 1;
|
|
let tmpl = null;
|
|
for (let k = 0; k < 2400; k++) {
|
|
try {
|
|
tmpl = await advRpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] });
|
|
const s = +(tmpl.powEpoch || tmpl.pow_epoch || {}).virtualDaaScore;
|
|
if (s >= target) break;
|
|
} catch { }
|
|
await sleep(100);
|
|
}
|
|
const A = tmpl.block;
|
|
A.header.voteKeyHash = advKey;
|
|
const nonce = 0xE7A0000000 + e;
|
|
A.header.nonce = nonce;
|
|
// the anchor for finding A afterwards: a settled chain block well below the cut (the sink of this moment may be
|
|
// reorged off the chain by A or by an honest sibling, and getBlocks from a non-chain block answers nothing)
|
|
const anchorBlock = await chainBlockBelow(n0, target - 30);
|
|
const anchor = anchorBlock ? hashOf(anchorBlock) : (await dagInfo(n0)).sink;
|
|
const tHold = Date.now();
|
|
const daaAtHold = await virtualDaa(n0);
|
|
// the adversary's candidate input: with the VDF off the draw of hash(A) is known at once (the stand-in); with it on
|
|
// the adversary runs the delay over the candidate it can name (A's selected parent: the real input is the hash over
|
|
// the cut block's day, fixed only when the cut settles, and no candidate's draw is known before T steps)
|
|
const candidate = String(A.header.parents?.[0]?.[0] || anchor);
|
|
let adv = { secs: 0, seed: null };
|
|
if (VDF === 'on') adv = await adversaryEval(candidate);
|
|
const daaAfterEval = await virtualDaa(n0);
|
|
let submit;
|
|
try { submit = submitReport(await advRpc.call('submitBlock', { block: A, allowNonDaaBlocks: false })); }
|
|
catch (e2) { submit = `error:${e2.message}`; }
|
|
const tPublish = (Date.now() - tHold) / 1000;
|
|
const aParent = A.header.parents?.[0]?.[0] || null;
|
|
await sleep(1500);
|
|
const a = await findAdversaryHash([[n2, aParent], [n0, aParent], [n0, anchor]], nonce);
|
|
// wait for the era to start, then for the node's seed of the era
|
|
for (let k = 0; k < 400; k++) { if (await virtualDaa(n0) >= e * ERA + 2) break; await sleep(250); }
|
|
const cutBlock = await chainBlockBelow(n0, score);
|
|
const cutHash = cutBlock ? hashOf(cutBlock) : null;
|
|
const rep = await reportedEraSeed(n0, e, 120_000);
|
|
const rep1 = await reportedEraSeed(n1, e, 20_000);
|
|
const rep2 = await reportedEraSeed(n2, e, 20_000);
|
|
const steer = !!(cutHash && a.hash && cutHash === a.hash);
|
|
const knownDraw = VDF === 'off' ? !!(rep.seed && a.hash && rep.seed === a.hash) : !!(rep.seed && adv.seed && rep.seed === adv.seed);
|
|
return {
|
|
era: e, cut_score: score, cut_block: cutHash, adversary_block: a.hash, adversary_block_daa: a.daa, submit,
|
|
hold_daa: daaAtHold, publish_daa: daaAfterEval, blocks_during_eval: daaAfterEval - daaAtHold, adversary_eval_secs: adv.secs, publish_after_secs: tPublish,
|
|
adversary_candidate: candidate, adversary_precomputed_seed: adv.seed,
|
|
era_seed: rep.seed, era_input: rep.input, era_vdf_state: rep.state, states_seen: [...new Set(rep.states)], seed_wait_secs: rep.waited,
|
|
nodes_agree: !!(rep.seed && rep.seed === rep1.seed && rep.seed === rep2.seed),
|
|
draw: rep.seed ? drawOf(rep.seed) : null,
|
|
steer_to_adversary_block: steer, reroll_known_draw: knownDraw,
|
|
};
|
|
}
|
|
|
|
let startDaa = 0;
|
|
for (let k = 0; k < 60; k++) { startDaa = await virtualDaa(n0); if (startDaa > 0) break; await sleep(500); }
|
|
const firstE = Math.floor(startDaa / ERA) + 2;
|
|
log(`start virtual daa ${startDaa}; attacking cuts for eras ${firstE}..${firstE + CUTS - 1} (S = ${firstE * ERA - LEAD} and up); vdf ${VDF}${VDF === 'on' ? ` scheme ${SCHEME} T ${T}` : ''}`);
|
|
const records = [];
|
|
for (let e = firstE; e < firstE + CUTS && Date.now() - t0 < SECS * 1000; e++) {
|
|
try {
|
|
const r = await attackCut(e);
|
|
records.push(r);
|
|
log(`cut era ${e} (S ${r.cut_score}): cut ${String(r.cut_block).slice(0, 12)} A ${String(r.adversary_block).slice(0, 12)} submit ${r.submit} eval ${r.adversary_eval_secs.toFixed(2)} s (${r.blocks_during_eval} blocks) seed ${String(r.era_seed).slice(0, 12)} state ${r.era_vdf_state} after ${r.seed_wait_secs.toFixed(1)} s ${r.nodes_agree ? 'agree' : 'DISAGREE'} ${r.reroll_known_draw ? 'RE-ROLLED (known draw)' : (r.steer_to_adversary_block ? 'steered blind' : 'held')} ${r.draw || ''}`);
|
|
} catch (e2) { log(`cut era ${e}: ${e2.message}`); }
|
|
}
|
|
|
|
await sleep(2000);
|
|
const sinks = (await Promise.all(nodes.map(n => dagInfo(n).catch(() => ({}))))).map(d => String(d.sink || '?').slice(0, 16));
|
|
const accepted = records.filter(r => r.submit === 'accepted');
|
|
const rerolls = records.filter(r => r.reroll_known_draw);
|
|
const steers = records.filter(r => r.steer_to_adversary_block);
|
|
const agree = records.every(r => r.nodes_agree);
|
|
const gatePass = rerolls.length === 0;
|
|
const expectReroll = VDF === 'off';
|
|
const harnessSound = expectReroll ? rerolls.length > 0 : rerolls.length === 0;
|
|
const evals = records.map(r => r.adversary_eval_secs);
|
|
const summary = {
|
|
tag: TAG, vdf: VDF, scheme: SCHEME, t: VDF === 'on' ? T : null, cuts_attempted: records.length, era_blocks: ERA, era_lead: LEAD, genesis_bits: GENESIS_BITS,
|
|
adversary_blocks_accepted: accepted.length, rerolls_known_draw: rerolls.length, steers_blind: steers.length,
|
|
adversary_eval_secs_min: evals.length ? Math.min(...evals) : 0, adversary_eval_secs_max: evals.length ? Math.max(...evals) : 0,
|
|
block_interval_secs: 1, all_nodes_agree: agree, gate_no_known_draw_reroll: gatePass, expect_reroll: expectReroll, harness_sound: harnessSound,
|
|
sinks, sinks_agree: new Set(sinks).size === 1, node: IGNEUMD, miner: MINER, records,
|
|
};
|
|
writeFileSync(`${OUT}/reroll-${TAG}.json`, JSON.stringify(summary, null, 2));
|
|
log(`SUMMARY ${TAG}: ${records.length} cuts, ${accepted.length} adversary blocks accepted, ${rerolls.length} known-draw re-rolls, ${steers.length} blind steers; adversary eval ${summary.adversary_eval_secs_min.toFixed(2)} to ${summary.adversary_eval_secs_max.toFixed(2)} s against a 1 s block interval; nodes ${agree ? 'agree on every era seed' : 'DISAGREE'}; gate(no known-draw re-roll) ${gatePass ? 'PASS' : 'FAIL'}; expect re-roll ${expectReroll} -> harness ${harnessSound ? 'SOUND' : 'UNSOUND'}; sinks ${sinks.join(' ')} (${summary.sinks_agree ? 'agree' : 'DIFFER'})`);
|
|
log(`summary: ${OUT}/reroll-${TAG}.json`);
|
|
await stopAll();
|
|
process.exit(harnessSound ? 0 : 1);
|