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>
122 lines
7.4 KiB
JavaScript
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); });
|