Skip to content

feat: implement head_v2 event - #9486

Merged
nflaig merged 44 commits into
ChainSafe:unstablefrom
markolazic01:feat/head-v2
Jul 27, 2026
Merged

feat: implement head_v2 event#9486
nflaig merged 44 commits into
ChainSafe:unstablefrom
markolazic01:feat/head-v2

Conversation

@markolazic01

@markolazic01 markolazic01 commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Description

Implements server-side emission of the head_v2 event as specified in ethereum/beacon-APIs#590, alongside the existing head event which is kept for backwards compatibility.

Closes #9472

Changes

  • Add head_v2 to EventType enum and eventTypes map in packages/api
  • Define HeadV2 SSZ container type and EventData shape
  • Emit head_v2 from importBlock alongside head
  • Add toApiPayloadStatus mapper from fork choice PayloadStatus enum to API string values
  • Add test data for head_v2

Edit:

  • Position of head(v1) emission was adjusted to be inside the recomputeForkChoiceHead() bringing more accuracy. Therefore, head_v2 is also emitted there, and not inside importBlock & importExecutionPayload.

TODO

  • VC-side consumption of head_v2 with fallback to head
  • Update duty revalidation logic for attester, proposer-v2, and PTC (updated through changes in chainHeadTracker.ts)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/beacon-node/src/chain/blocks/importBlock.ts Outdated
Comment thread packages/beacon-node/src/chain/blocks/importBlock.ts Outdated
Comment thread packages/api/src/beacon/routes/events.ts Outdated
@markolazic01
markolazic01 marked this pull request as ready for review June 10, 2026 19:12
@markolazic01
markolazic01 requested a review from a team as a code owner June 10, 2026 19:12
@markolazic01
markolazic01 marked this pull request as draft June 10, 2026 22:31
@markolazic01
markolazic01 marked this pull request as ready for review June 10, 2026 22:32

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/validator/src/services/chainHeaderTracker.ts Outdated
Comment thread packages/beacon-node/src/chain/blocks/importBlock.ts Outdated
@markolazic01

Copy link
Copy Markdown
Contributor Author

Only tests left to add

@markolazic01

Copy link
Copy Markdown
Contributor Author

Now ready for review

@markolazic01 markolazic01 changed the title feat: implement head_v2 event feat: implement head_v2 event Jun 19, 2026
const {data} = event.message;
const {slot, block, currentEpochDependentRoot, nextEpochDependentRoot} = data;
this.headBlockSlot = slot;
this.headBlockRoot = fromHex(block);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/block are the block's own identity — payload status doesn't change them.
  • currentEpochDependentRoot/nextEpochDependentRoot come from getDependentRoot(head, previous|current), which navigates by block/target/parent root and the default (PENDING) variant — it never reads payloadStatus, 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.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this, as recommended, just trimmed the comment a little.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 4bccabe5a ("refactor: vc skip duplicate head_v2 emission") — markolazic01 applied this in @nflaig's thread above. Verified, closing the loop here.

@markolazic01
markolazic01 marked this pull request as draft July 24, 2026 12:49
@markolazic01

Copy link
Copy Markdown
Contributor Author

Converted to a draft until adaptations are made.

@markolazic01
markolazic01 marked this pull request as ready for review July 24, 2026 17:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +46 to +49
const topics =
this.config.GLOAS_FORK_EPOCH === Infinity
? [EventType.head]
: [EventType.headV2, EventType.executionPayloadAvailable];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a known decision, not to make a fallback
(considered this to be an overkill + head v1 will be removed later)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_EPOCH subscribes [head_v2, executionPayloadAvailable] with no head (chainHeaderTracker.ts:46-49).
  • server/events.ts:15-17 rejects the whole subscription with 400 if any topic is unknown, and client/events.ts:57-59 treats 400 as unrecoverable and closes the EventSource with no reconnect. So against a BN that doesn't know head_v2, the entire validator event stream drops — head tracking and executionPayloadAvailable — 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 head in the initial stream" wouldn't actually help here — the server 400s the whole subscription because head_v2 is the unknown topic, so you'd lose head too. 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 verbosewarn 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?

Comment thread packages/beacon-node/src/chain/chain.ts Outdated
Comment on lines +1228 to +1229
} else if (prevHead.payloadStatus !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) {
// Implies that block root is the same, emits on payload status change empty -> full.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is intentional, recompute is the place where 'full' payload variant wins fork choice, so it is the right place to emit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@nflaig nflaig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks good to me, great job @markolazic01

my comments are mostly nits and two considerations

  1. do we wanna support full --> empty
  2. do we wanna hold off on vc changes until after gloas as using head_v2 early is a risk without any benefit

Comment thread packages/api/src/beacon/routes/events.ts
Comment thread packages/api/test/unit/beacon/testData/events.ts Outdated
Comment thread packages/api/src/beacon/routes/events.ts
Comment thread packages/api/src/beacon/routes/events.ts Outdated
Comment thread packages/beacon-node/src/chain/chain.ts Outdated
Comment thread packages/validator/src/services/chainHeaderTracker.ts Outdated
@markolazic01

Copy link
Copy Markdown
Contributor Author

@nflaig I applied all the recommendations and tiny nits, added support for full->empty payload status transition and removed vc support as it really doesn't bring value atm.
Maybe we should create an issue for vc support for later.

@lodekeeper

Copy link
Copy Markdown
Contributor

Verified all seven against eb0ecffaf8 — everything's applied (confirmed in-thread on each), and I double-checked the two considerations from @nflaig's review body:

  • full→empty: the chain.ts else if now emits on both empty→full and full→empty, and I checked the condition only fires on genuine transitions (no spurious/PENDING emits). ✅
  • VC changes: reverted to EventType.head (v1), head_v2 subscription + handler removed. ✅

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 head_v2 once clients broadly support it (gated on the post-heze timeline @nflaig raised)" fits naturally there. Happy to fold it into #9659, or open a focused issue if you'd rather track the client-support gating on its own — your call.

@nflaig

nflaig commented Jul 27, 2026

Copy link
Copy Markdown
Member

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 head_v2 once clients broadly support it (gated on the post-heze timeline @nflaig raised)" fits naturally there. Happy to fold it into #9659, or open a focused issue if you'd rather track the client-support gating on its own — your call.

@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 head_v2 across client progresses until mainnet, and may consider using it after gloas fork.

Comment thread packages/api/test/unit/beacon/testData/events.ts

@nflaig nflaig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@lodekeeper

Copy link
Copy Markdown
Contributor

Done — folded it into #9659 (already labeled spec-gloas, so it's tagged for gloas as you suggested) rather than opening a focused issue: re-subscribe the VC from head to head_v2 once head_v2 is broadly supported across clients, revisiting as adoption progresses toward mainnet and potentially enabling after the gloas fork.

@nflaig
nflaig merged commit cdb6b1a into ChainSafe:unstable Jul 27, 2026
19 checks passed
@lodekeeper

Copy link
Copy Markdown
Contributor

@nflaig added the note to #9659 (#9659 (comment)) — VC re-subscription to head_v2 gated on cross-client adoption, to revisit after the gloas fork. Thanks!

Comment on lines +1228 to +1231
} else if (
(prevHead.payloadStatus !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) ||
(prevHead.payloadStatus !== PayloadStatus.EMPTY && head.payloadStatus === PayloadStatus.EMPTY)
) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

realized since we also do full --> empty now, we can simply do the following

Suggested change
} else if (
(prevHead.payloadStatus !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) ||
(prevHead.payloadStatus !== PayloadStatus.EMPTY && head.payloadStatus === PayloadStatus.EMPTY)
) {
} else if (prevHead.payloadStatus !== head.payloadStatus) {

@lodekeeper lodekeeper Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. ✅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is so much better, I'll open up a PR with this, think it can be improved even further

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nflaig pushed a commit that referenced this pull request Jul 28, 2026
**Description**

Simplifies conditioning of `head` event emissions.
Inspired by
#9486 (comment).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Implement head_v2 event

7 participants