Repository navigation
feat: add state transition function skeleton - #13
Merged
Merged
Conversation
MegaRedHand
marked this pull request as ready for review
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.
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.
No description provided.