cl/beacon: resolve payload withdrawals in one place - #23280
Conversation
2737df2 to
0c0d8af
Compare
|
Rebased onto I had Withdrawals stay unconditional, which matches what production sent before this series. Also updated for the merge: the shared converter is now on Head is |
There was a problem hiding this comment.
Pull request overview
Centralizes withdrawal resolution and payload-attribute construction in beacon block production.
Changes:
- Extracts fork-specific withdrawal source selection.
- Reuses the shared withdrawal converter.
- Adds tests for source routing and versioned attributes.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
cl/beacon/handler/block_production.go |
Adds withdrawal and payload-attribute helpers. |
cl/beacon/handler/block_production_test.go |
Tests withdrawal routing and parent-root handling. |
Suppressed comments (2)
cl/beacon/handler/block_production.go:252
- This is not behavior-neutral as the PR description claims: the previous production path always populated
ParentBeaconBlockRoot, whereas Bellatrix and Capella now receive nil, and the new test explicitly pins that change. Please either restore the old behavior or document this Engine API compatibility fix and its pre-Deneb impact in the PR description.
if version.AfterOrEqual(clparams.DenebVersion) {
attrs.ParentBeaconBlockRoot = parentRoot
}
cl/beacon/handler/block_production_test.go:767
require.Emptyaccepts both nil and non-nil empty slices, so this does not pin the nil-ness that the PR identifies as execution-layer-significant. Assert nil explicitly; that also verifies this branch did not compute a fresh non-nil expectation.
withdrawals, err = a.expectedWithdrawals(gloasState, nil, clparams.GloasVersion, 0)
require.NoError(t, err)
require.Empty(t, withdrawals)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // payloadAttributes builds the attributes every fork sends. Withdrawals and the parent beacon block | ||
| // root go out regardless of the consensus fork because the execution layer decides what to do with | ||
| // them from the payload timestamp. | ||
| // payloadAttributes builds the attributes for a version of the forkchoice call. The wire format is | ||
| // versioned, so a field the chosen version does not carry has to be left out rather than sent and | ||
| // ignored: V1 and V2 reject a parent beacon block root outright. |
There was a problem hiding this comment.
Both fixed. The stale block was my own leftover: the replacement was inserted above the old one instead of over it, so the file carried two doc comments describing opposite behaviour. Removed.
| withdrawals, err = a.expectedWithdrawals(gloasState, withParentPayload, clparams.GloasVersion, 0) | ||
| require.NoError(t, err) | ||
| require.NotNil(t, withdrawals) |
There was a problem hiding this comment.
Correct, and this is the second weak test of mine in this series, so thank you for checking. Two fresh states both produce an empty expectation, so reading the wrong one passed just as well.
The state copy carrying the parent payload now holds a pending builder withdrawal that the head state does not, and the assertion is on that withdrawal rather than on non-nilness. Reverting the source selection makes it fail; it is no longer satisfiable by reading either state.
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes for two issues:
-
payloadAttributesassignsWithdrawalsfor Bellatrix.expectedWithdrawalsproduces a non-nil empty slice, while Bellatrix dispatchesengine_forkchoiceUpdatedV1; V1 rejects any non-nil withdrawals. Gate this field at Capella or later and extend the table test to require nil for Bellatrix. The old inline construction also had this problem, but the new version-aware constructor and Bellatrix test should not preserve the invalid request shape. -
TestExpectedWithdrawalsReadsTheRightSourcePerForkdoes not prove that the FULL path readswithParentPayload. Both states are fresh and produce the same empty expectation; the test still passes if the source selection is removed. Give the states different withdrawal outcomes and assert the exact result fromwithParentPayload.
Minor follow-ups: remove the contradictory duplicate payloadAttributes documentation, and update the PR description because the parent-root change means the PR is no longer behavior-neutral.
0c0d8af to
b37410c
Compare
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes for one remaining issue:
payloadAttributes still assigns Withdrawals for Bellatrix. expectedWithdrawals returns a non-nil empty slice there, while ForkChoiceUpdate dispatches Bellatrix to engine_forkchoiceUpdatedV1. V1 does not carry withdrawals, and strict execution clients reject the request instead of returning a payload ID. Gate this field at Capella or later and extend the table test to require nil for Bellatrix.
The FULL Gloas state-source test and the duplicate documentation are fixed.
Minor follow-up: the PR description still says "No behaviour change", although the parent beacon block root is now intentionally omitted below Deneb.
|
Fixed in The PR description is updated too. You are right that it was wrong to still call this behaviour-neutral: the refactor is, but the constructor now also omits two fields the chosen wire version cannot express, and both of those made the request fail rather than be ignored. That is spelled out as its own section now. |
| if version.AfterOrEqual(clparams.CapellaVersion) { | ||
| attrs.Withdrawals = withdrawals | ||
| } | ||
| if version.AfterOrEqual(clparams.DenebVersion) { | ||
| attrs.ParentBeaconBlockRoot = parentRoot |
There was a problem hiding this comment.
You are right that nil does not remove the key: PayloadAttributes has no omitempty, so V1 and V2 still receive withdrawals and parentBeaconBlockRoot as null. @yperbasis reached the same point and settled the practical half of it — the engine API schemas do not forbid extra properties, and execution clients validate decoded values rather than key presence, so a null reads as absent. Geth's V1 rejects a non-nil withdrawals list, which is what this PR stops sending, and accepts nil.
So this is a wording problem rather than a defect, and the wording is fixed: the field is left unpopulated, not omitted.
Making the keys genuinely absent is a separate change in engine_types, and not a simple one — bare omitempty on Withdrawals would be wrong, because V2 and later require the empty list to be present on the wire. omitzero is the right tool, in its own PR.
yperbasis
left a comment
There was a problem hiding this comment.
Approving.
Non-blocking follow-ups surfaced by the re-review:
-
Pre-existing: at the Gloas fork boundary the
isPreGloasParentcase leaves the FULL-state copy unset, soexpectedWithdrawalsreturns the emptypayload_expected_withdrawalsinstalled byUpgradeToGloas.processWithdrawalsGloasdoes not early-return there (latest_block_hash == bid.block_hashafter the upgrade) and computes a fresh expectation, soProcessExecutionPayloadEnveloperejects the proposer's own envelope ("withdrawals root mismatch with expected") and the boundary slot loses its payload. The new helper makes the fix small — route the boundary through the fresh-compute source. Please take it as the next PR in the series, with a boundary test. -
Pre-existing on a touched line:
randomcomes fromGetRandaoMixes(a.ethClock.GetCurrentEpoch())— the wall-clock epoch, not the target slot's. A request served after the wall clock crosses into the next epoch reads a not-yet-reset mix, and the block then fails its own randao check inProcessBlock. Usestate.Epoch(baseState), likeemitNextPaylodAttributesEventdoes. -
Consider passing
slotNumber/targetGasLimitintopayloadAttributesas well, so one function owns the versioned schema, and assertTargetGasLimitnon-nil on the Gloas row plus value pass-through in the table test — today deleting theattrs.TargetGasLimitassignment passes the whole suite while every Gloas proposal would get -38003. -
Wording nit, which also addresses the open Copilot comment: nil fields serialize as
nullkeys, not absent ones, so "not populated" is more exact than "left out". This is not a functional defect — the engine API schemas do not forbid extra properties, and ELs validate decoded values only (Geth's V1 rejects non-nil withdrawals, so the old non-nil empty slice failed there and nil passes). If we ever want absent keys, bareomitemptyonWithdrawalswould be wrong since V2+ requires the empty list on the wire;omitzero, in a separateengine_typesPR.
Split out of #23105 so it can be reviewed on its own. No behaviour change. produceBeaconBody chose between three withdrawal sources inline, each with its own hand-written conversion loop, and then assembled the attributes around the result. The choice is now a method that names what it selects between, and the attributes come from a single constructor. That leaves the fork-specific part of production as the two fields only Gloas sends. The three conversion loops go through the shared converter, which is what the doc comment on that converter has been describing.
…eject it The forkchoice call is versioned, and V1 and V2 refuse a parent beacon block root outright, so a field the chosen version does not carry has to be left out rather than sent and ignored. Carrying it below Deneb fails the request instead.
Remove a stale doc block left stacked above the current one, which described the behaviour before the parent beacon block root was version-gated. Give the two Gloas states different withdrawal expectations, so the test proves the revealed path reads the state copy carrying the parent payload instead of merely agreeing with the head state, which two fresh states did whichever was read.
Bellatrix dispatches to the forkchoice call's first version, which has no withdrawals field at all, and a strict execution client rejects a request that includes one rather than ignoring it. The expectation is a non-nil empty slice there, so it was being sent.
The slot number and target gas limit were set by the caller after the constructor, so the constructor did not in fact own the versioned schema and the table test could not see them: deleting either assignment left the whole suite green while every Gloas proposal would have been rejected. Both move inside, and the test asserts the values arrive rather than only that the fields are non-nil. An unpopulated field still appears on the wire as null, so say unpopulated rather than omitted.
3e6e2f9 to
28073af
Compare
|
Thanks for the approval. I took the two points that are about this PR's own code and left the rest for follow-ups, in Point 3 — one owner for the versioned schema. Taken, and it was a real gap rather than a tidiness one: the slot number and target gas limit were assigned by the caller after the constructor, so the constructor did not actually own the schema and the table test could not observe them. Deleting either assignment left the whole suite green while every Gloas proposal would have been rejected with -38003. Both are now parameters, and the test asserts the values arrive rather than only that the fields are non-nil — dropping Point 4 — wording. Fixed, and it also answers the open Copilot thread: nil does not remove the key, so "left unpopulated" rather than "omitted". Agreed this is not a functional defect, for the reason you give — clients validate decoded values, and Geth's V1 rejects a non-nil withdrawals list, which is exactly what this stops sending. I have noted on that thread that making keys genuinely absent needs Points 1 and 2 — separate, and I would rather not fold them in. Both are pre-existing and neither is in this PR's diff:
Say if you would rather see either of them here instead. |
Split out of #23105, which grew too large to review in one piece.
What this does
produceBeaconBodychose between three withdrawal sources inline — a Gloas head whose payload was revealed, a Gloas head whose payload was not, and everything before Gloas — each with its own hand-written conversion loop, and then assembled the payload attributes around whichever it picked.The choice is now a method that names what it selects between, and the attributes come from a single version-aware constructor. What is left inline is the genuinely fork-specific part: the two fields only Gloas sends. The three conversion loops go through the shared converter from #23271, which are the call sites @yperbasis pointed out as still hand-written when reviewing that PR.
Behaviour change
The refactor itself is behaviour-neutral, but the constructor also fixes two cases where the old inline construction built a request the chosen wire version cannot express:
forkchoiceUpdatedV2/V1, andvalidatePayloadAttributesPreFCUrejects a non-nil parent root there withInvalidPayloadAttributesErr;forkchoiceUpdatedV1, which has no withdrawals field, and the expectation is a non-nil empty slice, so one was being sent.Both only bite on the engine transport, and both made the request fail rather than be ignored.
Withdrawal source equivalence
The routing is otherwise unchanged, including the details that are easy to lose in a refactor:
TestExpectedWithdrawalsReadsTheRightSourcePerForkgives the two Gloas states different withdrawal outcomes, so it fails if the source selection is removed rather than passing either way.TestPayloadAttributesOmitFieldsTheChosenVersionCannotCarrypins the two gates across every fork.Part of a series splitting #23105.