Skip to content

feat: add state transition function skeleton - #13

Merged
MegaRedHand merged 3 commits into
mainfrom
add-stf-skeleton
Jan 9, 2026
Merged

MegaRedHand merged 3 commits into
mainfrom
add-stf-skeleton

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

No description provided.

@MegaRedHand
MegaRedHand marked this pull request as ready for review January 9, 2026 18:07
@MegaRedHand
MegaRedHand merged commit a2228bd into main Jan 9, 2026
@MegaRedHand
MegaRedHand deleted the add-stf-skeleton branch January 9, 2026 18:07
tcoratger pushed a commit to tcoratger/ethlambda that referenced this pull request Oct 30, 2026
…class#13)

* feat(beacon): name the payload status an execution client returns

* feat(beacon): let a signed block report its own execution block hash

* feat(storage): hold the beacon payload statuses fork choice will read

* feat(storage): delete named live chain rows, not just a slot range

* feat(beacon): name the optimistic import safety horizon

* feat(beacon): read an execution status as the verdict on_block takes

* feat(beacon): resolve which block a latestValidHash condemns

* feat(beacon): cut an invalidated subtree out of fork choice

* feat(beacon): gate optimistic import on the spec's two conditions

* feat(beacon): let on_block carry an execution payload verdict

Threads tasks 6-9 together: the verdict picks the ExecutionEngine the state
transition runs with, an INVALIDATED answer cuts the condemned branch out of
fork choice on the way out of the error path, and a successful import records
the block's execution hash plus whether the execution layer has vouched for it.

Both existing callers pass NotRequired, so every fork_choice fixture case and
the chain actor behave exactly as before; the engine client fills the actor's
in later.

Also folds in the Task 3 review: doc comments on the three undocumented beacon
scratch accessors, and the plan's own repeat_byte hash convention in the tests
that predated it.

* test(beacon): run the optimistic sync spec fixtures

* build: add the engine API crate skeleton

* feat(engine): mint the JWT an execution client authenticates

* feat(engine): shape the engine API's JSON on the wire

* feat(engine): call the four methods a follower needs

* test(engine): round-trip the wire against a mock execution client

* feat(beacon): encode a block's execution requests per EIP-7685

* feat(blockchain): assemble the engine question a beacon block poses

* refactor(blockchain): make the import cascade async

The engine round trip has to happen between the data-availability gate and
fork_choice::on_block, both of which live inside process_block, so that frame
and everything above it has to be able to await. No behaviour changes here: the
column-release path joins the cascade's frames because it re-enters on_block,
and the two production call sites were already inside async handlers.

* feat(blockchain): validate a beacon block's payload against the execution client

* feat(blockchain): tell the execution client where the head is

* feat(cli): pair the beacon follower with an execution client

* feat(cli): wire the execution client into the beacon follower

* docs(beacon): describe the execution layer pairing and its limits

* build: stop tracking a stray .DS_Store, and ignore them

Committed by accident alongside the engine crate skeleton. This repo is
developed on macOS, so ignore the whole class rather than only this one.

* refactor(engine): reuse H256's own hex serde, and check the HTTP status

H256 already serializes as 0x-prefixed lowercase hex and already deserializes
prefix-optional hex with a 32-byte check, so the two helpers here were a second
copy of that logic that could silently drift from it. The payload's own data()
calls stay: feeRecipient is an ExecutionAddress, which has no Serialize impl.

Also check the response status before parsing the envelope. A rejected JWT
answers 401 with a body that is not a JSON-RPC envelope, and parsing it first
turned an actionable failure into an opaque decode error.

* fix(beacon): read is_execution_block as the spec means it, not as a field check

is_optimistic_candidate_block asks whether the parent has execution enabled,
and was answering it with "the container has an execution_payload field". A
pre-merge bellatrix block carries that field with every byte zero, so its
children looked like descendants of a merge block and skipped the age horizon
that exists precisely to guard the merge transition. Not reachable today (this
follower anchors past electra and only ever asks about electra and fulu
blocks), but wrong, and wrong in the direction of importing too readily.

The cache's presence is now the predicate: a zero block hash is not stored,
which is exactly "the payload is not the fork's own empty one", since a real
payload's block hash is a keccak digest.

Also adds the metric for the other reason a block is now dropped without a
post-state, and records on invalidate_subtree the dangling-vote hazard it
creates for get_weight, which stays spec-strict on purpose.

* docs(beacon): stop telling operators to disable the availability gate

Both paragraphs described a gate that could not follow mainnet, on the
strength of which they instructed a mainnet follower to turn it off. The
column-fetch work underneath this branch removed that failure mode and the
switch they named is gone, so the instruction now points at a flag that does
not exist and at a limitation that no longer holds.

The engine retry ladder's own paragraph compared itself to that wedge; with
nothing left to compare to, it states its shape directly instead.

* docs(beacon): correct what the optimistic-import horizon actually gates

Two claims here did not survive running the follower against mainnet with a
syncing execution client.

Condition 1 was described as "a parent with execution enabled". It is
`store.beacon_el_block_hash(parent_root).is_some()`, and that hash is written on
import, so it asks whether *this node* imported the parent, not whether the
parent is post-merge. The difference is invisible until the two disagree.

They disagree at exactly one place, which is why the section also claimed
condition 2 never fires on a follower anchored past the merge. A checkpoint-sync
anchor is never imported and never asked about, so it has no recorded hash and
its child fails condition 1; with the execution client answering SYNCING, that
child waits for the horizon and the head sits at the anchor until it clears.
Measured 2026-09-15: an anchor 85 slots behind cleared in ~9 minutes, not the
full 128 slots, because the horizon is counted against the wall clock rather
than against the anchor. It happens once, then the backlog cascades on
condition 1.

Also drops BeaconOptions' claim to have "no flag of its own left", which three
flags below it have contradicted since the execution pairing landed.

* docs(beacon): point at the optimistic sync spec where it now lives

consensus-specs deleted its `dev` branch (the default is now `master`) and
moved the document from `sync/optimistic.md` to
`specs/bellatrix/optimistic-sync.md`, so the link 404s and the docs link
check fails the PR.

Bellatrix is the right target rather than `specs/heze/optimistic-sync.md`:
the Heze document is a work-in-progress delta covering inclusion list
satisfaction, which this follower does not implement, and it extends the
Bellatrix one for everything this page describes.

The `sync/optimistic` fixture suite named further down keeps its name: that
is a consensus-spec-tests directory, not the specification document, and it
did not move.

* docs(beacon): name the optimistic sync spec file that exists upstream

Twenty references named the optimistic sync specification by a path that
consensus-specs no longer has, in two stale forms: six said
`sync/optimistic.md`, the path from before the document moved, and fourteen
said `optimistic.md`, the name from before it was renamed. Upstream the
document is `specs/bellatrix/optimistic-sync.md`.

They all say `optimistic-sync.md` now, the bare file name, because that is
the short form the surrounding comments already use for a specification
document whose fork is clear from context: `beacon-chain.md`,
`fork-choice.md` and `das-core.md` all appear that way, and the fully
qualified form is reserved for where the fork is the point, as in
`specs/electra/beacon-chain.md`. The full path is pinned once, in this page's
own link, which is also where the ambiguity with
`specs/heze/optimistic-sync.md` is resolved: Heze is a work-in-progress delta
for inclusion list satisfaction, and Bellatrix is the document every one of
these references means.

Four references keep `sync/optimistic` unchanged, in `storage/src/store.rs`
and three places in `beacon/fork_choice.rs`. Those name the
consensus-spec-tests fixture directory, which still exists under that name;
they are not references to the specification document.

Eight paragraphs are rewrapped because the longer name pushed a line past the
width its file wraps at. No wording changed.

* fix(beacon): address the engine API review findings

Eleven findings from the review of this PR, in one commit because they
interleave: `blockchain/src/lib.rs` carries four of them, `fork_choice.rs`
four and `storage/src/store.rs` three, and splitting them apart by hand
would buy a history whose intermediate commits do not build.

Blockers:

- A held fulu block was stranded whenever its re-import failed for a reason
  that was not about columns. `release_block_if_columns_complete` removes the
  hold before re-importing, which is what makes the re-entry terminate, but an
  engine failure or a not-yet-an-optimistic-candidate verdict then recorded
  nothing, so neither `redrive_held_blocks` nor the finality eviction could
  still see the block and it, and every descendant, waited for a restart.
  `on_block` reports the handed block's `ImportOutcome` now and the hold goes
  back on `Held`. An import that failed on the block's own contents
  deliberately does not come back: it would fail identically every slot until
  finality evicted it, buying a state transition per slot and nothing else.

- `prune_beacon_el_block_hashes` deleted the finalized block's own execution
  hash. Its bound is a checkpoint's slot, which is that checkpoint's epoch's
  start, while the checkpoint root is the last block at *or before* that
  boundary, so a missed proposal at an epoch boundary left the finalized block
  below the bound. From then on every `forkchoiceUpdated` carried
  `finalized_block_hash = 0x00..0` and the execution client never advanced its
  own finalized block. The root is exempted by name now.

- `invalidate_subtree` obeyed a verdict against a finalized block. EIP-3675
  lets an execution client answer `INVALID` with `latestValidHash = 0x00..0`,
  and `resolve_invalid_block`'s zero branch then walks as far as the execution
  hash cache reaches, which finality bounds at the finalized block itself.
  Obeying that deletes every `LiveChain` row from finality upward, after which
  `get_head` fails its "block_root in store.blocks" check on every call. It is
  refused with an error log now: the two layers disagreeing about finalized
  history is an operator emergency, not something to resolve by emptying fork
  choice.

Majors:

- An invalidated head stayed in `KEY_HEAD` and `Table::BlockRoots` until the
  next tick recomputed it, and the req/resp handlers read both, so `Status`
  advertised the invalidated root and `BlocksByRange`/`BlocksByRoot` served
  the invalidated block to peers. That is a stale store, not the stale gauge
  the comment claimed. The head is recomputed inside the verdict handler now,
  through a new `update_head_from_fork_choice` that deliberately does not
  announce it: announcing from there would re-enter the `forkchoiceUpdated`
  being answered.

- `mark_validated` built `Store::block_index` unconditionally, an uncached
  scan of a table beacon never prunes plus a map build, once per imported
  block and once per `forkchoiceUpdated`, only to walk a set that is empty
  whenever the execution client is healthy. It asks
  `has_beacon_optimistic_roots` first now.

- `optimistic_roots` grew without bound. Its own doc claimed an entry left on
  finality and nothing did that, and the `BeaconScratch` doc called
  `el_block_hashes` the one bounded exception. An execution client doing a
  long state sync answers `NOT_VALIDATED` to every block, so the set took one
  root per import for the life of the process. It carries slots now and is
  pruned beside the execution hash cache, on the same horizon and at the same
  two call sites. Both doc claims are corrected, including a note that nothing
  outside `mark_validated`'s walk reads the set yet.

- An `INVALIDATED` verdict was enforced only by `state_transition` happening
  to fail. That holds only for forks whose `process_execution_payload`
  consults the engine: bellatrix gates that step on `is_execution_enabled`,
  and phase0 and altair have no such step, so a condemned block on one of
  those would transition cleanly and import with nothing recorded. `on_block`
  states the invariant on the verdict now. The transition still runs first and
  its own error is still what propagates, which is what keeps the failure
  arriving from inside `process_execution_payload`, where the specification
  puts it and where the `sync/optimistic` fixture exercises it.

Minors:

- `ClientVersionV1.commit` went out without its `0x` prefix. geth decodes that
  field into `hexutil.Bytes`, which rejects a bare hex string, so
  `engine_getClientVersionV1` came back an RPC error and the identification
  handshake never worked, invisibly at default log levels. The
  `chars().take(8)` is a `get(..8)` now too, since `VERGEN_GIT_SHA` is not
  guaranteed to be eight or more characters in every build configuration.

- `--execution-endpoint` without `--execution-jwt-secret` gave the operator a
  panic. clap 4 does express "both or neither", with `requires` on each flag,
  so the parser answers a mismatch with a usage error and the conversion's arm
  is `unreachable!`. `an_endpoint_without_a_secret_is_refused`, which pinned
  the panic, is replaced by `execution_flags_come_as_a_pair`, which pins the
  same invariant one layer earlier and over both halves rather than one.

- `get_execution_requests_list` had landed between `process_execution_payload`'s
  doc comment and its `fn`, so the two blocks concatenated, rustdoc attached
  them all to the new function, and `process_execution_payload` rendered with
  no documentation at all. Moved below the function it was dropped into.

- `spawn_beacon`'s doc comment opened with six slashes, which is an ordinary
  comment rather than a doc comment, so its summary line was dropped and the
  module index showed a truncated description.

`docs/beacon_engine.md` gains the finality floor, the two prune bounds, the
optimistic-root lifecycle and the immediate head rewrite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant