From 6ae21cbfe5217d3edcfcaf73fea740873ab61e51 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 10:38:11 +0100 Subject: [PATCH 1/9] chore(tests): pin EIP-7928 to its merged head d2a64c2d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `f834f000…` no longer exists in ethereum/EIPs: it was a pre-force-push commit of EIPs#12277, which squash-merged on 2026-09-09 as d2a64c2d4cc44f2f507577d0ebfb110dcc21d358 (rationale-only wording change). The stateful benchmark refresh copied the dead hash and the compute benchmark test still carried aca88aa0. PR CI only collects the version checks, so none of the three were flagged. --- tests/amsterdam/eip7928_block_level_access_lists/spec.py | 2 +- .../eip7928_block_level_access_lists/test_block_access_list.py | 2 +- .../benchmark/stateful/eip7928_block_level_access_lists/spec.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/spec.py b/tests/amsterdam/eip7928_block_level_access_lists/spec.py index 8b5a257a098..4e1676f7d8e 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/spec.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/spec.py @@ -13,5 +13,5 @@ class ReferenceSpec: ref_spec_7928 = ReferenceSpec( git_path="EIPS/eip-7928.md", - version="f834f0004aa5110a5f1ac0d6b80e3dc4b842d040", + version="d2a64c2d4cc44f2f507577d0ebfb110dcc21d358", ) diff --git a/tests/benchmark/compute/eip7928_block_level_access_lists/test_block_access_list.py b/tests/benchmark/compute/eip7928_block_level_access_lists/test_block_access_list.py index 42953d01339..00a0461a866 100644 --- a/tests/benchmark/compute/eip7928_block_level_access_lists/test_block_access_list.py +++ b/tests/benchmark/compute/eip7928_block_level_access_lists/test_block_access_list.py @@ -35,7 +35,7 @@ from ethereum.crypto.hash import keccak256 REFERENCE_SPEC_GIT_PATH = "EIPS/eip-7928.md" -REFERENCE_SPEC_VERSION = "aca88aa0932580c29d0233f902cb4390e88b8c41" +REFERENCE_SPEC_VERSION = "d2a64c2d4cc44f2f507577d0ebfb110dcc21d358" pytestmark = pytest.mark.valid_from("Amsterdam") diff --git a/tests/benchmark/stateful/eip7928_block_level_access_lists/spec.py b/tests/benchmark/stateful/eip7928_block_level_access_lists/spec.py index d8541199475..010c4226fc1 100644 --- a/tests/benchmark/stateful/eip7928_block_level_access_lists/spec.py +++ b/tests/benchmark/stateful/eip7928_block_level_access_lists/spec.py @@ -17,5 +17,5 @@ class ReferenceSpec: ref_spec_7928 = ReferenceSpec( git_path="EIPS/eip-7928.md", - version="f834f0004aa5110a5f1ac0d6b80e3dc4b842d040", + version="d2a64c2d4cc44f2f507577d0ebfb110dcc21d358", ) From 5ee4f2d7584408785c9a01a16c38b23dbd055ae7 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 10:38:11 +0100 Subject: [PATCH 2/9] chore(tests): annotate precompile parameters as Address The `with_all_precompiles` marker yields an `Address`, which is what `test_bal_precompile_funded` already declares; match it in the two other tests that consume the same parameter. --- .../test_block_access_lists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index a714a8b1191..5250ab9c839 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -782,7 +782,7 @@ def test_bal_2930_account_listed_but_untouched( def test_bal_2930_precompile_listed_but_untouched( pre: Alloc, state_test: StateTestFiller, - precompile: int, + precompile: Address, ) -> None: """ Ensure a precompile named in the access list but never called stays @@ -2103,7 +2103,7 @@ def test_bal_precompile_funded( def test_bal_precompile_call_opcode( pre: Alloc, blockchain_test: BlockchainTestFiller, - precompile: int, + precompile: Address, call_opcode: Op, ) -> None: """ From 0d282c920a21614276601a6210fcba5c627c78c5 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 10:38:11 +0100 Subject: [PATCH 3/9] chore(tests): attribute the coinbase BAL entry to the fee credit The coinbase lands in the BAL because the priority-fee credit touches it even when the fee is zero (`create_ether(coinbase, transaction_fee)` in `process_transaction`), not because EIP-3651 warms it: warmth is a gas property and records no access. Reword both gas-limit boundary comments. --- .../test_block_access_lists.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 5250ab9c839..dcb17c0a7ce 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -4278,7 +4278,8 @@ def test_bal_gas_limit_boundary( # charge that would otherwise inflate the tx's gas needs past # the BAL-sized ``block_gas_limit``. bob = pre.fund_eoa(amount=1) - # alice (sender) + bob (recipient) + coinbase (EIP-3651 warm). + # alice (sender) + bob (recipient) + coinbase, touched by the + # zero priority-fee credit. extra_items += 3 txs.append( Transaction( @@ -4400,7 +4401,8 @@ def test_bal_gas_limit_boundary_storage_keys( for _ in range(2) ] - # alice + counter + coinbase (EIP-3651 warm), then one item per key. + # alice + counter + coinbase (touched by the zero priority-fee + # credit), then one item per key. storage_keys = [written_slot, read_slot] total_items = fork.empty_block_bal_item_count() + 3 + len(storage_keys) gas_limit = ( From 4f2daa9ef1b7ba5ef8556f3a823f48eaf48bc924 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 11:30:17 +0100 Subject: [PATCH 4/9] fix(test-client-clis): map reth's BAL decode failure to INVALID_BLOCK_ACCESS_LIST reth rejects a non-minimally encoded BAL with "failed to decode block access list: leading zero". The mapper had no pattern for it, so six `test_bal_invalid_non_minimal_scalar_encoding` engine fixtures failed as "Undefined exception message" although reth rejects the block. --- packages/testing/src/execution_testing/client_clis/clis/reth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/testing/src/execution_testing/client_clis/clis/reth.py b/packages/testing/src/execution_testing/client_clis/clis/reth.py index 5fca3242ba4..d26ec1c671c 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/reth.py +++ b/packages/testing/src/execution_testing/client_clis/clis/reth.py @@ -118,6 +118,7 @@ class RethExceptionMapper(ExceptionMapper): # BAL Exceptions BlockException.INVALID_BAL_HASH: (r"block access list hash mismatch"), BlockException.INVALID_BLOCK_ACCESS_LIST: ( + r"failed to decode block access list|" r"block access list hash mismatch|" r"BAL rejection: FinalHashMismatch|" r"Bal error: Account .* not found in BAL|" From 437bf1503d6f4c4141181cc1432a2f6e61a4e424 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 11:30:17 +0100 Subject: [PATCH 5/9] chore(tests): accept the header-hash verdict for undecodable BAL payloads A client that hashes the payload's BAL bytes into its reconstructed header before validating the encoding (reth) answers INVALID_BLOCK_HASH where the fixtures only accepted INVALID_BLOCK_ACCESS_LIST. EIP-7928 fixes neither order, and both verdicts reject the block, so `test_bal_invalid_engine_payload_encoding` and `test_bal_invalid_non_minimal_scalar_encoding` now accept either. Nine reth consume-engine failures on the 2026-09-14 glamsterdam run. --- .../test_block_access_lists_invalid.py | 19 ++++++++++++++++--- .../test_cases.md | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) 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 1f387946943..f646db95ff0 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 @@ -1902,7 +1902,10 @@ def test_bal_invalid_engine_payload_encoding( list header `0xc1`. The field is present but not a valid encoding, so the payload is - invalid rather than the request being malformed. + invalid rather than the request being malformed. A client that hashes + the bytes as received into its reconstructed header reports the block + hash mismatch instead of the decoding failure; both verdicts reject + the block, so both are accepted. """ sender = pre.fund_eoa() receiver = pre.nonexistent_account() @@ -1921,7 +1924,10 @@ def test_bal_invalid_engine_payload_encoding( engine_new_payload_block_access_list=Bytes( invalid_bal_payload ), - exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + exception=[ + BlockException.INVALID_BLOCK_ACCESS_LIST, + BlockException.INVALID_BLOCK_HASH, + ], ) ], ) @@ -1960,6 +1966,10 @@ def test_bal_invalid_non_minimal_scalar_encoding( a matching hash and accepts. With the header committing to the payload RLP, a client that decodes leniently and hashes the bytes as received accepts instead. + + A strict client may notice the bad scalar while decoding, or only + once the hash it derives from the payload disagrees with the header; + both verdicts reject the block, so both are accepted. """ alice = pre.fund_eoa() oracle = pre.deploy_contract(code=Op.SSTORE(1, 1) + Op.SLOAD(2)) @@ -2020,7 +2030,10 @@ def test_bal_invalid_non_minimal_scalar_encoding( blocks=[ Block( txs=[tx], - exception=BlockException.INVALID_BLOCK_ACCESS_LIST, + exception=[ + BlockException.INVALID_BLOCK_ACCESS_LIST, + BlockException.INVALID_BLOCK_HASH, + ], expected_block_access_list=expectation, ) ], diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index d6e259996ed..8c9ff876446 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -187,7 +187,7 @@ | `test_bal_invalid_missing_created_code` | Verify clients reject a BAL that omits the deployed code of a contract created via a contract-creation transaction | Alice sends a top-level `CREATE` transaction (`to=None`) whose init code deploys a small runtime. BAL modifier removes the created contract's `code_changes` entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect a newly created contract whose deployed code has no corresponding BAL entry. | ✅ Completed | | `test_bal_invalid_omitted_slot_change_at_index` | Verify clients reject a BAL that drops a slot's earlier change while keeping its later one, misattributing the slot's first recorded change to a later transaction | Two transactions each write storage slot 0 of the same contract via the transaction's call value (slot 0: 0→1 at tx1, 1→2 at tx2). BAL modifier removes only tx1's `slot_changes` entry for slot 0 (new `remove_slot_change` modifier), leaving tx2's entry as the slot's only recorded change. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that a slot's first BAL-recorded change matches the transaction that actually performed it, not merely that some change with the correct final value exists. | ✅ Completed | | `test_bal_invalid_phantom_read_on_selfdestruct` | Verify clients reject a BAL with a phantom storage read for an account created and destroyed within the same transaction | A contract-creation transaction's init code immediately `SELFDESTRUCT`s, sending its endowment to beneficiary, without ever returning runtime code. Per EIP-6780 the created account has zero net BAL changes (`BalAccountExpectation.empty()`) but still legitimately appears in the BAL as an entry with empty changes. BAL modifier injects a phantom `storage_reads` entry for a slot the account never touched. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** reject a storage read recorded against an account that performed no storage access at all, even when the account otherwise legitimately appears in the BAL. | ✅ Completed | -| `test_bal_invalid_non_minimal_scalar_encoding` | Verify clients reject a BAL whose RLP encodes an integer scalar non-minimally | Alice calls a contract that does `SSTORE(1, 1)` and `SLOAD(2)`, sending value so the account also has a balance change. The BAL contents are left untouched; the BAL delivered in the engine payload is re-encoded with one scalar carrying a leading zero byte. Parametrized over each integer field: `storage_slot`, `storage_value`, `storage_read`, `balance`, `block_access_index` (from the contract's balance change), `nonce` (from Alice's nonce change); and over what the header commits to: the canonical RLP (`modify_rlp`, payload only) or the payload RLP (`override_rlp`, so the block hash also follows). | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. RLP integers **MUST** be minimally encoded. With the header committing to the canonical RLP, catches clients that decode these fields as raw byte strings and hash a re-encoding of the decoded BAL. With the header committing to the payload RLP, catches clients that hash the bytes as received without a minimal-encoding check. The uint32 index and uint64 nonce take different decoder paths from the uint256 fields, so each width is covered. | ✅ Completed | +| `test_bal_invalid_non_minimal_scalar_encoding` | Verify clients reject a BAL whose RLP encodes an integer scalar non-minimally | Alice calls a contract that does `SSTORE(1, 1)` and `SLOAD(2)`, sending value so the account also has a balance change. The BAL contents are left untouched; the BAL delivered in the engine payload is re-encoded with one scalar carrying a leading zero byte. Parametrized over each integer field: `storage_slot`, `storage_value`, `storage_read`, `balance`, `block_access_index` (from the contract's balance change), `nonce` (from Alice's nonce change); and over what the header commits to: the canonical RLP (`modify_rlp`, payload only) or the payload RLP (`override_rlp`, so the block hash also follows). | Block **MUST** be rejected, with `INVALID_BLOCK_ACCESS_LIST`, or with `INVALID_BLOCK_HASH` where the client compares the hash it derives from the payload against the header before validating the encoding. RLP integers **MUST** be minimally encoded. With the header committing to the canonical RLP, catches clients that decode these fields as raw byte strings and hash a re-encoding of the decoded BAL. With the header committing to the payload RLP, catches clients that hash the bytes as received without a minimal-encoding check. The uint32 index and uint64 nonce take different decoder paths from the uint256 fields, so each width is covered. | ✅ Completed | | `test_bal_invalid_missing_request_predeploy_accesses` | Verify clients reject a BAL that hides the queue slots a post-execution system call read from a request predeploy | Empty block; the post-execution system call reads the predeploy's excess, count, queue head and queue tail slots and writes nothing, so the entry's `storage_changes` is pinned empty. Parametrized over the EIP-7002, EIP-7251 and both EIP-8282 predeploys, and over the corruption: (1) `missing_entry`: the predeploy's entry is removed entirely. (2) `missing_reads`: the entry is kept but its `storage_reads` cleared, leaving an entry that records no accesses at all. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. The omission moves neither `state_root` nor gas, so clients **MUST** validate BAL completeness against the accesses of the post-execution system calls, not only their writes; and **MUST** validate that every slot read by such a call appears in `storage_reads` (or `storage_changes`) even when the predeploy's entry is otherwise present. | ✅ Completed | | `test_bal_invalid_missing_pre_block_system_call_read` | Verify clients reject a BAL that drops a storage read made by a pre-execution system call | Empty block with `parent_beacon_block_root = 0` (the framework default). The EIP-4788 system call writes the timestamp slot and rewrites the root slot with its current value, which EIP-7928 records as a `storage_reads` entry for `BEACON_ROOTS_ADDRESS`. BAL modifier clears that entry's `storage_reads`. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate `storage_reads` completeness at `block_access_index = 0`, not only the writes. EIP-7928 records a same-value write as a read, so a client that instead follows the spec's "record the two updated storage slots" phrasing for EIP-4788 emits the root slot as a `storage_changes` entry with `post_value = 0` and is caught here. | ✅ Completed | | `test_bal_invalid_missing_system_contract_entry` | Verify clients reject a BAL that omits a system contract written by a pre-execution system call | Parametrized over `HISTORY_STORAGE_ADDRESS` (EIP-2935) and `BEACON_ROOTS_ADDRESS` (EIP-4788). Empty block with a non-zero parent beacon root. BAL modifier removes the system contract's entry entirely. | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that every account written at `block_access_index = 0` has a BAL entry. | ✅ Completed | From b3b2b999cb070ac3aefe9210b91ac7d74323539d Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 11:30:17 +0100 Subject: [PATCH 6/9] fix(tests): keep test_tx_gas_limit below the EIP-7928 item cap With a 21000 block gas limit the BAL item cap is 10, but an empty Amsterdam block already carries 25 items of system-contract reads and writes, so the engine payload is invalid twice over. besu, geth, reth and nimbus report the cap before the transaction's gas allowance (#3439) while EELS reports the allowance first. A 100000 limit keeps the transaction the only defect; every fork still fills with GAS_ALLOWANCE_EXCEEDED. --- tests/frontier/validation/test_transaction.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/frontier/validation/test_transaction.py b/tests/frontier/validation/test_transaction.py index e417bdddbbe..bdbbc2dba38 100644 --- a/tests/frontier/validation/test_transaction.py +++ b/tests/frontier/validation/test_transaction.py @@ -37,12 +37,19 @@ def test_tx_gas_limit( """ Tests that if a tx gas limit is higher than the block gas limit, an exception is raised. + + The block gas limit is kept well above what an empty block's access + list needs under the EIP-7928 item cap (`gas_limit // 2000` items, + against the system-contract reads every Amsterdam block carries), so + the transaction's gas allowance is the only thing wrong with the + block and clients do not disagree on which check to report. """ sender = pre.fund_eoa() to = pre.fund_eoa() + block_gas_limit = 100_000 tx = Transaction( - gas_limit=21001, + gas_limit=block_gas_limit + 1, to=to, gas_price=0x10, # Must be >= base fee to isolate gas limit validation sender=sender, @@ -50,8 +57,8 @@ def test_tx_gas_limit( error=TransactionException.GAS_ALLOWANCE_EXCEEDED, ) - modified_fields = {"gas_limit": ZeroPaddedHexNumber(21000)} - env.gas_limit = ZeroPaddedHexNumber(21000) + modified_fields = {"gas_limit": ZeroPaddedHexNumber(block_gas_limit)} + env.gas_limit = ZeroPaddedHexNumber(block_gas_limit) block = Block( txs=[tx], From af813764cf860d717bf4b5242c563a1a1f4f6e75 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 11:56:33 +0100 Subject: [PATCH 7/9] test(bal): reject malformed encodings with matching header commitments --- .../test_block_access_lists_invalid.py | 36 +++++++++---------- .../test_cases.md | 8 +++++ 2 files changed, 26 insertions(+), 18 deletions(-) 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 f646db95ff0..0c6023df17c 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 @@ -1890,28 +1890,33 @@ def test_bal_invalid_extraneous_coinbase( pytest.param(b"\xc1", id="rlp_truncated_list"), ], ) +@pytest.mark.parametrize("header_commits_to", ["canonical_rlp", "payload_rlp"]) def test_bal_invalid_engine_payload_encoding( blockchain_test: BlockchainTestFiller, pre: Alloc, invalid_bal_payload: bytes, + header_commits_to: str, ) -> 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`. - - The field is present but not a valid encoding, so the payload is - invalid rather than the request being malformed. A client that hashes - the bytes as received into its reconstructed header reports the block - hash mismatch instead of the decoding failure; both verdicts reject - the block, so both are accepted. + Reject malformed BAL RLP with both matching and mismatched header + commitments, so a block-hash check cannot hide missing RLP validation. """ sender = pre.fund_eoa() receiver = pre.nonexistent_account() - tx = Transaction(sender=sender, to=receiver) + expectation = BlockAccessListExpectation() + exceptions = [BlockException.INVALID_BLOCK_ACCESS_LIST] + if header_commits_to == "canonical_rlp": + expectation = expectation.modify_rlp( + lambda _: Bytes(invalid_bal_payload) + ) + exceptions.append(BlockException.INVALID_BLOCK_HASH) + else: + expectation = expectation.modify( + override_rlp(lambda _: Bytes(invalid_bal_payload)) + ) + blockchain_test( pre=pre, post={ @@ -1921,13 +1926,8 @@ def test_bal_invalid_engine_payload_encoding( blocks=[ Block( txs=[tx], - engine_new_payload_block_access_list=Bytes( - invalid_bal_payload - ), - exception=[ - BlockException.INVALID_BLOCK_ACCESS_LIST, - BlockException.INVALID_BLOCK_HASH, - ], + expected_block_access_list=expectation, + exception=exceptions, ) ], ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 8c9ff876446..847d85a709f 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -227,3 +227,11 @@ | `test_bal_2930_precompile_listed_but_untouched` | Ensure a precompile named in a transaction's access list but never called stays out of the BAL, for every precompile. | A plain transfer whose access list declares the precompile, via `@pytest.mark.with_all_precompiles`. | The precompile **MUST NOT** appear. A client that tracks precompiles apart from other accounts would leak the declaration here and in no other fixture. | ✅ Completed | | `test_sstore_clear_then_reset_nets_zero` (EIP-8038) | Ensure a slot cleared and then reset within one transaction is a single BAL change holding the final value. File: `tests/amsterdam/eip8038_state_access_gas_cost_increase/test_sstore_refunds.py`. | `SSTORE` clears a non-zero slot and rewrites it to a different non-zero value in the same frame, which also reverses the clear refund; the receipt pins the gas. | The slot **MUST** carry exactly one change at index 1 with the final value and **MUST NOT** appear in `storage_reads`; one entry per write would be rejected as a duplicate index. | ✅ Completed | | `test_bal_6110_deposit` | Ensure a deposit reaches the BAL as the deposit contract's balance change at the transaction's own index. | A single 32 ETH deposit sent by an EOA through `SystemContractInteractionTransaction`; the request is pinned in the header with `requests_hash`. | The deposit contract **MUST** record one balance change at index 1 holding the deposited value and no nonce or code change; the request is read from its log, so nothing touches it at the post-execution index. | ✅ Completed | + +### Malformed BAL encoding + +`test_bal_invalid_engine_payload_encoding` covers empty bytes, an RLP string +instead of a list, and a truncated list. Each payload is tested with the header +committing to either the canonical BAL or the malformed bytes. Matching-header +cases require `INVALID_BLOCK_ACCESS_LIST`; mismatched-header cases also accept +`INVALID_BLOCK_HASH`. From f44bd11d446413c88fb201a3e6b6f2d3946b14d0 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 11:56:33 +0100 Subject: [PATCH 8/9] test(bal): require empty change lists for zero-value recipients --- .../test_block_access_lists.py | 18 +++++++++--------- .../test_cases.md | 12 ++++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index dcb17c0a7ce..3c113b83724 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -1068,7 +1068,7 @@ def test_bal_zero_value_transfer( ], ), # Include the address; omit from balance_changes. - bob: BalAccountExpectation(balance_changes=[]), + bob: BalAccountExpectation.empty(), } ), ) @@ -2182,10 +2182,10 @@ def test_bal_nonexistent_value_transfer( BalBalanceChange( block_access_index=1, post_balance=value ) - ] - if value > 0 - else [], - ), + ], + ) + if value > 0 + else BalAccountExpectation.empty(), } ), ) @@ -2360,10 +2360,10 @@ def test_bal_nonexistent_account_access_value_transfer( block_access_index=1, post_balance=bob_final_balance, ) - ] - if bob_has_balance_change - else [], - ), + ], + ) + if bob_has_balance_change + else BalAccountExpectation.empty(), } ), ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 847d85a709f..4e1de4288ab 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -235,3 +235,15 @@ instead of a list, and a truncated list. Each payload is tested with the header committing to either the canonical BAL or the malformed bytes. Matching-header cases require `INVALID_BLOCK_ACCESS_LIST`; mismatched-header cases also accept `INVALID_BLOCK_HASH`. + +### Zero-value touches and empty accounts (#3341) + +`test_bal_zero_value_transfer`, `test_bal_nonexistent_value_transfer`, and +`test_bal_nonexistent_account_access_value_transfer` require empty change lists +for recipients whose state is unchanged. The nonexistent-recipient cases also +assert that no account is created. + +An existing empty account is a different case: EIP-7523 excludes such accounts +from post-Merge test states, and #1160 deliberately removed their legacy touch +and deletion tracking. These tests cover valid Amsterdam states; they do not +claim to test deletion of pre-existing empty accounts. From b47945ae6750128e8d8093efaa2cf58f28e89fe9 Mon Sep 17 00:00:00 2001 From: spencer-tb Date: Tue, 15 Sep 2026 12:34:01 +0100 Subject: [PATCH 9/9] fix(test-fill): isolate pre-fork BAL hash rejection from API validation --- .../src/execution_testing/specs/blockchain.py | 20 +++++------- .../specs/tests/test_types.py | 32 +++++++++++-------- .../test_cases.md | 14 ++++++++ .../test_fork_transition.py | 17 +++++----- 4 files changed, 48 insertions(+), 35 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 6df509fd78b..651458b60d8 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -560,12 +560,13 @@ def engine_payload_modifier( self, ) -> "FixtureExecutionPayloadModifier | None": """ - Propagate ``rlp_modifier``'s header changes to the engine payload. + Propagate header changes and explicit overrides to the engine payload. The engine ``ExecutionPayload`` schema does not carry - ``block_access_list_hash`` directly; the equivalent payload field is - the ``block_access_list`` body. So a header modifier that touches the - BAL hash needs to drive a matching change on the payload body. + ``block_access_list_hash`` directly; the corresponding payload + field is the ``block_access_list`` body. Removing the header hash + therefore removes the payload field. Adding or changing the hash + is reflected in ``blockHash`` without synthesizing a BAL body. """ if self.engine_new_payload_slot_number is not None: return FixtureExecutionPayloadModifier( @@ -586,14 +587,9 @@ def engine_payload_modifier( FixtureExecutionPayloadModifier.REMOVE_FIELD ), ) - # The user injected a header BAL hash; mirror that on the engine - # 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 self.block_access_list is None: - return FixtureExecutionPayloadModifier( - block_access_list=Bytes(b""), - ) + # Do not introduce an unsupported pre-fork payload field when the + # intended defect is the header hash. API field-presence tests use + # an explicit engine_new_payload_block_access_list override. return None def get_fixture_engine_new_payload(self) -> FixtureEngineNewPayload: 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 1816716fae7..9544c9517c5 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_types.py +++ b/packages/testing/src/execution_testing/specs/tests/test_types.py @@ -18,7 +18,7 @@ FixtureExecutionPayloadModifier, FixtureHeader, ) -from execution_testing.forks import Amsterdam +from execution_testing.forks import Amsterdam, Fork, Osaka from execution_testing.test_types import Alloc, Environment from execution_testing.test_types.block_access_list import ( BlockAccessList, @@ -166,6 +166,7 @@ def test_fixture_header_join( def built_block( *, + fork: Fork = Amsterdam, rlp_modifier: Header | None = None, block_access_list: BlockAccessList | None = None, engine_new_payload_block_access_list: Bytes | None = None, @@ -181,7 +182,7 @@ def built_block( withdrawals=None, requests=None, result=result_empty, - fork=Amsterdam, + fork=fork, rlp_modifier=rlp_modifier, block_access_list=block_access_list, engine_new_payload_block_access_list=engine_new_payload_block_access_list, @@ -228,18 +229,19 @@ def test_remove_bal_hash_removes_body_from_payload(self) -> None: FixtureExecutionPayloadModifier.REMOVE_FIELD ) - def test_inject_bal_hash_on_pre_fork_adds_body(self) -> None: + def test_inject_bal_hash_on_pre_fork_keeps_body_absent(self) -> None: """ - Injecting a header BAL hash on a block that has no body (pre-fork) - triggers a body to be added to the engine payload, so a payload- - version mismatch is detectable. + Keep pre-fork payload parameters valid when only the header hash + is corrupted, so rejection tests the block hash alone. """ - 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"") + assert ( + built_block( + fork=Osaka, + rlp_modifier=Header(block_access_list_hash=Hash(0)), + block_access_list=None, + ).engine_payload_modifier() + is None + ) def test_inject_bal_hash_on_post_fork_leaves_body_alone(self) -> None: """ @@ -255,10 +257,12 @@ def test_inject_bal_hash_on_post_fork_leaves_body_alone(self) -> None: is None ) - def test_empty_bytes_override_sends_raw_body(self) -> None: + @pytest.mark.parametrize("fork", [Osaka, Amsterdam]) + def test_empty_bytes_override_sends_raw_body(self, fork: Fork) -> None: """Raw `Bytes` (e.g. the invalid `0x`) are sent verbatim.""" modifier = built_block( - engine_new_payload_block_access_list=Bytes(b"") + fork=fork, + 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_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 4e1de4288ab..4ee9ddd3df0 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -247,3 +247,17 @@ An existing empty account is a different case: EIP-7523 excludes such accounts from post-Merge test states, and #1160 deliberately removed their legacy touch and deletion tracking. These tests cover valid Amsterdam states; they do not claim to test deletion of pre-existing empty accounts. + +### Pre-fork Engine API field policy + +`test_invalid_pre_fork_block_with_bal_hash_field` keeps the engine payload's +`blockAccessList` field absent. The supplied block hash still commits to the +illegal extra header field, so rejection checks the hash without an unrelated +API parameter error. The RLP fixture retains the extra header field. + +`test_bal_invalid_engine_payload_field_before_fork` requires `-32602` for the +extra field. [Prague newPayloadV4](https://github.com/ethereum/execution-apis/blob/main/src/engine/prague.md#specification) +inherits [Cancun newPayloadV3's strict parameter and field matching rule](https://github.com/ethereum/execution-apis/blob/main/src/engine/cancun.md#specification). +Its ExecutionPayloadV3 has no `blockAccessList` field; returning `VALID` is an +Engine API conformance failure. Amsterdam missing-BAL and malformed-BAL checks +remain active with their existing expectations. 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 71ba29aa8f4..e92eac800b3 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 @@ -107,9 +107,9 @@ def test_invalid_pre_fork_block_with_bal_hash_field( Reject a pre-Amsterdam block whose header carries `block_access_list_hash`. - The engine fixture sends a pre-Amsterdam `newPayload` carrying an - empty `blockAccessList` param; the client's reconstructed header - omits the hash, so the block hash check fails. + The engine fixture omits `blockAccessList`, keeping the pre-fork API + parameters valid. Its block hash still commits to the extra header + field, so the client's reconstructed header cannot match it. """ sender = pre.fund_eoa() receiver = pre.fund_eoa(amount=0) @@ -138,11 +138,11 @@ def test_bal_invalid_engine_payload_field_before_fork( pre: Alloc, ) -> None: """ - Reject a pre-Amsterdam `newPayload` that carries a `blockAccessList`. + Reject an extra BAL field under the inherited strict-field API rule. - 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. + Prague's newPayloadV4 inherits Cancun's newPayloadV3 parameter checks + and accepts ExecutionPayloadV3, which has no blockAccessList field. + The otherwise-valid block isolates the required Invalid params error. """ sender = pre.fund_eoa() receiver = pre.nonexistent_account() @@ -156,8 +156,7 @@ def test_bal_invalid_engine_payload_field_before_fork( Block( timestamp=FORK_TIMESTAMP - 1, txs=[tx], - # A valid empty-BAL encoding: field presence alone, not - # decodability, must trigger the rejection. + # Isolate field presence from BAL decoding validity. engine_new_payload_block_access_list=Bytes(b"\xc0"), exception=BlockException.INCORRECT_BLOCK_FORMAT, engine_api_error_code=EngineAPIError.InvalidParams,