From c6f2a2d871878062423cfd070943d690280271a8 Mon Sep 17 00:00:00 2001 From: Dmytro Biloshytskyi Date: Mon, 20 Apr 2026 18:11:32 +0300 Subject: [PATCH 01/11] Added spamoor tests and plugin PoC --- .../plugins/spamoor/__init__.py | 0 .../plugins/spamoor/spamoor.py | 180 +++++++++++ tests/benchmark/spamoor/__init__.py | 0 tests/benchmark/spamoor/helpers.py | 290 ++++++++++++++++++ tests/benchmark/spamoor/test_calltx.py | 86 ++++++ tests/benchmark/spamoor/test_eoatx.py | 22 ++ .../benchmark/spamoor/test_factorydeploytx.py | 53 ++++ 7 files changed, 631 insertions(+) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/__init__.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py create mode 100644 tests/benchmark/spamoor/__init__.py create mode 100644 tests/benchmark/spamoor/helpers.py create mode 100644 tests/benchmark/spamoor/test_calltx.py create mode 100644 tests/benchmark/spamoor/test_eoatx.py create mode 100644 tests/benchmark/spamoor/test_factorydeploytx.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py new file mode 100644 index 00000000000..44fcbb26178 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py @@ -0,0 +1,180 @@ +import pytest +import requests + + +def pytest_addoption(parser: pytest.Parser) -> None: + group = parser.getgroup("spamoor", "Spamoor load generation tool options") + group.addoption( + "--spamoor-endpoint", + "--rpc-url", + dest="spamoor_endpoint", + default="http://localhost:8545", + help="RPC endpoint", + ) + group.addoption( + "--spamoor-count", + "--transactions-count", + dest="spamoor_count", + type=int, + default=10, + help="Number of txs", + ) + group.addoption( + "--spamoor-throughput", + type=float, + default=1.0, + help="Txs per slot/second multiplier", + ) + group.addoption( + "--spamoor-basefee", + type=int, + default=None, + help="Basefee override in wei", + ) + group.addoption( + "--spamoor-amount", + type=int, + default=1000000000000000000, + help="Amount in wei", + ) + group.addoption( + "--spamoor-from", type=str, default=None, help="Sender address" + ) + group.addoption( + "--spamoor-private-key", + type=str, + default=None, + help="Private key for signing", + ) + group.addoption( + "--spamoor-contract-code", + type=str, + default=None, + help="Contract bytecode to deploy", + ) + group.addoption( + "--spamoor-contract-address", + type=str, + default=None, + help="Contract address for execution txs", + ) + group.addoption( + "--spamoor-call-data", + type=str, + default="", + help="Call data for execution txs", + ) + group.addoption( + "--spamoor-deploy-gas-limit", + type=int, + default=2000000, + help="Gas limit for deployment", + ) + group.addoption( + "--spamoor-call-fn-sig", + type=str, + default="", + help="Function signature for call", + ) + group.addoption( + "--spamoor-call-args", + type=str, + default="[]", + help="JSON list of call arguments", + ) + group.addoption( + "--spamoor-contract-args", + type=str, + default="[]", + help="JSON list of constructor arguments", + ) + group.addoption( + "--spamoor-gas-limit", + type=int, + default=0, + help="Gas limit for execution txs (0=dynamic/fallback)", + ) + group.addoption( + "--spamoor-tip-fee", + type=int, + default=1_000_000_000, + help="Priority fee in wei", + ) + group.addoption( + "--spamoor-start-salt", + dest="spamoor_start_salt", + type=int, + default=0, + help="Salt to start the Spamoor sequence", + ) + group.addoption( + "--spamoor-init-code", + dest="spamoor_init_code", + type=str, + default="", + help="Initialization code for Spamoor", + ) + group.addoption( + "--spamoor-factory-address", + dest="spamoor_factory_address", + type=str, + default="", + help="Factory contract address for Spamoor initialization", + ) + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", "spamoor: Run spamoor load generation tests" + ) + + +@pytest.fixture(scope="session") +def spamoor_config(request): + return { + "endpoint": request.config.getoption("spamoor_endpoint"), + "count": request.config.getoption("spamoor_count"), + "throughput": request.config.getoption("--spamoor-throughput"), + "basefee": request.config.getoption("--spamoor-basefee"), + "amount": request.config.getoption("--spamoor-amount"), + "from_addr": request.config.getoption("--spamoor-from"), + "private_key": request.config.getoption("--spamoor-private-key"), + "contract_code": request.config.getoption("--spamoor-contract-code"), + "contract_address": request.config.getoption( + "--spamoor-contract-address" + ), + "call_data": request.config.getoption("--spamoor-call-data"), + "deploy_gas_limit": request.config.getoption( + "--spamoor-deploy-gas-limit" + ), + "call_fn_sig": request.config.getoption("--spamoor-call-fn-sig"), + "call_args": request.config.getoption("--spamoor-call-args"), + "contract_args": request.config.getoption("--spamoor-contract-args"), + "gas_limit": request.config.getoption("--spamoor-gas-limit"), + "tip_fee": request.config.getoption("--spamoor-tip-fee"), + # New Spamoor options + "start_salt": request.config.getoption("spamoor_start_salt"), + "init_code": request.config.getoption("spamoor_init_code"), + "factory_address": request.config.getoption("spamoor_factory_address"), + } + + +@pytest.fixture(scope="session") +def spamoor_rpc_client(spamoor_config): + endpoint = spamoor_config["endpoint"] + + def rpc_call(method, params): + try: + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + } + resp = requests.post(endpoint, json=payload, timeout=5) + resp.raise_for_status() + return resp.json().get("result") + except Exception: + return None + + return rpc_call diff --git a/tests/benchmark/spamoor/__init__.py b/tests/benchmark/spamoor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/benchmark/spamoor/helpers.py b/tests/benchmark/spamoor/helpers.py new file mode 100644 index 00000000000..47c79f9bfa0 --- /dev/null +++ b/tests/benchmark/spamoor/helpers.py @@ -0,0 +1,290 @@ +from typing import List, Optional, Callable, Any, Dict +import json + +try: + from eth_abi import encode as eth_abi_encode + from eth_utils import keccak +except ImportError: + eth_abi_encode = None + keccak = None + + +def build_eoatx_transactions( + count: int, + throughput: float, + amount: int = 0, + basefee: Optional[int] = None, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + max_priority_fee_per_gas = 1_000_000_000 + + to_addr = "0x0000000000000000000000000000000000000000" + txs = [] + for i in range(count): + tx = { + "type": 2, + "to": to_addr, + "value": amount, + "data": "", + "gas": 21000, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": max_priority_fee_per_gas, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + i + txs.append(tx) + return txs + + +def build_calltx_transactions( + count: int, + throughput: float, + amount: int = 0, + basefee: Optional[int] = None, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + contract_code: Optional[str] = None, + contract_address: Optional[str] = None, + call_data: str = "", + call_fn_sig: str = "", + call_args: str = "[]", + contract_args: str = "[]", + gas_limit: int = 0, + tip_fee: int = 1_000_000_000, + deploy_gas_limit: int = 2000000, + rpc_client: Optional[Callable] = None, +) -> List[Dict[str, Any]]: + """Build a list of calltx-like transactions. + + If contract_code is provided, first include a deployment transaction: + type=2, to="", value=0, data=contract_code, gas=deploy_gas_limit + Then append `count` execution transactions: + type=2, to=contract_address or fallback, value=amount, data=call_data, gas=21000 + Nonce handling mirrors build_eoatx_transactions: fetch once if from_addr and + rpc_client provided, and increment nonce for each subsequent tx when nonce is known. + """ + + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + max_priority_fee_per_gas = 1_000_000_000 + + txs: List[Dict[str, Any]] = [] + + # ABI-encode call_data when not provided but a function signature is given + parsed_call_data = call_data + if not parsed_call_data and call_fn_sig and eth_abi_encode and keccak: + import re + + sig_match = re.match(r"^[^\(]+\((.*)\)$", call_fn_sig) + if sig_match: + types_str = sig_match.group(1) + types = types_str.split(",") if types_str else [] + try: + args = json.loads(call_args) + encoded_args = eth_abi_encode(types, args) + selector = keccak(text=call_fn_sig)[:4] + parsed_call_data = "0x" + selector.hex() + encoded_args.hex() + except Exception: + pass + + # Deployment transaction if contract code provided + if contract_code is not None: + dep_tx: Dict[str, Any] = { + "type": 2, + "to": "", + "value": 0, + "data": contract_code, + "gas": deploy_gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + dep_tx["nonce"] = nonce + nonce += 1 # increment after using nonce for deployment + txs.append(dep_tx) + # MVP: prepare for potential contract constructor ABI encoding (no-op if types unavailable) + parsed_contract_code = contract_code + if ( + parsed_contract_code + and contract_args + and contract_args != "[]" + and eth_abi_encode + ): + # We need the constructor ABI types to properly encode this. + # But for MVP, if we don't have the types, we can't easily encode it. + # Spamoor Go code seems to know the types. Let's assume for MVP we only support raw `contract_code` unless types are known. + # Actually, let's just leave parsed_contract_code as is for now and document it. + pass + + # Execution transactions + target_to = ( + contract_address + if contract_address is not None + else "0x1111111111111111111111111111111111111111" + ) + # Determine gas to use for execution transactions (default 500000) + execution_gas = gas_limit if gas_limit and gas_limit > 0 else 500000 + + for i in range(count): + tx: Dict[str, Any] = { + "type": 2, + "to": target_to, + "value": amount, + "data": parsed_call_data, + "gas": execution_gas, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + nonce += 1 + txs.append(tx) + + return txs + + +def build_factorydeploytx_transactions( + count: int, + init_code: str, + start_salt: int = 0, + factory_address: str = "", + deploy_gas_limit: int = 2000000, + gas_limit: int = 500000, + max_fee_per_gas: int = 20_000_000_000, + tip_fee: int = 1_000_000_000, + rpc_client: Optional[Callable] = None, +) -> List[Dict[str, Any]]: + """Build factory deployment + deploy(bytes32,bytes) transactions. + + If factory_address is empty, emit a deployment tx with to: "" and data as + the factory bytecode (placeholder if not provided), then target the deployed + factory (mock address used if real receipt is unavailable). + Then emit `count` deploy calls to the factory with salt and init_code. + """ + + # Use a mock factory address if none provided + target_address = ( + factory_address + if factory_address + else "0x2222222222222222222222222222222222222222" + ) + + FACTORY_BYTECODE = "0x608060405234801561001057600080fd5b50610365806100206000396000f3fe6080604052600436106100295760003560e01c806310a935281461002e578063cdcb760a14610064575b600080fd5b34801561003a57600080fd5b5061004e6100493660046101db565b610077565b60405161005b91906102d7565b60405180910390f35b61004e6100723660046101fc565b6100ee565b6040516000906100b1907fff0000000000000000000000000000000000000000000000000000000000000090309086908690602001610273565b604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081840301815291905280516020909101209392505050565b600080600084848080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525050825192935088929150506020830134f5915073ffffffffffffffffffffffffffffffffffffffff821661018f576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610186906102f8565b60405180910390fd5b604051869073ffffffffffffffffffffffffffffffffffffffff8416907fb085ff794f342ed78acc7791d067e28a931e614b52476c0305795e1ff0a154bc90600090a350949350505050565b600080604083850312156101ed578182fd5b50508035926020909101359150565b600080600060408486031215610210578081fd5b83359250602084013567ffffffffffffffff8082111561022e578283fd5b818601915086601f830112610241578283fd5b81358181111561024f578384fd5b876020828501011115610260578384fd5b6020830194508093505050509250925092565b7fff0000000000000000000000000000000000000000000000000000000000000094909416845260609290921b7fffffffffffffffffffffffffffffffffffffffff0000000000000000000000001660018401526015830152603582015260550190565b73ffffffffffffffffffffffffffffffffffffffff91909116815260200190565b60208082526011908201527f4465706c6f796d656e74206661696c656400000000000000000000000000000060408201526060019056fea26469706673582212202d3e87dd998c22df28ccb2c934734610461c1e6888114d8003aa51583d65054c64736f6c63430008000033" + + txs: List[Dict[str, Any]] = [] + if factory_address == "": + txs.append( + { + "type": 2, + "to": "", + "value": 0, + "data": FACTORY_BYTECODE, + "gas": deploy_gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + ) + + def _to_bytes32(n: int) -> bytes: + return int(n).to_bytes(32, "big") + + init_code_bytes = bytes.fromhex( + init_code[2:] if init_code.startswith("0x") else init_code + ) + for i in range(count): + salt = start_salt + i + salt_bytes32 = _to_bytes32(salt) + call_data = None + # Try ABI encoding if available + try: + if eth_abi_encode is not None: + encoded = eth_abi_encode( + ["bytes32", "bytes"], [salt_bytes32, init_code_bytes] + ) + call_data = "0x4c8c9ea1" + encoded.hex() + except Exception: + call_data = None + if call_data is None: + # Manual ABI-like encoding for (bytes32,bytes) + offset = 64 + length = len(init_code_bytes) + length32 = length.to_bytes(32, "big") + offset32 = offset.to_bytes(32, "big") + pad_len = (32 - (length % 32)) % 32 + padded = init_code_bytes + (b"\x00" * pad_len) + call_data = ( + "0x" + + "4c8c9ea1" + + salt_bytes32.hex() + + offset32.hex() + + length32.hex() + + padded.hex() + ) + + txs.append( + { + "type": 2, + "to": target_address, + "value": 0, + "data": call_data, + "gas": gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + ) + + return txs diff --git a/tests/benchmark/spamoor/test_calltx.py b/tests/benchmark/spamoor/test_calltx.py new file mode 100644 index 00000000000..e15af226438 --- /dev/null +++ b/tests/benchmark/spamoor/test_calltx.py @@ -0,0 +1,86 @@ +import pytest +from .helpers import build_calltx_transactions + + +@pytest.mark.spamoor +def test_calltx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): + txs = build_calltx_transactions( + count=spamoor_config["count"], + throughput=spamoor_config["throughput"], + amount=spamoor_config["amount"], + basefee=spamoor_config["basefee"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + contract_code="0x6000600055", + contract_address=spamoor_config.get("contract_address"), + call_data=spamoor_config.get("call_data", ""), + deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), + call_fn_sig=spamoor_config.get("call_fn_sig", ""), + call_args=spamoor_config.get("call_args", "[]"), + contract_args=spamoor_config.get("contract_args", "[]"), + gas_limit=spamoor_config.get("gas_limit", 0), + tip_fee=spamoor_config.get("tip_fee", 1_000_000_000), + rpc_client=spamoor_rpc_client, + ) + + # If contract_code is provided, the first tx is deployment, followed by `count` execution txs + assert len(txs) == spamoor_config["count"] + 1 + + # Check deployment tx + assert txs[0]["type"] == 2 + assert txs[0]["to"] == "" + assert txs[0]["data"] == "0x6000600055" + + # Check execution tx + if spamoor_config["count"] > 0: + assert txs[1]["to"] == ( + spamoor_config.get("contract_address") + or "0x1111111111111111111111111111111111111111" + ) + # Gas assertion updated to use new gas_limit/configured value when provided + expected_gas = ( + spamoor_config.get("gas_limit") + if spamoor_config.get("gas_limit") + else 500000 + ) + assert txs[1]["gas"] == expected_gas + + +@pytest.mark.spamoor +def test_calltx_scenario_no_deploy(spamoor_config, spamoor_rpc_client): + txs = build_calltx_transactions( + count=spamoor_config["count"], + throughput=spamoor_config["throughput"], + amount=spamoor_config["amount"], + basefee=spamoor_config["basefee"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + contract_code=None, + contract_address=spamoor_config.get("contract_address"), + call_data=spamoor_config.get("call_data", "0x1234"), + deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), + call_fn_sig=spamoor_config.get("call_fn_sig", ""), + call_args=spamoor_config.get("call_args", "[]"), + contract_args=spamoor_config.get("contract_args", "[]"), + gas_limit=spamoor_config.get("gas_limit", 0), + tip_fee=spamoor_config.get("tip_fee", 1_000_000_000), + rpc_client=spamoor_rpc_client, + ) + + # If no contract_code, we only get `count` execution txs + assert len(txs) == spamoor_config["count"] + + if spamoor_config["count"] > 0: + assert txs[0]["type"] == 2 + assert txs[0]["to"] == ( + spamoor_config.get("contract_address") + or "0x1111111111111111111111111111111111111111" + ) + assert txs[0]["data"] == "0x1234" + # Gas assertion updated to use new gas_limit/configured value when provided + expected_gas = ( + spamoor_config.get("gas_limit") + if spamoor_config.get("gas_limit") + else 500000 + ) + assert txs[0]["gas"] == expected_gas diff --git a/tests/benchmark/spamoor/test_eoatx.py b/tests/benchmark/spamoor/test_eoatx.py new file mode 100644 index 00000000000..4ab56df13be --- /dev/null +++ b/tests/benchmark/spamoor/test_eoatx.py @@ -0,0 +1,22 @@ +import pytest +from .helpers import build_eoatx_transactions + +@pytest.mark.spamoor +def test_eoatx_scenario(spamoor_config, spamoor_rpc_client): + txs = build_eoatx_transactions( + count=spamoor_config["count"], + throughput=spamoor_config["throughput"], + amount=spamoor_config["amount"], + basefee=spamoor_config["basefee"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client + ) + + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] > 0: + assert txs[0]["type"] == 2 + assert txs[0]["value"] == spamoor_config["amount"] + assert txs[0]["gas"] == 21000 + assert "maxFeePerGas" in txs[0] + assert "maxPriorityFeePerGas" in txs[0] diff --git a/tests/benchmark/spamoor/test_factorydeploytx.py b/tests/benchmark/spamoor/test_factorydeploytx.py new file mode 100644 index 00000000000..d21054c4cf6 --- /dev/null +++ b/tests/benchmark/spamoor/test_factorydeploytx.py @@ -0,0 +1,53 @@ +import pytest +from .helpers import build_factorydeploytx_transactions + + +@pytest.mark.spamoor +def test_factorydeploytx_scenario_with_deploy( + spamoor_config, spamoor_rpc_client +): + txs = build_factorydeploytx_transactions( + count=spamoor_config.get("count", 10), + init_code=spamoor_config.get("init_code", "0x1234"), + start_salt=spamoor_config.get("start_salt", 0), + factory_address="", + deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), + gas_limit=spamoor_config.get("gas_limit", 500000), + rpc_client=spamoor_rpc_client, + ) + + assert len(txs) == spamoor_config.get("count", 10) + 1 + + # Check deployment tx + assert txs[0]["type"] == 2 + assert txs[0]["to"] == "" + assert txs[0]["gas"] == spamoor_config.get("deploy_gas_limit", 2000000) + assert txs[0]["data"].startswith("0x60806040") + + # Check execution tx + if spamoor_config.get("count", 10) > 0: + assert txs[1]["to"] == "0x2222222222222222222222222222222222222222" + # 0x4c8c9ea1 is the selector for deploy(bytes32,bytes) + assert txs[1]["data"].startswith("0x4c8c9ea1") + + +@pytest.mark.spamoor +def test_factorydeploytx_scenario_no_deploy( + spamoor_config, spamoor_rpc_client +): + txs = build_factorydeploytx_transactions( + count=spamoor_config.get("count", 10), + init_code=spamoor_config.get("init_code", "0x1234"), + start_salt=spamoor_config.get("start_salt", 0), + factory_address="0x3333333333333333333333333333333333333333", + deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), + gas_limit=spamoor_config.get("gas_limit", 500000), + rpc_client=spamoor_rpc_client, + ) + + assert len(txs) == spamoor_config.get("count", 10) + + if spamoor_config.get("count", 10) > 0: + assert txs[0]["type"] == 2 + assert txs[0]["to"] == "0x3333333333333333333333333333333333333333" + assert txs[0]["data"].startswith("0x4c8c9ea1") From 6dc12fa4fe9aaa52a7159bb4c3fd88757d7179c8 Mon Sep 17 00:00:00 2001 From: AnkushinDaniil Date: Mon, 20 Apr 2026 22:29:16 +0400 Subject: [PATCH 02/11] feat(testing-build-block): wire spamoor scenarios through testing_* RPCs Adds a pytest plugin that signs spamoor helper dicts and commits the resulting transactions on a Nethermind node via Marcin Sobczak's testing-namespace JSON-RPC. Two modes behind --bloat-commit-mode: - build: testing_buildBlockV1 + engine_newPayloadVn + engine_forkchoiceUpdatedVn - commit: testing_commitBlockV1 (single-shot; client advances head itself) Also exposes an `est execute testing-build-block` subcommand and mirrors the three spamoor scenarios (eoatx, calltx, factorydeploytx) under tests/benchmark/testing_build_block/ so they can be driven against a real client. Refs: docs/meetings/2026-04-17-ef-stateful-sync.md --- .../cli/pytest_commands/execute.py | 13 + .../plugins/testing_build_block/__init__.py | 1 + .../testing_build_block/commit_block.py | 191 ++++++++++++++ .../testing_build_block.py | 240 ++++++++++++++++++ .../testing_build_block/tests/__init__.py | 1 + .../tests/test_commit_block.py | 186 ++++++++++++++ .../tests/test_tx_convert.py | 93 +++++++ .../plugins/testing_build_block/tx_convert.py | 70 +++++ .../pytest-testing-build-block.ini | 11 + .../benchmark/testing_build_block/__init__.py | 1 + .../benchmark/testing_build_block/conftest.py | 7 + .../test_calltx_committed.py | 66 +++++ .../test_eoatx_committed.py | 57 +++++ .../test_factorydeploytx_committed.py | 60 +++++ 14 files changed, 997 insertions(+) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/__init__.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/commit_block.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/__init__.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_commit_block.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_tx_convert.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-testing-build-block.ini create mode 100644 tests/benchmark/testing_build_block/__init__.py create mode 100644 tests/benchmark/testing_build_block/conftest.py create mode 100644 tests/benchmark/testing_build_block/test_calltx_committed.py create mode 100644 tests/benchmark/testing_build_block/test_eoatx_committed.py create mode 100644 tests/benchmark/testing_build_block/test_factorydeploytx_committed.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/execute.py b/packages/testing/src/execution_testing/cli/pytest_commands/execute.py index 4c8b0ae5a93..690429ea2c1 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/execute.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/execute.py @@ -106,3 +106,16 @@ def command(pytest_args: List[str], **_kwargs: Any) -> None: EXECUTE_PATH / "execute_deploy_required_contracts.py" ], ) + +testing_build_block = _create_execute_subcommand( + "testing-build-block", + "pytest-testing-build-block.ini", + "Commit bloat transactions via the Nethermind `testing_*` RPCs.", + required_args=[ + "--bloat-rpc-url=http://localhost:8545", + "--bloat-engine-url=http://localhost:8551", + "--bloat-jwt-secret-file=./keystore/jwt.hex", + "--bloat-signer-key=", + "--bloat-chain-id=1", + ], +) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/__init__.py new file mode 100644 index 00000000000..567ede1afff --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/__init__.py @@ -0,0 +1 @@ +"""Pytest plugin for committing blocks via testing-namespace RPC.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/commit_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/commit_block.py new file mode 100644 index 00000000000..31eae043c1e --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/commit_block.py @@ -0,0 +1,191 @@ +"""Dispatch block-commit flows across ``testing_*`` RPC variants.""" + +from typing import Any, List, Sequence + +from execution_testing.base_types import ( + Address, + Bytes, + Hash, + HexNumber, + to_json, +) +from execution_testing.forks import Fork, TransitionFork +from execution_testing.rpc import EngineRPC, EthRPC, RPCCall, TestingRPC +from execution_testing.rpc.rpc_types import ( + ForkchoiceState, + PayloadAttributes, + PayloadStatusEnum, + TransactionProtocol, +) + + +def _payload_attributes_for_fork( + fork: Fork | TransitionFork, + next_block_number: int, + next_timestamp: int, +) -> PayloadAttributes: + """ + Assemble ``PayloadAttributes`` for the block to build. + + Mirrors ``ChainBuilderEthRPC._payload_attributes`` so behaviour stays + consistent with the main EEST chain-builder path. + """ + next_fork = fork.fork_at( + block_number=next_block_number, timestamp=next_timestamp + ) + parent_beacon_block_root = ( + Hash(0) if next_fork.header_beacon_root_required() else None + ) + target_blobs = ( + next_fork.target_blobs_per_block() + if next_fork.engine_payload_attribute_target_blobs_per_block() + else None + ) + max_blobs = ( + next_fork.max_blobs_per_block() + if next_fork.engine_payload_attribute_max_blobs_per_block() + else None + ) + return PayloadAttributes( + timestamp=HexNumber(next_timestamp), + prev_randao=Hash(0), + suggested_fee_recipient=Address(0), + withdrawals=( + [] + if next_fork.header_withdrawals_required() + else None + ), + parent_beacon_block_root=parent_beacon_block_root, + target_blobs_per_block=( + HexNumber(target_blobs) if target_blobs is not None else None + ), + max_blobs_per_block=( + HexNumber(max_blobs) if max_blobs is not None else None + ), + ) + + +def commit_via_build( + testing_rpc: TestingRPC, + engine_rpc: EngineRPC, + eth_rpc: EthRPC, + fork: Fork | TransitionFork, + txs: Sequence[TransactionProtocol], + *, + version: int = 1, +) -> Hash: + """ + Build a block and advance head via Engine API. + + Executes ``testing_buildBlockVX`` on top of the current ``latest`` + head, then submits the resulting payload through + ``engine_newPayloadVn`` and ``engine_forkchoiceUpdatedVn``. Returns + the hash of the new canonical head. + """ + head = eth_rpc.get_block_by_number("latest") + assert head is not None, "Cannot resolve latest head block" + parent_hash = Hash(head["hash"]) + parent_number = int(HexNumber(head["number"])) + parent_timestamp = int(HexNumber(head["timestamp"])) + next_block_number = parent_number + 1 + next_timestamp = parent_timestamp + 1 + + payload_attributes = _payload_attributes_for_fork( + fork, next_block_number, next_timestamp + ) + payload = testing_rpc.build_block( + parent_block_hash=parent_hash, + payload_attributes=payload_attributes, + transactions=txs, + extra_data=Bytes(b""), + version=version, + ) + + payload_fork = fork.fork_at( + block_number=payload.execution_payload.number, + timestamp=payload.execution_payload.timestamp, + ) + new_payload_args: List[Any] = [payload.execution_payload] + if payload.blobs_bundle is not None: + new_payload_args.append( + payload.blobs_bundle.blob_versioned_hashes() + ) + if payload_attributes.parent_beacon_block_root is not None: + new_payload_args.append( + payload_attributes.parent_beacon_block_root + ) + if payload.execution_requests is not None: + new_payload_args.append(payload.execution_requests) + + new_payload_version = payload_fork.engine_new_payload_version() + assert new_payload_version is not None, ( + "Fork does not support engine new_payload" + ) + new_payload_response = engine_rpc.new_payload( + *new_payload_args, version=new_payload_version + ) + assert new_payload_response.status == PayloadStatusEnum.VALID, ( + f"engine_newPayload rejected built block: " + f"{new_payload_response.status}" + ) + + fcu_version = payload_fork.engine_forkchoice_updated_version() + assert fcu_version is not None, ( + "Fork does not support engine forkchoice_updated" + ) + new_head_hash = Hash(payload.execution_payload.block_hash) + fcu_response = engine_rpc.forkchoice_updated( + ForkchoiceState(head_block_hash=new_head_hash), + None, + version=fcu_version, + ) + assert ( + fcu_response.payload_status.status == PayloadStatusEnum.VALID + ), ( + f"engine_forkchoiceUpdated rejected new head: " + f"{fcu_response.payload_status.status}" + ) + return new_head_hash + + +def commit_via_commit( + testing_rpc: TestingRPC, + eth_rpc: EthRPC, + fork: Fork | TransitionFork, + txs: Sequence[TransactionProtocol], + *, + version: int = 1, +) -> Hash: + """ + Single-shot ``testing_commitBlockV1`` dispatch. + + Reads the chain head from *eth_rpc* to derive fork-appropriate + payload attributes, then delegates block creation and head + advancement to the ``testing_commitBlockVX`` endpoint. No Engine + API round-trip is needed: the client advances head itself and + returns the committed block hash. + """ + head = eth_rpc.get_block_by_number("latest") + assert head is not None, "Cannot resolve latest head block" + parent_number = int(HexNumber(head["number"])) + parent_timestamp = int(HexNumber(head["timestamp"])) + next_block_number = parent_number + 1 + next_timestamp = parent_timestamp + 1 + + payload_attributes = _payload_attributes_for_fork( + fork, next_block_number, next_timestamp + ) + + method = f"commitBlockV{version}" + params: List[Any] = [ + to_json(payload_attributes), + [tx.rlp().hex() for tx in txs], + None, + ] + result = testing_rpc.post_request( + request=RPCCall(method=method, params=params) + ).result_or_raise() + assert result is not None, ( + "testing_commitBlockV1 returned null; commit failed" + ) + return Hash(result) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py new file mode 100644 index 00000000000..9206b545a1c --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py @@ -0,0 +1,240 @@ +"""Pytest plugin wiring spamoor scenarios through ``testing_*`` RPCs.""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Sequence + +import pytest + +from execution_testing.base_types import Hash +from execution_testing.forks import Fork, get_forks +from execution_testing.rpc import EngineRPC, EthRPC, TestingRPC +from execution_testing.test_types import EOA, Transaction + +from .commit_block import commit_via_build, commit_via_commit + +BLOAT_COMMIT_MODE_BUILD = "build" +BLOAT_COMMIT_MODE_COMMIT = "commit" +BLOAT_COMMIT_MODES = (BLOAT_COMMIT_MODE_BUILD, BLOAT_COMMIT_MODE_COMMIT) + + +@dataclass(frozen=True) +class BloatConfig: + """Resolved CLI options for the testing-build-block plugin.""" + + rpc_url: str + engine_url: str + jwt_secret: bytes + signer_key: str + chain_id: int + block_version: int + commit_mode: str + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Register ``--bloat-*`` CLI options for the plugin.""" + group = parser.getgroup( + "testing_build_block", + "Testing-namespace block commit options", + ) + group.addoption( + "--bloat-rpc-url", + dest="bloat_rpc_url", + default="http://localhost:8545", + help="Execution client RPC URL (Eth+Testing modules).", + ) + group.addoption( + "--bloat-engine-url", + dest="bloat_engine_url", + default="http://localhost:8551", + help="Execution client Engine API URL.", + ) + group.addoption( + "--bloat-jwt-secret-file", + dest="bloat_jwt_secret_file", + default=None, + help="Path to a hex-encoded JWT secret file.", + ) + group.addoption( + "--bloat-signer-key", + dest="bloat_signer_key", + default=None, + help="Hex private key of the funded sender.", + ) + group.addoption( + "--bloat-chain-id", + dest="bloat_chain_id", + type=int, + default=1, + help="Chain ID used when signing transactions.", + ) + group.addoption( + "--bloat-block-version", + dest="bloat_block_version", + type=int, + default=1, + help="``testing_buildBlockVX`` version to invoke.", + ) + group.addoption( + "--bloat-commit-mode", + dest="bloat_commit_mode", + choices=list(BLOAT_COMMIT_MODES), + default=BLOAT_COMMIT_MODE_BUILD, + help=( + "``build`` drives newPayload+FCU; ``commit`` reserves the " + "future single-shot endpoint." + ), + ) + group.addoption( + "--bloat-fork", + dest="bloat_fork", + default=None, + help="Override the active fork (defaults to latest known fork).", + ) + + +def _load_jwt_secret(path: str | None) -> bytes: + """ + Parse a hex-encoded JWT secret file, mirroring ``execute.remote``. + + Returns the raw secret bytes. Exits the pytest session with a + descriptive message if the file is missing or malformed. + """ + if path is None: + pytest.exit( + "--bloat-jwt-secret-file is required for the engine endpoint." + ) + secret_path = Path(path) + try: + raw = secret_path.read_text().strip() + except FileNotFoundError: + pytest.exit(f"JWT secret file not found: {secret_path}") + if raw.startswith("0x"): + raw = raw[2:] + try: + return bytes.fromhex(raw) + except ValueError: + pytest.exit( + "JWT secret file must contain a hex-encoded string; " + f"got invalid content at {secret_path}" + ) + + +def _resolve_fork(name: str | None) -> Any: + """Return the ``Fork`` matching *name* or the latest known fork.""" + forks = get_forks() + if name is None: + return forks[-1] + lowered = name.lower() + for fork in forks: + if fork.name().lower() == lowered: + return fork + raise pytest.UsageError( + f"Unknown --bloat-fork {name!r}; available: " + f"{', '.join(f.name() for f in forks)}" + ) + + +@pytest.fixture(scope="session") +def bloat_config(request: pytest.FixtureRequest) -> BloatConfig: + """Materialise CLI flags into an immutable ``BloatConfig``.""" + signer_key = request.config.getoption("bloat_signer_key") + if signer_key is None: + pytest.exit("--bloat-signer-key is required") + jwt_secret = _load_jwt_secret( + request.config.getoption("bloat_jwt_secret_file") + ) + return BloatConfig( + rpc_url=request.config.getoption("bloat_rpc_url"), + engine_url=request.config.getoption("bloat_engine_url"), + jwt_secret=jwt_secret, + signer_key=signer_key, + chain_id=int(request.config.getoption("bloat_chain_id")), + block_version=int( + request.config.getoption("bloat_block_version") + ), + commit_mode=request.config.getoption("bloat_commit_mode"), + ) + + +@pytest.fixture(scope="session") +def bloat_testing_rpc(bloat_config: BloatConfig) -> TestingRPC: + """Return the ``TestingRPC`` client bound to the bloat endpoint.""" + return TestingRPC(bloat_config.rpc_url) + + +@pytest.fixture(scope="session") +def bloat_engine_rpc(bloat_config: BloatConfig) -> EngineRPC: + """Return the ``EngineRPC`` client authorised with the JWT secret.""" + return EngineRPC( + bloat_config.engine_url, + jwt_secret=bloat_config.jwt_secret, + ) + + +@pytest.fixture(scope="session") +def bloat_eth_rpc(bloat_config: BloatConfig) -> EthRPC: + """Return the base ``EthRPC`` client for head/nonce queries.""" + return EthRPC(bloat_config.rpc_url) + + +@pytest.fixture(scope="session") +def bloat_fork(request: pytest.FixtureRequest) -> Fork: + """Return the fork selected via ``--bloat-fork`` or latest.""" + return _resolve_fork(request.config.getoption("bloat_fork")) + + +@pytest.fixture(scope="session") +def bloat_signer( + bloat_config: BloatConfig, bloat_eth_rpc: EthRPC +) -> EOA: + """Return an ``EOA`` seeded with the current on-chain nonce.""" + signer = EOA(key=bloat_config.signer_key, nonce=0) + try: + nonce = bloat_eth_rpc.get_transaction_count(signer, "latest") + except Exception: # noqa: BLE001 - avoid coupling to RPC failures + nonce = 0 + return EOA( + address=signer, + key=bloat_config.signer_key, + nonce=nonce, + ) + + +@pytest.fixture +def bloat_commit_block( + bloat_config: BloatConfig, + bloat_testing_rpc: TestingRPC, + bloat_engine_rpc: EngineRPC, + bloat_eth_rpc: EthRPC, + bloat_fork: Fork, +) -> Callable[[Sequence[Transaction]], Hash]: + """Return a callable dispatching to the configured commit mode.""" + + def _commit(txs: Sequence[Transaction]) -> Hash: + if bloat_config.commit_mode == BLOAT_COMMIT_MODE_BUILD: + return commit_via_build( + testing_rpc=bloat_testing_rpc, + engine_rpc=bloat_engine_rpc, + eth_rpc=bloat_eth_rpc, + fork=bloat_fork, + txs=txs, + version=bloat_config.block_version, + ) + return commit_via_commit( + testing_rpc=bloat_testing_rpc, + eth_rpc=bloat_eth_rpc, + fork=bloat_fork, + txs=txs, + version=bloat_config.block_version, + ) + + return _commit + + +def pytest_configure(config: pytest.Config) -> None: + """Register custom markers for testing-build-block scenarios.""" + config.addinivalue_line( + "markers", + "testing_build_block: run testing-namespace block-commit tests", + ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/__init__.py new file mode 100644 index 00000000000..3533a6f6bb8 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the testing-build-block plugin.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_commit_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_commit_block.py new file mode 100644 index 00000000000..7a91561ce17 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_commit_block.py @@ -0,0 +1,186 @@ +"""Unit tests for ``commit_block`` dispatch helpers.""" + +from typing import Any, List +from unittest.mock import MagicMock + +import pytest + +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.commit_block import ( # noqa: E501 + commit_via_build, + commit_via_commit, +) +from execution_testing.forks import get_forks +from execution_testing.rpc.rpc_types import ( + PayloadStatus, + PayloadStatusEnum, +) + + +def _latest_fork() -> Any: + """Return the latest known fork class.""" + return get_forks()[-1] + + +def _make_fake_payload( + parent_hash: Hash, block_number: int, timestamp: int +) -> MagicMock: + """Produce a minimal ``GetPayloadResponse`` stand-in.""" + exec_payload = MagicMock() + exec_payload.block_hash = Hash(0xDEADBEEF) + exec_payload.parent_hash = parent_hash + exec_payload.number = block_number + exec_payload.timestamp = timestamp + payload = MagicMock() + payload.execution_payload = exec_payload + payload.blobs_bundle = None + payload.execution_requests = None + return payload + + +def test_build_mode_call_order() -> None: + """``commit_via_build`` calls build, newPayload, then FCU.""" + calls: List[str] = [] + + eth_rpc = MagicMock() + eth_rpc.get_block_by_number.return_value = { + "hash": "0x" + "11" * 32, + "number": hex(100), + "timestamp": hex(1_700_000_000), + } + + testing_rpc = MagicMock() + + def _build(*_args: Any, **_kwargs: Any) -> MagicMock: + calls.append("build_block") + return _make_fake_payload( + Hash("0x" + "11" * 32), 101, 1_700_000_001 + ) + + testing_rpc.build_block.side_effect = _build + + engine_rpc = MagicMock() + + def _new_payload(*_args: Any, **_kwargs: Any) -> PayloadStatus: + calls.append("new_payload") + return PayloadStatus( + status=PayloadStatusEnum.VALID, + latest_valid_hash=Hash(0xDEADBEEF), + validation_error=None, + ) + + engine_rpc.new_payload.side_effect = _new_payload + + def _fcu(*_args: Any, **_kwargs: Any) -> MagicMock: + calls.append("forkchoice_updated") + resp = MagicMock() + resp.payload_status.status = PayloadStatusEnum.VALID + return resp + + engine_rpc.forkchoice_updated.side_effect = _fcu + + new_head = commit_via_build( + testing_rpc=testing_rpc, + engine_rpc=engine_rpc, + eth_rpc=eth_rpc, + fork=_latest_fork(), + txs=[], + version=1, + ) + + assert calls == ["build_block", "new_payload", "forkchoice_updated"] + assert new_head == Hash(0xDEADBEEF) + # Build must be invoked with the parent head hash. + build_kwargs = testing_rpc.build_block.call_args.kwargs + assert build_kwargs["parent_block_hash"] == Hash("0x" + "11" * 32) + assert build_kwargs["version"] == 1 + + +def test_build_mode_asserts_on_invalid_new_payload() -> None: + """Invalid ``newPayload`` status trips the build-mode assertion.""" + eth_rpc = MagicMock() + eth_rpc.get_block_by_number.return_value = { + "hash": "0x" + "22" * 32, + "number": hex(5), + "timestamp": hex(1_700_000_000), + } + testing_rpc = MagicMock() + testing_rpc.build_block.return_value = _make_fake_payload( + Hash("0x" + "22" * 32), 6, 1_700_000_001 + ) + engine_rpc = MagicMock() + engine_rpc.new_payload.return_value = PayloadStatus( + status=PayloadStatusEnum.INVALID, + latest_valid_hash=None, + validation_error=None, + ) + + with pytest.raises(AssertionError, match="engine_newPayload rejected"): + commit_via_build( + testing_rpc=testing_rpc, + engine_rpc=engine_rpc, + eth_rpc=eth_rpc, + fork=_latest_fork(), + txs=[], + ) + + +def test_commit_mode_posts_testing_commit_block() -> None: + """``commit_via_commit`` posts ``testing_commitBlockVX``.""" + eth_rpc = MagicMock() + eth_rpc.get_block_by_number.return_value = { + "hash": "0x" + "33" * 32, + "number": hex(42), + "timestamp": hex(1_700_000_000), + } + + new_head_hex = "0x" + "ab" * 32 + + testing_rpc = MagicMock() + response = MagicMock() + response.result_or_raise.return_value = new_head_hex + testing_rpc.post_request.return_value = response + + head = commit_via_commit( + testing_rpc=testing_rpc, + eth_rpc=eth_rpc, + fork=_latest_fork(), + txs=[], + version=1, + ) + + assert head == Hash(new_head_hex) + post_kwargs = testing_rpc.post_request.call_args.kwargs + call = post_kwargs["request"] + assert call.method == "commitBlockV1" + # params: [payload_attributes, tx_rlp_list, extra_data] + assert isinstance(call.params, list) + assert len(call.params) == 3 + assert call.params[1] == [] + assert call.params[2] is None + + +def test_commit_mode_asserts_on_null_result() -> None: + """A null RPC result raises so callers see the failure.""" + eth_rpc = MagicMock() + eth_rpc.get_block_by_number.return_value = { + "hash": "0x" + "44" * 32, + "number": hex(7), + "timestamp": hex(1_700_000_000), + } + testing_rpc = MagicMock() + response = MagicMock() + response.result_or_raise.return_value = None + testing_rpc.post_request.return_value = response + + with pytest.raises(AssertionError, match="returned null"): + commit_via_commit( + testing_rpc=testing_rpc, + eth_rpc=eth_rpc, + fork=_latest_fork(), + txs=[], + ) + + +if __name__ == "__main__": # pragma: no cover - manual invocation + pytest.main([__file__, "-v"]) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_tx_convert.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_tx_convert.py new file mode 100644 index 00000000000..0b62143efda --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_tx_convert.py @@ -0,0 +1,93 @@ +"""Unit tests for ``spamoor_dict_to_transaction``.""" + +from typing import Any, Dict + +import pytest + +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.test_types import EOA + +# Deterministic private key for hermetic tests. +_TEST_KEY = Hash( + 0x1234567890123456789012345678901234567890123456789012345678901234 +) +_SIGNER = EOA(key=_TEST_KEY, nonce=0) + + +def _base_tx_dict() -> Dict[str, Any]: + """Return a minimal spamoor-style type-2 transaction dict.""" + return { + "type": 2, + "to": "0x1111111111111111111111111111111111111111", + "value": 0, + "data": "", + "gas": 21000, + "maxFeePerGas": 20, + "maxPriorityFeePerGas": 1, + "chainId": 1, + "accessList": [], + } + + +def test_creation_tx_to_is_none() -> None: + """Empty ``to`` maps to ``None`` (contract creation).""" + tx_dict = _base_tx_dict() + tx_dict["to"] = "" + tx_dict["data"] = "0xdeadbeef" + + tx = spamoor_dict_to_transaction( + tx_dict, _SIGNER, chain_id=17000, nonce_override=0 + ) + + assert tx.to is None + + +def test_signed_rlp_round_trip() -> None: + """Signed transaction recovers the configured signer address.""" + tx_dict = _base_tx_dict() + + tx = spamoor_dict_to_transaction( + tx_dict, _SIGNER, chain_id=17000, nonce_override=7 + ) + + # ``with_signature_and_sender`` must populate ``sender`` from the + # recovered signature, matching the EOA we signed with. + assert tx.sender is not None + assert tx.sender == _SIGNER + # The serialised RLP must be non-empty and include a signature. + rlp_bytes = tx.rlp() + assert len(rlp_bytes) > 0 + assert "v" in tx.model_fields_set + assert "r" in tx.model_fields_set + assert "s" in tx.model_fields_set + + +def test_chain_id_override() -> None: + """Spamoor's ``chainId: 1`` is overridden by the caller's chain id.""" + tx_dict = _base_tx_dict() + assert tx_dict["chainId"] == 1 + + tx = spamoor_dict_to_transaction( + tx_dict, _SIGNER, chain_id=17000, nonce_override=0 + ) + + assert int(tx.chain_id) == 17000 + + +def test_nonce_override_wins_over_dict() -> None: + """``nonce_override`` trumps any nonce present in the dict.""" + tx_dict = _base_tx_dict() + tx_dict["nonce"] = 99 + + tx = spamoor_dict_to_transaction( + tx_dict, _SIGNER, chain_id=17000, nonce_override=3 + ) + + assert int(tx.nonce) == 3 + + +if __name__ == "__main__": # pragma: no cover - manual invocation + pytest.main([__file__, "-v"]) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py new file mode 100644 index 00000000000..6a55a490f1a --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py @@ -0,0 +1,70 @@ +"""Convert spamoor-helper transaction dicts into signed EST Transactions.""" + +from typing import Any + +from execution_testing.base_types import Address, Bytes, HexNumber +from execution_testing.test_types import EOA, Transaction + + +def _optional_address(value: Any) -> Address | None: + """ + Return an ``Address`` from *value* or ``None`` for contract creation. + + Spamoor uses ``"to": ""`` to flag contract creation, while EST expects + ``None``. + """ + if value is None or value == "": + return None + return Address(value) + + +def _data_bytes(value: Any) -> Bytes: + """Return ``Bytes`` payload from *value*, tolerating ``None``/empty.""" + if value is None or value == "": + return Bytes(b"") + return Bytes(value) + + +def spamoor_dict_to_transaction( + tx_dict: dict[str, Any], + signer: EOA, + chain_id: int, + *, + nonce_override: int | None = None, +) -> Transaction: + """ + Convert a spamoor helpers dict into a signed EST ``Transaction``. + + Key mapping: ``type`` -> ``ty``, ``to`` -> ``to`` (``""`` means + contract creation), ``value`` -> ``value``, ``data`` -> ``input``, + ``gas`` -> ``gas_limit``, ``maxFeePerGas`` and + ``maxPriorityFeePerGas`` are forwarded as-is, and ``accessList`` + maps to ``access_list``. ``chainId`` is always overridden with + *chain_id* because spamoor hard-codes ``1``. + """ + if nonce_override is not None: + nonce_value = nonce_override + elif "nonce" in tx_dict: + nonce_value = int(tx_dict["nonce"]) + else: + nonce_value = int(signer.nonce) + + access_list = tx_dict.get("accessList", []) + + tx = Transaction( + ty=HexNumber(int(tx_dict["type"])), + nonce=HexNumber(nonce_value), + to=_optional_address(tx_dict.get("to")), + value=HexNumber(int(tx_dict["value"])), + data=_data_bytes(tx_dict.get("data")), + gas_limit=HexNumber(int(tx_dict["gas"])), + max_fee_per_gas=HexNumber(int(tx_dict["maxFeePerGas"])), + max_priority_fee_per_gas=HexNumber( + int(tx_dict["maxPriorityFeePerGas"]) + ), + chain_id=chain_id, + access_list=access_list, + secret_key=signer.key, + sender=signer, + ) + return tx.with_signature_and_sender() diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-testing-build-block.ini b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-testing-build-block.ini new file mode 100644 index 00000000000..aadef4c5d71 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-testing-build-block.ini @@ -0,0 +1,11 @@ +[pytest] +console_output_style = count +minversion = 7.0 +python_files = test_*.py +testpaths = tests/benchmark/testing_build_block/ +addopts = + -p execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block + -p execution_testing.cli.pytest_commands.plugins.spamoor.spamoor + -p execution_testing.cli.pytest_commands.plugins.help.help + -p execution_testing.cli.pytest_commands.plugins.custom_logging.plugin_logging + --tb short diff --git a/tests/benchmark/testing_build_block/__init__.py b/tests/benchmark/testing_build_block/__init__.py new file mode 100644 index 00000000000..793b753698e --- /dev/null +++ b/tests/benchmark/testing_build_block/__init__.py @@ -0,0 +1 @@ +"""Integration scenarios driven through the ``testing_*`` RPC namespace.""" diff --git a/tests/benchmark/testing_build_block/conftest.py b/tests/benchmark/testing_build_block/conftest.py new file mode 100644 index 00000000000..4a156e89947 --- /dev/null +++ b/tests/benchmark/testing_build_block/conftest.py @@ -0,0 +1,7 @@ +""" +Shared fixtures for testing-build-block benchmark scenarios. + +The plugin package registers all ``--bloat-*`` CLI options and the +``bloat_*`` fixtures. Spamoor configuration fixtures come from the +sibling spamoor plugin, which is also loaded via the ini file. +""" diff --git a/tests/benchmark/testing_build_block/test_calltx_committed.py b/tests/benchmark/testing_build_block/test_calltx_committed.py new file mode 100644 index 00000000000..650d8842e26 --- /dev/null +++ b/tests/benchmark/testing_build_block/test_calltx_committed.py @@ -0,0 +1,66 @@ +"""End-to-end: commit a block of spamoor call/deploy transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_calltx_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_calltx_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Build contract call/deploy transactions and commit them.""" + raw_txs = build_calltx_transactions( + count=spamoor_config["count"], + throughput=spamoor_config["throughput"], + amount=spamoor_config["amount"], + basefee=spamoor_config["basefee"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + contract_code=spamoor_config["contract_code"], + contract_address=spamoor_config["contract_address"], + call_data=spamoor_config["call_data"], + call_fn_sig=spamoor_config["call_fn_sig"], + call_args=spamoor_config["call_args"], + contract_args=spamoor_config["contract_args"], + gas_limit=spamoor_config["gas_limit"], + tip_fee=spamoor_config["tip_fee"], + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_eoatx_committed.py b/tests/benchmark/testing_build_block/test_eoatx_committed.py new file mode 100644 index 00000000000..33571b7d7ee --- /dev/null +++ b/tests/benchmark/testing_build_block/test_eoatx_committed.py @@ -0,0 +1,57 @@ +"""End-to-end: commit a block of spamoor EOA transfer transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_eoatx_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_eoatx_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Build EOA transfers, commit them, and confirm head advanced.""" + raw_txs = build_eoatx_transactions( + count=spamoor_config["count"], + throughput=spamoor_config["throughput"], + amount=spamoor_config["amount"], + basefee=spamoor_config["basefee"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_factorydeploytx_committed.py b/tests/benchmark/testing_build_block/test_factorydeploytx_committed.py new file mode 100644 index 00000000000..340a1f4a9f2 --- /dev/null +++ b/tests/benchmark/testing_build_block/test_factorydeploytx_committed.py @@ -0,0 +1,60 @@ +"""End-to-end: commit a block of spamoor CREATE2 factory deploys.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import ( + build_factorydeploytx_transactions, +) + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_factorydeploytx_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Build factory+deploy transactions and commit them.""" + raw_txs = build_factorydeploytx_transactions( + count=spamoor_config["count"], + init_code=spamoor_config["init_code"], + start_salt=spamoor_config["start_salt"], + factory_address=spamoor_config["factory_address"], + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + gas_limit=spamoor_config["gas_limit"] or 500_000, + tip_fee=spamoor_config["tip_fee"], + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number From 20f081dd351b867ee31941fb90902ec8df996227 Mon Sep 17 00:00:00 2001 From: AnkushinDaniil Date: Tue, 21 Apr 2026 16:06:42 +0400 Subject: [PATCH 03/11] fix(testing-build-block): fetch real signer nonce before scenarios run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EOA.__new__` short-circuits when its `address` argument is already an `EOA` instance, returning the existing object unchanged and silently dropping the `nonce` kwarg. `bloat_signer` was therefore pinning nonce to 0 regardless of on-chain state, so every scenario after the first failed with `nonce too low` / `payload processing failed`. Wrap the probe as a plain `Address` to force re-construction with the freshly fetched nonce. Verified by chaining eoatx → calltx → factorydeploytx against a local Nethermind: block 1 → 2 → 3, signer nonce 5 → 10 → 16. --- .../testing_build_block/testing_build_block.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py index 9206b545a1c..712ff2d2818 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py @@ -6,7 +6,7 @@ import pytest -from execution_testing.base_types import Hash +from execution_testing.base_types import Address, Hash from execution_testing.forks import Fork, get_forks from execution_testing.rpc import EngineRPC, EthRPC, TestingRPC from execution_testing.test_types import EOA, Transaction @@ -189,13 +189,16 @@ def bloat_signer( bloat_config: BloatConfig, bloat_eth_rpc: EthRPC ) -> EOA: """Return an ``EOA`` seeded with the current on-chain nonce.""" - signer = EOA(key=bloat_config.signer_key, nonce=0) + probe = EOA(key=bloat_config.signer_key, nonce=0) try: - nonce = bloat_eth_rpc.get_transaction_count(signer, "latest") + nonce = bloat_eth_rpc.get_transaction_count(probe, "latest") except Exception: # noqa: BLE001 - avoid coupling to RPC failures nonce = 0 + # ``EOA.__new__`` short-circuits when ``address`` is already an + # ``EOA``, so wrap it as a plain ``Address`` to force re-construction + # with the freshly fetched nonce. return EOA( - address=signer, + Address(probe), key=bloat_config.signer_key, nonce=nonce, ) From 4f84e93cdac17a09ac552216add84739efeb88a5 Mon Sep 17 00:00:00 2001 From: Dmytro Biloshytskyi Date: Wed, 22 Apr 2026 15:54:22 +0300 Subject: [PATCH 04/11] Added more scenarios --- .../plugins/spamoor/spamoor.py | 178 +++ .../plugins/testing_build_block/tx_convert.py | 45 +- tests/benchmark/spamoor/helpers.py | 1015 +++++++++++++++++ tests/benchmark/spamoor/test_blob_combined.py | 31 + tests/benchmark/spamoor/test_deploytx.py | 64 ++ tests/benchmark/spamoor/test_erc20_bloater.py | 78 ++ tests/benchmark/spamoor/test_erc20tx.py | 71 ++ tests/benchmark/spamoor/test_evm_fuzz.py | 74 ++ tests/benchmark/spamoor/test_gasburnertx.py | 44 + .../benchmark/spamoor/test_storagerefundtx.py | 67 ++ tests/benchmark/spamoor/test_storagespam.py | 67 ++ tests/benchmark/spamoor/test_uniswap_swaps.py | 55 + .../test_blob_combined_committed.py | 73 ++ .../test_deploytx_committed.py | 60 + .../test_erc20_bloater_committed.py | 68 ++ .../test_erc20tx_committed.py | 64 ++ .../test_evm_fuzz_committed.py | 63 + .../test_gasburnertx_committed.py | 60 + .../test_storagerefundtx_committed.py | 64 ++ .../test_storagespam_committed.py | 62 + .../test_uniswap_swaps_committed.py | 62 + 21 files changed, 2361 insertions(+), 4 deletions(-) create mode 100644 tests/benchmark/spamoor/test_blob_combined.py create mode 100644 tests/benchmark/spamoor/test_deploytx.py create mode 100644 tests/benchmark/spamoor/test_erc20_bloater.py create mode 100644 tests/benchmark/spamoor/test_erc20tx.py create mode 100644 tests/benchmark/spamoor/test_evm_fuzz.py create mode 100644 tests/benchmark/spamoor/test_gasburnertx.py create mode 100644 tests/benchmark/spamoor/test_storagerefundtx.py create mode 100644 tests/benchmark/spamoor/test_storagespam.py create mode 100644 tests/benchmark/spamoor/test_uniswap_swaps.py create mode 100644 tests/benchmark/testing_build_block/test_blob_combined_committed.py create mode 100644 tests/benchmark/testing_build_block/test_deploytx_committed.py create mode 100644 tests/benchmark/testing_build_block/test_erc20_bloater_committed.py create mode 100644 tests/benchmark/testing_build_block/test_erc20tx_committed.py create mode 100644 tests/benchmark/testing_build_block/test_evm_fuzz_committed.py create mode 100644 tests/benchmark/testing_build_block/test_gasburnertx_committed.py create mode 100644 tests/benchmark/testing_build_block/test_storagerefundtx_committed.py create mode 100644 tests/benchmark/testing_build_block/test_storagespam_committed.py create mode 100644 tests/benchmark/testing_build_block/test_uniswap_swaps_committed.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py index 44fcbb26178..d598e9da300 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py @@ -121,6 +121,153 @@ def pytest_addoption(parser: pytest.Parser) -> None: default="", help="Factory contract address for Spamoor initialization", ) + group.addoption( + "--spamoor-sidecars", + dest="spamoor_sidecars", + type=int, + default=3, + help="Max blob sidecars per blob transaction", + ) + group.addoption( + "--spamoor-blob-fee", + dest="spamoor_blob_fee", + type=int, + default=20_000_000_000, + help="Max blob fee in wei", + ) + group.addoption( + "--spamoor-gas-units-to-burn", + dest="spamoor_gas_units_to_burn", + type=int, + default=2_000_000, + help="Per-tx gas limit for gasburnertx execution transactions", + ) + group.addoption( + "--spamoor-pair-count", + dest="spamoor_pair_count", + type=int, + default=1, + help="Number of uniswap pairs (informational for the port)", + ) + group.addoption( + "--spamoor-min-swap-amount", + dest="spamoor_min_swap_amount", + type=int, + default=100_000_000_000_000_000, + help="Minimum swap amount in wei (0.1 token by default)", + ) + group.addoption( + "--spamoor-max-swap-amount", + dest="spamoor_max_swap_amount", + type=int, + default=1_000_000_000_000_000_000_000, + help="Maximum swap amount in wei (1000 tokens by default)", + ) + group.addoption( + "--spamoor-buy-ratio", + dest="spamoor_buy_ratio", + type=int, + default=40, + help="Percent of swaps that are buys (0..100)", + ) + group.addoption( + "--spamoor-slippage", + dest="spamoor_slippage", + type=int, + default=50, + help="Slippage in basis points (out of 10000)", + ) + group.addoption( + "--spamoor-min-code-size", + dest="spamoor_min_code_size", + type=int, + default=100, + help="Minimum fuzz bytecode size in bytes", + ) + group.addoption( + "--spamoor-max-code-size", + dest="spamoor_max_code_size", + type=int, + default=512, + help="Maximum fuzz bytecode size in bytes", + ) + group.addoption( + "--spamoor-payload-seed", + dest="spamoor_payload_seed", + type=str, + default="", + help="Hex seed for evm-fuzz bytecode generator (empty = deterministic default)", + ) + group.addoption( + "--spamoor-tx-id-offset", + dest="spamoor_tx_id_offset", + type=int, + default=0, + help="Shift evm-fuzz per-tx IDs by this offset", + ) + group.addoption( + "--spamoor-fuzz-mode", + dest="spamoor_fuzz_mode", + type=str, + default="all", + help="evm-fuzz mode: 'all' | 'opcodes' | 'precompiles'", + ) + group.addoption( + "--spamoor-random-target", + dest="spamoor_random_target", + action="store_true", + default=False, + help="Use pseudo-random recipients for erc20tx transfers", + ) + group.addoption( + "--spamoor-random-amount", + dest="spamoor_random_amount", + action="store_true", + default=False, + help="Use pseudo-random amounts for erc20tx transfers", + ) + group.addoption( + "--spamoor-reuse-contract", + dest="spamoor_reuse_contract", + action="store_true", + default=False, + help="storagespam: reuse existing deployed contract (skip deploy tx)", + ) + group.addoption( + "--spamoor-addresses-per-tx", + dest="spamoor_addresses_per_tx", + type=int, + default=370, + help="erc20_bloater: addresses bloated per transaction", + ) + group.addoption( + "--spamoor-start-address-index", + dest="spamoor_start_address_index", + type=int, + default=1, + help="erc20_bloater: starting contract slot index", + ) + group.addoption( + "--spamoor-slots-per-call", + dest="spamoor_slots_per_call", + type=int, + default=500, + help="storagerefundtx: number of slots written+cleared per execute() call", + ) + group.addoption( + "--spamoor-bytecodes", + dest="spamoor_bytecodes", + type=str, + default="", + help="deploytx: comma-separated list of hex bytecodes to cycle through", + ) + group.addoption( + "--spamoor-bytecodes-file", + dest="spamoor_bytecodes_file", + type=str, + default="", + help="deploytx: path to file with one hex bytecode per line", + ) def pytest_configure(config: pytest.Config) -> None: @@ -156,6 +303,37 @@ def spamoor_config(request): "start_salt": request.config.getoption("spamoor_start_salt"), "init_code": request.config.getoption("spamoor_init_code"), "factory_address": request.config.getoption("spamoor_factory_address"), + "sidecars": request.config.getoption("spamoor_sidecars"), + "blob_fee": request.config.getoption("spamoor_blob_fee"), + "gas_units_to_burn": request.config.getoption( + "spamoor_gas_units_to_burn" + ), + "pair_count": request.config.getoption("spamoor_pair_count"), + "min_swap_amount": request.config.getoption( + "spamoor_min_swap_amount" + ), + "max_swap_amount": request.config.getoption( + "spamoor_max_swap_amount" + ), + "buy_ratio": request.config.getoption("spamoor_buy_ratio"), + "slippage": request.config.getoption("spamoor_slippage"), + "min_code_size": request.config.getoption("spamoor_min_code_size"), + "max_code_size": request.config.getoption("spamoor_max_code_size"), + "payload_seed": request.config.getoption("spamoor_payload_seed"), + "tx_id_offset": request.config.getoption("spamoor_tx_id_offset"), + "fuzz_mode": request.config.getoption("spamoor_fuzz_mode"), + "random_target": request.config.getoption("spamoor_random_target"), + "random_amount": request.config.getoption("spamoor_random_amount"), + "reuse_contract": request.config.getoption("spamoor_reuse_contract"), + "addresses_per_tx": request.config.getoption( + "spamoor_addresses_per_tx" + ), + "start_address_index": request.config.getoption( + "spamoor_start_address_index" + ), + "slots_per_call": request.config.getoption("spamoor_slots_per_call"), + "bytecodes": request.config.getoption("spamoor_bytecodes"), + "bytecodes_file": request.config.getoption("spamoor_bytecodes_file"), } diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py index 6a55a490f1a..715b3d60d09 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py @@ -2,8 +2,13 @@ from typing import Any -from execution_testing.base_types import Address, Bytes, HexNumber -from execution_testing.test_types import EOA, Transaction +from execution_testing.base_types import Address, Bytes, Hash, HexNumber +from execution_testing.forks import Fork +from execution_testing.test_types import ( + EOA, + Blob, + Transaction, +) def _optional_address(value: Any) -> Address | None: @@ -31,6 +36,8 @@ def spamoor_dict_to_transaction( chain_id: int, *, nonce_override: int | None = None, + fork: Fork | None = None, + blob_seed: int = 0, ) -> Transaction: """ Convert a spamoor helpers dict into a signed EST ``Transaction``. @@ -50,9 +57,10 @@ def spamoor_dict_to_transaction( nonce_value = int(signer.nonce) access_list = tx_dict.get("accessList", []) + ty = int(tx_dict["type"]) - tx = Transaction( - ty=HexNumber(int(tx_dict["type"])), + tx_kwargs: dict[str, Any] = dict( + ty=HexNumber(ty), nonce=HexNumber(nonce_value), to=_optional_address(tx_dict.get("to")), value=HexNumber(int(tx_dict["value"])), @@ -67,4 +75,33 @@ def spamoor_dict_to_transaction( secret_key=signer.key, sender=signer, ) + + if ty == 3: + if fork is None: + raise ValueError( + "fork is required for type-3 (blob) transactions" + ) + blob_count = int( + tx_dict.get( + "blobCount", + len(tx_dict.get("blobVersionedHashes", [])) or 1, + ) + ) + blob_objects = [ + Blob.from_fork(fork, seed=blob_seed + i) for i in range(blob_count) + ] + versioned_hashes = [ + Hash(blob.versioned_hash) for blob in blob_objects + ] + tx_kwargs["max_fee_per_blob_gas"] = HexNumber( + int(tx_dict.get("maxFeePerBlobGas", 1)) + ) + tx_kwargs["blob_versioned_hashes"] = versioned_hashes + # Block-form RLP (payload only). testing_commitBlockV1 does not + # take sidecars; it verifies versioned hashes from the payload. + _ = blob_objects # kept for potential future sidecar wiring + tx = Transaction(**tx_kwargs).with_signature_and_sender() + return tx + + tx = Transaction(**tx_kwargs) return tx.with_signature_and_sender() diff --git a/tests/benchmark/spamoor/helpers.py b/tests/benchmark/spamoor/helpers.py index 47c79f9bfa0..62df2fcd237 100644 --- a/tests/benchmark/spamoor/helpers.py +++ b/tests/benchmark/spamoor/helpers.py @@ -1,4 +1,5 @@ from typing import List, Optional, Callable, Any, Dict +import hashlib import json try: @@ -193,6 +194,71 @@ def build_calltx_transactions( return txs +def build_blob_combined_transactions( + count: int, + sidecars: int = 3, + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + blob_fee: int = 20_000_000_000, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build EIP-4844 blob transactions mirroring spamoor blob-combined. + + Produces `count` type-3 transactions, each carrying `sidecars` + placeholder versioned hashes. Blob sidecar data is left to the + caller for the committed-path conversion. + """ + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 20_000_000_000 + + max_fee_per_gas = base_fee_per_gas + tip_fee + + blob_count = max(1, min(int(sidecars), 6)) + to_addr = "0x1000000000000000000000000000000000000000" + txs: List[Dict[str, Any]] = [] + for i in range(count): + versioned_hashes = [ + "0x01" + f"{(i * 100 + j):062x}" for j in range(blob_count) + ] + tx: Dict[str, Any] = { + "type": 3, + "to": to_addr, + "value": 0, + "data": "", + "gas": 21000, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "maxFeePerBlobGas": blob_fee, + "blobVersionedHashes": versioned_hashes, + "blobCount": blob_count, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + i + txs.append(tx) + return txs + + def build_factorydeploytx_transactions( count: int, init_code: str, @@ -288,3 +354,952 @@ def _to_bytes32(n: int) -> bytes: ) return txs + + +# Minimal gas-burner runtime: loops on JUMPDEST/JUMP until gas runs out. +# Layout: JUMPDEST (0x5b), PUSH1 0x00, JUMP (0x56). Loops back to offset 0. +_GAS_BURNER_RUNTIME = bytes.fromhex("5b600056") +# Init code: copy the runtime into memory and RETURN it. +# PUSH1 len, PUSH1 runtime_offset, PUSH1 0 (mem), CODECOPY, +# PUSH1 len, PUSH1 0, RETURN +# With a 4-byte runtime, runtime offset = 12. +_GAS_BURNER_INIT = bytes.fromhex("6004600c60003960046000f3") + _GAS_BURNER_RUNTIME +_GAS_BURNER_CONTRACT_HEX = "0x" + _GAS_BURNER_INIT.hex() +_GAS_BURNER_PLACEHOLDER_ADDR = "0x3333333333333333333333333333333333333333" + + +def build_gasburnertx_transactions( + count: int, + gas_units_to_burn: int = 2_000_000, + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + throughput: float = 1.0, + deploy_gas_limit: int = 2_000_000, + contract_address: Optional[str] = None, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build gasburnertx deployment + execution transactions. + + Mirrors the Go scenario: one dynamic-fee deploy tx for the gas-burner + contract, followed by *count* dynamic-fee execution txs each carrying + a 4-byte big-endian txIdx payload and a gas limit of + *gas_units_to_burn*. + """ + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + + txs: List[Dict[str, Any]] = [] + + deploy_tx: Dict[str, Any] = { + "type": 2, + "to": "", + "value": 0, + "data": _GAS_BURNER_CONTRACT_HEX, + "gas": deploy_gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + deploy_tx["nonce"] = nonce + nonce += 1 + txs.append(deploy_tx) + + target_addr = contract_address or _GAS_BURNER_PLACEHOLDER_ADDR + for i in range(count): + tx_id_bytes = int(i).to_bytes(4, "big") + exec_tx: Dict[str, Any] = { + "type": 2, + "to": target_addr, + "value": 0, + "data": "0x" + tx_id_bytes.hex(), + "gas": gas_units_to_burn, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + exec_tx["nonce"] = nonce + nonce += 1 + txs.append(exec_tx) + + return txs + + +# Uniswap V2 Router02 selectors (well-known; first 4 bytes of keccak(signature)). +_SWAP_EXACT_TOKENS_FOR_TOKENS = "38ed1739" # (uint256,uint256,address[],address,uint256) +_SWAP_EXACT_ETH_FOR_TOKENS = "7ff36ab5" # payable; (uint256,address[],address,uint256) +_SWAP_EXACT_TOKENS_FOR_ETH = "18cbafe5" # (uint256,uint256,address[],address,uint256) + +_UNISWAP_ROUTER_ADDR = "0x4444444444444444444444444444444444444444" +_UNISWAP_WETH_ADDR = "0x5555555555555555555555555555555555555555" +_UNISWAP_DAI_ADDR = "0x6666666666666666666666666666666666666666" +_UNISWAP_RECIPIENT = "0x7777777777777777777777777777777777777777" + + +def _encode_uniswap_swap_call( + variant: int, + amount_in: int, + min_out: int, + path: List[str], + recipient: str, + deadline: int, +) -> tuple[str, int]: + """Return (hex call_data, tx_value_wei) for a router swap call. + + variant 0 → swapExactTokensForTokens (value=0) + variant 1 → swapExactETHForTokens (value=amount_in, payable) + variant 2 → swapExactTokensForETH (value=0) + """ + assert eth_abi_encode is not None, "eth_abi required for uniswap port" + path_addrs = [bytes.fromhex(a[2:]).rjust(20, b"\x00") for a in path] + if variant == 1: + # swapExactETHForTokens(uint256 amountOutMin, address[] path, + # address to, uint256 deadline). amountIn == msg.value. + encoded = eth_abi_encode( + ["uint256", "address[]", "address", "uint256"], + [min_out, path_addrs, bytes.fromhex(recipient[2:]), deadline], + ) + return "0x" + _SWAP_EXACT_ETH_FOR_TOKENS + encoded.hex(), amount_in + + selector = ( + _SWAP_EXACT_TOKENS_FOR_ETH + if variant == 2 + else _SWAP_EXACT_TOKENS_FOR_TOKENS + ) + encoded = eth_abi_encode( + ["uint256", "uint256", "address[]", "address", "uint256"], + [ + amount_in, + min_out, + path_addrs, + bytes.fromhex(recipient[2:]), + deadline, + ], + ) + return "0x" + selector + encoded.hex(), 0 + + +def build_uniswap_swaps_transactions( + count: int, + pair_count: int = 1, + min_swap_amount: int = 100_000_000_000_000_000, + max_swap_amount: int = 1_000_000_000_000_000_000_000, + buy_ratio: int = 40, + slippage: int = 50, + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + throughput: float = 1.0, + gas_limit: int = 200_000, + recipient: str = _UNISWAP_RECIPIENT, + router_address: str = _UNISWAP_ROUTER_ADDR, + weth_address: str = _UNISWAP_WETH_ADDR, + token_address: str = _UNISWAP_DAI_ADDR, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build uniswap-swaps execution transactions. + + Deterministic port of the Go scenario's action mix: per tx index we + alternate between *buy-with-ETH*, *buy-with-WETH*, and *sell-tokens*, + weighted by ``buy_ratio``. Every tx is a type-2 (EIP-1559) call to + ``router_address`` with ABI-encoded swap calldata. Swap amounts are + the midpoint of the ``[min_swap_amount, max_swap_amount]`` range so + the builder stays deterministic for shape assertions. + """ + if count <= 0: + return [] + if eth_abi_encode is None: + raise RuntimeError("eth_abi is required for uniswap-swaps port") + + nonce = None + if from_addr and rpc_client: + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + + swap_amount = (int(min_swap_amount) + int(max_swap_amount)) // 2 + min_out = swap_amount * max(0, 10_000 - int(slippage)) // 10_000 + deadline = 2_000_000_000 # fixed future unix ts; scenario uses now+300. + + # Deterministic buy/sell allocation proportional to buy_ratio so small + # counts still produce a mix. e.g. count=5, buy_ratio=40 → buys={0,2} + # (2 buys), sells={1,3,4}. Rounding keeps at least 1 of each when + # 0 < buy_ratio < 100 and count >= 2. + buy_count_target = max( + 0, min(count, (count * int(buy_ratio) + 50) // 100) + ) + if 0 < int(buy_ratio) < 100 and count >= 2: + buy_count_target = max(1, min(count - 1, buy_count_target)) + buy_stride = count / buy_count_target if buy_count_target else float("inf") + buys_issued = 0 + + txs: List[Dict[str, Any]] = [] + for i in range(count): + # Interleave buys by stride so they're spread across the batch. + want_buy = ( + buys_issued < buy_count_target + and i >= int(buys_issued * buy_stride) + ) + if want_buy: + variant = 0 if (buys_issued % 2 == 0) else 1 + path = [weth_address, token_address] + buys_issued += 1 + else: + variant = 2 + path = [token_address, weth_address] + + call_data, value = _encode_uniswap_swap_call( + variant, + amount_in=swap_amount, + min_out=min_out, + path=path, + recipient=recipient, + deadline=deadline, + ) + + tx: Dict[str, Any] = { + "type": 2, + "to": router_address, + "value": value, + "data": call_data, + "gas": gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + i + txs.append(tx) + + _ = pair_count # reserved for future multi-pair encoding + return txs + + +def _evm_fuzz_bytecode( + tx_id: int, seed_hex: str, min_size: int, max_size: int, mode: str +) -> bytes: + """Deterministically derive fuzz bytecode for *tx_id*. + + Rather than porting the full Go opcode generator, we expand the + seed + tx_id + mode through SHA-256 counters until we have enough + bytes. Resulting bytecodes are almost always invalid EVM init code, + which matches the scenario's purpose: exercise the EVM and the + deployment path with random-shaped payloads. + """ + if max_size < min_size: + max_size = min_size + seed_bytes = bytes.fromhex(seed_hex[2:] if seed_hex.startswith("0x") else seed_hex) if seed_hex else b"" + # Size is deterministic per tx within [min_size, max_size]. + size_span = max_size - min_size + 1 + size = min_size + ( + int.from_bytes( + hashlib.sha256(seed_bytes + tx_id.to_bytes(8, "big") + b"size").digest()[:4], + "big", + ) + % size_span + ) + mode_byte = {"all": 0, "opcodes": 1, "precompiles": 2}.get(mode, 0) + out = bytearray() + counter = 0 + while len(out) < size: + h = hashlib.sha256( + seed_bytes + + tx_id.to_bytes(8, "big") + + bytes([mode_byte]) + + counter.to_bytes(4, "big") + ).digest() + out.extend(h) + counter += 1 + return bytes(out[:size]) + + +def build_evm_fuzz_transactions( + count: int, + gas_limit: int = 0, + min_code_size: int = 100, + max_code_size: int = 512, + payload_seed: str = "", + tx_id_offset: int = 0, + fuzz_mode: str = "all", + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + throughput: float = 1.0, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build evm-fuzz contract creation transactions. + + Each tx is a type-2 contract creation (``to == ""``) carrying + deterministic pseudo-random bytes as init code. 25% of txs carry + ``value == 0``; the remainder carry a small random value in the + ``[0xa000, 0x10000)`` wei range, mirroring the Go scenario's 75/25 + value split. + """ + if count <= 0: + return [] + effective_gas = gas_limit if gas_limit and gas_limit > 0 else 1_000_000 + + nonce = None + if from_addr and rpc_client: + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + + seed = payload_seed or "deadbeef" + + txs: List[Dict[str, Any]] = [] + for i in range(count): + tx_id = i + int(tx_id_offset) + code = _evm_fuzz_bytecode( + tx_id, seed, int(min_code_size), int(max_code_size), fuzz_mode + ) + # Deterministic 75/25 value split: every 4th tx gets value=0. + if tx_id % 4 == 0: + value = 0 + else: + value_span = 0x6000 + value = 0xa000 + ( + int.from_bytes( + hashlib.sha256( + seed.encode() + tx_id.to_bytes(8, "big") + b"val" + ).digest()[:2], + "big", + ) + % value_span + ) + tx: Dict[str, Any] = { + "type": 2, + "to": "", + "value": value, + "data": "0x" + code.hex(), + "gas": effective_gas, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + i + txs.append(tx) + return txs + + +# keccak("transferMint(address,uint256)")[:4] +_TRANSFER_MINT_SELECTOR = "9d0f7cba" +_ERC20_PLACEHOLDER_ADDR = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + +def _erc20_recipient_for_idx(idx: int) -> str: + tail = f"{idx:040x}" + return "0x" + ("cc" * 12) + tail[-16:] + + +def build_erc20tx_transactions( + count: int, + amount: int = 1_000_000_000_000_000_000, + random_target: bool = False, + random_amount: bool = False, + contract_address: Optional[str] = None, + contract_code: Optional[str] = None, + deploy_gas_limit: int = 2_000_000, + gas_limit: int = 100_000, + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + throughput: float = 1.0, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build erc20tx deployment + transferMint execution transactions. + + If *contract_code* is provided, the first tx deploys it (to="", + data=code). Then *count* transferMint calls follow, targeting either + the provided *contract_address* or a fixed placeholder. + """ + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client( + "eth_getTransactionCount", [from_addr, "pending"] + ) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if ( + bf_resp + and "baseFeePerGas" in bf_resp + and bf_resp["baseFeePerGas"] + ): + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + + txs: List[Dict[str, Any]] = [] + + if contract_code is not None: + deploy_tx: Dict[str, Any] = { + "type": 2, + "to": "", + "value": 0, + "data": contract_code, + "gas": deploy_gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + deploy_tx["nonce"] = nonce + nonce += 1 + txs.append(deploy_tx) + + target_token = contract_address or _ERC20_PLACEHOLDER_ADDR + + for i in range(count): + if random_target: + recipient = "0x" + hashlib.sha256( + b"erc20tx:target:" + i.to_bytes(8, "big") + ).digest()[:20].hex() + else: + recipient = _erc20_recipient_for_idx(i) + + if random_amount: + transfer_amount = ( + int.from_bytes( + hashlib.sha256( + b"erc20tx:amount:" + i.to_bytes(8, "big") + ).digest()[:8], + "big", + ) + % max(int(amount), 1) + ) + else: + transfer_amount = int(amount) + + addr_word = bytes.fromhex(recipient[2:]).rjust(32, b"\x00") + amount_word = transfer_amount.to_bytes(32, "big") + call_data = ( + "0x" + + _TRANSFER_MINT_SELECTOR + + addr_word.hex() + + amount_word.hex() + ) + + tx: Dict[str, Any] = { + "type": 2, + "to": target_token, + "value": 0, + "data": call_data, + "gas": gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + nonce += 1 + txs.append(tx) + + return txs + + +# keccak("setRandomForGas(uint256,uint256)")[:4] +_SET_RANDOM_FOR_GAS_SELECTOR = "fed72935" +_STORAGESPAM_PLACEHOLDER_ADDR = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + +def build_storagespam_transactions( + count: int, + gas_units_to_burn: int = 2_000_000, + reuse_contract: bool = False, + contract_address: Optional[str] = None, + contract_code: Optional[str] = None, + deploy_gas_limit: int = 2_000_000, + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + throughput: float = 1.0, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build storagespam deployment + setRandomForGas execution transactions. + + Unless *reuse_contract* is True, a deployment tx is emitted first with + *contract_code* (a minimal placeholder when not provided). Each of + the *count* execution txs calls ``setRandomForGas(gas_units_to_burn, + txIdx)`` on the deployed (or placeholder) contract. Gas per exec tx + is ``gas_units_to_burn + 50_000`` to match the Go scenario. + """ + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client( + "eth_getTransactionCount", [from_addr, "pending"] + ) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if ( + bf_resp + and "baseFeePerGas" in bf_resp + and bf_resp["baseFeePerGas"] + ): + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + + txs: List[Dict[str, Any]] = [] + + # Inline minimal init code that returns a tiny runtime (so committed + # txs targeting the CREATE address still see a contract; the real + # StorageSpam.sol bytecode isn't bundled here). + stub_init_hex = ( + "0x6004600c60003960046000f35b600056" + ) + + if not reuse_contract: + deploy_code = contract_code or stub_init_hex + deploy_tx: Dict[str, Any] = { + "type": 2, + "to": "", + "value": 0, + "data": deploy_code, + "gas": deploy_gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + deploy_tx["nonce"] = nonce + nonce += 1 + txs.append(deploy_tx) + + target = contract_address or _STORAGESPAM_PLACEHOLDER_ADDR + exec_gas = int(gas_units_to_burn) + 50_000 + + for i in range(count): + gas_word = int(gas_units_to_burn).to_bytes(32, "big") + seed_word = int(i).to_bytes(32, "big") + call_data = ( + "0x" + + _SET_RANDOM_FOR_GAS_SELECTOR + + gas_word.hex() + + seed_word.hex() + ) + tx: Dict[str, Any] = { + "type": 2, + "to": target, + "value": 0, + "data": call_data, + "gas": exec_gas, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + nonce += 1 + txs.append(tx) + + return txs + + +# keccak("bloatStorage(uint256,uint256)")[:4] +_BLOAT_STORAGE_SELECTOR = "c1926de5" +# Reasonable gas limit for erc20_bloater tx. The Go scenario uses 16.7M +# (EIP-7825 cap). The lab genesis block gas limit is 30M so this fits. +_BLOAT_DEFAULT_GAS = 16_700_000 +_BLOAT_PLACEHOLDER_ADDR = "0xdddddddddddddddddddddddddddddddddddddddd" + + +def build_erc20_bloater_transactions( + count: int, + addresses_per_tx: int = 370, + start_address_index: int = 1, + gas_limit: int = 0, + contract_address: Optional[str] = None, + contract_code: Optional[str] = None, + deploy_gas_limit: int = 2_000_000, + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + throughput: float = 1.0, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build erc20_bloater deployment + bloatStorage execution transactions. + + When *contract_code* is provided (or the default placeholder is used) + the first tx deploys the contract. Each of the *count* execution + txs calls ``bloatStorage(uint256 startAddressIndex, uint256 + numAddresses)`` with a sliding ``startAddressIndex``, which mirrors + the Go scenario's sequential sweep of the address space. + """ + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client( + "eth_getTransactionCount", [from_addr, "pending"] + ) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if ( + bf_resp + and "baseFeePerGas" in bf_resp + and bf_resp["baseFeePerGas"] + ): + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + + exec_gas = gas_limit if gas_limit and gas_limit > 0 else _BLOAT_DEFAULT_GAS + + txs: List[Dict[str, Any]] = [] + + # Minimal stub init that returns a tiny runtime. The real ERC20Bloater + # bytecode lives in the Go contract package; we just need a well- + # formed deploy tx here. + stub_init_hex = "0x6004600c60003960046000f35b600056" + if contract_code is not None or contract_address is None: + deploy_tx: Dict[str, Any] = { + "type": 2, + "to": "", + "value": 0, + "data": contract_code or stub_init_hex, + "gas": deploy_gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + deploy_tx["nonce"] = nonce + nonce += 1 + txs.append(deploy_tx) + + target = contract_address or _BLOAT_PLACEHOLDER_ADDR + idx = int(start_address_index) + step = max(1, int(addresses_per_tx)) + + for _ in range(count): + start_word = idx.to_bytes(32, "big") + num_word = step.to_bytes(32, "big") + call_data = ( + "0x" + + _BLOAT_STORAGE_SELECTOR + + start_word.hex() + + num_word.hex() + ) + tx: Dict[str, Any] = { + "type": 2, + "to": target, + "value": 0, + "data": call_data, + "gas": exec_gas, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + nonce += 1 + txs.append(tx) + idx += step + + return txs + + +# keccak("execute(uint256)")[:4] +_STORAGE_REFUND_EXECUTE_SELECTOR = "fe0d94c1" +_STORAGE_REFUND_PLACEHOLDER_ADDR = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +# When the caller does not pin a gas limit, the Go scenario picks a +# value that comfortably fits SlotsPerCall SSTORE+clear cycles. 3M is +# large enough for SlotsPerCall<=500 in practice and small enough to +# fit many per block under the 30M lab cap. +_STORAGE_REFUND_DEFAULT_GAS = 3_000_000 + + +def build_storagerefundtx_transactions( + count: int, + slots_per_call: int = 500, + gas_limit: int = 0, + contract_address: Optional[str] = None, + contract_code: Optional[str] = None, + deploy_gas_limit: int = 2_000_000, + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + throughput: float = 1.0, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build storagerefundtx deployment + execute(uint256) calls. + + Unless *contract_address* is supplied, the first tx deploys a stub + (or user-provided *contract_code*) so the batch is self-contained. + Each of the *count* execution txs calls + ``execute(slots_per_call)`` on the deployed (or supplied) contract. + """ + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client( + "eth_getTransactionCount", [from_addr, "pending"] + ) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if ( + bf_resp + and "baseFeePerGas" in bf_resp + and bf_resp["baseFeePerGas"] + ): + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + + exec_gas = ( + gas_limit + if gas_limit and gas_limit > 0 + else _STORAGE_REFUND_DEFAULT_GAS + ) + + txs: List[Dict[str, Any]] = [] + + stub_init_hex = "0x6004600c60003960046000f35b600056" + if contract_address is None: + deploy_tx: Dict[str, Any] = { + "type": 2, + "to": "", + "value": 0, + "data": contract_code or stub_init_hex, + "gas": deploy_gas_limit, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + deploy_tx["nonce"] = nonce + nonce += 1 + txs.append(deploy_tx) + + target = contract_address or _STORAGE_REFUND_PLACEHOLDER_ADDR + slots_word = int(slots_per_call).to_bytes(32, "big") + call_data = ( + "0x" + _STORAGE_REFUND_EXECUTE_SELECTOR + slots_word.hex() + ) + + for _ in range(count): + tx: Dict[str, Any] = { + "type": 2, + "to": target, + "value": 0, + "data": call_data, + "gas": exec_gas, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + nonce += 1 + txs.append(tx) + + return txs + + +# Default bytecode: PUSH1 0x01 / PUSH1 0x00 / SSTORE / STOP — writes one +# slot on deploy; leaves empty runtime (so "runtime" is zero bytes). +_DEPLOYTX_DEFAULT_BYTECODE = "0x6001600055" + + +def _parse_deploytx_bytecodes( + bytecodes: str, bytecodes_file: str +) -> List[str]: + """Return the list of hex bytecodes (with ``0x`` prefix) to cycle.""" + out: List[str] = [] + if bytecodes: + for chunk in bytecodes.split(","): + chunk = chunk.strip() + if not chunk: + continue + if not chunk.startswith("0x"): + chunk = "0x" + chunk + out.append(chunk) + if bytecodes_file: + try: + with open(bytecodes_file, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#"): + continue + if not line.startswith("0x"): + line = "0x" + line + out.append(line) + except OSError: + pass + if not out: + out.append(_DEPLOYTX_DEFAULT_BYTECODE) + return out + + +def build_deploytx_transactions( + count: int, + bytecodes: str = "", + bytecodes_file: str = "", + gas_limit: int = 0, + basefee: Optional[int] = None, + tip_fee: int = 1_000_000_000, + throughput: float = 1.0, + from_addr: Optional[str] = None, + private_key: Optional[str] = None, + rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, +) -> List[Dict[str, Any]]: + """Build N deployment transactions cycling through *bytecodes*. + + Mirrors the Go deploytx scenario: every tx is type 2 with + ``to == ""`` (contract creation); the init code cycles through the + parsed list of bytecodes (or a tiny default if none supplied). + """ + if count <= 0: + return [] + + nonce = None + if from_addr and rpc_client: + resp = rpc_client( + "eth_getTransactionCount", [from_addr, "pending"] + ) + if isinstance(resp, str) and resp.startswith("0x"): + nonce = int(resp, 16) + + base_fee_per_gas = basefee + if base_fee_per_gas is None and rpc_client: + bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) + if ( + bf_resp + and "baseFeePerGas" in bf_resp + and bf_resp["baseFeePerGas"] + ): + try: + base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) + except ValueError: + pass + if base_fee_per_gas is None: + base_fee_per_gas = 1_000_000_000 + max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + + codes = _parse_deploytx_bytecodes(bytecodes, bytecodes_file) + exec_gas = gas_limit if gas_limit and gas_limit > 0 else 1_000_000 + + txs: List[Dict[str, Any]] = [] + for i in range(count): + data = codes[i % len(codes)] + tx: Dict[str, Any] = { + "type": 2, + "to": "", + "value": 0, + "data": data, + "gas": exec_gas, + "maxFeePerGas": max_fee_per_gas, + "maxPriorityFeePerGas": tip_fee, + "chainId": 1, + "accessList": [], + } + if nonce is not None: + tx["nonce"] = nonce + i + txs.append(tx) + return txs diff --git a/tests/benchmark/spamoor/test_blob_combined.py b/tests/benchmark/spamoor/test_blob_combined.py new file mode 100644 index 00000000000..e1c400cf1f0 --- /dev/null +++ b/tests/benchmark/spamoor/test_blob_combined.py @@ -0,0 +1,31 @@ +import pytest + +from .helpers import build_blob_combined_transactions + + +@pytest.mark.spamoor +def test_blob_combined_scenario(spamoor_config, spamoor_rpc_client): + txs = build_blob_combined_transactions( + count=spamoor_config["count"], + sidecars=spamoor_config["sidecars"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + blob_fee=spamoor_config["blob_fee"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] > 0: + tx0 = txs[0] + assert tx0["type"] == 3 + assert tx0["gas"] == 21000 + assert tx0["value"] == 0 + assert "maxFeePerGas" in tx0 + assert "maxPriorityFeePerGas" in tx0 + assert "maxFeePerBlobGas" in tx0 + assert tx0["maxFeePerBlobGas"] == spamoor_config["blob_fee"] + assert isinstance(tx0["blobVersionedHashes"], list) + expected_blobs = max(1, min(int(spamoor_config["sidecars"]), 6)) + assert len(tx0["blobVersionedHashes"]) == expected_blobs diff --git a/tests/benchmark/spamoor/test_deploytx.py b/tests/benchmark/spamoor/test_deploytx.py new file mode 100644 index 00000000000..361016aa16c --- /dev/null +++ b/tests/benchmark/spamoor/test_deploytx.py @@ -0,0 +1,64 @@ +import pytest + +from .helpers import build_deploytx_transactions + + +@pytest.mark.spamoor +def test_deploytx_default_bytecode(spamoor_config, spamoor_rpc_client): + txs = build_deploytx_transactions( + count=spamoor_config["count"], + bytecodes=spamoor_config["bytecodes"], + bytecodes_file=spamoor_config["bytecodes_file"], + gas_limit=spamoor_config["gas_limit"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] == 0: + return + + expected_gas = ( + spamoor_config["gas_limit"] + if spamoor_config["gas_limit"] + else 1_000_000 + ) + for tx in txs: + assert tx["type"] == 2 + assert tx["to"] == "" + assert tx["value"] == 0 + assert tx["gas"] == expected_gas + assert tx["data"].startswith("0x") + # When neither --spamoor-bytecodes nor --spamoor-bytecodes-file is + # set, every tx carries the default tiny bytecode. + if ( + not spamoor_config["bytecodes"] + and not spamoor_config["bytecodes_file"] + ): + assert txs[0]["data"] == "0x6001600055" + + +@pytest.mark.spamoor +def test_deploytx_cycles_bytecode_list(spamoor_config, spamoor_rpc_client): + # Force a two-entry list so we can check cycling even when the CLI + # doesn't provide bytecodes. + txs = build_deploytx_transactions( + count=max(3, spamoor_config["count"]), + bytecodes="0x6001600055,0x60ff60005255", + bytecodes_file="", + gas_limit=spamoor_config["gas_limit"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=None, + private_key=None, + rpc_client=None, + ) + + assert txs[0]["data"] == "0x6001600055" + assert txs[1]["data"] == "0x60ff60005255" + assert txs[2]["data"] == txs[0]["data"] # cycles every 2 diff --git a/tests/benchmark/spamoor/test_erc20_bloater.py b/tests/benchmark/spamoor/test_erc20_bloater.py new file mode 100644 index 00000000000..46a49543825 --- /dev/null +++ b/tests/benchmark/spamoor/test_erc20_bloater.py @@ -0,0 +1,78 @@ +import pytest + +from .helpers import build_erc20_bloater_transactions + + +@pytest.mark.spamoor +def test_erc20_bloater_scenario_with_deploy(spamoor_config, spamoor_rpc_client): + txs = build_erc20_bloater_transactions( + count=spamoor_config["count"], + addresses_per_tx=spamoor_config["addresses_per_tx"], + start_address_index=spamoor_config["start_address_index"], + gas_limit=spamoor_config["gas_limit"], + contract_address=None, + contract_code=None, + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + # Deploy tx + count bloat txs. + assert len(txs) == spamoor_config["count"] + 1 + + deploy = txs[0] + assert deploy["type"] == 2 + assert deploy["to"] == "" + + if spamoor_config["count"] > 0: + exec_tx = txs[1] + expected_gas = ( + spamoor_config["gas_limit"] + if spamoor_config["gas_limit"] + else 16_700_000 + ) + assert exec_tx["type"] == 2 + assert exec_tx["to"] == "0xdddddddddddddddddddddddddddddddddddddddd" + assert exec_tx["value"] == 0 + assert exec_tx["gas"] == expected_gas + # selector(4) + uint256(32) + uint256(32) = 68 bytes. + assert len(exec_tx["data"]) == 2 + 2 * 68 + assert exec_tx["data"].startswith("0xc1926de5") + + # numAddresses argument (last 32 bytes) stays constant across txs. + num_word = exec_tx["data"][-64:] + assert int(num_word, 16) == spamoor_config["addresses_per_tx"] + + # startAddressIndex advances by addresses_per_tx between txs. + if spamoor_config["count"] >= 2: + start_a = int(txs[1]["data"][10 : 10 + 64], 16) + start_b = int(txs[2]["data"][10 : 10 + 64], 16) + assert start_b - start_a == spamoor_config["addresses_per_tx"] + + +@pytest.mark.spamoor +def test_erc20_bloater_scenario_existing_contract(spamoor_config, spamoor_rpc_client): + txs = build_erc20_bloater_transactions( + count=spamoor_config["count"], + addresses_per_tx=spamoor_config["addresses_per_tx"], + start_address_index=spamoor_config["start_address_index"], + gas_limit=0, + contract_address="0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + contract_code=None, + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + # No deploy tx when targeting an existing contract. + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] > 0: + assert txs[0]["to"] == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + assert txs[0]["data"].startswith("0xc1926de5") diff --git a/tests/benchmark/spamoor/test_erc20tx.py b/tests/benchmark/spamoor/test_erc20tx.py new file mode 100644 index 00000000000..b63dd916c8f --- /dev/null +++ b/tests/benchmark/spamoor/test_erc20tx.py @@ -0,0 +1,71 @@ +import pytest + +from .helpers import build_erc20tx_transactions + + +@pytest.mark.spamoor +def test_erc20tx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): + txs = build_erc20tx_transactions( + count=spamoor_config["count"], + amount=spamoor_config["amount"], + random_target=spamoor_config["random_target"], + random_amount=spamoor_config["random_amount"], + contract_address=spamoor_config.get("contract_address"), + contract_code="0x6001600055", + deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2_000_000), + gas_limit=100_000, + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + # Deploy tx + count execution txs. + assert len(txs) == spamoor_config["count"] + 1 + + deploy = txs[0] + assert deploy["type"] == 2 + assert deploy["to"] == "" + assert deploy["data"].startswith("0x") + + if spamoor_config["count"] > 0: + exec_tx = txs[1] + assert exec_tx["type"] == 2 + assert exec_tx["to"] == ( + spamoor_config.get("contract_address") + or "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + assert exec_tx["value"] == 0 + assert exec_tx["gas"] == 100_000 + # selector(4) + address(32) + uint256(32) = 68 bytes => 136 hex chars + "0x". + assert len(exec_tx["data"]) == 2 + 2 * 68 + assert exec_tx["data"].startswith("0x9d0f7cba") + + +@pytest.mark.spamoor +def test_erc20tx_scenario_no_deploy(spamoor_config, spamoor_rpc_client): + txs = build_erc20tx_transactions( + count=spamoor_config["count"], + amount=spamoor_config["amount"], + random_target=True, + random_amount=False, + contract_address=spamoor_config.get("contract_address"), + contract_code=None, + gas_limit=100_000, + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + # No deploy tx when contract_code is None. + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] >= 2: + # random_target should produce distinct recipients. + addr_a = txs[0]["data"][10 : 10 + 64] + addr_b = txs[1]["data"][10 : 10 + 64] + assert addr_a != addr_b diff --git a/tests/benchmark/spamoor/test_evm_fuzz.py b/tests/benchmark/spamoor/test_evm_fuzz.py new file mode 100644 index 00000000000..56f90b1a99c --- /dev/null +++ b/tests/benchmark/spamoor/test_evm_fuzz.py @@ -0,0 +1,74 @@ +import pytest + +from .helpers import build_evm_fuzz_transactions + + +@pytest.mark.spamoor +def test_evm_fuzz_scenario(spamoor_config, spamoor_rpc_client): + txs = build_evm_fuzz_transactions( + count=spamoor_config["count"], + gas_limit=spamoor_config["gas_limit"], + min_code_size=spamoor_config["min_code_size"], + max_code_size=spamoor_config["max_code_size"], + payload_seed=spamoor_config["payload_seed"], + tx_id_offset=spamoor_config["tx_id_offset"], + fuzz_mode=spamoor_config["fuzz_mode"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] == 0: + return + + expected_gas = ( + spamoor_config["gas_limit"] + if spamoor_config["gas_limit"] + else 1_000_000 + ) + min_cs = spamoor_config["min_code_size"] + max_cs = spamoor_config["max_code_size"] + zero_value_count = 0 + for tx in txs: + assert tx["type"] == 2 + assert tx["to"] == "" # contract creation + assert tx["gas"] == expected_gas + assert tx["data"].startswith("0x") + # data is 0x + 2*bytes; each tx bytecode must fit [min, max] bytes. + byte_len = (len(tx["data"]) - 2) // 2 + assert min_cs <= byte_len <= max_cs + if tx["value"] == 0: + zero_value_count += 1 + + # 75/25 split: every 4th tx carries value=0. With count>=4 we must + # see at least one zero-value tx; and at least one non-zero if count>=2. + if spamoor_config["count"] >= 4: + assert zero_value_count >= 1 + if spamoor_config["count"] >= 2: + assert zero_value_count < spamoor_config["count"] + + +@pytest.mark.spamoor +def test_evm_fuzz_is_deterministic(spamoor_config, spamoor_rpc_client): + kwargs = dict( + count=max(2, spamoor_config["count"]), + gas_limit=spamoor_config["gas_limit"], + min_code_size=spamoor_config["min_code_size"], + max_code_size=spamoor_config["max_code_size"], + payload_seed="0x1234", + tx_id_offset=0, + fuzz_mode=spamoor_config["fuzz_mode"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=None, + private_key=None, + rpc_client=None, + ) + a = build_evm_fuzz_transactions(**kwargs) + b = build_evm_fuzz_transactions(**kwargs) + assert [t["data"] for t in a] == [t["data"] for t in b] diff --git a/tests/benchmark/spamoor/test_gasburnertx.py b/tests/benchmark/spamoor/test_gasburnertx.py new file mode 100644 index 00000000000..b1c24546bc1 --- /dev/null +++ b/tests/benchmark/spamoor/test_gasburnertx.py @@ -0,0 +1,44 @@ +import pytest + +from .helpers import build_gasburnertx_transactions + + +@pytest.mark.spamoor +def test_gasburnertx_scenario(spamoor_config, spamoor_rpc_client): + txs = build_gasburnertx_transactions( + count=spamoor_config["count"], + gas_units_to_burn=spamoor_config["gas_units_to_burn"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + contract_address=spamoor_config["contract_address"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + # Deploy tx + count exec txs. + assert len(txs) == spamoor_config["count"] + 1 + + deploy = txs[0] + assert deploy["type"] == 2 + assert deploy["to"] == "" + assert deploy["data"].startswith("0x") + assert deploy["gas"] == spamoor_config["deploy_gas_limit"] + + if spamoor_config["count"] > 0: + exec_tx = txs[1] + assert exec_tx["type"] == 2 + assert exec_tx["to"] == ( + spamoor_config["contract_address"] + or "0x3333333333333333333333333333333333333333" + ) + assert exec_tx["gas"] == spamoor_config["gas_units_to_burn"] + assert exec_tx["value"] == 0 + assert exec_tx["data"] == "0x00000000" + assert "maxFeePerGas" in exec_tx + assert "maxPriorityFeePerGas" in exec_tx + # Second exec tx encodes txIdx=1. + if spamoor_config["count"] > 1: + assert txs[2]["data"] == "0x00000001" diff --git a/tests/benchmark/spamoor/test_storagerefundtx.py b/tests/benchmark/spamoor/test_storagerefundtx.py new file mode 100644 index 00000000000..9fe8c9f73b3 --- /dev/null +++ b/tests/benchmark/spamoor/test_storagerefundtx.py @@ -0,0 +1,67 @@ +import pytest + +from .helpers import build_storagerefundtx_transactions + + +@pytest.mark.spamoor +def test_storagerefundtx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): + txs = build_storagerefundtx_transactions( + count=spamoor_config["count"], + slots_per_call=spamoor_config["slots_per_call"], + gas_limit=spamoor_config["gas_limit"], + contract_address=None, + contract_code=None, + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + assert len(txs) == spamoor_config["count"] + 1 + + deploy = txs[0] + assert deploy["type"] == 2 + assert deploy["to"] == "" + + if spamoor_config["count"] > 0: + exec_tx = txs[1] + expected_gas = ( + spamoor_config["gas_limit"] + if spamoor_config["gas_limit"] + else 3_000_000 + ) + assert exec_tx["type"] == 2 + assert exec_tx["to"] == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + assert exec_tx["value"] == 0 + assert exec_tx["gas"] == expected_gas + # selector(4) + uint256(32) = 36 bytes => 72 hex + "0x". + assert len(exec_tx["data"]) == 2 + 2 * 36 + assert exec_tx["data"].startswith("0xfe0d94c1") + # Encoded slotsPerCall matches the argument. + encoded_slots = int(exec_tx["data"][10:], 16) + assert encoded_slots == spamoor_config["slots_per_call"] + + +@pytest.mark.spamoor +def test_storagerefundtx_scenario_existing_contract(spamoor_config, spamoor_rpc_client): + txs = build_storagerefundtx_transactions( + count=spamoor_config["count"], + slots_per_call=spamoor_config["slots_per_call"], + gas_limit=0, + contract_address="0xffffffffffffffffffffffffffffffffffffffff", + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + # No deploy tx when targeting an existing contract. + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] > 0: + assert txs[0]["to"] == "0xffffffffffffffffffffffffffffffffffffffff" + assert txs[0]["data"].startswith("0xfe0d94c1") diff --git a/tests/benchmark/spamoor/test_storagespam.py b/tests/benchmark/spamoor/test_storagespam.py new file mode 100644 index 00000000000..d48163b2996 --- /dev/null +++ b/tests/benchmark/spamoor/test_storagespam.py @@ -0,0 +1,67 @@ +import pytest + +from .helpers import build_storagespam_transactions + + +@pytest.mark.spamoor +def test_storagespam_scenario_with_deploy(spamoor_config, spamoor_rpc_client): + txs = build_storagespam_transactions( + count=spamoor_config["count"], + gas_units_to_burn=spamoor_config["gas_units_to_burn"], + reuse_contract=False, + contract_address=spamoor_config.get("contract_address"), + contract_code=None, + deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2_000_000), + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + assert len(txs) == spamoor_config["count"] + 1 + + deploy = txs[0] + assert deploy["type"] == 2 + assert deploy["to"] == "" + assert deploy["data"].startswith("0x") + + if spamoor_config["count"] > 0: + exec_tx = txs[1] + assert exec_tx["type"] == 2 + assert exec_tx["to"] == ( + spamoor_config.get("contract_address") + or "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) + assert exec_tx["value"] == 0 + assert exec_tx["gas"] == spamoor_config["gas_units_to_burn"] + 50_000 + # selector(4) + uint256(32) + uint256(32) = 68 bytes. + assert len(exec_tx["data"]) == 2 + 2 * 68 + assert exec_tx["data"].startswith("0xfed72935") + # Second exec tx carries seed=1, so the trailing uint256 must differ. + if spamoor_config["count"] >= 2: + seed_word_a = txs[1]["data"][-64:] + seed_word_b = txs[2]["data"][-64:] + assert seed_word_a != seed_word_b + + +@pytest.mark.spamoor +def test_storagespam_scenario_reuse_contract(spamoor_config, spamoor_rpc_client): + txs = build_storagespam_transactions( + count=spamoor_config["count"], + gas_units_to_burn=spamoor_config["gas_units_to_burn"], + reuse_contract=True, + contract_address=spamoor_config.get("contract_address"), + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + # No deploy tx when reusing an existing contract. + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] > 0: + assert txs[0]["data"].startswith("0xfed72935") diff --git a/tests/benchmark/spamoor/test_uniswap_swaps.py b/tests/benchmark/spamoor/test_uniswap_swaps.py new file mode 100644 index 00000000000..4f7fd231820 --- /dev/null +++ b/tests/benchmark/spamoor/test_uniswap_swaps.py @@ -0,0 +1,55 @@ +import pytest + +from .helpers import build_uniswap_swaps_transactions + + +@pytest.mark.spamoor +def test_uniswap_swaps_scenario(spamoor_config, spamoor_rpc_client): + txs = build_uniswap_swaps_transactions( + count=spamoor_config["count"], + pair_count=spamoor_config["pair_count"], + min_swap_amount=spamoor_config["min_swap_amount"], + max_swap_amount=spamoor_config["max_swap_amount"], + buy_ratio=spamoor_config["buy_ratio"], + slippage=spamoor_config["slippage"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=spamoor_config["from_addr"], + private_key=spamoor_config["private_key"], + rpc_client=spamoor_rpc_client, + ) + + assert len(txs) == spamoor_config["count"] + if spamoor_config["count"] > 0: + tx0 = txs[0] + assert tx0["type"] == 2 + assert tx0["to"] == "0x4444444444444444444444444444444444444444" + assert tx0["gas"] == 200_000 + # Data carries a 4-byte Uniswap V2 Router02 selector then ABI args. + assert tx0["data"].startswith("0x") + selector = tx0["data"][2:10] + assert selector in {"38ed1739", "7ff36ab5", "18cbafe5"} + + # Buy-ratio mix: first N * buy_ratio / 100 txs should target a + # buy selector (either swapExactTokensForTokens or swapExactETHForTokens). + if spamoor_config["count"] >= 5 and spamoor_config["buy_ratio"] > 0: + buys_seen = 0 + sells_seen = 0 + for tx in txs: + selector = tx["data"][2:10] + if selector in {"38ed1739", "7ff36ab5"}: + buys_seen += 1 + elif selector == "18cbafe5": + sells_seen += 1 + assert buys_seen > 0 + if spamoor_config["buy_ratio"] < 100: + assert sells_seen > 0 + + # swapExactETHForTokens carries msg.value; others do not. + for tx in txs: + selector = tx["data"][2:10] + if selector == "7ff36ab5": + assert tx["value"] > 0 + else: + assert tx["value"] == 0 diff --git a/tests/benchmark/testing_build_block/test_blob_combined_committed.py b/tests/benchmark/testing_build_block/test_blob_combined_committed.py new file mode 100644 index 00000000000..af1113f2e0d --- /dev/null +++ b/tests/benchmark/testing_build_block/test_blob_combined_committed.py @@ -0,0 +1,73 @@ +"""End-to-end: commit a block of spamoor blob-combined transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.forks import Fork +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_blob_combined_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_blob_combined_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_fork: Fork, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Build blob transfers, commit them, and confirm head advanced.""" + raw_txs = build_blob_combined_transactions( + count=spamoor_config["count"], + sidecars=spamoor_config["sidecars"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + blob_fee=spamoor_config["blob_fee"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + base_nonce = int(bloat_signer.nonce) + blob_seed_cursor = 0 + txs: list[Transaction] = [] + for i, tx_dict in enumerate(raw_txs): + blob_count = int( + tx_dict.get( + "blobCount", + len(tx_dict.get("blobVersionedHashes", [])) or 1, + ) + ) + txs.append( + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=base_nonce + i, + fork=bloat_fork, + blob_seed=blob_seed_cursor, + ) + ) + blob_seed_cursor += blob_count + + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_deploytx_committed.py b/tests/benchmark/testing_build_block/test_deploytx_committed.py new file mode 100644 index 00000000000..01128a62d3f --- /dev/null +++ b/tests/benchmark/testing_build_block/test_deploytx_committed.py @@ -0,0 +1,60 @@ +"""End-to-end: commit a block of spamoor deploytx transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_deploytx_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_deploytx_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Commit a batch of deployment transactions against the lab node.""" + raw_txs = build_deploytx_transactions( + count=spamoor_config["count"], + bytecodes=spamoor_config["bytecodes"], + bytecodes_file=spamoor_config["bytecodes_file"], + gas_limit=spamoor_config["gas_limit"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_erc20_bloater_committed.py b/tests/benchmark/testing_build_block/test_erc20_bloater_committed.py new file mode 100644 index 00000000000..f2b5833946a --- /dev/null +++ b/tests/benchmark/testing_build_block/test_erc20_bloater_committed.py @@ -0,0 +1,68 @@ +"""End-to-end: commit a block of spamoor erc20_bloater transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_erc20_bloater_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_erc20_bloater_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Deploy the erc20_bloater stub and commit bloatStorage calls.""" + # Cap bloat gas so N+1 txs fit inside the ~30M block gas limit. + effective_gas_limit = ( + spamoor_config["gas_limit"] if spamoor_config["gas_limit"] else 3_000_000 + ) + + raw_txs = build_erc20_bloater_transactions( + count=spamoor_config["count"], + addresses_per_tx=spamoor_config["addresses_per_tx"], + start_address_index=spamoor_config["start_address_index"], + gas_limit=effective_gas_limit, + contract_address=spamoor_config["contract_address"], + contract_code=spamoor_config["contract_code"], + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_erc20tx_committed.py b/tests/benchmark/testing_build_block/test_erc20tx_committed.py new file mode 100644 index 00000000000..cd4a4a1034c --- /dev/null +++ b/tests/benchmark/testing_build_block/test_erc20tx_committed.py @@ -0,0 +1,64 @@ +"""End-to-end: commit a block of spamoor erc20tx transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_erc20tx_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_erc20tx_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Commit an ERC-20 deploy + transferMint batch against the lab node.""" + raw_txs = build_erc20tx_transactions( + count=spamoor_config["count"], + amount=spamoor_config["amount"], + random_target=spamoor_config["random_target"], + random_amount=spamoor_config["random_amount"], + contract_address=spamoor_config["contract_address"], + contract_code=spamoor_config["contract_code"] or "0x6001600055", + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + gas_limit=100_000, + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_evm_fuzz_committed.py b/tests/benchmark/testing_build_block/test_evm_fuzz_committed.py new file mode 100644 index 00000000000..b46b8f39232 --- /dev/null +++ b/tests/benchmark/testing_build_block/test_evm_fuzz_committed.py @@ -0,0 +1,63 @@ +"""End-to-end: commit a block of spamoor evm-fuzz deployment transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_evm_fuzz_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_evm_fuzz_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Commit a batch of fuzz-bytecode contract creations against the lab node.""" + raw_txs = build_evm_fuzz_transactions( + count=spamoor_config["count"], + gas_limit=spamoor_config["gas_limit"], + min_code_size=spamoor_config["min_code_size"], + max_code_size=spamoor_config["max_code_size"], + payload_seed=spamoor_config["payload_seed"], + tx_id_offset=spamoor_config["tx_id_offset"], + fuzz_mode=spamoor_config["fuzz_mode"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_gasburnertx_committed.py b/tests/benchmark/testing_build_block/test_gasburnertx_committed.py new file mode 100644 index 00000000000..cb613c20c03 --- /dev/null +++ b/tests/benchmark/testing_build_block/test_gasburnertx_committed.py @@ -0,0 +1,60 @@ +"""End-to-end: commit a block of spamoor gasburnertx transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_gasburnertx_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_gasburnertx_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Deploy the gas-burner contract and commit burn txs against it.""" + raw_txs = build_gasburnertx_transactions( + count=spamoor_config["count"], + gas_units_to_burn=spamoor_config["gas_units_to_burn"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + contract_address=spamoor_config["contract_address"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_storagerefundtx_committed.py b/tests/benchmark/testing_build_block/test_storagerefundtx_committed.py new file mode 100644 index 00000000000..67decf603ed --- /dev/null +++ b/tests/benchmark/testing_build_block/test_storagerefundtx_committed.py @@ -0,0 +1,64 @@ +"""End-to-end: commit a block of spamoor storagerefundtx transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import ( + build_storagerefundtx_transactions, +) + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_storagerefundtx_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Deploy storagerefundtx stub and commit execute(slotsPerCall) calls.""" + raw_txs = build_storagerefundtx_transactions( + count=spamoor_config["count"], + slots_per_call=spamoor_config["slots_per_call"], + gas_limit=spamoor_config["gas_limit"], + contract_address=spamoor_config["contract_address"], + contract_code=spamoor_config["contract_code"], + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_storagespam_committed.py b/tests/benchmark/testing_build_block/test_storagespam_committed.py new file mode 100644 index 00000000000..a9ee68ef29f --- /dev/null +++ b/tests/benchmark/testing_build_block/test_storagespam_committed.py @@ -0,0 +1,62 @@ +"""End-to-end: commit a block of spamoor storagespam transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_storagespam_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_storagespam_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Deploy the storagespam stub and commit setRandomForGas calls.""" + raw_txs = build_storagespam_transactions( + count=spamoor_config["count"], + gas_units_to_burn=spamoor_config["gas_units_to_burn"], + reuse_contract=spamoor_config["reuse_contract"], + contract_address=spamoor_config["contract_address"], + contract_code=spamoor_config["contract_code"], + deploy_gas_limit=spamoor_config["deploy_gas_limit"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number diff --git a/tests/benchmark/testing_build_block/test_uniswap_swaps_committed.py b/tests/benchmark/testing_build_block/test_uniswap_swaps_committed.py new file mode 100644 index 00000000000..87a1ccb8e4f --- /dev/null +++ b/tests/benchmark/testing_build_block/test_uniswap_swaps_committed.py @@ -0,0 +1,62 @@ +"""End-to-end: commit a block of spamoor uniswap-swaps transactions.""" + +from typing import Any, Callable, Dict, Sequence + +import pytest +from execution_testing.base_types import Hash +from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 + BloatConfig, +) +from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, +) +from execution_testing.rpc import EthRPC +from execution_testing.test_types import EOA, Transaction + +from tests.benchmark.spamoor.helpers import build_uniswap_swaps_transactions + + +@pytest.mark.spamoor +@pytest.mark.testing_build_block +def test_uniswap_swaps_committed( + spamoor_config: Dict[str, Any], + bloat_config: BloatConfig, + bloat_signer: EOA, + bloat_eth_rpc: EthRPC, + bloat_commit_block: Callable[[Sequence[Transaction]], Hash], +) -> None: + """Commit a batch of uniswap v2 swap calls against the lab node.""" + raw_txs = build_uniswap_swaps_transactions( + count=spamoor_config["count"], + pair_count=spamoor_config["pair_count"], + min_swap_amount=spamoor_config["min_swap_amount"], + max_swap_amount=spamoor_config["max_swap_amount"], + buy_ratio=spamoor_config["buy_ratio"], + slippage=spamoor_config["slippage"], + basefee=spamoor_config["basefee"], + tip_fee=spamoor_config["tip_fee"], + throughput=spamoor_config["throughput"], + from_addr=bloat_signer, + private_key=bloat_config.signer_key, + rpc_client=None, + ) + if not raw_txs: + pytest.skip("spamoor produced no transactions; nothing to commit") + + txs = [ + spamoor_dict_to_transaction( + tx_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=int(bloat_signer.nonce) + i, + ) + for i, tx_dict in enumerate(raw_txs) + ] + prev_head = bloat_eth_rpc.get_block_by_number("latest") + assert prev_head is not None + prev_number = int(prev_head["number"], 16) + + new_head_hash = bloat_commit_block(txs) + new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) + assert new_head is not None + assert int(new_head["number"], 16) > prev_number From aa092018d2ac59d05b167c113a9203a02e66cb1b Mon Sep 17 00:00:00 2001 From: Dmytro Biloshytskyi Date: Thu, 23 Apr 2026 12:11:22 +0300 Subject: [PATCH 05/11] Fixed static check --- .../plugins/spamoor/__init__.py | 1 + .../plugins/spamoor/spamoor.py | 31 ++- .../testing_build_block/commit_block.py | 18 +- .../testing_build_block.py | 8 +- .../tests/test_commit_block.py | 4 +- .../plugins/testing_build_block/tx_convert.py | 8 +- tests/benchmark/spamoor/__init__.py | 1 + tests/benchmark/spamoor/helpers.py | 233 ++++++++---------- tests/benchmark/spamoor/test_blob_combined.py | 10 +- tests/benchmark/spamoor/test_calltx.py | 26 +- tests/benchmark/spamoor/test_deploytx.py | 16 +- tests/benchmark/spamoor/test_eoatx.py | 16 +- tests/benchmark/spamoor/test_erc20_bloater.py | 16 +- tests/benchmark/spamoor/test_erc20tx.py | 18 +- tests/benchmark/spamoor/test_evm_fuzz.py | 16 +- .../benchmark/spamoor/test_factorydeploytx.py | 17 +- tests/benchmark/spamoor/test_gasburnertx.py | 10 +- .../benchmark/spamoor/test_storagerefundtx.py | 16 +- tests/benchmark/spamoor/test_storagespam.py | 16 +- tests/benchmark/spamoor/test_uniswap_swaps.py | 10 +- .../test_blob_combined_committed.py | 2 +- .../test_calltx_committed.py | 2 +- .../test_deploytx_committed.py | 2 +- .../test_eoatx_committed.py | 2 +- .../test_erc20_bloater_committed.py | 6 +- .../test_erc20tx_committed.py | 2 +- .../test_evm_fuzz_committed.py | 4 +- .../test_gasburnertx_committed.py | 2 +- .../test_storagerefundtx_committed.py | 2 +- .../test_storagespam_committed.py | 2 +- .../test_uniswap_swaps_committed.py | 2 +- whitelist.txt | 2 + 32 files changed, 314 insertions(+), 207 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/__init__.py index e69de29bb2d..e1717c0702d 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/__init__.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/__init__.py @@ -0,0 +1 @@ +"""Pytest plugin for spamoor scenario tests.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py index d598e9da300..a0ee50c5e7b 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py @@ -1,8 +1,13 @@ +"""Pytest plugin: CLI options and fixtures for spamoor scenarios.""" + +from typing import Any, Callable, Dict, List, Optional + import pytest import requests def pytest_addoption(parser: pytest.Parser) -> None: + """Register ``--spamoor-*`` CLI options under the spamoor group.""" group = parser.getgroup("spamoor", "Spamoor load generation tool options") group.addoption( "--spamoor-endpoint", @@ -196,7 +201,8 @@ def pytest_addoption(parser: pytest.Parser) -> None: dest="spamoor_payload_seed", type=str, default="", - help="Hex seed for evm-fuzz bytecode generator (empty = deterministic default)", + help="Hex seed for evm-fuzz bytecode generator " + "(empty = deterministic default)", ) group.addoption( "--spamoor-tx-id-offset", @@ -252,14 +258,14 @@ def pytest_addoption(parser: pytest.Parser) -> None: dest="spamoor_slots_per_call", type=int, default=500, - help="storagerefundtx: number of slots written+cleared per execute() call", + help="storagerefundtx: slots written+cleared per execute() call", ) group.addoption( "--spamoor-bytecodes", dest="spamoor_bytecodes", type=str, default="", - help="deploytx: comma-separated list of hex bytecodes to cycle through", + help="deploytx: comma-separated hex bytecodes to cycle through", ) group.addoption( "--spamoor-bytecodes-file", @@ -271,13 +277,15 @@ def pytest_addoption(parser: pytest.Parser) -> None: def pytest_configure(config: pytest.Config) -> None: + """Register the ``spamoor`` pytest marker.""" config.addinivalue_line( "markers", "spamoor: Run spamoor load generation tests" ) @pytest.fixture(scope="session") -def spamoor_config(request): +def spamoor_config(request: pytest.FixtureRequest) -> Dict[str, Any]: + """Collect all ``--spamoor-*`` options into a config dict.""" return { "endpoint": request.config.getoption("spamoor_endpoint"), "count": request.config.getoption("spamoor_count"), @@ -309,12 +317,8 @@ def spamoor_config(request): "spamoor_gas_units_to_burn" ), "pair_count": request.config.getoption("spamoor_pair_count"), - "min_swap_amount": request.config.getoption( - "spamoor_min_swap_amount" - ), - "max_swap_amount": request.config.getoption( - "spamoor_max_swap_amount" - ), + "min_swap_amount": request.config.getoption("spamoor_min_swap_amount"), + "max_swap_amount": request.config.getoption("spamoor_max_swap_amount"), "buy_ratio": request.config.getoption("spamoor_buy_ratio"), "slippage": request.config.getoption("spamoor_slippage"), "min_code_size": request.config.getoption("spamoor_min_code_size"), @@ -338,10 +342,13 @@ def spamoor_config(request): @pytest.fixture(scope="session") -def spamoor_rpc_client(spamoor_config): +def spamoor_rpc_client( + spamoor_config: Dict[str, Any], +) -> Callable[[str, List[Any]], Any]: + """Return a minimal JSON-RPC call helper bound to the configured RPC.""" endpoint = spamoor_config["endpoint"] - def rpc_call(method, params): + def rpc_call(method: str, params: List[Any]) -> Optional[Any]: try: payload = { "jsonrpc": "2.0", diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/commit_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/commit_block.py index 31eae043c1e..7d774545431 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/commit_block.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/commit_block.py @@ -50,11 +50,7 @@ def _payload_attributes_for_fork( timestamp=HexNumber(next_timestamp), prev_randao=Hash(0), suggested_fee_recipient=Address(0), - withdrawals=( - [] - if next_fork.header_withdrawals_required() - else None - ), + withdrawals=([] if next_fork.header_withdrawals_required() else None), parent_beacon_block_root=parent_beacon_block_root, target_blobs_per_block=( HexNumber(target_blobs) if target_blobs is not None else None @@ -107,13 +103,9 @@ def commit_via_build( ) new_payload_args: List[Any] = [payload.execution_payload] if payload.blobs_bundle is not None: - new_payload_args.append( - payload.blobs_bundle.blob_versioned_hashes() - ) + new_payload_args.append(payload.blobs_bundle.blob_versioned_hashes()) if payload_attributes.parent_beacon_block_root is not None: - new_payload_args.append( - payload_attributes.parent_beacon_block_root - ) + new_payload_args.append(payload_attributes.parent_beacon_block_root) if payload.execution_requests is not None: new_payload_args.append(payload.execution_requests) @@ -139,9 +131,7 @@ def commit_via_build( None, version=fcu_version, ) - assert ( - fcu_response.payload_status.status == PayloadStatusEnum.VALID - ), ( + assert fcu_response.payload_status.status == PayloadStatusEnum.VALID, ( f"engine_forkchoiceUpdated rejected new head: " f"{fcu_response.payload_status.status}" ) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py index 712ff2d2818..f2756a470a2 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py @@ -150,9 +150,7 @@ def bloat_config(request: pytest.FixtureRequest) -> BloatConfig: jwt_secret=jwt_secret, signer_key=signer_key, chain_id=int(request.config.getoption("bloat_chain_id")), - block_version=int( - request.config.getoption("bloat_block_version") - ), + block_version=int(request.config.getoption("bloat_block_version")), commit_mode=request.config.getoption("bloat_commit_mode"), ) @@ -185,9 +183,7 @@ def bloat_fork(request: pytest.FixtureRequest) -> Fork: @pytest.fixture(scope="session") -def bloat_signer( - bloat_config: BloatConfig, bloat_eth_rpc: EthRPC -) -> EOA: +def bloat_signer(bloat_config: BloatConfig, bloat_eth_rpc: EthRPC) -> EOA: """Return an ``EOA`` seeded with the current on-chain nonce.""" probe = EOA(key=bloat_config.signer_key, nonce=0) try: diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_commit_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_commit_block.py index 7a91561ce17..33092ed6497 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_commit_block.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tests/test_commit_block.py @@ -53,9 +53,7 @@ def test_build_mode_call_order() -> None: def _build(*_args: Any, **_kwargs: Any) -> MagicMock: calls.append("build_block") - return _make_fake_payload( - Hash("0x" + "11" * 32), 101, 1_700_000_001 - ) + return _make_fake_payload(Hash("0x" + "11" * 32), 101, 1_700_000_001) testing_rpc.build_block.side_effect = _build diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py index 715b3d60d09..7e6eb6b9b7a 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/tx_convert.py @@ -78,9 +78,7 @@ def spamoor_dict_to_transaction( if ty == 3: if fork is None: - raise ValueError( - "fork is required for type-3 (blob) transactions" - ) + raise ValueError("fork is required for type-3 (blob) transactions") blob_count = int( tx_dict.get( "blobCount", @@ -90,9 +88,7 @@ def spamoor_dict_to_transaction( blob_objects = [ Blob.from_fork(fork, seed=blob_seed + i) for i in range(blob_count) ] - versioned_hashes = [ - Hash(blob.versioned_hash) for blob in blob_objects - ] + versioned_hashes = [Hash(blob.versioned_hash) for blob in blob_objects] tx_kwargs["max_fee_per_blob_gas"] = HexNumber( int(tx_dict.get("maxFeePerBlobGas", 1)) ) diff --git a/tests/benchmark/spamoor/__init__.py b/tests/benchmark/spamoor/__init__.py index e69de29bb2d..c4b662ab0da 100644 --- a/tests/benchmark/spamoor/__init__.py +++ b/tests/benchmark/spamoor/__init__.py @@ -0,0 +1 @@ +"""Unit tests for spamoor scenario transaction builders.""" diff --git a/tests/benchmark/spamoor/helpers.py b/tests/benchmark/spamoor/helpers.py index 62df2fcd237..7f943c64f02 100644 --- a/tests/benchmark/spamoor/helpers.py +++ b/tests/benchmark/spamoor/helpers.py @@ -1,13 +1,15 @@ -from typing import List, Optional, Callable, Any, Dict +"""Transaction builders for the spamoor scenario unit tests.""" + import hashlib import json +from typing import Any, Callable, Dict, List, Optional, cast try: from eth_abi import encode as eth_abi_encode from eth_utils import keccak except ImportError: - eth_abi_encode = None - keccak = None + eth_abi_encode = cast(Any, None) + keccak = cast(Any, None) def build_eoatx_transactions( @@ -19,6 +21,7 @@ def build_eoatx_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: + """Build ``count`` EOA-to-EOA type-2 transfer transactions.""" if count <= 0: return [] @@ -81,16 +84,16 @@ def build_calltx_transactions( deploy_gas_limit: int = 2000000, rpc_client: Optional[Callable] = None, ) -> List[Dict[str, Any]]: - """Build a list of calltx-like transactions. - - If contract_code is provided, first include a deployment transaction: - type=2, to="", value=0, data=contract_code, gas=deploy_gas_limit - Then append `count` execution transactions: - type=2, to=contract_address or fallback, value=amount, data=call_data, gas=21000 - Nonce handling mirrors build_eoatx_transactions: fetch once if from_addr and - rpc_client provided, and increment nonce for each subsequent tx when nonce is known. """ - + Build a list of calltx-like transactions. + + If contract_code is provided, first include a deployment transaction + (type=2, to="", value=0, data=contract_code, gas=deploy_gas_limit). + Then append ``count`` execution transactions targeting + ``contract_address`` (or a placeholder) with the supplied call_data. + Nonce handling mirrors build_eoatx_transactions: fetch once if + ``from_addr`` and ``rpc_client`` are provided, then increment per tx. + """ if count <= 0: return [] @@ -113,13 +116,17 @@ def build_calltx_transactions( base_fee_per_gas = 1_000_000_000 max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) - max_priority_fee_per_gas = 1_000_000_000 txs: List[Dict[str, Any]] = [] # ABI-encode call_data when not provided but a function signature is given parsed_call_data = call_data - if not parsed_call_data and call_fn_sig and eth_abi_encode and keccak: + if ( + not parsed_call_data + and call_fn_sig + and eth_abi_encode is not None + and keccak is not None + ): import re sig_match = re.match(r"^[^\(]+\((.*)\)$", call_fn_sig) @@ -151,19 +158,10 @@ def build_calltx_transactions( dep_tx["nonce"] = nonce nonce += 1 # increment after using nonce for deployment txs.append(dep_tx) - # MVP: prepare for potential contract constructor ABI encoding (no-op if types unavailable) - parsed_contract_code = contract_code - if ( - parsed_contract_code - and contract_args - and contract_args != "[]" - and eth_abi_encode - ): - # We need the constructor ABI types to properly encode this. - # But for MVP, if we don't have the types, we can't easily encode it. - # Spamoor Go code seems to know the types. Let's assume for MVP we only support raw `contract_code` unless types are known. - # Actually, let's just leave parsed_contract_code as is for now and document it. - pass + # Constructor ABI encoding for contract_args is a potential + # follow-up; for now we only support raw ``contract_code`` and + # leave the field untouched when ``contract_args`` is set. + _ = (contract_args, eth_abi_encode) # Execution transactions target_to = ( @@ -174,7 +172,7 @@ def build_calltx_transactions( # Determine gas to use for execution transactions (default 500000) execution_gas = gas_limit if gas_limit and gas_limit > 0 else 500000 - for i in range(count): + for _i in range(count): tx: Dict[str, Any] = { "type": 2, "to": target_to, @@ -204,7 +202,8 @@ def build_blob_combined_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build EIP-4844 blob transactions mirroring spamoor blob-combined. + """ + Build EIP-4844 blob transactions mirroring spamoor blob-combined. Produces `count` type-3 transactions, each carrying `sidecars` placeholder versioned hashes. Blob sidecar data is left to the @@ -259,6 +258,9 @@ def build_blob_combined_transactions( return txs +_FACTORY_BYTECODE = "0x608060405234801561001057600080fd5b50610365806100206000396000f3fe6080604052600436106100295760003560e01c806310a935281461002e578063cdcb760a14610064575b600080fd5b34801561003a57600080fd5b5061004e6100493660046101db565b610077565b60405161005b91906102d7565b60405180910390f35b61004e6100723660046101fc565b6100ee565b6040516000906100b1907fff0000000000000000000000000000000000000000000000000000000000000090309086908690602001610273565b604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081840301815291905280516020909101209392505050565b600080600084848080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525050825192935088929150506020830134f5915073ffffffffffffffffffffffffffffffffffffffff821661018f576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610186906102f8565b60405180910390fd5b604051869073ffffffffffffffffffffffffffffffffffffffff8416907fb085ff794f342ed78acc7791d067e28a931e614b52476c0305795e1ff0a154bc90600090a350949350505050565b600080604083850312156101ed578182fd5b50508035926020909101359150565b600080600060408486031215610210578081fd5b83359250602084013567ffffffffffffffff8082111561022e578283fd5b818601915086601f830112610241578283fd5b81358181111561024f578384fd5b876020828501011115610260578384fd5b6020830194508093505050509250925092565b7fff0000000000000000000000000000000000000000000000000000000000000094909416845260609290921b7fffffffffffffffffffffffffffffffffffffffff0000000000000000000000001660018401526015830152603582015260550190565b73ffffffffffffffffffffffffffffffffffffffff91909116815260200190565b60208082526011908201527f4465706c6f796d656e74206661696c656400000000000000000000000000000060408201526060019056fea26469706673582212202d3e87dd998c22df28ccb2c934734610461c1e6888114d8003aa51583d65054c64736f6c63430008000033" # noqa: E501 + + def build_factorydeploytx_transactions( count: int, init_code: str, @@ -270,14 +272,15 @@ def build_factorydeploytx_transactions( tip_fee: int = 1_000_000_000, rpc_client: Optional[Callable] = None, ) -> List[Dict[str, Any]]: - """Build factory deployment + deploy(bytes32,bytes) transactions. - - If factory_address is empty, emit a deployment tx with to: "" and data as - the factory bytecode (placeholder if not provided), then target the deployed - factory (mock address used if real receipt is unavailable). - Then emit `count` deploy calls to the factory with salt and init_code. """ + Build factory deployment + deploy(bytes32,bytes) transactions. + If factory_address is empty, emit a deployment tx with ``to=""`` and + ``data`` as the factory bytecode (placeholder if not provided), then + target the deployed factory (mock address used if the real receipt + is unavailable). Then emit ``count`` deploy calls to the factory + with salt and init_code. + """ # Use a mock factory address if none provided target_address = ( factory_address @@ -285,8 +288,6 @@ def build_factorydeploytx_transactions( else "0x2222222222222222222222222222222222222222" ) - FACTORY_BYTECODE = "0x608060405234801561001057600080fd5b50610365806100206000396000f3fe6080604052600436106100295760003560e01c806310a935281461002e578063cdcb760a14610064575b600080fd5b34801561003a57600080fd5b5061004e6100493660046101db565b610077565b60405161005b91906102d7565b60405180910390f35b61004e6100723660046101fc565b6100ee565b6040516000906100b1907fff0000000000000000000000000000000000000000000000000000000000000090309086908690602001610273565b604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081840301815291905280516020909101209392505050565b600080600084848080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525050825192935088929150506020830134f5915073ffffffffffffffffffffffffffffffffffffffff821661018f576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610186906102f8565b60405180910390fd5b604051869073ffffffffffffffffffffffffffffffffffffffff8416907fb085ff794f342ed78acc7791d067e28a931e614b52476c0305795e1ff0a154bc90600090a350949350505050565b600080604083850312156101ed578182fd5b50508035926020909101359150565b600080600060408486031215610210578081fd5b83359250602084013567ffffffffffffffff8082111561022e578283fd5b818601915086601f830112610241578283fd5b81358181111561024f578384fd5b876020828501011115610260578384fd5b6020830194508093505050509250925092565b7fff0000000000000000000000000000000000000000000000000000000000000094909416845260609290921b7fffffffffffffffffffffffffffffffffffffffff0000000000000000000000001660018401526015830152603582015260550190565b73ffffffffffffffffffffffffffffffffffffffff91909116815260200190565b60208082526011908201527f4465706c6f796d656e74206661696c656400000000000000000000000000000060408201526060019056fea26469706673582212202d3e87dd998c22df28ccb2c934734610461c1e6888114d8003aa51583d65054c64736f6c63430008000033" - txs: List[Dict[str, Any]] = [] if factory_address == "": txs.append( @@ -294,7 +295,7 @@ def build_factorydeploytx_transactions( "type": 2, "to": "", "value": 0, - "data": FACTORY_BYTECODE, + "data": _FACTORY_BYTECODE, "gas": deploy_gas_limit, "maxFeePerGas": max_fee_per_gas, "maxPriorityFeePerGas": tip_fee, @@ -363,7 +364,9 @@ def _to_bytes32(n: int) -> bytes: # PUSH1 len, PUSH1 runtime_offset, PUSH1 0 (mem), CODECOPY, # PUSH1 len, PUSH1 0, RETURN # With a 4-byte runtime, runtime offset = 12. -_GAS_BURNER_INIT = bytes.fromhex("6004600c60003960046000f3") + _GAS_BURNER_RUNTIME +_GAS_BURNER_INIT = ( + bytes.fromhex("6004600c60003960046000f3") + _GAS_BURNER_RUNTIME +) _GAS_BURNER_CONTRACT_HEX = "0x" + _GAS_BURNER_INIT.hex() _GAS_BURNER_PLACEHOLDER_ADDR = "0x3333333333333333333333333333333333333333" @@ -380,7 +383,8 @@ def build_gasburnertx_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build gasburnertx deployment + execution transactions. + """ + Build gasburnertx deployment + execution transactions. Mirrors the Go scenario: one dynamic-fee deploy tx for the gas-burner contract, followed by *count* dynamic-fee execution txs each carrying @@ -449,10 +453,16 @@ def build_gasburnertx_transactions( return txs -# Uniswap V2 Router02 selectors (well-known; first 4 bytes of keccak(signature)). -_SWAP_EXACT_TOKENS_FOR_TOKENS = "38ed1739" # (uint256,uint256,address[],address,uint256) -_SWAP_EXACT_ETH_FOR_TOKENS = "7ff36ab5" # payable; (uint256,address[],address,uint256) -_SWAP_EXACT_TOKENS_FOR_ETH = "18cbafe5" # (uint256,uint256,address[],address,uint256) +# Uniswap V2 Router02 selectors (first 4 bytes of keccak(signature)). +_SWAP_EXACT_TOKENS_FOR_TOKENS = ( + "38ed1739" # (uint256,uint256,address[],address,uint256) +) +_SWAP_EXACT_ETH_FOR_TOKENS = ( + "7ff36ab5" # payable; (uint256,address[],address,uint256) +) +_SWAP_EXACT_TOKENS_FOR_ETH = ( + "18cbafe5" # (uint256,uint256,address[],address,uint256) +) _UNISWAP_ROUTER_ADDR = "0x4444444444444444444444444444444444444444" _UNISWAP_WETH_ADDR = "0x5555555555555555555555555555555555555555" @@ -468,7 +478,8 @@ def _encode_uniswap_swap_call( recipient: str, deadline: int, ) -> tuple[str, int]: - """Return (hex call_data, tx_value_wei) for a router swap call. + """ + Return (hex call_data, tx_value_wei) for a router swap call. variant 0 → swapExactTokensForTokens (value=0) variant 1 → swapExactETHForTokens (value=amount_in, payable) @@ -522,7 +533,8 @@ def build_uniswap_swaps_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build uniswap-swaps execution transactions. + """ + Build uniswap-swaps execution transactions. Deterministic port of the Go scenario's action mix: per tx index we alternate between *buy-with-ETH*, *buy-with-WETH*, and *sell-tokens*, @@ -562,9 +574,7 @@ def build_uniswap_swaps_transactions( # counts still produce a mix. e.g. count=5, buy_ratio=40 → buys={0,2} # (2 buys), sells={1,3,4}. Rounding keeps at least 1 of each when # 0 < buy_ratio < 100 and count >= 2. - buy_count_target = max( - 0, min(count, (count * int(buy_ratio) + 50) // 100) - ) + buy_count_target = max(0, min(count, (count * int(buy_ratio) + 50) // 100)) if 0 < int(buy_ratio) < 100 and count >= 2: buy_count_target = max(1, min(count - 1, buy_count_target)) buy_stride = count / buy_count_target if buy_count_target else float("inf") @@ -573,9 +583,8 @@ def build_uniswap_swaps_transactions( txs: List[Dict[str, Any]] = [] for i in range(count): # Interleave buys by stride so they're spread across the batch. - want_buy = ( - buys_issued < buy_count_target - and i >= int(buys_issued * buy_stride) + want_buy = buys_issued < buy_count_target and i >= int( + buys_issued * buy_stride ) if want_buy: variant = 0 if (buys_issued % 2 == 0) else 1 @@ -616,7 +625,8 @@ def build_uniswap_swaps_transactions( def _evm_fuzz_bytecode( tx_id: int, seed_hex: str, min_size: int, max_size: int, mode: str ) -> bytes: - """Deterministically derive fuzz bytecode for *tx_id*. + """ + Deterministically derive fuzz bytecode for *tx_id*. Rather than porting the full Go opcode generator, we expand the seed + tx_id + mode through SHA-256 counters until we have enough @@ -626,12 +636,18 @@ def _evm_fuzz_bytecode( """ if max_size < min_size: max_size = min_size - seed_bytes = bytes.fromhex(seed_hex[2:] if seed_hex.startswith("0x") else seed_hex) if seed_hex else b"" + seed_bytes = ( + bytes.fromhex(seed_hex[2:] if seed_hex.startswith("0x") else seed_hex) + if seed_hex + else b"" + ) # Size is deterministic per tx within [min_size, max_size]. size_span = max_size - min_size + 1 size = min_size + ( int.from_bytes( - hashlib.sha256(seed_bytes + tx_id.to_bytes(8, "big") + b"size").digest()[:4], + hashlib.sha256( + seed_bytes + tx_id.to_bytes(8, "big") + b"size" + ).digest()[:4], "big", ) % size_span @@ -666,7 +682,8 @@ def build_evm_fuzz_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build evm-fuzz contract creation transactions. + """ + Build evm-fuzz contract creation transactions. Each tx is a type-2 contract creation (``to == ""``) carrying deterministic pseudo-random bytes as init code. 25% of txs carry @@ -709,7 +726,7 @@ def build_evm_fuzz_transactions( value = 0 else: value_span = 0x6000 - value = 0xa000 + ( + value = 0xA000 + ( int.from_bytes( hashlib.sha256( seed.encode() + tx_id.to_bytes(8, "big") + b"val" @@ -761,7 +778,8 @@ def build_erc20tx_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build erc20tx deployment + transferMint execution transactions. + """ + Build erc20tx deployment + transferMint execution transactions. If *contract_code* is provided, the first tx deploys it (to="", data=code). Then *count* transferMint calls follow, targeting either @@ -772,20 +790,14 @@ def build_erc20tx_transactions( nonce = None if from_addr and rpc_client: - resp = rpc_client( - "eth_getTransactionCount", [from_addr, "pending"] - ) + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) if isinstance(resp, str) and resp.startswith("0x"): nonce = int(resp, 16) base_fee_per_gas = basefee if base_fee_per_gas is None and rpc_client: bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) - if ( - bf_resp - and "baseFeePerGas" in bf_resp - and bf_resp["baseFeePerGas"] - ): + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: try: base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) except ValueError: @@ -817,22 +829,22 @@ def build_erc20tx_transactions( for i in range(count): if random_target: - recipient = "0x" + hashlib.sha256( - b"erc20tx:target:" + i.to_bytes(8, "big") - ).digest()[:20].hex() + recipient = ( + "0x" + + hashlib.sha256(b"erc20tx:target:" + i.to_bytes(8, "big")) + .digest()[:20] + .hex() + ) else: recipient = _erc20_recipient_for_idx(i) if random_amount: - transfer_amount = ( - int.from_bytes( - hashlib.sha256( - b"erc20tx:amount:" + i.to_bytes(8, "big") - ).digest()[:8], - "big", - ) - % max(int(amount), 1) - ) + transfer_amount = int.from_bytes( + hashlib.sha256( + b"erc20tx:amount:" + i.to_bytes(8, "big") + ).digest()[:8], + "big", + ) % max(int(amount), 1) else: transfer_amount = int(amount) @@ -883,7 +895,8 @@ def build_storagespam_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build storagespam deployment + setRandomForGas execution transactions. + """ + Build storagespam deployment + setRandomForGas execution transactions. Unless *reuse_contract* is True, a deployment tx is emitted first with *contract_code* (a minimal placeholder when not provided). Each of @@ -896,20 +909,14 @@ def build_storagespam_transactions( nonce = None if from_addr and rpc_client: - resp = rpc_client( - "eth_getTransactionCount", [from_addr, "pending"] - ) + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) if isinstance(resp, str) and resp.startswith("0x"): nonce = int(resp, 16) base_fee_per_gas = basefee if base_fee_per_gas is None and rpc_client: bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) - if ( - bf_resp - and "baseFeePerGas" in bf_resp - and bf_resp["baseFeePerGas"] - ): + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: try: base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) except ValueError: @@ -923,9 +930,7 @@ def build_storagespam_transactions( # Inline minimal init code that returns a tiny runtime (so committed # txs targeting the CREATE address still see a contract; the real # StorageSpam.sol bytecode isn't bundled here). - stub_init_hex = ( - "0x6004600c60003960046000f35b600056" - ) + stub_init_hex = "0x6004600c60003960046000f35b600056" if not reuse_contract: deploy_code = contract_code or stub_init_hex @@ -999,7 +1004,8 @@ def build_erc20_bloater_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build erc20_bloater deployment + bloatStorage execution transactions. + """ + Build erc20_bloater deployment + bloatStorage execution transactions. When *contract_code* is provided (or the default placeholder is used) the first tx deploys the contract. Each of the *count* execution @@ -1012,20 +1018,14 @@ def build_erc20_bloater_transactions( nonce = None if from_addr and rpc_client: - resp = rpc_client( - "eth_getTransactionCount", [from_addr, "pending"] - ) + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) if isinstance(resp, str) and resp.startswith("0x"): nonce = int(resp, 16) base_fee_per_gas = basefee if base_fee_per_gas is None and rpc_client: bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) - if ( - bf_resp - and "baseFeePerGas" in bf_resp - and bf_resp["baseFeePerGas"] - ): + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: try: base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) except ValueError: @@ -1067,10 +1067,7 @@ def build_erc20_bloater_transactions( start_word = idx.to_bytes(32, "big") num_word = step.to_bytes(32, "big") call_data = ( - "0x" - + _BLOAT_STORAGE_SELECTOR - + start_word.hex() - + num_word.hex() + "0x" + _BLOAT_STORAGE_SELECTOR + start_word.hex() + num_word.hex() ) tx: Dict[str, Any] = { "type": 2, @@ -1116,7 +1113,8 @@ def build_storagerefundtx_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build storagerefundtx deployment + execute(uint256) calls. + """ + Build storagerefundtx deployment + execute(uint256) calls. Unless *contract_address* is supplied, the first tx deploys a stub (or user-provided *contract_code*) so the batch is self-contained. @@ -1128,20 +1126,14 @@ def build_storagerefundtx_transactions( nonce = None if from_addr and rpc_client: - resp = rpc_client( - "eth_getTransactionCount", [from_addr, "pending"] - ) + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) if isinstance(resp, str) and resp.startswith("0x"): nonce = int(resp, 16) base_fee_per_gas = basefee if base_fee_per_gas is None and rpc_client: bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) - if ( - bf_resp - and "baseFeePerGas" in bf_resp - and bf_resp["baseFeePerGas"] - ): + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: try: base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) except ValueError: @@ -1178,9 +1170,7 @@ def build_storagerefundtx_transactions( target = contract_address or _STORAGE_REFUND_PLACEHOLDER_ADDR slots_word = int(slots_per_call).to_bytes(32, "big") - call_data = ( - "0x" + _STORAGE_REFUND_EXECUTE_SELECTOR + slots_word.hex() - ) + call_data = "0x" + _STORAGE_REFUND_EXECUTE_SELECTOR + slots_word.hex() for _ in range(count): tx: Dict[str, Any] = { @@ -1249,7 +1239,8 @@ def build_deploytx_transactions( private_key: Optional[str] = None, rpc_client: Optional[Callable[[str, List[Any]], Any]] = None, ) -> List[Dict[str, Any]]: - """Build N deployment transactions cycling through *bytecodes*. + """ + Build N deployment transactions cycling through *bytecodes*. Mirrors the Go deploytx scenario: every tx is type 2 with ``to == ""`` (contract creation); the init code cycles through the @@ -1260,20 +1251,14 @@ def build_deploytx_transactions( nonce = None if from_addr and rpc_client: - resp = rpc_client( - "eth_getTransactionCount", [from_addr, "pending"] - ) + resp = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) if isinstance(resp, str) and resp.startswith("0x"): nonce = int(resp, 16) base_fee_per_gas = basefee if base_fee_per_gas is None and rpc_client: bf_resp = rpc_client("eth_feeHistory", ["0x1", "latest", []]) - if ( - bf_resp - and "baseFeePerGas" in bf_resp - and bf_resp["baseFeePerGas"] - ): + if bf_resp and "baseFeePerGas" in bf_resp and bf_resp["baseFeePerGas"]: try: base_fee_per_gas = int(bf_resp["baseFeePerGas"][-1], 16) except ValueError: diff --git a/tests/benchmark/spamoor/test_blob_combined.py b/tests/benchmark/spamoor/test_blob_combined.py index e1c400cf1f0..fd79d6b366a 100644 --- a/tests/benchmark/spamoor/test_blob_combined.py +++ b/tests/benchmark/spamoor/test_blob_combined.py @@ -1,10 +1,18 @@ +"""Tests for build_blob_combined_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_blob_combined_transactions @pytest.mark.spamoor -def test_blob_combined_scenario(spamoor_config, spamoor_rpc_client): +def test_blob_combined_scenario( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_blob_combined_scenario.""" txs = build_blob_combined_transactions( count=spamoor_config["count"], sidecars=spamoor_config["sidecars"], diff --git a/tests/benchmark/spamoor/test_calltx.py b/tests/benchmark/spamoor/test_calltx.py index e15af226438..267def5b4ec 100644 --- a/tests/benchmark/spamoor/test_calltx.py +++ b/tests/benchmark/spamoor/test_calltx.py @@ -1,9 +1,18 @@ +"""Tests for build_calltx_transactions.""" + +from typing import Any, Callable, Dict + import pytest + from .helpers import build_calltx_transactions @pytest.mark.spamoor -def test_calltx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): +def test_calltx_scenario_with_deploy( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_calltx_scenario_with_deploy.""" txs = build_calltx_transactions( count=spamoor_config["count"], throughput=spamoor_config["throughput"], @@ -23,7 +32,8 @@ def test_calltx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): rpc_client=spamoor_rpc_client, ) - # If contract_code is provided, the first tx is deployment, followed by `count` execution txs + # With contract_code set, the first tx is deployment, + # followed by `count` execution txs. assert len(txs) == spamoor_config["count"] + 1 # Check deployment tx @@ -37,7 +47,7 @@ def test_calltx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): spamoor_config.get("contract_address") or "0x1111111111111111111111111111111111111111" ) - # Gas assertion updated to use new gas_limit/configured value when provided + # Use the gas_limit override when provided. expected_gas = ( spamoor_config.get("gas_limit") if spamoor_config.get("gas_limit") @@ -47,7 +57,11 @@ def test_calltx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): @pytest.mark.spamoor -def test_calltx_scenario_no_deploy(spamoor_config, spamoor_rpc_client): +def test_calltx_scenario_no_deploy( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_calltx_scenario_no_deploy.""" txs = build_calltx_transactions( count=spamoor_config["count"], throughput=spamoor_config["throughput"], @@ -57,7 +71,7 @@ def test_calltx_scenario_no_deploy(spamoor_config, spamoor_rpc_client): private_key=spamoor_config["private_key"], contract_code=None, contract_address=spamoor_config.get("contract_address"), - call_data=spamoor_config.get("call_data", "0x1234"), + call_data=spamoor_config.get("call_data") or "0x1234", deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), call_fn_sig=spamoor_config.get("call_fn_sig", ""), call_args=spamoor_config.get("call_args", "[]"), @@ -77,7 +91,7 @@ def test_calltx_scenario_no_deploy(spamoor_config, spamoor_rpc_client): or "0x1111111111111111111111111111111111111111" ) assert txs[0]["data"] == "0x1234" - # Gas assertion updated to use new gas_limit/configured value when provided + # Use the gas_limit override when provided. expected_gas = ( spamoor_config.get("gas_limit") if spamoor_config.get("gas_limit") diff --git a/tests/benchmark/spamoor/test_deploytx.py b/tests/benchmark/spamoor/test_deploytx.py index 361016aa16c..7203ed58321 100644 --- a/tests/benchmark/spamoor/test_deploytx.py +++ b/tests/benchmark/spamoor/test_deploytx.py @@ -1,10 +1,18 @@ +"""Tests for build_deploytx_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_deploytx_transactions @pytest.mark.spamoor -def test_deploytx_default_bytecode(spamoor_config, spamoor_rpc_client): +def test_deploytx_default_bytecode( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_deploytx_default_bytecode.""" txs = build_deploytx_transactions( count=spamoor_config["count"], bytecodes=spamoor_config["bytecodes"], @@ -43,7 +51,11 @@ def test_deploytx_default_bytecode(spamoor_config, spamoor_rpc_client): @pytest.mark.spamoor -def test_deploytx_cycles_bytecode_list(spamoor_config, spamoor_rpc_client): +def test_deploytx_cycles_bytecode_list( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_deploytx_cycles_bytecode_list.""" # Force a two-entry list so we can check cycling even when the CLI # doesn't provide bytecodes. txs = build_deploytx_transactions( diff --git a/tests/benchmark/spamoor/test_eoatx.py b/tests/benchmark/spamoor/test_eoatx.py index 4ab56df13be..4711e8bc9d1 100644 --- a/tests/benchmark/spamoor/test_eoatx.py +++ b/tests/benchmark/spamoor/test_eoatx.py @@ -1,8 +1,18 @@ +"""Tests for build_eoatx_transactions.""" + +from typing import Any, Callable, Dict + import pytest + from .helpers import build_eoatx_transactions + @pytest.mark.spamoor -def test_eoatx_scenario(spamoor_config, spamoor_rpc_client): +def test_eoatx_scenario( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_eoatx_scenario.""" txs = build_eoatx_transactions( count=spamoor_config["count"], throughput=spamoor_config["throughput"], @@ -10,9 +20,9 @@ def test_eoatx_scenario(spamoor_config, spamoor_rpc_client): basefee=spamoor_config["basefee"], from_addr=spamoor_config["from_addr"], private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client + rpc_client=spamoor_rpc_client, ) - + assert len(txs) == spamoor_config["count"] if spamoor_config["count"] > 0: assert txs[0]["type"] == 2 diff --git a/tests/benchmark/spamoor/test_erc20_bloater.py b/tests/benchmark/spamoor/test_erc20_bloater.py index 46a49543825..a24dd99d9db 100644 --- a/tests/benchmark/spamoor/test_erc20_bloater.py +++ b/tests/benchmark/spamoor/test_erc20_bloater.py @@ -1,10 +1,18 @@ +"""Tests for build_erc20_bloater_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_erc20_bloater_transactions @pytest.mark.spamoor -def test_erc20_bloater_scenario_with_deploy(spamoor_config, spamoor_rpc_client): +def test_erc20_bloater_scenario_with_deploy( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_erc20_bloater_scenario_with_deploy.""" txs = build_erc20_bloater_transactions( count=spamoor_config["count"], addresses_per_tx=spamoor_config["addresses_per_tx"], @@ -55,7 +63,11 @@ def test_erc20_bloater_scenario_with_deploy(spamoor_config, spamoor_rpc_client): @pytest.mark.spamoor -def test_erc20_bloater_scenario_existing_contract(spamoor_config, spamoor_rpc_client): +def test_erc20_bloater_scenario_existing_contract( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_erc20_bloater_scenario_existing_contract.""" txs = build_erc20_bloater_transactions( count=spamoor_config["count"], addresses_per_tx=spamoor_config["addresses_per_tx"], diff --git a/tests/benchmark/spamoor/test_erc20tx.py b/tests/benchmark/spamoor/test_erc20tx.py index b63dd916c8f..a03370b7e00 100644 --- a/tests/benchmark/spamoor/test_erc20tx.py +++ b/tests/benchmark/spamoor/test_erc20tx.py @@ -1,10 +1,18 @@ +"""Tests for build_erc20tx_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_erc20tx_transactions @pytest.mark.spamoor -def test_erc20tx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): +def test_erc20tx_scenario_with_deploy( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_erc20tx_scenario_with_deploy.""" txs = build_erc20tx_transactions( count=spamoor_config["count"], amount=spamoor_config["amount"], @@ -39,13 +47,17 @@ def test_erc20tx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): ) assert exec_tx["value"] == 0 assert exec_tx["gas"] == 100_000 - # selector(4) + address(32) + uint256(32) = 68 bytes => 136 hex chars + "0x". + # selector(4) + address(32) + uint256(32) = 68 bytes. assert len(exec_tx["data"]) == 2 + 2 * 68 assert exec_tx["data"].startswith("0x9d0f7cba") @pytest.mark.spamoor -def test_erc20tx_scenario_no_deploy(spamoor_config, spamoor_rpc_client): +def test_erc20tx_scenario_no_deploy( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_erc20tx_scenario_no_deploy.""" txs = build_erc20tx_transactions( count=spamoor_config["count"], amount=spamoor_config["amount"], diff --git a/tests/benchmark/spamoor/test_evm_fuzz.py b/tests/benchmark/spamoor/test_evm_fuzz.py index 56f90b1a99c..83b75191271 100644 --- a/tests/benchmark/spamoor/test_evm_fuzz.py +++ b/tests/benchmark/spamoor/test_evm_fuzz.py @@ -1,10 +1,18 @@ +"""Tests for build_evm_fuzz_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_evm_fuzz_transactions @pytest.mark.spamoor -def test_evm_fuzz_scenario(spamoor_config, spamoor_rpc_client): +def test_evm_fuzz_scenario( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_evm_fuzz_scenario.""" txs = build_evm_fuzz_transactions( count=spamoor_config["count"], gas_limit=spamoor_config["gas_limit"], @@ -53,7 +61,11 @@ def test_evm_fuzz_scenario(spamoor_config, spamoor_rpc_client): @pytest.mark.spamoor -def test_evm_fuzz_is_deterministic(spamoor_config, spamoor_rpc_client): +def test_evm_fuzz_is_deterministic( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_evm_fuzz_is_deterministic.""" kwargs = dict( count=max(2, spamoor_config["count"]), gas_limit=spamoor_config["gas_limit"], diff --git a/tests/benchmark/spamoor/test_factorydeploytx.py b/tests/benchmark/spamoor/test_factorydeploytx.py index d21054c4cf6..86979190788 100644 --- a/tests/benchmark/spamoor/test_factorydeploytx.py +++ b/tests/benchmark/spamoor/test_factorydeploytx.py @@ -1,11 +1,18 @@ +"""Tests for build_factorydeploytx_transactions.""" + +from typing import Any, Callable, Dict + import pytest + from .helpers import build_factorydeploytx_transactions @pytest.mark.spamoor def test_factorydeploytx_scenario_with_deploy( - spamoor_config, spamoor_rpc_client -): + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_factorydeploytx_scenario_with_deploy.""" txs = build_factorydeploytx_transactions( count=spamoor_config.get("count", 10), init_code=spamoor_config.get("init_code", "0x1234"), @@ -33,8 +40,10 @@ def test_factorydeploytx_scenario_with_deploy( @pytest.mark.spamoor def test_factorydeploytx_scenario_no_deploy( - spamoor_config, spamoor_rpc_client -): + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_factorydeploytx_scenario_no_deploy.""" txs = build_factorydeploytx_transactions( count=spamoor_config.get("count", 10), init_code=spamoor_config.get("init_code", "0x1234"), diff --git a/tests/benchmark/spamoor/test_gasburnertx.py b/tests/benchmark/spamoor/test_gasburnertx.py index b1c24546bc1..bf127f0cf60 100644 --- a/tests/benchmark/spamoor/test_gasburnertx.py +++ b/tests/benchmark/spamoor/test_gasburnertx.py @@ -1,10 +1,18 @@ +"""Tests for build_gasburnertx_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_gasburnertx_transactions @pytest.mark.spamoor -def test_gasburnertx_scenario(spamoor_config, spamoor_rpc_client): +def test_gasburnertx_scenario( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_gasburnertx_scenario.""" txs = build_gasburnertx_transactions( count=spamoor_config["count"], gas_units_to_burn=spamoor_config["gas_units_to_burn"], diff --git a/tests/benchmark/spamoor/test_storagerefundtx.py b/tests/benchmark/spamoor/test_storagerefundtx.py index 9fe8c9f73b3..eea934e6e15 100644 --- a/tests/benchmark/spamoor/test_storagerefundtx.py +++ b/tests/benchmark/spamoor/test_storagerefundtx.py @@ -1,10 +1,18 @@ +"""Tests for build_storagerefundtx_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_storagerefundtx_transactions @pytest.mark.spamoor -def test_storagerefundtx_scenario_with_deploy(spamoor_config, spamoor_rpc_client): +def test_storagerefundtx_scenario_with_deploy( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_storagerefundtx_scenario_with_deploy.""" txs = build_storagerefundtx_transactions( count=spamoor_config["count"], slots_per_call=spamoor_config["slots_per_call"], @@ -46,7 +54,11 @@ def test_storagerefundtx_scenario_with_deploy(spamoor_config, spamoor_rpc_client @pytest.mark.spamoor -def test_storagerefundtx_scenario_existing_contract(spamoor_config, spamoor_rpc_client): +def test_storagerefundtx_scenario_existing_contract( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_storagerefundtx_scenario_existing_contract.""" txs = build_storagerefundtx_transactions( count=spamoor_config["count"], slots_per_call=spamoor_config["slots_per_call"], diff --git a/tests/benchmark/spamoor/test_storagespam.py b/tests/benchmark/spamoor/test_storagespam.py index d48163b2996..18cb3df5381 100644 --- a/tests/benchmark/spamoor/test_storagespam.py +++ b/tests/benchmark/spamoor/test_storagespam.py @@ -1,10 +1,18 @@ +"""Tests for build_storagespam_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_storagespam_transactions @pytest.mark.spamoor -def test_storagespam_scenario_with_deploy(spamoor_config, spamoor_rpc_client): +def test_storagespam_scenario_with_deploy( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_storagespam_scenario_with_deploy.""" txs = build_storagespam_transactions( count=spamoor_config["count"], gas_units_to_burn=spamoor_config["gas_units_to_burn"], @@ -47,7 +55,11 @@ def test_storagespam_scenario_with_deploy(spamoor_config, spamoor_rpc_client): @pytest.mark.spamoor -def test_storagespam_scenario_reuse_contract(spamoor_config, spamoor_rpc_client): +def test_storagespam_scenario_reuse_contract( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_storagespam_scenario_reuse_contract.""" txs = build_storagespam_transactions( count=spamoor_config["count"], gas_units_to_burn=spamoor_config["gas_units_to_burn"], diff --git a/tests/benchmark/spamoor/test_uniswap_swaps.py b/tests/benchmark/spamoor/test_uniswap_swaps.py index 4f7fd231820..ec19a1dc345 100644 --- a/tests/benchmark/spamoor/test_uniswap_swaps.py +++ b/tests/benchmark/spamoor/test_uniswap_swaps.py @@ -1,10 +1,18 @@ +"""Tests for build_uniswap_swaps_transactions.""" + +from typing import Any, Callable, Dict + import pytest from .helpers import build_uniswap_swaps_transactions @pytest.mark.spamoor -def test_uniswap_swaps_scenario(spamoor_config, spamoor_rpc_client): +def test_uniswap_swaps_scenario( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], +) -> None: + """Exercise test_uniswap_swaps_scenario.""" txs = build_uniswap_swaps_transactions( count=spamoor_config["count"], pair_count=spamoor_config["pair_count"], diff --git a/tests/benchmark/testing_build_block/test_blob_combined_committed.py b/tests/benchmark/testing_build_block/test_blob_combined_committed.py index af1113f2e0d..4a10bee77cb 100644 --- a/tests/benchmark/testing_build_block/test_blob_combined_committed.py +++ b/tests/benchmark/testing_build_block/test_blob_combined_committed.py @@ -34,7 +34,7 @@ def test_blob_combined_committed( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], blob_fee=spamoor_config["blob_fee"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_calltx_committed.py b/tests/benchmark/testing_build_block/test_calltx_committed.py index 650d8842e26..15d19f14db6 100644 --- a/tests/benchmark/testing_build_block/test_calltx_committed.py +++ b/tests/benchmark/testing_build_block/test_calltx_committed.py @@ -31,7 +31,7 @@ def test_calltx_committed( throughput=spamoor_config["throughput"], amount=spamoor_config["amount"], basefee=spamoor_config["basefee"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, contract_code=spamoor_config["contract_code"], contract_address=spamoor_config["contract_address"], diff --git a/tests/benchmark/testing_build_block/test_deploytx_committed.py b/tests/benchmark/testing_build_block/test_deploytx_committed.py index 01128a62d3f..1c8d52872cb 100644 --- a/tests/benchmark/testing_build_block/test_deploytx_committed.py +++ b/tests/benchmark/testing_build_block/test_deploytx_committed.py @@ -34,7 +34,7 @@ def test_deploytx_committed( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_eoatx_committed.py b/tests/benchmark/testing_build_block/test_eoatx_committed.py index 33571b7d7ee..9cf0c3a9447 100644 --- a/tests/benchmark/testing_build_block/test_eoatx_committed.py +++ b/tests/benchmark/testing_build_block/test_eoatx_committed.py @@ -31,7 +31,7 @@ def test_eoatx_committed( throughput=spamoor_config["throughput"], amount=spamoor_config["amount"], basefee=spamoor_config["basefee"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_erc20_bloater_committed.py b/tests/benchmark/testing_build_block/test_erc20_bloater_committed.py index f2b5833946a..131007f87fb 100644 --- a/tests/benchmark/testing_build_block/test_erc20_bloater_committed.py +++ b/tests/benchmark/testing_build_block/test_erc20_bloater_committed.py @@ -28,7 +28,9 @@ def test_erc20_bloater_committed( """Deploy the erc20_bloater stub and commit bloatStorage calls.""" # Cap bloat gas so N+1 txs fit inside the ~30M block gas limit. effective_gas_limit = ( - spamoor_config["gas_limit"] if spamoor_config["gas_limit"] else 3_000_000 + spamoor_config["gas_limit"] + if spamoor_config["gas_limit"] + else 3_000_000 ) raw_txs = build_erc20_bloater_transactions( @@ -42,7 +44,7 @@ def test_erc20_bloater_committed( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_erc20tx_committed.py b/tests/benchmark/testing_build_block/test_erc20tx_committed.py index cd4a4a1034c..442223b6b34 100644 --- a/tests/benchmark/testing_build_block/test_erc20tx_committed.py +++ b/tests/benchmark/testing_build_block/test_erc20tx_committed.py @@ -38,7 +38,7 @@ def test_erc20tx_committed( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_evm_fuzz_committed.py b/tests/benchmark/testing_build_block/test_evm_fuzz_committed.py index b46b8f39232..de84a11f3f3 100644 --- a/tests/benchmark/testing_build_block/test_evm_fuzz_committed.py +++ b/tests/benchmark/testing_build_block/test_evm_fuzz_committed.py @@ -25,7 +25,7 @@ def test_evm_fuzz_committed( bloat_eth_rpc: EthRPC, bloat_commit_block: Callable[[Sequence[Transaction]], Hash], ) -> None: - """Commit a batch of fuzz-bytecode contract creations against the lab node.""" + """Commit a batch of fuzz-bytecode contract creations to the lab node.""" raw_txs = build_evm_fuzz_transactions( count=spamoor_config["count"], gas_limit=spamoor_config["gas_limit"], @@ -37,7 +37,7 @@ def test_evm_fuzz_committed( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_gasburnertx_committed.py b/tests/benchmark/testing_build_block/test_gasburnertx_committed.py index cb613c20c03..05d1839c77c 100644 --- a/tests/benchmark/testing_build_block/test_gasburnertx_committed.py +++ b/tests/benchmark/testing_build_block/test_gasburnertx_committed.py @@ -34,7 +34,7 @@ def test_gasburnertx_committed( throughput=spamoor_config["throughput"], deploy_gas_limit=spamoor_config["deploy_gas_limit"], contract_address=spamoor_config["contract_address"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_storagerefundtx_committed.py b/tests/benchmark/testing_build_block/test_storagerefundtx_committed.py index 67decf603ed..526992956a0 100644 --- a/tests/benchmark/testing_build_block/test_storagerefundtx_committed.py +++ b/tests/benchmark/testing_build_block/test_storagerefundtx_committed.py @@ -38,7 +38,7 @@ def test_storagerefundtx_committed( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_storagespam_committed.py b/tests/benchmark/testing_build_block/test_storagespam_committed.py index a9ee68ef29f..5ed5f9ff1ee 100644 --- a/tests/benchmark/testing_build_block/test_storagespam_committed.py +++ b/tests/benchmark/testing_build_block/test_storagespam_committed.py @@ -36,7 +36,7 @@ def test_storagespam_committed( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/tests/benchmark/testing_build_block/test_uniswap_swaps_committed.py b/tests/benchmark/testing_build_block/test_uniswap_swaps_committed.py index 87a1ccb8e4f..a140191a69b 100644 --- a/tests/benchmark/testing_build_block/test_uniswap_swaps_committed.py +++ b/tests/benchmark/testing_build_block/test_uniswap_swaps_committed.py @@ -36,7 +36,7 @@ def test_uniswap_swaps_committed( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=bloat_signer, + from_addr=str(bloat_signer), private_key=bloat_config.signer_key, rpc_client=None, ) diff --git a/whitelist.txt b/whitelist.txt index 0c73495052e..4c52b396d4a 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -114,6 +114,7 @@ ae AF af alloc +amountIn AnnAssign api apis @@ -1310,6 +1311,7 @@ wordlist Words2 workspaces writelines +ws www x0 From c22e288f01af1f10f1ccd99afe761154bd808724 Mon Sep 17 00:00:00 2001 From: Dmytro Biloshytskyi Date: Thu, 23 Apr 2026 13:28:01 +0300 Subject: [PATCH 06/11] Updated spamoor tests --- tests/benchmark/spamoor/helpers.py | 142 ++++++++++++++++++ tests/benchmark/spamoor/test_blob_combined.py | 25 ++- tests/benchmark/spamoor/test_calltx.py | 54 +++---- tests/benchmark/spamoor/test_deploytx.py | 29 ++-- tests/benchmark/spamoor/test_eoatx.py | 25 +-- tests/benchmark/spamoor/test_erc20_bloater.py | 51 +++---- tests/benchmark/spamoor/test_erc20tx.py | 41 +++-- tests/benchmark/spamoor/test_evm_fuzz.py | 41 ++--- .../benchmark/spamoor/test_factorydeploytx.py | 54 ++++--- tests/benchmark/spamoor/test_gasburnertx.py | 37 ++--- .../benchmark/spamoor/test_storagerefundtx.py | 43 +++--- tests/benchmark/spamoor/test_storagespam.py | 44 +++--- tests/benchmark/spamoor/test_uniswap_swaps.py | 43 ++---- 13 files changed, 345 insertions(+), 284 deletions(-) diff --git a/tests/benchmark/spamoor/helpers.py b/tests/benchmark/spamoor/helpers.py index 7f943c64f02..ff4420c9ad3 100644 --- a/tests/benchmark/spamoor/helpers.py +++ b/tests/benchmark/spamoor/helpers.py @@ -2,6 +2,7 @@ import hashlib import json +import time from typing import Any, Callable, Dict, List, Optional, cast try: @@ -12,6 +13,147 @@ keccak = cast(Any, None) +# --- Broadcast helpers shared by the spamoor tests --------------------------- + + +def _normalize_fees(tx_dict: Dict[str, Any]) -> None: + """ + Clamp ``maxFeePerGas`` to satisfy EIP-1559 validity. + + Builders scale basefee by throughput to pick ``maxFeePerGas``, which + can fall below ``maxPriorityFeePerGas`` on low-basefee devnets. Raise + ``maxFeePerGas`` so the tx is accepted by the node. + """ + if tx_dict.get("type") not in (2, 3): + return + max_fee = int(tx_dict.get("maxFeePerGas", 0)) + priority = int(tx_dict.get("maxPriorityFeePerGas", 0)) + if max_fee < priority * 2: + tx_dict["maxFeePerGas"] = priority * 2 + + +def spamoor_signer_context( + spamoor_config: Dict[str, Any], + rpc_client: Optional[Callable[[str, List[Any]], Any]], +) -> Dict[str, Any]: + """ + Skip-or-return signing context for a spamoor broadcast test. + + Calls ``pytest.skip`` when the private key / sender / endpoint are + unavailable. Otherwise returns ``{signer, chain_id, start_nonce}``. + """ + import pytest + from execution_testing.test_types import EOA + + private_key = spamoor_config.get("private_key") + from_addr = spamoor_config.get("from_addr") + if not private_key or not from_addr: + pytest.skip("spamoor private_key/from_addr not configured") + if rpc_client is None: + pytest.skip("spamoor rpc_client not configured") + + chain_id_hex = rpc_client("eth_chainId", []) + if not isinstance(chain_id_hex, str): + pytest.skip("spamoor endpoint unreachable (eth_chainId failed)") + nonce_hex = rpc_client("eth_getTransactionCount", [from_addr, "pending"]) + assert isinstance(nonce_hex, str), "eth_getTransactionCount failed" + + start_nonce = int(nonce_hex, 16) + return { + "signer": EOA(key=private_key, nonce=start_nonce), + "chain_id": int(chain_id_hex, 16), + "start_nonce": start_nonce, + } + + +def broadcast_and_assert_receipts( + raw_txs: List[Dict[str, Any]], + ctx: Dict[str, Any], + rpc_client: Callable[[str, List[Any]], Any], + *, + fork: Optional[Any] = None, + blob_seed: int = 0, + timeout: float = 60.0, + poll_interval: float = 1.0, + allow_reverts: bool = False, +) -> Dict[str, Dict[str, Any]]: + """ + Sign every raw tx, broadcast, poll receipts, assert ``status == 0x1``. + + Returns the receipts keyed by tx hash. Skips the test if the builder + produced no txs. Type-3 (blob) transactions cannot be broadcast via + ``eth_sendRawTransaction`` with EST's block-form RLP (no sidecars), + so the helper skips when it encounters one. + """ + import pytest + from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, + ) + + if not raw_txs: + pytest.skip("builder produced no transactions") + if any(int(tx.get("type", 0)) == 3 for tx in raw_txs): + pytest.skip( + "type-3 blob broadcast requires network-form RLP with sidecars; " + "not implemented for spamoor smoke tests" + ) + + signer = ctx["signer"] + chain_id = ctx["chain_id"] + start_nonce = int(ctx["start_nonce"]) + + tx_hashes: List[str] = [] + for i, tx_dict in enumerate(raw_txs): + _normalize_fees(tx_dict) + tx = spamoor_dict_to_transaction( + tx_dict, + signer, + chain_id, + nonce_override=start_nonce + i, + fork=fork, + blob_seed=blob_seed, + ) + # ``Bytes.hex()`` already includes the ``0x`` prefix. + raw = tx.rlp().hex() + result = rpc_client("eth_sendRawTransaction", [raw]) + assert isinstance(result, str) and result.startswith("0x"), ( + f"eth_sendRawTransaction failed for tx {i} " + f"(type={tx_dict.get('type')} to={tx_dict.get('to')!r} " + f"gas={tx_dict.get('gas')} " + f"maxFee={tx_dict.get('maxFeePerGas')} " + f"tip={tx_dict.get('maxPriorityFeePerGas')} " + f"nonce={start_nonce + i} " + f"data_len={len(tx_dict.get('data', '') or '') // 2}): {result!r}" + ) + tx_hashes.append(result) + + deadline = time.time() + timeout + pending = list(tx_hashes) + receipts: Dict[str, Dict[str, Any]] = {} + while pending and time.time() < deadline: + still_pending: List[str] = [] + for h in pending: + receipt = rpc_client("eth_getTransactionReceipt", [h]) + if receipt is None: + still_pending.append(h) + else: + receipts[h] = receipt + pending = still_pending + if pending: + time.sleep(poll_interval) + + assert not pending, ( + f"transactions never mined within {timeout}s: {pending}" + ) + if not allow_reverts: + for h, r in receipts.items(): + status = r.get("status") + assert status == "0x1", ( + f"tx {h} reverted: status={status} receipt={r}" + ) + return receipts + + def build_eoatx_transactions( count: int, throughput: float, diff --git a/tests/benchmark/spamoor/test_blob_combined.py b/tests/benchmark/spamoor/test_blob_combined.py index fd79d6b366a..28347391f1d 100644 --- a/tests/benchmark/spamoor/test_blob_combined.py +++ b/tests/benchmark/spamoor/test_blob_combined.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_blob_combined_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_blob_combined_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,17 @@ def test_blob_combined_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_blob_combined_scenario.""" + """ + Exercise the blob-combined builder shape. + + ``broadcast_and_assert_receipts`` currently skips for type-3 txs: + ``eth_sendRawTransaction`` needs EIP-4844 network-form RLP (with + blobs/commitments/proofs sidecars), while EST's ``Transaction.rlp()`` + yields block-form (payload only). The test still exercises the + builder end-to-end and the broadcast helper will skip cleanly. + """ + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_blob_combined_transactions( count=spamoor_config["count"], sidecars=spamoor_config["sidecars"], @@ -29,11 +43,8 @@ def test_blob_combined_scenario( tx0 = txs[0] assert tx0["type"] == 3 assert tx0["gas"] == 21000 - assert tx0["value"] == 0 - assert "maxFeePerGas" in tx0 - assert "maxPriorityFeePerGas" in tx0 - assert "maxFeePerBlobGas" in tx0 assert tx0["maxFeePerBlobGas"] == spamoor_config["blob_fee"] - assert isinstance(tx0["blobVersionedHashes"], list) expected_blobs = max(1, min(int(spamoor_config["sidecars"]), 6)) assert len(tx0["blobVersionedHashes"]) == expected_blobs + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_calltx.py b/tests/benchmark/spamoor/test_calltx.py index 267def5b4ec..504e9da05e1 100644 --- a/tests/benchmark/spamoor/test_calltx.py +++ b/tests/benchmark/spamoor/test_calltx.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_calltx_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_calltx_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_calltx_scenario_with_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_calltx_scenario_with_deploy.""" + """Build deploy + call txs, broadcast, assert receipts.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_calltx_transactions( count=spamoor_config["count"], throughput=spamoor_config["throughput"], @@ -20,7 +26,7 @@ def test_calltx_scenario_with_deploy( basefee=spamoor_config["basefee"], from_addr=spamoor_config["from_addr"], private_key=spamoor_config["private_key"], - contract_code="0x6000600055", + contract_code="0x6001600055", contract_address=spamoor_config.get("contract_address"), call_data=spamoor_config.get("call_data", ""), deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), @@ -32,28 +38,12 @@ def test_calltx_scenario_with_deploy( rpc_client=spamoor_rpc_client, ) - # With contract_code set, the first tx is deployment, - # followed by `count` execution txs. assert len(txs) == spamoor_config["count"] + 1 - - # Check deployment tx assert txs[0]["type"] == 2 assert txs[0]["to"] == "" - assert txs[0]["data"] == "0x6000600055" + assert txs[0]["data"] == "0x6001600055" - # Check execution tx - if spamoor_config["count"] > 0: - assert txs[1]["to"] == ( - spamoor_config.get("contract_address") - or "0x1111111111111111111111111111111111111111" - ) - # Use the gas_limit override when provided. - expected_gas = ( - spamoor_config.get("gas_limit") - if spamoor_config.get("gas_limit") - else 500000 - ) - assert txs[1]["gas"] == expected_gas + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) @pytest.mark.spamoor @@ -61,7 +51,9 @@ def test_calltx_scenario_no_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_calltx_scenario_no_deploy.""" + """Build call-only txs, broadcast, assert receipts.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_calltx_transactions( count=spamoor_config["count"], throughput=spamoor_config["throughput"], @@ -81,20 +73,8 @@ def test_calltx_scenario_no_deploy( rpc_client=spamoor_rpc_client, ) - # If no contract_code, we only get `count` execution txs assert len(txs) == spamoor_config["count"] + assert txs[0]["type"] == 2 + assert txs[0]["data"] == "0x1234" - if spamoor_config["count"] > 0: - assert txs[0]["type"] == 2 - assert txs[0]["to"] == ( - spamoor_config.get("contract_address") - or "0x1111111111111111111111111111111111111111" - ) - assert txs[0]["data"] == "0x1234" - # Use the gas_limit override when provided. - expected_gas = ( - spamoor_config.get("gas_limit") - if spamoor_config.get("gas_limit") - else 500000 - ) - assert txs[0]["gas"] == expected_gas + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_deploytx.py b/tests/benchmark/spamoor/test_deploytx.py index 7203ed58321..9f01c7fb36a 100644 --- a/tests/benchmark/spamoor/test_deploytx.py +++ b/tests/benchmark/spamoor/test_deploytx.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_deploytx_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_deploytx_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_deploytx_default_bytecode( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_deploytx_default_bytecode.""" + """Broadcast contract-creation txs carrying the default bytecode.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_deploytx_transactions( count=spamoor_config["count"], bytecodes=spamoor_config["bytecodes"], @@ -27,37 +33,26 @@ def test_deploytx_default_bytecode( ) assert len(txs) == spamoor_config["count"] - if spamoor_config["count"] == 0: - return - - expected_gas = ( - spamoor_config["gas_limit"] - if spamoor_config["gas_limit"] - else 1_000_000 - ) for tx in txs: assert tx["type"] == 2 assert tx["to"] == "" assert tx["value"] == 0 - assert tx["gas"] == expected_gas assert tx["data"].startswith("0x") - # When neither --spamoor-bytecodes nor --spamoor-bytecodes-file is - # set, every tx carries the default tiny bytecode. if ( not spamoor_config["bytecodes"] and not spamoor_config["bytecodes_file"] ): assert txs[0]["data"] == "0x6001600055" + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) + @pytest.mark.spamoor def test_deploytx_cycles_bytecode_list( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_deploytx_cycles_bytecode_list.""" - # Force a two-entry list so we can check cycling even when the CLI - # doesn't provide bytecodes. + """Cycling over a bytecode list is deterministic — shape check only.""" txs = build_deploytx_transactions( count=max(3, spamoor_config["count"]), bytecodes="0x6001600055,0x60ff60005255", @@ -73,4 +68,4 @@ def test_deploytx_cycles_bytecode_list( assert txs[0]["data"] == "0x6001600055" assert txs[1]["data"] == "0x60ff60005255" - assert txs[2]["data"] == txs[0]["data"] # cycles every 2 + assert txs[2]["data"] == txs[0]["data"] diff --git a/tests/benchmark/spamoor/test_eoatx.py b/tests/benchmark/spamoor/test_eoatx.py index 4711e8bc9d1..01cb79e6b90 100644 --- a/tests/benchmark/spamoor/test_eoatx.py +++ b/tests/benchmark/spamoor/test_eoatx.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_eoatx_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_eoatx_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,8 +16,10 @@ def test_eoatx_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_eoatx_scenario.""" - txs = build_eoatx_transactions( + """Build, sign, broadcast EOA transfers and verify receipts.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + + raw_txs = build_eoatx_transactions( count=spamoor_config["count"], throughput=spamoor_config["throughput"], amount=spamoor_config["amount"], @@ -23,10 +29,9 @@ def test_eoatx_scenario( rpc_client=spamoor_rpc_client, ) - assert len(txs) == spamoor_config["count"] - if spamoor_config["count"] > 0: - assert txs[0]["type"] == 2 - assert txs[0]["value"] == spamoor_config["amount"] - assert txs[0]["gas"] == 21000 - assert "maxFeePerGas" in txs[0] - assert "maxPriorityFeePerGas" in txs[0] + assert len(raw_txs) == spamoor_config["count"] + assert raw_txs[0]["type"] == 2 + assert raw_txs[0]["value"] == spamoor_config["amount"] + assert raw_txs[0]["gas"] == 21000 + + broadcast_and_assert_receipts(raw_txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_erc20_bloater.py b/tests/benchmark/spamoor/test_erc20_bloater.py index a24dd99d9db..a0a7fb203ec 100644 --- a/tests/benchmark/spamoor/test_erc20_bloater.py +++ b/tests/benchmark/spamoor/test_erc20_bloater.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_erc20_bloater_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_erc20_bloater_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_erc20_bloater_scenario_with_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_erc20_bloater_scenario_with_deploy.""" + """Deploy ERC20Bloater stub + broadcast bloatStorage calls.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_erc20_bloater_transactions( count=spamoor_config["count"], addresses_per_tx=spamoor_config["addresses_per_tx"], @@ -29,37 +35,15 @@ def test_erc20_bloater_scenario_with_deploy( rpc_client=spamoor_rpc_client, ) - # Deploy tx + count bloat txs. assert len(txs) == spamoor_config["count"] + 1 - - deploy = txs[0] - assert deploy["type"] == 2 - assert deploy["to"] == "" - + assert txs[0]["type"] == 2 + assert txs[0]["to"] == "" if spamoor_config["count"] > 0: - exec_tx = txs[1] - expected_gas = ( - spamoor_config["gas_limit"] - if spamoor_config["gas_limit"] - else 16_700_000 - ) - assert exec_tx["type"] == 2 - assert exec_tx["to"] == "0xdddddddddddddddddddddddddddddddddddddddd" - assert exec_tx["value"] == 0 - assert exec_tx["gas"] == expected_gas - # selector(4) + uint256(32) + uint256(32) = 68 bytes. - assert len(exec_tx["data"]) == 2 + 2 * 68 - assert exec_tx["data"].startswith("0xc1926de5") + assert txs[1]["data"].startswith("0xc1926de5") - # numAddresses argument (last 32 bytes) stays constant across txs. - num_word = exec_tx["data"][-64:] - assert int(num_word, 16) == spamoor_config["addresses_per_tx"] - - # startAddressIndex advances by addresses_per_tx between txs. - if spamoor_config["count"] >= 2: - start_a = int(txs[1]["data"][10 : 10 + 64], 16) - start_b = int(txs[2]["data"][10 : 10 + 64], 16) - assert start_b - start_a == spamoor_config["addresses_per_tx"] + # Bloater txs carry 16.7M gas limits → tight block packing on a 30M + # cap. Give the node extra time to mine the batch. + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client, timeout=120) @pytest.mark.spamoor @@ -67,7 +51,9 @@ def test_erc20_bloater_scenario_existing_contract( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_erc20_bloater_scenario_existing_contract.""" + """Skip-deploy path: call bloatStorage on an existing address.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_erc20_bloater_transactions( count=spamoor_config["count"], addresses_per_tx=spamoor_config["addresses_per_tx"], @@ -83,8 +69,9 @@ def test_erc20_bloater_scenario_existing_contract( rpc_client=spamoor_rpc_client, ) - # No deploy tx when targeting an existing contract. assert len(txs) == spamoor_config["count"] if spamoor_config["count"] > 0: assert txs[0]["to"] == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" assert txs[0]["data"].startswith("0xc1926de5") + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client, timeout=120) diff --git a/tests/benchmark/spamoor/test_erc20tx.py b/tests/benchmark/spamoor/test_erc20tx.py index a03370b7e00..7c70766fec3 100644 --- a/tests/benchmark/spamoor/test_erc20tx.py +++ b/tests/benchmark/spamoor/test_erc20tx.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_erc20tx_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_erc20tx_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_erc20tx_scenario_with_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_erc20tx_scenario_with_deploy.""" + """Deploy stub ERC20 + broadcast transferMint calls.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_erc20tx_transactions( count=spamoor_config["count"], amount=spamoor_config["amount"], @@ -30,26 +36,13 @@ def test_erc20tx_scenario_with_deploy( rpc_client=spamoor_rpc_client, ) - # Deploy tx + count execution txs. assert len(txs) == spamoor_config["count"] + 1 - - deploy = txs[0] - assert deploy["type"] == 2 - assert deploy["to"] == "" - assert deploy["data"].startswith("0x") - + assert txs[0]["type"] == 2 + assert txs[0]["to"] == "" if spamoor_config["count"] > 0: - exec_tx = txs[1] - assert exec_tx["type"] == 2 - assert exec_tx["to"] == ( - spamoor_config.get("contract_address") - or "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ) - assert exec_tx["value"] == 0 - assert exec_tx["gas"] == 100_000 - # selector(4) + address(32) + uint256(32) = 68 bytes. - assert len(exec_tx["data"]) == 2 + 2 * 68 - assert exec_tx["data"].startswith("0x9d0f7cba") + assert txs[1]["data"].startswith("0x9d0f7cba") + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) @pytest.mark.spamoor @@ -57,7 +50,9 @@ def test_erc20tx_scenario_no_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_erc20tx_scenario_no_deploy.""" + """Skip-deploy path: broadcast transferMint calls only.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_erc20tx_transactions( count=spamoor_config["count"], amount=spamoor_config["amount"], @@ -74,10 +69,10 @@ def test_erc20tx_scenario_no_deploy( rpc_client=spamoor_rpc_client, ) - # No deploy tx when contract_code is None. assert len(txs) == spamoor_config["count"] if spamoor_config["count"] >= 2: - # random_target should produce distinct recipients. addr_a = txs[0]["data"][10 : 10 + 64] addr_b = txs[1]["data"][10 : 10 + 64] assert addr_a != addr_b + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_evm_fuzz.py b/tests/benchmark/spamoor/test_evm_fuzz.py index 83b75191271..51106a49b4c 100644 --- a/tests/benchmark/spamoor/test_evm_fuzz.py +++ b/tests/benchmark/spamoor/test_evm_fuzz.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_evm_fuzz_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_evm_fuzz_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_evm_fuzz_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_evm_fuzz_scenario.""" + """Broadcast random-bytecode contract creations. Reverts are OK.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_evm_fuzz_transactions( count=spamoor_config["count"], gas_limit=spamoor_config["gas_limit"], @@ -30,34 +36,15 @@ def test_evm_fuzz_scenario( ) assert len(txs) == spamoor_config["count"] - if spamoor_config["count"] == 0: - return - - expected_gas = ( - spamoor_config["gas_limit"] - if spamoor_config["gas_limit"] - else 1_000_000 - ) - min_cs = spamoor_config["min_code_size"] - max_cs = spamoor_config["max_code_size"] - zero_value_count = 0 for tx in txs: assert tx["type"] == 2 - assert tx["to"] == "" # contract creation - assert tx["gas"] == expected_gas + assert tx["to"] == "" assert tx["data"].startswith("0x") - # data is 0x + 2*bytes; each tx bytecode must fit [min, max] bytes. - byte_len = (len(tx["data"]) - 2) // 2 - assert min_cs <= byte_len <= max_cs - if tx["value"] == 0: - zero_value_count += 1 - # 75/25 split: every 4th tx carries value=0. With count>=4 we must - # see at least one zero-value tx; and at least one non-zero if count>=2. - if spamoor_config["count"] >= 4: - assert zero_value_count >= 1 - if spamoor_config["count"] >= 2: - assert zero_value_count < spamoor_config["count"] + # Random init code typically reverts — we only assert inclusion. + broadcast_and_assert_receipts( + txs, ctx, spamoor_rpc_client, allow_reverts=True + ) @pytest.mark.spamoor @@ -65,7 +52,7 @@ def test_evm_fuzz_is_deterministic( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_evm_fuzz_is_deterministic.""" + """Determinism check: same inputs yield same bytecodes. No broadcast.""" kwargs = dict( count=max(2, spamoor_config["count"]), gas_limit=spamoor_config["gas_limit"], diff --git a/tests/benchmark/spamoor/test_factorydeploytx.py b/tests/benchmark/spamoor/test_factorydeploytx.py index 86979190788..cae3c4cb899 100644 --- a/tests/benchmark/spamoor/test_factorydeploytx.py +++ b/tests/benchmark/spamoor/test_factorydeploytx.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_factorydeploytx_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_factorydeploytx_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,51 +16,51 @@ def test_factorydeploytx_scenario_with_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_factorydeploytx_scenario_with_deploy.""" + """Deploy factory bytecode + broadcast deploy(bytes32,bytes) calls.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_factorydeploytx_transactions( - count=spamoor_config.get("count", 10), - init_code=spamoor_config.get("init_code", "0x1234"), - start_salt=spamoor_config.get("start_salt", 0), + count=spamoor_config.get("count") or 10, + init_code=spamoor_config.get("init_code") or "0x1234", + start_salt=spamoor_config.get("start_salt") or 0, factory_address="", - deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), - gas_limit=spamoor_config.get("gas_limit", 500000), + deploy_gas_limit=spamoor_config.get("deploy_gas_limit") or 2_000_000, + gas_limit=spamoor_config.get("gas_limit") or 500_000, rpc_client=spamoor_rpc_client, ) - assert len(txs) == spamoor_config.get("count", 10) + 1 - - # Check deployment tx + assert len(txs) == (spamoor_config.get("count") or 10) + 1 assert txs[0]["type"] == 2 assert txs[0]["to"] == "" - assert txs[0]["gas"] == spamoor_config.get("deploy_gas_limit", 2000000) assert txs[0]["data"].startswith("0x60806040") - - # Check execution tx - if spamoor_config.get("count", 10) > 0: - assert txs[1]["to"] == "0x2222222222222222222222222222222222222222" - # 0x4c8c9ea1 is the selector for deploy(bytes32,bytes) + if (spamoor_config.get("count") or 10) > 0: assert txs[1]["data"].startswith("0x4c8c9ea1") + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) + @pytest.mark.spamoor def test_factorydeploytx_scenario_no_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_factorydeploytx_scenario_no_deploy.""" + """Skip-deploy path: deploy(bytes32,bytes) calls only.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_factorydeploytx_transactions( - count=spamoor_config.get("count", 10), - init_code=spamoor_config.get("init_code", "0x1234"), - start_salt=spamoor_config.get("start_salt", 0), + count=spamoor_config.get("count") or 10, + init_code=spamoor_config.get("init_code") or "0x1234", + start_salt=spamoor_config.get("start_salt") or 0, factory_address="0x3333333333333333333333333333333333333333", - deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), - gas_limit=spamoor_config.get("gas_limit", 500000), + deploy_gas_limit=spamoor_config.get("deploy_gas_limit") or 2_000_000, + gas_limit=spamoor_config.get("gas_limit") or 500_000, rpc_client=spamoor_rpc_client, ) - assert len(txs) == spamoor_config.get("count", 10) - - if spamoor_config.get("count", 10) > 0: + assert len(txs) == (spamoor_config.get("count") or 10) + if (spamoor_config.get("count") or 10) > 0: assert txs[0]["type"] == 2 assert txs[0]["to"] == "0x3333333333333333333333333333333333333333" assert txs[0]["data"].startswith("0x4c8c9ea1") + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_gasburnertx.py b/tests/benchmark/spamoor/test_gasburnertx.py index bf127f0cf60..76e3feeffc1 100644 --- a/tests/benchmark/spamoor/test_gasburnertx.py +++ b/tests/benchmark/spamoor/test_gasburnertx.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_gasburnertx_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_gasburnertx_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_gasburnertx_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_gasburnertx_scenario.""" + """Deploy gas-burner + broadcast gas-burning calls.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_gasburnertx_transactions( count=spamoor_config["count"], gas_units_to_burn=spamoor_config["gas_units_to_burn"], @@ -26,27 +32,10 @@ def test_gasburnertx_scenario( rpc_client=spamoor_rpc_client, ) - # Deploy tx + count exec txs. assert len(txs) == spamoor_config["count"] + 1 - - deploy = txs[0] - assert deploy["type"] == 2 - assert deploy["to"] == "" - assert deploy["data"].startswith("0x") - assert deploy["gas"] == spamoor_config["deploy_gas_limit"] - + assert txs[0]["type"] == 2 + assert txs[0]["to"] == "" if spamoor_config["count"] > 0: - exec_tx = txs[1] - assert exec_tx["type"] == 2 - assert exec_tx["to"] == ( - spamoor_config["contract_address"] - or "0x3333333333333333333333333333333333333333" - ) - assert exec_tx["gas"] == spamoor_config["gas_units_to_burn"] - assert exec_tx["value"] == 0 - assert exec_tx["data"] == "0x00000000" - assert "maxFeePerGas" in exec_tx - assert "maxPriorityFeePerGas" in exec_tx - # Second exec tx encodes txIdx=1. - if spamoor_config["count"] > 1: - assert txs[2]["data"] == "0x00000001" + assert txs[1]["data"] == "0x00000000" + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_storagerefundtx.py b/tests/benchmark/spamoor/test_storagerefundtx.py index eea934e6e15..4c18fe0dd49 100644 --- a/tests/benchmark/spamoor/test_storagerefundtx.py +++ b/tests/benchmark/spamoor/test_storagerefundtx.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_storagerefundtx_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_storagerefundtx_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_storagerefundtx_scenario_with_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_storagerefundtx_scenario_with_deploy.""" + """Deploy stub + broadcast execute(slotsPerCall) calls.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_storagerefundtx_transactions( count=spamoor_config["count"], slots_per_call=spamoor_config["slots_per_call"], @@ -29,28 +35,12 @@ def test_storagerefundtx_scenario_with_deploy( ) assert len(txs) == spamoor_config["count"] + 1 - - deploy = txs[0] - assert deploy["type"] == 2 - assert deploy["to"] == "" - + assert txs[0]["type"] == 2 + assert txs[0]["to"] == "" if spamoor_config["count"] > 0: - exec_tx = txs[1] - expected_gas = ( - spamoor_config["gas_limit"] - if spamoor_config["gas_limit"] - else 3_000_000 - ) - assert exec_tx["type"] == 2 - assert exec_tx["to"] == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - assert exec_tx["value"] == 0 - assert exec_tx["gas"] == expected_gas - # selector(4) + uint256(32) = 36 bytes => 72 hex + "0x". - assert len(exec_tx["data"]) == 2 + 2 * 36 - assert exec_tx["data"].startswith("0xfe0d94c1") - # Encoded slotsPerCall matches the argument. - encoded_slots = int(exec_tx["data"][10:], 16) - assert encoded_slots == spamoor_config["slots_per_call"] + assert txs[1]["data"].startswith("0xfe0d94c1") + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) @pytest.mark.spamoor @@ -58,7 +48,9 @@ def test_storagerefundtx_scenario_existing_contract( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_storagerefundtx_scenario_existing_contract.""" + """Skip-deploy path: execute() calls against a supplied address.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_storagerefundtx_transactions( count=spamoor_config["count"], slots_per_call=spamoor_config["slots_per_call"], @@ -72,8 +64,9 @@ def test_storagerefundtx_scenario_existing_contract( rpc_client=spamoor_rpc_client, ) - # No deploy tx when targeting an existing contract. assert len(txs) == spamoor_config["count"] if spamoor_config["count"] > 0: assert txs[0]["to"] == "0xffffffffffffffffffffffffffffffffffffffff" assert txs[0]["data"].startswith("0xfe0d94c1") + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_storagespam.py b/tests/benchmark/spamoor/test_storagespam.py index 18cb3df5381..f67e32cf6dd 100644 --- a/tests/benchmark/spamoor/test_storagespam.py +++ b/tests/benchmark/spamoor/test_storagespam.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_storagespam_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_storagespam_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_storagespam_scenario_with_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_storagespam_scenario_with_deploy.""" + """Deploy stub + broadcast setRandomForGas calls.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_storagespam_transactions( count=spamoor_config["count"], gas_units_to_burn=spamoor_config["gas_units_to_burn"], @@ -29,29 +35,12 @@ def test_storagespam_scenario_with_deploy( ) assert len(txs) == spamoor_config["count"] + 1 - - deploy = txs[0] - assert deploy["type"] == 2 - assert deploy["to"] == "" - assert deploy["data"].startswith("0x") - + assert txs[0]["type"] == 2 + assert txs[0]["to"] == "" if spamoor_config["count"] > 0: - exec_tx = txs[1] - assert exec_tx["type"] == 2 - assert exec_tx["to"] == ( - spamoor_config.get("contract_address") - or "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ) - assert exec_tx["value"] == 0 - assert exec_tx["gas"] == spamoor_config["gas_units_to_burn"] + 50_000 - # selector(4) + uint256(32) + uint256(32) = 68 bytes. - assert len(exec_tx["data"]) == 2 + 2 * 68 - assert exec_tx["data"].startswith("0xfed72935") - # Second exec tx carries seed=1, so the trailing uint256 must differ. - if spamoor_config["count"] >= 2: - seed_word_a = txs[1]["data"][-64:] - seed_word_b = txs[2]["data"][-64:] - assert seed_word_a != seed_word_b + assert txs[1]["data"].startswith("0xfed72935") + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) @pytest.mark.spamoor @@ -59,7 +48,9 @@ def test_storagespam_scenario_reuse_contract( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_storagespam_scenario_reuse_contract.""" + """Reuse-contract path: setRandomForGas calls only, no deploy.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_storagespam_transactions( count=spamoor_config["count"], gas_units_to_burn=spamoor_config["gas_units_to_burn"], @@ -73,7 +64,8 @@ def test_storagespam_scenario_reuse_contract( rpc_client=spamoor_rpc_client, ) - # No deploy tx when reusing an existing contract. assert len(txs) == spamoor_config["count"] if spamoor_config["count"] > 0: assert txs[0]["data"].startswith("0xfed72935") + + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_uniswap_swaps.py b/tests/benchmark/spamoor/test_uniswap_swaps.py index ec19a1dc345..040bf9b8c3a 100644 --- a/tests/benchmark/spamoor/test_uniswap_swaps.py +++ b/tests/benchmark/spamoor/test_uniswap_swaps.py @@ -4,7 +4,11 @@ import pytest -from .helpers import build_uniswap_swaps_transactions +from .helpers import ( + broadcast_and_assert_receipts, + build_uniswap_swaps_transactions, + spamoor_signer_context, +) @pytest.mark.spamoor @@ -12,7 +16,9 @@ def test_uniswap_swaps_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], ) -> None: - """Exercise test_uniswap_swaps_scenario.""" + """Broadcast Uniswap V2 router swap calls against a placeholder router.""" + ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) + txs = build_uniswap_swaps_transactions( count=spamoor_config["count"], pair_count=spamoor_config["pair_count"], @@ -30,34 +36,9 @@ def test_uniswap_swaps_scenario( assert len(txs) == spamoor_config["count"] if spamoor_config["count"] > 0: - tx0 = txs[0] - assert tx0["type"] == 2 - assert tx0["to"] == "0x4444444444444444444444444444444444444444" - assert tx0["gas"] == 200_000 - # Data carries a 4-byte Uniswap V2 Router02 selector then ABI args. - assert tx0["data"].startswith("0x") - selector = tx0["data"][2:10] + selector = txs[0]["data"][2:10] assert selector in {"38ed1739", "7ff36ab5", "18cbafe5"} - # Buy-ratio mix: first N * buy_ratio / 100 txs should target a - # buy selector (either swapExactTokensForTokens or swapExactETHForTokens). - if spamoor_config["count"] >= 5 and spamoor_config["buy_ratio"] > 0: - buys_seen = 0 - sells_seen = 0 - for tx in txs: - selector = tx["data"][2:10] - if selector in {"38ed1739", "7ff36ab5"}: - buys_seen += 1 - elif selector == "18cbafe5": - sells_seen += 1 - assert buys_seen > 0 - if spamoor_config["buy_ratio"] < 100: - assert sells_seen > 0 - - # swapExactETHForTokens carries msg.value; others do not. - for tx in txs: - selector = tx["data"][2:10] - if selector == "7ff36ab5": - assert tx["value"] > 0 - else: - assert tx["value"] == 0 + # swapExactETHForTokens carries non-zero value to the placeholder. All + # calls land on an empty address → succeed as no-ops. + broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) From 143d650fb43e25f57d96cd7a4269a6eadef38098 Mon Sep 17 00:00:00 2001 From: Dmytro Biloshytskyi Date: Fri, 24 Apr 2026 15:37:40 +0300 Subject: [PATCH 07/11] Added an ability to use spamoor yml config --- .../plugins/spamoor/spamoor.py | 135 +++++++++++++++++- .../testing_build_block.py | 26 ++++ tests/benchmark/spamoor/helpers.py | 23 ++- 3 files changed, 178 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py index a0ee50c5e7b..4b395243146 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py @@ -1,9 +1,116 @@ """Pytest plugin: CLI options and fixtures for spamoor scenarios.""" +from pathlib import Path from typing import Any, Callable, Dict, List, Optional import pytest import requests +import yaml + + +# YAML key → spamoor_config key. Infrastructure keys (endpoint, count, +# private_key, from_addr) are intentionally absent so that a CLI invocation +# keeps full control of them regardless of the config file. +_YAML_TO_CONFIG_KEY: Dict[str, str] = { + "amount": "amount", + "contract_address": "contract_address", + "contract_code": "contract_code", + "call_data": "call_data", + "call_fn_sig": "call_fn_sig", + "call_args": "call_args", + "contract_args": "contract_args", + "gas_limit": "gas_limit", + "deploy_gas_limit": "deploy_gas_limit", + "throughput": "throughput", + "random_target": "random_target", + "random_amount": "random_amount", + "seed": "payload_seed", +} + + +def _load_scenario_from_yaml(path: str, index: int) -> Dict[str, Any]: + """Return the YAML list entry at *index*, validating structure.""" + p = Path(path) + if not p.is_file(): + raise pytest.UsageError( + f"--spamoor-config-file {path!r}: file not found" + ) + try: + data = yaml.safe_load(p.read_text()) + except yaml.YAMLError as exc: + raise pytest.UsageError( + f"--spamoor-config-file {path!r}: invalid YAML ({exc})" + ) from exc + if not isinstance(data, list): + raise pytest.UsageError( + f"--spamoor-config-file {path!r}: expected a YAML list at the " + f"top level, got {type(data).__name__}" + ) + if index < 0 or index >= len(data): + raise pytest.UsageError( + f"--spamoor-scenario-index {index} out of range (file has " + f"{len(data)} scenario(s))" + ) + entry = data[index] + if not isinstance(entry, dict) or "config" not in entry: + raise pytest.UsageError( + f"--spamoor-config-file {path!r}: entry #{index} is not a " + f"scenario object with a 'config' field" + ) + return entry + + +def _overlay_yaml_on_config( + cfg: Dict[str, Any], scenario_entry: Dict[str, Any] +) -> None: + """ + Overwrite ``cfg`` in place with mapped values from a YAML scenario. + + ``base_fee`` / ``tip_fee`` in the YAML are gwei floats; the ``*_wei`` + variants, when non-empty, take precedence and are interpreted as + decimal integers. + """ + yaml_cfg = scenario_entry.get("config") or {} + if not isinstance(yaml_cfg, dict): + raise pytest.UsageError( + f"scenario entry {scenario_entry.get('name')!r} has a non-dict " + f"'config'" + ) + + for y_key, c_key in _YAML_TO_CONFIG_KEY.items(): + if y_key not in yaml_cfg: + continue + value = yaml_cfg[y_key] + if value is None or value == "": + continue + # YAML's safe_load parses ``0xfd1a...`` as a Python int. Address / + # code / call-data fields must round-trip as hex strings. + if c_key in ( + "contract_address", + "contract_code", + "call_data", + ) and isinstance(value, int): + value = "0x" + format(value, "040x" if c_key == "contract_address" else "x") + cfg[c_key] = value + + base_fee_wei = yaml_cfg.get("base_fee_wei") + if isinstance(base_fee_wei, str) and base_fee_wei.strip(): + cfg["basefee"] = int(base_fee_wei) + elif isinstance(yaml_cfg.get("base_fee"), (int, float)): + cfg["basefee"] = int(float(yaml_cfg["base_fee"]) * 1e9) + + tip_fee_wei = yaml_cfg.get("tip_fee_wei") + if isinstance(tip_fee_wei, str) and tip_fee_wei.strip(): + cfg["tip_fee"] = int(tip_fee_wei) + elif isinstance(yaml_cfg.get("tip_fee"), (int, float)): + cfg["tip_fee"] = int(float(yaml_cfg["tip_fee"]) * 1e9) + + # Spamoor throughput is an int (txs/sec) but the helpers treat it as + # a float multiplier for max_fee_per_gas. Coerce for consistency. + if "throughput" in yaml_cfg and yaml_cfg["throughput"] is not None: + cfg["throughput"] = float(yaml_cfg["throughput"]) + + cfg["yaml_scenario"] = scenario_entry def pytest_addoption(parser: pytest.Parser) -> None: @@ -274,6 +381,24 @@ def pytest_addoption(parser: pytest.Parser) -> None: default="", help="deploytx: path to file with one hex bytecode per line", ) + group.addoption( + "--spamoor-config-file", + dest="spamoor_config_file", + type=str, + default="", + help=( + "Path to a spammer-export YAML. When set, the scenario at " + "--spamoor-scenario-index overlays tx-shape options " + "(contract_address, fees, gas_limit, ...) onto the fixture." + ), + ) + group.addoption( + "--spamoor-scenario-index", + dest="spamoor_scenario_index", + type=int, + default=0, + help="0-indexed scenario entry to load from --spamoor-config-file.", + ) def pytest_configure(config: pytest.Config) -> None: @@ -286,7 +411,7 @@ def pytest_configure(config: pytest.Config) -> None: @pytest.fixture(scope="session") def spamoor_config(request: pytest.FixtureRequest) -> Dict[str, Any]: """Collect all ``--spamoor-*`` options into a config dict.""" - return { + cfg: Dict[str, Any] = { "endpoint": request.config.getoption("spamoor_endpoint"), "count": request.config.getoption("spamoor_count"), "throughput": request.config.getoption("--spamoor-throughput"), @@ -340,6 +465,14 @@ def spamoor_config(request: pytest.FixtureRequest) -> Dict[str, Any]: "bytecodes_file": request.config.getoption("spamoor_bytecodes_file"), } + config_file = request.config.getoption("spamoor_config_file") + if config_file: + index = int(request.config.getoption("spamoor_scenario_index")) + entry = _load_scenario_from_yaml(config_file, index) + _overlay_yaml_on_config(cfg, entry) + + return cfg + @pytest.fixture(scope="session") def spamoor_rpc_client( diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py index f2756a470a2..4f85512f707 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py @@ -91,6 +91,24 @@ def pytest_addoption(parser: pytest.Parser) -> None: default=None, help="Override the active fork (defaults to latest known fork).", ) + group.addoption( + "--bloat-config-file", + dest="bloat_config_file", + type=str, + default="", + help=( + "Path to a spammer-export YAML. Parallels " + "--spamoor-config-file; tx-shape overlay still happens in " + "spamoor_config, this option only validates the YAML parses." + ), + ) + group.addoption( + "--bloat-scenario-index", + dest="bloat_scenario_index", + type=int, + default=0, + help="0-indexed scenario entry to load from --bloat-config-file.", + ) def _load_jwt_secret(path: str | None) -> bytes: @@ -141,6 +159,14 @@ def bloat_config(request: pytest.FixtureRequest) -> BloatConfig: signer_key = request.config.getoption("bloat_signer_key") if signer_key is None: pytest.exit("--bloat-signer-key is required") + bloat_config_file = request.config.getoption("bloat_config_file") + if bloat_config_file: + from ..spamoor.spamoor import _load_scenario_from_yaml + + _load_scenario_from_yaml( + bloat_config_file, + int(request.config.getoption("bloat_scenario_index")), + ) jwt_secret = _load_jwt_secret( request.config.getoption("bloat_jwt_secret_file") ) diff --git a/tests/benchmark/spamoor/helpers.py b/tests/benchmark/spamoor/helpers.py index ff4420c9ad3..2d8ae1fe760 100644 --- a/tests/benchmark/spamoor/helpers.py +++ b/tests/benchmark/spamoor/helpers.py @@ -259,6 +259,12 @@ def build_calltx_transactions( max_fee_per_gas = int(base_fee_per_gas * (1.0 + throughput)) + # Treat empty strings from YAML config as "unset". + if isinstance(contract_code, str) and contract_code == "": + contract_code = None + if isinstance(contract_address, str) and contract_address == "": + contract_address = None + txs: List[Dict[str, Any]] = [] # ABI-encode call_data when not provided but a function signature is given @@ -778,11 +784,18 @@ def _evm_fuzz_bytecode( """ if max_size < min_size: max_size = min_size - seed_bytes = ( - bytes.fromhex(seed_hex[2:] if seed_hex.startswith("0x") else seed_hex) - if seed_hex - else b"" - ) + # Seed can be a 0x-hex blob or an arbitrary label (e.g. "evm-fuzz-7" + # from spamoor YAML exports). Parse as hex when it looks hex-shaped; + # otherwise hash the raw UTF-8 bytes so every label maps to a stable + # 32-byte seed. + if not seed_hex: + seed_bytes = b"" + else: + hx = seed_hex[2:] if seed_hex.startswith("0x") else seed_hex + try: + seed_bytes = bytes.fromhex(hx) + except ValueError: + seed_bytes = hashlib.sha256(seed_hex.encode("utf-8")).digest() # Size is deterministic per tx within [min_size, max_size]. size_span = max_size - min_size + 1 size = min_size + ( From 8d89e82f80ff76dbff59acdc80d8175ccc911a2d Mon Sep 17 00:00:00 2001 From: Dmytro Biloshytskyi Date: Tue, 28 Apr 2026 12:46:09 +0300 Subject: [PATCH 08/11] Migrated to wallet pool to meet spamoor logic --- .../plugins/spamoor/spamoor.py | 510 +++++++++++++++++- .../plugins/spamoor/submitter.py | 346 ++++++++++++ .../plugins/spamoor/wallet_pool.py | 216 ++++++++ .../testing_build_block.py | 24 +- tests/benchmark/spamoor/helpers.py | 107 ++-- tests/benchmark/spamoor/pool_runner.py | 239 ++++++++ tests/benchmark/spamoor/test_blob_combined.py | 43 +- tests/benchmark/spamoor/test_calltx.py | 229 ++++++-- tests/benchmark/spamoor/test_deploytx.py | 29 +- tests/benchmark/spamoor/test_eoatx.py | 40 +- tests/benchmark/spamoor/test_erc20_bloater.py | 49 +- tests/benchmark/spamoor/test_erc20tx.py | 49 +- tests/benchmark/spamoor/test_evm_fuzz.py | 30 +- tests/benchmark/spamoor/test_gasburnertx.py | 31 +- tests/benchmark/spamoor/test_uniswap_swaps.py | 31 +- .../test_calltx_committed.py | 287 ++++++++-- 16 files changed, 1989 insertions(+), 271 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/submitter.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/wallet_pool.py create mode 100644 tests/benchmark/spamoor/pool_runner.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py index 4b395243146..b2c9443069a 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py @@ -1,5 +1,6 @@ """Pytest plugin: CLI options and fixtures for spamoor scenarios.""" +import warnings from pathlib import Path from typing import Any, Callable, Dict, List, Optional @@ -8,6 +9,10 @@ import yaml +class SpamoorYAMLWarning(UserWarning): + """Surface-level warning for YAML scenario overlay issues.""" + + # YAML key → spamoor_config key. Infrastructure keys (endpoint, count, # private_key, from_addr) are intentionally absent so that a CLI invocation # keeps full control of them regardless of the config file. @@ -24,9 +29,104 @@ "throughput": "throughput", "random_target": "random_target", "random_amount": "random_amount", - "seed": "payload_seed", + "seed": "wallet_seed", + "total_count": "total_count", + "max_wallets": "max_wallets", + "max_pending": "max_pending", + "rebroadcast": "rebroadcast", } +# Config-level keys understood by the overlay (either via _YAML_TO_CONFIG_KEY +# or the custom fee handling below). +_MAPPED_YAML_KEYS = set(_YAML_TO_CONFIG_KEY) | { + "base_fee", + "base_fee_wei", + "tip_fee", + "tip_fee_wei", + "refill_amount", + "refill_balance", +} + +# Config-level keys that EST intentionally ignores (infrastructure concerns +# with no EST-side analogue). No warning, no strict failure — they're common +# in spammer exports and carry no actionable signal. +_IGNORED_YAML_KEYS = { + "client_group", + "deploy_client_group", + "refill_interval", + "log_txs", + "timeout", +} + +# Top-level (sibling of `config`) keys allowed on each scenario entry. +_ALLOWED_TOP_LEVEL_KEYS = {"scenario", "name", "description", "config"} + + +def _is_empty(value: Any) -> bool: + """Return True when a YAML value carries no user intent.""" + if value is None or value is False: + return True + if isinstance(value, (str, list, dict, tuple, set)) and len(value) == 0: + return True + if isinstance(value, (int, float)) and value == 0: + return True + return False + + +def _validate_yaml_keys( + scenario_entry: Dict[str, Any], *, strict: bool +) -> None: + """ + Check a scenario entry for unknown keys. + + Empty/zero/null/false values for unknown keys always emit a warning. + Non-empty values for unknown keys warn in non-strict mode and raise + ``pytest.UsageError`` when ``strict=True``. + """ + name = scenario_entry.get("name") or "" + yaml_cfg = scenario_entry.get("config") or {} + + unknown_nonempty: List[str] = [] + + for key in scenario_entry: + if key in _ALLOWED_TOP_LEVEL_KEYS: + continue + value = scenario_entry[key] + if _is_empty(value): + warnings.warn( + f"scenario {name!r}: unknown top-level key {key!r} " + f"(empty value ignored)", + SpamoorYAMLWarning, + stacklevel=2, + ) + else: + unknown_nonempty.append(f"{key}={value!r} (top-level)") + + if isinstance(yaml_cfg, dict): + for key, value in yaml_cfg.items(): + if key in _MAPPED_YAML_KEYS or key in _IGNORED_YAML_KEYS: + continue + if _is_empty(value): + warnings.warn( + f"scenario {name!r}: unknown config key {key!r} " + f"(empty value ignored)", + SpamoorYAMLWarning, + stacklevel=2, + ) + else: + unknown_nonempty.append(f"config.{key}={value!r}") + + if not unknown_nonempty: + return + + msg = ( + f"scenario {name!r}: the following YAML keys are not supported by " + f"the EST overlay: " + ", ".join(unknown_nonempty) + ) + if strict: + raise pytest.UsageError(msg) + warnings.warn(msg, SpamoorYAMLWarning, stacklevel=2) + def _load_scenario_from_yaml(path: str, index: int) -> Dict[str, Any]: """Return the YAML list entry at *index*, validating structure.""" @@ -68,7 +168,8 @@ def _overlay_yaml_on_config( ``base_fee`` / ``tip_fee`` in the YAML are gwei floats; the ``*_wei`` variants, when non-empty, take precedence and are interpreted as - decimal integers. + decimal integers. When ``total_count`` is set, it also overrides + ``cfg["count"]`` — YAML is authoritative when a scenario file is in use. """ yaml_cfg = scenario_entry.get("config") or {} if not isinstance(yaml_cfg, dict): @@ -110,6 +211,20 @@ def _overlay_yaml_on_config( if "throughput" in yaml_cfg and yaml_cfg["throughput"] is not None: cfg["throughput"] = float(yaml_cfg["throughput"]) + total_count = yaml_cfg.get("total_count") + if isinstance(total_count, int) and total_count > 0: + cfg["count"] = total_count + + # Wallet-pool refill amounts: YAML stores them in wei as Python ints + # (the export tool deliberately keeps the precise wei value). Surface + # under the *_wei keys consumed by spamoor_wallet_pool. + refill_amount = yaml_cfg.get("refill_amount") + if isinstance(refill_amount, int) and refill_amount > 0: + cfg["refill_amount_wei"] = refill_amount + refill_balance = yaml_cfg.get("refill_balance") + if isinstance(refill_balance, int) and refill_balance > 0: + cfg["refill_balance_wei"] = refill_balance + cfg["yaml_scenario"] = scenario_entry @@ -392,6 +507,41 @@ def pytest_addoption(parser: pytest.Parser) -> None: "(contract_address, fees, gas_limit, ...) onto the fixture." ), ) + group.addoption( + "--spamoor-max-count", + dest="spamoor_max_count", + type=int, + default=None, + help=( + "Cap for the effective tx count. When set, overrides YAML " + "``total_count`` and any lower-priority source. Intended for " + "local runs against spammer exports whose ``total_count`` " + "values are production-scale." + ), + ) + group.addoption( + "--spamoor-skip-assert", + dest="spamoor_skip_assert", + action="store_true", + default=False, + help=( + "Submit-only mode: build and broadcast every tx, but do not " + "fail the test if some txs never mine or if a block commit " + "call errors. Intended for bloat-style load runs where the " + "goal is to hammer the client, not to assert receipts." + ), + ) + group.addoption( + "--spamoor-strict", + dest="spamoor_strict", + action="store_true", + default=False, + help=( + "Fail the run when the selected YAML scenario uses keys the " + "EST overlay does not support. Empty/zero values always " + "produce a warning only." + ), + ) group.addoption( "--spamoor-scenario-index", dest="spamoor_scenario_index", @@ -399,6 +549,67 @@ def pytest_addoption(parser: pytest.Parser) -> None: default=0, help="0-indexed scenario entry to load from --spamoor-config-file.", ) + group.addoption( + "--spamoor-max-wallets", + dest="spamoor_max_wallets", + type=int, + default=0, + help=( + "Number of HD-derived child wallets to spread tx load across. " + "0 (default) auto-derives clamp(total_count / 50, 10, 1000) " + "to match upstream spamoor." + ), + ) + group.addoption( + "--spamoor-max-pending", + dest="spamoor_max_pending", + type=int, + default=0, + help=( + "Maximum in-flight (submitted-but-not-confirmed) transactions. " + "0 (default) auto-derives clamp(throughput * 10, 100, 4000)." + ), + ) + group.addoption( + "--spamoor-rebroadcast", + dest="spamoor_rebroadcast", + type=int, + default=1, + help=( + "Slots without a receipt before re-broadcasting a stuck tx " + "(0 disables rebroadcast)." + ), + ) + group.addoption( + "--spamoor-refill-amount", + dest="spamoor_refill_amount", + type=int, + default=0, + help=( + "Wei sent from the root wallet to each child during pool " + "preparation (0 falls back to the spamoor default 5e18)." + ), + ) + group.addoption( + "--spamoor-refill-balance", + dest="spamoor_refill_balance", + type=int, + default=0, + help=( + "Minimum wei balance each child must reach during pool " + "preparation (0 falls back to the spamoor default 1e18)." + ), + ) + group.addoption( + "--spamoor-wallet-seed", + dest="spamoor_wallet_seed", + type=str, + default="", + help=( + "Seed string mixed into HD wallet derivation. Blank picks the " + "value from the YAML scenario or empty string." + ), + ) def pytest_configure(config: pytest.Config) -> None: @@ -463,14 +674,52 @@ def spamoor_config(request: pytest.FixtureRequest) -> Dict[str, Any]: "slots_per_call": request.config.getoption("spamoor_slots_per_call"), "bytecodes": request.config.getoption("spamoor_bytecodes"), "bytecodes_file": request.config.getoption("spamoor_bytecodes_file"), + "skip_assert": bool( + request.config.getoption("spamoor_skip_assert") + ), + # Wallet-pool / submitter knobs. Defaults of 0 mean "auto-derive + # from total_count / throughput at fixture-build time", matching + # upstream spamoor (txscenario.go:140-313). + "max_wallets": int( + request.config.getoption("spamoor_max_wallets") + ), + "max_pending": int( + request.config.getoption("spamoor_max_pending") + ), + "rebroadcast": int( + request.config.getoption("spamoor_rebroadcast") + ), + "refill_amount_wei": int( + request.config.getoption("spamoor_refill_amount") + ), + "refill_balance_wei": int( + request.config.getoption("spamoor_refill_balance") + ), + "wallet_seed": str( + request.config.getoption("spamoor_wallet_seed") + ), } config_file = request.config.getoption("spamoor_config_file") if config_file: index = int(request.config.getoption("spamoor_scenario_index")) entry = _load_scenario_from_yaml(config_file, index) + strict = bool(request.config.getoption("spamoor_strict")) + # Also honour --bloat-strict when the bloat plugin is loaded. + if not strict: + try: + strict = bool(request.config.getoption("bloat_strict")) + except (ValueError, KeyError): + pass + _validate_yaml_keys(entry, strict=strict) _overlay_yaml_on_config(cfg, entry) + max_count = request.config.getoption("spamoor_max_count") + if max_count is not None and int(max_count) > 0: + cap = int(max_count) + if int(cfg.get("count", 0)) > cap: + cfg["count"] = cap + return cfg @@ -478,7 +727,14 @@ def spamoor_config(request: pytest.FixtureRequest) -> Dict[str, Any]: def spamoor_rpc_client( spamoor_config: Dict[str, Any], ) -> Callable[[str, List[Any]], Any]: - """Return a minimal JSON-RPC call helper bound to the configured RPC.""" + """Return a minimal JSON-RPC call helper bound to the configured RPC. + + The helper still returns ``None`` on failure to keep the hot path + non-throwing, but it stashes the most recent error message in + ``rpc_call.last_error`` so callers (e.g. the submitter's + AssertionError formatter) can surface why a send failed without + having to retry. + """ endpoint = spamoor_config["endpoint"] def rpc_call(method: str, params: List[Any]) -> Optional[Any]: @@ -491,8 +747,252 @@ def rpc_call(method: str, params: List[Any]) -> Optional[Any]: } resp = requests.post(endpoint, json=payload, timeout=5) resp.raise_for_status() - return resp.json().get("result") - except Exception: + body = resp.json() + if "error" in body: + rpc_call.last_error = ( # type: ignore[attr-defined] + f"{method}: {body['error']!r}" + ) + return None + rpc_call.last_error = None # type: ignore[attr-defined] + return body.get("result") + except Exception as exc: + rpc_call.last_error = ( # type: ignore[attr-defined] + f"{method}: {exc!r}" + ) return None + rpc_call.last_error = None # type: ignore[attr-defined] return rpc_call + + +def _verbose_rpc( + endpoint: str, method: str, params: List[Any] +) -> Any: + """Direct RPC call that surfaces JSON-RPC errors via RuntimeError. + + The session-scoped ``spamoor_rpc_client`` deliberately swallows errors + so the tx-submission hot path stays single-return; this variant is + used by setup paths where the error message is more useful than a + silent ``None``. + """ + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + } + resp = requests.post(endpoint, json=payload, timeout=10) + resp.raise_for_status() + body = resp.json() + if "error" in body: + raise RuntimeError( + f"RPC {method} returned error: {body['error']!r}" + ) + return body.get("result") + + +def resolve_pool_sizing(spamoor_config: Dict[str, Any]) -> Dict[str, int]: + """ + Auto-derive `max_wallets` / `max_pending` from the config when 0. + + Mirrors txscenario.go:140-313: ``max_wallets = + clamp(total_count // 50, 10, 1000)``; ``max_pending = + clamp(throughput * 10, 100, 4000)``. Explicit non-zero values pass + through untouched. + """ + from .wallet_pool import default_max_wallets + + total = int(spamoor_config.get("count", 0)) + explicit_w = int(spamoor_config.get("max_wallets", 0)) + if explicit_w > 0: + max_wallets = explicit_w + else: + max_wallets = default_max_wallets(total) if total > 0 else 10 + + explicit_p = int(spamoor_config.get("max_pending", 0)) + if explicit_p > 0: + max_pending = explicit_p + else: + throughput = float(spamoor_config.get("throughput", 0) or 0) + proposed = int(throughput * 10) if throughput > 0 else 100 + max_pending = max(100, min(4000, proposed)) + + return {"max_wallets": max_wallets, "max_pending": max_pending} + + +@pytest.fixture +def spamoor_wallet_pool( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, List[Any]], Any], +) -> Any: + """ + Build, fund, and yield a :class:`WalletPool` matching the YAML scenario. + + Mirrors upstream spamoor's pool prepare-and-fund step + (``walletpool.go:560-638``). Skips the test when a private key / + endpoint is not configured. + + The fixture is function-scoped on purpose. Upstream spamoor refills + children continuously via the ``refill_interval`` watcher on the + wallet pool; EST does not yet have an in-workload refill loop, so we + re-fund deterministically before each test instead. Children whose + balance is already above the threshold are skipped, so a re-fund is + a no-op when the previous test left them well-stocked. + + The effective refill amount is the maximum of the YAML's + ``refill_amount``, the YAML's ``refill_balance`` top-up math, AND + the workload's expected per-wallet gas reservation (``count / + max_wallets`` txs × ``gas`` × ``maxFeePerGas`` × 1.5 safety + factor). This guarantees each child has enough balance to cover + the txpool reservation imposed by Nethermind, which would otherwise + reject the second tx with ``InsufficientFunds``. + + The funding batch is sent via ``eth_sendRawTransaction`` from the root + EOA, signed using the existing tx_convert helper. We block until each + funding tx is observed in a receipt before yielding so the children + have spendable balance when the workload starts. + """ + import time + + from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, + ) + + from .wallet_pool import ( + DEFAULT_REFILL_AMOUNT_WEI, + DEFAULT_REFILL_BALANCE_WEI, + WalletPool, + ) + + private_key = spamoor_config.get("private_key") + if not private_key: + pytest.skip("spamoor private_key not configured") + + chain_hex = spamoor_rpc_client("eth_chainId", []) + if not isinstance(chain_hex, str): + pytest.skip("spamoor endpoint unreachable (eth_chainId failed)") + chain_id = int(chain_hex, 16) + + sizing = resolve_pool_sizing(spamoor_config) + yaml_refill_amount = ( + int(spamoor_config.get("refill_amount_wei") or 0) + or DEFAULT_REFILL_AMOUNT_WEI + ) + yaml_refill_balance = ( + int(spamoor_config.get("refill_balance_wei") or 0) + or DEFAULT_REFILL_BALANCE_WEI + ) + + # Compute the upper bound of gas reservation each wallet needs. The + # txpool charges `gas * maxFeePerGas + value` per pending tx; a wallet + # round-robin'd into receives `count // max_wallets` txs, sometimes one + # more. Apply a 1.5x safety factor so the wallet survives a partial + # refund / repeat run that doubles up on a single account. + count = int(spamoor_config.get("count") or 0) + max_wallets = sizing["max_wallets"] + txs_per_wallet = max(1, (count + max_wallets - 1) // max_wallets) + gas = int(spamoor_config.get("gas_limit") or 0) or 500_000 + basefee = int(spamoor_config.get("basefee") or 1_000_000_000) + tip = int(spamoor_config.get("tip_fee") or 1_000_000_000) + max_fee_per_gas = max(basefee, tip) + value = int(spamoor_config.get("amount") or 0) + expected_per_wallet = int( + (gas * max_fee_per_gas + value) * txs_per_wallet * 1.5 + ) + + refill_amount = max(yaml_refill_amount, expected_per_wallet) + refill_balance = max(yaml_refill_balance, expected_per_wallet) + seed = str(spamoor_config.get("wallet_seed") or "") + + pool = WalletPool( + private_key, + seed=seed, + count=sizing["max_wallets"], + refill_amount_wei=refill_amount, + refill_balance_wei=refill_balance, + ) + + endpoint = spamoor_config["endpoint"] + + # Sync root nonce — surface the error rather than silently skipping. + root_addr = str(pool.root_eoa) + nonce_hex = _verbose_rpc( + endpoint, "eth_getTransactionCount", [root_addr, "pending"] + ) + if not isinstance(nonce_hex, str): + pytest.skip("eth_getTransactionCount(root) failed") + root_nonce = int(nonce_hex, 16) + + funding = pool.prepare(spamoor_rpc_client) + if funding: + # basefee + tip large enough to land quickly on a fresh devnet. + basefee = int(spamoor_config.get("basefee") or 1_000_000_000) + tip = int(spamoor_config.get("tip_fee") or 1_000_000_000) + max_fee = max(basefee * 2, tip * 2) + tx_hashes: List[str] = [] + send_errors: List[str] = [] + for f in funding: + tx_dict = { + "type": 2, + "to": f.to_address, + "value": f.value_wei, + "data": "", + "gas": 21000, + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": tip, + "chainId": 1, # overridden by tx_convert + "accessList": [], + } + tx = spamoor_dict_to_transaction( + tx_dict, + pool.root_eoa, + chain_id, + nonce_override=root_nonce, + ) + root_nonce += 1 + raw = tx.rlp().hex() + if not raw.startswith("0x"): + raw = "0x" + raw + try: + h = _verbose_rpc(endpoint, "eth_sendRawTransaction", [raw]) + except Exception as exc: + send_errors.append(f"{f.to_address}: {exc}") + continue + if isinstance(h, str) and h.startswith("0x"): + tx_hashes.append(h) + else: + send_errors.append( + f"{f.to_address}: unexpected response {h!r}" + ) + + if send_errors and not tx_hashes: + raise RuntimeError( + "spamoor_wallet_pool: every funding send failed.\n" + + "\n".join(send_errors) + ) + if send_errors: + print( + "spamoor_wallet_pool: partial funding " + f"({len(tx_hashes)} ok, {len(send_errors)} failed):\n" + + "\n".join(send_errors) + ) + + # Wait for funding receipts (max 120 s) so children have balance. + deadline = time.time() + 120.0 + pending = list(tx_hashes) + while pending and time.time() < deadline: + still_pending: List[str] = [] + for h in pending: + r = spamoor_rpc_client("eth_getTransactionReceipt", [h]) + if r is None: + still_pending.append(h) + pending = still_pending + if pending: + time.sleep(1.0) + if pending: + pytest.skip( + f"wallet pool funding incomplete: {len(pending)} of " + f"{len(tx_hashes)} funding txs unmined after 120 s" + ) + + return pool diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/submitter.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/submitter.py new file mode 100644 index 00000000000..ff8106fb183 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/submitter.py @@ -0,0 +1,346 @@ +""" +Rate-limited concurrent transaction submitter for the spamoor pytest path. + +Mirrors the upstream Go implementation: + +- Token-bucket rate limiter sized off ``throughput / slot_seconds`` + (``.lab/spamoor/scenario/txscenario.go:115-312``). +- ``max_pending`` cap enforced by a counting semaphore + condition + variable (same file, lines 230-313). +- Per-tx watcher thread polls receipts, advances per-wallet + confirmed_nonce, and rebroadcasts stale txs with bounded retries + (``.lab/spamoor/spamoor/submitter.go``). + +The submitter takes a *builder* callable that receives an assigned +:class:`Wallet` plus the global tx index and returns a raw tx dict +matching the existing ``tests/benchmark/spamoor/helpers.py`` shape. +The submitter signs each tx with the assigned wallet and broadcasts it +via ``eth_sendRawTransaction``. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from .wallet_pool import Wallet, WalletPool + + +SLOT_SECONDS = 12.0 +DEFAULT_REBROADCAST_AFTER_SLOTS = 1 +MAX_REBROADCASTS = 4 + + +# --- Rate limiter ------------------------------------------------------------ + + +class _TokenBucket: + """Simple monotonic-clock token bucket. + + `rate_per_sec` tokens per real-time second, with a 1-second burst + capacity. ``acquire()`` blocks until a token is available. + """ + + def __init__(self, rate_per_sec: float) -> None: + if rate_per_sec <= 0: + raise ValueError("rate_per_sec must be > 0") + self.rate = rate_per_sec + self.capacity = max(rate_per_sec, 1.0) + self._tokens = self.capacity + self._last = time.monotonic() + self._lock = threading.Lock() + self._cond = threading.Condition(self._lock) + + def _refill_locked(self) -> None: + now = time.monotonic() + delta = now - self._last + if delta > 0: + self._tokens = min(self.capacity, self._tokens + delta * self.rate) + self._last = now + + def acquire(self, stop_event: threading.Event) -> bool: + """Block until a token is available (or stop_event is set).""" + with self._cond: + while not stop_event.is_set(): + self._refill_locked() + if self._tokens >= 1.0: + self._tokens -= 1.0 + return True + missing = 1.0 - self._tokens + wait = missing / self.rate + self._cond.wait(timeout=wait) + return False + + +# --- Result types ------------------------------------------------------------ + + +@dataclass +class TxRecord: + index: int + wallet_address: str + nonce: int + tx_hash: str + submitted_at: float + confirmed_at: Optional[float] = None + rebroadcasts: int = 0 + receipt: Optional[Dict[str, Any]] = None + failed: bool = False + error: Optional[str] = None + + +@dataclass +class WorkloadResult: + submitted: int = 0 + confirmed: int = 0 + failed: int = 0 + pending: int = 0 + wall_clock_seconds: float = 0.0 + records: List[TxRecord] = field(default_factory=list) + + def asserts_pass(self) -> bool: + return self.failed == 0 and self.pending == 0 + + +# --- Submitter -------------------------------------------------------------- + + +def _sign_and_serialize( + tx_dict: Dict[str, Any], + wallet: Wallet, + chain_id: int, + nonce: int, + fork: Any | None, + blob_seed: int, +) -> str: + # Imported lazily to keep this module light when only the rate limiter + # is used (e.g. unit tests of the rate limiter alone). + from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, + ) + tx = spamoor_dict_to_transaction( + tx_dict, + wallet.eoa, + chain_id, + nonce_override=nonce, + fork=fork, + blob_seed=blob_seed, + ) + raw = tx.rlp().hex() + if not raw.startswith("0x"): + raw = "0x" + raw + return raw + + +def submit_workload( + *, + builder: Callable[[Wallet, int, int], Dict[str, Any]], + pool: WalletPool, + rpc_client: Callable[[str, List[Any]], Any], + chain_id: int, + total_count: int, + throughput: float, + max_pending: int, + rebroadcast_after: float = SLOT_SECONDS * DEFAULT_REBROADCAST_AFTER_SLOTS, + poll_interval: float = 1.0, + drain_timeout: Optional[float] = None, + fork: Any | None = None, + blob_seed: int = 0, + skip_assert: bool = False, +) -> WorkloadResult: + """ + Drive ``total_count`` transactions across the wallet pool. + + The caller supplies *builder*, invoked as + ``builder(wallet, global_index, nonce)`` and returning a raw tx dict + (the existing ``helpers.py`` shape: ``type``, ``to``, ``value``, + ``data``, ``gas``, ``maxFeePerGas``, ``maxPriorityFeePerGas``, + ``accessList``). + + Submission is paced at ``throughput`` tx/sec and capped at + ``max_pending`` in-flight transactions. Each watcher polls + ``eth_getTransactionReceipt`` every ``poll_interval`` seconds and + rebroadcasts the same raw bytes after ``rebroadcast_after`` seconds + of silence (up to ``MAX_REBROADCASTS`` times) before marking the tx + failed. + + Returns a :class:`WorkloadResult`. With ``skip_assert=True`` the + function never raises on partial inclusion — used for bloat-style + load runs where dropped/pending receipts are expected. + """ + if total_count <= 0: + return WorkloadResult() + if max_pending <= 0: + raise ValueError("max_pending must be positive") + + if drain_timeout is None: + # Empirically the kurtosis EL confirms ~16 tx/sec on this workload + # (~50 tx/block × 6 s slot under preset:minimal); a 1.5× safety + # factor leaves headroom for funding-warmup and basefee bumps. + # The 120 s floor covers the cold-start case where the first + # block lands a slot late. + expected_secs = (total_count / 10.0) + 60.0 + drain_timeout = max(120.0, expected_secs) + + limiter = _TokenBucket(throughput) if throughput > 0 else None + pending_sem = threading.BoundedSemaphore(max_pending) + stop_event = threading.Event() + rpc_lock = threading.Lock() + + records: List[TxRecord] = [] + records_lock = threading.Lock() + confirmed_event = threading.Event() + watcher_threads: List[threading.Thread] = [] + + def call_rpc(method: str, params: List[Any]) -> Any: + with rpc_lock: + return rpc_client(method, params) + + def watch(record: TxRecord, raw_hex: str, wallet: Wallet) -> None: + deadline = record.submitted_at + drain_timeout + next_rebroadcast = record.submitted_at + rebroadcast_after + while not stop_event.is_set(): + now = time.monotonic() + if now >= deadline: + record.failed = True + record.error = "drain_timeout" + break + try: + receipt = call_rpc( + "eth_getTransactionReceipt", [record.tx_hash] + ) + except Exception as exc: # pragma: no cover - defensive + receipt = None + record.error = f"poll_error: {exc}" + if receipt: + record.receipt = receipt + record.confirmed_at = time.monotonic() + wallet.mark_confirmed(record.nonce) + break + if ( + time.monotonic() >= next_rebroadcast + and record.rebroadcasts < MAX_REBROADCASTS + ): + # Skip the rebroadcast when the tx is still known to the + # node (mempool or chain) — Nethermind replies + # ``AlreadyKnown`` and the resend just spams the log. + # ``eth_getTransactionByHash`` returns the tx for both + # pending and mined states; only ``None`` means the node + # has forgotten the tx and we need to re-send. + try: + known = call_rpc( + "eth_getTransactionByHash", [record.tx_hash] + ) + except Exception: # pragma: no cover - defensive + known = None + if known is None: + try: + call_rpc("eth_sendRawTransaction", [raw_hex]) + except Exception: # already-known is fine + pass + record.rebroadcasts += 1 + next_rebroadcast = time.monotonic() + rebroadcast_after + time.sleep(poll_interval) + try: + pending_sem.release() + except ValueError: + # Already released because of an early submission failure. + pass + confirmed_event.set() + + start = time.monotonic() + submitted = 0 + for i in range(total_count): + if limiter is not None and not limiter.acquire(stop_event): + break + pending_sem.acquire() + wallet = pool.pick() + nonce = wallet.next_nonce() + try: + tx_dict = builder(wallet, i, nonce) + raw_hex = _sign_and_serialize( + tx_dict, wallet, chain_id, nonce, fork, blob_seed + ) + tx_hash = call_rpc("eth_sendRawTransaction", [raw_hex]) + if not (isinstance(tx_hash, str) and tx_hash.startswith("0x")): + # rpc_client may stash a structured error here; surface it. + last_err = getattr(rpc_client, "last_error", None) + detail = f"; last_error={last_err}" if last_err else "" + raise RuntimeError( + f"eth_sendRawTransaction returned {tx_hash!r}{detail}" + ) + except Exception as exc: + with records_lock: + rec = TxRecord( + index=i, + wallet_address=wallet.address, + nonce=nonce, + tx_hash="", + submitted_at=time.monotonic(), + failed=True, + error=f"submit_error: {exc}", + ) + records.append(rec) + pending_sem.release() + continue + + record = TxRecord( + index=i, + wallet_address=wallet.address, + nonce=nonce, + tx_hash=tx_hash, + submitted_at=time.monotonic(), + ) + with records_lock: + records.append(record) + submitted += 1 + t = threading.Thread( + target=watch, args=(record, raw_hex, wallet), daemon=True + ) + t.start() + watcher_threads.append(t) + + deadline = time.monotonic() + drain_timeout + for t in watcher_threads: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + t.join(timeout=remaining) + stop_event.set() + + confirmed = sum( + 1 for r in records if r.receipt is not None and not r.failed + ) + failed = sum(1 for r in records if r.failed) + pending = sum( + 1 for r in records if r.receipt is None and not r.failed + ) + result = WorkloadResult( + submitted=submitted, + confirmed=confirmed, + failed=failed, + pending=pending, + wall_clock_seconds=time.monotonic() - start, + records=records, + ) + if not skip_assert and not result.asserts_pass(): + sample = next( + (r.error for r in records if r.error), "" + ) + raise AssertionError( + f"workload incomplete: submitted={result.submitted} " + f"confirmed={result.confirmed} pending={result.pending} " + f"failed={result.failed}; first error: {sample}" + ) + return result + + +__all__ = [ + "MAX_REBROADCASTS", + "SLOT_SECONDS", + "TxRecord", + "WorkloadResult", + "submit_workload", +] diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/wallet_pool.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/wallet_pool.py new file mode 100644 index 00000000000..edce7b27513 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/wallet_pool.py @@ -0,0 +1,216 @@ +""" +HD-derived wallet pool for spamoor-style multi-wallet load generation. + +Mirrors the upstream Go implementation at +``.lab/spamoor/spamoor/walletpool.go`` (``prepareChildWallet``, +``calculateFundingAmount``) and ``.lab/spamoor/spamoor/wallet.go`` +(``GetNextNonce``, pending/confirmed split). The derivation is byte-for-byte +identical so a `(root_key, seed, count)` tuple yields the same child +addresses as the upstream binary — required for the EST committed-path +tests to enqueue from the same accounts as the spamoor-pytest path. +""" + +from __future__ import annotations + +import hashlib +import struct +import threading +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional + +from execution_testing.test_types import EOA + + +# Defaults matching .lab/spamoor/scenario/txscenario.go:140-165 and +# .lab/spamoor/spamoor/walletpool_config.go. +DEFAULT_MAX_WALLETS_MIN = 10 +DEFAULT_MAX_WALLETS_MAX = 1000 +DEFAULT_REFILL_AMOUNT_WEI = 5 * 10**18 +DEFAULT_REFILL_BALANCE_WEI = 1 * 10**18 + + +def derive_child_key(root_key: bytes, idx: int, seed: str = "") -> bytes: + """ + Derive a 32-byte child private key from the root key. + + Mirrors ``prepareChildWallet`` in walletpool.go:573-590: + ``sha256(root_priv || u64_be(idx) || seed_bytes)``. + """ + if len(root_key) != 32: + raise ValueError( + f"root_key must be 32 raw bytes, got {len(root_key)}" + ) + payload = root_key + struct.pack(">Q", idx) + seed.encode("utf-8") + return hashlib.sha256(payload).digest() + + +def default_max_wallets(total_count: int) -> int: + """Match `txscenario.go:159-165`: clamp(total_count // 50, 10, 1000).""" + proposed = max(1, total_count // 50) + return max(DEFAULT_MAX_WALLETS_MIN, min(DEFAULT_MAX_WALLETS_MAX, proposed)) + + +def _normalize_root_key(key: str | bytes) -> bytes: + if isinstance(key, str): + key = key.removeprefix("0x") + key = bytes.fromhex(key) + if len(key) != 32: + raise ValueError( + f"root key must be 32 bytes, got {len(key)}" + ) + return key + + +@dataclass +class Wallet: + """A single derived wallet with thread-safe nonce tracking. + + pending_nonce is the next nonce to hand out; confirmed_nonce is the + highest nonce known to be mined. Mirrors the split in wallet.go:300-348. + """ + + eoa: EOA + pending_nonce: int = 0 + confirmed_nonce: int = -1 + _lock: threading.Lock = field( + default_factory=threading.Lock, repr=False, compare=False + ) + + @property + def address(self) -> str: + return str(self.eoa) + + @property + def key_hex(self) -> str: + assert self.eoa.key is not None + return f"0x{bytes(self.eoa.key).hex()}" + + def next_nonce(self) -> int: + """Atomically reserve the next pending nonce.""" + with self._lock: + n = self.pending_nonce + self.pending_nonce = n + 1 + return n + + def mark_confirmed(self, nonce: int) -> None: + """Advance confirmed_nonce; never moves backward.""" + with self._lock: + if nonce > self.confirmed_nonce: + self.confirmed_nonce = nonce + if nonce >= self.pending_nonce: + self.pending_nonce = nonce + 1 + + def in_flight(self) -> int: + with self._lock: + return max(0, self.pending_nonce - self.confirmed_nonce - 1) + + def sync_pending(self, pending_nonce: int) -> None: + """Sync pending_nonce from chain (e.g. after a re-run).""" + with self._lock: + if pending_nonce > self.pending_nonce: + self.pending_nonce = pending_nonce + + +@dataclass +class FundingTx: + """A pending root → child transfer emitted by ``WalletPool.prepare()``.""" + + to_address: str + value_wei: int + child_index: int + + +class WalletPool: + """A deterministic pool of HD-derived child wallets.""" + + def __init__( + self, + root_key: str | bytes, + *, + seed: str = "", + count: int, + refill_amount_wei: int = DEFAULT_REFILL_AMOUNT_WEI, + refill_balance_wei: int = DEFAULT_REFILL_BALANCE_WEI, + ) -> None: + if count <= 0: + raise ValueError("count must be positive") + root_bytes = _normalize_root_key(root_key) + self.root_key = root_bytes + self.root_eoa = EOA(key=root_bytes) + self.seed = seed + self.refill_amount_wei = refill_amount_wei + self.refill_balance_wei = refill_balance_wei + + self.wallets: List[Wallet] = [ + Wallet(eoa=EOA(key=derive_child_key(root_bytes, i, seed))) + for i in range(count) + ] + self._rr_lock = threading.Lock() + self._rr_idx = 0 + + def __len__(self) -> int: + return len(self.wallets) + + def by_index(self, idx: int) -> Wallet: + return self.wallets[idx] + + def pick(self) -> Wallet: + """Round-robin wallet selection.""" + with self._rr_lock: + w = self.wallets[self._rr_idx % len(self.wallets)] + self._rr_idx += 1 + return w + + def prepare( + self, + rpc_client: Callable[[str, List[Any]], Any], + ) -> List[FundingTx]: + """Compute funding transfers needed to bring each child up to balance. + + Mirrors ``calculateFundingAmount`` in walletpool.go:626-638: + ``funding = max(refill_amount, refill_balance - current_balance)`` + when ``current_balance < refill_balance``; emit nothing otherwise. + + Also synchronizes each wallet's ``pending_nonce`` with whatever the + EL already remembers — important on re-runs against a non-clean + kurtosis enclave so we don't reuse mined nonces. + """ + funding: List[FundingTx] = [] + for idx, wallet in enumerate(self.wallets): + balance_hex = rpc_client( + "eth_getBalance", [wallet.address, "pending"] + ) + current = int(balance_hex, 16) if isinstance(balance_hex, str) \ + else 0 + nonce_hex = rpc_client( + "eth_getTransactionCount", [wallet.address, "pending"] + ) + if isinstance(nonce_hex, str): + wallet.sync_pending(int(nonce_hex, 16)) + wallet.confirmed_nonce = wallet.pending_nonce - 1 + + if current >= self.refill_balance_wei: + continue + need = self.refill_balance_wei - current + amount = max(self.refill_amount_wei, need) + funding.append( + FundingTx( + to_address=wallet.address, + value_wei=amount, + child_index=idx, + ) + ) + return funding + + +__all__ = [ + "DEFAULT_MAX_WALLETS_MAX", + "DEFAULT_MAX_WALLETS_MIN", + "DEFAULT_REFILL_AMOUNT_WEI", + "DEFAULT_REFILL_BALANCE_WEI", + "FundingTx", + "Wallet", + "WalletPool", + "default_max_wallets", + "derive_child_key", +] diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py index 4f85512f707..62dbf5ca1d7 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py @@ -109,6 +109,16 @@ def pytest_addoption(parser: pytest.Parser) -> None: default=0, help="0-indexed scenario entry to load from --bloat-config-file.", ) + group.addoption( + "--bloat-strict", + dest="bloat_strict", + action="store_true", + default=False, + help=( + "Fail the run when the selected YAML scenario uses keys the " + "EST overlay does not support. Mirrors --spamoor-strict." + ), + ) def _load_jwt_secret(path: str | None) -> bytes: @@ -161,12 +171,22 @@ def bloat_config(request: pytest.FixtureRequest) -> BloatConfig: pytest.exit("--bloat-signer-key is required") bloat_config_file = request.config.getoption("bloat_config_file") if bloat_config_file: - from ..spamoor.spamoor import _load_scenario_from_yaml + from ..spamoor.spamoor import ( + _load_scenario_from_yaml, + _validate_yaml_keys, + ) - _load_scenario_from_yaml( + entry = _load_scenario_from_yaml( bloat_config_file, int(request.config.getoption("bloat_scenario_index")), ) + strict = bool(request.config.getoption("bloat_strict")) + if not strict: + try: + strict = bool(request.config.getoption("spamoor_strict")) + except (ValueError, KeyError): + pass + _validate_yaml_keys(entry, strict=strict) jwt_secret = _load_jwt_secret( request.config.getoption("bloat_jwt_secret_file") ) diff --git a/tests/benchmark/spamoor/helpers.py b/tests/benchmark/spamoor/helpers.py index 2d8ae1fe760..471289d920a 100644 --- a/tests/benchmark/spamoor/helpers.py +++ b/tests/benchmark/spamoor/helpers.py @@ -76,10 +76,22 @@ def broadcast_and_assert_receipts( timeout: float = 60.0, poll_interval: float = 1.0, allow_reverts: bool = False, + batch_size: int = 256, + skip_assert: bool = False, ) -> Dict[str, Dict[str, Any]]: """ Sign every raw tx, broadcast, poll receipts, assert ``status == 0x1``. + When ``batch_size`` is positive and smaller than the full set, txs are + submitted in chunks and each chunk is drained (all receipts received) + before the next chunk is submitted. This keeps the per-account txpool + from overflowing on large runs. + + Submit-only mode (``skip_assert=True``): every tx is still built and + submitted, but the "all receipts observed" and "status == 0x1" checks + are relaxed. Intended for bloat-style load generation where dropped + or pending receipts are expected. + Returns the receipts keyed by tx hash. Skips the test if the builder produced no txs. Type-3 (blob) transactions cannot be broadcast via ``eth_sendRawTransaction`` with EST's block-form RLP (no sidecars), @@ -102,50 +114,59 @@ def broadcast_and_assert_receipts( chain_id = ctx["chain_id"] start_nonce = int(ctx["start_nonce"]) - tx_hashes: List[str] = [] - for i, tx_dict in enumerate(raw_txs): - _normalize_fees(tx_dict) - tx = spamoor_dict_to_transaction( - tx_dict, - signer, - chain_id, - nonce_override=start_nonce + i, - fork=fork, - blob_seed=blob_seed, - ) - # ``Bytes.hex()`` already includes the ``0x`` prefix. - raw = tx.rlp().hex() - result = rpc_client("eth_sendRawTransaction", [raw]) - assert isinstance(result, str) and result.startswith("0x"), ( - f"eth_sendRawTransaction failed for tx {i} " - f"(type={tx_dict.get('type')} to={tx_dict.get('to')!r} " - f"gas={tx_dict.get('gas')} " - f"maxFee={tx_dict.get('maxFeePerGas')} " - f"tip={tx_dict.get('maxPriorityFeePerGas')} " - f"nonce={start_nonce + i} " - f"data_len={len(tx_dict.get('data', '') or '') // 2}): {result!r}" - ) - tx_hashes.append(result) - - deadline = time.time() + timeout - pending = list(tx_hashes) + total = len(raw_txs) + chunk = batch_size if batch_size and batch_size > 0 else total receipts: Dict[str, Dict[str, Any]] = {} - while pending and time.time() < deadline: - still_pending: List[str] = [] - for h in pending: - receipt = rpc_client("eth_getTransactionReceipt", [h]) - if receipt is None: - still_pending.append(h) - else: - receipts[h] = receipt - pending = still_pending - if pending: - time.sleep(poll_interval) - - assert not pending, ( - f"transactions never mined within {timeout}s: {pending}" - ) - if not allow_reverts: + + for batch_start in range(0, total, chunk): + batch = raw_txs[batch_start : batch_start + chunk] + tx_hashes: List[str] = [] + for offset, tx_dict in enumerate(batch): + i = batch_start + offset + _normalize_fees(tx_dict) + tx = spamoor_dict_to_transaction( + tx_dict, + signer, + chain_id, + nonce_override=start_nonce + i, + fork=fork, + blob_seed=blob_seed, + ) + raw = tx.rlp().hex() + result = rpc_client("eth_sendRawTransaction", [raw]) + assert isinstance(result, str) and result.startswith("0x"), ( + f"eth_sendRawTransaction failed for tx {i} " + f"(type={tx_dict.get('type')} to={tx_dict.get('to')!r} " + f"gas={tx_dict.get('gas')} " + f"maxFee={tx_dict.get('maxFeePerGas')} " + f"tip={tx_dict.get('maxPriorityFeePerGas')} " + f"nonce={start_nonce + i} " + f"data_len={len(tx_dict.get('data', '') or '') // 2}): " + f"{result!r}" + ) + tx_hashes.append(result) + + deadline = time.time() + timeout + pending = list(tx_hashes) + while pending and time.time() < deadline: + still_pending: List[str] = [] + for h in pending: + receipt = rpc_client("eth_getTransactionReceipt", [h]) + if receipt is None: + still_pending.append(h) + else: + receipts[h] = receipt + pending = still_pending + if pending: + time.sleep(poll_interval) + + if pending and not skip_assert: + assert not pending, ( + f"batch {batch_start}..{batch_start + len(batch)} never " + f"mined within {timeout}s: {pending}" + ) + + if not allow_reverts and not skip_assert: for h, r in receipts.items(): status = r.get("status") assert status == "0x1", ( diff --git a/tests/benchmark/spamoor/pool_runner.py b/tests/benchmark/spamoor/pool_runner.py new file mode 100644 index 00000000000..579e909ff2a --- /dev/null +++ b/tests/benchmark/spamoor/pool_runner.py @@ -0,0 +1,239 @@ +""" +Shared runner that dispatches spamoor scenario tx-dicts through the +HD-derived wallet pool. + +Each scenario's existing ``build_X_transactions`` helper produces a list +of EIP-1559 tx dicts in roughly the shape spamoor's Go scenarios emit. +Historically those dicts came with sequential nonces from a single +signer plus a throughput-multiplied ``maxFeePerGas`` — both wrong against +the spamoor parity goal. ``submit_pool_workload`` re-routes the same +list through the pool: it strips the per-tx nonce so the pool can assign +one (per-wallet sequential), rewrites fees to upstream's legacy strategy +(``feeCap = base_fee_wei`` directly), and submits via +:func:`submit_workload`. + +Use ``root_setup_txs`` for any deploy/setup that must come from the root +EOA before the per-wallet workload starts (the calltx scenario does this +for the contract deploy that the call txs target). +""" + +from __future__ import annotations + +import time +from typing import Any, Callable, Dict, List, Optional + +from execution_testing.cli.pytest_commands.plugins.spamoor.spamoor import ( + resolve_pool_sizing, +) +from execution_testing.cli.pytest_commands.plugins.spamoor.submitter import ( + submit_workload, +) +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + Wallet, + WalletPool, +) + + +def upstream_fee(spamoor_config: Dict[str, Any]) -> tuple[int, int]: + """``feeCap = max(base_fee_wei, tip)``; ``tipCap = tip_fee``. + + Mirrors ``.lab/spamoor/spamoor/txpool.go:1626-1631``. + """ + basefee = int(spamoor_config.get("basefee") or 1_000_000_000) + tip = int(spamoor_config.get("tip_fee") or 1_000_000_000) + return max(basefee, tip), tip + + +def normalize_tx_dicts( + tx_dicts: List[Dict[str, Any]], spamoor_config: Dict[str, Any] +) -> None: + """Strip nonces and rewrite fees in place. + + The scenario builders set the nonce assuming a single signer; the + pool runner re-assigns per-wallet nonces, so the pre-set value would + just collide. Fees are coerced to upstream-style basefee. + """ + max_fee, tip = upstream_fee(spamoor_config) + for tx in tx_dicts: + tx.pop("nonce", None) + if int(tx.get("type", 2)) in (2, 3): + tx["maxFeePerGas"] = max_fee + tx["maxPriorityFeePerGas"] = tip + + +def _send_root_tx( + *, + rpc_client: Callable[[str, List[Any]], Any], + pool: WalletPool, + chain_id: int, + tx_dict: Dict[str, Any], + nonce: int, + timeout: float = 60.0, +) -> int: + """Sign-and-send a single tx from the root EOA, block until receipt. + + Returns the next root nonce. Raises if the EL never confirms within + *timeout*; setup txs must complete before the workload starts. + """ + from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, + ) + + tx_dict.pop("nonce", None) + signed = spamoor_dict_to_transaction( + tx_dict, pool.root_eoa, chain_id, nonce_override=nonce + ) + raw = signed.rlp().hex() + if not raw.startswith("0x"): + raw = "0x" + raw + tx_hash = rpc_client("eth_sendRawTransaction", [raw]) + if not (isinstance(tx_hash, str) and tx_hash.startswith("0x")): + last_err = getattr(rpc_client, "last_error", None) + raise RuntimeError( + f"setup tx submission failed (response={tx_hash!r}, " + f"last_error={last_err})" + ) + deadline = time.time() + timeout + while time.time() < deadline: + if rpc_client("eth_getTransactionReceipt", [tx_hash]) is not None: + return nonce + 1 + time.sleep(1.0) + raise AssertionError( + f"setup tx {tx_hash} not mined within {timeout}s" + ) + + +def submit_pool_workload( + *, + spamoor_config: Dict[str, Any], + rpc_client: Callable[[str, List[Any]], Any], + pool: WalletPool, + tx_dicts: List[Dict[str, Any]], + root_setup_txs: Optional[List[Dict[str, Any]]] = None, + fork: Any | None = None, + blob_seed: int = 0, +) -> None: + """End-to-end: optional root setup, then per-wallet submission. + + Skips the test (via ``pytest.skip``) when *tx_dicts* is empty so the + common ``count=0`` no-op case stays uniform with the original + ``broadcast_and_assert_receipts`` semantics. + """ + import pytest + + def _tx_cost(tx: Dict[str, Any]) -> int: + gas = int(tx.get("gas") or 0) + max_fee = int(tx.get("maxFeePerGas") or 0) + value = int(tx.get("value") or 0) + return gas * max_fee + value + + if not tx_dicts: + pytest.skip("scenario produced no transactions") + if any(int(tx.get("type", 0)) == 3 for tx in tx_dicts): + # eth_sendRawTransaction needs EIP-4844 network-form RLP with + # blobs/commitments/proofs sidecars; ``Transaction.rlp()`` only + # produces block-form (payload only). Mirrors the skip path in + # ``broadcast_and_assert_receipts``. + pytest.skip( + "type-3 blob broadcast needs network-form RLP with sidecars" + ) + + chain_hex = rpc_client("eth_chainId", []) + if not isinstance(chain_hex, str): + pytest.skip("spamoor endpoint unreachable (eth_chainId failed)") + chain_id = int(chain_hex, 16) + + normalize_tx_dicts(tx_dicts, spamoor_config) + if root_setup_txs: + normalize_tx_dicts(root_setup_txs, spamoor_config) + + if root_setup_txs: + root_nonce_hex = rpc_client( + "eth_getTransactionCount", [str(pool.root_eoa), "pending"] + ) + if not isinstance(root_nonce_hex, str): + pytest.skip("eth_getTransactionCount(root) failed") + root_nonce = int(root_nonce_hex, 16) + for tx in root_setup_txs: + root_nonce = _send_root_tx( + rpc_client=rpc_client, + pool=pool, + chain_id=chain_id, + tx_dict=tx, + nonce=root_nonce, + ) + + # Per-wallet top-up: scan the workload to compute each wallet's + # cumulative cost (gas reservation + value), then send a root → child + # transfer to cover any deficit. The fixture's static refill_amount + # only covers a uniform-cost workload; scenarios like uniswap-swaps + # carry random per-tx ``value`` up to thousands of ETH and would + # otherwise blow past the 5 ETH default refill mid-run. + n_wallets = len(pool) + cost_by_wallet: List[int] = [0] * n_wallets + for i, tx in enumerate(tx_dicts): + cost_by_wallet[i % n_wallets] += _tx_cost(tx) + + deficits: List[tuple[int, int]] = [] + for idx in range(n_wallets): + try: + balance_hex = rpc_client( + "eth_getBalance", [pool.by_index(idx).address, "latest"] + ) + balance = int(balance_hex, 16) if isinstance(balance_hex, str) else 0 + except Exception: + balance = 0 + # 1.25× headroom covers basefee bumps between funding tx and + # the workload's last tx. + needed = int(cost_by_wallet[idx] * 1.25) + if balance < needed: + deficits.append((idx, needed - balance)) + + if deficits: + max_fee, tip = upstream_fee(spamoor_config) + funding_max_fee = max(max_fee, tip * 2) + root_nonce_hex = rpc_client( + "eth_getTransactionCount", [str(pool.root_eoa), "pending"] + ) + if not isinstance(root_nonce_hex, str): + pytest.skip("eth_getTransactionCount(root) failed (top-up)") + root_nonce = int(root_nonce_hex, 16) + for child_idx, amount in deficits: + root_nonce = _send_root_tx( + rpc_client=rpc_client, + pool=pool, + chain_id=chain_id, + tx_dict={ + "type": 2, + "to": pool.by_index(child_idx).address, + "value": amount, + "data": "", + "gas": 21000, + "maxFeePerGas": funding_max_fee, + "maxPriorityFeePerGas": tip, + "chainId": 1, + "accessList": [], + }, + nonce=root_nonce, + ) + + sizing = resolve_pool_sizing(spamoor_config) + submit_workload( + builder=lambda _w, i, _n: tx_dicts[i], + pool=pool, + rpc_client=rpc_client, + chain_id=chain_id, + total_count=len(tx_dicts), + throughput=float(spamoor_config.get("throughput") or 1.0), + max_pending=sizing["max_pending"], + skip_assert=bool(spamoor_config.get("skip_assert", False)), + fork=fork, + blob_seed=blob_seed, + ) + + +__all__ = [ + "normalize_tx_dicts", + "submit_pool_workload", + "upstream_fee", +] diff --git a/tests/benchmark/spamoor/test_blob_combined.py b/tests/benchmark/spamoor/test_blob_combined.py index 28347391f1d..6737b483ed8 100644 --- a/tests/benchmark/spamoor/test_blob_combined.py +++ b/tests/benchmark/spamoor/test_blob_combined.py @@ -1,41 +1,38 @@ -"""Tests for build_blob_combined_transactions.""" +"""Tests for build_blob_combined_transactions. + +Type-3 blob broadcast requires network-form RLP with the blob sidecars, +which EST's ``Transaction.rlp()`` does not currently emit. The submit +path therefore skips at runtime; the builder shape is still exercised. +""" from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_blob_combined_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + WalletPool, ) +from .helpers import build_blob_combined_transactions +from .pool_runner import submit_pool_workload + @pytest.mark.spamoor def test_blob_combined_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """ - Exercise the blob-combined builder shape. - - ``broadcast_and_assert_receipts`` currently skips for type-3 txs: - ``eth_sendRawTransaction`` needs EIP-4844 network-form RLP (with - blobs/commitments/proofs sidecars), while EST's ``Transaction.rlp()`` - yields block-form (payload only). The test still exercises the - builder end-to-end and the broadcast helper will skip cleanly. - """ - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - + """Exercise the blob-combined builder shape; submission is skipped.""" txs = build_blob_combined_transactions( count=spamoor_config["count"], sidecars=spamoor_config["sidecars"], basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], blob_fee=spamoor_config["blob_fee"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] @@ -47,4 +44,10 @@ def test_blob_combined_scenario( expected_blobs = max(1, min(int(spamoor_config["sidecars"]), 6)) assert len(tx0["blobVersionedHashes"]) == expected_blobs - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) + # Pool-runner skips when any tx is type-3 (network-form RLP needed). + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=txs, + ) diff --git a/tests/benchmark/spamoor/test_calltx.py b/tests/benchmark/spamoor/test_calltx.py index 504e9da05e1..a3ef71446ee 100644 --- a/tests/benchmark/spamoor/test_calltx.py +++ b/tests/benchmark/spamoor/test_calltx.py @@ -1,80 +1,197 @@ -"""Tests for build_calltx_transactions.""" +"""Tests for the calltx scenario, driven through the spamoor wallet pool. +Mirrors the upstream Go scenario at ``.lab/spamoor/scenarios/calltx``: +deploys a contract from the root wallet (when ``contract_code`` is set), +then submits ``count`` call transactions spread across the HD-derived +wallet pool, paced at the configured throughput and capped at +``max_pending`` in flight. +""" + +from __future__ import annotations + +import time from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_calltx_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.submitter import ( + submit_workload, +) +from execution_testing.cli.pytest_commands.plugins.spamoor.spamoor import ( + resolve_pool_sizing, +) +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + Wallet, + WalletPool, ) -@pytest.mark.spamoor -def test_calltx_scenario_with_deploy( +def _max_fee_per_gas(spamoor_config: Dict[str, Any]) -> int: + """Suggested-fee math mirroring upstream spamoor's legacy strategy. + + See ``.lab/spamoor/spamoor/txpool.go:1616-1646`` ``GetSuggestedFees``: + when the YAML supplies a non-zero ``base_fee_wei`` (or ``base_fee``) + that value is used directly as ``feeCap`` — there is no throughput + multiplier. EIP-1559 still requires ``feeCap >= tipCap``, so we + clamp upward when the tip happens to exceed basefee. + """ + basefee = int(spamoor_config.get("basefee") or 1_000_000_000) + tip = int(spamoor_config.get("tip_fee") or 0) + return max(basefee, tip) + + +def _execution_gas(spamoor_config: Dict[str, Any]) -> int: + """Default to 500_000 when unspecified — matches helpers.py:342.""" + gas_limit = int(spamoor_config.get("gas_limit") or 0) + return gas_limit if gas_limit > 0 else 500_000 + + +def _calltx_dict( + spamoor_config: Dict[str, Any], + target_to: str, + call_data: str, +) -> Dict[str, Any]: + return { + "type": 2, + "to": target_to, + "value": int(spamoor_config.get("amount") or 0), + "data": call_data, + "gas": _execution_gas(spamoor_config), + "maxFeePerGas": _max_fee_per_gas(spamoor_config), + "maxPriorityFeePerGas": int( + spamoor_config.get("tip_fee") or 1_000_000_000 + ), + "chainId": 1, # overridden by tx_convert with the real chain id + "accessList": [], + } + + +def _resolved_target(spamoor_config: Dict[str, Any]) -> str: + addr = spamoor_config.get("contract_address") + if addr: + return str(addr) + return "0x1111111111111111111111111111111111111111" + + +def _send_root_deploy( + spamoor_config: Dict[str, Any], + pool: WalletPool, + rpc_client: Callable[[str, list], Any], + chain_id: int, + contract_code: str, +) -> str: + """Send a single deploy tx from the root wallet, return its tx hash. + + The deploy must complete before the call workload starts so children + have a real contract to target. Mirrors the way the upstream Go + scenario sequences ``deployContract`` ahead of the per-tx loop + (``calltx.go:331-371``). + """ + from execution_testing.cli.pytest_commands.plugins.testing_build_block.tx_convert import ( # noqa: E501 + spamoor_dict_to_transaction, + ) + + nonce_hex = rpc_client( + "eth_getTransactionCount", [str(pool.root_eoa), "pending"] + ) + assert isinstance(nonce_hex, str) + nonce = int(nonce_hex, 16) + tip = int(spamoor_config.get("tip_fee") or 1_000_000_000) + tx_dict = { + "type": 2, + "to": "", + "value": 0, + "data": contract_code, + "gas": int( + spamoor_config.get("deploy_gas_limit") or 2_000_000 + ), + "maxFeePerGas": max(_max_fee_per_gas(spamoor_config), tip * 2), + "maxPriorityFeePerGas": tip, + "chainId": 1, + "accessList": [], + } + tx = spamoor_dict_to_transaction( + tx_dict, pool.root_eoa, chain_id, nonce_override=nonce + ) + raw = tx.rlp().hex() + if not raw.startswith("0x"): + raw = "0x" + raw + tx_hash = rpc_client("eth_sendRawTransaction", [raw]) + assert isinstance(tx_hash, str) and tx_hash.startswith("0x"), tx_hash + deadline = time.time() + 60.0 + while time.time() < deadline: + receipt = rpc_client("eth_getTransactionReceipt", [tx_hash]) + if receipt is not None: + return tx_hash + time.sleep(1.0) + raise AssertionError(f"deploy tx {tx_hash} never mined within 60s") + + +def _run_calltx( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, + *, + call_data: str, + contract_code: str | None, ) -> None: - """Build deploy + call txs, broadcast, assert receipts.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - - txs = build_calltx_transactions( - count=spamoor_config["count"], - throughput=spamoor_config["throughput"], - amount=spamoor_config["amount"], - basefee=spamoor_config["basefee"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - contract_code="0x6001600055", - contract_address=spamoor_config.get("contract_address"), - call_data=spamoor_config.get("call_data", ""), - deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), - call_fn_sig=spamoor_config.get("call_fn_sig", ""), - call_args=spamoor_config.get("call_args", "[]"), - contract_args=spamoor_config.get("contract_args", "[]"), - gas_limit=spamoor_config.get("gas_limit", 0), - tip_fee=spamoor_config.get("tip_fee", 1_000_000_000), + chain_hex = spamoor_rpc_client("eth_chainId", []) + assert isinstance(chain_hex, str) + chain_id = int(chain_hex, 16) + + if contract_code: + _send_root_deploy( + spamoor_config, + spamoor_wallet_pool, + spamoor_rpc_client, + chain_id, + contract_code, + ) + + target_to = _resolved_target(spamoor_config) + template = _calltx_dict(spamoor_config, target_to, call_data) + + def builder(_wallet: Wallet, _idx: int, _nonce: int) -> Dict[str, Any]: + return dict(template) + + sizing = resolve_pool_sizing(spamoor_config) + submit_workload( + builder=builder, + pool=spamoor_wallet_pool, rpc_client=spamoor_rpc_client, + chain_id=chain_id, + total_count=int(spamoor_config["count"]), + throughput=float(spamoor_config.get("throughput") or 1.0), + max_pending=sizing["max_pending"], + skip_assert=bool(spamoor_config.get("skip_assert", False)), ) - assert len(txs) == spamoor_config["count"] + 1 - assert txs[0]["type"] == 2 - assert txs[0]["to"] == "" - assert txs[0]["data"] == "0x6001600055" - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) +@pytest.mark.spamoor +def test_calltx_scenario_with_deploy( + spamoor_config: Dict[str, Any], + spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, +) -> None: + _run_calltx( + spamoor_config, + spamoor_rpc_client, + spamoor_wallet_pool, + call_data=spamoor_config.get("call_data", "") or "0x1234", + contract_code="0x6001600055", + ) @pytest.mark.spamoor def test_calltx_scenario_no_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Build call-only txs, broadcast, assert receipts.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - - txs = build_calltx_transactions( - count=spamoor_config["count"], - throughput=spamoor_config["throughput"], - amount=spamoor_config["amount"], - basefee=spamoor_config["basefee"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], + _run_calltx( + spamoor_config, + spamoor_rpc_client, + spamoor_wallet_pool, + call_data=spamoor_config.get("call_data", "") or "0x1234", contract_code=None, - contract_address=spamoor_config.get("contract_address"), - call_data=spamoor_config.get("call_data") or "0x1234", - deploy_gas_limit=spamoor_config.get("deploy_gas_limit", 2000000), - call_fn_sig=spamoor_config.get("call_fn_sig", ""), - call_args=spamoor_config.get("call_args", "[]"), - contract_args=spamoor_config.get("contract_args", "[]"), - gas_limit=spamoor_config.get("gas_limit", 0), - tip_fee=spamoor_config.get("tip_fee", 1_000_000_000), - rpc_client=spamoor_rpc_client, ) - - assert len(txs) == spamoor_config["count"] - assert txs[0]["type"] == 2 - assert txs[0]["data"] == "0x1234" - - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) diff --git a/tests/benchmark/spamoor/test_deploytx.py b/tests/benchmark/spamoor/test_deploytx.py index 9f01c7fb36a..feb7760fd59 100644 --- a/tests/benchmark/spamoor/test_deploytx.py +++ b/tests/benchmark/spamoor/test_deploytx.py @@ -1,24 +1,24 @@ -"""Tests for build_deploytx_transactions.""" +"""Tests for build_deploytx_transactions, dispatched via the wallet pool.""" from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_deploytx_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + WalletPool, ) +from .helpers import build_deploytx_transactions +from .pool_runner import submit_pool_workload + @pytest.mark.spamoor def test_deploytx_default_bytecode( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Broadcast contract-creation txs carrying the default bytecode.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - + """Submit contract-creation txs carrying the default bytecode.""" txs = build_deploytx_transactions( count=spamoor_config["count"], bytecodes=spamoor_config["bytecodes"], @@ -27,9 +27,9 @@ def test_deploytx_default_bytecode( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] @@ -44,7 +44,12 @@ def test_deploytx_default_bytecode( ): assert txs[0]["data"] == "0x6001600055" - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=txs, + ) @pytest.mark.spamoor diff --git a/tests/benchmark/spamoor/test_eoatx.py b/tests/benchmark/spamoor/test_eoatx.py index 01cb79e6b90..f32a3df9cf0 100644 --- a/tests/benchmark/spamoor/test_eoatx.py +++ b/tests/benchmark/spamoor/test_eoatx.py @@ -1,37 +1,41 @@ -"""Tests for build_eoatx_transactions.""" +"""Tests for build_eoatx_transactions, dispatched via the wallet pool.""" from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_eoatx_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + WalletPool, ) +from .helpers import build_eoatx_transactions +from .pool_runner import submit_pool_workload + @pytest.mark.spamoor def test_eoatx_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Build, sign, broadcast EOA transfers and verify receipts.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - - raw_txs = build_eoatx_transactions( + """Build ``count`` EOA-to-EOA transfers and submit through the pool.""" + txs = build_eoatx_transactions( count=spamoor_config["count"], throughput=spamoor_config["throughput"], amount=spamoor_config["amount"], basefee=spamoor_config["basefee"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) + assert len(txs) == spamoor_config["count"] + assert txs[0]["type"] == 2 + assert txs[0]["value"] == spamoor_config["amount"] + assert txs[0]["gas"] == 21000 - assert len(raw_txs) == spamoor_config["count"] - assert raw_txs[0]["type"] == 2 - assert raw_txs[0]["value"] == spamoor_config["amount"] - assert raw_txs[0]["gas"] == 21000 - - broadcast_and_assert_receipts(raw_txs, ctx, spamoor_rpc_client) + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=txs, + ) diff --git a/tests/benchmark/spamoor/test_erc20_bloater.py b/tests/benchmark/spamoor/test_erc20_bloater.py index a0a7fb203ec..52c6852a531 100644 --- a/tests/benchmark/spamoor/test_erc20_bloater.py +++ b/tests/benchmark/spamoor/test_erc20_bloater.py @@ -1,24 +1,24 @@ -"""Tests for build_erc20_bloater_transactions.""" +"""Tests for build_erc20_bloater_transactions, dispatched via the pool.""" from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_erc20_bloater_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + WalletPool, ) +from .helpers import build_erc20_bloater_transactions +from .pool_runner import submit_pool_workload + @pytest.mark.spamoor def test_erc20_bloater_scenario_with_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Deploy ERC20Bloater stub + broadcast bloatStorage calls.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - + """Deploy ERC20Bloater stub from root, then submit bloatStorage via pool.""" txs = build_erc20_bloater_transactions( count=spamoor_config["count"], addresses_per_tx=spamoor_config["addresses_per_tx"], @@ -30,9 +30,9 @@ def test_erc20_bloater_scenario_with_deploy( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] + 1 @@ -41,19 +41,23 @@ def test_erc20_bloater_scenario_with_deploy( if spamoor_config["count"] > 0: assert txs[1]["data"].startswith("0xc1926de5") - # Bloater txs carry 16.7M gas limits → tight block packing on a 30M - # cap. Give the node extra time to mine the batch. - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client, timeout=120) + deploy_tx, exec_txs = txs[0], txs[1:] + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=exec_txs, + root_setup_txs=[deploy_tx], + ) @pytest.mark.spamoor def test_erc20_bloater_scenario_existing_contract( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: """Skip-deploy path: call bloatStorage on an existing address.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - txs = build_erc20_bloater_transactions( count=spamoor_config["count"], addresses_per_tx=spamoor_config["addresses_per_tx"], @@ -64,9 +68,9 @@ def test_erc20_bloater_scenario_existing_contract( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] @@ -74,4 +78,9 @@ def test_erc20_bloater_scenario_existing_contract( assert txs[0]["to"] == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" assert txs[0]["data"].startswith("0xc1926de5") - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client, timeout=120) + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=txs, + ) diff --git a/tests/benchmark/spamoor/test_erc20tx.py b/tests/benchmark/spamoor/test_erc20tx.py index 7c70766fec3..62826b8dcc9 100644 --- a/tests/benchmark/spamoor/test_erc20tx.py +++ b/tests/benchmark/spamoor/test_erc20tx.py @@ -1,24 +1,24 @@ -"""Tests for build_erc20tx_transactions.""" +"""Tests for build_erc20tx_transactions, dispatched via the wallet pool.""" from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_erc20tx_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + WalletPool, ) +from .helpers import build_erc20tx_transactions +from .pool_runner import submit_pool_workload + @pytest.mark.spamoor def test_erc20tx_scenario_with_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Deploy stub ERC20 + broadcast transferMint calls.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - + """Deploy stub ERC20 from root, then submit transferMint calls via pool.""" txs = build_erc20tx_transactions( count=spamoor_config["count"], amount=spamoor_config["amount"], @@ -31,9 +31,9 @@ def test_erc20tx_scenario_with_deploy( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] + 1 @@ -42,17 +42,23 @@ def test_erc20tx_scenario_with_deploy( if spamoor_config["count"] > 0: assert txs[1]["data"].startswith("0x9d0f7cba") - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) + deploy_tx, exec_txs = txs[0], txs[1:] + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=exec_txs, + root_setup_txs=[deploy_tx], + ) @pytest.mark.spamoor def test_erc20tx_scenario_no_deploy( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Skip-deploy path: broadcast transferMint calls only.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - + """Skip-deploy path: submit transferMint calls only via pool.""" txs = build_erc20tx_transactions( count=spamoor_config["count"], amount=spamoor_config["amount"], @@ -64,9 +70,9 @@ def test_erc20tx_scenario_no_deploy( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] @@ -75,4 +81,9 @@ def test_erc20tx_scenario_no_deploy( addr_b = txs[1]["data"][10 : 10 + 64] assert addr_a != addr_b - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=txs, + ) diff --git a/tests/benchmark/spamoor/test_evm_fuzz.py b/tests/benchmark/spamoor/test_evm_fuzz.py index 51106a49b4c..b604efc879d 100644 --- a/tests/benchmark/spamoor/test_evm_fuzz.py +++ b/tests/benchmark/spamoor/test_evm_fuzz.py @@ -1,24 +1,24 @@ -"""Tests for build_evm_fuzz_transactions.""" +"""Tests for build_evm_fuzz_transactions, dispatched via the wallet pool.""" from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_evm_fuzz_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + WalletPool, ) +from .helpers import build_evm_fuzz_transactions +from .pool_runner import submit_pool_workload + @pytest.mark.spamoor def test_evm_fuzz_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Broadcast random-bytecode contract creations. Reverts are OK.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - + """Submit random-bytecode contract creations through the pool.""" txs = build_evm_fuzz_transactions( count=spamoor_config["count"], gas_limit=spamoor_config["gas_limit"], @@ -30,9 +30,9 @@ def test_evm_fuzz_scenario( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] @@ -41,9 +41,11 @@ def test_evm_fuzz_scenario( assert tx["to"] == "" assert tx["data"].startswith("0x") - # Random init code typically reverts — we only assert inclusion. - broadcast_and_assert_receipts( - txs, ctx, spamoor_rpc_client, allow_reverts=True + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=txs, ) diff --git a/tests/benchmark/spamoor/test_gasburnertx.py b/tests/benchmark/spamoor/test_gasburnertx.py index 76e3feeffc1..5982dff6c22 100644 --- a/tests/benchmark/spamoor/test_gasburnertx.py +++ b/tests/benchmark/spamoor/test_gasburnertx.py @@ -1,24 +1,24 @@ -"""Tests for build_gasburnertx_transactions.""" +"""Tests for build_gasburnertx_transactions, dispatched via the wallet pool.""" from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_gasburnertx_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + WalletPool, ) +from .helpers import build_gasburnertx_transactions +from .pool_runner import submit_pool_workload + @pytest.mark.spamoor def test_gasburnertx_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Deploy gas-burner + broadcast gas-burning calls.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - + """Deploy gas-burner from root, then submit gas-burning calls via pool.""" txs = build_gasburnertx_transactions( count=spamoor_config["count"], gas_units_to_burn=spamoor_config["gas_units_to_burn"], @@ -27,9 +27,9 @@ def test_gasburnertx_scenario( throughput=spamoor_config["throughput"], deploy_gas_limit=spamoor_config["deploy_gas_limit"], contract_address=spamoor_config["contract_address"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] + 1 @@ -38,4 +38,11 @@ def test_gasburnertx_scenario( if spamoor_config["count"] > 0: assert txs[1]["data"] == "0x00000000" - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) + deploy_tx, exec_txs = txs[0], txs[1:] + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=exec_txs, + root_setup_txs=[deploy_tx], + ) diff --git a/tests/benchmark/spamoor/test_uniswap_swaps.py b/tests/benchmark/spamoor/test_uniswap_swaps.py index 040bf9b8c3a..17538a3296d 100644 --- a/tests/benchmark/spamoor/test_uniswap_swaps.py +++ b/tests/benchmark/spamoor/test_uniswap_swaps.py @@ -1,24 +1,24 @@ -"""Tests for build_uniswap_swaps_transactions.""" +"""Tests for build_uniswap_swaps_transactions, dispatched via the pool.""" from typing import Any, Callable, Dict import pytest -from .helpers import ( - broadcast_and_assert_receipts, - build_uniswap_swaps_transactions, - spamoor_signer_context, +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + WalletPool, ) +from .helpers import build_uniswap_swaps_transactions +from .pool_runner import submit_pool_workload + @pytest.mark.spamoor def test_uniswap_swaps_scenario( spamoor_config: Dict[str, Any], spamoor_rpc_client: Callable[[str, list], Any], + spamoor_wallet_pool: WalletPool, ) -> None: - """Broadcast Uniswap V2 router swap calls against a placeholder router.""" - ctx = spamoor_signer_context(spamoor_config, spamoor_rpc_client) - + """Submit Uniswap V2 router swap calls against a placeholder router.""" txs = build_uniswap_swaps_transactions( count=spamoor_config["count"], pair_count=spamoor_config["pair_count"], @@ -29,9 +29,9 @@ def test_uniswap_swaps_scenario( basefee=spamoor_config["basefee"], tip_fee=spamoor_config["tip_fee"], throughput=spamoor_config["throughput"], - from_addr=spamoor_config["from_addr"], - private_key=spamoor_config["private_key"], - rpc_client=spamoor_rpc_client, + from_addr=None, + private_key=None, + rpc_client=None, ) assert len(txs) == spamoor_config["count"] @@ -39,6 +39,9 @@ def test_uniswap_swaps_scenario( selector = txs[0]["data"][2:10] assert selector in {"38ed1739", "7ff36ab5", "18cbafe5"} - # swapExactETHForTokens carries non-zero value to the placeholder. All - # calls land on an empty address → succeed as no-ops. - broadcast_and_assert_receipts(txs, ctx, spamoor_rpc_client) + submit_pool_workload( + spamoor_config=spamoor_config, + rpc_client=spamoor_rpc_client, + pool=spamoor_wallet_pool, + tx_dicts=txs, + ) diff --git a/tests/benchmark/testing_build_block/test_calltx_committed.py b/tests/benchmark/testing_build_block/test_calltx_committed.py index 15d19f14db6..a94ea79e3b6 100644 --- a/tests/benchmark/testing_build_block/test_calltx_committed.py +++ b/tests/benchmark/testing_build_block/test_calltx_committed.py @@ -1,9 +1,29 @@ -"""End-to-end: commit a block of spamoor call/deploy transactions.""" +"""End-to-end: commit blocks of calltx workload using the wallet pool. -from typing import Any, Callable, Dict, Sequence +The committed-path test mirrors the spamoor-pytest flow in +``tests/benchmark/spamoor/test_calltx.py``: the same root key derives +the same HD-wallet pool (so child addresses match upstream spamoor +byte-for-byte), the root funds underfunded children before the +workload, and call transactions are signed by per-wallet round-robin +with per-wallet nonce sequencing. The only structural difference from +the spamoor pytest path is that here txs are committed via +``bloat_commit_block`` rather than broadcast through the EL mempool. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Sequence import pytest -from execution_testing.base_types import Hash +from execution_testing.base_types import Address, Hash +from execution_testing.cli.pytest_commands.plugins.spamoor.spamoor import ( + resolve_pool_sizing, +) +from execution_testing.cli.pytest_commands.plugins.spamoor.wallet_pool import ( + DEFAULT_REFILL_AMOUNT_WEI, + DEFAULT_REFILL_BALANCE_WEI, + WalletPool, +) from execution_testing.cli.pytest_commands.plugins.testing_build_block.testing_build_block import ( # noqa: E501 BloatConfig, ) @@ -11,9 +31,187 @@ spamoor_dict_to_transaction, ) from execution_testing.rpc import EthRPC +from execution_testing.rpc.rpc_types import JSONRPCError from execution_testing.test_types import EOA, Transaction -from tests.benchmark.spamoor.helpers import build_calltx_transactions + +def _calltx_template(spamoor_config: Dict[str, Any]) -> Dict[str, Any]: + """Match upstream spamoor's legacy fee strategy: feeCap = base_fee + directly, with an EIP-1559 ``feeCap >= tip`` safety clamp. + """ + basefee = int(spamoor_config.get("basefee") or 1_000_000_000) + tip = int(spamoor_config.get("tip_fee") or 1_000_000_000) + gas_limit = int(spamoor_config.get("gas_limit") or 0) or 500_000 + target = ( + spamoor_config.get("contract_address") + or "0x1111111111111111111111111111111111111111" + ) + call_data = spamoor_config.get("call_data") or "0x1234" + return { + "type": 2, + "to": str(target), + "value": int(spamoor_config.get("amount") or 0), + "data": call_data, + "gas": gas_limit, + "maxFeePerGas": max(basefee, tip), + "maxPriorityFeePerGas": tip, + "chainId": 1, + "accessList": [], + } + + +def _build_pool( + bloat_config: BloatConfig, + bloat_eth_rpc: EthRPC, + spamoor_config: Dict[str, Any], +) -> WalletPool: + sizing = resolve_pool_sizing(spamoor_config) + seed = str(spamoor_config.get("wallet_seed") or "") + pool = WalletPool( + bloat_config.signer_key, + seed=seed, + count=sizing["max_wallets"], + refill_amount_wei=int( + spamoor_config.get("refill_amount_wei") or 0 + ) or DEFAULT_REFILL_AMOUNT_WEI, + refill_balance_wei=int( + spamoor_config.get("refill_balance_wei") or 0 + ) or DEFAULT_REFILL_BALANCE_WEI, + ) + for w in pool.wallets: + try: + n = bloat_eth_rpc.get_transaction_count( + EOA(Address(w.address)), "latest" + ) + except Exception: + n = 0 + w.pending_nonce = int(n) + w.confirmed_nonce = int(n) - 1 + return pool + + +def _funding_txs( + pool: WalletPool, + bloat_signer: EOA, + bloat_config: BloatConfig, + spamoor_config: Dict[str, Any], + bloat_eth_rpc: EthRPC, +) -> List[Transaction]: + """Emit root → child transfer txs sufficient to top each child to + refill_balance_wei (mirrors WalletPool.prepare's math). + """ + txs: List[Transaction] = [] + refill_amount = int( + spamoor_config.get("refill_amount_wei") or 0 + ) or DEFAULT_REFILL_AMOUNT_WEI + refill_balance = int( + spamoor_config.get("refill_balance_wei") or 0 + ) or DEFAULT_REFILL_BALANCE_WEI + tip = int(spamoor_config.get("tip_fee") or 1_000_000_000) + basefee = int(spamoor_config.get("basefee") or 1_000_000_000) + max_fee = max(int(basefee * 2), tip * 2) + + nonce = int(bloat_signer.nonce) + for w in pool.wallets: + try: + balance = bloat_eth_rpc.get_balance( + EOA(Address(w.address)), "latest" + ) + except Exception: + balance = 0 + if int(balance) >= refill_balance: + continue + amount = max(refill_amount, refill_balance - int(balance)) + funding_dict = { + "type": 2, + "to": w.address, + "value": amount, + "data": "", + "gas": 21000, + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": tip, + "chainId": 1, + "accessList": [], + } + txs.append( + spamoor_dict_to_transaction( + funding_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=nonce, + ) + ) + nonce += 1 + return txs + + +def _deploy_tx( + bloat_signer: EOA, + bloat_config: BloatConfig, + spamoor_config: Dict[str, Any], + deploy_nonce: int, +) -> Transaction: + tip = int(spamoor_config.get("tip_fee") or 1_000_000_000) + basefee = int(spamoor_config.get("basefee") or 1_000_000_000) + throughput = float(spamoor_config.get("throughput") or 1.0) + deploy_dict = { + "type": 2, + "to": "", + "value": 0, + "data": spamoor_config.get("contract_code") or "", + "gas": int(spamoor_config.get("deploy_gas_limit") or 2_000_000), + "maxFeePerGas": max(int(basefee * (1.0 + throughput)), tip * 2), + "maxPriorityFeePerGas": tip, + "chainId": 1, + "accessList": [], + } + return spamoor_dict_to_transaction( + deploy_dict, + bloat_signer, + bloat_config.chain_id, + nonce_override=deploy_nonce, + ) + + +def _build_call_txs( + pool: WalletPool, + bloat_config: BloatConfig, + spamoor_config: Dict[str, Any], +) -> List[Transaction]: + template = _calltx_template(spamoor_config) + count = int(spamoor_config.get("count") or 0) + out: List[Transaction] = [] + for _ in range(count): + wallet = pool.pick() + nonce = wallet.next_nonce() + out.append( + spamoor_dict_to_transaction( + dict(template), + wallet.eoa, + bloat_config.chain_id, + nonce_override=nonce, + ) + ) + return out + + +def _chunk_by_gas( + txs: Sequence[Transaction], block_gas_limit: int +) -> List[List[Transaction]]: + chunks: List[List[Transaction]] = [] + current: List[Transaction] = [] + current_gas = 0 + for tx in txs: + tx_gas = int(tx.gas_limit) + if current and current_gas + tx_gas > block_gas_limit: + chunks.append(current) + current = [] + current_gas = 0 + current.append(tx) + current_gas += tx_gas + if current: + chunks.append(current) + return chunks @pytest.mark.spamoor @@ -25,42 +223,59 @@ def test_calltx_committed( bloat_eth_rpc: EthRPC, bloat_commit_block: Callable[[Sequence[Transaction]], Hash], ) -> None: - """Build contract call/deploy transactions and commit them.""" - raw_txs = build_calltx_transactions( - count=spamoor_config["count"], - throughput=spamoor_config["throughput"], - amount=spamoor_config["amount"], - basefee=spamoor_config["basefee"], - from_addr=str(bloat_signer), - private_key=bloat_config.signer_key, - contract_code=spamoor_config["contract_code"], - contract_address=spamoor_config["contract_address"], - call_data=spamoor_config["call_data"], - call_fn_sig=spamoor_config["call_fn_sig"], - call_args=spamoor_config["call_args"], - contract_args=spamoor_config["contract_args"], - gas_limit=spamoor_config["gas_limit"], - tip_fee=spamoor_config["tip_fee"], - deploy_gas_limit=spamoor_config["deploy_gas_limit"], - rpc_client=None, + """Commit calltx workload across HD-derived wallet pool. + + Pre-funds each child wallet from the root signer, optionally deploys + a contract from the root, then signs ``count`` call transactions with + per-wallet round-robin sequencing. Order of txs handed to + ``bloat_commit_block`` exactly matches what the spamoor pytest path + submits to the EL mempool. + """ + pool = _build_pool(bloat_config, bloat_eth_rpc, spamoor_config) + + funding = _funding_txs( + pool, bloat_signer, bloat_config, spamoor_config, bloat_eth_rpc ) - if not raw_txs: - pytest.skip("spamoor produced no transactions; nothing to commit") - - txs = [ - spamoor_dict_to_transaction( - tx_dict, - bloat_signer, - bloat_config.chain_id, - nonce_override=int(bloat_signer.nonce) + i, - ) - for i, tx_dict in enumerate(raw_txs) - ] + deploy_nonce = int(bloat_signer.nonce) + len(funding) + deploy: List[Transaction] = [] + if spamoor_config.get("contract_code"): + deploy = [ + _deploy_tx( + bloat_signer, bloat_config, spamoor_config, deploy_nonce + ) + ] + + call_txs = _build_call_txs(pool, bloat_config, spamoor_config) + if not call_txs and not funding and not deploy: + pytest.skip("no transactions to commit") + prev_head = bloat_eth_rpc.get_block_by_number("latest") assert prev_head is not None prev_number = int(prev_head["number"], 16) + block_gas_limit = int(prev_head["gasLimit"], 16) + + all_txs: List[Transaction] = [*funding, *deploy, *call_txs] + chunks = _chunk_by_gas(all_txs, block_gas_limit) + + skip_assert = bool(spamoor_config.get("skip_assert", False)) + new_head_hash: Hash | None = None + committed = 0 + for chunk in chunks: + try: + new_head_hash = bloat_commit_block(chunk) + committed += 1 + except JSONRPCError as exc: + if not skip_assert: + raise + print( + f"[skip_assert] bloat_commit_block failed on chunk of " + f"{len(chunk)} tx(s): {exc}" + ) + + if skip_assert: + return - new_head_hash = bloat_commit_block(txs) + assert new_head_hash is not None new_head = bloat_eth_rpc.get_block_by_hash(new_head_hash) assert new_head is not None - assert int(new_head["number"], 16) > prev_number + assert int(new_head["number"], 16) >= prev_number + committed From 7a8168b7294981c88cb3a67c4963fedf7c25ebde Mon Sep 17 00:00:00 2001 From: AnkushinDaniil Date: Thu, 30 Apr 2026 14:36:31 +0400 Subject: [PATCH 09/11] chore(spamoor): publish builders as standalone eels-spamoor-builders package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a sibling pyproject.toml in tests/benchmark/spamoor/ that maps the directory to a pip-installable package named eels-spamoor-builders. The 12 build__transactions helpers in helpers.py become reusable as a self-contained library by downstream tooling, without inheriting the rest of execution-specs. Layout: the directory stays in place; the new pyproject's tool.setuptools.package-dir maps "." to "eels_spamoor_builders" so the wheel exposes the dir under that name. tests/benchmark/spamoor/__init__.py gains re-exports of the 12 builders. Existing relative imports inside test_*.py and pool_runner.py (`from .helpers import …`) are unchanged and continue to work in pytest collection. Verified end-to-end: - python -m build --wheel → eels_spamoor_builders-0.1.0-py3-none-any.whl - pip install → pulls only eth-abi + eth-utils + eth-hash + eth-typing - from eels_spamoor_builders import build_eoatx_transactions → works - build_eoatx_transactions(count=3, …) returns 3 well-formed type-2 txs Optional `[test]` extra installs pytest for the spamoor_signer_context / broadcast_and_assert_receipts helpers, which import pytest + execution_testing.test_types lazily and aren't needed by builder-only consumers. --- tests/benchmark/spamoor/README.md | 55 ++++++++++++++++++++++++++ tests/benchmark/spamoor/__init__.py | 47 +++++++++++++++++++++- tests/benchmark/spamoor/pyproject.toml | 36 +++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 tests/benchmark/spamoor/README.md create mode 100644 tests/benchmark/spamoor/pyproject.toml diff --git a/tests/benchmark/spamoor/README.md b/tests/benchmark/spamoor/README.md new file mode 100644 index 00000000000..5ac9ac9df97 --- /dev/null +++ b/tests/benchmark/spamoor/README.md @@ -0,0 +1,55 @@ +# eels-spamoor-builders + +Standalone Ethereum transaction builders extracted from the execution-specs +Spamoor benchmark scenarios. + +## Why this exists + +The 12 `build_*_transactions` helpers in `helpers.py` are useful as a +self-contained library for any tooling that wants to drive a node through +the same transaction shapes that the Spamoor pytest scenarios use, without +inheriting the rest of execution-specs (~250 MB of test fixtures + the +Python EVM). + +## Install + +```bash +pip install git+https://github.com/ethereum/execution-specs.git#subdirectory=tests/benchmark/spamoor +``` + +## Use + +```python +from eels_spamoor_builders import build_eoatx_transactions + +txs = build_eoatx_transactions( + count=10, + throughput=1.0, + from_addr="0x1111…", + private_key="0x" + "11" * 32, + rpc_client=lambda method, params: {"jsonrpc": "2.0", "result": "0x0"}, +) +``` + +The 12 builders are: +`build_blob_combined_transactions`, `build_calltx_transactions`, +`build_deploytx_transactions`, `build_eoatx_transactions`, +`build_erc20_bloater_transactions`, `build_erc20tx_transactions`, +`build_evm_fuzz_transactions`, `build_factorydeploytx_transactions`, +`build_gasburnertx_transactions`, `build_storagerefundtx_transactions`, +`build_storagespam_transactions`, `build_uniswap_swaps_transactions`. + +The `spamoor_signer_context` and `broadcast_and_assert_receipts` helpers in +`helpers.py` are *not* re-exported because they import `pytest` and +`execution_testing.test_types` lazily; install the `test` extra +(`pip install eels-spamoor-builders[test]`) and import them from +`eels_spamoor_builders.helpers` if you need them. + +## Layout + +This directory is dual-purpose: + +- a *pytest test package* under the execution-specs test tree (`tests/benchmark/spamoor`); +- a *pip-installable package* via this `pyproject.toml` (`pip install` maps the directory to the `eels_spamoor_builders` package name). + +The same source files satisfy both consumers. diff --git a/tests/benchmark/spamoor/__init__.py b/tests/benchmark/spamoor/__init__.py index c4b662ab0da..8cac42fa427 100644 --- a/tests/benchmark/spamoor/__init__.py +++ b/tests/benchmark/spamoor/__init__.py @@ -1 +1,46 @@ -"""Unit tests for spamoor scenario transaction builders.""" +"""Spamoor scenario transaction builders. + +This directory is dual-purpose: + +- As a *pytest test package* (``tests/benchmark/spamoor``) under the + execution-specs test tree. The relative imports inside ``test_*.py`` + (``from .helpers import …``, ``from .pool_runner import …``) continue + to work unchanged. +- As a *standalone pip package* (``eels-spamoor-builders``, declared in + the sibling ``pyproject.toml``). Downstream tooling that only needs to + build signed Spamoor transactions can ``pip install`` it without + pulling in the rest of execution-specs: + + pip install git+https://github.com/ethereum/execution-specs.git#subdirectory=tests/benchmark/spamoor + python -c "from eels_spamoor_builders import build_eoatx_transactions" +""" + +from .helpers import ( + build_blob_combined_transactions, + build_calltx_transactions, + build_deploytx_transactions, + build_eoatx_transactions, + build_erc20_bloater_transactions, + build_erc20tx_transactions, + build_evm_fuzz_transactions, + build_factorydeploytx_transactions, + build_gasburnertx_transactions, + build_storagerefundtx_transactions, + build_storagespam_transactions, + build_uniswap_swaps_transactions, +) + +__all__ = [ + "build_blob_combined_transactions", + "build_calltx_transactions", + "build_deploytx_transactions", + "build_eoatx_transactions", + "build_erc20_bloater_transactions", + "build_erc20tx_transactions", + "build_evm_fuzz_transactions", + "build_factorydeploytx_transactions", + "build_gasburnertx_transactions", + "build_storagerefundtx_transactions", + "build_storagespam_transactions", + "build_uniswap_swaps_transactions", +] diff --git a/tests/benchmark/spamoor/pyproject.toml b/tests/benchmark/spamoor/pyproject.toml new file mode 100644 index 00000000000..008251a3c49 --- /dev/null +++ b/tests/benchmark/spamoor/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=77.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "eels-spamoor-builders" +version = "0.1.0" +description = "Standalone Ethereum tx builders extracted from execution-specs Spamoor scenarios." +readme = "README.md" +requires-python = ">=3.10" +license = "CC0-1.0" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Testing", + "Topic :: System :: Benchmark", +] +dependencies = [ + "eth-abi>=4,<6", + "eth-utils>=2,<6", +] + +[project.optional-dependencies] +# Spamoor's pytest test helpers (`spamoor_signer_context`, `broadcast_and_assert_receipts`) +# import pytest + execution_testing.test_types lazily; consumers that only need the +# build_* functions can omit this. +test = ["pytest>=7"] + +[project.urls] +Homepage = "https://github.com/ethereum/execution-specs" + +[tool.setuptools] +packages = ["eels_spamoor_builders"] + +[tool.setuptools.package-dir] +eels_spamoor_builders = "." From c1b46499b83489cd9758be135362cf1ddca84dad Mon Sep 17 00:00:00 2001 From: AnkushinDaniil Date: Thu, 30 Apr 2026 15:30:09 +0400 Subject: [PATCH 10/11] review: address Copilot feedback on the eels-spamoor-builders package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four valid points from the bot review on PR #3: - README rpc_client example was wrong: lambda returned the JSON-RPC envelope ({"jsonrpc": "2.0", "result": "0x0"}) but the builders expect the unwrapped result. Replace with a function that returns "0x0" for eth_getTransactionCount and a feeHistory dict for eth_feeHistory, matching the actual call shape in build_eoatx_transactions and the 11 sister builders. - README's "pip install eels-spamoor-builders[test]" wouldn't work for a VCS-installed package. Drop the [test] extra entirely instead — the spamoor_signer_context / broadcast_and_assert_receipts helpers are not part of the build_* public API, and the suggested pytest-only extra was also incomplete (missing execution_testing). Document manual install for consumers that want them. - pyproject.toml [test] extra dropped (see above). Replaced with a comment pointing to the README's manual-install note. - __init__.py docstring claimed the builders "build signed Spamoor transactions". They actually return unsigned tx dicts; signing happens later via spamoor_dict_to_transaction. Reword to "build Spamoor transaction dictionaries" and call out that signing is downstream. Re-verified end-to-end against the orchestrator's existing usage: build_eoatx_transactions(count=3, ..., rpc_client=...) returns 3 unsigned type-2 dicts with nonces from rpc_client, maxFeePerGas = basefee*(1+throughput), signed=False. --- tests/benchmark/spamoor/README.md | 32 +++++++++++++++++++++----- tests/benchmark/spamoor/__init__.py | 4 +++- tests/benchmark/spamoor/pyproject.toml | 10 ++++---- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/benchmark/spamoor/README.md b/tests/benchmark/spamoor/README.md index 5ac9ac9df97..488a170d5cb 100644 --- a/tests/benchmark/spamoor/README.md +++ b/tests/benchmark/spamoor/README.md @@ -19,16 +19,35 @@ pip install git+https://github.com/ethereum/execution-specs.git#subdirectory=tes ## Use +The builders return *unsigned* type-2 transaction dictionaries — signing is the +caller's responsibility. `rpc_client` is invoked with `(method, params)` and +must return the *unwrapped* JSON-RPC `result` (not the full envelope). + ```python from eels_spamoor_builders import build_eoatx_transactions + +def rpc_client(method, params): + if method == "eth_getTransactionCount": + return "0x0" # nonce hex + if method == "eth_feeHistory": + return { + "oldestBlock": "0x0", + "baseFeePerGas": ["0x1", "0x1"], + "gasUsedRatio": [0.5], + "reward": [], + } + raise NotImplementedError(method) + + txs = build_eoatx_transactions( count=10, throughput=1.0, - from_addr="0x1111…", + from_addr="0x1111111111111111111111111111111111111111", private_key="0x" + "11" * 32, - rpc_client=lambda method, params: {"jsonrpc": "2.0", "result": "0x0"}, + rpc_client=rpc_client, ) +# txs is a list of unsigned tx dicts; sign them downstream. ``` The 12 builders are: @@ -40,10 +59,11 @@ The 12 builders are: `build_storagespam_transactions`, `build_uniswap_swaps_transactions`. The `spamoor_signer_context` and `broadcast_and_assert_receipts` helpers in -`helpers.py` are *not* re-exported because they import `pytest` and -`execution_testing.test_types` lazily; install the `test` extra -(`pip install eels-spamoor-builders[test]`) and import them from -`eels_spamoor_builders.helpers` if you need them. +`helpers.py` are *not* re-exported. They import `pytest` and +`execution_testing.test_types` lazily — neither is a hard dependency of this +package. If you need them, install both manually +(`pip install pytest "ethereum-execution @ git+https://github.com/ethereum/execution-specs.git#subdirectory=src/ethereum_spec_tools"` — adjust to whichever package ships `execution_testing` in your tree) +and import them from `eels_spamoor_builders.helpers`. ## Layout diff --git a/tests/benchmark/spamoor/__init__.py b/tests/benchmark/spamoor/__init__.py index 8cac42fa427..325e024bd10 100644 --- a/tests/benchmark/spamoor/__init__.py +++ b/tests/benchmark/spamoor/__init__.py @@ -8,7 +8,9 @@ to work unchanged. - As a *standalone pip package* (``eels-spamoor-builders``, declared in the sibling ``pyproject.toml``). Downstream tooling that only needs to - build signed Spamoor transactions can ``pip install`` it without + build Spamoor transaction dictionaries (the ``build_*`` helpers return + unsigned ``dict``\\ s; signing is done downstream — typically via + ``spamoor_dict_to_transaction``) can ``pip install`` it without pulling in the rest of execution-specs: pip install git+https://github.com/ethereum/execution-specs.git#subdirectory=tests/benchmark/spamoor diff --git a/tests/benchmark/spamoor/pyproject.toml b/tests/benchmark/spamoor/pyproject.toml index 008251a3c49..3b3f505d1f6 100644 --- a/tests/benchmark/spamoor/pyproject.toml +++ b/tests/benchmark/spamoor/pyproject.toml @@ -20,11 +20,11 @@ dependencies = [ "eth-utils>=2,<6", ] -[project.optional-dependencies] -# Spamoor's pytest test helpers (`spamoor_signer_context`, `broadcast_and_assert_receipts`) -# import pytest + execution_testing.test_types lazily; consumers that only need the -# build_* functions can omit this. -test = ["pytest>=7"] +# No [project.optional-dependencies] — `helpers.py` also defines two pytest-only +# helpers (`spamoor_signer_context`, `broadcast_and_assert_receipts`) that import +# `pytest` and `execution_testing.test_types` lazily. They are *not* part of the +# `build_*` public API exposed by this package; consumers that need them should +# install pytest + the execution-specs test infrastructure manually (see README). [project.urls] Homepage = "https://github.com/ethereum/execution-specs" From e3f3350348f6603e81cc65beff38079dd5369f1d Mon Sep 17 00:00:00 2001 From: Dmytro Biloshytskyi Date: Tue, 12 May 2026 17:09:53 +0300 Subject: [PATCH 11/11] Fixed type conversion and added workaround for test_evm_fuzz --- .../plugins/spamoor/spamoor.py | 18 +++++++-- .../plugins/spamoor/submitter.py | 40 +++++++++++++++++-- .../testing_build_block.py | 10 ++++- tests/benchmark/spamoor/pool_runner.py | 1 + tests/benchmark/spamoor/test_evm_fuzz.py | 4 +- 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py index b2c9443069a..100bb85add9 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/spamoor.py @@ -194,14 +194,22 @@ def _overlay_yaml_on_config( value = "0x" + format(value, "040x" if c_key == "contract_address" else "x") cfg[c_key] = value + # ``*_wei`` may arrive as a Python int (YAML ``safe_load`` default) or + # as a string (e.g. ``"1000000000"``). Accept both — silently falling + # back to the gwei field when the int form is present would change the + # effective fee profile without telling the user. base_fee_wei = yaml_cfg.get("base_fee_wei") - if isinstance(base_fee_wei, str) and base_fee_wei.strip(): + if isinstance(base_fee_wei, int) and not isinstance(base_fee_wei, bool): + cfg["basefee"] = base_fee_wei + elif isinstance(base_fee_wei, str) and base_fee_wei.strip(): cfg["basefee"] = int(base_fee_wei) elif isinstance(yaml_cfg.get("base_fee"), (int, float)): cfg["basefee"] = int(float(yaml_cfg["base_fee"]) * 1e9) tip_fee_wei = yaml_cfg.get("tip_fee_wei") - if isinstance(tip_fee_wei, str) and tip_fee_wei.strip(): + if isinstance(tip_fee_wei, int) and not isinstance(tip_fee_wei, bool): + cfg["tip_fee"] = tip_fee_wei + elif isinstance(tip_fee_wei, str) and tip_fee_wei.strip(): cfg["tip_fee"] = int(tip_fee_wei) elif isinstance(yaml_cfg.get("tip_fee"), (int, float)): cfg["tip_fee"] = int(float(yaml_cfg["tip_fee"]) * 1e9) @@ -894,7 +902,11 @@ def spamoor_wallet_pool( gas = int(spamoor_config.get("gas_limit") or 0) or 500_000 basefee = int(spamoor_config.get("basefee") or 1_000_000_000) tip = int(spamoor_config.get("tip_fee") or 1_000_000_000) - max_fee_per_gas = max(basefee, tip) + # Mirror the ``max(basefee*2, tip*2)`` upper bound that the funding + # path below sets as ``maxFeePerGas`` — the txpool reserves balance + # against that cap, not against the current basefee, so a tighter + # estimate here triggers intermittent ``InsufficientFunds``. + max_fee_per_gas = max(basefee * 2, tip * 2) value = int(spamoor_config.get("amount") or 0) expected_per_wallet = int( (gas * max_fee_per_gas + value) * txs_per_wallet * 1.5 diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/submitter.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/submitter.py index ff8106fb183..f7f575e8834 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/submitter.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/spamoor/submitter.py @@ -88,6 +88,7 @@ class TxRecord: rebroadcasts: int = 0 receipt: Optional[Dict[str, Any]] = None failed: bool = False + reverted: bool = False error: Optional[str] = None @@ -97,11 +98,33 @@ class WorkloadResult: confirmed: int = 0 failed: int = 0 pending: int = 0 + reverted: int = 0 wall_clock_seconds: float = 0.0 records: List[TxRecord] = field(default_factory=list) def asserts_pass(self) -> bool: - return self.failed == 0 and self.pending == 0 + return ( + self.failed == 0 + and self.pending == 0 + and self.reverted == 0 + ) + + +def _receipt_status_ok(receipt: Dict[str, Any]) -> bool: + """Return ``True`` if the receipt reports a successful execution. + + Pre-Byzantium receipts use ``root`` instead of ``status``; lacking + either, we treat the receipt as successful since we cannot prove + otherwise. + """ + status = receipt.get("status") + if status is None: + return True + if isinstance(status, int): + return status == 1 + if isinstance(status, str): + return int(status, 16) == 1 + return False # --- Submitter -------------------------------------------------------------- @@ -149,6 +172,7 @@ def submit_workload( fork: Any | None = None, blob_seed: int = 0, skip_assert: bool = False, + allow_revert: bool = False, ) -> WorkloadResult: """ Drive ``total_count`` transactions across the wallet pool. @@ -217,6 +241,11 @@ def watch(record: TxRecord, raw_hex: str, wallet: Wallet) -> None: if receipt: record.receipt = receipt record.confirmed_at = time.monotonic() + if not _receipt_status_ok(receipt) and not allow_revert: + record.reverted = True + record.error = ( + f"reverted: status={receipt.get('status')!r}" + ) wallet.mark_confirmed(record.nonce) break if ( @@ -311,9 +340,12 @@ def watch(record: TxRecord, raw_hex: str, wallet: Wallet) -> None: stop_event.set() confirmed = sum( - 1 for r in records if r.receipt is not None and not r.failed + 1 + for r in records + if r.receipt is not None and not r.failed and not r.reverted ) failed = sum(1 for r in records if r.failed) + reverted = sum(1 for r in records if r.reverted) pending = sum( 1 for r in records if r.receipt is None and not r.failed ) @@ -322,6 +354,7 @@ def watch(record: TxRecord, raw_hex: str, wallet: Wallet) -> None: confirmed=confirmed, failed=failed, pending=pending, + reverted=reverted, wall_clock_seconds=time.monotonic() - start, records=records, ) @@ -332,7 +365,8 @@ def watch(record: TxRecord, raw_hex: str, wallet: Wallet) -> None: raise AssertionError( f"workload incomplete: submitted={result.submitted} " f"confirmed={result.confirmed} pending={result.pending} " - f"failed={result.failed}; first error: {sample}" + f"failed={result.failed} reverted={result.reverted}; " + f"first error: {sample}" ) return result diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py index 62dbf5ca1d7..f30a5b1293a 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/testing_build_block/testing_build_block.py @@ -234,8 +234,14 @@ def bloat_signer(bloat_config: BloatConfig, bloat_eth_rpc: EthRPC) -> EOA: probe = EOA(key=bloat_config.signer_key, nonce=0) try: nonce = bloat_eth_rpc.get_transaction_count(probe, "latest") - except Exception: # noqa: BLE001 - avoid coupling to RPC failures - nonce = 0 + except Exception as exc: # noqa: BLE001 + # Fail fast: a silent ``nonce=0`` fallback would collide with any + # prior tx the signer has on chain and produce flaky "invalid + # nonce" rejects that mask the real RPC error. + raise pytest.UsageError( + f"bloat_signer: failed to fetch on-chain nonce for " + f"{probe} from {bloat_config.rpc_url}: {exc}" + ) from exc # ``EOA.__new__`` short-circuits when ``address`` is already an # ``EOA``, so wrap it as a plain ``Address`` to force re-construction # with the freshly fetched nonce. diff --git a/tests/benchmark/spamoor/pool_runner.py b/tests/benchmark/spamoor/pool_runner.py index 579e909ff2a..8b3e6a9f30d 100644 --- a/tests/benchmark/spamoor/pool_runner.py +++ b/tests/benchmark/spamoor/pool_runner.py @@ -227,6 +227,7 @@ def _tx_cost(tx: Dict[str, Any]) -> int: throughput=float(spamoor_config.get("throughput") or 1.0), max_pending=sizing["max_pending"], skip_assert=bool(spamoor_config.get("skip_assert", False)), + allow_revert=bool(spamoor_config.get("allow_revert", False)), fork=fork, blob_seed=blob_seed, ) diff --git a/tests/benchmark/spamoor/test_evm_fuzz.py b/tests/benchmark/spamoor/test_evm_fuzz.py index b604efc879d..8c3d05f789b 100644 --- a/tests/benchmark/spamoor/test_evm_fuzz.py +++ b/tests/benchmark/spamoor/test_evm_fuzz.py @@ -41,8 +41,10 @@ def test_evm_fuzz_scenario( assert tx["to"] == "" assert tx["data"].startswith("0x") + # Random bytecode reverts by design — landing on-chain is the success + # criterion for this scenario, not execution status. submit_pool_workload( - spamoor_config=spamoor_config, + spamoor_config={**spamoor_config, "allow_revert": True}, rpc_client=spamoor_rpc_client, pool=spamoor_wallet_pool, tx_dicts=txs,