Skip to content

feat(test-fill): make engine_x fixtures consumable via devp2p sync - #3364

Open
danceratopz wants to merge 12 commits into
forks/amsterdamfrom
experiments/wirex-fill
Open

feat(test-fill): make engine_x fixtures consumable via devp2p sync#3364
danceratopz wants to merge 12 commits into
forks/amsterdamfrom
experiments/wirex-fill

Conversation

@danceratopz

@danceratopz danceratopz commented Aug 12, 2026

Copy link
Copy Markdown
Member

Description

What

This PR modifies blockchain_test_engine_x fixtures to allow their use in a new Hive simulator that tests clients by delivering ingest test case blocks via a full sync using the devp2p wire protocol.

⚠️ Companion: #3365 (consume wirex + the devp2p peer). Corpus-scale client validation results can be found in that PR's Validation section.

To allow full sync testing, the framework adds an empty block (the "sync" block) to every eligible chain in the blockchain_test_engine_x fixture format. Whether the block is prepended or appended to the test case chain, depends on whether it generates a valid or invalid chain and on an invalid chain's length (see the table below). This modification is scoped to the engine_x format: every other fixture format builds the test's chain exactly as written, whatever the flags. It is on by default; --no-sync-block disables it.

Chain class Sequence Sync block Fixture representation Fee compensation Wire-guaranteed test content
Valid (single or multi-block) G → T₁…Tₙ → S* appended, salted out-of-chain: a new optional syncPayload field none; nothing sits below the test's blocks all of T₁…Tₙ, on every client, by construction
Invalid singleton (expected exception or Engine API error code) G → S → T₁* prepended, salted in-chain: engineNewPayloads[0], tagged "phase": "sync" genesis wound one exact step up S only; the client judges T₁ after S arrives
Invalid multi-block G → T₁…Tₙᵢ* none unchanged none T₁…Tₙ₋₁ already travel the wire

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

User-visible impact:

  • A valid engine_x chain keeps its payload list, lastblockhash and post state byte-identical to the author's. It gains only the out-of-chain syncPayload field that was introduced in feat(consume): Add consume sync tests to sync clients after engine tests execution-spec-tests#2007.
  • An invalid engine_x singleton gains one leading payload tagged "phase": "sync" and a genesis wound one fee step up.
  • All other chains, all other fixture formats, benchmark specs and measuring sessions produce byte-identical output.
  • Tests that cannot take a sync block are marked appropriately and get filled without the sync block. These tests run only in enginex; not in wirex (44 test cases for fork_Osaka).

Why

A post-merge client has two production ways to ingest blocks: engine_newPayload and devp2p sync. Two protocol facts decide what a sync-based consumer (consume wirex, the follow-up PR) can guarantee:

  • First, a client only starts a sync when the announced head's parent is unknown to it.
  • Second, only blocks below the head must travel devp2p. The head's payload always arrives through the Engine API. Whether a client also re-fetches the head's body from a peer is an implementation choice. Measured across four engaged clients the choice goes both ways: geth, ethrex and nethermind fetch the announced head's body from the peer; reth executes it straight from the announcement. Any client could adopt reth's shape tomorrow. That shape is legitimate and arguably optimal.

This asymmetry decides the placement for valid chains. If the announced head were the test's own block, delivery of the test's content over the wire would depend on each client's fetch choice. Appending the sync block above a valid chain removes that dependency: every one of the test's own blocks becomes an ancestor of the announced head. A full-syncing client must fetch their headers and bodies from the peer and execute them through its sync pipeline. The test's content reaches the client over devp2p by chain structure, not by client courtesy. The simulator's per-block wire-coverage check then holds on every client.

The sync block also creates syncable chains where none existed: a single-block chain built on the client's own genesis never triggers a sync, and 97% of the Cancun engine_x corpus is single-block chains.

flowchart LR
    subgraph pre["Prepend (invalid singleton): S gives the sync a reason to start"]
        direction LR
        G2["genesis"] -->|devp2p| S2["sync block S"] -->|"Engine API (judged)"| T2["T₁* invalid"]
    end
    subgraph app["Append (valid chain): every test block is a wire-guaranteed ancestor"]
        direction LR
        G1["genesis"] -->|devp2p| T1["T₁…Tₙ"] -->|Engine API| S1["sync block S*"]
    end
Loading

Exactly one class takes the prepend: the single expected-invalid block. Nothing can be built on an invalid block, so an appended trigger cannot exist. The block below it gives the sync a reason to start before the client judges the announced head.

A single block whose engine_api_error_code expects the announcement itself to be refused also takes the prepend, for a sharper reason. That block is consensus-valid to the fill. Under the append rule the sync block would become the announced head, the payload would arrive over devp2p instead of through newPayload, and the refusal the test asserts would never happen. Keeping such singletons at height two also keeps them in sync runs at all: sync-based consumers skip single-block chains, so a bare classification would silently drop their refusal coverage.

Invalid multi-block chains need no help. Their valid ancestors already travel the wire, and the client judges the head after those ancestors arrive.

The obvious alternative is a new fixture format for sync-based consumers, leaving blockchain_test_engine_x untouched. Three reasons rejected it.

First, the differential signal. consume enginex and the sync-based consumer are two production ingestion paths for the same workload. The strongest evidence either produces is differential: a failure in one but not the other points at the ingestion path, not the test. That argument only works if both simulators consume identical fixtures. A separate sync format would fork the corpus, and every differential finding would first have to rule out the fixture difference.

Second, the impact on the two consumers is asymmetric. The sync-based consumer cannot work without the sync block. consume enginex needs no code change: it ignores the unknown syncPayload field on valid chains, and it replays the prepended payload like any other valid block, at the cost of one near-empty newPayload round-trip per prepend-class test. Extending the existing format gives the sync consumer what it needs at the smallest total cost.

Third, duplication. A sibling format could share most of the build work: an appended sync block sits on top of the test's own chain, so the valid-chain majority could be derived cheaply from the same t8n results. The costs that remain do not shrink with the build work. The release grows by a whole fixture flavor that is an engine_x fixture plus one sync block, and every layer that knows fixture formats (the consume ingestion, the hive simulator registry, the release layout, the fixture index) would have to learn it.

The option is on by default for the same reason: an engine_x corpus without sync blocks is strictly less useful, because most of its chains cannot trigger a sync, while Engine API consumers read the valid-chain majority identically either way.

Behavior and implementation

In reading order: the out-of-chain trailer, per-format scoping, a real salted block, prepend-only compensation, per-class ineligibility, and a no-op-modifier guard.

The appended block lives outside of the test chain, in a new optional syncPayload field

This is the exact representation, field name and builder call that BlockchainEngineSyncFixture has always used for its trigger block. The prepended block is different: it is load-bearing ancestry. Skipping it would leave the test's own block with an unknown parent. It therefore stays in-chain as engineNewPayloads[0], tagged "phase": "sync" (the sync phase joins the existing setup/execution/cleanup vocabulary in commit 1), and every consumer replays it.

Three properties follow from the out-of-chain placement:

  • The fixture's semantic surface stays the author's. engineNewPayloads, lastblockhash and the post state describe exactly the chain the test wrote. The fill verifies the test's post conditions before the sync block exists. This matters post-Prague, where the appended block executes real system work (EIP-2935 writes the head's hash), none of which leaks into what the fixture asserts.
  • Engine API consumers are untouched. consume enginex replays the same payloads at the same cost. The format ignores unknown fields, so released older readers skip syncPayload wholesale.
  • The fill-time consistency check compares append-class payload lists against the engine sibling at full strictness. Nothing is dropped or scrubbed, because nothing shifts.
"engineNewPayloads": [
  { "params": [ { "blockNumber": "0x1", "transactions": [ ... ], ... } ] }
],
"syncPayload": {
  "phase": "sync",
  "params": [ { "blockNumber": "0x2", "transactions": [], ... } ]
},
"lastblockhash": "<the test's own head, not the sync block>"
The sync block is scoped per fixture format

Fixture formats declare sync_block as a class property. Only BlockchainEngineXFixture sets it. Formats that share a positional t8n output cache must build byte-identical chains; a unit test enforces this through cache-key agreement. Engine_x declares no cache key, because its phase-2 chains build on a packed group genesis, so its divergence corrupts nothing and costs no extra t8n work. Spec types whose measurements the extra block would distort opt out (supports_sync_block is false on BenchmarkTest), as does any measuring session.

The sync block is a real block, salted per test

The block is built through the normal generate_block_data path. Its state root, base fee, system-contract writes and header hash come from the same machinery as every other block. Both placements carry a digest of the pytest node id in extra_data, with the fixture format and the xdist group suffix stripped from the id first. The salt exists because the tests of a pre-allocation group share one reused client, and every announced head must be a block that client has never seen; the salt makes each sync attempt attributable to its own test. A prepended block builds on the shared group genesis and would be identical across the group without the salt. An appended block usually inherits uniqueness from its parent, but two group tests with byte-identical chains would share it. Because the digest strips the format and the xdist suffix, parallel and sequential fills build the same chains.

Prepend only: Genesis compensation for tests that depend on exact fee values or block positions

This subsection matters only for tests whose expectations depend on the exact chain context their block executes in: pinned fee values (base fee, excess blob gas), balances calibrated against exact fee costs, or pinned block positions. For every other test the prepended empty block is neutral, and no appended block ever needs any of this.

A prepended block changes that context. It decays the base fee by one EIP-1559 step and the excess blob gas by one target, and it shifts every block position up by one. Without correction, a fee-pinning test would execute one fee step below the environment its author specified. The insufficient-balance blob families show why the exact value matters: they pin single-wei sender balances against exact blob fee values, and only the exact environment keeps them invalid for the right reason.

The fill corrects each effect in the way its semantics allow:

  • Fee values: the fill winds the invalid singleton's genesis one exact preimage step up, so the prepended block consumes precisely the step it introduces and the test's block executes in the fee environment its author pinned. The compensation lives in get_genesis_environment because phase 1 of the two-phase fill hashes that environment for pre-allocation grouping; the policy resolution reads only statically declared block fields, so both phases agree.
  • Block numbers: a pinned block number shifts up by one. Tests whose expectations derive from absolute positions cannot survive this and carry the absolute_block_position marker instead.
  • Timestamps: timestamps are semantic and never shift. A pinned timestamp the prepended block cannot clear fails the fill loudly.

Valid chains get none of this. Their genesis, numbers, timestamps and payloads pass through byte-for-byte, so valid tests share pre-allocation groups exactly as before the option existed.

Some tests cannot take a sync block; they fill without one and cannot be tested via sync / wirex; only via enginex

For a small set of tests, adding the sync block would change what the test verifies, or would produce a block that cannot be built or parsed. These tests carry a marker and fill exactly as written, with no sync block. The test stays in the fixture release and keeps its full Engine API coverage. The cost is sync coverage only: a single-block chain without a sync block cannot trigger a devp2p sync, so sync-based consumers skip it at consume time.

Each marker applies to one placement only. A marker for the prepended block means nothing to a valid chain, and a marker for the appended block means nothing to an invalid singleton, so a mark never costs a test anything in the other class. The filler collects the markers on a test node into the spec's sync_block_ineligibilities, and the policy resolution (SyncBlockPolicy) applies the veto. Five markers:

Marker Vetoes Sites at head Why
absolute_block_position prepend 0 expectations derive from absolute positions; nothing in the tree needs it now
pre_state_affects_empty_block prepend 3 sabotaged system contracts that every block calls: the prepended block itself fails to build on that pre-state
no_empty_block_fee_preimage prepend 0 pinned fee values with no empty-block preimage; valid chains are never compensated, so currently not needed
post_state_affects_sync_block append 0 the mirror image: final state that breaks the block built on top; machinery for the full corpus, named loudly by the trailer-build error
no_sync_block_timestamp_headroom append 4 param sites (2 families) a head pinned at or next to 2**64 − 1 leaves the trailer no uint64 room; no client could parse an overflowing syncPayload, so the trailer build guards loudly and the affected variants fill bare

Each guard message names its marker, so a failing fill points straight at its remedy.

The three pre_state_affects_empty_block marks sit on test_system_contract_errors (EIP-7002, EIP-7251, EIP-8282 builder). Their generated error modes are invalid singletons whose sabotaged contract breaks the prepended block's own build. Their generated passing params are valid chains that keep their appended trailer. A mark cannot name a parameter combination, but the class-scoped veto makes the whole-test mark free: it costs the passing params nothing. The adjudication sweep judged every other candidate site by scoped fills, and all fill clean unmarked: the 35 absolute_block_position candidates, the EIP-2935 history families, the Osaka reserve-price fee pins, and the test_extra_* record-returning trio. The census is in the sweep commit message.

One additional fix: The fill now rejects no-op rlp_modifiers on expected-invalid blocks

An rlp_modifier on an expected-invalid block is exempt from exception verification. That exemption leaves a silent hole: if chain context shifts under a test, the wrong pinned value can become correct, the modifier no-ops, and the fixture claims an invalidity it no longer has. The prepend class is the one place this PR shifts context under a test, so the fill now closes the hole: it rejects a block whose expected exceptions are all block level when its rlp_modifier leaves the header unchanged. Blocks expecting a transaction exception are excluded, because they are invalid regardless of their header, and the state-test conversion routinely pins header fields to values that legitimately match the computed ones. The guard is general: any future recurrence of this class fails loudly at fill time instead of surfacing as a cross-client disagreement.

Validation

Unit and pytester coverage, by test file:

Test file What it pins
specs/tests/test_sync_block.py policy resolution for every chain class including the error-code-head cases; the prepend transform (salt digest, number shift, timestamp guard with its expected-invalid exemption); the append-side uint64 headroom guard at its exact boundary; both fee-preimage solvers and their refusals; the untouched append-class genesis; the two-step state-test composition
plugins/filler/tests/test_sync_block_fill.py appended block placement, parentage, block number, salt length and untouched lastblockhash; the prepend shape; distinct per-test salts across a pre-allocation group; the default-on path and the --no-sync-block opt-out restoring byte-identical chains in every format
plugins/filler/tests/test_sync_block_markers.py all five markers' class-scoped fallback: marked tests fill bare, unmarked siblings keep their placement, marks are inert for the other class
plugins/filler/tests/test_noop_rlp_modifier.py both directions of the no-op-modifier guard: a no-op modifier on a block-level exception fails the fill naming the block; a transaction-exception block with a legitimately matching pinned header field still fills
plugins/filler/tests/test_t8n_cache.py cache-key agreement between formats that share a positional t8n cache
fixtures/tests/test_engine_x_checks.py the consistency check's strict append-class comparison and its prepend-class scrubbing
fixtures/tests/test_base.py the sync_block format classvar

Related Issues or PRs

Companion simulator PR: #3365 (consume wirex, the sync-based consumer of the per-class sync block this PR fills). An upstream issue for the pre-existing Amsterdam BAL consistency-check base condition (see Limitations) will be filed and linked here.

Checklist

  • Ran fast static checks: just static
  • PR title has the form <type>(<area>): <title> matching C-*/A-* labels; the title matches the target squash commit message.

Cute Animal Picture

every chain deserves a block it can sync from

The stateful fill introduced per-payload phase tags (setup /
execution / cleanup) so consumers can partition a fixture's payloads
by role. Add a `sync` phase for payloads the framework injects purely
to change the chain's shape: they prepare no state a test depends on,
so neither of the existing phases describes them, and borrowing
`setup` would send a reader looking for state that is not there.

The first users are the empty blocks the filler adds to engine_x
chains so that sync-based consumers can trigger a devp2p sync: a
salted empty block appended above a valid chain's head, and one
prepended between genesis and a single invalid block. Consumers that
replay payloads through the Engine API can treat a sync payload like
any other block; the tag exists so a framework-injected payload is
distinguishable from the test's own after the fixture is reloaded.
A client only starts a devp2p sync when the announced head's parent
is unknown to it, and only blocks below the head are guaranteed to
travel devp2p: the head's payload is always delivered through
`engine_newPayload`, and whether a client also re-fetches it from
the peer is an implementation choice - measured across four engaged
clients it goes both ways (geth, ethrex and nethermind fetch the
head's body, reth executes it from the announcement). A single-block
chain built on the client's own genesis never syncs at all - and 97%
of the Cancun engine_x corpus is single-block chains.

The new --sync-block option gives every eligible chain one
framework-built empty block, placed per test by the chain's own
statically declared structure (`SyncBlockPolicy`):

- append (fully valid chains): the block is built on the test's
  post-state and is what a sync-based consumer announces. Every one
  of the test's own blocks is then an ancestor of the announced
  head, which a syncing client must fetch and execute through its
  sync pipeline: the test's content reaches the client over the wire
  by chain structure, not by client courtesy, on any client.
- prepend (a single expected-invalid or Engine API-refused block):
  nothing can be built on such a block, so the empty block lands
  between genesis and the test's block, giving the sync a reason to
  start before the client judges the announced head. A block pinning
  an absolute number is shifted up by one; a pinned timestamp the
  empty block does not clear fails the fill loudly (timestamps are
  semantic - fork activation, TIMESTAMP expectations - and are never
  shifted).
- none (everything else): an invalid multi-block chain already
  carries valid ancestors that travel the wire and needs no help;
  chains with an Engine API error code on any block keep their own
  announcement.

An Engine API-refused head must never take an appended block even
though it is consensus-valid to the fill: the appended block would
become the announced head, the refused payload would arrive over
devp2p instead of through `newPayload`, and the refusal the test
asserts would never happen. Refused singletons take the prepend
rather than nothing because sync-based consumers skip single-block
chains: bare, these tests would silently drop out of every sync run;
prepended, their announcement stays the test's own payload and the
expected refusal stays exercised.

Both placements guard their timestamp boundary. The prepend refuses
pinned timestamps that cannot clear the extra block below it; the
append refuses a head whose timestamp leaves no uint64 headroom for
the block above it - at or next to 2**64 - 1 the trailer's timestamp
would not fit uint64 and no client could even parse the payload,
while nothing fill-side notices (Python integers do not overflow and
t8n accepts the value). Timestamps are semantic and are never
clamped or shifted; both directions fail the fill loudly.

The appended block is serialized out-of-chain, in a new optional
`syncPayload` field - the exact representation, field name and
builder call `BlockchainEngineSyncFixture` has always used - rather
than in-chain as the last payload:

- The fixture's semantic surface stays the author's: `payloads`,
  `lastblockhash` and the post state describe exactly the chain the
  test wrote, and the fill verifies the test's post conditions
  before the extra block exists. In-chain placement would move all
  three onto framework-built state (post-Prague the appended block
  executes real system work, e.g. writing the head's hash into
  EIP-2935 history).
- Engine API consumers are untouched: engine_x replays the same
  payloads as today at the same cost, and the format ignores unknown
  fields, so older readers skip the field wholesale and every
  append-class fixture stays consumable by them.
- The fill-time consistency check compares the append-class payload
  list against the engine sibling at full strictness - nothing is
  dropped or scrubbed, because nothing shifts. Only the prepend
  class needs the leading-payload drop and the position scrub, keyed
  off the in-chain sync-phase tag.

The prepended block stays in-chain as `payloads[0]`, tagged with the
`sync` phase: there it is load-bearing ancestry - skipping it leaves
the test's own block with an unknown parent - so every consumer must
replay it.

Both placements carry a digest of the pytest node id in their
`extra_data`. The tests of a pre-allocation group share one reused
client, and every announced head must be a block that client has
never seen, so each sync attempt is attributable to its own test: a
prepended block builds on the shared group genesis and would
otherwise be identical across the group, and an appended block
usually inherits uniqueness from its parent but two group tests with
byte-identical chains would share it. The digest is taken over the
test's own id: the fixture format and the xdist group suffix both
ride along in the raw node id, and neither may reach the block, so a
parallel fill builds the same chains as a sequential one.

The option is scoped per fixture format - only
blockchain_test_engine_x opts in, and formats sharing a non-empty
t8n cache key must agree on it, which a unit test enforces - and is
withheld from spec types that measure per-block (benchmark tests)
and from any measuring session. It is off by default until the
marker sweeps land: a fill without it is byte-identical to one from
the unmodified code.

Verified: unit tests pin the policy resolution for every chain
class (including the refused-head cases), the prepend transform
(salt digest, number shift, timestamp guard with its
expected-invalid exemption), the append-side uint64 headroom guard
at its exact boundary, and the consistency check's strict
append-class comparison; pytester fills of a two-test pre-allocation
group and an invalid singleton assert the appended block's
placement, parentage, block number, salt length and untouched
`lastblockhash`, the prepend shape, distinct per-test salts, and
that a fill without the option emits no sync block in any format.
`just static` and `just test-tests` pass.
The prepended sync block participates in fee mechanics: it decays the
base fee by one EIP-1559 step and the excess blob gas by one target,
so a prepend-class test pinning those values would fill against a fee
environment one step below the one its author specified. On the
prepend-everywhere design this class dominated the Cancun refill
catalogue (674 of 681 failures); under the per-class policy it is
confined to invalid singletons, where fee precision is often the
test's whole point (insufficient-balance variants pin single-wei
sender balances against exact blob fee values).

Wind genesis one progression step up in get_genesis_environment when
the resolved policy is prepend, so the empty block consumes exactly
the step it introduces: it lands precisely on the author's genesis
fee values and the test's own block derives its fee context from an
identical parent. The preimages are exact, found by a bounded scan
and verified forward with the fork's own calculators; a value with no
preimage fails the fill loudly instead of producing a semantically
shifted fixture. The compensation lives in get_genesis_environment
because phase 1 of the two-phase fill hashes that environment for
pre-allocation grouping - both phases must see the same genesis, and
the policy resolution reads only statically declared block fields, so
they always agree. Append-class chains are untouched: nothing sits
below the test's own blocks, so the author's genesis passes through
and valid tests share pre-allocation groups exactly as before the
sync-block option existed. State tests compose transparently: their
conversion already winds genesis one step up from the pinned block
environment, and this adds the one further step the prepended block
consumes.

Verified: the 188 insufficient-balance blob variants at Cancun
(single-wei balance precision plus exact excess blob gas, all
resolving to the prepend class) fill clean with --sync-block; the
same fill without this commit's compensation fails loudly, the
execution-consistency check reporting 144 of the 188 engine_x
fixtures executing differently from their engine siblings - the
check doubles as a tripwire for any future fee-environment drift. An
option-off fill of ported stRefundTest at Cancun is content-identical
to one from the branch's base across all 104 fixtures of every
format (per-fixture content hashes compared). Unit tests round-trip
both preimages through the fork's own calculators, pin the refusal
for values a fee floor makes unreachable, the prepend-class
compensation (blob and pre-blob forks), the untouched append-class
genesis, and the two-step state-test composition; `just static` and
`just test-tests` pass.
…othing

An `rlp_modifier` on an expected-invalid block is what makes the
block invalid when the expected exception is header level, so
exception verification is deliberately skipped for it. That leaves a
silent hole: when the chain context shifts under a test, the "wrong"
value the modifier pins can become the correct one, the modifier
no-ops, and the fill emits a valid chain whose fixture still claims
invalidity - no fill-side check notices, and consumers disagree with
the fixture at run time. The ethrex engagement caught exactly this
on the prepend-everywhere design: the extra block's excess blob gas
decay turned two `test_invalid_static_excess_blob_gas` fixtures
valid while their expectation stayed INVALID, discovered only
because two clients answered VALID on a refill and INVALID on the
release.

Raise a fill error when a block whose expected exceptions are all
block level applies an `rlp_modifier` that leaves the header
unchanged: the fixture's invalidity expectation no longer tests
anything. Blocks expecting a transaction exception are excluded -
they are invalid regardless of their header, and the state test
conversion routinely pins header fields (e.g. `blob_gas_used`) to
values that legitimately match the computed ones, which the full
Cancun prepend refill confirmed on 84 such tests.

Under the per-class policy the exposure is confined to the prepend
class - append-class chains are built byte-for-byte as authored, so
no context shifts under them - and the genesis fee compensation
keeps the prepend class's pinned values wrong exactly as their
authors meant them. The check makes any future recurrence of this
class fail loudly at fill time instead of surfacing as a
cross-client disagreement.

Verified: pytester coverage pins both directions - a no-op modifier
on a block-level exception fails the fill naming the block, and a
transaction-exception block with a legitimately matching pinned
header field still fills; `just static` and `just test-tests` pass.
…tions

A handful of tests assert values derived from absolute block numbers
or block hashes (BLOCKHASH lookups, storage keyed by NUMBER). The
prepended sync block shifts every block position by one, so no fill
transformation can preserve what these tests verify when they are
invalid singletons; they are permanently ineligible for the prepend,
by construction. They would otherwise surface as fill failures,
indistinguishable from bugs in the prepend transformation itself.

Register an `absolute_block_position` marker and the mechanism every
sync-block eligibility marker shares: the filler collects the
markers present on a test node into the spec's
`sync_block_ineligibilities`, and the policy resolution vetoes only
the placement a marker is about - a marked invalid singleton
resolves to no sync block and fills with exactly its own chain
(genesis compensation included: the author's pinned fee environment
passes through untouched, in both fill phases), while a marked valid
chain keeps its appended trailer, because the append shifts nothing
and the marker means nothing to it. A marked test is never skipped,
so no test leaves the fixture release; the eligibility rule becomes
explicit at the test definition, and refill failure lists stay
reserved for real regressions.

Under the per-class policy this marker's constituency shrinks from
every position-pinned test (the prepend-everywhere design) to
position-pinned *invalid singletons*; the marker sweep commits
re-adjudicate every existing site against that rule.

Verified: unit tests pin the veto (marked invalid singleton resolves
to none, bare chain, untouched genesis) and its irrelevance to valid
chains; a pytester fill of one module asserts all three behaviors
side by side - the marked invalid singleton fills bare, the unmarked
one gains the prepended block, and the marked valid test keeps its
trailer. `just static` and `just test-tests` pass.
The Prague sweep of the prepend-everywhere design found a class the
position marker does not cover: tests that sabotage a system
contract every post-Prague block calls. A prepended sync block runs
the same system calls, so on such pre-state it either fails to build
outright (the block-invalidating error modes) or consumes the
contract's one-shot behavior before the test's own block runs -
either way the transformation cannot be neutral.

Pre-state is not the only setup that can reach the prepended block:
a test pinning a genesis gas limit below a minimal block's own
system work makes the block invalid outright, with no compensation
available, so the marker's description names the genesis environment
alongside pre-state rather than splitting the same decision across
two markers.

Like its position sibling, `pre_state_affects_empty_block` vetoes
only the prepend: a marked invalid singleton fills with exactly its
own chain rather than being skipped. Under the per-class policy the
class it describes is exactly the sabotage-style *invalid
singletons* - the block-invalidating sabotage variants that remain
prepend class keep needing it, while sabotage tests whose own block
is valid resolve to append, where the extra block sits *above* the
test's blocks and meets the same broken contract from the other
side; that mirror class gets its own marker in the follow-up
commit, and the sweep commits re-adjudicate every existing site.

Verified: the pytester marker coverage runs over both prepend-class
markers (marked invalid singleton fills bare, unmarked prepends,
marked valid keeps its trailer); `just static` and `just test-tests`
pass.
…ot reproduce

The prepend compensates a test's genesis fee environment so the
empty block leaves the test's own block seeing what it pinned. Some
pinned values have no preimage at all: under EIP-7918's reserve
price the excess-blob-gas decay is `0` below target, identity while
the reserve is active, and `x - target` otherwise, so a small
nonzero value is unreachable from any parent.

The fill already refuses these loudly, which is correct but leaves
the "genuinely ineligible" class with no way to record the verdict
in-tree. Add `no_empty_block_fee_preimage` alongside the position
and pre-state markers so the exclusion is explicit and reviewable
rather than a command-line ignore, and name it in the refusal the
preimage scan raises, the way the marker registrations name their
own remedies. Like its siblings, the marker vetoes only the prepend:
a marked invalid singleton fills with its pinned fee environment
preserved and stays in the release. Valid chains cannot hit the
preimage scan at all under the per-class policy - their genesis is
never compensated - so the marker's constituency is exactly the
prepend class's fee-pinning tests; the sweep commits re-adjudicate
the existing sites.

Verified: the pytester marker coverage runs over all three
prepend-class markers; unit tests keep pinning the refusal for
unreachable values, whose message now names the marker; `just
static` and `just test-tests` pass.
…t survive

The appended sync block is built by t8n on the test's *post*-state,
and post-Prague it executes the same per-block system calls as any
real block. A valid test that ends with a sabotaged system contract
(the EIP-7002/7251 error-mode families), or one-shot state the extra
block would consume, breaks that build: the fill fails loudly inside
the trailer construction, indistinguishable from a bug in the append
itself. This is the mirror image of `pre_state_affects_empty_block`,
on the other side of the chain: the same broken contract stops a
prepended block below an invalid singleton and an appended block
above a valid chain.

Add `post_state_affects_sync_block`, the append class's one
eligibility marker: a marked valid chain resolves to no sync block
and fills as exactly the author's chain - fallback bare, never skip,
so no test leaves the fixture release; sync-based consumers skip
what cannot sync at consume time. The marker vetoes only the append;
an invalid singleton keeps its prepend regardless, because the
prepended block never sees the test's post-state. The trailer build
now names the marker when it fails, the way the preimage refusal
names its own, so a failing fill points straight at its remedy.

Nothing that the fixture records changes for a marked test: under
the out-of-chain representation the payload list, head and post
state are the author's whether or not a trailer exists, so the
marker's only observable effect is the absent `syncPayload`.

Verified: unit tests pin the veto (a marked valid chain resolves to
none and its chain is untouched; the marker is irrelevant to an
invalid singleton's prepend), and the pytester marker coverage
asserts the marked valid test emits no `syncPayload` while its
unmarked sibling keeps one. The trailer-build failure context is
exercised on the real sabotage families by the marker sweep that
follows, whose commit records the observed refusals. `just static`
and `just test-tests` pass.
…block room

The appended sync block takes its parent's timestamp plus the
default increment, and a block timestamp must fit uint64. A valid
chain whose head pins a timestamp at or next to `2**64 - 1` - the
beacon-root families test exactly this boundary - therefore gets a
trailer whose timestamp does not fit, and no client can even parse
the fixture's sync payload: geth refuses at the JSON-RPC layer
(`hex number > 64 bits`) before any consensus logic runs. The fill
itself noticed nothing - Python integers do not overflow, t8n
accepts the value, and the consistency check compares payload
lists, which the out-of-chain trailer is not part of - so the
transformation commit's headroom guard now fails such fills loudly,
and this commit gives the verdict its in-tree record.

Add `no_sync_block_timestamp_headroom`, the append class's second
eligibility marker: a marked valid chain resolves to no sync block
and fills as exactly the author's chain - fallback bare, never
skip, so no test leaves the fixture release; sync-based consumers
skip what cannot sync at consume time. The marker vetoes only the
append; an invalid singleton keeps its prepend regardless, because
the prepended block sits below the chain, where the uint64 ceiling
above the head is not its problem. The headroom guard names the
marker in its refusal, the way the preimage refusals name theirs,
so a failing fill points straight at its remedy.

Clamping the trailer's step instead (`min(head + 12, 2**64 - 1)`)
was considered and set aside: it would rescue sync coverage for the
near-max case only, at the cost of a special-cased trailer
environment for a handful of fixtures and of breaking the "built
through the normal block machinery" property every other trailer
holds; the max case is unfixable regardless, and timestamps are
semantic - never clamped, never shifted - on both sides of the
chain.

Verified: unit tests pin the veto (a marked maximal-timestamp valid
chain resolves to none and its chain is untouched; the marker is
irrelevant to an invalid singleton's prepend) and the guard's exact
boundary; the append-class pytester marker coverage now runs over
both append markers, asserting the marked valid test emits no
`syncPayload` while its unmarked sibling keeps one. `just static`
and `just test-tests` pass.
…prepended block

Re-adjudicate every site the prepend-everywhere design had marked
(35 absolute_block_position, 6 pre_state_affects_empty_block, 2
no_empty_block_fee_preimage) against the per-class policy's rules,
by filling every affected file with --sync-block and judging each
failure. The verdict: three sites need a mark, everything else fills
clean unmarked - the append placement dissolves the position, fee
and one-shot classes wholesale, because nothing shifts under a valid
chain and its genesis is never compensated.

The three marks, all `pre_state_affects_empty_block`, all on
`test_system_contract_errors` (EIP-7002 withdrawals, EIP-7251
consolidations, EIP-8282 builder requests): their generated error
modes (system_contract_reverts / _throws / _out_of_gas) are invalid
singletons whose sabotaged pre-state contract is called by every
post-Prague (or builder) block, so the prepended sync block itself
fails to build - 12 engine_x fill failures
(SYSTEM_CONTRACT_CALL_FAILED building block 1, six at Prague, six at
Amsterdam), exactly the class the marker names. The mark is
whole-test, and costs the generated *passing* params nothing: the
veto is class-scoped, so the valid params keep their appended
trailers - the over-skip the prepend-everywhere design accepted for
this family ("a mark cannot name a parameter combination") does not
exist under the hybrid. EIP-6110's sabotage family needs no mark:
deposits parse transaction logs, no per-block system call reaches
the modified contract, and its 23 invalid-layout singletons prepend
cleanly with the genesis compensation.

The census of everything else, per sweep fill:

- Cancun-era (the 16 files carrying the 19 position marks: stRandom
  x10, stWalletTest, vmTests block_info, scenarios, blockhash,
  Shanghai withdrawals, point-evaluation transition): 1115 passed,
  zero failures; 295 append-class fixtures, 4 prepend-class
  (test_withdrawals_root x3, test_use_value_in_tx x1, compensated,
  clean). BLOCKHASH expectations, NUMBER-keyed storage and
  number-embedding wallet hashes are all untouched by a trailer
  above the head.
- Osaka (the 2 fee-preimage sites): 258 passed; all 86 reserve-price
  variants append - their genesis is never compensated, so the
  unreachable-preimage refusal cannot arise and the reference's
  over-skip of 86 variants for 35 affected drops to zero.
- Prague (the 2935 history families, both sabotage families, the
  extra_* trio): 210 passed, the 6 failures marked here, 3
  pre-existing skips; 43 append-class, 23 prepend-class. The
  history-pinning tests (test_block_hashes_history*,
  test_invalid_history_contract_calls current/future_block) keep
  their absolute meaning - "current block" stays current when
  nothing sits below it. The extra_* trio's record-returning
  modified contracts are harmless above the chain too: the appended
  block simply carries the records its system call returns, t8n-
  verified (16 append-class fixtures).
- Amsterdam (EIP-8024/7843/7928/8282 families): 8282's 6 error
  modes marked as above; 307 append-class, 14 prepend-class, 1
  none-class fixture. The five prepend-class consistency-check
  drifts (shifted slotNumber, and the EIP-2935 system write
  embedding the parent hash inside blockAccessList) are handled by
  the transformation commit's position scrub, not by marks.

Separately disclosed, not addressed here: 274 Amsterdam
append-class fixtures fail the fill-time execution-consistency
check on this branch *and byte-identically on the untouched base*
(exit 1 at 5b2b22c filling test_block_access_lists_eip2935.py
alone, default options): their blockAccessList embeds
genesis-hash-derived data, which cannot survive pre-alloc grouping.
Append-class payload lists are byte-identical to option-off fills,
so this is a pre-existing condition surfaced by the sweep, reported
upstream rather than worked around.

`no_sync_block_timestamp_headroom` has exactly one constituency,
found by consume-side validation of the filled corpus rather than by
the fill: the two beacon-root families
(test_beacon_root_contract_timestamps,
test_beacon_root_equal_to_timestamp) parametrize head timestamps at
2**64 - 1 and 2**64 - 2, and the trailers appended above them carried
timestamps that do not fit uint64 - 48 such fixtures in the full
--until=BPO4 corpus (12 + 4 per fork at Cancun, Prague and Osaka),
each refused by geth at the JSON-RPC layer before any consensus
logic. Param-level marks on the max and near-max variants of both
families fill exactly those bare while every other variant keeps its
trailer: the Cancun refill of the two families yields 16 appended +
16 bare, 96 fixtures across all formats, zero failures, zero
overflowing trailers.

`post_state_affects_sync_block` gained no constituents anywhere:
every valid chain in the sweeps built its trailer, including the
sabotage families' passing params - the one-shot-consumption worry
was an artifact of the prepend running *before* the test; after it,
the author's chain is already complete. The marker and its
build-failure naming stay as machinery for the class the full
corpus may yet contain.

Verified: refilling the Prague sabotage files with the marks turns
the 6 failures into 24 clean fills (6 error modes bare, 2 passing
params trailered, exit 0), and the 8282 refill mirrors it exactly;
re-running the consistency check over the full Amsterdam sweep
output with the extended position scrub leaves drift on exactly the
274 append-class fixtures of the disclosed base condition and none
of the prepend class; `just static` and `just test-tests` pass.
The sync block exists so that sync-based consumers can trigger a
devp2p sync on every engine_x chain, and the per-class scoping work
confined it to exactly that format and to the placement each chain
class can take: a valid chain keeps its payload list, head and post
state byte-identical to the author's and gains only the out-of-chain
`syncPayload` trailer; a single invalid block gains the prepended
in-chain block with its compensated genesis; every other chain, every
marked test, every other fixture format, benchmark specs and
measuring sessions are untouched. There is therefore no reason left
to fill engine_x fixtures without it: a corpus without sync blocks is
strictly less useful - 97% of its chains are single blocks that can
never trigger a sync - while consumers that replay payloads through
the Engine API read the valid-chain majority identically either way.

Flip the option's default to on and keep --no-sync-block as the
opt-out, e.g. for comparing against corpora filled before the option
existed. The pytester coverage now exercises the default path and
the opt-out.

Verified: the pytester fill suite asserts the default path emits the
per-class shapes without the flag (valid chains carry `syncPayload`,
an invalid singleton carries the tagged in-chain prepend) and that
--no-sync-block restores chains byte-for-byte as the tests define
them in every format; `just static` and `just test-tests` pass.
The sync block changes what filled engine_x chains look like and the
five eligibility markers are decisions test authors have to make, so
both belong in the user-facing documentation rather than only in
`--help` and the marker registrations.

Add a section to the fill command-line page - the two protocol facts
the placement follows from, the chain-class table (append out-of-chain
in `syncPayload` for valid chains, prepend in-chain for invalid
singletons, nothing otherwise), why the appended block makes the
test's own blocks wire-guaranteed, the per-test salt, the
prepend-only genesis compensation, that it applies to engine_x
fixtures only and is on by default - and document
`absolute_block_position`, `pre_state_affects_empty_block`,
`no_empty_block_fee_preimage`, `post_state_affects_sync_block`
and `no_sync_block_timestamp_headroom`
alongside the other test markers, including which placement each
vetoes and that marked tests fill without the extra block instead of
being skipped.
@danceratopz danceratopz added C-feat Category: an improvement or new feature A-test-fill Area: execution_testing.cli.pytest_commands.plugins.filler labels Aug 12, 2026
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.53%. Comparing base (5b2b22c) to head (06471b1).
⚠️ Report is 5 commits behind head on forks/amsterdam.

Additional details and impacted files
@@               Coverage Diff                @@
##           forks/amsterdam    #3364   +/-   ##
================================================
  Coverage            93.53%   93.53%           
================================================
  Files                  624      624           
  Lines                37070    37074    +4     
  Branches              3394     3394           
================================================
+ Hits                 34675    34679    +4     
  Misses                1645     1645           
  Partials               750      750           
Flag Coverage Δ
unittests 93.53% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-test-fill Area: execution_testing.cli.pytest_commands.plugins.filler C-feat Category: an improvement or new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant