diff --git a/scenarios/scenarios.go b/scenarios/scenarios.go index d17d956..e9a4778 100644 --- a/scenarios/scenarios.go +++ b/scenarios/scenarios.go @@ -18,6 +18,7 @@ import ( "github.com/ethpandaops/spamoor/scenarios/geastx" "github.com/ethpandaops/spamoor/scenarios/setcodetx" erc20bloater "github.com/ethpandaops/spamoor/scenarios/statebloat/erc20_bloater" + storagetriebrancher "github.com/ethpandaops/spamoor/scenarios/statebloat/storage_trie_brancher" "github.com/ethpandaops/spamoor/scenarios/storagespam" "github.com/ethpandaops/spamoor/scenarios/taskrunner" uniswapswaps "github.com/ethpandaops/spamoor/scenarios/uniswap-swaps" @@ -44,6 +45,7 @@ var ScenarioDescriptors = []*scenario.Descriptor{ &gasburnertx.ScenarioDescriptor, &geastx.ScenarioDescriptor, &setcodetx.ScenarioDescriptor, + &storagetriebrancher.ScenarioDescriptor, &storagespam.ScenarioDescriptor, &taskrunner.ScenarioDescriptor, &uniswapswaps.ScenarioDescriptor, diff --git a/scenarios/statebloat/storage_trie_brancher/README.md b/scenarios/statebloat/storage_trie_brancher/README.md new file mode 100644 index 0000000..1c12e03 --- /dev/null +++ b/scenarios/statebloat/storage_trie_brancher/README.md @@ -0,0 +1,203 @@ +# 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 2,510 attacks per transaction (8.3x improvement over previous implementation) +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. + +For complete deployment setup and instructions, see the gist: https://gist.github.com/CPerezz/44d521c0f9e6adf7d84187a4f2c11978 + +## Prerequisites + +- Python with `uv` package manager +- Anvil (Ethereum node implementation) +- Solc (Solidity compiler) +- Nick's factory deployed at `0x4e59b44847b379578588920ca78fbf26c0b4956c` + +## Workflow + +### Step 1: Generate Artifacts + +Use [worst_case_miner](https://github.com/CPerezz/worst_case_miner) to generate the necessary artifacts: + +```bash +# Clone and build worst_case_miner +git clone https://github.com/CPerezz/worst_case_miner +cd worst_case_miner +cargo build --release + +# Generate artifacts (example for depth 9, account depth 3) +./target/release/worst_case_miner --storage-depth 9 --account-depth 3 --output s9_acc3.json +``` + +This generates: +- `depth_9.sol` - Solidity contract with deep storage trie +- `s9_acc3.json` - Pre-computed CREATE2 addresses and auxiliary accounts + +### Step 2: 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 3: Deploy Contracts + +Deploy contracts using the provided script with batched transactions: + +```bash +# Deploy 1,000 contracts (recommended for testing) +uv run python deploy_worst_case_contracts.py \ + --rpc-url http://localhost:8546 \ + --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + --storage-depth 9 \ + --account-depth 3 \ + --num-contracts 1000 \ + --output deployed_contracts.json + + +The script: +- Funds auxiliary accounts in batches (3 accounts per contract) +- Deploys contracts via CREATE2 for deterministic addresses +- Dynamically calculates batch sizes based on network gas limit + +### Step 4: Run Attack Test + +Execute the worst-case depth attack test: + +```bash +# Update NUM_CONTRACTS in deep_branch_testing.py to match deployed count (1000 or 15000) + +# Run the attack test +uv run execute remote \ + --rpc-endpoint=http://localhost:8546 \ + --rpc-seed-key=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + --rpc-chain-id=31337 \ + --gas-benchmark-values 60 \ + --fork Prague \ + -m stateful \ + deep_branch_testing.py::test_worst_depth_stateroot_recomp +``` + +## Spamoor Scenario Usage + +### Command Line Usage + +```bash +./bin/spamoor storage-trie-brancher \ + --count 1000 \ + --storage-depth 10 \ + --account-depth 5 \ + --data-file "https://example.com/s10_acc5.json" \ + --bytecode "https://example.com/depth_10.bin" \ + --rpchost http://localhost:8545 \ + --privkey 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + --seed "test-seed" \ + --basefee 20 \ + --tipfee 2 \ + --max-wallets 50 +``` + +Note: Both `--data-file` and `--bytecode` are required parameters and can be: +- Local file paths +- HTTP/HTTPS URLs +- For bytecode: direct hex string (e.g., "0x608060...") + +### YAML Configuration Example + +Create a file `storage_trie_brancher_config.yaml`: + +```yaml +scenarios: + - name: storage-trie-brancher + config: + # Number of contracts to deploy + total_contracts: 1000 + + # Storage trie depth (9 or 10) + storage_depth: 10 + + # Account trie depth (3, 4, or 5) + account_depth: 5 + + # Maximum number of wallets for parallel execution + max_wallets: 50 + + # Skip contract deployment (only fund EOAs) + skip_contracts: false + + # Skip EOA funding (only deploy contracts) + skip_funding: false + + # Path or URL to CREATE2 data JSON file (required) + # Can be a local file path or HTTP/HTTPS URL + data_file: "https://raw.githubusercontent.com/example/repo/main/s10_acc5.json" + + # Contract bytecode (required) + # Can be: + # - Direct hex string: "0x608060405260..." + # - Local file path: "./bytecode.bin" + # - URL: "https://raw.githubusercontent.com/example/repo/main/depth_10.bin" + bytecode: "https://raw.githubusercontent.com/example/repo/main/depth_10.bin" + + # Gas settings (in gwei) + base_fee: 20 + tip_fee: 2 + + # Client group for transaction routing (optional) + client_group: "" + + # Log all submitted transactions + log_txs: true +``` + +Run with the YAML config: + +```bash +# Using spamoor directly with config file +./bin/spamoor storage-trie-brancher \ + --config storage_trie_brancher_config.yaml \ + --rpchost http://localhost:8545 \ + --privkey 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + --seed "test-seed" + +# Or using spamoor-daemon for web interface +./bin/spamoor-daemon \ + --rpchost http://localhost:8545 \ + --privkey 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + --scenario-file storage_trie_brancher_config.yaml +``` + +### Using External URLs + +The scenario supports loading files from external sources: + +```yaml +scenarios: + - name: storage-trie-brancher + config: + total_contracts: 5000 + storage_depth: 10 + account_depth: 5 + max_wallets: 100 + + # Load from GitHub raw URLs or any HTTP server + data_file: "https://raw.githubusercontent.com/CPerezz/worst-case-artifacts/main/s10_acc5.json" + bytecode: "https://raw.githubusercontent.com/CPerezz/worst-case-artifacts/main/depth_10.bin" + + base_fee: 20 + tip_fee: 2 + log_txs: true +``` + +## Configuration + +Adjust `NUM_CONTRACTS` in `deep_branch_testing.py` to match your deployment: diff --git a/scenarios/statebloat/storage_trie_brancher/deploy_deep_branches.py b/scenarios/statebloat/storage_trie_brancher/deploy_deep_branches.py new file mode 100644 index 0000000..27e0a12 --- /dev/null +++ b/scenarios/statebloat/storage_trie_brancher/deploy_deep_branches.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +""" +Deploy worst-case attack contracts to an Ethereum node. + +This script deploys X WorstCaseERC20.sol contracts using Nick's factory method, +funds auxiliary accounts, and saves all deployed addresses to a JSON file. + +Usage: + python deploy_worst_case_contracts.py --rpc-url http://localhost:8546 \ + --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + --storage-depth 9 --account-depth 3 --num-contracts 15000 + +The script will: +1. Load CREATE2 salt data from s9_acc3.json (or similar) +2. Deploy all contracts via Nick's factory +3. Fund auxiliary accounts for trie depth +4. Save deployed addresses to deployed_contracts.json +""" + +import argparse +import json +import sys +import time +from pathlib import Path +from typing import Dict, List, Any +from eth_account import Account +from web3 import Web3 +from web3.types import TxParams, HexBytes +from eth_utils import keccak, to_checksum_address +import subprocess + +# Nick's factory address +NICK_FACTORY = "0x4e59b44847b379578588920ca78fbf26c0b4956c" + +# Gas settings +MAX_GAS_PER_TX = 16_000_000 # Fusaka limit +DEPLOY_GAS_LIMIT = 3_000_000 # Gas for deploying one contract +FUND_GAS_LIMIT = 21_000 # Gas for funding accounts + +def compile_contract(storage_depth: int, account_depth: int) -> bytes: + """Compile the depth contract and return init bytecode.""" + sol_path = Path(f"depth_{storage_depth}.sol") + if not sol_path.exists(): + raise FileNotFoundError(f"Contract file not found: {sol_path}") + + print(f"Compiling {sol_path}...") + + # Compile to get init code (with constructor) + # MUST use same flags as pre-mining to get matching bytecode + result = subprocess.run( + [ + "solc", + "--bin", + "--optimize", + "--optimize-runs", "200", + "--metadata-hash", "none", # Critical for reproducible bytecode + str(sol_path) + ], + capture_output=True, + text=True + ) + + if result.returncode != 0: + raise Exception(f"Failed to compile: {result.stderr}") + + # Extract bytecode from solc output + lines = result.stdout.split('\n') + init_code_hex = None + in_binary_section = False + for line in lines: + if "Binary:" in line: + in_binary_section = True + elif in_binary_section and line.strip() and not line.startswith("="): + init_code_hex = line.strip() + break + + if init_code_hex.startswith("0x"): + init_code_hex = init_code_hex[2:] + + return bytes.fromhex(init_code_hex) + +def load_create2_data(storage_depth: int, account_depth: int) -> Dict: + """Load pre-mined CREATE2 salt data.""" + filename = f"s{storage_depth}_acc{account_depth}.json" + filepath = Path(filename) + + if not filepath.exists(): + raise FileNotFoundError(f"CREATE2 data file not found: {filename}") + + with open(filepath, 'r') as f: + return json.load(f) + +def calculate_create2_address(deployer: str, salt: bytes, init_code: bytes) -> str: + """Calculate CREATE2 address.""" + init_code_hash = keccak(init_code) + create2_input = ( + bytes.fromhex("ff") + + bytes.fromhex(deployer[2:]) + + salt + + init_code_hash + ) + addr = keccak(create2_input)[-20:] + return to_checksum_address("0x" + addr.hex()) + +def deploy_via_nick_factory( + w3: Web3, + account: Account, + init_code: bytes, + salt: bytes, + nonce: int +) -> tuple[str, int]: + """Deploy contract via Nick's factory.""" + # Call Nick's factory with salt + init_code + factory_data = salt + init_code + + tx: TxParams = { + 'from': account.address, + 'to': to_checksum_address(NICK_FACTORY), + 'data': factory_data, + 'gas': DEPLOY_GAS_LIMIT, + 'gasPrice': w3.eth.gas_price, + 'nonce': nonce, + } + + signed_tx = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + + # Calculate deployed address + deployed_addr = calculate_create2_address(NICK_FACTORY, salt, init_code) + + return deployed_addr, nonce + 1 + +def fund_account( + w3: Web3, + account: Account, + recipient: str, + amount: int, + nonce: int +) -> int: + """Fund an account with specified amount.""" + tx: TxParams = { + 'from': account.address, + 'to': to_checksum_address(recipient), + 'value': amount, + 'gas': FUND_GAS_LIMIT, + 'gasPrice': w3.eth.gas_price, + 'nonce': nonce, + } + + signed_tx = account.sign_transaction(tx) + w3.eth.send_raw_transaction(signed_tx.raw_transaction) + + return nonce + 1 + +def batch_transactions(transactions: List[Any], batch_size: int = 100): + """Batch transactions to avoid overwhelming the RPC.""" + for i in range(0, len(transactions), batch_size): + yield transactions[i:i + batch_size] + +def main(): + parser = argparse.ArgumentParser(description='Deploy worst-case attack contracts') + parser.add_argument('--rpc-url', default='http://localhost:8546', help='RPC endpoint URL (can include auth like https://user:pass@host)') + parser.add_argument('--private-key', required=True, help='Private key for deployment') + parser.add_argument('--storage-depth', type=int, default=9, help='Storage depth') + parser.add_argument('--account-depth', type=int, default=3, help='Account depth') + parser.add_argument('--num-contracts', type=int, default=15000, help='Number of contracts to deploy') + parser.add_argument('--output', default='deployed_contracts.json', help='Output JSON file') + parser.add_argument('--skip-contracts', action='store_true', help='Skip contract deployment (only fund EOAs)') + parser.add_argument('--skip-funding', action='store_true', help='Skip EOA funding (only deploy contracts)') + + args = parser.parse_args() + + # Validate arguments + if args.skip_contracts and args.skip_funding: + print("Error: Cannot skip both contracts and funding. Nothing to do!") + sys.exit(1) + + # Connect to node - HTTPProvider automatically handles basic auth in URL + print(f"Connecting to RPC endpoint...") + # Mask credentials in output + display_url = args.rpc_url + if '@' in display_url: + # Extract and mask the auth part + protocol_end = display_url.find('://') + 3 + auth_end = display_url.find('@', protocol_end) + if auth_end > protocol_end: + masked_url = display_url[:protocol_end] + '***:***@' + display_url[auth_end+1:] + print(f" URL: {masked_url}") + else: + print(f" URL: {display_url}") + + w3 = Web3(Web3.HTTPProvider(args.rpc_url)) + + if not w3.is_connected(): + print("Failed to connect to Ethereum node") + sys.exit(1) + + # Setup account + account = Account.from_key(args.private_key) + print(f"Deploying from account: {account.address}") + + balance = w3.eth.get_balance(account.address) + print(f"Account balance: {Web3.from_wei(balance, 'ether')} ETH") + + if balance == 0: + print("\nWARNING: Account has 0 ETH balance!") + print("You need to fund this account before deploying contracts or funding EOAs.") + print(f"Send ETH to: {account.address}") + response = input("\nDo you want to continue anyway? (y/n): ") + if response.lower() != 'y': + print("Exiting...") + sys.exit(1) + + # Load CREATE2 data + print(f"\nLoading CREATE2 data for depth {args.storage_depth}, account depth {args.account_depth}...") + create2_data = load_create2_data(args.storage_depth, args.account_depth) + contracts = create2_data.get("contracts", [])[:args.num_contracts] + + if len(contracts) < args.num_contracts: + print(f"Warning: Only {len(contracts)} contracts available in data file") + + # We always need to compile or know the init code to calculate addresses + if not args.skip_contracts: + print(f"Will deploy {len(contracts)} contracts") + else: + print("Skipping contract deployment (--skip-contracts flag set)") + + # Compile contract (needed for address calculation even if skipping deployment) + init_code = compile_contract(args.storage_depth, args.account_depth) + print(f"Contract init code size: {len(init_code)} bytes") + + # Get starting nonce + nonce = w3.eth.get_transaction_count(account.address) + print(f"Starting nonce: {nonce}") + + # Deployed contracts info + deployed_info = { + "storage_depth": args.storage_depth, + "account_depth": args.account_depth, + "deployer": account.address, + "nick_factory": NICK_FACTORY, + "contracts": [] + } + + # Phase 1: Fund auxiliary accounts + funded_count = 0 + total_aux_accounts = 0 + + if not args.skip_funding: + print("\n=== Phase 1: Funding Auxiliary Accounts ===") + + # Collect all unique auxiliary accounts to fund + aux_accounts_to_fund = set() + for contract_data in contracts: + auxiliary_accounts = contract_data.get("auxiliary_accounts", []) + aux_accounts_to_fund.update(auxiliary_accounts) + + total_aux_accounts = len(aux_accounts_to_fund) + print(f"Total unique auxiliary accounts to fund: {total_aux_accounts}") + + # Calculate batch size for funding transactions + # Simple transfers use about 21,000 gas each + FUND_GAS_LIMIT = 21000 + latest_block = w3.eth.get_block('latest') + network_gas_limit = latest_block['gasLimit'] + print(f"Network gas limit: {network_gas_limit:,}") + + # Target 50% of network gas limit for funding batches + target_gas_per_batch = network_gas_limit // 2 + funding_batch_size = max(1, target_gas_per_batch // FUND_GAS_LIMIT) + print(f"Calculated funding batch size: {funding_batch_size} accounts per batch") + + # Convert set to list for batching + aux_accounts_list = list(aux_accounts_to_fund) + + # Process funding in batches + for batch_num, batch_start in enumerate(range(0, len(aux_accounts_list), funding_batch_size)): + batch = aux_accounts_list[batch_start:batch_start + funding_batch_size] + print(f"\nFunding batch {batch_num + 1} ({len(batch)} accounts)...") + + tx_hashes = [] + batch_start_nonce = nonce + + # Send all funding transactions in the batch + for aux_account in batch: + try: + tx = { + 'from': account.address, + 'to': to_checksum_address(aux_account), + 'value': 1, # 1 wei to create the account + 'gas': FUND_GAS_LIMIT, + 'gasPrice': w3.eth.gas_price, + 'nonce': nonce, + 'chainId': w3.eth.chain_id, # Add EIP-155 replay protection + } + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hashes.append(tx_hash) + nonce += 1 + except Exception as e: + print(f" Error funding {aux_account}: {e}") + # Try to get more details about the error + if "invalid fields" in str(e): + print(f" Debug: Original address: {aux_account}") + print(f" Debug: Checksummed: {to_checksum_address(aux_account)}") + # Only show first few errors in detail + if batch.index(aux_account) < 3: + print(f" Debug: Full tx: {tx}") + + # Wait for all transactions in batch to be mined + if tx_hashes: + print(f" Waiting for {len(tx_hashes)} funding transactions to be mined...") + for tx_hash in tx_hashes: + try: + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60) + if receipt.status == 1: + funded_count += 1 + else: + print(f" Funding transaction failed: {tx_hash.hex()}") + except Exception as e: + print(f" Error waiting for funding tx receipt: {e}") + + print(f" Funded {funded_count}/{total_aux_accounts} accounts so far") + + print(f"\nTotal auxiliary accounts funded: {funded_count}") + else: + print("\n=== Phase 1: Skipping EOA Funding (--skip-funding flag set) ===") + + # Phase 2: Deploy contracts via Nick's factory + if not args.skip_contracts: + print("\n=== Phase 2: Deploying Contracts ===") + + # Get the network gas limit + latest_block = w3.eth.get_block('latest') + network_gas_limit = latest_block['gasLimit'] + print(f"Network gas limit: {network_gas_limit:,}") + + # Calculate batch size: target 50% of network gas limit + # Each deployment uses approximately DEPLOY_GAS_LIMIT + target_gas_per_batch = network_gas_limit // 2 # Use 50% of gas limit + batch_size = max(1, target_gas_per_batch // DEPLOY_GAS_LIMIT) + print(f"Calculated batch size: {batch_size} contracts per batch (targeting 50% of gas limit)") + + for batch_num, batch in enumerate(batch_transactions(contracts, batch_size)): + print(f"\nDeploying batch {batch_num + 1} ({len(batch)} contracts)...") + + batch_start_nonce = nonce + deployed_in_batch = [] + tx_hashes = [] + + # Send all transactions in the batch + for contract_data in batch: + salt = contract_data["salt"] + + # Convert salt to bytes + if isinstance(salt, int): + salt_bytes = salt.to_bytes(32, 'big') + else: + salt_bytes = bytes.fromhex(salt[2:] if salt.startswith("0x") else salt) + + # Deploy via Nick's factory + try: + # Call Nick's factory with salt + init_code + factory_data = salt_bytes + init_code + tx: TxParams = { + 'from': account.address, + 'to': to_checksum_address(NICK_FACTORY), + 'data': factory_data, + 'gas': DEPLOY_GAS_LIMIT, + 'gasPrice': w3.eth.gas_price, + 'nonce': nonce, + 'chainId': w3.eth.chain_id, # Add EIP-155 replay protection + } + + signed_tx = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + tx_hashes.append(tx_hash) + + # Calculate deployed address + deployed_addr = calculate_create2_address(NICK_FACTORY, salt_bytes, init_code) + + # Store deployed info + contract_info = { + "address": deployed_addr, + "salt": "0x" + salt_bytes.hex(), + "auxiliary_accounts": contract_data.get("auxiliary_accounts", []) + } + deployed_info["contracts"].append(contract_info) + deployed_in_batch.append(deployed_addr) + + nonce += 1 + + except Exception as e: + print(f" Error sending deployment tx: {e}") + continue + + # Wait for all transactions in batch to be mined + if tx_hashes: + print(f" Waiting for {len(tx_hashes)} transactions to be mined...") + for tx_hash in tx_hashes: + try: + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60) + if receipt.status == 0: + print(f" Transaction failed: {tx_hash.hex()}") + except Exception as e: + print(f" Error waiting for tx receipt: {e}") + + # Verify deployments + verified = 0 + for addr in deployed_in_batch: + code = w3.eth.get_code(addr) + if len(code) > 0: + verified += 1 + + print(f" Verified {verified}/{len(deployed_in_batch)} contracts deployed") + + # Progress update + total_deployed = len(deployed_info["contracts"]) + print(f"Total progress: {total_deployed}/{len(contracts)} contracts deployed") + else: + print("\n=== Phase 2: Skipping Contract Deployment (--skip-contracts flag set) ===") + # When skipping contracts, populate deployed_info with pre-calculated addresses + for contract_data in contracts: + salt = contract_data["salt"] + # Convert salt to bytes + if isinstance(salt, int): + salt_bytes = salt.to_bytes(32, 'big') + else: + salt_bytes = bytes.fromhex(salt[2:] if salt.startswith("0x") else salt) + + # Calculate what the deployed address would be + deployed_addr = calculate_create2_address(NICK_FACTORY, salt_bytes, init_code) + + contract_info = { + "address": deployed_addr, + "salt": "0x" + salt_bytes.hex(), + "auxiliary_accounts": contract_data.get("auxiliary_accounts", []) + } + deployed_info["contracts"].append(contract_info) + + # Save deployed contracts info + print(f"\n=== Saving Deployment Info ===") + output_path = Path(args.output) + with open(output_path, 'w') as f: + json.dump(deployed_info, f, indent=2) + + print(f"Deployment info saved to: {output_path}") + + # Also save just the contract addresses to stubs.json + stubs_path = Path("stubs.json") + contract_addresses = [contract["address"] for contract in deployed_info["contracts"]] + with open(stubs_path, 'w') as f: + json.dump(contract_addresses, f, indent=2) + + print(f"Contract addresses saved to: {stubs_path}") + print(f"\nOperation complete!") + if not args.skip_contracts: + print(f" Total contracts deployed: {len(deployed_info['contracts'])}") + else: + print(f" Total contract addresses calculated: {len(deployed_info['contracts'])}") + if not args.skip_funding: + print(f" Total auxiliary accounts funded: {funded_count}") + print(f" Final nonce: {nonce}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scenarios/statebloat/storage_trie_brancher/storage_trie_brancher.go b/scenarios/statebloat/storage_trie_brancher/storage_trie_brancher.go new file mode 100644 index 0000000..b791020 --- /dev/null +++ b/scenarios/statebloat/storage_trie_brancher/storage_trie_brancher.go @@ -0,0 +1,800 @@ +package storagetriebrancher + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "os" + "strings" + "sync" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/holiman/uint256" + "github.com/sirupsen/logrus" + "github.com/spf13/pflag" + + "github.com/ethpandaops/spamoor/scenario" + "github.com/ethpandaops/spamoor/spamoor" + "github.com/ethpandaops/spamoor/txbuilder" +) + +const ( + // Nick's factory address for CREATE2 deployments + NickFactoryAddress = "0x4e59b44847b379578588920ca78fbf26c0b4956c" + + // Gas limits + DeployGasLimit = 3000000 + FundGasLimit = 21000 + + // Funding amount for auxiliary accounts (1 wei to create the account) + FundingAmount = 1 +) + +type ScenarioOptions struct { + TotalContracts uint64 `yaml:"total_contracts"` + StorageDepth uint64 `yaml:"storage_depth"` + AccountDepth uint64 `yaml:"account_depth"` + MaxWallets uint64 `yaml:"max_wallets"` + SkipContracts bool `yaml:"skip_contracts"` + SkipFunding bool `yaml:"skip_funding"` + DataFile string `yaml:"data_file"` + Bytecode string `yaml:"bytecode"` // Bytecode hex string (or path/URL to bytecode file) + BaseFee float64 `yaml:"base_fee"` + TipFee float64 `yaml:"tip_fee"` + ClientGroup string `yaml:"client_group"` + LogTxs bool `yaml:"log_txs"` +} + +type Scenario struct { + options ScenarioOptions + logger *logrus.Entry + walletPool *spamoor.WalletPool + + // Deployment data + deployData *DeploymentData + initCode []byte + deployments []DeploymentInfo + factoryAddress common.Address // Actual factory address (might differ from Nick's canonical address) + + // Progress tracking + fundedAccounts map[string]bool + deployedContracts map[string]bool + currentIndex uint64 + phase string // "funding" or "deploying" + mu sync.RWMutex +} + +type DeploymentData struct { + Deployer string `json:"deployer"` + InitCodeHash string `json:"init_code_hash"` + TargetDepth int `json:"target_depth"` + NumContracts int `json:"num_contracts"` + Contracts []ContractData `json:"contracts"` +} + +type ContractData struct { + Salt interface{} `json:"salt"` // Can be number or string + ContractAddress string `json:"contract_address"` + AuxiliaryAccounts []string `json:"auxiliary_accounts"` +} + +type DeploymentInfo struct { + Address string `json:"address"` + Salt string `json:"salt"` + AuxiliaryAccounts []string `json:"auxiliary_accounts"` +} + +var ScenarioName = "storage-trie-brancher" +var ScenarioDefaultOptions = ScenarioOptions{ + TotalContracts: 1000, + StorageDepth: 9, + AccountDepth: 3, + MaxWallets: 50, + SkipContracts: false, + SkipFunding: false, + DataFile: "", + Bytecode: "", + BaseFee: 20, + TipFee: 2, + ClientGroup: "", + LogTxs: false, +} + +var ScenarioDescriptor = scenario.Descriptor{ + Name: ScenarioName, + Description: "Deploy worst-case depth attack contracts (deep storage tries with auxiliary accounts)", + DefaultOptions: ScenarioDefaultOptions, + NewScenario: newScenario, +} + +func newScenario(logger logrus.FieldLogger) scenario.Scenario { + return &Scenario{ + options: ScenarioDefaultOptions, + logger: logger.WithField("scenario", ScenarioName), + fundedAccounts: make(map[string]bool), + deployedContracts: make(map[string]bool), + } +} + +func (s *Scenario) Flags(flags *pflag.FlagSet) error { + flags.Uint64VarP(&s.options.TotalContracts, "count", "c", ScenarioDefaultOptions.TotalContracts, "Total number of contracts to deploy") + flags.Uint64Var(&s.options.StorageDepth, "storage-depth", ScenarioDefaultOptions.StorageDepth, "Storage trie depth (9 or 10)") + flags.Uint64Var(&s.options.AccountDepth, "account-depth", ScenarioDefaultOptions.AccountDepth, "Account trie depth (3, 4, or 5)") + flags.Uint64Var(&s.options.MaxWallets, "max-wallets", ScenarioDefaultOptions.MaxWallets, "Maximum number of wallets to use for parallel execution") + flags.BoolVar(&s.options.SkipContracts, "skip-contracts", ScenarioDefaultOptions.SkipContracts, "Skip contract deployment (only fund EOAs)") + flags.BoolVar(&s.options.SkipFunding, "skip-funding", ScenarioDefaultOptions.SkipFunding, "Skip EOA funding (only deploy contracts)") + flags.StringVar(&s.options.DataFile, "data-file", ScenarioDefaultOptions.DataFile, "Path or URL to CREATE2 data JSON file (required)") + flags.StringVar(&s.options.Bytecode, "bytecode", ScenarioDefaultOptions.Bytecode, "Contract bytecode hex string or path/URL to bytecode file (required)") + flags.Float64Var(&s.options.BaseFee, "basefee", ScenarioDefaultOptions.BaseFee, "Max fee per gas (in gwei)") + flags.Float64Var(&s.options.TipFee, "tipfee", ScenarioDefaultOptions.TipFee, "Max tip per gas (in gwei)") + flags.StringVar(&s.options.ClientGroup, "client-group", ScenarioDefaultOptions.ClientGroup, "Client group to use for sending transactions") + flags.BoolVar(&s.options.LogTxs, "log-txs", ScenarioDefaultOptions.LogTxs, "Log all submitted transactions") + return nil +} + +func (s *Scenario) Init(options *scenario.Options) error { + s.walletPool = options.WalletPool + + if options.Config != "" { + // Use the generalized config validation and parsing helper + err := scenario.ParseAndValidateConfig(&ScenarioDescriptor, options.Config, &s.options, s.logger) + if err != nil { + return err + } + } + + // Validate options + if s.options.SkipContracts && s.options.SkipFunding { + return fmt.Errorf("cannot skip both contracts and funding - nothing to do") + } + + if s.options.TotalContracts == 0 { + return fmt.Errorf("total contracts must be greater than 0") + } + + // Set up wallets for parallel execution + // Use the configured MaxWallets, or default to 50 if not specified + if s.options.MaxWallets > 0 { + s.walletPool.SetWalletCount(s.options.MaxWallets) + } else { + s.walletPool.SetWalletCount(50) // Default fallback + } + + // Validate required parameters + if s.options.DataFile == "" { + return fmt.Errorf("data-file is required") + } + + if s.options.Bytecode == "" { + return fmt.Errorf("bytecode is required") + } + + // Load deployment data + if err := s.loadDeploymentData(); err != nil { + return fmt.Errorf("failed to load deployment data: %w", err) + } + + // Load bytecode + if err := s.loadBytecode(); err != nil { + return fmt.Errorf("failed to load bytecode: %w", err) + } + + s.logger.WithFields(logrus.Fields{ + "contracts": s.options.TotalContracts, + "storage_depth": s.options.StorageDepth, + "account_depth": s.options.AccountDepth, + "data_file": s.options.DataFile, + "bytecode_length": len(s.initCode), + }).Info("initialized storage trie brancher scenario") + + return nil +} + +// loadDataFromPathOrURL loads data from a file path or URL +func (s *Scenario) loadDataFromPathOrURL(pathOrURL string) ([]byte, error) { + if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") { + // Load from URL + s.logger.WithField("url", pathOrURL).Debug("Loading data from URL") + resp, err := http.Get(pathOrURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP error: %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + return data, nil + } + + // Load from file + s.logger.WithField("file", pathOrURL).Debug("Loading data from file") + return os.ReadFile(pathOrURL) +} + +func (s *Scenario) loadDeploymentData() error { + data, err := s.loadDataFromPathOrURL(s.options.DataFile) + if err != nil { + return fmt.Errorf("failed to load data file: %w", err) + } + + s.deployData = &DeploymentData{} + if err := json.Unmarshal(data, s.deployData); err != nil { + return fmt.Errorf("failed to parse JSON data: %w", err) + } + + // Limit contracts to requested count + if uint64(len(s.deployData.Contracts)) > s.options.TotalContracts { + s.deployData.Contracts = s.deployData.Contracts[:s.options.TotalContracts] + } else if uint64(len(s.deployData.Contracts)) < s.options.TotalContracts { + s.logger.Warnf("Only %d contracts available in data file, requested %d", + len(s.deployData.Contracts), s.options.TotalContracts) + s.options.TotalContracts = uint64(len(s.deployData.Contracts)) + } + + return nil +} + +func (s *Scenario) loadBytecode() error { + bytecodeInput := s.options.Bytecode + + // Check if it's a file path or URL + if strings.HasPrefix(bytecodeInput, "http://") || strings.HasPrefix(bytecodeInput, "https://") || + strings.Contains(bytecodeInput, "/") || strings.HasSuffix(bytecodeInput, ".bin") { + // It's a path or URL, load from file/URL + s.logger.WithField("source", bytecodeInput).Debug("Loading bytecode from file/URL") + data, err := s.loadDataFromPathOrURL(bytecodeInput) + if err != nil { + return fmt.Errorf("failed to load bytecode file: %w", err) + } + bytecodeInput = string(data) + } + + // Clean up the bytecode hex string + bytecodeHex := strings.TrimSpace(bytecodeInput) + bytecodeHex = strings.TrimPrefix(bytecodeHex, "0x") + + // Decode hex string to bytes + var err error + s.initCode, err = hex.DecodeString(bytecodeHex) + if err != nil { + return fmt.Errorf("failed to decode bytecode hex: %w", err) + } + + if len(s.initCode) == 0 { + return fmt.Errorf("bytecode is empty") + } + + s.logger.Infof("Loaded bytecode, init code size: %d bytes", len(s.initCode)) + return nil +} + +func (s *Scenario) ensureNicksFactory(ctx context.Context) error { + s.logger.Info("Phase 2: Checking if Nick's factory exists") + + // Get a client to check factory existence + client := s.walletPool.GetClient(spamoor.WithClientGroup(s.options.ClientGroup)) + if client == nil { + return scenario.ErrNoClients + } + + // First check canonical Nick's factory address + canonicalAddr := common.HexToAddress(NickFactoryAddress) + code, err := client.GetEthClient().CodeAt(ctx, canonicalAddr, nil) + if err != nil { + return fmt.Errorf("failed to check factory existence: %w", err) + } + + if len(code) > 0 { + s.logger.Infof("Nick's factory already exists at canonical address %s", NickFactoryAddress) + s.factoryAddress = canonicalAddr + return nil + } + + s.logger.Infof("Nick's factory not found at canonical address, deploying it now") + + // Deploy Nick's factory at the canonical address + if err := s.deployNicksFactory(ctx); err != nil { + return fmt.Errorf("failed to deploy Nick's factory: %w", err) + } + + // Verify deployment + code, err = client.GetEthClient().CodeAt(ctx, canonicalAddr, nil) + if err != nil { + return fmt.Errorf("failed to verify factory deployment: %w", err) + } + + if len(code) == 0 { + return fmt.Errorf("factory deployment failed - no code at canonical address") + } + + s.factoryAddress = canonicalAddr + s.logger.Infof("Successfully deployed Nick's factory at canonical address %s", NickFactoryAddress) + return nil +} + +func (s *Scenario) deployNicksFactory(ctx context.Context) error { + // Nick's factory deployment method: + // 1. Fund the deployment address 0x3fab184622dc19b6109349b94811493bf2a45362 + // 2. Send the pre-signed deployment transaction + + client := s.walletPool.GetClient(spamoor.WithClientGroup(s.options.ClientGroup)) + if client == nil { + return scenario.ErrNoClients + } + + // The deployment address that needs to be funded + deployerAddr := common.HexToAddress("0x3fab184622dc19b6109349b94811493bf2a45362") + + // Check if deployer already has funds + balance, err := client.GetEthClient().BalanceAt(ctx, deployerAddr, nil) + if err != nil { + return fmt.Errorf("failed to check deployer balance: %w", err) + } + + // Need at least 0.04 ETH for deployment + requiredBalance := new(big.Int).Mul(big.NewInt(4), big.NewInt(1e16)) // 0.04 ETH + + if balance.Cmp(requiredBalance) < 0 { + s.logger.Infof("Funding Nick's factory deployer address with 0.05 ETH") + + // Fund the deployer address + wallet := s.walletPool.GetWallet(spamoor.SelectWalletByIndex, 0) + if wallet == nil { + return scenario.ErrNoWallet + } + + if err := wallet.ResetNoncesIfNeeded(ctx, client); err != nil { + return err + } + + feeCap, tipCap, err := s.walletPool.GetTxPool().GetSuggestedFees(client, s.options.BaseFee, s.options.TipFee) + if err != nil { + return err + } + + // Send 0.05 ETH to the deployer address + fundAmount := new(big.Int).Mul(big.NewInt(5), big.NewInt(1e16)) // 0.05 ETH + txData, err := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + Gas: 21000, + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + To: &deployerAddr, + Value: uint256.MustFromBig(fundAmount), + }) + + if err != nil { + return fmt.Errorf("failed to build funding tx: %w", err) + } + + tx, err := wallet.BuildDynamicFeeTx(txData) + if err != nil { + return fmt.Errorf("failed to sign funding tx: %w", err) + } + + receipt, err := s.walletPool.GetTxPool().SendAndAwaitTransaction(ctx, wallet, tx, &spamoor.SendTransactionOptions{ + Client: client, + ClientGroup: s.options.ClientGroup, + Rebroadcast: true, + }) + + if err != nil { + return fmt.Errorf("failed to fund deployer address: %w", err) + } + + if receipt == nil || receipt.Status != 1 { + return fmt.Errorf("funding transaction failed") + } + + s.logger.Infof("Funded deployer address in block %s", receipt.BlockNumber.String()) + } + + // Now send the pre-signed deployment transaction + // This is the actual signed transaction from Nick that deploys the factory + rawTx := "0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222" + + var tx types.Transaction + if err := tx.UnmarshalBinary(common.FromHex(rawTx)); err != nil { + return fmt.Errorf("failed to unmarshal pre-signed transaction: %w", err) + } + + // Send the pre-signed transaction + if err := client.GetEthClient().SendTransaction(ctx, &tx); err != nil { + // Check if the error is because factory already exists + if strings.Contains(err.Error(), "already known") || strings.Contains(err.Error(), "nonce too low") { + s.logger.Info("Factory deployment transaction already processed") + return nil + } + return fmt.Errorf("failed to send deployment transaction: %w", err) + } + + // Wait for the transaction to be mined + receipt, err := bind.WaitMined(ctx, client.GetEthClient(), &tx) + if err != nil { + return fmt.Errorf("failed to wait for deployment: %w", err) + } + + if receipt.Status != 1 { + return fmt.Errorf("factory deployment transaction failed") + } + + s.logger.Infof("Nick's factory deployed successfully in block %s", receipt.BlockNumber.String()) + return nil +} + +func (s *Scenario) Run(ctx context.Context) error { + // Phase 1: Fund auxiliary accounts + if !s.options.SkipFunding { + s.phase = "funding" + s.logger.Info("Phase 1: Funding auxiliary accounts") + + // Count total auxiliary accounts + auxAccounts := make(map[string]bool) + for _, contract := range s.deployData.Contracts { + for _, acc := range contract.AuxiliaryAccounts { + auxAccounts[acc] = true + } + } + totalAuxAccounts := uint64(len(auxAccounts)) + s.logger.Infof("Total unique auxiliary accounts to fund: %d", totalAuxAccounts) + + // Run funding scenario - deploy all as fast as possible + err := scenario.RunTransactionScenario(ctx, scenario.TransactionScenarioOptions{ + TotalCount: totalAuxAccounts, + Throughput: 0, // No throughput limit - send as fast as possible + MaxPending: 100, // Reasonable pending limit + ThroughputIncrementInterval: 0, + Timeout: 0, + WalletPool: s.walletPool, + Logger: s.logger, + ProcessNextTxFn: s.sendFundingTx, + }) + + if err != nil { + s.logger.Warnf("Error during funding phase: %v", err) + } + + s.logger.Infof("Funded %d auxiliary accounts", len(s.fundedAccounts)) + } + + // Phase 2: Ensure Nick's factory exists + if !s.options.SkipContracts { + if err := s.ensureNicksFactory(ctx); err != nil { + return fmt.Errorf("failed to ensure Nick's factory exists: %w", err) + } + } + + // Phase 3: Deploy contracts via Nick's factory + if !s.options.SkipContracts { + s.phase = "deploying" + s.currentIndex = 0 + s.logger.Info("Phase 3: Deploying contracts via Nick's factory") + + err := scenario.RunTransactionScenario(ctx, scenario.TransactionScenarioOptions{ + TotalCount: s.options.TotalContracts, + Throughput: 0, // No throughput limit - deploy as fast as possible + MaxPending: 50, // Lower limit for deployments due to higher gas usage + ThroughputIncrementInterval: 0, + Timeout: 0, + WalletPool: s.walletPool, + Logger: s.logger, + ProcessNextTxFn: s.sendDeploymentTx, + }) + + if err != nil { + s.logger.Warnf("Error during deployment phase: %v", err) + } + + s.logger.Infof("Deployed %d contracts", len(s.deployedContracts)) + } + + // Log deployment info + s.logDeploymentInfo() + + s.logger.WithFields(logrus.Fields{ + "contracts_deployed": len(s.deployedContracts), + "accounts_funded": len(s.fundedAccounts), + }).Info("storage trie brancher scenario completed") + + return nil +} + +func (s *Scenario) sendFundingTx(ctx context.Context, params *scenario.ProcessNextTxParams) error { + // Collect all unique auxiliary accounts with proper locking + s.mu.RLock() + auxAccounts := make([]string, 0) + for _, contract := range s.deployData.Contracts { + for _, acc := range contract.AuxiliaryAccounts { + if !s.fundedAccounts[acc] { + auxAccounts = append(auxAccounts, acc) + } + } + } + s.mu.RUnlock() + + if params.TxIdx >= uint64(len(auxAccounts)) { + return fmt.Errorf("funding index out of range") + } + + account := auxAccounts[params.TxIdx] + + // Get client and wallet + client := s.walletPool.GetClient( + spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, int(params.TxIdx)), + spamoor.WithClientGroup(s.options.ClientGroup), + ) + wallet := s.walletPool.GetWallet(spamoor.SelectWalletByIndex, int(params.TxIdx)) + + if client == nil { + return scenario.ErrNoClients + } + if wallet == nil { + return scenario.ErrNoWallet + } + + if err := wallet.ResetNoncesIfNeeded(ctx, client); err != nil { + return err + } + + feeCap, tipCap, err := s.walletPool.GetTxPool().GetSuggestedFees(client, s.options.BaseFee, s.options.TipFee) + if err != nil { + return err + } + + // Build funding transaction + toAddr := common.HexToAddress(account) + txData, err := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + Gas: FundGasLimit, + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + To: &toAddr, + Value: uint256.NewInt(FundingAmount), + }) + + if err != nil { + return fmt.Errorf("failed to build funding tx for %s: %w", account, err) + } + + tx, err := wallet.BuildDynamicFeeTx(txData) + if err != nil { + return fmt.Errorf("failed to sign funding tx for %s: %w", account, err) + } + + // Send transaction + receipt, err := s.walletPool.GetTxPool().SendAndAwaitTransaction(ctx, wallet, tx, &spamoor.SendTransactionOptions{ + Client: client, + ClientGroup: s.options.ClientGroup, + Rebroadcast: true, // Always use rebroadcast for reliability + }) + + params.NotifySubmitted() + + logger := s.logger.WithFields(logrus.Fields{ + "rpc": client.GetName(), + "nonce": tx.Nonce(), + "wallet": s.walletPool.GetWalletName(wallet.GetAddress()), + }) + + params.OrderedLogCb(func() { + if err != nil { + logger.Warnf("could not send funding transaction: %v", err) + } else if s.options.LogTxs { + logger.Infof("sent funding tx #%6d: %v to %s", params.TxIdx+1, tx.Hash().String(), account) + } else { + logger.Debugf("sent funding tx #%6d: %v to %s", params.TxIdx+1, tx.Hash().String(), account) + } + }) + + if receipt != nil { + s.mu.Lock() + s.fundedAccounts[account] = true + s.mu.Unlock() + } + + return err +} + +func (s *Scenario) sendDeploymentTx(ctx context.Context, params *scenario.ProcessNextTxParams) error { + if params.TxIdx >= uint64(len(s.deployData.Contracts)) { + return fmt.Errorf("deployment index out of range") + } + + contract := s.deployData.Contracts[params.TxIdx] + + // Skip if already deployed + s.mu.RLock() + if s.deployedContracts[contract.ContractAddress] { + s.mu.RUnlock() + return nil + } + s.mu.RUnlock() + + // Get client and wallet + client := s.walletPool.GetClient( + spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, int(params.TxIdx)), + spamoor.WithClientGroup(s.options.ClientGroup), + ) + wallet := s.walletPool.GetWallet(spamoor.SelectWalletByIndex, int(params.TxIdx)) + + if client == nil { + return scenario.ErrNoClients + } + if wallet == nil { + return scenario.ErrNoWallet + } + + if err := wallet.ResetNoncesIfNeeded(ctx, client); err != nil { + return err + } + + feeCap, tipCap, err := s.walletPool.GetTxPool().GetSuggestedFees(client, s.options.BaseFee, s.options.TipFee) + if err != nil { + return err + } + + // Convert salt to bytes + saltBytes, err := s.saltToBytes(contract.Salt) + if err != nil { + return fmt.Errorf("failed to convert salt: %w", err) + } + + // Build factory call data (salt + init code) + factoryData := append(saltBytes, s.initCode...) + + // Build deployment transaction + txData, err := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + Gas: DeployGasLimit, + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + To: &s.factoryAddress, + Data: factoryData, + }) + + if err != nil { + return fmt.Errorf("failed to build deployment tx: %w", err) + } + + tx, err := wallet.BuildDynamicFeeTx(txData) + if err != nil { + return fmt.Errorf("failed to sign deployment tx: %w", err) + } + + // Send transaction + receipt, err := s.walletPool.GetTxPool().SendAndAwaitTransaction(ctx, wallet, tx, &spamoor.SendTransactionOptions{ + Client: client, + ClientGroup: s.options.ClientGroup, + Rebroadcast: true, // Always use rebroadcast for reliability + }) + + params.NotifySubmitted() + + logger := s.logger.WithFields(logrus.Fields{ + "rpc": client.GetName(), + "nonce": tx.Nonce(), + "wallet": s.walletPool.GetWalletName(wallet.GetAddress()), + }) + + params.OrderedLogCb(func() { + if err != nil { + logger.Warnf("could not send deployment transaction: %v", err) + } else if s.options.LogTxs { + deployAddr := s.calculateCreate2Address(saltBytes) + logger.Infof("sent deployment tx #%6d: %v (contract: %s)", params.TxIdx+1, tx.Hash().String(), deployAddr.Hex()) + } else { + logger.Debugf("sent deployment tx #%6d: %v", params.TxIdx+1, tx.Hash().String()) + } + }) + + if receipt != nil && receipt.Status == 1 { + // Calculate deployment address + deployAddr := s.calculateCreate2Address(saltBytes) + + s.mu.Lock() + s.deployedContracts[contract.ContractAddress] = true + s.deployments = append(s.deployments, DeploymentInfo{ + Address: deployAddr.Hex(), + Salt: "0x" + hex.EncodeToString(saltBytes), + AuxiliaryAccounts: contract.AuxiliaryAccounts, + }) + s.mu.Unlock() + } + + return err +} + +func (s *Scenario) saltToBytes(salt interface{}) ([]byte, error) { + switch v := salt.(type) { + case float64: + // JSON numbers come as float64 + saltBig := big.NewInt(int64(v)) + saltBytes := make([]byte, 32) + saltBig.FillBytes(saltBytes) + return saltBytes, nil + case int: + saltBig := big.NewInt(int64(v)) + saltBytes := make([]byte, 32) + saltBig.FillBytes(saltBytes) + return saltBytes, nil + case string: + saltStr := v + saltStr = strings.TrimPrefix(saltStr, "0x") + saltBytes, err := hex.DecodeString(saltStr) + if err != nil { + return nil, err + } + if len(saltBytes) != 32 { + // Pad with zeros if needed + padded := make([]byte, 32) + copy(padded[32-len(saltBytes):], saltBytes) + return padded, nil + } + return saltBytes, nil + default: + return nil, fmt.Errorf("unsupported salt type: %T", v) + } +} + +func (s *Scenario) calculateCreate2Address(salt []byte) common.Address { + // CREATE2 address = keccak256(0xff ++ deployer ++ salt ++ keccak256(init_code))[12:] + initCodeHash := crypto.Keccak256(s.initCode) + + data := []byte{0xff} + data = append(data, s.factoryAddress.Bytes()...) + data = append(data, salt...) + data = append(data, initCodeHash...) + + hash := crypto.Keccak256(data) + return common.BytesToAddress(hash[12:]) +} + +func (s *Scenario) logDeploymentInfo() { + // Log deployment summary + s.logger.WithFields(logrus.Fields{ + "storage_depth": s.options.StorageDepth, + "account_depth": s.options.AccountDepth, + "deployer": s.factoryAddress.Hex(), + "contracts_deployed": len(s.deployedContracts), + "accounts_funded": len(s.fundedAccounts), + }).Info("Deployment completed - summary") + + // Log deployed contract addresses for reference + if len(s.deployments) > 0 { + addresses := make([]string, 0, len(s.deployments)) + for _, deploy := range s.deployments { + addresses = append(addresses, deploy.Address) + } + + // Log first 10 addresses as example + logCount := 10 + if len(addresses) < logCount { + logCount = len(addresses) + } + + s.logger.WithField("sample_addresses", addresses[:logCount]).Info("Sample deployed contract addresses") + + // Log all deployment details as structured JSON for programmatic access + if s.options.LogTxs { + deploymentData, _ := json.Marshal(map[string]interface{}{ + "storage_depth": s.options.StorageDepth, + "account_depth": s.options.AccountDepth, + "deployer": s.factoryAddress.Hex(), + "contracts_deployed": len(s.deployedContracts), + "accounts_funded": len(s.fundedAccounts), + "contracts": s.deployments, + }) + s.logger.WithField("deployment_data", string(deploymentData)).Debug("Full deployment data (JSON)") + } + } +}