// Scenario 1: malformed and boundary transactions, over eth_sendRawTransaction and inside blocks a hostile miner // includes directly (igneum-inject). Design 1.5: state-free faults make the block invalid; state-dependent faults // skip the transaction with no receipt and no fee; the node never panics; memory stays bounded. // // For each case we assert the mempool path (eth_sendRawTransaction) AND the direct-inclusion path (a hostile block) // behave as the design says, then confirm the node is still producing blocks and its RSS has not run away. import { toRlp, parseEther } from 'viem'; import * as k from './lib/common.mjs'; const results = { scenario: '1-malformed-and-boundary', cases: [] }; const checks = new k.Checks(); // Minimal big-endian hex for an RLP integer field ('0x' for zero). function int(n) { n = BigInt(n); if (n === 0n) return '0x'; let h = n.toString(16); if (h.length % 2) h = '0' + h; return '0x' + h; } // A type-2 transaction with an attacker-chosen signature (yParity, r, s). s=0 is an invalid signature. function type2WithSig({ nonce, maxPrio, maxFee, gas, to, value = 0n, data = '0x' }, yParity, r, s) { const body = toRlp([int(k.CHAIN_ID), int(nonce), int(maxPrio), int(maxFee), int(gas), to, int(value), data, [], int(yParity), int(r), int(s)]); return '0x02' + body.slice(2); } async function expectBlockInvalid(name, raw) { // Mempool should reject with an error (not a crash); the hostile block should be rejected by body validation. const m = await k.send(k.node1, raw); const rep = k.inject([[raw]])[0]; const mempoolRejected = m.hash === null; const blockRejected = rep.accepted === false; checks.check(mempoolRejected, `${name}: mempool rejects (error: ${m.error ?? 'none'})`); checks.check(blockRejected, `${name}: hostile block invalid (${rep.reject ?? rep.error ?? 'accepted!'})`); results.cases.push({ name, class: 'state-free', mempoolRejected, blockRejected, mempoolError: m.error, blockReject: rep.reject ?? rep.error }); } async function expectSkipped(name, rawList, hashesToCheck) { // A hostile block with these txs must be ACCEPTED, but each named transaction gets no receipt (skipped). const rep = k.inject([rawList])[0]; const accepted = rep.accepted === true; checks.check(accepted, `${name}: hostile block accepted (${rep.reject ?? rep.error ?? 'ok'})`); await k.sleep(1500); let allReceiptless = true; for (const h of hashesToCheck) { const r = await k.receiptOf(h); const st = await k.rpc(k.node1, 'igneum_getTransactionStatus', [h]); const receiptless = r === null && (!st || st.executed === false); if (!receiptless) allReceiptless = false; } checks.check(allReceiptless, `${name}: skipped tx has no receipt, not executed`); results.cases.push({ name, class: 'state-dependent', accepted, allReceiptless }); } async function main() { await k.waitTip(2); await k.fund([k.A, k.B, k.C, k.D]); const rssBefore = k.nodeRssKib(); // ---- state-free faults: block must be invalid ---- await expectBlockInvalid('bad-rlp', '0x02deadbeef'); await expectBlockInvalid('unsupported-type-3-blob', '0x03c0'); await expectBlockInvalid('wrong-chain-id', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, chainId: 4461 })); await expectBlockInvalid('intrinsic-gas-above-limit', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, data: '0x' + 'ab'.repeat(5000), gas: 21000n })); await expectBlockInvalid('initcode-too-large', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: null, data: '0x' + '60'.repeat(60000), gas: 20_000_000n })); await expectBlockInvalid('invalid-signature-s-zero', type2WithSig({ nonce: 0, maxPrio: 1_000_000_000n, maxFee: 2_000_000_000n, gas: 21000n, to: k.B.address, value: 1n }, 0, 1n, 0n)); // Single gas limit above the block limit B_e: the hostile block is invalid (sum of gas limits > B_e, state-free). // The mempool, however, has no gas-limit bound in pool.add, so it ADMITS such a transaction; it can never be // selected (pool.select breaks on it) nor form a valid block. Recorded as an observation, not a consensus fault. { const raw = await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, gas: 31_000_000n, maxFeePerGas: 2_000_000_000n }); const m = await k.send(k.node1, raw); const rep = k.inject([[raw]])[0]; checks.check(rep.accepted === false, `single-gas-limit-over-block: hostile block invalid (${rep.reject ?? rep.error})`); results.cases.push({ name: 'single-gas-limit-over-block', class: 'state-free', blockRejected: rep.accepted === false, mempoolAdmitted: m.hash !== null, observation: m.hash !== null ? 'mempool admits a tx with gas_limit > B_e (pool.add has no gas-limit bound)' : null }); } // Duplicate hash in one block, and same sender+nonce twice in one block (nonce not contiguous): block-level, // so only the hostile-inclusion path applies (the mempool never offers these together). { const raw = await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, value: 1n }); const rep = k.inject([[raw, raw]])[0]; checks.check(rep.accepted === false, `duplicate-hash-in-block: invalid (${rep.reject ?? rep.error})`); results.cases.push({ name: 'duplicate-hash-in-block', class: 'state-free', blockReject: rep.reject ?? rep.error }); } { const n = await k.nonceOf(k.A); const a = await k.signTx(k.A, { nonce: n, to: k.B.address, value: 1n }); const b = await k.signTx(k.A, { nonce: n + 2, to: k.B.address, value: 2n }); // gap -> not contiguous const rep = k.inject([[a, b]])[0]; checks.check(rep.accepted === false, `nonces-not-contiguous-in-block: invalid (${rep.reject ?? rep.error})`); results.cases.push({ name: 'nonces-not-contiguous-in-block', class: 'state-free', blockReject: rep.reject ?? rep.error }); } const { keccak256 } = await import('viem'); // ---- boundary: gas limit exactly at the block limit is NOT a body fault; the hostile block is accepted and the // transaction executes (actual gas used is 21000). Injected so the mempool's own checks do not get in the way. { const raw = await k.signTx(k.B, { nonce: await k.nonceOf(k.B), to: k.C.address, value: 1n, gas: 30_000_000n, maxFeePerGas: 2_000_000_000n }); const rep = k.inject([[raw]])[0]; checks.check(rep.accepted === true, `gas-limit-at-block-limit: block accepted, not a body fault (${rep.reject ?? rep.error ?? 'accepted'})`); const r = await k.waitReceipt(keccak256(raw), 20_000); checks.check(r !== null, 'gas-limit-at-block-limit: transaction executed (has receipt)'); results.cases.push({ name: 'gas-limit-at-block-limit', class: 'boundary', accepted: rep.accepted, executed: r !== null }); } // ---- state-dependent faults: block accepted, tx skipped, no receipt. Each uses a distinct sender so an earlier // case never leaves a queued transaction at the nonce a later case reuses. ---- { const n = await k.nonceOf(k.C); const raw = await k.signTx(k.C, { nonce: n + 50, to: k.B.address, value: 1n }); // nonce far ahead await expectSkipped('nonce-far-ahead', [raw], [keccak256(raw)]); } { // Reuse a nonce that has already executed: land one through the mempool, then inject the same nonce again. const n = await k.nonceOf(k.D); const first = await k.signTx(k.D, { nonce: n, to: k.B.address, value: 1n }); const fr = await k.send(k.node1, first); const got = await k.waitReceipt(fr.hash); checks.check(got !== null, 'nonce-reuse: the first copy executed through the mempool'); const reuse = await k.signTx(k.D, { nonce: n, to: k.C.address, value: 7n }); await expectSkipped('nonce-reuse', [reuse], [keccak256(reuse)]); } { const n = await k.nonceOf(k.C); const zeroFee = await k.signTx(k.C, { nonce: n, to: k.B.address, value: 1n, maxFeePerGas: 0n, maxPriorityFeePerGas: 0n }); await expectSkipped('zero-fee-below-base', [zeroFee], [keccak256(zeroFee)]); } { const n = await k.nonceOf(k.C); const broke = await k.signTx(k.C, { nonce: n, to: k.B.address, value: parseEther('1000000'), gas: 21000n }); // more than balance await expectSkipped('insufficient-funds', [broke], [keccak256(broke)]); } { // Max fee at a u128-scale extreme: the wei budget exceeds the balance, so the tx is skipped, no crash. const n = await k.nonceOf(k.C); const big = await k.signTx(k.C, { nonce: n, to: k.B.address, value: 1n, maxFeePerGas: (1n << 120n), maxPriorityFeePerGas: 1n, gas: 21000n }); await expectSkipped('max-fee-extreme', [big], [keccak256(big)]); } // ---- liveness and memory after the barrage ---- const tipBefore = await k.rpc(k.node1, 'eth_blockNumber'); await k.sleep(4000); const tipAfter = await k.rpc(k.node1, 'eth_blockNumber'); checks.check(BigInt(tipAfter) > BigInt(tipBefore), `node still produces blocks after the barrage (${tipBefore} -> ${tipAfter})`); const rssAfter = k.nodeRssKib(); const maxAfter = Math.max(0, ...rssAfter); const maxBefore = Math.max(1, ...rssBefore); const growth = maxAfter / maxBefore; checks.check(growth < 1.5, `node RSS bounded (max ${maxBefore} -> ${maxAfter} KiB, x${growth.toFixed(2)})`); results.rss = { beforeKib: rssBefore, afterKib: rssAfter }; const s = checks.summary('scenario 1'); results.summary = s; const { writeFileSync } = await import('node:fs'); writeFileSync(new URL('./results/scenario1.json', import.meta.url), JSON.stringify(results, null, 2)); process.exit(s.ok ? 0 : 1); } main().catch((e) => { console.error(e); process.exit(2); });