diff --git a/packages/beacon-node/src/chain/errors/attestationError.ts b/packages/beacon-node/src/chain/errors/attestationError.ts index 94cfde56b852..8f9e063bdb75 100644 --- a/packages/beacon-node/src/chain/errors/attestationError.ts +++ b/packages/beacon-node/src/chain/errors/attestationError.ts @@ -49,6 +49,7 @@ export enum AttestationErrorCode { * The `attestation.data.beacon_block_root` block is unknown or prefinalized. */ UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT = "ATTESTATION_ERROR_UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT", + NOT_FINALIZED_DESCENDANT = "ATTESTATION_ERROR_NOT_FINALIZED_DESCENDANT", /** * The `attestation.data.slot` is not from the same epoch as `data.target.epoch`. */ @@ -165,6 +166,7 @@ export type AttestationErrorType = | {code: AttestationErrorCode.ATTESTERS_ALREADY_KNOWN; targetEpoch: Epoch; aggregateRoot: RootHex} | {code: AttestationErrorCode.AGGREGATOR_INDEX_TOO_HIGH; aggregatorIndex: ValidatorIndex} | {code: AttestationErrorCode.UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT; root: RootHex} + | {code: AttestationErrorCode.NOT_FINALIZED_DESCENDANT; root: RootHex} | {code: AttestationErrorCode.BAD_TARGET_EPOCH} | {code: AttestationErrorCode.HEAD_NOT_TARGET_DESCENDANT} | {code: AttestationErrorCode.UNKNOWN_TARGET_ROOT; root: Uint8Array} diff --git a/packages/beacon-node/src/chain/errors/blsToExecutionChangeError.ts b/packages/beacon-node/src/chain/errors/blsToExecutionChangeError.ts index 2d18d3531f45..3314343af641 100644 --- a/packages/beacon-node/src/chain/errors/blsToExecutionChangeError.ts +++ b/packages/beacon-node/src/chain/errors/blsToExecutionChangeError.ts @@ -4,10 +4,12 @@ export enum BlsToExecutionChangeErrorCode { ALREADY_EXISTS = "BLS_TO_EXECUTION_CHANGE_ERROR_ALREADY_EXISTS", INVALID = "BLS_TO_EXECUTION_CHANGE_ERROR_INVALID", INVALID_SIGNATURE = "BLS_TO_EXECUTION_CHANGE_ERROR_INVALID_SIGNATURE", + PRE_CAPELLA = "BLS_TO_EXECUTION_CHANGE_ERROR_PRE_CAPELLA", } export type BlsToExecutionChangeErrorType = | {code: BlsToExecutionChangeErrorCode.ALREADY_EXISTS} | {code: BlsToExecutionChangeErrorCode.INVALID} - | {code: BlsToExecutionChangeErrorCode.INVALID_SIGNATURE}; + | {code: BlsToExecutionChangeErrorCode.INVALID_SIGNATURE} + | {code: BlsToExecutionChangeErrorCode.PRE_CAPELLA}; export class BlsToExecutionChangeError extends GossipActionError {} diff --git a/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts b/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts index 8e039deef3fb..1582050969db 100644 --- a/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts +++ b/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts @@ -1,3 +1,4 @@ +import {CheckpointWithHex} from "@lodestar/fork-choice"; import {CommitteeIndex, RootHex, Slot, SubnetID, phase0} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; @@ -18,6 +19,8 @@ export type AttestationDataCacheEntry = { // caching this for 3 slots take 600 instances max, this is nothing compared to attestations processed per slot // for example in a mainnet node subscribing to all subnets, attestations are processed up to 20k per slot attestationData: phase0.AttestationData; + /** Checkpoint against which ancestry was checked; skipped slots can give successive checkpoints the same root. */ + finalizedCheckpoint: Pick; subnet: SubnetID; }; diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index 9de67f56bb74..45cc0a50b2b2 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -57,6 +57,7 @@ import { PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, SeenAttDataKey, } from "../seenCache/seenAttestationData.js"; +import {isFinalizedCheckpointAncestor} from "./isFinalizedCheckpointAncestor.js"; export type BatchResult = { results: Result[]; @@ -285,6 +286,7 @@ async function validateAttestationNoSignatureCheck( const attEpoch = computeEpochAtSlot(attSlot); const attTarget = attData.target; const targetEpoch = attTarget.epoch; + const finalizedCheckpoint = chain.forkChoice.getFinalizedCheckpoint(); let committeeIndex: number | null; if (attestationOrCache.attestation) { if (isElectraSingleAttestation(attestationOrCache.attestation)) { @@ -398,6 +400,13 @@ async function validateAttestationNoSignatureCheck( let getSigningRoot: () => Uint8Array; let expectedSubnet: SubnetID; if (attestationOrCache.cache) { + if ( + attestationOrCache.cache.finalizedCheckpoint.rootHex !== finalizedCheckpoint.rootHex || + attestationOrCache.cache.finalizedCheckpoint.epoch !== finalizedCheckpoint.epoch + ) { + verifyHeadBlockIsKnown(chain, attData.beaconBlockRoot); + attestationOrCache.cache.finalizedCheckpoint = finalizedCheckpoint; + } committeeValidatorIndices = attestationOrCache.cache.committeeValidatorIndices; const signingRoot = attestationOrCache.cache.signingRoot; getSigningRoot = () => signingRoot; @@ -554,6 +563,7 @@ async function validateAttestationNoSignatureCheck( // root of AttestationData was already cached during getIndexedAttestationSignatureSet attDataRootHex, attestationData: attData, + finalizedCheckpoint, }); } } @@ -654,7 +664,9 @@ export function verifyPropagationSlotRange(fork: ForkName, chain: IBeaconChain, ); const earliestPermissiblePreviousEpoch = Math.max(currentEpochWithPastTolerance - 1, 0); - if (attestationEpoch < earliestPermissiblePreviousEpoch) { + // The upper time boundary is inclusive, including at exactly the gossip disparity. + const endSlot = computeStartSlotAtEpoch(attestationEpoch + 2); + if (chain.clock.msFromSlot(endSlot) > chain.config.MAXIMUM_GOSSIP_CLOCK_DISPARITY) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.PAST_EPOCH, previousEpoch: earliestPermissiblePreviousEpoch, @@ -781,6 +793,14 @@ function verifyHeadBlockIsKnown(chain: IBeaconChain, beaconBlockRoot: Root): Pro }); } + const finalizedCheckpoint = chain.forkChoice.getFinalizedCheckpoint(); + if (!isFinalizedCheckpointAncestor(chain.forkChoice, headBlock.blockRoot, finalizedCheckpoint)) { + throw new AttestationError(GossipAction.IGNORE, { + code: AttestationErrorCode.NOT_FINALIZED_DESCENDANT, + root: headBlock.blockRoot, + }); + } + return headBlock; } diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 9fbb06ec62d7..57ff5b7741ee 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -31,6 +31,7 @@ import {byteArrayEquals, sleep, toRootHex} from "@lodestar/utils"; import {BlockErrorCode, BlockGossipError, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../interface.js"; import {RegenCaller} from "../regen/index.js"; +import {isFinalizedCheckpointAncestor} from "./isFinalizedCheckpointAncestor.js"; export type GossipBlockValidationResult = { /** Number of skipped slots between the block and its parent (blockSlot - parentSlot - 1) */ @@ -106,21 +107,13 @@ export async function validateGossipBlock( throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.ALREADY_KNOWN, root: blockRoot}); } - // [REJECT] The current finalized_checkpoint is an ancestor of block -- i.e. - // get_ancestor(store, block.parent_root, compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)) == store.finalized_checkpoint.root + // [IGNORE] The block parent has been seen via gossip or another import path. const parentRoot = toRootHex(block.parentRoot); const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentRoot); if (parentBlock === null) { - // If fork choice does *not* consider the parent to be a descendant of the finalized block, - // then there are two more cases: - // - // 1. We have the parent stored in our database. Because fork-choice has confirmed the - // parent is *not* in our post-finalization DAG, all other blocks must be either - // pre-finalization or conflicting with finalization. - // 2. The parent is unknown to us, we probably want to download it since it might actually - // descend from the finalized root. - // (Non-Lighthouse): Since we prune all blocks non-descendant from finalized checking the `db.block` database won't be useful to guard - // against known bad fork blocks, so we throw PARENT_BLOCK_UNKNOWN for cases (1) and (2) + // The parent may be unknown, pre-finalization, or outside the finalized branch. + // Non-canonical blocks are deleted during archiving, so db.block cannot reliably + // distinguish an unseen parent from a known conflicting block. throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.PARENT_BLOCK_UNKNOWN, parentRoot}); } @@ -155,6 +148,12 @@ export async function validateGossipBlock( }); } + // [REJECT] The current finalized_checkpoint is an ancestor of block. + // At epoch zero, fork-choice lookup does not enforce finalized ancestry. + if (!isFinalizedCheckpointAncestor(chain.forkChoice, parentRoot, finalizedCheckpoint)) { + throw new BlockGossipError(GossipAction.REJECT, {code: BlockErrorCode.NOT_FINALIZED_DESCENDANT, parentRoot}); + } + // Number of skipped slots between block and parent (non-spec). Previously this gated blocks via // maxSkipSlots; now the caller only observes it so legitimate post-skip blocks are no longer ignored. const skippedSlots = blockSlot - parentBlock.slot - 1; @@ -251,11 +250,9 @@ export async function validateGossipBlock( } // For gossip forwarding we only need the state to check the block's proposer index. - // If the state cannot be regenerated we throw an IGNORE (whereas the spec says we should REJECT for the - // finalized-ancestor scenario, which is already guarded by the parentBlock lookup above). - // this is something we should change this in the future to make the code airtight to the spec. - // [IGNORE] The block's parent (defined by block.parent_root) has been seen (via both gossip and non-gossip sources) (a client MAY queue blocks for processing once the parent block is retrieved). // [REJECT] The block's parent (defined by block.parent_root) passes validation. + // Parents in fork choice have passed consensus validation, but their states may still be unavailable. + // If regeneration fails, IGNORE rather than treating a missing state as evidence of an invalid parent. const canUseParentState = blockEpoch - computeEpochAtSlot(parentBlock.slot) <= MIN_SEED_LOOKAHEAD; const getValidationState = async () => { diff --git a/packages/beacon-node/src/chain/validation/blsToExecutionChange.ts b/packages/beacon-node/src/chain/validation/blsToExecutionChange.ts index c76052339ae5..efe07f9cd09d 100644 --- a/packages/beacon-node/src/chain/validation/blsToExecutionChange.ts +++ b/packages/beacon-node/src/chain/validation/blsToExecutionChange.ts @@ -16,6 +16,13 @@ export async function validateGossipBlsToExecutionChange( chain: IBeaconChain, blsToExecutionChange: capella.SignedBLSToExecutionChange ): Promise { + // [IGNORE] The current epoch is at or after the Capella fork epoch + // (where current_epoch is defined by the current wall-clock time). + if (chain.clock.currentEpoch < chain.config.CAPELLA_FORK_EPOCH) { + throw new BlsToExecutionChangeError(GossipAction.IGNORE, { + code: BlsToExecutionChangeErrorCode.PRE_CAPELLA, + }); + } return validateBlsToExecutionChange(chain, blsToExecutionChange); } diff --git a/packages/beacon-node/src/chain/validation/isFinalizedCheckpointAncestor.ts b/packages/beacon-node/src/chain/validation/isFinalizedCheckpointAncestor.ts new file mode 100644 index 000000000000..476a72ecb188 --- /dev/null +++ b/packages/beacon-node/src/chain/validation/isFinalizedCheckpointAncestor.ts @@ -0,0 +1,20 @@ +import {CheckpointWithHex, ForkChoiceError, ForkChoiceErrorCode, IForkChoice} from "@lodestar/fork-choice"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {RootHex} from "@lodestar/types"; + +export function isFinalizedCheckpointAncestor( + forkChoice: Pick, + blockRoot: RootHex, + finalizedCheckpoint: CheckpointWithHex +): boolean { + try { + return ( + forkChoice.getAncestor(blockRoot, computeStartSlotAtEpoch(finalizedCheckpoint.epoch)).blockRoot === + finalizedCheckpoint.rootHex + ); + } catch (e) { + // Pruning can leave a conflicting branch whose ancestors before finalization are gone. + if (e instanceof ForkChoiceError && e.type.code === ForkChoiceErrorCode.UNKNOWN_ANCESTOR) return false; + throw e; + } +} diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index ed8bc034a46d..b6c0bcc98801 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -117,7 +117,7 @@ export type ValidatorFnsModules = { metrics: Metrics | null; events: NetworkEventBus; aggregatorTracker: AggregatorTracker; - core: INetworkCore; + core: Pick; }; const MAX_UNKNOWN_BLOCK_ROOT_RETRIES = 1; diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 05e10f70c1b2..302f348b6e92 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -56,6 +56,7 @@ vi.mock("@lodestar/fork-choice", async (importActual) => { getHead: vi.fn(), getHeadRoot: vi.fn(), getDependentRoot: vi.fn(), + getAncestor: vi.fn(), getBlockHex: vi.fn(), getBlock: vi.fn(), getBlockDefaultStatus: vi.fn(), diff --git a/packages/beacon-node/test/spec/presets/networking.test.ts b/packages/beacon-node/test/spec/presets/networking.test.ts index 577794bbb224..9ffe210aa05e 100644 --- a/packages/beacon-node/test/spec/presets/networking.test.ts +++ b/packages/beacon-node/test/spec/presets/networking.test.ts @@ -66,6 +66,12 @@ const networking: TestRunnerCustom = (fork, testHandler, testSuite, testSuiteDir it(testCaseName, async () => { await runGossipValidationTest(fork, testHandler, testCaseDir); }, 30_000); + // Pyspec retains old blocks; also exercise these finalized branches after production fork-choice pruning. + if (testCaseName.endsWith("_finalized_fork")) { + it(`${testCaseName} after pruning`, async () => { + await runGossipValidationTest(fork, testHandler, testCaseDir, {pruneFinalized: true}); + }, 30_000); + } } } else if (networkingFns[testHandler] !== undefined) { runNetworkingFnTests(testHandler, testSuite, testSuiteDirpath); diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index 46564fb4ea1d..09f7e5c339a0 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -4,55 +4,64 @@ import path from "node:path"; import {generateKeyPair} from "@libp2p/crypto/keys"; import jsyaml from "js-yaml"; import snappy from "snappy"; +import tmp from "tmp"; import {expect} from "vitest"; import {pubkeyCache} from "@chainsafe/lodestar-z/pubkeys"; +import {routes} from "@lodestar/api"; import {chainConfigFromJson, chainConfigTypes, createBeaconConfig} from "@lodestar/config"; import {getConfig} from "@lodestar/config/test-utils"; -import {ExecutionStatus} from "@lodestar/fork-choice"; +import {LevelDbController} from "@lodestar/db/controller/level"; +import {ExecutionStatus, ForkChoice} from "@lodestar/fork-choice"; import {testLogger} from "@lodestar/logger/test-utils"; -import {ForkName} from "@lodestar/params"; +import {ForkName, isForkPostBellatrix} from "@lodestar/params"; import { BeaconStateAllForks, BeaconStateView, - DataAvailabilityStatus, - ExecutionPayloadStatus, - IBeaconStateView, computeEpochAtSlot, - computeStartSlotAtEpoch, createCachedBeaconState, isExecutionStateType, + signedBlockToSignedHeader, } from "@lodestar/state-transition"; -import {RootHex, SignedBeaconBlock, ssz, sszTypesFor} from "@lodestar/types"; +import {RootHex, phase0, 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"; +import {BlockInputSource} from "../../../src/chain/blocks/blockInput/index.js"; +import {AttestationImportOpt} from "../../../src/chain/blocks/types.js"; +import {IChainEvents} from "../../../src/chain/emitter.js"; import {GossipAction, GossipActionError} from "../../../src/chain/errors/gossipValidation.js"; +import { + AttestationError, + AttestationErrorCode, + BlockErrorCode, + BlockGossipError, +} from "../../../src/chain/errors/index.js"; +import {ForkchoiceCaller} from "../../../src/chain/forkChoice/index.js"; import {BeaconChain, ChainEvent} from "../../../src/chain/index.js"; import {defaultChainOptions} from "../../../src/chain/options.js"; import {validateGossipAggregateAndProof} from "../../../src/chain/validation/aggregateAndProof.js"; 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"; import {validateGossipVoluntaryExit} from "../../../src/chain/validation/voluntaryExit.js"; import {ZERO_HASH_HEX} from "../../../src/constants/constants.js"; +import {BeaconDb} from "../../../src/db/index.js"; +import {ExecutionPayloadStatus as EnginePayloadStatus} from "../../../src/execution/engine/interface.js"; import {ExecutionEngineMockBackend} from "../../../src/execution/engine/mock.js"; import {getExecutionEngineFromBackend} from "../../../src/execution/index.js"; -import {GossipType} from "../../../src/network/gossip/interface.js"; +import {NetworkEventBus} from "../../../src/network/events.js"; +import {GossipHandlers, GossipType, SequentialGossipHandler} from "../../../src/network/gossip/interface.js"; +import {sszDeserialize, sszDeserializeSingleAttestation} from "../../../src/network/gossip/topic.js"; +import {AggregatorTracker} from "../../../src/network/processor/aggregatorTracker.js"; +import {getGossipHandlers} from "../../../src/network/processor/gossipHandlers.js"; import type {IClock} from "../../../src/util/clock.js"; +import {nextEventLoop} from "../../../src/util/eventLoop.js"; import {getBeaconAttestationGossipIndex, getSlotFromBeaconAttestationSerialized} from "../../../src/util/sszBytes.js"; -import {getMockedBeaconDb} from "../../mocks/mockedBeaconDb.js"; import {assertCorrectProgressiveBalances} from "../config.js"; -/** - * A test clock that models gossip clock disparity from a millisecond timestamp. - * Unlike ClockStopped which returns exact slot values, this clock computes - * currentSlotWithGossipDisparity correctly for spec conformance tests. - */ -class GossipTestClock extends EventEmitter implements IClock { +const gossipLogger = testLogger("spec-gossip"); + +/** Deterministic millisecond clock for gossip boundary vectors. */ +export class GossipTestClock extends EventEmitter implements IClock { genesisTime: number; private currentTimeMs: number; private secondsPerSlot: number; @@ -71,9 +80,6 @@ class GossipTestClock extends EventEmitter implements IClock { } get currentSlotWithGossipDisparity(): number { - // Model: if we're within maxDisparityMs of next slot, return next slot - // Spec: current_time_ms + MAXIMUM_GOSSIP_CLOCK_DISPARITY >= block_time_ms - // This means: nextSlotTimeMs - currentTimeMs <= maxDisparityMs const slot = this.currentSlot; const nextSlotTimeMs = (this.genesisTime + (slot + 1) * this.secondsPerSlot) * 1000; if (nextSlotTimeMs - this.currentTimeMs <= this.maxDisparityMs) { @@ -109,7 +115,7 @@ class GossipTestClock extends EventEmitter implements IClock { } async waitForSlot(): Promise { - // Not used in tests + throw Error("Gossip fixture clock does not support waiting for slots"); } secFromSlot(slot: number, toSec?: number): number { @@ -127,7 +133,6 @@ class GossipTestClock extends EventEmitter implements IClock { this.currentTimeMs = this.genesisTime * 1000 + ms; } - /** Also support setSlot for block import phases */ setSlot(slot: number): void { this.currentTimeMs = (this.genesisTime + slot * this.secondsPerSlot) * 1000; } @@ -137,7 +142,7 @@ type MetaPayloadStatus = "VALID" | "NOT_VALIDATED" | "INVALIDATED"; interface MetaYaml { topic: GossipType; - blocks?: {block: string; failed?: boolean; payload_status?: MetaPayloadStatus}[]; + blocks: {block: string; failed?: boolean; pending?: boolean; payload_status?: MetaPayloadStatus}[]; finalized_checkpoint?: {epoch: bigint; root?: string; block?: string}; current_time_ms?: bigint; messages: { @@ -249,60 +254,67 @@ function resolveFinalizedCheckpoint( return {epoch: Number(cp.epoch), rootHex}; } -function setFinalizedCheckpoint(chain: BeaconChain, checkpoint: FinalizedCheckpoint): void { - const checkpointWithHex = { - epoch: checkpoint.epoch, - root: fromHex(checkpoint.rootHex), - rootHex: checkpoint.rootHex, - }; - - const forkChoice = chain.forkChoice as unknown as { - fcStore: { - finalizedCheckpoint: typeof checkpointWithHex; - unrealizedFinalizedCheckpoint: typeof checkpointWithHex; - }; - protoArray: { - finalizedEpoch: number; - finalizedRoot: RootHex; +// Pyspec anchors can be synthetic blocks whose header is not state.latestBlockHeader. +// Only initialization metadata differs; the fixture state and its root stay unchanged. +class GossipAnchorState extends BeaconStateView { + constructor( + state: ConstructorParameters[0], + private readonly anchorHeader: phase0.BeaconBlockHeader + ) { + super(state); + } + + override computeAnchorCheckpoint() { + return { + blockHeader: this.anchorHeader, + checkpoint: { + epoch: computeEpochAtSlot(this.slot), + root: ssz.phase0.BeaconBlockHeader.hashTreeRoot(this.anchorHeader), + }, }; - updateHead?: () => unknown; - }; - - forkChoice.fcStore.finalizedCheckpoint = checkpointWithHex; - forkChoice.fcStore.unrealizedFinalizedCheckpoint = checkpointWithHex; - forkChoice.protoArray.finalizedEpoch = checkpoint.epoch; - forkChoice.protoArray.finalizedRoot = checkpoint.rootHex; - forkChoice.updateHead?.(); + } } -function getDataAvailabilityStatusForFork(fork: ForkName): DataAvailabilityStatus { - switch (fork) { - case ForkName.deneb: - case ForkName.electra: - case ForkName.fulu: - case ForkName.gloas: - return DataAvailabilityStatus.Available; +class GossipForkChoice extends ForkChoice { + private readonly fixtureStore: ConstructorParameters[1]; + private readonly fixtureProtoArray: ConstructorParameters[2]; + + constructor(...args: ConstructorParameters) { + // The checkpoint-sync justified-epoch safety bump is not part of get_forkchoice_store. + const [, store, protoArray] = args; + store.justified = {...store.justified, checkpoint: store.finalizedCheckpoint}; + store.unrealizedJustified = store.justified; + protoArray.justifiedEpoch = store.finalizedCheckpoint.epoch; + protoArray.nodes[0].justifiedEpoch = store.finalizedCheckpoint.epoch; + protoArray.nodes[0].unrealizedJustifiedEpoch = store.finalizedCheckpoint.epoch; + super(...args); + this.fixtureStore = args[1]; + this.fixtureProtoArray = args[2]; + } - default: - return DataAvailabilityStatus.PreData; + setFinalizedCheckpoint(checkpoint: FinalizedCheckpoint): void { + const checkpointWithHex = { + epoch: checkpoint.epoch, + root: fromHex(checkpoint.rootHex), + rootHex: checkpoint.rootHex, + }; + this.fixtureStore.finalizedCheckpoint = checkpointWithHex; + this.fixtureStore.unrealizedFinalizedCheckpoint = checkpointWithHex; + this.fixtureProtoArray.finalizedEpoch = checkpoint.epoch; + this.fixtureProtoArray.finalizedRoot = checkpoint.rootHex; } -} -function computePostState( - parentState: IBeaconStateView, - signedBlock: SignedBeaconBlock, - fork: ForkName -): IBeaconStateView { - return parentState.stateTransition( - signedBlock, - { - verifyStateRoot: true, - verifyProposer: true, - executionPayloadStatus: ExecutionPayloadStatus.valid, - dataAvailabilityStatus: getDataAvailabilityStatusForFork(fork), - }, - {} - ); + pruneFinalizedCheckpoint(): void { + const {epoch, rootHex} = this.getFinalizedCheckpoint(); + expect(epoch, "Pruning requires a finalized checkpoint after genesis").toBeGreaterThan(0); + expect(this.getBlockHexDefaultStatus(rootHex)).not.toBeNull(); + this.fixtureProtoArray.pruneThreshold = 0; + expect(this.prune(rootHex).length, "Pruning must remove ancestors of the finalized block").toBeGreaterThan(0); + } + + getEquivocatingIndices(): ReadonlySet { + return this.fixtureStore.equivocatingIndices; + } } function invalidateImportedBlock(chain: BeaconChain, blockRootHex: RootHex, parentRootHex: RootHex): void { @@ -323,37 +335,43 @@ function invalidateImportedBlock(chain: BeaconChain, blockRootHex: RootHex, pare }); } -function isDescendantAtFinalizedCheckpoint( - chain: BeaconChain, - blockRootHex: RootHex, - checkpoint: FinalizedCheckpoint -): boolean { - try { - const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); - return chain.forkChoice.getAncestor(blockRootHex, finalizedSlot).blockRoot === checkpoint.rootHex; - } catch { - return false; +export function gossipValidationResult( + e: unknown, + fork: ForkName, + unimportedBlocks: Map +): "ignore" | "reject" { + // Lodestar drops consensus-invalid blocks before fork choice and keeps pending + // blocks outside it. Translate only a production unknown-block result for these + // explicitly known fixtures; all other validation decisions stay in production. + if (e instanceof BlockGossipError && e.type.code === BlockErrorCode.PARENT_BLOCK_UNKNOWN) { + const block = unimportedBlocks.get(e.type.parentRoot); + if (block) { + return isForkPostBellatrix(fork) && block.payload_status && block.payload_status !== "NOT_VALIDATED" + ? "ignore" + : "reject"; + } } -} + if ( + e instanceof AttestationError && + e.type.code === AttestationErrorCode.UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT && + unimportedBlocks.has(e.type.root) + ) + return "reject"; -function mapErrorToResult(e: unknown): "valid" | "ignore" | "reject" { if (e instanceof GossipActionError) { return e.action === GossipAction.IGNORE ? "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) { - return "reject"; - } throw e; } export async function runGossipValidationTest( fork: ForkName, topicHandler: string, - testCaseDir: string + testCaseDir: string, + opts?: {pruneFinalized?: boolean} ): Promise { const meta = loadMeta(testCaseDir); + const logger = gossipLogger.child({module: `${fork}/${path.basename(testCaseDir)}`}); const topic = getGossipTopic(topicHandler); if (meta.topic !== topic) { throw Error(`Gossip test topic mismatch for ${topicHandler}: expected ${topic}, got ${meta.topic}`); @@ -379,7 +397,7 @@ export async function runGossipValidationTest( }); const executionEngine = getExecutionEngineFromBackend(executionEngineBackend, { signal: controller.signal, - logger: testLogger("executionEngine"), + logger: logger.child({module: "executionEngine"}), }); pubkeyCache.syncPubkeys(anchorState.validators.getAllReadonlyValues()); const cachedState = createCachedBeaconState( @@ -387,162 +405,170 @@ export async function runGossipValidationTest( {config: beaconConfig, pubkeyCache}, {skipSyncPubkeys: true} ); - const anchorStateView = new BeaconStateView(cachedState); - - const chain = new BeaconChain( - { - ...defaultChainOptions, - // Disable non-spec maxSkipSlots check for conformance tests - maxSkipSlots: undefined, - blsVerifyAllMainThread: true, - disableArchiveOnCheckpoint: true, - disableLightClientServerOnImportBlockHead: true, - disableOnBlockError: true, - disablePrepareNextSlot: true, - assertCorrectProgressiveBalances, - proposerBoost: true, - proposerBoostReorg: true, - }, - { - privateKey: await generateKeyPair("secp256k1"), - config: beaconConfig, - pubkeyCache, - db: getMockedBeaconDb(), - dataDir: ".", - dbName: ",", - logger: testLogger("spec-gossip"), - processShutdownCallback: () => {}, - clock, - metrics: null, - validatorMonitor: null, - anchorState: anchorStateView, - isAnchorStateFinalized: true, - executionEngine, - executionBuilder: undefined, - } + const anchorEntry = meta.blocks[0]; + if (!anchorEntry || anchorEntry.failed || anchorEntry.pending) { + throw Error("First blocks entry must be the anchor"); + } + const anchorBlock = sszTypesFor(fork).SignedBeaconBlock.deserialize(loadSszSnappy(testCaseDir, anchorEntry.block)); + expect(toRootHex(anchorBlock.message.stateRoot)).toBe(toRootHex(anchorState.hashTreeRoot())); + expect(anchorBlock.message.slot).toBe(anchorState.slot); + const anchorStateView = new GossipAnchorState( + cachedState, + signedBlockToSignedHeader(beaconConfig, anchorBlock).message ); - - chain.emitter.removeAllListeners(ChainEvent.forkChoiceFinalized); - + clock.setSlot(anchorState.slot); + + const dbDir = tmp.dirSync({unsafeCleanup: true}); + const db = new BeaconDb(beaconConfig, await LevelDbController.create({name: dbDir.name}, {logger})); + const finalizationTasks: Promise[]>[] = []; + async function drainFinalizationTasks(): Promise { + for (const results of await Promise.all(finalizationTasks.splice(0))) { + for (const result of results) { + if (result.status === "rejected") throw result.reason; + } + } + } + let chain: BeaconChain | undefined; try { - const blockRootsByName = new Map(); - const blockStatesByRoot = new Map(); - const rejectedFailedBlockRoots = new Set(); - - if (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 (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); - - if (blockEntry.failed) { - // payload_status === "VALID" (filtered above) - clock.setSlot(slot); - chain.forkChoice.updateTime(slot); - chain.forkChoice.onBlock( - signedBlock.message, - postState, - 0, - 0, - slot, - ExecutionStatus.Valid, - getDataAvailabilityStatusForFork(fork) - ); - blockStatesByRoot.set(blockRootHex, postState); - continue; - } + await db.blockArchive.add(anchorBlock); + chain = new BeaconChain( + { + ...defaultChainOptions, + // Disable non-spec maxSkipSlots check for conformance tests + maxSkipSlots: undefined, + blsVerifyAllMainThread: true, + disableArchiveOnCheckpoint: true, + disableLightClientServerOnImportBlockHead: true, + disableOnBlockError: true, + disablePrepareNextSlot: true, + assertCorrectProgressiveBalances, + forkchoiceConstructor: GossipForkChoice, + proposerBoost: true, + proposerBoostReorg: true, + }, + { + privateKey: await generateKeyPair("secp256k1"), + config: beaconConfig, + pubkeyCache, + db, + dataDir: dbDir.name, + dbName: "gossip-spec", + logger, + processShutdownCallback: () => {}, + clock, + metrics: null, + validatorMonitor: null, + anchorState: anchorStateView, + isAnchorStateFinalized: true, + executionEngine, + executionBuilder: undefined, + } + ); - if (blockEntry.payload_status === "INVALIDATED") { - clock.setSlot(slot); - chain.forkChoice.updateTime(slot); - chain.forkChoice.onBlock( - signedBlock.message, - postState, - 0, - 0, - slot, - ExecutionStatus.Syncing, - getDataAvailabilityStatusForFork(fork) - ); - blockStatesByRoot.set(blockRootHex, postState); - invalidateImportedBlock(chain, blockRootHex, parentRootHex); - continue; - } + // Run the real listeners, but await their work before validating messages or closing the fixture database. + const finalizedListeners = chain.emitter.listeners( + ChainEvent.forkChoiceFinalized + ) as IChainEvents[ChainEvent.forkChoiceFinalized][]; + for (const listener of finalizedListeners) { + chain.emitter.off(ChainEvent.forkChoiceFinalized, listener); + chain.emitter.on(ChainEvent.forkChoiceFinalized, (checkpoint) => { + finalizationTasks.push(Promise.allSettled([listener(checkpoint)])); + }); + } - clock.setSlot(slot); - chain.forkChoice.updateTime(slot); + const blockRootsByName = new Map(); + const unimportedBlocks = new Map(); + + const anchorRootHex = toRootHex(anchorStateView.computeAnchorCheckpoint().checkpoint.root); + blockRootsByName.set(anchorEntry.block, anchorRootHex); + + // Setup blocks use the normal import path, including its seen-cache and fork-choice updates. + for (const blockEntry of meta.blocks.slice(1)) { + const signedBlock = sszTypesFor(fork).SignedBeaconBlock.deserialize(loadSszSnappy(testCaseDir, blockEntry.block)); + const slot = signedBlock.message.slot; + const blockRootHex = toRootHex(sszTypesFor(fork).BeaconBlock.hashTreeRoot(signedBlock.message)); + blockRootsByName.set(blockEntry.block, blockRootHex); + if (blockEntry.pending) { + unimportedBlocks.set(blockRootHex, blockEntry); + continue; + } - const blockImport = BlockInputPreData.createFromBlock({ - forkName: fork, - block: signedBlock, - blockRootHex, - source: BlockInputSource.gossip, - seenTimestampSec: 0, - daOutOfRange: false, + const parentRootHex = toRootHex(signedBlock.message.parentRoot); + clock.setSlot(slot); + chain.forkChoice.updateTime(slot); + if ("executionPayload" in signedBlock.message.body) { + const blockHash = toRootHex(signedBlock.message.body.executionPayload.blockHash); + const optimistic = blockEntry.payload_status === "INVALIDATED" || blockEntry.payload_status === "NOT_VALIDATED"; + executionEngineBackend.addPredefinedPayloadStatus(blockHash, { + status: optimistic ? EnginePayloadStatus.SYNCING : EnginePayloadStatus.VALID, + latestValidHash: optimistic ? null : blockHash, + validationError: null, }); + } - await chain.processBlock(blockImport, { - seenTimestampSec: 0, - validBlobSidecars: BlobSidecarValidation.Full, - importAttestations: AttestationImportOpt.Force, - validSignatures: false, + if ("blobKzgCommitments" in signedBlock.message.body) { + expect(signedBlock.message.body.blobKzgCommitments).toHaveLength(0); + } + const blockInput = chain.seenBlockInputCache.getByBlock({ + block: signedBlock, + blockRootHex, + source: BlockInputSource.byRange, + seenTimestampSec: genesisTimeSec + (slot * beaconConfig.SLOT_DURATION_MS) / 1000, + }); + const importResult = chain.processBlock(blockInput, { + importAttestations: AttestationImportOpt.Force, + validSignatures: false, + }); + if (blockEntry.failed) { + // Failed fixtures deliberately use incorrect state roots, not arbitrary import errors. + await expect( + importResult, + `Failed fixture ${blockEntry.block} must fail production import` + ).rejects.toMatchObject({ + type: {code: BlockErrorCode.INVALID_STATE_ROOT}, }); - - blockStatesByRoot.set(blockRootHex, postState); + expect(chain.forkChoice.getBlockHexDefaultStatus(blockRootHex)).toBeNull(); + unimportedBlocks.set(blockRootHex, blockEntry); + continue; + } + await importResult; + await drainFinalizationTasks(); + if (blockEntry.payload_status === "INVALIDATED") { + invalidateImportedBlock(chain, blockRootHex, parentRootHex); + chain.recomputeForkChoiceHead(ForkchoiceCaller.importBlock); } } const finalizedCheckpoint = resolveFinalizedCheckpoint(meta, testCaseDir, fork, blockRootsByName); if (finalizedCheckpoint) { - setFinalizedCheckpoint(chain, finalizedCheckpoint); + if (!(chain.forkChoice instanceof GossipForkChoice)) throw Error("Unexpected fork choice"); + chain.forkChoice.setFinalizedCheckpoint(finalizedCheckpoint); + chain.recomputeForkChoiceHead(ForkchoiceCaller.importBlock); + } + await drainFinalizationTasks(); + + if (opts?.pruneFinalized) { + expect(meta.finalized_checkpoint, "Pruning coverage must use finalization from imported blocks").toBeUndefined(); + if (!(chain.forkChoice instanceof GossipForkChoice)) throw Error("Unexpected fork choice"); + chain.forkChoice.pruneFinalizedCheckpoint(); + chain.recomputeForkChoiceHead(ForkchoiceCaller.importBlock); } - const failedBlockRoots = new Set( - (meta.blocks ?? []) - .filter((blockEntry) => blockEntry.failed === true) - .map((blockEntry) => { - const rootHex = blockRootsByName.get(blockEntry.block); - if (!rootHex) throw new Error(`Missing cached root for block ${blockEntry.block}`); - return rootHex; - }) + const gossipHandlers = getGossipHandlers( + { + chain, + config: beaconConfig, + logger, + metrics: null, + events: new NetworkEventBus(), + aggregatorTracker: new AggregatorTracker(), + core: { + reportPeer: () => { + throw Error("Operation gossip fixtures must not report peers"); + }, + }, + }, + {} ); const baseCurrentTimeMs = Number(meta.current_time_ms ?? 0); @@ -551,30 +577,29 @@ export async function runGossipValidationTest( clock.setCurrentTimeMs(messageTimeMs); let result: "valid" | "ignore" | "reject"; + let validationError: unknown; try { - await validateMessageForTopic( - chain, - fork, - topic, - testCaseDir, - message, - failedBlockRoots, - rejectedFailedBlockRoots, - finalizedCheckpoint - ); + await validateMessageForTopic(chain, fork, topic, testCaseDir, message, gossipHandlers); result = "valid"; } catch (e) { - result = mapErrorToResult(e); + validationError = e; + result = gossipValidationResult(e, fork, unimportedBlocks); } expect(result).toEqualWithMessage( message.expected, - `Unexpected gossip result for ${topicHandler}/${path.basename(testCaseDir)}/${message.message}` + `Unexpected gossip result for ${topicHandler}/${path.basename(testCaseDir)}/${message.message}: ${String(validationError ?? "accepted")}` ); } } finally { - controller.abort(); - await chain.close(); + try { + await drainFinalizationTasks(); + } finally { + controller.abort(); + await chain?.close(); + await db.close(); + dbDir.removeCallback(); + } } } @@ -584,77 +609,33 @@ async function validateMessageForTopic( topic: GossipType, testCaseDir: string, message: MetaYaml["messages"][number], - failedBlockRoots: Set, - rejectedFailedBlockRoots: Set, - finalizedCheckpoint: FinalizedCheckpoint | null + gossipHandlers: GossipHandlers ): Promise { - const bytes = rejectOnInvalidSerializedBytes(() => loadSszSnappy(testCaseDir, message.message)); + const bytes = loadSszSnappy(testCaseDir, message.message); + const boundary = {fork, epoch: chain.config.forks[fork].epoch}; + const subnet = Number(message.subnet_id ?? 0); switch (topic) { case GossipType.beacon_block: { - const signedBlock = rejectOnInvalidSerializedBytes(() => sszTypesFor(fork).SignedBeaconBlock.deserialize(bytes)); - const parentRootHex = toRootHex(signedBlock.message.parentRoot); - - if (rejectedFailedBlockRoots.has(parentRootHex)) { - throw new GossipActionError(GossipAction.REJECT, {code: "SPEC_PARENT_BLOCK_FAILED"}); - } - - if ( - finalizedCheckpoint !== null && - !isDescendantAtFinalizedCheckpoint(chain, parentRootHex, finalizedCheckpoint) - ) { - throw new GossipActionError(GossipAction.REJECT, {code: "SPEC_FINALIZED_NOT_ANCESTOR"}); - } + const signedBlock = sszDeserialize({type: topic, boundary}, bytes); await validateGossipBlock(chain.config, chain, signedBlock, fork); - chain.seenBlockProposers.add( - signedBlock.message.slot, - signedBlock.message.proposerIndex, - toRootHex(sszTypesFor(fork).BeaconBlock.hashTreeRoot(signedBlock.message)) - ); break; } case GossipType.beacon_aggregate_and_proof: { - const aggregate = rejectOnInvalidSerializedBytes(() => - sszTypesFor(fork).SignedAggregateAndProof.deserialize(bytes) - ); - const beaconBlockRootHex = toRootHex(aggregate.message.aggregate.data.beaconBlockRoot); - - if (failedBlockRoots.has(beaconBlockRootHex)) { - throw new GossipActionError(GossipAction.REJECT, {code: "SPEC_BLOCK_FAILED_VALIDATION"}); - } - - if ( - finalizedCheckpoint !== null && - !isDescendantAtFinalizedCheckpoint(chain, beaconBlockRootHex, finalizedCheckpoint) - ) { - throw new GossipActionError(GossipAction.IGNORE, {code: "SPEC_FINALIZED_NOT_ANCESTOR"}); - } + const aggregate = sszDeserialize({type: topic, boundary}, bytes); await validateGossipAggregateAndProof(fork, chain, aggregate, bytes); break; } case GossipType.beacon_attestation: { - const attestation = rejectOnInvalidSerializedBytes(() => sszTypesFor(fork).Attestation.deserialize(bytes)); - const beaconBlockRootHex = toRootHex(attestation.data.beaconBlockRoot); - - if (failedBlockRoots.has(beaconBlockRootHex)) { - throw new GossipActionError(GossipAction.REJECT, {code: "SPEC_BLOCK_FAILED_VALIDATION"}); - } - - if ( - finalizedCheckpoint !== null && - !isDescendantAtFinalizedCheckpoint(chain, beaconBlockRootHex, finalizedCheckpoint) - ) { - throw new GossipActionError(GossipAction.IGNORE, {code: "SPEC_FINALIZED_NOT_ANCESTOR"}); - } - const attDataBase64 = getBeaconAttestationGossipIndex(fork, bytes); const attSlot = getSlotFromBeaconAttestationSerialized(fork, bytes); if (attDataBase64 == null || attSlot == null) { - throw new GossipActionError(GossipAction.REJECT, {code: "SPEC_INVALID_ATTESTATION_SERIALIZATION"}); + sszDeserializeSingleAttestation(fork, bytes); + throw Error("Could not index a structurally valid gossip attestation"); } const gossipAttestation: GossipAttestation = { @@ -662,34 +643,63 @@ async function validateMessageForTopic( serializedData: bytes, attSlot, attDataBase64, - subnet: Number(message.subnet_id ?? 0), + subnet, }; const batchResult = await validateGossipAttestationsSameAttData(fork, chain, [gossipAttestation]); + expect(batchResult.results).toHaveLength(1); const first = batchResult.results[0]; - if (first?.err) throw first.err; - break; - } - - case GossipType.proposer_slashing: { - const slashing = rejectOnInvalidSerializedBytes(() => sszTypesFor(fork).ProposerSlashing.deserialize(bytes)); - await validateGossipProposerSlashing(chain, slashing); - // Mirror gossip handler: insert into opPool so duplicate detection works - chain.opPool.insertProposerSlashing(slashing); + if (first.err) throw first.err; + expect(first.result).toBeDefined(); break; } - case GossipType.attester_slashing: { - const slashing = rejectOnInvalidSerializedBytes(() => sszTypesFor(fork).AttesterSlashing.deserialize(bytes)); - await validateGossipAttesterSlashing(chain, slashing); - // Mirror gossip handler: insert into opPool + fork choice - chain.opPool.insertAttesterSlashing(fork, slashing); - chain.forkChoice.onAttesterSlashing(slashing); + case GossipType.proposer_slashing: + case GossipType.attester_slashing: + case GossipType.bls_to_execution_change: { + const event = { + [GossipType.proposer_slashing]: routes.events.EventType.proposerSlashing, + [GossipType.attester_slashing]: routes.events.EventType.attesterSlashing, + [GossipType.bls_to_execution_change]: routes.events.EventType.blsToExecutionChange, + }[topic]; + let handled = false; + const onHandled = (): void => { + handled = true; + }; + chain.emitter.once(event, onHandled); + try { + const handler = gossipHandlers[topic] as SequentialGossipHandler; + await handler({ + gossipData: {serializedData: bytes}, + topic: {type: topic, boundary}, + peerIdStr: "spec-test", + seenTimestampSec: chain.clock.genesisTime + chain.clock.secFromSlot(0), + }); + // Handler updates are deferred until after the validation result is returned. + await nextEventLoop(); + expect(handled, `Accepted ${topic} did not complete its production handler`).toBe(true); + if (topic === GossipType.attester_slashing) { + const slashing = sszDeserialize({type: topic, boundary}, bytes); + if (!(chain.forkChoice instanceof GossipForkChoice)) throw Error("Unexpected fork choice"); + const equivocatingIndices = chain.forkChoice.getEquivocatingIndices(); + const secondIndices = new Set(slashing.attestation2.attestingIndices); + for (const index of slashing.attestation1.attestingIndices) { + if (secondIndices.has(index)) { + expect( + equivocatingIndices.has(index), + `Slashed validator ${index} must be excluded from fork choice` + ).toBe(true); + } + } + } + } finally { + chain.emitter.off(event, onHandled); + } break; } case GossipType.voluntary_exit: { - const exit = rejectOnInvalidSerializedBytes(() => sszTypesFor(fork).SignedVoluntaryExit.deserialize(bytes)); + const exit = sszDeserialize({type: topic, boundary}, bytes); await validateGossipVoluntaryExit(chain, exit); // Mirror gossip handler: insert into opPool so duplicate detection works chain.opPool.insertVoluntaryExit(exit); @@ -697,46 +707,18 @@ async function validateMessageForTopic( } case GossipType.sync_committee: { - const syncCommitteeMessage = rejectOnInvalidSerializedBytes(() => - ssz.altair.SyncCommitteeMessage.deserialize(bytes) - ); - await validateGossipSyncCommittee(chain, syncCommitteeMessage, Number(message.subnet_id ?? 0)); + const syncCommitteeMessage = sszDeserialize({type: topic, boundary, subnet}, bytes); + await validateGossipSyncCommittee(chain, syncCommitteeMessage, subnet); break; } case GossipType.sync_committee_contribution_and_proof: { - const signedContributionAndProof = rejectOnInvalidSerializedBytes(() => - ssz.altair.SignedContributionAndProof.deserialize(bytes) - ); + const signedContributionAndProof = sszDeserialize({type: topic, boundary}, bytes); await validateSyncCommitteeGossipContributionAndProof(chain, signedContributionAndProof); 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}`); } } - -function rejectOnInvalidSerializedBytes(fn: () => T): T { - try { - return fn(); - } catch (e) { - if (e instanceof Error) { - throw new GossipActionError(GossipAction.REJECT, {code: "SPEC_INVALID_SERIALIZED_BYTES"}); - } - throw e; - } -} diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index 229699432cfd..6be83def6b3c 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -64,6 +64,12 @@ const coveredTestRunners = [ export const defaultSkipOpts: SkipOpts = { skippedForks: ["eip8148"], skippedTestSuites: [ + // Gossip tests after Electra will be enabled separately. + /^(fulu|gloas|heze)\/networking\/gossip_.*/, + // Blob gossip is obsolete: following head on pre-Fulu networks is no longer supported. + /^(deneb|electra)\/networking\/gossip_blob_sidecar\/.*/, + // Voluntary exit gossip tests will be enabled separately. + /^.+\/networking\/gossip_voluntary_exit\/.*/, // Merge transition tests are skipped because we no longer support performing the merge transition. // All networks have already completed the merge, so this code path is no longer needed. /^bellatrix\/fork_choice\/on_merge_block\/.*/, @@ -100,8 +106,6 @@ export const defaultSkipOpts: SkipOpts = { // Enable this after https://github.com/ChainSafe/lodestar/issues/9771 is resolved /^(gloas|heze)\/sanity\/slots\/pyspec_tests\/historical_accumulator$/, ], - // TODO GLOAS: Investigate why networking tests are failing since alpha.5 - skippedRunners: ["networking"], }; /** diff --git a/packages/beacon-node/test/unit/chain/validation/aggregateAndProof.test.ts b/packages/beacon-node/test/unit/chain/validation/aggregateAndProof.test.ts index c00a88edc0db..0c4eca3a38e2 100644 --- a/packages/beacon-node/test/unit/chain/validation/aggregateAndProof.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/aggregateAndProof.test.ts @@ -1,4 +1,4 @@ -import {describe, expect, it} from "vitest"; +import {describe, expect, it, vi} from "vitest"; import {BitArray, toHexString} from "@chainsafe/ssz"; import {createBeaconConfig} from "@lodestar/config"; import {config as defaultConfig} from "@lodestar/config/default"; @@ -113,6 +113,13 @@ describe("chain / validation / aggregateAndProof", () => { await expectError(chain, signedAggregateAndProof, AttestationErrorCode.INVALID_TARGET_ROOT); }); + it("ignores an aggregate on a conflicting finalized branch before pruning", async () => { + const {chain, signedAggregateAndProof} = getValidData(); + const ancestor = chain.forkChoice.getAncestor("", 0); + vi.spyOn(chain.forkChoice, "getAncestor").mockReturnValue({...ancestor, blockRoot: "conflicting-root"}); + await expectError(chain, signedAggregateAndProof, AttestationErrorCode.NOT_FINALIZED_DESCENDANT); + }); + it("EMPTY_AGGREGATION_BITFIELD", async () => { const {chain, signedAggregateAndProof} = getValidData(); // Unset all aggregationBits diff --git a/packages/beacon-node/test/unit/chain/validation/attestation/validateAttestation.test.ts b/packages/beacon-node/test/unit/chain/validation/attestation/validateAttestation.test.ts index 99c5d63c9bf2..353b77b17ff7 100644 --- a/packages/beacon-node/test/unit/chain/validation/attestation/validateAttestation.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/attestation/validateAttestation.test.ts @@ -1,4 +1,4 @@ -import {describe, expect, it} from "vitest"; +import {describe, expect, it, vi} from "vitest"; import {BitArray} from "@chainsafe/ssz"; import {ForkName, SLOTS_PER_EPOCH} from "@lodestar/params"; import {generateTestCachedBeaconStateOnlyValidators} from "@lodestar/state-transition/test-utils"; @@ -6,6 +6,7 @@ import {ssz} from "@lodestar/types"; import {LodestarError} from "@lodestar/utils"; import {AttestationErrorCode, GossipErrorCode} from "../../../../../src/chain/errors/index.js"; import {IBeaconChain} from "../../../../../src/chain/index.js"; +import {SeenAttestationDatas} from "../../../../../src/chain/seenCache/seenAttestationData.js"; import { ApiAttestation, GossipAttestation, @@ -50,6 +51,51 @@ describe("validateAttestation", () => { await validateApiAttestation(fork, chain, {attestation, serializedData: null}); }); + it("ignores an attestation on a conflicting finalized branch before pruning", async () => { + const {chain, attestation} = getValidData(); + const ancestor = chain.forkChoice.getAncestor("", 0); + vi.spyOn(chain.forkChoice, "getAncestor").mockReturnValue({...ancestor, blockRoot: "conflicting-root"}); + await expectApiError(chain, {attestation, serializedData: null}, AttestationErrorCode.NOT_FINALIZED_DESCENDANT); + }); + + it.each(["root", "epoch"] as const)( + "rechecks cached attestation ancestry when the finalized %s changes", + async (field) => { + const {chain: baseChain, attestation, subnet} = getValidData(); + const chain = {...baseChain, seenAttestationDatas: new SeenAttestationDatas(null)}; + const fork = chain.config.getForkName(stateSlot); + const originalAncestor = chain.forkChoice.getAncestor("", 0); + const getAncestor = vi.spyOn(chain.forkChoice, "getAncestor"); + const validate = (value: typeof attestation) => { + const serializedData = ssz.phase0.Attestation.serialize(value); + return validateGossipAttestationsSameAttData(fork, chain, [ + { + attestation: null, + serializedData, + attSlot: value.data.slot, + attDataBase64: getAttDataFromAttestationSerialized(serializedData) as string, + subnet, + }, + ]); + }; + expect((await validate(attestation)).results[0].err).toBeNull(); + getAncestor.mockClear(); + expect((await validate(getValidData({bitIndex: 2}).attestation)).results[0].err).toBeNull(); + expect(getAncestor).not.toHaveBeenCalled(); + const checkpoint = chain.forkChoice.getFinalizedCheckpoint(); + vi.spyOn(chain.forkChoice, "getFinalizedCheckpoint").mockReturnValue({ + ...checkpoint, + ...(field === "root" ? {root: UNKNOWN_ROOT, rootHex: "conflicting-root"} : {epoch: checkpoint.epoch + 1}), + }); + if (field === "epoch") { + getAncestor.mockReturnValue({...originalAncestor, blockRoot: "conflicting-root"}); + } + expect((await validate(getValidData({bitIndex: 3}).attestation)).results[0].err).toMatchObject({ + type: {code: AttestationErrorCode.NOT_FINALIZED_DESCENDANT}, + }); + } + ); + it("INVALID_SERIALIZED_BYTES_ERROR_CODE", async () => { const {chain, subnet} = getValidData(); await expectGossipError( diff --git a/packages/beacon-node/test/unit/chain/validation/block.test.ts b/packages/beacon-node/test/unit/chain/validation/block.test.ts index 0960268683fe..268c345ee2d7 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -65,6 +65,7 @@ describe("gossip block validation", () => { root: ZERO_HASH, rootHex: "", }); + forkChoice.getAncestor.mockReturnValue({blockRoot: ""} as ReturnType); // Reset seen cache ( @@ -114,6 +115,18 @@ describe("gossip block validation", () => { ); }); + it("rejects an explicit finalized checkpoint mismatch at epoch zero", async () => { + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null).mockReturnValue({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getAncestor.mockReturnValue({blockRoot: "conflicting-root"} as ReturnType< + typeof forkChoice.getAncestor + >); + await expectRejectedWithLodestarError( + validateGossipBlock(config, chain, job, ForkName.phase0), + BlockErrorCode.NOT_FINALIZED_DESCENDANT + ); + expect(regen.getState).not.toHaveBeenCalled(); + }); + describe("repeat proposal handling", () => { beforeEach(() => { setupChain(gloasConfig); diff --git a/packages/beacon-node/test/unit/chain/validation/blsToExecutionChange.test.ts b/packages/beacon-node/test/unit/chain/validation/blsToExecutionChange.test.ts index f6af83cbb62c..654fb4bf6a19 100644 --- a/packages/beacon-node/test/unit/chain/validation/blsToExecutionChange.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/blsToExecutionChange.test.ts @@ -1,4 +1,4 @@ -import {afterEach, beforeEach, describe, it, vi} from "vitest"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {digest} from "@chainsafe/as-sha256"; import {SecretKey} from "@chainsafe/lodestar-z/blst"; import {createBeaconConfig} from "@lodestar/config"; @@ -14,7 +14,11 @@ import { import {BeaconStateView, computeSigningRoot} from "@lodestar/state-transition"; import {capella, ssz} from "@lodestar/types"; import {BlsToExecutionChangeErrorCode} from "../../../../src/chain/errors/blsToExecutionChangeError.js"; -import {validateGossipBlsToExecutionChange} from "../../../../src/chain/validation/blsToExecutionChange.js"; +import {GossipAction} from "../../../../src/chain/errors/gossipValidation.js"; +import { + validateApiBlsToExecutionChange, + validateGossipBlsToExecutionChange, +} from "../../../../src/chain/validation/blsToExecutionChange.js"; import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; import {createCachedBeaconStateTest} from "../../../utils/cachedBeaconState.js"; import {expectRejectedWithLodestarError} from "../../../utils/errors.js"; @@ -84,6 +88,7 @@ describe("validate bls to execution change", () => { beforeEach(() => { chainStub = getMockedBeaconChain({config}); + vi.spyOn(chainStub.clock, "currentEpoch", "get").mockReturnValue(config.CAPELLA_FORK_EPOCH); opPool = chainStub.opPool; vi.spyOn(chainStub, "getHeadState").mockReturnValue(state); vi.spyOn(chainStub, "getHeadStateAtCurrentEpoch"); @@ -94,6 +99,16 @@ describe("validate bls to execution change", () => { vi.clearAllMocks(); }); + it("ignores gossip before Capella without rejecting API submissions", async () => { + vi.spyOn(chainStub.clock, "currentEpoch", "get").mockReturnValue(config.CAPELLA_FORK_EPOCH - 1); + await expect(validateGossipBlsToExecutionChange(chainStub, signedBlsToExecChange)).rejects.toMatchObject({ + action: GossipAction.IGNORE, + type: {code: BlsToExecutionChangeErrorCode.PRE_CAPELLA}, + }); + expect(chainStub.getHeadState).not.toHaveBeenCalled(); + await expect(validateApiBlsToExecutionChange(chainStub, signedBlsToExecChange)).resolves.toBeUndefined(); + }); + it("should return invalid bls to execution Change - existing", async () => { const signedBlsToExecChangeInvalid: capella.SignedBLSToExecutionChange = { message: signedBlsToExecChange.message, diff --git a/packages/beacon-node/test/unit/chain/validation/isFinalizedCheckpointAncestor.test.ts b/packages/beacon-node/test/unit/chain/validation/isFinalizedCheckpointAncestor.test.ts new file mode 100644 index 000000000000..da6a274363ee --- /dev/null +++ b/packages/beacon-node/test/unit/chain/validation/isFinalizedCheckpointAncestor.test.ts @@ -0,0 +1,56 @@ +import {describe, expect, it, vi} from "vitest"; +import {ForkChoiceError, ForkChoiceErrorCode, IForkChoice, ProtoArray, ProtoNode} from "@lodestar/fork-choice"; +import {SLOTS_PER_EPOCH, ZERO_HASH, ZERO_HASH_HEX} from "@lodestar/params"; +import {isFinalizedCheckpointAncestor} from "../../../../src/chain/validation/isFinalizedCheckpointAncestor.js"; +import {generateProtoBlock} from "../../../utils/typeGenerator.js"; + +describe("finalized checkpoint ancestry", () => { + const checkpoint = {epoch: 2, root: ZERO_HASH, rootHex: ZERO_HASH_HEX}; + + it("compares the ancestor at the finalized checkpoint slot", () => { + const forkChoice = { + getAncestor: vi.fn().mockReturnValue({blockRoot: ZERO_HASH_HEX} as ProtoNode), + }; + expect(isFinalizedCheckpointAncestor(forkChoice, "descendant", checkpoint)).toBe(true); + expect(forkChoice.getAncestor).toHaveBeenCalledWith("descendant", 2 * SLOTS_PER_EPOCH); + forkChoice.getAncestor.mockReturnValue({blockRoot: "conflicting-root"} as ProtoNode); + expect(isFinalizedCheckpointAncestor(forkChoice, "descendant", checkpoint)).toBe(false); + }); + + it("handles a retained conflicting branch whose ancestor has been pruned", () => { + const protoArray = ProtoArray.initialize(generateProtoBlock({blockRoot: "genesis"}), 0); + const finalizedSlot = checkpoint.epoch * SLOTS_PER_EPOCH; + protoArray.onBlock( + generateProtoBlock({slot: finalizedSlot, blockRoot: checkpoint.rootHex, parentRoot: "genesis"}), + finalizedSlot, + null + ); + protoArray.onBlock( + generateProtoBlock({slot: finalizedSlot + 1, blockRoot: "conflicting", parentRoot: "genesis"}), + finalizedSlot + 1, + null + ); + expect(isFinalizedCheckpointAncestor(protoArray, checkpoint.rootHex, checkpoint)).toBe(true); + expect(isFinalizedCheckpointAncestor(protoArray, "conflicting", checkpoint)).toBe(false); + + protoArray.pruneThreshold = 0; + expect(protoArray.maybePrune(checkpoint.rootHex).map((block) => block.blockRoot)).toEqual(["genesis"]); + expect(() => protoArray.getAncestor("conflicting", finalizedSlot)).toThrowError( + expect.objectContaining({type: expect.objectContaining({code: ForkChoiceErrorCode.UNKNOWN_ANCESTOR})}) + ); + expect(isFinalizedCheckpointAncestor(protoArray, checkpoint.rootHex, checkpoint)).toBe(true); + expect(isFinalizedCheckpointAncestor(protoArray, "conflicting", checkpoint)).toBe(false); + }); + + it.each([ + new TypeError("unexpected"), + new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: "descendant"}), + ])("does not disguise other fork-choice errors: %s", (error) => { + const forkChoice = { + getAncestor: () => { + throw error; + }, + }; + expect(() => isFinalizedCheckpointAncestor(forkChoice, "descendant", checkpoint)).toThrow(error); + }); +}); diff --git a/packages/beacon-node/test/unit/spec/gossipValidation.test.ts b/packages/beacon-node/test/unit/spec/gossipValidation.test.ts new file mode 100644 index 000000000000..321771521fc8 --- /dev/null +++ b/packages/beacon-node/test/unit/spec/gossipValidation.test.ts @@ -0,0 +1,113 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {config} from "@lodestar/config/default"; +import {ForkName, SLOTS_PER_EPOCH, ZERO_HASH_HEX} from "@lodestar/params"; +import { + AttestationError, + AttestationErrorCode, + BlockErrorCode, + BlockGossipError, + GossipAction, +} from "../../../src/chain/errors/index.js"; +import {Clock} from "../../../src/util/clock.js"; +import {GossipTestClock, gossipValidationResult, runGossipValidationTest} from "../../spec/utils/gossipValidation.js"; + +describe("gossip fixture error adaptation", () => { + const parentRoot = ZERO_HASH_HEX; + const unknownParent = new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.PARENT_BLOCK_UNKNOWN, + parentRoot, + }); + const unimported = new Map([[parentRoot, {block: "parent", failed: true}]]); + + it.each([new Error("unexpected"), new TypeError("bad access"), new RangeError("out of bounds")])( + "does not turn %s into a successful rejection", + (error) => { + expect(() => gossipValidationResult(error, ForkName.deneb, unimported)).toThrow(error); + } + ); + + it("does not change the result for an unknown parent absent from the fixtures", () => { + expect(gossipValidationResult(unknownParent, ForkName.deneb, new Map())).toBe("ignore"); + }); + + it.each(["failed", "pending"] as const)("adapts an explicitly %s parent without importing it", (flag) => { + expect( + gossipValidationResult(unknownParent, ForkName.deneb, new Map([[parentRoot, {block: "parent", [flag]: true}]])) + ).toBe("reject"); + }); + + it.each(["VALID", "INVALIDATED"] as const)("ignores a consensus-invalid parent with %s payload", (payloadStatus) => { + const blocks = new Map([[parentRoot, {block: "parent", failed: true, payload_status: payloadStatus}]]); + expect(gossipValidationResult(unknownParent, ForkName.bellatrix, blocks)).toBe("ignore"); + expect(gossipValidationResult(unknownParent, ForkName.phase0, blocks)).toBe("reject"); + }); + + it("does not adapt other validation failures for a known failed parent", () => { + const future = new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.FUTURE_SLOT, + currentSlot: 1, + blockSlot: 2, + }); + expect(gossipValidationResult(future, ForkName.deneb, unimported)).toBe("ignore"); + const invalidPayload = new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.PARENT_EXECUTION_INVALID, + parentRoot, + }); + expect(gossipValidationResult(invalidPayload, ForkName.deneb, unimported)).toBe("ignore"); + }); + + it("adapts unknown attested blocks only when explicitly unimported", () => { + const error = new AttestationError(GossipAction.IGNORE, { + code: AttestationErrorCode.UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT, + root: parentRoot, + }); + expect(gossipValidationResult(error, ForkName.deneb, unimported)).toBe("reject"); + expect(gossipValidationResult(error, ForkName.deneb, new Map())).toBe("ignore"); + }); + + it("fails on missing fixture files", async () => { + await expect( + runGossipValidationTest(ForkName.phase0, "gossip_beacon_block", "/missing-gossip-fixture") + ).rejects.toMatchObject({code: "ENOENT"}); + }); +}); + +describe("gossip fixture clock", () => { + afterEach(() => vi.restoreAllMocks()); + + it("matches the production clock at inclusive slot and epoch boundaries", () => { + const genesisTime = 1000; + const slotDuration = config.SLOT_DURATION_MS; + const disparity = config.MAXIMUM_GOSSIP_CLOCK_DISPARITY; + const now = vi.spyOn(Date, "now").mockReturnValue(genesisTime * 1000); + const controller = new AbortController(); + const clock = new Clock({config, genesisTime, signal: controller.signal}); + controller.abort(); + const fixtureClock = new GossipTestClock(genesisTime, slotDuration / 1000, disparity); + + for (const slot of [1, SLOTS_PER_EPOCH, SLOTS_PER_EPOCH * 2]) { + for (const offset of [-disparity - 1, -disparity, -1, 0, 1, disparity, disparity + 1]) { + const time = slot * slotDuration + offset; + now.mockReturnValue(genesisTime * 1000 + time); + fixtureClock.setCurrentTimeMs(time); + const context = `slot=${slot}, offset=${offset}`; + expect(fixtureClock.currentSlot, context).toBe(clock.currentSlot); + expect(fixtureClock.currentEpoch, context).toBe(clock.currentEpoch); + expect(fixtureClock.currentSlotWithGossipDisparity, context).toBe(clock.currentSlotWithGossipDisparity); + expect(fixtureClock.slotWithPastTolerance(disparity / 1000), context).toBe( + clock.slotWithPastTolerance(disparity / 1000) + ); + expect(fixtureClock.slotWithFutureTolerance(disparity / 1000), context).toBe( + clock.slotWithFutureTolerance(disparity / 1000) + ); + expect(fixtureClock.msFromSlot(slot), context).toBe(clock.msFromSlot(slot)); + expect(fixtureClock.secFromSlot(slot), context).toBe(clock.secFromSlot(slot)); + for (const candidate of [slot - 1, slot, slot + 1]) { + expect(fixtureClock.isCurrentSlotGivenGossipDisparity(candidate), `${context}, candidate=${candidate}`).toBe( + clock.isCurrentSlotGivenGossipDisparity(candidate) + ); + } + } + } + }); +}); diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index f0df8e85e7c5..e0866b243c6c 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -125,6 +125,8 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { return headBlock; }, getDependentRoot: () => state.epochCtx.currentDecisionRoot, + getFinalizedCheckpoint: () => ({epoch: 0, root: ZERO_HASH, rootHex: ZERO_HASH_HEX}), + getAncestor: () => ({...headBlock, slot: 0, blockRoot: ZERO_HASH_HEX, weight: 0n, attestationScore: 0n}), } as Partial as IForkChoice; const committeeIndices = state.epochCtx.getBeaconCommittee(attSlot, attIndex);