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>
128 lines
9.3 KiB
JavaScript
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); });
|