diff --git a/tests/benchmark/stateful/bloatnet/test_multi_opcode.py b/tests/benchmark/stateful/bloatnet/test_multi_opcode.py index ea6f9872646..001481d03bb 100644 --- a/tests/benchmark/stateful/bloatnet/test_multi_opcode.py +++ b/tests/benchmark/stateful/bloatnet/test_multi_opcode.py @@ -465,3 +465,225 @@ def test_bloatnet_balance_extcodehash( blocks=[Block(txs=[attack_tx])], post=post, ) + + +# ERC20 function selectors +BALANCEOF_SELECTOR = 0x70A08231 # balanceOf(address) +APPROVE_SELECTOR = 0x095EA7B3 # approve(address,uint256) + + +@pytest.mark.valid_from("Prague") +@pytest.mark.parametrize( + "sload_percent,sstore_percent", + [ + pytest.param(50, 50, id="50-50"), + pytest.param(70, 30, id="70-30"), + pytest.param(90, 10, id="90-10"), + ], +) +def test_mixed_sload_sstore( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + gas_benchmark_value: int, + address_stubs, + sload_percent: int, + sstore_percent: int, +): + """ + BloatNet mixed SLOAD/SSTORE benchmark with configurable operation ratios. + + This test: + 1. Auto-discovers ERC20 contracts from stubs + 2. Divides gas budget evenly across all contracts + 3. For each contract, 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 + """ + gas_costs = fork.gas_costs() + + # Calculate gas costs + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") + + num_contracts = len(address_stubs.root) + + # Cost per SLOAD iteration (balanceOf call) + sload_cost_per_iteration = ( + # 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 for counter decrement (2*3) + + gas_costs.G_BASE * 2 # ISZERO * 2 for loop condition (2*2) + + gas_costs.G_MID # JUMPI (8) + # CALL to ERC20 contract + + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL to same contract (100) + # Inside ERC20 balanceOf + + 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 for 64 bytes (2*6) + + gas_costs.G_COLD_SLOAD # Cold SLOAD (2100) + + gas_costs.G_VERY_LOW * 3 # MSTORE result + RETURN setup (3*3) + ) + + # Cost per SSTORE iteration (approve call) + sstore_cost_per_iteration = ( + # 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_LOW # MLOAD counter (5) + + gas_costs.G_VERY_LOW # MSTORE amount at memory[96] (3) + # CALL to ERC20 contract + + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL base cost (100) + + 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) + # Inside ERC20 approve function + + 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 for 64 bytes (12) + + gas_costs.G_STORAGE_SET # SSTORE to zero slot (20000) + + 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) + ) + + # Calculate gas budget per contract + available_gas = gas_benchmark_value - intrinsic_gas + gas_per_contract = available_gas // num_contracts + + # For each contract, split gas by percentage + sload_gas_per_contract = (gas_per_contract * sload_percent) // 100 + sstore_gas_per_contract = (gas_per_contract * sstore_percent) // 100 + + # Calculate calls per contract per operation + sload_calls_per_contract = int(sload_gas_per_contract // sload_cost_per_iteration) + sstore_calls_per_contract = int(sstore_gas_per_contract // sstore_cost_per_iteration) + + # Deploy all discovered ERC20 contracts using stubs + erc20_addresses = [] + for stub_name in address_stubs.root: + addr = pre.deploy_contract( + code=Bytecode(), + stub=stub_name, + ) + erc20_addresses.append(addr) + + # Log test requirements + print( + f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas. " + f"~{gas_per_contract / 1_000_000:.1f}M gas per contract " + f"({sload_percent}% SLOAD, {sstore_percent}% SSTORE). " + f"Per contract: {sload_calls_per_contract} balanceOf calls, " + f"{sstore_calls_per_contract} approve calls." + ) + + # Build attack code that loops through each contract + attack_code: Bytecode = ( + Op.JUMPDEST # Entry point + + Op.MSTORE(offset=0, value=BALANCEOF_SELECTOR) # Store selector once for all contracts + ) + + for erc20_address in erc20_addresses: + # For each contract, execute SLOAD operations (balanceOf) + attack_code += ( + # Initialize counter in memory[32] = number of balanceOf calls + Op.MSTORE(offset=32, value=sload_calls_per_contract) + # Loop for balanceOf calls + + While( + condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, + body=( + # Call balanceOf(address) on ERC20 contract + # args_offset=28 reads: selector from MEM[28:32] + address + # from MEM[32:64] + Op.CALL( + address=erc20_address, + value=0, + args_offset=28, + args_size=36, + ret_offset=0, + ret_size=0, + ) + + Op.POP # Discard CALL success status + # Decrement counter + + Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1)) + ), + ) + ) + + # For each contract, execute SSTORE operations (approve) + # Reuse the same memory layout as balanceOf + attack_code += ( + # Store approve selector at memory[0] (reusing same slot) + Op.MSTORE(offset=0, value=APPROVE_SELECTOR) + # Initialize counter in memory[32] = number of approve calls + # (reusing same slot) + + Op.MSTORE(offset=32, value=sstore_calls_per_contract) + # Loop for approve calls + + While( + condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, + body=( + # Store spender address at memory[64] (use counter) + Op.MSTORE(offset=64, value=Op.MLOAD(32)) + # Store amount at memory[96] (use counter as amount) + + Op.MSTORE(offset=96, value=Op.MLOAD(32)) + # Call approve(spender, amount) on ERC20 contract + # args_offset=28 reads: selector from MEM[28:32] + spender + # from MEM[32:64] + amount from MEM[64:96] + + Op.CALL( + address=erc20_address, + value=0, + args_offset=28, + args_size=68, + ret_offset=0, + ret_size=0, + ) + + Op.POP # Discard CALL success status + # Decrement counter + + Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1)) + ), + ) + ) + + # Deploy attack contract + attack_address = pre.deploy_contract(code=attack_code) + + # Run the attack + attack_tx = Transaction( + to=attack_address, + gas_limit=gas_benchmark_value, + sender=pre.fund_eoa(), + ) + + # Post-state + post = { + attack_address: Account(storage={}), + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[attack_tx])], + post=post, + ) diff --git a/tests/benchmark/stateful/bloatnet/test_single_opcode.py b/tests/benchmark/stateful/bloatnet/test_single_opcode.py new file mode 100644 index 00000000000..4f56935e1d8 --- /dev/null +++ b/tests/benchmark/stateful/bloatnet/test_single_opcode.py @@ -0,0 +1,336 @@ +""" +abstract: BloatNet single-opcode benchmark cases for state-related operations. + + These tests focus on individual EVM opcodes (SLOAD, SSTORE) to measure + their performance when accessing many storage slots across pre-deployed + contracts. Unlike multi-opcode tests, these isolate single operations + to benchmark specific state-handling bottlenecks. +""" + +import pytest + +from ethereum_test_forks import Fork +from ethereum_test_tools import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Transaction, + While, +) +from ethereum_test_vm import Bytecode +from ethereum_test_vm import Opcodes as Op + +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) + + +# SLOAD BENCHMARK ARCHITECTURE: +# +# [Pre-deployed ERC20 Contract] ──── Storage slots for balances +# │ +# ��� balanceOf(address) → SLOAD(keccak256(address || slot)) +# │ +# [Attack Contract] ──CALL──► ERC20.balanceOf(random_address) +# │ +# └─► Loop(i=0 to N): +# 1. Generate random address from counter +# 2. CALL balanceOf(random_address) → forces cold SLOAD +# 3. Most addresses have zero balance → empty storage slots +# +# WHY IT STRESSES CLIENTS: +# - Each balanceOf() call forces a cold SLOAD on a likely-empty slot +# - Storage slot = keccak256(address || balances_slot) +# - Random addresses ensure maximum cache misses +# - Tests client's sparse storage handling efficiency + + +@pytest.mark.valid_from("Prague") +def test_sload_empty_erc20_balanceof( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + gas_benchmark_value: int, + address_stubs, +): + """ + BloatNet SLOAD benchmark using ERC20 balanceOf queries on random addresses. + + This test: + 1. Auto-discovers ERC20 contracts from stubs (pattern: erc20_contract_*) + 2. Splits gas budget evenly across all discovered contracts + 3. Queries balanceOf() incrementally starting by 0 and increasing by 1. + (thus forcing SLOADs to non-existing addresses.) + """ + gas_costs = fork.gas_costs() + + # Calculate gas costs + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") + + # Cost per iteration (attack contract overhead + balanceOf call) + cost_per_iteration = ( + # 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 for counter decrement (2*3) + + gas_costs.G_BASE * 2 # ISZERO * 2 for loop condition (2*2) + + gas_costs.G_MID # JUMPI (8) + # CALL to ERC20 contract + + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL to same contract (100) + # Inside ERC20 balanceOf + + 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 for 64 bytes (2*6) + + gas_costs.G_COLD_SLOAD # Cold SLOAD (2100) + + gas_costs.G_VERY_LOW * 3 # MSTORE result + RETURN setup (3*3) + # RETURN costs 0 gas + ) + + num_contracts = len(address_stubs.root) + + # Calculate gas budget per contract and calls per contract + available_gas = gas_benchmark_value - intrinsic_gas + gas_per_contract = available_gas // num_contracts + calls_per_contract = int(gas_per_contract // cost_per_iteration) + + # Deploy all discovered ERC20 contracts using stubs + # In execute mode: stubs point to already-deployed contracts on chain + # In fill mode: empty bytecode is deployed as placeholder + erc20_addresses = [] + for stub_name in address_stubs.root: + addr = pre.deploy_contract( + code=Bytecode(), # Required parameter, ignored for stubs in execute mode + stub=stub_name, + ) + erc20_addresses.append(addr) + + # Log test requirements + print( + f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas. " + f"~{gas_per_contract / 1_000_000:.1f}M gas per contract, " + f"{calls_per_contract} balanceOf calls per contract." + ) + + # Build attack code that loops through each contract + attack_code: Bytecode = ( + Op.JUMPDEST # Entry point + + Op.MSTORE(offset=0, value=BALANCEOF_SELECTOR) # Store selector once for all contracts + ) + + for erc20_address in erc20_addresses: + # For each contract, initialize counter and loop + attack_code += ( + # Initialize counter in memory[32] = number of calls + Op.MSTORE(offset=32, value=calls_per_contract) + # Loop for this specific contract + + While( + condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, # Continue while counter > 0 + body=( + # Call balanceOf(address) on ERC20 contract + # args_offset=28 reads: selector from MEM[28:32] + address + # from MEM[32:64] + Op.CALL( + address=erc20_address, + value=0, + args_offset=28, + args_size=36, + ret_offset=0, + ret_size=0, + ) + + Op.POP # Discard CALL success status + # Decrement counter: counter - 1 + + Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1)) + ), + ) + ) + + # Deploy attack contract + attack_address = pre.deploy_contract(code=attack_code) + + # Run the attack + attack_tx = Transaction( + to=attack_address, + gas_limit=gas_benchmark_value, + sender=pre.fund_eoa(), + ) + + # Post-state + post = { + attack_address: Account(storage={}), + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[attack_tx])], + post=post, + ) + + +@pytest.mark.valid_from("Prague") +def test_sstore_erc20_approve( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + gas_benchmark_value: int, + address_stubs, +): + """ + BloatNet SSTORE benchmark using ERC20 approve to write to storage. + + This test: + 1. Auto-discovers ERC20 contracts from stubs (pattern: erc20_contract_*) + 2. Splits gas budget evenly across all discovered contracts + 3. Calls approve(spender, amount) incrementally (counter as spender) + 4. Forces SSTOREs to allowance mapping storage slots + """ + gas_costs = fork.gas_costs() + + # Calculate gas costs + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()(calldata=b"") + + num_contracts = len(address_stubs.root) + + # Per-contract fixed overhead (setup + teardown) + memory_expansion_cost = 15 # Memory expansion to 160 bytes (5 words) + overhead_per_contract = ( + gas_costs.G_VERY_LOW # MSTORE to initialize counter (3) + + memory_expansion_cost # Memory expansion (15) + + gas_costs.G_JUMPDEST # JUMPDEST at loop start (1) + + gas_costs.G_LOW # MLOAD for While condition check (5) + + gas_costs.G_BASE # ISZERO (2) + + gas_costs.G_BASE # ISZERO (2) + + gas_costs.G_MID # JUMPI (8) + + gas_costs.G_BASE # POP to clean up counter at end (2) + ) # = 38 + + # Cost per iteration (attack contract operations + ERC20 execution) + cost_per_iteration = ( + # 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_LOW # MLOAD counter (5) + + gas_costs.G_VERY_LOW # MSTORE amount at memory[96] (3) + # CALL to ERC20 contract + + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm CALL base cost (100) + + 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) + # Inside ERC20 approve function + + 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 for 64 bytes (12) + + gas_costs.G_STORAGE_SET # SSTORE to zero slot (20000) + + 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 + ) # = 20,226 + + # Calculate total gas needed + total_overhead = intrinsic_gas + (overhead_per_contract * num_contracts) + available_gas_for_iterations = gas_benchmark_value - total_overhead + + # Calculate calls per contract + total_iterations_possible = available_gas_for_iterations // cost_per_iteration + calls_per_contract = total_iterations_possible // num_contracts + + # Deploy all discovered ERC20 contracts using stubs + erc20_addresses = [] + for stub_name in address_stubs.root: + addr = pre.deploy_contract( + code=Bytecode(), + stub=stub_name, + ) + erc20_addresses.append(addr) + + # Log test requirements + print( + f"Total gas budget: {gas_benchmark_value / 1_000_000:.1f}M gas. " + f"Intrinsic: {intrinsic_gas}, Overhead per contract: {overhead_per_contract}, " + f"Cost per iteration: {cost_per_iteration}. " + f"{calls_per_contract} approve calls per contract ({num_contracts} contracts)." + ) + + # Build attack code that loops through each contract + attack_code: Bytecode = ( + Op.JUMPDEST # Entry point + + Op.MSTORE(offset=0, value=APPROVE_SELECTOR) # Store selector once for all contracts + ) + + for erc20_address in erc20_addresses: + # For each contract, initialize counter and loop + attack_code += ( + # Initialize counter in memory[32] = number of calls + Op.MSTORE(offset=32, value=calls_per_contract) + # Loop for this specific contract + + While( + condition=Op.MLOAD(32) + Op.ISZERO + Op.ISZERO, # Continue while counter > 0 + body=( + # Store spender address at memory[64] (use counter) + Op.MSTORE(offset=64, value=Op.MLOAD(32)) + # Store amount at memory[96] (use counter as amount) + + Op.MSTORE(offset=96, value=Op.MLOAD(32)) + # Call approve(spender, amount) on ERC20 contract + # args_offset=28 reads: selector from MEM[28:32] + spender + # from MEM[32:64] + amount from MEM[64:96] + + Op.CALL( + address=erc20_address, + value=0, + args_offset=28, + args_size=68, # 4 bytes selector + 32 bytes spender + 32 bytes amount + ret_offset=0, + ret_size=0, + ) + + Op.POP # Discard CALL success status + # Decrement counter: counter - 1 + + Op.MSTORE(offset=32, value=Op.SUB(Op.MLOAD(32), 1)) + ), + ) + ) + + # Deploy attack contract + attack_address = pre.deploy_contract(code=attack_code) + + # Run the attack + attack_tx = Transaction( + to=attack_address, + gas_limit=gas_benchmark_value, + sender=pre.fund_eoa(), + ) + + # Post-state + post = { + attack_address: Account(storage={}), + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[attack_tx])], + post=post, + )