Skip to content
Open
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
27 changes: 27 additions & 0 deletions docs/filling_tests/filling_tests_command_line.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,33 @@ This flag automatically performs a two-phase execution:
uv run fill --generate-all-formats --output=fixtures.tar.gz tests/shanghai/
```

## The Sync Block

By default the filler adds one framework-built empty block to every blockchain test's chain **in `blockchain_test_engine_x` fixtures only**; every other fixture format's chains are exactly what the test defines. `--no-sync-block` disables it:

```console
uv run fill --no-sync-block --generate-all-formats tests/cancun/
```

A consumer that makes the client download and execute a test's own blocks over devp2p needs the client to actually sync, and two facts about the protocol decide what such a consumer can guarantee: a client only starts a sync when the announced head's parent is unknown to it, and only blocks *below* the head must travel devp2p - the head's payload is always delivered through `engine_newPayload`, and whether a client also re-fetches it from a peer is an implementation choice. The sync block's placement is therefore resolved per test from the chain's own structure:

| Chain class | Sequence | Placement | Fixture representation |
| ----------- | -------- | --------- | ---------------------- |
| Fully valid (single or multi-block) | `G → T₁…Tₙ → S*` | **appended** above the head | out-of-chain, the fixture's `syncPayload` field |
| Single expected-invalid or Engine API-refused block | `G → S → T₁*` | **prepended** below it | in-chain, `engineNewPayloads[0]` tagged `"phase": "sync"` |
| Invalid multi-block | `G → T₁…Tₙᵢ*` | none | - |

(`*` marks the block a sync-based consumer announces; `ᵢ` the intentionally invalid block.)

For a valid chain the appended block makes every one of the test's own blocks an ancestor of the announced head, which a syncing client must fetch and execute through its sync pipeline - the test's content is wire-guaranteed by chain structure, on any client. The fixture's payload list, `lastblockhash` and post state keep describing exactly the chain the test author wrote; the trailer rides out-of-chain in `syncPayload`, the same representation [`consume sync`](../running_tests/running.md#sync)'s fixture format has always used, and consumers that replay payloads through the Engine API ignore it. For a single invalid block nothing can be built on top, so the extra block lands below, giving the sync a reason to start before the client judges the announced head; there it is load-bearing ancestry, so it lives in-chain and every consumer replays it. An invalid multi-block chain needs no help: its valid ancestors already travel the wire.

The sync block is a real block, built through the same machinery as every other block, and carries a per-test digest in its `extra_data` so every announced head is a block the client has never seen, even across tests sharing a pre-allocation group. Prepend-class chains get their genesis fee fields wound one progression step up to cancel the step the extra block introduces, so the test's own block executes in the fee environment its author specified; append-class chains need no compensation at all. Timestamps are never shifted: a prepend-class test pinning a timestamp the extra block cannot clear fails the fill instead of producing a non-monotonic chain.

!!! note "Only engine_x fixtures are affected"
Prepending shifts the invalid singleton's block number and hash, so engine_x fixtures filled with and without the option are not comparable. The other blockchain formats never carry the extra block: they share a positional `t8n` output cache and must build byte-identical chains, while engine_x fixtures opt out of that cache and pay no extra `t8n` work for the divergence.

Spec types the extra block would distort opt out and are filled without it (benchmark tests), so a combined fill needs no extra options. Tests that cannot take their class's placement are marked in the tree and fill without the extra block instead of being skipped - no test leaves the fixture release; sync-based consumers skip chains that cannot sync at consume time. See [`absolute_block_position`](../writing_tests/test_markers.md#pytestmarkabsolute_block_position) and its sibling markers.

## Debugging the `t8n` Command

The `--evm-dump-dir` flag can be used to dump the inputs and outputs of every call made to the `t8n` command for debugging purposes, see [Debugging Transition Tools](./debugging_t8n_tools.md).
Expand Down
30 changes: 30 additions & 0 deletions docs/writing_tests/test_markers.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,36 @@ Examples of this include:
- Contracts having zero-nonce
- Deploying a contract to a hard-coded address

### `@pytest.mark.absolute_block_position`

This marker is used to mark tests whose logic or expectations depend on absolute block numbers or block hashes, e.g. `BLOCKHASH` lookups with static expectations, storage keyed by `NUMBER`, or EIP-2935 history contents.

An invalid singleton so marked fills without the [prepended sync block](../filling_tests/filling_tests_command_line.md#the-sync-block): the prepended block shifts every block position, and the expectations are derived from the position rather than merely equal to it, so no fill transformation can preserve what the test verifies. The marker is irrelevant to valid chains - their sync block is appended above the head and shifts nothing - and only affects `blockchain_test_engine_x` fixtures (the only format that carries the extra block); the test itself always fills.

### `@pytest.mark.pre_state_affects_empty_block`

This marker is used to mark tests whose pre-state or genesis environment changes what *any* block executes, for example a deliberately broken system contract that every post-Prague block calls, one-shot pre-state that an extra block would consume, or a genesis gas limit too small for a minimal block's own system work.

An invalid singleton so marked fills without the prepended sync block: the extra block would either fail on that setup or consume its one-shot behavior before the test's own block runs.

### `@pytest.mark.no_empty_block_fee_preimage`

This marker is used to mark tests pinning a fee value that no parent value decays to across an empty block, e.g. a small nonzero excess blob gas while EIP-7918's reserve price is active.

An invalid singleton so marked fills without the prepended sync block: no genesis compensation preserves its fee environment, so the fill would otherwise refuse it loudly. Valid chains never need this marker - their genesis is never compensated.

### `@pytest.mark.post_state_affects_sync_block`

This marker is used to mark tests whose *final* state breaks the empty block the filler would append above their chain, for example a test that ends with a sabotaged system contract that every subsequent block calls, or with one-shot state the extra block would consume.

A valid chain so marked fills as exactly the author's chain, without the appended sync block (`syncPayload` is absent from its fixture). This is the mirror image of `pre_state_affects_empty_block` on the other side of the chain; it is irrelevant to invalid singletons, whose prepended block never sees the test's post-state.

### `@pytest.mark.no_sync_block_timestamp_headroom`

This marker is used to mark tests whose head pins a timestamp at or next to the uint64 ceiling (`2**64 - 1`), for example the EIP-4788 beacon-root tests that exercise exactly that boundary. The appended sync block takes its parent's timestamp plus the default increment, so above such a head its timestamp would not fit uint64 and no client could parse the fixture's `syncPayload`.

A valid chain so marked fills as exactly the author's chain, without the appended sync block; the fill refuses loudly (naming this marker) when an unmarked test hits the boundary. Timestamps are semantic and are never clamped or shifted, on either side of the chain. The marker is irrelevant to invalid singletons, whose prepended block sits below the chain.

### `@pytest.mark.skip()`

This marker can be used to skip a test.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Self, Set, Type
from typing import (
TYPE_CHECKING,
Any,
Dict,
FrozenSet,
Generator,
List,
Self,
Set,
Type,
)

import pytest
import xdist
Expand Down Expand Up @@ -79,6 +89,10 @@
)
from execution_testing.specs import BaseTest
from execution_testing.specs.base import FillResult, OpMode
from execution_testing.specs.blockchain import (
APPEND_SYNC_BLOCK_INELIGIBILITY_MARKERS,
PREPEND_SYNC_BLOCK_INELIGIBILITY_MARKERS,
)
from execution_testing.test_types import EnvironmentDefaults
from execution_testing.test_types.chain_config_types import (
DEFAULT_CHAIN_ID,
Expand Down Expand Up @@ -107,6 +121,16 @@
if TYPE_CHECKING:
from .pre_alloc import Alloc

SYNC_BLOCK_INELIGIBILITY_MARKERS: FrozenSet[str] = (
PREPEND_SYNC_BLOCK_INELIGIBILITY_MARKERS
| APPEND_SYNC_BLOCK_INELIGIBILITY_MARKERS
)
"""
All sync-block ineligibility markers the filler collects from a test
node. Each vetoes one placement in the spec's policy resolution; the
spec knows which marker belongs to which placement.
"""

# Fixture output dir for keyboard interrupt cleanup (set in pytest_configure).
# Used by _merge_on_exit to merge partial JSONL files on Ctrl+C or SIGTERM.
_fixture_output_dir: Path | None = None
Expand Down Expand Up @@ -623,6 +647,39 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=False,
help="Fill tests using existing pre-allocation groups (phase 2 only).",
)
test_group.addoption(
"--sync-block",
action="store_true",
dest="sync_block",
default=True,
help=(
"Add one framework-built empty block to every blockchain "
"test's chain for fixture formats that opt in "
"(blockchain_test_engine_x), so that sync-based consumers "
"can trigger a devp2p sync. The block's placement is "
"resolved per test from the chain's structure: appended "
"above a fully valid chain's head (stored out-of-chain in "
"the fixture's `syncPayload` field, making every test "
"block a wire-guaranteed ancestor), prepended below a "
"single expected-invalid or Engine API-refused block "
"(stored in-chain as the first payload, tagged with the "
"`sync` phase), and omitted otherwise. Spec types that "
"opt out (benchmark tests) are filled without it. On by "
"default; --no-sync-block disables it. Prepending shifts "
"the invalid singleton's number and hash, so those "
"fixtures are not comparable with fixtures filled "
"without the option."
),
)
test_group.addoption(
"--no-sync-block",
action="store_false",
dest="sync_block",
help=(
"Do not add sync blocks to any fixture format; every "
"chain is built exactly as the test defines it."
),
)
test_group.addoption(
"--generate-all-formats",
action="store_true",
Expand Down Expand Up @@ -1504,6 +1561,23 @@ def _strip_xdist_group_suffix(s: str) -> str:
return s


def _node_id_without_xdist_group(nodeid: str) -> str:
"""
Return the node id without any xdist group suffix.

Under ``--dist=loadgroup`` the xdist worker appends ``@<group>`` to
every grouped item's node id, so anything derived from the raw id
depends on whether the fill ran in parallel. Every group name the
fill sets is a bare word, while a parametrized node id always ends
in ``]``, so a trailing ``@`` segment without one is a group name
and never part of the test's own id.
"""
base, separator, suffix = nodeid.rpartition("@")
if separator and base and "]" not in suffix:
return base
return nodeid


def node_to_test_info(node: pytest.Item) -> TestInfo:
"""Return test info of the current node item."""
# Strip xdist group suffix (@groupname) that may be added during execution.
Expand Down Expand Up @@ -1614,6 +1688,37 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
kwargs["fork"] = fork
op_mode: OpMode = request.config.op_mode # type: ignore
kwargs["operation_mode"] = op_mode
# The sync block only applies to fixture formats that
# opt in (sync-based consumers need it); it is further
# withheld from spec types that measure per-block and
# from any session that is measuring. Where the block
# lands in an eligible test's chain - appended,
# prepended, or not at all - is resolved by the spec
# itself from the chain's structure.
kwargs["sync_block"] = (
request.config.getoption("sync_block", False)
and fixture_format.sync_block
and cls.supports_sync_block
and op_mode != OpMode.BENCHMARKING
)
# Each ineligibility marker vetoes one sync-block
# placement in the spec's policy resolution; a marked
# test fills without the extra block rather than being
# skipped, so no test ever leaves the fixture release.
kwargs["sync_block_ineligibilities"] = frozenset(
marker
for marker in SYNC_BLOCK_INELIGIBILITY_MARKERS
if request.node.get_closest_marker(marker) is not None
)
# Salt with the test's own id, not with the raw node
# id: the fixture format and the xdist group suffix
# both ride along in the latter, and every format of
# one test must build the same chain (they share a
# t8n output cache) whether or not the fill ran in
# parallel.
kwargs["sync_block_salt"] = _node_id_without_xdist_group(
strip_fixture_format_from_node(request.node)
)
kwargs["is_tx_gas_heavy_test"] = is_tx_gas_heavy_test
kwargs["is_exception_test"] = is_exception_test
if (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""
Test that filling fails loudly when an expected-invalid block's
``rlp_modifier`` does not actually change the header.

A modifier that pins a header field to the value the header already
holds produces a block that is valid while the fixture claims it is
invalid. This can happen silently when the chain context shifts under
a test, e.g. when a fee progression is moved by the prepended sync
block, so the fill must refuse instead of emitting the fixture.

The refusal only applies when every expected exception is a block
exception: a block carrying an invalid transaction is invalid
regardless of its header, and the state test conversion routinely
pins header fields to values that legitimately match the computed
ones.
"""

import textwrap
from typing import Any

noop_rlp_modifier_test_module = textwrap.dedent(
"""\
from execution_testing import Block
from execution_testing.exceptions.exceptions import BlockException
from execution_testing.specs.blockchain import Header


def test_noop_rlp_modifier(blockchain_test, pre) -> None:
blockchain_test(
pre=pre,
post={},
blocks=[
Block(
timestamp=12,
rlp_modifier=Header(timestamp=12),
exception=BlockException.INVALID_BLOCK_HASH,
)
],
)
"""
)


noop_modifier_tx_exception_test_module = textwrap.dedent(
"""\
import pytest

from execution_testing import Block, Transaction
from execution_testing.exceptions.exceptions import (
TransactionException,
)
from execution_testing.specs.blockchain import Header


@pytest.mark.exception_test
def test_noop_rlp_modifier_tx_exception(blockchain_test, pre) -> None:
tx = Transaction(
to=0,
gas_limit=21_000,
gas_price=10**9,
sender=pre.fund_eoa(amount=1),
error=TransactionException.INSUFFICIENT_ACCOUNT_FUNDS,
)
blockchain_test(
pre=pre,
post={},
blocks=[
Block(
timestamp=12,
txs=[tx],
rlp_modifier=Header(timestamp=12),
exception=(
TransactionException.INSUFFICIENT_ACCOUNT_FUNDS
),
)
],
)
"""
)


def test_fill_rejects_noop_rlp_modifier_on_invalid_block(
pytester: Any, capsys: Any, pytestconfig: Any
) -> None:
"""A no-op modifier on an expected-invalid block must fail the fill."""
tests_dir = pytester.mkdir("tests")
cancun_tests_dir = tests_dir / "cancun"
cancun_tests_dir.mkdir()
module_dir = cancun_tests_dir / "noop_rlp_modifier_module"
module_dir.mkdir()
test_module = module_dir / "test_noop_rlp_modifier.py"
test_module.write_text(noop_rlp_modifier_test_module)

pytester.copy_example(
name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini"
)

result = pytester.runpytest_subprocess(
"-c",
"pytest-fill.ini",
"--fork",
"Cancun",
"-m",
"blockchain_test",
"--no-html",
"--output=stdout",
str(test_module.relative_to(pytester.path)),
)
# Suppress the expected inner pytest failure output from the outer test
capsys.readouterr()

assert result.ret != 0, "Fill command was expected to fail"

output = "\n".join(result.outlines + result.errlines)
expected_message = "`rlp_modifier` changed nothing"
assert expected_message in output

error_line = next(
line for line in output.splitlines() if expected_message in line
)
# show print but only when -s is passed
if pytestconfig.getoption("capture") == "no":
with capsys.disabled():
print(error_line)


def test_fill_accepts_noop_rlp_modifier_on_tx_exception_block(
pytester: Any,
) -> None:
"""
A no-op modifier on a block whose invalidity comes from a
transaction must fill: the block is invalid regardless of its
header.
"""
tests_dir = pytester.mkdir("tests")
cancun_tests_dir = tests_dir / "cancun"
cancun_tests_dir.mkdir()
module_dir = cancun_tests_dir / "noop_rlp_modifier_tx_module"
module_dir.mkdir()
test_module = module_dir / "test_noop_rlp_modifier_tx_exception.py"
test_module.write_text(noop_modifier_tx_exception_test_module)

pytester.copy_example(
name="src/execution_testing/cli/pytest_commands/pytest_ini_files/pytest-fill.ini"
)

result = pytester.runpytest_subprocess(
"-c",
"pytest-fill.ini",
"--fork",
"Cancun",
"-m",
"blockchain_test",
"--no-html",
"--output=stdout",
str(test_module.relative_to(pytester.path)),
)
outcomes = result.parseoutcomes()
assert outcomes.get("failed", 0) == 0
assert outcomes.get("passed", 0) > 0
Loading
Loading