diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1d419042a88..8716c3b978b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -131,6 +131,7 @@ Users can select any of the artifacts depending on their testing needs for their ### 🧪 Test Cases +- ✨ [BloatNet](bloatnet.info)/Multidimensional Metering: Add benchmarks to be used as part of the BloatNet project and also for Multidimensional Metering. - ✨ [EIP-7951](https://eips.ethereum.org/EIPS/eip-7951): Add additional test cases for modular comparison. - 🔀 Refactored `BLOBHASH` opcode context tests to use the `pre_alloc` plugin in order to avoid contract and EOA address collisions ([#1637](https://github.com/ethereum/execution-spec-tests/pull/1637)). - 🔀 Refactored `SELFDESTRUCT` opcode collision tests to use the `pre_alloc` plugin in order to avoid contract and EOA address collisions ([#1643](https://github.com/ethereum/execution-spec-tests/pull/1643)). diff --git a/pyproject.toml b/pyproject.toml index 188cfc81ac5..99b6ea86837 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,7 @@ exclude = [ '^fixtures/', '^logs/', '^site/', + '^tests/benchmark/bloatnet/deploy_.*\.py$', ] plugins = ["pydantic.mypy"] diff --git a/tests/benchmark/bloatnet/README.md b/tests/benchmark/bloatnet/README.md new file mode 100644 index 00000000000..3da2743c9a6 --- /dev/null +++ b/tests/benchmark/bloatnet/README.md @@ -0,0 +1,144 @@ +# BloatNet Benchmark Tests setup guide + +## Overview + +This README pretends to be a guide for any user that wants to run the bloatnet test/benchmark suite in any network. +BloatNet bench cases can be seen in: https://hackmd.io/9icZeLN7R0Sk5mIjKlZAHQ. +The idea of all these tests is to stress client implementations to find out where the limits of processing are focusing specifically on state-related operations. + +In this document you will find a guide that will help you deploy all the setup contracts required by the benchmarks in `/benchmarks/bloatnet`. + +## Gas Cost Constants + +### BALANCE + EXTCODESIZE Pattern +**Gas per contract: ~2,772** +- `SHA3` (CREATE2 address generation): 30 gas (static) + 18 gas (dynamic for 85 bytes) +- `BALANCE` (cold access): 2,600 gas +- `POP`: 2 gas +- `EXTCODESIZE` (warm): 100 gas +- `POP`: 2 gas +- Memory operations and loop overhead: ~20 gas + +### BALANCE + EXTCODECOPY(single-byte) Pattern +**Gas per contract: ~2,775** +- `SHA3` (CREATE2 address generation): 30 gas (static) + 18 gas (dynamic for 85 bytes) +- `BALANCE` (cold access): 2,600 gas +- `POP`: 2 gas +- `EXTCODECOPY` (warm, 1 byte): 100 gas (base) + 3 gas (copy 1 byte) +- Memory operations: 4 gas +- Loop overhead: ~20 gas + +Note: Reading just 1 byte (specifically the last byte at offset 24575) forces the client +to load the entire 24KB contract from disk while minimizing gas cost. This allows +targeting nearly as many contracts as the EXTCODESIZE pattern while forcing maximum I/O. + +## Required Contracts Calculation Example: + +### For BALANCE + EXTCODESIZE: +| Gas Limit | Contracts Needed | Calculation | +| --------- | ---------------- | ------------------- | +| 1M | 352 | 1,000,000 ÷ 2,772 | +| 5M | 1,769 | 5,000,000 ÷ 2,772 | +| 50M | 17,690 | 50,000,000 ÷ 2,772 | +| 150M | 53,071 | 150,000,000 ÷ 2,772 | + +### For BALANCE + EXTCODECOPY: +| Gas Limit | Contracts Needed | Calculation | +| --------- | ---------------- | ------------------- | +| 1M | 352 | 1,000,000 ÷ 2,775 | +| 5M | 1,768 | 5,000,000 ÷ 2,775 | +| 50M | 17,684 | 50,000,000 ÷ 2,775 | +| 150M | 53,053 | 150,000,000 ÷ 2,775 | + +The CREATE2 address generation adds ~48 gas per contract but eliminates memory limitations in test framework. + +## Quick Start: 150M Gas Attack + +### 1. Deploy CREATE2 Factory with Initcode Template + +```bash +# Deploy the factory and initcode template (one-time setup) +python3 tests/benchmark/bloatnet/deploy_create2_factory_refactored.py + +# Output will show: +# Factory deployed at: 0x... <-- Save this address +# Init code hash: 0x... <-- Save this hash +``` + +### 2. Deploy Contracts + +Deploy contracts using the factory. Each contract will be unique due to ADDRESS-based randomness in the initcode. + +#### Calculate Contracts Needed + +Before running the deployment, calculate the number of contracts needed: +- For 150M gas BALANCE+EXTCODESIZE: 53,071 contracts +- For 150M gas BALANCE+EXTCODECOPY: 53,053 contracts + +_Deploy enough contracts to cover the max gas you plan to use in your tests/benchmarks._ + +#### Running the Deployment + +```bash +# Deploy contracts for 150M gas attack +python3 tests/benchmark/bloatnet/deploy_create2_factory_refactored.py \ + --deploy-contracts 53100 + +# For smaller tests (e.g., 1M gas) +python3 tests/benchmark/bloatnet/deploy_create2_factory_refactored.py \ + --deploy-contracts 370 +``` + +#### Deployment Output + +After successful deployment, the script will display: + +``` +✅ Successfully deployed 53100 contracts +NUM_DEPLOYED_CONTRACTS = 53100 +``` + +### 3. Update Test Configuration + +Edit `tests/benchmark/bloatnet/test_bloatnet.py` and update with values from deployment: + +```python +FACTORY_ADDRESS = Address("0x...") # From step 1 output +INIT_CODE_HASH = bytes.fromhex("...") # From step 1 output +NUM_DEPLOYED_CONTRACTS = 53100 # From step 2 output +``` + +### 4. Run Benchmark Tests + +#### Generate Test Fixtures +```bash +# Run with specific gas values (in millions) +uv run fill --fork=Prague --gas-benchmark-values=150 \ + tests/benchmark/bloatnet/test_bloatnet.py --clean + +# Multiple gas values +uv run fill --fork=Prague --gas-benchmark-values=1,5,50,150 \ + tests/benchmark/bloatnet/test_bloatnet.py +``` + +#### Execute Against Live Client +```bash +# Start a test node (e.g., Geth) +geth --dev --http --http.api eth,web3,net,debug + +# Run tests +uv run execute remote --rpc-endpoint http://127.0.0.1:8545 \ + --rpc-chain-id 1337 --rpc-seed-key 0x0000000000000000000000000000000000000000000000000000000000000001 \ + tests/benchmark/bloatnet/test_bloatnet.py \ + --fork=Prague --gas-benchmark-values=150 -v +``` + +#### With EVM Traces for Analysis +```bash +uv run fill --fork=Prague --gas-benchmark-values=150 \ + --evm-dump-dir=traces/ --traces \ + tests/benchmark/bloatnet/test_bloatnet.py + +# Analyze opcodes executed +jq -r '.opName' traces/**/*.jsonl | sort | uniq -c +``` diff --git a/tests/benchmark/bloatnet/deploy_bloatnet_simple.py b/tests/benchmark/bloatnet/deploy_bloatnet_simple.py new file mode 100644 index 00000000000..cd1daa07cf6 --- /dev/null +++ b/tests/benchmark/bloatnet/deploy_bloatnet_simple.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +""" +CREATE2 deployment script for bloatnet benchmark contracts. +Uses a factory pattern to deploy contracts at deterministic addresses. + +Based on the pattern from EIP-7997, this deploys contracts using CREATE2 +so they can be accessed from any account and reused across tests. +""" + +import argparse +import os +import subprocess +import sys +from typing import Dict, List, Optional, Tuple + +from eth_utils import keccak +from web3 import Web3 + + +class ContractType: + """Define contract types for different benchmarks.""" + + MAX_SIZE_24KB = "max_size_24kb" + SLOAD_HEAVY = "sload_heavy" + STORAGE_HEAVY = "storage_heavy" + CUSTOM = "custom" + + +CONTRACT_DESCRIPTIONS = { + ContractType.MAX_SIZE_24KB: "24KB contracts filled with unique bytecode (standard bloatnet)", + ContractType.SLOAD_HEAVY: "Contracts optimized for SLOAD benchmarking", + ContractType.STORAGE_HEAVY: "Contracts with pre-initialized storage", + ContractType.CUSTOM: "Custom bytecode (provide your own)", +} + + +def generate_max_size_bytecode(salt: int = 0, max_code_size: int = 24576) -> Tuple[bytes, bytes]: + """ + Generate max-size contract bytecode for standard bloatnet tests. + + Args: + salt: Unique salt for generating unique bytecode + max_code_size: Maximum contract size (default 24576 bytes for mainnet) + + """ + # Init code copies runtime bytecode to memory and returns it + init_code = bytearray() + + # Init code: PUSH2 size, PUSH1 offset, PUSH1 dest, CODECOPY, PUSH2 size, PUSH1 0, RETURN + bytecode_size = max_code_size + init_size = 13 # Size of init code instructions + + # PUSH2 bytecode_size, PUSH1 init_size, PUSH1 0, CODECOPY + init_code.extend( + [ + 0x61, + (bytecode_size >> 8) & 0xFF, + bytecode_size & 0xFF, # PUSH2 bytecode_size + 0x60, + init_size, # PUSH1 init_size (offset where runtime code starts) + 0x60, + 0x00, # PUSH1 0 (dest in memory) + 0x39, # CODECOPY + ] + ) + + # PUSH2 bytecode_size, PUSH1 0, RETURN + init_code.extend( + [ + 0x61, + (bytecode_size >> 8) & 0xFF, + bytecode_size & 0xFF, # PUSH2 bytecode_size + 0x60, + 0x00, # PUSH1 0 (offset in memory) + 0xF3, # RETURN + ] + ) + + # Generate unique 24KB runtime bytecode to prevent deduplication + runtime_bytecode = bytearray([0x00]) # Start with STOP + + # Fill with unique pattern + pattern_count = 0 + while len(runtime_bytecode) < bytecode_size - 100: + # Use a simple pattern that's still unique per contract + unique_value = keccak(f"bloatnet_{salt}_{pattern_count}".encode()) + runtime_bytecode.append(0x7F) # PUSH32 + runtime_bytecode.extend(unique_value[:31]) # Use 31 bytes of hash + runtime_bytecode.append(0x50) # POP + pattern_count += 1 + + # Fill rest with JUMPDEST + while len(runtime_bytecode) < bytecode_size: + runtime_bytecode.append(0x5B) + + full_init_code = bytes(init_code) + bytes(runtime_bytecode) + return full_init_code, keccak(full_init_code) + + +def generate_sload_heavy_bytecode(salt: int = 0) -> Tuple[bytes, bytes]: + """Generate contracts optimized for SLOAD benchmarking.""" + # Runtime bytecode that performs many SLOAD operations + runtime_bytecode = bytearray() + + # Store some values during deployment + for i in range(10): + # PUSH1 value, PUSH1 key, SSTORE + runtime_bytecode.extend([0x60, i * 2, 0x60, i, 0x55]) + + # Main runtime: series of SLOAD operations + for i in range(100): + # PUSH1 key, SLOAD, POP + runtime_bytecode.extend([0x60, i % 10, 0x54, 0x50]) + + # Final STOP + runtime_bytecode.append(0x00) + + # Create init code that deploys this runtime + runtime_size = len(runtime_bytecode) + init_size = 13 + + init_code = bytearray() + init_code.extend( + [ + 0x61, + (runtime_size >> 8) & 0xFF, + runtime_size & 0xFF, # PUSH2 runtime_size + 0x60, + init_size, # PUSH1 init_size + 0x60, + 0x00, # PUSH1 0 + 0x39, # CODECOPY + 0x61, + (runtime_size >> 8) & 0xFF, + runtime_size & 0xFF, # PUSH2 runtime_size + 0x60, + 0x00, # PUSH1 0 + 0xF3, # RETURN + ] + ) + + full_init_code = bytes(init_code) + bytes(runtime_bytecode) + return full_init_code, keccak(full_init_code) + + +def select_contract_type() -> str: + """Interactive contract type selection.""" + print("\n=== Contract Type Selection ===") + print("Select the type of contracts to deploy:\n") + + options = list(CONTRACT_DESCRIPTIONS.keys()) + for i, (key, desc) in enumerate(CONTRACT_DESCRIPTIONS.items(), 1): + print(f"{i}. {key}: {desc}") + + while True: + try: + choice = input(f"\nEnter choice (1-{len(options)}): ").strip() + idx = int(choice) - 1 + if 0 <= idx < len(options): + selected = options[idx] + print(f"\nSelected: {selected}") + return selected + else: + print(f"Please enter a number between 1 and {len(options)}") + except (ValueError, KeyboardInterrupt): + print("\nExiting...") + sys.exit(0) + + +def get_bytecode_generator(contract_type: str, max_code_size: int): + """ + Get the appropriate bytecode generator for the contract type. + + Args: + contract_type: Type of contract to generate + max_code_size: Maximum contract size in bytes + + """ + if contract_type == ContractType.MAX_SIZE_24KB: + return lambda salt: generate_max_size_bytecode(salt, max_code_size) + elif contract_type == ContractType.SLOAD_HEAVY: + return lambda salt: generate_sload_heavy_bytecode(salt) + else: + print(f"Error: No generator implemented for {contract_type}") + if contract_type == ContractType.CUSTOM: + print("Custom bytecode deployment not yet implemented") + sys.exit(1) + + +def deploy_factory(rpc_url: str) -> str: + """Deploy CREATE2 factory if needed.""" + print("\nDeploying CREATE2 factory...") + + try: + script_dir = os.path.dirname(os.path.abspath(__file__)) + factory_script = os.path.join(script_dir, "deploy_create2_factory.py") + + result = subprocess.run( + [sys.executable, factory_script, "--rpc-url", rpc_url], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print("Failed to deploy factory:") + print(result.stderr) + sys.exit(1) + + # Extract factory address from output + for line in result.stdout.split("\n"): + if "Factory address:" in line: + factory_address = line.split(":")[1].strip() + print(f"Factory deployed at: {factory_address}") + return factory_address + + print("Could not extract factory address from deployment output") + sys.exit(1) + + except Exception as e: + print(f"Error deploying factory: {e}") + sys.exit(1) + + +def deploy_contracts( + rpc_url: str, + num_contracts: int, + contract_type: str, + factory_address: Optional[str] = None, + max_code_size: int = 24576, +): + """Deploy contracts using CREATE2 factory pattern.""" + # Connect to Geth + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + print(f"Failed to connect to {rpc_url}") + sys.exit(1) + + test_account = w3.eth.accounts[0] + print(f"Using test account: {test_account}") + print(f"Balance: {w3.eth.get_balance(test_account) / 10**18:.4f} ETH") + + # Check/deploy factory + if factory_address: + factory_code = w3.eth.get_code(Web3.to_checksum_address(factory_address)) + if len(factory_code) > 0: + print(f"\nUsing existing CREATE2 factory at: {factory_address}") + else: + print(f"\nNo factory found at {factory_address}") + factory_address = deploy_factory(rpc_url) + else: + factory_address = deploy_factory(rpc_url) + + # Get bytecode generator + bytecode_generator = get_bytecode_generator(contract_type, max_code_size) + + # Generate sample to show info + sample_init_code, sample_hash = bytecode_generator(0) + + print(f"\nContract Type: {contract_type}") + print(f"Init code size: {len(sample_init_code)} bytes") + print(f"Sample init code hash: 0x{sample_hash.hex()}") + + confirm = input(f"\nProceed with deploying {num_contracts} {contract_type} contracts? (y/n): ") + if confirm.lower() != "y": + print("Deployment cancelled") + sys.exit(0) + + # Deploy contracts + print(f"\nDeploying {num_contracts} {contract_type} contracts using CREATE2...") + + deployed = [] + init_code_hashes: Dict[str, List[int]] = {} # Track different init code hashes if they vary + + for salt in range(num_contracts): + if salt % 100 == 0: + print(f"Progress: {salt}/{num_contracts}") + + # Generate bytecode for this specific salt + full_init_code, init_code_hash = bytecode_generator(salt) + + # Track unique init code hashes + hash_hex = init_code_hash.hex() + if hash_hex not in init_code_hashes: + init_code_hashes[hash_hex] = [] + init_code_hashes[hash_hex].append(salt) + + # Factory expects: salt (32 bytes) + bytecode + call_data = salt.to_bytes(32, "big") + full_init_code + + try: + tx_hash = w3.eth.send_transaction( + { + "from": test_account, + "to": factory_address, + "data": bytes.fromhex(call_data.hex()), + "gas": 10000000, + } + ) + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=10) + + if receipt["status"] == 1: + # Calculate CREATE2 address + create2_input = ( + b"\xff" + + bytes.fromhex(factory_address[2:]) + + salt.to_bytes(32, "big") + + init_code_hash + ) + contract_address = "0x" + keccak(create2_input)[-20:].hex() + deployed.append(contract_address) + + if (salt + 1) % 100 == 0 or salt == num_contracts - 1: + print(f" [{salt + 1}/{num_contracts}] Deployed at {contract_address}") + else: + print(f" [{salt + 1}/{num_contracts}] Failed") + + except Exception as e: + print(f" [{salt + 1}/{num_contracts}] Error: {e}") + break + + print(f"\nDeployed {len(deployed)} contracts") + + if deployed: + print("\nContract addresses:") + print(f"First: {deployed[0]}") + print(f"Last: {deployed[-1]}") + + print(f"\n=== Configuration for {contract_type} tests ===") + print(f'CONTRACT_TYPE = "{contract_type}"') + print(f'FACTORY_ADDRESS = Address("{factory_address}")') + + if len(init_code_hashes) == 1: + # All contracts have the same init code hash + hash_hex = list(init_code_hashes.keys())[0] + print(f'INIT_CODE_HASH = bytes.fromhex("{hash_hex}")') + else: + # Multiple init code hashes (rare but possible) + print("# Multiple init code hashes detected:") + for hash_hex, salts in init_code_hashes.items(): + print(f"# Hash {hash_hex[:8]}... used for salts: {salts[:5]}...") + + print(f"NUM_DEPLOYED_CONTRACTS = {len(deployed)}") + + # Save configuration to file + config_file = f"bloatnet_config_{contract_type}.txt" + with open(config_file, "w") as f: + f.write(f"# Configuration for {contract_type} benchmarks\n") + f.write(f'CONTRACT_TYPE = "{contract_type}"\n') + f.write(f'FACTORY_ADDRESS = Address("{factory_address}")\n') + if len(init_code_hashes) == 1: + hash_hex = list(init_code_hashes.keys())[0] + f.write(f'INIT_CODE_HASH = bytes.fromhex("{hash_hex}")\n') + f.write(f"NUM_DEPLOYED_CONTRACTS = {len(deployed)}\n") + + print(f"\nConfiguration saved to: {config_file}") + + +def main(): + """Execute the deployment script.""" + parser = argparse.ArgumentParser(description="Deploy bloatnet contracts using CREATE2") + parser.add_argument( + "--num-contracts", + type=int, + default=100, + help="Number of contracts to deploy", + ) + parser.add_argument( + "--rpc-url", + default="http://127.0.0.1:8545", + help="RPC URL", + ) + parser.add_argument( + "--factory-address", + default=None, + help="CREATE2 factory address (deploys new one if not provided)", + ) + parser.add_argument( + "--max-code-size", + type=int, + default=24576, + help="Maximum contract code size in bytes (default: 24576 for mainnet/Prague fork)", + ) + + args = parser.parse_args() + + # Always run in interactive mode - user selects contract type + contract_type = select_contract_type() + + deploy_contracts( + args.rpc_url, + args.num_contracts, + contract_type, + args.factory_address, + args.max_code_size, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/benchmark/bloatnet/deploy_create2_factory_refactored.py b/tests/benchmark/bloatnet/deploy_create2_factory_refactored.py new file mode 100644 index 00000000000..f4a5ce64867 --- /dev/null +++ b/tests/benchmark/bloatnet/deploy_create2_factory_refactored.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Deploy a CREATE2 factory for on-the-fly contract address generation in BloatNet tests. + +This factory uses a constant initcode that generates unique 24KB contracts by: +1. Using ADDRESS opcode for pseudo-randomness (within the factory's context) +2. Expanding randomness with SHA3 and XOR operations +3. Creating max-size contracts with deterministic CREATE2 addresses +""" + +import argparse +import sys +from pathlib import Path + +# Add parent directories to path to import EEST tools +sys.path.insert(0, str(Path(__file__).parents[3])) + +try: + from eth_utils import keccak + from web3 import Web3 + + from ethereum_test_tools.vm.opcode import Opcodes as Op +except ImportError as e: + print(f"Error: Missing dependencies - {e}") + print("This refactored version requires the EEST framework.") + print("Run: uv sync --all-extras") + sys.exit(1) + +# XOR table for pseudo-random bytecode generation (reused from test_worst_bytecode.py) +XOR_TABLE_SIZE = 256 +XOR_TABLE = [keccak(i.to_bytes(32, "big")) for i in range(XOR_TABLE_SIZE)] +MAX_CONTRACT_SIZE = 24576 # 24KB + + +def build_initcode() -> bytes: + """Build the initcode that generates unique 24KB contracts using ADDRESS for randomness.""" + from ethereum_test_tools import While + + # This initcode follows the pattern from test_worst_bytecode.py: + # 1. Uses ADDRESS as initial seed for pseudo-randomness (creates uniqueness per deployment) + # 2. Expands to 24KB using SHA3 and XOR operations + # 3. Sets first byte to STOP for quick CALL returns + initcode = ( + # Store ADDRESS as initial seed - THIS IS CRITICAL FOR UNIQUENESS + Op.MSTORE(0, Op.ADDRESS) + # Loop to expand bytecode using SHA3 and XOR operations + + While( + body=( + Op.SHA3(Op.SUB(Op.MSIZE, 32), 32) + # Use XOR table to expand without excessive SHA3 calls + + sum( + (Op.PUSH32[xor_value] + Op.XOR + Op.DUP1 + Op.MSIZE + Op.MSTORE) + for xor_value in XOR_TABLE + ) + + Op.POP + ), + condition=Op.LT(Op.MSIZE, MAX_CONTRACT_SIZE), + ) + # Set first byte to STOP for efficient CALL handling + + Op.MSTORE8(0, 0x00) + # Return the full contract + + Op.RETURN(0, MAX_CONTRACT_SIZE) + ) + return bytes(initcode) + + +def deploy_factory_and_initcode(rpc_url: str): + """Deploy the initcode template and factory that uses it.""" + # Connect to Geth + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + print(f"Failed to connect to {rpc_url}") + return None, None + + test_account = w3.eth.accounts[0] + print(f"Using test account: {test_account}") + + # Build the initcode + initcode = build_initcode() + print(f"\nInitcode size: {len(initcode)} bytes") + print(f"Initcode (first 100 bytes): 0x{initcode[:100].hex()}...") + + # Deploy the initcode as a contract that the factory can copy from + print("\nDeploying initcode template contract...") + tx_hash = w3.eth.send_transaction({"from": test_account, "data": initcode, "gas": 10000000}) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt["status"] != 1: + print("Failed to deploy initcode template") + return None, None + + initcode_address = receipt["contractAddress"] + print(f"✅ Initcode template deployed at: {initcode_address}") + + # Build the factory contract following the pattern from test_worst_bytecode.py + # The factory: + # 1. Copies the initcode from the template contract + # 2. Uses incrementing salt from storage for CREATE2 + # 3. Returns the created contract address + factory_code = ( + # Copy initcode from template to memory + Op.EXTCODECOPY( + address=initcode_address, + dest_offset=0, + offset=0, + size=Op.EXTCODESIZE(initcode_address), + ) + # Store the result of CREATE2 + + Op.MSTORE( + 0, + Op.CREATE2( + value=0, + offset=0, + size=Op.EXTCODESIZE(initcode_address), + salt=Op.SLOAD(0), + ), + ) + # Increment salt for next call + + Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1)) + # Return created address + + Op.RETURN(0, 32) + ) + + factory_bytecode = bytes(factory_code) + print(f"\nFactory bytecode size: {len(factory_bytecode)} bytes") + print(f"Factory bytecode: 0x{factory_bytecode.hex()}") + + # Deploy the factory + print("\nDeploying CREATE2 factory...") + tx_hash = w3.eth.send_transaction( + {"from": test_account, "data": factory_bytecode, "gas": 3000000} + ) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt["status"] != 1: + print("Failed to deploy factory") + return None, None + + factory_address = receipt["contractAddress"] + print(f"✅ Factory deployed at: {factory_address}") + + # Calculate init code hash for CREATE2 address calculation + init_code_hash = keccak(initcode) + print(f"\nInit code hash: 0x{init_code_hash.hex()}") + + return factory_address, init_code_hash.hex() + + +def deploy_contracts(rpc_url: str, factory_address: str, num_contracts: int): + """Deploy multiple contracts using the factory.""" + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + print(f"Failed to connect to {rpc_url}") + return False + + test_account = w3.eth.accounts[0] + print(f"\nDeploying {num_contracts} contracts via factory...") + + # Batch size for deployments + batch_size = 100 + deployed_count = 0 + + for batch_start in range(0, num_contracts, batch_size): + batch_end = min(batch_start + batch_size, num_contracts) + current_batch = batch_end - batch_start + + batch_num = batch_start // batch_size + 1 + print(f"Deploying batch {batch_num}: contracts {batch_start}-{batch_end - 1}...") + + for i in range(current_batch): + try: + tx_hash = w3.eth.send_transaction( + {"from": test_account, "to": factory_address, "gas": 10000000} + ) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60) + if receipt["status"] == 1: + deployed_count += 1 + else: + print(f" ⚠️ Failed to deploy contract {batch_start + i}") + except Exception as e: + print(f" ⚠️ Error deploying contract {batch_start + i}: {e}") + + print(f" ✅ Deployed {deployed_count}/{batch_end} contracts") + + return deployed_count == num_contracts + + +def main(): + """Execute the factory deployment script.""" + parser = argparse.ArgumentParser(description="Deploy CREATE2 factory for BloatNet tests") + parser.add_argument( + "--rpc-url", + default="http://127.0.0.1:8545", + help="RPC URL (default: http://127.0.0.1:8545)", + ) + parser.add_argument( + "--deploy-contracts", type=int, metavar="N", help="Deploy N contracts using the factory" + ) + + args = parser.parse_args() + + # Deploy factory and initcode template + factory_address, init_code_hash = deploy_factory_and_initcode(args.rpc_url) + + if not factory_address: + print("\n❌ Failed to deploy factory") + sys.exit(1) + + print("\n" + "=" * 60) + print("Factory deployed successfully!") + print(f"Factory address: {factory_address}") + print(f"Init code hash: 0x{init_code_hash}") + print("\nAdd this to your test configuration:") + print(f'FACTORY_ADDRESS = Address("{factory_address}")') + print(f'INIT_CODE_HASH = bytes.fromhex("{init_code_hash}")') + + # Deploy contracts if requested + if args.deploy_contracts: + success = deploy_contracts(args.rpc_url, factory_address, args.deploy_contracts) + if success: + print(f"\n✅ Successfully deployed {args.deploy_contracts} contracts") + print(f"NUM_DEPLOYED_CONTRACTS = {args.deploy_contracts}") + else: + print("\n⚠️ Some contracts failed to deploy") + + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/tests/benchmark/bloatnet/test_bloatnet.py b/tests/benchmark/bloatnet/test_bloatnet.py new file mode 100644 index 00000000000..480f5513e58 --- /dev/null +++ b/tests/benchmark/bloatnet/test_bloatnet.py @@ -0,0 +1,246 @@ +""" +abstract: BloatNet bench cases extracted from https://hackmd.io/9icZeLN7R0Sk5mIjKlZAHQ. + + The idea of all these tests is to stress client implementations to find out where the limits of + processing are focusing specifically on state-related operations. +""" + +import pytest + +from ethereum_test_forks import Fork +from ethereum_test_tools import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Transaction, + While, +) +from ethereum_test_tools.vm.opcode import Opcodes as Op + +REFERENCE_SPEC_GIT_PATH = "DUMMY/bloatnet.md" +REFERENCE_SPEC_VERSION = "1.0" + + +# Configuration for CREATE2 factory - UPDATE THESE after running deploy script +# These values come from deploy_create2_factory_refactored.py output +FACTORY_ADDRESS = Address("0x847a04f0a1FfC4E68CC80e7D870Eb4eC51235CE8") # UPDATE THIS +INIT_CODE_HASH = bytes.fromhex( + "9e1a230bdc29e66a6027083ec52c6724e7e6cac4a8e59c1c9a852c0a1e954b45" +) # UPDATE THIS +NUM_DEPLOYED_CONTRACTS = 370 # UPDATE THIS - number of contracts deployed via factory + + +@pytest.mark.valid_from("Prague") +def test_bloatnet_balance_extcodesize( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + gas_benchmark_value: int, +): + """ + 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 (cold) then EXTCODESIZE (warm) on each + 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 + + gas_costs.G_KECCAK_256_WORD * 3 # SHA3 dynamic cost (85 bytes) + + gas_costs.G_COLD_ACCOUNT_ACCESS # Cold BALANCE (2600) + + gas_costs.G_BASE # POP balance + + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm EXTCODESIZE (100) + + gas_costs.G_BASE # POP code size + + 20 # Overhead for memory operations and loop control + ) + + # Calculate how many contracts to access + available_gas = gas_benchmark_value - intrinsic_gas - 1000 # Reserve for cleanup + contracts_needed = int(available_gas // cost_per_contract) + + # Limit to actually deployed contracts + num_contracts = min(contracts_needed, NUM_DEPLOYED_CONTRACTS) + + if contracts_needed > NUM_DEPLOYED_CONTRACTS: + import warnings + + warnings.warn( + f"Test needs {contracts_needed} contracts for " + f"{gas_benchmark_value / 1_000_000:.1f}M gas, " + f"but only {NUM_DEPLOYED_CONTRACTS} are deployed. " + f"Deploy {contracts_needed} contracts for full test coverage.", + stacklevel=2, + ) + + # Generate attack contract with on-the-fly CREATE2 address calculation + attack_code = ( + # Setup memory for CREATE2 address generation + # Memory layout: 0xFF + factory_address(20) + salt(32) + init_code_hash(32) + Op.MSTORE(0, FACTORY_ADDRESS) + + Op.MSTORE8(32 - 20 - 1, 0xFF) # Prefix for CREATE2 + + Op.MSTORE(32, 0) # Initial salt (start from 0) + + Op.MSTORE(64, INIT_CODE_HASH) # Init code hash + # Limit counter for number of contracts to access + + Op.PUSH2(num_contracts) + # Main attack loop + + While( + body=( + # Generate CREATE2 address: keccak256(0xFF + factory + salt + init_code_hash) + Op.SHA3(32 - 20 - 1, 85) # Hash 85 bytes starting from 0xFF + # The address is now on the stack + + Op.DUP1 # Duplicate for EXTCODESIZE + + Op.BALANCE # Cold access + + Op.POP + + Op.EXTCODESIZE # Warm access + + Op.POP + # Increment salt for next iteration + + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)) + ), + # 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 + ) + + # 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: just verify attack contract exists + post = { + attack_address: Account(storage={}), + } + + blockchain_test( + pre=pre, + blocks=[Block(txs=[attack_tx])], + post=post, + ) + + +@pytest.mark.valid_from("Prague") +def test_bloatnet_balance_extcodecopy( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + gas_benchmark_value: int, +): + """ + 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 (cold) to warm the account + 4. Using EXTCODECOPY (warm) to read 1 byte from the END of the bytecode + """ + 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 + + gas_costs.G_KECCAK_256_WORD * 3 # SHA3 dynamic cost (85 bytes) + + gas_costs.G_COLD_ACCOUNT_ACCESS # Cold BALANCE (2600) + + gas_costs.G_BASE # POP balance + + gas_costs.G_WARM_ACCOUNT_ACCESS # Warm EXTCODECOPY base (100) + + gas_costs.G_COPY * 1 # Copy cost for 1 byte (3) + + gas_costs.G_BASE * 4 # PUSH operations and POP + + 20 # Overhead + ) + + # Calculate how many contracts to access + available_gas = gas_benchmark_value - intrinsic_gas - 1000 + contracts_needed = int(available_gas // cost_per_contract) + + # Limit to actually deployed contracts + num_contracts = min(contracts_needed, NUM_DEPLOYED_CONTRACTS) + + if contracts_needed > NUM_DEPLOYED_CONTRACTS: + import warnings + + warnings.warn( + f"Test needs {contracts_needed} contracts for " + f"{gas_benchmark_value / 1_000_000:.1f}M gas, " + f"but only {NUM_DEPLOYED_CONTRACTS} are deployed. " + f"Deploy {contracts_needed} contracts for full test coverage.", + stacklevel=2, + ) + + # Generate attack contract with on-the-fly CREATE2 address calculation + attack_code = ( + # Setup memory for CREATE2 address generation + Op.MSTORE(0, FACTORY_ADDRESS) + + Op.MSTORE8(32 - 20 - 1, 0xFF) + + Op.MSTORE(32, 0) # Initial salt (start from 0) + + Op.MSTORE(64, INIT_CODE_HASH) + # Counter for number of contracts + + Op.PUSH2(num_contracts) + # Main attack loop + + While( + body=( + # Generate CREATE2 address + Op.SHA3(32 - 20 - 1, 85) + # The address is now on the stack + + Op.DUP1 # Duplicate for later operations + + Op.BALANCE # Cold access + + Op.POP + # EXTCODECOPY(addr, mem_offset, last_byte_offset, 1) + # Read the LAST byte to force full contract load + + Op.PUSH1(1) # size (1 byte) + + Op.PUSH2(max_contract_size - 1) # code offset (last byte) + # Use salt as memory offset to avoid overlap + + Op.MLOAD(32) # Get current salt + + Op.PUSH2(96) # Base memory offset + + Op.ADD # Unique memory position + + Op.DUP4 # address (duplicated earlier) + + Op.EXTCODECOPY + + Op.POP # Clean up address + # Increment salt + + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)) + ), + # 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 + ) + + # 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, + )