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 99be6d7aaf0d..a0a0b58bb9b2 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -217,7 +217,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, @@ -312,7 +312,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/chain.ts b/packages/beacon-node/src/chain/chain.ts index 8778e33c7130..fd6636661fc3 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -505,7 +505,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.hasBlockHexUnsafe(blockRoot); + } + + seenPayloadEnvelope(blockRoot: RootHex): boolean { + return this.seenPayloadEnvelopeInputCache.hasPayload(blockRoot) || this.forkChoice.hasPayloadHexUnsafe(blockRoot); } regenCanAcceptWork(): boolean { diff --git a/packages/beacon-node/src/chain/emitter.ts b/packages/beacon-node/src/chain/emitter.ts index a5d5f0af9ced..1b11eea89e11 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 {DataColumnSidecar, 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"; @@ -54,13 +55,22 @@ 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 */ - unknownParent = "unknownParent", + blockUnknownParent = "blockUnknownParent", /** - * Trigger BlockInputSync for objects that correspond to a block that is not known to fork choice + * Trigger BlockInputSync to find a SignedBeaconBlock given a SignedExecutionPayloadEnvelop received + */ + envelopeUnknownBlock = "envelopeUnknownBlock", + /** + * 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 @@ -75,9 +85,15 @@ 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.envelopeUnknownBlock]: { + 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}; }; export type IChainEvents = ApiEvents & { @@ -96,9 +112,11 @@ 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.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/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 358ecb641451..44785055dea9 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) */ + 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/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..e36147638061 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); + hasPayload(blockRootHex: RootHex): boolean { + return this.payloadInputs.get(blockRootHex)?.hasPayloadEnvelope() ?? false; } prune(blockRootHex: RootHex): void { 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 643a08b64475..6eeb813dd32e 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 93019bba548d..623fca31039c 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -285,6 +285,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..9ebc959e07e6 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -1,23 +1,25 @@ -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"; /** * Extract the slot and block root of a gossip message form serialized data. - * Not applicable for all topics. + * 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 { @@ -57,21 +59,45 @@ 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); + // 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}; }, - [GossipType.execution_payload]: (data: Uint8Array): SlotRootHex | null => { + [GossipType.execution_payload]: (data: Uint8Array): SlotOptionalRoot | null => { const slot = getSlotFromExecutionPayloadEnvelopeSerialized(data); - const root = getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(data); + // 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; + } + return {slot}; + }, + [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; + } + + return {slot}; + }, }; } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index b2ca9788fbc9..7787158ef5d5 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -169,16 +169,19 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand logger.debug("Received gossip block", {...logCtx}); - let blockInput: IBlockInput | undefined; + // 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 + 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; @@ -194,9 +197,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) { - 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, @@ -1037,8 +1040,37 @@ 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) { + // TODO GLOAS: UnknownBlockSync to handle this + chain.emitter.emit(ChainEvent.envelopeUnknownBlock, { + 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/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index 73e52c382487..ea26d58259c2 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -12,6 +12,16 @@ 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 { + getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, + getIndexFromSignedAggregateAndProofSerialized, + getIndexFromSingleAttestationSerialized, + getParentBlockHashFromGloasSignedBeaconBlockSerialized, + getParentBlockHashFromSignedExecutionPayloadBidSerialized, + getParentBlockRootFromSignedExecutionPayloadBidSerialized, + getParentRootFromSignedBeaconBlockSerialized, + getPayloadPresentFromPayloadAttestationMessageSerialized, +} from "../../util/sszBytes.js"; import {NetworkEvent, NetworkEventBus} from "../events.js"; import { GossipHandlers, @@ -89,6 +99,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 +138,20 @@ export enum CannotAcceptWorkReason { regen = "regen_busy", } +/** + * No metrics needed here; using a number to keep it lightweight + */ +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 @@ -159,7 +185,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, @@ -179,11 +209,13 @@ 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.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()); // TODO: Implement queues and priorization for ReqResp incoming requests // Listens to NetworkEvent.reqRespIncomingRequest event @@ -196,6 +228,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 +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.executionPayload, this.onPayloadEnvelopeProcessed); this.chain.emitter.off(ClockEvent.slot, this.onClockSlot); } @@ -232,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 { @@ -248,9 +282,29 @@ export class NetworkProcessor { this.chain.emitter.emit(ChainEvent.unknownBlockRoot, {rootHex: root, peer, source}); } - private onPendingGossipsubMessage(message: PendingGossipsubMessage): void { + /** + * Search envelope via `ChainEvent.unknownEnvelopeBlockRoot` event + * 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 { + if ( + this.chain.seenPayloadEnvelope(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]; + + // 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) : null; @@ -282,27 +336,196 @@ export class NetworkProcessor { message.msgSlot = slot; + // 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 the Envelope instead + preprocessResult = {action: PreprocessAction.AwaitBlock, root}; + } - 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; + // 2nd extract round for some specific topics + // 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. + 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() + ); + } 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, + 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}; + } - this.metrics?.awaitingBlockGossipMessages.queue.inc({topic: topicType}); - const awaitingGossipsubMessages = this.awaitingMessagesByBlockRoot.getOrDefault(root); - awaitingGossipsubMessages.add(message); - return; + if (ForkSeq[fork] >= ForkSeq.gloas) { + // specific check for each topic + // 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: + 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.hasPayloadHexUnsafe(root)) { + // ptc attestation votes for the payload but the envelope is not yet known + this.searchUnknownEnvelope( + {slot, root}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + preprocessResult = {action: PreprocessAction.AwaitEnvelope, root}; + } + break; + } + case GossipType.payload_attestation_message: { + if (root == null) break; + const payloadPresent = getPayloadPresentFromPayloadAttestationMessageSerialized(message.msg.data); + if (payloadPresent && !this.chain.forkChoice.hasPayloadHexUnsafe(root)) { + this.searchUnknownEnvelope( + {slot, root}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + preprocessResult = {action: PreprocessAction.AwaitEnvelope, root}; + } + break; + } + case GossipType.data_column_sidecar: { + if (root == null) break; + if (!this.chain.forkChoice.hasPayloadHexUnsafe(root)) { + this.searchUnknownEnvelope( + {slot, root}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + // do not await the envelope, we can do gossip validation + // also do not reset preprocessResult, we may already await for the block + } + 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 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( + {slot, root: blockRoot}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + } + // do not await the block, we want UnknownBlockSync to handle it. + preprocessResult = {action: PreprocessAction.PushToQueue}; + break; + } + case GossipType.execution_payload_bid: { + // 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 ( + 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() + ); + preprocessResult = {action: PreprocessAction.AwaitBlock, root: parentBlockRoot}; + } else if ( + protoBlock.executionPayloadBlockHash && + protoBlock.executionPayloadBlockHash !== parentBlockHash + ) { + this.searchUnknownEnvelope( + {slot, root: parentBlockRoot}, + BlockInputSource.network_processor, + message.propagationSource.toString() + ); + preprocessResult = {action: PreprocessAction.AwaitEnvelope, root: parentBlockRoot}; + } + } + break; + } + } } - this.pushPendingGossipsubMessageToQueue(message); - } + switch (preprocessResult.action) { + case PreprocessAction.PushToQueue: + this.pushPendingGossipsubMessageToQueue(message); + break; + 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({ + reason: ReprocessRejectReason.reached_limit, + topic: topicType, + }); + return; + } + + this.metrics?.awaitingBlockGossipMessages.queue.inc({topic: topicType}); + const awaitingGossipsubMessages = this.awaitingMessagesByBlockRoot.getOrDefault(preprocessResult.root); + awaitingGossipsubMessages.add(message); + break; + } + case PreprocessAction.AwaitEnvelope: { + 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}); + const awaitingPayloadGossipsubMessages = this.awaitingMessagesByPayloadBlockRoot.getOrDefault( + preprocessResult.root + ); + awaitingPayloadGossipsubMessages.add(message); + break; + } + } + }; private pushPendingGossipsubMessageToQueue(message: PendingGossipsubMessage): void { const topicType = message.topic.type; @@ -316,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; @@ -343,9 +566,35 @@ export class NetworkProcessor { } this.awaitingMessagesByBlockRoot.delete(rootHex); - } + }; + + private onPayloadEnvelopeProcessed = async ({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); + } + } - private onClockSlot(clockSlot: Slot): void { + this.awaitingMessagesByPayloadBlockRoot.delete(rootHex); + }; + + private onClockSlot = (clockSlot: Slot): void => { const nowSec = Date.now() / 1000; const minSlot = clockSlot - MAX_UNKNOWN_ROOTS_SLOT_CACHE_SIZE; @@ -371,7 +620,30 @@ 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 { // TODO: Maybe de-bounce by timing the last time executeWork was run @@ -517,4 +789,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/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/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 0f7bf9c36882..42b70941b7ec 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -179,13 +179,13 @@ 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 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 +269,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 +304,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 @@ -369,6 +383,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) { @@ -378,6 +394,68 @@ 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 + +// 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; + } + 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 ], @@ -527,6 +605,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/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/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(); }); }); 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/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index e30bde3735ff..aaed05330e92 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,18 @@ import { getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getBlobKzgCommitmentsCountFromSignedBeaconBlockSerialized, getBlockRootFromAttestationSerialized, + getBlockRootFromPayloadAttestationMessageSerialized, getBlockRootFromSignedAggregateAndProofSerialized, getBlockRootFromSingleAttestationSerialized, getCommitteeBitsFromSignedAggregateAndProofElectra, - getCommitteeIndexFromSingleAttestationSerialized, + getIndexFromSignedAggregateAndProofSerialized, + getIndexFromSingleAttestationSerialized, getLastProcessedSlotFromBeaconStateSerialized, + getParentBlockHashFromGloasSignedBeaconBlockSerialized, + getParentBlockHashFromSignedExecutionPayloadBidSerialized, + getParentBlockRootFromSignedExecutionPayloadBidSerialized, + getParentRootFromSignedBeaconBlockSerialized, + getPayloadPresentFromPayloadAttestationMessageSerialized, getSignatureFromAttestationSerialized, getSignatureFromSingleAttestationSerialized, getSlotFromAttestationSerialized, @@ -42,8 +50,10 @@ import { getSlotFromBlobSidecarSerialized, getSlotFromDataColumnSidecarSerialized, getSlotFromExecutionPayloadEnvelopeSerialized, + getSlotFromPayloadAttestationMessageSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, + getSlotFromSignedExecutionPayloadBidSerialized, getSlotFromSingleAttestationSerialized, } from "../../../src/util/sszBytes.js"; import {generateRandomBlob} from "../../utils/kzg.js"; @@ -79,9 +89,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 +99,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 +157,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 +300,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)]; @@ -301,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)); }); } @@ -310,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", () => { @@ -338,6 +401,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 +642,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..5da6c431f5ce 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1046,19 +1046,34 @@ 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); } + /** + * 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. + */ + hasPayloadUnsafe(blockRoot: Root): boolean { + return this.hasPayloadHexUnsafe(toRootHex(blockRoot)); + } + + /** + * Same as hasPayloadUnsafe but accepts a hex-encoded block root. + */ + hasPayloadHexUnsafe(blockRoot: RootHex): boolean { + return this.protoArray.hasPayload(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..6b258518dd17 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. + */ + 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 485c4011e6ef..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; } @@ -1673,6 +1671,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. + */ + 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; + } + /** * Return ProtoNode for blockRoot with explicit payload status * diff --git a/packages/types/package.json b/packages/types/package.json index 3fa52d4a3a60..4619247eb9e6 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": [