ethlambda beacon follows the Ethereum Beacon Chain. A beacon block carries an
execution payload that consensus cannot validate on its own: only an execution
client can say whether the transactions in it are valid and whether the state
root they produce matches. This page describes how the follower asks, what it
does with each answer, and what it does not yet do.
The consensus rules implemented here are
specs/bellatrix/optimistic-sync.md
and the verify_and_notify_new_payload half of each fork's process_execution_payload.
The wire is
execution-apis/src/engine.
Six:
| Method | Introduced | Why |
|---|---|---|
engine_newPayloadV4 |
prague | Validate one block's payload |
engine_newPayloadV5 |
amsterdam | Validate the payload a gloas envelope reveals |
engine_forkchoiceUpdatedV3 |
cancun | Say where the head, safe and finalized blocks are |
engine_forkchoiceUpdatedV4 |
amsterdam | The same, from a gloas head on; also carries the node's custody columns |
engine_exchangeCapabilities |
common | Startup handshake |
engine_getClientVersionV1 |
identification | Log what the execution client is |
Osaka introduces no new newPayload. Its own document adds only
engine_getPayloadV5 and engine_getBlobsV2/V3; engine_newPayloadV5 belongs
to Amsterdam, where gloas's envelope import uses it. So the Osaka-current call for a payload is prague's V4, and the
Osaka-current fork choice notification is cancun's V3. Amsterdam's pair (newPayload V5, forkchoiceUpdated V4) is
used from gloas on; the startup handshake warns when the execution client does not advertise either, but only on a network whose schedule includes gloas.
Three method families a full client would have are deliberately absent:
engine_getPayload*outside the gloas path. The follower itself never asks an execution client to start building a block, so its ownforkchoiceUpdatedcalls carrynullpayload attributes. Block production for a validator client (produceBlockV3for fulu,produceBlockV4for gloas) does:forkchoiceUpdatedV3withPayloadAttributesV3thenengine_getPayloadV5before gloas, andforkchoiceUpdatedV4withPayloadAttributesV4(V3's fields plusslotNumberandtargetGasLimit, the parent bid's gas limit) thenengine_getPayloadV6(ExecutionPayloadV4, withblockAccessListandslotNumber) from gloas on. The handshake warns when a gloas network's execution client does not advertiseengine_getPayloadV6.engine_getBlobs*. There is no blob-pool fetch path here; data columns come from peers over the network.engine_getPayloadBodies*. Nothing consumes them.
Blocks before electra are not asked about at all. This node checkpoint-syncs onto
a chain far past bellatrix, capella and deneb and never imports one of their
blocks, and engine_newPayloadV4 would refuse their payloads as an unsupported
fork anyway. Supporting them would mean carrying V1 through V3 as well, for
chains this follower cannot reach.
process_block (beacon arm)
│
├─ data availability gate ────────────► held, if a custody column is missing
│
├─ engine_newPayloadV4 ◄── this page
│
└─ fork_choice::on_block(…, payload_validity)
│
├─ state_transition, with the engine derived from the verdict
└─ record the block's execution hash and its optimistic status
This diagram is the pre-gloas path. A gloas block carries no payload, only a builder's bid, so nothing is asked about it at import; the question moves to the envelope that reveals the payload (see Gloas below).
The order is the specification's own: is_data_available runs before
state_transition, so a block about to be held for its custody columns is never
one this node asks an execution client about.
Every field of the request is a pure function of the block:
parent_beacon_block_root is state.latest_block_header.parent_root, which
after process_block_header is the block's own parent_root, and the other
three are body fields. That is what lets the round trip happen outside the
state transition, so the state transition itself stays synchronous while the
import cascade above it is async.
The verdict then reaches state_transition as an ExecutionEngine: an
INVALIDATED answer makes verify_and_notify_new_payload return false and the
transition fail from inside process_execution_payload, which is where the
specification puts that failure. The transition is run even when the verdict is
already INVALIDATED, which costs one merkleization on a path that should never
run and buys the failure arriving where a reviewer checks this code against the
specification.
optimistic-sync.md groups the five wire statuses into three outcomes:
| Wire status | Alias | What happens |
|---|---|---|
VALID |
Imported. The block and every optimistic ancestor leave optimistic_roots |
|
SYNCING, ACCEPTED |
NOT_VALIDATED |
Imported if is_optimistic_candidate_block allows it, and joins optimistic_roots |
INVALID, INVALID_BLOCK_HASH |
INVALIDATED |
Not imported. latestValidHash decides how much of the branch dies with it |
A block in optimistic_roots carries normal fork-choice weight and can be the
head. That is the point of optimistic sync, and the sync/optimistic fixture
suite proves it: its one case, from_syncing_to_invalid, has a SYNCING branch
take the head from a VALID one on attestation weight, and then requires the
head to fall back when the SYNCING branch turns out to be invalid.
An invalidated block leaves fork choice by having its LiveChain index row
deleted. That is sufficient because Store::block_index() is the only source
filter_block_tree, compute_node_weights and walk_head read: a root with no
row contributes no weight to any ancestor and can never be walked to. The block
and its state stay in their own tables, so an operator can still inspect what
was rejected.
That an INVALIDATED block is never imported is enforced on the verdict itself,
in on_block, not on the state transition having failed. The transition still
runs first, so the failure arrives from inside process_execution_payload where
the specification puts it and where the sync/optimistic fixture exercises it.
But it only fails there for forks that consult the ExecutionEngine at all:
bellatrix gates that step on is_execution_enabled, and phase0 and altair have
no such step, so on those a condemned block would otherwise transition cleanly.
A block imported on NOT_VALIDATED is recorded in optimistic_roots against
its slot. An entry leaves on a later VALID (which clears the whole optimistic
prefix, per optimistic-sync.md's "all ancestors of the block MUST also
transition") or INVALIDATED verdict, and, for the ones that get neither, on
finality: an execution client doing a long state sync answers NOT_VALIDATED to
every block, so without that bound the set would take one root per import for
the life of the process.
Besides mark_validated's own ancestor walk, the Beacon API reads the set:
every execution_optimistic response field (see
rpc.md for a gloas block's),
is_optimistic on /eth/v1/node/syncing and the 206 on
/eth/v1/node/health (both about the head only), and attestation_data and
aggregate_attestation, which answer 503 rather than hand a validator a
vote for a block the execution client has not validated (see
rpc.md).
A NOT_VALIDATED verdict is not enough on its own. The block must also either
- have a parent this node has already recorded an execution hash for, or
- be at least
--safe-slots-to-import-optimisticallybehind the wall clock.
Condition 1 is store.beacon_el_block_hash(parent_root).is_some(): the
specification's is_execution_block(parent), read off a cache of the parent's
payload hash rather than by decoding the block. The cache holds a hash for every
post-merge block the store has, and none for a pre-merge block (a zero payload
hash is not an execution block). It is filled at import, for the checkpoint
anchor when the store is built, and on resume for the unfinalized window.
The horizon guards against a poisoned merge transition block, whose parent hash names an execution block nobody can produce. Once a node is on a post-merge chain every block's parent has a recorded hash, so condition 1 carries each one and condition 2 stops mattering.
The anchor is never imported and never asked about (see The anchor is assumed
valid), so its hash is cached when the store is
built; only the hash, not a verdict. That is what lets the first block after a
checkpoint-sync anchor take condition 1 even while the execution client is still
syncing and answers SYNCING to every newPayload. A restart empties the cache,
so resume rebuilds it from the stored blocks.
An INVALID answer names the last execution block that was valid. Resolving
which beacon block that condemns is a walk up the rejected block's own
ancestry, not a lookup in a global index, because the specification scopes it
that way: "the child of a block with
body.execution_payload.block_hash == latestValidHash in the chain containing
the block with payload in question". Two branches can share a parent whose
payload is the last valid one, and only the branch that was rejected may die.
latestValidHash |
Condemned |
|---|---|
| An execution hash found on this chain | The child of the block carrying it |
| All zeroes | The deepest indexed ancestor carrying a payload |
null, or a hash not on this chain |
The rejected block itself |
The last row is the specification's own instruction, not a convenience: "when
latestValidHash is a meaningful execution block hash but consensus engine
cannot find a block satisfying
body.execution_payload.block_hash == latestValidHash, consensus engine SHOULD
behave the same as if latestValidHash was null". A checkpoint-synced follower
meets this whenever the named block is below its anchor.
A condemned root at or below the finalized checkpoint is refused: the log says
so and nothing is removed. Obeying it would delete every LiveChain row from
finality upward, after which get_head fails its "block_root in store.blocks"
check on every call and the node can only report that it cannot compute a head
until its database is rebuilt.
The all-zeroes row above is what reaches this without anyone naming a finalized block: the walk it describes stops where the execution hash cache does, and that cache is bounded by finality, so its deepest entry is the finalized block itself. An execution client condemning finalized history means it and this node disagree about what is final, which is an operator emergency rather than something to resolve by emptying fork choice.
A gloas block commits to a builder's bid and the payload arrives later in a
SignedExecutionPayloadEnvelope, so the execution client judges the envelope,
not the block. The order differs from the specification's, which asks the engine
last inside one boolean function: the follower runs the pure consensus checks
first (signature, bid, state, data availability), and only an envelope that
passed them is sent to engine_newPayloadV5. That way an unsigned copy with the
honest block hash and a different body cannot earn an INVALID that lands on the
honest payload, and the engine never spends time on an envelope this node would
refuse anyway.
| Answer | What happens to the envelope |
|---|---|
VALID |
Applied; the payload is recorded VALID |
SYNCING, ACCEPTED |
Applied and recorded SYNCING (not yet validated); a later forkchoiceUpdated VALID promotes it |
INVALID with a latestValidHash |
The payload is invalid: not applied, so the block's FULL node never exists; the block and its EMPTY branch stay |
INVALID with a null latestValidHash, or INVALID_BLOCK_HASH |
The envelope's contents do not hash to the block hash it claims: only that envelope is refused, since the builder's real one may still arrive |
| no answer after the retry ladder | Kept (it is consensus-valid) and asked again once a slot |
The null-latestValidHash row exists because ethrex has no INVALID_BLOCK_HASH
status: it answers a mismatch with a null hash and an executed-and-failed payload
with its last valid ancestor. latestValidHash and VALID follow the execution
chain (each block's bid.parent_block_hash), not every verified ancestor, since
a block built past an ancestor's EMPTY node skips that ancestor's payload. The
details, and why each departs from the specification, are in
spec_deviations.md.
With the engine down, the per-slot retry asks about the oldest held envelope only and releases the rest once that ask is answered, so an unreachable client costs one retry ladder per slot however many envelopes wait. This is the gloas counterpart of the limitation below, and unlike a block the envelope is not dropped.
Sent once after every head recompute, which is once per import cascade and once per tick, not once per block. It is sent even when nothing moved: an execution client doing state sync needs to keep being fed a recent head or its sync cannot converge.
Its three hashes come from the cached execution hash of the head, the justified
checkpoint's root and the finalized checkpoint's root. A root with no cached hash
contributes the zero hash, which is a meaningful value here rather than an
absence: EIP-3675 requires finalized_block_hash to be zero before a
post-transition block is finalized, and the specification's own
get_safe_execution_block_hash returns zero when no payload is justified yet. A
follower whose head has no cached hash is still sitting on its checkpoint
anchor and sends nothing at all.
The cache is pruned to the unfinalized window on every tick and every import,
with the finalized checkpoint's own root exempted by name. The slot bound alone
does not reach it: a checkpoint is stored as its epoch's start slot, while its
root is the last block at or before that boundary, so a missed proposal at an
epoch boundary leaves the finalized block below the bound. Dropping its hash
would make every later call carry finalized_block_hash = 0x00..0 and stop the
execution client advancing its own finalized block for the life of the process.
For a gloas head the hashes come from bids rather than from a cached payload
hash: the head node's bid.block_hash when it is FULL and its
bid.parent_block_hash when it is EMPTY, the finalized block's
bid.parent_block_hash, and the justified block's bid.parent_block_hash as
the safe hash. The last is a fallback, since the specification builds the safe
hash on fast confirmation, which the follower does not run. The call is V4 with
the node's custody columns. A head whose payload status has not been computed
yet (the first moments after a restart) sends nothing.
The response carries a PayloadStatusV1 of its own, and that is the channel by
which a block imported on SYNCING later becomes VALID or is found to be
INVALID. An invalidation reaching the follower this way can remove the head
itself, unlike one reaching it through newPayload, because the block in
question has already been imported and is in the index. When it does, fork
choice is re-run and the head rewritten before the response handler returns:
KEY_HEAD and Table::BlockRoots still name the removed roots otherwise, and
the req/resp handlers read both, so a peer would be advertised and served a
block this node has just refused. Only the head is rewritten there, not
announced: telling the execution client from inside its own response would
re-enter the call being answered, so the new head goes out on the next cascade
or tick.
--execution-endpoint <URL> e.g. http://127.0.0.1:8551
--execution-jwt-secret <PATH> file holding 32 bytes of hex
--safe-slots-to-import-optimistically <N> default: the spec's own value
The first two must be given together: an Engine API endpoint always requires
authentication, so an endpoint without a secret is refused at startup, before
anything binds or connects. Clap cannot express "both or neither" across two
optional flags, so the pairing is checked once, in From<BeaconOptions>.
With neither flag, the follower contacts no execution client and every block
gets PayloadValidity::NotRequired, which is exactly what it did before any of
this existed.
Authentication is HMAC-SHA256 over a JOSE header and a claim set whose only
member is iat. Execution clients accept about a minute of skew either way; a
fresh token is minted per request rather than cached, because minting is two
hashes and a cache would need a clock of its own.
The startup handshake warns rather than refuses when the execution client does not advertise a method this node needs: an execution client that under-reports its capabilities still works, and refusing to start over a handshake would turn a cosmetic mismatch into an outage.
Each call is attempted ENGINE_MAX_ATTEMPTS times, with a per-attempt timeout of
ENGINE_TIMEOUT and a backoff starting at ENGINE_INITIAL_BACKOFF and doubling.
An RPC error is not retried: that is the execution client answering, with a
refusal but an answer, so asking again would get the same refusal and burn the
ladder for nothing.
After the last attempt the block is dropped. optimistic-sync.md requires
exactly that much ("a consensus engine MUST NOT import the block and MUST NOT
apply it to the fork choice store"), but nothing here re-drives the call
afterwards, so a persistently unreachable execution client parks the follower's
head behind the first block it could not ask about, permanently, until a
restart.
This was accepted knowingly for this change. The intended fix is a tick-driven
re-drive of the set of blocks that got no verdict. lean_engine_no_verdict_total is the
metric that says it is happening: a non-zero rate means the follower has stopped
following rather than merely slowed down.
The optimistic set lives in memory alongside the rest of the beacon fork-choice scratch and is not persisted. After a restart, blocks that were imported optimistically are no longer marked as such.
This is safe because the set is advisory. It records which blocks have not yet
been vouched for, so that a later VALID can clear them and a later INVALID
can be attributed; it never gates whether a block stays in fork choice. On
restart the execution client is asked again about everything the follower
imports from that point on, and forkchoiceUpdated re-establishes the head's
status on the first tick.
A checkpoint-synced follower takes its anchor state and block on trust, without asking an execution client about the anchor's own payload. That is inherent to checkpoint sync, not specific to this change.
validate_merge_block and the terminal-PoW checks are bellatrix's, are reached
from one place, and were removed outright by capella. Nothing here changes them.