Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/beacon-node/src/chain/errors/blockError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
/** The block's parent execution payload (defined by bid.parent_block_hash) has not been seen */
PARENT_PAYLOAD_UNKNOWN = "BLOCK_ERROR_PARENT_PAYLOAD_UNKNOWN",
}
Expand Down Expand Up @@ -117,6 +119,7 @@ export type BlockErrorType =
| {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.PARENT_EXECUTION_INVALID; parentRoot: RootHex}
| {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentBlockHash: RootHex};

export class BlockGossipError extends GossipActionError<BlockErrorType> {}
Expand Down
15 changes: 15 additions & 0 deletions packages/beacon-node/src/chain/validation/block.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -86,6 +87,15 @@ export async function validateGossipBlock(
throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot});
}

// [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,
parentRoot,
});
}

// [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).
if (isGloasBeaconBlock(block)) {
Expand Down Expand Up @@ -194,6 +204,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);
Expand Down
192 changes: 178 additions & 14 deletions packages/beacon-node/test/spec/utils/gossipValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,28 @@ 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,
createPubkeyCache,
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";
Expand All @@ -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";
Expand Down Expand Up @@ -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: {
Expand All @@ -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<string, GossipType>;

export function isGossipValidationHandler(topicHandler: string): topicHandler is keyof typeof gossipTopicByHandler {
Expand All @@ -169,6 +178,28 @@ function loadMeta(testCaseDir: string): MetaYaml {
return loadYaml<MetaYaml>(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()`.
// 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<string, unknown>;
const configJson: Record<string, unknown> = {};

for (const [key, value] of Object.entries(parsed)) {
if (key in chainConfigTypes) {
configJson[key] = value;
}
Comment thread
nflaig marked this conversation as resolved.
}

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);
Expand Down Expand Up @@ -245,6 +276,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,
Expand All @@ -264,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;
Expand All @@ -282,8 +356,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(
Expand Down Expand Up @@ -311,6 +385,7 @@ export async function runGossipValidationTest(
{config: beaconConfig, pubkeyCache},
{skipSyncPubkeys: true}
);
const anchorStateView = new BeaconStateView(cachedState);

const chain = new BeaconChain(
{
Expand Down Expand Up @@ -338,7 +413,7 @@ export async function runGossipValidationTest(
clock,
metrics: null,
validatorMonitor: null,
anchorState: new BeaconStateView(cachedState),
anchorState: anchorStateView,
isAnchorStateFinalized: true,
executionEngine,
executionBuilder: undefined,
Expand All @@ -349,20 +424,84 @@ export async function runGossipValidationTest(

try {
const blockRootsByName = new Map<string, RootHex>();
const blockStatesByRoot = new Map<RootHex, IBeaconStateView>();
const rejectedFailedBlockRoots = new Set<RootHex>();

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)
);
const slot = signedBlock.message.slot;
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) {
// 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;
}

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

// 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);
Comment thread
nflaig marked this conversation as resolved.

if (blockEntry.failed) {
// 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;
}

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);
Expand All @@ -382,6 +521,8 @@ export async function runGossipValidationTest(
importAttestations: AttestationImportOpt.Force,
validSignatures: false,
});

blockStatesByRoot.set(blockRootHex, postState);
}
}

Expand All @@ -407,7 +548,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);
Expand All @@ -431,6 +581,7 @@ async function validateMessageForTopic(
testCaseDir: string,
message: MetaYaml["messages"][number],
failedBlockRoots: Set<RootHex>,
rejectedFailedBlockRoots: Set<RootHex>,
finalizedCheckpoint: FinalizedCheckpoint | null
): Promise<void> {
const bytes = rejectOnInvalidSerializedBytes(() => loadSszSnappy(testCaseDir, message.message));
Expand All @@ -440,7 +591,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"});
}

Expand Down Expand Up @@ -553,6 +704,19 @@ async function validateMessageForTopic(
break;
}

case GossipType.bls_to_execution_change: {
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);
break;
}

default:
throw new Error(`Unknown gossip topic: ${topic}`);
}
Expand Down
Loading