Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7898bad
feat: add head_v2 event emission
markolazic01 Jun 9, 2026
2ad8a52
fix: improper input to getForkName
markolazic01 Jun 10, 2026
eecf451
accept undefined payload status
markolazic01 Jun 10, 2026
484d8ce
refactor: simplify head v2 using withVersion()
markolazic01 Jun 10, 2026
9e76ba8
Merge branch 'unstable' into feat/head-v2
markolazic01 Jun 10, 2026
471f5ef
feat: consume head_v2 event in validator chain header tracker
markolazic01 Jun 10, 2026
78fbdd8
Merge branch 'unstable' into feat/head-v2
markolazic01 Jun 10, 2026
142d9c5
chore: remove redundant case clauses + add missing semicolon
markolazic01 Jun 10, 2026
445da6b
fix: gate head_v2 subscription behind GLOAS_FORK_EPOCH config
markolazic01 Jun 11, 2026
9c42ce9
feat: emit head_v2 inside importExecutionPayload and add headV2Payloa…
markolazic01 Jun 11, 2026
8a35439
chore: add metric
markolazic01 Jun 11, 2026
6ae1fee
fix: add missing condition on head_v2 emission in importExecutionPayload
markolazic01 Jun 11, 2026
fd0b255
feat: drop head event post gloas + improvements
markolazic01 Jun 11, 2026
bb28875
refactor: simplify event topic subscription based on gloas config
markolazic01 Jun 11, 2026
656b16f
test: add unit tests
markolazic01 Jun 15, 2026
ea0a5c8
refactor: extract emitHeadV2 helper and add unit tests
markolazic01 Jun 15, 2026
314f04a
fix: add missing headChanged condition + reorg unit test
markolazic01 Jun 15, 2026
e03ab7f
Merge branch 'unstable' into feat/head-v2
markolazic01 Jun 15, 2026
49b6780
Merge branch 'unstable' into feat/head-v2
markolazic01 Jun 30, 2026
94890ac
fix: resolve type errors in headV2Event test
markolazic01 Jul 2, 2026
e9dae84
Merge branch 'unstable' into feat/head-v2
markolazic01 Jul 2, 2026
30cfaf3
Merge branch 'unstable' into feat/head-v2
markolazic01 Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/api/src/beacon/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fuluDataColumnSidecarSSE>;
type GloasDataColumnSidecarSSE = ValueOf<typeof gloasDataColumnSidecarSSE>;
type DataColumnSidecarSSE = FuluDataColumnSidecarSSE | GloasDataColumnSidecarSSE;
type HeadV2 = ValueOf<typeof headV2>;

export enum EventType {
/**
Expand All @@ -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 */
Expand Down Expand Up @@ -116,6 +131,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,
Expand Down Expand Up @@ -150,6 +166,10 @@ export type EventData = {
currentDutyDependentRoot: RootHex;
executionOptimistic: boolean;
};
[EventType.headV2]: {
version: ForkName;
data: HeadV2;
};
[EventType.block]: {
slot: Slot;
block: RootHex;
Expand Down Expand Up @@ -295,7 +315,7 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type
},
{jsonCase: "eth2"}
),

[EventType.headV2]: WithVersion(() => headV2),
[EventType.block]: new ContainerType(
{
slot: ssz.Slot,
Expand Down
13 changes: 13 additions & 0 deletions packages/api/test/unit/beacon/testData/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions packages/beacon-node/src/chain/blocks/headV2Event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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, headChanged: boolean) {

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.

I think reusing the same function in importBlock and importExecutionPayload is a bad abstraction

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.

i will keep this in mind and address it once I handle the emissions properly

if (
headChanged ||
!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";
}
}
4 changes: 3 additions & 1 deletion packages/beacon-node/src/chain/blocks/importBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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";

Expand Down Expand Up @@ -316,9 +317,10 @@ export async function importBlock(
currentDutyDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.current),
executionOptimistic: isOptimisticBlock(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 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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,
Expand Down Expand Up @@ -254,6 +255,12 @@ export async function importExecutionPayload(
});
}

try {
emitHeadV2.call(this, head, false);
} 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(
Expand Down
23 changes: 22 additions & 1 deletion packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -222,6 +229,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<RootHex, {status: PayloadStatus; slot: Slot}>();

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.

not entirely clear to me why we need a cache for this, the second head event can be emitted if your head in fork choice moves from empty to full

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.

well, I added the cache to prevent duplicate emissions between importBlock and importExecutionPayload, but after investigating further I see the importExecutionPayload emission doesn't work correctly since the head is never updated there.
Should I add a head update call after onExecutionPayload, or should both emission types come from importBlock?
Either way the cache can be removed. Let me know what works best.

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.

I think I raised something similar in the PR itself, to me emitting head twice per slot still feels strange but that's what we are supposed to do, I would need to re-read what the decision was on the PR, if it's up to me I would probably get rid of payload_status field or report the payload status of the parent, need to take more time to think about this and re-read what we discussed on the PR


protected readonly blockProcessor: BlockProcessor;
protected readonly payloadEnvelopeProcessor: PayloadEnvelopeProcessor;
protected readonly db: IBeaconDb;
Expand Down Expand Up @@ -1482,6 +1495,14 @@ 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);
}
}
this.metrics?.headV2PayloadStatusCacheSize.set(this.headV2PayloadStatusCache.size);

const metrics = this.metrics;
if (metrics && (slot + 1) % SLOTS_PER_EPOCH === 0) {
// On the last slot of the epoch
Expand Down
4 changes: 4 additions & 0 deletions packages/beacon-node/src/metrics/metrics/beacon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions packages/beacon-node/test/mocks/mockedBeaconChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ vi.mock("../../src/chain/chain.js", async (importActual) => {
canAcceptWork: vi.fn().mockReturnValue(true),
},
emitter: new ChainEventEmitter(),
headV2PayloadStatusCache: new Map(),
};
});

Expand Down
101 changes: 101 additions & 0 deletions packages/beacon-node/test/unit/chain/blocks/headV2Event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {beforeEach, describe, expect, it} from "vitest";
import {routes} from "@lodestar/api";
import {ExecutionStatus, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice";
import {DataAvailabilityStatus} from "@lodestar/state-transition";
import {emitHeadV2} from "../../../../src/chain/blocks/headV2Event.js";
import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js";

const {EventType} = routes.events;

const blockRoot1 = "0xaabb";
const blockRoot2 = "0xbbcc";

function protoBlock(blockRoot: string, 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.PreMerge,
dataAvailabilityStatus: DataAvailabilityStatus.PreData,
payloadStatus,
parentBlockHash: null,
};
}

describe("head_v2 event emission", () => {
let chain: MockedBeaconChain;
const slot = 10;

beforeEach(() => {
chain = getMockedBeaconChain();
chain.forkChoice.getDependentRoot.mockReturnValue("0x1234");
});

it("emits head_v2 with empty on first import", () => {
const events: routes.events.EventData[typeof EventType.headV2][] = [];
chain.emitter.on(EventType.headV2, (data) => events.push(data));

emitHeadV2.call(chain, protoBlock(blockRoot1, slot, PayloadStatus.EMPTY), true);

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][] = [];
chain.emitter.on(EventType.headV2, (data) => events.push(data));

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");
expect(events[1].data.payloadStatus).toBe("full");
});

it("does not re-emit on empty→empty", () => {
const events: routes.events.EventData[typeof EventType.headV2][] = [];
chain.emitter.on(EventType.headV2, (data) => events.push(data));

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");
});

it("does not re-emit on full→full", () => {
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(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");
});
});
38 changes: 34 additions & 4 deletions packages/validator/src/services/chainHeaderTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +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
if (this.config.GLOAS_FORK_EPOCH !== Infinity) {
topics.push(EventType.executionPayloadAvailable);
}
// Post-gloas BNs are required to support head_v2 per beacon-APIs#590,
// so we assume any gloas-configured BN will accept this topic.
const topics =
this.config.GLOAS_FORK_EPOCH === Infinity
? [EventType.head]
: [EventType.headV2, EventType.executionPayloadAvailable];

this.api.events
.eventstream({
Expand Down Expand Up @@ -74,6 +76,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) {
const {message} = event;
const {slot, block, previousDutyDependentRoot, currentDutyDependentRoot} = message;
Expand Down Expand Up @@ -101,6 +104,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,
Comment on lines +116 to +117

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.

maybe we refactor our head event data and add the shim to v1 event, the new semantics of head v2 are much nicer

@markolazic01 markolazic01 Jul 5, 2026

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.

i'm glad to make that change, but maybe it is best to do it in another PR to keep this one readable.
I did some greps and it looks like change touches a lot of places.

let me know if you agree and I will make an issue for this

};

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);

Expand Down