From ed4534e00d3ae9b56a356a095c6290e64298723d Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Apr 2026 22:22:35 +0100 Subject: [PATCH 1/7] test: add bellatrix and capella gossip validation spec tests --- .../src/chain/errors/blockError.ts | 5 +- .../beacon-node/src/chain/validation/block.ts | 9 + .../test/spec/utils/gossipValidation.ts | 173 ++++++++++++++++-- 3 files changed, 173 insertions(+), 14 deletions(-) diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index 49224927e4da..b5fe7b88b925 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", + /** The parent block's execution payload has been verified as invalid */ + PARENT_EXECUTION_INVALID = "BLOCK_ERROR_PARENT_EXECUTION_INVALID", } 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_EXECUTION_INVALID; parentRoot: 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 28d425b8f8f1..062161722b3b 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -1,4 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; +import {ExecutionStatus} from "@lodestar/fork-choice"; import {ForkName, isForkPostBellatrix, isForkPostDeneb, isForkPostGloas} from "@lodestar/params"; import { computeEpochAtSlot, @@ -91,6 +92,14 @@ export async function validateGossipBlock( throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot}); } + // [IGNORE] The block's parent has been verified to have an invalid execution payload. + if (isForkPostBellatrix(fork) && parentBlock.executionStatus === ExecutionStatus.Invalid) { + throw new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.PARENT_EXECUTION_INVALID, + parentRoot, + }); + } + // [IGNORE] The attestation head block is too far behind the attestation slot, causing many skip slots. // This is deemed a DoS risk because we need to get the proposerShuffling. To get the shuffling we have // to do a bunch of epoch transitions, the longer the distance between the parent and block, diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index 5d2779cf3875..d7381e58e7e6 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -2,15 +2,20 @@ import {EventEmitter} from "node:events"; import fs from "node:fs"; import path from "node:path"; import {generateKeyPair} from "@libp2p/crypto/keys"; +import jsyaml from "js-yaml"; import snappy from "snappy"; import {expect} from "vitest"; -import {createBeaconConfig} from "@lodestar/config"; +import {chainConfigFromJson, chainConfigTypes, createBeaconConfig} from "@lodestar/config"; import {getConfig} from "@lodestar/config/test-utils"; +import {ExecutionStatus} from "@lodestar/fork-choice"; import {testLogger} from "@lodestar/logger/test-utils"; import {ForkName} from "@lodestar/params"; import { BeaconStateAllForks, BeaconStateView, + DataAvailabilityStatus, + ExecutionPayloadStatus, + IBeaconStateView, computeEpochAtSlot, computeStartSlotAtEpoch, createCachedBeaconState, @@ -18,7 +23,7 @@ import { isExecutionStateType, syncPubkeys, } from "@lodestar/state-transition"; -import {RootHex, ssz, sszTypesFor} from "@lodestar/types"; +import {RootHex, SignedBeaconBlock, ssz, sszTypesFor} from "@lodestar/types"; import {fromHex, loadYaml, toHex, toRootHex} from "@lodestar/utils"; import {BlockInputPreData, BlockInputSource} from "../../../src/chain/blocks/blockInput/index.js"; import {AttestationImportOpt, BlobSidecarValidation} from "../../../src/chain/blocks/types.js"; @@ -29,6 +34,7 @@ import {validateGossipAggregateAndProof} from "../../../src/chain/validation/agg import {GossipAttestation, validateGossipAttestationsSameAttData} from "../../../src/chain/validation/attestation.js"; import {validateGossipAttesterSlashing} from "../../../src/chain/validation/attesterSlashing.js"; import {validateGossipBlock} from "../../../src/chain/validation/block.js"; +import {validateGossipBlsToExecutionChange} from "../../../src/chain/validation/blsToExecutionChange.js"; import {validateGossipProposerSlashing} from "../../../src/chain/validation/proposerSlashing.js"; import {validateGossipSyncCommittee} from "../../../src/chain/validation/syncCommittee.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../src/chain/validation/syncCommitteeContributionAndProof.js"; @@ -128,9 +134,11 @@ class GossipTestClock extends EventEmitter implements IClock { } } +type MetaPayloadStatus = "VALID" | "NOT_VALIDATED" | "INVALIDATED"; + interface MetaYaml { topic: GossipType; - blocks?: {block: string; failed?: boolean}[]; + blocks?: {block: string; failed?: boolean; payload_status?: MetaPayloadStatus}[]; finalized_checkpoint?: {epoch: bigint; root?: string; block?: string}; current_time_ms?: bigint; messages: { @@ -151,6 +159,7 @@ const gossipTopicByHandler = { gossip_voluntary_exit: GossipType.voluntary_exit, gossip_sync_committee_message: GossipType.sync_committee, gossip_sync_committee_contribution_and_proof: GossipType.sync_committee_contribution_and_proof, + gossip_bls_to_execution_change: GossipType.bls_to_execution_change, } as const satisfies Record; export function isGossipValidationHandler(topicHandler: string): topicHandler is keyof typeof gossipTopicByHandler { @@ -169,6 +178,26 @@ function loadMeta(testCaseDir: string): MetaYaml { return loadYaml(raw); } +function loadTestCaseChainConfig(testCaseDir: string, fork: ForkName) { + const configPath = path.join(testCaseDir, "config.yaml"); + if (!fs.existsSync(configPath)) return getConfig(fork); + + // Parse config scalars as raw strings so byte values such as `0x00000001` + // keep their leading zeros before passing through `chainConfigFromJson()`. + const parsed = jsyaml.load(fs.readFileSync(configPath, "utf8"), { + schema: jsyaml.FAILSAFE_SCHEMA, + }) as Record; + const configJson: Record = {}; + + for (const [key, value] of Object.entries(parsed)) { + if (key in chainConfigTypes) { + configJson[key] = String(value); + } + } + + return {...getConfig(fork), ...chainConfigFromJson(configJson)}; +} + function loadSszSnappy(testCaseDir: string, name: string): Uint8Array { const compressed = fs.readFileSync(path.join(testCaseDir, `${name}.ssz_snappy`)); const decompressed = snappy.uncompressSync(compressed); @@ -245,6 +274,49 @@ function setFinalizedCheckpoint(chain: BeaconChain, checkpoint: FinalizedCheckpo forkChoice.updateHead?.(); } +function getDataAvailabilityStatusForFork(fork: ForkName): DataAvailabilityStatus { + switch (fork) { + case ForkName.deneb: + case ForkName.electra: + case ForkName.fulu: + case ForkName.gloas: + return DataAvailabilityStatus.Available; + + default: + return DataAvailabilityStatus.PreData; + } +} + +function computePostState( + parentState: IBeaconStateView, + signedBlock: SignedBeaconBlock, + fork: ForkName +): IBeaconStateView { + return parentState.stateTransition( + signedBlock, + { + verifyStateRoot: true, + verifyProposer: true, + executionPayloadStatus: ExecutionPayloadStatus.valid, + dataAvailabilityStatus: getDataAvailabilityStatusForFork(fork), + }, + {} + ); +} + +function invalidateImportedBlock(chain: BeaconChain, blockRootHex: RootHex, parentRootHex: RootHex): void { + const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentRootHex); + if (!parentBlock?.executionPayloadBlockHash) { + throw new Error(`Cannot invalidate ${blockRootHex}: parent ${parentRootHex} has no latest valid execution hash`); + } + + chain.forkChoice.validateLatestHash({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: parentBlock.executionPayloadBlockHash, + invalidateFromParentBlockRoot: blockRootHex, + }); +} + function isDescendantAtFinalizedCheckpoint( chain: BeaconChain, blockRootHex: RootHex, @@ -282,8 +354,8 @@ export async function runGossipValidationTest( } const anchorState = loadState(testCaseDir, fork); - const config = getConfig(fork); - const beaconConfig = createBeaconConfig(config, anchorState.genesisValidatorsRoot); + const testCaseConfig = loadTestCaseChainConfig(testCaseDir, fork); + const beaconConfig = createBeaconConfig(testCaseConfig, anchorState.genesisValidatorsRoot); const genesisTimeSec = Number(anchorState.genesisTime); const clock = new GossipTestClock( @@ -311,6 +383,7 @@ export async function runGossipValidationTest( {config: beaconConfig, pubkeyCache}, {skipSyncPubkeys: true} ); + const anchorStateView = new BeaconStateView(cachedState); const chain = new BeaconChain( { @@ -338,7 +411,7 @@ export async function runGossipValidationTest( clock, metrics: null, validatorMonitor: null, - anchorState: new BeaconStateView(cachedState), + anchorState: anchorStateView, isAnchorStateFinalized: true, executionEngine, executionBuilder: undefined, @@ -349,9 +422,11 @@ export async function runGossipValidationTest( try { const blockRootsByName = new Map(); + const blockStatesByRoot = new Map(); + const rejectedFailedBlockRoots = new Set(); if (meta.blocks) { - for (const blockEntry of meta.blocks) { + for (const [index, blockEntry] of meta.blocks.entries()) { const signedBlock = sszTypesFor(fork).SignedBeaconBlock.deserialize( loadSszSnappy(testCaseDir, blockEntry.block) ); @@ -359,10 +434,57 @@ export async function runGossipValidationTest( const blockRootHex = toHex(beaconConfig.getForkTypes(slot).BeaconBlock.hashTreeRoot(signedBlock.message)); blockRootsByName.set(blockEntry.block, blockRootHex); - if (blockEntry.failed) continue; - - // Skip genesis block — it's already the anchor state - if (slot === 0) continue; + if (index === 0) { + blockStatesByRoot.set(blockRootHex, anchorStateView); + continue; + } + + const parentRootHex = toRootHex(signedBlock.message.parentRoot); + const parentState = blockStatesByRoot.get(parentRootHex); + if (!parentState) { + if (blockEntry.failed) { + rejectedFailedBlockRoots.add(blockRootHex); + continue; + } + throw new Error(`Missing parent state for ${blockEntry.block} with parent ${parentRootHex}`); + } + + const postState = computePostState(parentState, signedBlock, fork); + + if (blockEntry.failed) { + if (blockEntry.payload_status === "VALID") { + clock.setSlot(slot); + chain.forkChoice.updateTime(slot); + chain.forkChoice.onBlock( + signedBlock.message, + postState, + 0, + slot, + ExecutionStatus.Valid, + getDataAvailabilityStatusForFork(fork) + ); + blockStatesByRoot.set(blockRootHex, postState); + } else { + rejectedFailedBlockRoots.add(blockRootHex); + } + continue; + } + + if (blockEntry.payload_status === "INVALIDATED") { + clock.setSlot(slot); + chain.forkChoice.updateTime(slot); + chain.forkChoice.onBlock( + signedBlock.message, + postState, + 0, + slot, + ExecutionStatus.Syncing, + getDataAvailabilityStatusForFork(fork) + ); + blockStatesByRoot.set(blockRootHex, postState); + invalidateImportedBlock(chain, blockRootHex, parentRootHex); + continue; + } clock.setSlot(slot); chain.forkChoice.updateTime(slot); @@ -382,6 +504,8 @@ export async function runGossipValidationTest( importAttestations: AttestationImportOpt.Force, validSignatures: false, }); + + blockStatesByRoot.set(blockRootHex, postState); } } @@ -407,7 +531,16 @@ export async function runGossipValidationTest( let result: "valid" | "ignore" | "reject"; try { - await validateMessageForTopic(chain, fork, topic, testCaseDir, message, failedBlockRoots, finalizedCheckpoint); + await validateMessageForTopic( + chain, + fork, + topic, + testCaseDir, + message, + failedBlockRoots, + rejectedFailedBlockRoots, + finalizedCheckpoint + ); result = "valid"; } catch (e) { result = mapErrorToResult(e); @@ -431,6 +564,7 @@ async function validateMessageForTopic( testCaseDir: string, message: MetaYaml["messages"][number], failedBlockRoots: Set, + rejectedFailedBlockRoots: Set, finalizedCheckpoint: FinalizedCheckpoint | null ): Promise { const bytes = rejectOnInvalidSerializedBytes(() => loadSszSnappy(testCaseDir, message.message)); @@ -440,7 +574,7 @@ async function validateMessageForTopic( const signedBlock = rejectOnInvalidSerializedBytes(() => sszTypesFor(fork).SignedBeaconBlock.deserialize(bytes)); const parentRootHex = toRootHex(signedBlock.message.parentRoot); - if (failedBlockRoots.has(parentRootHex)) { + if (rejectedFailedBlockRoots.has(parentRootHex)) { throw new GossipActionError(GossipAction.REJECT, {code: "SPEC_PARENT_BLOCK_FAILED"}); } @@ -553,6 +687,19 @@ async function validateMessageForTopic( break; } + case GossipType.bls_to_execution_change: { + if (chain.clock.currentEpoch < chain.config.CAPELLA_FORK_EPOCH) { + throw new GossipActionError(GossipAction.IGNORE, {code: "SPEC_PRE_CAPELLA"}); + } + + const change = rejectOnInvalidSerializedBytes(() => + sszTypesFor(fork).SignedBLSToExecutionChange.deserialize(bytes) + ); + await validateGossipBlsToExecutionChange(chain, change); + chain.opPool.insertBlsToExecutionChange(change); + break; + } + default: throw new Error(`Unknown gossip topic: ${topic}`); } From b52ca3e56a93d1832a01b7fb71629c437674a57b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Apr 2026 22:43:01 +0100 Subject: [PATCH 2/7] Fix types --- packages/beacon-node/test/spec/utils/gossipValidation.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index d7381e58e7e6..29f3f8037d5f 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -692,11 +692,12 @@ async function validateMessageForTopic( throw new GossipActionError(GossipAction.IGNORE, {code: "SPEC_PRE_CAPELLA"}); } - const change = rejectOnInvalidSerializedBytes(() => - sszTypesFor(fork).SignedBLSToExecutionChange.deserialize(bytes) + const blsToExecutionChange = rejectOnInvalidSerializedBytes(() => + ssz.capella.SignedBLSToExecutionChange.deserialize(bytes) ); - await validateGossipBlsToExecutionChange(chain, change); - chain.opPool.insertBlsToExecutionChange(change); + await validateGossipBlsToExecutionChange(chain, blsToExecutionChange); + // Mirror gossip handler: insert into opPool so duplicate detection works + chain.opPool.insertBlsToExecutionChange(blsToExecutionChange); break; } From ae36fcbe3c79bd95b9ca8a7425f94691c3aa96a7 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Apr 2026 23:03:25 +0100 Subject: [PATCH 3/7] review --- packages/beacon-node/src/chain/validation/block.ts | 3 ++- .../beacon-node/test/spec/utils/gossipValidation.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 062161722b3b..2f4a7a64cd90 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -92,7 +92,8 @@ export async function validateGossipBlock( throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot}); } - // [IGNORE] The block's parent has been verified to have an invalid execution payload. + // [IGNORE] The block's parent (defined by `block.parent_root`) passes all validation + // (including execution node verification of the `block.body.execution_payload`) if (isForkPostBellatrix(fork) && parentBlock.executionStatus === ExecutionStatus.Invalid) { throw new BlockGossipError(GossipAction.IGNORE, { code: BlockErrorCode.PARENT_EXECUTION_INVALID, diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index 29f3f8037d5f..ff1e17b3e3c9 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -435,6 +435,16 @@ export async function runGossipValidationTest( blockRootsByName.set(blockEntry.block, blockRootHex); if (index === 0) { + // We assume the first block in meta.blocks is the anchor block whose post-state is + // the loaded anchor state. Assert this to avoid silently mis-seeding the state map. + if (blockEntry.failed) { + throw new Error(`First block ${blockEntry.block} must not be marked as failed`); + } + if (slot !== anchorState.latestBlockHeader.slot) { + throw new Error( + `First block slot ${slot} does not match anchor state slot ${anchorState.latestBlockHeader.slot}` + ); + } blockStatesByRoot.set(blockRootHex, anchorStateView); continue; } From 93b5f67ae358ccd7c0bdfb8c43775d5e169b67dd Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Apr 2026 23:20:16 +0100 Subject: [PATCH 4/7] update bls_to_execution_change handler --- packages/beacon-node/test/spec/utils/gossipValidation.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index ff1e17b3e3c9..918db72f4421 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -698,13 +698,12 @@ async function validateMessageForTopic( } case GossipType.bls_to_execution_change: { - if (chain.clock.currentEpoch < chain.config.CAPELLA_FORK_EPOCH) { - throw new GossipActionError(GossipAction.IGNORE, {code: "SPEC_PRE_CAPELLA"}); - } - const blsToExecutionChange = rejectOnInvalidSerializedBytes(() => ssz.capella.SignedBLSToExecutionChange.deserialize(bytes) ); + if (chain.clock.currentEpoch < chain.config.CAPELLA_FORK_EPOCH) { + throw new GossipActionError(GossipAction.IGNORE, {code: "SPEC_PRE_CAPELLA"}); + } await validateGossipBlsToExecutionChange(chain, blsToExecutionChange); // Mirror gossip handler: insert into opPool so duplicate detection works chain.opPool.insertBlsToExecutionChange(blsToExecutionChange); From 3400650e2e3b73816dbc548f0b2ccecc55f516d5 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 6 Apr 2026 00:03:35 +0100 Subject: [PATCH 5/7] address codex review --- .../test/spec/utils/gossipValidation.ts | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index 918db72f4421..bfa8dab740c7 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -184,14 +184,16 @@ function loadTestCaseChainConfig(testCaseDir: string, fork: ForkName) { // Parse config scalars as raw strings so byte values such as `0x00000001` // keep their leading zeros before passing through `chainConfigFromJson()`. + // FAILSAFE_SCHEMA produces strings for scalars and preserves arrays/objects + // (e.g. `BLOB_SCHEDULE`) as-is for `chainConfigFromJson` to deserialize. const parsed = jsyaml.load(fs.readFileSync(configPath, "utf8"), { schema: jsyaml.FAILSAFE_SCHEMA, }) as Record; - const configJson: Record = {}; + const configJson: Record = {}; for (const [key, value] of Object.entries(parsed)) { if (key in chainConfigTypes) { - configJson[key] = String(value); + configJson[key] = value; } } @@ -459,24 +461,29 @@ export async function runGossipValidationTest( throw new Error(`Missing parent state for ${blockEntry.block} with parent ${parentRootHex}`); } + // Failed blocks only need a post-state if they'll be imported into fork-choice + // (payload_status=VALID). Skip the state transition otherwise — it would be wasted + // work, and would throw for fixtures that intentionally include consensus-invalid blocks. + if (blockEntry.failed && blockEntry.payload_status !== "VALID") { + rejectedFailedBlockRoots.add(blockRootHex); + continue; + } + const postState = computePostState(parentState, signedBlock, fork); if (blockEntry.failed) { - if (blockEntry.payload_status === "VALID") { - clock.setSlot(slot); - chain.forkChoice.updateTime(slot); - chain.forkChoice.onBlock( - signedBlock.message, - postState, - 0, - slot, - ExecutionStatus.Valid, - getDataAvailabilityStatusForFork(fork) - ); - blockStatesByRoot.set(blockRootHex, postState); - } else { - rejectedFailedBlockRoots.add(blockRootHex); - } + // payload_status === "VALID" (filtered above) + clock.setSlot(slot); + chain.forkChoice.updateTime(slot); + chain.forkChoice.onBlock( + signedBlock.message, + postState, + 0, + slot, + ExecutionStatus.Valid, + getDataAvailabilityStatusForFork(fork) + ); + blockStatesByRoot.set(blockRootHex, postState); continue; } From 779dcfba5df0f05123a1f517ec88132a067e9a19 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 6 Apr 2026 00:09:52 +0100 Subject: [PATCH 6/7] Remove Error mapping --- packages/beacon-node/test/spec/utils/gossipValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index bfa8dab740c7..1d0c2c7becac 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -338,7 +338,7 @@ function mapErrorToResult(e: unknown): "valid" | "ignore" | "reject" { } // Some validation paths throw raw errors instead of GossipActionError // (e.g., validator index out of range → TypeError on undefined access). - if (e instanceof TypeError || e instanceof RangeError || e instanceof Error) { + if (e instanceof TypeError || e instanceof RangeError) { return "reject"; } throw e; From 06a0c33973422d88e8244c98ac84ce92b5c57ab9 Mon Sep 17 00:00:00 2001 From: Lodekeeper <258435968+lodekeeper@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:27:26 +0100 Subject: [PATCH 7/7] fix: add proposer index bounds check before signature verification (#9194) --- packages/beacon-node/src/chain/validation/block.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 2f4a7a64cd90..f9bd68e69956 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -197,6 +197,11 @@ export async function validateGossipBlock( } } + // [REJECT] The proposer index is a valid validator index + if (proposerIndex >= blockState.validatorCount) { + throw new BlockGossipError(GossipAction.REJECT, {code: BlockErrorCode.UNKNOWN_PROPOSER, proposerIndex}); + } + // [REJECT] The proposer signature, signed_beacon_block.signature, is valid with respect to the proposer_index pubkey. if (!chain.seenBlockInputCache.isVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature)) { const signatureSet = getBlockProposerSignatureSet(chain.config, signedBlock);