1. Goals
Benchmarkoor measures isolated client re-execution of a single crafted block on a synthetic state. That is the right tool for pricing individual operations, and it is how we set the EIP-7904, EIP-8038 and EIP-2780 numbers. It cannot tell us whether a client stays a healthy network participant under sustained load.
This plan covers four gaps:
- Block building. Can each client build a worst case block at the target gas limit in time?
- CL-driven validation. Can clients validate blocks through the Engine API without missing attestation deadlines?
- Syncing. Can fresh nodes catch up while the chain stays under load?
- Mempool and propagation. Can transaction gossip, txpools and block propagation keep up?
Two conditions make the results meaningful, and both are missing from our current setup:
- Mainnet sized state. Our worst numbers come from cold, uncached state access. On a small synthetic state the caches hide the cost. The
test_account_access result (8.6 Mgas/s on erigon) only appears with NO_CACHE against large code.
- Continuous high load. Test whether repeated heavy blocks cause disk saturation, growing queues or missed duties.
Use existing mainnet targets for sustained access tests and small batches of crafted contracts for single block worst cases (see 4.2).
Scope. Test the calibrated repricing bundle in a live network; deriving new gas costs is out of scope.
2. Metrics
2.1 Validator health
| Metric |
Notes |
| Missed slots |
Proposals missed under load. |
| Orphaned and reorged blocks |
Chain instability under load. |
| Missed and late attestations |
Validators could not validate the head in time to attest. |
| Participation rate |
Share of validators attesting. |
| Finalized and justified lag |
Whether the chain is keeping up at all. |
| Invalid or rejected payloads |
Record rejection reasons and client disagreements. |
2.2 Resource load
| Metric |
Notes |
| CPU per EL and CL container |
The basic saturation check. |
| Memory, including growth over the run |
Track peak use and growth over time. |
| Disk IO (read and write throughput, IOPS, queue depth) |
Track saturation during cold state access. |
| Datadir growth |
State growth during each run. |
engine_newPayload latency |
Re-execution time as the CL actually sees it. |
engine_getPayload latency |
Payload retrieval latency; also record build duration and gas used. |
engine_forkchoiceUpdated latency |
Fork choice latency under load. |
| Txpool size and eviction |
Pending, queued and discarded transactions. |
2.3 Propagation and sync
| Metric |
Notes |
| Transaction and block propagation delay |
Time from broadcast to receipt across nodes. |
| Block gas used / gas limit |
Confirm the intended load reaches the chain. |
| Time to synced |
From fresh-node startup to catching up with head, per client and sync mode. |
| Sync head lag |
Whether a syncing node catches up and stays caught up. |
3. Strategy
The core loop: fork mainnet, move it to Glamsterdam, then hold it under continuous spam, with the deployment-bound worst cases run as separate single block tests.
- Fork mainnet. Sync a small set of EL and CL pairs to mainnet head.
- Transition to Glamsterdam. Schedule the fork one or two epochs after shadow fork genesis.
- Pre-position the crafted contracts. Deploy the max code size, JUMPDEST dense contracts in a setup phase, using a modified CL for speed.
- Sustained spam. Use spamoor against random existing mainnet accounts for account reads, writes and ETH transfers, and XEN, top ERC-20s and random contracts for storage reads and writes. Deployment and create/selfdestruct scenarios create their own targets.
- Single block worst cases. Against the contracts from step 3, submit one crafted block per worst case workload and measure build duration,
engine_getPayload and engine_newPayload for that slot.
- Test sync against the loaded chain. With the sustained spam of step 4 running, repeatedly launch fresh EL and CL pairs and measure time to synced per client.
4. Scenarios
4.1 What to test, and why
Priority follows the devnet-8 stateful benchmark results (suite 7eec9e8fcd40b131, 532 non baseline tests, four clients, against a 100 Mgas/s anchor). Clients below the anchor: reth 32 tests (6.0 percent), nethermind 60 (11.3), geth 100 (18.8), erigon 129 (24.2).
The Mode column records whether the workload can run as sustained load or only as a single block, per 4.2.
| Priority |
Workload |
Mode |
Benchmark evidence |
| P0 |
Account reads via cold account access |
Sustained, random mainnet accounts |
test_account_access (64 KiB, JUMPDEST/DIFF_MAX, NO_CACHE): 8.6 (erigon), 49.6 (geth), 59.5 (nethermind), 63.5 (reth) Mgas/s. 20 tests below the anchor on all clients. |
| P0 |
Large code loading at max code size |
Single block only |
Same 64 KiB tests: worst case 8.6 Mgas/s on erigon. Requires crafted contracts. |
| P0 |
ETH transfers to on-chain receivers |
Sustained, random mainnet accounts |
test_ether_transfers_onchain_receivers (64 KiB): worst case 29.9 Mgas/s on erigon; below the anchor in 68/72 configs. The 64 KiB variant is single block only. |
| P0 |
Account writes via value bearing cold calls |
Sustained, random mainnet accounts |
GAS_COLD_ACCOUNT_WRITE: 195,132 needed on erigon versus 6,700 current gas. Frozen bundle raised it from 8,000 to 9,000. |
| P1 |
Storage reads |
Sustained, XEN, top ERC-20s and random contracts |
test_sload_bloated (10 GB account, NO_CACHE): 81.4 Mgas/s on geth; 250–640 on other clients. |
| P1 |
Storage writes |
Sustained, XEN, top ERC-20s and random contracts |
test_sstore_bloated: 81.6 Mgas/s on geth; 89.3–97.1 on erigon. |
| P2 |
Contract deployment at 24 KiB and 64 KiB |
Sustained |
Expensive under EIP-8037 state gas. Measure during setup for the single block tests. |
| P2 |
Create and selfdestruct patterns |
Sustained |
test_selfdestruct_created: worst case 256 Mgas/s, above the anchor. Needs further testing. |
| P2 |
Worst case compute |
Sustained |
2,031 of 2,038 compute tests clear the anchor. |
4.2 What can and cannot be sustained
Cold access to crafted 64 KiB contracts is tested one block at a time:
- Cache reuse hides disk costs. Rotate accounts and distinct code, and check that accesses still cause disk reads.
- Fresh code changes the state size. Assuming 60M gas blocks, 30,000 gas per target and 12-second slots, each block uses about 2,000 targets: 125 MiB of code per block, or 37 GiB per hour.
- Preparation is expensive. At roughly 14M gas per deployment, before EIP-8037 increases, one test block needs about 500 deployment blocks. An hour of load needs weeks of preparation at full capacity, so we prioritize single block tests.
Sample random accounts from the forked mainnet state for sustained account reads, writes and ETH transfers. For storage reads and writes, use XEN, top ERC-20s and random contracts, with calls that exercise the intended slots. Check target counts and disk reads before choosing the run length. The large-code and bloated-state benchmarks motivate these tests; random targets may be less demanding.
For the 64 KiB worst cases, deploy only enough distinct contracts for one block (about 2,000), then measure build duration, engine_getPayload and engine_newPayload. This tests a single bad slot, not sustained behaviour.
4.3 Spamoor coverage
Assessed against Spamoor's scenarios and configs at commit 9224b73. Two knobs matter throughout: gas_limit: 0 makes a call burn all remaining block gas, and total_count bounds a spammer to a fixed number of transactions. Together they cover the single block cases of 4.2 as well as sustained load, so the gaps below are about targets, not about throughput control.
The gap in almost every P0 row is the same one: no scenario accepts a list of addresses. calltx call arguments support only {txid}, {random}, {random:N} and {randomaddr}, and {randomaddr} is a random address, so it is an empty account rather than an existing mainnet one. Random new addresses exercise state growth (already covered by statebloat-basics.yaml); our P0 rows need cold access to accounts that are already in the mainnet trie.
| 4.1 workload |
Mode |
Status |
Available tooling / gap |
| P0 Account reads via cold account access |
Sustained |
Covered for synthetic targets |
max-contract-read.yaml pairs factorydeploytx (CREATE2 deploys at deterministic addresses) with calltx calling AttackController.extcodehashAttack, which loops EXTCODEHASH over pseudo-random CREATE2 addresses until gas runs out. Targets are freshly deployed, so warmer on disk than untouched mainnet accounts. Mainnet sampling needs an address-list input. |
| P0 Large code loading at max code size |
Single block |
Partial |
Same config's lastByteAttack does EXTCODESIZE plus EXTCODECOPY(size-32, 32) per target, forcing a full code load. Its fixture init code returns 24 KiB (0x6000) of mostly zeros; 64 KiB and JUMPDEST dense variants are new fixtures, accepted by factorydeploytx.init_code or deploytx --bytecodes-file. |
| P0 ETH transfers to on-chain receivers |
Sustained |
Gap |
eoatx --to takes one fixed address and --random-target sends to random new addresses. Existing receivers need a sampled receiver list, or a geastx contract that CALLs an embedded address list (contract calls, not EOA transfers). |
| P0 Account writes via value bearing cold calls |
Sustained |
Gap |
Same address-list gap. calltx --amount with --random-target gives cold writes to empty accounts, which is the EIP-8037 state growth case rather than the GAS_COLD_ACCOUNT_WRITE case; decide whether we want both. |
| P1 Storage reads |
Sustained |
Gap |
No scenario loops SLOAD over an existing contract's slots. erc20_bloater writes two slots per sequential address but has no read path, and calling a view function such as balanceOf through calltx is one SLOAD per transaction. Needs an AttackController style read loop plus slot layouts for the chosen targets. |
| P1 Storage writes |
Sustained |
Partial |
max-storage-update-write.yaml and storagespam fill a block with SSTOREs, but to sequential new slots in one contract. xentoken --xen-address can point at the real mainnet XEN contract on the fork, giving writes into genuinely bloated storage. Missing: updates of random existing slots, the test_sstore_bloated shape. |
| P2 Contract deployment at 24 KiB and 64 KiB |
Sustained |
Tooling covered, fixtures needed |
deploytx --bytecodes-file and factorydeploytx.init_code both take arbitrary init code and run sustained. Needs size boundary and JUMPDEST dense fixtures; 64 KiB deployments depend on EIP-7907 being active on the fork. |
| P2 Create and selfdestruct patterns |
Sustained |
Covered |
deploy-destruct: DeployTestChild's constructor creates nested children via CREATE and CREATE2 and destroys about 40 percent of them inside the same transaction, which is the EIP-6780 same-transaction pattern; the rest are destroyed in later transactions. Branch probabilities are hardcoded, so patterns are mixed rather than isolated. |
| P2 Worst case compute |
Sustained |
Covered |
gasburnertx, geastx, test-opcodes.yaml and the perf-precompile-* configs. |
Replaying our own benchmark fixtures. The replay-eest scenario runs EEST fixtures converted by spamoor-utils convert-eest, deploying the fixture pre-state and sending its transactions with post-execution state checks. Our benchmark blocks are EEST based, so the devnet-8 suite (7eec9e8fcd40b131) can be replayed against the shadow fork directly, at -t 1 roughly one worst case block per slot. This is the cheapest path to step 5 of section 3 and it reproduces exactly the tests the priorities in 4.1 come from. Caveat: the fixtures build their own small synthetic pre-state, so the replayed transactions do not get mainnet sized cold access unless we also point them at forked state.
Work needed:
- Collect targets. Sample existing mainnet accounts. Select XEN, top ERC-20s and random contracts, and identify calls and slots for storage tests.
- Add an address-list input. One target-list mechanism unblocks three P0 rows: either a
{targetaddr} style placeholder fed from a file in calltx and eoatx, or a controller contract holding sampled addresses that loops account reads and value bearing calls over them. Check that calls perform the intended reads and writes.
- Add a bloated-slot read loop. An
AttackController variant doing random SLOADs over a slot range, pointed at erc20_bloater output and at real mainnet contracts.
- Prepare fixtures. Build the 24 KiB and 64 KiB JUMPDEST dense init code for
factorydeploytx, and convert the devnet-8 EEST fixtures for replay-eest. Prepare balances and permissions for storage calls; measure deployment during setup.
1. Goals
Benchmarkoor measures isolated client re-execution of a single crafted block on a synthetic state. That is the right tool for pricing individual operations, and it is how we set the EIP-7904, EIP-8038 and EIP-2780 numbers. It cannot tell us whether a client stays a healthy network participant under sustained load.
This plan covers four gaps:
Two conditions make the results meaningful, and both are missing from our current setup:
test_account_accessresult (8.6 Mgas/s on erigon) only appears withNO_CACHEagainst large code.Use existing mainnet targets for sustained access tests and small batches of crafted contracts for single block worst cases (see 4.2).
Scope. Test the calibrated repricing bundle in a live network; deriving new gas costs is out of scope.
2. Metrics
2.1 Validator health
2.2 Resource load
engine_newPayloadlatencyengine_getPayloadlatencyengine_forkchoiceUpdatedlatency2.3 Propagation and sync
3. Strategy
The core loop: fork mainnet, move it to Glamsterdam, then hold it under continuous spam, with the deployment-bound worst cases run as separate single block tests.
engine_getPayloadandengine_newPayloadfor that slot.4. Scenarios
4.1 What to test, and why
Priority follows the devnet-8 stateful benchmark results (suite
7eec9e8fcd40b131, 532 non baseline tests, four clients, against a 100 Mgas/s anchor). Clients below the anchor: reth 32 tests (6.0 percent), nethermind 60 (11.3), geth 100 (18.8), erigon 129 (24.2).The Mode column records whether the workload can run as sustained load or only as a single block, per 4.2.
test_account_access(64 KiB, JUMPDEST/DIFF_MAX,NO_CACHE): 8.6 (erigon), 49.6 (geth), 59.5 (nethermind), 63.5 (reth) Mgas/s. 20 tests below the anchor on all clients.test_ether_transfers_onchain_receivers(64 KiB): worst case 29.9 Mgas/s on erigon; below the anchor in 68/72 configs. The 64 KiB variant is single block only.GAS_COLD_ACCOUNT_WRITE: 195,132 needed on erigon versus 6,700 current gas. Frozen bundle raised it from 8,000 to 9,000.test_sload_bloated(10 GB account,NO_CACHE): 81.4 Mgas/s on geth; 250–640 on other clients.test_sstore_bloated: 81.6 Mgas/s on geth; 89.3–97.1 on erigon.test_selfdestruct_created: worst case 256 Mgas/s, above the anchor. Needs further testing.4.2 What can and cannot be sustained
Cold access to crafted 64 KiB contracts is tested one block at a time:
Sample random accounts from the forked mainnet state for sustained account reads, writes and ETH transfers. For storage reads and writes, use XEN, top ERC-20s and random contracts, with calls that exercise the intended slots. Check target counts and disk reads before choosing the run length. The large-code and bloated-state benchmarks motivate these tests; random targets may be less demanding.
For the 64 KiB worst cases, deploy only enough distinct contracts for one block (about 2,000), then measure build duration,
engine_getPayloadandengine_newPayload. This tests a single bad slot, not sustained behaviour.4.3 Spamoor coverage
Assessed against Spamoor's scenarios and configs at commit
9224b73. Two knobs matter throughout:gas_limit: 0makes a call burn all remaining block gas, andtotal_countbounds a spammer to a fixed number of transactions. Together they cover the single block cases of 4.2 as well as sustained load, so the gaps below are about targets, not about throughput control.The gap in almost every P0 row is the same one: no scenario accepts a list of addresses.
calltxcall arguments support only{txid},{random},{random:N}and{randomaddr}, and{randomaddr}is a random address, so it is an empty account rather than an existing mainnet one. Random new addresses exercise state growth (already covered bystatebloat-basics.yaml); our P0 rows need cold access to accounts that are already in the mainnet trie.max-contract-read.yamlpairsfactorydeploytx(CREATE2 deploys at deterministic addresses) withcalltxcallingAttackController.extcodehashAttack, which loops EXTCODEHASH over pseudo-random CREATE2 addresses until gas runs out. Targets are freshly deployed, so warmer on disk than untouched mainnet accounts. Mainnet sampling needs an address-list input.lastByteAttackdoes EXTCODESIZE plusEXTCODECOPY(size-32, 32)per target, forcing a full code load. Its fixture init code returns 24 KiB (0x6000) of mostly zeros; 64 KiB and JUMPDEST dense variants are new fixtures, accepted byfactorydeploytx.init_codeordeploytx--bytecodes-file.eoatx--totakes one fixed address and--random-targetsends to random new addresses. Existing receivers need a sampled receiver list, or ageastxcontract that CALLs an embedded address list (contract calls, not EOA transfers).calltx --amountwith--random-targetgives cold writes to empty accounts, which is the EIP-8037 state growth case rather than theGAS_COLD_ACCOUNT_WRITEcase; decide whether we want both.erc20_bloaterwrites two slots per sequential address but has no read path, and calling a view function such asbalanceOfthroughcalltxis one SLOAD per transaction. Needs anAttackControllerstyle read loop plus slot layouts for the chosen targets.max-storage-update-write.yamlandstoragespamfill a block with SSTOREs, but to sequential new slots in one contract.xentoken--xen-addresscan point at the real mainnet XEN contract on the fork, giving writes into genuinely bloated storage. Missing: updates of random existing slots, thetest_sstore_bloatedshape.deploytx --bytecodes-fileandfactorydeploytx.init_codeboth take arbitrary init code and run sustained. Needs size boundary and JUMPDEST dense fixtures; 64 KiB deployments depend on EIP-7907 being active on the fork.deploy-destruct:DeployTestChild's constructor creates nested children via CREATE and CREATE2 and destroys about 40 percent of them inside the same transaction, which is the EIP-6780 same-transaction pattern; the rest are destroyed in later transactions. Branch probabilities are hardcoded, so patterns are mixed rather than isolated.gasburnertx,geastx,test-opcodes.yamland theperf-precompile-*configs.Replaying our own benchmark fixtures. The
replay-eestscenario runs EEST fixtures converted byspamoor-utils convert-eest, deploying the fixture pre-state and sending its transactions with post-execution state checks. Our benchmark blocks are EEST based, so the devnet-8 suite (7eec9e8fcd40b131) can be replayed against the shadow fork directly, at-t 1roughly one worst case block per slot. This is the cheapest path to step 5 of section 3 and it reproduces exactly the tests the priorities in 4.1 come from. Caveat: the fixtures build their own small synthetic pre-state, so the replayed transactions do not get mainnet sized cold access unless we also point them at forked state.Work needed:
{targetaddr}style placeholder fed from a file incalltxandeoatx, or a controller contract holding sampled addresses that loops account reads and value bearing calls over them. Check that calls perform the intended reads and writes.AttackControllervariant doing random SLOADs over a slot range, pointed aterc20_bloateroutput and at real mainnet contracts.factorydeploytx, and convert the devnet-8 EEST fixtures forreplay-eest. Prepare balances and permissions for storage calls; measure deployment during setup.