From f4d60a7ca4c9ba1fe4d59886ed1222fa6f1113cc Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Sun, 15 Mar 2026 12:26:02 +0000 Subject: [PATCH 1/2] feat: block gossip validation for parent payload (consensus-specs#4923) - IGNORE block if parent execution payload has not been seen - Add PARENT_PAYLOAD_UNKNOWN block error code - Add hasExecutionPayload() method to fork-choice and proto-array - Linear scan for now (TODO: secondary index for O(1) lookups) --- .../src/chain/errors/blockError.ts | 5 +++- .../beacon-node/src/chain/validation/block.ts | 11 +++++++ .../fork-choice/src/forkChoice/forkChoice.ts | 8 +++++ .../fork-choice/src/forkChoice/interface.ts | 5 ++++ .../fork-choice/src/protoArray/protoArray.ts | 30 +++++++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index 7eb02a60bb40..01de8b27883c 100644 --- a/packages/beacon-node/src/chain/errors/blockError.ts +++ b/packages/beacon-node/src/chain/errors/blockError.ts @@ -70,6 +70,8 @@ export enum BlockErrorCode { TOO_MANY_KZG_COMMITMENTS = "BLOCK_ERROR_TOO_MANY_KZG_COMMITMENTS", /** Bid parent block root does not match block parent root */ BID_PARENT_ROOT_MISMATCH = "BLOCK_ERROR_BID_PARENT_ROOT_MISMATCH", + /** Parent execution payload has not been seen */ + PARENT_PAYLOAD_UNKNOWN = "BLOCK_ERROR_PARENT_PAYLOAD_UNKNOWN", } type ExecutionErrorStatus = Exclude< @@ -114,7 +116,8 @@ export type BlockErrorType = | {code: BlockErrorCode.EXECUTION_ENGINE_ERROR; execStatus: ExecutionErrorStatus; errorMessage: string} | {code: BlockErrorCode.DATA_UNAVAILABLE} | {code: BlockErrorCode.TOO_MANY_KZG_COMMITMENTS; blobKzgCommitmentsLen: number; commitmentLimit: number} - | {code: BlockErrorCode.BID_PARENT_ROOT_MISMATCH; bidParentRoot: RootHex; blockParentRoot: RootHex}; + | {code: BlockErrorCode.BID_PARENT_ROOT_MISMATCH; bidParentRoot: RootHex; blockParentRoot: RootHex} + | {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentBlockHash: RootHex}; export class BlockGossipError extends GossipActionError {} diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index f7addf9a5794..8570e7751086 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -153,6 +153,17 @@ export async function validateGossipBlock( }); } + // [IGNORE] The block's parent execution payload (defined by bid.parent_block_hash) has been seen + // (via gossip or non-gossip sources) (a client MAY queue blocks for processing once the parent + // payload is retrieved). + const parentBlockHashHex = toRootHex(bid.parentBlockHash); + if (!chain.forkChoice.hasExecutionPayload(parentBlockHashHex)) { + throw new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN, + parentBlockHash: parentBlockHashHex, + }); + } + // TODO GLOAS: [REJECT] The block's execution payload parent (defined by bid.parent_block_hash) passes all validation // This requires execution engine integration to verify the parent block hash } diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 2d0cef22540c..840e395a265d 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1056,6 +1056,14 @@ export class ForkChoice implements IForkChoice { return this.protoArray.hasBlock(blockRoot); } + /** + * Check if an execution payload with the given block hash has been seen. + * Spec: gloas/p2p-interface.md — parent execution payload must have been seen + */ + hasExecutionPayload(executionPayloadBlockHash: RootHex): boolean { + return this.protoArray.hasExecutionPayload(executionPayloadBlockHash); + } + /** * 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 26e7f08c2be5..bcdd8dd290bc 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -228,6 +228,11 @@ export interface IForkChoice { */ hasBlockUnsafe(blockRoot: Root): boolean; hasBlockHexUnsafe(blockRoot: RootHex): boolean; + /** + * Check if an execution payload with the given block hash has been seen. + * For Gloas: checks if any block has a FULL variant with matching execution payload block hash. + */ + hasExecutionPayload(executionPayloadBlockHash: 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 683ba6b38145..4826f87cfb3d 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1671,6 +1671,36 @@ export class ProtoArray { return this.getDefaultNodeIndex(blockRoot) !== undefined; } + /** + * Check if an execution payload with the given block hash has been seen. + * Looks through all nodes with FULL variant status for a matching executionPayloadBlockHash. + * Used for Gloas gossip validation: parent execution payload must have been seen. + * + * TODO GLOAS: Add secondary index (Set by executionPayloadBlockHash) for O(1) lookups. + * Current O(n) linear scan is acceptable during development but should be optimized before mainnet. + */ + hasExecutionPayload(executionPayloadBlockHash: RootHex): boolean { + for (const [, variantOrArr] of this.indices) { + if (!Array.isArray(variantOrArr)) { + // Pre-Gloas: check directly + const node = this.nodes[variantOrArr]; + if (node?.executionPayloadBlockHash === executionPayloadBlockHash) { + return true; + } + } else { + // Gloas: check FULL variant if it exists + const fullIndex = variantOrArr[PayloadStatus.FULL]; + if (fullIndex !== undefined) { + const node = this.nodes[fullIndex]; + if (node?.executionPayloadBlockHash === executionPayloadBlockHash) { + return true; + } + } + } + } + return false; + } + /** * Return ProtoNode for blockRoot with explicit payload status * From 0ac2422d2800336249522ff3e5d18454ccd9442f Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Sun, 15 Mar 2026 22:48:58 +0000 Subject: [PATCH 2/2] perf: O(1) hasExecutionPayload via secondary index Add Set secondary index for executionPayloadBlockHash lookups. Maintained in onBlock() (pre-Gloas) and onExecutionPayload() (Gloas), pruned in maybePrune(). Replaces O(n) linear scan. Reference: ChainSafe/lodestar@e275112 Co-authored-by: lodekeeper <258435968+lodekeeper@users.noreply.github.com> AI-assisted: yes --- .../fork-choice/src/protoArray/protoArray.ts | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 4826f87cfb3d..df79c76ee1d0 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -70,6 +70,13 @@ export class ProtoArray { */ private ptcVotes = new Map(); + /** + * Secondary index: O(1) lookup by execution payload block hash. + * Maintained by onBlock() (pre-Gloas) and onExecutionPayload() (Gloas). + * Pruned in maybePrune(). + */ + private executionPayloadBlockHashes = new Set(); + constructor({ pruneThreshold, justifiedEpoch, @@ -515,6 +522,11 @@ export class ProtoArray { // Pre-Gloas: store FULL index instead of array this.indices.set(block.blockRoot, nodeIndex); + // Maintain secondary index for O(1) hasExecutionPayload lookups + if (block.executionPayloadBlockHash != null) { + this.executionPayloadBlockHashes.add(block.executionPayloadBlockHash); + } + // If this node is valid, lets propagate the valid status up the chain // and throw error if we counter invalid, as this breaks consensus if (node.parent !== undefined) { @@ -602,6 +614,9 @@ export class ProtoArray { // Add FULL variant to the indices array variants[PayloadStatus.FULL] = fullIndex; + // Maintain secondary index for O(1) hasExecutionPayload lookups + this.executionPayloadBlockHashes.add(executionPayloadBlockHash); + // Update bestChild for PENDING node (may now prefer FULL over EMPTY) this.maybeUpdateBestChildAndDescendant(pendingIndex, fullIndex, currentSlot, proposerBoostRoot); } @@ -1078,6 +1093,13 @@ export class ProtoArray { prunedRoots.add(node.blockRoot); } + // Clean up secondary execution payload block hash index + for (let i = 0; i < finalizedIndex; i++) { + const node = this.nodes[i]; + if (node?.executionPayloadBlockHash != null) { + this.executionPayloadBlockHashes.delete(node.executionPayloadBlockHash); + } + } // Remove indices for pruned blocks and PTC votes for (const root of prunedRoots) { this.indices.delete(root); @@ -1673,32 +1695,12 @@ export class ProtoArray { /** * Check if an execution payload with the given block hash has been seen. - * Looks through all nodes with FULL variant status for a matching executionPayloadBlockHash. * Used for Gloas gossip validation: parent execution payload must have been seen. * - * TODO GLOAS: Add secondary index (Set by executionPayloadBlockHash) for O(1) lookups. - * Current O(n) linear scan is acceptable during development but should be optimized before mainnet. + * O(1) via secondary index maintained in onBlock() (pre-Gloas) and onExecutionPayload() (Gloas). */ hasExecutionPayload(executionPayloadBlockHash: RootHex): boolean { - for (const [, variantOrArr] of this.indices) { - if (!Array.isArray(variantOrArr)) { - // Pre-Gloas: check directly - const node = this.nodes[variantOrArr]; - if (node?.executionPayloadBlockHash === executionPayloadBlockHash) { - return true; - } - } else { - // Gloas: check FULL variant if it exists - const fullIndex = variantOrArr[PayloadStatus.FULL]; - if (fullIndex !== undefined) { - const node = this.nodes[fullIndex]; - if (node?.executionPayloadBlockHash === executionPayloadBlockHash) { - return true; - } - } - } - } - return false; + return this.executionPayloadBlockHashes.has(executionPayloadBlockHash); } /**