256 lines
17 KiB
JavaScript
256 lines
17 KiB
JavaScript
// Round-4 consensus items runner (4 October 2026, night): ledger F23 (deterministic equivocation bans), F24
|
|
// (re-determination after a deep reorg) and G12/X18 (the params digest in the handshake) on the fast-time 3-node
|
|
// network of v3.mjs. Ports 29400 and up, network igneum-devnet-940, data under /tmp/igneum-fin-fud; the live devnet
|
|
// is never touched.
|
|
//
|
|
// node tools/finality-attacks/fud.mjs digest ban reorg # the three scenarios on the fud-consensus build
|
|
// IGNEUMD=... IGNEUM_MINER=... node tools/finality-attacks/fud.mjs ban # another build (the control: finality-fixes)
|
|
// DELAY_MS=100 BPS=1 node tools/finality-attacks/fud.mjs ... # one-way delay per proxied link, total block rate
|
|
//
|
|
// Topology (v3.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2; a proxy adds DELAY_MS
|
|
// one way and can be cut and healed.
|
|
//
|
|
// digest n1 runs the shared override; n0 dials it with a finality block that differs by one DAA second of window
|
|
// (another consensus params digest): the handshake must refuse it and n1 must stay without peers; then n2
|
|
// dials with the shared override and connects. Under the old build the mismatched n0 connects.
|
|
// ban six voters, two per node, equal shares; voter a0 (on n0) equivocates once at index EQ_INDEX. P2 is cut
|
|
// just before that index is determined and healed 45 s later, so n2 sees the evidence late, from the block
|
|
// that carries it, while n0 saw it over RPC and n1 from the block at once. Through the ban's expiry every
|
|
// node must build or accept certificates over the same voter count at every index: no "names N voters"
|
|
// refusal, no conflicting certificate, no locked index disagreeing, the stripped index range identical.
|
|
// reorg 4/2 keys with the 4 side (n1, n2) at 70% of the weight; P0 is cut for SPLIT s so n0 determines at least
|
|
// one checkpoint on its own chain, then healed: n0 must re-determine those indices on the majority chain
|
|
// and lock them from the network's certificates, with no CONFLICTING line and no index locked on two
|
|
// different blocks across the nodes. Under the old build n0 logs CONFLICTING for each such index.
|
|
|
|
const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/';
|
|
process.env.IGNEUM_FIN_BASE_PORT ||= '29400';
|
|
process.env.IGNEUM_FIN_SUFFIX ||= '940';
|
|
process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-fud';
|
|
process.env.IGNEUM_FAST_TIME ||= '1';
|
|
process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node-fud/target/release/igneumd`;
|
|
process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node-fud/target/release/igneum-miner`;
|
|
const DELAY_MS = +(process.env.DELAY_MS || 100);
|
|
const BPS = +(process.env.BPS || 1);
|
|
const EQ_INDEX = +(process.env.EQ_INDEX || 9);
|
|
const WARM = +(process.env.WARM || 230), SPLIT = +(process.env.SPLIT || 180), HEAL = +(process.env.HEAL || 150);
|
|
const BAN_CUT = +(process.env.BAN_CUT || 45), BAN_RUN = +(process.env.BAN_RUN || 480);
|
|
const TAG = process.env.TAG || 'fud';
|
|
// rule v3 from checkpoint DAA 0 on every node (the fast-time file says never)
|
|
process.env.IGNEUM_FIN_OVERRIDE_JSON ||= JSON.stringify({ finality_v3_activation_daa: 0 });
|
|
|
|
const { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs');
|
|
const { mkdirSync, writeFileSync, appendFileSync, copyFileSync, existsSync } = await import('node:fs');
|
|
mkdirSync(TMP, { recursive: true });
|
|
// every scenario keeps its node logs (the next scenario wipes the node directories)
|
|
function keepLogs(name, nodes) {
|
|
const dir = `${TMP}/logs-${name}`;
|
|
mkdirSync(dir, { recursive: true });
|
|
for (const n of nodes) if (existsSync(n.logFile)) copyFileSync(n.logFile, `${dir}/${n.name}.log`);
|
|
return dir;
|
|
}
|
|
const results = [];
|
|
const out = (line) => { console.log(line); appendFileSync(`${TMP}/results-${TAG}.md`, line + '\n'); };
|
|
|
|
const lockedMap = (cp) => new Map((cp?.checkpoints || []).filter(c => c.state === 'locked').map(c => [c.index, c.hash]));
|
|
const maxLocked = (cp) => Math.max(0, ...lockedMap(cp).keys());
|
|
async function checkpoints(node, last = 800) { return node.rpc.call('getFinalityCheckpoints', { last }).catch(() => null); }
|
|
async function peers(node) { const r = await node.rpc.call('getConnectedPeerInfo', {}).catch(() => null); return (r?.peerInfo || r?.infos || []).length; }
|
|
|
|
// per index, the voter count every certificate line on a node names (built / received / replaced / folded)
|
|
function voterCounts(node) {
|
|
const m = new Map();
|
|
for (const l of node.grepLog(/Finality: certificate/)) {
|
|
let x;
|
|
if ((x = l.match(/certificate built for checkpoint (\d+) .* by (\d+) of (\d+) voters/))) m.set(+x[1], +x[3]);
|
|
else if ((x = l.match(/certificate at index (\d+) received: (\d+) of (\d+) voters/))) m.set(+x[1], +x[3]);
|
|
else if ((x = l.match(/certificate at index (\d+) replaced by a heavier one: (\d+) of (\d+) voters/))) m.set(+x[1], +x[3]);
|
|
else if ((x = l.match(/certificate for checkpoint (\d+) folded: (\d+) of (\d+) voters/))) m.set(+x[1], +x[3]);
|
|
}
|
|
return m;
|
|
}
|
|
function disagreeing(cps) {
|
|
const maps = cps.map(lockedMap);
|
|
const all = new Set(maps.flatMap(m => [...m.keys()]));
|
|
let n = 0;
|
|
for (const k of all) { const hs = new Set(maps.filter(m => m.has(k)).map(m => m.get(k))); if (hs.size > 1) n++; }
|
|
return n;
|
|
}
|
|
const count = (node, re) => node.grepLog(re).length;
|
|
|
|
async function network() {
|
|
const n1 = await new Node(1).start();
|
|
const p0 = await new Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }).start();
|
|
const p2 = await new Proxy(1, n1.p2pPort, { delayMs: DELAY_MS }).start();
|
|
const n0 = await new Node(0, { connect: [p0.addr] }).start();
|
|
const n2 = await new Node(2, { connect: [p2.addr] }).start();
|
|
return { n0, n1, n2, p0, p2 };
|
|
}
|
|
|
|
async function digest() {
|
|
const name = `digest-${TAG}`;
|
|
const n1 = await new Node(1).start();
|
|
// n0: the same file but one DAA second more of weight window: another digest
|
|
const n0 = await new Node(0, { connect: [n1.p2p], override: { finality: { weight_window: 121 } } }).start();
|
|
await sleep(25000);
|
|
const refusedOn0 = count(n0, /consensus params digest mismatch/), refusedOn1 = count(n1, /consensus params digest mismatch/);
|
|
const peers1 = await peers(n1), peers0 = await peers(n0);
|
|
const digestLine = (n) => (n.grepLog(/Consensus params digest:/)[0] || '').replace(/^.*digest: /, '').split(' ')[0];
|
|
// n2: the shared file, connects
|
|
const n2 = await new Node(2, { connect: [n1.p2p] }).start();
|
|
let connected = null;
|
|
for (let i = 0; i < 25; i++) { await sleep(1000); if ((await peers(n2)) > 0) { connected = i + 1; break; } }
|
|
const peers1After = await peers(n1);
|
|
const refusedOn2 = count(n2, /consensus params digest mismatch/);
|
|
keepLogs(name, [n0, n1, n2]);
|
|
await stopAll();
|
|
const pass = refusedOn0 > 0 && refusedOn1 > 0 && peers1 === 0 && peers0 === 0 && connected != null && refusedOn2 === 0;
|
|
out(`\n### ${name}: n1 on the shared fast-time override, n0 with finality.weight_window 121 (one DAA second more), n2 on the shared override\n`);
|
|
out('| measure | n0 (mismatched) | n1 (listener) | n2 (matching) |');
|
|
out('|---|---|---|---|');
|
|
out(`| params digest printed at start | ${digestLine(n0) || 'none'} | ${digestLine(n1) || 'none'} | ${digestLine(n2) || 'none'} |`);
|
|
out(`| "consensus params digest mismatch" lines | ${refusedOn0} | ${refusedOn1} | ${refusedOn2} |`);
|
|
out(`| peers after 25 s (n0, n1) and after n2 dialled (n1) | ${peers0} | ${peers1} then ${peers1After} | ${connected == null ? 'not connected in 25 s' : 'connected after ' + connected + ' s'} |`);
|
|
const sample = n0.grepLog(/consensus params digest mismatch/)[0];
|
|
if (sample) out(`\nn0's line: \`${sample.replace(/^.*?(Refusing|WARN)/, '$1').slice(0, 300)}\``);
|
|
results.push({ name, pass });
|
|
}
|
|
|
|
async function ban() {
|
|
const name = `ban-${TAG}`;
|
|
const { n0, n1, n2, p2 } = await network();
|
|
const miners = [];
|
|
for (const [node, label, eq] of [[n0, 'a0', true], [n0, 'a1', false], [n1, 'b0', false], [n1, 'b1', false], [n2, 'c0', false], [n2, 'c1', false]])
|
|
miners.push(new Miner(node, { label, share: 1 / 6, bps: BPS, secs: BAN_RUN, equivocateAt: eq ? EQ_INDEX : undefined }).start());
|
|
const t0 = Date.now();
|
|
// cut n2 off just before index EQ_INDEX is determined on n0 (when EQ_INDEX - 1 is), heal BAN_CUT s later
|
|
let cutAt = null, healAt = null, eqSeenAt = null;
|
|
while (Date.now() - t0 < BAN_RUN * 1000) {
|
|
const cp = await checkpoints(n0, 50);
|
|
const t = Math.round((Date.now() - t0) / 1000);
|
|
if (cutAt == null && cp && cp.nextIndex >= EQ_INDEX) { p2.cut(); cutAt = t; log(`${name}: P2 cut at ${t} s (n0 next index ${cp.nextIndex})`); }
|
|
if (eqSeenAt == null && count(n0, /EQUIVOCATION by key/) > 0) { eqSeenAt = t; log(`${name}: n0 detected the equivocation at ${t} s`); }
|
|
if (cutAt != null && healAt == null && t - cutAt >= BAN_CUT) { p2.heal(); healAt = t; log(`${name}: P2 healed at ${t} s`); }
|
|
await sleep(1000);
|
|
}
|
|
const cps = await Promise.all([n0, n1, n2].map(n => checkpoints(n)));
|
|
for (const m of miners) await m.stop();
|
|
const nodes = [n0, n1, n2];
|
|
const refusals = nodes.map(n => count(n, /names \d+ voters, this node counts/));
|
|
const conflicts = nodes.map(n => count(n, /CONFLICTING certificate/));
|
|
const equiv = nodes.map(n => count(n, /EQUIVOCATION by key/));
|
|
const carried = nodes.map(n => count(n, /EQUIVOCATION by key .* carried by block/));
|
|
const counts = nodes.map(voterCounts);
|
|
const indices = new Set(counts.flatMap(m => [...m.keys()]));
|
|
let agree = 0, differ = 0;
|
|
const stripped = nodes.map(() => []);
|
|
for (const i of [...indices].sort((a, b) => a - b)) {
|
|
const vs = counts.map(m => m.get(i)).filter(v => v != null);
|
|
if (vs.length >= 2) { if (new Set(vs).size === 1) agree++; else differ++; }
|
|
counts.forEach((m, k) => { if (m.get(i) != null && m.get(i) < 6) stripped[k].push(i); });
|
|
}
|
|
const range = (xs) => xs.length ? `${xs[0]}..${xs[xs.length - 1]} (${xs.length})` : 'none';
|
|
const locks = cps.map(maxLocked);
|
|
const disagree = disagreeing(cps);
|
|
keepLogs(name, nodes);
|
|
await stopAll();
|
|
const sameRange = new Set(stripped.map(range)).size === 1 && stripped[0].length > 0;
|
|
const pass = refusals.every(r => r === 0) && conflicts.every(c => c === 0) && disagree === 0 && differ === 0 && sameRange && locks.every(l => l > EQ_INDEX + 3);
|
|
out(`\n### ${name}: ${BAN_RUN} s, ${BPS} blocks/s in all, 6 voters, delay ${DELAY_MS} ms, a0 equivocates once at index ${EQ_INDEX}; P2 cut at ${cutAt ?? 'never'} s, healed at ${healAt ?? 'never'} s; n0 detected the equivocation at ${eqSeenAt ?? 'never'} s\n`);
|
|
out('| measure | n0 (saw it over RPC) | n1 (from the block at once) | n2 (from the block, after the heal) |');
|
|
out('|---|---|---|---|');
|
|
out(`| EQUIVOCATION lines (of which "carried by block") | ${equiv[0]} (${carried[0]}) | ${equiv[1]} (${carried[1]}) | ${equiv[2]} (${carried[2]}) |`);
|
|
out(`| certificates refused "names N voters, this node counts M" | ${refusals.join(' | ')} |`);
|
|
out(`| CONFLICTING certificate lines | ${conflicts.join(' | ')} |`);
|
|
out(`| indices whose certificates name 5 voters (a0 stripped) | ${stripped.map(range).join(' | ')} |`);
|
|
out(`| max locked index at the end | ${locks.join(' | ')} |`);
|
|
out(`\nindices with certificate lines on at least two nodes: voter counts agree at ${agree}, differ at ${differ}; locked indices disagreeing across the three nodes: ${disagree}`);
|
|
results.push({ name, pass });
|
|
}
|
|
|
|
async function reorg() {
|
|
const name = `reorg-${TAG}`;
|
|
const { n0, n1, n2, p0 } = await network();
|
|
const secs = WARM + SPLIT + HEAL + 30;
|
|
const miners = [];
|
|
for (const [node, label, share] of [[n0, 'q0', 0.15], [n0, 'q1', 0.15], [n1, 'p0', 0.175], [n1, 'p1', 0.175], [n2, 'p2', 0.175], [n2, 'p3', 0.175]])
|
|
miners.push(new Miner(node, { label, share, bps: BPS, secs }).start());
|
|
await sleep(WARM * 1000);
|
|
const before = await Promise.all([n0, n1, n2].map(n => checkpoints(n)));
|
|
const beforeMax = before.map(maxLocked);
|
|
const preNext = (await checkpoints(n0, 5))?.nextIndex;
|
|
log(`${name}: cut at ${WARM} s: max locked ${beforeMax.join('/')}, n0 next index ${preNext}`);
|
|
p0.cut();
|
|
await sleep(SPLIT * 1000);
|
|
const during = await Promise.all([n0, n1, n2].map(n => checkpoints(n)));
|
|
const ownDetermined = (during[0]?.nextIndex ?? 0) - preNext;
|
|
const duringMax = during.map(maxLocked);
|
|
p0.heal();
|
|
const tHeal = Date.now();
|
|
let reconnected = null;
|
|
while (Date.now() - tHeal < HEAL * 1000) {
|
|
if (reconnected == null && (await peers(n0)) > 0) reconnected = Math.round((Date.now() - tHeal) / 1000);
|
|
await sleep(2000);
|
|
}
|
|
const after = await Promise.all([n0, n1, n2].map(n => checkpoints(n)));
|
|
for (const m of miners) await m.stop();
|
|
const nodes = [n0, n1, n2];
|
|
const redetermined = nodes.map(n => count(n, /re-determined/));
|
|
const conflicts = nodes.map(n => count(n, /CONFLICTING certificate/));
|
|
const pending = nodes.map(n => count(n, /kept pending until the chain decides/));
|
|
const afterMax = after.map(maxLocked);
|
|
const disagree = disagreeing(after);
|
|
// n0's locks at the indices it determined on its own chain: the same block as n1 holds
|
|
const m0 = lockedMap(after[0]), m1 = lockedMap(after[1]);
|
|
const splitIdx = [...Array(Math.max(0, ownDetermined)).keys()].map(k => preNext + k);
|
|
const agreed = splitIdx.filter(i => m0.has(i) && m1.has(i) && m0.get(i) === m1.get(i)).length;
|
|
// the build before F24 refused a certificate over another block with "is for X, this node's checkpoint is Y" and
|
|
// kept it as conflicting; the F24 build logs the same words with "kept pending"
|
|
const refusedOld = nodes.map(n => n.grepLog(/this node's checkpoint is/).filter(l => !/kept pending/.test(l)).length);
|
|
const verified = nodes.map(n => count(n, /pending certificate at index \d+ over \S+ verified/));
|
|
const unverified = nodes.map(n => count(n, /did not verify once the index was determined/));
|
|
// every index n1 locked, n0 locked on the same block by the end (the split indices included)
|
|
const missing = [...m1.keys()].filter(i => !m0.has(i));
|
|
keepLogs(name, nodes);
|
|
await stopAll();
|
|
// the majority may not lock every split index (70% nominal is noise away from the floor), so the test is: every
|
|
// index the majority locked, n0 locked on the same block, and nothing n0 holds is off the chain or below its target
|
|
const belowTarget = (after[0]?.checkpoints || []).filter(c => c.blueScore < 30 * c.index).map(c => c.index);
|
|
const pass = ownDetermined >= 1 && conflicts.every(c => c === 0) && disagree === 0 && afterMax[0] > duringMax[0] && redetermined[0] >= 1 && missing.length === 0 && belowTarget.length === 0;
|
|
out(`\n### ${name}: warm ${WARM} s, split ${SPLIT} s (n0 alone with 30% of the weight), heal window ${HEAL} s, ${BPS} blocks/s in all, delay ${DELAY_MS} ms\n`);
|
|
out('| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) |');
|
|
out('|---|---|---|---|');
|
|
out(`| max locked index at the cut | ${beforeMax.join(' | ')} |`);
|
|
out(`| max locked index at the heal | ${duringMax.join(' | ')} |`);
|
|
out(`| max locked index at the end | ${afterMax.join(' | ')} |`);
|
|
out(`| "re-determined" lines | ${redetermined.join(' | ')} |`);
|
|
out(`| certificates kept pending | ${pending.join(' | ')} |`);
|
|
out(`| CONFLICTING certificate lines | ${conflicts.join(' | ')} |`);
|
|
out(`| certificates refused over another block, pre-F24 wording | ${refusedOld.join(' | ')} |`);
|
|
out(`| pending certificates verified at determination (did not verify) | ${verified.map((v, i) => `${v} (${unverified[i]})`).join(' | ')} |`);
|
|
out(`| indices n1 locked that this node did not lock | ${nodes.map(n => (n === n0 ? missing.join(' ') || 'none' : '')).join(' | ')} |`);
|
|
out(`| records whose block is below the index's target blue score | ${nodes.map(n => (n === n0 ? belowTarget.join(' ') || 'none' : '')).join(' | ')} |`);
|
|
out(`\nn0 determined ${ownDetermined} checkpoint(s) on its own chain during the split (indices ${splitIdx.join(', ') || 'none'}); after the heal n0 holds the same locked block as n1 at ${agreed} of them; locked indices disagreeing across the three nodes: ${disagree}; n0 reconnected ${reconnected == null ? 'not within the heal window' : reconnected + ' s after the gate reopened'}`);
|
|
const lines = n0.grepLog(/re-determined|CONFLICTING/).slice(0, 4);
|
|
for (const l of lines) out(` ${l.replace(/^.*?(Finality:)/, '$1').slice(0, 260)}`);
|
|
results.push({ name, pass });
|
|
}
|
|
|
|
const ALL = { digest, ban, reorg };
|
|
async function main() {
|
|
assertBinaries();
|
|
log(`tag ${TAG}; node ${IGNEUMD}; delay ${DELAY_MS} ms; ${BPS} blocks/s; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`);
|
|
const asked = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
|
for (const key of asked.length ? asked : ['digest', 'ban', 'reorg']) {
|
|
const fn = ALL[key];
|
|
if (!fn) { log(`unknown scenario ${key}`); continue; }
|
|
log(`=== ${key} (${TAG}) starting ===`);
|
|
try { await fn(); } catch (e) { log(`${key} threw: ${e.stack || e}`); results.push({ name: key, pass: false }); await stopAll(); }
|
|
log(`=== ${key} done ===`);
|
|
}
|
|
out('\n' + results.map(r => `[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`).join('\n'));
|
|
writeFileSync(`${TMP}/results-${TAG}.json`, JSON.stringify(results, null, 2));
|
|
await stopAll();
|
|
process.exit(results.some(r => !r.pass) ? 1 : 0);
|
|
}
|
|
main();
|