diff --git a/.gitmodules b/.gitmodules index e69de29bb2..3152990094 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "tests/benchmark/stateful/bloatnet/depth_benchmarks/.worst_case_miner"] + path = tests/benchmark/stateful/bloatnet/depth_benchmarks/.worst_case_miner + url = https://github.com/CPerezz/worst_case_miner + branch = master diff --git a/packages/testing/src/execution_testing/client_clis/clis/ethrex.py b/packages/testing/src/execution_testing/client_clis/clis/ethrex.py index e488237ea3..2a29aac68c 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/ethrex.py +++ b/packages/testing/src/execution_testing/client_clis/clis/ethrex.py @@ -11,6 +11,9 @@ class EthrexExceptionMapper(ExceptionMapper): """Ethrex exception mapper.""" mapping_substring = { + BlockException.INVALID_GASLIMIT: ( + "Gas limit changed more than allowed from the parent" + ), TransactionException.TYPE_3_TX_MAX_BLOB_GAS_ALLOWANCE_EXCEEDED: ( "Exceeded MAX_BLOB_GAS_PER_BLOCK" ), diff --git a/tests/benchmark/compute/helpers.py b/tests/benchmark/compute/helpers.py index 07d93338fc..6f52cb1582 100644 --- a/tests/benchmark/compute/helpers.py +++ b/tests/benchmark/compute/helpers.py @@ -179,27 +179,31 @@ def calculate_optimal_input_length( The optimal input length in bytes that maximizes total work. """ - gsc = fork.gas_costs() mem_exp_gas_calculator = fork.memory_expansion_gas_calculator() + precompile_call = Op.POP( + Op.STATICCALL( + gas=Op.GAS, + address=0x01, # Placeholder Address + args_offset=Op.PUSH0, + args_size=Op.PUSH0, + ret_offset=Op.PUSH0, + ret_size=Op.PUSH0, + # gas cost + address_warm=True, + ) + ) + basic_gas = precompile_call.gas_cost(fork) + max_work = 0 optimal_input_length = 0 for input_length in range(1, 1_000_000, 32): - parameters_gas = ( - gsc.G_BASE # PUSH0 = arg offset - + gsc.G_BASE # PUSH0 = arg size - + gsc.G_BASE # PUSH0 = arg size - + gsc.G_VERY_LOW # PUSH0 = arg offset - + gsc.G_VERY_LOW # PUSHN = address - + gsc.G_BASE # GAS - ) iteration_gas_cost = ( - parameters_gas + basic_gas + static_cost # Precompile static cost + math.ceil(input_length / 32) * per_word_dynamic_cost # Precompile dynamic cost - + gsc.G_BASE # POP ) # From the available gas, subtract the memory expansion costs diff --git a/tests/benchmark/compute/instruction/test_account_query.py b/tests/benchmark/compute/instruction/test_account_query.py index 082e37135a..c74b2d8db8 100644 --- a/tests/benchmark/compute/instruction/test_account_query.py +++ b/tests/benchmark/compute/instruction/test_account_query.py @@ -274,15 +274,19 @@ def test_ext_account_query_cold( attack_gas_limit = gas_benchmark_value - gas_costs = fork.gas_costs() intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator() + cold_account_access_gas = opcode( + 0, + # gas accounting + address_warm=False, + ).gas_cost(fork) # For calculation robustness, the calculation below ignores "glue" opcodes # like PUSH and POP. It should be considered a worst-case number of # accounts, and a few of them might not be targeted before the attacking # transaction runs out of gas. num_target_accounts = ( attack_gas_limit - intrinsic_gas_cost_calc() - ) // gas_costs.G_COLD_ACCOUNT_ACCESS + ) // cold_account_access_gas blocks = [] post = {} @@ -294,11 +298,9 @@ def test_ext_account_query_cold( addr_offset = int.from_bytes(pre.fund_eoa(amount=0)) if not absent_accounts: - account_creation_gas = ( - gas_costs.G_COLD_ACCOUNT_ACCESS - + gas_costs.G_CALL_VALUE - + gas_costs.G_NEW_ACCOUNT - ) + account_creation_gas = Op.CALL( + value=1, address_warm=False, account_new=True, value_transfer=True + ).gas_cost(fork) # To avoid brittle/tight gas calculations of glue opcodes, we take # 90% of the maximum tx capacity. Even if this calculation fails # in the future, it will be caught by the post-state check. @@ -372,7 +374,7 @@ def test_ext_account_query_cold( with TestPhaseManager.execution(): max_target_per_tx = ( tx_gas_limit - intrinsic_gas_cost_calc() - ) // gas_costs.G_COLD_ACCOUNT_ACCESS + ) // cold_account_access_gas num_execution_txs = math.ceil(num_target_accounts / max_target_per_tx) gas_used = 0 diff --git a/tests/benchmark/compute/instruction/test_system.py b/tests/benchmark/compute/instruction/test_system.py index ad1af5a4d4..b80a14b0ed 100644 --- a/tests/benchmark/compute/instruction/test_system.py +++ b/tests/benchmark/compute/instruction/test_system.py @@ -13,8 +13,6 @@ - SELFDESTRUCT """ -import math - import pytest from execution_testing import ( Account, @@ -23,14 +21,13 @@ Block, Bytecode, Create2PreimageLayout, - Environment, ExtCallGenerator, Fork, Hash, + IteratingBytecode, JumpLoopGenerator, Op, TestPhaseManager, - Transaction, While, compute_create2_address, compute_create_address, @@ -194,20 +191,18 @@ def test_creates_collisions( # Note that these CREATE(2) calls will fail because in (**) below we pre- # alloc contracts with the same address as the ones that CREATE(2) will try # to create. - proxy_contract = pre.deploy_contract( - code=Op.CREATE2( + proxy_contract_code = ( + Op.CREATE2( value=Op.PUSH0, salt=Op.PUSH0, offset=Op.PUSH0, size=Op.PUSH0 ) if opcode == Op.CREATE2 else Op.CREATE(value=Op.PUSH0, offset=Op.PUSH0, size=Op.PUSH0) ) + proxy_contract = pre.deploy_contract(code=proxy_contract_code) - gas_costs = fork.gas_costs() # The CALL to the proxy contract needs at a minimum gas corresponding to # the CREATE(2) plus extra required PUSH0s for arguments. - min_gas_required = gas_costs.G_CREATE + gas_costs.G_BASE * ( - 3 if opcode == Op.CREATE else 4 - ) + min_gas_required = proxy_contract_code.gas_cost(fork) setup = Op.PUSH20(proxy_contract) + Op.PUSH3(min_gas_required) attack_block = Op.POP( # DUP7 refers to the PUSH3 above. @@ -224,7 +219,8 @@ def test_creates_collisions( pre.deploy_contract(address=addr, code=Op.INVALID) else: # Heuristic to have an upper bound. - max_contract_count = 2 * gas_benchmark_value // gas_costs.G_CREATE + creation_cost = proxy_contract_code.gas_cost(fork) + max_contract_count = 2 * gas_benchmark_value // creation_cost for nonce in range(max_contract_count): addr = compute_create_address(address=proxy_contract, nonce=nonce) pre.deploy_contract(address=addr, code=Op.INVALID) @@ -285,173 +281,155 @@ def test_return_revert( @pytest.mark.parametrize("value_bearing", [True, False]) def test_selfdestruct_existing( benchmark_test: BenchmarkTestFiller, - fork: Fork, pre: Alloc, value_bearing: bool, + fork: Fork, gas_benchmark_value: int, - tx_gas_limit: int, ) -> None: - """ - Benchmark SELFDESTRUCT instruction for existing contracts. - contracts. - """ - attack_gas_limit = gas_benchmark_value - fee_recipient = pre.fund_eoa(amount=1) - - # Template code that will be used to deploy a large number of contracts. - selfdestructable_contract_addr = pre.deploy_contract( - code=Op.SELFDESTRUCT(Op.COINBASE) - ) - initcode = Op.EXTCODECOPY( - address=selfdestructable_contract_addr, - dest_offset=0, - offset=0, - size=Op.EXTCODESIZE(selfdestructable_contract_addr), - ) + Op.RETURN(0, Op.EXTCODESIZE(selfdestructable_contract_addr)) - initcode_address = pre.deploy_contract(code=initcode) - - # Calculate the number of contracts that can be deployed with the available - # gas. - gas_costs = fork.gas_costs() - intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator() - loop_cost = ( - gas_costs.G_KECCAK_256 # KECCAK static cost - + math.ceil(85 / 32) * gas_costs.G_KECCAK_256_WORD # KECCAK dynamic - # cost for CREATE2 - + gas_costs.G_VERY_LOW * 3 # ~MSTOREs+ADDs - + gas_costs.G_COLD_ACCOUNT_ACCESS # CALL to self-destructing contract - + gas_costs.G_BASE - + gas_costs.G_SELF_DESTRUCT - + 88 # ~Gluing opcodes - ) - final_storage_gas = ( - gas_costs.G_VERY_LOW # ADD - + gas_costs.G_VERY_LOW * 3 # PUSHs - + gas_costs.G_COLD_SLOAD # SSTORE cold - + gas_costs.G_STORAGE_RESET # SSTORE new value - ) - memory_expansion_cost = fork().memory_expansion_gas_calculator()( - new_bytes=96 - ) - base_costs = ( - intrinsic_gas_cost_calc() - + (gas_costs.G_VERY_LOW * 12) # 8 PUSHs + 4 MSTOREs - + final_storage_gas - + memory_expansion_cost - ) - num_contracts = (attack_gas_limit - base_costs) // loop_cost - - # Create a factory that deploys a new SELFDESTRUCT contract instance pre- - # funded depending on the value_bearing parameter. We use CREATE2 so the - # caller contract can easily reproduce the addresses in a loop for CALLs. - factory_code = ( - Op.EXTCODECOPY( - address=initcode_address, - dest_offset=0, - offset=0, - size=Op.EXTCODESIZE(initcode_address), + """Benchmark SELFDESTRUCT instruction for existing contracts.""" + selfdestructable_contract = Op.SELFDESTRUCT(Op.CALLER, address_warm=True) + + # Initcode + initcode = ( + Op.MSTORE8( + 0, + Op.CALLER.int(), + # gas accounting + old_memory_size=0, + new_memory_size=2, ) - + Op.MSTORE( + + Op.MSTORE8(1, Op.SELFDESTRUCT.int()) + + Op.RETURN(0, 2, code_deposit_size=2) + ) + + # Factory Contract Setup + # CALLDATA[0:32] = start index + # CALLDATA[32:64] = end index + factory_setup = ( + Op.MSTORE( 0, + initcode.hex(), + # Gas accounting + old_memory_size=0, + new_memory_size=32, + ) + + Op.ADD(1, Op.CALLDATALOAD(32)) + + Op.CALLDATALOAD(0) + ) + + factory_iterating = While( + body=Op.POP( Op.CREATE2( value=1 if value_bearing else 0, - offset=0, - size=Op.EXTCODESIZE(initcode_address), - salt=Op.SLOAD(0), - ), - ) - + Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1)) - + Op.RETURN(0, 32) + offset=32 - len(initcode), + size=len(initcode), + salt=Op.DUP1, + # gas accounting + init_code_size=len(initcode), + ) + ), + condition=Op.PUSH1(1) + Op.ADD + Op.DUP1 + Op.DUP3 + Op.GT, ) - required_balance = ( - num_contracts if value_bearing else 0 - ) # 1 wei per contract - factory_address = pre.deploy_contract( - code=factory_code, balance=required_balance + factory_code = IteratingBytecode( + setup=factory_setup, + iterating=factory_iterating, + iterating_subcall=initcode, + cleanup=Op.STOP, ) - factory_caller_code = Op.CALLDATALOAD(0) + While( - body=Op.POP(Op.CALL(address=factory_address)), - condition=Op.PUSH1(1) - + Op.SWAP1 - + Op.SUB - + Op.DUP1 - + Op.ISZERO - + Op.ISZERO, + factory_address = pre.deploy_contract( + code=factory_code, + balance=10**18, ) - factory_caller_address = pre.deploy_contract(code=factory_caller_code) - # The deployed code length is two-bytes. The dominant factor - # is the static cost, plus a few glue opcodes/memory-expansion costs. - estimated_gas_per_creation = gas_costs.G_CREATE + 3_000 - max_creations_per_tx = int(tx_gas_limit // estimated_gas_per_creation) - num_setup_txs = math.ceil(num_contracts / max_creations_per_tx) + create2_preimage = Create2PreimageLayout( + factory_address=factory_address, + salt=Op.CALLDATALOAD(0), + init_code_hash=initcode.keccak256(), + ) - setup_txs = [] - with TestPhaseManager.setup(): - for i in range(num_setup_txs): - count = min( - max_creations_per_tx, num_contracts - i * max_creations_per_tx - ) - setup_txs.append( - Transaction( - to=factory_caller_address, - gas_limit=tx_gas_limit, - data=Hash(count), - sender=pre.fund_eoa(), - ) - ) + # Attack Contract Setup + # CALLDATA[0:32] = start index + # CALLDATA[32:64] = end index + attack_setup = ( + create2_preimage + Op.ADD(1, Op.CALLDATALOAD(32)) + Op.CALLDATALOAD(0) + ) - code = ( - ( - create2_preimage := Create2PreimageLayout( - factory_address=factory_address, - salt=Op.CALLDATALOAD(0), - init_code_hash=initcode.keccak256(), + loop = While( + body=Op.POP( + Op.CALL( + address=create2_preimage.address_op(), + address_warm=False, ) ) - # Main loop - + While( - body=Op.POP(Op.CALL(address=create2_preimage.address_op())) - + create2_preimage.increment_salt_op(), - # Loop while we have enough gas AND within target count - condition=Op.GT(Op.GAS, final_storage_gas + loop_cost), + + create2_preimage.increment_salt_op(), + condition=Op.PUSH1(1) + Op.ADD + Op.DUP1 + Op.DUP3 + Op.GT, + ) + + attack_code = IteratingBytecode( + setup=attack_setup, + iterating=loop, + iterating_subcall=selfdestructable_contract, + cleanup=Op.STOP, + ) + + attack_code_address = pre.deploy_contract(code=attack_code) + + def calldata(iteration_count: int, start_iteration: int) -> bytes: + index_end = iteration_count + start_iteration - 1 + return Hash(start_iteration) + Hash(index_end) + + # Compute iteration counts and expected gas from the gas model. + iteration_counts = list( + attack_code.tx_iterations_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + calldata=calldata, ) - + Op.SSTORE( - 0, Op.ADD(Op.SLOAD(0), 1) - ) # Done for successful tx execution assertion below. ) - assert len(code) <= fork.max_code_size() + num_contracts = sum(iteration_counts) - # The 0 storage slot is initialize to avoid creation costs in SSTORE above. - code_addr = pre.deploy_contract(code=code, storage={0: 1}) + start = 0 + total_gas_cost = 0 + for iters in iteration_counts: + total_gas_cost += attack_code.tx_gas_cost_by_iteration_count( + fork=fork, + iteration_count=iters, + start_iteration=start, + calldata=calldata, + ) + start += iters + + def factory_calldata(iteration_count: int, start_iteration: int) -> bytes: + index_end = iteration_count + start_iteration - 1 + return Hash(start_iteration) + Hash(index_end) - # Calculate max targets per execution TX based on effective gas limit - max_targets_per_tx = (tx_gas_limit - base_costs) // loop_cost - num_exec_txs = math.ceil(num_contracts / max_targets_per_tx) + with TestPhaseManager.setup(): + setup_sender = pre.fund_eoa() + setup_txs = list( + factory_code.transactions_by_total_iteration_count( + fork=fork, + total_iterations=num_contracts, + sender=setup_sender, + to=factory_address, + calldata=factory_calldata, + ) + ) - exec_txs = [] with TestPhaseManager.execution(): - for i in range(num_exec_txs): - start = i * max_targets_per_tx - count = min(max_targets_per_tx, num_contracts - start) - exec_txs.append( - Transaction( - to=code_addr, - gas_limit=tx_gas_limit, - data=Hash(start), - sender=pre.fund_eoa(), - ) + attack_sender = pre.fund_eoa() + exec_txs = list( + attack_code.transactions_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + sender=attack_sender, + to=attack_code_address, + calldata=calldata, ) + ) - post = { - factory_address: Account(storage={0: num_contracts}), - code_addr: Account( - storage={0: len(exec_txs) + 1} - ), # Check for successful execution. - } - deployed_contract_addresses = [] + post = {} for i in range(num_contracts): deployed_contract_address = compute_create2_address( address=factory_address, @@ -459,16 +437,19 @@ def test_selfdestruct_existing( initcode=initcode, ) post[deployed_contract_address] = Account(nonce=1) - deployed_contract_addresses.append(deployed_contract_address) + + post[attack_code_address] = Account( + balance=num_contracts if value_bearing else 0 + ) benchmark_test( post=post, target_opcode=Op.SELFDESTRUCT, blocks=[ Block(txs=setup_txs), - Block(txs=exec_txs, fee_recipient=fee_recipient), + Block(txs=exec_txs), ], - skip_gas_used_validation=True, + expected_benchmark_gas_used=total_gas_cost, ) @@ -478,128 +459,112 @@ def test_selfdestruct_created( pre: Alloc, value_bearing: bool, fork: Fork, - env: Environment, gas_benchmark_value: int, - tx_gas_limit: int, ) -> None: - """ - Benchmark SELFDESTRUCT instruction for deployed contracts within same tx. - """ - # Avoid account creation costs in the SELFDESTRUCT recipient. - fee_recipient = pre.fund_eoa(amount=1) - env.fee_recipient = fee_recipient - - # SELFDESTRUCT(COINBASE) contract deployment + """Benchmark SELFDESTRUCT instruction for contracts created in same tx.""" + selfdestructable_contract = Op.SELFDESTRUCT(Op.CALLER, address_warm=True) + + # Initcode initcode = ( - Op.MSTORE8(0, Op.COINBASE.int()) + Op.MSTORE8( + 0, + Op.CALLER.int(), + # gas accounting + old_memory_size=0, + new_memory_size=2, + ) + Op.MSTORE8(1, Op.SELFDESTRUCT.int()) - + Op.RETURN(0, 2) - ) - gas_costs = fork.gas_costs() - memory_expansion_calc = fork().memory_expansion_gas_calculator() - intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator() - - initcode_costs = ( - gas_costs.G_VERY_LOW * 8 # MSTOREs, PUSHs - + memory_expansion_calc(new_bytes=2) # return into memory - ) - create_costs = ( - initcode_costs - + gas_costs.G_CREATE - + gas_costs.G_VERY_LOW * 3 # Create Parameter PUSHs - + gas_costs.G_CODE_DEPOSIT_BYTE * 2 - + gas_costs.G_INITCODE_WORD - ) - call_costs = ( - gas_costs.G_WARM_ACCOUNT_ACCESS - + gas_costs.G_BASE # COINBASE - + gas_costs.G_SELF_DESTRUCT - + gas_costs.G_VERY_LOW * 5 # CALL Parameter PUSHs - + gas_costs.G_BASE # Parameter GAS - ) - extra_costs = ( - gas_costs.G_BASE * 2 # POP, GAS - + gas_costs.G_VERY_LOW * 5 # PUSHs, ADD, DUP, GT - + gas_costs.G_HIGH # JUMPI - + gas_costs.G_JUMPDEST - ) - loop_cost = create_costs + call_costs + extra_costs - - prefix_cost = ( - gas_costs.G_VERY_LOW * 3 - + gas_costs.G_BASE - + memory_expansion_calc(new_bytes=32) - ) - suffix_cost = ( - gas_costs.G_VERY_LOW - + gas_costs.G_VERY_LOW * 3 - + gas_costs.G_COLD_SLOAD - + gas_costs.G_STORAGE_RESET - ) - - base_costs = prefix_cost + suffix_cost + intrinsic_gas_cost_calc() - - iterations = (gas_benchmark_value - base_costs) // loop_cost - - code_prefix = Op.MSTORE(0, initcode.hex()) + Op.PUSH0 + Op.JUMPDEST - code_suffix = ( - Op.SSTORE( - 0, Op.ADD(Op.SLOAD(0), 1) - ) # Done for successful tx execution assertion below. - + Op.STOP - ) - loop_body = ( - Op.POP( + + Op.RETURN(0, 2, code_deposit_size=2) + ) + + # CALLDATA[0:32] = iteration_count + setup = ( + Op.MSTORE( + 0, + initcode.hex(), + # Gas accounting + old_memory_size=0, + new_memory_size=32, + ) + + Op.CALLDATALOAD(0) + + Op.PUSH0 + ) + + loop = While( + body=Op.POP( Op.CALL( address=Op.CREATE( value=1 if value_bearing else 0, offset=32 - len(initcode), size=len(initcode), - ) + init_code_size=len(initcode), + ), + address_warm=True, ) - ) - + Op.PUSH1[1] - + Op.ADD - + Op.JUMPI( - len(code_prefix) - 1, Op.GT(Op.GAS, suffix_cost + loop_cost) + ), + condition=Op.PUSH1(1) + Op.ADD + Op.DUP1 + Op.DUP3 + Op.GT, + ) + + attack_code = IteratingBytecode( + setup=setup, + iterating=loop, + iterating_subcall=selfdestructable_contract.gas_cost(fork) + + initcode.gas_cost(fork), + cleanup=Op.STOP, + ) + + def calldata(iteration_count: int, start_iteration: int) -> bytes: + del start_iteration + return Hash(iteration_count) + + iteration_counts = list( + attack_code.tx_iterations_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + calldata=calldata, ) ) - code = code_prefix + loop_body + code_suffix + num_iterations = sum(iteration_counts) - max_iterations_per_tx = (tx_gas_limit - base_costs) // loop_cost - num_exec_txs = math.ceil(iterations / max_iterations_per_tx) - total_expected_iterations = max_iterations_per_tx * num_exec_txs + total_gas_cost = sum( + attack_code.tx_gas_cost_by_iteration_count( + fork=fork, + iteration_count=iters, + calldata=calldata, + ) + for iters in iteration_counts + ) - # The 0 storage slot is initialize to avoid creation costs in SSTORE above. - code_addr = pre.deploy_contract( - code=code, - balance=total_expected_iterations if value_bearing else 0, - storage={0: 1}, + attack_code_address = pre.deploy_contract( + code=attack_code, + balance=num_iterations if value_bearing else 0, ) - exec_txs = [] with TestPhaseManager.execution(): - for _ in range(num_exec_txs): - exec_txs.append( - Transaction( - to=code_addr, - gas_limit=tx_gas_limit, - sender=pre.fund_eoa(), - ) + sender = pre.fund_eoa() + exec_txs = list( + attack_code.transactions_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + sender=sender, + to=attack_code_address, + calldata=calldata, ) + ) post = { - code_addr: Account(storage={0: len(exec_txs) + 1}) - } # Check for successful execution. + attack_code_address: Account( + balance=num_iterations if value_bearing else 0 + ) + } + benchmark_test( post=post, + target_opcode=Op.SELFDESTRUCT, blocks=[ - Block(txs=exec_txs, fee_recipient=fee_recipient), + Block(txs=exec_txs), ], - expected_benchmark_gas_used=( - max_iterations_per_tx * loop_cost + base_costs - ) - * num_exec_txs, + expected_benchmark_gas_used=total_gas_cost, ) @@ -609,106 +574,93 @@ def test_selfdestruct_initcode( pre: Alloc, value_bearing: bool, fork: Fork, - env: Environment, gas_benchmark_value: int, - tx_gas_limit: int, ) -> None: """Benchmark SELFDESTRUCT instruction executed in initcode.""" - # Avoid account creation costs in the SELFDESTRUCT recipient. - fee_recipient = pre.fund_eoa(amount=1) - env.fee_recipient = fee_recipient - - gas_costs = fork.gas_costs() - memory_expansion_calc = fork().memory_expansion_gas_calculator() - intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator() - - initcode_costs = ( - gas_costs.G_BASE # COINBASE - + gas_costs.G_SELF_DESTRUCT - ) - create_costs = ( - initcode_costs - + gas_costs.G_CREATE - + gas_costs.G_VERY_LOW * 3 # Create Parameter PUSHs - + gas_costs.G_INITCODE_WORD - ) - extra_costs = ( - gas_costs.G_BASE * 2 # POP, GAS - + gas_costs.G_VERY_LOW * 5 # PUSHs, ADD, GT - + gas_costs.G_HIGH # JUMPI - + gas_costs.G_JUMPDEST - ) - loop_cost = create_costs + extra_costs - - prefix_cost = ( - gas_costs.G_VERY_LOW * 3 - + gas_costs.G_BASE - + memory_expansion_calc(new_bytes=32) - ) - suffix_cost = ( - gas_costs.G_VERY_LOW - + gas_costs.G_VERY_LOW * 3 - + gas_costs.G_COLD_SLOAD - + gas_costs.G_STORAGE_RESET - ) - - base_costs = prefix_cost + suffix_cost + intrinsic_gas_cost_calc() + initcode = Op.SELFDESTRUCT(Op.CALLER, address_warm=True) - initcode = Op.SELFDESTRUCT(Op.COINBASE) - code_prefix = Op.MSTORE(0, initcode.hex()) + Op.PUSH0 + Op.JUMPDEST - code_suffix = ( - Op.SSTORE( - 0, Op.ADD(Op.SLOAD(0), 1) - ) # Done for successful tx execution assertion below. - + Op.STOP + # CALLDATA[0:32] = iteration_count + setup = ( + Op.MSTORE( + 0, + initcode.hex(), + # Gas accounting + old_memory_size=0, + new_memory_size=32, + ) + + Op.CALLDATALOAD(0) + + Op.PUSH0 ) - loop_body = ( - Op.POP( + loop = While( + body=Op.POP( Op.CREATE( value=1 if value_bearing else 0, offset=32 - len(initcode), size=len(initcode), + init_code_size=len(initcode), ) + ), + condition=Op.PUSH1(1) + Op.ADD + Op.DUP1 + Op.DUP3 + Op.GT, + ) + + attack_code = IteratingBytecode( + setup=setup, + iterating=loop, + iterating_subcall=initcode, + cleanup=Op.STOP, + ) + + def calldata(iteration_count: int, start_iteration: int) -> bytes: + del start_iteration + return Hash(iteration_count) + + iteration_counts = list( + attack_code.tx_iterations_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + calldata=calldata, ) - + Op.PUSH1[1] - + Op.ADD - + Op.JUMPI( - len(code_prefix) - 1, Op.GT(Op.GAS, suffix_cost + loop_cost) + ) + num_iterations = sum(iteration_counts) + + total_gas_cost = sum( + attack_code.tx_gas_cost_by_iteration_count( + fork=fork, + iteration_count=iters, + calldata=calldata, ) + for iters in iteration_counts ) - code = code_prefix + loop_body + code_suffix - # The 0 storage slot is initialized to avoid creation - # costs in SSTORE above. - code_addr = pre.deploy_contract(code=code, balance=100_000, storage={0: 1}) + attack_code_address = pre.deploy_contract( + code=attack_code, + balance=num_iterations if value_bearing else 0, + ) - exec_txs = [] - expected_benchmark_gas_used = 0 with TestPhaseManager.execution(): - used_gas = 0 - while used_gas < gas_benchmark_value: - gas_limit = min(tx_gas_limit, gas_benchmark_value - used_gas) - if gas_limit < intrinsic_gas_cost_calc(): - break - exec_txs.append( - Transaction( - to=code_addr, - gas_limit=gas_limit, - sender=pre.fund_eoa(), - ) + sender = pre.fund_eoa() + exec_txs = list( + attack_code.transactions_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + sender=sender, + to=attack_code_address, + calldata=calldata, ) - used_gas += gas_limit - iterations = (gas_limit - base_costs) // loop_cost - expected_benchmark_gas_used += iterations * loop_cost + base_costs + ) post = { - code_addr: Account(storage={0: len(exec_txs) + 1}) - } # Check for successful execution. + attack_code_address: Account( + balance=num_iterations if value_bearing else 0 + ) + } + benchmark_test( post=post, + target_opcode=Op.SELFDESTRUCT, blocks=[ - Block(txs=exec_txs, fee_recipient=fee_recipient), + Block(txs=exec_txs), ], - expected_benchmark_gas_used=expected_benchmark_gas_used, + expected_benchmark_gas_used=total_gas_cost, ) diff --git a/tests/benchmark/compute/precompile/test_alt_bn128.py b/tests/benchmark/compute/precompile/test_alt_bn128.py index 987ff81010..8f1355f9cc 100644 --- a/tests/benchmark/compute/precompile/test_alt_bn128.py +++ b/tests/benchmark/compute/precompile/test_alt_bn128.py @@ -423,13 +423,23 @@ def test_bn128_pairings_amortized( tx_gas_limit: int, ) -> None: """Test running a block with as many BN128 pairings as possible.""" - base_cost = 45_000 - pairing_cost = 34_000 size_per_pairing = 192 gsc = fork.gas_costs() + base_cost = gsc.G_PRECOMPILE_ECPAIRING_BASE + pairing_cost = gsc.G_PRECOMPILE_ECPAIRING_PER_POINT intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() mem_exp_gas_calculator = fork.memory_expansion_gas_calculator() + warm_account_access_cost = Op.STATICCALL( + gas=Op.GAS, + address=Op.PUSH20(0), + args_offset=Op.PUSH0, + args_size=Op.PUSH0, + ret_offset=Op.PUSH0, + ret_size=Op.PUSH0, + # gas accounting + address_warm=True, + ).gas_cost(fork) # This is a theoretical maximum number of pairings that can be done in a # block. It is only used for an upper bound for calculating the optimal @@ -458,10 +468,8 @@ def test_bn128_pairings_amortized( - mem_exp_gas_calculator(new_bytes=i * size_per_pairing), ) - # This is ignoring "glue" opcodes, but helps to have a rough idea of - # the right cutting point. approx_gas_cost_per_call = ( - gsc.G_WARM_ACCOUNT_ACCESS + base_cost + i * pairing_cost + warm_account_access_cost + base_cost + i * pairing_cost ) num_precompile_calls = ( diff --git a/tests/benchmark/compute/precompile/test_identity.py b/tests/benchmark/compute/precompile/test_identity.py index 6536a6ce90..959e06ccc6 100644 --- a/tests/benchmark/compute/precompile/test_identity.py +++ b/tests/benchmark/compute/precompile/test_identity.py @@ -20,11 +20,12 @@ def test_identity( intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() gas_available = tx_gas_limit - intrinsic_gas_calculator() + gas_costs = fork.gas_costs() optimal_input_length = calculate_optimal_input_length( available_gas=gas_available, fork=fork, - static_cost=15, - per_word_dynamic_cost=3, + static_cost=gas_costs.G_PRECOMPILE_IDENTITY_BASE, + per_word_dynamic_cost=gas_costs.G_PRECOMPILE_IDENTITY_WORD, bytes_per_unit_of_work=1, ) diff --git a/tests/benchmark/compute/precompile/test_ripemd160.py b/tests/benchmark/compute/precompile/test_ripemd160.py index 270bd17e86..321f7d4a88 100644 --- a/tests/benchmark/compute/precompile/test_ripemd160.py +++ b/tests/benchmark/compute/precompile/test_ripemd160.py @@ -20,11 +20,12 @@ def test_ripemd160( intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() gas_available = tx_gas_limit - intrinsic_gas_calculator() + gas_costs = fork.gas_costs() optimal_input_length = calculate_optimal_input_length( available_gas=gas_available, fork=fork, - static_cost=600, - per_word_dynamic_cost=120, + static_cost=gas_costs.G_PRECOMPILE_RIPEMD160_BASE, + per_word_dynamic_cost=gas_costs.G_PRECOMPILE_RIPEMD160_WORD, bytes_per_unit_of_work=64, ) diff --git a/tests/benchmark/compute/precompile/test_sha256.py b/tests/benchmark/compute/precompile/test_sha256.py index 5f909fc8f3..e7265a24bd 100644 --- a/tests/benchmark/compute/precompile/test_sha256.py +++ b/tests/benchmark/compute/precompile/test_sha256.py @@ -20,11 +20,12 @@ def test_sha256( intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() gas_available = tx_gas_limit - intrinsic_gas_calculator() + gas_costs = fork.gas_costs() optimal_input_length = calculate_optimal_input_length( available_gas=gas_available, fork=fork, - static_cost=60, - per_word_dynamic_cost=12, + static_cost=gas_costs.G_PRECOMPILE_SHA256_BASE, + per_word_dynamic_cost=gas_costs.G_PRECOMPILE_SHA256_WORD, bytes_per_unit_of_work=64, ) diff --git a/tests/benchmark/stateful/bloatnet/depth_benchmarks/.worst_case_miner b/tests/benchmark/stateful/bloatnet/depth_benchmarks/.worst_case_miner new file mode 160000 index 0000000000..c75646fe1c --- /dev/null +++ b/tests/benchmark/stateful/bloatnet/depth_benchmarks/.worst_case_miner @@ -0,0 +1 @@ +Subproject commit c75646fe1c09db3759b093fd044afd2c5008e8be diff --git a/tests/benchmark/stateful/bloatnet/depth_benchmarks/README.md b/tests/benchmark/stateful/bloatnet/depth_benchmarks/README.md new file mode 100644 index 0000000000..e2f009c116 --- /dev/null +++ b/tests/benchmark/stateful/bloatnet/depth_benchmarks/README.md @@ -0,0 +1,72 @@ +# Depth Benchmark Tests + +This directory contains tests for worst-case depth attacks on Ethereum state and account tries. + +## Scenario Description + +These benchmarks test the worst-case scenario for Ethereum clients when dealing with extremely deep state and account tries. The attack involves: + +1. **Pre-deployed contracts** with deep storage tries that maximize trie traversal costs +2. **CREATE2-based addressing** for deterministic contract addresses across test runs +3. **Optimized batched attacks** using an AttackOrchestrator contract that can execute up to 1,980 attacks per transaction +4. **Account trie depth** increased by funding auxiliary accounts that make the path deeper + +The test measures the performance impact of state root recomputation and IO when modifying deep storage slots across thousands of contracts, simulating the maximum theoretical load on the state trie. + +## Contract Sources + +- **Pre-mined assets** (depth\__.sol, s_\_acc\*.json): https://github.com/CPerezz/worst_case_miner/tree/master/mined_assets + +For complete deployment setup and instructions, see the gist: https://gist.github.com/CPerezz/44d521c0f9e6adf7d84187a4f2c11978 + +To update the submodule in this repository to the latest master in `CPerezz/worst_case_miner` run the following command: `git submodule update --remote --merge tests/benchmark/stateful/bloatnet/depth_benchmarks/.worst_case_miner`. + +## Prerequisites + +- Python with `uv` package manager +- Anvil (Ethereum node implementation) or another EVM client +- Nick's factory deployed at `0x4e59b44847b379578588920ca78fbf26c0b4956c` (automatically deployed by `execute` otherwise) + +## Workflow + +### Step 1: Start the Node (Anvil in this example) + +```bash +# Start Anvil with high gas limit and auto-mining +anvil --hardfork prague --block-time 6 --steps-tracing --gas-limit 500000000 --balance 99999999999999 --port 8545 +``` + +### Step 2: Obtain the mined assets + +```bash +git submodule update --init --recursive +``` + +### Step 3: Run Attack Test + +Execute the worst-case depth attack test: + +```bash +# Run the attack test +export RPC_ENDPOINT= +export RPC_SEED_KEY= +export RPC_CHAIN_ID= +uv run execute remote \ + --gas-benchmark-values 60 \ + --fork Prague \ + -m stateful \ + tests/benchmark/stateful/bloatnet/depth_benchmarks/test_deep_branch.py +``` + +## Available Configurations + +Currently available pre-mined assets from [worst_case_miner](https://github.com/CPerezz/worst_case_miner/tree/master/mined_assets): + +| Storage Depth | Account Depth | File | +| ------------- | ------------- | ------------- | +| 10 | 6 | s10_acc6.json | +| 10 | 7 | s10_acc7.json | +| 11 | 6 | s11_acc6.json | +| 11 | 7 | s11_acc7.json | + +To generate new configurations, use [worst_case_miner](https://github.com/CPerezz/worst_case_miner). diff --git a/tests/benchmark/stateful/bloatnet/depth_benchmarks/__init__.py b/tests/benchmark/stateful/bloatnet/depth_benchmarks/__init__.py new file mode 100644 index 0000000000..132a529652 --- /dev/null +++ b/tests/benchmark/stateful/bloatnet/depth_benchmarks/__init__.py @@ -0,0 +1,3 @@ +""" +abstract: BloatNet worst-case attack benchmark for maximum SSTORE stress. +""" diff --git a/tests/benchmark/stateful/bloatnet/depth_benchmarks/test_deep_branch.py b/tests/benchmark/stateful/bloatnet/depth_benchmarks/test_deep_branch.py new file mode 100644 index 0000000000..b4d3fabc6d --- /dev/null +++ b/tests/benchmark/stateful/bloatnet/depth_benchmarks/test_deep_branch.py @@ -0,0 +1,390 @@ +""" +abstract: BloatNet worst-case attack benchmark for maximum SSTORE stress. + +This test implements a worst-case scenario for Ethereum block processing +that exploits the computational complexity of Patricia Merkle Trie +operations. It uses CREATE2 to deploy contracts at pre-mined addresses +with shared prefixes, maximizing trie traversal depth. + +Key features: +- Attacks pre-deployed contracts via CREATE2 address derivation +- Each contract has deep storage slots with configurable trie depth +- Executes optimized attack bytecode with multiple SSTORE operations +- Respects Fusaka tx gas limit (16M gas) and fills blocks fully +- Verifies attack success via a storage check in each of the attack contracts + +Test parameters: +- storage_depth: Depth of storage slots (e.g., 10, 11) +- account_depth: Account address prefix sharing depth (e.g., 6, 7) + +Contract sources: +- Pre-mined assets (depth_*.sol, s*_acc*.json): + https://github.com/CPerezz/worst_case_miner/tree/master/mined_assets +""" + +import time +from pathlib import Path +from typing import Annotated, Any, List, Self + +import pytest +from execution_testing import ( + DETERMINISTIC_FACTORY_ADDRESS, + Account, + Address, + Alloc, + BenchmarkTestFiller, + Block, + Bytecode, + Bytes, + Create2PreimageLayout, + Fork, + Hash, + IteratingBytecode, + Op, + TransactionWithCost, + While, +) +from pydantic import BaseModel, BeforeValidator, Field + +# Folder path to the submodule and mined assets +WORST_CASE_MINER_SUBMODULE_PATH = Path(__file__).parent / ".worst_case_miner" +MINED_ASSETS_PATH = WORST_CASE_MINER_SUBMODULE_PATH / "mined_assets" + +# Arbitrary value written to storage slots during attack +DEFAULT_ATTACK_VALUE = 42 + + +def get_mined_asset(filename: str) -> str: + """ + Get the contents of the mined asset. + + Requires `git submodule update --init --recursive` if the repository + was not cloned using submodules initially. + + Args: + filename: Name of the file (e.g., "s9_acc5.json" or "depth_9.sol") + + Returns: + str: Content of the file + + """ + asset_path = MINED_ASSETS_PATH / filename + + if not asset_path.exists(): + raise RuntimeError( + f""" + File {filename} not found in {MINED_ASSETS_PATH}. + Please run `git submodule update --init --recursive` to download + the submodule before running the test. + """ + ) + + return asset_path.read_text() + + +class SaltedContractInstance(BaseModel): + """ + Represents a single instance of a contract deployed using the given salt. + """ + + salt: int + contract_address: Address + auxiliary_accounts: List[Address] + + +class MinedContractFile(BaseModel): + """ + Model to load information about a contract mined using + https://github.com/CPerezz/worst_case_miner. + """ + + deployer: Address + initcode_hash: Hash = Field(..., alias="init_code_hash") + initcode: Bytes = Field(..., alias="init_code") + deploy_code: Bytes + storage_keys: List[ + Annotated[Hash, BeforeValidator(lambda v: Hash(v, left_padding=True))] + ] + target_depth: int + num_contracts: int + total_time: float + contracts: List[SaltedContractInstance] + + def model_post_init(self, __context: Any) -> None: + """ + Perform post-initialization checks. + """ + if len(self.contracts) != self.num_contracts: + raise ValueError( + f"Number of contracts specified in the `num_contracts` field, " + f"({self.num_contracts})does not match number of " + f"contracts ({len(self.contracts)})." + ) + if self.initcode_hash != self.initcode.keccak256(): + raise ValueError( + f"init code hash ({self.initcode_hash}) does not match " + f"calculated hash ({self.initcode.keccak256()})" + ) + + @classmethod + def load(cls, storage_depth: int, account_depth: int) -> Self: + """ + Load the pre-mined CREATE2 data for given depth parameters. + + Args: + storage_depth: Depth of storage slots in the contract (e.g., 9) + account_depth: Depth of account address prefix sharing (e.g., 5) + + Returns dict with: + - initcode_hash: Expected hash for reproducible compilation + - deployer: Nick's deployer address + - contracts: List of dicts with 'salt' and 'auxiliary_accounts' + + """ + json_filename = f"s{storage_depth}_acc{account_depth}.json" + return cls.model_validate_json(get_mined_asset(json_filename)) + + +@pytest.fixture +def attack_value(request: pytest.FixtureRequest) -> int: + """ + Value to set in storage to trigger the update. + + During test fill, it's desirable to use a constant so the filled fixtures + are always the same. + + However during execute we should use a random value to guarantee the + storage update that's required for the attack. + """ + if request.config.pluginmanager.has_plugin( + "execution_testing.cli.pytest_commands.plugins.execute.execute" + ): + return int(time.time()) + return DEFAULT_ATTACK_VALUE + + +@pytest.mark.valid_from("Prague") +@pytest.mark.parametrize( + "storage_depth,account_depth", + [ + # From .worst_case_miner/mined_assets + (10, 3), + (10, 4), + (10, 5), + (10, 6), + (10, 7), + (11, 3), + (11, 4), + (11, 5), + (11, 6), + (11, 7), + (12, 3), + (12, 4), + (12, 5), + ], +) +def test_worst_depth_stateroot_recomp( + benchmark_test: BenchmarkTestFiller, + fork: Fork, + pre: Alloc, + gas_benchmark_value: int, + storage_depth: int, + account_depth: int, + attack_value: int, +) -> None: + """ + BloatNet worst-case SSTORE attack benchmark with pre-deployed contracts. + + This test: + 1. Derives CREATE2 addresses from initcode_hash + Nick's deployer + 2. Deploys AttackOrchestrator that calls attack() on each target + 3. Fills blocks with 16M gas transactions attacking contracts + 4. Adds a verification transaction at the end to confirm success + + Args: + benchmark_test: The benchmark test filler + fork: The fork to test on + pre: Pre-state allocation + gas_benchmark_value: Gas budget for benchmark + storage_depth: Depth of storage slots in the contract (e.g., 9) + account_depth: Depth of account address prefix sharing (e.g., 5) + attack_value: The value to be written to storage in each attack + + """ + # Load the mined contract file + mined_contract_file = MinedContractFile.load(storage_depth, account_depth) + + # Generate the attack orchestrator + factory_address = ( + fork.deterministic_factory_predeploy_address() + or DETERMINISTIC_FACTORY_ADDRESS + ) + + # Prepare attack contract + create2_preimage = Create2PreimageLayout( + factory_address=factory_address, + salt=Op.CALLDATALOAD(32), + init_code_hash=Op.CALLDATALOAD(96), + ) + args_offset = 96 + setup: Bytecode = ( + create2_preimage + # Place ABI (`attack(uint256)`) in memory + + Op.MSTORE( + args_offset, + Hash(bytes.fromhex("64dd891a"), right_padding=True), + old_memory_size=args_offset, + new_memory_size=args_offset + 32, + ) + # Place attack value in memory + + Op.MSTORE( + args_offset + 4, + Op.CALLDATALOAD(0), + old_memory_size=args_offset + 32, + new_memory_size=args_offset + 32 + 4, + ) + # Place end index in stack + + Op.CALLDATALOAD(64) + ) + iterating = While( + body=Op.POP( + Op.CALL( + address=create2_preimage.address_op(), + args_offset=args_offset, + args_size=4 + 32, + ) + ) + # Increment salt in memory by one + + create2_preimage.increment_salt_op(), + # Check that current salt is less than the batch end + # Salt + 1 < End Index + condition=Op.LT( + Op.MLOAD(create2_preimage.salt_offset), + Op.DUP1, + ), + ) + cleanup = Op.STOP + + # The code was compiled by solidity, so these opcode counts were obtained + # from the traces. + # The purpose of this bytecode definition is to calculate the gas cost of + # the inner call, not to deploy it, hence the unsorted opcodes. + # This collection of opcodes represents Solidity's function dispatching + # logic, and the `attack(uint256 value)` function that can be seen in + # `depth_N.sol` files. + inner_call_bytecode = ( + # Attack sstore + Op.SSTORE(key_warm=False, original_value=1, new_value=2) + # Rest of the opcodes + + Op.CALLDATALOAD * 2 + + Op.CALLDATASIZE * 2 + + Op.CALLVALUE + + Op.DUP1 * 5 + + Op.EQ + + Op.GT + + Op.ISZERO * 2 + + Op.JUMP * 3 + + Op.JUMPDEST * 6 + + Op.JUMPI * 5 + + Op.LT + + Op.MSTORE(new_memory_size=96) + + Op.POP * 3 + + Op.PUSH0 * 2 + + Op.PUSH1 * 17 + + Op.SHR + + Op.SLT + + Op.SUB + + Op.SWAP1 * 2 + ) + + attack_orchestrator_bytecode = IteratingBytecode( + setup=setup, + iterating=iterating, + cleanup=cleanup, + iterating_subcall=inner_call_bytecode, + ) + + # Deploy orchestrator to deterministic address + attack_orchestrator_address = pre.deterministic_deploy_contract( + deploy_code=attack_orchestrator_bytecode + ) + print(f" Orchestrator will be deployed at: {attack_orchestrator_address}") + + # Calldata generator for each transaction of the iterating bytecode. + def calldata(iteration_count: int, start_iteration: int) -> bytes: + end_iteration = start_iteration + iteration_count + return ( + Hash(attack_value) + + Hash(start_iteration) + + Hash(end_iteration) + + mined_contract_file.initcode_hash + ) + + # Get the number of contracts to deploy + contracts_required = sum( + attack_orchestrator_bytecode.tx_iterations_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + start_iteration=0, + calldata=calldata, + ) + ) + + # Deploy all contracts required. + post = Alloc({}) + for salt in range(contracts_required): + if salt >= len(mined_contract_file.contracts): + raise RuntimeError( + f"Requested salt {salt} but only " + f"{len(mined_contract_file.contracts)} available" + ) + salted_contract_info = mined_contract_file.contracts[salt] + assert salted_contract_info.salt == salt, ( + f"Salt out of order: {salted_contract_info.salt} != {salt}" + ) + deployed_contract_address = pre.deterministic_deploy_contract( + deploy_code=mined_contract_file.deploy_code, + salt=Hash(salt), + initcode=mined_contract_file.initcode, + storage=dict.fromkeys(mined_contract_file.storage_keys, 1), + ) + assert ( + deployed_contract_address == salted_contract_info.contract_address + ), ( + f"Contract address mismatch: {deployed_contract_address} != " + f"{salted_contract_info.contract_address}, salt: {salt}" + ) + for auxiliary_account in salted_contract_info.auxiliary_accounts: + # Ensure the account exists in the state trie + pre.fund_address( + address=auxiliary_account, amount=1, minimum_balance=True + ) + + # Set the post expectations + storage = dict.fromkeys(mined_contract_file.storage_keys, 1) + storage[mined_contract_file.storage_keys[-1]] = attack_value + post[salted_contract_info.contract_address] = Account(storage=storage) + + # Create an EOA with funds for the deployer + sender = pre.fund_eoa() + + # Build attack transactions + attack_txs: list[TransactionWithCost] = list( + attack_orchestrator_bytecode.transactions_by_gas_limit( + fork=fork, + gas_limit=gas_benchmark_value, + start_iteration=0, + sender=sender, + to=attack_orchestrator_address, + calldata=calldata, + ) + ) + + total_gas_cost = sum(tx.gas_cost for tx in attack_txs) + + benchmark_test( + pre=pre, + blocks=[Block(txs=attack_txs)], + post=post, + expected_benchmark_gas_used=total_gas_cost, + ) diff --git a/tests/benchmark/stateful/bloatnet/stubs_bloatnet.json b/tests/benchmark/stateful/bloatnet/stubs_bloatnet.json index c572ef2cee..89a35cc113 100644 --- a/tests/benchmark/stateful/bloatnet/stubs_bloatnet.json +++ b/tests/benchmark/stateful/bloatnet/stubs_bloatnet.json @@ -13,5 +13,11 @@ "test_mixed_sload_sstore_IMT": "0x13119e34e140097a507b07a5564bde1bc375d9e6", "test_sload_empty_erc20_balanceof_STR": "0x7c4d13a8e743b036f6ba71c5dcadfe4fc9aa7a17", "test_sstore_erc20_approve_STR": "0x7c4d13a8e743b036f6ba71c5dcadfe4fc9aa7a17", - "test_mixed_sload_sstore_STR": "0x7c4d13a8e743b036f6ba71c5dcadfe4fc9aa7a17" + "test_mixed_sload_sstore_STR": "0x7c4d13a8e743b036f6ba71c5dcadfe4fc9aa7a17", + "bloatnet_factory_0_5kb": "0xDf9da7E8660d38654C8aA267635222c9f5Ac0530", + "bloatnet_factory_1kb": "0xA52178573dc859741499946148CA1Fc3822Af600", + "bloatnet_factory_2kb": "0x88c159584059308C4B466bafC82c45d52De9951D", + "bloatnet_factory_5kb": "0x3f59809dac4b33e251e518acDadf923184151334", + "bloatnet_factory_10kb": "0xf90E8a6Ce32A949Ce5F58803BBa07476404cD89F", + "bloatnet_factory_24kb": "0x091988Afd81E0a2A3E20530A2510c089fF4bfAef" } diff --git a/tests/benchmark/stateful/bloatnet/stubs_mainnet.json b/tests/benchmark/stateful/bloatnet/stubs_mainnet.json index 2417eadd79..890ea25487 100644 --- a/tests/benchmark/stateful/bloatnet/stubs_mainnet.json +++ b/tests/benchmark/stateful/bloatnet/stubs_mainnet.json @@ -10,5 +10,11 @@ "test_mixed_sload_sstore_IMT": "0x13119e34e140097a507b07a5564bde1bc375d9e6", "test_sload_empty_erc20_balanceof_STR": "0x7c4d13a8e743b036f6ba71c5dcadfe4fc9aa7a17", "test_sstore_erc20_approve_STR": "0x7c4d13a8e743b036f6ba71c5dcadfe4fc9aa7a17", - "test_mixed_sload_sstore_STR": "0x7c4d13a8e743b036f6ba71c5dcadfe4fc9aa7a17" + "test_mixed_sload_sstore_STR": "0x7c4d13a8e743b036f6ba71c5dcadfe4fc9aa7a17", + "bloatnet_factory_0_5kb": "0xDf9da7E8660d38654C8aA267635222c9f5Ac0530", + "bloatnet_factory_1kb": "0xA52178573dc859741499946148CA1Fc3822Af600", + "bloatnet_factory_2kb": "0x88c159584059308C4B466bafC82c45d52De9951D", + "bloatnet_factory_5kb": "0x3f59809dac4b33e251e518acDadf923184151334", + "bloatnet_factory_10kb": "0xf90E8a6Ce32A949Ce5F58803BBa07476404cD89F", + "bloatnet_factory_24kb": "0x091988Afd81E0a2A3E20530A2510c089fF4bfAef" } diff --git a/tests/benchmark/stateful/bloatnet/test_multi_opcode.py b/tests/benchmark/stateful/bloatnet/test_multi_opcode.py index 7b48ee5a8d..5b8b305941 100755 --- a/tests/benchmark/stateful/bloatnet/test_multi_opcode.py +++ b/tests/benchmark/stateful/bloatnet/test_multi_opcode.py @@ -6,23 +6,28 @@ operations. """ -import json -import math -from pathlib import Path - import pytest from execution_testing import ( - Account, + AccessList, Alloc, + BenchmarkTestFiller, Block, - BlockchainTestFiller, Bytecode, + Conditional, + Create2PreimageLayout, Fork, + Hash, Op, Transaction, While, ) +from tests.benchmark.stateful.helpers import ( + APPROVE_SELECTOR, + BALANCEOF_SELECTOR, + MIXED_TOKENS, +) + REFERENCE_SPEC_GIT_PATH = "DUMMY/bloatnet.md" REFERENCE_SPEC_VERSION = "1.0" @@ -57,76 +62,50 @@ [True, False], ids=["balance_extcodesize", "extcodesize_balance"], ) -@pytest.mark.valid_from("Prague") def test_bloatnet_balance_extcodesize( - blockchain_test: BlockchainTestFiller, + benchmark_test: BenchmarkTestFiller, pre: Alloc, fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, balance_first: bool, ) -> None: - """ - BloatNet test using BALANCE + EXTCODESIZE with "on-the-fly" CREATE2 - address generation. - - This test: - 1. Assumes contracts are already deployed via the factory (salt 0 to N-1) - 2. Generates CREATE2 addresses dynamically during execution - 3. Calls BALANCE and EXTCODESIZE (order controlled by balance_first param) - 4. Maximizes cache eviction by accessing many contracts - """ - gas_costs = fork.gas_costs() - - # Calculate gas costs - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") - - # Cost per contract access with CREATE2 address generation - cost_per_contract = ( - gas_costs.G_KECCAK_256 # SHA3 static cost for address generation (30) - + gas_costs.G_KECCAK_256_WORD - * 3 # SHA3 dynamic cost (85 bytes = 3 words * 6) - + gas_costs.G_COLD_ACCOUNT_ACCESS # Cold access (2600) - + gas_costs.G_BASE # POP first result (2) - + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm access (100) - + gas_costs.G_BASE # POP second result (2) - + gas_costs.G_BASE # DUP1 before first op (3) - + gas_costs.G_VERY_LOW * 4 # PUSH1 operations (4 * 3) - + gas_costs.G_LOW # MLOAD for salt (3) - + gas_costs.G_VERY_LOW # ADD for increment (3) - + gas_costs.G_LOW # MSTORE salt back (3) - + 10 # While loop overhead - ) - - # Deploy factory using stub contract - NO HARDCODED VALUES - # The stub "bloatnet_factory" must be provided via --address-stubs flag - # The factory at that address MUST have: - # - Slot 0: Number of deployed contracts - # - Slot 1: Init code hash for CREATE2 address calculation + """Benchmark BALANACE and EXTCODESIZE combination on bloatnet.""" + # Stub Account factory_address = pre.deploy_contract( code=Bytecode(), # Required parameter, but will be ignored for stubs stub="bloatnet_factory", ) - # Calculate number of transactions needed (EIP-7825 compliance) - num_txs = max(1, math.ceil(gas_benchmark_value / tx_gas_limit)) - - # Calculate how many contracts to access based on available gas - total_available_gas = ( - gas_benchmark_value - (intrinsic_gas * num_txs) - 1000 + # Contract Construction + setup = Bytecode() + + setup += Conditional( + condition=Op.STATICCALL( + gas=Op.GAS, + address=factory_address, + args_offset=0, + args_size=0, + ret_offset=96, + ret_size=64, + # gas accounting + address_warm=False, + old_memory_size=0, + new_memory_size=160, + ), + if_false=Op.INVALID, ) - total_contracts = int(total_available_gas // cost_per_contract) - contracts_per_tx = total_contracts // num_txs - - # Log test requirements - deployed count read from factory storage - print( - f"Test needs {total_contracts} contracts for " - f"{gas_benchmark_value / 1_000_000:.1f}M gas " - f"across {num_txs} transaction(s). " - f"Factory storage will be checked during execution." + + create2_preimage = Create2PreimageLayout( + factory_address=factory_address, + salt=Op.CALLDATALOAD(32), + init_code_hash=Op.MLOAD(128), + old_memory_size=160, ) - # Define operations that differ based on parameter + setup += create2_preimage + setup += Op.CALLDATALOAD(0) # [num_contract] + balance_op = Op.POP(Op.BALANCE) extcodesize_op = Op.POP(Op.EXTCODESIZE) benchmark_ops = ( @@ -135,101 +114,67 @@ def test_bloatnet_balance_extcodesize( else (extcodesize_op + balance_op) ) - # Build transactions + loop = While( + body=( + create2_preimage.address_op() + + Op.DUP1 + + benchmark_ops + + create2_preimage.increment_salt_op() + ), + condition=Op.PUSH1(1) # [1, num_contract] + + Op.SWAP1 # [num_contract, 1] + + Op.SUB # [num_contract-1] + + Op.DUP1 # [num_contract-1, num_contract-1] + + Op.ISZERO # [num_contract-1==0, num_contract-1] + + Op.ISZERO, # [num_contract-1!=0, num_contract-1] + ) + + # Contract Deployment + code = setup + loop + attack_contract_address = pre.deploy_contract(code=code) + + # Gas Accounting + setup_cost = setup.gas_cost(fork) + loop_cost = loop.gas_cost(fork) + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=b"\xff" * 64 + ) + + # Attack Loop + gas_remaining = gas_benchmark_value txs = [] - post = {} - contracts_remaining = total_contracts salt_offset = 0 - for i in range(num_txs): - # Last tx gets remaining contracts - tx_contracts = ( - contracts_per_tx if i < num_txs - 1 else contracts_remaining - ) - contracts_remaining -= tx_contracts - - # Build attack contract that reads config from factory - attack_code = ( - # Call getConfig() on factory to get config - Op.STATICCALL( - gas=Op.GAS, - address=factory_address, - args_offset=0, - args_size=0, - ret_offset=96, - ret_size=64, - ) - # Check if call succeeded - + Op.ISZERO - + Op.PUSH2(0x1000) # Jump to error handler if failed (far jump) - + Op.JUMPI - # Load results from memory - # Memory[96:128] = num_deployed_contracts - # Memory[128:160] = init_code_hash - + Op.MLOAD(128) # Load init_code_hash - # Setup memory for CREATE2 address generation - # Memory layout at 0: 0xFF + factory_addr(20) + salt(32) + hash(32) - + Op.MSTORE( - 0, factory_address - ) # Store factory address at memory position 0 - + Op.MSTORE8(11, 0xFF) # Store 0xFF prefix at byte 11 - + Op.MSTORE(32, salt_offset) # Store starting salt at position 32 - # Stack now has: [init_code_hash] - + Op.PUSH1(64) # Push memory position - + Op.MSTORE # Store init_code_hash at memory[64] - # Push our iteration count onto stack - + Op.PUSH4(tx_contracts) - # Main attack loop - iterate through contracts for this tx - + While( - body=( - # Generate CREATE2 addr: keccak256(0xFF+factory+salt+hash) - Op.SHA3(11, 85) # CREATE2 addr from memory[11:96] - # The address is now on the stack - + Op.DUP1 # Duplicate for second operation - + benchmark_ops # Execute operations in specified order - # Increment salt for next iteration - + Op.MSTORE( - 32, Op.ADD(Op.MLOAD(32), 1) - ) # Increment and store salt - ), - # Continue while we haven't reached the limit - condition=Op.DUP1 - + Op.PUSH1(1) - + Op.SWAP1 - + Op.SUB - + Op.DUP1 - + Op.ISZERO - + Op.ISZERO, - ) - + Op.POP # Clean up counter - ) + while gas_remaining > intrinsic_gas + setup_cost + loop_cost: + gas_available = min(gas_remaining, tx_gas_limit) - # Deploy attack contract for this tx - attack_address = pre.deploy_contract(code=attack_code) + if gas_available < intrinsic_gas + setup_cost: + break - # Calculate gas for this transaction - this_tx_gas = min( - tx_gas_limit, gas_benchmark_value - (i * tx_gas_limit) - ) + num_contract = ( + gas_available - intrinsic_gas - setup_cost + ) // loop_cost + + if num_contract == 0: + break + + calldata = Hash(num_contract) + Hash(salt_offset) txs.append( Transaction( - to=attack_address, - gas_limit=this_tx_gas, + gas_limit=gas_available, + data=calldata, + to=attack_contract_address, sender=pre.fund_eoa(), ) ) - # Add to post-state - post[attack_address] = Account(storage={}) - - # Update salt offset for next transaction - salt_offset += tx_contracts + gas_remaining -= gas_available + salt_offset += num_contract - blockchain_test( + benchmark_test( pre=pre, blocks=[Block(txs=txs)], - post=post, ) @@ -238,86 +183,60 @@ def test_bloatnet_balance_extcodesize( [True, False], ids=["balance_extcodecopy", "extcodecopy_balance"], ) -@pytest.mark.valid_from("Prague") def test_bloatnet_balance_extcodecopy( - blockchain_test: BlockchainTestFiller, + benchmark_test: BenchmarkTestFiller, pre: Alloc, fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, balance_first: bool, ) -> None: - """ - BloatNet test using BALANCE + EXTCODECOPY with on-the-fly CREATE2 - address generation. - - This test forces actual bytecode reads from disk by: - 1. Assumes contracts are already deployed via the factory - 2. Generating CREATE2 addresses dynamically during execution - 3. Using BALANCE and EXTCODECOPY (order controlled by balance_first param) - 4. Reading 1 byte from the END of the bytecode to force full contract load - """ - gas_costs = fork.gas_costs() - max_contract_size = fork.max_code_size() - - # Calculate costs - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") - - # Cost per contract with EXTCODECOPY and CREATE2 address generation - cost_per_contract = ( - gas_costs.G_KECCAK_256 # SHA3 static cost for address generation (30) - + gas_costs.G_KECCAK_256_WORD - * 3 # SHA3 dynamic cost (85 bytes = 3 words * 6) - + gas_costs.G_COLD_ACCOUNT_ACCESS # Cold access (2600) - + gas_costs.G_BASE # POP first result (2) - + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm access base (100) - + gas_costs.G_COPY * 1 # Copy cost for 1 byte (3) - + gas_costs.G_BASE * 2 # DUP1 before first op, DUP4 for address (6) - + gas_costs.G_VERY_LOW * 8 # PUSH operations (8 * 3 = 24) - + gas_costs.G_LOW * 2 # MLOAD for salt twice (6) - + gas_costs.G_VERY_LOW * 2 # ADD operations (6) - + gas_costs.G_LOW # MSTORE salt back (3) - + gas_costs.G_BASE # POP after second op (2) - + 10 # While loop overhead - ) - - # Deploy factory using stub contract - NO HARDCODED VALUES - # The stub "bloatnet_factory" must be provided via --address-stubs flag - # The factory at that address MUST have: - # - Slot 0: Number of deployed contracts - # - Slot 1: Init code hash for CREATE2 address calculation + """Benchmark BALANACE and EXTCODECOPY combination on bloatnet.""" + # Stub Account factory_address = pre.deploy_contract( code=Bytecode(), # Required parameter, but will be ignored for stubs stub="bloatnet_factory", ) - # Calculate number of transactions needed (EIP-7825 compliance) - num_txs = max(1, math.ceil(gas_benchmark_value / tx_gas_limit)) - - # Calculate how many contracts to access - total_available_gas = ( - gas_benchmark_value - (intrinsic_gas * num_txs) - 1000 + # Contract Construction + setup = Bytecode() + + setup += Conditional( + condition=Op.STATICCALL( + gas=Op.GAS, + address=factory_address, + args_offset=0, + args_size=0, + ret_offset=96, + ret_size=64, + # gas accounting + address_warm=False, + old_memory_size=0, + new_memory_size=160, + ), + if_false=Op.INVALID, ) - total_contracts = int(total_available_gas // cost_per_contract) - contracts_per_tx = total_contracts // num_txs - - # Log test requirements - deployed count read from factory storage - print( - f"Test needs {total_contracts} contracts for " - f"{gas_benchmark_value / 1_000_000:.1f}M gas " - f"across {num_txs} transaction(s). " - f"Factory storage will be checked during execution." + + create2_preimage = Create2PreimageLayout( + factory_address=factory_address, + salt=Op.CALLDATALOAD(32), + init_code_hash=Op.MLOAD(128), + old_memory_size=160, ) - # Define operations that differ based on parameter + setup += create2_preimage + setup += Op.CALLDATALOAD(0) # [num_contract] + + max_contract_size = fork.max_code_size() + balance_op = Op.POP(Op.BALANCE) - extcodecopy_op = ( - Op.PUSH1(1) # size (1 byte) - + Op.PUSH2(max_contract_size - 1) # code offset (last byte) - + Op.ADD(Op.MLOAD(32), 96) # unique memory offset - + Op.DUP4 # address (duplicated earlier) - + Op.EXTCODECOPY - + Op.POP # clean up address + extcodecopy_op = Op.POP( + Op.EXTCODECOPY( + address=Op.DUP4, + destOffset=Op.ADD(Op.MLOAD(32), 96), + offset=max_contract_size - 1, + size=1, + ) ) benchmark_ops = ( (balance_op + extcodecopy_op) @@ -325,100 +244,67 @@ def test_bloatnet_balance_extcodecopy( else (extcodecopy_op + balance_op) ) - # Build transactions + loop = While( + body=( + create2_preimage.address_op() + + Op.DUP1 + + benchmark_ops + + create2_preimage.increment_salt_op() + ), + condition=Op.PUSH1(1) # [1, num_contract] + + Op.SWAP1 # [num_contract, 1] + + Op.SUB # [num_contract-1] + + Op.DUP1 # [num_contract-1, num_contract-1] + + Op.ISZERO # [num_contract-1==0, num_contract-1] + + Op.ISZERO, # [num_contract-1!=0, num_contract-1] + ) + + # Contract Deployment + code = setup + loop + attack_contract_address = pre.deploy_contract(code=code) + + # Gas Accounting + setup_cost = setup.gas_cost(fork) + loop_cost = loop.gas_cost(fork) + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=b"\xff" * 64 + ) + + # Attack Loop + gas_remaining = gas_benchmark_value txs = [] - post = {} - contracts_remaining = total_contracts salt_offset = 0 - for i in range(num_txs): - # Last tx gets remaining contracts - tx_contracts = ( - contracts_per_tx if i < num_txs - 1 else contracts_remaining - ) - contracts_remaining -= tx_contracts - - # Build attack contract that reads config from factory - attack_code = ( - # Call getConfig() on factory to get config - Op.STATICCALL( - gas=Op.GAS, - address=factory_address, - args_offset=0, - args_size=0, - ret_offset=96, - ret_size=64, - ) - # Check if call succeeded - + Op.ISZERO - + Op.PUSH2(0x1000) # Jump to error handler if failed (far jump) - + Op.JUMPI - # Load results from memory - # Memory[128:160] = init_code_hash - + Op.MLOAD(128) # Load init_code_hash - # Setup memory for CREATE2 address generation - # Memory layout at 0: 0xFF + factory_addr(20) + salt(32) + hash(32) - + Op.MSTORE( - 0, factory_address - ) # Store factory address at memory position 0 - + Op.MSTORE8(11, 0xFF) # Store 0xFF prefix at byte 11 - + Op.MSTORE(32, salt_offset) # Store starting salt at position 32 - # Stack now has: [init_code_hash] - + Op.PUSH1(64) # Push memory position - + Op.MSTORE # Store init_code_hash at memory[64] - # Push our iteration count onto stack - + Op.PUSH4(tx_contracts) - # Main attack loop - iterate through contracts for this tx - + While( - body=( - # Generate CREATE2 address - Op.SHA3(11, 85) # CREATE2 addr from memory[11:96] - # The address is now on the stack - + Op.DUP1 # Duplicate for later operations - + benchmark_ops # Execute operations in specified order - # Increment salt for next iteration - + Op.MSTORE( - 32, Op.ADD(Op.MLOAD(32), 1) - ) # Increment and store salt - ), - # Continue while counter > 0 - condition=Op.DUP1 - + Op.PUSH1(1) - + Op.SWAP1 - + Op.SUB - + Op.DUP1 - + Op.ISZERO - + Op.ISZERO, - ) - + Op.POP # Clean up counter - ) + while gas_remaining > intrinsic_gas + setup_cost + loop_cost: + gas_available = min(gas_remaining, tx_gas_limit) - # Deploy attack contract for this tx - attack_address = pre.deploy_contract(code=attack_code) + if gas_available < intrinsic_gas + setup_cost: + break - # Calculate gas for this transaction - this_tx_gas = min( - tx_gas_limit, gas_benchmark_value - (i * tx_gas_limit) - ) + num_contract = ( + gas_available - intrinsic_gas - setup_cost + ) // loop_cost + + if num_contract == 0: + break + + calldata = Hash(num_contract) + Hash(salt_offset) txs.append( Transaction( - to=attack_address, - gas_limit=this_tx_gas, + gas_limit=gas_available, + data=calldata, + to=attack_contract_address, sender=pre.fund_eoa(), ) ) - # Add to post-state - post[attack_address] = Account(storage={}) - - # Update salt offset for next transaction - salt_offset += tx_contracts + gas_remaining -= gas_available + salt_offset += num_contract - blockchain_test( + benchmark_test( pre=pre, blocks=[Block(txs=txs)], - post=post, ) @@ -427,72 +313,50 @@ def test_bloatnet_balance_extcodecopy( [True, False], ids=["balance_extcodehash", "extcodehash_balance"], ) -@pytest.mark.valid_from("Prague") def test_bloatnet_balance_extcodehash( - blockchain_test: BlockchainTestFiller, + benchmark_test: BenchmarkTestFiller, pre: Alloc, fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, balance_first: bool, ) -> None: - """ - BloatNet test using BALANCE + EXTCODEHASH with on-the-fly CREATE2 - address generation. - - This test: - 1. Assumes contracts are already deployed via the factory - 2. Generates CREATE2 addresses dynamically during execution - 3. Calls BALANCE and EXTCODEHASH (order controlled by balance_first param) - 4. Forces client to compute code hash for 24KB bytecode - """ - gas_costs = fork.gas_costs() - - # Calculate gas costs - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") - - # Cost per contract access with CREATE2 address generation - cost_per_contract = ( - gas_costs.G_KECCAK_256 # SHA3 static cost for address generation (30) - + gas_costs.G_KECCAK_256_WORD - * 3 # SHA3 dynamic cost (85 bytes = 3 words * 6) - + gas_costs.G_COLD_ACCOUNT_ACCESS # Cold access (2600) - + gas_costs.G_BASE # POP first result (2) - + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm access (100) - + gas_costs.G_BASE # POP second result (2) - + gas_costs.G_BASE # DUP1 before first op (3) - + gas_costs.G_VERY_LOW * 4 # PUSH1 operations (4 * 3) - + gas_costs.G_LOW # MLOAD for salt (3) - + gas_costs.G_VERY_LOW # ADD for increment (3) - + gas_costs.G_LOW # MSTORE salt back (3) - + 10 # While loop overhead - ) - - # Deploy factory using stub contract + """Benchmark BALANACE and EXTCODEHASH combination on bloatnet.""" + # Stub Account factory_address = pre.deploy_contract( - code=Bytecode(), + code=Bytecode(), # Required parameter, but will be ignored for stubs stub="bloatnet_factory", ) - # Calculate number of transactions needed (EIP-7825 compliance) - num_txs = max(1, math.ceil(gas_benchmark_value / tx_gas_limit)) - - # Calculate how many contracts to access based on available gas - total_available_gas = ( - gas_benchmark_value - (intrinsic_gas * num_txs) - 1000 + # Contract Construction + setup = Bytecode() + + setup += Conditional( + condition=Op.STATICCALL( + gas=Op.GAS, + address=factory_address, + args_offset=0, + args_size=0, + ret_offset=96, + ret_size=64, + # gas accounting + address_warm=False, + old_memory_size=0, + new_memory_size=160, + ), + if_false=Op.INVALID, ) - total_contracts = int(total_available_gas // cost_per_contract) - contracts_per_tx = total_contracts // num_txs - - # Log test requirements - print( - f"Test needs {total_contracts} contracts for " - f"{gas_benchmark_value / 1_000_000:.1f}M gas " - f"across {num_txs} transaction(s). " - f"Factory storage will be checked during execution." + + create2_preimage = Create2PreimageLayout( + factory_address=factory_address, + salt=Op.CALLDATALOAD(32), + init_code_hash=Op.MLOAD(128), + old_memory_size=160, ) - # Define operations that differ based on parameter + setup += create2_preimage + setup += Op.CALLDATALOAD(0) # [num_contract] + balance_op = Op.POP(Op.BALANCE) extcodehash_op = Op.POP(Op.EXTCODEHASH) benchmark_ops = ( @@ -501,112 +365,70 @@ def test_bloatnet_balance_extcodehash( else (extcodehash_op + balance_op) ) - # Build transactions + loop = While( + body=( + create2_preimage.address_op() + + Op.DUP1 + + benchmark_ops + + create2_preimage.increment_salt_op() + ), + condition=Op.PUSH1(1) # [1, num_contract] + + Op.SWAP1 # [num_contract, 1] + + Op.SUB # [num_contract-1] + + Op.DUP1 # [num_contract-1, num_contract-1] + + Op.ISZERO # [num_contract-1==0, num_contract-1] + + Op.ISZERO, # [num_contract-1!=0, num_contract-1] + ) + + # Contract Deployment + code = setup + loop + attack_contract_address = pre.deploy_contract(code=code) + + # Gas Accounting + setup_cost = setup.gas_cost(fork) + loop_cost = loop.gas_cost(fork) + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=b"\xff" * 64 + ) + + # Attack Loop + gas_remaining = gas_benchmark_value txs = [] - post = {} - contracts_remaining = total_contracts salt_offset = 0 - for i in range(num_txs): - # Last tx gets remaining contracts - tx_contracts = ( - contracts_per_tx if i < num_txs - 1 else contracts_remaining - ) - contracts_remaining -= tx_contracts - - # Build attack contract that reads config from factory - attack_code = ( - # Call getConfig() on factory to get config - Op.STATICCALL( - gas=Op.GAS, - address=factory_address, - args_offset=0, - args_size=0, - ret_offset=96, - ret_size=64, - ) - # Check if call succeeded - + Op.ISZERO - + Op.PUSH2(0x1000) # Jump to error handler if failed - + Op.JUMPI - # Load results from memory - + Op.MLOAD(128) # Load init_code_hash - # Setup memory for CREATE2 address generation - + Op.MSTORE(0, factory_address) - + Op.MSTORE8(11, 0xFF) - + Op.MSTORE(32, salt_offset) # Starting salt for this tx - + Op.PUSH1(64) - + Op.MSTORE # Store init_code_hash - # Push our iteration count onto stack - + Op.PUSH4(tx_contracts) - # Main attack loop - + While( - body=( - # Generate CREATE2 address - Op.SHA3(11, 85) - + Op.DUP1 # Duplicate for second operation - + benchmark_ops # Execute operations in specified order - # Increment salt - + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)) - ), - condition=Op.DUP1 - + Op.PUSH1(1) - + Op.SWAP1 - + Op.SUB - + Op.DUP1 - + Op.ISZERO - + Op.ISZERO, - ) - + Op.POP # Clean up counter - ) + while gas_remaining > intrinsic_gas + setup_cost + loop_cost: + gas_available = min(gas_remaining, tx_gas_limit) - # Deploy attack contract for this tx - attack_address = pre.deploy_contract(code=attack_code) + if gas_available < intrinsic_gas + setup_cost: + break - # Calculate gas for this transaction - this_tx_gas = min( - tx_gas_limit, gas_benchmark_value - (i * tx_gas_limit) - ) + num_contract = ( + gas_available - intrinsic_gas - setup_cost + ) // loop_cost + + if num_contract == 0: + break + + calldata = Hash(num_contract) + Hash(salt_offset) txs.append( Transaction( - to=attack_address, - gas_limit=this_tx_gas, + gas_limit=gas_available, + data=calldata, + to=attack_contract_address, sender=pre.fund_eoa(), ) ) - # Add to post-state - post[attack_address] = Account(storage={}) + gas_remaining -= gas_available + salt_offset += num_contract - # Update salt offset for next transaction - salt_offset += tx_contracts - - blockchain_test( + benchmark_test( pre=pre, blocks=[Block(txs=txs)], - post=post, ) -# ERC20 function selectors -BALANCEOF_SELECTOR = 0x70A08231 # balanceOf(address) -APPROVE_SELECTOR = 0x095EA7B3 # approve(address,uint256) - -# Load token names from stubs.json for test parametrization -_STUBS_FILE = Path(__file__).parent / "stubs_bloatnet.json" -with open(_STUBS_FILE) as f: - _STUBS = json.load(f) - -# Extract unique token names for mixed sload/sstore tests -MIXED_TOKENS = [ - k.replace("test_mixed_sload_sstore_", "") - for k in _STUBS.keys() - if k.startswith("test_mixed_sload_sstore_") -] - - -@pytest.mark.valid_from("Prague") @pytest.mark.parametrize("token_name", MIXED_TOKENS) @pytest.mark.parametrize( "sload_percent,sstore_percent", @@ -619,7 +441,7 @@ def test_bloatnet_balance_extcodehash( ], ) def test_mixed_sload_sstore( - blockchain_test: BlockchainTestFiller, + benchmark_test: BenchmarkTestFiller, pre: Alloc, fork: Fork, gas_benchmark_value: int, @@ -628,215 +450,214 @@ def test_mixed_sload_sstore( sload_percent: int, sstore_percent: int, ) -> None: - """ - BloatNet mixed SLOAD/SSTORE benchmark with configurable operation ratios. - - This test: - 1. Uses a single ERC20 contract specified by token_name parameter - 2. Allocates full gas budget to that contract - 3. Divides gas into SLOAD and SSTORE portions by percentage - 4. Executes balanceOf (SLOAD) and approve (SSTORE) calls per the ratio - 5. Stresses clients with combined read/write operations on large contracts - """ - stub_name = f"test_mixed_sload_sstore_{token_name}" - gas_costs = fork.gas_costs() - - # Calculate gas costs - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") - - # Fixed overhead for SLOAD loop - sload_loop_overhead = ( - # Attack contract loop overhead - gas_costs.G_VERY_LOW * 2 # MLOAD counter (3*2) - + gas_costs.G_VERY_LOW * 2 # MSTORE selector (3*2) - + gas_costs.G_VERY_LOW * 3 # MLOAD + MSTORE address (3*3) - + gas_costs.G_BASE # POP (2) - + gas_costs.G_BASE * 3 # SUB + MLOAD + MSTORE counter decrement - + gas_costs.G_BASE * 2 # ISZERO * 2 for loop condition (2*2) - + gas_costs.G_MID # JUMPI (8) + """Benchmark mixed SLOAD/SSTORE on bloatnet.""" + # Stub Account + erc20_address = pre.deploy_contract( + code=Bytecode(), + stub=f"test_mixed_sload_sstore_{token_name}", ) - # ERC20 balanceOf internal gas - sload_erc20_internal = ( - gas_costs.G_VERY_LOW # PUSH4 selector (3) - + gas_costs.G_BASE # EQ selector match (2) - + gas_costs.G_MID # JUMPI to function (8) - + gas_costs.G_JUMPDEST # JUMPDEST at function start (1) - + gas_costs.G_VERY_LOW * 2 # CALLDATALOAD arg (3*2) - + gas_costs.G_KECCAK_256 # keccak256 static (30) - + gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic 64 bytes - + gas_costs.G_COLD_SLOAD # Cold SLOAD - always cold - + gas_costs.G_VERY_LOW * 3 # MSTORE result + RETURN setup (3*3) + # Contract Construction + # MEM[0] = function selector + # MEM[32] = address/slot offset (incremented each iteration) + # MEM[64] = spender/amount for approve (copied from MEM[32]) + setup = ( + Op.MSTORE( + 0, + BALANCEOF_SELECTOR, + # gas accounting + old_memory_size=0, + new_memory_size=32, + ) + + Op.MSTORE( + 32, + Op.CALLDATALOAD(64), # Slot Offset + # gas accounting + old_memory_size=32, + new_memory_size=64, + ) + + Op.CALLDATALOAD(0) # [num_sload_calls] ) - # Fixed overhead for SSTORE loop - sstore_loop_overhead = ( - # Attack contract loop body operations - gas_costs.G_VERY_LOW # MSTORE selector at memory[32] (3) - + gas_costs.G_LOW # MLOAD counter (5) - + gas_costs.G_VERY_LOW # MSTORE spender at memory[64] (3) - + gas_costs.G_BASE # POP call result (2) - # Counter decrement - + gas_costs.G_LOW # MLOAD counter (5) - + gas_costs.G_VERY_LOW # PUSH1 1 (3) - + gas_costs.G_VERY_LOW # SUB (3) - + gas_costs.G_VERY_LOW # MSTORE counter back (3) - # While loop condition check - + gas_costs.G_LOW # MLOAD counter (5) - + gas_costs.G_BASE # ISZERO (2) - + gas_costs.G_BASE # ISZERO (2) - + gas_costs.G_MID # JUMPI back to loop start (8) + sload_loop = While( + body=Op.POP( + Op.CALL( + address=erc20_address, + value=0, + args_offset=28, + args_size=36, + ret_offset=0, + ret_size=0, + # gas accounting + address_warm=True, + ) + ) + + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)), + condition=Op.PUSH1(1) # [1, num_sload] + + Op.SWAP1 # [num_sload, 1] + + Op.SUB # [num_sload-1] + + Op.DUP1 # [num_sload-1, num_sload-1] + + Op.ISZERO # [num_sload-1==0, num_sload-1] + + Op.ISZERO, # [num_sload-1!=0, num_sload-1] ) - # ERC20 approve internal gas - # Cold SSTORE: 22100 = 20000 base + 2100 cold access - sstore_erc20_internal = ( - gas_costs.G_VERY_LOW # PUSH4 selector (3) - + gas_costs.G_BASE # EQ selector match (2) - + gas_costs.G_MID # JUMPI to function (8) - + gas_costs.G_JUMPDEST # JUMPDEST at function start (1) - + gas_costs.G_VERY_LOW # CALLDATALOAD spender (3) - + gas_costs.G_VERY_LOW # CALLDATALOAD amount (3) - + gas_costs.G_KECCAK_256 # keccak256 static (30) - + gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic 64 bytes - + gas_costs.G_COLD_SLOAD # Cold SLOAD for allowance check (2100) - + gas_costs.G_STORAGE_SET # SSTORE base cost (20000) - + gas_costs.G_COLD_SLOAD # Additional cold storage access (2100) - + gas_costs.G_VERY_LOW # PUSH1 1 for return value (3) - + gas_costs.G_VERY_LOW # MSTORE return value (3) - + gas_costs.G_VERY_LOW # PUSH1 32 for return size (3) - + gas_costs.G_VERY_LOW # PUSH1 0 for return offset (3) + transition = ( + Op.POP # remove 0 counter from sload loop + + Op.MSTORE(0, APPROVE_SELECTOR) + + Op.CALLDATALOAD(32) # [num_sstore_calls] ) - # Account for cold/warm transitions in CALL costs - # First SLOAD call is COLD (2600), rest are WARM (100) - sload_warm_cost = ( - sload_loop_overhead - + gas_costs.G_WARM_ACCOUNT_ACCESS - + sload_erc20_internal - ) - cold_warm_diff = ( - gas_costs.G_COLD_ACCOUNT_ACCESS - gas_costs.G_WARM_ACCOUNT_ACCESS + sstore_loop = While( + body=( + Op.MSTORE(64, Op.MLOAD(32)) + + Op.POP( + Op.CALL( + address=erc20_address, + value=0, + args_offset=28, + args_size=68, + ret_offset=0, + ret_size=0, + # gas accounting + address_warm=True, + ) + ) + + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)) + ), + condition=Op.PUSH1(1) # [1, num_sstore] + + Op.SWAP1 # [num_sstore, 1] + + Op.SUB # [num_sstore-1] + + Op.DUP1 # [num_sstore-1, num_sstore-1] + + Op.ISZERO # [num_sstore-1==0, num_sstore-1] + + Op.ISZERO, # [num_sstore-1!=0, num_sstore-1] ) - # First SSTORE call is COLD (2600), rest are WARM (100) - sstore_warm_cost = ( - sstore_loop_overhead - + gas_costs.G_WARM_ACCOUNT_ACCESS - + sstore_erc20_internal - ) + # Contract Deployment + code = setup + sload_loop + transition + sstore_loop + attack_contract_address = pre.deploy_contract(code=code) - # Deploy ERC20 contract using stub - erc20_address = pre.deploy_contract( - code=Bytecode(), - stub=stub_name, + # Gas Accounting + setup_cost = setup.gas_cost(fork) + sload_loop_cost = sload_loop.gas_cost(fork) + transition_cost = transition.gas_cost(fork) + sstore_loop_cost = sstore_loop.gas_cost(fork) + + access_list = [AccessList(address=erc20_address, storage_keys=[])] + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + access_list=access_list, + calldata=b"\xff" * 96, ) - # Calculate number of transactions needed (EIP-7825 compliance) - num_txs = max(1, math.ceil(gas_benchmark_value / tx_gas_limit)) - - # Calculate total available gas and split by percentage - total_available_gas = gas_benchmark_value - (intrinsic_gas * num_txs) - sload_gas = (total_available_gas * sload_percent) // 100 - sstore_gas = (total_available_gas * sstore_percent) // 100 - - # Calculate total calls for each operation type - total_sload_calls = int((sload_gas - cold_warm_diff) // sload_warm_cost) - total_sstore_calls = int((sstore_gas - cold_warm_diff) // sstore_warm_cost) - - # Distribute calls across transactions - sload_calls_per_tx = total_sload_calls // num_txs - sstore_calls_per_tx = total_sstore_calls // num_txs - - # Log test requirements - print( - f"Token: {token_name}, " - f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas " - f"({sload_percent}% SLOAD, {sstore_percent}% SSTORE). " - f"{total_sload_calls} balanceOf, {total_sstore_calls} approve " - f"across {num_txs} tx(s)." + # ERC20 balanceOf bytecode structure: + sload_dispatch = ( + # Selector dispatch + Op.PUSH4(BALANCEOF_SELECTOR) + + Op.EQ + + Op.JUMPI + # Function body + + Op.JUMPDEST + + Op.CALLDATALOAD(4) + + Op.MSTORE(0) + + Op.MSTORE(32, 0) + + Op.SHA3( + 0, + 64, + # gas accounting + data_size=64, + old_memory_size=0, + new_memory_size=64, + ) + + Op.SLOAD + # Return value + + Op.MSTORE(0) + + Op.RETURN(0, 32) ) - # Build transactions - txs = [] - post = {} - sload_remaining = total_sload_calls - sstore_remaining = total_sstore_calls - - for i in range(num_txs): - # Last tx gets remaining calls - tx_sload_calls = ( - sload_calls_per_tx if i < num_txs - 1 else sload_remaining + sload_dispatch_cost = sload_dispatch.gas_cost(fork) + + # ERC20 approve bytecode structure: + sstore_dispatch = ( + # Selector dispatch + Op.PUSH4(APPROVE_SELECTOR) + + Op.EQ + + Op.JUMPI + # Function body + + Op.JUMPDEST + + Op.CALLDATALOAD(4) + + Op.CALLDATALOAD(36) + + Op.MSTORE(0, Op.CALLER) + + Op.MSTORE(32, 1) + + Op.SHA3( + 0, + 64, + # gas accounting + data_size=64, + old_memory_size=0, + new_memory_size=64, ) - tx_sstore_calls = ( - sstore_calls_per_tx if i < num_txs - 1 else sstore_remaining - ) - sload_remaining -= tx_sload_calls - sstore_remaining -= tx_sstore_calls - - # Build attack code for this transaction - attack_code: Bytecode = ( - Op.JUMPDEST # Entry point - + Op.MSTORE(offset=0, value=BALANCEOF_SELECTOR) - # SLOAD operations (balanceOf) - + Op.MSTORE(offset=32, value=tx_sload_calls) - + While( - condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, - body=( - Op.CALL( - address=erc20_address, - value=0, - args_offset=28, - args_size=36, - ret_offset=0, - ret_size=0, - ) - + Op.POP - + Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1)) - ), - ) - # SSTORE operations (approve) - + Op.MSTORE(offset=0, value=APPROVE_SELECTOR) - + Op.MSTORE(offset=32, value=tx_sstore_calls) - + While( - condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, - body=( - Op.MSTORE(offset=64, value=Op.MLOAD(32)) - + Op.CALL( - address=erc20_address, - value=0, - args_offset=28, - args_size=68, - ret_offset=0, - ret_size=0, - ) - + Op.POP - + Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1)) - ), - ) + + Op.MSTORE(32) + + Op.MSTORE(0, Op.CALLDATALOAD(4)) + + Op.SHA3( + 0, + 64, + # gas accounting + data_size=64, ) + + Op.DUP1 + + Op.SLOAD.with_metadata(access_warm=False) + + Op.POP + + Op.SSTORE + # Return true + + Op.PUSH1(1) + + Op.MSTORE(0) + + Op.PUSH1(32) + + Op.PUSH1(0) + + Op.RETURN(0, 32) + ) - # Deploy attack contract for this tx - attack_address = pre.deploy_contract(code=attack_code) + sstore_dispatch_cost = sstore_dispatch.gas_cost(fork) - # Calculate gas for this transaction - this_tx_gas = min( - tx_gas_limit, gas_benchmark_value - (i * tx_gas_limit) - ) + sload_iter_cost = sload_loop_cost + sload_dispatch_cost + sstore_iter_cost = sstore_loop_cost + sstore_dispatch_cost + fixed_overhead = intrinsic_gas + setup_cost + transition_cost + + # Attack Loop + gas_remaining = gas_benchmark_value + txs = [] + slot_offset = 0 + + while gas_remaining > fixed_overhead + sload_iter_cost + sstore_iter_cost: + gas_available = min(gas_remaining, tx_gas_limit) + + if gas_available < fixed_overhead + sload_iter_cost + sstore_iter_cost: + break + + available = gas_available - fixed_overhead + sload_gas = (available * sload_percent) // 100 + sstore_gas = (available * sstore_percent) // 100 + + num_sload = sload_gas // sload_iter_cost + num_sstore = sstore_gas // sstore_iter_cost + + if num_sload == 0 or num_sstore == 0: + break + + calldata = Hash(num_sload) + Hash(num_sstore) + Hash(slot_offset) txs.append( Transaction( - to=attack_address, - gas_limit=this_tx_gas, + gas_limit=gas_available, + data=calldata, + to=attack_contract_address, sender=pre.fund_eoa(), + access_list=access_list, ) ) - # Add to post-state - post[attack_address] = Account(storage={}) + gas_remaining -= gas_available + slot_offset += num_sload + num_sstore - blockchain_test( + benchmark_test( pre=pre, blocks=[Block(txs=txs)], - post=post, ) diff --git a/tests/benchmark/stateful/bloatnet/test_single_opcode.py b/tests/benchmark/stateful/bloatnet/test_single_opcode.py index ecff876acd..dc0da3a20d 100644 --- a/tests/benchmark/stateful/bloatnet/test_single_opcode.py +++ b/tests/benchmark/stateful/bloatnet/test_single_opcode.py @@ -7,23 +7,18 @@ to benchmark specific state-handling bottlenecks. """ -import json -import math from functools import partial -from pathlib import Path from typing import Callable, List import pytest from execution_testing import ( EOA, AccessList, - Account, Address, Alloc, AuthorizationTuple, BenchmarkTestFiller, Block, - BlockchainTestFiller, Bytecode, Fork, Hash, @@ -36,31 +31,16 @@ While, ) +from tests.benchmark.stateful.helpers import ( + APPROVE_SELECTOR, + BALANCEOF_SELECTOR, + SLOAD_TOKENS, + SSTORE_TOKENS, +) + REFERENCE_SPEC_GIT_PATH = "DUMMY/bloatnet.md" REFERENCE_SPEC_VERSION = "1.0" -# ERC20 function selectors -BALANCEOF_SELECTOR = 0x70A08231 # balanceOf(address) -APPROVE_SELECTOR = 0x095EA7B3 # approve(address,uint256) -ALLOWANCE_SELECTOR = 0xDD62ED3E # allowance(address,address) - -# Load token names from stubs.json for test parametrization -_STUBS_FILE = Path(__file__).parent / "stubs_bloatnet.json" -with open(_STUBS_FILE) as f: - _STUBS = json.load(f) - -# Extract unique token names for each test type -SLOAD_TOKENS = [ - k.replace("test_sload_empty_erc20_balanceof_", "") - for k in _STUBS.keys() - if k.startswith("test_sload_empty_erc20_balanceof_") -] -SSTORE_TOKENS = [ - k.replace("test_sstore_erc20_approve_", "") - for k in _STUBS.keys() - if k.startswith("test_sstore_erc20_approve_") -] - # SLOAD BENCHMARK ARCHITECTURE: # @@ -107,304 +87,299 @@ # - Simulates real-world contract state accumulation over time -@pytest.mark.valid_from("Prague") @pytest.mark.parametrize("token_name", SLOAD_TOKENS) def test_sload_empty_erc20_balanceof( - blockchain_test: BlockchainTestFiller, + benchmark_test: BenchmarkTestFiller, pre: Alloc, fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, token_name: str, ) -> None: - """ - BloatNet SLOAD benchmark using ERC20 balanceOf queries on random - addresses. - - This test: - 1. Uses a single ERC20 contract specified by token_name parameter - 2. Allocates full gas budget to that contract - 3. Queries balanceOf() incrementally starting by 0 and increasing by 1 - (thus forcing SLOADs to non-existing addresses) - 4. Splits into multiple transactions if gas_benchmark_value > tx_gas_limit - (EIP-7825 compliance) - """ - stub_name = f"test_sload_empty_erc20_balanceof_{token_name}" - gas_costs = fork.gas_costs() - - # Calculate gas costs - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") - - # Fixed overhead per iteration (loop mechanics, independent of warm/cold) - loop_overhead = ( - # Attack contract loop overhead - gas_costs.G_VERY_LOW * 2 # MLOAD counter (3*2) - + gas_costs.G_VERY_LOW * 2 # MSTORE selector (3*2) - + gas_costs.G_VERY_LOW * 3 # MLOAD + MSTORE address (3*3) - + gas_costs.G_BASE # POP (2) - + gas_costs.G_BASE * 3 # SUB + MLOAD + MSTORE counter decrement - + gas_costs.G_BASE * 2 # ISZERO * 2 for loop condition (2*2) - + gas_costs.G_MID # JUMPI (8) + """Benchmark SLOAD using ERC20 balanceOf on bloatnet.""" + # Stub Account + erc20_address = pre.deploy_contract( + code=Bytecode(), + stub=f"test_sload_empty_erc20_balanceof_{token_name}", ) - # ERC20 internal gas (same for all calls) - erc20_internal_gas = ( - gas_costs.G_VERY_LOW # PUSH4 selector (3) - + gas_costs.G_BASE # EQ selector match (2) - + gas_costs.G_MID # JUMPI to function (8) - + gas_costs.G_JUMPDEST # JUMPDEST at function start (1) - + gas_costs.G_VERY_LOW * 2 # CALLDATALOAD arg (3*2) - + gas_costs.G_KECCAK_256 # keccak256 static (30) - + gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic 64 bytes - + gas_costs.G_COLD_SLOAD # Cold SLOAD - always cold - + gas_costs.G_VERY_LOW * 3 # MSTORE result + RETURN setup (3*3) - # RETURN costs 0 gas + # MEM[0] = function selector + # MEM[32] = starting address offset + setup = ( + Op.MSTORE( + 0, + BALANCEOF_SELECTOR, + # gas accounting + old_memory_size=0, + new_memory_size=32, + ) + + Op.MSTORE( + 32, + Op.CALLDATALOAD(32), # Address Offset + # gas accounting + old_memory_size=32, + new_memory_size=64, + ) + + Op.CALLDATALOAD(0) # [num_calls] ) - # First call is COLD (2600), subsequent are WARM (100) - warm_call_cost = ( - loop_overhead + gas_costs.G_WARM_ACCOUNT_ACCESS + erc20_internal_gas - ) - cold_warm_diff = ( - gas_costs.G_COLD_ACCOUNT_ACCESS - gas_costs.G_WARM_ACCOUNT_ACCESS + loop = While( + body=Op.POP( + Op.CALL( + address=erc20_address, + value=0, + args_offset=28, + args_size=36, + ret_offset=0, + ret_size=0, + # gas accounting + address_warm=True, + ) + ) + + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)), + condition=Op.PUSH1(1) # [1, num_calls] + + Op.SWAP1 # [num_calls, 1] + + Op.SUB # [num_calls-1] + + Op.DUP1 # [num_calls-1, num_calls-1] + + Op.ISZERO # [num_calls-1==0, num_calls-1] + + Op.ISZERO, # [num_calls-1!=0, num_calls-1] ) - # Deploy ERC20 contract using stub - # In execute mode: stub points to already-deployed contract on chain - # In fill mode: empty bytecode is deployed as placeholder - erc20_address = pre.deploy_contract( - code=Bytecode(), - stub=stub_name, - ) + # Contract Deployment + code = setup + loop + attack_contract_address = pre.deploy_contract(code=code) - # Calculate number of transactions needed (EIP-7825 compliance) - num_txs = max(1, math.ceil(gas_benchmark_value / tx_gas_limit)) + # Gas Accounting + setup_cost = setup.gas_cost(fork) + loop_cost = loop.gas_cost(fork) - # Calculate total calls based on full gas budget - total_available_gas = gas_benchmark_value - (intrinsic_gas * num_txs) - total_calls = int((total_available_gas - cold_warm_diff) // warm_call_cost) - calls_per_tx = total_calls // num_txs + access_list = [AccessList(address=erc20_address, storage_keys=[])] + intrinsic_gas_with_access_list = ( + fork.transaction_intrinsic_cost_calculator()( + access_list=access_list, + calldata=b"\xff" * 64, + ) + ) - # Log test requirements - print( - f"Token: {token_name}, " - f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas, " - f"{total_calls} balanceOf calls across {num_txs} transaction(s)." + # ERC20 balanceOf bytecode structure: + function_dispatch = ( + # Selector dispatch + Op.PUSH4(BALANCEOF_SELECTOR) + + Op.EQ + + Op.JUMPI + # Function body + + Op.JUMPDEST + + Op.CALLDATALOAD(4) + + Op.MSTORE(0) + + Op.MSTORE(32, 0) + + Op.SHA3( + 0, + 64, + # gas accounting + data_size=64, + old_memory_size=0, + new_memory_size=64, + ) + + Op.SLOAD + # Return value + + Op.MSTORE(0) + + Op.RETURN(0, 32) ) - # Build transactions + function_dispatch_cost = function_dispatch.gas_cost(fork) + + # Transaction Loops txs = [] - post = {} - calls_remaining = total_calls - - for i in range(num_txs): - # Last tx gets remaining calls - tx_calls = calls_per_tx if i < num_txs - 1 else calls_remaining - calls_remaining -= tx_calls - - # Build attack code for this transaction - attack_code: Bytecode = ( - Op.JUMPDEST # Entry point - + Op.MSTORE(offset=0, value=BALANCEOF_SELECTOR) - + Op.MSTORE(offset=32, value=tx_calls) - + While( - condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, - body=( - Op.CALL( - address=erc20_address, - value=0, - args_offset=28, - args_size=36, - ret_offset=0, - ret_size=0, - ) - + Op.POP - + Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1)) - ), - ) - ) + gas_remaining = gas_benchmark_value + slot_offset = 0 - # Deploy attack contract for this tx - attack_address = pre.deploy_contract(code=attack_code) + while gas_remaining > intrinsic_gas_with_access_list: + gas_available = min(gas_remaining, tx_gas_limit) - # Calculate gas for this transaction - this_tx_gas = min( - tx_gas_limit, gas_benchmark_value - (i * tx_gas_limit) - ) + if gas_available < intrinsic_gas_with_access_list + setup_cost: + break + + num_calls = ( + gas_available - intrinsic_gas_with_access_list - setup_cost + ) // (function_dispatch_cost + loop_cost) + + if num_calls == 0: + break + + calldata = Hash(num_calls) + Hash(slot_offset) txs.append( Transaction( - to=attack_address, - gas_limit=this_tx_gas, + gas_limit=gas_available, + data=calldata, + to=attack_contract_address, sender=pre.fund_eoa(), + access_list=access_list, ) ) - # Add to post-state - post[attack_address] = Account(storage={}) + gas_remaining -= gas_available + slot_offset += num_calls - blockchain_test( + benchmark_test( pre=pre, blocks=[Block(txs=txs)], - post=post, ) -@pytest.mark.valid_from("Prague") @pytest.mark.parametrize("token_name", SSTORE_TOKENS) def test_sstore_erc20_approve( - blockchain_test: BlockchainTestFiller, + benchmark_test: BenchmarkTestFiller, pre: Alloc, fork: Fork, gas_benchmark_value: int, tx_gas_limit: int, token_name: str, ) -> None: - """ - BloatNet SSTORE benchmark using ERC20 approve to write to storage. - - This test: - 1. Uses a single ERC20 contract specified by token_name parameter - 2. Allocates full gas budget to that contract - 3. Calls approve(spender, amount) incrementally (counter as spender) - 4. Forces SSTOREs to allowance mapping storage slots - 5. Splits into multiple transactions if gas_benchmark_value > tx_gas_limit - (EIP-7825 compliance) - """ - stub_name = f"test_sstore_erc20_approve_{token_name}" - gas_costs = fork.gas_costs() - - # Calculate gas costs - intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") - - # Fixed overhead per iteration (loop mechanics, independent of warm/cold) - loop_overhead = ( - # Attack contract loop body operations - gas_costs.G_VERY_LOW # MSTORE selector at memory[32] (3) - + gas_costs.G_LOW # MLOAD counter (5) - + gas_costs.G_VERY_LOW # MSTORE spender at memory[64] (3) - + gas_costs.G_BASE # POP call result (2) - # Counter decrement: MSTORE(0, SUB(MLOAD(0), 1)) - + gas_costs.G_LOW # MLOAD counter (5) - + gas_costs.G_VERY_LOW # PUSH1 1 (3) - + gas_costs.G_VERY_LOW # SUB (3) - + gas_costs.G_VERY_LOW # MSTORE counter back (3) - # While loop condition check - + gas_costs.G_LOW # MLOAD counter (5) - + gas_costs.G_BASE # ISZERO (2) - + gas_costs.G_BASE # ISZERO (2) - + gas_costs.G_MID # JUMPI back to loop start (8) + """Benchmark SSTORE using ERC20 approve on bloatnet.""" + # Stub Account + erc20_address = pre.deploy_contract( + code=Bytecode(), + stub=f"test_sstore_erc20_approve_{token_name}", ) - # ERC20 internal gas (same for all calls) - # Note: SSTORE cost is 22100 for cold slot, zero-to-non-zero - # (20000 base + 2100 cold access) - erc20_internal_gas = ( - gas_costs.G_VERY_LOW # PUSH4 selector (3) - + gas_costs.G_BASE # EQ selector match (2) - + gas_costs.G_MID # JUMPI to function (8) - + gas_costs.G_JUMPDEST # JUMPDEST at function start (1) - + gas_costs.G_VERY_LOW # CALLDATALOAD spender (3) - + gas_costs.G_VERY_LOW # CALLDATALOAD amount (3) - + gas_costs.G_KECCAK_256 # keccak256 static (30) - + gas_costs.G_KECCAK_256_WORD * 2 # keccak256 dynamic 64 bytes - + gas_costs.G_COLD_SLOAD # Cold SLOAD for allowance check (2100) - + gas_costs.G_STORAGE_SET # SSTORE base cost (20000) - + gas_costs.G_COLD_SLOAD # Additional cold storage access (2100) - + gas_costs.G_VERY_LOW # PUSH1 1 for return value (3) - + gas_costs.G_VERY_LOW # MSTORE return value (3) - + gas_costs.G_VERY_LOW # PUSH1 32 for return size (3) - + gas_costs.G_VERY_LOW # PUSH1 0 for return offset (3) - # RETURN costs 0 gas + # MEM[0] = function selector + # MEM[32] = starting address offset + setup = ( + Op.MSTORE( + 0, + APPROVE_SELECTOR, + # gas accounting + old_memory_size=0, + new_memory_size=32, + ) + + Op.MSTORE( + 32, + Op.CALLDATALOAD(32), # Address Offset + # gas accounting + old_memory_size=32, + new_memory_size=64, + ) + + Op.CALLDATALOAD(0) # [num_calls] ) - # First call is COLD (2600), subsequent are WARM (100) - warm_call_cost = ( - loop_overhead + gas_costs.G_WARM_ACCOUNT_ACCESS + erc20_internal_gas + loop = While( + body=( + Op.MSTORE(64, Op.MLOAD(32)) + + Op.POP( + Op.CALL( + address=erc20_address, + value=0, + args_offset=28, + args_size=68, + ret_offset=0, + ret_size=0, + # gas accounting + address_warm=True, + ) + ) + + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)) + ), + condition=Op.PUSH1(1) # [1, num_calls] + + Op.SWAP1 # [num_calls, 1] + + Op.SUB # [num_calls-1] + + Op.DUP1 # [num_calls-1, num_calls-1] + + Op.ISZERO # [num_calls-1==0, num_calls-1] + + Op.ISZERO, # [num_calls-1!=0, num_calls-1] ) - cold_warm_diff = ( - gas_costs.G_COLD_ACCOUNT_ACCESS - gas_costs.G_WARM_ACCOUNT_ACCESS + + # Contract Deployment + code = setup + loop + attack_contract_address = pre.deploy_contract(code=code) + + # Gas Accounting + setup_cost = setup.gas_cost(fork) + loop_cost = loop.gas_cost(fork) + access_list = [AccessList(address=erc20_address, storage_keys=[])] + intrinsic_gas_with_access_list = ( + fork.transaction_intrinsic_cost_calculator()( + access_list=access_list, + calldata=b"\xff" * 64, + ) ) - # Deploy ERC20 contract using stub - erc20_address = pre.deploy_contract( - code=Bytecode(), - stub=stub_name, + function_dispatch = ( + # Selector dispatch + Op.PUSH4(APPROVE_SELECTOR) + + Op.EQ + + Op.JUMPI + # Function body + + Op.JUMPDEST + + Op.CALLDATALOAD(4) + + Op.CALLDATALOAD(36) + + Op.MSTORE(0, Op.CALLER) + + Op.MSTORE(32, 1) + + Op.SHA3( + 0, + 64, + # gas accounting + data_size=64, + old_memory_size=0, + new_memory_size=64, + ) + + Op.MSTORE(32) + + Op.MSTORE(0, Op.CALLDATALOAD(4)) + + Op.SHA3( + 0, + 64, + # gas accounting + data_size=64, + ) + + Op.DUP1 + + Op.SLOAD.with_metadata(access_warm=False) + + Op.POP + + Op.SSTORE + # Return true + + Op.PUSH1(1) + + Op.MSTORE(0) + + Op.PUSH1(32) + + Op.PUSH1(0) + + Op.RETURN(0, 32) ) - # Calculate number of transactions needed (EIP-7825 compliance) - num_txs = max(1, math.ceil(gas_benchmark_value / tx_gas_limit)) + function_dispatch_cost = function_dispatch.gas_cost(fork) - # Calculate total calls based on full gas budget - total_available_gas = gas_benchmark_value - (intrinsic_gas * num_txs) - total_calls = int((total_available_gas - cold_warm_diff) // warm_call_cost) - calls_per_tx = total_calls // num_txs + # Transaction Loops + txs = [] + gas_remaining = gas_benchmark_value + slot_offset = 0 - # Log test requirements - print( - f"Token: {token_name}, " - f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas, " - f"{total_calls} approve calls across {num_txs} transaction(s)." - ) + while gas_remaining > intrinsic_gas_with_access_list: + gas_available = min(gas_remaining, tx_gas_limit) - # Build transactions - txs = [] - post = {} - calls_remaining = total_calls - - for i in range(num_txs): - # Last tx gets remaining calls - tx_calls = calls_per_tx if i < num_txs - 1 else calls_remaining - calls_remaining -= tx_calls - - # Build attack code for this transaction - attack_code: Bytecode = ( - Op.JUMPDEST # Entry point - + Op.MSTORE(offset=0, value=APPROVE_SELECTOR) - + Op.MSTORE(offset=32, value=tx_calls) - + While( - condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, - body=( - # Store spender at memory[64] (counter as spender/amount) - Op.MSTORE(offset=64, value=Op.MLOAD(32)) - # Call approve(spender, amount) on ERC20 contract - + Op.CALL( - address=erc20_address, - value=0, - args_offset=28, - args_size=68, - ret_offset=0, - ret_size=0, - ) - + Op.POP - + Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1)) - ), - ) - ) + if gas_available < intrinsic_gas_with_access_list + setup_cost: + break - # Deploy attack contract for this tx - attack_address = pre.deploy_contract(code=attack_code) + num_calls = ( + gas_available - intrinsic_gas_with_access_list - setup_cost + ) // (function_dispatch_cost + loop_cost) - # Calculate gas for this transaction - this_tx_gas = min( - tx_gas_limit, gas_benchmark_value - (i * tx_gas_limit) - ) + if num_calls == 0: + break + + calldata = Hash(num_calls) + Hash(slot_offset) txs.append( Transaction( - to=attack_address, - gas_limit=this_tx_gas, + gas_limit=gas_available, + data=calldata, + to=attack_contract_address, sender=pre.fund_eoa(), + access_list=access_list, ) ) - # Add to post-state - post[attack_address] = Account(storage={}) + gas_remaining -= gas_available + slot_offset += num_calls - blockchain_test( + benchmark_test( pre=pre, blocks=[Block(txs=txs)], - post=post, ) @@ -799,6 +774,7 @@ def test_sstore_variants( blocks.append(Block(txs=exec_txs)) benchmark_test( + pre=pre, blocks=blocks, expected_benchmark_gas_used=expected_gas_used, ) @@ -934,6 +910,7 @@ def test_storage_sload_benchmark( blocks.append(Block(txs=exec_txs)) benchmark_test( + pre=pre, blocks=blocks, expected_benchmark_gas_used=expected_gas_used, ) diff --git a/tests/benchmark/stateful/helpers.py b/tests/benchmark/stateful/helpers.py new file mode 100644 index 0000000000..de2a3a78e4 --- /dev/null +++ b/tests/benchmark/stateful/helpers.py @@ -0,0 +1,31 @@ +"""Shared constants and helpers for stateful benchmark tests.""" + +import json +from pathlib import Path + +# ERC20 function selectors +BALANCEOF_SELECTOR = 0x70A08231 # balanceOf(address) +APPROVE_SELECTOR = 0x095EA7B3 # approve(address,uint256) +ALLOWANCE_SELECTOR = 0xDD62ED3E # allowance(address,address) + +# Load token names from stubs_bloatnet.json for test parametrization +_STUBS_FILE = Path(__file__).parent / "bloatnet" / "stubs_bloatnet.json" +with open(_STUBS_FILE) as f: + _STUBS = json.load(f) + +# Extract unique token names for each test type +SLOAD_TOKENS = [ + k.replace("test_sload_empty_erc20_balanceof_", "") + for k in _STUBS.keys() + if k.startswith("test_sload_empty_erc20_balanceof_") +] +SSTORE_TOKENS = [ + k.replace("test_sstore_erc20_approve_", "") + for k in _STUBS.keys() + if k.startswith("test_sstore_erc20_approve_") +] +MIXED_TOKENS = [ + k.replace("test_mixed_sload_sstore_", "") + for k in _STUBS.keys() + if k.startswith("test_mixed_sload_sstore_") +]