From 7898bad72aef60f617b77e97d9af24c8674ff785 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Tue, 9 Jun 2026 12:17:30 +0200 Subject: [PATCH 01/16] feat: add head_v2 event emission --- packages/api/src/beacon/routes/events.ts | 34 ++++++++++++++++++- .../api/test/unit/beacon/testData/events.ts | 13 +++++++ .../src/chain/blocks/importBlock.ts | 24 +++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index 84d4c73e4e02..c7fbaba052b6 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -56,9 +56,23 @@ const gloasDataColumnSidecarSSE = new ContainerType( }, {typeName: "DataColumnSidecarSSE", jsonCase: "eth2"} ); +const headV2 = new ContainerType( + { + slot: ssz.Slot, + block: stringType, + state: stringType, + payloadStatus: stringType, + epochTransition: ssz.Boolean, + currentEpochDependentRoot: stringType, + nextEpochDependentRoot: stringType, + executionOptimistic: ssz.Boolean, + }, + {typeName: "HeadV2", jsonCase: "eth2"} +); type FuluDataColumnSidecarSSE = ValueOf; type GloasDataColumnSidecarSSE = ValueOf; type DataColumnSidecarSSE = FuluDataColumnSidecarSSE | GloasDataColumnSidecarSSE; +type HeadV2 = ValueOf; export enum EventType { /** @@ -68,6 +82,7 @@ export enum EventType { * Both dependent roots use the genesis block root in the case of underflow. */ head = "head", + headV2 = "head_v2", /** The node has received a block (from P2P or API) that is successfully imported on the fork-choice `on_block` handler */ block = "block", /** The node has received a block (from P2P or API) that passes validation rules of the `beacon_block` topic */ @@ -114,6 +129,7 @@ export enum EventType { export const eventTypes: {[K in EventType]: K} = { [EventType.head]: EventType.head, + [EventType.headV2]: EventType.headV2, [EventType.block]: EventType.block, [EventType.blockGossip]: EventType.blockGossip, [EventType.attestation]: EventType.attestation, @@ -147,6 +163,10 @@ export type EventData = { currentDutyDependentRoot: RootHex; executionOptimistic: boolean; }; + [EventType.headV2]: { + version: ForkName; + data: HeadV2; + }; [EventType.block]: { slot: Slot; block: RootHex; @@ -287,7 +307,19 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type }, {jsonCase: "eth2"} ), - + [EventType.headV2]: { + toJson: ({data, version}) => ({ + version, + data: headV2.toJson(data), + }), + fromJson: (val) => { + const {version} = VersionType.fromJson(val); + return { + version, + data: headV2.fromJson((val as {data: unknown}).data), + }; + }, + }, [EventType.block]: new ContainerType( { slot: ssz.Slot, diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index 990b9615130b..bcfc4dc32939 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -30,6 +30,19 @@ export const eventTestData: EventData = { currentDutyDependentRoot: "0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91", executionOptimistic: false, }, + [EventType.headV2]: { + version: ForkName.bellatrix, + data: { + slot: 10, + block: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", + state: "0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9", + payloadStatus: "full", + epochTransition: false, + currentEpochDependentRoot: "0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91", + nextEpochDependentRoot: "0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91", + executionOptimistic: false, + }, + }, [EventType.block]: { slot: 10, block: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index bfb9cecf9d4b..43d1bd21ff87 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -7,6 +7,7 @@ import { ForkChoiceError, ForkChoiceErrorCode, NotReorgedReason, + PayloadStatus, getSafeExecutionBlockHash, } from "@lodestar/fork-choice"; import {ForkPostAltair, ForkPostElectra, ForkSeq, MAX_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; @@ -295,6 +296,19 @@ export async function importBlock( currentDutyDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.current), executionOptimistic: isOptimisticBlock(newHead), }); + this.emitter.emit(routes.events.EventType.headV2, { + version: this.config.getForkName(blockSlot), + data: { + slot: newHead.slot, + block: newHead.blockRoot, + state: newHead.stateRoot, + payloadStatus: toApiPayloadStatus(newHead.payloadStatus), + epochTransition: computeStartSlotAtEpoch(computeEpochAtSlot(newHead.slot)) === newHead.slot, + currentEpochDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.previous), + nextEpochDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.current), + executionOptimistic: isOptimisticBlock(newHead), + }, + }); } catch (e) { // getDependentRoot() may fail with error: "No block for root" as we can see in holesky non-finality issue this.logger.debug("Error emitting head event", {slot: newHead.slot, root: newHead.blockRoot}, e as Error); @@ -665,3 +679,13 @@ export function addAttestationPostElectra( } } } + +function toApiPayloadStatus(status: PayloadStatus): "empty" | "full" { + switch (status) { + case PayloadStatus.FULL: + return "full"; + case PayloadStatus.EMPTY: + case PayloadStatus.PENDING: + return "empty"; + } +} From 2ad8a52c0b1505b396fdd8b94a0a33e4d99536d8 Mon Sep 17 00:00:00 2001 From: Marko Lazic Date: Wed, 10 Jun 2026 13:40:01 +0200 Subject: [PATCH 02/16] fix: improper input to getForkName Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/beacon-node/src/chain/blocks/importBlock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 43d1bd21ff87..509dde6a416a 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -297,7 +297,7 @@ export async function importBlock( executionOptimistic: isOptimisticBlock(newHead), }); this.emitter.emit(routes.events.EventType.headV2, { - version: this.config.getForkName(blockSlot), + version: this.config.getForkName(newHead.slot), data: { slot: newHead.slot, block: newHead.blockRoot, From eecf451fb7933f97ea71960d28c6b95a0b9d621f Mon Sep 17 00:00:00 2001 From: Marko Lazic Date: Wed, 10 Jun 2026 13:40:45 +0200 Subject: [PATCH 03/16] accept undefined payload status Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/beacon-node/src/chain/blocks/importBlock.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 509dde6a416a..144b661a5f9c 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -680,12 +680,13 @@ export function addAttestationPostElectra( } } -function toApiPayloadStatus(status: PayloadStatus): "empty" | "full" { +function toApiPayloadStatus(status?: PayloadStatus): "empty" | "full" { switch (status) { case PayloadStatus.FULL: return "full"; case PayloadStatus.EMPTY: case PayloadStatus.PENDING: + default: return "empty"; } } From 484d8ce337cadeeba4b3e110b21337ee06f57b10 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Wed, 10 Jun 2026 13:46:02 +0200 Subject: [PATCH 04/16] refactor: simplify head v2 using withVersion() --- packages/api/src/beacon/routes/events.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index c7fbaba052b6..eb1e96f16d5b 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -307,19 +307,7 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type }, {jsonCase: "eth2"} ), - [EventType.headV2]: { - toJson: ({data, version}) => ({ - version, - data: headV2.toJson(data), - }), - fromJson: (val) => { - const {version} = VersionType.fromJson(val); - return { - version, - data: headV2.fromJson((val as {data: unknown}).data), - }; - }, - }, + [EventType.headV2]: WithVersion(() => headV2), [EventType.block]: new ContainerType( { slot: ssz.Slot, From 471f5ef9f105deab8a56c01bfabc150d1e216063 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Wed, 10 Jun 2026 20:50:03 +0200 Subject: [PATCH 05/16] feat: consume head_v2 event in validator chain header tracker --- .../src/services/chainHeaderTracker.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/validator/src/services/chainHeaderTracker.ts b/packages/validator/src/services/chainHeaderTracker.ts index 885df270ac51..2ce0b91855df 100644 --- a/packages/validator/src/services/chainHeaderTracker.ts +++ b/packages/validator/src/services/chainHeaderTracker.ts @@ -39,7 +39,7 @@ export class ChainHeaderTracker { start(signal: AbortSignal): void { this.logger.verbose("Subscribing to validator events"); - const topics = [EventType.head]; + const topics = [EventType.head, EventType.headV2]; // We wait until the gloas fork is configured to avoid breaking // connections with pre-gloas beacon nodes if (this.config.GLOAS_FORK_EPOCH !== Infinity) { @@ -101,6 +101,33 @@ export class ChainHeaderTracker { }); } + if (event.type === EventType.headV2) { + const {data} = event.message; + const {slot, block, currentEpochDependentRoot, nextEpochDependentRoot} = data; + this.headBlockSlot = slot; + this.headBlockRoot = fromHex(block); + + const headEventData = { + slot: this.headBlockSlot, + head: block, + previousDutyDependentRoot: currentEpochDependentRoot, + currentDutyDependentRoot: nextEpochDependentRoot, + } + + for (const fn of this.fns) { + fn(headEventData).catch((e) => this.logger.error("Error calling head event handler", e)); + } + + this.emitter.emit(ValidatorEvent.chainHead, headEventData); + + this.logger.verbose("Found new chain head", { + slot: slot, + head: block, + previousDuty: currentEpochDependentRoot, + currentDuty: nextEpochDependentRoot, + }); + } + if (event.type === EventType.executionPayloadAvailable) { this.emitter.emit(ValidatorEvent.executionPayloadAvailable, event.message); From 142d9c5b038f5a8ac3f715e152ade1aced5b9115 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Wed, 10 Jun 2026 20:57:59 +0200 Subject: [PATCH 06/16] chore: remove redundant case clauses + add missing semicolon --- packages/beacon-node/src/chain/blocks/importBlock.ts | 2 -- packages/validator/src/services/chainHeaderTracker.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 144b661a5f9c..b324f0e00046 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -684,8 +684,6 @@ function toApiPayloadStatus(status?: PayloadStatus): "empty" | "full" { switch (status) { case PayloadStatus.FULL: return "full"; - case PayloadStatus.EMPTY: - case PayloadStatus.PENDING: default: return "empty"; } diff --git a/packages/validator/src/services/chainHeaderTracker.ts b/packages/validator/src/services/chainHeaderTracker.ts index 2ce0b91855df..e75dfada1846 100644 --- a/packages/validator/src/services/chainHeaderTracker.ts +++ b/packages/validator/src/services/chainHeaderTracker.ts @@ -112,7 +112,7 @@ export class ChainHeaderTracker { head: block, previousDutyDependentRoot: currentEpochDependentRoot, currentDutyDependentRoot: nextEpochDependentRoot, - } + }; for (const fn of this.fns) { fn(headEventData).catch((e) => this.logger.error("Error calling head event handler", e)); From 445da6be58b54caa74cdec64f5a4b66e0f4d7dd5 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 11 Jun 2026 10:27:17 +0200 Subject: [PATCH 07/16] fix: gate head_v2 subscription behind GLOAS_FORK_EPOCH config --- packages/validator/src/services/chainHeaderTracker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/validator/src/services/chainHeaderTracker.ts b/packages/validator/src/services/chainHeaderTracker.ts index e75dfada1846..8b32657e7b2c 100644 --- a/packages/validator/src/services/chainHeaderTracker.ts +++ b/packages/validator/src/services/chainHeaderTracker.ts @@ -39,11 +39,12 @@ export class ChainHeaderTracker { start(signal: AbortSignal): void { this.logger.verbose("Subscribing to validator events"); - const topics = [EventType.head, EventType.headV2]; + const topics = [EventType.head]; // We wait until the gloas fork is configured to avoid breaking // connections with pre-gloas beacon nodes if (this.config.GLOAS_FORK_EPOCH !== Infinity) { topics.push(EventType.executionPayloadAvailable); + topics.push(EventType.headV2); } this.api.events From 9c42ce9a9e823c6c94b8dec378f73f0ef2133c3b Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 11 Jun 2026 11:54:12 +0200 Subject: [PATCH 08/16] feat: emit head_v2 inside importExecutionPayload and add headV2PayloadStatusCache to chain --- .../src/chain/blocks/importBlock.ts | 3 +- .../chain/blocks/importExecutionPayload.ts | 38 ++++++++++++++++++- packages/beacon-node/src/chain/chain.ts | 22 ++++++++++- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index b324f0e00046..d4044c552a74 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -309,6 +309,7 @@ export async function importBlock( executionOptimistic: isOptimisticBlock(newHead), }, }); + this.headV2PayloadStatusCache.set(newHead.blockRoot, {status: newHead.payloadStatus, slot: newHead.slot}); } catch (e) { // getDependentRoot() may fail with error: "No block for root" as we can see in holesky non-finality issue this.logger.debug("Error emitting head event", {slot: newHead.slot, root: newHead.blockRoot}, e as Error); @@ -680,7 +681,7 @@ export function addAttestationPostElectra( } } -function toApiPayloadStatus(status?: PayloadStatus): "empty" | "full" { +export function toApiPayloadStatus(status?: PayloadStatus): "empty" | "full" { switch (status) { case PayloadStatus.FULL: return "full"; diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index dac0b486c374..10fc3947e79c 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,13 +1,26 @@ import {routes} from "@lodestar/api"; -import {ExecutionStatus, PayloadExecutionStatus, getSafeExecutionBlockHash} from "@lodestar/fork-choice"; -import {DataAvailabilityStatus, isStatePostGloas} from "@lodestar/state-transition"; +import { + EpochDifference, + ExecutionStatus, + PayloadExecutionStatus, + PayloadStatus, + getSafeExecutionBlockHash, +} from "@lodestar/fork-choice"; +import { + DataAvailabilityStatus, + computeEpochAtSlot, + computeStartSlotAtEpoch, + isStatePostGloas, +} from "@lodestar/state-transition"; import {isErrorAborted} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; import {ExecutionPayloadStatus} from "../../execution/index.js"; +import {isOptimisticBlock} from "../../util/forkChoice.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; +import {toApiPayloadStatus} from "./importBlock.js"; import {ImportPayloadOpts} from "./types.js"; import { verifyExecutionPayloadEnvelope, @@ -254,6 +267,27 @@ export async function importExecutionPayload( }); } + if (this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL) { + try { + this.emitter.emit(routes.events.EventType.headV2, { + version: this.config.getForkName(head.slot), + data: { + slot: head.slot, + block: head.blockRoot, + state: head.stateRoot, + payloadStatus: toApiPayloadStatus(head.payloadStatus), + epochTransition: computeStartSlotAtEpoch(computeEpochAtSlot(head.slot)) === head.slot, + currentEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.previous), + nextEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.current), + executionOptimistic: isOptimisticBlock(head), + }, + }); + this.headV2PayloadStatusCache.set(head.blockRoot, {status: head.payloadStatus, slot: head.slot}); + } catch (e) { + this.logger.debug("Error emitting head_v2 event", {slot: head.slot, root: head.blockRoot}, e as Error); + } + } + // 8. Record metrics for payload envelope and column sources const delaySec = this.clock.secFromSlot(slot); this.metrics?.importPayload.elapsedTimeTillImported.observe( diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index c23f1034699c..cfb4ac43d560 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -2,7 +2,14 @@ import path from "node:path"; import {PrivateKey} from "@libp2p/interface"; import {Type} from "@chainsafe/ssz"; import {BeaconConfig} from "@lodestar/config"; -import {CheckpointWithHex, ForkChoiceStateGetter, IForkChoice, ProtoBlock, UpdateHeadOpt} from "@lodestar/fork-choice"; +import { + CheckpointWithHex, + ForkChoiceStateGetter, + IForkChoice, + PayloadStatus, + ProtoBlock, + UpdateHeadOpt, +} from "@lodestar/fork-choice"; import {LoggerNode} from "@lodestar/logger/node"; import { EFFECTIVE_BALANCE_INCREMENT, @@ -223,6 +230,12 @@ export class BeaconChain implements IBeaconChain { readonly opts: IChainOptions; + /** + * Cache of head block payload status at time of head_v2 emission, used to detect + * empty -> full transitions and emit a second head_v2 event accordingly. + */ + readonly headV2PayloadStatusCache = new Map(); + protected readonly blockProcessor: BlockProcessor; protected readonly payloadEnvelopeProcessor: PayloadEnvelopeProcessor; protected readonly db: IBeaconDb; @@ -1483,6 +1496,13 @@ export class BeaconChain implements IBeaconChain { pruneSetToMax(this.blockProductionCache, this.opts.maxCachedProducedRoots ?? DEFAULT_MAX_CACHED_PRODUCED_RESULTS); this.metrics?.blockProductionCacheSize.set(this.blockProductionCache.size); + // Prune outdated head v2 payload statuses + for (const [blockRoot, value] of this.headV2PayloadStatusCache.entries()) { + if (value.slot + SLOTS_PER_EPOCH < slot) { + this.headV2PayloadStatusCache.delete(blockRoot); + } + } + const metrics = this.metrics; if (metrics && (slot + 1) % SLOTS_PER_EPOCH === 0) { // On the last slot of the epoch From 8a354391cacdab6cad46dd83c8cfb9956f9280dc Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 11 Jun 2026 12:02:01 +0200 Subject: [PATCH 09/16] chore: add metric --- packages/beacon-node/src/chain/blocks/importBlock.ts | 1 + .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 1 + packages/beacon-node/src/chain/chain.ts | 1 + packages/beacon-node/src/metrics/metrics/beacon.ts | 4 ++++ 4 files changed, 7 insertions(+) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index d4044c552a74..194572bb1202 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -310,6 +310,7 @@ export async function importBlock( }, }); this.headV2PayloadStatusCache.set(newHead.blockRoot, {status: newHead.payloadStatus, slot: newHead.slot}); + this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); } catch (e) { // getDependentRoot() may fail with error: "No block for root" as we can see in holesky non-finality issue this.logger.debug("Error emitting head event", {slot: newHead.slot, root: newHead.blockRoot}, e as Error); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 10fc3947e79c..ef05245339e1 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -283,6 +283,7 @@ export async function importExecutionPayload( }, }); this.headV2PayloadStatusCache.set(head.blockRoot, {status: head.payloadStatus, slot: head.slot}); + this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); } catch (e) { this.logger.debug("Error emitting head_v2 event", {slot: head.slot, root: head.blockRoot}, e as Error); } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index cfb4ac43d560..182045074d0e 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1502,6 +1502,7 @@ export class BeaconChain implements IBeaconChain { this.headV2PayloadStatusCache.delete(blockRoot); } } + this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); const metrics = this.metrics; if (metrics && (slot + 1) % SLOTS_PER_EPOCH === 0) { diff --git a/packages/beacon-node/src/metrics/metrics/beacon.ts b/packages/beacon-node/src/metrics/metrics/beacon.ts index 4698600dd65c..e4dee8328831 100644 --- a/packages/beacon-node/src/metrics/metrics/beacon.ts +++ b/packages/beacon-node/src/metrics/metrics/beacon.ts @@ -137,6 +137,10 @@ export function createBeaconMetrics(register: RegistryMetricCreator) { name: "beacon_block_production_cache_size", help: "Count of cached produced results", }), + headV2PayloadStatusCacheSize: register.gauge({ + name: "head_v2_payload_status_cache_size", + help: "Count of cached head v2 payload statuses", + }), blockPayload: { payloadAdvancePrepTime: register.histogram({ From 6ae1fee078da30135b276e8087b6726f90fa37ff Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 11 Jun 2026 12:29:45 +0200 Subject: [PATCH 10/16] fix: add missing condition on head_v2 emission in importExecutionPayload --- .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index ef05245339e1..f717d366f03e 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -20,7 +20,6 @@ import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; -import {toApiPayloadStatus} from "./importBlock.js"; import {ImportPayloadOpts} from "./types.js"; import { verifyExecutionPayloadEnvelope, @@ -267,7 +266,7 @@ export async function importExecutionPayload( }); } - if (this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL) { + if (this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) { try { this.emitter.emit(routes.events.EventType.headV2, { version: this.config.getForkName(head.slot), @@ -275,14 +274,14 @@ export async function importExecutionPayload( slot: head.slot, block: head.blockRoot, state: head.stateRoot, - payloadStatus: toApiPayloadStatus(head.payloadStatus), + payloadStatus: "full", epochTransition: computeStartSlotAtEpoch(computeEpochAtSlot(head.slot)) === head.slot, currentEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.previous), nextEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.current), executionOptimistic: isOptimisticBlock(head), }, }); - this.headV2PayloadStatusCache.set(head.blockRoot, {status: head.payloadStatus, slot: head.slot}); + this.headV2PayloadStatusCache.set(head.blockRoot, {status: PayloadStatus.FULL, slot: head.slot}); this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); } catch (e) { this.logger.debug("Error emitting head_v2 event", {slot: head.slot, root: head.blockRoot}, e as Error); From fd0b255f50b66bddfe50326092606c21efd34dfe Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 11 Jun 2026 12:47:59 +0200 Subject: [PATCH 11/16] feat: drop head event post gloas + improvements --- packages/beacon-node/src/chain/blocks/importBlock.ts | 2 +- .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 5 ++++- packages/validator/src/services/chainHeaderTracker.ts | 7 +++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 194572bb1202..0422f21ccfd1 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -313,7 +313,7 @@ export async function importBlock( this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); } catch (e) { // getDependentRoot() may fail with error: "No block for root" as we can see in holesky non-finality issue - this.logger.debug("Error emitting head event", {slot: newHead.slot, root: newHead.blockRoot}, e as Error); + this.logger.debug("Error emitting head/head_v2 event", {slot: newHead.slot, root: newHead.blockRoot}, e as Error); } const delaySec = this.clock.secFromSlot(newHead.slot); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index f717d366f03e..11acc03b45ea 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -266,7 +266,10 @@ export async function importExecutionPayload( }); } - if (this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) { + if ( + this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL && + head.payloadStatus === PayloadStatus.FULL + ) { try { this.emitter.emit(routes.events.EventType.headV2, { version: this.config.getForkName(head.slot), diff --git a/packages/validator/src/services/chainHeaderTracker.ts b/packages/validator/src/services/chainHeaderTracker.ts index 8b32657e7b2c..d7cca9061bb1 100644 --- a/packages/validator/src/services/chainHeaderTracker.ts +++ b/packages/validator/src/services/chainHeaderTracker.ts @@ -42,9 +42,11 @@ export class ChainHeaderTracker { const topics = [EventType.head]; // We wait until the gloas fork is configured to avoid breaking // connections with pre-gloas beacon nodes + // Post-gloas BNs are required to support head_v2 per beacon-APIs#590, + // so we assume any gloas-configured BN will accept this topic. if (this.config.GLOAS_FORK_EPOCH !== Infinity) { - topics.push(EventType.executionPayloadAvailable); topics.push(EventType.headV2); + topics.push(EventType.executionPayloadAvailable); } this.api.events @@ -75,7 +77,8 @@ export class ChainHeaderTracker { } private onEvent = (event: routes.events.BeaconEvent): void => { - if (event.type === EventType.head) { + // Use head_v2 instead when gloas is configured, as head is deprecated post-gloas + if (event.type === EventType.head && this.config.GLOAS_FORK_EPOCH === Infinity) { const {message} = event; const {slot, block, previousDutyDependentRoot, currentDutyDependentRoot} = message; this.headBlockSlot = slot; From bb288752f4549be06bacebbf81434068f6cf795c Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 11 Jun 2026 13:12:16 +0200 Subject: [PATCH 12/16] refactor: simplify event topic subscription based on gloas config --- packages/validator/src/services/chainHeaderTracker.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/validator/src/services/chainHeaderTracker.ts b/packages/validator/src/services/chainHeaderTracker.ts index d7cca9061bb1..0f00079e8419 100644 --- a/packages/validator/src/services/chainHeaderTracker.ts +++ b/packages/validator/src/services/chainHeaderTracker.ts @@ -39,15 +39,14 @@ export class ChainHeaderTracker { start(signal: AbortSignal): void { this.logger.verbose("Subscribing to validator events"); - const topics = [EventType.head]; // We wait until the gloas fork is configured to avoid breaking // connections with pre-gloas beacon nodes // Post-gloas BNs are required to support head_v2 per beacon-APIs#590, // so we assume any gloas-configured BN will accept this topic. - if (this.config.GLOAS_FORK_EPOCH !== Infinity) { - topics.push(EventType.headV2); - topics.push(EventType.executionPayloadAvailable); - } + const topics = + this.config.GLOAS_FORK_EPOCH === Infinity + ? [EventType.head] + : [EventType.headV2, EventType.executionPayloadAvailable]; this.api.events .eventstream({ @@ -78,7 +77,7 @@ export class ChainHeaderTracker { private onEvent = (event: routes.events.BeaconEvent): void => { // Use head_v2 instead when gloas is configured, as head is deprecated post-gloas - if (event.type === EventType.head && this.config.GLOAS_FORK_EPOCH === Infinity) { + if (event.type === EventType.head) { const {message} = event; const {slot, block, previousDutyDependentRoot, currentDutyDependentRoot} = message; this.headBlockSlot = slot; From 656b16ff25ced18612e15dd28cdcc70505a665de Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 15 Jun 2026 15:10:09 +0200 Subject: [PATCH 13/16] test: add unit tests --- .../unit/chain/blocks/headV2Event.test.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts diff --git a/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts b/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts new file mode 100644 index 000000000000..d5a7c8eb04c3 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts @@ -0,0 +1,135 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; +import {ExecutionStatus, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {DataAvailabilityStatus} from "@lodestar/state-transition"; +import {RootHex} from "@lodestar/types"; +import {toApiPayloadStatus} from "../../../../src/chain/blocks/importBlock.js"; +import {ChainEventEmitter} from "../../../../src/chain/emitter.js"; +import {config} from "../../../utils/blocksAndData.js"; + +const {EventType} = routes.events; + +const blockRoot = "0xaabb"; + +function protoBlock(slot: number, payloadStatus: PayloadStatus): ProtoBlock { + return { + slot, + blockRoot, + parentRoot: blockRoot, + stateRoot: blockRoot, + targetRoot: blockRoot, + justifiedEpoch: 0, + justifiedRoot: blockRoot, + finalizedEpoch: 0, + finalizedRoot: blockRoot, + unrealizedJustifiedEpoch: 0, + unrealizedJustifiedRoot: blockRoot, + unrealizedFinalizedEpoch: 0, + unrealizedFinalizedRoot: blockRoot, + timeliness: false, + executionPayloadBlockHash: null, + executionStatus: ExecutionStatus.Valid, + dataAvailabilityStatus: DataAvailabilityStatus.Available, + payloadStatus, + parentBlockHash: null, + }; +} + +function emitHeadV2( + emitter: ChainEventEmitter, + cache: Map, + head: ProtoBlock, + forkChoice: Pick +): void { + if ( + !cache.has(blockRoot) || + (cache.get(blockRoot)?.status !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) + ) { + emitter.emit(EventType.headV2, { + version: config.getForkName(head.slot), + data: { + slot: head.slot, + block: head.blockRoot, + state: head.stateRoot, + payloadStatus: toApiPayloadStatus(head.payloadStatus), + epochTransition: false, + currentEpochDependentRoot: forkChoice.getDependentRoot(head, 0 as any), + nextEpochDependentRoot: forkChoice.getDependentRoot(head, 1 as any), + executionOptimistic: false, + }, + }); + cache.set(head.blockRoot, {status: head.payloadStatus, slot: head.slot}); + } +} + +describe("head_v2 event emission", () => { + let emitter: ChainEventEmitter; + let cache: Map; + let forkChoice: Pick; + const slot = 10; + + beforeEach(() => { + emitter = new ChainEventEmitter(); + cache = new Map(); + forkChoice = { + getDependentRoot: vi.fn().mockReturnValue("0x1234"), + }; + }); + + it("emits head_v2 with empty on first import", () => { + const events: routes.events.EventData[typeof EventType.headV2][] = []; + emitter.on(EventType.headV2, (data) => events.push(data)); + + const head = protoBlock(slot, PayloadStatus.EMPTY); + emitHeadV2(emitter, cache, head, forkChoice); + + expect(events).toHaveLength(1); + expect(events[0].data.payloadStatus).toBe("empty"); + }); + + it("emits head_v2 with full on empty→full transition", () => { + const events: routes.events.EventData[typeof EventType.headV2][] = []; + emitter.on(EventType.headV2, (data) => events.push(data)); + + // First emission: empty + const emptyHead = protoBlock(slot, PayloadStatus.EMPTY); + emitHeadV2(emitter, cache, emptyHead, forkChoice); + + // Second emission: full + const fullHead = protoBlock(slot, PayloadStatus.FULL); + emitHeadV2(emitter, cache, fullHead, forkChoice); + + expect(events).toHaveLength(2); + expect(events[0].data.payloadStatus).toBe("empty"); + expect(events[1].data.payloadStatus).toBe("full"); + }); + + it("does not re-emit on empty→empty (no second emission)", () => { + const events: routes.events.EventData[typeof EventType.headV2][] = []; + emitter.on(EventType.headV2, (data) => events.push(data)); + + const emptyHead = protoBlock(slot, PayloadStatus.EMPTY); + emitHeadV2(emitter, cache, emptyHead, forkChoice); + + // Attempt second emission with still-empty status + const stillEmptyHead = protoBlock(slot, PayloadStatus.EMPTY); + emitHeadV2(emitter, cache, stillEmptyHead, forkChoice); + + expect(events).toHaveLength(1); + expect(events[0].data.payloadStatus).toBe("empty"); + }); + + it("does not re-emit on full→full", () => { + const events: routes.events.EventData[typeof EventType.headV2][] = []; + emitter.on(EventType.headV2, (data) => events.push(data)); + + const fullHead = protoBlock(slot, PayloadStatus.FULL); + emitHeadV2(emitter, cache, fullHead, forkChoice); + + // Attempt second emission with full status + emitHeadV2(emitter, cache, fullHead, forkChoice); + + expect(events).toHaveLength(1); + expect(events[0].data.payloadStatus).toBe("full"); + }); +}); From ea0a5c82e63b85c7269d93d25981a41dc3722eb4 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 15 Jun 2026 21:15:39 +0200 Subject: [PATCH 14/16] refactor: extract emitHeadV2 helper and add unit tests --- .../src/chain/blocks/headV2Event.ts | 38 ++++++++ .../src/chain/blocks/importBlock.ts | 27 +----- .../chain/blocks/importExecutionPayload.ts | 44 ++-------- .../unit/chain/blocks/headV2Event.test.ts | 86 +++++-------------- 4 files changed, 67 insertions(+), 128 deletions(-) create mode 100644 packages/beacon-node/src/chain/blocks/headV2Event.ts diff --git a/packages/beacon-node/src/chain/blocks/headV2Event.ts b/packages/beacon-node/src/chain/blocks/headV2Event.ts new file mode 100644 index 000000000000..d02f2192ed8d --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/headV2Event.ts @@ -0,0 +1,38 @@ +import {routes} from "@lodestar/api"; +import {EpochDifference, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {isOptimisticBlock} from "../../util/forkChoice.js"; +import {BeaconChain} from "../chain.js"; + +export function emitHeadV2(this: BeaconChain, head: ProtoBlock) { + if ( + !this.headV2PayloadStatusCache.has(head.blockRoot) || + (this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL && + head.payloadStatus === PayloadStatus.FULL) + ) { + this.emitter.emit(routes.events.EventType.headV2, { + version: this.config.getForkName(head.slot), + data: { + slot: head.slot, + block: head.blockRoot, + state: head.stateRoot, + payloadStatus: toApiPayloadStatus(head.payloadStatus), + epochTransition: computeStartSlotAtEpoch(computeEpochAtSlot(head.slot)) === head.slot, + currentEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.previous), + nextEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.current), + executionOptimistic: isOptimisticBlock(head), + }, + }); + this.headV2PayloadStatusCache.set(head.blockRoot, {status: head.payloadStatus, slot: head.slot}); + this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); + } +} + +function toApiPayloadStatus(status?: PayloadStatus): "empty" | "full" { + switch (status) { + case PayloadStatus.FULL: + return "full"; + default: + return "empty"; + } +} diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 0422f21ccfd1..14e6bb12c6d7 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -7,7 +7,6 @@ import { ForkChoiceError, ForkChoiceErrorCode, NotReorgedReason, - PayloadStatus, getSafeExecutionBlockHash, } from "@lodestar/fork-choice"; import {ForkPostAltair, ForkPostElectra, ForkSeq, MAX_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; @@ -33,6 +32,7 @@ import {ForkchoiceCaller} from "../forkChoice/index.js"; import {REPROCESS_MIN_TIME_TO_NEXT_SLOT_SEC} from "../reprocess.js"; import {toCheckpointHex} from "../stateCache/persistentCheckpointsCache.js"; import {isBlockInputBlobs, isBlockInputColumns} from "./blockInput/blockInput.js"; +import {emitHeadV2} from "./headV2Event.js"; import {AttestationImportOpt, FullyVerifiedBlock, ImportBlockOpts} from "./types.js"; import {getCheckpointFromState} from "./utils/checkpoint.js"; @@ -296,21 +296,7 @@ export async function importBlock( currentDutyDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.current), executionOptimistic: isOptimisticBlock(newHead), }); - this.emitter.emit(routes.events.EventType.headV2, { - version: this.config.getForkName(newHead.slot), - data: { - slot: newHead.slot, - block: newHead.blockRoot, - state: newHead.stateRoot, - payloadStatus: toApiPayloadStatus(newHead.payloadStatus), - epochTransition: computeStartSlotAtEpoch(computeEpochAtSlot(newHead.slot)) === newHead.slot, - currentEpochDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.previous), - nextEpochDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.current), - executionOptimistic: isOptimisticBlock(newHead), - }, - }); - this.headV2PayloadStatusCache.set(newHead.blockRoot, {status: newHead.payloadStatus, slot: newHead.slot}); - this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); + emitHeadV2.call(this, newHead); } catch (e) { // getDependentRoot() may fail with error: "No block for root" as we can see in holesky non-finality issue this.logger.debug("Error emitting head/head_v2 event", {slot: newHead.slot, root: newHead.blockRoot}, e as Error); @@ -681,12 +667,3 @@ export function addAttestationPostElectra( } } } - -export function toApiPayloadStatus(status?: PayloadStatus): "empty" | "full" { - switch (status) { - case PayloadStatus.FULL: - return "full"; - default: - return "empty"; - } -} diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 11acc03b45ea..024708530aac 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,25 +1,14 @@ import {routes} from "@lodestar/api"; -import { - EpochDifference, - ExecutionStatus, - PayloadExecutionStatus, - PayloadStatus, - getSafeExecutionBlockHash, -} from "@lodestar/fork-choice"; -import { - DataAvailabilityStatus, - computeEpochAtSlot, - computeStartSlotAtEpoch, - isStatePostGloas, -} from "@lodestar/state-transition"; +import {ExecutionStatus, PayloadExecutionStatus, getSafeExecutionBlockHash} from "@lodestar/fork-choice"; +import {DataAvailabilityStatus, isStatePostGloas} from "@lodestar/state-transition"; import {isErrorAborted} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; import {ExecutionPayloadStatus} from "../../execution/index.js"; -import {isOptimisticBlock} from "../../util/forkChoice.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; +import {emitHeadV2} from "./headV2Event.js"; import {ImportPayloadOpts} from "./types.js"; import { verifyExecutionPayloadEnvelope, @@ -266,29 +255,10 @@ export async function importExecutionPayload( }); } - if ( - this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL && - head.payloadStatus === PayloadStatus.FULL - ) { - try { - this.emitter.emit(routes.events.EventType.headV2, { - version: this.config.getForkName(head.slot), - data: { - slot: head.slot, - block: head.blockRoot, - state: head.stateRoot, - payloadStatus: "full", - epochTransition: computeStartSlotAtEpoch(computeEpochAtSlot(head.slot)) === head.slot, - currentEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.previous), - nextEpochDependentRoot: this.forkChoice.getDependentRoot(head, EpochDifference.current), - executionOptimistic: isOptimisticBlock(head), - }, - }); - this.headV2PayloadStatusCache.set(head.blockRoot, {status: PayloadStatus.FULL, slot: head.slot}); - this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size); - } catch (e) { - this.logger.debug("Error emitting head_v2 event", {slot: head.slot, root: head.blockRoot}, e as Error); - } + try { + emitHeadV2.call(this, head); + } catch (e) { + this.logger.debug("Error emitting head_v2 event", {slot: head.slot, root: head.blockRoot}, e as Error); } // 8. Record metrics for payload envelope and column sources diff --git a/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts b/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts index d5a7c8eb04c3..1a3f10af57d6 100644 --- a/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts @@ -1,11 +1,9 @@ -import {beforeEach, describe, expect, it, vi} from "vitest"; +import {beforeEach, describe, expect, it} from "vitest"; import {routes} from "@lodestar/api"; -import {ExecutionStatus, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {ExecutionStatus, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {DataAvailabilityStatus} from "@lodestar/state-transition"; -import {RootHex} from "@lodestar/types"; -import {toApiPayloadStatus} from "../../../../src/chain/blocks/importBlock.js"; -import {ChainEventEmitter} from "../../../../src/chain/emitter.js"; -import {config} from "../../../utils/blocksAndData.js"; +import {emitHeadV2} from "../../../../src/chain/blocks/headV2Event.js"; +import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; const {EventType} = routes.events; @@ -35,53 +33,21 @@ function protoBlock(slot: number, payloadStatus: PayloadStatus): ProtoBlock { }; } -function emitHeadV2( - emitter: ChainEventEmitter, - cache: Map, - head: ProtoBlock, - forkChoice: Pick -): void { - if ( - !cache.has(blockRoot) || - (cache.get(blockRoot)?.status !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) - ) { - emitter.emit(EventType.headV2, { - version: config.getForkName(head.slot), - data: { - slot: head.slot, - block: head.blockRoot, - state: head.stateRoot, - payloadStatus: toApiPayloadStatus(head.payloadStatus), - epochTransition: false, - currentEpochDependentRoot: forkChoice.getDependentRoot(head, 0 as any), - nextEpochDependentRoot: forkChoice.getDependentRoot(head, 1 as any), - executionOptimistic: false, - }, - }); - cache.set(head.blockRoot, {status: head.payloadStatus, slot: head.slot}); - } -} - describe("head_v2 event emission", () => { - let emitter: ChainEventEmitter; - let cache: Map; - let forkChoice: Pick; + let chain: MockedBeaconChain; const slot = 10; beforeEach(() => { - emitter = new ChainEventEmitter(); - cache = new Map(); - forkChoice = { - getDependentRoot: vi.fn().mockReturnValue("0x1234"), - }; + chain = getMockedBeaconChain(); + chain.headV2PayloadStatusCache = new Map(); + chain.forkChoice.getDependentRoot.mockReturnValue("0x1234"); }); it("emits head_v2 with empty on first import", () => { const events: routes.events.EventData[typeof EventType.headV2][] = []; - emitter.on(EventType.headV2, (data) => events.push(data)); + chain.emitter.on(EventType.headV2, (data) => events.push(data)); - const head = protoBlock(slot, PayloadStatus.EMPTY); - emitHeadV2(emitter, cache, head, forkChoice); + emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.EMPTY)); expect(events).toHaveLength(1); expect(events[0].data.payloadStatus).toBe("empty"); @@ -89,31 +55,22 @@ describe("head_v2 event emission", () => { it("emits head_v2 with full on empty→full transition", () => { const events: routes.events.EventData[typeof EventType.headV2][] = []; - emitter.on(EventType.headV2, (data) => events.push(data)); - - // First emission: empty - const emptyHead = protoBlock(slot, PayloadStatus.EMPTY); - emitHeadV2(emitter, cache, emptyHead, forkChoice); + chain.emitter.on(EventType.headV2, (data) => events.push(data)); - // Second emission: full - const fullHead = protoBlock(slot, PayloadStatus.FULL); - emitHeadV2(emitter, cache, fullHead, forkChoice); + emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.EMPTY)); + emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.FULL)); expect(events).toHaveLength(2); expect(events[0].data.payloadStatus).toBe("empty"); expect(events[1].data.payloadStatus).toBe("full"); }); - it("does not re-emit on empty→empty (no second emission)", () => { + it("does not re-emit on empty→empty", () => { const events: routes.events.EventData[typeof EventType.headV2][] = []; - emitter.on(EventType.headV2, (data) => events.push(data)); + chain.emitter.on(EventType.headV2, (data) => events.push(data)); - const emptyHead = protoBlock(slot, PayloadStatus.EMPTY); - emitHeadV2(emitter, cache, emptyHead, forkChoice); - - // Attempt second emission with still-empty status - const stillEmptyHead = protoBlock(slot, PayloadStatus.EMPTY); - emitHeadV2(emitter, cache, stillEmptyHead, forkChoice); + emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.EMPTY)); + emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.EMPTY)); expect(events).toHaveLength(1); expect(events[0].data.payloadStatus).toBe("empty"); @@ -121,13 +78,10 @@ describe("head_v2 event emission", () => { it("does not re-emit on full→full", () => { const events: routes.events.EventData[typeof EventType.headV2][] = []; - emitter.on(EventType.headV2, (data) => events.push(data)); - - const fullHead = protoBlock(slot, PayloadStatus.FULL); - emitHeadV2(emitter, cache, fullHead, forkChoice); + chain.emitter.on(EventType.headV2, (data) => events.push(data)); - // Attempt second emission with full status - emitHeadV2(emitter, cache, fullHead, forkChoice); + emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.FULL)); + emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.FULL)); expect(events).toHaveLength(1); expect(events[0].data.payloadStatus).toBe("full"); From 314f04ace83c167640890b90994b7a1c2f7e82d8 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 15 Jun 2026 23:44:58 +0200 Subject: [PATCH 15/16] fix: add missing headChanged condition + reorg unit test --- .../src/chain/blocks/headV2Event.ts | 3 +- .../src/chain/blocks/importBlock.ts | 2 +- .../chain/blocks/importExecutionPayload.ts | 2 +- .../unit/chain/blocks/headV2Event.test.ts | 31 +++++++++++++------ 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/headV2Event.ts b/packages/beacon-node/src/chain/blocks/headV2Event.ts index d02f2192ed8d..b22f0efbe613 100644 --- a/packages/beacon-node/src/chain/blocks/headV2Event.ts +++ b/packages/beacon-node/src/chain/blocks/headV2Event.ts @@ -4,8 +4,9 @@ import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-trans import {isOptimisticBlock} from "../../util/forkChoice.js"; import {BeaconChain} from "../chain.js"; -export function emitHeadV2(this: BeaconChain, head: ProtoBlock) { +export function emitHeadV2(this: BeaconChain, head: ProtoBlock, headChanged: boolean) { if ( + headChanged || !this.headV2PayloadStatusCache.has(head.blockRoot) || (this.headV2PayloadStatusCache.get(head.blockRoot)?.status !== PayloadStatus.FULL && head.payloadStatus === PayloadStatus.FULL) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 14e6bb12c6d7..5cfb2994bcf0 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -296,7 +296,7 @@ export async function importBlock( currentDutyDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.current), executionOptimistic: isOptimisticBlock(newHead), }); - emitHeadV2.call(this, newHead); + emitHeadV2.call(this, newHead, true); } catch (e) { // getDependentRoot() may fail with error: "No block for root" as we can see in holesky non-finality issue this.logger.debug("Error emitting head/head_v2 event", {slot: newHead.slot, root: newHead.blockRoot}, e as Error); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 024708530aac..1813e1ce480f 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -256,7 +256,7 @@ export async function importExecutionPayload( } try { - emitHeadV2.call(this, head); + emitHeadV2.call(this, head, false); } catch (e) { this.logger.debug("Error emitting head_v2 event", {slot: head.slot, root: head.blockRoot}, e as Error); } diff --git a/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts b/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts index 1a3f10af57d6..9b3d72f6c35b 100644 --- a/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts @@ -7,9 +7,10 @@ import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeac const {EventType} = routes.events; -const blockRoot = "0xaabb"; +const blockRoot1 = "0xaabb"; +const blockRoot2 = "0xbbcc"; -function protoBlock(slot: number, payloadStatus: PayloadStatus): ProtoBlock { +function protoBlock(blockRoot: string, slot: number, payloadStatus: PayloadStatus): ProtoBlock { return { slot, blockRoot, @@ -47,7 +48,7 @@ describe("head_v2 event emission", () => { const events: routes.events.EventData[typeof EventType.headV2][] = []; chain.emitter.on(EventType.headV2, (data) => events.push(data)); - emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.EMPTY)); + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), true); expect(events).toHaveLength(1); expect(events[0].data.payloadStatus).toBe("empty"); @@ -57,8 +58,8 @@ describe("head_v2 event emission", () => { const events: routes.events.EventData[typeof EventType.headV2][] = []; chain.emitter.on(EventType.headV2, (data) => events.push(data)); - emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.EMPTY)); - emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.FULL)); + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), true); + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), false); expect(events).toHaveLength(2); expect(events[0].data.payloadStatus).toBe("empty"); @@ -69,8 +70,8 @@ describe("head_v2 event emission", () => { const events: routes.events.EventData[typeof EventType.headV2][] = []; chain.emitter.on(EventType.headV2, (data) => events.push(data)); - emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.EMPTY)); - emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.EMPTY)); + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), true); + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), false); expect(events).toHaveLength(1); expect(events[0].data.payloadStatus).toBe("empty"); @@ -80,10 +81,22 @@ describe("head_v2 event emission", () => { const events: routes.events.EventData[typeof EventType.headV2][] = []; chain.emitter.on(EventType.headV2, (data) => events.push(data)); - emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.FULL)); - emitHeadV2.call(chain, protoBlock(slot, PayloadStatus.FULL)); + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), true); + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), false); expect(events).toHaveLength(1); expect(events[0].data.payloadStatus).toBe("full"); }); + + it("re-emits on head change to already-cached root (reorg)", () => { + const events: routes.events.EventData[typeof EventType.headV2][] = []; + chain.emitter.on(EventType.headV2, (data) => events.push(data)); + + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), true); + emitHeadV2.call(chain, protoBlock(blockRoot2, slot, PayloadStatus.FULL), true); + emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.FULL), true); + + expect(events).toHaveLength(3); + expect(events[0].data.payloadStatus).toBe("full"); + }); }); From 94890ac0052735357015762ada524ea8e9b44bb8 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 2 Jul 2026 23:55:50 +0200 Subject: [PATCH 16/16] fix: resolve type errors in headV2Event test --- packages/beacon-node/test/mocks/mockedBeaconChain.ts | 1 + .../beacon-node/test/unit/chain/blocks/headV2Event.test.ts | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index ab74100273ca..d003d69c9415 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -186,6 +186,7 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { canAcceptWork: vi.fn().mockReturnValue(true), }, emitter: new ChainEventEmitter(), + headV2PayloadStatusCache: new Map(), }; }); diff --git a/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts b/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts index 9b3d72f6c35b..33e97942d9c1 100644 --- a/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts @@ -27,8 +27,8 @@ function protoBlock(blockRoot: string, slot: number, payloadStatus: PayloadStatu unrealizedFinalizedRoot: blockRoot, timeliness: false, executionPayloadBlockHash: null, - executionStatus: ExecutionStatus.Valid, - dataAvailabilityStatus: DataAvailabilityStatus.Available, + executionStatus: ExecutionStatus.PreMerge, + dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus, parentBlockHash: null, }; @@ -40,7 +40,6 @@ describe("head_v2 event emission", () => { beforeEach(() => { chain = getMockedBeaconChain(); - chain.headV2PayloadStatusCache = new Map(); chain.forkChoice.getDependentRoot.mockReturnValue("0x1234"); });