igneum/tools/exec-attacks/scenario3_pgas.mjs
igneum-josh 1d58fd8726 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

88 lines
5.3 KiB
JavaScript

// Scenario 3: proving-gas (pgas) exhaustion. Transactions that are cheap in execution gas but heavy in pgas (loops
// of the modexp precompile) are sent with growing loop counts. Design 4.3: the per-block pgas budget B_p caps
// inclusion (an over-budget transaction is skipped at execution, no receipt, the block stays valid) and no executed
// block carries more than B_p pgas. We also measure execution time per block under the attack and note whether the
// template builder pre-filters by pgas.
import { encodeFunctionData, keccak256 } from 'viem';
import { readFileSync } from 'node:fs';
import * as k from './lib/common.mjs';
const art = JSON.parse(readFileSync(new URL('./contracts/PgasBomb.json', import.meta.url)));
const results = { scenario: '3-pgas-exhaustion', deploy: null, runs: [] };
const checks = new k.Checks();
let B_p = 30_000_000;
async function statusOf(hash) { return k.rpc(k.node1, 'igneum_getTransactionStatus', [hash]); }
async function segment(n) { return k.rpc(k.node1, 'igneum_getSegment', ['0x' + n.toString(16)]); }
async function main() {
await k.waitTip(2);
await k.fund([k.A], '20');
const budgets = await k.rpc(k.node1, 'igneum_getBudgets');
B_p = Number(BigInt(budgets.provingGasLimit));
// Deploy the pgas bomb.
let nonce = await k.nonceOf(k.A);
const deployRaw = await k.signTx(k.A, { nonce: nonce++, to: null, data: art.bytecode, gas: 500_000n, maxFeePerGas: 2_000_000_000n });
const dsend = await k.send(k.node1, deployRaw);
const drcpt = await k.waitReceipt(dsend.hash, 30_000);
checks.check(drcpt && drcpt.contractAddress, `deployed PgasBomb (${drcpt?.contractAddress})`);
const bomb = drcpt.contractAddress;
results.deploy = { address: bomb, gasUsed: drcpt && parseInt(drcpt.gasUsed, 16) };
// Growing modexp loop counts: small ones execute (high pgas, under B_p), large ones exceed B_p and are skipped.
const counts = [1000, 3000, 6000, 9000, 14000, 20000];
let sawExecuted = false, sawSkippedByBudget = false, maxExecutedPgas = 0;
for (const nIter of counts) {
const data = encodeFunctionData({ abi: art.abi, functionName: 'modexpLoop', args: [BigInt(nIter)] });
const raw = await k.signTx(k.A, { nonce: nonce++, to: bomb, data, gas: 29_000_000n, maxFeePerGas: 2_000_000_000n });
const hash = keccak256(raw);
const s = await k.send(k.node1, raw);
if (s.error) { results.runs.push({ nIter, mempoolError: s.error }); continue; }
// Wait for it to land (executed receipt) or be included-and-skipped.
let rec = null, st = null;
const start = Date.now();
while (Date.now() - start < 30_000) {
rec = await k.receiptOf(hash);
st = await statusOf(hash);
if (rec || (st && st.includedIn && st.includedIn.length)) break;
await k.sleep(400);
}
const inc = st?.includedIn?.[0];
let blockNum = rec ? parseInt(rec.blockNumber, 16) : inc?.chainBlockNumber ? parseInt(inc.chainBlockNumber, 16) : null;
let execMicros = null, blockPgas = null;
if (blockNum != null) { const seg = await segment(blockNum); execMicros = parseInt(seg.executionMicros, 16); blockPgas = parseInt(seg.pgasUsed, 16); }
if (rec) {
const pgas = parseInt(rec.pgasUsed ?? (await statusOf(hash)).pgasUsed ?? '0x0', 16);
const pg = rec.igneum ? parseInt(rec.igneum.pgasUsed, 16) : pgas;
sawExecuted = true; maxExecutedPgas = Math.max(maxExecutedPgas, pg);
results.runs.push({ nIter, outcome: 'executed', pgasUsed: pg, gasUsed: parseInt(rec.gasUsed, 16), blockNum, blockPgas, execMicros });
} else {
const reason = inc?.skipReason ?? 'not-included';
const budgetSkip = /BlockProvingBudget/i.test(reason);
if (budgetSkip) sawSkippedByBudget = true;
results.runs.push({ nIter, outcome: 'skipped', reason, blockNum, blockPgas, execMicros });
}
}
checks.check(sawExecuted, 'at least one pgas-heavy transaction executed under the budget');
checks.check(sawSkippedByBudget, 'the per-block pgas budget caps inclusion: a heavy transaction is skipped with BlockProvingBudget');
const executedBlocks = results.runs.filter((r) => r.outcome === 'executed' && r.blockPgas != null);
const overBudgetBlock = executedBlocks.find((r) => r.blockPgas > B_p);
checks.check(!overBudgetBlock, `no executed block carries more than B_p pgas (B_p=${B_p}, max executed block pgas=${Math.max(0, ...executedBlocks.map((r) => r.blockPgas))})`);
// Execution time per block under the attack, for the bench log.
const micros = results.runs.map((r) => r.execMicros).filter((x) => x != null);
results.executionMicros = { perRun: results.runs.map((r) => ({ nIter: r.nIter, outcome: r.outcome, execMicros: r.execMicros, blockPgas: r.blockPgas })), max: Math.max(0, ...micros) };
results.maxExecutedPgas = maxExecutedPgas;
results.B_p = B_p;
// Observation: whether a skipped (over-budget) transaction still forced full native execution for no fee.
results.observation = sawSkippedByBudget ? 'an over-budget transaction is executed natively in full, then skipped and charged no fee (free computation for the attacker, every node pays)' : null;
const s = checks.summary('scenario 3');
results.summary = s;
const { writeFileSync } = await import('node:fs');
writeFileSync(new URL('./results/scenario3.json', import.meta.url), JSON.stringify(results, null, 2));
process.exit(s.ok ? 0 : 1);
}
main().catch((e) => { console.error(e); process.exit(2); });