fix(l1): suppress payload build on FCU with finalized-ancestor head#6676
Conversation
|
Lines of code reportTotal lines added: Detailed view |
🤖 Kimi Code ReviewThe change correctly implements the Engine API specification update (execution-apis PR 786) for handling canonical head references that are ancestors of the finalized block. Key observations: crates/networking/rpc/engine/fork_choice.rs:319-332
Suggestion: Ensure that Otherwise, the change is correct and safe. Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
Greptile SummaryThis PR fixes a spec-compliance bug where
Confidence Score: 4/5The change is a focused, well-motivated single-arm fix with no side-effects on other fork-choice paths; safe to merge. The diff is minimal and the logic is sound: returning None from the NewHeadAlreadyCanonical arm reliably blocks the build_payload path for all FCU versions. The blockchain-layer guard uses head.number <= stored_finalized (inclusive of head == finalized itself), while both the new comment and the spec describe the case as a strict ancestor — the equality case is also silently blocked from payload building, which is safe but the comment is slightly misleading. No tests were added for the corrected path, which makes future regressions harder to catch. crates/networking/rpc/engine/fork_choice.rs — the NewHeadAlreadyCanonical arm and its interaction with V1/V2 handlers (which are also affected by this change, though only V3/V4 are mentioned in the PR description)
|
| Filename | Overview |
|---|---|
| crates/networking/rpc/engine/fork_choice.rs | Removes the head block header fetch and returns None in the NewHeadAlreadyCanonical arm, preventing build_payload from being called when head is a canonical ancestor of the finalized block — correct per execution-apis PR #786. |
Sequence Diagram
sequenceDiagram
participant CL as Consensus Layer
participant RPC as engine_forkchoiceUpdatedV3
participant HFC as handle_forkchoice
participant AFC as apply_fork_choice
CL->>RPC: "FCU(head = finalized-ancestor, payloadAttrs)"
RPC->>HFC: handle_forkchoice(state, ctx, 3)
HFC->>AFC: apply_fork_choice(head, safe, finalized)
Note over AFC: head.number <= stored_finalized AND head_is_canonical
AFC-->>HFC: Err(NewHeadAlreadyCanonical)
Note over HFC: OLD: return Some(head_header) / NEW: return None
HFC-->>RPC: (None, VALID + latestValidHash)
Note over RPC: if let (Some(_), Some(_)) guard FAILS
RPC-->>CL: "{payloadStatus: VALID, payloadId: null}"
Reviews (1): Last reviewed commit: "fix(l1): suppress payload build on FCU w..." | Re-trigger Greptile
🤖 Codex Code Review
Other than that, the intent of aligning the no-build path with the finalized-ancestor rule looks reasonable, and I don’t see an additional security or performance concern in this small diff. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is the full review: PR Review: fix(l1) — suppress payload build on FCU with finalized-ancestor headOverall: This is a correct, targeted fix with good spec alignment. The change is minimal (3 net lines) and the rationale is well-explained in both the PR description and the updated comment. CorrectnessThe core fix is sound. The key property to verify is whether From if let Some(stored_finalized) = store.get_finalized_block_number().await?
&& head.number <= stored_finalized
&& head_is_canonical
{
return Err(InvalidForkChoice::NewHeadAlreadyCanonical);
}All three conditions must hold simultaneously. A head at the current canonical tip (beyond the finalized height) passes this check and proceeds normally — so returning The Minor comment imprecisionThe new comment says:
The actual condition is Unnecessary storage read eliminatedThe old code called Test coverage gapThe PR validates with Summary
Automated review by Claude (Anthropic) · sonnet · custom prompt |
| // still want to build a payload on top of it (even if it equals the finalized block), | ||
| // so we fall through to normal FCU handling instead of suppressing payload build. | ||
| if let Some(stored_finalized) = store.get_finalized_block_number().await? | ||
| && head.number < latest |
There was a problem hiding this comment.
nit: the comment two lines up says "head IS the current canonical head" — that interpretation is correct, but it's the negation of this guard (we suppress when head is a strict ancestor, fall through when head IS current). Specifically: head.number == latest && head_is_canonical implies head_hash == canonical_at(latest) via the canonical-chain invariant, so the carve-out semantically reads "if head IS current head, allow payload build."
The condition itself (head.number < latest) is correct. Just consider rephrasing the comment to lead with the guard's actual shape — e.g. "head.number < latest is the strict-ancestor check; equality (head IS current canonical head) falls through to normal FCU so the CL can build a payload on top." Future readers won't have to mentally invert the comment to match the code.
No behavior change. Drop or keep at your discretion.
| // - mark the node synced so eth_syncing reports `false`, | ||
| // - return the head header so the caller can build a payload | ||
| // when payloadAttributes is non-null (engine API spec). | ||
| // execution-apis PR 786: when head references a VALID ancestor of |
There was a problem hiding this comment.
This fix is consequential to CL coordination (a non-null payloadId on a finalized-ancestor head causes the CL to chase a payload that's not coming) and the failure mode is subtle — engine-API spec compliance, easy to regress on the next refactor that touches handle_forkchoice.
Worth adding a unit test that drives handle_forkchoice (or the RPC handler directly) with a forkchoiceState.headBlockHash pointing at a finalized ancestor + non-null payloadAttributes, and asserts the response is {payloadStatus: VALID, payloadId: null}. The cargo test -p ethrex-rpc line in the test plan passes today but doesn't lock the invariant. Hive's engine group probably covers it on integration, but a unit test would catch regressions before they reach hive.
Non-blocking — your call whether to add it in this PR or as a follow-up.
|
Thanks for the review @ElFantasma. Both nits addressed:
|
execution-apis PR 786 says: when forkchoiceState.headBlockHash references a
VALID ancestor of the latest known finalized block, the response MUST be
{payloadStatus: VALID, payloadId: null} and the client MUST NOT begin a
payload build process.
The handler for InvalidForkChoice::NewHeadAlreadyCanonical was returning
Some(head_header), which caused ForkChoiceUpdatedV3/V4::handle to invoke
build_payload and set payloadId on the response when payloadAttributes was
non-null. Return None instead so the V3/V4 dispatch short-circuits.
The pre-existing gate fired `NewHeadAlreadyCanonical` whenever the head was a canonical ancestor (or equal to) the latest known finalized block. Combined with the previous commit that suppresses `build_payload` on that arm, this incorrectly blocked payload building when the CL sent `head == latest == finalized` with `payloadAttributes` set, e.g. when asking the EL to build a new block on top of the finalized head. Mirror geth's `head == current_head` carve-out (`eth/catalyst/api.go`, PR 34767): require `head.number < latest` for the skip to fire. When head is the current canonical head, fall through to normal FCU handling so the V3/V4 dispatch can still call `build_payload`. Fixes Hive `Valid NewPayload->ForkchoiceUpdated on Syncing Client` and `In-Order Consecutive Payload Execution` regressions surfaced by the previous commit.
Address review nit from ElFantasma: rephrase the comment above the NewHeadAlreadyCanonical guard to lead with the actual guard shape (head.number < latest is the strict-ancestor check; equality falls through) instead of the inverted form.
Drives engine_forkchoiceUpdatedV3 with headBlockHash pointing at a
canonical strict ancestor of the finalized block and non-null
payloadAttributes, asserts response is {payloadStatus: VALID,
latestValidHash: head, payloadId: null} per execution-apis PR #786.
Locks the invariant that a future refactor of handle_forkchoice or the
RPC V3/V4 dispatch must not re-introduce a non-null payloadId on this
path. Addresses review nit from ElFantasma.
36a1225 to
4dc1a05
Compare
…ambdaclass#6676) ## Summary execution-apis PR [lambdaclass#786](ethereum/execution-apis#786) revised paris.md: when `forkchoiceState.headBlockHash` references a VALID ancestor of the latest known finalized block, the response MUST be `{payloadStatus: VALID, payloadId: null}` and the client MUST NOT begin a payload build process. ethrex already had the correct `InvalidForkChoice::NewHeadAlreadyCanonical` detection path in `crates/blockchain/fork_choice.rs` (gate: `head.number <= stored_finalized && head_is_canonical`; the `<=` matches geth's `block.NumberU64() <= finalized.Number.Uint64()` in `eth/catalyst/api.go`, see geth PR [#34767](ethereum/go-ethereum#34767)). The bug was in `crates/networking/rpc/engine/fork_choice.rs`: the `NewHeadAlreadyCanonical` arm returned `Some(head_header)`, which caused `ForkChoiceUpdatedV3::handle` / `ForkChoiceUpdatedV4::handle` to call `build_payload` and set a non-null `payloadId` when `payloadAttributes` was present. Fix: return `None` for the head header so the V3/V4 dispatch's `if let (Some(_), Some(_))` guard short-circuits `build_payload`. The `VALID + latestValidHash = head` response is preserved, matching geth's `valid(nil)` return. ## Test plan - `cargo check -p ethrex-rpc` passes - `cargo clippy -p ethrex-rpc` passes - `cargo test -p ethrex-rpc` passes
Summary
execution-apis PR #786 revised paris.md: when
forkchoiceState.headBlockHashreferences a VALID ancestor of the latest known finalized block, the response MUST be{payloadStatus: VALID, payloadId: null}and the client MUST NOT begin a payload build process.ethrex already had the correct
InvalidForkChoice::NewHeadAlreadyCanonicaldetection path incrates/blockchain/fork_choice.rs(gate:head.number <= stored_finalized && head_is_canonical; the<=matches geth'sblock.NumberU64() <= finalized.Number.Uint64()ineth/catalyst/api.go, see geth PR #34767).The bug was in
crates/networking/rpc/engine/fork_choice.rs: theNewHeadAlreadyCanonicalarm returnedSome(head_header), which causedForkChoiceUpdatedV3::handle/ForkChoiceUpdatedV4::handleto callbuild_payloadand set a non-nullpayloadIdwhenpayloadAttributeswas present.Fix: return
Nonefor the head header so the V3/V4 dispatch'sif let (Some(_), Some(_))guard short-circuitsbuild_payload. TheVALID + latestValidHash = headresponse is preserved, matching geth'svalid(nil)return.Test plan
cargo check -p ethrex-rpcpassescargo clippy -p ethrex-rpcpassescargo test -p ethrex-rpcpasses