From 257ff69d5c721e353ca53f1305575edb59d1c27e Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 24 Mar 2026 16:04:15 +0700 Subject: [PATCH 01/19] feat: implement network processor for gloas --- packages/beacon-node/src/chain/chain.ts | 4 + packages/beacon-node/src/chain/emitter.ts | 6 + packages/beacon-node/src/chain/interface.ts | 2 + .../src/chain/validation/attestation.ts | 6 +- .../src/metrics/metrics/lodestar.ts | 34 +++ packages/beacon-node/src/network/interface.ts | 1 + packages/beacon-node/src/network/network.ts | 4 + .../network/processor/extractSlotRootFns.ts | 34 ++- .../src/network/processor/gossipHandlers.ts | 1 + .../src/network/processor/index.ts | 245 +++++++++++++++++- packages/beacon-node/src/util/sszBytes.ts | 117 ++++++++- .../test/unit/util/sszBytes.test.ts | 155 ++++++++++- .../fork-choice/src/forkChoice/forkChoice.ts | 15 ++ .../fork-choice/src/forkChoice/interface.ts | 6 + .../fork-choice/src/protoArray/protoArray.ts | 10 + 15 files changed, 609 insertions(+), 31 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index ea2b9870eea1..c4b05ea7e44a 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -507,6 +507,10 @@ export class BeaconChain implements IBeaconChain { return this.seenBlockInputCache.has(blockRoot) || this.forkChoice.hasBlockHex(blockRoot); } + seenEnvelope(blockRoot: RootHex): boolean { + return this.seenPayloadEnvelopeInputCache.has(blockRoot) || this.forkChoice.hasEnvelopeHexUnsafe(blockRoot); + } + regenCanAcceptWork(): boolean { return this.regen.canAcceptWork(); } diff --git a/packages/beacon-node/src/chain/emitter.ts b/packages/beacon-node/src/chain/emitter.ts index 24c5e61894fe..e132022804a1 100644 --- a/packages/beacon-node/src/chain/emitter.ts +++ b/packages/beacon-node/src/chain/emitter.ts @@ -66,6 +66,10 @@ export enum ChainEvent { * cut-off window passes for waiting on gossip */ incompleteBlockInput = "incompleteBlockInput", + /** + * Trigger sync for objects that correspond to a payload envelope (SignedExecutionPayloadEnvelope) that is not yet known + */ + unknownEnvelopeBlockRoot = "unknownEnvelopeBlockRoot", } export type HeadEventData = routes.events.EventData[routes.events.EventType.head]; @@ -78,6 +82,7 @@ export type ChainEventData = { [ChainEvent.unknownParent]: {blockInput: IBlockInput; peer: PeerIdStr; source: BlockInputSource}; [ChainEvent.unknownBlockRoot]: {rootHex: RootHex; peer?: PeerIdStr; source: BlockInputSource}; [ChainEvent.incompleteBlockInput]: {blockInput: IBlockInput; peer: PeerIdStr; source: BlockInputSource}; + [ChainEvent.unknownEnvelopeBlockRoot]: {rootHex: RootHex; peer?: PeerIdStr; source: BlockInputSource}; }; export type IChainEvents = ApiEvents & { @@ -99,6 +104,7 @@ export type IChainEvents = ApiEvents & { [ChainEvent.unknownParent]: (data: ChainEventData[ChainEvent.unknownParent]) => void; [ChainEvent.unknownBlockRoot]: (data: ChainEventData[ChainEvent.unknownBlockRoot]) => void; [ChainEvent.incompleteBlockInput]: (data: ChainEventData[ChainEvent.incompleteBlockInput]) => void; + [ChainEvent.unknownEnvelopeBlockRoot]: (data: ChainEventData[ChainEvent.unknownEnvelopeBlockRoot]) => void; }; /** diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index d6d31e6a2a4e..a69f79b425f4 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -159,6 +159,8 @@ export interface IBeaconChain { close(): Promise; /** Chain has seen the specified block root or not. The block may not be processed yet, use forkchoice.hasBlock to check it */ seenBlock(blockRoot: RootHex): boolean; + /** Chain has seen a SignedExecutionPayloadEnvelope for this block root (via seenCache or fork choice FULL variant) */ + seenEnvelope(blockRoot: RootHex): boolean; /** Populate in-memory caches with persisted data. Call at least once on startup */ loadFromDisk(): Promise; /** Persist in-memory data to the DB. Call at least once before stopping the process */ diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index d410469ed1c6..8325e4772e41 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -44,7 +44,7 @@ import { getAttDataFromSignedAggregateAndProofElectra, getAttDataFromSignedAggregateAndProofPhase0, getAttesterIndexFromSingleAttestationSerialized, - getCommitteeIndexFromSingleAttestationSerialized, + getIndexFromSingleAttestationSerialized, getSignatureFromAttestationSerialized, getSignatureFromSingleAttestationSerialized, } from "../../util/sszBytes.js"; @@ -882,12 +882,12 @@ export function getCommitteeIndexFromAttestationOrBytes( if (isForkPostElectra(fork)) { if (isGossipAttestation) { - return getCommitteeIndexFromSingleAttestationSerialized(ForkName.electra, attestationOrBytes.serializedData); + return getIndexFromSingleAttestationSerialized(ForkName.electra, attestationOrBytes.serializedData); } return (attestationOrBytes.attestation as SingleAttestation).committeeIndex; } if (isGossipAttestation) { - return getCommitteeIndexFromSingleAttestationSerialized(ForkName.phase0, attestationOrBytes.serializedData); + return getIndexFromSingleAttestationSerialized(ForkName.phase0, attestationOrBytes.serializedData); } return (attestationOrBytes.attestation as SingleAttestation).data.index; } diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 00087979fc9a..358054bdd8e9 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -1746,6 +1746,40 @@ export function createLodestarMetrics( }), }, + // some gossip messages need to wait for payload to be processed before they can be processed + awaitingPayloadGossipMessages: { + queue: register.gauge<{topic: GossipType}>({ + name: "lodestar_awaiting_payload_gossip_messages_total", + help: "Total number of gossip messages waiting for payload to be processed", + labelNames: ["topic"], + }), + countPerSlot: register.gauge({ + name: "lodestar_awaiting_payload_gossip_messages_per_slot_total", + help: "Total number of gossip messages waiting for payload to be processed per slot", + }), + resolve: register.gauge<{topic: GossipType}>({ + name: "lodestar_awaiting_payload_gossip_messages_resolve_total", + help: "Total number of gossip messages are reprocessed", + labelNames: ["topic"], + }), + waitSecBeforeResolve: register.gauge<{topic: GossipType}>({ + name: "lodestar_awaiting_payload_gossip_messages_wait_time_resolve_seconds", + help: "Time to wait for unknown payload in seconds", + labelNames: ["topic"], + }), + // having 2 labels here is not great for performance, however it's rarely happening and having the reason label is important for debugging + reject: register.gauge<{reason: ReprocessRejectReason; topic: GossipType}>({ + name: "lodestar_awaiting_payload_gossip_messages_reject_total", + help: "Total number of gossip messages are rejected to reprocess", + labelNames: ["reason", "topic"], + }), + waitSecBeforeReject: register.gauge<{reason: ReprocessRejectReason; topic: GossipType}>({ + name: "lodestar_awaiting_payload_gossip_messages_wait_time_reject_seconds", + help: "Time to wait for unknown payload before being rejected", + labelNames: ["reason", "topic"], + }), + }, + lightclientServer: { onSyncAggregate: register.gauge<{event: string}>({ name: "lodestar_lightclient_server_on_sync_aggregate_event_total", diff --git a/packages/beacon-node/src/network/interface.ts b/packages/beacon-node/src/network/interface.ts index 0c748e3eef70..c5afa3a950a1 100644 --- a/packages/beacon-node/src/network/interface.ts +++ b/packages/beacon-node/src/network/interface.ts @@ -74,6 +74,7 @@ export interface INetwork extends INetworkCorePublic { shouldAggregate(subnet: SubnetID, slot: Slot): boolean; reStatusPeers(peers: PeerIdStr[]): Promise; searchUnknownBlock(slotRoot: SlotRootHex, source: BlockInputSource, peer?: PeerIdStr): void; + searchUnknownEnvelope(slotRoot: SlotRootHex, source: BlockInputSource, peer?: PeerIdStr): void; // ReqResp sendBeaconBlocksByRange(peerId: PeerIdStr, request: phase0.BeaconBlocksByRangeRequest): Promise; sendBeaconBlocksByRoot(peerId: PeerIdStr, request: BeaconBlocksByRootRequest): Promise; diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 913aa3e625ca..20acbe17c918 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -286,6 +286,10 @@ export class Network implements INetwork { this.networkProcessor.searchUnknownBlock(slotRoot, source, peer); } + searchUnknownEnvelope(slotRoot: SlotRootHex, source: BlockInputSource, peer?: PeerIdStr): void { + this.networkProcessor.searchUnknownEnvelope(slotRoot, source, peer); + } + async reportPeer(peer: PeerIdStr, action: PeerAction, actionName: string): Promise { return this.core.reportPeer(peer, action, actionName); } diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 33e8982cea2a..8359fc846f89 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -1,16 +1,19 @@ -import {ForkName, isForkPostGloas} from "@lodestar/params"; +import {ForkName, ForkSeq} from "@lodestar/params"; import {SlotOptionalRoot, SlotRootHex} from "@lodestar/types"; import { getBeaconBlockRootFromDataColumnSidecarSerialized, getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getBlockRootFromBeaconAttestationSerialized, + getBlockRootFromPayloadAttestationMessageSerialized, getBlockRootFromSignedAggregateAndProofSerialized, getSlotFromBeaconAttestationSerialized, getSlotFromBlobSidecarSerialized, getSlotFromDataColumnSidecarSerialized, getSlotFromExecutionPayloadEnvelopeSerialized, + getSlotFromPayloadAttestationMessageSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, + getSlotFromSignedExecutionPayloadBidSerialized, } from "../../util/sszBytes.js"; import {GossipType} from "../gossip/index.js"; import {ExtractSlotRootFns} from "./types.js"; @@ -57,11 +60,16 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { }, [GossipType.data_column_sidecar]: (data: Uint8Array, fork: ForkName): SlotOptionalRoot | null => { const slot = getSlotFromDataColumnSidecarSerialized(data, fork); + if (slot === null) { return null; } - const root = isForkPostGloas(fork) ? getBeaconBlockRootFromDataColumnSidecarSerialized(data) : null; + if (ForkSeq[fork] < ForkSeq.gloas) { + return {slot}; + } + + const root = getBeaconBlockRootFromDataColumnSidecarSerialized(data); return root !== null ? {slot, root} : {slot}; }, [GossipType.execution_payload]: (data: Uint8Array): SlotRootHex | null => { @@ -73,5 +81,27 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { } return {slot, root}; }, + [GossipType.payload_attestation_message]: (data: Uint8Array): SlotRootHex | null => { + const slot = getSlotFromPayloadAttestationMessageSerialized(data); + const root = getBlockRootFromPayloadAttestationMessageSerialized(data); + + if (slot === null || root === null) { + return null; + } + return {slot, root}; + }, + [GossipType.execution_payload_bid]: (data: Uint8Array): SlotOptionalRoot | null => { + const slot = getSlotFromSignedExecutionPayloadBidSerialized(data); + + if (slot === null) { + return null; + } + + // Don't extract a root here — the bid's awaiting logic is handled explicitly + // in the processor switch case using getParentBlockRootFromSignedExecutionPayloadBidSerialized. + // Returning a root here would cause the initial block-root check to queue this message + // in awaitingMessagesByBlockRoot under a garbage key. + return {slot}; + }, }; } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 8c4c43f4ae99..ed34a576a84b 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -187,6 +187,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand } catch (e) { if (e instanceof BlockGossipError) { if (e.type.code === BlockErrorCode.PARENT_UNKNOWN && blockInput) { + // TODO GLOAS: dead code logger.debug("Gossip block has error", {slot, root: blockShortHex, code: e.type.code}); chain.emitter.emit(ChainEvent.unknownParent, { blockInput, diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index 73e52c382487..cf77f623761b 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -12,6 +12,13 @@ import {Metrics} from "../../metrics/metrics.js"; import {ClockEvent} from "../../util/clock.js"; import {callInNextEventLoop} from "../../util/eventLoop.js"; import {PeerIdStr} from "../../util/peerId.js"; +import { + getIndexFromSignedAggregateAndProofSerialized, + getIndexFromSingleAttestationSerialized, + getParentBlockHashFromSignedExecutionPayloadBidSerialized, + getParentBlockRootFromSignedExecutionPayloadBidSerialized, + getPayloadPresentFromPayloadAttestationMessageSerialized, +} from "../../util/sszBytes.ts"; import {NetworkEvent, NetworkEventBus} from "../events.js"; import { GossipHandlers, @@ -89,6 +96,8 @@ const MAX_JOBS_SUBMITTED_PER_TICK = 128; // How many gossip messages we keep before new ones get dropped. const MAX_QUEUED_UNKNOWN_BLOCK_GOSSIP_OBJECTS = 16_384; +// TODO gloas: arbitrary constant, check metrics. +const MAX_QUEUED_UNKNOWN_PAYLOAD_GOSSIP_OBJECTS = 1024; // We don't want to process too many gossip messages in a single tick // As seen on mainnet, gossip messages concurrency metric ranges from 1000 to 2000 @@ -126,6 +135,15 @@ export enum CannotAcceptWorkReason { regen = "regen_busy", } +/** + * No need metrics for this so make it as numeric to make it ligghtweight + */ +enum PreProcessAction { + await_block, + await_envelope, + push_to_queue, +} + /** * Network processor handles the gossip queues and throtles processing to not overload the main thread * - Decides when to process work and what to process @@ -159,7 +177,11 @@ export class NetworkProcessor { // we may not receive the block for messages like Attestation and SignedAggregateAndProof messages, in that case PendingGossipsubMessage needs // to be stored in this Map and reprocessed once the block comes private readonly awaitingMessagesByBlockRoot: MapDef>; + // we may not receive the payload for messages like payload_attestation_message messages, in that case PendingGossipsubMessage needs + // to be stored in this Map and reprocessed once the payload comes + private readonly awaitingMessagesByPayloadBlockRoot: MapDef>; private unknownBlocksBySlot = new MapDef>(() => new Set()); + private unknownEnvelopesBySlot = new MapDef>(() => new Set()); constructor( modules: NetworkProcessorModules, @@ -181,9 +203,14 @@ export class NetworkProcessor { events.on(NetworkEvent.pendingGossipsubMessage, this.onPendingGossipsubMessage.bind(this)); this.chain.emitter.on(routes.events.EventType.block, this.onBlockProcessed.bind(this)); + this.chain.emitter.on( + routes.events.EventType.executionPayloadAvailable, + this.onPayloadEnvelopeProcessed.bind(this) + ); this.chain.clock.on(ClockEvent.slot, this.onClockSlot.bind(this)); this.awaitingMessagesByBlockRoot = new MapDef>(() => new Set()); + this.awaitingMessagesByPayloadBlockRoot = new MapDef>(() => new Set()); // TODO: Implement queues and priorization for ReqResp incoming requests // Listens to NetworkEvent.reqRespIncomingRequest event @@ -196,6 +223,7 @@ export class NetworkProcessor { metrics.gossipValidationQueue.concurrency.set({topic}, this.gossipTopicConcurrency[topic]); } metrics.awaitingBlockGossipMessages.countPerSlot.set(this.unknownBlockGossipsubMessagesCount); + metrics.awaitingPayloadGossipMessages.countPerSlot.set(this.unknownPayloadGossipsubMessagesCount); // specific metric for beacon_attestation topic metrics.gossipValidationQueue.keyAge.reset(); for (const ageMs of this.gossipQueues.beacon_attestation.getDataAgeMs()) { @@ -212,6 +240,7 @@ export class NetworkProcessor { async stop(): Promise { this.events.off(NetworkEvent.pendingGossipsubMessage, this.onPendingGossipsubMessage); this.chain.emitter.off(routes.events.EventType.block, this.onBlockProcessed); + this.chain.emitter.off(routes.events.EventType.executionPayloadAvailable, this.onPayloadEnvelopeProcessed); this.chain.emitter.off(ClockEvent.slot, this.onClockSlot); } @@ -248,6 +277,23 @@ export class NetworkProcessor { this.chain.emitter.emit(ChainEvent.unknownBlockRoot, {rootHex: root, peer, source}); } + /** + * Search envelope via `ChainEvent.unknownEnvelopeBlockRoot` event + * Note that slot is not necessarily the same to the envelope's slot but it can be used for a good prune strategy. + * In the rare case, if 2 messages on 2 slots search for the same root (for example beacon_attestation) we may emit the same root twice but BlockInputSync should handle it well. + */ + searchUnknownEnvelope({slot, root}: SlotRootHex, source: BlockInputSource, peer?: PeerIdStr): void { + if ( + this.chain.seenEnvelope(root) || + this.awaitingMessagesByPayloadBlockRoot.has(root) || + this.unknownEnvelopesBySlot.getOrDefault(slot).has(root) + ) { + return; + } + this.unknownEnvelopesBySlot.getOrDefault(slot).add(root); + this.chain.emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, {rootHex: root, peer, source}); + } + private onPendingGossipsubMessage(message: PendingGossipsubMessage): void { const topicType = message.topic.type; const extractBlockSlotRootFn = this.extractBlockSlotRootFns[topicType]; @@ -282,26 +328,142 @@ export class NetworkProcessor { message.msgSlot = slot; + // this to determine this message needs to wait for Block or Envelope + // a message should only be waited for what they voted for, hence we don't want to put them on both queues + let preProcessAction = PreProcessAction.push_to_queue; // no need to check if root is a descendant of the current finalized block, it will be checked once we validate the message if needed if (root && !this.chain.forkChoice.hasBlockHexUnsafe(root)) { + // starting from GLOAS, unknown root from data_column_sidecar also falls into this case this.searchUnknownBlock({slot, root}, BlockInputSource.network_processor, message.propagationSource.toString()); + // for beacon_attestation and beacon_aggregate_and_proof messages, this is only temporary + // if "index = 1" we need to await for Envelope instead + preProcessAction = PreProcessAction.await_block; + } - if (this.unknownBlockGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_BLOCK_GOSSIP_OBJECTS) { - // No need to report the dropped job to gossip. It will be eventually pruned from the mcache - this.metrics?.awaitingBlockGossipMessages.reject.inc({ - reason: ReprocessRejectReason.reached_limit, - topic: topicType, - }); - return; + if (ForkSeq[fork] >= ForkSeq.gloas) { + // specific check for each topic + // note that it's supposed to NOT queues for beacon_block and execution_payload because it's not a one-off + // for those topics, gossip handlers will throw and BlockInputSync will handle a tree of them instead + switch (topicType) { + case GossipType.beacon_attestation: + case GossipType.beacon_aggregate_and_proof: { + if (root == null) break; + const attIndex = + topicType === GossipType.beacon_attestation + ? getIndexFromSingleAttestationSerialized(fork, message.msg.data) + : getIndexFromSignedAggregateAndProofSerialized(message.msg.data); + if (attIndex === 1 && !this.chain.forkChoice.hasEnvelopeHexUnsafe(root)) { + this.searchUnknownEnvelope( + {slot, root}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + preProcessAction = PreProcessAction.await_envelope; + } + break; + } + case GossipType.payload_attestation_message: { + if (root == null) break; + const payloadPresent = getPayloadPresentFromPayloadAttestationMessageSerialized(message.msg.data); + if (payloadPresent && !this.chain.forkChoice.hasEnvelopeHexUnsafe(root)) { + this.searchUnknownEnvelope( + {slot, root}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + preProcessAction = PreProcessAction.await_envelope; + } + break; + } + case GossipType.data_column_sidecar: { + if (root == null) break; + if (!this.chain.forkChoice.hasEnvelopeHexUnsafe(root)) { + this.searchUnknownEnvelope( + {slot, root}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + preProcessAction = PreProcessAction.await_envelope; + } + break; + } + // TODO GLOAS: handle for beacon_block and execution_payload too, but do not queue them + case GossipType.execution_payload_bid: { + // instead of search for root, this searches for parent root + const parentBlockRoot = getParentBlockRootFromSignedExecutionPayloadBidSerialized(message.msg.data); + const parentBlockHash = getParentBlockHashFromSignedExecutionPayloadBidSerialized(message.msg.data); + if ( + parentBlockRoot && + parentBlockHash && + !this.chain.forkChoice.getBlockHexAndBlockHash(parentBlockRoot, parentBlockHash) + ) { + const protoBlock = this.chain.forkChoice.getBlockHexDefaultStatus(parentBlockRoot); + if (protoBlock === null) { + this.searchUnknownBlock( + {slot, root: parentBlockRoot}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + } + if (protoBlock?.executionPayloadBlockHash && protoBlock?.executionPayloadBlockHash !== parentBlockHash) { + this.searchUnknownEnvelope( + {slot, root: parentBlockRoot}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + } + + // don't queue for this execution_payload_bid message because PreProcessAction.await_* expect non-null root + // it's only an issue if we're the block proposer + // other gossip messages of the previous slot should search for the missing block/envelope anyway + } + break; + } } - - this.metrics?.awaitingBlockGossipMessages.queue.inc({topic: topicType}); - const awaitingGossipsubMessages = this.awaitingMessagesByBlockRoot.getOrDefault(root); - awaitingGossipsubMessages.add(message); - return; } - this.pushPendingGossipsubMessageToQueue(message); + switch (preProcessAction) { + case PreProcessAction.push_to_queue: + this.pushPendingGossipsubMessageToQueue(message); + break; + case PreProcessAction.await_block: { + if (this.unknownBlockGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_BLOCK_GOSSIP_OBJECTS) { + // No need to report the dropped job to gossip. It will be eventually pruned from the mcache + this.metrics?.awaitingBlockGossipMessages.reject.inc({ + reason: ReprocessRejectReason.reached_limit, + topic: topicType, + }); + return; + } + + this.metrics?.awaitingBlockGossipMessages.queue.inc({topic: topicType}); + if (root == null) { + // should not happen + throw Error(`Root should not be null if preProcessAction is ${PreProcessAction.await_block}`); + } + const awaitingGossipsubMessages = this.awaitingMessagesByBlockRoot.getOrDefault(root); + awaitingGossipsubMessages.add(message); + break; + } + case PreProcessAction.await_envelope: { + if (this.unknownPayloadGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_PAYLOAD_GOSSIP_OBJECTS) { + this.metrics?.awaitingPayloadGossipMessages.reject.inc({ + reason: ReprocessRejectReason.reached_limit, + topic: topicType, + }); + return; + } + + this.metrics?.awaitingPayloadGossipMessages.queue.inc({topic: topicType}); + if (root == null) { + // should not happen + throw Error(`Root should not be null if preProcessAction is ${PreProcessAction.await_envelope}`); + } + const awaitingPayloadGossipsubMessages = this.awaitingMessagesByPayloadBlockRoot.getOrDefault(root); + awaitingPayloadGossipsubMessages.add(message); + break; + } + } } private pushPendingGossipsubMessageToQueue(message: PendingGossipsubMessage): void { @@ -345,6 +507,32 @@ export class NetworkProcessor { this.awaitingMessagesByBlockRoot.delete(rootHex); } + private async onPayloadEnvelopeProcessed({blockRoot: rootHex}: {blockRoot: RootHex}): Promise { + const waitingGossipsubMessages = this.awaitingMessagesByPayloadBlockRoot.get(rootHex); + if (!waitingGossipsubMessages || waitingGossipsubMessages.size === 0) { + return; + } + + const nowSec = Date.now() / 1000; + let count = 0; + for (const message of waitingGossipsubMessages) { + const topicType = message.topic.type; + this.metrics?.awaitingPayloadGossipMessages.waitSecBeforeResolve.set( + {topic: topicType}, + nowSec - message.seenTimestampSec + ); + this.metrics?.awaitingPayloadGossipMessages.resolve.inc({topic: topicType}); + this.pushPendingGossipsubMessageToQueue(message); + count++; + if (count === MAX_AWAITING_GOSSIP_OBJECTS_PER_TICK) { + count = 0; + await sleep(AWAITING_GOSSIP_OBJECTS_YIELD_EVERY_MS); + } + } + + this.awaitingMessagesByPayloadBlockRoot.delete(rootHex); + } + private onClockSlot(clockSlot: Slot): void { const nowSec = Date.now() / 1000; const minSlot = clockSlot - MAX_UNKNOWN_ROOTS_SLOT_CACHE_SIZE; @@ -371,6 +559,29 @@ export class NetworkProcessor { } this.unknownBlocksBySlot.delete(slot); } + + for (const [slot, roots] of this.unknownEnvelopesBySlot) { + if (slot > minSlot) continue; + for (const rootHex of roots) { + const gossipMessages = this.awaitingMessagesByPayloadBlockRoot.get(rootHex); + if (gossipMessages !== undefined) { + for (const message of gossipMessages) { + const topicType = message.topic.type; + this.metrics?.awaitingPayloadGossipMessages.reject.inc({ + topic: topicType, + reason: ReprocessRejectReason.expired, + }); + this.metrics?.awaitingPayloadGossipMessages.waitSecBeforeReject.set( + {topic: topicType, reason: ReprocessRejectReason.expired}, + nowSec - message.seenTimestampSec + ); + // No need to report the dropped job to gossip. It will be eventually pruned from the mcache + } + this.awaitingMessagesByPayloadBlockRoot.delete(rootHex); + } + } + this.unknownEnvelopesBySlot.delete(slot); + } } private executeWork(): void { @@ -517,4 +728,12 @@ export class NetworkProcessor { } return count; } + + private get unknownPayloadGossipsubMessagesCount(): number { + let count = 0; + for (const messages of this.awaitingMessagesByPayloadBlockRoot.values()) { + count += messages.size; + } + return count; + } } diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index d33bd898033d..d146802d23fd 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -182,10 +182,7 @@ export function getSlotFromSingleAttestationSerialized(data: Uint8Array): Slot | * Extract committee index from SingleAttestation serialized bytes. * Return null if data is not long enough to extract slot. */ -export function getCommitteeIndexFromSingleAttestationSerialized( - fork: ForkName, - data: Uint8Array -): CommitteeIndex | null { +export function getIndexFromSingleAttestationSerialized(fork: ForkName, data: Uint8Array): CommitteeIndex | null { if (isForkPostElectra(fork)) { if (data.length !== SINGLE_ATTESTATION_SIZE) { return null; @@ -269,6 +266,7 @@ export function getSignatureFromSingleAttestationSerialized(data: Uint8Array): B const AGGREGATE_AND_PROOF_OFFSET = 4 + 96; const AGGREGATE_OFFSET = AGGREGATE_AND_PROOF_OFFSET + 8 + 4 + 96; const SIGNED_AGGREGATE_AND_PROOF_SLOT_OFFSET = AGGREGATE_OFFSET + VARIABLE_FIELD_OFFSET; +const SIGNED_AGGREGATE_AND_PROOF_COMMITTEE_INDEX_OFFSET = SIGNED_AGGREGATE_AND_PROOF_SLOT_OFFSET + SLOT_SIZE; const SIGNED_AGGREGATE_AND_PROOF_BLOCK_ROOT_OFFSET = SIGNED_AGGREGATE_AND_PROOF_SLOT_OFFSET + 8 + 8; /** @@ -303,6 +301,19 @@ export function getBlockRootFromSignedAggregateAndProofSerialized(data: Uint8Arr return "0x" + blockRootBuf.toString("hex"); } +/** + * Extract index from signed aggregate and proof serialized bytes. + * Return null if data is not long enough to extract index. + * This works for both phase0 + electra (index is in attestation data at the same offset). + */ +export function getIndexFromSignedAggregateAndProofSerialized(data: Uint8Array): CommitteeIndex | null { + if (data.length < SIGNED_AGGREGATE_AND_PROOF_COMMITTEE_INDEX_OFFSET + COMMITTEE_INDEX_SIZE) { + return null; + } + + return getIndexFromOffset(data, SIGNED_AGGREGATE_AND_PROOF_COMMITTEE_INDEX_OFFSET); +} + /** * Extract AttestationData base64 from SignedAggregateAndProof for electra * Return null if data is not long enough @@ -527,6 +538,104 @@ export function getSlotFromBeaconStateSerialized(data: Uint8Array): Slot | null return getSlotFromOffset(data, SLOT_BYTES_POSITION_IN_BEACON_STATE); } +/** + * PayloadAttestationMessage: { + * validatorIndex: ValidatorIndex (8 bytes) + * data: PayloadAttestationData { + * beaconBlockRoot: Root (32 bytes) ← offset 8 + * slot: Slot (8 bytes) ← offset 40 + * payloadPresent: Boolean (1 byte) + * blobDataAvailable: Boolean (1 byte) + * } + * signature: BLSSignature (96 bytes) + * } + * Fully fixed-size container, no offset table. + */ +const PAYLOAD_ATTESTATION_MESSAGE_BEACON_BLOCK_ROOT_OFFSET = 8; +const PAYLOAD_ATTESTATION_MESSAGE_SLOT_OFFSET = 8 + ROOT_SIZE; // 40 +const PAYLOAD_ATTESTATION_MESSAGE_PAYLOAD_PRESENT_OFFSET = PAYLOAD_ATTESTATION_MESSAGE_SLOT_OFFSET + SLOT_SIZE; // 48 + +export function getSlotFromPayloadAttestationMessageSerialized(data: Uint8Array): Slot | null { + if (data.length < PAYLOAD_ATTESTATION_MESSAGE_SLOT_OFFSET + SLOT_SIZE) { + return null; + } + return getSlotFromOffset(data, PAYLOAD_ATTESTATION_MESSAGE_SLOT_OFFSET); +} + +export function getPayloadPresentFromPayloadAttestationMessageSerialized(data: Uint8Array): boolean | null { + if (data.length < PAYLOAD_ATTESTATION_MESSAGE_PAYLOAD_PRESENT_OFFSET + 1) { + return null; + } + return data[PAYLOAD_ATTESTATION_MESSAGE_PAYLOAD_PRESENT_OFFSET] !== 0; +} + +export function getBlockRootFromPayloadAttestationMessageSerialized(data: Uint8Array): RootHex | null { + if (data.length < PAYLOAD_ATTESTATION_MESSAGE_BEACON_BLOCK_ROOT_OFFSET + ROOT_SIZE) { + return null; + } + blockRootBuf.set( + data.subarray( + PAYLOAD_ATTESTATION_MESSAGE_BEACON_BLOCK_ROOT_OFFSET, + PAYLOAD_ATTESTATION_MESSAGE_BEACON_BLOCK_ROOT_OFFSET + ROOT_SIZE + ) + ); + return `0x${blockRootBuf.toString("hex")}`; +} + +/** + * SignedExecutionPayloadBid: {message: ExecutionPayloadBid (variable), signature: BLSSignature (96 bytes)} + * Fixed part: 4-byte offset + 96-byte signature = 100 bytes + * message data starts at byte 100 + * + * ExecutionPayloadBid fixed fields (in order): + * parentBlockHash: Bytes32 (32 bytes) + * parentBlockRoot: Root (32 bytes) + * blockHash: Bytes32 (32 bytes) + * prevRandao: Bytes32 (32 bytes) + * feeRecipient: ExecutionAddress(20 bytes) + * gasLimit: UintBn64 (8 bytes) + * builderIndex: BuilderIndex (8 bytes) + * slot: Slot (8 bytes) ← absolute offset 264 + */ +const SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_HASH_OFFSET = VARIABLE_FIELD_OFFSET + SIGNATURE_SIZE; // 100 +const SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_ROOT_OFFSET = + SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_HASH_OFFSET + ROOT_SIZE; // 132 +const SIGNED_EXECUTION_PAYLOAD_BID_SLOT_OFFSET = + VARIABLE_FIELD_OFFSET + SIGNATURE_SIZE + 32 + 32 + 32 + 32 + 20 + 8 + 8; // 264 + +export function getSlotFromSignedExecutionPayloadBidSerialized(data: Uint8Array): Slot | null { + if (data.length < SIGNED_EXECUTION_PAYLOAD_BID_SLOT_OFFSET + SLOT_SIZE) { + return null; + } + return getSlotFromOffset(data, SIGNED_EXECUTION_PAYLOAD_BID_SLOT_OFFSET); +} + +export function getParentBlockHashFromSignedExecutionPayloadBidSerialized(data: Uint8Array): RootHex | null { + if (data.length < SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_HASH_OFFSET + ROOT_SIZE) { + return null; + } + blockRootBuf.set( + data.subarray( + SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_HASH_OFFSET, + SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_HASH_OFFSET + ROOT_SIZE + ) + ); + return `0x${blockRootBuf.toString("hex")}`; +} + +export function getParentBlockRootFromSignedExecutionPayloadBidSerialized(data: Uint8Array): RootHex | null { + if (data.length < SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_ROOT_OFFSET + ROOT_SIZE) { + return null; + } + blockRootBuf.set( + data.subarray( + SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_ROOT_OFFSET, + SIGNED_EXECUTION_PAYLOAD_BID_PARENT_BLOCK_ROOT_OFFSET + ROOT_SIZE + ) + ); + return `0x${blockRootBuf.toString("hex")}`; +} + /** * Read only the first 4 bytes of Slot, max value is 4,294,967,295 will be reached 1634 years after genesis * diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index e30bde3735ff..e3ef0173dce8 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -12,6 +12,7 @@ import { ValidatorIndex, deneb, electra, + gloas, isElectraSingleAttestation, phase0, ssz, @@ -30,11 +31,16 @@ import { getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getBlobKzgCommitmentsCountFromSignedBeaconBlockSerialized, getBlockRootFromAttestationSerialized, + getBlockRootFromPayloadAttestationMessageSerialized, getBlockRootFromSignedAggregateAndProofSerialized, getBlockRootFromSingleAttestationSerialized, getCommitteeBitsFromSignedAggregateAndProofElectra, - getCommitteeIndexFromSingleAttestationSerialized, + getIndexFromSignedAggregateAndProofSerialized, + getIndexFromSingleAttestationSerialized, getLastProcessedSlotFromBeaconStateSerialized, + getParentBlockHashFromSignedExecutionPayloadBidSerialized, + getParentBlockRootFromSignedExecutionPayloadBidSerialized, + getPayloadPresentFromPayloadAttestationMessageSerialized, getSignatureFromAttestationSerialized, getSignatureFromSingleAttestationSerialized, getSlotFromAttestationSerialized, @@ -42,8 +48,10 @@ import { getSlotFromBlobSidecarSerialized, getSlotFromDataColumnSidecarSerialized, getSlotFromExecutionPayloadEnvelopeSerialized, + getSlotFromPayloadAttestationMessageSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, + getSlotFromSignedExecutionPayloadBidSerialized, getSlotFromSingleAttestationSerialized, } from "../../../src/util/sszBytes.js"; import {generateRandomBlob} from "../../utils/kzg.js"; @@ -79,9 +87,7 @@ describe("SinlgeAttestation SSZ serialized picking", () => { if (isElectra) { expect(getSlotFromSingleAttestationSerialized(bytes)).toEqual(attestation.data.slot); - expect(getCommitteeIndexFromSingleAttestationSerialized(ForkName.electra, bytes)).toEqual( - attestation.committeeIndex - ); + expect(getIndexFromSingleAttestationSerialized(ForkName.electra, bytes)).toEqual(attestation.committeeIndex); expect(getAttesterIndexFromSingleAttestationSerialized(bytes)).toEqual(attestation.attesterIndex); expect(getBlockRootFromSingleAttestationSerialized(bytes)).toEqual(toRootHex(attestation.data.beaconBlockRoot)); // base64, not hex @@ -91,9 +97,7 @@ describe("SinlgeAttestation SSZ serialized picking", () => { expect(getSignatureFromSingleAttestationSerialized(bytes)).toEqual(attestation.signature); } else { expect(getSlotFromAttestationSerialized(bytes)).toBe(attestation.data.slot); - expect(getCommitteeIndexFromSingleAttestationSerialized(ForkName.phase0, bytes)).toEqual( - attestation.data.index - ); + expect(getIndexFromSingleAttestationSerialized(ForkName.phase0, bytes)).toEqual(attestation.data.index); expect(getBlockRootFromAttestationSerialized(bytes)).toBe(toRootHex(attestation.data.beaconBlockRoot)); expect(getAggregationBitsFromAttestationSerialized(bytes)?.toBoolArray()).toEqual( attestation.aggregationBits.toBoolArray() @@ -151,10 +155,10 @@ describe("SinlgeAttestation SSZ serialized picking", () => { } }); - it("getCommitteeIndexFromSingleAttestationSerialized - invalid data", () => { + it("getIndexFromSingleAttestationSerialized - invalid data", () => { const invalidCommitteeIndexDataSizes = [0, 4, 11]; for (const size of invalidCommitteeIndexDataSizes) { - expect(getCommitteeIndexFromSingleAttestationSerialized(ForkName.electra, Buffer.alloc(size))).toBeNull(); + expect(getIndexFromSingleAttestationSerialized(ForkName.electra, Buffer.alloc(size))).toBeNull(); } }); @@ -294,6 +298,38 @@ describe("electra SignedAggregateAndProof SSZ serialized picking", () => { }); }); +describe("getIndexFromSignedAggregateAndProofSerialized", () => { + it("phase0 - extracts data.index from aggregate", () => { + const agg = phase0SignedAggregateAndProofFromValues( + 4_000_000, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 200_00, + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeffffffffffffffffffffffffffffffff" + ); + agg.message.aggregate.data.index = 3; + const bytes = ssz.phase0.SignedAggregateAndProof.serialize(agg); + expect(getIndexFromSignedAggregateAndProofSerialized(bytes)).toBe(3); + }); + + it("electra - extracts data.index from aggregate", () => { + const agg = electraSignedAggregateAndProofFromValues( + 4_000_000, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 200_00, + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeffffffffffffffffffffffffffffffff" + ); + agg.message.aggregate.data.index = 7; + const bytes = ssz.electra.SignedAggregateAndProof.serialize(agg); + expect(getIndexFromSignedAggregateAndProofSerialized(bytes)).toBe(7); + }); + + it("invalid data returns null", () => { + for (const size of [0, 4, 219]) { + expect(getIndexFromSignedAggregateAndProofSerialized(Buffer.alloc(size))).toBeNull(); + } + }); +}); + describe("signedBeaconBlock SSZ serialized picking", () => { const testCases = [ssz.phase0.SignedBeaconBlock.defaultValue(), signedBeaconBlockFromValues(1_000_000)]; @@ -338,6 +374,7 @@ describe("getBlobKzgCommitmentsCountFromSignedBeaconBlockSerialized", () => { DENEB_FORK_EPOCH: 5, ELECTRA_FORK_EPOCH: 10, FULU_FORK_EPOCH: 15, + GLOAS_FORK_EPOCH: 20, }); it("should return 0 blob count pre deneb", async () => { @@ -578,3 +615,103 @@ describe("DataColumnSidecar SSZ serialized picking (fork-aware)", () => { }); }); }); + +describe("PayloadAttestationMessage SSZ serialized picking", () => { + const testCases = [ + ssz.gloas.PayloadAttestationMessage.defaultValue(), + payloadAttestationMessageFromValues(1_000_000, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ]; + + for (const [i, msg] of testCases.entries()) { + it(`payloadAttestationMessage ${i}`, () => { + const bytes = ssz.gloas.PayloadAttestationMessage.serialize(msg); + + expect(getSlotFromPayloadAttestationMessageSerialized(bytes)).toBe(msg.data.slot); + expect(getBlockRootFromPayloadAttestationMessageSerialized(bytes)).toBe(toRootHex(msg.data.beaconBlockRoot)); + expect(getPayloadPresentFromPayloadAttestationMessageSerialized(bytes)).toBe(msg.data.payloadPresent); + }); + } + + it("getPayloadPresentFromPayloadAttestationMessageSerialized - true/false", () => { + const msg = ssz.gloas.PayloadAttestationMessage.defaultValue(); + msg.data.payloadPresent = true; + expect( + getPayloadPresentFromPayloadAttestationMessageSerialized(ssz.gloas.PayloadAttestationMessage.serialize(msg)) + ).toBe(true); + msg.data.payloadPresent = false; + expect( + getPayloadPresentFromPayloadAttestationMessageSerialized(ssz.gloas.PayloadAttestationMessage.serialize(msg)) + ).toBe(false); + }); + + it("getSlotFromPayloadAttestationMessageSerialized - invalid data", () => { + const invalidSlotDataSizes = [0, 20, 47]; + for (const size of invalidSlotDataSizes) { + expect(getSlotFromPayloadAttestationMessageSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getBlockRootFromPayloadAttestationMessageSerialized - invalid data", () => { + const invalidBlockRootDataSizes = [0, 4, 39]; + for (const size of invalidBlockRootDataSizes) { + expect(getBlockRootFromPayloadAttestationMessageSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getPayloadPresentFromPayloadAttestationMessageSerialized - invalid data", () => { + for (const size of [0, 20, 47]) { + expect(getPayloadPresentFromPayloadAttestationMessageSerialized(Buffer.alloc(size))).toBeNull(); + } + }); +}); + +describe("SignedExecutionPayloadBid SSZ serialized picking", () => { + const testCases = [ + ssz.gloas.SignedExecutionPayloadBid.defaultValue(), + signedExecutionPayloadBidFromValues(1_000_000), + ]; + + for (const [i, bid] of testCases.entries()) { + it(`signedExecutionPayloadBid ${i}`, () => { + const bytes = ssz.gloas.SignedExecutionPayloadBid.serialize(bid); + + expect(getSlotFromSignedExecutionPayloadBidSerialized(bytes)).toBe(bid.message.slot); + expect(getParentBlockHashFromSignedExecutionPayloadBidSerialized(bytes)).toBe(toHex(bid.message.parentBlockHash)); + expect(getParentBlockRootFromSignedExecutionPayloadBidSerialized(bytes)).toBe(toHex(bid.message.parentBlockRoot)); + }); + } + + it("getSlotFromSignedExecutionPayloadBidSerialized - invalid data", () => { + const invalidSlotDataSizes = [0, 100, 271]; + for (const size of invalidSlotDataSizes) { + expect(getSlotFromSignedExecutionPayloadBidSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getParentBlockHashFromSignedExecutionPayloadBidSerialized - invalid data", () => { + for (const size of [0, 99, 131]) { + expect(getParentBlockHashFromSignedExecutionPayloadBidSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getParentBlockRootFromSignedExecutionPayloadBidSerialized - invalid data", () => { + for (const size of [0, 99, 163]) { + expect(getParentBlockRootFromSignedExecutionPayloadBidSerialized(Buffer.alloc(size))).toBeNull(); + } + }); +}); + +function payloadAttestationMessageFromValues(slot: Slot, blockRoot: RootHex): gloas.PayloadAttestationMessage { + const msg = ssz.gloas.PayloadAttestationMessage.defaultValue(); + msg.data.slot = slot; + msg.data.beaconBlockRoot = fromHex(blockRoot); + return msg; +} + +function signedExecutionPayloadBidFromValues(slot: Slot): gloas.SignedExecutionPayloadBid { + const bid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + bid.message.slot = slot; + bid.message.parentBlockHash = Buffer.alloc(32, 0xaa); + bid.message.parentBlockRoot = Buffer.alloc(32, 0xbb); + return bid; +} diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 85485f4f31c2..b787ee22d716 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1059,6 +1059,21 @@ export class ForkChoice implements IForkChoice { return this.protoArray.hasBlock(blockRoot); } + /** + * Returns true if the FULL payload variant (execution payload envelope) exists for this block root, + * without checking if the block is a descendant of the finalized root. + */ + hasEnvelopeUnsafe(blockRoot: Root): boolean { + return this.hasEnvelopeHexUnsafe(toRootHex(blockRoot)); + } + + /** + * Same to hasEnvelopeUnsafe but accepts a hex-encoded block root. + */ + hasEnvelopeHexUnsafe(blockRoot: RootHex): boolean { + return this.protoArray.hasEnvelope(blockRoot); + } + /** * Returns a MUTABLE `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 23162d1dce81..ac75d8a2e3e7 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -226,6 +226,12 @@ export interface IForkChoice { */ hasBlockUnsafe(blockRoot: Root): boolean; hasBlockHexUnsafe(blockRoot: RootHex): boolean; + /** + * Returns true if the FULL payload variant (execution payload envelope) exists for this block root, + * without checking if the block is a descendant of the finalized root. + */ + hasEnvelopeUnsafe(blockRoot: Root): boolean; + hasEnvelopeHexUnsafe(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; /** * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 485c4011e6ef..4ad36b2b50e2 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1673,6 +1673,16 @@ export class ProtoArray { return this.getDefaultNodeIndex(blockRoot) !== undefined; } + /** + * Check if a FULL payload variant (execution payload envelope) exists for this block root. + * Returns true once the SignedExecutionPayloadEnvelope for this block has been received and processed. + */ + hasEnvelope(blockRoot: RootHex): boolean { + // we should also make sure this blockRoot is gloas block, however we only call this function + // starting from GLOAS_FORK_EPOCH, so we can assume the blockRoot is from gloas block + return this.getNodeIndexByRootAndStatus(blockRoot, PayloadStatus.FULL) !== undefined; + } + /** * Return ProtoNode for blockRoot with explicit payload status * From 00c1ba41692f15815a66625a9a2dd9667951eb95 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Wed, 25 Mar 2026 11:19:17 +0700 Subject: [PATCH 02/19] fix: add PreprocessResult = action + ?root --- .../src/network/processor/index.ts | 55 +++++++++---------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index cf77f623761b..c34852e03a9c 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -138,12 +138,17 @@ export enum CannotAcceptWorkReason { /** * No need metrics for this so make it as numeric to make it ligghtweight */ -enum PreProcessAction { - await_block, - await_envelope, - push_to_queue, +enum PreprocessAction { + AwaitBlock, + AwaitEnvelope, + PushToQueue, } +type PreprocessResult = + | {action: PreprocessAction.PushToQueue} + | {action: PreprocessAction.AwaitBlock; root: RootHex} + | {action: PreprocessAction.AwaitEnvelope; root: RootHex}; + /** * Network processor handles the gossip queues and throtles processing to not overload the main thread * - Decides when to process work and what to process @@ -261,7 +266,7 @@ export class NetworkProcessor { /** * Search block via `ChainEvent.unknownBlockRoot` event - * Note that slot is not necessarily the same to the block's slot but it can be used for a good prune strategy. + * Slot is the message slot, which is not necessarily the same as the block's slot, but it can be used for a good prune strategy. * In the rare case, if 2 messages on 2 slots search for the same root (for example beacon_attestation) we may emit the same root twice but BlockInputSync should handle it well. */ searchUnknownBlock({slot, root}: SlotRootHex, source: BlockInputSource, peer?: PeerIdStr): void { @@ -279,7 +284,7 @@ export class NetworkProcessor { /** * Search envelope via `ChainEvent.unknownEnvelopeBlockRoot` event - * Note that slot is not necessarily the same to the envelope's slot but it can be used for a good prune strategy. + * Slot is the message slot, which is not necessarily the same as the envelope's slot, but it can be used for a good prune strategy. * In the rare case, if 2 messages on 2 slots search for the same root (for example beacon_attestation) we may emit the same root twice but BlockInputSync should handle it well. */ searchUnknownEnvelope({slot, root}: SlotRootHex, source: BlockInputSource, peer?: PeerIdStr): void { @@ -330,14 +335,14 @@ export class NetworkProcessor { // this to determine this message needs to wait for Block or Envelope // a message should only be waited for what they voted for, hence we don't want to put them on both queues - let preProcessAction = PreProcessAction.push_to_queue; + let preprocessResult: PreprocessResult = {action: PreprocessAction.PushToQueue}; // no need to check if root is a descendant of the current finalized block, it will be checked once we validate the message if needed if (root && !this.chain.forkChoice.hasBlockHexUnsafe(root)) { // starting from GLOAS, unknown root from data_column_sidecar also falls into this case this.searchUnknownBlock({slot, root}, BlockInputSource.network_processor, message.propagationSource.toString()); // for beacon_attestation and beacon_aggregate_and_proof messages, this is only temporary // if "index = 1" we need to await for Envelope instead - preProcessAction = PreProcessAction.await_block; + preprocessResult = {action: PreprocessAction.AwaitBlock, root}; } if (ForkSeq[fork] >= ForkSeq.gloas) { @@ -358,7 +363,7 @@ export class NetworkProcessor { BlockInputSource.network_processor, message.propagationSource.toString() ); - preProcessAction = PreProcessAction.await_envelope; + preprocessResult = {action: PreprocessAction.AwaitEnvelope, root}; } break; } @@ -371,7 +376,7 @@ export class NetworkProcessor { BlockInputSource.network_processor, message.propagationSource.toString() ); - preProcessAction = PreProcessAction.await_envelope; + preprocessResult = {action: PreprocessAction.AwaitEnvelope, root}; } break; } @@ -383,7 +388,7 @@ export class NetworkProcessor { BlockInputSource.network_processor, message.propagationSource.toString() ); - preProcessAction = PreProcessAction.await_envelope; + preprocessResult = {action: PreprocessAction.AwaitEnvelope, root}; } break; } @@ -404,6 +409,7 @@ export class NetworkProcessor { BlockInputSource.network_processor, message.propagationSource.toString() ); + preprocessResult = {action: PreprocessAction.AwaitBlock, root: parentBlockRoot}; } if (protoBlock?.executionPayloadBlockHash && protoBlock?.executionPayloadBlockHash !== parentBlockHash) { this.searchUnknownEnvelope( @@ -411,22 +417,19 @@ export class NetworkProcessor { BlockInputSource.network_processor, message.propagationSource.toString() ); + preprocessResult = {action: PreprocessAction.AwaitEnvelope, root: parentBlockRoot}; } - - // don't queue for this execution_payload_bid message because PreProcessAction.await_* expect non-null root - // it's only an issue if we're the block proposer - // other gossip messages of the previous slot should search for the missing block/envelope anyway } break; } } } - switch (preProcessAction) { - case PreProcessAction.push_to_queue: + switch (preprocessResult.action) { + case PreprocessAction.PushToQueue: this.pushPendingGossipsubMessageToQueue(message); break; - case PreProcessAction.await_block: { + case PreprocessAction.AwaitBlock: { if (this.unknownBlockGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_BLOCK_GOSSIP_OBJECTS) { // No need to report the dropped job to gossip. It will be eventually pruned from the mcache this.metrics?.awaitingBlockGossipMessages.reject.inc({ @@ -437,15 +440,11 @@ export class NetworkProcessor { } this.metrics?.awaitingBlockGossipMessages.queue.inc({topic: topicType}); - if (root == null) { - // should not happen - throw Error(`Root should not be null if preProcessAction is ${PreProcessAction.await_block}`); - } - const awaitingGossipsubMessages = this.awaitingMessagesByBlockRoot.getOrDefault(root); + const awaitingGossipsubMessages = this.awaitingMessagesByBlockRoot.getOrDefault(preprocessResult.root); awaitingGossipsubMessages.add(message); break; } - case PreProcessAction.await_envelope: { + case PreprocessAction.AwaitEnvelope: { if (this.unknownPayloadGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_PAYLOAD_GOSSIP_OBJECTS) { this.metrics?.awaitingPayloadGossipMessages.reject.inc({ reason: ReprocessRejectReason.reached_limit, @@ -455,11 +454,9 @@ export class NetworkProcessor { } this.metrics?.awaitingPayloadGossipMessages.queue.inc({topic: topicType}); - if (root == null) { - // should not happen - throw Error(`Root should not be null if preProcessAction is ${PreProcessAction.await_envelope}`); - } - const awaitingPayloadGossipsubMessages = this.awaitingMessagesByPayloadBlockRoot.getOrDefault(root); + const awaitingPayloadGossipsubMessages = this.awaitingMessagesByPayloadBlockRoot.getOrDefault( + preprocessResult.root + ); awaitingPayloadGossipsubMessages.add(message); break; } From e523ef56fc2da9a00929bc525a85ff9212e0224f Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Wed, 25 Mar 2026 14:53:47 +0700 Subject: [PATCH 03/19] feat: update gossip handler to queue block+envelope in UnknownBlockSync --- .../src/api/impl/beacon/blocks/index.ts | 4 +- packages/beacon-node/src/chain/emitter.ts | 17 +++++- .../src/network/processor/gossipHandlers.ts | 56 ++++++++++++++----- packages/beacon-node/src/sync/unknownBlock.ts | 6 +- .../test/e2e/sync/unknownBlockSync.test.ts | 6 +- .../test/unit/sync/unknownBlock.test.ts | 16 +++--- packages/types/package.json | 13 +++-- 7 files changed, 82 insertions(+), 36 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index e9e4bd78111c..d504f6995544 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -210,7 +210,7 @@ export function getBeaconBlockApi({ if (!blockLocallyProduced) { const parentBlock = chain.forkChoice.getBlockDefaultStatus(signedBlock.message.parentRoot); if (parentBlock === null) { - chain.emitter.emit(ChainEvent.unknownParent, { + chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput: blockForImport, peer: IDENTITY_PEER_ID, source: BlockInputSource.api, @@ -305,7 +305,7 @@ export function getBeaconBlockApi({ .processBlock(blockForImport, opts) .catch((e) => { if (e instanceof BlockError && e.type.code === BlockErrorCode.PARENT_UNKNOWN) { - chain.emitter.emit(ChainEvent.unknownParent, { + chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput: blockForImport, peer: IDENTITY_PEER_ID, source: BlockInputSource.api, diff --git a/packages/beacon-node/src/chain/emitter.ts b/packages/beacon-node/src/chain/emitter.ts index e132022804a1..152a2e0a87de 100644 --- a/packages/beacon-node/src/chain/emitter.ts +++ b/packages/beacon-node/src/chain/emitter.ts @@ -4,6 +4,7 @@ import {routes} from "@lodestar/api"; import {CheckpointWithPayloadStatus} from "@lodestar/fork-choice"; import {IBeaconStateView} from "@lodestar/state-transition"; import {DataColumnSidecars, RootHex, deneb, phase0} from "@lodestar/types"; +import {SignedExecutionPayloadEnvelope} from "@lodestar/types/gloas"; import {PeerIdStr} from "../util/peerId.js"; import {BlockInputSource, IBlockInput} from "./blocks/blockInput/types.js"; @@ -56,7 +57,11 @@ export enum ChainEvent { /** * Trigger a BlockInputSync for blocks where the parentRoot is not known to fork choice */ - unknownParent = "unknownParent", + blockUnknownParent = "blockUnknownParent", + /** + * Trigger a BlockInputSync for envelope (payload) with unknown block root + */ + envelopeUnknownBlockRoot = "envelopeUnknownBlockRoot", /** * Trigger BlockInputSync for objects that correspond to a block that is not known to fork choice */ @@ -79,7 +84,12 @@ export type ReorgEventData = routes.events.EventData[routes.events.EventType.cha type ApiEvents = {[K in routes.events.EventType]: (data: routes.events.EventData[K]) => void}; export type ChainEventData = { - [ChainEvent.unknownParent]: {blockInput: IBlockInput; peer: PeerIdStr; source: BlockInputSource}; + [ChainEvent.blockUnknownParent]: {blockInput: IBlockInput; peer: PeerIdStr; source: BlockInputSource}; + [ChainEvent.envelopeUnknownBlockRoot]: { + envelope: SignedExecutionPayloadEnvelope; + peer?: PeerIdStr; + source: BlockInputSource; + }; [ChainEvent.unknownBlockRoot]: {rootHex: RootHex; peer?: PeerIdStr; source: BlockInputSource}; [ChainEvent.incompleteBlockInput]: {blockInput: IBlockInput; peer: PeerIdStr; source: BlockInputSource}; [ChainEvent.unknownEnvelopeBlockRoot]: {rootHex: RootHex; peer?: PeerIdStr; source: BlockInputSource}; @@ -101,7 +111,8 @@ export type IChainEvents = ApiEvents & { // Sync events that are chain->chain. Initiated from network requests but do not cross the network // barrier so are considered ChainEvent(s). - [ChainEvent.unknownParent]: (data: ChainEventData[ChainEvent.unknownParent]) => void; + [ChainEvent.blockUnknownParent]: (data: ChainEventData[ChainEvent.blockUnknownParent]) => void; + [ChainEvent.envelopeUnknownBlockRoot]: (data: ChainEventData[ChainEvent.envelopeUnknownBlockRoot]) => void; [ChainEvent.unknownBlockRoot]: (data: ChainEventData[ChainEvent.unknownBlockRoot]) => void; [ChainEvent.incompleteBlockInput]: (data: ChainEventData[ChainEvent.incompleteBlockInput]) => void; [ChainEvent.unknownEnvelopeBlockRoot]: (data: ChainEventData[ChainEvent.unknownEnvelopeBlockRoot]) => void; diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index ed34a576a84b..710f2c335cd5 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -161,16 +161,19 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand logger.debug("Received gossip block", {...logCtx}); - let blockInput: IBlockInput | undefined; + // optimically add gossip block to the seen cache + // if validation fails, we will NOT forward this gossip block to peers + // - if PARENT_UNKNOWN error, blockInput will then be queued inside BlockInputSync. If the gossip block is really invalid, it will be pruned there + // - if other validator errors, blockInput will stay in the seen cache and will be pruned on finalization + const blockInput = chain.seenBlockInputCache.getByBlock({ + block: signedBlock, + blockRootHex, + source: BlockInputSource.gossip, + seenTimestampSec, + peerIdStr, + }); try { await validateGossipBlock(config, chain, signedBlock, fork); - blockInput = chain.seenBlockInputCache.getByBlock({ - block: signedBlock, - blockRootHex, - source: BlockInputSource.gossip, - seenTimestampSec, - peerIdStr, - }); const blockInputMeta = blockInput.getLogMeta(); const recvToValidation = Date.now() / 1000 - seenTimestampSec; @@ -186,10 +189,9 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand return blockInput; } catch (e) { if (e instanceof BlockGossipError) { + logger.debug("Gossip block has error", {slot, root: blockShortHex, code: e.type.code}); if (e.type.code === BlockErrorCode.PARENT_UNKNOWN && blockInput) { - // TODO GLOAS: dead code - logger.debug("Gossip block has error", {slot, root: blockShortHex, code: e.type.code}); - chain.emitter.emit(ChainEvent.unknownParent, { + chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput, peer: peerIdStr, source: BlockInputSource.gossip, @@ -844,8 +846,36 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand const {serializedData} = gossipData; const signedEnvelope = sszDeserialize(topic, serializedData); const envelope = signedEnvelope.message; - // TODO GLOAS: handle BLOCK_ROOT_UNKNOWN error to trigger sync - await validateGossipExecutionPayloadEnvelope(chain, signedEnvelope); + + // TODO GLOAS: consider optimistically create PayloadEnvelopeInput here similar to how we do that for beacon_block + // so that UnknownBlockSync can handle backward sync + // the problem now is we cannot create a PayloadEnvelopeInput without the beacon block being known, we need at least the proposer index + // we can achieve that by looking into the EpochCache + try { + await validateGossipExecutionPayloadEnvelope(chain, signedEnvelope); + } catch (e) { + if (e instanceof ExecutionPayloadEnvelopeError) { + const {slot, beaconBlockRoot} = signedEnvelope.message; + logger.debug("Gossip envelope has error", {slot, root: toRootHex(beaconBlockRoot), code: e.type.code}); + if (e.type.code === ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN) { + chain.emitter.emit(ChainEvent.envelopeUnknownBlockRoot, { + envelope: signedEnvelope, + peer: peerIdStr, + source: BlockInputSource.gossip, + }); + } + + if (e.action === GossipAction.REJECT) { + chain.persistInvalidSszValue( + ssz.gloas.SignedExecutionPayloadEnvelope, + signedEnvelope, + `gossip_reject_slot_${slot}` + ); + } + } + + throw e; + } const slot = envelope.slot; const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 11db2d8cae74..4875911f28b6 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -115,7 +115,7 @@ export class BlockInputSync { this.logger.verbose("BlockInputSync enabled."); this.chain.emitter.on(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); this.chain.emitter.on(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); - this.chain.emitter.on(ChainEvent.unknownParent, this.onUnknownParent); + this.chain.emitter.on(ChainEvent.blockUnknownParent, this.onUnknownParent); this.network.events.on(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.on(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = true; @@ -126,7 +126,7 @@ export class BlockInputSync { this.logger.verbose("BlockInputSync disabled."); this.chain.emitter.off(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); this.chain.emitter.off(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); - this.chain.emitter.off(ChainEvent.unknownParent, this.onUnknownParent); + this.chain.emitter.off(ChainEvent.blockUnknownParent, this.onUnknownParent); this.network.events.off(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.off(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = false; @@ -171,7 +171,7 @@ export class BlockInputSync { /** * Process an unknownBlockParent event and register the block in `pendingBlocks` Map. */ - private onUnknownParent = (data: ChainEventData[ChainEvent.unknownParent]): void => { + private onUnknownParent = (data: ChainEventData[ChainEvent.blockUnknownParent]): void => { try { this.addByRootHex(data.blockInput.parentRootHex, data.peer); this.addByBlockInput(data.blockInput, data.peer); diff --git a/packages/beacon-node/test/e2e/sync/unknownBlockSync.test.ts b/packages/beacon-node/test/e2e/sync/unknownBlockSync.test.ts index b69c34db3ca9..af6d99e47623 100644 --- a/packages/beacon-node/test/e2e/sync/unknownBlockSync.test.ts +++ b/packages/beacon-node/test/e2e/sync/unknownBlockSync.test.ts @@ -51,7 +51,7 @@ describe("sync / unknown block sync for fulu", () => { const testCases: {id: string; event: ChainEvent}[] = [ { id: "should do an unknown block parent sync from another BN", - event: ChainEvent.unknownParent, + event: ChainEvent.blockUnknownParent, }, { id: "should do an unknown block sync from another BN", @@ -167,12 +167,12 @@ describe("sync / unknown block sync for fulu", () => { }); switch (event) { - case ChainEvent.unknownParent: + case ChainEvent.blockUnknownParent: await bn2.chain.processBlock(headInput).catch((e) => { loggerNodeB.info("Error processing block", {slot: headInput.slot, code: e.type.code}); if (e instanceof BlockError && e.type.code === BlockErrorCode.PARENT_UNKNOWN) { // Expected - bn2.chain.emitter.emit(ChainEvent.unknownParent, { + bn2.chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput: headInput, peer: bn2.network.peerId.toString(), source: BlockInputSource.gossip, diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index 96700c05e041..a12d46e42444 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -41,7 +41,7 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { const testCases: { id: string; - event: ChainEvent.unknownParent | ChainEvent.unknownBlockRoot; + event: ChainEvent.blockUnknownParent | ChainEvent.unknownBlockRoot; finalizedSlot: number; reportPeer?: boolean; seenBlock?: boolean; @@ -55,12 +55,12 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { }, { id: "fetch and process multiple unknown block parents", - event: ChainEvent.unknownParent, + event: ChainEvent.blockUnknownParent, finalizedSlot: 0, }, { id: "downloaded parent is before finalized slot", - event: ChainEvent.unknownParent, + event: ChainEvent.blockUnknownParent, finalizedSlot: 2, // Peer reporting is currently disabled in source (commented out in removeAndDownScoreAllDescendants) // Test verifies blocks are cleaned up from pendingBlocks instead @@ -86,7 +86,7 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { }, { id: "downloaded blocks only", - event: ChainEvent.unknownParent, + event: ChainEvent.blockUnknownParent, finalizedSlot: 0, maxPendingBlocks: 1, }, @@ -232,8 +232,8 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { clientAgent: "test-client", }); - if (event === ChainEvent.unknownParent) { - emitter.emit(ChainEvent.unknownParent, { + if (event === ChainEvent.blockUnknownParent) { + emitter.emit(ChainEvent.blockUnknownParent, { blockInput: BlockInputPreData.createFromBlock({ block: blockC, blockRootHex: blockRootHexC, @@ -340,11 +340,11 @@ describe("UnknownBlockSync", () => { if (expected) { expect(events.listenerCount(ChainEvent.unknownBlockRoot)).toBe(1); - expect(events.listenerCount(ChainEvent.unknownParent)).toBe(1); + expect(events.listenerCount(ChainEvent.blockUnknownParent)).toBe(1); expect(service.isSubscribedToNetwork()).toBe(true); } else { expect(events.listenerCount(ChainEvent.unknownBlockRoot)).toBe(0); - expect(events.listenerCount(ChainEvent.unknownParent)).toBe(0); + expect(events.listenerCount(ChainEvent.blockUnknownParent)).toBe(0); expect(service.isSubscribedToNetwork()).toBe(false); } }); diff --git a/packages/types/package.json b/packages/types/package.json index ba62afc42ba0..15d038535b6d 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -19,6 +19,11 @@ "types": "./lib/index.d.ts", "import": "./lib/index.js" }, + "./phase0": { + "bun": "./src/phase0/index.ts", + "types": "./lib/phase0/index.d.ts", + "import": "./lib/phase0/index.js" + }, "./altair": { "bun": "./src/altair/index.ts", "types": "./lib/altair/index.d.ts", @@ -49,10 +54,10 @@ "types": "./lib/fulu/index.d.ts", "import": "./lib/fulu/index.js" }, - "./phase0": { - "bun": "./src/phase0/index.ts", - "types": "./lib/phase0/index.d.ts", - "import": "./lib/phase0/index.js" + "./gloas": { + "bun": "./src/gloas/index.ts", + "types": "./lib/gloas/index.d.ts", + "import": "./lib/gloas/index.js" } }, "files": [ From 0ad8f98d374e36e80df7c46dd439e3be65fac5af Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Wed, 25 Mar 2026 15:13:01 +0700 Subject: [PATCH 04/19] feat: handle beacon_block and execution_payload_envelope in network processor --- .../network/processor/extractSlotRootFns.ts | 17 ++--- .../src/network/processor/index.ts | 64 ++++++++++++++++++- packages/beacon-node/src/util/sszBytes.ts | 63 ++++++++++++++++++ .../test/unit/util/sszBytes.test.ts | 27 ++++++++ 4 files changed, 158 insertions(+), 13 deletions(-) diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 8359fc846f89..309c14d6f715 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -2,7 +2,6 @@ import {ForkName, ForkSeq} from "@lodestar/params"; import {SlotOptionalRoot, SlotRootHex} from "@lodestar/types"; import { getBeaconBlockRootFromDataColumnSidecarSerialized, - getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getBlockRootFromBeaconAttestationSerialized, getBlockRootFromPayloadAttestationMessageSerialized, getBlockRootFromSignedAggregateAndProofSerialized, @@ -20,7 +19,7 @@ import {ExtractSlotRootFns} from "./types.js"; /** * Extract the slot and block root of a gossip message form serialized data. - * Not applicable for all topics. + * Only do it for messages that has slot and block root, and we want to await for the block if the block root is not known. */ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { return { @@ -72,14 +71,14 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { const root = getBeaconBlockRootFromDataColumnSidecarSerialized(data); return root !== null ? {slot, root} : {slot}; }, - [GossipType.execution_payload]: (data: Uint8Array): SlotRootHex | null => { + [GossipType.execution_payload]: (data: Uint8Array): SlotOptionalRoot | null => { const slot = getSlotFromExecutionPayloadEnvelopeSerialized(data); - const root = getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(data); - - if (slot === null || root === null) { + // do not extract root now because network processor will await for block + // instead, we want network processor to extract root and search for block later if it's missing and BlockInputSync will queue it + if (slot === null) { return null; } - return {slot, root}; + return {slot}; }, [GossipType.payload_attestation_message]: (data: Uint8Array): SlotRootHex | null => { const slot = getSlotFromPayloadAttestationMessageSerialized(data); @@ -97,10 +96,6 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { return null; } - // Don't extract a root here — the bid's awaiting logic is handled explicitly - // in the processor switch case using getParentBlockRootFromSignedExecutionPayloadBidSerialized. - // Returning a root here would cause the initial block-root check to queue this message - // in awaitingMessagesByBlockRoot under a garbage key. return {slot}; }, }; diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index c34852e03a9c..5b0591152c7d 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -13,10 +13,13 @@ import {ClockEvent} from "../../util/clock.js"; import {callInNextEventLoop} from "../../util/eventLoop.js"; import {PeerIdStr} from "../../util/peerId.js"; import { + getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getIndexFromSignedAggregateAndProofSerialized, getIndexFromSingleAttestationSerialized, + getParentBlockHashFromGloasSignedBeaconBlockSerialized, getParentBlockHashFromSignedExecutionPayloadBidSerialized, getParentBlockRootFromSignedExecutionPayloadBidSerialized, + getParentRootFromSignedBeaconBlockSerialized, getPayloadPresentFromPayloadAttestationMessageSerialized, } from "../../util/sszBytes.ts"; import {NetworkEvent, NetworkEventBus} from "../events.js"; @@ -302,6 +305,9 @@ export class NetworkProcessor { private onPendingGossipsubMessage(message: PendingGossipsubMessage): void { const topicType = message.topic.type; const extractBlockSlotRootFn = this.extractBlockSlotRootFns[topicType]; + + // 1st extract round: make sure slot in range and if block root is not available + // proactively search for it + queue the message const slotRoot = extractBlockSlotRootFn ? extractBlockSlotRootFn(message.msg.data, message.topic.boundary.fork) : null; @@ -345,9 +351,48 @@ export class NetworkProcessor { preprocessResult = {action: PreprocessAction.AwaitBlock, root}; } + // 2nd extract round for some specific topics + // we separate to search action vs await action + + // beacon_block: proactively search for parent block/envelope across all forks, but never queue. + // BlockInputSync handles cascading recovery if the gossip handler throws. + if (topicType === GossipType.beacon_block) { + const parentRoot = getParentRootFromSignedBeaconBlockSerialized(message.msg.data); + if (parentRoot) { + if (ForkSeq[fork] >= ForkSeq.gloas) { + // GLOAS: also check parent envelope, same logic as execution_payload_bid + const parentBlockHash = getParentBlockHashFromGloasSignedBeaconBlockSerialized(message.msg.data); + if (parentBlockHash && !this.chain.forkChoice.getBlockHexAndBlockHash(parentRoot, parentBlockHash)) { + const protoBlock = this.chain.forkChoice.getBlockHexDefaultStatus(parentRoot); + if (protoBlock === null) { + this.searchUnknownBlock( + {slot, root: parentRoot}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + } + if (protoBlock?.executionPayloadBlockHash && protoBlock.executionPayloadBlockHash !== parentBlockHash) { + this.searchUnknownEnvelope( + {slot, root: parentRoot}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + } + } + } else if (!this.chain.forkChoice.hasBlockHexUnsafe(parentRoot)) { + this.searchUnknownBlock( + {slot, root: parentRoot}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + } + } + preprocessResult = {action: PreprocessAction.PushToQueue}; + } + if (ForkSeq[fork] >= ForkSeq.gloas) { // specific check for each topic - // note that it's supposed to NOT queues for beacon_block and execution_payload because it's not a one-off + // note that it's supposed to NOT queue beacon_block (handled above) and execution_payload because it's not a one-off; // for those topics, gossip handlers will throw and BlockInputSync will handle a tree of them instead switch (topicType) { case GossipType.beacon_attestation: @@ -392,7 +437,22 @@ export class NetworkProcessor { } break; } - // TODO GLOAS: handle for beacon_block and execution_payload too, but do not queue them + case GossipType.execution_payload: { + // extractBlockSlotRootFn does not return a root for this topic. + // Extract beacon_block_root directly and proactively trigger block sync if missing. + // Do NOT queue — the handler runs immediately; BlockInputSync handles recovery. + const blockRoot = getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(message.msg.data); + if (blockRoot && !this.chain.forkChoice.hasBlockHexUnsafe(blockRoot)) { + this.searchUnknownBlock( + {slot, root: blockRoot}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + } + // do not await for block, we want UnknownBlockSync to handle it. + preprocessResult = {action: PreprocessAction.PushToQueue}; + break; + } case GossipType.execution_payload_bid: { // instead of search for root, this searches for parent root const parentBlockRoot = getParentBlockRootFromSignedExecutionPayloadBidSerialized(message.msg.data); diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index d146802d23fd..dfdbdc8ec2e9 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -380,6 +380,8 @@ export function getAttDataFromSignedAggregateAndProofPhase0(data: Uint8Array): A * ``` */ const SLOT_BYTES_POSITION_IN_SIGNED_BEACON_BLOCK = VARIABLE_FIELD_OFFSET + SIGNATURE_SIZE; +// proposer_index is ValidatorIndex = uint64 = 8 bytes +const PARENT_ROOT_BYTES_POSITION_IN_SIGNED_BEACON_BLOCK = VARIABLE_FIELD_OFFSET + SIGNATURE_SIZE + SLOT_SIZE + 8; export function getSlotFromSignedBeaconBlockSerialized(data: Uint8Array): Slot | null { if (data.length < SLOT_BYTES_POSITION_IN_SIGNED_BEACON_BLOCK + SLOT_SIZE) { @@ -389,6 +391,67 @@ export function getSlotFromSignedBeaconBlockSerialized(data: Uint8Array): Slot | return getSlotFromOffset(data, SLOT_BYTES_POSITION_IN_SIGNED_BEACON_BLOCK); } +export function getParentRootFromSignedBeaconBlockSerialized(data: Uint8Array): RootHex | null { + if (data.length < PARENT_ROOT_BYTES_POSITION_IN_SIGNED_BEACON_BLOCK + ROOT_SIZE) { + return null; + } + blockRootBuf.set( + data.subarray( + PARENT_ROOT_BYTES_POSITION_IN_SIGNED_BEACON_BLOCK, + PARENT_ROOT_BYTES_POSITION_IN_SIGNED_BEACON_BLOCK + ROOT_SIZE + ) + ); + return `0x${blockRootBuf.toString("hex")}`; +} + +/** + * Extract parentBlockHash from a GLOAS SignedBeaconBlock by navigating the SSZ offset pointer + * to the embedded SignedExecutionPayloadBid. + * + * Layout (bytes from start of SignedBeaconBlock): + * [0..4) message offset + * [4..100) signature (96 B) + * [100..184) BeaconBlock fixed section: slot(8)+proposer_index(8)+parent_root(32)+state_root(32)+body_offset(4) + * [184..) BeaconBlockBody + * + * BeaconBlockBody (GLOAS) fixed section before signedExecutionPayloadBid offset pointer: + * randaoReveal(96) + eth1Data(72) + graffiti(32) + * + proposerSlashings(4) + attesterSlashings(4) + attestations(4) + deposits(4) + voluntaryExits(4) + * + syncAggregate(160) + blsToExecutionChanges(4) = 384 bytes + * + * The 4-byte pointer at byte 568 (= 184+384) gives the offset of SignedExecutionPayloadBid + * within BeaconBlockBody. parentBlockHash is at that bid's byte 100 (after offset+sig). + */ +// BeaconBlock body starts after: msg_offset(4) + sig(96) + slot(8) + proposer_index(8) + parent_root(32) + state_root(32) + body_offset_ptr(4) +const GLOAS_BODY_START_IN_SIGNED_BEACON_BLOCK = + VARIABLE_FIELD_OFFSET + SIGNATURE_SIZE + SLOT_SIZE + 8 + ROOT_SIZE + ROOT_SIZE + VARIABLE_FIELD_OFFSET; // = 184 +const GLOAS_SIGNED_BID_OFFSET_POINTER_IN_BODY = 96 + 72 + 32 + 4 + 4 + 4 + 4 + 4 + 160 + 4; // = 384 +const GLOAS_SIGNED_BID_OFFSET_POINTER_IN_SIGNED_BEACON_BLOCK = + GLOAS_BODY_START_IN_SIGNED_BEACON_BLOCK + GLOAS_SIGNED_BID_OFFSET_POINTER_IN_BODY; // = 568 +// Within SignedExecutionPayloadBid, parentBlockHash is at byte 100 (msg_offset:4 + sig:96) +const PARENT_BLOCK_HASH_OFFSET_IN_SIGNED_BID = VARIABLE_FIELD_OFFSET + SIGNATURE_SIZE; // = 100 + +export function getParentBlockHashFromGloasSignedBeaconBlockSerialized(data: Uint8Array): RootHex | null { + if (data.length < GLOAS_SIGNED_BID_OFFSET_POINTER_IN_SIGNED_BEACON_BLOCK + VARIABLE_FIELD_OFFSET) { + return null; + } + const bidOffset = + data[GLOAS_SIGNED_BID_OFFSET_POINTER_IN_SIGNED_BEACON_BLOCK] | + (data[GLOAS_SIGNED_BID_OFFSET_POINTER_IN_SIGNED_BEACON_BLOCK + 1] << 8) | + (data[GLOAS_SIGNED_BID_OFFSET_POINTER_IN_SIGNED_BEACON_BLOCK + 2] << 16) | + (data[GLOAS_SIGNED_BID_OFFSET_POINTER_IN_SIGNED_BEACON_BLOCK + 3] << 24); + + const parentBlockHashStart = + GLOAS_BODY_START_IN_SIGNED_BEACON_BLOCK + bidOffset + PARENT_BLOCK_HASH_OFFSET_IN_SIGNED_BID; + + if (data.length < parentBlockHashStart + ROOT_SIZE) { + return null; + } + + blockRootBuf.set(data.subarray(parentBlockHashStart, parentBlockHashStart + ROOT_SIZE)); + return `0x${blockRootBuf.toString("hex")}`; +} + /** * class BlobSidecar(Container): * index: BlobIndex [fixed - 8 bytes ], diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index e3ef0173dce8..aaed05330e92 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -38,8 +38,10 @@ import { getIndexFromSignedAggregateAndProofSerialized, getIndexFromSingleAttestationSerialized, getLastProcessedSlotFromBeaconStateSerialized, + getParentBlockHashFromGloasSignedBeaconBlockSerialized, getParentBlockHashFromSignedExecutionPayloadBidSerialized, getParentBlockRootFromSignedExecutionPayloadBidSerialized, + getParentRootFromSignedBeaconBlockSerialized, getPayloadPresentFromPayloadAttestationMessageSerialized, getSignatureFromAttestationSerialized, getSignatureFromSingleAttestationSerialized, @@ -337,6 +339,7 @@ describe("signedBeaconBlock SSZ serialized picking", () => { const bytes = ssz.phase0.SignedBeaconBlock.serialize(signedBeaconBlock); it(`signedBeaconBlock ${i}`, () => { expect(getSlotFromSignedBeaconBlockSerialized(bytes)).toBe(signedBeaconBlock.message.slot); + expect(getParentRootFromSignedBeaconBlockSerialized(bytes)).toBe(toRootHex(signedBeaconBlock.message.parentRoot)); }); } @@ -346,6 +349,30 @@ describe("signedBeaconBlock SSZ serialized picking", () => { expect(getSlotFromSignedBeaconBlockSerialized(Buffer.alloc(size))).toBeNull(); } }); + + it("getParentRootFromSignedBeaconBlockSerialized - invalid data", () => { + for (const size of [0, 100, 147]) { + expect(getParentRootFromSignedBeaconBlockSerialized(Buffer.alloc(size))).toBeNull(); + } + }); +}); + +describe("getParentBlockHashFromGloasSignedBeaconBlockSerialized", () => { + it("extracts parent block hash from GLOAS signed beacon block", () => { + const signedBeaconBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + signedBeaconBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash = Buffer.alloc(32, 0xaa); + const bytes = ssz.gloas.SignedBeaconBlock.serialize(signedBeaconBlock); + + expect(getParentBlockHashFromGloasSignedBeaconBlockSerialized(bytes)).toBe( + toHex(signedBeaconBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash) + ); + }); + + it("returns null for invalid data", () => { + for (const size of [0, 200, 571]) { + expect(getParentBlockHashFromGloasSignedBeaconBlockSerialized(Buffer.alloc(size))).toBeNull(); + } + }); }); describe("BlobSidecar SSZ serialized picking", () => { From c01cfbf41dd424065d0e7d60b94e6f27a93834cd Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Wed, 25 Mar 2026 15:34:03 +0700 Subject: [PATCH 05/19] fix: ts import --- packages/beacon-node/src/network/processor/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index 5b0591152c7d..a119eec39613 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -21,7 +21,7 @@ import { getParentBlockRootFromSignedExecutionPayloadBidSerialized, getParentRootFromSignedBeaconBlockSerialized, getPayloadPresentFromPayloadAttestationMessageSerialized, -} from "../../util/sszBytes.ts"; +} from "../../util/sszBytes.js"; import {NetworkEvent, NetworkEventBus} from "../events.js"; import { GossipHandlers, @@ -403,6 +403,7 @@ export class NetworkProcessor { ? getIndexFromSingleAttestationSerialized(fork, message.msg.data) : getIndexFromSignedAggregateAndProofSerialized(message.msg.data); if (attIndex === 1 && !this.chain.forkChoice.hasEnvelopeHexUnsafe(root)) { + // ptc attestation vote for the payload but it's not known this.searchUnknownEnvelope( {slot, root}, BlockInputSource.network_processor, From d6b8617b263a319066e3bb98f70ca254c154a7f1 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Wed, 25 Mar 2026 16:06:57 +0700 Subject: [PATCH 06/19] fix: do not await block for data_column_sidecar + fix comments --- .../network/processor/extractSlotRootFns.ts | 4 +-- .../src/network/processor/gossipHandlers.ts | 2 +- .../src/network/processor/index.ts | 25 ++++++++++--------- .../fork-choice/src/forkChoice/forkChoice.ts | 6 ++--- .../fork-choice/src/protoArray/protoArray.ts | 2 +- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 309c14d6f715..46817156981a 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -19,7 +19,7 @@ import {ExtractSlotRootFns} from "./types.js"; /** * Extract the slot and block root of a gossip message form serialized data. - * Only do it for messages that has slot and block root, and we want to await for the block if the block root is not known. + * Only do it for messages that have a slot and block root, and we want to await the block if the block root is not known. */ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { return { @@ -73,7 +73,7 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { }, [GossipType.execution_payload]: (data: Uint8Array): SlotOptionalRoot | null => { const slot = getSlotFromExecutionPayloadEnvelopeSerialized(data); - // do not extract root now because network processor will await for block + // do not extract root now because network processor will await the block // instead, we want network processor to extract root and search for block later if it's missing and BlockInputSync will queue it if (slot === null) { return null; diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 710f2c335cd5..bed719826c53 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -161,7 +161,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand logger.debug("Received gossip block", {...logCtx}); - // optimically add gossip block to the seen cache + // optimistically add gossip block to the seen cache // if validation fails, we will NOT forward this gossip block to peers // - if PARENT_UNKNOWN error, blockInput will then be queued inside BlockInputSync. If the gossip block is really invalid, it will be pruned there // - if other validator errors, blockInput will stay in the seen cache and will be pruned on finalization diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index a119eec39613..5dac14438f79 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -139,7 +139,7 @@ export enum CannotAcceptWorkReason { } /** - * No need metrics for this so make it as numeric to make it ligghtweight + * No metrics needed here; using a number to keep it lightweight */ enum PreprocessAction { AwaitBlock, @@ -306,7 +306,7 @@ export class NetworkProcessor { const topicType = message.topic.type; const extractBlockSlotRootFn = this.extractBlockSlotRootFns[topicType]; - // 1st extract round: make sure slot in range and if block root is not available + // 1st extract round: make sure slot is in range and if block root is not available // proactively search for it + queue the message const slotRoot = extractBlockSlotRootFn ? extractBlockSlotRootFn(message.msg.data, message.topic.boundary.fork) @@ -339,20 +339,20 @@ export class NetworkProcessor { message.msgSlot = slot; - // this to determine this message needs to wait for Block or Envelope - // a message should only be waited for what they voted for, hence we don't want to put them on both queues + // this determines whether this message needs to wait for a Block or Envelope + // a message should only wait for what it voted for, hence we don't want to put it on both queues let preprocessResult: PreprocessResult = {action: PreprocessAction.PushToQueue}; // no need to check if root is a descendant of the current finalized block, it will be checked once we validate the message if needed if (root && !this.chain.forkChoice.hasBlockHexUnsafe(root)) { // starting from GLOAS, unknown root from data_column_sidecar also falls into this case this.searchUnknownBlock({slot, root}, BlockInputSource.network_processor, message.propagationSource.toString()); - // for beacon_attestation and beacon_aggregate_and_proof messages, this is only temporary - // if "index = 1" we need to await for Envelope instead + // for beacon_attestation and beacon_aggregate_and_proof messages, this is only temporary. + // if "index = 1" we need to await for the Envelope instead preprocessResult = {action: PreprocessAction.AwaitBlock, root}; } // 2nd extract round for some specific topics - // we separate to search action vs await action + // we separate the search action from the await action // beacon_block: proactively search for parent block/envelope across all forks, but never queue. // BlockInputSync handles cascading recovery if the gossip handler throws. @@ -403,7 +403,7 @@ export class NetworkProcessor { ? getIndexFromSingleAttestationSerialized(fork, message.msg.data) : getIndexFromSignedAggregateAndProofSerialized(message.msg.data); if (attIndex === 1 && !this.chain.forkChoice.hasEnvelopeHexUnsafe(root)) { - // ptc attestation vote for the payload but it's not known + // ptc attestation votes for the payload but the envelope is not yet known this.searchUnknownEnvelope( {slot, root}, BlockInputSource.network_processor, @@ -434,14 +434,15 @@ export class NetworkProcessor { BlockInputSource.network_processor, message.propagationSource.toString() ); - preprocessResult = {action: PreprocessAction.AwaitEnvelope, root}; + // do not await the envelope, we can do gossip validation + preprocessResult = {action: PreprocessAction.PushToQueue}; } break; } case GossipType.execution_payload: { // extractBlockSlotRootFn does not return a root for this topic. // Extract beacon_block_root directly and proactively trigger block sync if missing. - // Do NOT queue — the handler runs immediately; BlockInputSync handles recovery. + // Do NOT await the block — the handler runs immediately; BlockInputSync handles recovery. const blockRoot = getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(message.msg.data); if (blockRoot && !this.chain.forkChoice.hasBlockHexUnsafe(blockRoot)) { this.searchUnknownBlock( @@ -450,12 +451,12 @@ export class NetworkProcessor { message.propagationSource.toString() ); } - // do not await for block, we want UnknownBlockSync to handle it. + // do not await the block, we want UnknownBlockSync to handle it. preprocessResult = {action: PreprocessAction.PushToQueue}; break; } case GossipType.execution_payload_bid: { - // instead of search for root, this searches for parent root + // instead of searching for the message root, this searches for the parent root const parentBlockRoot = getParentBlockRootFromSignedExecutionPayloadBidSerialized(message.msg.data); const parentBlockHash = getParentBlockHashFromSignedExecutionPayloadBidSerialized(message.msg.data); if ( diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index b787ee22d716..e15c3331532a 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1046,14 +1046,14 @@ export class ForkChoice implements IForkChoice { } /** - * Same to hasBlock but without checking if the block is a descendant of the finalized root. + * Same as hasBlock but without checking if the block is a descendant of the finalized root. */ hasBlockUnsafe(blockRoot: Root): boolean { return this.hasBlockHexUnsafe(toRootHex(blockRoot)); } /** - * Same to hasBlockHex but without checking if the block is a descendant of the finalized root. + * Same as hasBlockHex but without checking if the block is a descendant of the finalized root. */ hasBlockHexUnsafe(blockRoot: RootHex): boolean { return this.protoArray.hasBlock(blockRoot); @@ -1068,7 +1068,7 @@ export class ForkChoice implements IForkChoice { } /** - * Same to hasEnvelopeUnsafe but accepts a hex-encoded block root. + * Same as hasEnvelopeUnsafe but accepts a hex-encoded block root. */ hasEnvelopeHexUnsafe(blockRoot: RootHex): boolean { return this.protoArray.hasEnvelope(blockRoot); diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 4ad36b2b50e2..9bb0ef370bde 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1678,7 +1678,7 @@ export class ProtoArray { * Returns true once the SignedExecutionPayloadEnvelope for this block has been received and processed. */ hasEnvelope(blockRoot: RootHex): boolean { - // we should also make sure this blockRoot is gloas block, however we only call this function + // we should also make sure this blockRoot is a gloas block, however we only call this function // starting from GLOAS_FORK_EPOCH, so we can assume the blockRoot is from gloas block return this.getNodeIndexByRootAndStatus(blockRoot, PayloadStatus.FULL) !== undefined; } From e297ba2eb9f6c72d4d4d0a4f670c48608138b075 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 09:00:10 +0700 Subject: [PATCH 07/19] fix: implement hasBlock and hasEnvelope --- packages/beacon-node/src/chain/chain.ts | 4 ++-- .../src/chain/seenCache/seenGossipBlockInput.ts | 4 ++-- .../src/chain/seenCache/seenPayloadEnvelopeInput.ts | 4 ++-- .../test/unit/chain/seenCache/seenBlockInput.test.ts | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index c4b05ea7e44a..b58c9a004e14 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -504,11 +504,11 @@ export class BeaconChain implements IBeaconChain { } seenBlock(blockRoot: RootHex): boolean { - return this.seenBlockInputCache.has(blockRoot) || this.forkChoice.hasBlockHex(blockRoot); + return this.seenBlockInputCache.hasBlock(blockRoot) || this.forkChoice.hasBlockHex(blockRoot); } seenEnvelope(blockRoot: RootHex): boolean { - return this.seenPayloadEnvelopeInputCache.has(blockRoot) || this.forkChoice.hasEnvelopeHexUnsafe(blockRoot); + return this.seenPayloadEnvelopeInputCache.hasEnvelope(blockRoot) || this.forkChoice.hasEnvelopeHexUnsafe(blockRoot); } regenCanAcceptWork(): boolean { diff --git a/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts b/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts index 336256678962..abf0df1959f9 100644 --- a/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts @@ -149,8 +149,8 @@ export class SeenBlockInput { }); } - has(rootHex: RootHex): boolean { - return this.blockInputs.has(rootHex); + hasBlock(rootHex: RootHex): boolean { + return this.blockInputs.get(rootHex)?.hasBlock() ?? false; } get(rootHex: RootHex): IBlockInput | undefined { diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 1932a4c38f42..fa2fd1d8f9ea 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -84,8 +84,8 @@ export class SeenPayloadEnvelopeInput { return this.payloadInputs.get(blockRootHex); } - has(blockRootHex: RootHex): boolean { - return this.payloadInputs.has(blockRootHex); + hasEnvelope(blockRootHex: RootHex): boolean { + return this.payloadInputs.get(blockRootHex)?.hasPayloadEnvelope() ?? false; } prune(blockRootHex: RootHex): void { diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockInput.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockInput.test.ts index 1a121e70bf8e..e9531b816c95 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenBlockInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockInput.test.ts @@ -55,8 +55,8 @@ describe("SeenBlockInputCache", async () => { }); }); - describe("has()", () => { - it("should return true if in cache", () => { + describe("hasBlock()", () => { + it("should return true if block is in cache", () => { const {block, rootHex} = generateBlock({forkName: ForkName.capella}); cache.getByBlock({ block, @@ -64,7 +64,7 @@ describe("SeenBlockInputCache", async () => { source: BlockInputSource.gossip, seenTimestampSec: Date.now() / 1000, }); - expect(cache.has(rootHex)).toBeTruthy(); + expect(cache.hasBlock(rootHex)).toBeTruthy(); }); it("should return false if not in cache", () => { @@ -75,11 +75,11 @@ describe("SeenBlockInputCache", async () => { source: BlockInputSource.gossip, seenTimestampSec: Date.now() / 1000, }); - expect(cache.has(rootHex)).toBeTruthy(); + expect(cache.hasBlock(rootHex)).toBeTruthy(); blockRoot[0] = (blockRoot[0] + 1) % 255; blockRoot[1] = (blockRoot[1] + 1) % 255; blockRoot[2] = (blockRoot[2] + 1) % 255; - expect(cache.has(toRootHex(blockRoot))).toBeFalsy(); + expect(cache.hasBlock(toRootHex(blockRoot))).toBeFalsy(); }); }); @@ -153,7 +153,7 @@ describe("SeenBlockInputCache", async () => { blockRoot[1] = (blockRoot[1] + 1) % 255; blockRoot[2] = (blockRoot[2] + 1) % 255; expect(() => cache.remove(toRootHex(blockRoot))).not.toThrow(); - expect(cache.has(rootHex)).toBeTruthy(); + expect(cache.hasBlock(rootHex)).toBeTruthy(); }); }); From 27a9b2c04d5f2b492016104668358540dfaa3985 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 09:09:15 +0700 Subject: [PATCH 08/19] refactor: rename forkchoice.hasEnvelope -> forkchoice.hasPayload --- packages/beacon-node/src/chain/chain.ts | 2 +- .../beacon-node/src/network/processor/gossipHandlers.ts | 1 + packages/beacon-node/src/network/processor/index.ts | 6 +++--- packages/fork-choice/src/forkChoice/forkChoice.ts | 8 ++++---- packages/fork-choice/src/forkChoice/interface.ts | 4 ++-- packages/fork-choice/src/protoArray/protoArray.ts | 2 +- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index b58c9a004e14..554a552c4e47 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -508,7 +508,7 @@ export class BeaconChain implements IBeaconChain { } seenEnvelope(blockRoot: RootHex): boolean { - return this.seenPayloadEnvelopeInputCache.hasEnvelope(blockRoot) || this.forkChoice.hasEnvelopeHexUnsafe(blockRoot); + return this.seenPayloadEnvelopeInputCache.hasEnvelope(blockRoot) || this.forkChoice.hasPayloadHexUnsafe(blockRoot); } regenCanAcceptWork(): boolean { diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index bed719826c53..f25051b99e57 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -858,6 +858,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand const {slot, beaconBlockRoot} = signedEnvelope.message; logger.debug("Gossip envelope has error", {slot, root: toRootHex(beaconBlockRoot), code: e.type.code}); if (e.type.code === ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN) { + // TODO GLOAS: UnknownBlockSync to handle this chain.emitter.emit(ChainEvent.envelopeUnknownBlockRoot, { envelope: signedEnvelope, peer: peerIdStr, diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index 5dac14438f79..89060a443d21 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -402,7 +402,7 @@ export class NetworkProcessor { topicType === GossipType.beacon_attestation ? getIndexFromSingleAttestationSerialized(fork, message.msg.data) : getIndexFromSignedAggregateAndProofSerialized(message.msg.data); - if (attIndex === 1 && !this.chain.forkChoice.hasEnvelopeHexUnsafe(root)) { + if (attIndex === 1 && !this.chain.forkChoice.hasPayloadHexUnsafe(root)) { // ptc attestation votes for the payload but the envelope is not yet known this.searchUnknownEnvelope( {slot, root}, @@ -416,7 +416,7 @@ export class NetworkProcessor { case GossipType.payload_attestation_message: { if (root == null) break; const payloadPresent = getPayloadPresentFromPayloadAttestationMessageSerialized(message.msg.data); - if (payloadPresent && !this.chain.forkChoice.hasEnvelopeHexUnsafe(root)) { + if (payloadPresent && !this.chain.forkChoice.hasPayloadHexUnsafe(root)) { this.searchUnknownEnvelope( {slot, root}, BlockInputSource.network_processor, @@ -428,7 +428,7 @@ export class NetworkProcessor { } case GossipType.data_column_sidecar: { if (root == null) break; - if (!this.chain.forkChoice.hasEnvelopeHexUnsafe(root)) { + if (!this.chain.forkChoice.hasPayloadHexUnsafe(root)) { this.searchUnknownEnvelope( {slot, root}, BlockInputSource.network_processor, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index e15c3331532a..2eaeca95a225 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1063,15 +1063,15 @@ export class ForkChoice implements IForkChoice { * Returns true if the FULL payload variant (execution payload envelope) exists for this block root, * without checking if the block is a descendant of the finalized root. */ - hasEnvelopeUnsafe(blockRoot: Root): boolean { - return this.hasEnvelopeHexUnsafe(toRootHex(blockRoot)); + hasPayloadUnsafe(blockRoot: Root): boolean { + return this.hasPayloadHexUnsafe(toRootHex(blockRoot)); } /** * Same as hasEnvelopeUnsafe but accepts a hex-encoded block root. */ - hasEnvelopeHexUnsafe(blockRoot: RootHex): boolean { - return this.protoArray.hasEnvelope(blockRoot); + hasPayloadHexUnsafe(blockRoot: RootHex): boolean { + return this.protoArray.hasPayload(blockRoot); } /** diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index ac75d8a2e3e7..6b258518dd17 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -230,8 +230,8 @@ export interface IForkChoice { * Returns true if the FULL payload variant (execution payload envelope) exists for this block root, * without checking if the block is a descendant of the finalized root. */ - hasEnvelopeUnsafe(blockRoot: Root): boolean; - hasEnvelopeHexUnsafe(blockRoot: RootHex): boolean; + hasPayloadUnsafe(blockRoot: Root): boolean; + hasPayloadHexUnsafe(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; /** * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 9bb0ef370bde..033a7190ee78 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1677,7 +1677,7 @@ export class ProtoArray { * Check if a FULL payload variant (execution payload envelope) exists for this block root. * Returns true once the SignedExecutionPayloadEnvelope for this block has been received and processed. */ - hasEnvelope(blockRoot: RootHex): boolean { + hasPayload(blockRoot: RootHex): boolean { // we should also make sure this blockRoot is a gloas block, however we only call this function // starting from GLOAS_FORK_EPOCH, so we can assume the blockRoot is from gloas block return this.getNodeIndexByRootAndStatus(blockRoot, PayloadStatus.FULL) !== undefined; From 7be4b1674cf9943fc9dc6926db406983cce06845 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 15:24:57 +0700 Subject: [PATCH 09/19] chore: use hasPayload in ProtoArray.isPayloadTimely() --- packages/beacon-node/src/util/sszBytes.ts | 5 ++++- packages/fork-choice/src/protoArray/protoArray.ts | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index dfdbdc8ec2e9..76690a71e0bc 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -179,7 +179,10 @@ export function getSlotFromSingleAttestationSerialized(data: Uint8Array): Slot | } /** - * Extract committee index from SingleAttestation serialized bytes. + * Extract index from SingleAttestation serialized bytes. + * Post-gloas, `index` field is repurposed: + * - 0 — payload was not available (or attestation is same-slot, where availability is not yet known) + * - 1 - payload was available * Return null if data is not long enough to extract slot. */ export function getIndexFromSingleAttestationSerialized(fork: ForkName, data: Uint8Array): CommitteeIndex | null { diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 033a7190ee78..d32944a6f3a9 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -653,9 +653,7 @@ export class ProtoArray { } // If payload is not locally available, it's not timely - // In our implementation, payload is locally available if proto array has FULL variant of the block - const fullNodeIndex = this.getNodeIndexByRootAndStatus(blockRoot, PayloadStatus.FULL); - if (fullNodeIndex === undefined) { + if (!this.hasPayload(blockRoot)) { return false; } From c7794b02724d42d0e00d2320697b658367e7695d Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 15:27:29 +0700 Subject: [PATCH 10/19] fix: use unsafe version for BeaconChain.seenBlock() --- packages/beacon-node/src/chain/chain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 554a552c4e47..6267c6fe6937 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -504,7 +504,7 @@ export class BeaconChain implements IBeaconChain { } seenBlock(blockRoot: RootHex): boolean { - return this.seenBlockInputCache.hasBlock(blockRoot) || this.forkChoice.hasBlockHex(blockRoot); + return this.seenBlockInputCache.hasBlock(blockRoot) || this.forkChoice.hasBlockHexUnsafe(blockRoot); } seenEnvelope(blockRoot: RootHex): boolean { From 104c83318cfe9b3b6b13d5c912eb0e05fdfa62bd Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 15:34:34 +0700 Subject: [PATCH 11/19] chore: add comment for data_column_sidecar root extraction --- .../beacon-node/src/network/processor/extractSlotRootFns.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 46817156981a..2bf10dd014e4 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -69,6 +69,8 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { } const root = getBeaconBlockRootFromDataColumnSidecarSerialized(data); + // null root means the message is invalid here and will be rejected in gossip handler later + // returning the slot here helps check the earliest permissable slot in the network processor return root !== null ? {slot, root} : {slot}; }, [GossipType.execution_payload]: (data: Uint8Array): SlotOptionalRoot | null => { From d7860f20f627dc3c8061f28ffe964a1d4b5d28b6 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 30 Mar 2026 14:03:32 +0700 Subject: [PATCH 12/19] fix: handle data_column_sidecar unknown envelope --- packages/beacon-node/src/network/processor/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index 89060a443d21..9e69bea4a620 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -435,7 +435,7 @@ export class NetworkProcessor { message.propagationSource.toString() ); // do not await the envelope, we can do gossip validation - preprocessResult = {action: PreprocessAction.PushToQueue}; + // also do not reset preprocessResult, we may already await for the block } break; } From bfeb5f4560c9c5f342a133983d1b3896bd43414a Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 30 Mar 2026 14:23:22 +0700 Subject: [PATCH 13/19] chore: tweak event names --- packages/beacon-node/src/chain/emitter.ts | 21 ++++++++++--------- .../src/network/processor/gossipHandlers.ts | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/beacon-node/src/chain/emitter.ts b/packages/beacon-node/src/chain/emitter.ts index 152a2e0a87de..bcd9ee86a46e 100644 --- a/packages/beacon-node/src/chain/emitter.ts +++ b/packages/beacon-node/src/chain/emitter.ts @@ -55,26 +55,27 @@ export enum ChainEvent { */ updateStatus = "updateStatus", /** - * Trigger a BlockInputSync for blocks where the parentRoot is not known to fork choice + * Trigger BlockInputSync to find parent of a SignedBeaconBlock received + * Post-gloas, missing parent could be a SignedBeaconBlock and/or a SignedExecutionPayloadEnvelope */ blockUnknownParent = "blockUnknownParent", /** - * Trigger a BlockInputSync for envelope (payload) with unknown block root + * Trigger BlockInputSync to find a SignedBeaconBlock given a SignedExecutionPayloadEnvelop received */ - envelopeUnknownBlockRoot = "envelopeUnknownBlockRoot", + envelopeUnknownBlock = "envelopeUnknownBlock", /** - * Trigger BlockInputSync for objects that correspond to a block that is not known to fork choice + * Trigger BlockInputSync to find a SignedBeaconBlock with specified block root. */ unknownBlockRoot = "unknownBlockRoot", + /** + * Trigger BlockInputSync to find a SignedExecutionPayloadEnvelope with specified block root. + */ + unknownEnvelopeBlockRoot = "unknownEnvelopeBlockRoot", /** * Trigger BlockInputSync for blocks that are partially received via gossip but are not complete by time the * cut-off window passes for waiting on gossip */ incompleteBlockInput = "incompleteBlockInput", - /** - * Trigger sync for objects that correspond to a payload envelope (SignedExecutionPayloadEnvelope) that is not yet known - */ - unknownEnvelopeBlockRoot = "unknownEnvelopeBlockRoot", } export type HeadEventData = routes.events.EventData[routes.events.EventType.head]; @@ -85,7 +86,7 @@ type ApiEvents = {[K in routes.events.EventType]: (data: routes.events.EventData export type ChainEventData = { [ChainEvent.blockUnknownParent]: {blockInput: IBlockInput; peer: PeerIdStr; source: BlockInputSource}; - [ChainEvent.envelopeUnknownBlockRoot]: { + [ChainEvent.envelopeUnknownBlock]: { envelope: SignedExecutionPayloadEnvelope; peer?: PeerIdStr; source: BlockInputSource; @@ -112,7 +113,7 @@ export type IChainEvents = ApiEvents & { // Sync events that are chain->chain. Initiated from network requests but do not cross the network // barrier so are considered ChainEvent(s). [ChainEvent.blockUnknownParent]: (data: ChainEventData[ChainEvent.blockUnknownParent]) => void; - [ChainEvent.envelopeUnknownBlockRoot]: (data: ChainEventData[ChainEvent.envelopeUnknownBlockRoot]) => void; + [ChainEvent.envelopeUnknownBlock]: (data: ChainEventData[ChainEvent.envelopeUnknownBlock]) => void; [ChainEvent.unknownBlockRoot]: (data: ChainEventData[ChainEvent.unknownBlockRoot]) => void; [ChainEvent.incompleteBlockInput]: (data: ChainEventData[ChainEvent.incompleteBlockInput]) => void; [ChainEvent.unknownEnvelopeBlockRoot]: (data: ChainEventData[ChainEvent.unknownEnvelopeBlockRoot]) => void; diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index f25051b99e57..af71891ed81d 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -859,7 +859,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand logger.debug("Gossip envelope has error", {slot, root: toRootHex(beaconBlockRoot), code: e.type.code}); if (e.type.code === ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN) { // TODO GLOAS: UnknownBlockSync to handle this - chain.emitter.emit(ChainEvent.envelopeUnknownBlockRoot, { + chain.emitter.emit(ChainEvent.envelopeUnknownBlock, { envelope: signedEnvelope, peer: peerIdStr, source: BlockInputSource.gossip, From e957cb0d0cf3b0e1935635f8f7ef907772f6e9b8 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 30 Mar 2026 14:25:34 +0700 Subject: [PATCH 14/19] fix: outdated method reference --- packages/fork-choice/src/forkChoice/forkChoice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 2eaeca95a225..5da6c431f5ce 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1068,7 +1068,7 @@ export class ForkChoice implements IForkChoice { } /** - * Same as hasEnvelopeUnsafe but accepts a hex-encoded block root. + * Same as hasPayloadUnsafe but accepts a hex-encoded block root. */ hasPayloadHexUnsafe(blockRoot: RootHex): boolean { return this.protoArray.hasPayload(blockRoot); From 6b458b4d7d5f57064e2283a551438e1e26deda9e Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 30 Mar 2026 14:28:11 +0700 Subject: [PATCH 15/19] fix: comment in execution_payload root extraction --- .../beacon-node/src/network/processor/extractSlotRootFns.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 2bf10dd014e4..909a914de117 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -75,8 +75,7 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { }, [GossipType.execution_payload]: (data: Uint8Array): SlotOptionalRoot | null => { const slot = getSlotFromExecutionPayloadEnvelopeSerialized(data); - // do not extract root now because network processor will await the block - // instead, we want network processor to extract root and search for block later if it's missing and BlockInputSync will queue it + // Do not extract the root here; the network processor will extract it in the 2nd round to trigger block search without awaiting. if (slot === null) { return null; } From 134a7fed117e998ba8741a45c54ce194cf6ecf0e Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 30 Mar 2026 14:36:52 +0700 Subject: [PATCH 16/19] chore: add comment for getParentBlockHashFromGloasSignedBeaconBlockSerialized() --- packages/beacon-node/src/util/sszBytes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 76690a71e0bc..77626d0a8a79 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -434,6 +434,7 @@ const GLOAS_SIGNED_BID_OFFSET_POINTER_IN_SIGNED_BEACON_BLOCK = // Within SignedExecutionPayloadBid, parentBlockHash is at byte 100 (msg_offset:4 + sig:96) const PARENT_BLOCK_HASH_OFFSET_IN_SIGNED_BID = VARIABLE_FIELD_OFFSET + SIGNATURE_SIZE; // = 100 +// CAUTION: update offsets if BeaconBlockBody fixed fields change after Gloas export function getParentBlockHashFromGloasSignedBeaconBlockSerialized(data: Uint8Array): RootHex | null { if (data.length < GLOAS_SIGNED_BID_OFFSET_POINTER_IN_SIGNED_BEACON_BLOCK + VARIABLE_FIELD_OFFSET) { return null; From 25156fa2fbc0a6e27fab8f4db7c0efd6f3ce3cdc Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 31 Mar 2026 09:22:24 +0700 Subject: [PATCH 17/19] chore: more comments for beacon_block topic --- packages/beacon-node/src/network/processor/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index 9e69bea4a620..be9566580851 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -370,8 +370,11 @@ export class NetworkProcessor { BlockInputSource.network_processor, message.propagationSource.toString() ); - } - if (protoBlock?.executionPayloadBlockHash && protoBlock.executionPayloadBlockHash !== parentBlockHash) { + } else if ( + protoBlock.executionPayloadBlockHash && + protoBlock.executionPayloadBlockHash !== parentBlockHash + ) { + // only search for the envelope by block root if we're sure there is one. Otherwise UnknownBlockSync will penalize the peer. this.searchUnknownEnvelope( {slot, root: parentRoot}, BlockInputSource.network_processor, From 546a10912c113e81c7a35c56df7aa631258c167d Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 31 Mar 2026 10:34:18 +0700 Subject: [PATCH 18/19] fix: await for routes.events.EventType.executionPayload event --- .../src/network/processor/index.ts | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index be9566580851..a9549f324cd4 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -209,13 +209,10 @@ export class NetworkProcessor { modules ); - events.on(NetworkEvent.pendingGossipsubMessage, this.onPendingGossipsubMessage.bind(this)); - this.chain.emitter.on(routes.events.EventType.block, this.onBlockProcessed.bind(this)); - this.chain.emitter.on( - routes.events.EventType.executionPayloadAvailable, - this.onPayloadEnvelopeProcessed.bind(this) - ); - this.chain.clock.on(ClockEvent.slot, this.onClockSlot.bind(this)); + events.on(NetworkEvent.pendingGossipsubMessage, this.onPendingGossipsubMessage); + this.chain.emitter.on(routes.events.EventType.block, this.onBlockProcessed); + this.chain.emitter.on(routes.events.EventType.executionPayload, this.onPayloadEnvelopeProcessed); + this.chain.clock.on(ClockEvent.slot, this.onClockSlot); this.awaitingMessagesByBlockRoot = new MapDef>(() => new Set()); this.awaitingMessagesByPayloadBlockRoot = new MapDef>(() => new Set()); @@ -248,7 +245,7 @@ export class NetworkProcessor { async stop(): Promise { this.events.off(NetworkEvent.pendingGossipsubMessage, this.onPendingGossipsubMessage); this.chain.emitter.off(routes.events.EventType.block, this.onBlockProcessed); - this.chain.emitter.off(routes.events.EventType.executionPayloadAvailable, this.onPayloadEnvelopeProcessed); + this.chain.emitter.off(routes.events.EventType.executionPayload, this.onPayloadEnvelopeProcessed); this.chain.emitter.off(ClockEvent.slot, this.onClockSlot); } @@ -302,7 +299,7 @@ export class NetworkProcessor { this.chain.emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, {rootHex: root, peer, source}); } - private onPendingGossipsubMessage(message: PendingGossipsubMessage): void { + private onPendingGossipsubMessage = (message: PendingGossipsubMessage): void => { const topicType = message.topic.type; const extractBlockSlotRootFn = this.extractBlockSlotRootFns[topicType]; @@ -475,8 +472,10 @@ export class NetworkProcessor { message.propagationSource.toString() ); preprocessResult = {action: PreprocessAction.AwaitBlock, root: parentBlockRoot}; - } - if (protoBlock?.executionPayloadBlockHash && protoBlock?.executionPayloadBlockHash !== parentBlockHash) { + } else if ( + protoBlock.executionPayloadBlockHash && + protoBlock.executionPayloadBlockHash !== parentBlockHash + ) { this.searchUnknownEnvelope( {slot, root: parentBlockRoot}, BlockInputSource.network_processor, @@ -526,7 +525,7 @@ export class NetworkProcessor { break; } } - } + }; private pushPendingGossipsubMessageToQueue(message: PendingGossipsubMessage): void { const topicType = message.topic.type; @@ -540,7 +539,7 @@ export class NetworkProcessor { this.executeWork(); } - private async onBlockProcessed({block: rootHex}: {block: string; executionOptimistic: boolean}): Promise { + private onBlockProcessed = async ({block: rootHex}: {block: string; executionOptimistic: boolean}): Promise => { const waitingGossipsubMessages = this.awaitingMessagesByBlockRoot.get(rootHex); if (!waitingGossipsubMessages || waitingGossipsubMessages.size === 0) { return; @@ -567,9 +566,9 @@ export class NetworkProcessor { } this.awaitingMessagesByBlockRoot.delete(rootHex); - } + }; - private async onPayloadEnvelopeProcessed({blockRoot: rootHex}: {blockRoot: RootHex}): Promise { + private onPayloadEnvelopeProcessed = async ({blockRoot: rootHex}: {blockRoot: RootHex}): Promise => { const waitingGossipsubMessages = this.awaitingMessagesByPayloadBlockRoot.get(rootHex); if (!waitingGossipsubMessages || waitingGossipsubMessages.size === 0) { return; @@ -593,9 +592,9 @@ export class NetworkProcessor { } this.awaitingMessagesByPayloadBlockRoot.delete(rootHex); - } + }; - private onClockSlot(clockSlot: Slot): void { + private onClockSlot = (clockSlot: Slot): void => { const nowSec = Date.now() / 1000; const minSlot = clockSlot - MAX_UNKNOWN_ROOTS_SLOT_CACHE_SIZE; @@ -644,7 +643,7 @@ export class NetworkProcessor { } this.unknownEnvelopesBySlot.delete(slot); } - } + }; private executeWork(): void { // TODO: Maybe de-bounce by timing the last time executeWork was run From 946c38d6390f586a8d3a07a7a6fa086b708a090b Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Thu, 2 Apr 2026 09:41:22 +0700 Subject: [PATCH 19/19] refactor: rename hasEnvelope() -> hasPayload() --- packages/beacon-node/src/chain/chain.ts | 4 ++-- packages/beacon-node/src/chain/interface.ts | 2 +- .../src/chain/seenCache/seenPayloadEnvelopeInput.ts | 2 +- .../beacon-node/src/network/processor/extractSlotRootFns.ts | 2 +- packages/beacon-node/src/network/processor/index.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 7f40a5300f50..fd6636661fc3 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -508,8 +508,8 @@ export class BeaconChain implements IBeaconChain { return this.seenBlockInputCache.hasBlock(blockRoot) || this.forkChoice.hasBlockHexUnsafe(blockRoot); } - seenEnvelope(blockRoot: RootHex): boolean { - return this.seenPayloadEnvelopeInputCache.hasEnvelope(blockRoot) || this.forkChoice.hasPayloadHexUnsafe(blockRoot); + seenPayloadEnvelope(blockRoot: RootHex): boolean { + return this.seenPayloadEnvelopeInputCache.hasPayload(blockRoot) || this.forkChoice.hasPayloadHexUnsafe(blockRoot); } regenCanAcceptWork(): boolean { diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index ab06a90eb4c7..44785055dea9 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -160,7 +160,7 @@ export interface IBeaconChain { /** Chain has seen the specified block root or not. The block may not be processed yet, use forkchoice.hasBlock to check it */ seenBlock(blockRoot: RootHex): boolean; /** Chain has seen a SignedExecutionPayloadEnvelope for this block root (via seenCache or fork choice FULL variant) */ - seenEnvelope(blockRoot: RootHex): boolean; + seenPayloadEnvelope(blockRoot: RootHex): boolean; /** Populate in-memory caches with persisted data. Call at least once on startup */ loadFromDisk(): Promise; /** Persist in-memory data to the DB. Call at least once before stopping the process */ diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index fa2fd1d8f9ea..e36147638061 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -84,7 +84,7 @@ export class SeenPayloadEnvelopeInput { return this.payloadInputs.get(blockRootHex); } - hasEnvelope(blockRootHex: RootHex): boolean { + hasPayload(blockRootHex: RootHex): boolean { return this.payloadInputs.get(blockRootHex)?.hasPayloadEnvelope() ?? false; } diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 909a914de117..9ebc959e07e6 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -69,7 +69,7 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { } const root = getBeaconBlockRootFromDataColumnSidecarSerialized(data); - // null root means the message is invalid here and will be rejected in gossip handler later + // null root means the message is invalid here and will be ignored in gossip handler later // returning the slot here helps check the earliest permissable slot in the network processor return root !== null ? {slot, root} : {slot}; }, diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index a9549f324cd4..ea26d58259c2 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -289,7 +289,7 @@ export class NetworkProcessor { */ searchUnknownEnvelope({slot, root}: SlotRootHex, source: BlockInputSource, peer?: PeerIdStr): void { if ( - this.chain.seenEnvelope(root) || + this.chain.seenPayloadEnvelope(root) || this.awaitingMessagesByPayloadBlockRoot.has(root) || this.unknownEnvelopesBySlot.getOrDefault(slot).has(root) ) {