feat: implement head_v2 event - #9486
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for the head_v2 Server-Sent Events (SSE) event type, defining its schema, serialization logic, and emitting it upon block import. The review feedback highlights three key improvements: handling undefined payload status for pre-Bellatrix blocks in toApiPayloadStatus to prevent serialization errors, using newHead.slot instead of blockSlot to accurately determine the fork name for the event version, and simplifying the event type mapping by utilizing the existing WithVersion helper.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 142d9c5b03
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…dStatusCache to chain
|
Only tests left to add |
|
Now ready for review |
head_v2 event
| const {data} = event.message; | ||
| const {slot, block, currentEpochDependentRoot, nextEpochDependentRoot} = data; | ||
| this.headBlockSlot = slot; | ||
| this.headBlockRoot = fromHex(block); |
There was a problem hiding this comment.
since head_v2 is expected to be emitted twice per slot, do we just wanna return here if the block is our headBlockRoot, the vc doesn't really care about the payload status changing
There was a problem hiding this comment.
Agree — early-returning on block === headBlockRoot is correct, and it's purely an efficiency/log-noise change with no behavior impact.
The tracker only ever derives headBlockSlot, headBlockRoot, and the two duty dependent roots from head_v2, and every one of those is identical across a block's EMPTY/FULL variants:
slot/blockare the block's own identity — payload status doesn't change them.currentEpochDependentRoot/nextEpochDependentRootcome fromgetDependentRoot(head, previous|current), which navigates by block/target/parent root and the default (PENDING) variant — it never readspayloadStatus, so it returns the same value for the EMPTY and the FULL emission.
And downstream is already idempotent on a same-root re-emission: attestationDuties/blockDuties/ptcDuties onNewHead all act only when a dependent root changed vs their cache, and waitForBlockSlot is one-shot. So today the second emission does no real work — the only visible effect is a duplicate "Found new chain head" log line every slot.
The thing the VC genuinely cares about across the EMPTY→FULL transition — payload availability — is delivered by the separate execution_payload_available event (PTC's waitForCanonicalPayload listens on that, not head_v2), so nothing on the VC side needs the second head_v2.
Suggested change — key it on root only (root uniquely determines slot) and keep it after the destructure so the legitimate "block only becomes head once its late payload arrives" case still runs (there the new head root differs from the tracked one, so it isn't skipped):
const {slot, block, currentEpochDependentRoot, nextEpochDependentRoot} = data;
+
+ // head_v2 is emitted twice per slot: once when the block becomes head, and again when its
+ // payload transitions EMPTY -> FULL. The VC only tracks head root/slot and duty dependent
+ // roots, which are identical across a block's payload variants (payload availability is
+ // delivered separately via the execution_payload_available event), so skip the redundant
+ // re-emission for the same head.
+ if (this.headBlockRoot !== null && block === toRootHex(this.headBlockRoot)) {
+ return;
+ }
+
this.headBlockSlot = slot;
this.headBlockRoot = fromHex(block);(needs toRootHex added to the @lodestar/utils import — already used this way in ptc.ts.)
There was a problem hiding this comment.
Added this, as recommended, just trimmed the comment a little.
There was a problem hiding this comment.
Verified at 4bccabe5a — looks good. Guard is in the right spot (after the destructure, so a genuine head change to a different root still processes), and keying on root alone is sufficient since a block root fixes its slot. Thanks for trimming the comment. 👍
There was a problem hiding this comment.
Resolved in 4bccabe5a ("refactor: vc skip duplicate head_v2 emission") — markolazic01 applied this in @nflaig's thread above. Verified, closing the loop here.
|
Converted to a draft until adaptations are made. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4729e804b9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const topics = | ||
| this.config.GLOAS_FORK_EPOCH === Infinity | ||
| ? [EventType.head] | ||
| : [EventType.headV2, EventType.executionPayloadAvailable]; |
There was a problem hiding this comment.
Retry head events when head_v2 is rejected
When the VC is pointed at an older beacon node on a network where GLOAS_FORK_EPOCH is finite, this subscribes to head_v2 without also keeping the v1 head fallback. I checked packages/api/src/beacon/server/events.ts and packages/api/src/beacon/client/events.ts: unknown topics are rejected with 400 and the event client closes on 400, so chain-head tracking never starts. Fresh evidence in this version is that the finite-Gloas topics list still excludes EventType.head; keep head in the initial stream or retry v1 on rejection.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
this is a known decision, not to make a fallback
(considered this to be an overkill + head v1 will be removed later)
There was a problem hiding this comment.
Reviewer take: I'd put this a notch above "overkill" — it's a real (if narrow) silent break, and the exact mechanism matters because it changes which fix actually works.
Confirmed against the current head:
- Finite
GLOAS_FORK_EPOCHsubscribes[head_v2, executionPayloadAvailable]with nohead(chainHeaderTracker.ts:46-49). server/events.ts:15-17rejects the whole subscription with 400 if any topic is unknown, andclient/events.ts:57-59treats 400 as unrecoverable and closes the EventSource with no reconnect. So against a BN that doesn't knowhead_v2, the entire validator event stream drops — head tracking andexecutionPayloadAvailable— and the only signal is the verbose"Closed stream for validator events".
The trigger is a network-schedule signal, not a capability one: GLOAS_FORK_EPOCH being finite means the network scheduled Gloas, not that the peered BN's software supports head_v2. During a rolling upgrade after the fork is scheduled — especially a version-skewed or cross-client VC↔BN, which the head/head_v2 split exists to accommodate — a new VC on an older BN hits exactly this. It's not a dead VC (duties still poll), but runOnNewHead reorg handling never fires and getCurrentChainHead stays null, silently.
Two corrections on the fix options:
- "Keep
headin the initial stream" wouldn't actually help here — the server 400s the whole subscription becausehead_v2is the unknown topic, so you'd loseheadtoo. Only "retry with the v1[head]set on 400" works as a fallback. - On "head v1 will be removed later" — I checked Adopt head_v2 event semantics across head-event consumers #9659 (the follow-up I opened for the head_v2 migration): it's scoped to adopting head_v2 semantics internally and explicitly says to preserve v1 compat for external consumers. It doesn't define a v1-removal timeline, so it doesn't really back skipping the fallback.
Not arguing this must block the PR. If our supported policy is "upgrade the BN before the VC" (or same-client/same-version across the Gloas boundary), the decision is defensible — in that case I'd just document that assumption inline and bump the close log from verbose → warn so an operator can actually see it happen. But if version-skewed VC↔BN across the boundary is in scope, the retry-on-400 fallback is the safety net for precisely that transition window. @markolazic01 @nflaig — which are we committing to?
| } else if (prevHead.payloadStatus !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) { | ||
| // Implies that block root is the same, emits on payload status change empty -> full. |
There was a problem hiding this comment.
Recompute after payload imports before relying on head_v2
This same-root empty to full branch only runs when recomputeForkChoiceHead() is called, but the late Gloas payload path mutates fork choice via importExecutionPayload -> forkChoice.onExecutionPayload() and then only reads the cached getHead(). Fresh evidence is that the current payload import path still never calls recomputeForkChoiceHead() after that mutation, so head_v2 subscribers can keep stale payload_status until an unrelated block or slot recompute. Move the recompute or emission to the payload import path after the proto-array mutation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
this is intentional, recompute is the place where 'full' payload variant wins fork choice, so it is the right place to emit
There was a problem hiding this comment.
Verified the mechanism against current head (f21804cbd7) — Codex is right that importExecutionPayload.ts never calls recomputeForkChoiceHead() directly: step 6 mutates via onExecutionPayload(), step 7 only reads the cached getHead() (line 246). Repo-wide, recomputeForkChoiceHead (chain.ts:1186) has exactly two callers: prepareNextSlot.ts:84 (fixed per-slot timer at PREPARE_NEXT_SLOT_BPS=67%) and importBlock.ts:283 (new block import). Neither is reached from the late-payload path.
But this is the same trade-off from the recompute-necessity thread above (root r3624726564), not a separate issue — which is why @markolazic01's "intentional" is correct on mechanism, if terse. That thread's conclusion was explicitly to not call recompute synchronously off the payload mutation (full proto-array walk per import is real cost for a signal only genuinely-late payloads need) and let the existing prepareNextSlot tick pick it up instead.
So head_v2 does still fire for a same-root empty→full flip, just deferred rather than synchronous. Worth being precise about the bound, since "stale until an unrelated block or slot recompute" undersells it: in normal operation the next slot's block import triggers its own recomputeForkChoiceHead and catches the flip almost immediately. Only when the following slot is also skipped does it fall back to the prepareNextSlot timer — ticks are exactly one slot apart, so worst case (flip lands right after a tick) is just under one slot of staleness, not unbounded.
Given that's a known, bounded cost we already accepted for the reasons above, I'd leave this as the documented trade-off rather than a fix. Happy to add a one-line comment at the step-7 getHead() call pointing back at this thread so it doesn't get re-litigated blind next time, if useful.
There was a problem hiding this comment.
Agree — deferring the emit to recomputeForkChoiceHead() is the right call, and it lines up with @twoeths's point in the sibling thread. Recompute is only wired to importBlock and prepareNextSlot, so an empty→full transition surfaces at the next block import or the next slot's prepare — a bounded (≤~1 slot) lag, not "never".
The reason not to close that lag by recomputing in the payload path: at the moment the payload lands (same slot as the block), the EMPTY/FULL tiebreaker just resolves to raw status → FULL, before any PTC/attester input exists to say whether the payload was actually timely. Emitting full there could be immediately contradicted if the network reorgs to empty next slot, so waiting for a real recompute is what makes payload_status meaningful.
And immediate "the payload is here" is already covered separately by execution_payload / execution_payload_available, so subscribers that only need availability aren't affected by the head_v2 lag. So I read the bot's stale-payload_status observation as accurate but intentional, not a gap — no change needed here.
There was a problem hiding this comment.
this looks good to me, great job @markolazic01
my comments are mostly nits and two considerations
- do we wanna support full --> empty
- do we wanna hold off on vc changes until after gloas as using
head_v2early is a risk without any benefit
|
@nflaig I applied all the recommendations and tiny nits, added support for |
|
Verified all seven against
On the VC-support follow-up — I opened #9659 for the broader "adopt head_v2 semantics across consumers" question, and "re-subscribe the VC to |
@lodekeeper please add a note to the issue, I think we can reuse it for this purpose, will tag it for gloas for now, we can check how adoption of |
|
Done — folded it into #9659 (already labeled |
|
@nflaig added the note to #9659 (#9659 (comment)) — VC re-subscription to |
| } else if ( | ||
| (prevHead.payloadStatus !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) || | ||
| (prevHead.payloadStatus !== PayloadStatus.EMPTY && head.payloadStatus === PayloadStatus.EMPTY) | ||
| ) { |
There was a problem hiding this comment.
realized since we also do full --> empty now, we can simply do the following
| } else if ( | |
| (prevHead.payloadStatus !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) || | |
| (prevHead.payloadStatus !== PayloadStatus.EMPTY && head.payloadStatus === PayloadStatus.EMPTY) | |
| ) { | |
| } else if (prevHead.payloadStatus !== head.payloadStatus) { |
There was a problem hiding this comment.
Confirmed — safe to simplify. getHead() never returns a PENDING head: in proto-array a Gloas block is stored PENDING → EMPTY (the EMPTY variant is created as a child of the block's own PENDING node), so PENDING is always an internal node and findHead descends past it to EMPTY/FULL. head.payloadStatus (and prevHead's) is therefore always EMPTY or FULL, and over that domain the verbose check is exactly equivalent to prevHead.payloadStatus !== head.payloadStatus. ✅
There was a problem hiding this comment.
that is so much better, I'll open up a PR with this, think it can be improved even further
**Description** Simplifies conditioning of `head` event emissions. Inspired by #9486 (comment).
Description
Implements server-side emission of the
head_v2event as specified in ethereum/beacon-APIs#590, alongside the existing head event which is kept for backwards compatibility.Closes #9472
Changes
head_v2toEventTypeenum andeventTypesmap inpackages/apiHeadV2SSZ container type andEventDatashapehead_v2from importBlock alongside headtoApiPayloadStatusmapper from fork choicePayloadStatusenum to API string valueshead_v2Edit:
head(v1) emission was adjusted to be inside therecomputeForkChoiceHead()bringing more accuracy. Therefore,head_v2is also emitted there, and not insideimportBlock&importExecutionPayload.TODO
head_v2with fallback to headchainHeadTracker.ts)