Skip to content

feat(tools,forks): Extend EEST to support EIP-7928 payload #1866

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: feat/block-access-list
Choose a base branch
from
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies = [
"pytest-regex>=0.2.0,<0.3",
"eth-abi>=5.2.0",
"joblib>=1.4.2",
"pokebal",
]

[project.urls]
Expand Down Expand Up @@ -148,3 +149,4 @@ ignore-words-list = "ingenuous"

[tool.uv.sources]
ethereum-spec-evm-resolver = { git = "https://github.com/spencer-tb/ethereum-spec-evm-resolver", rev = "ee273e7344e24a739ebfbf0ea1f758530c4d032b" }
pokebal = { git = "https://github.com/raxhvl/pokebal.git", subdirectory = "packages/py" }
5 changes: 5 additions & 0 deletions src/ethereum_test_fixtures/blockchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ class FixtureHeader(CamelModel):
None
)
requests_hash: Annotated[Hash, HeaderForkRequirement("requests")] | None = Field(None)
bal_hash: Annotated[Hash, HeaderForkRequirement("bal_hash")] | None = Field(None)

fork: Fork | None = Field(None, exclude=True)

Expand Down Expand Up @@ -243,6 +244,7 @@ def genesis(cls, fork: Fork, env: Environment, state_root: Hash) -> "FixtureHead
),
parent_beacon_block_root=env.parent_beacon_block_root,
requests_hash=Requests() if fork.header_requests_required(0, 0) else None,
bal_hash=Hash(0) if fork.header_bal_hash_required(0, 0) else None,
fork=fork,
)

Expand Down Expand Up @@ -420,6 +422,9 @@ class FixtureBlockBase(CamelModel):
txs: List[FixtureTransaction] = Field(default_factory=list, alias="transactions")
ommers: List[FixtureHeader] = Field(default_factory=list, alias="uncleHeaders")
withdrawals: List[FixtureWithdrawal] | None = None
block_access_lists: Bytes | None = Field(
None, description="Serialized EIP-7928 Block Access Lists"
)

@computed_field(alias="blocknumber") # type: ignore[misc]
@cached_property
Expand Down
6 changes: 6 additions & 0 deletions src/ethereum_test_forks/base_fork.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ def header_requests_required(cls, block_number: int = 0, timestamp: int = 0) ->
"""Return true if the header must contain beacon chain requests."""
pass

@classmethod
@abstractmethod
def header_bal_hash_required(cls, block_number: int = 0, timestamp: int = 0) -> bool:
"""Return true if the header must contain block access list hash."""
pass

# Gas related abstract methods

@classmethod
Expand Down
9 changes: 9 additions & 0 deletions src/ethereum_test_forks/forks/forks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1473,3 +1473,12 @@ def is_deployed(cls) -> bool:
def solc_min_version(cls) -> Version:
"""Return minimum version of solc that supports this fork."""
return Version.parse("1.0.0") # set a high version; currently unknown


class BlockAccessLists(Prague):
"""A development fork for Block Access Lists."""

@classmethod
def header_bal_hash_required(cls, block_number: int = 0, timestamp: int = 0) -> bool:
"""Hash of the block access list is required starting from this fork."""
return True
15 changes: 15 additions & 0 deletions src/ethereum_test_specs/blockchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class Header(CamelModel):
excess_blob_gas: Removable | HexNumber | None = None
parent_beacon_block_root: Removable | Hash | None = None
requests_hash: Removable | Hash | None = None
bal_hash: Removable | Hash | None = None

REMOVE_FIELD: ClassVar[Removable] = Removable()
"""
Expand Down Expand Up @@ -240,6 +241,10 @@ class Block(Header):
"""
Post state for verification after block execution in BlockchainTest
"""
block_access_lists: Bytes | None = Field(None)
"""
EIP-7928: Block-level access lists (serialized).
"""

def set_environment(self, env: Environment) -> Environment:
"""
Expand Down Expand Up @@ -269,6 +274,13 @@ def set_environment(self, env: Environment) -> Environment:
new_env_values["blob_gas_used"] = self.blob_gas_used
if not isinstance(self.parent_beacon_block_root, Removable):
new_env_values["parent_beacon_block_root"] = self.parent_beacon_block_root
if not isinstance(self.requests_hash, Removable) and self.block_access_lists is not None:
new_env_values["bal_hash"] = self.block_access_lists.keccak256()
if (
not isinstance(self.block_access_lists, Removable)
and self.block_access_lists is not None
):
new_env_values["block_access_lists"] = self.block_access_lists
"""
These values are required, but they depend on the previous environment,
so they can be calculated here.
Expand Down Expand Up @@ -308,6 +320,7 @@ class BuiltBlock(CamelModel):
expected_exception: BLOCK_EXCEPTION_TYPE = None
engine_api_error_code: EngineAPIError | None = None
fork: Fork
block_access_lists: Bytes

def get_fixture_block(self) -> FixtureBlock | InvalidFixtureBlock:
"""Get a FixtureBlockBase from the built block."""
Expand All @@ -319,6 +332,7 @@ def get_fixture_block(self) -> FixtureBlock | InvalidFixtureBlock:
if self.withdrawals is not None
else None
),
block_access_lists=self.block_access_lists,
fork=self.fork,
).with_rlp(txs=self.txs)

Expand Down Expand Up @@ -574,6 +588,7 @@ def generate_block_data(
expected_exception=block.exception,
engine_api_error_code=block.engine_api_error_code,
fork=fork,
block_access_lists=block.block_access_lists,
)

try:
Expand Down
3 changes: 3 additions & 0 deletions src/ethereum_test_types/block_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ class Environment(EnvironmentGeneric[ZeroPaddedHexNumber]):
withdrawals: List[Withdrawal] | None = Field(None)
extra_data: Bytes = Field(Bytes(b"\x00"), exclude=True)

# EIP-7928: Block-level access lists
bal_hash: Hash | None = Field(None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel this belongs to theResult returned from the transition tool after executing the state transition.

Environment is more for things that affect the EVM execution and/or are observable in the EVM context.

If we put it in Result, we can catch it in here:

header = FixtureHeader(
**(
transition_tool_output.result.model_dump(
exclude_none=True, exclude={"blob_gas_used", "transactions_trie"}
)
| env.model_dump(exclude_none=True, exclude={"blob_gas_used"})
),
blob_gas_used=blob_gas_used,
transactions_trie=Transaction.list_root(txs),
extra_data=block.extra_data if block.extra_data is not None else b"",
fork=fork,
)

and then even verify it during the test by using header_verify as in this example here.

Copy link
Member Author

@raxhvl raxhvl Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @marioevz I see this differently. The test supplies the following to the client as part of a block:

  1. A list of transactions
  2. SSZ encoded block access list (BAL)
  3. BAL hash

In the PR (1) and (2) is part of a test. (3) is computed by the framework from (2).

State transition

The client's state transition function takes all three to produce a block. In addition to executing the transactions, the client:

  1. Computes its own copy BAL and then
  2. Computes BAL hash

The Client and NOT test verifies hash

The client MUST reject the block if computed hash does not match the provided one. ref: spec

If hash is not supplied as an input to the client it will not be able to perform this check. Hence its inclusion in ENV.

Copy link
Collaborator

@fselmo fselmo Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if I misunderstood anything in this flow.

We are trying to fill tests here to generate a test fixture with a built block to then test against clients. So we are essentially testing the EL's ability to locally build a valid block when we're filling tests. This block is then sent as a payload to a client via the test fixture. So I feel like we could actually perform the check at the Result level here. If we build our BALs and compute our own hash to check against, what we need from the filler (let's say this is EELS) is that they compute their own BALs internally, generate the hash, and we validate that this hash is the expected hash in the result with header_verify=Header(bal_hash=correct_bal_hash). And we can test any invalid cases with rlp_modifier=Header(bal_hash=invalid_bal_hash) and exception=BlockException.INVALID_BLOCK_HASH (or a more appropriate bal hash exception).

The client MUST reject the block if computed hash does not match the provided one. ref: spec

I think this comes into play when we're executing the test payload against a client. They will now have a filled block in the test fixture, with the hash that we validated is correct, and they should indeed correctly raise on an invalid hash according to the spec.

Am I missing anything @raxhvl? Does this go along with what you were thinking @marioevz?


@computed_field # type: ignore[misc]
@cached_property
def parent_hash(self) -> Hash | None:
Expand Down
3 changes: 3 additions & 0 deletions tests/unscheduled/eip7928_block_level_access_lists/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from dataclasses import dataclass

ACTIVATION_FORK_NAME = "BlockAccessLists"
"""The fork name for EIP-7928 activation."""


@dataclass(frozen=True)
class ReferenceSpec:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
"""Tests for validating EIP-7928: Block-level Access Lists (BAL)."""

import pytest
from pokebal.bal.types import BlockAccessList

from ethereum_test_tools import (
Account,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unused, do we plan to use it? Gives lint errors atm.

Copy link
Member Author

@raxhvl raxhvl Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For simplicity, I have broken down implementation into 2 parts.

  1. Changes to framework required to implement this EIP (This PR). Here we have more high level discussion about the flow and how everything fits together.
  2. There will be a follow up PR from this branch which will contain test cases. See: WIP Tests

To avoid confusion, I have cleared the contents of test file from this branch.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, thank you. That context helps.

Alloc,
Block,
BlockchainTestFiller,
Transaction,
)

from .spec import ACTIVATION_FORK_NAME, ref_spec_7928

@pytest.mark.valid_from("Amsterdam")
REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path
REFERENCE_SPEC_VERSION = ref_spec_7928.version


@pytest.mark.valid_from(ACTIVATION_FORK_NAME)
class TestBALValidity:
"""Test BAL validity and data structure integrity."""

Expand All @@ -18,8 +27,40 @@ def test_bal_hash_basic_transaction(
blockchain_test: BlockchainTestFiller,
):
"""Test BAL hash generation for basic ETH transfer."""
# TODO: Implement BAL hash validation for basic ETH transfer
pass
# Setup accounts for basic ETH transfer

# TODO: Populate BAL.
bal = BlockAccessList()

transfer_amount = 1000
sender = pre.fund_eoa()
recipient = pre.fund_eoa(amount=0)

# Create a basic ETH transfer transaction
tx = Transaction(
sender=sender,
to=recipient,
value=1000,
)

# Create block with custom header that includes BAL hash
block = Block(txs=[tx], block_access_lists=bal.serialize())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be more of a verification that the BAL-specific tests do, rather than something you have to pass on every test.

There's two ways of handling BALs in tests IMO:

  1. The test is unconcerned with BALs, so we take the value that was returned from the transition tool for granted and just put it in the result.
  2. The test is meant to verify BALs, so we add the header_verify field when constructing the block for the result from the transition tool to be cross-checked with the one that the test expects.

The BAL will be either way be verified at client level when the test is being executed either in hive or by the client consumer, and the test should fail if the BAL hash does not match what the filled test says.

Taking into account, I think this specific example should be rewritten as something like:

Suggested change
block = Block(txs=[tx], block_access_lists=bal.serialize())
block = Block(txs=[tx], header_verify=Header(bal_hash=bal.serialize().hash()))

Assuming bal.serialize().hash() returns the hash that should be placed in the header.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continuing the discussion, client simply rejects a block with invalid hash. For the test, client behaves like a black box which takes all three inputs to either accept or reject the block.

Copy link
Collaborator

@fselmo fselmo Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just getting to this comment now but this agrees with the way I'm thinking about it as well as I described above.

The client will build its own BAL and compute its own hash. We do so internally in the test and will need validate that it's correct after filling.

So what we need is a built BAL that can be hashed in the appropriate way (this can be bal.hash, bal.serialize().hash(), or however we want to wire it) and we need to check here that the hash is as expected. When the client runs this filled test against it, the payload from the fixture is built correctly (or if invalid will have an expect_exception) and this will test that the client independently builds its own BAL and hashes appropriately.


# Execute the blockchain test
blockchain_test(
pre=pre,
blocks=[block],
post={
sender: Account(
nonce=1,
),
recipient: Account(balance=transfer_amount),
},
)

# Note: In the generated fixture, the block header will include:
# - bal_hash: the computed hash of the Block Access List
# - bal_data: the SSZ-encoded Block Access List data (when framework supports it)

def test_bal_hash_storage_operations(
self,
Expand Down Expand Up @@ -103,7 +144,7 @@ def test_bal_failed_transaction_inclusion(
pass


@pytest.mark.valid_from("Amsterdam")
@pytest.mark.valid_from(ACTIVATION_FORK_NAME)
class TestBALEncoding:
"""Test SSZ encoding/decoding of BAL data structures."""

Expand Down Expand Up @@ -153,7 +194,7 @@ def test_ssz_encoding_full_bal(
pass


@pytest.mark.valid_from("Amsterdam")
@pytest.mark.valid_from(ACTIVATION_FORK_NAME)
class TestBALEdgeCases:
"""Test edge cases and error conditions for BAL."""

Expand Down Expand Up @@ -203,7 +244,7 @@ def test_bal_maximum_block_size(
pass


@pytest.mark.valid_from("Amsterdam")
@pytest.mark.valid_from(ACTIVATION_FORK_NAME)
class TestBALValidationFailures:
"""Test validation failure scenarios for malformed or incorrect BALs."""

Expand Down Expand Up @@ -244,7 +285,7 @@ def test_malformed_ssz_rejection(
pass


@pytest.mark.valid_from("Amsterdam")
@pytest.mark.valid_from(ACTIVATION_FORK_NAME)
class TestBALLimits:
"""Test EIP-7928 specification limits and boundaries."""

Expand Down Expand Up @@ -357,7 +398,7 @@ def test_hash_size_validation(
pass


@pytest.mark.valid_from("Amsterdam")
@pytest.mark.valid_from(ACTIVATION_FORK_NAME)
class TestBALBoundaryConditions:
"""Test boundary conditions and edge cases for EIP-7928 limits."""

Expand Down
Loading
Loading