igneum/tools/exec-attacks/scenario6_reorg.mjs
igneum-labs 72d7bff0ee exec-attacks: execution-layer attack suite (tools + bench log)
Adversarial robustness and conformance tests of the execution layer against a
throwaway 3-node simnet on ports 27600+. Six scenarios, each a runnable command
with a design-derived pass criterion and a measured result: malformed/boundary
txs, nonce games across parallel blocks, RPC fuzz, pgas exhaustion, reorgs under
execution, and developer-registry abuse. 98 checks, 0 failures, 0 node panics.

Two findings filed in the bench-log entry: the mempool admits txs with gas_limit
above B_e (low), and an over-pgas-budget tx is executed natively in full before
being skipped for no fee (medium, griefing).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 22:57:19 +00:00

122 lines
7.4 KiB
JavaScript

// Scenario 6: reorgs under execution. The network starts partitioned (P1 = node1, P2 = node2 + node3, two miners
// so P2 outweighs P1) and heals after PARTITION_SECS, forcing the lighter partition to reorg its whole chain while
// transactions flow. Design 1.2 (reorgs), 2.3 (executed can be undone), 10.2 (64-deep snapshots). Criterion: the
// state root is recomputed deterministically (all nodes agree), receipts for displaced transactions are handled as
// the design says (the displaced copy loses its receipt), and the mempool is not stuck afterwards.
import { keccak256 } from 'viem';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import * as k from './lib/common.mjs';
const SECS = Number(process.env.PARTITION_SECS ?? 10);
const [n1, n2, n3] = k.clients;
const checks = new k.Checks();
const cycle = { partitionSecs: SECS };
const bn = (c) => k.rpc(c, 'eth_blockNumber').then((x) => Number(x));
const blockAt = (c, h) => k.rpc(c, 'eth_getBlockByNumber', ['0x' + h.toString(16), false]).catch(() => null);
const budgets = (c) => k.rpc(c, 'igneum_getBudgets');
function daginfoSink(grpc) { try { return JSON.parse(k.injectCmd(['daginfo'], grpc)).sink; } catch { return null; } }
async function waitBalance(addr, min, client, ms = 60_000) {
const start = Date.now();
while (Date.now() - start < ms) { if ((await k.balanceOf(addr, client)) >= min) return true; await k.sleep(1000); }
return false;
}
async function main() {
// Wait for both partitions to be producing and clearly divergent (different hash at height 1).
const t0 = Date.now();
while (Date.now() - t0 < 60_000) { try { if ((await bn(n1)) >= 1 && (await bn(n2)) >= 1) break; } catch {} await k.sleep(500); }
const b1 = await blockAt(n1, 1), b2 = await blockAt(n2, 1);
cycle.divergentAtHeight1 = !!(b1 && b2 && b1.hash !== b2.hash);
checks.check(cycle.divergentAtHeight1, `partitions divergent (node1 h1 ${b1?.hash?.slice(0, 10)} != node2 h1 ${b2?.hash?.slice(0, 10)})`);
// Fund the displaced-tx sender on P1 (node1's miner pays the `miner` account), then execute T on node1 only.
const { parseEther } = await import('viem');
await waitBalance(k.miner.address, parseEther('2'), n1);
const sNonce = await k.nonceOf(k.miner, n1);
const T = await k.signTx(k.miner, { nonce: sNonce, to: k.A.address, value: parseEther('1') });
const Th = keccak256(T);
const sent = await k.send(n1, T);
const Trcpt = await k.waitReceipt(Th, 30_000, n1);
checks.check(Trcpt !== null, `displaced tx T executed on node1 before heal (block ${Trcpt && parseInt(Trcpt.blockNumber, 16)})`);
cycle.displacedTx = { hash: Th, executedOnNode1Block: Trcpt ? parseInt(Trcpt.blockNumber, 16) : null };
// Let the partitions diverge.
await k.sleep(SECS * 1000);
const h1 = await bn(n1), h2 = await bn(n2);
cycle.beforeHeal = { node1Height: h1, node2Height: h2 };
// Heal: connect the two partitions both ways.
k.injectCmd(['addpeer', '127.0.0.1:27621'], k.GRPC1); // node1 -> node2
k.injectCmd(['addpeer', '127.0.0.1:27611'], 'grpc://127.0.0.1:27620'); // node2 -> node1
// Wait for convergence: all three nodes report the same sink.
let converged = false, sinks = null;
const tc = Date.now();
while (Date.now() - tc < 60_000) {
const s1 = daginfoSink(k.GRPC1), s2 = daginfoSink('grpc://127.0.0.1:27620'), s3 = daginfoSink('grpc://127.0.0.1:27630');
sinks = { s1, s2, s3 };
if (s1 && s1 === s2 && s2 === s3) { converged = true; break; }
await k.sleep(1000);
}
checks.check(converged, `all three nodes converged to one sink after heal (${JSON.stringify(sinks)})`);
await k.sleep(2000);
// Reorg depth observed on each node (the executor tracks the deepest selected-chain reorg).
const [g1, g2, g3] = [await budgets(n1), await budgets(n2), await budgets(n3)];
const depths = [g1, g2, g3].map((g) => Number(BigInt(g.deepestReorg)));
cycle.deepestReorgPerNode = depths;
cycle.maxReorgDepth = Math.max(...depths);
checks.check(cycle.maxReorgDepth >= 1, `a reorg of depth >= 1 happened (per node ${depths.join(',')})`);
// Determinism: all three nodes agree on the state root at a safe common height below the tips.
const common = Math.max(0, Math.min(await bn(n1), await bn(n2), await bn(n3)) - 3);
const roots = [];
for (const c of k.clients) { const b = await blockAt(c, common); roots.push(b?.stateRoot ?? null); }
const agree = roots[0] && roots.every((r) => r === roots[0]);
cycle.stateRootAtCommon = { height: common, roots };
checks.check(agree, `state roots identical on all nodes at block ${common} after reorg (${roots[0]})`);
// Displaced-tx handling on a BlockDAG. A selected-chain reorg does not orphan merged blocks (design 1.2/1.3:
// merged blocks, blue or red, are executed in the segment that merges them). So after the reorg T must resolve to
// ONE consistent outcome on every node: either executed exactly once at a block that is canonical on each node,
// or (if its funding did not survive) no receipt on any node. A receipt that disagrees across nodes, or points to
// a non-canonical block, is the failure.
const recs = [];
for (const c of k.clients) recs.push(await k.receiptOf(Th, c));
const allNull = recs.every((r) => r === null);
const allSameBlock = recs.every((r) => r && recs[0] && r.blockHash === recs[0].blockHash && r.blockNumber === recs[0].blockNumber);
checks.check(allNull || allSameBlock, `displaced tx T resolves consistently on all nodes (${recs.map((r) => (r ? parseInt(r.blockNumber, 16) : 'null')).join(',')})`);
let canonical = true;
if (!allNull) {
for (let i = 0; i < k.clients.length; i++) {
const r = recs[i]; if (!r) { canonical = false; continue; }
const b = await blockAt(k.clients[i], parseInt(r.blockNumber, 16));
if (!b || b.hash !== r.blockHash) canonical = false;
}
checks.check(canonical, `displaced tx T receipt points to a canonical block on every node (block ${recs[0] && parseInt(recs[0].blockNumber, 16)})`);
}
cycle.displacedAfterHeal = { resolvedConsistently: allNull || allSameBlock, executedBlock: recs[0] ? parseInt(recs[0].blockNumber, 16) : null, canonicalPointer: allNull ? 'n/a (not executed)' : canonical, originalBlock: cycle.displacedTx.executedOnNode1Block };
// Mempool not stuck: a fresh transaction from a P2-funded account (node2's miner pays B) lands after the reorg.
await waitBalance(k.B.address, parseEther('1'), n1, 40_000);
const uNonce = await k.nonceOf(k.B, n1);
const U = await k.signTx(k.B, { nonce: uNonce, to: k.C.address, value: 1n });
const usent = await k.send(n1, U);
const Urcpt = await k.waitReceipt(keccak256(U), 40_000, n1);
checks.check(Urcpt !== null, `mempool not stuck: a new tx is mined after the reorg (${usent.error ?? 'ok'})`);
cycle.postReorgTxMined = Urcpt !== null;
// Append this cycle to the aggregate result file.
const path = new URL('./results/scenario6.json', import.meta.url);
const agg = existsSync(path) ? JSON.parse(readFileSync(path)) : { scenario: '6-reorg-under-execution', cycles: [], checks: { pass: 0, fail: 0 } };
cycle.summary = { pass: checks.pass, fail: checks.fail };
agg.cycles.push(cycle);
agg.checks.pass += checks.pass; agg.checks.fail += checks.fail;
agg.maxReorgDepthSeen = Math.max(agg.maxReorgDepthSeen ?? 0, cycle.maxReorgDepth);
writeFileSync(path, JSON.stringify(agg, null, 2));
checks.summary(`scenario 6 (partition ${SECS}s, reorg depth ${cycle.maxReorgDepth})`);
process.exit(checks.fail === 0 ? 0 : 1);
}
main().catch((e) => { console.error(e); process.exit(2); });