igneum/tools/exec-attacks/scenario2_nonce.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

90 lines
4.9 KiB
JavaScript

// Scenario 2: nonce games across parallel blocks. One sender's transactions with nonces n..n+k are spread across
// several parallel blocks (same selected parent) in different orders, with duplicates in several blocks and a
// conflicting same-nonce pair. Design 1.3/1.4: exactly one execution per nonce, deterministic across all nodes,
// state roots identical. The parallel blocks are built with igneum-inject off one template so they truly share a
// parent; the honest node-1 miner then merges them and the executor orders the mergeset.
import { keccak256 } from 'viem';
import * as k from './lib/common.mjs';
const results = { scenario: '2-nonce-games', rounds: [] };
const checks = new k.Checks();
async function allNodesAgreeAt(height, timeoutMs = 30_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const roots = [];
let ok = true;
for (const c of k.clients) {
const b = await k.rpc(c, 'eth_getBlockByNumber', ['0x' + height.toString(16), false]).catch(() => null);
if (!b) { ok = false; break; }
roots.push(b.stateRoot);
}
if (ok && roots.every((r) => r === roots[0])) return { agree: true, root: roots[0], roots };
await k.sleep(500);
}
// one last read for the report
const roots = [];
for (const c of k.clients) { const b = await k.rpc(c, 'eth_getBlockByNumber', ['0x' + height.toString(16), false]).catch(() => null); roots.push(b?.stateRoot ?? null); }
return { agree: roots.every((r) => r === roots[0] && r !== null), root: roots[0], roots };
}
async function receiptCount(hashes) {
let n = 0;
for (const h of hashes) if (await k.rpc(k.node1, 'eth_getTransactionReceipt', [h])) n++;
return n;
}
async function main() {
await k.waitTip(2);
await k.fund([k.A], '200');
// ---- Round 1: ordering + duplicates across parallel blocks ----
let n = await k.nonceOf(k.A);
const txs = [];
for (let i = 0; i < 4; i++) txs.push(await k.signTx(k.A, { nonce: n + i, to: k.B.address, value: BigInt(100 + i) }));
const hashes = txs.map(keccak256);
// Parallel blocks, each a contiguous run from nonce n, with heavy duplication:
const reports = k.inject([
[txs[0], txs[1], txs[2], txs[3]],
[txs[0], txs[1]],
[txs[0]],
]);
const allAccepted = reports.every((r) => r.accepted === true);
checks.check(allAccepted, `round1: 3 parallel blocks accepted (${reports.map((r) => r.accepted).join(',')})`);
// Wait for the honest miner to merge them and execute the segment.
await k.sleep(5000);
const finalNonce = await k.nonceOf(k.A);
checks.check(finalNonce === n + 4, `round1: account nonce advanced to n+4 (n=${n}, got ${finalNonce})`);
let perNonceOk = true;
for (let i = 0; i < 4; i++) { const c = await receiptCount([hashes[i]]); if (c !== 1) { perNonceOk = false; console.error(` nonce ${n + i}: ${c} receipts`); } }
checks.check(perNonceOk, 'round1: each nonce executed exactly once (one receipt per unique tx)');
const tip1 = Number(await k.rpc(k.node1, 'eth_blockNumber'));
const agree1 = await allNodesAgreeAt(tip1);
checks.check(agree1.agree, `round1: state roots identical on all nodes at block ${tip1} (${agree1.root})`);
results.rounds.push({ round: 1, allAccepted, finalNonce, expectedNonce: n + 4, perNonceOk, tip: tip1, rootsAgree: agree1.agree, root: agree1.root });
// ---- Round 2: conflicting same-nonce pair in two parallel blocks ----
const m = await k.nonceOf(k.A);
const u0 = await k.signTx(k.A, { nonce: m, to: k.B.address, value: 1_000n });
const u0b = await k.signTx(k.A, { nonce: m, to: k.C.address, value: 9_999n }); // same nonce, different content -> different hash
const h0 = keccak256(u0), h0b = keccak256(u0b);
checks.check(h0 !== h0b, 'round2: conflicting pair has distinct hashes');
const rep2 = k.inject([[u0], [u0b]]);
checks.check(rep2.every((r) => r.accepted), `round2: both parallel blocks accepted (${rep2.map((r) => r.accepted).join(',')})`);
await k.sleep(5000);
const cnt = await receiptCount([h0, h0b]);
checks.check(cnt === 1, `round2: exactly one of the conflicting pair executed (${cnt} receipts)`);
const nonce2 = await k.nonceOf(k.A);
checks.check(nonce2 === m + 1, `round2: nonce advanced by exactly one (m=${m}, got ${nonce2})`);
const tip2 = Number(await k.rpc(k.node1, 'eth_blockNumber'));
const agree2 = await allNodesAgreeAt(tip2);
checks.check(agree2.agree, `round2: state roots identical on all nodes at block ${tip2} (${agree2.root})`);
results.rounds.push({ round: 2, bothAccepted: rep2.every((r) => r.accepted), executedCount: cnt, nonce: nonce2, expected: m + 1, rootsAgree: agree2.agree, root: agree2.root, which: (await k.rpc(k.node1, 'eth_getTransactionReceipt', [h0])) ? 'u0' : 'u0b' });
const s = checks.summary('scenario 2');
results.summary = s;
const { writeFileSync } = await import('node:fs');
writeFileSync(new URL('./results/scenario2.json', import.meta.url), JSON.stringify(results, null, 2));
process.exit(s.ok ? 0 : 1);
}
main().catch((e) => { console.error(e); process.exit(2); });