Skip to content

Commit 669cc35

Browse files
committed
feat(benchmark): add EXTCODESIZE bytecode size benchmark for cold access testing (ethereum#1961)
1 parent 76f1397 commit 669cc35

1 file changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
r"""
2+
Test EXTCODESIZE with parametrized bytecode sizes using CREATE2 factory.
3+
4+
This benchmark measures the performance impact of `EXTCODESIZE` operations
5+
on contracts of varying sizes (0.5KB to 24KB).
6+
It stresses client state loading by maximizing **cold** EXTCODESIZE calls.
7+
8+
Designed for execute mode only - contracts must be pre-deployed.
9+
10+
## Gas-Based Loop Strategy
11+
12+
The attack contract uses a gas-based loop exit (per Jochem's suggestion):
13+
1. Reads current salt from storage slot 0
14+
2. Loops while gas > 50K, calling EXTCODESIZE on CREATE2 addresses
15+
3. Saves final salt to storage slot 0 when exiting
16+
4. Next TX automatically resumes from where previous left off
17+
18+
This eliminates manual gas calculations - the contract self-regulates.
19+
20+
## Test Block Structure
21+
22+
┌───────────────────────────────────────────────────────────────┐
23+
│ Test Block │
24+
├───────────────────────────────────────────────────────────────┤
25+
│ TX1: Attack (~16M gas) │
26+
│ └─> Loops EXTCODESIZE until gas < 50K, saves salt │
27+
│ │
28+
│ TX2: Attack (~16M gas) │
29+
│ └─> Resumes from TX1's salt, continues looping │
30+
│ │
31+
│ TX3: Attack (~16M gas) │
32+
│ └─> Resumes from TX2's salt, continues looping │
33+
└───────────────────────────────────────────────────────────────┘
34+
35+
Post-state verification checks attack contract's slot 1 for expected size.
36+
37+
### Execute a Single Size
38+
39+
```bash
40+
uv run execute remote \\
41+
--fork Osaka \\
42+
--rpc-endpoint http://127.0.0.1:8545 \\
43+
--rpc-seed-key <SEED_KEY> \\
44+
--rpc-chain-id 1337 \\
45+
--address-stubs tests/benchmark/stateful/bloatnet/stubs.json \\
46+
-- -m stateful --gas-benchmark-values 60 \\
47+
tests/benchmark/stateful/bloatnet/test_extcodesize_bytecode_sizes.py \\
48+
-k '24KB' -v
49+
```
50+
51+
### Execute All Sizes
52+
53+
```bash
54+
uv run execute remote \\
55+
--fork Osaka \\
56+
--rpc-endpoint http://127.0.0.1:8545 \\
57+
--rpc-seed-key <SEED_KEY> \\
58+
--rpc-chain-id 1337 \\
59+
--address-stubs tests/benchmark/stateful/bloatnet/stubs.json \\
60+
-- -m stateful --gas-benchmark-values 60 \\
61+
tests/benchmark/stateful/bloatnet/test_extcodesize_bytecode_sizes.py -v
62+
```
63+
"""
64+
65+
import pytest
66+
from execution_testing import (
67+
Account,
68+
Address,
69+
Alloc,
70+
Block,
71+
BlockchainTestFiller,
72+
Bytecode,
73+
Conditional,
74+
Op,
75+
Storage,
76+
Transaction,
77+
While,
78+
)
79+
80+
REFERENCE_SPEC_GIT_PATH = "DUMMY/bloatnet.md"
81+
REFERENCE_SPEC_VERSION = "1.0"
82+
83+
84+
def get_factory_stub_name(size_kb: float) -> str:
85+
"""Generate stub name for factory based on size."""
86+
if size_kb == 0.5:
87+
return "bloatnet_factory_0_5kb"
88+
elif size_kb == 1.0:
89+
return "bloatnet_factory_1kb"
90+
elif size_kb == 2.0:
91+
return "bloatnet_factory_2kb"
92+
elif size_kb == 5.0:
93+
return "bloatnet_factory_5kb"
94+
elif size_kb == 10.0:
95+
return "bloatnet_factory_10kb"
96+
elif size_kb == 24.0:
97+
return "bloatnet_factory_24kb"
98+
else:
99+
raise ValueError(f"Unsupported size: {size_kb}KB")
100+
101+
102+
def build_attack_contract(factory_address: Address) -> Bytecode:
103+
"""
104+
Benchmark EXTCODESIZE calls with gas-based loop exit.
105+
106+
Storage Layout:
107+
- Slot 0: current salt (persists across transactions)
108+
- Slot 1: last EXTCODESIZE result (for verification)
109+
110+
CREATE2 Memory Layout (85 bytes from offset 11):
111+
- MEM[11] = 0xFF prefix
112+
- MEM[12-31] = factory address (20 bytes)
113+
- MEM[32-63] = salt (32 bytes)
114+
- MEM[64-95] = init_code_hash (32 bytes)
115+
"""
116+
gas_reserve = 50_000 # Reserve for 2x SSTORE + cleanup
117+
118+
return (
119+
# Call factory.getConfig() -> (num_deployed, init_code_hash)
120+
Conditional(
121+
condition=Op.STATICCALL(
122+
gas=Op.GAS,
123+
address=factory_address,
124+
args_offset=0,
125+
args_size=0,
126+
ret_offset=96, # MEM[96]=num_deployed, MEM[128]=init_code_hash
127+
ret_size=64,
128+
),
129+
if_false=Op.REVERT(0, 0),
130+
)
131+
# Setup CREATE2 memory: keccak256(0xFF ++ factory ++ salt ++ hash)
132+
+ Op.MSTORE(0, factory_address)
133+
+ Op.MSTORE8(11, 0xFF)
134+
+ Op.MSTORE(32, Op.SLOAD(0)) # Load salt directly to memory
135+
+ Op.MSTORE(64, Op.MLOAD(128)) # init_code_hash
136+
+ Op.MSTORE(160, 0) # Initialize last_size
137+
+ While(
138+
body=(
139+
Op.MSTORE(160, Op.EXTCODESIZE(Op.SHA3(11, 85)))
140+
+ Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1))
141+
),
142+
condition=(
143+
Op.AND(
144+
Op.GT(Op.GAS, gas_reserve),
145+
Op.GT(Op.MLOAD(96), Op.MLOAD(32)), # num_deployed > salt
146+
)
147+
),
148+
)
149+
+ Op.SSTORE(0, Op.MLOAD(32)) # Save final salt
150+
+ Op.SSTORE(1, Op.MLOAD(160)) # Save last result
151+
+ Op.STOP
152+
)
153+
154+
155+
@pytest.mark.parametrize(
156+
"bytecode_size_kb",
157+
[0.5, 1.0, 2.0, 5.0, 10.0, 24.0],
158+
ids=lambda size: f"{size}KB",
159+
)
160+
@pytest.mark.valid_from("Prague")
161+
def test_extcodesize_bytecode_sizes(
162+
blockchain_test: BlockchainTestFiller,
163+
pre: Alloc,
164+
bytecode_size_kb: float,
165+
gas_benchmark_value: int,
166+
tx_gas_limit: int,
167+
) -> None:
168+
"""
169+
Execute EXTCODESIZE benchmark against pre-deployed contracts.
170+
171+
Uses a gas-based loop exit strategy:
172+
1. Attack contract reads/writes salt from storage slot 0
173+
2. Loop exits when gas < 50K, saves salt for next TX
174+
3. Each TX automatically resumes from where previous left off
175+
176+
Post-state verifies that the attack contract's slot 1 contains the
177+
expected bytecode size (last EXTCODESIZE result).
178+
"""
179+
expected_size_bytes = int(bytecode_size_kb * 1024)
180+
181+
# Get factory stub name for this size
182+
factory_stub = get_factory_stub_name(bytecode_size_kb)
183+
184+
# Deploy factory stub (address comes from stub file)
185+
factory_address = pre.deploy_contract(
186+
code=Bytecode(), # Empty bytecode - address from stub
187+
stub=factory_stub,
188+
)
189+
190+
# Build and deploy the attack contract
191+
attack_code = build_attack_contract(factory_address)
192+
attack_address = pre.deploy_contract(code=attack_code)
193+
194+
# Calculate how many transactions we need to fill the block
195+
num_attack_txs = gas_benchmark_value // tx_gas_limit
196+
if num_attack_txs == 0:
197+
num_attack_txs = 1
198+
199+
# Fund the sender
200+
sender = pre.fund_eoa()
201+
202+
# Build transactions
203+
txs = []
204+
205+
# Attack transactions: all identical, no calldata needed
206+
for _ in range(num_attack_txs):
207+
attack_tx = Transaction(
208+
gas_limit=tx_gas_limit,
209+
to=attack_address,
210+
sender=sender,
211+
)
212+
txs.append(attack_tx)
213+
214+
# Create block with all transactions
215+
block = Block(txs=txs)
216+
217+
# Post-state verification:
218+
# Attack contract slot 1 = expected size (last EXTCODESIZE result)
219+
# Slot 0 can be any value (final salt depends on gas used)
220+
attack_storage = Storage({1: expected_size_bytes}) # type: ignore[dict-item]
221+
attack_storage.set_expect_any(0)
222+
223+
post = {
224+
attack_address: Account(storage=attack_storage),
225+
}
226+
227+
blockchain_test(
228+
pre=pre,
229+
post=post,
230+
blocks=[block],
231+
)

0 commit comments

Comments
 (0)