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>
169 lines
14 KiB
JavaScript
169 lines
14 KiB
JavaScript
// Ledger F20 (5 October 2026, night, ledger close round 1): a finality pause under a 45% silent set through at least
|
|
// three program epoch boundaries, on the fast-time 3-node network (60-DAA epochs, 120-DAA weight window). The silent
|
|
// set keeps mining and stops signing, so under 2/3 of the window is signing and finality must pause (CLAUDE.md,
|
|
// FINALITY RULE V2). What is recorded: that every node's template advanced its program epoch at each boundary on
|
|
// schedule (the template's powEpoch), that every node derived the same epoch seed per index and ended on one sink
|
|
// (no fork on the seed), that no checkpoint locked during the pause, and that every lock taken before the pause is
|
|
// still held by every node, with the same hash, after the silent set returns and locking resumes.
|
|
//
|
|
// node tools/finality-attacks/f20.mjs # WARM 230 s, PAUSE 200 s, HEAL 150 s, links 100 ms
|
|
// WARM=230 PAUSE=200 HEAL=150 node tools/finality-attacks/f20.mjs
|
|
//
|
|
// Ports 30200 and up, network igneum-devnet-1020, data under /tmp/igneum-fin-f20; the live devnet is never touched.
|
|
// Topology (as c4.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2, both with DELAY_MS.
|
|
// Keys: honest h0 h1 on n0, h2 on n1, h3 on n2 at share 0.1375 each (55%); silent s0 on n1, s1 on n2 at share 0.225
|
|
// each (45%). One block per second in all. The silent keys are restarted with --no-vote for the pause and voting again
|
|
// for the heal; a vmine key is derived from its label, so the weight stays with the same key across restarts.
|
|
|
|
const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/';
|
|
process.env.IGNEUM_FIN_BASE_PORT ||= '30200';
|
|
process.env.IGNEUM_FIN_SUFFIX ||= '1020';
|
|
process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-f20';
|
|
process.env.IGNEUM_FAST_TIME ||= '1';
|
|
// the live node line (release-0.3.6, vendor/igneum-node-036) 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 WARM = +(process.env.WARM || 230), PAUSE = +(process.env.PAUSE || 200), HEAL = +(process.env.HEAL || 150);
|
|
const POLL_MS = +(process.env.POLL_MS || 2000);
|
|
// a devnet pay address for the template probe (any valid devnet address; the probe never submits)
|
|
const PAY = process.env.PAY_ADDRESS || 'igneumdev:qpdkezwu04kuscr3hx9wvqhtrnn5xunt4wjjaxsrgmks3z9cjltf58g2zl4c7';
|
|
|
|
const { Node, Miner, 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'); };
|
|
|
|
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; }
|
|
async function dag(node) { return node.rpc.call('getBlockDagInfo', {}).catch(() => null); }
|
|
async function epoch(node) {
|
|
const r = await node.rpc.call('getBlockTemplate', { payAddress: PAY, extraData: [] }).catch(() => null);
|
|
const e = r?.powEpoch; if (!e) return null;
|
|
return { index: Number(e.epochIndex), seed: String(e.epochSeed), boundary: Number(e.boundaryDaaScore), next: String(e.nextEpochSeed ?? ''), blocks: Number(e.epochBlocks), lead: Number(e.epochLead) };
|
|
}
|
|
function keyOf(miner) { const m = miner.logText().match(/key=([0-9a-f]{64})/); return m ? m[1] : null; }
|
|
|
|
async function network() {
|
|
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);
|
|
log(`network up: peers n0 ${await peers(n0)} n1 ${await peers(n1)} n2 ${await peers(n2)}`);
|
|
return { n0, n1, n2, p0, p2 };
|
|
}
|
|
|
|
async function main() {
|
|
assertBinaries();
|
|
const t0 = Date.now();
|
|
const { n0, n1, n2 } = await network();
|
|
const nodes = [n0, n1, n2];
|
|
const total = WARM + PAUSE + HEAL + 60;
|
|
const honestPlan = [[n0, 'h0'], [n0, 'h1'], [n1, 'h2'], [n2, 'h3']];
|
|
const silentPlan = [[n1, 's0'], [n2, 's1']];
|
|
const honest = honestPlan.map(([node, label]) => new Miner(node, { label, share: 0.1375, bps: 1, secs: total }).start());
|
|
let silent = silentPlan.map(([node, label]) => new Miner(node, { label, share: 0.225, bps: 1, secs: WARM + 10 }).start());
|
|
await sleep(WARM * 1000);
|
|
// the state at the pause
|
|
const silentKeys = new Set(silent.map(keyOf).filter(Boolean));
|
|
const w = await n1.rpc.call('getFinalityWeights', {}).catch(() => ({}));
|
|
const silentWeight = (w.keys || []).filter(k => silentKeys.has(String(k.keyHash))).reduce((s, k) => s + Number(k.blocks), 0);
|
|
const before = await Promise.all(nodes.map(n => checkpoints(n)));
|
|
const beforeMaps = before.map(lockedMap);
|
|
const beforeMax = beforeMaps.map(m => Math.max(0, ...m.keys()));
|
|
const preMax = Math.max(...beforeMax);
|
|
const dagsCut = await Promise.all(nodes.map(dag));
|
|
const epochsCut = await Promise.all(nodes.map(epoch));
|
|
log(`pause at warm ${WARM} s: window daa ${w.daaScore}, total weight ${w.totalWeight}, silent keys ${silentKeys.size} hold ${silentWeight} (${w.totalWeight ? (100 * silentWeight / Number(w.totalWeight)).toFixed(1) : '?'}%), voters ${w.voters}, max locked ${beforeMax.join('/')}, daa ${dagsCut.map(d => d?.virtualDaaScore).join('/')}, epoch index ${epochsCut.map(e => e?.index).join('/')}`);
|
|
// the pause: the silent keys keep mining and stop voting
|
|
for (const m of silent) await m.stop();
|
|
const tPause = Date.now();
|
|
const daaPause = Number(dagsCut[1]?.virtualDaaScore ?? 0);
|
|
silent = silentPlan.map(([node, label]) => new Miner(node, { label, share: 0.225, bps: 1, secs: PAUSE + 10, vote: false }).start());
|
|
const transitions = [[], [], []]; // per node: {index, seed, boundary, daaSeen, sAfterPause}
|
|
const lastIndex = epochsCut.map(e => e?.index ?? -1);
|
|
const maxDuring = [...beforeMax];
|
|
let firstNew = [null, null, null];
|
|
while (Date.now() - tPause < PAUSE * 1000) {
|
|
const [ds, es, cps] = await Promise.all([Promise.all(nodes.map(dag)), Promise.all(nodes.map(epoch)), Promise.all(nodes.map(n => checkpoints(n)))]);
|
|
es.forEach((e, i) => {
|
|
if (e && e.index !== lastIndex[i]) {
|
|
transitions[i].push({ index: e.index, seed: e.seed, boundary: e.boundary, blocks: e.blocks, daaSeen: Number(ds[i]?.virtualDaaScore ?? NaN), sAfterPause: ((Date.now() - tPause) / 1000).toFixed(1) });
|
|
lastIndex[i] = e.index;
|
|
}
|
|
});
|
|
cps.forEach((cp, i) => { const m = maxLocked(cp); if (m > maxDuring[i]) maxDuring[i] = m; if (firstNew[i] == null && m > preMax) firstNew[i] = Math.round((Date.now() - tPause) / 1000); });
|
|
await sleep(POLL_MS);
|
|
}
|
|
const dagsEndPause = await Promise.all(nodes.map(dag));
|
|
const daaEndPause = Number(dagsEndPause[1]?.virtualDaaScore ?? 0);
|
|
const cpsEndPause = await Promise.all(nodes.map(n => checkpoints(n)));
|
|
const unlockedDuring = cpsEndPause.map(cp => (cp?.checkpoints || []).filter(c => c.daaScore > daaPause && c.state !== 'locked').length);
|
|
const newLocksDuring = maxDuring.map(m => Math.max(0, m - preMax));
|
|
log(`end of pause: daa ${daaPause} to ${daaEndPause}, new locks ${newLocksDuring.join('/')}, boundaries seen ${transitions.map(t => t.length).join('/')}`);
|
|
// the heal: the silent keys vote again
|
|
for (const m of silent) await m.stop();
|
|
const tHeal = Date.now();
|
|
silent = silentPlan.map(([node, label]) => new Miner(node, { label, share: 0.225, bps: 1, secs: HEAL + 10 }).start());
|
|
let resumedAt = [null, null, null];
|
|
while (Date.now() - tHeal < HEAL * 1000) {
|
|
const cps = await Promise.all(nodes.map(n => checkpoints(n)));
|
|
cps.forEach((cp, i) => { if (resumedAt[i] == null && maxLocked(cp) > maxDuring[i]) resumedAt[i] = Math.round((Date.now() - tHeal) / 1000); });
|
|
const es = await Promise.all(nodes.map(epoch));
|
|
es.forEach((e, i) => { if (e && e.index !== lastIndex[i]) { transitions[i].push({ index: e.index, seed: e.seed, boundary: e.boundary, daaSeen: NaN, sAfterPause: 'heal' }); lastIndex[i] = e.index; } });
|
|
await sleep(POLL_MS);
|
|
}
|
|
for (const m of [...honest, ...silent]) await m.stop();
|
|
await sleep(3000);
|
|
const after = await Promise.all(nodes.map(n => checkpoints(n)));
|
|
const afterMaps = after.map(lockedMap);
|
|
const afterMax = afterMaps.map(m => Math.max(0, ...m.keys()));
|
|
const dagsAfter = await Promise.all(nodes.map(dag));
|
|
const sinks = dagsAfter.map(d => d?.sink);
|
|
const converged = new Set(sinks).size === 1;
|
|
// pre-pause locks: every (index, hash) each node held before the pause is held unchanged by every node after
|
|
let survived = 0, lost = 0, changed = 0;
|
|
const union = new Map();
|
|
for (const m of beforeMaps) for (const [i, h] of m) union.set(i, h);
|
|
for (const [i, h] of union) for (const m of afterMaps) { if (!m.has(i)) lost++; else if (m.get(i) !== h) changed++; else survived++; }
|
|
// seeds per epoch index across nodes
|
|
const seeds = new Map();
|
|
transitions.forEach((ts, i) => ts.forEach(t => { if (!seeds.has(t.index)) seeds.set(t.index, new Map()); seeds.get(t.index).set(i, t.seed); }));
|
|
const seedRows = [...seeds.entries()].sort((a, b) => a[0] - b[0]).map(([idx, m]) => ({ idx, agree: new Set(m.values()).size === 1, nodes: m.size, seed: [...m.values()][0] }));
|
|
const seedFork = seedRows.filter(r => !r.agree).length;
|
|
const cacheBuilt = nodes.map(n => n.grepLog(/PoW cache built/).length);
|
|
const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length);
|
|
const boundariesInPause = transitions.map(ts => ts.filter(t => t.sAfterPause !== 'heal').length);
|
|
// on schedule: the new index was first seen at or after its boundary (index x epoch blocks) and within one poll of it
|
|
const onSchedule = transitions.map(ts => ts.filter(t => t.sAfterPause !== 'heal').every(t => t.daaSeen >= t.index * t.blocks && t.daaSeen - t.index * t.blocks <= 2 * POLL_MS / 1000 + 3));
|
|
await stopAll();
|
|
const pass = boundariesInPause.every(b => b >= 3) && seedFork === 0 && converged && newLocksDuring.every(x => x === 0) && lost === 0 && changed === 0 && resumedAt.every(r => r != null) && conflicts.every(c => c === 0);
|
|
out(`\n### f20-silent45: warm ${WARM} s, pause ${PAUSE} s with the 45% set mining and not voting, heal window ${HEAL} s, 1 block/s in all, link delay ${DELAY_MS} ms, fast time (epoch 60 DAA, window 120 DAA), node ${IGNEUMD.split('/').slice(-3).join('/')}\n`);
|
|
out(`Silent set at the pause: ${silentKeys.size} keys holding ${silentWeight} of ${w.totalWeight} window blocks (${w.totalWeight ? (100 * silentWeight / Number(w.totalWeight)).toFixed(1) : '?'}%); window DAA ${w.daaScore}; the pause ran from DAA ${daaPause} to ${daaEndPause}.\n`);
|
|
out('| measure | n0 | n1 | n2 |');
|
|
out('|---|---|---|---|');
|
|
out(`| max locked index at the pause | ${beforeMax.join(' | ')} |`);
|
|
out(`| epoch index at the pause | ${epochsCut.map(e => e?.index).join(' | ')} |`);
|
|
out(`| epoch boundaries crossed during the pause | ${boundariesInPause.join(' | ')} |`);
|
|
out(`| boundary seen within the poll of its DAA (on schedule) | ${onSchedule.join(' | ')} |`);
|
|
out(`| PoW cache builds in the node log (one per dataset day, M30) | ${cacheBuilt.join(' | ')} |`);
|
|
out(`| new locks during the pause (index above ${preMax}) | ${newLocksDuring.join(' | ')} |`);
|
|
out(`| checkpoints proposed in the pause and not locked | ${unlockedDuring.join(' | ')} |`);
|
|
out(`| first new lock after the heal, s | ${resumedAt.map(x => x ?? 'none').join(' | ')} |`);
|
|
out(`| max locked index at the end | ${afterMax.join(' | ')} |`);
|
|
out(`| sink at the end | ${sinks.map(s => String(s).slice(0, 10)).join(' | ')} |`);
|
|
out(`| conflicting certificates logged | ${conflicts.join(' | ')} |`);
|
|
out('\nEpoch boundaries during the pause (the template\'s powEpoch, polled every ' + POLL_MS + ' ms; DAA seen = the node\'s virtual DAA at the poll that first showed the new index):\n');
|
|
out('| node | epoch index | boundary DAA | DAA seen | s after the pause start | seed |');
|
|
out('|---|---|---|---|---|---|');
|
|
transitions.forEach((ts, i) => ts.forEach(t => out(`| n${i} | ${t.index} | ${t.boundary} | ${t.daaSeen} | ${t.sAfterPause} | ${t.seed.slice(0, 12)} |`)));
|
|
out(`\nSeeds agree across the nodes that saw the index: ${seedRows.map(r => `${r.idx}:${r.agree ? 'yes' : 'NO'}(${r.nodes})`).join(' ')}; seed forks ${seedFork}. Pre-pause locks (${union.size} indices across the nodes) after the heal: ${survived} held with the same hash, ${lost} missing, ${changed} with another hash. Sinks ${converged ? 'agree' : 'DISAGREE'}. ${pass ? 'PASS' : 'FAIL'} (3 boundaries on every node, one seed per index, 0 locks in the pause, every pre-pause lock held, locking resumed, 0 conflicts). Wall ${Math.round((Date.now() - t0) / 1000)} s.`);
|
|
writeFileSync(`${TMP}/results.json`, JSON.stringify({ pass, silentWeight, totalWeight: w.totalWeight, daaPause, daaEndPause, beforeMax, afterMax, newLocksDuring, unlockedDuring, resumedAt, transitions, seedRows, survived, lost, changed, converged, conflicts, cacheBuilt }, null, 2));
|
|
process.exit(pass ? 0 : 1);
|
|
}
|
|
main().catch(async (e) => { log(`threw: ${e.stack || e}`); await stopAll(); process.exit(1); });
|