feat(test-fill): make engine_x fixtures consumable via devp2p sync - #3364
Open
danceratopz wants to merge 12 commits into
Open
feat(test-fill): make engine_x fixtures consumable via devp2p sync#3364danceratopz wants to merge 12 commits into
danceratopz wants to merge 12 commits into
Conversation
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.
2 tasks
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
What
This PR modifies
blockchain_test_engine_xfixtures 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.To allow full sync testing, the framework adds an empty block (the "sync" block) to every eligible chain in the
blockchain_test_engine_xfixture 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 theengine_xformat: every other fixture format builds the test's chain exactly as written, whatever the flags. It is on by default;--no-sync-blockdisables it.G → T₁…Tₙ → S*syncPayloadfieldG → S → T₁*engineNewPayloads[0], tagged"phase": "sync"G → T₁…Tₙᵢ*(
*marks the block a sync-based consumer announces;ᵢmarks the intentionally invalid block.)User-visible impact:
lastblockhashand post state byte-identical to the author's. It gains only the out-of-chainsyncPayloadfield that was introduced in feat(consume): Addconsume synctests to sync clients after engine tests execution-spec-tests#2007."phase": "sync"and a genesis wound one fee step up.enginex; not inwirex(44 test cases forfork_Osaka).Why
A post-merge client has two production ways to ingest blocks:
engine_newPayloadand devp2p sync. Two protocol facts decide what a sync-based consumer (consume wirex, the follow-up PR) can guarantee: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*"] endExactly 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_codeexpects 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 throughnewPayload, 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_xuntouched. Three reasons rejected it.First, the differential signal.
consume enginexand 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 enginexneeds no code change: it ignores the unknownsyncPayloadfield on valid chains, and it replays the prepended payload like any other valid block, at the cost of one near-emptynewPayloadround-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
syncPayloadfieldThis is the exact representation, field name and builder call that
BlockchainEngineSyncFixturehas 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 asengineNewPayloads[0], tagged"phase": "sync"(thesyncphase joins the existing setup/execution/cleanup vocabulary in commit 1), and every consumer replays it.Three properties follow from the out-of-chain placement:
engineNewPayloads,lastblockhashand 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.consume enginexreplays the same payloads at the same cost. The format ignores unknown fields, so released older readers skipsyncPayloadwholesale.The sync block is scoped per fixture format
Fixture formats declare
sync_blockas a class property. OnlyBlockchainEngineXFixturesets 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_blockis false onBenchmarkTest), as does any measuring session.The sync block is a real block, salted per test
The block is built through the normal
generate_block_datapath. 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 inextra_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:
get_genesis_environmentbecause 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.absolute_block_positionmarker instead.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 viaenginexFor 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:absolute_block_positionpre_state_affects_empty_blockno_empty_block_fee_preimagepost_state_affects_sync_blockno_sync_block_timestamp_headroom2**64 − 1leaves the trailer no uint64 room; no client could parse an overflowingsyncPayload, so the trailer build guards loudly and the affected variants fill bareEach guard message names its marker, so a failing fill points straight at its remedy.
The three
pre_state_affects_empty_blockmarks sit ontest_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 35absolute_block_positioncandidates, the EIP-2935 history families, the Osaka reserve-price fee pins, and thetest_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 blocksAn
rlp_modifieron 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 itsrlp_modifierleaves 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
just staticandjust test-tests. Commit messages carry their own per-commit verification evidence.Unit and pytester coverage, by test file:
specs/tests/test_sync_block.pyplugins/filler/tests/test_sync_block_fill.pylastblockhash; the prepend shape; distinct per-test salts across a pre-allocation group; the default-on path and the--no-sync-blockopt-out restoring byte-identical chains in every formatplugins/filler/tests/test_sync_block_markers.pyplugins/filler/tests/test_noop_rlp_modifier.pyplugins/filler/tests/test_t8n_cache.pyfixtures/tests/test_engine_x_checks.pyfixtures/tests/test_base.pysync_blockformat classvarRelated 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
just static<type>(<area>): <title>matchingC-*/A-*labels; the title matches the target squash commit message.Cute Animal Picture