tools/finality-attacks: run.mjs drives a private igneum-devnet-800 network (ports 27800+, /tmp/igneum-fin-attacks, skip_proof_of_work) with the test-only hostile flags of igneum-miner (vmine, --equivocate, --sybil, --drop-votes, --pulse, fin-rpc-attack; worktree fin-attacks on master c6d47547..2a00ff55). Seven scenarios in priority order 3,2,1,6,4,8,5 with a spec 03 criterion and a measured result each; README carries the catalogue, what needs a finality-aware p2p probe, and a proposed diff for every FAIL. Results (six voters): S3 dishonest aggregators PASS (35/35/35 locks, 0 conflicts, 1,018 ms); S2 Sybil dust: weights PASS, aggregator sortition FAIL (per key, ledger F17); S1 equivocation PASS (2/2/2 stripped, 0 conflicts); S6A 3/3 partition FAIL (floor is time-bounded, one side crossed 56.7% at 84 s of a 90 s split, T* = 2F/13R, 9.2 days for a 50/50 split at mainnet scale); S6B 4/2 PASS; S4 vote-dropping producer PASS (0 ms added); S8 malformed votes over RPC PASS (9 cases, no crash); S5 pulse: no retarget amplification (ratio 0.999) PASS, lock-alone FAIL (a 20 s burst locked checkpoints 1 to 10 alone on a young window, ledger F1, spec 3.8 not implemented). S7 eclipse not run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
349 lines
22 KiB
JavaScript
349 lines
22 KiB
JavaScript
// Finality v2 attack runner (docs/spec/03-finality.md, gate 3). Drives a private network of our own igneumd
|
||
// nodes with the test-only hostile vmine miners and records, per scenario, the spec pass criterion and a
|
||
// measured result. Priority order 3, 2, 1, 6, 4, 8, 5, 7 (as time allows). Read-only against the spec; the
|
||
// hostile behaviour lives in igneum-miner test flags, never in honest node or consensus code.
|
||
//
|
||
// node tools/finality-attacks/run.mjs # the achievable catalogue
|
||
// node tools/finality-attacks/run.mjs s1 s6 # named scenarios
|
||
// node tools/finality-attacks/run.mjs --quick # short durations (smoke)
|
||
// SCALE=0.5 node tools/finality-attacks/run.mjs # scale every duration
|
||
|
||
import { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP } from './lib/net.mjs';
|
||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||
|
||
const QUICK = process.argv.includes('--quick');
|
||
const SCALE = QUICK ? 0.35 : (parseFloat(process.env.SCALE || '1') || 1);
|
||
const dur = (s) => Math.max(20, Math.round(s * SCALE));
|
||
const results = [];
|
||
mkdirSync(TMP, { recursive: true });
|
||
|
||
const sumLocked = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').length;
|
||
const maxLockedIndex = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').reduce((m, c) => Math.max(m, c.index), 0);
|
||
function minerLockLatency(miners) {
|
||
const xs = [];
|
||
for (const m of miners) {
|
||
const t = m.logText();
|
||
const match = t.match(/lock_latency=median (\d+) ms, max (\d+) ms/);
|
||
if (match) xs.push(+match[1]);
|
||
}
|
||
xs.sort((a, b) => a - b);
|
||
return xs.length ? xs[Math.floor(xs.length / 2)] : null;
|
||
}
|
||
function minerFound(miner) {
|
||
const m = miner.logText().match(/engine=vmine found=(\d+)/);
|
||
return m ? +m[1] : null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Scenario 3: dishonest aggregators. Six voters across three nodes; every node aggregates (anyone MAY). A
|
||
// certificate below quorum cannot lock (code: FinalityManager::lock_test). We measure that locks still form on
|
||
// every node, latency stays under 2 s, and votes carried in blocks let participation be computed (F3).
|
||
async function s3() {
|
||
const name = 's3-dishonest-aggregators';
|
||
const secs = dur(300);
|
||
const n0 = await new Node(0).start();
|
||
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
|
||
const n2 = await new Node(2, { connect: [n0.p2p] }).start();
|
||
const miners = [];
|
||
// two voters per node
|
||
const plan = [[n0, 'a0'], [n0, 'a1'], [n1, 'b0'], [n1, 'b1'], [n2, 'c0'], [n2, 'c1']];
|
||
for (const [node, label] of plan) miners.push(new Miner(node, { label, share: 1 / 6, bps: 6, secs }).start());
|
||
await sleep(secs * 1000 + 3000);
|
||
const cps = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => null)));
|
||
const locked = cps.map(sumLocked);
|
||
const maxIdx = cps.map(maxLockedIndex);
|
||
const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||
const lat = minerLockLatency(miners);
|
||
// agreement: every node's set of locked (index->hash) is consistent
|
||
const hashAt = (cp, idx) => (cp.checkpoints.find(c => c.index === idx && c.state === 'locked') || {}).hash;
|
||
let agree = true;
|
||
const common = Math.min(...maxIdx);
|
||
for (let i = 1; i <= common; i++) { const h = cps.map(cp => hashAt(cp, i)).filter(Boolean); if (new Set(h).size > 1) agree = false; }
|
||
const pass = locked.every(l => l > 3) && conflicts.every(c => c === 0) && agree && lat != null && lat < 2000;
|
||
for (const m of miners) await m.stop();
|
||
results.push({ name, secs,
|
||
criterion: 'other aggregators’ certs still lock; sub-quorum cert cannot lock (lock_test); block-carried votes give participation (F3); 0 conflicting certs; lock latency < 2 s median',
|
||
result: `locked per node ${locked.join('/')}, conflicting certs ${conflicts.join('/')}, cross-node lock hashes agree=${agree}, median lock latency ${lat} ms`,
|
||
pass });
|
||
await stopAll();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Scenario 2: Sybil dust. One miner mints 200 keys below dust (4 blocks each; dust = 5) and 200 above (6 each).
|
||
// A few honest voters advance the chain. Criterion: dust keys carry zero weight and are no voters; above-dust
|
||
// keys carry weight = their blocks; total weight = blue blocks of voters. Aggregator sortition must pick by
|
||
// weight not key count (F17): the implementation picks per key, reported as a FAIL with its blast radius.
|
||
async function s2() {
|
||
const name = 's2-sybil-dust';
|
||
const secs = dur(300);
|
||
const n0 = await new Node(0).start();
|
||
const miners = [];
|
||
// honest voters keep checkpoints advancing and are the real voter set
|
||
for (const l of ['h0', 'h1', 'h2']) miners.push(new Miner(n0, { label: l, share: 1 / 3, bps: 3, secs }).start());
|
||
// one Sybil miner mints both populations (200 x 4 dust, 200 x 6 above) from a single process
|
||
const sybil = new Miner(n0, { label: 'syb', secs, sybil: '200:4:200:6', vote: false }).start();
|
||
// wait for the sybil to finish minting (or the run deadline)
|
||
const t0 = Date.now();
|
||
while (Date.now() - t0 < secs * 1000) { if (sybil.exited) break; await sleep(2000); }
|
||
await sleep(3000);
|
||
const w = await n0.rpc.call('getFinalityWeights', {});
|
||
const keys = w.keys || [];
|
||
const dust = keys.filter(k => k.pubkey && k.blocks > 0 && k.blocks < 5);
|
||
const above = keys.filter(k => k.voter);
|
||
const dustNonzeroWeightVoters = dust.filter(k => k.voter).length;
|
||
const totalOfVoters = above.reduce((s, k) => s + k.blocks, 0);
|
||
const totalMatches = totalOfVoters === w.totalWeight;
|
||
// F17: aggregator sortition is per key (is_aggregator counts voters, not weight). Inspect a recent checkpoint.
|
||
const cp = await n0.rpc.call('getFinalityCheckpoints', { last: 5 });
|
||
const aggCount = (cp.checkpoints.slice(-1)[0] || {}).aggregators?.length ?? 0;
|
||
const sortitionByKey = (w.voters || 0) > 8; // with >8 voters the per-key draw is observable
|
||
const weightsOk = dustNonzeroWeightVoters === 0 && totalMatches && above.length > 0;
|
||
const pass = weightsOk && !sortitionByKey; // FAIL whenever per-key sortition is observable among >8 voters
|
||
for (const m of miners) await m.stop();
|
||
await sybil.stop();
|
||
results.push({ name, secs,
|
||
criterion: 'dust keys zero weight and no voters; above-dust weight = blocks; total weight = blue blocks of voters; sortition by weight not key count (F17)',
|
||
result: `dust keys seen ${dust.length} (all non-voters: ${dustNonzeroWeightVoters === 0}); above-dust voters ${above.length}; total weight ${w.totalWeight} = sum of voter blocks ${totalOfVoters} (${totalMatches}); voters=${w.voters}; aggregator sortition is PER KEY (is_aggregator counts voters, not weight), observable with ${w.voters} voters, agg/checkpoint=${aggCount}`,
|
||
pass });
|
||
await stopAll();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Scenario 1: equivocation at scale. Six voters across three nodes, two equivocate at every index. Criterion:
|
||
// both keys stripped within one checkpoint on every node, no conflicting certificate ever forms, honest locks
|
||
// continue.
|
||
async function s1() {
|
||
const name = 's1-equivocation';
|
||
const secs = dur(300);
|
||
const n0 = await new Node(0).start();
|
||
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
|
||
const n2 = await new Node(2, { connect: [n0.p2p] }).start();
|
||
const miners = [];
|
||
const plan = [[n0, 'e0', true], [n0, 'e1', true], [n1, 'h0', false], [n1, 'h1', false], [n2, 'h2', false], [n2, 'h3', false]];
|
||
for (const [node, label, eq] of plan) miners.push(new Miner(node, { label, share: 1 / 6, bps: 6, secs, equivocate: eq }).start());
|
||
await sleep(secs * 1000 + 3000);
|
||
const ws = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityWeights', {}).catch(() => null)));
|
||
const strippedPerNode = ws.map(w => (w.keys || []).filter(k => k.strippedUntilDaa > 0).length);
|
||
const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||
const equivDetections = [n0, n1, n2].map(n => n.grepLog(/EQUIVOCATION by key/).length);
|
||
const cps = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => null)));
|
||
const locked = cps.map(sumLocked);
|
||
// locks continued after the first strip: the latest locked index is well past the first detection
|
||
const pass = strippedPerNode.every(s => s >= 2) && conflicts.every(c => c === 0) && locked.every(l => l > 3);
|
||
for (const m of miners) await m.stop();
|
||
results.push({ name, secs,
|
||
criterion: '2 equivocating keys stripped within one checkpoint on every node; no conflicting certificate forms; honest locks continue',
|
||
result: `stripped keys per node ${strippedPerNode.join('/')} (want >=2 each); equivocation detections ${equivDetections.join('/')}; conflicting certs ${conflicts.join('/')}; locked checkpoints per node ${locked.join('/')}`,
|
||
pass });
|
||
await stopAll();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Scenario 6: partition with the floor. Two nodes over a proxy, 6 keys known from a shared warmup. Part A: 3/3
|
||
// split, neither side holds 56.7% of total, so zero new locks on either side; heal, locks resume. Part B: 4/2
|
||
// split, the 4 side holds 66.7% and locks, the 2 side does not.
|
||
async function s6() {
|
||
const name = 's6-partition-floor';
|
||
// The floor protects a partition only while the majority side's fresh blocks stay small against its weight
|
||
// window (devnet window 7,200 DAA). So warm up long enough to fill most of the window, then split briefly. At
|
||
// ~12 bps the window fills in about 600 s; a 3-miner side adds ~3 bps, so a split under ~300 s keeps the
|
||
// majority below the 56.7% floor (3.3.1 on a real DAG; spec 3.7 item 8). --quick shrinks both, which exposes
|
||
// the under-filled-window breach instead (reported honestly as the time-to-breach).
|
||
const warm = dur(420), split = dur(150), healWin = dur(150);
|
||
|
||
async function partition(plan0, plan1, tag) {
|
||
const secs = warm + split + healWin + 90;
|
||
const n0 = await new Node(0).start();
|
||
const proxy = await new Proxy(0, n0.p2pPort).start();
|
||
const n1 = await new Node(1, { connect: [proxy.addr] }).start();
|
||
const miners = [];
|
||
for (const l of plan0) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||
for (const l of plan1) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||
await sleep(warm * 1000);
|
||
const w0 = await n0.rpc.call('getFinalityWeights', {}).catch(() => ({}));
|
||
const before0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 300 }));
|
||
const before1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 300 }));
|
||
log(`s6 ${tag} cut at warm ${warm}s: window daa ~${w0.daaScore}, voters ${w0.voters}, max locked ${before0}/${before1}`);
|
||
proxy.cut();
|
||
// Poll during the split: record the first new lock beyond the pre-cut index on each side (time to breach)
|
||
const t0 = Date.now();
|
||
let breach0 = null, breach1 = null, maxNew0 = before0, maxNew1 = before1;
|
||
while (Date.now() - t0 < split * 1000) {
|
||
const c0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null));
|
||
const c1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null));
|
||
if (c0 > maxNew0) maxNew0 = c0;
|
||
if (c1 > maxNew1) maxNew1 = c1;
|
||
if (breach0 == null && c0 > before0) breach0 = Math.round((Date.now() - t0) / 1000);
|
||
if (breach1 == null && c1 > before1) breach1 = Math.round((Date.now() - t0) / 1000);
|
||
await sleep(3000);
|
||
}
|
||
const newLocks = (maxNew0 - before0) + (maxNew1 - before1);
|
||
proxy.heal();
|
||
await sleep(healWin * 1000);
|
||
const after0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 800 }));
|
||
const after1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 800 }));
|
||
const healed = after0 > maxNew0 && after1 > maxNew1;
|
||
const conflicts = [n0, n1].map(n => n.grepLog(/CONFLICTING certificate/).length);
|
||
for (const m of miners) await m.stop();
|
||
await stopAll();
|
||
return { before0, before1, maxNew0, maxNew1, newLocks, breach0, breach1, after0, after1, healed, conflicts, daa: w0.daaScore, voters: w0.voters };
|
||
}
|
||
|
||
// Part A: 3/3. Neither side holds 56.7% of the (filled) total, so no side should lock during a short split.
|
||
const a = await partition(['a0', 'a1', 'a2'], ['b0', 'b1', 'b2'], 'A(3/3)');
|
||
const passA = a.newLocks === 0 && a.healed && a.conflicts.every(c => c === 0);
|
||
results.push({ name: name + '-A(3/3)', secs: warm + split + healWin,
|
||
criterion: '3/3 split on a filled window: zero new locks on either side (neither holds 56.7% of total); locks resume after healing; no conflicting certificates',
|
||
result: `warmup window daa ~${a.daa} (voters ${a.voters}); new locks during ${split}s split ${a.newLocks} (time to first new lock side0=${a.breach0 ?? 'none'}s side1=${a.breach1 ?? 'none'}s); resumed after heal ${a.healed}; conflicting certs ${a.conflicts.join('/')}`,
|
||
pass: passA });
|
||
|
||
// Part B: 4/2. The 4 side holds 66.7% of total and should keep locking once the 2 silent keys decay out of the
|
||
// active denominator (presence window); the 2 side (33%) must not lock.
|
||
const b = await partition(['p0', 'p1', 'p2', 'p3'], ['q0', 'q1'], 'B(4/2)');
|
||
const passB = b.maxNew0 > b.before0 && b.maxNew1 === b.before1 && b.conflicts.every(c => c === 0);
|
||
results.push({ name: name + '-B(4/2)', secs: warm + split + healWin,
|
||
criterion: '4/2 split: the 4 side (66.7% of total) locks (after the 2 silent keys decay from active); the 2 side (33%) does not; no conflicting certificates',
|
||
result: `4-side locked ${b.before0}->${b.maxNew0} (advanced ${b.maxNew0 > b.before0}, first new lock at ${b.breach0 ?? 'none'}s); 2-side locked ${b.before1}->${b.maxNew1} (stalled ${b.maxNew1 === b.before1}); conflicting certs ${b.conflicts.join('/')}`,
|
||
pass: passB });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Scenario 4: a block producer that includes no votes in its blocks. One dropper with 40% of blocks on node A;
|
||
// node B learns votes only through other blocks and p2p. Criterion: participation and locks unaffected; measure
|
||
// the delay. A control run without dropping gives the baseline latency.
|
||
async function s4() {
|
||
const name = 's4-vote-dropping-producer';
|
||
const secs = dur(300);
|
||
async function run(drop) {
|
||
const n0 = await new Node(0).start();
|
||
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
|
||
const miners = [];
|
||
// dropper: 40% of blocks on node A; four honest voters share the rest
|
||
miners.push(new Miner(n0, { label: 'drop', share: 0.40, bps: 6, secs, dropVotes: drop }).start());
|
||
for (const [node, label, sh] of [[n0, 'h0', 0.15], [n0, 'h1', 0.15], [n1, 'h2', 0.15], [n1, 'h3', 0.15]])
|
||
miners.push(new Miner(node, { label, share: sh, bps: 6, secs }).start());
|
||
await sleep(secs * 1000 + 3000);
|
||
const cpB = await n1.rpc.call('getFinalityCheckpoints', { last: 400 });
|
||
const lockedB = sumLocked(cpB);
|
||
const lat = minerLockLatency(miners);
|
||
for (const m of miners) await m.stop();
|
||
await stopAll();
|
||
return { lockedB, lat };
|
||
}
|
||
const withDrop = await run(true);
|
||
const control = await run(false);
|
||
const delta = (withDrop.lat != null && control.lat != null) ? withDrop.lat - control.lat : null;
|
||
const pass = withDrop.lockedB > 3 && withDrop.lat != null && withDrop.lat < 2000;
|
||
results.push({ name, secs,
|
||
criterion: 'participation and locks unaffected because other blocks carry the votes; delay measured',
|
||
result: `node B locked checkpoints ${withDrop.lockedB} with a 40% vote-dropping producer; median lock latency ${withDrop.lat} ms vs control ${control.lat} ms (delay ${delta} ms)`,
|
||
pass });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Scenario 8 (RPC half): malformed, mis-signed and replayed votes over submitFinalityVote. Criterion: each is
|
||
// rejected without a crash. The p2p message-70 half (certificates, oversized bitmaps) needs a finality-aware
|
||
// p2p probe, which is not built this session; noted in the README.
|
||
async function s8() {
|
||
const name = 's8-malformed-rpc';
|
||
const secs = dur(90);
|
||
const n0 = await new Node(0).start();
|
||
const miners = [];
|
||
for (const l of ['h0', 'h1', 'h2']) miners.push(new Miner(n0, { label: l, share: 1 / 3, bps: 3, secs: secs + 60 }).start());
|
||
await sleep(secs * 1000); // let some checkpoints form so there is a known checkpoint to attack
|
||
const { spawnSync } = await import('node:child_process');
|
||
const { MINER } = await import('./lib/net.mjs');
|
||
const out = spawnSync(MINER, ['fin-rpc-attack', n0.grpc], { encoding: 'utf8', timeout: 120000 });
|
||
const txt = (out.stdout || '') + (out.stderr || '');
|
||
const m = txt.match(/node_alive_after_each=(\d+)\/(\d+)/);
|
||
const alive = m ? (+m[1] === +m[2] && +m[2] >= 8) : false;
|
||
const control = /\[control-valid\] accepted=true/.test(txt);
|
||
const badSigRejected = /\[bad-signature\] accepted=false/.test(txt);
|
||
const wrongChainRejected = /\[wrong-chain-id\] accepted=false/.test(txt);
|
||
const replayDeduped = /\[replay\] accepted=true equivocation=false reason=already known/.test(txt);
|
||
const garbageRejected = /\[short-garbage\] rpc error/.test(txt) && /\[oversized-2mb\] rpc error/.test(txt) && /\[non-hex\] rpc error/.test(txt);
|
||
const stillUp = (await n0.rpc.call('getInfo').catch(() => null)) != null;
|
||
writeFileSync(`${TMP}/s8-fin-rpc-attack.out`, txt);
|
||
for (const mm of miners) await mm.stop();
|
||
const pass = alive && control && badSigRejected && wrongChainRejected && replayDeduped && garbageRejected && stillUp;
|
||
results.push({ name, secs,
|
||
criterion: 'wrong signatures, wrong index/checkpoint, replays, oversized and non-hex payloads rejected without a crash; node stays up (p2p-70 half needs a probe, not built)',
|
||
result: `node answered getInfo after every case=${alive}; control accepted=${control}; bad-sig rejected=${badSigRejected}; wrong-chain rejected=${wrongChainRejected}; replay deduped=${replayDeduped}; garbage/oversized/non-hex rejected=${garbageRejected}; node up after=${stillUp}; transcript ${TMP}/s8-fin-rpc-attack.out`,
|
||
pass });
|
||
await stopAll();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Scenario 5 (light): pulsed rental against the difficulty controller. A miner bursts to 50x for a short duty
|
||
// window each period; the DAA controller (Kaspa's rule on master) is in the loop via block cadence. Criterion
|
||
// (F14): the burst earns weight proportional to its share of blocks over the window; it cannot lock alone.
|
||
async function s5() {
|
||
const name = 's5-pulse';
|
||
const secs = dur(360);
|
||
const n0 = await new Node(0).start();
|
||
const miners = [];
|
||
// Five steady voters plus one burster, all base share 1/6. The burster pulses 10x for 20 s of every 120 s, so
|
||
// over the window it produces about a third of the blocks and stays below 2/3. F14: its weight share should
|
||
// equal its block share (no retarget amplification), and a third cannot lock alone (needs 2/3 + the floor).
|
||
for (const l of ['s0', 's1', 's2', 's3', 's4']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
|
||
const burst = new Miner(n0, { label: 'burst', share: 1 / 6, bps: 6, secs, pulse: '10:20:120' }).start();
|
||
miners.push(burst);
|
||
await sleep(secs * 1000 + 3000);
|
||
const w = await n0.rpc.call('getFinalityWeights', {});
|
||
const keys = (w.keys || []).filter(k => k.pubkey);
|
||
const burstHash = (burst.logText().match(/key=([0-9a-f]{64})/) || [])[1];
|
||
const burstKey = burstHash ? keys.find(k => k.keyHash === burstHash) : null;
|
||
const total = w.totalWeight || keys.reduce((s, k) => s + k.blocks, 0);
|
||
const burstBlocks = burstKey ? burstKey.blocks : null;
|
||
const burstWeightShare = burstBlocks != null ? (burstBlocks / total) : null;
|
||
// Block share from what the burster actually submitted vs all miners
|
||
const allFound = miners.map(minerFound).filter(x => x != null).reduce((a, b) => a + b, 0);
|
||
const burstFound = minerFound(burst);
|
||
const burstBlockShare = (burstFound != null && allFound > 0) ? (burstFound / allFound) : null;
|
||
const ratio = (burstWeightShare != null && burstBlockShare) ? burstWeightShare / burstBlockShare : null;
|
||
// Could it lock alone? A lock with only the burster's single vote would show votesSeen == 1. Check no lock did.
|
||
const cp = await n0.rpc.call('getFinalityCheckpoints', { last: 400 });
|
||
const soloLocks = (cp.checkpoints || []).filter(c => c.state === 'locked' && c.votesSeen < 2).length;
|
||
const conflicts = n0.grepLog(/CONFLICTING certificate/).length;
|
||
for (const m of miners) await m.stop();
|
||
const pass = ratio != null && ratio > 0.82 && ratio < 1.18 && burstWeightShare < 0.567 && soloLocks === 0 && conflicts === 0;
|
||
results.push({ name, secs,
|
||
criterion: 'the burst earns weight proportional to its block share over the window (no retarget amplification, W2/F14) and cannot lock alone',
|
||
result: burstWeightShare != null
|
||
? `burster weight share ${(burstWeightShare * 100).toFixed(1)}% vs block share ${(burstBlockShare * 100).toFixed(1)}% (ratio ${ratio.toFixed(3)}, want ~1.0 = no amplification); below the 56.7% floor so cannot lock alone; locks with <2 votes ${soloLocks}; conflicting certs ${conflicts}`
|
||
: `could not identify the burster key; conflicting certs ${conflicts}`,
|
||
pass });
|
||
await stopAll();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
const ALL = { s3, s2, s1, s6, s4, s8, s5 };
|
||
const ORDER = ['s3', 's2', 's1', 's6', 's4', 's8', 's5'];
|
||
|
||
async function main() {
|
||
assertBinaries();
|
||
const asked = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
||
const run = asked.length ? asked : ORDER;
|
||
for (const key of run) {
|
||
const fn = ALL[key];
|
||
if (!fn) { log(`unknown scenario ${key}`); continue; }
|
||
log(`=== ${key} starting (scale ${SCALE}) ===`);
|
||
try { await fn(); } catch (e) { log(`${key} threw: ${e.stack || e}`); results.push({ name: key, criterion: '(scenario errored)', result: String(e.message || e), pass: false }); await stopAll(); }
|
||
log(`=== ${key} done ===`);
|
||
}
|
||
// report
|
||
const lines = ['', 'SCENARIO RESULTS', '================'];
|
||
for (const r of results) {
|
||
lines.push(`\n[${r.pass ? 'PASS' : 'FAIL'}] ${r.name} (${r.secs || '?'} s)`);
|
||
lines.push(` criterion: ${r.criterion}`);
|
||
lines.push(` result: ${r.result}`);
|
||
}
|
||
const text = lines.join('\n');
|
||
console.log(text);
|
||
writeFileSync(`${TMP}/results.txt`, text);
|
||
writeFileSync(`${TMP}/results.json`, JSON.stringify(results, null, 2));
|
||
await stopAll();
|
||
process.exit(results.some(r => !r.pass) ? 1 : 0);
|
||
}
|
||
main();
|