// 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); });