Skip to content

Make the execution layer optional for proof-only nodes - #42

Open
frisitano wants to merge 15 commits into
optional-proofs-gloasfrom
optional-proofs-gloas-pr-3
Open

Make the execution layer optional for proof-only nodes#42
frisitano wants to merge 15 commits into
optional-proofs-gloasfrom
optional-proofs-gloas-pr-3

Conversation

@frisitano

@frisitano frisitano commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Makes the execution layer optional so a node can validate execution with EIP-8025 proofs instead of an engine, and fixes a deadlock that stopped such a node importing any payload.

Base is optional-proofs-gloas at 5350460.

Configuration

--execution-endpoint was required, so ClientConfig always carried an execution layer config. It is now optional, and startup instead requires that the node can validate execution somehow: an engine, a proof engine, or both. A node with neither is rejected. An execution_layer::Config is only built when an endpoint is supplied, so a proof-only node constructs no ExecutionLayer at all.

Two builder settings that feed the Gloas builder client moved to a BuilderClientConfig, so a node without an engine can configure the client it still runs. --builder and its header timeout stay gated on the endpoint, since they configure the pre-Gloas relay that lives inside the execution layer.

Behaviour without an engine

Absence is handled where it arises rather than left to error:

  • Fork choice skips the execution layer update, and the state advance timer skips proposer preparation.
  • Both PayloadNotifiers decide the payload status at construction rather than reaching the engine helper. Gloas reports Irrelevant, because the envelope only imports once its proofs verify and Gloas rejects an optimistic envelope outright. Pre-Gloas reports Optimistic: no proof gate covers that fork, so nothing verifies the payload and the node must not attest to it.
  • Engine blob fetching, engine capability checks and the genesis payload check are skipped.
  • el_offline reports the engine's status when there is one, and false otherwise.

Proofs gate import only when there is no engine. With one, re-execution validates the payload, so proofs are observed but do not hold it up.

The deadlock

Proof gossip verification read the envelope only from the store. Without an engine the store gains it only after an import that waits on proofs, and a proof is cached only after it verifies. Nothing could ever verify. The lookup now tries the pending payload cache first and falls back to the store.

A proof arriving before its envelope is still dropped without a requeue, which is a deliberate deferral rather than an oversight.

Tests

beacon_node/beacon_chain/tests/optional_execution_layer.rs covers construction, el_offline, proposer preparation, block production, fork choice, and the proof requirement across all three modes. A node without an engine imports a payload envelope and holds it pending proofs, using a block and envelope built on an engine-backed harness sharing the same genesis. lighthouse/tests/beacon_node.rs covers the four configurations and the builder settings.

Both notifier tests were checked against the defects they guard.

Validation

cargo fmt, cargo check --workspace --benches --tests and clippy are clean. The Gloas beacon chain suite passes apart from three attestation_production tests, which fail identically on base 5350460; they assert global metric counters that accumulate across tests in one process. network and ef_tests were not run, as no file in either is touched.

Not covered

No test runs a real proof-only node: syncing, gossip and proposing from an external bid are unexercised, and belong with the execution proof network test crate. Proofs gate availability and never mark a payload valid in fork choice; Gloas tracks payload arrival rather than execution status, so there is no valid-marking step to add here.

frisitano and others added 5 commits September 10, 2026 03:32
A beacon node could not start without `--execution-endpoint`: the flag was
required by clap and parsed with `parse_required`, so `ClientConfig` always
carried an execution layer config. `BeaconChain` already held
`Option<ExecutionLayer>`, but no supported configuration ever left it `None`.

Make the endpoint optional and require that a node can validate execution
somehow: either it drives an execution engine, or it verifies execution proofs
with the in-process proof engine. `get_config` rejects a node with neither, and
only builds an `execution_layer::Config` when an endpoint is supplied, so a
proof-only node constructs no `ExecutionLayer` at all. JWT parsing moves inside
that branch, since a JWT is meaningless without an endpoint.

Handle the absence explicitly on the paths that assumed an engine:

- Fork choice skips the execution layer update task, which would otherwise log
  a critical error twice per head update.
- The state advance timer skips proposer preparation, matching the existing
  guard in `start_proposer_prep_service`.
- `notify_new_payload` reports `Optimistic` rather than erroring, because a
  proof-only node has not executed the payload itself; the proof pipeline gates
  availability on `REQUIRED_EXECUTION_PROOFS` distinct proofs. A chain with
  neither still returns `NoExecutionConnection`.
- Blob fetching from the engine mempool is skipped rather than failing.
- The notifier skips engine capability and genesis payload checks.
- `el_offline` reports the engine's status when there is one, false for a
  proof-only node, and true when the node cannot validate execution at all.
  Reporting a proof-only node as offline would make validator clients refuse it.

Block production and payload reconstruction keep returning
`ExecutionLayerMissing`, which is already explicit.

The reference branch (origin/optional-proofs at e81a316) keeps `ExecutionLayer`
always present with an inner optional engine, and asks an out-of-process proof
engine for a payload status over JSON-RPC. Neither is portable here: this
baseline verifies proof artifacts in-process and already models absence as
`Option<ExecutionLayer>`. Only the configuration and startup validation ideas
are ported.

Remaining limitation: a proof-only node imports payloads optimistically and so
does not attest. Promoting proof-verified payloads to valid in fork choice
belongs with the proof verification work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6aanDLFL9Zif1Ufb6xZzF
Gossip verification of an execution proof loaded the payload envelope only from
the store. On a node with a proof engine that deadlocks.

The envelope reaches the store in `import.rs` only after `make_available`
succeeds, and that waits for `REQUIRED_EXECUTION_PROOFS` distinct proofs. A
proof is cached only after it verifies here, and verification needs the
envelope. So no proof could ever verify and no envelope could ever be imported.
`builder.rs` sets the requirement to 2 exactly when a proof engine is present,
so execution-layer-backed nodes were unaffected and proof-only nodes could never
import a payload at all.

Consult the pending payload cache first, which holds the executed envelope from
the moment it is executed, and fall back to the store for blocks already
imported and evicted from that cache. Full data availability is not required to
reconstruct the proof: it commits to the `NewPayloadRequest` root, which needs
the payload, the bid's blob commitments, the parent root, and the execution
requests, none of which depend on data columns. The `[IGNORE]` condition is
restated accordingly.

The new test fails against the old store-only lookup and passes here. The
existing tests in this module stop at the cheap rejections and at
`PayloadUnavailable`, so the success path was never exercised, which is why the
cycle went unnoticed.

A proof that arrives before its envelope is still ignored without a requeue, so
early proofs are dropped. That is a known and deliberate limitation of this
change rather than an oversight.

Also scope the block production test to before Gloas. From Gloas the proposer
publishes a bid and a builder produces the payload, so block production never
asks the local engine for one and `prepare_execution_payload` in the Gloas path
has no callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6aanDLFL9Zif1Ufb6xZzF
Replace the match with a lazy chained fallback and cut the comment down to the
reason the order matters.

`or_else` does not fit: the fallback returns `Result<Option<_>>`, not `Option`.
`or` would fit but is eager, which would read the store on every cache hit.
`map(Ok).unwrap_or_else(..)` keeps the store read lazy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6aanDLFL9Zif1Ufb6xZzF
The summary said the method reports whether an execution engine is reachable,
which contradicts what it returns: a proof-only node cannot reach an engine and
is deliberately not reported as offline. What it reports is whether the node has
any working means of validating execution, by re-execution or by proofs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6aanDLFL9Zif1Ufb6xZzF
Skip spawning the update task rather than returning early inside it. The
`Option` in the return type already means "did not need to update the EL", so
a proof-only node reports that instead of spawning a task that does nothing.
This matches `start_proposer_prep_service`, which checks before spawning.

Also stop reporting a chain with neither an execution layer nor a proof engine
as offline. `get_config` rejects that combination, so encoding it here was a
fallback for a state a node cannot launch in. The test for it goes too; the
configuration test in `lighthouse/tests/beacon_node.rs` is what enforces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6aanDLFL9Zif1Ufb6xZzF
Move the no-execution-layer case out of the `notify_new_payload` helper and into
both `PayloadNotifier::new` implementations. That constructor is already where
"does this payload need the engine at all" is decided, which is why the Gloas
branch and the not-execution-enabled branch resolve there. The helper goes back
to a single contract: it requires an engine, and is only reached when one exists.

Appending the resolution with `or_else` leaves every existing path untouched.
`None` already means "ask the engine", so only that case is intercepted, and the
optimistic-finalized-sync arm keeps doing its block hash verification.

The status is `Optimistic`, not `Irrelevant`: the node has not verified the
payload itself, and `Irrelevant` would report it as non-optimistic and let the
node attest. This also drops the `proof_engine.is_none()` special case, matching
the invariant that `get_config` enforces.

Document the three modes of execution validation on `BeaconChain::execution_layer`,
since several paths branch on which is in use, and note that the proof requirement
applies only when a proof engine is configured. The lookup comment in proof gossip
verification said this unconditionally, which was wrong for an engine-only node.
Proofs were required before import whenever a proof engine was configured, which
also held up a node that runs an engine alongside one. An engine validates the
payload by re-executing it, so waiting on proofs there adds latency without
adding assurance.

Require them only when there is no execution layer, which is the one mode with
nothing else to validate execution. Proofs are still verified and observed in the
other modes, they just do not hold up import.

This narrows where the payload envelope deadlock could occur but does not remove
it: a proof-only node still waits for proofs, so the pending cache lookup in
gossip verification is still required. The regression test now models a
proof-only node rather than one that also has an engine.

Add a `required_execution_proofs` accessor so the mode selection can be asserted,
and correct the comments that stated the requirement applied whenever a proof
engine was present.
Gloas does not support optimistic import: `into_executed_payload_envelope`
rejects an optimistic envelope with `OptimisticSyncNotSupported`. The previous
commit had the Gloas notifier report `Optimistic` when there is no execution
layer, so a proof-only node rejected every payload envelope it received.

Report `Irrelevant` instead. There is no engine verdict to wait on, and the
envelope only reaches import once `REQUIRED_EXECUTION_PROOFS` proofs have
verified it, so the question an engine would answer does not apply. The
pre-Gloas notifier keeps `Optimistic`, because no proof gate covers that path
and that path has no such rejection.

The notifier test now asserts the status is not optimistic, which is the exact
condition the import guard checks. A full import test would need a proof-only
harness to produce an envelope, which it cannot do: envelopes come from block
production, and that needs an engine.

Move the description of the three execution validation modes to `get_config`,
where the flags that select them are parsed, and drop the cross-reference from
the `proof_engine` field.
Three comments said or implied that Gloas block production does not use the
local execution engine. It does. `get_execution_payload_gloas` always builds
locally through `prepare_execution_payload`, and that build is then ranked
against direct and gossip builder bids. The earlier claim that
`prepare_execution_payload` had no callers was wrong: the call site uses a
turbofish and was missed.

So a proof-only node still cannot propose, but not for the reason given. The
Gloas builder flow exists and could in principle supply a payload without a
local engine; Lighthouse just attempts the local build unconditionally first.

The graffiti comment no longer speculates about block production at all. The
mode description records the real reason. The block production test's Gloas
skip is now attributed to test setup, which is what it is, rather than to a
behavioural difference that does not exist.
The no-execution-layer case was resolved by appending `or_else` after the match,
which also caught the `None` from the optimistic-finalized-sync arm. That `None`
means the cheap block hash check failed and the engine must do the slow one, so
overriding it imported a payload nothing had checked.

Handle it as a match arm instead. The arm above keeps its `None` and falls
through to `NoExecutionConnection`, and the match becomes the whole decision
rather than a decision plus a correction underneath it.
The Gloas builder client is constructed whenever the Gloas fork is scheduled,
with or without an execution layer, but two of its settings could only be
reached through `--builder`, which requires an execution endpoint. A proof-only
node therefore ran that client on defaults with no way to change them.

Move the user agent and the SSZ flag onto a `BuilderClientConfig` on the client
config, parsed independently of the endpoint, and read them there when building
the Gloas client. The execution layer still receives copies, so the pre-Gloas
relay client is unchanged. `--builder` and its header timeout stay where they
are: they configure the relay, which cannot exist without an execution layer.

Drop the `requires("builder")` gating from both flags. The rendered help is
unchanged, since clap does not show flag dependencies.
The pre-Gloas notifier moved this decision into a match arm; its Gloas sibling
was left on `or_else`, which was an oversight rather than a choice.

The reason applies more strongly here. The arm above returns `None` when the
cheap block hash check fails and the engine must do the slow one. Overriding
that with `Irrelevant` reports the payload as non-optimistic, so a proof-only
node would treat an envelope nothing verified as settled. The pre-Gloas slip
produced `Optimistic`, which at least withholds attestation.

Both notifiers now decide in the same place, in the same shape.
Rewrote the comments added on this branch. Most were two or three times longer
than the point they made, and several explained machinery that is nowhere near
the line they sit on.

Dropped references that the reader cannot see from where the comment is: the
name of the config validation from a beacon chain accessor, the constant for the
proof requirement from a lookup that never mentions it, and the import guard's
error type from a test. Where the fact still matters, it is stated in terms of
what happens rather than which function does it.

Two fixes rather than trims. The comment on the proof requirement had been
truncated mid-sentence by an earlier edit and read "so proofs are verified and".
And the mode description still claimed a node without an engine cannot propose,
which we established is wrong: the local build failure is caught and production
continues on external bids. It now says such a node can only propose on an
external bid.
Two tests for behaviour that nothing exercised.

The first pins an invariant that spans two files. `Irrelevant` is only sound
because import waits for proofs, and those decisions are made in the notifier
and the chain builder respectively. A chain that reports a non-optimistic status
without an engine must also require proofs, so assert both together.

The second imports a payload envelope on a node with no engine, which nothing
did before. Such a node cannot produce an envelope, so it is built on an
engine-backed harness sharing the same genesis and fed in, as `run_skip_slot_test`
does with blocks. The envelope is accepted and held pending its proofs.

The second test was checked against the defect it guards: reverting the notifier
to `Optimistic` makes it fail with `OptimisticSyncNotSupported`, which is the
error a proof-only node hit for every envelope.
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