Make the execution layer optional for proof-only nodes - #42
Open
frisitano wants to merge 15 commits into
Open
Conversation
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.
…onal-proofs-gloas-pr-3
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.
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-gloasat 5350460.Configuration
--execution-endpointwas required, soClientConfigalways 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. Anexecution_layer::Configis only built when an endpoint is supplied, so a proof-only node constructs noExecutionLayerat 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.--builderand 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:
PayloadNotifiers decide the payload status at construction rather than reaching the engine helper. Gloas reportsIrrelevant, because the envelope only imports once its proofs verify and Gloas rejects an optimistic envelope outright. Pre-Gloas reportsOptimistic: no proof gate covers that fork, so nothing verifies the payload and the node must not attest to it.el_offlinereports 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.rscovers 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.rscovers the four configurations and the builder settings.Both notifier tests were checked against the defects they guard.
Validation
cargo fmt,cargo check --workspace --benches --testsand clippy are clean. The Gloas beacon chain suite passes apart from threeattestation_productiontests, which fail identically on base 5350460; they assert global metric counters that accumulate across tests in one process.networkandef_testswere 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.