igneum/tools/finality-attacks/m21.mjs
igneum-labs def6ef137d Ledger close round 1 (sims): F20, M21, X14, E16 code half, P17, X12 run and logged
F20: tools/finality-attacks/f20.mjs, a 47.5% silent set through four fast-time epoch boundaries (0 locks in the pause, program on schedule, one seed per index, 12 pre-pause locks held, 0 conflicts).
M21: tools/finality-attacks/m21.mjs, 490 KB coinbase bodies on 100-ms proxied links, k re-derived with the fork's calculate_ghostdag_k (p99 812 ms, k 5).
X14 and E16: tools/finality-attacks/x14-concentration.mjs, read-only concentration from the observer node (signing not exposed by any RPC) and one live block's burn output beside its escrow payout.
P17: report only, igneum_getTransactionStatus per state, finalized resolves to the tip.
X12: sim/difficulty/record_report.py, the 3 October record's step profile, retarget trajectory, 2-minute buckets and epoch gap in the bench-log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 19:15:59 +00:00

128 lines
9.3 KiB
JavaScript

// Ledger M21 / O-2.2 (5 October 2026, night, ledger close round 1): block propagation with bodies near the mass
// limit on the fast-time 3-node network with proxied 100-ms links, and GHOSTDAG k re-derived from the measured delay
// with the fork's own `calculate_ghostdag_k` (consensus/core/src/config/bps.rs, ported below).
//
// node tools/finality-attacks/m21.mjs # sizes 0 and 490,000 bytes of coinbase padding, 180 s each
// SIZES=0,100000,490000 SECS=180 DELAY_MS=100 node tools/finality-attacks/m21.mjs
//
// Ports 30100 and up, network igneum-devnet-1010, data under /tmp/igneum-fin-m21; the live devnet is never touched.
// Topology (as c4.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2; each proxy holds every
// byte DELAY_MS one way, so n0 to n1 is one hop and n0 to n2 two hops plus n1's relay. The proxy adds delay and no
// bandwidth limit (loopback), so serialisation time is not in these numbers; the ledger's arithmetic adds it.
//
// Bodies: the block producer here is the harness itself (getBlockTemplate with `extraData` of SIZE zero bytes, then
// submitBlock; the network runs with skip_proof_of_work, the nonce carries a sequence number so every node's
// blockAdded notification can be matched to its submit time). The devnet-v4 line retired UTXO transactions from
// bodies and EVM transactions ride in the coinbase-carried lanes, so the one body a producer can grow without a
// transaction relay is the coinbase payload; `max_coinbase_payload_len` is raised to 600,000 in this network's
// override (the fast-time file carries 16,384) and the body stays under the 500,000 compute-mass limit, which counts
// every coinbase byte at 1 mass per byte (`check_block_mass`). n0 produces at RATE0 blocks/s and n2 at RATE2, both
// padded, so both directions of the two-hop path are measured.
const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/';
process.env.IGNEUM_FIN_BASE_PORT ||= '30100';
process.env.IGNEUM_FIN_SUFFIX ||= '1010';
process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-m21';
process.env.IGNEUM_FAST_TIME ||= '1';
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`;
process.env.IGNEUM_FIN_OVERRIDE_JSON ||= JSON.stringify({ max_coinbase_payload_len: 600000 });
const DELAY_MS = +(process.env.DELAY_MS || 100);
const SIZES = (process.env.SIZES || '0,490000').split(',').map(Number);
const SECS = +(process.env.SECS || 180);
const RATE0 = +(process.env.RATE0 || 0.7), RATE2 = +(process.env.RATE2 || 0.3);
const PAY = process.env.PAY_ADDRESS || 'igneumdev:qpdkezwu04kuscr3hx9wvqhtrnn5xunt4wjjaxsrgmks3z9cjltf58g2zl4c7';
const { Node, Proxy, stopAll, sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs');
const { mkdirSync, writeFileSync, appendFileSync } = await import('node:fs');
mkdirSync(TMP, { recursive: true });
const out = (line) => { console.log(line); appendFileSync(`${TMP}/results.md`, line + '\n'); };
// 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; }
}
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) => `${q(xs, 0.5)} / ${q(xs, 0.9)} / ${q(xs, 0.99)} / ${Math.max(...xs)}`;
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 main() {
assertBinaries();
if (ghostdagK(10, 0.01) !== 18) throw new Error(`ghostdagK port wrong: D 5 s gives ${ghostdagK(10, 0.01)}, the fork says 18`);
const t0 = Date.now();
const n1 = new Node(1, { name: 'n1' }); await n1.start();
const p0 = new Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }); await p0.start();
const p2 = new Proxy(2, n1.p2pPort, { delayMs: DELAY_MS }); await p2.start();
const n0 = new Node(0, { name: 'n0', connect: [p0.addr] });
const n2 = new Node(2, { name: 'n2', connect: [p2.addr] });
await n0.start(); await n2.start();
await sleep(3000);
const nodes = [n0, n1, n2];
log(`network up: peers n0 ${await peers(n0)} n1 ${await peers(n1)} n2 ${await peers(n2)}; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`);
// arrivals: nonce -> [t_n0, t_n1, t_n2]
const arrivals = new Map();
const submits = new Map(); // nonce -> { t, origin, size }
nodes.forEach((n, i) => {
n.rpc.onNotification = (method, params) => {
if (method !== 'blockAddedNotification') return;
const block = params?.BlockAdded?.block || params?.block; const nonce = Number(block?.header?.nonce);
if (!Number.isFinite(nonce)) return;
if (!arrivals.has(nonce)) arrivals.set(nonce, [null, null, null]);
if (arrivals.get(nonce)[i] == null) arrivals.get(nonce)[i] = Date.now();
};
});
for (const n of nodes) await n.rpc.call('subscribe', { BlockAdded: {} });
let seq = 1;
async function producer(node, origin, rate, size, untilMs, stats) {
const pad = new Array(size).fill(0);
while (Date.now() < untilMs) {
const gap = -Math.log(1 - Math.random()) / rate * 1000;
await sleep(Math.min(gap, untilMs - Date.now()));
if (Date.now() >= untilMs) break;
let tm; try { tm = await node.rpc.call('getBlockTemplate', { payAddress: PAY, extraData: pad }, 30000); } catch (e) { stats.templateErrors++; continue; }
const nonce = seq++;
tm.block.header.nonce = nonce;
submits.set(nonce, { t: Date.now(), origin, size, bytes: Math.round(String(tm.block.transactions[0].payload).length / 2) });
try { const r = await node.rpc.call('submitBlock', { block: tm.block, allowNonDaaBlocks: false }, 30000); if (r?.report?.type !== 'success') { stats.rejected++; submits.delete(nonce); } else stats.accepted++; }
catch (e) { stats.submitErrors++; submits.delete(nonce); }
}
}
const rows = [];
for (const size of SIZES) {
const stats = { accepted: 0, rejected: 0, templateErrors: 0, submitErrors: 0 };
const dagBefore = await Promise.all(nodes.map(dag));
const until = Date.now() + SECS * 1000;
const firstNonce = seq;
await Promise.all([producer(n0, 0, RATE0, size, until, stats), producer(n2, 2, RATE2, size, until, stats)]);
await sleep(4000);
const dagAfter = await Promise.all(nodes.map(dag));
// samples for this size
const hop1 = [], hop2 = [], local = [], relay = [];
let bytes = 0, n = 0, missing = 0;
for (const [nonce, s] of submits) {
if (nonce < firstNonce || s.size !== size) continue;
const a = arrivals.get(nonce); n++; bytes += s.bytes;
if (!a || a.some(x => x == null)) { missing++; continue; }
const far = s.origin === 0 ? 2 : 0;
local.push(a[s.origin] - s.t); hop1.push(a[1] - s.t); hop2.push(a[far] - s.t); relay.push(a[far] - a[1]);
}
const D99 = q(hop2, 0.99) / 1000, Dmax = Math.max(...hop2) / 1000, D90 = q(hop2, 0.9) / 1000, D50 = q(hop2, 0.5) / 1000;
const row = { size, bytes: n ? Math.round(bytes / n) : 0, blocks: n, missing, stats, local: fmt(local), hop1: fmt(hop1), hop2: fmt(hop2), relay: fmt(relay),
k50: ghostdagK(2 * D50, 0.01), k90: ghostdagK(2 * D90, 0.01), k99: ghostdagK(2 * D99, 0.01), kmax: ghostdagK(2 * Dmax, 0.01),
blockCount: dagAfter.map(d => d?.blockCount), sinks: new Set(dagAfter.map(d => d?.sink)).size, added: dagAfter.map((d, i) => Number(d?.blockCount) - Number(dagBefore[i]?.blockCount)) };
rows.push(row);
log(`size ${size}: ${n} blocks (${missing} not seen on every node), body ${row.bytes} B, 1 hop ${row.hop1} ms, 2 hops ${row.hop2} ms, relay ${row.relay} ms, k(p99) ${row.k99}`);
}
await stopAll();
out(`\n### m21-bodies: ${SIZES.join(', ')} bytes of coinbase padding, ${SECS} s each, n0 at ${RATE0} and n2 at ${RATE2} blocks/s, one-way delay ${DELAY_MS} ms per proxied link (n0 to n2 is two links and n1's relay), fast time, node ${IGNEUMD.split('/').slice(-3).join('/')}\n`);
out('| padding (B) | coinbase payload (B) | blocks | seen on all 3 | own node p50 / p90 / p99 / max (ms) | 1 hop p50 / p90 / p99 / max (ms) | 2 hops p50 / p90 / p99 / max (ms) | second hop alone (ms) | k from p50 / p90 / p99 / max (delta 0.01, 1 block/s) | blocks per node at the end | sinks |');
out('|---|---|---|---|---|---|---|---|---|---|---|');
for (const r of rows) out(`| ${r.size} | ${r.bytes} | ${r.blocks} (${r.stats.accepted} accepted, ${r.stats.rejected} rejected, ${r.stats.templateErrors + r.stats.submitErrors} errors) | ${r.blocks - r.missing} | ${r.local} | ${r.hop1} | ${r.hop2} | ${r.relay} | ${r.k50} / ${r.k90} / ${r.k99} / ${r.kmax} | ${r.blockCount.join(' / ')} | ${r.sinks} |`);
out(`\nThe delay bound D feeding k is the two-hop time from submit on the origin node to the blockAdded notification on the far node (both stamped by this process); k = calculate_ghostdag_k(2 D, 0.01), which gives 18 at D = 5 s as the fork's comment says. 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}`); await stopAll(); process.exit(1); });