diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 39fc641c3fb..c22cbd945cc 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -306,6 +306,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).""" + engine_new_payload_block_access_list: Bytes | None = None + """EIP-7928: override only the engine newPayload blockAccessList field.""" expected_gas_used: int | None = None """Expected gas used for the block.""" @@ -399,6 +401,7 @@ class BuiltBlock(CamelModel): rlp_modifier: Header | None = None fork: Fork block_access_list: BlockAccessList | None + engine_new_payload_block_access_list: Bytes | None = None def get_fixture_block( self, *, include_receipts: bool = True @@ -449,10 +452,8 @@ def get_block_rlp(self) -> Bytes: """Get the RLP of the block.""" return self.get_fixture_block().rlp - @staticmethod - def derive_engine_payload_modifier( - rlp_modifier: Header | None, - block_access_list: BlockAccessList | None, + def engine_payload_modifier( + self, ) -> "FixtureExecutionPayloadModifier | None": """ Propagate ``rlp_modifier``'s header changes to the engine payload. @@ -462,9 +463,13 @@ def derive_engine_payload_modifier( the ``block_access_list`` body. So a header modifier that touches the BAL hash needs to drive a matching change on the payload body. """ - if rlp_modifier is None: + if self.engine_new_payload_block_access_list is not None: + return FixtureExecutionPayloadModifier( + block_access_list=self.engine_new_payload_block_access_list, + ) + if self.rlp_modifier is None: return None - bal_hash_override = rlp_modifier.block_access_list_hash + bal_hash_override = self.rlp_modifier.block_access_list_hash if bal_hash_override is None: return None if bal_hash_override is Header.REMOVE_FIELD: @@ -477,7 +482,7 @@ def derive_engine_payload_modifier( # payload by forcing a body to be present. Its exact value is # irrelevant for negative tests — a non-``None`` value is enough to # make a payload-version mismatch detectable. - if block_access_list is None: + if self.block_access_list is None: return FixtureExecutionPayloadModifier( block_access_list=Bytes(b""), ) @@ -494,9 +499,7 @@ def get_fixture_engine_new_payload(self) -> FixtureEngineNewPayload: block_access_list=self.block_access_list.rlp if self.block_access_list else None, - execution_payload_modifier=self.derive_engine_payload_modifier( - self.rlp_modifier, self.block_access_list - ), + execution_payload_modifier=self.engine_payload_modifier(), validation_error=self.expected_exception, error_code=self.engine_api_error_code, ) @@ -850,6 +853,9 @@ def generate_block_data( rlp_modifier=block.rlp_modifier, fork=fork, block_access_list=bal, + engine_new_payload_block_access_list=( + block.engine_new_payload_block_access_list + ), ) try: @@ -861,6 +867,7 @@ def generate_block_data( and block.rlp_modifier is None and block.requests is None and not block.skip_exception_verification + and block.engine_new_payload_block_access_list is None and not ( block.expected_block_access_list is not None and block.expected_block_access_list._modifier is not None @@ -871,9 +878,11 @@ def generate_block_data( # exceptions. - No RLP modifier was specified, because the # modifier is what normally produces the block exception. - No # requests were specified, because modified requests are also - # what normally produces the block exception. - No BAL modifier - # was specified, because modified BAL also produces block - # exceptions. + # what normally produces the block exception. - No engine + # payload BAL override was specified, because it corrupts only + # the engine payload after the transition tool has run. - No + # BAL modifier was specified, because modified BAL also + # produces block exceptions. built_block.verify_block_exception( transition_tool_exceptions_reliable=t8n.exception_mapper.reliable, ) diff --git a/packages/testing/src/execution_testing/specs/tests/test_types.py b/packages/testing/src/execution_testing/specs/tests/test_types.py index f5c43cb5fe9..838f98f68b5 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_types.py +++ b/packages/testing/src/execution_testing/specs/tests/test_types.py @@ -9,10 +9,14 @@ Hash, HeaderNonce, ) +from execution_testing.client_clis import Result +from execution_testing.client_clis.cli_types import LazyAllocStr from execution_testing.fixtures.blockchain import ( FixtureExecutionPayloadModifier, FixtureHeader, ) +from execution_testing.forks import Amsterdam +from execution_testing.test_types import Environment from execution_testing.test_types.block_access_list import BlockAccessList from ..blockchain import BuiltBlock, Header @@ -39,6 +43,15 @@ excess_blob_gas=1, # hash=Hash(1), ) +result_empty = Result( + state_root=0, + transactions_trie=0, + receipts_root=0, + logs_hash=0, + logs_bloom=0, + receipts=[], + gas_used=0, +) @pytest.mark.parametrize( @@ -145,6 +158,30 @@ def test_fixture_header_join( assert modifier.apply(fixture_header) == fixture_header_expected +def built_block( + *, + rlp_modifier: Header | None = None, + block_access_list: BlockAccessList | None = None, + engine_new_payload_block_access_list: Bytes | None = None, +) -> BuiltBlock: + """Generate a dummy built block with all default values.""" + return BuiltBlock( + header=fixture_header_ones, + env=Environment(), + alloc=LazyAllocStr(raw="", _state_root=Hash(0)), + state_root=Hash(0), + txs=[], + ommers=[], + withdrawals=None, + requests=None, + result=result_empty, + fork=Amsterdam, + rlp_modifier=rlp_modifier, + block_access_list=block_access_list, + engine_new_payload_block_access_list=engine_new_payload_block_access_list, + ) + + class TestDeriveEnginePayloadModifier: """ Verify the auto-propagation from ``rlp_modifier``'s header-only changes @@ -156,29 +193,30 @@ class TestDeriveEnginePayloadModifier: def test_no_rlp_modifier_returns_none(self) -> None: """No modifier → no engine payload override.""" assert ( - BuiltBlock.derive_engine_payload_modifier( + built_block( rlp_modifier=None, block_access_list=None, - ) + engine_new_payload_block_access_list=None, + ).engine_payload_modifier() is None ) def test_rlp_modifier_unrelated_field_returns_none(self) -> None: """A modifier that doesn't touch BAL hash leaves the payload alone.""" assert ( - BuiltBlock.derive_engine_payload_modifier( + built_block( rlp_modifier=Header(state_root=Hash(100)), block_access_list=None, - ) + ).engine_payload_modifier() is None ) def test_remove_bal_hash_removes_body_from_payload(self) -> None: """Removing the header's BAL hash also removes the payload body.""" - modifier = BuiltBlock.derive_engine_payload_modifier( + modifier = built_block( rlp_modifier=Header(block_access_list_hash=Header.REMOVE_FIELD), block_access_list=BlockAccessList(), - ) + ).engine_payload_modifier() assert isinstance(modifier, FixtureExecutionPayloadModifier) assert modifier.block_access_list is ( FixtureExecutionPayloadModifier.REMOVE_FIELD @@ -190,10 +228,10 @@ def test_inject_bal_hash_on_pre_fork_adds_body(self) -> None: triggers a body to be added to the engine payload, so a payload- version mismatch is detectable. """ - modifier = BuiltBlock.derive_engine_payload_modifier( + modifier = built_block( rlp_modifier=Header(block_access_list_hash=Hash(0)), block_access_list=None, - ) + ).engine_payload_modifier() assert isinstance(modifier, FixtureExecutionPayloadModifier) assert modifier.block_access_list == Bytes(b"") @@ -204,9 +242,17 @@ def test_inject_bal_hash_on_post_fork_leaves_body_alone(self) -> None: what triggers the client rejection in that scenario. """ assert ( - BuiltBlock.derive_engine_payload_modifier( + built_block( rlp_modifier=Header(block_access_list_hash=Hash(0)), block_access_list=BlockAccessList(), - ) + ).engine_payload_modifier() is None ) + + def test_empty_bytes_override_sends_raw_body(self) -> None: + """Raw `Bytes` (e.g. the invalid `0x`) are sent verbatim.""" + modifier = built_block( + engine_new_payload_block_access_list=Bytes(b"") + ).engine_payload_modifier() + assert isinstance(modifier, FixtureExecutionPayloadModifier) + assert modifier.block_access_list == Bytes(b"") diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py index 52d366019d8..05a3ea10001 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_invalid.py @@ -21,7 +21,9 @@ BlockAccessListExpectation, BlockchainTestFiller, BlockException, + Bytes, EIPChecklist, + EngineAPIError, Environment, Fork, Hash, @@ -1627,3 +1629,49 @@ def test_bal_invalid_extraneous_coinbase( ) ], ) + + +@pytest.mark.valid_from("Amsterdam") +@pytest.mark.blockchain_test_engine_only +@pytest.mark.exception_test +@pytest.mark.parametrize( + "invalid_bal_payload", + [ + pytest.param(b"", id="empty_byte_string"), + pytest.param(b"\x80", id="rlp_non_list"), + pytest.param(b"\xc1", id="rlp_truncated_list"), + ], +) +def test_bal_invalid_engine_payload_encoding( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + invalid_bal_payload: bytes, +) -> None: + """ + Reject a `newPayload` whose `blockAccessList` does not decode as an RLP + list: the empty byte string `0x` (an empty BAL is `0xc0`), the RLP + empty byte string `0x80` (valid RLP but not a list), or a truncated + list header `0xc1`. + """ + sender = pre.fund_eoa() + receiver = pre.nonexistent_account() + + tx = Transaction(sender=sender, to=receiver) + + blockchain_test( + pre=pre, + post={ + sender: Account(nonce=0), + receiver: None, + }, + blocks=[ + Block( + txs=[tx], + engine_new_payload_block_access_list=Bytes( + invalid_bal_payload + ), + exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + engine_api_error_code=EngineAPIError.InvalidParams, + ) + ], + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_fork_transition.py b/tests/amsterdam/eip7928_block_level_access_lists/test_fork_transition.py index 8e524c8e68e..a9df078ea58 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_fork_transition.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_fork_transition.py @@ -11,6 +11,7 @@ BlockAccessListExpectation, BlockchainTestFiller, BlockException, + Bytes, EIPChecklist, EngineAPIError, Environment, @@ -118,6 +119,42 @@ def test_invalid_pre_fork_block_with_bal_hash_field( ) +@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.blockchain_test_engine_only +@pytest.mark.exception_test +def test_bal_invalid_engine_payload_field_before_fork( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Reject a pre-Amsterdam `newPayload` that carries a `blockAccessList`. + + The block and its header are otherwise valid, so the spurious payload + field is the only defect: clients that silently drop unknown + `newPayloadV4` fields would answer VALID and must fail this test. + """ + sender = pre.fund_eoa() + receiver = pre.nonexistent_account() + + tx = Transaction(sender=sender, to=receiver, value=100) + + blockchain_test( + pre=pre, + post={}, + blocks=[ + Block( + timestamp=FORK_TIMESTAMP - 1, + txs=[tx], + # A valid empty-BAL encoding: field presence alone, not + # decodability, must trigger the rejection. + engine_new_payload_block_access_list=Bytes(b"\xc0"), + exception=BlockException.INCORRECT_BLOCK_FORMAT, + engine_api_error_code=EngineAPIError.InvalidParams, + ), + ], + ) + + @EIPChecklist.BlockHeaderField.Test.ForkTransition.After() @pytest.mark.valid_at_transition_to("Amsterdam") @pytest.mark.exception_test