From 471387a88bc1d08db168f66ecfe17849ffe69ef2 Mon Sep 17 00:00:00 2001 From: LouisTsai Date: Tue, 2 Jun 2026 17:43:36 +0800 Subject: [PATCH] implement 8070 --- .../execution/blob_transaction.py | 128 ++++++++++++++- .../src/execution_testing/rpc/__init__.py | 2 + .../testing/src/execution_testing/rpc/rpc.py | 23 ++- .../src/execution_testing/rpc/rpc_types.py | 30 ++++ .../src/execution_testing/specs/blobs.py | 2 + .../eip8070_sparse_blobpool/__init__.py | 4 + .../eip8070_sparse_blobpool/conftest.py | 149 ++++++++++++++++++ .../amsterdam/eip8070_sparse_blobpool/spec.py | 31 ++++ .../eip8070_sparse_blobpool/test_get_cells.py | 130 +++++++++++++++ 9 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 tests/amsterdam/eip8070_sparse_blobpool/__init__.py create mode 100644 tests/amsterdam/eip8070_sparse_blobpool/conftest.py create mode 100644 tests/amsterdam/eip8070_sparse_blobpool/spec.py create mode 100644 tests/amsterdam/eip8070_sparse_blobpool/test_get_cells.py diff --git a/packages/testing/src/execution_testing/execution/blob_transaction.py b/packages/testing/src/execution_testing/execution/blob_transaction.py index fa3f22c0051..fa7a46ef505 100644 --- a/packages/testing/src/execution_testing/execution/blob_transaction.py +++ b/packages/testing/src/execution_testing/execution/blob_transaction.py @@ -14,11 +14,16 @@ from execution_testing.rpc import ( BlobAndProofV1, BlobAndProofV2, + BlobCellsAndProofsV1, EngineRPC, EthRPC, ) -from execution_testing.rpc.rpc_types import GetBlobsResponse +from execution_testing.rpc.rpc_types import ( + GetBlobsResponse, + GetBlobsV4Response, +) from execution_testing.test_types import ( + Blob, NetworkWrappedTransaction, Transaction, ) @@ -106,6 +111,80 @@ def _validate_blob_and_proof( ) +def _validate_cells_and_proofs( + expected_blob: Blob | None, + received: BlobCellsAndProofsV1 | None, + cell_mask: int, + index: int, +) -> None: + """ + Validate an `engine_getBlobsV4` partial cell matrix against a local blob. + + For each cell index `i`, the bit at position `i` in `cell_mask` decides + whether the cell was requested. Requested cells (and their proofs) must + match the locally computed values; non-requested cells must be `null`. + + When `expected_blob` is `None` (a non-existing versioned hash), the whole + entry must be `null`. + """ + if expected_blob is None: + if received is None: + logger.info( + f"Blob at index {index} correctly returned null " + "(non-existing blob hash)" + ) + return + raise ValueError( + f"Blob at index {index} should not exist but " + f"client returned a cell matrix." + ) + if received is None: + raise ValueError(f"Received cell matrix at index {index} is empty.") + + assert expected_blob.cells is not None, ( + "Local blob has no cells; getBlobsV4 requires a fork with cell proofs." + ) + assert isinstance(expected_blob.proof, list), ( + "Local blob proof is not a cell-proof list." + ) + cells_per_ext_blob = len(expected_blob.cells) + if len(received.blob_cells) != cells_per_ext_blob: + raise ValueError( + f"Cell matrix at index {index} has {len(received.blob_cells)} " + f"cells, expected {cells_per_ext_blob}." + ) + if len(received.proofs) != cells_per_ext_blob: + raise ValueError( + f"Proof matrix at index {index} has {len(received.proofs)} " + f"proofs, expected {cells_per_ext_blob}." + ) + + for i in range(cells_per_ext_blob): + requested = (cell_mask >> i) & 1 + recv_cell = received.blob_cells[i] + recv_proof = received.proofs[i] + if requested: + if recv_cell != expected_blob.cells[i]: + raise ValueError( + f"Cell mismatch at blob index {index}, cell {i}." + ) + if recv_proof != expected_blob.proof[i]: + raise ValueError( + f"Cell proof mismatch at blob index {index}, cell {i}." + ) + else: + if recv_cell is not None: + raise ValueError( + f"Cell at blob index {index}, cell {i} was not requested " + "but client returned a non-null cell." + ) + if recv_proof is not None: + raise ValueError( + f"Proof at blob index {index}, cell {i} was not requested " + "but client returned a non-null proof." + ) + + def versioned_hashes_with_blobs_and_proofs( tx: NetworkWrappedTransaction, ) -> Dict[Hash, BlobAndProofV1 | BlobAndProofV2]: @@ -148,6 +227,7 @@ class BlobTransaction(BaseExecute): txs: List[NetworkWrappedTransaction | Transaction] nonexisting_blob_hashes: List[Hash] | None = None get_blobs_version: int | None = None + cell_mask: int | None = None def get_required_sender_balances( self, @@ -183,6 +263,7 @@ def execute( ) -> ExecuteResult: """Execute the format.""" versioned_hashes: Dict[Hash, BlobAndProofV1 | BlobAndProofV2] = {} + blobs_by_hash: Dict[Hash, Blob] = {} sent_txs: List[Transaction] = [] for tx_index, tx in enumerate(self.txs): tx = tx.with_signature_and_sender() @@ -193,6 +274,8 @@ def execute( versioned_hashes.update( versioned_hashes_with_blobs_and_proofs(tx) ) + for blob in tx.blob_objects: + blobs_by_hash[blob.versioned_hash] = blob else: sent_txs.append(tx) label = ( @@ -234,12 +317,46 @@ def execute( if self.nonexisting_blob_hashes is not None: list_versioned_hashes.extend(self.nonexisting_blob_hashes) - blob_response: GetBlobsResponse | None = engine_rpc.get_blobs( - list_versioned_hashes, version=version + indices_bitarray = self.cell_mask if version >= 4 else None + blob_response: GetBlobsResponse | GetBlobsV4Response | None = ( + engine_rpc.get_blobs( + list_versioned_hashes, + version=version, + indices_bitarray=indices_bitarray, + ) ) - if version <= 2: + if version >= 4: + # V4 (EIP-8070): partial cell matrix, selected by cell_mask + assert self.cell_mask is not None, ( + f"getBlobsV{version} requires a cell_mask." + ) + if blob_response is None: + raise ValueError( + f"getBlobsV{version} returned 'null' for the entire " + "response, but V4 should always return an array " + "(with null entries for missing blobs)." + ) + assert isinstance(blob_response, GetBlobsV4Response) + expected_blobs: List[Blob | None] = [ + blobs_by_hash[vh] for vh in versioned_hashes + ] + if self.nonexisting_blob_hashes is not None: + expected_blobs += [None] * len(self.nonexisting_blob_hashes) + if len(blob_response) != len(expected_blobs): + raise ValueError( + f"Expected {len(expected_blobs)} blob responses, " + f"got {len(blob_response)}." + ) + for i, (expected_cells, received_cells) in enumerate( + zip(expected_blobs, blob_response.root, strict=True) + ): + _validate_cells_and_proofs( + expected_cells, received_cells, self.cell_mask, i + ) + elif version <= 2: # V1/V2: all-or-nothing behavior + assert isinstance(blob_response, (GetBlobsResponse, type(None))) if self.nonexisting_blob_hashes is not None: # Any missing blob must cause the entire response to be null if blob_response is not None: @@ -274,6 +391,7 @@ def execute( _validate_blob_and_proof(expected_blob, received_blob, i) elif version == 3: # V3: partial responses (null only for missing blobs) + assert isinstance(blob_response, (GetBlobsResponse, type(None))) if blob_response is None: raise ValueError( f"getBlobsV{version} returned 'null' for the entire " @@ -312,7 +430,7 @@ def execute( else: raise NotImplementedError( f"getBlobsV{version} is not supported. " - "Supported versions: V1, V2, V3." + "Supported versions: V1, V2, V3, V4." ) eth_rpc.wait_for_transactions(sent_txs) diff --git a/packages/testing/src/execution_testing/rpc/__init__.py b/packages/testing/src/execution_testing/rpc/__init__.py index 4fe14aa2aa0..94d418f5150 100644 --- a/packages/testing/src/execution_testing/rpc/__init__.py +++ b/packages/testing/src/execution_testing/rpc/__init__.py @@ -20,6 +20,7 @@ from .rpc_types import ( BlobAndProofV1, BlobAndProofV2, + BlobCellsAndProofsV1, EthConfigResponse, ForkConfig, ForkConfigBlobSchedule, @@ -33,6 +34,7 @@ "AdminRPC", "BlobAndProofV1", "BlobAndProofV2", + "BlobCellsAndProofsV1", "BlockNotAvailableError", "BlockNumberType", "DebugRPC", diff --git a/packages/testing/src/execution_testing/rpc/rpc.py b/packages/testing/src/execution_testing/rpc/rpc.py index 2c8cdcb50b5..7001c0c578a 100644 --- a/packages/testing/src/execution_testing/rpc/rpc.py +++ b/packages/testing/src/execution_testing/rpc/rpc.py @@ -42,6 +42,7 @@ ForkchoiceState, ForkchoiceUpdateResponse, GetBlobsResponse, + GetBlobsV4Response, GetPayloadResponse, JSONRPCRequest, JSONRPCResponse, @@ -1314,21 +1315,35 @@ def get_blobs( versioned_hashes: List[Hash], *, version: int, - ) -> GetBlobsResponse | None: + indices_bitarray: int | None = None, + ) -> GetBlobsResponse | GetBlobsV4Response | None: """ `engine_getBlobsVX`: Retrieves blobs from an execution layers tx pool. + + For `V4` (EIP-8070), `indices_bitarray` is a required 16-byte (uint128) + cell mask selecting which cells to retrieve, encoded as a fixed-width + `DATA` value. """ method = f"getBlobsV{version}" - params = [f"{h}" for h in versioned_hashes] + params: List[Any] = [[f"{h}" for h in versioned_hashes]] + + if version >= 4: + assert indices_bitarray is not None, ( + f"getBlobsV{version} requires an indices_bitarray cell mask." + ) + params.append(f"0x{indices_bitarray.to_bytes(16, 'big').hex()}") response = self.post_request( - request=RPCCall(method=method, params=[params]), + request=RPCCall(method=method, params=params), ).result_or_raise() if response is None: # for tests that request non-existing blobs logger.debug("get_blobs response received but it has value: None") return None - return GetBlobsResponse.model_validate( + response_model = ( + GetBlobsV4Response if version >= 4 else GetBlobsResponse + ) + return response_model.model_validate( response, context=self.response_validation_context, ) diff --git a/packages/testing/src/execution_testing/rpc/rpc_types.py b/packages/testing/src/execution_testing/rpc/rpc_types.py index c9a9fc7d959..99f969de871 100644 --- a/packages/testing/src/execution_testing/rpc/rpc_types.py +++ b/packages/testing/src/execution_testing/rpc/rpc_types.py @@ -331,6 +331,36 @@ def __getitem__( return self.root[index] +class BlobCellsAndProofsV1(CamelModel): + """ + Represents a partial cell matrix for a single blob, as returned by + `engine_getBlobsV4` (EIP-8070: eth/72 - Sparse Blobpool). + + `blob_cells` and `proofs` are partial matrices of length + `CELLS_PER_EXT_BLOB` (128), with `null` entries at indices the client was + not asked for (or does not hold). + """ + + blob_cells: List[Bytes | None] + proofs: List[Bytes | None] + + +class GetBlobsV4Response( + EthereumTestRootModel[List[BlobCellsAndProofsV1 | None]] +): + """Represents the response of an `engine_getBlobsV4` request.""" + + root: List[BlobCellsAndProofsV1 | None] + + def __len__(self) -> int: + """Return the number of blob entries in the response.""" + return len(self.root) + + def __getitem__(self, index: int) -> BlobCellsAndProofsV1 | None: + """Return the blob cell matrix at the given index.""" + return self.root[index] + + class ForkConfigBlobSchedule(CamelModel): """Representation of the blob schedule of a given fork.""" diff --git a/packages/testing/src/execution_testing/specs/blobs.py b/packages/testing/src/execution_testing/specs/blobs.py index 7eb8c8dc20e..30f1f2e9542 100644 --- a/packages/testing/src/execution_testing/specs/blobs.py +++ b/packages/testing/src/execution_testing/specs/blobs.py @@ -24,6 +24,7 @@ class BlobsTest(BaseTest): txs: List[NetworkWrappedTransaction | Transaction] nonexisting_blob_hashes: List[Hash] | None = None get_blobs_version: int | None = None + cell_mask: int | None = None supported_execute_formats: ClassVar[Sequence[LabeledExecuteFormat]] = [ LabeledExecuteFormat( @@ -54,6 +55,7 @@ def execute( txs=self.txs, nonexisting_blob_hashes=self.nonexisting_blob_hashes, get_blobs_version=self.get_blobs_version, + cell_mask=self.cell_mask, ) raise Exception(f"Unsupported execute format: {execute_format}") diff --git a/tests/amsterdam/eip8070_sparse_blobpool/__init__.py b/tests/amsterdam/eip8070_sparse_blobpool/__init__.py new file mode 100644 index 00000000000..7fcdca20cc2 --- /dev/null +++ b/tests/amsterdam/eip8070_sparse_blobpool/__init__.py @@ -0,0 +1,4 @@ +""" +Test suite for +[EIP-8070: eth/72 - Sparse Blobpool](https://eips.ethereum.org/EIPS/eip-8070). +""" diff --git a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py new file mode 100644 index 00000000000..e17b8e35ac9 --- /dev/null +++ b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py @@ -0,0 +1,149 @@ +"""Shared fixtures for building blob transactions in EIP-8070 tests.""" + +from typing import List, Optional + +import pytest +from execution_testing import ( + Address, + Alloc, + Blob, + Fork, + NetworkWrappedTransaction, + Transaction, + TransactionException, +) + + +@pytest.fixture +def destination_account(pre: Alloc) -> Address: + """Destination account for the blob transactions.""" + return pre.fund_eoa(amount=0) + + +@pytest.fixture +def tx_value() -> int: + """Value contained by the transactions sent during test.""" + return 1 + + +@pytest.fixture +def tx_gas() -> int: + """Gas allocated to transactions sent during test.""" + return 21_000 + + +@pytest.fixture +def block_base_fee_per_gas() -> int: + """Return default max fee per gas for transactions sent during test.""" + return 7 + + +@pytest.fixture +def tx_calldata() -> bytes: + """Calldata in transactions sent during test.""" + return b"" + + +@pytest.fixture(autouse=True) +def parent_excess_blobs() -> int: + """Excess blobs of the parent block (defaults to a blob gas price of 1).""" + return 10 + + +@pytest.fixture(autouse=True) +def parent_blobs() -> int: + """Blobs of the parent block.""" + return 0 + + +@pytest.fixture +def excess_blob_gas( + fork: Fork, + parent_excess_blobs: int | None, + parent_blobs: int | None, + block_base_fee_per_gas: int, +) -> int | None: + """Calculate the excess blob gas of the block under test.""" + if parent_excess_blobs is None or parent_blobs is None: + return None + excess_blob_gas = fork.excess_blob_gas_calculator() + return excess_blob_gas( + parent_excess_blobs=parent_excess_blobs, + parent_blob_count=parent_blobs, + parent_base_fee_per_gas=block_base_fee_per_gas, + ) + + +@pytest.fixture +def blob_gas_price( + fork: Fork, + excess_blob_gas: int | None, +) -> int | None: + """Return blob gas price for the block of the test.""" + if excess_blob_gas is None: + return None + get_blob_gas_price = fork.blob_gas_price_calculator() + return get_blob_gas_price(excess_blob_gas=excess_blob_gas) + + +@pytest.fixture +def txs_versioned_hashes(txs_blobs: List[List[Blob]]) -> List[List[bytes]]: + """List of blob versioned hashes derived from the blobs.""" + return [[blob.versioned_hash for blob in blob_tx] for blob_tx in txs_blobs] + + +@pytest.fixture +def tx_max_fee_per_blob_gas(blob_gas_price: Optional[int]) -> int: + """Max fee per blob gas for transactions sent during test.""" + if blob_gas_price is None: + # When fork transitioning, the default blob gas price is 1. + return 1 + return blob_gas_price + + +@pytest.fixture +def tx_error() -> Optional[TransactionException]: + """No transaction is expected to be rejected by the transition tool.""" + return None + + +@pytest.fixture(autouse=True) +def txs( + pre: Alloc, + destination_account: Optional[Address], + tx_gas: int, + tx_value: int, + tx_calldata: bytes, + tx_max_fee_per_blob_gas: int, + txs_versioned_hashes: List[List[bytes]], + tx_error: Optional[TransactionException], + txs_blobs: List[List[Blob]], + fork: Fork, +) -> List[NetworkWrappedTransaction | Transaction]: + """Prepare the list of transactions that are sent during the test.""" + if len(txs_blobs) != len(txs_versioned_hashes): + raise ValueError( + "txs_blobs and txs_versioned_hashes should have the same length" + ) + txs: List[NetworkWrappedTransaction | Transaction] = [] + for tx_blobs, tx_versioned_hashes in zip( + txs_blobs, txs_versioned_hashes, strict=False + ): + tx = Transaction( + sender=pre.fund_eoa(), + to=destination_account, + value=tx_value, + gas_limit=tx_gas, + data=tx_calldata, + max_fee_per_blob_gas=tx_max_fee_per_blob_gas, + access_list=[], + blob_versioned_hashes=tx_versioned_hashes, + error=tx_error, + ) + network_wrapped_tx = NetworkWrappedTransaction( + tx=tx, + blob_objects=tx_blobs, + wrapper_version=fork.full_blob_tx_wrapper_version(), + ) + txs.append(network_wrapped_tx) + return txs diff --git a/tests/amsterdam/eip8070_sparse_blobpool/spec.py b/tests/amsterdam/eip8070_sparse_blobpool/spec.py new file mode 100644 index 00000000000..191a12b1fc3 --- /dev/null +++ b/tests/amsterdam/eip8070_sparse_blobpool/spec.py @@ -0,0 +1,31 @@ +"""Defines EIP-8070 specification constants and functions.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +# TODO: update `version` to the EIP-8070 commit hash once the draft stabilizes. +ref_spec_8070 = ReferenceSpec( + "EIPS/eip-8070.md", "0000000000000000000000000000000000000000" +) + + +@dataclass(frozen=True) +class Spec: + """ + Parameters from the EIP-8070 specification as defined at + https://eips.ethereum.org/EIPS/eip-8070. + """ + + # Number of cells in an extended blob; the `cell_mask` / `indices_bitarray` + # is a uint128 bitarray of this length. + CELLS_PER_EXT_BLOB = 128 + # Cells required for Reed-Solomon reconstruction of a blob. + RECONSTRUCTION_THRESHOLD = 64 diff --git a/tests/amsterdam/eip8070_sparse_blobpool/test_get_cells.py b/tests/amsterdam/eip8070_sparse_blobpool/test_get_cells.py new file mode 100644 index 00000000000..0e0ca814a49 --- /dev/null +++ b/tests/amsterdam/eip8070_sparse_blobpool/test_get_cells.py @@ -0,0 +1,130 @@ +""" +Get cells engine endpoint tests. + +Tests for the `engine_getBlobsV4` endpoint in [EIP-8070: eth/72 - Sparse +Blobpool](https://eips.ethereum.org/EIPS/eip-8070). + +`engine_getBlobsV4` retrieves a custody-aligned subset of a blob's cells, +selected by a `uint128` `indices_bitarray` cell mask, and returns a partial +cell matrix with `null` entries for cells that were not requested or are not +held by the client. +""" + +from hashlib import sha256 +from typing import List + +import pytest +from execution_testing import ( + Alloc, + Blob, + BlobsTestFiller, + Fork, + Hash, + NetworkWrappedTransaction, + Transaction, +) + +from .spec import Spec, ref_spec_8070 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8070.git_path +REFERENCE_SPEC_VERSION = ref_spec_8070.version + +CELLS = Spec.CELLS_PER_EXT_BLOB +ALL_CELLS_MASK = (1 << CELLS) - 1 + + +def generate_blob_layouts(fork: Fork) -> List: + """Return blob transaction layouts to exercise `getBlobsV4`.""" + max_blobs_per_tx = fork.max_blobs_per_tx() + return [ + pytest.param( + [[Blob.from_fork(fork)]], + id="single_blob_transaction", + ), + pytest.param( + [[Blob.from_fork(fork, s) for s in range(max_blobs_per_tx)]], + id="max_blobs_per_tx", + ), + ] + + +@pytest.mark.parametrize( + "cell_mask", + [ + pytest.param(ALL_CELLS_MASK, id="all_cells"), + pytest.param((1 << Spec.RECONSTRUCTION_THRESHOLD) - 1, id="first_64"), + pytest.param(0xFF, id="custody_aligned_8"), + pytest.param(1, id="single_cell"), + pytest.param( + sum(1 << i for i in range(0, CELLS, 2)), id="alternating_cells" + ), + ], +) +@pytest.mark.parametrize_by_fork("txs_blobs", generate_blob_layouts) +@pytest.mark.exception_test +@pytest.mark.valid_from("Amsterdam") +def test_get_cells( + blobs_test: BlobsTestFiller, + pre: Alloc, + txs: List[NetworkWrappedTransaction | Transaction], + cell_mask: int, +) -> None: + """ + Test that `getBlobsV4` returns exactly the cells selected by the mask. + + Requested cells (and their proofs) must match the locally computed values; + non-requested cell indices must be `null` in the partial matrix. + """ + blobs_test( + pre=pre, + txs=txs, + get_blobs_version=4, + cell_mask=cell_mask, + ) + + +@pytest.mark.parametrize_by_fork("txs_blobs", generate_blob_layouts) +@pytest.mark.exception_test +@pytest.mark.valid_from("Amsterdam") +def test_get_cells_partial_and_missing( + blobs_test: BlobsTestFiller, + pre: Alloc, + txs: List[NetworkWrappedTransaction | Transaction], +) -> None: + """ + Test that `getBlobsV4` returns a partial response: existing blobs yield a + cell matrix while non-existing versioned hashes yield `null` entries. + """ + nonexisting_blob_hashes = [ + Hash(sha256(str(i).encode()).digest()) for i in range(5) + ] + blobs_test( + pre=pre, + txs=txs, + get_blobs_version=4, + cell_mask=ALL_CELLS_MASK, + nonexisting_blob_hashes=nonexisting_blob_hashes, + ) + + +@pytest.mark.parametrize("txs_blobs", [[]], ids=["no_blobs"]) +@pytest.mark.exception_test +@pytest.mark.valid_from("Amsterdam") +def test_get_cells_only_nonexisting( + blobs_test: BlobsTestFiller, + pre: Alloc, +) -> None: + """ + Test that `getBlobsV4` returns an array of `null` entries (one per + requested hash) when all requested blobs are non-existing. + """ + nonexisting_blob_hashes = [ + Hash(sha256(str(i).encode()).digest()) for i in range(5) + ] + blobs_test( + pre=pre, + txs=[], + get_blobs_version=4, + cell_mask=ALL_CELLS_MASK, + nonexisting_blob_hashes=nonexisting_blob_hashes, + )