igneum/infra/fast-time/digest-compat.mjs

84 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
// The 0.3.15 digest rule's two cases (docs/plans/counter-asic-3-node.md section 6.2; main's ruling of 6 October 2026,
// 20:1x UK): an absent class v4 field contributes nothing to the consensus digest. Four nodes on a private network
// (suffix 995, ports 29800 and up, data /tmp/igneum-digest-compat), all on the same thirteen-field live devnet object
// COPIED from /tmp/igneum-devnet/override-v3.json (the live file is never written) except the last:
// n0 the fixed (0.3.15) node, thirteen fields the hub every other node connects to
// n1 the fixed node, thirteen fields must peer with n0 (same binary, same file)
// n2 the OLD node (0.3.13 or 0.3.14), thirteen fields the COMPAT case: must peer with n0 (same digest across binaries)
// n3 the fixed node, SIXTEEN fields (the exec pin, the floor, the window) the REFUSAL case: must NOT peer (its digest moved)
// PASS = n0 has n1 and n2 as peers, n3 has none and its log (or n0's) carries the digest refusal. The refusal case is
// the rule's own known-failed case: the fields present DO move the digest.
// node infra/fast-time/digest-compat.mjs --new <igneumd> --old <igneumd> [--secs 40]
import { spawn } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
const args = process.argv.slice(2);
const sflag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
const NEW = sflag('new'), OLD = sflag('old');
const SECS = +(sflag('secs') || 40);
if (!NEW || !OLD || !existsSync(NEW) || !existsSync(OLD)) { console.error('usage: --new <igneumd 0.3.15> --old <igneumd 0.3.13/14>'); process.exit(2); }
const TMP = '/tmp/igneum-digest-compat';
const BASE = 29800, SUFFIX = 995;
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
// the live thirteen-field object, read (never written) from the Mac node's file; the sixteen-field one adds the exec pin,
// the floor and the window as text (the never heights must not pass through a JavaScript number)
const live13 = readFileSync('/tmp/igneum-devnet/override-v3.json', 'utf8').trim();
if (!/^\{.*\}$/s.test(live13) || (live13.match(/":/g) || []).length !== 13) { console.error(`the live file is not a thirteen-field object: ${live13.slice(0, 80)}`); process.exit(2); }
const file13 = `${TMP}/override-13.json`; writeFileSync(file13, live13 + '\n');
const file16 = `${TMP}/override-16.json`;
writeFileSync(file16, live13.replace(/\}\s*$/, ',"exec_restart_state_root":"0xed27bb2d6128daf50493ed5539a73bb93f9d7c63ad7f70a7cbb46ba81c9e164e","program_class_v4_activation_daa":219600,"program_class_v4_signal_window_daa":86400}\n'));
const started = [];
class Node {
constructor(i, bin, file, connect) { this.i = i; this.bin = bin; this.file = file; this.connect = connect; this.grpc = BASE + i * 10; this.p2p = BASE + i * 10 + 1; this.json = BASE + i * 10 + 2; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--utxoindex', `--appdir=${this.dir}`,
`--rpclisten=127.0.0.1:${this.grpc}`, `--rpclisten-json=127.0.0.1:${this.json}`, `--listen=127.0.0.1:${this.p2p}`, `--override-params-file=${this.file}`, '--loglevel=info', '--yes'];
if (this.connect) a.push(`--connect=127.0.0.1:${this.connect}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
this.proc = spawn(this.bin, a, { stdio: ['ignore', out, out] }); started.push(this.proc);
await sleep(1500);
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.json}`); } catch (e) { log(`n${this.i} rpc: ${e.message}`); }
return this;
}
grep(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
digest() { return (this.grep(/Consensus params digest/)[0] || '').replace(/^.*digest: /, '').slice(0, 64) || null; }
async peers() { try { const r = await this.rpc.call('getConnectedPeerInfo', {}); const list = r.peerInfo || r.peer_info || r.infos || r; return Array.isArray(list) ? list.length : null; } catch (e) { return `rpc: ${e.message}`; } }
}
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('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
const n0 = await new Node(0, NEW, file13, null).start();
const n1 = await new Node(1, NEW, file13, n0.p2p).start();
const n2 = await new Node(2, OLD, file13, n0.p2p).start();
const n3 = await new Node(3, NEW, file16, n0.p2p).start();
const nodes = [n0, n1, n2, n3];
await sleep(SECS * 1000);
const rows = [];
for (const n of nodes) {
const peers = await n.peers();
// the handshake's own line (flow_context.rs): `Refusing peer <addr>: consensus params digest mismatch, local <d> remote <d>`
const refusal = n.grep(/Refusing peer .*digest/).map(l => l.replace(/^.*?\] /, '')).slice(0, 2);
rows.push({ node: `n${n.i}`, binary: n.bin === NEW ? 'new' : 'old', fields: n.file === file13 ? 13 : 16, digest: n.digest(), peers, refusal });
log(`n${n.i} ${n.bin === NEW ? 'new' : 'old'} ${n.file === file13 ? 13 : 16} fields: digest ${n.digest()}, peers ${peers}${refusal.length ? `, digest lines: ${refusal.join(' | ').slice(0, 300)}` : ''}`);
}
const checks = {
new_and_old_on_13_fields_print_one_digest: rows[0].digest && rows[0].digest === rows[1].digest && rows[0].digest === rows[2].digest,
the_16_field_digest_differs: rows[3].digest && rows[3].digest !== rows[0].digest,
n0_peers_with_n1_and_n2: rows[0].peers === 2,
old_node_peers_with_new: rows[2].peers === 1,
n3_has_no_peer: rows[3].peers === 0,
a_digest_refusal_line_exists: rows[3].refusal.length > 0 || rows[0].refusal.length > 0,
};
const pass = Object.values(checks).every(Boolean);
writeFileSync(`${TMP}/summary.json`, JSON.stringify({ pass, checks, rows, new_binary: NEW, old_binary: OLD, secs: SECS }, null, 2));
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'}: COMPAT new-13 ${rows[0].digest?.slice(0, 16)} = old-13 ${rows[2].digest?.slice(0, 16)} (n0 peers ${rows[0].peers}, old node peers ${rows[2].peers}); REFUSAL new-16 ${rows[3].digest?.slice(0, 16)} peers ${rows[3].peers}`);
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);