igneum/infra/fast-time/class-v5-signal.mjs

421 lines
33 KiB
JavaScript

#!/usr/bin/env node
// Class v5, proof of stored state and of following (docs/design/class-v5-stored-state.md section 10): the class v4
// signal gate's shape with class v5. Three nodes on override-60x.json with CPU genesis bits, v3 from DAA 60 (epoch 1),
// the v4 FLOOR at --v4-floor (default 120, epoch 2), the class v5 floor at --floor (default 100000: far away, so v5 is
// ENABLED and the signal decides; `never` turns the object off), the signal window at --window (default 60 DAA, one
// epoch; seven windows = 420 DAA, so the first epoch whose seed block has seven full windows below it is epoch 8 at
// DAA 480), each node's object byte by IGNEUM_CLASS_SIGNAL (--signal a,b,c; since the 0.3.20 node line the amended
// class v4 is object byte 5 and class v5 is 6), each node's exec RPC on its own loopback
// port and each CPU miner's --exec-rpc pointing at its node (the state stream after each epoch's seed block). A fourth
// node runs with --evm-disable (--stateless-node, default on): it mines until the flip and then serves no class v5
// template, which the harness counts. Ports 29760 and up, network igneum-devnet-976, data /tmp/igneum-fast-time-v5s.
//
// The cases and the known-failed cases:
// --signal 6,6,7 --expect no-flip two of three signal v5: 67 percent, the class must stay v4 (run 11 epochs)
// --signal 6,6,6 --expect flip all three: v5 from epoch 8, the first boundary with seven full windows; every
// miner's v5 id equals the CLI's --program-class v5 id and differs from the same
// seed's v4 id; the stateless node's miner accepts 0 blocks after the flip; 0 rejected
// --signal 6,6,6 --floor never --expect no-flip the object off: byte 6 counts as v4 only
// --signal 7,7,7 --floor 600 --expect floor nobody signals v5: it flips at the floor (epoch 10) and not before
// --signal 6,6,6 --expect flip --stale 2 proof of following's known-failed case: miner 2 keeps the first stream it
// fetched (--freeze-state); from the epoch after the flip every block it mines is
// rejected (accepted after the first refresh 0, rejected above 0); PASS means the
// chain threw the stale miner off
// --signal 6,6,7 --expect flip the known-failed case of the harness itself: it must report FAIL (no flip)
//
// node infra/fast-time/class-v5-signal.mjs --signal a,b,c --expect flip|no-flip|floor [--floor <daa>|never]
// [--v4-floor 120] [--window 60] [--v3-activation 60] [--secs 780] [--epochs 11] [--stale <miner index>] [--no-stateless-node]
// IGNEUMD, IGNEUM_MINER, IGNEUM_POW name the binaries (defaults: the class-v5 fork worktree's target/release and
// igneum-pow/target/release/igneum-pow, the layout on igneum-build-1 under /srv/builds/igneum-wt-class-v5).
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
import { devAddress } from '../../tools/harness/lib/address.mjs';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const BIN = process.env.IGNEUM_V5_BIN || `${ROOT}vendor/igneum-node-class-v5/target/release`;
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
const IGNEUM_POW = process.env.IGNEUM_POW || `${ROOT}igneum-pow/target/release/igneum-pow`;
const TMP = process.env.IGNEUM_V5_TMP || '/tmp/igneum-fast-time-v5s';
const BASE = +(process.env.IGNEUM_V5_BASE || 29760), SUFFIX = 976;
const NEVER = '18446744073709551615';
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) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
const actflag = (name, dflt) => { const v = sflag(name); if (v == null) return dflt; return v === 'never' ? null : +v; };
const GENESIS_BITS = flag('genesis-bits', 0x1f010000);
const SECS = flag('secs', 780);
const EPOCHS = flag('epochs', 11);
const FLOOR = actflag('floor', 100000);
const V4_FLOOR = actflag('v4-floor', 120);
const V3_ACTIVATION = actflag('v3-activation', 60);
const WINDOW = flag('window', 60);
const STALE = sflag('stale') == null ? null : +sflag('stale');
const STATELESS_NODE = !args.includes('--no-stateless-node');
const WINDOWS = 7;
const SIGNAL = (sflag('signal') || '6,6,6').split(',').map(Number);
// since the 0.3.23 node line the bytes are not ordered by class: 7 = class v4 sub-version 3, 6 = class v5 (counted exactly)
const EXPECT = sflag('expect') || 'flip';
if (!['flip', 'no-flip', 'floor'].includes(EXPECT) || SIGNAL.length !== 3) { console.error('usage: --signal a,b,c --expect flip|no-flip|floor'); 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));
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
// the previous run's survivors (the flip-stale run of 7 October 2026, 08:49Z: two nodes of the earlier case were still bound
// to the ports and the harness read a stranger's chain): the pid file of this data dir is killed first, by pid, and a node
// whose log says a port was in use fails the run at once
const PIDS = `${TMP}/pids`;
try { for (const pid of readFileSync(PIDS, 'utf8').split('\n').map(Number).filter(Boolean)) { try { process.kill(pid, 'SIGKILL'); console.log(`killed survivor pid ${pid} of the previous run`); } catch { } } } catch { }
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const recordPid = (p) => { try { writeFileSync(PIDS, `${readFileSync(PIDS, 'utf8')}${p.pid}\n`); } catch { writeFileSync(PIDS, `${p.pid}\n`); } };
let baseText = readFileSync(FILE, 'utf8');
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
const EPOCH = field('pow_epoch_blocks');
const LEAD = field('pow_epoch_lead');
const DAY_MS = field('pow_day_ms');
const FIRST_V3_EPOCH = V3_ACTIVATION == null ? null : Math.ceil(V3_ACTIVATION / EPOCH);
const V4_FLOOR_EPOCH = V4_FLOOR == null ? null : Math.ceil(V4_FLOOR / EPOCH);
const FLOOR_EPOCH = FLOOR == null ? null : Math.ceil(FLOOR / EPOCH);
// the first epoch whose seed block (the last chain block below L*e - lead) can have DAA >= 7 x WINDOW: L*e - lead - 1 >= 7W,
// and whose previous epoch is already v4 (one step per epoch)
let FIRST_FULL_EPOCH = 0;
while (FIRST_FULL_EPOCH * EPOCH - LEAD - 1 < WINDOWS * WINDOW) FIRST_FULL_EPOCH++;
if (V4_FLOOR_EPOCH != null && FIRST_FULL_EPOCH <= V4_FLOOR_EPOCH) FIRST_FULL_EPOCH = V4_FLOOR_EPOCH + 1;
export 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`);
}
const asText = (v) => v == null ? NEVER : String(v);
// The 60x file is master's and carries every lane's override fields; the fork binary under BIN was built from the
// fork source beside it and refuses a field it does not know (OverrideParams is deny_unknown_fields). The fields the
// binary knows are the fields its OverrideParams names: read them from the source, drop the rest, and say so. A
// missing source file means no filtering (the binary then speaks for itself).
export function knownOverrideFields(paramsSource) {
const m = /pub struct OverrideParams \{([\s\S]*?)\n\}/.exec(paramsSource);
if (!m) return null;
return new Set([...m[1].matchAll(/^\s*pub (\w+):/gm)].map((x) => x[1]));
}
export function dropUnknownOverrideText(text, known) {
if (!known) return { text, dropped: [] };
const dropped = [];
let out = text;
// text, not JSON.parse round-trip: the never value 18446744073709551615 does not survive a JS number. A value is a
// one-level object, a one-level array, or a scalar (the 60x file's shapes).
for (const k of Object.keys(JSON.parse(text))) if (!known.has(k)) { dropped.push(k); out = out.replace(new RegExp(`\\s*"${k}":\\s*(\\{[^{}]*\\}|\\[[^\\[\\]]*\\]|[^,}\\n]+),?`), ''); }
out = out.replace(/,(\s*)}\s*$/, '$1}\n');
return { text: out, dropped };
}
const paramsSource = (() => { try { return readFileSync(`${BIN}/../../consensus/core/src/config/params.rs`, 'utf8'); } catch { return null; } })();
const filtered = dropUnknownOverrideText(baseText, paramsSource ? knownOverrideFields(paramsSource) : null);
if (filtered.dropped.length) log(`override: dropped ${filtered.dropped.length} field(s) the fork binary does not know (another lane's, riding in master's 60x file): ${filtered.dropped.join(', ')}`);
baseText = filtered.text;
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(baseText, { genesis_bits: GENESIS_BITS, skip_proof_of_work: false, program_class_v3_activation_daa: asText(V3_ACTIVATION), program_class_v4_activation_daa: asText(V4_FLOOR), program_class_v4_signal_window_daa: String(WINDOW), program_class_v5_activation_daa: asText(FLOOR) }));
log(`signals ${SIGNAL.join('/')}, expect ${EXPECT}; v3 from ${V3_ACTIVATION ?? 'never'} (epoch ${FIRST_V3_EPOCH ?? 'none'}), v4 floor ${V4_FLOOR ?? 'never'} (epoch ${V4_FLOOR_EPOCH ?? 'none'}), v5 floor ${FLOOR ?? 'never'} (epoch ${FLOOR_EPOCH ?? 'none'}), window ${WINDOW} DAA x ${WINDOWS} (the first epoch with seven full windows after v4 is ${FIRST_FULL_EPOCH}); stale miner ${STALE ?? 'none'}; stateless node ${STATELESS_NODE ? 'n3' : 'none'}; ${EPOCH} DAA per epoch, lead ${LEAD}; run ${SECS} s or ${EPOCHS} epochs`);
class Node {
constructor(i, connect = [], stateless = false) {
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.execPort = BASE + i * 10 + 3;
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; this.stateless = stateless;
}
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
get execRpc() { return `http://127.0.0.1:${this.execPort}`; }
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'];
// class v5: each node's executor serves the state stream on its own loopback port; the stateless node has none
if (this.stateless) a.push('--evm-disable'); else a.push(`--evm-rpclisten=127.0.0.1:${this.execPort}`);
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
// the node's own object byte: what its templates signal (the stateless node signals the highest byte too)
const byte = this.i < SIGNAL.length ? SIGNAL[this.i] : Math.max(...SIGNAL);
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_CLASS_SIGNAL: String(byte) } });
started.push(this.proc);
recordPid(this.proc);
await sleep(1200);
if (this.grepLog(/Address already in use|AddrInUse/).length) { log(`FAILED: n${this.i} could not bind its ports (a previous run's node is still alive): ${this.grepLog(/AddrInUse|Address already in use/)[0].slice(0, 160)}`); await stopAll(); process.exit(4); }
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort} exec ${this.stateless ? 'disabled' : this.execPort}, signals ${byte}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
}
function miner(bin, argv, name, env = {}) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(bin, argv, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
started.push(p);
recordPid(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); });
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
const minerLog = (i) => { try { return readFileSync(`${TMP}/cpu${i}.log`, 'utf8').split('\n'); } catch { return []; } };
const SIGNAL_LINE = /Program class v5 by miner signal: epoch (\d+) \(share (\d+) bps/;
const FLOOR_LINE = /Program class v5 from the override file/;
const REFUSAL_LINE = /class v5 needs the execution state/;
const WINDOW_LINE = /Program class v4 signal window from the override file/;
const OWN_LINE = /Program class signal from IGNEUM_CLASS_SIGNAL: this node signals object version (\d+)/;
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
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];
if (STATELESS_NODE) nodes.push(await new Node(3, [`127.0.0.1:${n0.p2pPort}`], true).start());
const SIGNALLING = [n0, n1, n2];
for (const n of nodes) log(`n${n.i}: ${n.grepLog(WINDOW_LINE).map(l => l.replace(/^.*?(Program class v4 signal window)/, '$1'))[0] || '(no window line)'} | ${n.grepLog(OWN_LINE).map(l => l.replace(/^.*?(this node signals)/, '$1'))[0] || '(no signal line)'}`);
log(`n0 digest: ${n0.grepLog(/Consensus params digest/).map(l => l.replace(/^.*?digest: /, '').slice(0, 16)).join(' ')}`);
nodes.forEach((n, i) => {
// the stateless node's miner is pointed at its own node's (absent) exec port, so its refusal is its node's and not a stranger's
const extra = ['--exec-rpc', n.execRpc];
if (STALE === i) extra.push('--freeze-state');
miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', `cpu${i}`, '--status-secs', '30', '--no-vote', ...extra], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) });
});
const pay = devAddress('fast-time-v5s');
const minerAcceptedAt = (i) => minerLog(i).filter(l => /ACCEPTED block/.test(l)).map(l => { const m = /^(\d+\.\d+) /.exec(l); return m ? +m[1] : null; }).filter(t => t != null);
const epochs = new Map();
let firstV4 = null, lastEpoch = -1, lastReport = 0, lastDaa = 0, endAt = null, flipWall = null;
const epochSeeds = new Map();
const dayOfEpoch = new Map();
const samples = [];
while (Date.now() - t0 < SECS * 1000) {
await sleep(1000);
let daa = null, epoch = null, cls = null, nextCls = null, eraSeed = null, bps = null, bps5 = null, win = null, sig = null, sigEpoch = null, seed = null, day = null;
try {
const t = await n0.rpc.call('getBlockTemplate', { payAddress: pay, extraData: [] });
const pe = t.powEpoch || t.pow_epoch || {};
daa = pe.virtualDaaScore ?? t.block?.header?.daaScore; epoch = pe.epochIndex; cls = pe.programClass; nextCls = pe.nextProgramClass;
eraSeed = pe.eraSeed; bps = pe.programClassV4SignalBps; bps5 = pe.programClassV5SignalBps; win = pe.programClassV4SignalWindowDaa; sig = pe.programClassSignal; sigEpoch = pe.programClassV5SignalEpoch;
seed = pe.epochSeed; day = Math.floor((+t.block?.header?.timestamp || 0) / DAY_MS);
} catch (e) { log(`template: ${e.message}`); }
if (epoch != null && epoch !== lastEpoch) {
epochs.set(epoch, { class: cls, firstSeenDaa: daa, at: +since(), eraSeed: eraSeed == null ? null : String(eraSeed), bps, bps5, signal_epoch: sigEpoch ?? null, day });
if (seed != null) epochSeeds.set(epoch, String(seed));
dayOfEpoch.set(epoch, day);
log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s: template class ${cls}, next ${nextCls}, day ${day}, signal share at the sink v4 ${bps} v5 ${bps5} bps (window ${win}, this node signals ${sig}, v5 decided by signal at epoch ${sigEpoch ?? 'none'})`);
if (firstV4 == null && cls === 5) { firstV4 = { epoch, daa, at: +since() }; flipWall = Date.now(); log(`CLASS SWITCH: the template is class v5 from epoch ${epoch} (daa ${daa}) at ${since()} s wall`); }
lastEpoch = epoch;
}
lastDaa = daa ?? lastDaa;
if (Date.now() - lastReport > 15000) {
lastReport = Date.now();
const counts = await Promise.all(nodes.map(async n => { try { const d = await n.rpc.call('getBlockDagInfo'); return `${d.blockCount}/${String(d.sink).slice(0, 8)}`; } catch { return '?'; } }));
log(`t=${since()} s daa ${daa} epoch ${epoch} class ${cls} signal v4 ${bps} v5 ${bps5} bps blocks/sink per node ${counts.join(' ')}`);
samples.push({ t: +since(), daa, epoch, class: cls, bps, bps5, nodes: counts });
}
// the end: three epochs after a flip (two refreshes for the stale miner), or --epochs epochs when no flip is expected
if (firstV4 != null && daa != null && daa >= (firstV4.epoch + 3) * EPOCH) { endAt = +since(); break; }
if (firstV4 == null && daa != null && daa >= EPOCHS * EPOCH) { endAt = +since(); break; }
}
// the miners stop before the final read: with every node mining at the fast-time rate a node's own newest block is
// a tip its peers have not seen for a moment, and a read taken while they mine compares sinks mid-propagation (the
// 19:50 UK run of 7 October 2026 read 666/665/665 blocks and two sinks on three agreeing nodes). Their logs stay.
const nodeProcs = new Set(nodes.map(n => n.proc));
for (const p of started) if (!nodeProcs.has(p)) { try { p.kill('SIGINT'); } catch { } }
await sleep(3000);
const dag = await Promise.all(nodes.map(async n => { try { return await n.rpc.call('getBlockDagInfo'); } catch (e) { return { error: e.message }; } }));
const genesis = dag[0].pruningPointHash;
async function allBlocks(n) {
const out = []; let low = genesis; const seen = new Set();
for (let round = 0; round < 500; round++) {
const r = await n.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: false });
const blocks = r.blocks || [];
let added = 0;
for (const b of blocks) { const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h); out.push({ hash: h, daa: +b.header.daaScore, version: +b.header.version, chain: !!b.verboseData?.isChainBlock }); added++; }
if (!blocks.length || added === 0) break;
low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break;
}
return out;
}
let blocks = [];
try { blocks = await allBlocks(n0); } catch (e) { log(`getBlocks: ${e.message}`); }
const BOUNDARY = firstV4 ? firstV4.epoch * EPOCH : Infinity;
const before = blocks.filter(b => b.daa < BOUNDARY), after = blocks.filter(b => b.daa >= BOUNDARY);
// the signal bytes on the chain: the share of blocks whose version high byte is 4
const versionBytes = blocks.reduce((m, b) => { const v = b.version >> 8; m[v] = (m[v] || 0) + 1; return m; }, {});
const V5_BYTE = 6;
const signalShareOnChain = blocks.length ? Math.round(10000 * (blocks.filter(b => (b.version >> 8) === V5_BYTE).length) / blocks.length) : 0;
const programs = new Map();
for (const i of nodes.map(n => n.i)) for (const l of minerLog(i)) {
const m = /epoch seed ([0-9a-f]{64}) day (\d+) \(daa (\d+)\): program and 256 MiB cache ready in ([\d.]+) ms; class (v\d) program id ([0-9a-f]{16})/.exec(l);
if (!m) continue;
const k = m[1]; const e = programs.get(k) || { seed: k.slice(0, 16), epoch: Math.floor(+m[3] / EPOCH), class: m[5], id: m[6], miners: new Set() };
if (e.id !== m[6] || e.class !== m[5]) e.disagree = true;
e.miners.add(i); programs.set(k, e);
}
const programRows = [...programs.values()].sort((a, b) => a.epoch - b.epoch).map(p => ({ epoch: p.epoch, class: p.class, program_id: p.id, seed: p.seed, miners: p.miners.size, disagree: !!p.disagree }));
// the state stream after an epoch's seed block, from node 0's exec RPC, for the CLI's --state
async function streamFile(epoch) {
const seed = epochSeeds.get(epoch);
if (!seed) return null;
try {
const r = await fetch(n0.execRpc, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'igneum_getPowStateLeaves', params: [seed] }) });
const j = await r.json();
if (!j.result?.streamHex) { log(`stream for epoch ${epoch}: ${JSON.stringify(j.error || j).slice(0, 200)}`); return null; }
const path = `${TMP}/state-e${epoch}.bin`;
writeFileSync(path, Buffer.from(j.result.streamHex.slice(2), 'hex'));
return { path, root: j.result.stateRoot, records: j.result.records, block: j.result.block };
} catch (e) { log(`stream for epoch ${epoch}: ${e.message}`); return null; }
}
function cliId(seedHex, eraHex, cls, statePath) {
if (!existsSync(IGNEUM_POW)) return null;
const extra = statePath ? ['--state', statePath] : [];
const r = spawnSync(IGNEUM_POW, ['show', '--epoch-hex', seedHex, '--program-class', cls, '--era-hex', eraHex, ...extra], { encoding: 'utf8' });
const m = /program id ([0-9a-f]{16})/.exec(r.stdout || '');
return m ? m[1] : null;
}
const idRows = [];
const streams = {};
for (const [k, e] of programs) {
if (e.class !== 'v5') continue;
const era = epochs.get(e.epoch)?.eraSeed;
const st = await streamFile(e.epoch);
if (st) streams[e.epoch] = { root: st.root, records: st.records, block: st.block };
idRows.push({ epoch: e.epoch, seed: e.seed, miners_id: e.id, miners: e.miners.size, cli_v4: era ? cliId(k, era, 'v4') : null, cli_v5: era && st ? cliId(k, era, 'v5', st.path) : null, state_root: st?.root ?? null, state_records: st?.records ?? null });
}
// every executing node's stream for the LAST v5 epoch's seed block must carry the same root (the serialisation agrees
// across nodes); the executor keeps the current epoch's capture and the two before it, so the flip epoch's stream is
// gone by the end of a run that went three epochs past it (the 10:55Z run read -32xxx errors for epoch 8 and streams
// for 9, 10 and 11)
const rootsAtFlip = [];
const lastV5Epoch = firstV4 ? Math.max(firstV4.epoch, ...[...epochs.entries()].filter(([, v]) => v.class === 5).map(([e]) => +e)) : null;
if (firstV4) for (const n of nodes) {
if (n.stateless) continue;
try {
const r = await fetch(n.execRpc, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'igneum_getPowStateLeaves', params: [epochSeeds.get(lastV5Epoch)] }) });
const j = await r.json();
rootsAtFlip.push(j.result?.stateRoot ?? `error: ${JSON.stringify(j.error || j).slice(0, 120)}`);
} catch (e) { rootsAtFlip.push(`error: ${e.message}`); }
}
const minerIdx = nodes.map(n => n.i);
const accepted = minerIdx.map(i => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length);
const rejectedMiner = minerIdx.map(i => minerLog(i).filter(l => /rejected nonce=|submit error/.test(l)));
const rejectedNode = SIGNALLING.map(n => n.grepLog(/PoW rejected|Rejected block|rejected block/i));
const signalLines = SIGNALLING.map(n => n.grepLog(SIGNAL_LINE).map(l => l.replace(/^.*?(Program class v5 by miner signal)/, '$1'))[0] || null);
const signalEpochs = signalLines.map(l => { const m = l && SIGNAL_LINE.exec(l); return m ? +m[1] : null; });
const signalShares = signalLines.map(l => { const m = l && SIGNAL_LINE.exec(l); return m ? +m[2] : null; });
const floorLines = SIGNALLING.map(n => n.grepLog(FLOOR_LINE).map(l => l.replace(/^.*?(Program class v5 from)/, '$1'))[0] || null);
// the stateless node: its miner's blocks accepted after the flip (must be 0) and its refusal lines
const statelessNode = nodes.find(n => n.stateless) || null;
const flipAtMs = flipWall;
// accepted-after counts: each miner's ACCEPTED lines after a wall time, by the line's own clock. igneum-miner stamps
// every line with epoch SECONDS (`1791374997.353 ACCEPTED block ...`); the first form of this function matched
// `HH:MM:SS` and counted nothing (found by the v5-fasttime lane, 7 October 2026, 20:3x UK: two PASS rows of the
// 12:11 and 13:21 runs read "accepted 0" as a parse miss). The parser is proved on the honest miners in the same
// run: `accepted_parser_proved` below requires at least one honest miner to show accepted lines after the flip.
function acceptedAfter(i, wallMs) {
if (wallMs == null) return 0;
const since = wallMs / 1000;
let count = 0;
for (const l of minerLog(i)) { if (!/ACCEPTED block/.test(l)) continue; const m = /^(\d{10}(?:\.\d+)?) /.exec(l); if (m && +m[1] >= since) count++; }
return count;
}
const statelessAcceptedAfterFlip = statelessNode ? acceptedAfter(statelessNode.i, flipAtMs) : 0;
// the parser's own known-finished case: the honest miners accepted blocks after the flip (else every "accepted 0" is a miss)
const honestAcceptedAfterFlip = SIGNALLING.filter(n => n.i !== STALE).map(n => acceptedAfter(n.i, flipAtMs));
// the refusal reaches the miner as a template error and the node's log as the engine's line; either counts
const statelessRefusals = statelessNode ? statelessNode.grepLog(REFUSAL_LINE).length + minerLog(statelessNode.i).filter(l => REFUSAL_LINE.test(l)).length : 0;
// the stale miner: accepted after the FIRST REFRESH after the flip (one epoch later), and its rejections
const refreshWall = flipAtMs == null ? null : flipAtMs + EPOCH * 1000;
const staleAcceptedAfterRefresh = STALE == null ? null : acceptedAfter(STALE, refreshWall);
const staleRejected = STALE == null ? null : rejectedMiner[STALE].length;
const daysSeen = [...new Set([...dayOfEpoch.values()])];
const sinks = dag.map(d => String(d.sink || '?').slice(0, 16));
const counts = dag.map(d => d.blockCount ?? '?');
const maxEpochSeen = Math.max(-1, ...epochs.keys());
const classesSeen = [...epochs.values()].map(e => e.class);
// the stale miner's rejections are the point of its case (its own node rejects them), and the stateless node must fall
// behind at the flip: both are excluded here and asserted by their own checks below
const signallingIdx = SIGNALLING.map(n => n.i);
const common = {
zero_rejected_by_miners: rejectedMiner.every((r, i) => i === STALE || r.length === 0),
zero_rejected_by_nodes: rejectedNode.every((r, i) => i === STALE || r.length === 0),
sinks_agree: new Set(signallingIdx.map(i => sinks[i])).size === 1,
block_counts_agree: new Set(signallingIdx.map(i => String(counts[i]))).size === 1,
miners_agree_on_every_program: programRows.every(p => !p.disagree),
window_line_on_every_node: nodes.every(n => n.grepLog(WINDOW_LINE).length > 0),
every_node_signals_its_byte: nodes.every((n, i) => n.grepLog(OWN_LINE).some(l => +OWN_LINE.exec(l)[1] === (SIGNAL[i] ?? Math.max(...SIGNAL)))),
// every block's byte is one of the nodes' (genesis, made before any node, is the one byte-0 block)
chain_carries_the_bytes: blocks.length > 0 && Object.keys(versionBytes).every(v => SIGNAL.includes(+v) || +v === Math.max(...SIGNAL) || (+v === 0 && versionBytes[v] === 1)),
// class v5: every executing node served the same state root for the flip epoch's seed block
state_roots_agree_across_nodes: !firstV4 || (rootsAtFlip.length === SIGNALLING.length && rootsAtFlip.every(r => r && !r.startsWith('error')) && new Set(rootsAtFlip).size === 1),
};
let checks;
if (EXPECT === 'flip') {
checks = {
...common,
template_switched_to_v5: firstV4 != null,
switched_at_the_first_full_window_epoch: firstV4 != null && firstV4.epoch === FIRST_FULL_EPOCH,
switched_before_the_floor: firstV4 != null && (FLOOR_EPOCH == null || firstV4.epoch < FLOOR_EPOCH),
signal_line_on_every_node_same_epoch: signalEpochs.every(e => e != null) && new Set(signalEpochs).size === 1 && signalEpochs[0] === (firstV4 && firstV4.epoch),
signal_share_at_or_above_threshold: signalShares.every(s => s != null && s >= 9500),
blocks_on_both_sides: before.length > 0 && after.length > 0,
// the rows whose stream the executor still held at the end (the last three epochs) must match the CLI; at least two of them
v5_ids_equal_the_cli_v5_id: idRows.filter(r => r.state_root != null).length >= 2 && idRows.filter(r => r.state_root != null).every(r => r.cli_v5 != null && r.cli_v5 === r.miners_id && r.miners === 3),
v5_ids_differ_from_the_same_seed_v4_id: idRows.length > 0 && idRows.every(r => r.cli_v4 != null && r.cli_v4 !== r.miners_id),
// proof of following: every v5 epoch's leaves came from another state root (the refresh per window)
a_v5_epoch_per_window_refresh: idRows.filter(r => r.state_root != null).length >= 2 && new Set(idRows.filter(r => r.state_root != null).map(r => r.state_root)).size === idRows.filter(r => r.state_root != null).length,
accepted_parser_proved: firstV4 == null || honestAcceptedAfterFlip.some(c => c > 0),
stateless_node_mines_nothing_after_the_flip: !statelessNode || (statelessAcceptedAfterFlip === 0 && statelessRefusals > 0),
stateless_node_stops_at_the_flip: !statelessNode || (counts[statelessNode.i] <= before.length && counts[statelessNode.i] >= before.length - 5),
stale_miner_falls_off_at_the_first_refresh: STALE == null || (staleAcceptedAfterRefresh === 0 && staleRejected > 0),
};
} else if (EXPECT === 'no-flip') {
checks = {
...common,
template_never_v5: firstV4 == null && !classesSeen.includes(5),
no_signal_line_on_any_node: signalLines.every(l => l == null),
ran_the_epochs: maxEpochSeen >= EPOCHS - 1,
v4_programs_seen: programRows.some(p => p.class === 'v4'),
signal_share_under_threshold_on_chain_or_the_object_off: signalShareOnChain < 9500 || FLOOR == null,
};
} else {
checks = {
...common,
template_switched_to_v5: firstV4 != null,
switched_at_the_floor_epoch: firstV4 != null && firstV4.epoch === FLOOR_EPOCH,
no_signal_line_on_any_node: signalLines.every(l => l == null),
floor_line_names_the_floor_epoch: floorLines.every(l => l && l.includes(`the floor at epoch ${FLOOR_EPOCH} `)),
blocks_on_both_sides: before.length > 0 && after.length > 0,
v5_ids_equal_the_cli_v5_id: idRows.filter(r => r.state_root != null).length >= 2 && idRows.filter(r => r.state_root != null).every(r => r.cli_v5 != null && r.cli_v5 === r.miners_id && r.miners === 3),
stateless_node_mines_nothing_after_the_flip: !statelessNode || (statelessAcceptedAfterFlip === 0 && statelessRefusals > 0),
stateless_node_stops_at_the_flip: !statelessNode || (counts[statelessNode.i] <= before.length && counts[statelessNode.i] >= before.length - 5),
};
}
const pass = Object.values(checks).every(Boolean);
const summary = {
pass, expect: EXPECT, signals: SIGNAL, checks, window: WINDOW, windows: WINDOWS, floor: FLOOR ?? 'never', v4_floor: V4_FLOOR ?? 'never', v3_activation: V3_ACTIVATION ?? 'never', epoch_blocks: EPOCH, lead: LEAD, first_full_window_epoch: FIRST_FULL_EPOCH, floor_epoch: FLOOR_EPOCH,
stale_miner: STALE, stale_accepted_after_first_refresh: staleAcceptedAfterRefresh, honest_accepted_after_flip: honestAcceptedAfterFlip, stale_rejected: staleRejected, stateless_node: statelessNode ? statelessNode.i : null, stateless_accepted_after_flip: statelessAcceptedAfterFlip, stateless_refusal_lines: statelessRefusals,
state_roots_at_flip: rootsAtFlip, streams, days_seen: daysSeen, day_boundary_crossed: daysSeen.length > 1,
node: IGNEUMD, miner: CPU_MINER, template_switch: firstV4, run_ended_at_s: endAt, final_daa: lastDaa, max_epoch_seen: maxEpochSeen,
epochs: Object.fromEntries([...epochs.entries()].map(([k, v]) => [k, v])),
blocks: { total: blocks.length, before_boundary: before.length, after_boundary: after.length, version_bytes: versionBytes, signal_share_bps_on_chain: signalShareOnChain },
programs: programRows, program_id_rows: idRows, accepted_per_miner: accepted,
rejected_by_miners: rejectedMiner.map(r => r.length), rejected_by_nodes: rejectedNode.map(r => r.length),
sinks, block_counts: counts, signal_lines: signalLines, floor_lines: floorLines, samples,
};
writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2));
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}, signals ${SIGNAL.join('/')}, stale ${STALE ?? 'none'}): ${firstV4 ? `v5 from epoch ${firstV4.epoch} at DAA ${firstV4.daa}` : 'no v5 epoch'}; epochs seen ${[...epochs.entries()].map(([e, v]) => `e${e}:${v.class}:${v.bps5}bps:d${v.day}`).join(' ')}; chain bytes ${JSON.stringify(versionBytes)} (${signalShareOnChain} bps at byte ${V5_BYTE}); blocks ${before.length} / ${after.length}; rejected miners ${rejectedMiner.map(r => r.length).join('/')} nodes ${rejectedNode.map(r => r.length).join('/')}; sinks ${sinks.join(' ')} (${checks.sinks_agree ? 'agree' : 'DIFFER'}); counts ${counts.join('/')}; signal lines ${signalLines.filter(Boolean).length}/3 (epochs ${signalEpochs.join('/')}, shares ${signalShares.join('/')}); floor lines ${floorLines.filter(Boolean).length}/3; state roots at the flip ${rootsAtFlip.map(r => String(r).slice(0, 18)).join(' ')}; stateless node accepted after the flip ${statelessAcceptedAfterFlip} (refusal lines ${statelessRefusals}); stale miner accepted after the first refresh ${staleAcceptedAfterRefresh ?? 'n/a'} rejected ${staleRejected ?? 'n/a'}; days ${daysSeen.join('/')}`);
for (const r of idRows) log(`PROGRAM ID epoch ${r.epoch} seed ${r.seed}: miners ${r.miners_id} (${r.miners} of 3) cli v5 ${r.cli_v5} cli v4 ${r.cli_v4}; state root ${r.state_root} (${r.state_records} records)`);
for (const [k, v] of Object.entries(checks)) if (!v) log(`FAILED CHECK ${k}`);
log(`summary: ${TMP}/summary.json`);
await stopAll();
process.exit(pass ? 0 : 1);