diff --git a/docs/filling_tests/fill_stateful.md b/docs/filling_tests/fill_stateful.md index ebbb52bba9..dde7ac8548 100644 --- a/docs/filling_tests/fill_stateful.md +++ b/docs/filling_tests/fill_stateful.md @@ -123,6 +123,7 @@ Optional: - `--output PATH` — default `./fixtures`. - `--clean` — wipe the output dir before filling. - `--extract-opcode-count` — after building each block, trace it via `debug_traceBlockByHash` (a JS opcode-counting tracer) and record per-opcode execution counts (execution-phase blocks only) in the fixture's `_info.metadata.opcode_counts` — an array with one entry per `engineNewPayloads` block (`opcode_counts[i]` is the count for `engineNewPayloads[i]`, or `null` if its trace was unavailable), so multi-block benchmarks keep per-payload granularity. When a benchmark test declares a target opcode count (`fixed_opcode_count`/`expected_opcode_count`), the live-client count is verified against it and the fill fails on >5% divergence. Requires the `debug` namespace with JS tracer support. Adds a full re-execution trace per block, so it is slow and opt-in. +- `--no-reset-between-tests` — skip the between-test rewind to `start_block`, so each test builds on the state the previous one left behind and the client head accumulates upward. Used to pre-populate a datadir (e.g. deploy setup contracts) whose persisted state a later run builds on. The written fixtures are unchanged — each still records its own `start_block` — so a fixture produced this way is only valid to replay against a client already at that `start_block`; do not mix accumulate-state fills and normal single-anchor fills in one output dir. ## Output layout @@ -198,6 +199,7 @@ Both backends satisfy `FillerBackend` (`client_clis/filler_backend.py`). `Client |---|---|---| | `--snapshot-block` | `fill-stateful` | Anchor by 32-byte hash (reorg-safe) or block number; defaults to `latest`. | | `--rpc-seed-key` | `fill-stateful` | Pin the seed EOA; otherwise generated + funded via CL withdrawal. | +| `--no-reset-between-tests` | `fill-stateful` | Skip the between-test rewind so tests accumulate state on the live client (pre-populate a datadir a later run builds on). | | `--default-gas-price`, `--default-max-fee-per-gas`, `--default-max-priority-fee-per-gas`, `--default-max-fee-per-blob-gas` | `shared/live_client_flags` | Pin per-session fees; defaults bump a one-shot live query by `1.5x`. | | `--max-gas-per-test`, `--max-tx-per-batch`, `--transaction-gas-limit`, ... | `shared/live_client_flags` | Generic live-client knobs reused across commands. | diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index 0b47af7a9b..9bd3ff0392 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -50,6 +50,7 @@ ) from .test_types import ( DETERMINISTIC_FACTORY_ADDRESS, + DETERMINISTIC_FACTORY_BYTECODE, EOA, Alloc, AuthorizationTuple, @@ -132,6 +133,7 @@ __all__ = ( "DETERMINISTIC_FACTORY_ADDRESS", + "DETERMINISTIC_FACTORY_BYTECODE", "AccessList", "Account", "Address", diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py index e98f15a657..11661319aa 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/fill_stateful/fill_stateful.py @@ -158,6 +158,17 @@ def pytest_addoption(parser: pytest.Parser) -> None: "This flag checks every account at start_block.)" ), ) + group.addoption( + "--no-reset-between-tests", + action="store_true", + dest="no_reset_between_tests", + default=False, + help=( + "Accumulate state across tests (don't rewind between them). Useful" + "for pre-populating datadir. Fixtures remain valid only from their" + "recorded start_block. Don't mix with single-anchor fills." + ), + ) def _resolve_session_fork( @@ -507,6 +518,12 @@ def extract_opcode_count(request: pytest.FixtureRequest) -> bool: return request.config.getoption("extract_opcode_count") +@pytest.fixture(scope="session") +def no_reset_between_tests(request: pytest.FixtureRequest) -> bool: + """Whether --no-reset-between-tests state accumulation is enabled.""" + return request.config.getoption("no_reset_between_tests") + + @pytest.fixture(scope="session") def client_backend( eth_rpc: ChainBuilderEthRPC, @@ -770,6 +787,7 @@ def _reset_chain_between_tests( client_backend: ClientBackend, debug_rpc: DebugRPC, eth_rpc: "ChainBuilderEthRPC", + no_reset_between_tests: bool, ) -> Generator[None, None, None]: """ Rewind to start_block after each test so the chain is identical for @@ -781,6 +799,8 @@ def _reset_chain_between_tests( drifted (e.g. a live reorg). """ yield + if no_reset_between_tests: + return if client_backend.start_block is None: return start_hex = client_backend.start_block["number"] diff --git a/tests/benchmark/helper/transactions.py b/tests/benchmark/helper/transactions.py index 70a623fdaa..ee5a7f5721 100644 --- a/tests/benchmark/helper/transactions.py +++ b/tests/benchmark/helper/transactions.py @@ -10,6 +10,7 @@ Fork, Hash, Transaction, + TransactionWithCost, ) from .enums import CacheStrategy @@ -138,3 +139,48 @@ def pack_transactions_into_blocks( blocks.append(Block(txs=current_txs)) return blocks + + +def pack_transactions_with_cost_into_blocks( + transactions: list[TransactionWithCost], + gas_limit: int, +) -> list[Block]: + """ + Pack transactions into blocks, tracking both gas dimensions. + + A transaction is includable only while its gas limit still fits the + room left in the regular and in the state dimension alike, so the + room a block has left is measured against the larger of the two + running totals. Raise when a single transaction cannot fit an empty + block, which no packing can rescue. + """ + if not transactions: + return [] + + blocks: list[Block] = [] + current_txs: list[TransactionWithCost] = [] + current_regular = 0 + current_state = 0 + + for tx in transactions: + tx_gas_limit = int(tx.gas_limit) + if tx_gas_limit > gas_limit: + raise ValueError( + f"transaction gas limit {tx_gas_limit} exceeds the " + f"{gas_limit} block gas limit" + ) + room = gas_limit - max(current_regular, current_state) + if tx_gas_limit > room and current_txs: + blocks.append(Block(txs=current_txs)) + current_txs = [] + current_regular = 0 + current_state = 0 + + current_txs.append(tx) + current_regular += tx.regular_cost + current_state += tx.state_cost + + if current_txs: + blocks.append(Block(txs=current_txs)) + + return blocks diff --git a/tests/benchmark/stateful/bloatnet/test_setup_contracts.py b/tests/benchmark/stateful/bloatnet/test_setup_contracts.py new file mode 100644 index 0000000000..f66e377970 --- /dev/null +++ b/tests/benchmark/stateful/bloatnet/test_setup_contracts.py @@ -0,0 +1,222 @@ +"""Deploy the CREATE2 contracts for benchmarks.""" + +import os + +import pytest +from execution_testing import ( + DETERMINISTIC_FACTORY_ADDRESS, + DETERMINISTIC_FACTORY_BYTECODE, + EOA, + Account, + Alloc, + AuthorizationTuple, + BenchmarkTestFiller, + Fork, + Hash, + Op, + TransactionWithCost, + compute_create2_address, +) + +from tests.benchmark.helper.account_creator import ( + AccountCreator, + AccountMode, +) +from tests.benchmark.helper.account_sender_receiver import ( + DELEGATE_BASE_KEY, +) +from tests.benchmark.helper.transactions import ( + pack_transactions_with_cost_into_blocks, +) +from tests.prague.eip7702_set_code_tx.spec import Spec as Spec7702 + +# Number of CREATE2 receiver contracts deployed per mode. Overridable via +# BLOATNET_RECEIVER_CONTRACT_COUNT so a smoke/plumbing run (e.g. benchmarkoor +# pre_runs) can deploy a small set for fast iteration; defaults to the full +# 100k benchmark set. +RECEIVER_CONTRACT_COUNT = int( + os.environ.get("BLOATNET_RECEIVER_CONTRACT_COUNT", "100000") +) + +CONTRACT_MODES = [ + AccountMode.EXISTING_CONTRACT_MINIMAL, + AccountMode.EXISTING_CONTRACT_SAME_MAX, + AccountMode.EXISTING_CONTRACT_DIFF_MAX, +] + +# Factory-frame and initcode execution costs (CALLDATACOPY, the MCOPY +# doubling loop, memory expansion) plus CREATE2's 63/64 retention. +EXECUTION_GAS_BUFFER = 50_000 + + +def deployment_gas( + fork: Fork, initcode: bytes, runtime_size: int +) -> tuple[int, int]: + """ + Return the (regular, state) gas for one CREATE2 deployment, derived + from the intrinsic, CREATE2, and code-deposit costs with a margin + for the factory and initcode execution. + + The opcode costs are two-dimensional totals, so the state gas the + account creation and the code deposit charge is split back out; the + margin lands on the regular side, which is what it pays for. + """ + initcode_size = len(initcode) + intrinsic = fork.transaction_intrinsic_cost_calculator()( + calldata=b"\xff" * 32 + initcode + ) + create_cost = Op.CREATE2( + value=0, + offset=0, + size=initcode_size, + salt=0, + # Gas accounting + init_code_size=initcode_size, + ).gas_cost(fork) + # The factory frame wrapped around that CREATE2: its own bytecode, + # less the CREATE2 already counted above, plus the copy of the + # initcode out of the calldata that the bytecode alone cannot size. + factory_cost = ( + DETERMINISTIC_FACTORY_BYTECODE.gas_cost(fork) + - Op.CREATE2(value=0, offset=0, size=0, salt=0).gas_cost(fork) + + Op.CALLDATACOPY( + dest_offset=0, + offset=32, + size=initcode_size, + # Gas accounting + data_size=initcode_size, + old_memory_size=0, + new_memory_size=initcode_size, + ).gas_cost(fork) + ) + deposit_cost = Op.RETURN( + 0, + runtime_size, + code_deposit_size=runtime_size, + ).gas_cost(fork) + base = intrinsic + factory_cost + create_cost + deposit_cost + state = fork.create_state_gas(code_size=runtime_size) + regular = base - state + base // 16 + EXECUTION_GAS_BUFFER + return regular, state + + +@pytest.mark.valid_from("Amsterdam") +def test_deploy_existing_contracts( + benchmark_test: BenchmarkTestFiller, + pre: Alloc, + fork: Fork, + gas_benchmark_value: int, + tx_gas_limit: int, +) -> None: + """ + Deploy the contracts behind the `AccountMode.EXISTING_CONTRACT_*` + receivers via the deterministic CREATE2 factory. + + Delegate deterministic EOAs to EXISTING_CONTRACT_DIFF_MAX receivers. + """ + txs = [] + post: dict = {} + for account_mode in CONTRACT_MODES: + creator = AccountCreator(account_mode) + initcode = creator.initcode + regular_gas, state_gas = deployment_gas( + fork, initcode, creator.runtime_size + ) + sender = pre.fund_eoa() + for salt in range(RECEIVER_CONTRACT_COUNT): + txs.append( + TransactionWithCost( + to=DETERMINISTIC_FACTORY_ADDRESS, + data=Hash(salt) + initcode, + gas_limit=regular_gas + state_gas, + sender=sender, + regular_cost=regular_gas, + state_cost=state_gas, + ) + ) + # Nonce 1 at the CREATE2-derived address proves deployment. + for salt in (0, RECEIVER_CONTRACT_COUNT - 1): + contract = compute_create2_address( + address=DETERMINISTIC_FACTORY_ADDRESS, + salt=salt, + initcode=initcode, + ) + post[contract] = Account(nonce=1) + + # Delegate authority i to the i-th DIFF receiver (EIP-7702). + delegation_sender = pre.fund_eoa() + intrinsic = fork.transaction_intrinsic_cost_calculator() + top_frame = fork.transaction_top_frame_gas_calculator() + # DIFF receivers share one initcode; build it once for CREATE2 derivation. + diff_initcode = AccountCreator( + AccountMode.EXISTING_CONTRACT_DIFF_MAX + ).initcode + + authorizations = [] + for i in range(RECEIVER_CONTRACT_COUNT): + authority = EOA(key=DELEGATE_BASE_KEY + i) + target = compute_create2_address( + address=DETERMINISTIC_FACTORY_ADDRESS, + salt=i, + initcode=diff_initcode, + ) + authorizations.append( + AuthorizationTuple( + address=target, + nonce=0, + signer=authority, + # The authorities are deterministic and never funded, so + # applying the delegation is what brings them into being. + creates_account=True, + ) + ) + if i == 0 or i == RECEIVER_CONTRACT_COUNT - 1: + post[authority] = Account( + nonce=1, + code=Spec7702.delegation_designation(target), + ) + + def authorization_gas( + authorization_list: list[AuthorizationTuple], + ) -> tuple[int, int]: + """Return the (regular, state) gas an authorization list costs.""" + regular = intrinsic( + authorization_list_or_count=len(authorization_list) + ) + top_frame(authorizations=authorization_list) + state = fork.transaction_top_frame_state_gas( + authorizations=authorization_list + ) + return regular, state + + gas_buffer = 100_000 + base_regular, base_state = authorization_gas([]) + one_regular, one_state = authorization_gas(authorizations[:1]) + per_auth_gas = (one_regular + one_state) - (base_regular + base_state) + auths_per_tx = max( + 1, + (tx_gas_limit - gas_buffer - base_regular - base_state) + // per_auth_gas, + ) + + for start in range(0, RECEIVER_CONTRACT_COUNT, auths_per_tx): + authorization_list = authorizations[start : start + auths_per_tx] + regular_gas, state_gas = authorization_gas(authorization_list) + txs.append( + TransactionWithCost( + to=delegation_sender, + gas_limit=regular_gas + state_gas + gas_buffer, + sender=delegation_sender, + authorization_list=authorization_list, + regular_cost=regular_gas + gas_buffer, + state_cost=state_gas, + ) + ) + + benchmark_test( + post=post, + blocks=pack_transactions_with_cost_into_blocks( + txs, gas_benchmark_value + ), + skip_gas_used_validation=True, + expected_receipt_status=1, + )