#!/usr/bin/env node // ATTACK-PASS COPY (F10, 7 October 2026) of infra/fast-time/latency-ladder.mjs from branch ladder at 7003f9f5, taken // verbatim except: (1) ROOT is found by walking up from this file to the tree that holds tools/finality-attacks/lib/rpc.mjs // (the file lives at tools/attack/f10-ladder/ on the Mac and at attack-f10/ on the box), the two lib imports are dynamic; // (2) the override file is this directory's copy of the ladder branch's override-60x.json (the attack-pass tree's copy // lacks the latency_ladder fields); (3) ports, devnet suffix and data dir come from IGNEUM_F10_BASE / IGNEUM_F10_SUFFIX / // IGNEUM_LADDER_TMP with the F10 defaults 29900 / 990 / /tmp/igneum-fast-time-attack-f10, so this network never collides // with the ladder lane's (29720 / 972) or F7's (29800 / 980); (4) the binaries default to the box's ladder lane layout, // read-only. The checks, the cases and the known-failed case are the original's, unchanged. // // The latency ladder's fast-time gate (docs/design/latency-ladder.md section 9; the class-v4-signal.mjs shape): a 3-node // network on override-60x.json, class v4 from genesis (v3 and the v4 floor at 0, the class window 0: class signalling off, // so the ladder opens the header's high byte on its own), the ladder active from DAA 0 with one window of --window DAA // (default 60, 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 ladder signal set by IGNEUM_LADDER_SIGNAL (--signal a,b,c of up|down|none), one real CPU // miner per node. Ports 29720 and up, network igneum-devnet-972, data /tmp/igneum-fast-time-ladder. // // The cases and the known-failed case: // --signal up,up,none --expect no-step two of three miners signal up: about 67 percent, rung 0 must hold (run 10 epochs) // --signal up,up,up --expect step all three: rung 1 (35 shadow passes) from epoch 8, the first with seven full // windows, every miner's rung-1 program id equal to the CLI's --shadow-reps 35 id and // unequal to the rung-0 id, and NO second step inside the next two epochs // --signal up,up,none --expect step the known-failed case: the harness must report FAIL (no step happened) // // node infra/fast-time/latency-ladder.mjs --signal a,b,c --expect step|no-step [--window 60] [--secs 900] [--epochs 10] // IGNEUMD, IGNEUM_MINER, IGNEUM_POW name the binaries (defaults: the ladder fork worktree's target/release and // igneum-pow/target/release/igneum-pow, the layout on igneum-build-1 under /srv/builds/igneum-wt-ladder). import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs'; import { dirname } from 'node:path'; const HERE = dirname(new URL(import.meta.url).pathname) + '/'; function findRoot(from) { let d = from; for (let i = 0; i < 6; i++) { if (existsSync(`${d}tools/finality-attacks/lib/rpc.mjs`)) return d; d = dirname(d.replace(/\/$/, '')) + '/'; } throw new Error('no tree with tools/finality-attacks/lib/rpc.mjs above ' + from); } const ROOT = process.env.IGNEUM_ROOT ? process.env.IGNEUM_ROOT.replace(/\/?$/, '/') : findRoot(HERE); const { connectRpc } = await import(`${ROOT}tools/finality-attacks/lib/rpc.mjs`); const { devAddress } = await import(`${ROOT}tools/harness/lib/address.mjs`); const FILE = `${HERE}override-60x.json`; const BIN = process.env.IGNEUM_LADDER_BIN || '/srv/builds/igneum-wt-ladder/vendor/igneum-node-ladder/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 || '/srv/builds/igneum-wt-ladder/igneum-pow/target/release/igneum-pow'; const TMP = process.env.IGNEUM_LADDER_TMP || '/tmp/igneum-fast-time-attack-f10'; const BASE = +(process.env.IGNEUM_F10_BASE || 29900), SUFFIX = +(process.env.IGNEUM_F10_SUFFIX || 990); const NEVER = '18446744073709551615'; const RUNG0 = 27, RUNG1 = 35, WINDOWS = 7, THRESHOLD = 9000; 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 GENESIS_BITS = flag('genesis-bits', 0x1f010000); const SECS = flag('secs', 900); const EPOCHS = flag('epochs', 10); const WINDOW = flag('window', 60); const SIGNAL = (sflag('signal') || 'up,up,up').split(',').map(s => s.trim().toLowerCase()); const EXPECT = sflag('expect') || 'step'; if (!['step', 'no-step'].includes(EXPECT) || SIGNAL.length !== 3 || !SIGNAL.every(s => ['up', 'down', 'none'].includes(s))) { console.error('usage: --signal a,b,c (up|down|none) --expect step|no-step'); 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); } rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); const 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'); // 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 let FIRST_STEP_EPOCH = 0; while (FIRST_STEP_EPOCH * EPOCH - LEAD - 1 < WINDOWS * WINDOW) FIRST_STEP_EPOCH++; 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 override = `${TMP}/override.json`; writeFileSync(override, mergeOverrideText(baseText, { genesis_bits: GENESIS_BITS, skip_proof_of_work: false, program_class_v3_activation_daa: '0', program_class_v4_activation_daa: '0', program_class_v4_signal_window_daa: '0', latency_ladder_activation_daa: '0', latency_ladder_window_daa: String(WINDOW), })); log(`signals ${SIGNAL.join('/')}, expect ${EXPECT}; class v4 from genesis, the ladder active from DAA 0, window ${WINDOW} DAA x ${WINDOWS} (the first epoch that can step is ${FIRST_STEP_EPOCH}, DAA ${FIRST_STEP_EPOCH * EPOCH}); ${EPOCH} DAA per epoch, lead ${LEAD}; run ${SECS} s or ${EPOCHS} epochs`); 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 grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; } 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'); // the node's own ladder signal: what its templates carry in bits 15 and 14 this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_LADDER_SIGNAL: SIGNAL[this.i] } }); started.push(this.proc); await sleep(1200); 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}, signals ${SIGNAL[this.i]}`); 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); 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 STEP_LINE = /Latency ladder step by miner signal: epoch (\d+) moves to rung (\d+) \((\d+) shadow passes, from rung (\d+)\): (up|down) in each of (\d+) consecutive windows of (\d+) DAA .*weakest up (\d+) bps, weakest down (\d+) bps/; const LADDER_LINE = /Latency ladder from the override file/; const ACTIVE_LINE = /Latency ladder active: rungs/; const OWN_LINE = /Latency ladder signal from IGNEUM_LADDER_SIGNAL: this node signals (\w+)/; 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]; for (const n of nodes) log(`n${n.i}: ${n.grepLog(LADDER_LINE).map(l => l.replace(/^.*?(Latency ladder from)/, '$1'))[0] || '(no ladder 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) => miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', `cpu${i}`, '--status-secs', '30', '--no-vote'], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) })); const pay = devAddress('fast-time-ladder'); const epochs = new Map(); let firstStep = null, lastEpoch = -1, lastReport = 0, lastDaa = 0, endAt = null; const samples = []; while (Date.now() - t0 < SECS * 1000) { await sleep(1000); let daa = null, epoch = null, cls = null, reps = null, nextReps = null, step = null, nextStep = null, up = null, upWeak = null, down = null, sig = null, stepEpoch = null, eraSeed = 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; eraSeed = pe.eraSeed; reps = pe.latencyLadderReps; nextReps = pe.nextLatencyLadderReps; step = pe.latencyLadderStep; nextStep = pe.nextLatencyLadderStep; up = pe.latencyLadderUpBps; upWeak = pe.latencyLadderUpWeakestBps; down = pe.latencyLadderDownBps; sig = pe.latencyLadderSignal; stepEpoch = pe.latencyLadderStepEpoch; } catch (e) { log(`template: ${e.message}`); } if (epoch != null && epoch !== lastEpoch) { epochs.set(epoch, { class: cls, reps, step, firstSeenDaa: daa, at: +since(), eraSeed: eraSeed == null ? null : String(eraSeed), up_bps: up, up_weakest_bps: upWeak, down_bps: down, step_epoch: stepEpoch ?? null }); log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s: template class ${cls} rung ${step} (${reps} passes), next rung ${nextStep} (${nextReps}), up ${up} bps (weakest of ${WINDOWS}: ${upWeak}), down ${down} bps, this node signals ${sig}, step took effect at epoch ${stepEpoch ?? 'none'}`); if (firstStep == null && step > 0) { firstStep = { epoch, daa, step, reps, at: +since() }; log(`LADDER STEP: the template is rung ${step} (${reps} shadow passes) 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} rung ${step} (${reps}) up ${up} bps weakest ${upWeak} blocks/sink per node ${counts.join(' ')}`); samples.push({ t: +since(), daa, epoch, step, reps, up_bps: up, up_weakest_bps: upWeak, nodes: counts }); } // the end: two epochs after a step (to show no second step), or --epochs epochs when no step is expected if (firstStep != null && daa != null && daa >= (firstStep.epoch + 2) * EPOCH + LEAD) { endAt = +since(); break; } if (firstStep == null && daa != null && daa >= EPOCHS * EPOCH) { endAt = +since(); break; } } 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 = firstStep ? firstStep.epoch * EPOCH : Infinity; const before = blocks.filter(b => b.daa < BOUNDARY), after = blocks.filter(b => b.daa >= BOUNDARY); // the ladder bits on the chain: bit 15 up, bit 14 down; the object byte (bits 8 to 13) must be 0 (class signalling off) const bitsOf = (v) => (v & 0x8000) ? 'up' : (v & 0x4000) ? 'down' : 'none'; const ladderBits = blocks.reduce((m, b) => { const k = bitsOf(b.version); m[k] = (m[k] || 0) + 1; return m; }, {}); const upShareOnChain = blocks.length ? Math.round(10000 * (blocks.filter(b => bitsOf(b.version) === 'up').length) / blocks.length) : 0; const objectBytes = blocks.reduce((m, b) => { const v = (b.version >> 8) & 0x3f; m[v] = (m[v] || 0) + 1; return m; }, {}); // genesis carries header version 0 (genesis.rs), every mined block the block version 2 in its low byte const lowBytes = new Set(blocks.filter(b => b.daa > 0).map(b => b.version & 0xff)); const programs = new Map(); for (const i of [0, 1, 2]) 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 })); function cliId(seedHex, eraHex, reps) { if (!existsSync(IGNEUM_POW)) return null; const r = spawnSync(IGNEUM_POW, ['show', '--epoch-hex', seedHex, '--program-class', 'v4', '--era-hex', eraHex, '--shadow-reps', String(reps)], { encoding: 'utf8' }); const m = /program id ([0-9a-f]{16})/.exec(r.stdout || ''); return m ? m[1] : null; } const idRows = []; for (const [k, e] of programs) { const ep = epochs.get(e.epoch); if (!ep || ep.eraSeed == null) continue; const reps = ep.reps ?? 0; idRows.push({ epoch: e.epoch, seed: e.seed, reps, miners_id: e.id, miners: e.miners.size, cli_rung0: cliId(k, ep.eraSeed, 0), cli_at_reps: cliId(k, ep.eraSeed, reps) }); } const steppedRows = idRows.filter(r => r.reps !== RUNG0 && r.reps !== 0); const accepted = [0, 1, 2].map(i => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length); const rejectedMiner = [0, 1, 2].map(i => minerLog(i).filter(l => /rejected nonce=|submit error/.test(l))); const rejectedNode = nodes.map(n => n.grepLog(/PoW rejected|Rejected block|rejected block/i)); const stepLines = nodes.map(n => n.grepLog(STEP_LINE).map(l => l.replace(/^.*?(Latency ladder step by miner signal)/, '$1'))); const firstStepLine = stepLines.map(ls => ls[0] || null); const stepEpochs = firstStepLine.map(l => { const m = l && STEP_LINE.exec(l); return m ? +m[1] : null; }); const stepRungs = firstStepLine.map(l => { const m = l && STEP_LINE.exec(l); return m ? +m[2] : null; }); const stepWeakestUp = firstStepLine.map(l => { const m = l && STEP_LINE.exec(l); return m ? +m[8] : null; }); 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 repsSeen = [...epochs.values()].map(e => e.reps); const afterStep = firstStep ? [...epochs.entries()].filter(([e]) => e > firstStep.epoch).map(([, v]) => v.step) : []; const common = { zero_rejected_by_miners: rejectedMiner.every(r => r.length === 0), zero_rejected_by_nodes: rejectedNode.every(r => r.length === 0), sinks_agree: new Set(sinks).size === 1, block_counts_agree: new Set(counts.map(String)).size === 1, miners_agree_on_every_program: programRows.every(p => !p.disagree), ladder_line_on_every_node: nodes.every(n => n.grepLog(LADDER_LINE).length > 0 && n.grepLog(ACTIVE_LINE).length > 0), every_node_signals_its_bits: nodes.every((n, i) => n.grepLog(OWN_LINE).some(l => OWN_LINE.exec(l)[1] === SIGNAL[i])), every_epoch_class_v4: [...epochs.values()].every(e => e.class === 4), rung0_ids_equal_the_cli_rung0_id: idRows.filter(r => r.reps === RUNG0).length > 0 && idRows.filter(r => r.reps === RUNG0).every(r => r.cli_rung0 != null && r.cli_rung0 === r.miners_id), }; // every block carries block version 2, an object byte of 0 (class signalling off) and the ladder bits of one of the three // nodes; genesis, made before any node, is the one bit-less block when every node signals (the first run of the known-failed // case, 22:26Z, failed this check on genesis's version 0 in the low-byte test, a harness fault, not a chain one) const noneNodes = SIGNAL.filter(s => s === 'none').length; common.chain_carries_the_bits = blocks.length > 0 && [...lowBytes].every(v => v === 2) && Object.keys(objectBytes).every(v => +v === 0) && Object.keys(ladderBits).every(k => SIGNAL.includes(k) || k === 'none') && (noneNodes > 0 || (ladderBits.none || 0) === 1); let checks; if (EXPECT === 'step') { checks = { ...common, template_stepped_to_rung_1: firstStep != null && firstStep.step === 1 && firstStep.reps === RUNG1, stepped_at_the_first_full_window_epoch: firstStep != null && firstStep.epoch === FIRST_STEP_EPOCH, step_line_on_every_node_same_epoch: stepEpochs.every(e => e != null) && new Set(stepEpochs).size === 1 && stepEpochs[0] === (firstStep && firstStep.epoch) && stepRungs.every(r => r === 1), weakest_up_at_or_above_threshold: stepWeakestUp.every(s => s != null && s >= THRESHOLD), no_second_step_inside_seven_windows: firstStep != null && afterStep.length >= 2 && afterStep.every(s => s === 1) && stepLines.every(ls => ls.length === 1), blocks_on_both_sides: before.length > 0 && after.length > 0, rung1_ids_equal_the_cli_rung1_id: steppedRows.length > 0 && steppedRows.every(r => r.reps === RUNG1 && r.cli_at_reps != null && r.cli_at_reps === r.miners_id && r.miners === 3), rung1_ids_differ_from_the_same_seed_rung0_id: steppedRows.length > 0 && steppedRows.every(r => r.cli_rung0 != null && r.cli_rung0 !== r.miners_id), }; } else { checks = { ...common, template_never_above_rung_0: firstStep == null && repsSeen.every(r => r === RUNG0 || r === 0), no_step_line_on_any_node: stepLines.every(ls => ls.length === 0), ran_the_epochs: maxEpochSeen >= EPOCHS - 1, passed_the_first_full_window_epoch: maxEpochSeen >= FIRST_STEP_EPOCH, up_share_under_threshold_on_chain: upShareOnChain < THRESHOLD, }; } const pass = Object.values(checks).every(Boolean); const summary = { pass, expect: EXPECT, signals: SIGNAL, checks, window: WINDOW, windows: WINDOWS, threshold_bps: THRESHOLD, epoch_blocks: EPOCH, lead: LEAD, first_step_epoch: FIRST_STEP_EPOCH, node: IGNEUMD, miner: CPU_MINER, pow: IGNEUM_POW, template_step: firstStep, 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, ladder_bits: ladderBits, object_bytes: objectBytes, up_share_bps_on_chain: upShareOnChain }, 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, step_lines: stepLines, samples, }; writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2)); log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}, signals ${SIGNAL.join('/')}): ${firstStep ? `rung ${firstStep.step} (${firstStep.reps} passes) from epoch ${firstStep.epoch} at DAA ${firstStep.daa}` : 'no step'}; epochs seen ${[...epochs.entries()].map(([e, v]) => `e${e}:r${v.step}:${v.up_weakest_bps}bps`).join(' ')}; chain bits ${JSON.stringify(ladderBits)} (${upShareOnChain} bps up); blocks ${before.length} / ${after.length}; rejected miners ${rejectedMiner.map(r => r.length).join('/')} nodes ${rejectedNode.map(r => r.length).join('/')}; sinks ${sinks.join(' ')} at ${counts.join('/')}`); for (const r of idRows) log(`PROGRAM ID epoch ${r.epoch} seed ${r.seed} reps ${r.reps}: miners ${r.miners_id} (${r.miners} of 3) cli at reps ${r.cli_at_reps} cli rung 0 ${r.cli_rung0}`); 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);