From cd8c9815985f40e4a3486f388d764357d33b5c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E4=BD=B3=E8=AA=A0=20Louis=20Tsai?= <72684086+LouisTsai-Csie@users.noreply.github.com> Date: Wed, 13 May 2026 22:25:51 +0200 Subject: [PATCH 1/4] feat(spec-specs, spec-tests): add EIP-7778 block gas accounting without refunds (#2840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(spec-specs): Implement EIP-7778 Block Gas Accounting without Refunds (#1401) * feat(specs): add eip-7778 implementation * fix(specs) spliting receipt gas from block gas * revert receipt_gas_used * make sure that the calldata floor cost overwrites the tx gas before refund * receipt gas used after refunds * clarify variable naming @gurukamath * revert to using gas used after refunds for cumulative gas in receipt and add gasSpent to receipt * spec(amsterdam): fix code formatting --------- Co-authored-by: Guruprasad Kamath * fix(tests): fix legacy tests to run with eip-7778 (#2048) * fix(tests): fix legacy tests to run with eip-7778 * fix(tests): check gas_spent and gas_used after Amsterdam * feat(spec-tests): add basic eip7778 test (#2045) * feat(spec-tests): add eip-7778 tests for calldata checks (#2060) * feat(spec-tests): add eip-7778 for calldata checks * feat(spec-tests): post review - use code gas_cost function Co-authored-by: Louis Tsai <72684086+LouisTsai-Csie@users.noreply.github.com> * Apply suggestions from code review --------- Co-authored-by: Louis Tsai <72684086+LouisTsai-Csie@users.noreply.github.com> Co-authored-by: Mario Vega * fix(tests): Fix gas used/spent in EIP-7702 tests (#2068) * feat(spec-specs): use post refund gas in receipts in EIP-7778 (#2073) * fix(test): Update refspec for EIP-7778 to match latest revision (#2093) * feat(test-forks): Add EIP-7778 * refactor(tests): Condition EIP-7778 tests to EIP inclusion * fix(test): use deterministic iteration order for refund types The set iteration in `build_refund_tx` is non-deterministic due to Python's hash randomization, causing fixture output to vary between runs. Sort by enum value to ensure reproducible fixtures. * doc: remove changelog md * fix: broken auth tx benchmark * doc: add eip checklist * fix: typo * Update tests/benchmark/compute/scenario/test_transaction_types.py * Apply suggestions from code review Co-authored-by: Mario Vega --------- Co-authored-by: Toni Wahrstätter <51536394+nerolation@users.noreply.github.com> Co-authored-by: Guruprasad Kamath Co-authored-by: Guruprasad Kamath <48196632+gurukamath@users.noreply.github.com> Co-authored-by: Mario Vega Co-authored-by: felipe Co-authored-by: spencer-tb --- docs/writing_tests/test_markers.md | 24 + .../testing/src/execution_testing/__init__.py | 3 +- .../pytest_commands/plugins/forks/forks.py | 9 + .../src/execution_testing/forks/__init__.py | 2 + .../src/execution_testing/forks/base_fork.py | 17 + .../forks/forks/eips/amsterdam/eip_7778.py | 16 + .../forks/forks/eips/prague/eip_7702.py | 10 + .../execution_testing/forks/forks/forks.py | 8 + .../src/execution_testing/specs/blockchain.py | 10 + src/ethereum/forks/amsterdam/blocks.py | 1 + src/ethereum/forks/amsterdam/fork.py | 19 +- src/ethereum/forks/amsterdam/vm/__init__.py | 1 + .../__init__.py | 1 + .../eip_checklist_external_coverage.txt | 2 + .../eip_checklist_not_applicable.txt | 17 + .../test_gas_accounting.py | 530 ++++++++++++++++++ .../scenario/test_transaction_types.py | 9 +- 17 files changed, 670 insertions(+), 9 deletions(-) create mode 100644 packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7778.py create mode 100644 tests/amsterdam/eip7778_block_gas_accounting_without_refunds/__init__.py create mode 100644 tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_external_coverage.txt create mode 100644 tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_not_applicable.txt create mode 100644 tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py diff --git a/docs/writing_tests/test_markers.md b/docs/writing_tests/test_markers.md index 25fce189257..22c613ab498 100644 --- a/docs/writing_tests/test_markers.md +++ b/docs/writing_tests/test_markers.md @@ -203,6 +203,30 @@ def test_something_with_all_system_contracts( In this example, the test will be parameterized for parameter `system_contract` with value `[0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02]` for fork Cancun. +### `@pytest.mark.with_all_refund_types` + +This marker is used to automatically parameterize a test with all types of refunds that are valid for the fork being tested. + +Useful to mark tests to fail if a new refund type is introduced by a future fork and the test needs to be kept up to date and maintained. + +```python +import pytest + +from execution_testing import Address, Alloc, RefundTypes, StateTestFiller + +@pytest.mark.with_all_refund_types +@pytest.mark.valid_from("Prague") +def test_something_with_all_refund_types( + state_test: StateTestFiller, + pre: Alloc, + refund_type: RefundTypes, +): + pass + +``` + +In this example, the test will be parameterized for parameter `refund_type` with value `[RefundTypes.STORAGE_CLEAR, RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY]` for fork Prague. + ### Covariant Marker Keyword Arguments All fork covariant markers accept the following keyword arguments: diff --git a/packages/testing/src/execution_testing/__init__.py b/packages/testing/src/execution_testing/__init__.py index ae04d86a2e0..e8a5b2e3211 100644 --- a/packages/testing/src/execution_testing/__init__.py +++ b/packages/testing/src/execution_testing/__init__.py @@ -30,7 +30,7 @@ TransactionException, ) from .fixtures import BaseFixture, FixtureCollector -from .forks import Fork, GasCosts, TransitionFork +from .forks import Fork, GasCosts, RefundTypes, TransitionFork from .specs import ( BaseTest, BenchmarkTest, @@ -185,6 +185,7 @@ "ParameterSet", "ReferenceSpec", "ReferenceSpecTypes", + "RefundTypes", "Removable", "Requests", "SequentialAddressLayout", diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py index 2dc7336f6e8..b10bc17c5ec 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py @@ -453,6 +453,15 @@ def covariant_decorator( fork_attribute_name="system_contracts", argnames=["system_contract"], ), + covariant_decorator( + marker_name="with_all_refund_types", + description=( + "marks a test to be parametrized for all refund types at " + "parameter named refund_type" + ), + fork_attribute_name="refund_types", + argnames=["refund_type"], + ), ] diff --git a/packages/testing/src/execution_testing/forks/__init__.py b/packages/testing/src/execution_testing/forks/__init__.py index 9dd5f0d4334..cd333c559e8 100644 --- a/packages/testing/src/execution_testing/forks/__init__.py +++ b/packages/testing/src/execution_testing/forks/__init__.py @@ -1,5 +1,6 @@ """Ethereum test fork definitions.""" +from .base_fork import RefundTypes from .forks.forks import ( BPO1, BPO2, @@ -87,6 +88,7 @@ "TransitionFork", "TransitionForkAdapter", "TransitionForkOrNoneAdapter", + "RefundTypes", "Amsterdam", "ArrowGlacier", "Berlin", diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index c074c3da840..419b27a915b 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -2,6 +2,7 @@ import re from abc import ABCMeta, abstractmethod +from enum import Enum, auto from typing import ( TYPE_CHECKING, Callable, @@ -168,6 +169,13 @@ def __call__( pass +class RefundTypes(Enum): + """Enum used to describe all refund types a fork can have.""" + + STORAGE_CLEAR = auto() + AUTHORIZATION_EXISTING_AUTHORITY = auto() + + class BaseForkMeta(ABCMeta): """Metaclass for BaseFork.""" @@ -979,6 +987,15 @@ def max_request_type(cls) -> int: """Return max request type supported by the fork.""" pass + @classmethod + @abstractmethod + def refund_types(cls) -> List[RefundTypes]: + """ + Return the list of refund types that are possible given current + fork logic. + """ + pass + # Meta information about the fork @classmethod def name(cls) -> str: diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7778.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7778.py new file mode 100644 index 00000000000..e7153a7fc8a --- /dev/null +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7778.py @@ -0,0 +1,16 @@ +""" +EIP-7778: Block Gas Accounting without Refunds. + +Prevent Block Gas Limit Circumvention by Excluding Refunds from Block Gas +Accounting. + +https://eips.ethereum.org/EIPS/eip-7778 +""" + +from ....base_fork import BaseFork + + +class EIP7778(BaseFork): + """EIP-7778 class.""" + + pass diff --git a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py index d19fd6009c1..424c4ab2dab 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py @@ -15,6 +15,7 @@ from ....base_fork import ( BaseFork, + RefundTypes, TransactionIntrinsicCostCalculator, ) from ....gas_costs import GasCosts @@ -95,3 +96,12 @@ def fn( return intrinsic_cost return fn + + @classmethod + def refund_types(cls) -> List[RefundTypes]: + """ + At Prague, existing authorization refund is introduced. + """ + refunds = super(EIP7702, cls).refund_types() + refunds.append(RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY) + return refunds diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index d1d6cd06143..454f3d8cabd 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -29,6 +29,7 @@ CalldataGasCalculator, ExcessBlobGasCalculator, MemoryExpansionGasCalculator, + RefundTypes, TransactionDataFloorCostCalculator, TransactionIntrinsicCostCalculator, ) @@ -1199,6 +1200,13 @@ def max_request_type(cls) -> int: """At genesis, no request type is supported, signaled by -1.""" return -1 + @classmethod + def refund_types(cls) -> List[RefundTypes]: + """ + At genesis, storage clearing refund is introduced. + """ + return [RefundTypes.STORAGE_CLEAR] + @classmethod def pre_allocation(cls) -> Mapping: """ diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 61ab40360fd..3313e8795f3 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -303,6 +303,8 @@ class Block(Header): """Post state for verification after block execution in BlockchainTest""" block_access_list: Bytes | None = Field(None) """EIP-7928: Block-level access lists (serialized).""" + expected_gas_used: int | None = None + """Expected gas used for the block.""" def set_environment(self, env: Environment) -> Environment: """ @@ -695,6 +697,14 @@ def generate_block_data( f"Verification of block {int(env.number)} failed" ) from e + if block.expected_gas_used is not None: + gas_used = int(transition_tool_output.result.gas_used) + assert gas_used == block.expected_gas_used, ( + f"gas_used ({gas_used}) does not match expected_gas_used " + f"({block.expected_gas_used})" + f", difference: {gas_used - block.expected_gas_used}" + ) + requests_list: List[Bytes] | None = None if fork.header_requests_required(): assert transition_tool_output.result.requests is not None, ( diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index f67d52adfc2..5a4e1b410f0 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -360,6 +360,7 @@ class Receipt: cumulative_gas_used: Uint """ Total gas used in the block up to and including this transaction. + This is the gas used after refunds, paid by the user. """ bloom: Bloom diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index bbb1d832193..0af84822dc8 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -654,7 +654,7 @@ def make_receipt( Error in the top level frame of the transaction, if any. cumulative_gas_used : The total gas used so far in the block after the transaction was - executed. + executed. This is the gas used after refunds. logs : The logs produced by the transaction. @@ -1039,16 +1039,17 @@ def process_transaction( # Transactions with less execution_gas_used than the floor pay at the # floor cost. - tx_gas_used_after_refund = max( - tx_gas_used_after_refund, calldata_floor_gas_cost + tx_gas_used = max(tx_gas_used_after_refund, calldata_floor_gas_cost) + block_gas_used_in_tx = max( + tx_gas_used_before_refund, calldata_floor_gas_cost ) - tx_gas_left = tx.gas - tx_gas_used_after_refund + tx_gas_left = tx.gas - tx_gas_used gas_refund_amount = tx_gas_left * effective_gas_price # For non-1559 transactions effective_gas_price == tx.gas_price priority_fee_per_gas = effective_gas_price - block_env.base_fee_per_gas - transaction_fee = tx_gas_used_after_refund * priority_fee_per_gas + transaction_fee = tx_gas_used * priority_fee_per_gas # refund gas sender_balance_after_refund = get_account(tx_state, sender).balance + U256( @@ -1090,11 +1091,15 @@ def process_transaction( ): destroy_account(tx_state, block_env.coinbase) - block_output.block_gas_used += tx_gas_used_after_refund + block_output.cumulative_gas_used += tx_gas_used + block_output.block_gas_used += block_gas_used_in_tx block_output.blob_gas_used += tx_blob_gas_used receipt = make_receipt( - tx, tx_output.error, block_output.block_gas_used, all_logs + tx, + tx_output.error, + block_output.cumulative_gas_used, + all_logs, ) receipt_key = rlp.encode(Uint(index)) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 2ae8d4c984c..ff523e2c33c 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -88,6 +88,7 @@ class BlockOutput: """ block_gas_used: Uint = Uint(0) + cumulative_gas_used: Uint = Uint(0) transactions_trie: Trie[Bytes, Optional[Bytes | LegacyTransaction]] = ( field(default_factory=lambda: Trie(secured=False, default=None)) ) diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/__init__.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/__init__.py new file mode 100644 index 00000000000..fcecd89390c --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/__init__.py @@ -0,0 +1 @@ +"""Tests for [EIP-7778: Block Gas Accounting without Refunds](https://eips.ethereum.org/EIPS/eip-7778).""" diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_external_coverage.txt b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_external_coverage.txt new file mode 100644 index 00000000000..eafb5d812a7 --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_external_coverage.txt @@ -0,0 +1,2 @@ +general/code_coverage/eels = Please check https://app.codecov.io/gh/ethereum/execution-specs/pull/2840 for relevant test coverage +general/code_coverage/test_coverage = Please run the test with `--cov` flag for final coverage diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_not_applicable.txt b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_not_applicable.txt new file mode 100644 index 00000000000..54926eee1f9 --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/eip_checklist_not_applicable.txt @@ -0,0 +1,17 @@ +opcode = EIP does not introduce a new opcode +precompile = EIP does not introduce a new precompile +removed_precompile = EIP does not remove a precompile +system_contract = EIP does not introduce a new system contract +transaction_type = EIP does not introduce a new transaction type +block_header_field = EIP does not add any new block header fields +block_body_field = EIP does not add any new block body fields +gas_cost_changes = EIP does not modify per-operation gas costs +gas_refunds_changes/test/refund_calculation/over = EIP does not change refund calculation, only block-level accounting +gas_refunds_changes/test/refund_calculation/exact = EIP does not change refund calculation, only block-level accounting +gas_refunds_changes/test/refund_calculation/under = EIP does not change refund calculation, only block-level accounting +gas_refunds_changes/test/exceptional_abort/revertable = EIP does not change refund behavior on revertable aborts +gas_refunds_changes/test/exceptional_abort/non_revertable = EIP does not change refund behavior on non-revertable aborts +blob_count_changes = EIP does not introduce any blob count changes +execution_layer_request = EIP does not introduce an execution layer request +new_transaction_validity_constraint = EIP does not introduce a new transaction validity constraint +modified_transaction_validity_constraint = EIP does not introduce a modified transaction validity constraint \ No newline at end of file diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py new file mode 100644 index 00000000000..27f373a1409 --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py @@ -0,0 +1,530 @@ +""" +Test cases for +[EIP-7778 Block Gas Accounting without Refunds](https://eips.ethereum.org/EIPS/eip-7778). +""" + +from enum import Enum +from typing import Set, Tuple + +import pytest +from execution_testing import ( + Account, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + BlockException, + Bytecode, + Environment, + Fork, + RefundTypes, + Transaction, + TransactionException, +) +from execution_testing.base_types import HashInt +from execution_testing.vm import Op + +REFERENCE_SPEC_GIT_PATH = "EIPS/eip-7778.md" +REFERENCE_SPEC_VERSION = "ce17d00b8341032a946301944124c4a6013032d6" + + +def build_refund_tx( + fork: Fork, + pre: Alloc, + post: Alloc, + refund_types: Set[RefundTypes], + refunds_count: int = 1, + refund_tx_reverts: bool = False, + call_data: bytes = b"", + refund_tx_has_extra_gas_limit: bool = False, + exceed_block_gas_limit: bool = False, +) -> Tuple[int, int, int, Transaction]: + """Build a transaction that has different refund types from a fork.""" + # All essential calc functions + intrinsic_cost_calc = fork.transaction_intrinsic_cost_calculator() + max_refund_quotient = fork.max_refund_quotient() + gsc = fork.gas_costs() + data_floor_calc = fork.transaction_data_floor_cost_calculator() + + # Initial account pre loading + initial_fund = 10**18 + refund_tx_sender = pre.fund_eoa(initial_fund) + + # Initialize other aspects of pre-alloc + code = Bytecode() + authorization_list = None + refund_counter = 0 + storage_slots = list(range(HashInt(refunds_count))) + + empty_storage_on_success = False + refund_tx_extra_gas = 1 if refund_tx_has_extra_gas_limit else 0 + + for refund_type in sorted(refund_types, key=lambda r: r.value): + match refund_type: + case RefundTypes.STORAGE_CLEAR: + for slot in storage_slots: + code += Op.SSTORE( + slot, + Op.PUSH0, + # Gas accounting + original_value=1, + new_value=0, + ) + empty_storage_on_success = True + + case RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY: + code += Op.PUSH0 + delegated_contract = pre.deploy_contract(code=Bytecode()) + authorization_list = [ + AuthorizationTuple( + address=delegated_contract, + nonce=0, + signer=pre.fund_eoa(amount=1), + ) + for _ in range(refunds_count) + ] + refund_counter += ( + gsc.REFUND_AUTH_PER_EXISTING_ACCOUNT * refunds_count + ) + case _: + raise ValueError( + f"Unknown refund type: {refund_type} (Test needs update)" + ) + + if refund_tx_reverts: + code += Op.REVERT(0, 0) + + contract_address = pre.deploy_contract( + code=code, + storage=dict.fromkeys(storage_slots, 1), + ) + + gas_used_pre_refund = intrinsic_cost_calc( + calldata=call_data, + return_cost_deducted_prior_execution=True, + authorization_list_or_count=authorization_list, + ) + code.gas_cost(fork) + + # Calculate refund (still applied to user's balance) + if not refund_tx_reverts: + refund_counter += code.refund(fork) + + effective_refund = min( + refund_counter, gas_used_pre_refund // max_refund_quotient + ) + gas_used_post_refund = gas_used_pre_refund - effective_refund + call_data_floor_cost = data_floor_calc(data=call_data) + + refund_tx_block_gas_used = max(call_data_floor_cost, gas_used_pre_refund) + refund_tx_gas_used = max(call_data_floor_cost, gas_used_post_refund) + + # Build refund transaction + refund_tx = Transaction( + to=contract_address, + data=call_data, + gas_limit=refund_tx_block_gas_used + refund_tx_extra_gas, + sender=refund_tx_sender, + authorization_list=authorization_list, + expected_receipt={ + "gas_used": refund_tx_gas_used, + }, + ) + refund_tx_gas_price = ( + refund_tx.gas_price + if refund_tx.gas_price + else refund_tx.max_fee_per_gas + ) + + if ( + refund_tx_reverts + or exceed_block_gas_limit + or not empty_storage_on_success + ): + post[contract_address] = Account( + storage=dict.fromkeys(storage_slots, 1), + ) + else: + post[contract_address] = Account( + storage=dict.fromkeys(storage_slots, 0), + ) + + assert refund_tx_gas_price is not None, ( + "refund_tx_gas_price should not be None" + ) + expected_balance = initial_fund - ( + refund_tx_gas_used * refund_tx_gas_price + ) + + if not exceed_block_gas_limit: + post[refund_tx_sender] = Account(balance=expected_balance) + + return ( + gas_used_post_refund, + gas_used_pre_refund, + call_data_floor_cost, + refund_tx, + ) + + +@pytest.mark.parametrize( + "refund_tx_reverts", + [ + pytest.param(True, id="refund_tx_reverts"), + pytest.param(False, id=""), + ], +) +@pytest.mark.with_all_refund_types() +@pytest.mark.execute(pytest.mark.skip(reason="Requires specific gas price")) +@pytest.mark.valid_from("EIP7778") +def test_simple_gas_accounting( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + refund_type: RefundTypes, + refund_tx_reverts: bool, +) -> None: + """Test gas accounting for all refund types available in the given fork.""" + refunds_count = 10 + + post = Alloc() + + (_, gas_used_pre_refund, call_data_floor_cost, refund_tx) = ( + build_refund_tx( + fork=fork, + pre=pre, + post=post, + refund_types={refund_type}, + refunds_count=refunds_count, + refund_tx_reverts=refund_tx_reverts, + ) + ) + + refund_tx_block_gas_used = max(gas_used_pre_refund, call_data_floor_cost) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[refund_tx], + expected_gas_used=refund_tx_block_gas_used, + ) + ], + post=post, + ) + + +@pytest.mark.parametrize( + "refund_tx_reverts", + [ + pytest.param(True, id="refund_tx_reverts"), + pytest.param(False, id=""), + ], +) +@pytest.mark.parametrize( + "refund_tx_has_extra_gas_limit", + [ + pytest.param(True, id="refund_tx_has_extra_gas"), + pytest.param(False, id=""), + ], +) +@pytest.mark.parametrize( + "extra_tx_data_floor", + [ + pytest.param(True, id=""), + pytest.param(False, id="extra_tx_hits_data_floor"), + ], +) +@pytest.mark.parametrize( + "exceed_block_gas_limit", + [ + pytest.param(True, marks=pytest.mark.exception_test), + False, + ], +) +@pytest.mark.with_all_refund_types() +@pytest.mark.execute(pytest.mark.skip(reason="Requires specific gas price")) +@pytest.mark.valid_from("EIP7778") +def test_multi_transaction_gas_accounting( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + refund_type: RefundTypes, + refund_tx_has_extra_gas_limit: bool, + exceed_block_gas_limit: bool, + extra_tx_data_floor: bool, + refund_tx_reverts: bool, +) -> None: + """ + Test block gas accounting with refunds per EIP-7778. + + When exceed_block_gas_limit=True, we create a scenario where: + - Pre-refund gas (gas_used) > block_gas_limit - intrinsic_cost + (no room for another tx with correct EIP-7778 accounting) + - Post-refund gas (gas_spent) <= block_gas_limit - intrinsic_cost + (appears to have room with old refund-based accounting) + + This tests that clients correctly use pre-refund gas for block accounting. + """ + intrinsic_cost_calc = fork.transaction_intrinsic_cost_calculator() + + refunds_count = 10 + stop_bytecode = Op.STOP + stop_address = pre.deterministic_deploy_contract(deploy_code=stop_bytecode) + + post = Alloc() + ( + gas_used_post_refund, + gas_used_pre_refund, + call_data_floor_cost, + refund_tx, + ) = build_refund_tx( + fork=fork, + pre=pre, + post=post, + refund_types={refund_type}, + refunds_count=refunds_count, + refund_tx_reverts=refund_tx_reverts, + call_data=b"", + refund_tx_has_extra_gas_limit=refund_tx_has_extra_gas_limit, + exceed_block_gas_limit=exceed_block_gas_limit, + ) + refund_tx_gas_used = max(gas_used_post_refund, call_data_floor_cost) + refund_tx_block_gas_used = max(gas_used_pre_refund, call_data_floor_cost) + + extra_tx_sender = pre.fund_eoa() + extra_tx_calldata = b"\xff" if extra_tx_data_floor else b"" + extra_tx_intrinsic_gas_cost = intrinsic_cost_calc( + calldata=extra_tx_calldata + ) + + extra_tx = Transaction( + to=stop_address, + data=extra_tx_calldata, + gas_limit=extra_tx_intrinsic_gas_cost, + sender=extra_tx_sender, + expected_receipt={ + "gas_used": refund_tx_gas_used + extra_tx_intrinsic_gas_cost, + }, + error=TransactionException.GAS_ALLOWANCE_EXCEEDED + if exceed_block_gas_limit + else None, + ) + + total_block_gas_used = ( + refund_tx_block_gas_used + extra_tx_intrinsic_gas_cost + ) + if exceed_block_gas_limit: + environment_gas_limit = total_block_gas_used - 1 + else: + environment_gas_limit = total_block_gas_used + + txs = [refund_tx, extra_tx] + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=txs, + exception=BlockException.GAS_USED_OVERFLOW + if exceed_block_gas_limit + else None, + expected_gas_used=total_block_gas_used + if not exceed_block_gas_limit + else None, + gas_limit=environment_gas_limit, + ) + ], + post=post, + genesis_environment=Environment(gas_limit=environment_gas_limit), + ) + + +class CallDataTestType(Enum): + """Refund test type.""" + + DATA_FLOOR_LT_TX_GAS_AFTER_REFUND = -1 + """ + calldata_floor < tx_gas_after_refund. + """ + DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER = 0 + """ + tx_gas_after_refund < calldata_floor < tx_gas_before_refund. + """ + DATA_FLOOR_GT_TX_GAS_BEFORE_REFUND = 1 + """calldata_floor > tx_gas_before_refund.""" + + +@pytest.mark.parametrize( + "refund_tx_reverts", + [ + pytest.param(True, id="refund_tx_reverts"), + pytest.param(False, id=""), + ], +) +@pytest.mark.parametrize( + "calldata_test_type", + [ + CallDataTestType.DATA_FLOOR_LT_TX_GAS_AFTER_REFUND, + CallDataTestType.DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER, + CallDataTestType.DATA_FLOOR_GT_TX_GAS_BEFORE_REFUND, + ], +) +@pytest.mark.with_all_refund_types() +@pytest.mark.valid_from("EIP7778") +def test_varying_calldata_costs( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + refund_type: RefundTypes, + refund_tx_reverts: bool, + calldata_test_type: CallDataTestType, +) -> None: + """ + Test by varying the calldata_floor_cost. + + Performs tests for the following 3 scenarios. + + 1. calldata_floor < tx_gas_after_refund + 2. tx_gas_after_refund < calldata_floor < tx_gas_before_refund + 3. calldata_floor > tx_gas_before_refund + """ + if refund_type == RefundTypes.STORAGE_CLEAR: + if ( + refund_tx_reverts + and calldata_test_type + == CallDataTestType.DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER + ): + pytest.skip( + "calldata_cost cannot be between pre and post refund gas" + "since refund is zero when execution reverts" + ) + + match refund_type: + case RefundTypes.STORAGE_CLEAR: + bytes_to_add_per_iteration = b"00" * 2 + case RefundTypes.AUTHORIZATION_EXISTING_AUTHORITY: + bytes_to_add_per_iteration = b"00" * 10 + case _: + raise ValueError( + f"Unknown refund type: {refund_type} (Test needs update)" + ) + + data = b"" + + # Time to start searching for appropriate call data for each scenario + num_iterations = 200 + # Currently in EIP-7778, the optimal call data is found in about + # 30 iterations for CallDataTestType.DATA_FLOOR_GT_TX_GAS_BEFORE_REFUND. + # Setting this higher just to make it + # a bit more future proof if the gas calc logic changes + found_call_data = False + for _ in range(num_iterations): + post = Alloc() + + ( + gas_used_post_refund, + gas_used_pre_refund, + call_data_floor_cost, + refund_tx, + ) = build_refund_tx( + fork=fork, + pre=pre, + post=post, + refund_types={refund_type}, + refund_tx_reverts=refund_tx_reverts, + call_data=data, + ) + + if ( + calldata_test_type + == CallDataTestType.DATA_FLOOR_LT_TX_GAS_AFTER_REFUND + ): + if call_data_floor_cost < gas_used_post_refund: + found_call_data = True + break + elif ( + calldata_test_type + == CallDataTestType.DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER + ): + if ( + gas_used_post_refund + < call_data_floor_cost + < gas_used_pre_refund + ): + found_call_data = True + break + elif ( + calldata_test_type + == CallDataTestType.DATA_FLOOR_GT_TX_GAS_BEFORE_REFUND + ): + if gas_used_pre_refund < call_data_floor_cost: + found_call_data = True + break + else: + raise ValueError("Invalid calldata test type") + + data += bytes_to_add_per_iteration + + if not found_call_data: + raise ValueError( + f"Could not find the call_data with {num_iterations} iterations." + ) + + refund_tx_block_gas_used = max(call_data_floor_cost, gas_used_pre_refund) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[refund_tx], + expected_gas_used=refund_tx_block_gas_used, + ) + ], + post=post, + ) + + +@pytest.mark.parametrize( + "refund_tx_reverts", + [ + pytest.param(True, id="refund_tx_reverts"), + pytest.param(False, id=""), + ], +) +@pytest.mark.execute(pytest.mark.skip(reason="Requires specific gas price")) +@pytest.mark.valid_from("Amsterdam") +def test_multiple_refund_types_in_one_tx( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + refund_tx_reverts: bool, +) -> None: + """Test gas accounting for all refund types available in the given fork.""" + refunds_count = 10 + + post = Alloc() + refund_types = set(fork.refund_types()) + + (_, gas_used_pre_refund, call_data_floor_cost, refund_tx) = ( + build_refund_tx( + fork=fork, + pre=pre, + post=post, + refund_types=refund_types, + refunds_count=refunds_count, + refund_tx_reverts=refund_tx_reverts, + ) + ) + + refund_tx_block_gas_used = max(gas_used_pre_refund, call_data_floor_cost) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[refund_tx], + expected_gas_used=refund_tx_block_gas_used, + ) + ], + post=post, + ) diff --git a/tests/benchmark/compute/scenario/test_transaction_types.py b/tests/benchmark/compute/scenario/test_transaction_types.py index b23d3180d22..5d4c3d1b002 100644 --- a/tests/benchmark/compute/scenario/test_transaction_types.py +++ b/tests/benchmark/compute/scenario/test_transaction_types.py @@ -612,11 +612,18 @@ def test_auth_transaction( ) ) + # EIP-7778: refunds no longer reduce block-level gas accounting + expected_gas_usage = ( + total_gas_used + if fork.is_eip_enabled(7778) + else total_gas_used - total_refund + ) + benchmark_test( pre=pre, post={}, blocks=[Block(txs=txs)], - expected_benchmark_gas_used=total_gas_used - total_refund, + expected_benchmark_gas_used=expected_gas_usage, ) From b087114c2610616ebe72aa5e0ea446e5c7a3e852 Mon Sep 17 00:00:00 2001 From: Edgar Date: Wed, 13 May 2026 22:43:47 +0200 Subject: [PATCH 2/4] fix(consume): add ethereum/execution-specs to SUPPORTED_REPOS (#2849) `FixtureDownloader.get_cache_path` versions the on-disk cache directory by release tag only when the release URL's owner/repo appears in `SUPPORTED_REPOS`. Otherwise it falls through to the unversioned `cache_folder / "other" / ` path. When the BAL fixture release was moved from `ethereum/execution-spec-tests` to `ethereum/execution-specs` for `tests-bal@v7.1.0`, the new URL stopped being recognized as a release URL. A v7.0.0 download from a prior session, cached at `other/fixtures_bal/`, kept satisfying every subsequent request, including ones for v7.1.0. Consumers got stale fixtures with no warning. Add `ethereum/execution-specs` to `SUPPORTED_REPOS` so its release URLs hit the versioned cache path. Add regression tests covering `is_release_url` against every supported repo and asserting the new entry is present. Discovered while validating ethrex against tests-bal@v7.1.0: 76 SD-related ef-tests appeared to fail due to a spec mismatch, but the client was actually executing v7.1.0 semantics against v7.0.0 fixtures served by the stale cache. After clearing `~/.cache/ethereum-execution-spec-tests/cached_downloads/` all 2145 amsterdam tests pass. --- .../plugins/consume/releases.py | 1 + .../plugins/consume/tests/test_releases.py | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/releases.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/releases.py index 159d7d467d1..556fa7fe2c3 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/releases.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/releases.py @@ -20,6 +20,7 @@ SUPPORTED_REPOS = [ "ethereum/execution-spec-tests", + "ethereum/execution-specs", "ethereum/tests", "ethereum/legacytests", ] diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/tests/test_releases.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/tests/test_releases.py index f34479b1dff..0b36da6228b 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/tests/test_releases.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/tests/test_releases.py @@ -7,8 +7,10 @@ import pytest from ..releases import ( + SUPPORTED_REPOS, ReleaseInformation, get_release_url_from_release_information, + is_release_url, parse_release_information_from_file, ) @@ -61,3 +63,62 @@ def test_release_parsing( ) == get_release_url_from_release_information( release_name, release_information ) + + +@pytest.mark.parametrize( + "url,expected", + [ + # All currently supported release-hosting repos must be recognized so + # `FixtureDownloader.get_cache_path` versions the cache directory by + # release tag. A repo missing from `SUPPORTED_REPOS` falls through to + # the unversioned `cache_folder / "other" / archive_name` path, which + # silently shadows newer releases with the same archive filename. + ( + "https://github.com/ethereum/execution-spec-tests/releases/download/v3.0.0/fixtures_stable.tar.gz", + True, + ), + ( + "https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.1.0/fixtures_bal.tar.gz", + True, + ), + ( + "https://github.com/ethereum/tests/releases/download/v14.0/some.tar.gz", + True, + ), + ( + "https://github.com/ethereum/legacytests/releases/download/v1.0/some.tar.gz", + True, + ), + ( + # Not a recognized repo; must NOT match. + "https://github.com/some-fork/execution-spec-tests/releases/download/v1/foo.tar.gz", + False, + ), + ( + # Local path, not a URL. + "./fixtures", + False, + ), + ], +) +def test_is_release_url_covers_supported_repos( + url: str, expected: bool +) -> None: + """ + All entries in `SUPPORTED_REPOS` must be matched by `is_release_url`. + + Regression test for bal-devnet-7: when the BAL fixture URL moved from + `execution-spec-tests` to `execution-specs`, the new URL stopped being + recognized as a release URL, so the cache key dropped the version tag and + a `tests-bal@v7.0.0` download from a prior session silently shadowed + `tests-bal@v7.1.0`. + """ + assert is_release_url(url) is expected + + +def test_supported_repos_contains_execution_specs() -> None: + """ + `ethereum/execution-specs` hosts the BAL fixture releases (from + `tests-bal@v7.1.0` onward) and must be in `SUPPORTED_REPOS`. + """ + assert "ethereum/execution-specs" in SUPPORTED_REPOS From cc8022a2340a55a825f44b8db9105785a4cbb83f Mon Sep 17 00:00:00 2001 From: danceratopz Date: Wed, 13 May 2026 23:01:58 +0200 Subject: [PATCH 3/4] refactor(tests): Use `filter_combinations` marker to drop infeasible cell in `test_varying_calldata_costs` (#2852) `STORAGE_CLEAR` refund is zero on revert, so `gas_used_pre_refund == gas_used_post_refund` and the `(post, pre)` interval that `DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER` requires is empty. Replace the runtime `pytest.skip()` with the `filter_combinations` marker introduced in #2543, which runs in `pytest_collection_modifyitems` and can express predicates across covariant-marker-injected and inline parametrize axes uniformly. Keeps `with_all_refund_types()` so new refund types added to the fork in future automatically participate, preserves the original three-decorator parametrize stack, and produces byte-identical fixture IDs and content vs the pre-refactor branch (verified via `diff` on `varying_calldata_costs.json` keys and bodies). The two infeasible items per fork are now deselected at collection time rather than skipped at runtime. --- .../test_gas_accounting.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py index 27f373a1409..1b6a6be1d81 100644 --- a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/test_gas_accounting.py @@ -370,6 +370,18 @@ class CallDataTestType(Enum): ], ) @pytest.mark.with_all_refund_types() +@pytest.mark.filter_combinations( + lambda refund_type, refund_tx_reverts, calldata_test_type, **_: not ( + refund_type == RefundTypes.STORAGE_CLEAR + and refund_tx_reverts + and calldata_test_type + == CallDataTestType.DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER + ), + reason=( + "STORAGE_CLEAR refund is zero on revert, so the (post, pre) " + "interval that DATA_FLOOR_BETWEEN needs is empty" + ), +) @pytest.mark.valid_from("EIP7778") def test_varying_calldata_costs( blockchain_test: BlockchainTestFiller, @@ -388,17 +400,6 @@ def test_varying_calldata_costs( 2. tx_gas_after_refund < calldata_floor < tx_gas_before_refund 3. calldata_floor > tx_gas_before_refund """ - if refund_type == RefundTypes.STORAGE_CLEAR: - if ( - refund_tx_reverts - and calldata_test_type - == CallDataTestType.DATA_FLOOR_BETWEEN_TX_GAS_BEFORE_AND_AFTER - ): - pytest.skip( - "calldata_cost cannot be between pre and post refund gas" - "since refund is zero when execution reverts" - ) - match refund_type: case RefundTypes.STORAGE_CLEAR: bytes_to_add_per_iteration = b"00" * 2 From d61f7ac51d5fb058186e9d75013d763fe5aabdb6 Mon Sep 17 00:00:00 2001 From: felipe Date: Wed, 13 May 2026 16:41:24 -0600 Subject: [PATCH 4/4] feat(test-cli): Add support for testing block building via simulator (#2679) * feat(test-cli): Add support for testing block building via simulator - Test build building via ``testing_buildBlockV1``, validating the built block, and re-consuming against the client * chore: pretty diff BALs on errors * refactor(test): cleanup based on comments on PR #2679 * chore: cleanup from comments on PR #2679 * chore: initial docs for build-block * chore: e2e run for block-building tests; tighten up docs --- .github/workflows/hive-consume.yaml | 25 +- docs/running_tests/consume/simulators.md | 4 + docs/running_tests/running.md | 23 ++ packages/testing/pyproject.toml | 1 + .../cli/pytest_commands/build_block.py | 43 +++ .../plugins/consume/simulators/base.py | 1 + .../simulators/build_block/__init__.py | 1 + .../simulators/build_block/conftest.py | 66 ++++ .../simulator_logic/test_via_build.py | 333 ++++++++++++++++++ .../cli/pytest_commands/processors.py | 32 +- .../execution_testing/fixtures/blockchain.py | 24 ++ .../testing/src/execution_testing/rpc/rpc.py | 12 +- 12 files changed, 534 insertions(+), 31 deletions(-) create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/build_block.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/__init__.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/conftest.py create mode 100644 packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_build.py diff --git a/.github/workflows/hive-consume.yaml b/.github/workflows/hive-consume.yaml index b3f439789cf..3d726814465 100644 --- a/.github/workflows/hive-consume.yaml +++ b/.github/workflows/hive-consume.yaml @@ -20,6 +20,7 @@ on: - ".github/actions/load-docker-images/**" - ".github/configs/hive/**" - "packages/testing/src/execution_testing/cli/pytest_commands/consume.py" + - "packages/testing/src/execution_testing/cli/pytest_commands/build_block.py" - "packages/testing/src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-consume.ini" - "packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/**" - "packages/testing/src/execution_testing/cli/pytest_commands/plugins/pytest_hive/**" @@ -106,15 +107,27 @@ jobs: mode: simulator simulator: ethereum/eels/consume-sync sim_limit: ".*test_block_at_rlp_limit_with_logs.*Osaka.*" - - name: Dev Mode + - name: Dev Mode Consume Engine mode: dev - consume_command: engine + cli_command: consume engine test_filter: "Osaka and test_block_at_rlp_limit_with_logs" # TODO: Enable once we have a release with the pre-alloc builder format cf #2140 - # - name: dev-mode-enginex + # - name: Dev Mode Consume EngineX # mode: dev - # consume_command: enginex + # cli_command: consume enginex # test_filter: "(fork_shanghai or fork_cancun) and push0" + # NOTE: build-block uses testing_buildBlockV1 on the HTTP RPC port + # (8545), which has a smaller body cap than the Engine API port + # (8551); avoid filters that target near-block-size-limit tests. + - name: Dev Mode Build Block + mode: dev + cli_command: build-block + test_filter: "Osaka and test_clz_stack_underflow" + # TODO: Enable once eels/build-block simulator is added to Hive + # - name: Build Block + # mode: simulator + # simulator: ethereum/eels/build-block + # sim_limit: ".*test_block_at_rlp_limit_with_logs.*Osaka.*" steps: - name: Checkout execution-specs uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -186,14 +199,14 @@ jobs: hive-path: hive timeout: "30" - - name: Run consume in dev mode + - name: Run dev mode command if: matrix.mode == 'dev' working-directory: execution-specs env: HIVE_SIMULATOR: ${{ steps.start-hive.outputs.hive-url }} run: | uv sync --all-extras - uv run consume ${{ matrix.consume_command }} --input ${{ env.FIXTURES_URL }} -k "${{ matrix.test_filter }}" + uv run ${{ matrix.cli_command }} --input ${{ env.FIXTURES_URL }} -k "${{ matrix.test_filter }}" - name: Upload Hive logs if: always() diff --git a/docs/running_tests/consume/simulators.md b/docs/running_tests/consume/simulators.md index f8b88306785..f38fbe476c8 100644 --- a/docs/running_tests/consume/simulators.md +++ b/docs/running_tests/consume/simulators.md @@ -14,3 +14,7 @@ uv run consume [OPTIONS] - Help [setting up](../hive/index.md) and [starting Hive in dev mode](../hive/dev_mode.md). - For an explanation of how the `consume` simulators work, see the [Engine](../running.md#engine) and [RLP](../running.md#rlp) sections in [Running Tests](../running.md). - Help for relevant options can be found in [Consume Cache and Fixture Inputs](./cache.md) and [Useful Pytest Options](../useful_pytest_options.md). + +## Related: Block Building + +A separate hive simulator [`build-block`](../running.md#block-building) is also fixture-driven but tests the client's **producer-side** path via the `testing_buildBlockV1` engine-API testing-namespace endpoint, rather than the consumer-side import path that the simulators above exercise. diff --git a/docs/running_tests/running.md b/docs/running_tests/running.md index d09a1b3003c..d5278726ca4 100644 --- a/docs/running_tests/running.md +++ b/docs/running_tests/running.md @@ -17,6 +17,7 @@ Both `consume` and `execute` provide sub-commands which correspond to different | [`consume enginex`](#enginex) | Client imports blocks via Engine API in Hive, optimized by client reuse | EVM, block processing, Engine API | Staging, Hive | System test | | [`consume sync`](#sync) | Client syncs from another client using Engine API in Hive | EVM, block processing, Engine API, P2P sync | Staging, Hive | System test | | [`consume rlp`](#rlp) | Client imports RLP-encoded blocks upon start-up in Hive | EVM, block processing, RLP import (sync\*) | Staging, Hive | System test | +| [`build-block`](#block-building) | Client builds blocks via `testing_buildBlockV1` in Hive, validated against fixture | EVM, block production, Engine API (testing namespace) | Staging, Hive | System test | | [`execute hive`](./execute/hive.md) | Tests executed against a client via JSON RPC `eth_sendRawTransaction` in Hive | EVM, JSON RPC, mempool | Staging, Hive | System test | | [`execute remote`](./execute/remote.md) | Tests executed against a client via JSON RPC `eth_sendRawTransaction` on a live network | EVM, JSON RPC, mempool, EL-EL/EL-CL interaction (indirectly) | Production | System Test | @@ -144,6 +145,28 @@ The `consume sync` command: 5. **Monitors sync progress** and validates that the sync client reaches the same state. 6. **Verifies final state** matches between both clients. +## Block Building + +| Nomenclature | | +| -------------- | ------------------------ | +| Command | `build-block` | +| Simulator | `eels/build-block` | +| Fixture format | `blockchain_test_engine` | + +The block-building method tests the **producer-side** of an execution client: rather than asking the client to validate and import a pre-built block, it asks the client to build a block from inputs (parent, payload attributes, transactions) and then validates the resulting block field-by-field against the fixture's expected block. This exercises tx ordering, gas accounting, payload assembly, and (for fork ≥ Prague) `executionRequests` derivation. + +The endpoint used is `testing_buildBlockV1`, an engine-API testing-namespace method exposed by `ethpandaops/:master` (and similar performance builds). It is not part of the standard Engine API — the testing namespace is opt-in and intended for fixture-driven block-building verification. + +The `build-block` command, for each valid payload in the fixture: + +1. **Builds the block** via `testing_buildBlockV1(parent_hash, payload_attributes, transactions, extra_data)`. The client returns its own constructed `ExecutionPayload`. +2. **Validates execution-dependent fields** of the built payload against the fixture's expected payload (everything except `gas_limit` and `block_hash`, which depend on client-side EIP-1559 elasticity and are validated via a range check separately). +3. **Validates `executionRequests`** for fork ≥ Prague (`engine_newPayloadV4+`). +4. **Imports the fixture block** (not the client-built one) via `engine_newPayloadVX` so the chain advances with the fixture's expected gas limit and block hash. +5. **Advances the chain** via `engine_forkchoiceUpdatedVX`. + +This complements `consume engine`: where `consume engine` tests the client's payload-validation path, `build-block` tests its payload-production path against the same fixtures. + ## Engine vs RLP Simulator The RLP Simulator (`eels/consume-rlp`) and the Engine Simulator (`eels/consume-engine`) should be seen as complimentary to one another. Although they execute the same underlying EVM test cases, the block validation logic is executed via different client code paths (using different [fixture formats](./test_formats/index.md)). Therefore, ideally, **both simulators should be executed for full coverage**. diff --git a/packages/testing/pyproject.toml b/packages/testing/pyproject.toml index 28a07929f12..f50ec6d049a 100644 --- a/packages/testing/pyproject.toml +++ b/packages/testing/pyproject.toml @@ -84,6 +84,7 @@ checkfixtures = "execution_testing.cli.check_fixtures:check_fixtures" check_eip_versions = "execution_testing.cli.pytest_commands.check_eip_versions:check_eip_versions" consume = "execution_testing.cli.pytest_commands.consume:consume" protec = "execution_testing.cli.pytest_commands.consume:consume" +build-block = "execution_testing.cli.pytest_commands.build_block:build_block" checklist = "execution_testing.cli.pytest_commands.checklist:checklist" generate_checklist_stubs = "execution_testing.cli.generate_checklist_stubs:generate_checklist_stubs" genindex = "execution_testing.cli.gen_index:generate_fixtures_index_cli" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/build_block.py b/packages/testing/src/execution_testing/cli/pytest_commands/build_block.py new file mode 100644 index 00000000000..102436ffacf --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/build_block.py @@ -0,0 +1,43 @@ +"""CLI entry point for the `build-block` pytest-based command.""" + +from pathlib import Path +from typing import Any, List + +import click + +from .base import PytestCommand, common_pytest_options +from .processors import ( + ConsumeCommandProcessor, + HelpFlagsProcessor, + HiveEnvironmentProcessor, +) + + +def create_build_block_command() -> PytestCommand: + """Initialize the build-block command with paths and processors.""" + base_path = Path("cli/pytest_commands/plugins/consume") + command_logic_test_paths = [ + base_path / "simulators" / "simulator_logic" / "test_via_build.py" + ] + return PytestCommand( + config_file="pytest-consume.ini", + argument_processors=[ + HelpFlagsProcessor("consume"), + HiveEnvironmentProcessor(command_name="build_block"), + ConsumeCommandProcessor(is_hive=True), + ], + command_logic_test_paths=command_logic_test_paths, + ) + + +@click.command( + name="build-block", + context_settings={"ignore_unknown_options": True}, +) +@common_pytest_options +def build_block(pytest_args: List[str], **kwargs: Any) -> None: + """Test block building via testing_buildBlockV1.""" + del kwargs + + cmd = create_build_block_command() + cmd.execute(list(pytest_args)) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py index 5780353cf68..969a7a06e67 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/base.py @@ -34,6 +34,7 @@ def check_live_port(test_suite_name: str) -> Literal[8545, 8551]: "eels/consume-engine", "eels/consume-enginex", "eels/consume-sync", + "eels/build-block", }: return 8551 raise ValueError( diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/__init__.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/__init__.py new file mode 100644 index 00000000000..91f13d8b15f --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/__init__.py @@ -0,0 +1 @@ +"""Pytest configuration for the build-block simulator.""" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/conftest.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/conftest.py new file mode 100644 index 00000000000..e23b5d03563 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/build_block/conftest.py @@ -0,0 +1,66 @@ +""" +Pytest fixtures for the `build-block` simulator. + +Configures the hive back-end & EL clients for block building correctness +testing via the ``testing_buildBlockV1`` endpoint. +""" + +import io +from typing import Mapping + +import pytest +from hive.client import Client + +from execution_testing.fixtures import BlockchainEngineFixture +from execution_testing.fixtures.blockchain import FixtureHeader +from execution_testing.rpc import TestingRPC + +pytest_plugins = ( + "execution_testing.cli.pytest_commands.plugins.pytest_hive.pytest_hive", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.base", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.single_test_client", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.test_case_description", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.timing_data", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.exceptions", + "execution_testing.cli.pytest_commands.plugins.consume.simulators.engine_api", +) + + +def pytest_configure(config: pytest.Config) -> None: + """Set the supported fixture formats for the build-block simulator.""" + config.supported_fixture_formats = [BlockchainEngineFixture] # type: ignore[attr-defined] + + +@pytest.fixture(scope="module") +def test_suite_name() -> str: + """The name of the hive test suite used in this simulator.""" + return "eels/build-block" + + +@pytest.fixture(scope="module") +def test_suite_description() -> str: + """The description of the hive test suite used in this simulator.""" + return ( + "Test block building correctness via the " + "testing_buildBlockV1 endpoint." + ) + + +@pytest.fixture(scope="function") +def client_files( + buffered_genesis: io.BufferedReader, +) -> Mapping[str, io.BufferedReader]: + """Define the files that hive will start the client with.""" + return {"/genesis.json": buffered_genesis} + + +@pytest.fixture(scope="function") +def genesis_header(fixture: BlockchainEngineFixture) -> FixtureHeader: + """Provide the genesis header from the fixture.""" + return fixture.genesis + + +@pytest.fixture(scope="function") +def testing_rpc(client: Client) -> TestingRPC: + """Initialize Testing RPC client for the execution client under test.""" + return TestingRPC(f"http://{client.ip}:8545") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_build.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_build.py new file mode 100644 index 00000000000..00350737c1a --- /dev/null +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/simulator_logic/test_via_build.py @@ -0,0 +1,333 @@ +""" +A hive based simulator that tests block building correctness via the +``testing_buildBlockV1`` endpoint. + +For each valid payload in a fixture, transactions and block attributes +are sent to the block building endpoint. The resulting block is validated +field-by-field against the fixture's expected block, then imported back +into the client via ``engine_newPayloadVX`` and +``engine_forkchoiceUpdatedVX`` to advance the chain. +""" + +import json +from difflib import unified_diff +from typing import List + +from execution_testing.base_types import Bytes +from execution_testing.fixtures import BlockchainEngineFixture +from execution_testing.fixtures.blockchain import ( + FixtureEngineNewPayload, + FixtureExecutionPayload, + FixtureHeader, +) +from execution_testing.logging import get_logger +from execution_testing.rpc import ( + EngineRPC, + EthRPC, + ForkchoiceUpdateTimeoutError, + TestingRPC, +) +from execution_testing.rpc.rpc_types import ( + ForkchoiceState, + PayloadStatusEnum, +) +from execution_testing.test_types.block_access_list import BlockAccessList + +from ..helpers.exceptions import ( + GenesisBlockMismatchExceptionError, + LoggedError, +) +from ..helpers.timing import TimingData + +logger = get_logger(__name__) + + +def _format_block_access_list_diff( + expected_rlp: bytes | None, + built_rlp: bytes | None, +) -> str: + """Return a readable diff for a BAL mismatch.""" + if expected_rlp is None or built_rlp is None: + return f"expected {expected_rlp!r}, got {built_rlp!r}" + + try: + expected_bal = BlockAccessList.from_rlp(Bytes(expected_rlp)) + built_bal = BlockAccessList.from_rlp(Bytes(built_rlp)) + except Exception as exc: + return ( + f"expected {expected_rlp!r}, got {built_rlp!r} " + f"(BAL decode failed: {exc})" + ) + + expected_json = json.dumps( + expected_bal.model_dump(mode="json"), + indent=2, + sort_keys=True, + ) + built_json = json.dumps( + built_bal.model_dump(mode="json"), + indent=2, + sort_keys=True, + ) + diff = "\n".join( + unified_diff( + expected_json.splitlines(), + built_json.splitlines(), + fromfile="expected_bal", + tofile="client_built_bal", + lineterm="", + ) + ) + return ( + f"expected {expected_rlp!r}, got {built_rlp!r}\n" + "decoded BAL diff relative to expected BAL:\n" + " '-' lines are present in the expected BAL but missing from the " + "client-built BAL.\n" + " '+' lines are extra or changed in the client-built BAL compared " + "to the expected BAL.\n" + f"{diff}" + ) + + +def _validate_gas_limit( + built: FixtureExecutionPayload, + parent_gas_limit: int, +) -> None: + """Validate the built block's gas limit is within EIP-1559 range.""" + built_gas_limit = int(built.gas_limit) + max_delta = parent_gas_limit // 1024 + + if abs(built_gas_limit - parent_gas_limit) >= max_delta: + raise LoggedError( + f"Gas limit for block {built.number} outside " + f"EIP-1559 range: parent={parent_gas_limit} " + f"(±{max_delta}), got {built.gas_limit}" + ) + + +def _validate_built_block( + built: FixtureExecutionPayload, + expected: FixtureExecutionPayload, + parent_gas_limit: int, +) -> None: + """ + Validate the built block against the fixture's expected block. + + Check all execution-dependent fields for exact match and verify + the gas limit is within the valid EIP-1559 range. + """ + _validate_gas_limit(built, parent_gas_limit) + + mismatches: List[str] = [] + + # All FixtureExecutionPayload fields are validated except: + # - gas_limit: testing_buildBlockV1 doesn't accept it; the client + # picks its own via EIP-1559 (validated separately by range check). + # - block_hash: depends on gas_limit, so it will differ too. + validated_fields = tuple( + name + for name in FixtureExecutionPayload.model_fields + if name not in {"gas_limit", "block_hash"} + ) + for field in validated_fields: + built_val = getattr(built, field) + expected_val = getattr(expected, field) + if built_val != expected_val: + if field == "block_access_list": + mismatches.append( + " block_access_list:\n" + + _format_block_access_list_diff(expected_val, built_val) + ) + continue + mismatches.append( + f" {field}: expected {expected_val}, got {built_val}" + ) + + if mismatches: + detail = "\n".join(mismatches) + raise LoggedError( + f"Block validation failed for block {expected.number}:\n{detail}" + ) + + logger.info(f"Block validated for block {expected.number}.") + + +def _bootstrap_engine_at_genesis( + engine_rpc: EngineRPC, + eth_rpc: EthRPC, + fixture: BlockchainEngineFixture, + genesis_header: FixtureHeader, + timing_data: TimingData, +) -> None: + """Send initial FCU to genesis and verify the client's genesis hash.""" + with timing_data.time("Initial forkchoice update"): + logger.info("Sending initial forkchoice update to genesis block...") + try: + response = engine_rpc.forkchoice_updated_with_retry( + forkchoice_state=ForkchoiceState( + head_block_hash=fixture.genesis.block_hash, + ), + forkchoice_version=fixture.payloads[ + 0 + ].forkchoice_updated_version, + ) + if response.payload_status.status != PayloadStatusEnum.VALID: + raise LoggedError( + "Unexpected status on forkchoice updated to " + f"genesis: {response.payload_status.status}" + ) + except ForkchoiceUpdateTimeoutError as e: + raise LoggedError( + f"Timed out waiting for forkchoice update to genesis: {e}" + ) from None + + with timing_data.time("Get genesis block"): + logger.info("Calling getBlockByNumber to get genesis block...") + genesis_block = eth_rpc.get_block_by_number(0) + assert genesis_block is not None, "genesis_block is None" + if genesis_block["hash"] != str(genesis_header.block_hash): + raise GenesisBlockMismatchExceptionError( + expected_header=genesis_header, + got_genesis_block=genesis_block, + ) + + +def _build_validate_and_advance( + testing_rpc: TestingRPC, + engine_rpc: EngineRPC, + payload: FixtureEngineNewPayload, + parent_gas_limit: int, + block_index: int, + total_blocks: int, + total_build_timing: TimingData, +) -> int: + """ + Build, validate, import, and advance the chain for one payload. + + Returns the new ``parent_gas_limit`` to use for the next block (taken + from the fixture's expected payload, not the client-built one, since + we import the fixture block to advance the chain). + """ + expected_payload = payload.params[0] + logger.info( + f"Building block {block_index + 1}/{total_blocks} " + f"(number {expected_payload.number})..." + ) + with total_build_timing.time(f"Block {block_index + 1}") as block_timing: + # 1. Build the block + with block_timing.time("testing_buildBlockV1"): + build_response = testing_rpc.build_block( + parent_block_hash=expected_payload.parent_hash, + payload_attributes=payload.get_payload_attributes(), + transactions=expected_payload.transactions, + extra_data=expected_payload.extra_data, + ) + + # 2. Validate fields + gas limit range + _validate_built_block( + built=build_response.execution_payload, + expected=expected_payload, + parent_gas_limit=parent_gas_limit, + ) + + # 3. Validate execution_requests (V4+) + if payload.new_payload_version >= 4: + expected_requests = ( + payload.params[3] if len(payload.params) >= 4 else None + ) + if build_response.execution_requests != expected_requests: + raise LoggedError( + f"execution_requests mismatch for block " + f"{expected_payload.number}: expected " + f"{expected_requests}, got " + f"{build_response.execution_requests}" + ) + + # 4. Import the fixture block (not the built block) so the chain + # advances with the expected gas limit and block hash. + with block_timing.time( + f"engine_newPayloadV{payload.new_payload_version}" + ): + logger.info( + "Importing block via " + f"engine_newPayloadV{payload.new_payload_version}..." + ) + import_response = engine_rpc.new_payload( + *payload.params, + version=payload.new_payload_version, + ) + if import_response.status != PayloadStatusEnum.VALID: + raise LoggedError( + "Unexpected status importing " + f"block: {import_response.status}" + ) + + # 5. Advance the chain via forkchoice update + v = payload.forkchoice_updated_version + with block_timing.time(f"engine_forkchoiceUpdatedV{v}"): + logger.info(f"Sending engine_forkchoiceUpdatedV{v}...") + fcu_response = engine_rpc.forkchoice_updated( + forkchoice_state=ForkchoiceState( + head_block_hash=expected_payload.block_hash, + ), + payload_attributes=None, + version=v, + ) + fcu_status = fcu_response.payload_status.status + if fcu_status != PayloadStatusEnum.VALID: + raise LoggedError( + "Unexpected status on forkchoice update: " + f"want {PayloadStatusEnum.VALID}, got {fcu_status}" + ) + + # Use the fixture's gas limit since we import the fixture block, + # not the built one. + return int(expected_payload.gas_limit) + + +def test_blockchain_via_build( + timing_data: TimingData, + eth_rpc: EthRPC, + engine_rpc: EngineRPC, + testing_rpc: TestingRPC, + fixture: BlockchainEngineFixture, + genesis_header: FixtureHeader, +) -> None: + """ + Test block building correctness against a client. + + For each valid payload in the fixture: + 1. Build a block via ``testing_buildBlockV1`` + 2. Validate execution-dependent fields + gas limit range + 3. Validate execution_requests for fork >= Prague (V4+) + 4. Import the fixture block via ``engine_newPayloadVX`` + 5. Advance the chain via ``engine_forkchoiceUpdatedVX`` + """ + _bootstrap_engine_at_genesis( + engine_rpc=engine_rpc, + eth_rpc=eth_rpc, + fixture=fixture, + genesis_header=genesis_header, + timing_data=timing_data, + ) + + with timing_data.time("Block building") as total_build_timing: + valid_payloads = [p for p in fixture.payloads if p.valid()] + logger.info( + f"Starting block building for {len(valid_payloads)} " + f"valid payload(s) " + f"(of {len(fixture.payloads)} total)..." + ) + parent_gas_limit = int(genesis_header.gas_limit) + for i, payload in enumerate(valid_payloads): + parent_gas_limit = _build_validate_and_advance( + testing_rpc=testing_rpc, + engine_rpc=engine_rpc, + payload=payload, + parent_gas_limit=parent_gas_limit, + block_index=i, + total_blocks=len(valid_payloads), + total_build_timing=total_build_timing, + ) + + logger.info("All blocks built and verified successfully.") diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py index 7862140380e..d23174fc34e 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/processors.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/processors.py @@ -122,32 +122,18 @@ def process_args(self, args: List[str]) -> List[str]: if os.getenv("HIVE_LOGLEVEL") is not None: warnings.warn("HIVE_LOG_LEVEL is not yet supported.", stacklevel=2) - if self.command_name == "engine": + simulator_commands = { + "engine", + "enginex", + "sync", + "rlp", + "build_block", + } + if self.command_name in simulator_commands: modified_args.extend( [ "-p", - "execution_testing.cli.pytest_commands.plugins.consume.simulators.engine.conftest", - ] - ) - elif self.command_name == "enginex": - modified_args.extend( - [ - "-p", - "execution_testing.cli.pytest_commands.plugins.consume.simulators.enginex.conftest", - ] - ) - elif self.command_name == "sync": - modified_args.extend( - [ - "-p", - "execution_testing.cli.pytest_commands.plugins.consume.simulators.sync.conftest", - ] - ) - elif self.command_name == "rlp": - modified_args.extend( - [ - "-p", - "execution_testing.cli.pytest_commands.plugins.consume.simulators.rlp.conftest", + f"execution_testing.cli.pytest_commands.plugins.consume.simulators.{self.command_name}.conftest", ] ) else: diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index 26209fc1b07..92ab859f612 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -2,6 +2,7 @@ from functools import cached_property from typing import ( + TYPE_CHECKING, Annotated, Any, ClassVar, @@ -69,6 +70,9 @@ FixtureTransactionReceipt, ) +if TYPE_CHECKING: + from execution_testing.rpc.rpc_types import PayloadAttributes + def post_state_validator( alternate_field: str | None = None, mode: str = "after" @@ -472,6 +476,26 @@ def valid(self) -> bool: """Return whether the payload is valid.""" return self.validation_error is None + def get_payload_attributes(self) -> "PayloadAttributes": + """Return the ``PayloadAttributes`` corresponding to this payload.""" + from execution_testing.rpc.rpc_types import PayloadAttributes + + execution_payload = self.params[0] + # parent_beacon_block_root exists from V3 onwards. The length check + # is for mypy narrowing; the version check captures the actual rule. + parent_beacon_block_root = ( + self.params[2] + if self.forkchoice_updated_version >= 3 and len(self.params) >= 3 + else None + ) + return PayloadAttributes( + timestamp=execution_payload.timestamp, + prev_randao=execution_payload.prev_randao, + suggested_fee_recipient=execution_payload.fee_recipient, + withdrawals=execution_payload.withdrawals, + parent_beacon_block_root=parent_beacon_block_root, + ) + @classmethod def from_fixture_header( cls, diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index e7f75aabe7f..22490da2547 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -1418,7 +1418,7 @@ def build_block( self, parent_block_hash: Hash, payload_attributes: PayloadAttributes, - transactions: Sequence[TransactionProtocol] | None, + transactions: Sequence[TransactionProtocol | Bytes] | None, extra_data: Bytes | None = None, *, version: int = 1, @@ -1428,6 +1428,9 @@ def build_block( provided *payload_attributes* and *transactions*. Calls ``testing_buildBlockVX``. + + Transactions can be either ``TransactionProtocol`` objects (with + an ``rlp()`` method) or raw ``Bytes`` (already RLP-encoded). """ method = f"buildBlockV{version}" params: List[Any] = [ @@ -1435,7 +1438,12 @@ def build_block( to_json(payload_attributes), ] if transactions is not None: - params.append([tx.rlp().hex() for tx in transactions]) + params.append( + [ + tx.hex() if isinstance(tx, bytes) else tx.rlp().hex() + for tx in transactions + ] + ) else: params.append(None) if extra_data is not None: