|
| 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