193 lines
15 KiB
JavaScript
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); });
|