421 lines
33 KiB
JavaScript
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);
|