igneum/tools/finality-attacks/f7.mjs

193 lines
15 KiB
JavaScript

// Ledger F7 / O-3.2 (5 October 2026, night, ledger close round 2): the checkpoint depth d at other block rates. The
// 1 block/s evidence is the cloud devnet (reorg depth p50 1, p99 3, max 5; propagation p50 343 ms). This runs the
// fast-time 3-node network with proxied 100-ms links (as m21.mjs) at 1, 2 and 5 blocks/s, at least 5 minutes of
// measurement each, and records per rate: block propagation (first arrival anywhere to each other node, as the cloud
// entry measured), reorg depth (every `virtualChainChanged` removal on every node, as the cloud's chain.tsv), the
// checkpoints each node locked, conflicting locks at one index, re-determinations (a checkpoint moved by a reorg
// deeper than d, spec 3.2 C1), and GHOSTDAG k from the fork's `calculate_ghostdag_k(2 D bps, 0.01)` at the measured
// p99 (consensus/core/src/config/bps.rs, ported in m21.mjs and checked: D 5 s at 1 block/s gives 18).
//
// node tools/finality-attacks/f7.mjs # rates 1,2,5; warm 120 s, measure 330 s each
// RATES=2,5 WARM=120 MEASURE=330 DELAY_MS=100 node tools/finality-attacks/f7.mjs
//
// Ports 29970 and up, network igneum-devnet-997, data under /tmp/igneum-fin-f7; the live devnet is never touched.
// Topology (as c4.mjs, m21.mjs, f20.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2;
// each proxy holds every byte DELAY_MS one way, no bandwidth limit (loopback).
//
// The rate. The nodes run with skip_proof_of_work, so a vmine miner "finds" blocks on a Poisson clock at
// `share x bps` blocks per second (igneum/miner/src/proving.rs: wait = -ln(U) / (bps x share)); six keys at share 1/6
// and --bps B make B blocks/s in all. The profile is the 60x fast-time file with its `blockrate` object replaced by
// the fork's own Bps<B> constants (bps.rs: target_time_per_block 1000/B ms, ghostdag_k from the table (18, 31, 67),
// sample rates, parents, mergeset, merge depth B x 60, finality depth B x 720, pruning depth, maturity) and every
// DAA-denominated finality and epoch parameter multiplied by B so each window is the same number of seconds at every
// rate (weight window, min_daa and ban 120 s; epoch 60 s, lead 10 s; fold 3 s). Block counts stay: checkpoint every
// 30 blue blocks, determined d = 20 blocks later (the devnet value the cloud entry compares), dust 5, 8 aggregators.
const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/';
process.env.IGNEUM_FIN_BASE_PORT ||= '29970';
process.env.IGNEUM_FIN_SUFFIX ||= '997';
process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-f7';
process.env.IGNEUM_FAST_TIME ||= '1';
// the live node line (release-0.3.6, vendor/igneum-node-036 at a24ab01a) built on the Mac on 5 October 2026
process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneumd`;
process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneum-miner`;
const DELAY_MS = +(process.env.DELAY_MS || 100);
const RATES = (process.env.RATES || '1,2,5').split(',').map(Number);
const WARM = +(process.env.WARM || 120), MEASURE = +(process.env.MEASURE || 330);
const KEYS = +(process.env.KEYS || 6);
const PAY = process.env.PAY_ADDRESS || 'igneumdev:qpdkezwu04kuscr3hx9wvqhtrnn5xunt4wjjaxsrgmks3z9cjltf58g2zl4c7';
// the fork's Bps<B> table (bps.rs `ghostdag_k`): pre-computed calculate_ghostdag_k(2 x 5 x B, 0.01)
const K_TABLE = { 1: 18, 2: 31, 3: 43, 4: 55, 5: 67, 6: 79, 7: 90, 8: 102, 9: 113, 10: 124 };
// the fork's calculate_ghostdag_k (bps.rs): the smallest k with P[Poisson(x) > k] < delta, x = 2 D bps
function ghostdagK(x, delta) {
let k = 0, sigma = 0, fraction = 1; const exp = Math.exp(-x);
for (; ;) { sigma += exp * fraction; if (1 - sigma < delta) return k; k += 1; fraction *= x / k; }
}
// the fork's Bps<B> constants at the 60x profile (durations in DAA seconds divided by 60, counts unchanged)
function blockrateFor(B) {
const k = K_TABLE[B]; if (!k) throw new Error(`no k in the fork's table for ${B} blocks/s`);
const parents = Math.min(16, Math.max(10, Math.floor(k / 2)));
const mergeset = Math.min(512, Math.max(180, 2 * k));
const merge_depth = B * 60, finality_depth = B * 720;
const lower = finality_depth + 2 * merge_depth + 4 * mergeset * k + 2 * k + 2;
return {
target_time_per_block: 1000 / B, ghostdag_k: k, past_median_time_sample_rate: B * 10, difficulty_sample_rate: B * 4,
max_block_parents: parents, mergeset_size_limit: mergeset, merge_depth, finality_depth,
pruning_depth: Math.max(lower, B * 1800), coinbase_maturity: B * 2,
};
}
function overrideFor(B) {
return {
blockrate: blockrateFor(B),
finality: { weight_window: 120 * B, min_daa: 120 * B, equivocation_ban: 120 * B, aggregator_fallback: 1 * B, certificate_fold: 3 * B },
pow_epoch_blocks: 60 * B, pow_epoch_lead: 10 * B,
};
}
const q = (xs, p) => { if (!xs.length) return NaN; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
const fmt = (xs) => xs.length ? `${q(xs, 0.5)} / ${q(xs, 0.9)} / ${q(xs, 0.99)} / ${Math.max(...xs)}` : 'none';
const { sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs');
let current = null; // the net module instance of the rate being run (re-imported per rate, see runRate)
const { mkdirSync, writeFileSync, appendFileSync } = await import('node:fs');
mkdirSync(TMP, { recursive: true });
const out = (line) => { console.log(line); appendFileSync(`${TMP}/results.md`, line + '\n'); };
async function dag(node) { return node.rpc.call('getBlockDagInfo', {}).catch(() => null); }
async function peers(node) { const r = await node.rpc.call('getConnectedPeerInfo', {}).catch(() => null); return (r?.peerInfo || r?.infos || []).length; }
async function checkpoints(node, last = 4000) { return node.rpc.call('getFinalityCheckpoints', { last }).catch(() => null); }
const lockedMap = (cp) => new Map((cp?.checkpoints || []).filter(c => c.state === 'locked').map(c => [c.index, c.hash]));
async function runRate(B) {
process.env.IGNEUM_FIN_OVERRIDE_JSON = JSON.stringify(overrideFor(B));
// net.mjs reads EXTRA_OVERRIDE at import; re-import per rate so the merged file carries this rate's blockrate
const net = await import(`./lib/net.mjs?rate=${B}`); current = net;
const t0 = Date.now();
const n1 = new net.Node(1, { name: `n1-${B}` }); await n1.start();
const p0 = new net.Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }); await p0.start();
const p2 = new net.Proxy(2, n1.p2pPort, { delayMs: DELAY_MS }); await p2.start();
const n0 = new net.Node(0, { name: `n0-${B}`, connect: [p0.addr] });
const n2 = new net.Node(2, { name: `n2-${B}`, connect: [p2.addr] });
await n0.start(); await n2.start();
await sleep(3000);
const nodes = [n0, n1, n2];
log(`rate ${B}: network up: peers n0 ${await peers(n0)} n1 ${await peers(n1)} n2 ${await peers(n2)}; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`);
// arrivals: hash -> [t_n0, t_n1, t_n2]; removals: per node, [t, removed, added]
const arrivals = new Map();
const removals = [[], [], []];
let shapeLogged = false;
nodes.forEach((n, i) => {
n.rpc.onNotification = (method, params) => {
if (method === 'blockAddedNotification') {
const block = params?.BlockAdded?.block || params?.block; const hash = String(block?.verboseData?.hash || block?.header?.hash || '');
if (!hash) return;
if (!arrivals.has(hash)) arrivals.set(hash, [null, null, null]);
if (arrivals.get(hash)[i] == null) arrivals.get(hash)[i] = Date.now();
} else if (method === 'virtualChainChangedNotification') {
const body = params?.VirtualChainChanged || params;
const removed = body?.removedChainBlockHashes || body?.removed_chain_block_hashes || [];
const added = body?.addedChainBlockHashes || body?.added_chain_block_hashes || [];
if (!shapeLogged) { shapeLogged = true; log(`virtualChainChanged shape: keys ${Object.keys(params || {}).join(',')} / ${Object.keys(body || {}).join(',')}`); }
removals[i].push([Date.now(), removed.length, added.length]);
}
};
});
for (const n of nodes) {
await n.rpc.call('subscribe', { BlockAdded: {} });
await n.rpc.call('subscribe', { VirtualChainChanged: { include_accepted_transaction_ids: false } });
}
// six keys, two per node, share 1/6 each at --bps B: B blocks/s in all
const plan = []; for (let k = 0; k < KEYS; k++) plan.push([nodes[k % 3], `k${k}`]);
const miners = plan.map(([node, label]) => new net.Miner(node, { label, share: 1 / KEYS, bps: B, secs: WARM + MEASURE + 15 }).start());
await sleep(WARM * 1000);
const tMeasure = Date.now();
const dagStart = await Promise.all(nodes.map(dag));
const cpStart = await Promise.all(nodes.map(n => checkpoints(n)));
const lockedStart = cpStart.map(cp => lockedMap(cp).size);
log(`rate ${B}: measuring from DAA ${dagStart.map(d => d?.virtualDaaScore).join('/')}, blocks ${dagStart.map(d => d?.blockCount).join('/')}, locked so far ${lockedStart.join('/')}`);
await sleep(MEASURE * 1000);
const tEnd = Date.now();
const dagEnd = await Promise.all(nodes.map(dag));
for (const m of miners) await m.stop();
await sleep(4000);
const cpEnd = await Promise.all(nodes.map(n => checkpoints(n)));
const lockedEnd = cpEnd.map(lockedMap);
// propagation over the measurement window: first arrival anywhere to each other node
const toLast = [], hop1 = [], hop2 = [], perNode = [[], [], []];
let seenAll = 0, seenSome = 0;
for (const [, a] of arrivals) {
const first = Math.min(...a.filter(x => x != null));
if (first < tMeasure || first > tEnd) continue;
if (a.some(x => x == null)) { seenSome++; continue; }
seenAll++;
const origin = a.indexOf(first);
toLast.push(Math.max(...a) - first);
a.forEach((t, i) => { if (i !== origin) perNode[i].push(t - first); });
if (origin === 1) { hop1.push(a[0] - first, a[2] - first); }
else { hop1.push(a[1] - first); hop2.push(a[origin === 0 ? 2 : 0] - first); }
}
// reorg depth over the measurement window: every virtualChainChanged with at least one removed chain block
const depth = removals.map(rs => rs.filter(([t, r]) => t >= tMeasure && t <= tEnd && r > 0).map(([, r]) => r));
const changes = removals.map(rs => rs.filter(([t]) => t >= tMeasure && t <= tEnd).length);
// locks: per node in the window, conflicts across nodes at one index, log lines
const union = new Map(); let conflicts = 0;
for (const m of lockedEnd) for (const [i, h] of m) { if (union.has(i) && union.get(i) !== h) conflicts++; else union.set(i, h); }
const lockedInWindow = lockedEnd.map((m, i) => m.size - lockedStart[i]);
const conflictLines = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length);
const redetermined = nodes.map(n => n.grepLog(/re-determined/).length);
const unlocked = cpEnd.map(cp => (cp?.checkpoints || []).filter(c => c.state !== 'locked').length);
const maxLocked = lockedEnd.map(m => Math.max(0, ...m.keys()));
const sinks = dagEnd.map(d => d?.sink);
const blocksPerS = dagEnd.map((d, i) => ((Number(d?.blockCount) - Number(dagStart[i]?.blockCount)) / ((tEnd - tMeasure) / 1000)).toFixed(2));
const daaPerS = dagEnd.map((d, i) => ((Number(d?.virtualDaaScore) - Number(dagStart[i]?.virtualDaaScore)) / ((tEnd - tMeasure) / 1000)).toFixed(2));
const D = { p50: q(toLast, 0.5) / 1000, p90: q(toLast, 0.9) / 1000, p99: q(toLast, 0.99) / 1000, max: Math.max(...toLast) / 1000 };
const ks = { p50: ghostdagK(2 * D.p50 * B, 0.01), p90: ghostdagK(2 * D.p90 * B, 0.01), p99: ghostdagK(2 * D.p99 * B, 0.01), max: ghostdagK(2 * D.max * B, 0.01) };
const row = { B, blocksSeenAll: seenAll, blocksSeenSome: seenSome, blocksPerS, daaPerS, toLast: fmt(toLast), hop1: fmt(hop1), hop2: fmt(hop2), perNode: perNode.map(fmt),
depth: depth.map(fmt), depthN: depth.map(d => d.length), changes, depthAll: fmt(depth.flat()), depthAllN: depth.flat().length,
lockedInWindow, maxLocked, unlocked, conflicts, conflictLines, redetermined, sinks: new Set(sinks).size, ks, D, kTable: K_TABLE[B],
blockCount: dagEnd.map(d => d?.blockCount), wall: Math.round((Date.now() - t0) / 1000) };
log(`rate ${B}: ${seenAll} blocks on all 3 (${seenSome} partial), ${blocksPerS.join('/')} blocks/s, to last node ${row.toLast} ms, reorg depth ${row.depthAll} over ${row.depthAllN} removals, locks ${lockedInWindow.join('/')}, conflicts ${conflicts}, re-determined ${redetermined.join('/')}, k(p99) ${ks.p99}`);
await net.stopAll();
writeFileSync(`${TMP}/results-${B}.json`, JSON.stringify({ row, removals: removals.map(r => r.filter(([t]) => t >= tMeasure && t <= tEnd)) }, null, 2));
return row;
}
async function main() {
assertBinaries();
if (ghostdagK(10, 0.01) !== 18 || ghostdagK(20, 0.01) !== 31 || ghostdagK(50, 0.01) !== 67) throw new Error('ghostdagK port disagrees with the fork\'s table');
const t0 = Date.now();
const rows = [];
for (const B of RATES) rows.push(await runRate(B));
out(`\n### f7-rates: ${RATES.join(', ')} blocks/s, ${KEYS} keys at share 1/${KEYS}, warm ${WARM} s then ${MEASURE} s measured per rate, one-way delay ${DELAY_MS} ms per proxied link (n0 to n2 is two links and n1's relay), fast time with the fork's Bps<B> blockrate and DAA windows scaled by B, node ${IGNEUMD.split('/').slice(-3).join('/')}\n`);
out('| blocks/s | k in the fork (D 5 s) | blocks seen on all 3 | measured blocks/s per node | DAA/s per node | to the last node p50 / p90 / p99 / max (ms) | 1 hop (ms) | 2 hops (ms) | reorg depth p50 / p90 / p99 / max (blocks), all nodes | removals (n0 / n1 / n2) | chain changes (n0 / n1 / n2) | locks in the window (n0 / n1 / n2) | unlocked checkpoints at the end | conflicting locks at one index | CONFLICTING log lines | re-determined (n0 / n1 / n2) | sinks | k from p50 / p90 / p99 / max |');
out('|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|');
for (const r of rows) out(`| ${r.B} | ${r.kTable} | ${r.blocksSeenAll} (${r.blocksSeenSome} partial) | ${r.blocksPerS.join(' / ')} | ${r.daaPerS.join(' / ')} | ${r.toLast} | ${r.hop1} | ${r.hop2} | ${r.depthAll} over ${r.depthAllN} | ${r.depthN.join(' / ')} | ${r.changes.join(' / ')} | ${r.lockedInWindow.join(' / ')} | ${r.unlocked.join(' / ')} | ${r.conflicts} | ${r.conflictLines.join(' / ')} | ${r.redetermined.join(' / ')} | ${r.sinks} | ${r.ks.p50} / ${r.ks.p90} / ${r.ks.p99} / ${r.ks.max} |`);
out('\nPer node reorg depth p50 / p90 / p99 / max and propagation from the first arrival:\n');
out('| blocks/s | depth n0 | depth n1 | depth n2 | propagation to n0 | to n1 | to n2 |');
out('|---|---|---|---|---|---|---|');
for (const r of rows) out(`| ${r.B} | ${r.depth.join(' | ')} | ${r.perNode.join(' | ')} |`);
out(`\nk = calculate_ghostdag_k(2 D bps, 0.01) with D the time from the first arrival anywhere to the last of the three nodes (every node's blockAdded stamped by this process). Reorg depth = removed chain blocks per virtualChainChanged notification with at least one removal. Wall ${Math.round((Date.now() - t0) / 1000)} s.`);
writeFileSync(`${TMP}/results.json`, JSON.stringify(rows, null, 2));
process.exit(0);
}
main().catch(async (e) => { log(`threw: ${e.stack || e}`); if (current) await current.stopAll(); process.exit(1); });