Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 61 additions & 42 deletions packages/testing/src/execution_testing/execution/blob_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ class BlobTransaction(BaseExecute):
interleave_nonexisting_blob_hashes: bool = False
get_blobs_version: int | None = None
cell_mask: int | None = None
custody_columns: bytes | None = None
custody_columns_updates: List[bytes | None] | None = None

def prepare_transactions(
self,
Expand Down Expand Up @@ -299,21 +299,24 @@ def get_required_sender_balances(
balances[sender] += tx.signer_minimum_balance(fork=fork)
return balances

def _update_custody_columns(
def _send_custody_columns_updates(
self,
fork: Fork,
eth_rpc: EthRPC,
engine_rpc: EngineRPC,
) -> None:
"""
Send a forkchoice update carrying the `custodyColumns` bitmap.
Send one forkchoice update per entry of `custody_columns_updates`.

A 16-byte bitmap must be accepted with a VALID payload status
(custody set update errors must not affect the forkchoice flow,
per `engine_forkchoiceUpdatedV4`); any other length must be
rejected with `-32602: Invalid params`.
A 16-byte bitmap or an explicit `null` must be accepted with a
VALID payload status (`null` and a bitmap identical to the current
set are blobpool no-ops, and custody set update errors must not
affect the forkchoice flow, per `engine_forkchoiceUpdatedV4`); any
other length must be rejected with `-32602: Invalid params`.
"""
assert self.custody_columns is not None
assert self.custody_columns_updates, (
"custody_columns_updates must hold at least one update."
)
fcu_version = fork.engine_forkchoice_updated_version()
assert fcu_version is not None and fcu_version >= 4, (
"custodyColumns requires engine_forkchoiceUpdatedV4."
Expand All @@ -323,39 +326,55 @@ def _update_custody_columns(
forkchoice_state = ForkchoiceState(
head_block_hash=Hash(latest_block["hash"]),
)
valid_length = len(self.custody_columns) == CUSTODY_COLUMNS_BYTE_LENGTH
try:
response = engine_rpc.forkchoice_updated(
forkchoice_state,
None,
version=fcu_version,
custody_columns=self.custody_columns,
)
except JSONRPCError as e:
if valid_length:
raise
if e.code != -32602:
for custody_columns in self.custody_columns_updates:
if custody_columns is None:
response = engine_rpc.forkchoice_updated(
forkchoice_state,
None,
version=fcu_version,
custody_columns_null=True,
)
status = response.payload_status.status
if status != PayloadStatusEnum.VALID:
raise ValueError(
f"forkchoiceUpdatedV{fcu_version} with a null "
f"custodyColumns returned payload status {status}, "
"expected VALID."
)
continue
valid_length = len(custody_columns) == CUSTODY_COLUMNS_BYTE_LENGTH
try:
response = engine_rpc.forkchoice_updated(
forkchoice_state,
None,
version=fcu_version,
custody_columns=custody_columns,
)
except JSONRPCError as e:
if valid_length:
raise
if e.code != -32602:
raise ValueError(
f"Expected error -32602 (Invalid params) for a "
f"{len(custody_columns)}-byte custodyColumns, "
f"got {e.code}: {e.message}"
) from e
logger.info(
f"Client correctly rejected a "
f"{len(custody_columns)}-byte custodyColumns bitmap."
)
continue
if not valid_length:
raise ValueError(
f"Expected error -32602 (Invalid params) for a "
f"{len(self.custody_columns)}-byte custodyColumns, "
f"got {e.code}: {e.message}"
) from e
logger.info(
f"Client correctly rejected a "
f"{len(self.custody_columns)}-byte custodyColumns bitmap."
)
return
if not valid_length:
raise ValueError(
f"Client accepted a {len(self.custody_columns)}-byte "
"custodyColumns bitmap; expected -32602 (Invalid params)."
)
status = response.payload_status.status
if status != PayloadStatusEnum.VALID:
raise ValueError(
f"forkchoiceUpdatedV{fcu_version} with custodyColumns "
f"returned payload status {status}, expected VALID."
)
f"Client accepted a {len(custody_columns)}-byte "
"custodyColumns bitmap; expected -32602 (Invalid params)."
)
status = response.payload_status.status
if status != PayloadStatusEnum.VALID:
raise ValueError(
f"forkchoiceUpdatedV{fcu_version} with custodyColumns "
f"returned payload status {status}, expected VALID."
)

def execute(
self,
Expand Down Expand Up @@ -429,8 +448,8 @@ def execute(
else:
list_versioned_hashes.extend(self.nonexisting_blob_hashes)

if self.custody_columns is not None:
self._update_custody_columns(fork, eth_rpc, engine_rpc)
if self.custody_columns_updates is not None:
self._send_custody_columns_updates(fork, eth_rpc, engine_rpc)

indices_bitarray = self.cell_mask if version >= 4 else None
blob_response: GetBlobsResponse | GetBlobsV4Response | None = (
Expand Down
11 changes: 11 additions & 0 deletions packages/testing/src/execution_testing/rpc/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1494,12 +1494,20 @@ def forkchoice_updated(
*,
version: int,
custody_columns: bytes | None = None,
custody_columns_null: bool = False,
) -> ForkchoiceUpdateResponse:
"""
`engine_forkchoiceUpdatedVX`: Updates the forkchoice state of the
execution client.

`custody_columns_null` sends an explicit `null` as the
`custodyColumns` parameter (EIP-8070: the client treats it as a
no-op for its blobpool).
"""
method = f"forkchoiceUpdatedV{version}"
assert custody_columns is None or not custody_columns_null, (
"Pass either custody_columns or custody_columns_null, not both."
)

params: List[Any]
if payload_attributes is None:
Expand All @@ -1511,6 +1519,9 @@ def forkchoice_updated(
# a bitmap of the blob columns custodied by the node.
assert version >= 4, "custodyColumns requires forkchoiceUpdatedV4."
params.append(f"0x{custody_columns.hex()}")
elif custody_columns_null:
assert version >= 4, "custodyColumns requires forkchoiceUpdatedV4."
params.append(None)

return ForkchoiceUpdateResponse.model_validate(
self.post_request(
Expand Down
25 changes: 23 additions & 2 deletions packages/testing/src/execution_testing/specs/blobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from typing import Callable, ClassVar, Generator, List, Sequence, Type

from pydantic import model_validator
from typing_extensions import Self

from execution_testing.base_types import Alloc
from execution_testing.base_types.base_types import Hash
from execution_testing.client_clis import TransitionTool
Expand All @@ -27,7 +30,25 @@ class BlobsTest(BaseTest):
interleave_nonexisting_blob_hashes: bool = False
get_blobs_version: int | None = None
cell_mask: int | None = None
custody_columns: bytes | None = None
custody_columns_updates: List[bytes | None] | None = None
"""
`custodyColumns` values to send in order, one
`engine_forkchoiceUpdatedV4` each, before `engine_getBlobsV*`: a bitmap
sends that value and `None` sends an explicit `null`. Leave unset to
send no update.
"""

@model_validator(mode="after")
def _check_custody_columns_updates(self) -> Self:
"""Reject an empty update list, which would send nothing."""
if self.custody_columns_updates is not None and not (
self.custody_columns_updates
):
raise ValueError(
"custody_columns_updates must hold at least one update; "
"leave it unset to send no custodyColumns update."
)
return self

supported_execute_formats: ClassVar[Sequence[LabeledExecuteFormat]] = [
LabeledExecuteFormat(
Expand Down Expand Up @@ -62,7 +83,7 @@ def execute(
),
get_blobs_version=self.get_blobs_version,
cell_mask=self.cell_mask,
custody_columns=self.custody_columns,
custody_columns_updates=self.custody_columns_updates,
)
raise Exception(f"Unsupported execute format: {execute_format}")

Expand Down
23 changes: 22 additions & 1 deletion packages/testing/src/execution_testing/specs/tests/test_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
LabeledFixtureFormat,
StateFixture,
)
from execution_testing.forks import Istanbul
from execution_testing.forks import Amsterdam, Istanbul
from execution_testing.test_types import Alloc, Environment, Transaction

from ..base import BaseTest
from ..blobs import BlobsTest
from ..blockchain import BlockchainTest
from ..state import StateTest

Expand Down Expand Up @@ -199,3 +200,23 @@ def test_state_test_conversion_checks_the_env_first() -> None:
)
with pytest.raises(ValueError, match="excess_blob_gas"):
state_test.generate_blockchain_test()


def test_blobs_test_rejects_empty_custody_columns_updates() -> None:
"""
Verify a blobs test accepts any non-empty sequence of `custodyColumns`
updates, with `None` for an explicit `null`, and rejects an empty one,
which would send nothing while looking like a request to.
"""
bitmap = b"\xff" * 16
for updates in ([bitmap], [None], [bitmap, None], [bitmap, bitmap]):
BlobsTest(
pre=Alloc(),
txs=[],
fork=Amsterdam,
custody_columns_updates=updates,
)
with pytest.raises(ValueError, match="custody_columns_updates"):
BlobsTest(
pre=Alloc(), txs=[], fork=Amsterdam, custody_columns_updates=[]
)
2 changes: 1 addition & 1 deletion tests/amsterdam/eip8070_sparse_blobpool/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class ReferenceSpec:


ref_spec_8070 = ReferenceSpec(
"EIPS/eip-8070.md", "43c7af020f1641924bed60565fde86f6ad3469df"
"EIPS/eip-8070.md", "5f1154bdeba6fb473a49dbbb430383fa71bc5b3f"
)


Expand Down
79 changes: 68 additions & 11 deletions tests/amsterdam/eip8070_sparse_blobpool/test_custody_columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@
`custodyColumns` is an optional 16-byte bitmap informing the execution
client of the blob columns it must custody. A well-formed bitmap must be
accepted (custody set update errors must not affect the forkchoice flow);
a bitmap of any other length must be rejected with `-32602: Invalid
params`. Blob serving via `engine_getBlobsV4` must be unaffected either
way, since the client holds the full blobs.
a `null` value or a bitmap identical to the current set is a blobpool
no-op and must also be accepted; a bitmap of any other length must be
rejected with `-32602: Invalid params`. Blob serving via
`engine_getBlobsV4` must be unaffected either way, since the client
holds the full blobs.

Each test sends its `custodyColumns` values in order, one forkchoice
update each, before requesting the blobs. The custody set itself only
steers devp2p sampling of peer-announced transactions and is not
observable through the Engine API, so these tests pin acceptance and
unaffected blob serving, not the resulting custody set.
"""

from typing import List
Expand All @@ -35,6 +43,9 @@
CELLS = Spec.CELLS_PER_EXT_BLOB
ALL_CELLS_MASK = (1 << CELLS) - 1
BITMAP_BYTES = Spec.CUSTODY_BITMAP_BYTES
CUSTODY_ALIGNED_8 = ((1 << Spec.SAMPLES_PER_SLOT) - 1).to_bytes(
BITMAP_BYTES, "little"
)


def generate_single_blob_layout(fork: Fork) -> List:
Expand All @@ -48,12 +59,7 @@ def generate_single_blob_layout(fork: Fork) -> List:
"custody_columns",
[
pytest.param(b"\xff" * BITMAP_BYTES, id="all_columns"),
pytest.param(
((1 << Spec.SAMPLES_PER_SLOT) - 1).to_bytes(
BITMAP_BYTES, "little"
),
id="custody_aligned_8",
),
pytest.param(CUSTODY_ALIGNED_8, id="custody_aligned_8"),
pytest.param(b"\x00" * BITMAP_BYTES, id="no_columns"),
],
)
Expand All @@ -75,7 +81,58 @@ def test_fcu_custody_columns(
txs=txs,
get_blobs_version=4,
cell_mask=ALL_CELLS_MASK,
custody_columns=custody_columns,
custody_columns_updates=[custody_columns],
)


@pytest.mark.parametrize(
"custody_columns_updates",
[
pytest.param([None], id="fresh"),
pytest.param([CUSTODY_ALIGNED_8, None], id="after_custody_set"),
],
)
@pytest.mark.parametrize_by_fork("txs_blobs", generate_single_blob_layout)
@pytest.mark.exception_test
def test_fcu_custody_columns_null(
blobs_test: BlobsTestFiller,
pre: Alloc,
txs: List[NetworkWrappedTransaction | Transaction],
custody_columns_updates: List[bytes | None],
) -> None:
"""
Test that `engine_forkchoiceUpdatedV4` accepts a `null`
`custodyColumns` with a VALID payload status, both on a client with no
custody set and after one was configured, and that `getBlobsV4` still
serves every cell of the pending transaction afterwards.
"""
blobs_test(
pre=pre,
txs=txs,
get_blobs_version=4,
cell_mask=ALL_CELLS_MASK,
custody_columns_updates=custody_columns_updates,
)


@pytest.mark.parametrize_by_fork("txs_blobs", generate_single_blob_layout)
@pytest.mark.exception_test
def test_fcu_custody_columns_identical(
blobs_test: BlobsTestFiller,
pre: Alloc,
txs: List[NetworkWrappedTransaction | Transaction],
) -> None:
"""
Test that resending the current custody set, the other no-op case the
EIP lists alongside `null`, is accepted with a VALID payload status
and that `getBlobsV4` still serves every cell afterwards.
"""
blobs_test(
pre=pre,
txs=txs,
get_blobs_version=4,
cell_mask=ALL_CELLS_MASK,
custody_columns_updates=[CUSTODY_ALIGNED_8, CUSTODY_ALIGNED_8],
)


Expand Down Expand Up @@ -104,5 +161,5 @@ def test_fcu_custody_columns_invalid_length(
txs=txs,
get_blobs_version=4,
cell_mask=ALL_CELLS_MASK,
custody_columns=custody_columns,
custody_columns_updates=[custody_columns],
)
Loading