From 317e04bab2fdd644e952e559c9db149b531b0bc9 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 3 Aug 2026 14:27:24 +0100 Subject: [PATCH 01/24] fix: reject payload envelopes for proposer equivocations --- .../src/api/impl/beacon/blocks/index.ts | 30 ++++--- .../src/chain/blocks/verifyBlock.ts | 10 +++ .../src/chain/seenCache/seenBlockProposers.ts | 35 +++++++- .../beacon-node/src/chain/validation/block.ts | 54 ++++++++----- .../test/e2e/api/lodestar/lodestar.test.ts | 7 +- .../test/spec/utils/gossipValidation.ts | 6 +- .../publishExecutionPayloadEnvelope.test.ts | 80 +++++++++++++++++++ .../seenCache/seenBlockProposers.test.ts | 37 +++++++++ .../test/unit/chain/validation/block.test.ts | 52 +++++++++++- 9 files changed, 271 insertions(+), 40 deletions(-) create mode 100644 packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts create mode 100644 packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 005a812d19ab..cc13cdd636f6 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -775,15 +775,6 @@ export function getBeaconBlockApi({ throw new ApiError(400, (error as Error).message); } chain.logger.debug("Consensus validated while publishing execution payload envelope", valLogMeta); - - // TODO GLOAS: check the block is not a proposer equivocation before publishing the envelope - if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { - const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; - if (chain.opts.broadcastValidationStrictness === "error") { - throw Error(message); - } - chain.logger.warn(message, valLogMeta); - } break; } @@ -886,6 +877,27 @@ export function getBeaconBlockApi({ await sleep(msToBlockSlot); } + // Keep this as the final async validation before publishing. A conflicting block may be observed while the + // envelope, blob data, or slot timing is being validated above. + if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + const conflictingRoots = chain.seenBlockProposers.getConflictingBlockRoots( + slot, + payloadInput.proposerIndex, + blockRootHex + ); + if (conflictingRoots.length > 0) { + chain.logger.error("Equivocation checks failed while publishing execution payload envelope", { + ...valLogMeta, + conflictingRoots: conflictingRoots.join(", "), + }); + throw new ApiError( + 400, + `Block of execution payload envelope is a proposer equivocation, conflicting block roots: ${conflictingRoots.join(", ")}` + ); + } + chain.logger.debug("Equivocation validated while publishing execution payload envelope", valLogMeta); + } + if (payloadInput.hasPayloadEnvelope()) { // The envelope may have been added while this request was being validated, e.g. via gossip chain.logger.debug("Execution payload envelope already added during publishing", valLogMeta); diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index c420c661cc73..776b028a9bf3 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -2,6 +2,7 @@ import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, ForkSeq, isForkPostFulu} from "@lodestar/params"; import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition"; import {IndexedAttestation, Slot, deneb} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; import {getBlobKzgCommitments} from "../../util/dataColumns.js"; import type {BeaconChain} from "../chain.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; @@ -203,6 +204,15 @@ export async function verifyBlocksInEpoch( // maybe chain with the above verifyBlocksSignatures() ]); + if (opts.skipVerifyBlockSignatures !== true) { + for (const block of blocks) { + const blockRoot = toRootHex( + this.config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message) + ); + this.seenBlockProposers.add(block.message.slot, block.message.proposerIndex, blockRoot); + } + } + if (opts.verifyOnly !== true) { const fromForkBoundary = this.config.getForkBoundaryAtEpoch(computeEpochAtSlot(parentBlock.slot)); const toForkBoundary = this.config.getForkBoundaryAtEpoch(computeEpochAtSlot(lastBlock.message.slot)); diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 6b37015ecb88..031bc9e7845f 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -1,26 +1,48 @@ import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {Epoch, Slot, ValidatorIndex} from "@lodestar/types"; +import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; /** * Keeps a cache to filter block proposals from the same validator in the same slot. * + * Block roots with a verified proposer signature are tracked separately from proposals that passed gossip validation. + * This allows proposer equivocations to be detected without letting invalid blocks suppress valid gossip. + * * This cache is not bounded and for extremely long periods of non-finality it can grow a lot. However it's practically * limited by the possible shufflings in those epochs, and the stored data is very cheap */ export class SeenBlockProposers { private readonly proposerIndexesBySlot = new MapDef>(() => new Set()); - private finalizedSlot: Epoch = 0; + private readonly blockRootsBySlot = new MapDef>>( + () => new MapDef>(() => new Set()) + ); + private finalizedSlot: Slot = 0; isKnown(blockSlot: Slot, proposerIndex: ValidatorIndex): boolean { return this.proposerIndexesBySlot.get(blockSlot)?.has(proposerIndex) === true; } - add(blockSlot: Slot, proposerIndex: ValidatorIndex): void { + hasBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): boolean { + return this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex)?.has(blockRoot) === true; + } + + getConflictingBlockRoots(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): RootHex[] { + const roots = this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex); + return roots === undefined ? [] : Array.from(roots).filter((root) => root !== blockRoot); + } + + /** Record a block only after its proposer signature has been verified. */ + observeBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void { if (blockSlot < this.finalizedSlot) { throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); } + this.blockRootsBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex).add(blockRoot); + } + + /** Mark a signature-verified block as known from gossip or another block import path. */ + add(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void { + this.observeBlockRoot(blockSlot, proposerIndex, blockRoot); this.proposerIndexesBySlot.getOrDefault(blockSlot).add(proposerIndex); } @@ -31,9 +53,14 @@ export class SeenBlockProposers { this.proposerIndexesBySlot.delete(slot); } } + for (const slot of this.blockRootsBySlot.keys()) { + if (slot < finalizedSlot) { + this.blockRootsBySlot.delete(slot); + } + } } - seenAtEpoch(epoch: Slot, index: ValidatorIndex): boolean { + seenAtEpoch(epoch: Epoch, index: ValidatorIndex): boolean { const fromSlot = computeStartSlotAtEpoch(epoch); const toSlot = computeStartSlotAtEpoch(epoch + 1); diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 8739c0e6d3a2..4325c9802167 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -24,7 +24,7 @@ import { isExecutionBlockBodyType, isStatePostBellatrix, } from "@lodestar/state-transition"; -import {SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; +import {RootHex, SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; import {byteArrayEquals, sleep, toRootHex} from "@lodestar/utils"; import {BlockErrorCode, BlockGossipError, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../interface.js"; @@ -86,6 +86,10 @@ export async function validateGossipBlock( // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot. const proposerIndex = block.proposerIndex; if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { + if (!chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot)) { + await verifyBlockProposerSignature(chain, signedBlock, blockRoot); + chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); + } throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); } @@ -270,18 +274,8 @@ export async function validateGossipBlock( } // [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); - // Don't batch so verification is not delayed - if (!(await chain.bls.verifySignatureSets([signatureSet], {verifyOnMainThread: true}))) { - throw new BlockGossipError(GossipAction.REJECT, { - code: BlockErrorCode.PROPOSAL_SIGNATURE_INVALID, - blockSlot, - }); - } - - chain.seenBlockInputCache.markVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature); - } + await verifyBlockProposerSignature(chain, signedBlock, blockRoot); + chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); // [REJECT] The block is proposed by the expected proposer_index for the block's slot in the context of the current // shuffling (defined by parent_root/slot). If the proposer_index cannot immediately be verified against the expected @@ -291,11 +285,6 @@ export async function validateGossipBlock( throw new BlockGossipError(GossipAction.REJECT, {code: BlockErrorCode.INCORRECT_PROPOSER, proposerIndex}); } - // Check again in case there two blocks are processed concurrently - if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { - throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); - } - // Simple implementation of a pending block queue. Keeping the block here recycles the queue logic, and keeps the // gossip validation promise without any extra infrastructure. // Do the sleep at the end, since regen and signature validation can already take longer than `msToBlockSlot`. @@ -305,7 +294,34 @@ export async function validateGossipBlock( await sleep(msToBlockSlot); } - chain.seenBlockProposers.add(blockSlot, proposerIndex); + // Check again after all async validation, including the early-block delay, so concurrent proposals cannot both pass. + if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { + throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); + } + + chain.seenBlockProposers.add(blockSlot, proposerIndex, blockRoot); return {skippedSlots}; } + +async function verifyBlockProposerSignature( + chain: IBeaconChain, + signedBlock: SignedBeaconBlock, + blockRoot: RootHex +): Promise { + const blockSlot = signedBlock.message.slot; + if (chain.seenBlockInputCache.isVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature)) { + return; + } + + const signatureSet = getBlockProposerSignatureSet(chain.config, signedBlock); + // Don't batch so verification is not delayed + if (!(await chain.bls.verifySignatureSets([signatureSet], {verifyOnMainThread: true}))) { + throw new BlockGossipError(GossipAction.REJECT, { + code: BlockErrorCode.PROPOSAL_SIGNATURE_INVALID, + blockSlot, + }); + } + + chain.seenBlockInputCache.markVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature); +} diff --git a/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts b/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts index 774ba8e1c569..d398224c7204 100644 --- a/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts +++ b/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts @@ -5,6 +5,7 @@ import {chainConfig as chainConfigDef} from "@lodestar/config/default"; import {LogLevel, TestLoggerOpts, testLogger} from "@lodestar/logger/test-utils"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {phase0} from "@lodestar/types"; +import {ZERO_HASH_HEX} from "../../../../src/constants/index.js"; import {BeaconNode} from "../../../../src/index.js"; import {ClockEvent} from "../../../../src/util/clock.js"; import {waitForEvent} from "../../../utils/events/resolver.js"; @@ -61,12 +62,12 @@ describe("api / impl / validator", () => { }); // live indices at epoch of consideration, epoch 0 - bn.chain.seenBlockProposers.add(0, 1); + bn.chain.seenBlockProposers.add(0, 1, ZERO_HASH_HEX); bn.chain.seenBlockAttesters.add(0, 2); bn.chain.seenAttesters.add(0, 3); bn.chain.seenAggregators.add(0, 4); // live indices at other epochs, epoch 10 - bn.chain.seenBlockProposers.add(10, 1000); + bn.chain.seenBlockProposers.add(10, 1000, ZERO_HASH_HEX); bn.chain.seenAttesters.add(10, 2000); bn.chain.seenAggregators.add(10, 3000); @@ -105,7 +106,7 @@ describe("api / impl / validator", () => { await waitForEvent(bn.chain.clock, ClockEvent.epoch, timeout); // wait for epoch 1 await waitForEvent(bn.chain.clock, ClockEvent.epoch, timeout); // wait for epoch 2 - bn.chain.seenBlockProposers.add(bn.chain.clock.currentEpoch, 1); + bn.chain.seenBlockProposers.add(bn.chain.clock.currentEpoch, 1, ZERO_HASH_HEX); const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}); diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index 961400e7fb76..ba5def4c4772 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -608,7 +608,11 @@ async function validateMessageForTopic( } await validateGossipBlock(chain.config, chain, signedBlock, fork); - chain.seenBlockProposers.add(signedBlock.message.slot, signedBlock.message.proposerIndex); + chain.seenBlockProposers.add( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + toRootHex(chain.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message)) + ); break; } diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts new file mode 100644 index 000000000000..bc027444bf90 --- /dev/null +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts @@ -0,0 +1,80 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; +import {createChainForkConfig} from "@lodestar/config"; +import {config as configDef} from "@lodestar/config/default"; +import {ForkName} from "@lodestar/params"; +import {IBeaconStateView} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import {fromHex, toRootHex} from "@lodestar/utils"; +import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; +import {PayloadEnvelopeInput} from "../../../../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; +import {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; +import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; +import {generateProtoBlock} from "../../../../../utils/typeGenerator.js"; + +vi.mock("../../../../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js", () => ({ + verifyExecutionPayloadEnvelope: vi.fn(), +})); +vi.mock("../../../../../../src/chain/validation/executionPayloadEnvelope.js", () => ({ + validateApiExecutionPayloadEnvelope: vi.fn(), +})); + +describe("api - beacon - publishExecutionPayloadEnvelope", () => { + const config = createChainForkConfig({ + ...configDef, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + }); + let modules: ApiTestModules; + + beforeEach(() => { + modules = getApiTestModules({config}); + Object.defineProperty(modules.chain, "blockProductionCache", {value: {get: vi.fn()}}); + Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); + modules.network.publishSignedExecutionPayloadEnvelope = vi.fn(); + modules.chain.processExecutionPayload = vi.fn(); + }); + + it("rejects an envelope for a proposer equivocation without publishing or importing it", async () => { + const signedBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + const slot = signedBlock.message.slot; + const proposerIndex = signedBlock.message.proposerIndex; + const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(signedBlock.message)); + const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); + const payloadInput = PayloadEnvelopeInput.createFromBlock({ + blockRootHex: blockRoot, + block: signedBlock, + forkName: ForkName.gloas, + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: 0, + daOutOfRange: false, + }); + const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + signedEnvelope.message.beaconBlockRoot = fromHex(blockRoot); + signedEnvelope.message.payload.slotNumber = slot; + + modules.forkChoice.getBlockHex.mockReturnValue(generateProtoBlock({slot})); + vi.mocked(modules.chain.seenPayloadEnvelopeInputCache.get).mockReturnValue(payloadInput); + modules.chain.regen.getBlockSlotState.mockResolvedValue({forkName: ForkName.gloas} as IBeaconStateView); + modules.chain.seenBlockProposers.add(slot, proposerIndex, blockRoot); + modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishExecutionPayloadEnvelope({ + signedEnvelopeOrContents: signedEnvelope, + broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, + }) + ).rejects.toThrow(/proposer equivocation/); + + expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled(); + expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled(); + expect(payloadInput.hasPayloadEnvelope()).toBe(false); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts new file mode 100644 index 000000000000..b0525edd8d8d --- /dev/null +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts @@ -0,0 +1,37 @@ +import {describe, expect, it} from "vitest"; +import {toRootHex} from "@lodestar/utils"; +import {SeenBlockProposers} from "../../../../src/chain/seenCache/seenBlockProposers.js"; + +describe("SeenBlockProposers", () => { + const slot = 32; + const proposerIndex = 1; + const blockRoot = toRootHex(Buffer.alloc(32, 1)); + const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 2)); + + it("tracks signature-verified roots separately from gossip-accepted proposals", () => { + const cache = new SeenBlockProposers(); + + cache.observeBlockRoot(slot, proposerIndex, blockRoot); + + expect(cache.isKnown(slot, proposerIndex)).toBe(false); + expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(true); + + cache.add(slot, proposerIndex, blockRoot); + cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); + + expect(cache.isKnown(slot, proposerIndex)).toBe(true); + expect(cache.getConflictingBlockRoots(slot, proposerIndex, blockRoot)).toEqual([conflictingBlockRoot]); + expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([blockRoot]); + }); + + it("prunes accepted proposals and observed roots", () => { + const cache = new SeenBlockProposers(); + cache.add(slot, proposerIndex, blockRoot); + + cache.prune(slot + 1); + + expect(cache.isKnown(slot, proposerIndex)).toBe(false); + expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(false); + expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([]); + }); +}); 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 b9879f0c13d6..f4449d063cb0 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -1,10 +1,11 @@ -import {Mock, Mocked, beforeEach, describe, it, vi} from "vitest"; +import {Mock, Mocked, beforeEach, describe, expect, it, vi} from "vitest"; import {createBeaconConfig, createChainForkConfig} from "@lodestar/config"; import {config as configDef} from "@lodestar/config/default"; import {ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, ForkPostDeneb, ForkPreFulu} from "@lodestar/params"; import {BeaconStateView} from "@lodestar/state-transition"; -import {SignedBeaconBlock, ssz} from "@lodestar/types"; +import {SignedBeaconBlock, deneb, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; import {QueuedStateRegenerator} from "../../../../src/chain/regen/index.js"; import {SeenBlockProposers} from "../../../../src/chain/seenCache/index.js"; @@ -19,7 +20,7 @@ describe("gossip block validation", () => { let forkChoice: MockedBeaconChain["forkChoice"]; let regen: Mocked; let verifySignature: Mock<() => boolean>; - let job: SignedBeaconBlock; + let job: deneb.SignedBeaconBlock; const proposerIndex = 0; const clockSlot = 32; const block = ssz.deneb.BeaconBlock.defaultValue(); @@ -98,7 +99,11 @@ describe("gossip block validation", () => { it("REPEAT_PROPOSAL", async () => { // Register the proposer as known - chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); + chain.seenBlockProposers.add( + job.message.slot, + job.message.proposerIndex, + toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)) + ); await expectRejectedWithLodestarError( validateGossipBlock(config, chain, job, ForkName.phase0), @@ -106,6 +111,45 @@ describe("gossip block validation", () => { ); }); + it("REPEAT_PROPOSAL records a conflicting root after verifying its proposer signature", async () => { + const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); + chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex, blockRoot); + + const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex( + config.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) + ); + + await expectRejectedWithLodestarError( + validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), + BlockErrorCode.REPEAT_PROPOSAL + ); + + expect(verifySignature).toHaveBeenCalledOnce(); + expect( + chain.seenBlockProposers.getConflictingBlockRoots(job.message.slot, job.message.proposerIndex, blockRoot) + ).toEqual([conflictingBlockRoot]); + }); + + it("does not record a conflicting root with an invalid proposer signature", async () => { + const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); + chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex, blockRoot); + + const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + verifySignature.mockResolvedValue(false); + + await expectRejectedWithLodestarError( + validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), + BlockErrorCode.PROPOSAL_SIGNATURE_INVALID + ); + + expect( + chain.seenBlockProposers.getConflictingBlockRoots(job.message.slot, job.message.proposerIndex, blockRoot) + ).toEqual([]); + }); + it("PARENT_BLOCK_UNKNOWN (fork-choice)", async () => { // Return not known for proposed block forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); From b4fad046931b21d30f2ccabb0027c464272f488b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 3 Aug 2026 18:01:56 +0100 Subject: [PATCH 02/24] fix: track gloas proposals after execution verification --- .../beacon-node/src/chain/blocks/index.ts | 11 ++ .../src/chain/blocks/verifyBlock.ts | 4 +- .../src/chain/seenCache/seenBlockProposers.ts | 5 +- .../beacon-node/src/chain/validation/block.ts | 16 ++- .../test/e2e/api/lodestar/lodestar.test.ts | 7 +- .../test/spec/utils/gossipValidation.ts | 6 +- .../publishExecutionPayloadEnvelope.test.ts | 3 +- .../unit/chain/blocks/processBlocks.test.ts | 114 ++++++++++++++++++ .../seenCache/seenBlockProposers.test.ts | 5 +- .../test/unit/chain/validation/block.test.ts | 78 ++++++++---- 10 files changed, 202 insertions(+), 47 deletions(-) create mode 100644 packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index c7b60d4f5a73..27075d48c08d 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -1,3 +1,4 @@ +import {ForkSeq} from "@lodestar/params"; import {SignedBeaconBlock, Slot} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; @@ -108,6 +109,16 @@ export async function processBlocks( throw segmentExecStatus.execAborted.execError; } + if ( + opts.skipVerifyBlockSignatures !== true && + this.config.getForkSeq(relevantBlocks[0].getBlock().message.slot) >= ForkSeq.gloas + ) { + for (const blockInput of relevantBlocks) { + const block = blockInput.getBlock().message; + this.seenBlockProposers.add(block.slot, block.proposerIndex); + } + } + const {executionStatuses} = segmentExecStatus; const verifiedBlocksBySlot = new Map(); for (let i = 0; i < relevantBlocks.length; i++) { diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 776b028a9bf3..6de9078eb32c 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -204,12 +204,12 @@ export async function verifyBlocksInEpoch( // maybe chain with the above verifyBlocksSignatures() ]); - if (opts.skipVerifyBlockSignatures !== true) { + if (fork >= ForkSeq.gloas && opts.skipVerifyBlockSignatures !== true) { for (const block of blocks) { const blockRoot = toRootHex( this.config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message) ); - this.seenBlockProposers.add(block.message.slot, block.message.proposerIndex, blockRoot); + this.seenBlockProposers.observeBlockRoot(block.message.slot, block.message.proposerIndex, blockRoot); } } diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 031bc9e7845f..97aeeb047199 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -40,9 +40,8 @@ export class SeenBlockProposers { this.blockRootsBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex).add(blockRoot); } - /** Mark a signature-verified block as known from gossip or another block import path. */ - add(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void { - this.observeBlockRoot(blockSlot, proposerIndex, blockRoot); + /** Mark a block as known from gossip or another block import path. */ + add(blockSlot: Slot, proposerIndex: ValidatorIndex): void { this.proposerIndexesBySlot.getOrDefault(blockSlot).add(proposerIndex); } diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 4325c9802167..af7a0909f112 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -86,7 +86,7 @@ export async function validateGossipBlock( // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot. const proposerIndex = block.proposerIndex; if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { - if (!chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot)) { + if (isForkPostGloas(fork) && !chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot)) { await verifyBlockProposerSignature(chain, signedBlock, blockRoot); chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); } @@ -275,7 +275,9 @@ export async function validateGossipBlock( // [REJECT] The proposer signature, signed_beacon_block.signature, is valid with respect to the proposer_index pubkey. await verifyBlockProposerSignature(chain, signedBlock, blockRoot); - chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); + if (isForkPostGloas(fork)) { + chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); + } // [REJECT] The block is proposed by the expected proposer_index for the block's slot in the context of the current // shuffling (defined by parent_root/slot). If the proposer_index cannot immediately be verified against the expected @@ -285,6 +287,10 @@ export async function validateGossipBlock( throw new BlockGossipError(GossipAction.REJECT, {code: BlockErrorCode.INCORRECT_PROPOSER, proposerIndex}); } + if (!isForkPostGloas(fork) && chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { + throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); + } + // Simple implementation of a pending block queue. Keeping the block here recycles the queue logic, and keeps the // gossip validation promise without any extra infrastructure. // Do the sleep at the end, since regen and signature validation can already take longer than `msToBlockSlot`. @@ -294,12 +300,12 @@ export async function validateGossipBlock( await sleep(msToBlockSlot); } - // Check again after all async validation, including the early-block delay, so concurrent proposals cannot both pass. - if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { + // Check again after all async validation so concurrent Gloas proposals cannot both pass. + if (isForkPostGloas(fork) && chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); } - chain.seenBlockProposers.add(blockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(blockSlot, proposerIndex); return {skippedSlots}; } diff --git a/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts b/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts index d398224c7204..774ba8e1c569 100644 --- a/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts +++ b/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts @@ -5,7 +5,6 @@ import {chainConfig as chainConfigDef} from "@lodestar/config/default"; import {LogLevel, TestLoggerOpts, testLogger} from "@lodestar/logger/test-utils"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {phase0} from "@lodestar/types"; -import {ZERO_HASH_HEX} from "../../../../src/constants/index.js"; import {BeaconNode} from "../../../../src/index.js"; import {ClockEvent} from "../../../../src/util/clock.js"; import {waitForEvent} from "../../../utils/events/resolver.js"; @@ -62,12 +61,12 @@ describe("api / impl / validator", () => { }); // live indices at epoch of consideration, epoch 0 - bn.chain.seenBlockProposers.add(0, 1, ZERO_HASH_HEX); + bn.chain.seenBlockProposers.add(0, 1); bn.chain.seenBlockAttesters.add(0, 2); bn.chain.seenAttesters.add(0, 3); bn.chain.seenAggregators.add(0, 4); // live indices at other epochs, epoch 10 - bn.chain.seenBlockProposers.add(10, 1000, ZERO_HASH_HEX); + bn.chain.seenBlockProposers.add(10, 1000); bn.chain.seenAttesters.add(10, 2000); bn.chain.seenAggregators.add(10, 3000); @@ -106,7 +105,7 @@ describe("api / impl / validator", () => { await waitForEvent(bn.chain.clock, ClockEvent.epoch, timeout); // wait for epoch 1 await waitForEvent(bn.chain.clock, ClockEvent.epoch, timeout); // wait for epoch 2 - bn.chain.seenBlockProposers.add(bn.chain.clock.currentEpoch, 1, ZERO_HASH_HEX); + bn.chain.seenBlockProposers.add(bn.chain.clock.currentEpoch, 1); const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}); diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index ba5def4c4772..961400e7fb76 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -608,11 +608,7 @@ async function validateMessageForTopic( } await validateGossipBlock(chain.config, chain, signedBlock, fork); - chain.seenBlockProposers.add( - signedBlock.message.slot, - signedBlock.message.proposerIndex, - toRootHex(chain.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message)) - ); + chain.seenBlockProposers.add(signedBlock.message.slot, signedBlock.message.proposerIndex); break; } diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts index bc027444bf90..d890a1717432 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts @@ -62,7 +62,8 @@ describe("api - beacon - publishExecutionPayloadEnvelope", () => { modules.forkChoice.getBlockHex.mockReturnValue(generateProtoBlock({slot})); vi.mocked(modules.chain.seenPayloadEnvelopeInputCache.get).mockReturnValue(payloadInput); modules.chain.regen.getBlockSlotState.mockResolvedValue({forkName: ForkName.gloas} as IBeaconStateView); - modules.chain.seenBlockProposers.add(slot, proposerIndex, blockRoot); + modules.chain.seenBlockProposers.add(slot, proposerIndex); + modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockRoot); modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); const api = getBeaconBlockApi(modules); diff --git a/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts new file mode 100644 index 000000000000..25f061c1c474 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts @@ -0,0 +1,114 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {createChainForkConfig} from "@lodestar/config"; +import {config as configDef} from "@lodestar/config/default"; +import {ExecutionStatus} from "@lodestar/fork-choice"; +import {ForkName} from "@lodestar/params"; +import {DataAvailabilityStatus, IBeaconStateView} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {importBlock} from "../../../../src/chain/blocks/importBlock.js"; +import {processBlocks} from "../../../../src/chain/blocks/index.js"; +import {assertLinearChainSegment} from "../../../../src/chain/blocks/utils/chainSegment.js"; +import {verifyBlocksInEpoch} from "../../../../src/chain/blocks/verifyBlock.js"; +import {verifyBlocksSanityChecks} from "../../../../src/chain/blocks/verifyBlocksSanityChecks.js"; +import {BlockError, BlockErrorCode} from "../../../../src/chain/errors/index.js"; +import {SeenBlockProposers} from "../../../../src/chain/seenCache/seenBlockProposers.js"; +import {ExecutionPayloadStatus} from "../../../../src/execution/engine/interface.js"; +import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; +import {MockBlockInput} from "../../../utils/blockInput.js"; +import {generateProtoBlock} from "../../../utils/typeGenerator.js"; + +vi.mock("../../../../src/chain/blocks/importBlock.js"); +vi.mock("../../../../src/chain/blocks/utils/chainSegment.js"); +vi.mock("../../../../src/chain/blocks/verifyBlock.js"); +vi.mock("../../../../src/chain/blocks/verifyBlocksSanityChecks.js"); + +describe("chain / blocks / processBlocks", () => { + const slot = 1; + const proposerIndex = 2; + const config = createChainForkConfig({ + ...configDef, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + }); + let chain: MockedBeaconChain; + let seenBlockProposers: SeenBlockProposers; + let blockInput: MockBlockInput; + + beforeEach(() => { + vi.clearAllMocks(); + + chain = getMockedBeaconChain({config}); + seenBlockProposers = new SeenBlockProposers(); + Object.defineProperty(chain, "seenBlockProposers", {value: seenBlockProposers}); + + const block = ssz.gloas.SignedBeaconBlock.defaultValue(); + block.message.slot = slot; + block.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(block.message)); + blockInput = new MockBlockInput({forkName: ForkName.gloas, slot, blockRootHex: blockRoot}); + blockInput._block = block; + + vi.mocked(verifyBlocksSanityChecks).mockReturnValue({ + relevantBlocks: [blockInput], + parentSlots: [slot - 1], + parentBlock: generateProtoBlock({slot: slot - 1}), + }); + vi.mocked(assertLinearChainSegment).mockReturnValue({warnings: null}); + vi.mocked(importBlock).mockResolvedValue(undefined); + }); + + it("does not mark a Gloas proposal known when execution verification aborts", async () => { + const block = blockInput.getBlock(); + const execError = new BlockError(block, { + code: BlockErrorCode.EXECUTION_ENGINE_ERROR, + execStatus: ExecutionPayloadStatus.ELERROR, + errorMessage: "test execution error", + }); + vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { + seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); + return { + postStates: [{forkName: ForkName.gloas} as IBeaconStateView], + proposerBalanceDeltas: [0], + segmentExecStatus: {execAborted: {blockIndex: 0, execError}}, + blockDAStatuses: [DataAvailabilityStatus.Available], + payloadDAStatuses: new Map(), + indexedAttestationsByBlock: [[]], + }; + }); + + await expect(processBlocks.call(chain, [blockInput], null, {})).rejects.toBe(execError); + + expect(seenBlockProposers.hasBlockRoot(slot, proposerIndex, blockInput.blockRootHex)).toBe(true); + expect(seenBlockProposers.isKnown(slot, proposerIndex)).toBe(false); + expect(importBlock).not.toHaveBeenCalled(); + }); + + it("marks a Gloas proposal known after execution verification succeeds", async () => { + vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { + seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); + return { + postStates: [{forkName: ForkName.gloas} as IBeaconStateView], + proposerBalanceDeltas: [0], + segmentExecStatus: { + execAborted: null, + executionStatuses: [ExecutionStatus.Valid], + executionTime: 0, + }, + blockDAStatuses: [DataAvailabilityStatus.Available], + payloadDAStatuses: new Map(), + indexedAttestationsByBlock: [[]], + }; + }); + + await processBlocks.call(chain, [blockInput], null, {}); + + expect(seenBlockProposers.isKnown(slot, proposerIndex)).toBe(true); + expect(importBlock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts index b0525edd8d8d..ffebbf3399c3 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts @@ -16,7 +16,7 @@ describe("SeenBlockProposers", () => { expect(cache.isKnown(slot, proposerIndex)).toBe(false); expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(true); - cache.add(slot, proposerIndex, blockRoot); + cache.add(slot, proposerIndex); cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); expect(cache.isKnown(slot, proposerIndex)).toBe(true); @@ -26,7 +26,8 @@ describe("SeenBlockProposers", () => { it("prunes accepted proposals and observed roots", () => { const cache = new SeenBlockProposers(); - cache.add(slot, proposerIndex, blockRoot); + cache.observeBlockRoot(slot, proposerIndex, blockRoot); + cache.add(slot, proposerIndex); cache.prune(slot + 1); 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 f4449d063cb0..47f44eb0832d 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -34,6 +34,19 @@ describe("gossip block validation", () => { DENEB_FORK_EPOCH: 0, }); const config = createBeaconConfig(configDef, Buffer.alloc(32, 0xaa)); + const gloasConfig = createBeaconConfig( + { + ...configDef, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + }, + Buffer.alloc(32, 0xaa) + ); beforeEach(() => { chain = getMockedBeaconChain({config}); @@ -97,57 +110,72 @@ describe("gossip block validation", () => { ); }); - it("REPEAT_PROPOSAL", async () => { - // Register the proposer as known - chain.seenBlockProposers.add( - job.message.slot, - job.message.proposerIndex, - toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)) - ); + it("pre-Gloas REPEAT_PROPOSAL does not record a conflicting root", async () => { + chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); + + const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); await expectRejectedWithLodestarError( - validateGossipBlock(config, chain, job, ForkName.phase0), + validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), BlockErrorCode.REPEAT_PROPOSAL ); + + expect(verifySignature).not.toHaveBeenCalled(); + expect( + chain.seenBlockProposers.hasBlockRoot( + conflictingBlock.message.slot, + conflictingBlock.message.proposerIndex, + toRootHex(config.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message)) + ) + ).toBe(false); }); - it("REPEAT_PROPOSAL records a conflicting root after verifying its proposer signature", async () => { - const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); - chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex, blockRoot); + it("Gloas REPEAT_PROPOSAL records a conflicting root after verifying its proposer signature", async () => { + Object.defineProperty(chain, "config", {value: gloasConfig}); + const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + gloasBlock.message.slot = clockSlot; + gloasBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(gloasConfig.getForkTypes(clockSlot).BeaconBlock.hashTreeRoot(gloasBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); + const conflictingBlock = ssz.gloas.SignedBeaconBlock.clone(gloasBlock); conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); const conflictingBlockRoot = toRootHex( - config.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) + gloasConfig.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) ); await expectRejectedWithLodestarError( - validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), BlockErrorCode.REPEAT_PROPOSAL ); expect(verifySignature).toHaveBeenCalledOnce(); - expect( - chain.seenBlockProposers.getConflictingBlockRoots(job.message.slot, job.message.proposerIndex, blockRoot) - ).toEqual([conflictingBlockRoot]); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + conflictingBlockRoot, + ]); }); - it("does not record a conflicting root with an invalid proposer signature", async () => { - const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); - chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex, blockRoot); + it("does not record a Gloas conflicting root with an invalid proposer signature", async () => { + Object.defineProperty(chain, "config", {value: gloasConfig}); + const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + gloasBlock.message.slot = clockSlot; + gloasBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(gloasConfig.getForkTypes(clockSlot).BeaconBlock.hashTreeRoot(gloasBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); + const conflictingBlock = ssz.gloas.SignedBeaconBlock.clone(gloasBlock); conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); verifySignature.mockResolvedValue(false); await expectRejectedWithLodestarError( - validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), BlockErrorCode.PROPOSAL_SIGNATURE_INVALID ); - expect( - chain.seenBlockProposers.getConflictingBlockRoots(job.message.slot, job.message.proposerIndex, blockRoot) - ).toEqual([]); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); }); it("PARENT_BLOCK_UNKNOWN (fork-choice)", async () => { From 348df9368b1a9ce932d8cdc8b908ef52401fc654 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 3 Aug 2026 18:17:51 +0100 Subject: [PATCH 03/24] feat: validate block proposer equivocations --- .../src/api/impl/beacon/blocks/index.ts | 74 ++++++++- .../beacon-node/src/chain/blocks/index.ts | 6 +- .../src/chain/blocks/verifyBlock.ts | 2 +- .../beacon-node/src/chain/validation/block.ts | 12 +- .../impl/beacon/blocks/publishBlock.test.ts | 141 ++++++++++++++++++ .../unit/chain/blocks/processBlocks.test.ts | 17 +-- .../test/unit/chain/validation/block.test.ts | 19 +-- 7 files changed, 233 insertions(+), 38 deletions(-) create mode 100644 packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index cc13cdd636f6..898ee5eb7c8c 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -24,6 +24,7 @@ import { } from "@lodestar/state-transition"; import { ProducedBlockSource, + RootHex, SignedBeaconBlock, SignedBlindedBeaconBlock, SignedBlockContents, @@ -35,7 +36,7 @@ import { isSignedExecutionPayloadEnvelopeContents, sszTypesFor, } from "@lodestar/types"; -import {fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; +import {LogDataBasic, fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; import {BlockInputSource, isBlockInputBlobs, isBlockInputColumns} from "../../../../chain/blocks/blockInput/index.js"; import {PayloadEnvelopeInputSource} from "../../../../chain/blocks/payloadEnvelopeInput/index.js"; import {ImportBlockOpts} from "../../../../chain/blocks/types.js"; @@ -58,7 +59,7 @@ import { ProduceFullGloas, } from "../../../../chain/produceBlock/index.js"; import {RegenCaller} from "../../../../chain/regen/index.js"; -import {validateGossipBlock} from "../../../../chain/validation/block.js"; +import {validateGossipBlock, verifyBlockProposerSignature} from "../../../../chain/validation/block.js"; import {validateApiExecutionPayloadBid} from "../../../../chain/validation/executionPayloadBid.js"; import {validateApiExecutionPayloadEnvelope} from "../../../../chain/validation/executionPayloadEnvelope.js"; import {OpSource} from "../../../../chain/validatorMonitor.js"; @@ -93,6 +94,30 @@ const MAX_API_CLOCK_DISPARITY_MS = 1000; */ const IDENTITY_PEER_ID = ""; // TODO: Compute identity keypair +function assertBlockNotEquivocating( + chain: ApiModules["chain"], + signedBlock: SignedBeaconBlock | SignedBlindedBeaconBlock, + blockRoot: RootHex, + logMeta: Record +): void { + const conflictingRoots = chain.seenBlockProposers.getConflictingBlockRoots( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + blockRoot + ); + if (conflictingRoots.length > 0) { + chain.logger.error("Equivocation checks failed while publishing the block", { + ...logMeta, + conflictingRoots: conflictingRoots.join(", "), + }); + throw new ApiError( + 400, + `Block is a proposer equivocation, conflicting block roots: ${conflictingRoots.join(", ")}` + ); + } + chain.logger.debug("Equivocation validated while publishing the block", logMeta); +} + export function getBeaconBlockApi({ chain, config, @@ -286,11 +311,22 @@ export function getBeaconBlockApi({ chain.logger.debug("Consensus validated while publishing block", valLogMeta); if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { - const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; - if (chain.opts.broadcastValidationStrictness === "error") { - throw Error(message); + try { + await verifyBlockProposerSignature(chain, signedBlock, blockRoot); + chain.seenBlockProposers.observeBlockRoot(slot, signedBlock.message.proposerIndex, blockRoot); + } catch (error) { + chain.logger.error( + "Proposer signature validation failed while publishing the block", + valLogMeta, + error as Error + ); + chain.persistInvalidSszValue( + chain.config.getForkTypes(slot).SignedBeaconBlock, + signedBlock, + "api_reject_consensus_and_equivocation_failure" + ); + throw error; } - chain.logger.warn(message, valLogMeta); } break; } @@ -318,6 +354,10 @@ export function getBeaconBlockApi({ await sleep(msToBlockSlot); } + if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + assertBlockNotEquivocating(chain, signedBlock, blockRoot, valLogMeta); + } + // TODO: Validate block const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipBlock.elapsedTimeTillReceived.observe({source: OpSource.api}, delaySec); @@ -438,6 +478,20 @@ export function getBeaconBlockApi({ throw new ApiError(400, `Blinded blocks are not available for post-gloas fork=${fork}`); } + if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + try { + await verifyBlockProposerSignature(chain, signedBlindedBlock, blockRoot); + chain.seenBlockProposers.observeBlockRoot(slot, signedBlindedBlock.message.proposerIndex, blockRoot); + } catch (error) { + chain.logger.error( + "Proposer signature validation failed while publishing the blinded block", + {slot, blockRoot, broadcastValidation}, + error as Error + ); + throw error; + } + } + // Either the payload/blobs are cached from i) engine locally or ii) they are from the builder const producedResult = chain.blockProductionCache.get(blockRoot); if (producedResult !== undefined && producedResult.type !== BlockType.Blinded) { @@ -457,6 +511,14 @@ export function getBeaconBlockApi({ const source = ProducedBlockSource.builder; + if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + assertBlockNotEquivocating(chain, signedBlindedBlock, blockRoot, { + slot, + blockRoot, + broadcastValidation, + }); + } + if (isForkPostFulu(fork)) { await submitBlindedBlockToBuilder(chain, { data: signedBlindedBlock, diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 27075d48c08d..f3dcbda3bb6d 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -1,4 +1,3 @@ -import {ForkSeq} from "@lodestar/params"; import {SignedBeaconBlock, Slot} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; @@ -109,10 +108,7 @@ export async function processBlocks( throw segmentExecStatus.execAborted.execError; } - if ( - opts.skipVerifyBlockSignatures !== true && - this.config.getForkSeq(relevantBlocks[0].getBlock().message.slot) >= ForkSeq.gloas - ) { + if (opts.skipVerifyBlockSignatures !== true) { for (const blockInput of relevantBlocks) { const block = blockInput.getBlock().message; this.seenBlockProposers.add(block.slot, block.proposerIndex); diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 6de9078eb32c..0fdf01e2859a 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -204,7 +204,7 @@ export async function verifyBlocksInEpoch( // maybe chain with the above verifyBlocksSignatures() ]); - if (fork >= ForkSeq.gloas && opts.skipVerifyBlockSignatures !== true) { + if (opts.skipVerifyBlockSignatures !== true) { for (const block of blocks) { const blockRoot = toRootHex( this.config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message) diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index af7a0909f112..4cc94adff054 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -24,7 +24,7 @@ import { isExecutionBlockBodyType, isStatePostBellatrix, } from "@lodestar/state-transition"; -import {RootHex, SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; +import {RootHex, SignedBeaconBlock, SignedBlindedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; import {byteArrayEquals, sleep, toRootHex} from "@lodestar/utils"; import {BlockErrorCode, BlockGossipError, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../interface.js"; @@ -86,7 +86,7 @@ export async function validateGossipBlock( // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot. const proposerIndex = block.proposerIndex; if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { - if (isForkPostGloas(fork) && !chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot)) { + if (!chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot)) { await verifyBlockProposerSignature(chain, signedBlock, blockRoot); chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); } @@ -275,9 +275,7 @@ export async function validateGossipBlock( // [REJECT] The proposer signature, signed_beacon_block.signature, is valid with respect to the proposer_index pubkey. await verifyBlockProposerSignature(chain, signedBlock, blockRoot); - if (isForkPostGloas(fork)) { - chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); - } + chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); // [REJECT] The block is proposed by the expected proposer_index for the block's slot in the context of the current // shuffling (defined by parent_root/slot). If the proposer_index cannot immediately be verified against the expected @@ -310,9 +308,9 @@ export async function validateGossipBlock( return {skippedSlots}; } -async function verifyBlockProposerSignature( +export async function verifyBlockProposerSignature( chain: IBeaconChain, - signedBlock: SignedBeaconBlock, + signedBlock: SignedBeaconBlock | SignedBlindedBeaconBlock, blockRoot: RootHex ): Promise { const blockSlot = signedBlock.message.slot; diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts new file mode 100644 index 000000000000..d8e9241548f2 --- /dev/null +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -0,0 +1,141 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; +import {createBeaconConfig} from "@lodestar/config"; +import {config as configDef} from "@lodestar/config/default"; +import {ForkName} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; +import {BlockInputPreData, BlockInputSource} from "../../../../../../src/chain/blocks/blockInput/index.js"; +import {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; +import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; + +describe("api - beacon - publishBlockV2", () => { + const config = createBeaconConfig(configDef, Buffer.alloc(32, 1)); + let modules: ApiTestModules; + + beforeEach(() => { + modules = getApiTestModules({config}); + Object.defineProperty(modules.chain, "blockProductionCache", {value: new Map()}); + Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); + modules.network.publishBeaconBlock = vi.fn(); + modules.chain.processBlock = vi.fn().mockResolvedValue(undefined); + }); + + it("publishes a locally produced pre-Gloas block after equivocation validation", async () => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = 1; + signedBlock.message.proposerIndex = 2; + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + vi.spyOn(modules.chain.blockProductionCache, "has").mockReturnValue(true); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + + const api = getBeaconBlockApi(modules); + await api.publishBlockV2({ + signedBlockContents: {signedBlock}, + broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, + }); + + expect(modules.chain.bls.verifySignatureSets).toHaveBeenCalledOnce(); + expect( + modules.chain.seenBlockProposers.hasBlockRoot( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + blockRoot + ) + ).toBe(true); + expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); + expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); + }); + + it("rejects a locally produced pre-Gloas block equivocation before publishing", async () => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = 1; + signedBlock.message.proposerIndex = 2; + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + vi.spyOn(modules.chain.blockProductionCache, "has").mockReturnValue(true); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + modules.chain.seenBlockProposers.observeBlockRoot( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + conflictingBlockRoot + ); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishBlockV2({ + signedBlockContents: {signedBlock}, + broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, + }) + ).rejects.toThrow(/proposer equivocation/); + + expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); + expect(modules.chain.processBlock).not.toHaveBeenCalled(); + }); +}); + +describe("api - beacon - publishBlindedBlockV2", () => { + const config = createBeaconConfig( + { + ...configDef, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + }, + Buffer.alloc(32, 1) + ); + let modules: ApiTestModules; + + beforeEach(() => { + modules = getApiTestModules({config}); + Object.defineProperty(modules.chain, "blockProductionCache", {value: new Map()}); + Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); + modules.chain.executionBuilder.submitBlindedBlockNoResponse = vi.fn(); + }); + + it("rejects a pre-Gloas blinded block equivocation before revealing it to the builder", async () => { + const signedBlindedBlock = ssz.fulu.SignedBlindedBeaconBlock.defaultValue(); + signedBlindedBlock.message.slot = 1; + signedBlindedBlock.message.proposerIndex = 2; + const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); + modules.chain.seenBlockProposers.observeBlockRoot( + signedBlindedBlock.message.slot, + signedBlindedBlock.message.proposerIndex, + conflictingBlockRoot + ); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishBlindedBlockV2({ + signedBlindedBlock, + broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, + }) + ).rejects.toThrow(/proposer equivocation/); + + expect(modules.chain.executionBuilder.submitBlindedBlockNoResponse).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts index 25f061c1c474..705fb19a0f82 100644 --- a/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts @@ -32,9 +32,6 @@ describe("chain / blocks / processBlocks", () => { BELLATRIX_FORK_EPOCH: 0, CAPELLA_FORK_EPOCH: 0, DENEB_FORK_EPOCH: 0, - ELECTRA_FORK_EPOCH: 0, - FULU_FORK_EPOCH: 0, - GLOAS_FORK_EPOCH: 0, }); let chain: MockedBeaconChain; let seenBlockProposers: SeenBlockProposers; @@ -47,11 +44,11 @@ describe("chain / blocks / processBlocks", () => { seenBlockProposers = new SeenBlockProposers(); Object.defineProperty(chain, "seenBlockProposers", {value: seenBlockProposers}); - const block = ssz.gloas.SignedBeaconBlock.defaultValue(); + const block = ssz.deneb.SignedBeaconBlock.defaultValue(); block.message.slot = slot; block.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(block.message)); - blockInput = new MockBlockInput({forkName: ForkName.gloas, slot, blockRootHex: blockRoot}); + const blockRoot = toRootHex(ssz.deneb.BeaconBlock.hashTreeRoot(block.message)); + blockInput = new MockBlockInput({forkName: ForkName.deneb, slot, blockRootHex: blockRoot}); blockInput._block = block; vi.mocked(verifyBlocksSanityChecks).mockReturnValue({ @@ -63,7 +60,7 @@ describe("chain / blocks / processBlocks", () => { vi.mocked(importBlock).mockResolvedValue(undefined); }); - it("does not mark a Gloas proposal known when execution verification aborts", async () => { + it("does not mark a proposal known when execution verification aborts", async () => { const block = blockInput.getBlock(); const execError = new BlockError(block, { code: BlockErrorCode.EXECUTION_ENGINE_ERROR, @@ -73,7 +70,7 @@ describe("chain / blocks / processBlocks", () => { vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); return { - postStates: [{forkName: ForkName.gloas} as IBeaconStateView], + postStates: [{forkName: ForkName.deneb} as IBeaconStateView], proposerBalanceDeltas: [0], segmentExecStatus: {execAborted: {blockIndex: 0, execError}}, blockDAStatuses: [DataAvailabilityStatus.Available], @@ -89,11 +86,11 @@ describe("chain / blocks / processBlocks", () => { expect(importBlock).not.toHaveBeenCalled(); }); - it("marks a Gloas proposal known after execution verification succeeds", async () => { + it("marks a proposal known after execution verification succeeds", async () => { vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); return { - postStates: [{forkName: ForkName.gloas} as IBeaconStateView], + postStates: [{forkName: ForkName.deneb} as IBeaconStateView], proposerBalanceDeltas: [0], segmentExecStatus: { execAborted: null, 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 47f44eb0832d..2f3142724ddf 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -110,25 +110,26 @@ describe("gossip block validation", () => { ); }); - it("pre-Gloas REPEAT_PROPOSAL does not record a conflicting root", async () => { + it("pre-Gloas REPEAT_PROPOSAL records a conflicting root after verifying its proposer signature", async () => { + const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); + chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex( + config.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) + ); await expectRejectedWithLodestarError( validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), BlockErrorCode.REPEAT_PROPOSAL ); - expect(verifySignature).not.toHaveBeenCalled(); - expect( - chain.seenBlockProposers.hasBlockRoot( - conflictingBlock.message.slot, - conflictingBlock.message.proposerIndex, - toRootHex(config.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message)) - ) - ).toBe(false); + expect(verifySignature).toHaveBeenCalledOnce(); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + conflictingBlockRoot, + ]); }); it("Gloas REPEAT_PROPOSAL records a conflicting root after verifying its proposer signature", async () => { From 2cb6adebc968f6a3d10136bb3e6c238677590192 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 13:08:23 +0100 Subject: [PATCH 04/24] refine proposer equivocation validation --- .../src/api/impl/beacon/blocks/index.ts | 74 ++++++----------- .../src/chain/seenCache/seenBlockProposers.ts | 15 +++- .../beacon-node/src/chain/validation/block.ts | 14 ++-- .../impl/beacon/blocks/publishBlock.test.ts | 80 +++++++++---------- .../seenCache/seenBlockProposers.test.ts | 17 ++++ .../test/unit/chain/validation/block.test.ts | 22 +++++ 6 files changed, 118 insertions(+), 104 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 898ee5eb7c8c..10c7916cd421 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -24,7 +24,6 @@ import { } from "@lodestar/state-transition"; import { ProducedBlockSource, - RootHex, SignedBeaconBlock, SignedBlindedBeaconBlock, SignedBlockContents, @@ -36,7 +35,7 @@ import { isSignedExecutionPayloadEnvelopeContents, sszTypesFor, } from "@lodestar/types"; -import {LogDataBasic, fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; +import {fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; import {BlockInputSource, isBlockInputBlobs, isBlockInputColumns} from "../../../../chain/blocks/blockInput/index.js"; import {PayloadEnvelopeInputSource} from "../../../../chain/blocks/payloadEnvelopeInput/index.js"; import {ImportBlockOpts} from "../../../../chain/blocks/types.js"; @@ -94,30 +93,6 @@ const MAX_API_CLOCK_DISPARITY_MS = 1000; */ const IDENTITY_PEER_ID = ""; // TODO: Compute identity keypair -function assertBlockNotEquivocating( - chain: ApiModules["chain"], - signedBlock: SignedBeaconBlock | SignedBlindedBeaconBlock, - blockRoot: RootHex, - logMeta: Record -): void { - const conflictingRoots = chain.seenBlockProposers.getConflictingBlockRoots( - signedBlock.message.slot, - signedBlock.message.proposerIndex, - blockRoot - ); - if (conflictingRoots.length > 0) { - chain.logger.error("Equivocation checks failed while publishing the block", { - ...logMeta, - conflictingRoots: conflictingRoots.join(", "), - }); - throw new ApiError( - 400, - `Block is a proposer equivocation, conflicting block roots: ${conflictingRoots.join(", ")}` - ); - } - chain.logger.debug("Equivocation validated while publishing the block", logMeta); -} - export function getBeaconBlockApi({ chain, config, @@ -293,7 +268,6 @@ export function getBeaconBlockApi({ await verifyBlocksInEpoch.call(chain as BeaconChain, parentBlock, [blockForImport], null, { ...opts, verifyOnly: true, - skipVerifyBlockSignatures: true, skipVerifyExecutionPayload: true, seenTimestampSec, }); @@ -310,7 +284,12 @@ export function getBeaconBlockApi({ chain.logger.debug("Consensus validated while publishing block", valLogMeta); - if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + // Non-local blocks had all signatures checked and their root observed by verifyBlocksInEpoch above. + // Locally produced blocks skip that path, so verify their proposer signature and observe the root here. + if ( + broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation && + blockLocallyProduced + ) { try { await verifyBlockProposerSignature(chain, signedBlock, blockRoot); chain.seenBlockProposers.observeBlockRoot(slot, signedBlock.message.proposerIndex, blockRoot); @@ -355,7 +334,22 @@ export function getBeaconBlockApi({ } if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { - assertBlockNotEquivocating(chain, signedBlock, blockRoot, valLogMeta); + const conflictingRoots = chain.seenBlockProposers.getConflictingBlockRoots( + slot, + signedBlock.message.proposerIndex, + blockRoot + ); + if (conflictingRoots.length > 0) { + chain.logger.error("Equivocation checks failed while publishing the block", { + ...valLogMeta, + conflictingRoots: conflictingRoots.join(", "), + }); + throw new ApiError( + 400, + `Block is a proposer equivocation, conflicting block roots: ${conflictingRoots.join(", ")}` + ); + } + chain.logger.debug("Equivocation validated while publishing the block", valLogMeta); } // TODO: Validate block @@ -478,20 +472,6 @@ export function getBeaconBlockApi({ throw new ApiError(400, `Blinded blocks are not available for post-gloas fork=${fork}`); } - if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { - try { - await verifyBlockProposerSignature(chain, signedBlindedBlock, blockRoot); - chain.seenBlockProposers.observeBlockRoot(slot, signedBlindedBlock.message.proposerIndex, blockRoot); - } catch (error) { - chain.logger.error( - "Proposer signature validation failed while publishing the blinded block", - {slot, blockRoot, broadcastValidation}, - error as Error - ); - throw error; - } - } - // Either the payload/blobs are cached from i) engine locally or ii) they are from the builder const producedResult = chain.blockProductionCache.get(blockRoot); if (producedResult !== undefined && producedResult.type !== BlockType.Blinded) { @@ -511,14 +491,6 @@ export function getBeaconBlockApi({ const source = ProducedBlockSource.builder; - if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { - assertBlockNotEquivocating(chain, signedBlindedBlock, blockRoot, { - slot, - blockRoot, - broadcastValidation, - }); - } - if (isForkPostFulu(fork)) { await submitBlindedBlockToBuilder(chain, { data: signedBlindedBlock, diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 97aeeb047199..43921e263570 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -2,14 +2,16 @@ import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; +const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; + /** * Keeps a cache to filter block proposals from the same validator in the same slot. * * Block roots with a verified proposer signature are tracked separately from proposals that passed gossip validation. * This allows proposer equivocations to be detected without letting invalid blocks suppress valid gossip. * - * This cache is not bounded and for extremely long periods of non-finality it can grow a lot. However it's practically - * limited by the possible shufflings in those epochs, and the stored data is very cheap + * The cache is pruned on finalization and stores at most two roots per proposer and slot, since two roots are sufficient + * to establish an equivocation. */ export class SeenBlockProposers { private readonly proposerIndexesBySlot = new MapDef>(() => new Set()); @@ -26,6 +28,10 @@ export class SeenBlockProposers { return this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex)?.has(blockRoot) === true; } + isEquivocating(blockSlot: Slot, proposerIndex: ValidatorIndex): boolean { + return (this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex)?.size ?? 0) >= MAX_BLOCK_ROOTS_PER_PROPOSAL; + } + getConflictingBlockRoots(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): RootHex[] { const roots = this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex); return roots === undefined ? [] : Array.from(roots).filter((root) => root !== blockRoot); @@ -37,7 +43,10 @@ export class SeenBlockProposers { throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); } - this.blockRootsBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex).add(blockRoot); + const blockRoots = this.blockRootsBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex); + if (blockRoots.size < MAX_BLOCK_ROOTS_PER_PROPOSAL) { + blockRoots.add(blockRoot); + } } /** Mark a block as known from gossip or another block import path. */ diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 4cc94adff054..a497baa2cc6f 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -24,7 +24,7 @@ import { isExecutionBlockBodyType, isStatePostBellatrix, } from "@lodestar/state-transition"; -import {RootHex, SignedBeaconBlock, SignedBlindedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; +import {RootHex, SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; import {byteArrayEquals, sleep, toRootHex} from "@lodestar/utils"; import {BlockErrorCode, BlockGossipError, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../interface.js"; @@ -85,9 +85,10 @@ export async function validateGossipBlock( // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot. const proposerIndex = block.proposerIndex; + const hasBlockRoot = chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot); if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { - if (!chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot)) { - await verifyBlockProposerSignature(chain, signedBlock, blockRoot); + if (!hasBlockRoot && !chain.seenBlockProposers.isEquivocating(blockSlot, proposerIndex)) { + await verifyBlockProposerSignature(chain, signedBlock, blockRoot, {verifyOnMainThread: false}); chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); } throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); @@ -310,8 +311,9 @@ export async function validateGossipBlock( export async function verifyBlockProposerSignature( chain: IBeaconChain, - signedBlock: SignedBeaconBlock | SignedBlindedBeaconBlock, - blockRoot: RootHex + signedBlock: SignedBeaconBlock, + blockRoot: RootHex, + opts: {verifyOnMainThread?: boolean} = {} ): Promise { const blockSlot = signedBlock.message.slot; if (chain.seenBlockInputCache.isVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature)) { @@ -320,7 +322,7 @@ export async function verifyBlockProposerSignature( const signatureSet = getBlockProposerSignatureSet(chain.config, signedBlock); // Don't batch so verification is not delayed - if (!(await chain.bls.verifySignatureSets([signatureSet], {verifyOnMainThread: true}))) { + if (!(await chain.bls.verifySignatureSets([signatureSet], {verifyOnMainThread: opts.verifyOnMainThread ?? true}))) { throw new BlockGossipError(GossipAction.REJECT, { code: BlockErrorCode.PROPOSAL_SIGNATURE_INVALID, blockSlot, diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index d8e9241548f2..0834db01d44c 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -7,14 +7,20 @@ import {ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; import {BlockInputPreData, BlockInputSource} from "../../../../../../src/chain/blocks/blockInput/index.js"; +import {verifyBlocksInEpoch} from "../../../../../../src/chain/blocks/verifyBlock.js"; import {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; +import {generateProtoBlock} from "../../../../../utils/typeGenerator.js"; + +vi.mock("../../../../../../src/chain/blocks/verifyBlock.js"); describe("api - beacon - publishBlockV2", () => { const config = createBeaconConfig(configDef, Buffer.alloc(32, 1)); let modules: ApiTestModules; beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verifyBlocksInEpoch).mockResolvedValue({} as Awaited>); modules = getApiTestModules({config}); Object.defineProperty(modules.chain, "blockProductionCache", {value: new Map()}); Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); @@ -58,7 +64,7 @@ describe("api - beacon - publishBlockV2", () => { expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); }); - it("rejects a locally produced pre-Gloas block equivocation before publishing", async () => { + it("rejects a non-local pre-Gloas block equivocation before publishing", async () => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); signedBlock.message.slot = 1; signedBlock.message.proposerIndex = 2; @@ -74,7 +80,7 @@ describe("api - beacon - publishBlockV2", () => { seenTimestampSec: 0, daOutOfRange: false, }); - vi.spyOn(modules.chain.blockProductionCache, "has").mockReturnValue(true); + modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(generateProtoBlock({slot: 0})); modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); modules.chain.seenBlockProposers.observeBlockRoot( signedBlock.message.slot, @@ -90,52 +96,38 @@ describe("api - beacon - publishBlockV2", () => { }) ).rejects.toThrow(/proposer equivocation/); + expect(verifyBlocksInEpoch).toHaveBeenCalledOnce(); expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); expect(modules.chain.processBlock).not.toHaveBeenCalled(); }); -}); -describe("api - beacon - publishBlindedBlockV2", () => { - const config = createBeaconConfig( - { - ...configDef, - ALTAIR_FORK_EPOCH: 0, - BELLATRIX_FORK_EPOCH: 0, - CAPELLA_FORK_EPOCH: 0, - DENEB_FORK_EPOCH: 0, - ELECTRA_FORK_EPOCH: 0, - FULU_FORK_EPOCH: 0, - }, - Buffer.alloc(32, 1) - ); - let modules: ApiTestModules; + it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( + "verifies all signatures for a non-local block with %s validation", + async (broadcastValidation) => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = 1; + signedBlock.message.proposerIndex = 2; + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(generateProtoBlock({slot: 0})); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); - beforeEach(() => { - modules = getApiTestModules({config}); - Object.defineProperty(modules.chain, "blockProductionCache", {value: new Map()}); - Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); - modules.chain.executionBuilder.submitBlindedBlockNoResponse = vi.fn(); - }); + const api = getBeaconBlockApi(modules); + await api.publishBlockV2({signedBlockContents: {signedBlock}, broadcastValidation}); - it("rejects a pre-Gloas blinded block equivocation before revealing it to the builder", async () => { - const signedBlindedBlock = ssz.fulu.SignedBlindedBeaconBlock.defaultValue(); - signedBlindedBlock.message.slot = 1; - signedBlindedBlock.message.proposerIndex = 2; - const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); - modules.chain.seenBlockProposers.observeBlockRoot( - signedBlindedBlock.message.slot, - signedBlindedBlock.message.proposerIndex, - conflictingBlockRoot - ); - - const api = getBeaconBlockApi(modules); - await expect( - api.publishBlindedBlockV2({ - signedBlindedBlock, - broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, - }) - ).rejects.toThrow(/proposer equivocation/); - - expect(modules.chain.executionBuilder.submitBlindedBlockNoResponse).not.toHaveBeenCalled(); - }); + expect(verifyBlocksInEpoch).toHaveBeenCalledOnce(); + const verifyOpts = vi.mocked(verifyBlocksInEpoch).mock.calls[0][3]; + expect(verifyOpts.skipVerifyBlockSignatures).not.toBe(true); + expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); + } + ); }); diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts index ffebbf3399c3..94c3c6dc4813 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts @@ -7,6 +7,7 @@ describe("SeenBlockProposers", () => { const proposerIndex = 1; const blockRoot = toRootHex(Buffer.alloc(32, 1)); const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 2)); + const additionalBlockRoot = toRootHex(Buffer.alloc(32, 3)); it("tracks signature-verified roots separately from gossip-accepted proposals", () => { const cache = new SeenBlockProposers(); @@ -20,10 +21,26 @@ describe("SeenBlockProposers", () => { cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); expect(cache.isKnown(slot, proposerIndex)).toBe(true); + expect(cache.isEquivocating(slot, proposerIndex)).toBe(true); expect(cache.getConflictingBlockRoots(slot, proposerIndex, blockRoot)).toEqual([conflictingBlockRoot]); expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([blockRoot]); }); + it("stores at most two roots per proposal", () => { + const cache = new SeenBlockProposers(); + + cache.observeBlockRoot(slot, proposerIndex, blockRoot); + cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); + cache.observeBlockRoot(slot, proposerIndex, additionalBlockRoot); + + expect(cache.isEquivocating(slot, proposerIndex)).toBe(true); + expect(cache.hasBlockRoot(slot, proposerIndex, additionalBlockRoot)).toBe(false); + expect(cache.getConflictingBlockRoots(slot, proposerIndex, additionalBlockRoot)).toEqual([ + blockRoot, + conflictingBlockRoot, + ]); + }); + it("prunes accepted proposals and observed roots", () => { const cache = new SeenBlockProposers(); cache.observeBlockRoot(slot, proposerIndex, blockRoot); 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 2f3142724ddf..4152a42cf32f 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -127,11 +127,33 @@ describe("gossip block validation", () => { ); expect(verifySignature).toHaveBeenCalledOnce(); + expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ conflictingBlockRoot, ]); }); + it("does not verify additional roots after observing an equivocation", async () => { + const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); + chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); + chain.seenBlockProposers.observeBlockRoot( + job.message.slot, + job.message.proposerIndex, + toRootHex(Buffer.alloc(32, 1)) + ); + chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); + + const additionalBlock = ssz.deneb.SignedBeaconBlock.clone(job); + additionalBlock.message.stateRoot = Buffer.alloc(32, 2); + + await expectRejectedWithLodestarError( + validateGossipBlock(config, chain, additionalBlock, ForkName.phase0), + BlockErrorCode.REPEAT_PROPOSAL + ); + + expect(verifySignature).not.toHaveBeenCalled(); + }); + it("Gloas REPEAT_PROPOSAL records a conflicting root after verifying its proposer signature", async () => { Object.defineProperty(chain, "config", {value: gloasConfig}); const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); From 890255120cf6f524aeeb927dd26f317089a7f7cd Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 17:48:10 +0100 Subject: [PATCH 05/24] fix: log proposer equivocation rejections as warnings --- .../beacon-node/src/api/impl/beacon/blocks/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 10c7916cd421..cb9d58e51234 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -293,18 +293,18 @@ export function getBeaconBlockApi({ try { await verifyBlockProposerSignature(chain, signedBlock, blockRoot); chain.seenBlockProposers.observeBlockRoot(slot, signedBlock.message.proposerIndex, blockRoot); - } catch (error) { + } catch (e) { chain.logger.error( "Proposer signature validation failed while publishing the block", valLogMeta, - error as Error + e as Error ); chain.persistInvalidSszValue( chain.config.getForkTypes(slot).SignedBeaconBlock, signedBlock, "api_reject_consensus_and_equivocation_failure" ); - throw error; + throw e; } } break; @@ -340,7 +340,7 @@ export function getBeaconBlockApi({ blockRoot ); if (conflictingRoots.length > 0) { - chain.logger.error("Equivocation checks failed while publishing the block", { + chain.logger.warn("Not publishing block due to proposer equivocation", { ...valLogMeta, conflictingRoots: conflictingRoots.join(", "), }); @@ -920,7 +920,7 @@ export function getBeaconBlockApi({ blockRootHex ); if (conflictingRoots.length > 0) { - chain.logger.error("Equivocation checks failed while publishing execution payload envelope", { + chain.logger.warn("Not publishing execution payload envelope due to proposer equivocation", { ...valLogMeta, conflictingRoots: conflictingRoots.join(", "), }); From 10522f413da96e060291153a8e61310baf098031 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:02:41 +0100 Subject: [PATCH 06/24] Restore finalized slot guard for seen block proposers --- .../src/chain/seenCache/seenBlockProposers.ts | 4 ++++ .../unit/chain/seenCache/seenBlockProposers.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 43921e263570..9dd05d22f852 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -51,6 +51,10 @@ export class SeenBlockProposers { /** Mark a block as known from gossip or another block import path. */ add(blockSlot: Slot, proposerIndex: ValidatorIndex): void { + if (blockSlot < this.finalizedSlot) { + throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); + } + this.proposerIndexesBySlot.getOrDefault(blockSlot).add(proposerIndex); } diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts index 94c3c6dc4813..ae155e0bc4b2 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts @@ -52,4 +52,16 @@ describe("SeenBlockProposers", () => { expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(false); expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([]); }); + + it("rejects proposals older than the finalized slot", () => { + const cache = new SeenBlockProposers(); + cache.prune(slot + 1); + + expect(() => cache.add(slot, proposerIndex)).toThrow(`blockSlot ${slot} < finalizedSlot ${slot + 1}`); + expect(() => cache.observeBlockRoot(slot, proposerIndex, blockRoot)).toThrow( + `blockSlot ${slot} < finalizedSlot ${slot + 1}` + ); + expect(cache.isKnown(slot, proposerIndex)).toBe(false); + expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(false); + }); }); From c4485214e84f5e9ee9424e2ba214bdd12cd40d3a Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:06:35 +0100 Subject: [PATCH 07/24] clarify seen block proposer cache invariants --- .../beacon-node/src/chain/seenCache/seenBlockProposers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 9dd05d22f852..2c74c2c60624 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -7,8 +7,10 @@ const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; /** * Keeps a cache to filter block proposals from the same validator in the same slot. * - * Block roots with a verified proposer signature are tracked separately from proposals that passed gossip validation. - * This allows proposer equivocations to be detected without letting invalid blocks suppress valid gossip. + * Block roots with a signature verified against the block's proposer index are tracked separately from proposals + * accepted by gossip validation or block import. A signature-verified root is potential equivocation evidence, but the + * block may still fail other validation. Such a block must not mark the proposal as known, since that would cause a + * later valid block for the same slot and proposer to be ignored as a repeat proposal. * * The cache is pruned on finalization and stores at most two roots per proposer and slot, since two roots are sufficient * to establish an equivocation. From 589d073ab4c4c07434fe6a6d3571137c51a2e921 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:14:17 +0100 Subject: [PATCH 08/24] apply repeat proposal race check across forks --- .../beacon-node/src/chain/validation/block.ts | 8 ++--- .../test/unit/chain/validation/block.test.ts | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index a497baa2cc6f..f58d2e11fe58 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -286,10 +286,6 @@ export async function validateGossipBlock( throw new BlockGossipError(GossipAction.REJECT, {code: BlockErrorCode.INCORRECT_PROPOSER, proposerIndex}); } - if (!isForkPostGloas(fork) && chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { - throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); - } - // Simple implementation of a pending block queue. Keeping the block here recycles the queue logic, and keeps the // gossip validation promise without any extra infrastructure. // Do the sleep at the end, since regen and signature validation can already take longer than `msToBlockSlot`. @@ -299,8 +295,8 @@ export async function validateGossipBlock( await sleep(msToBlockSlot); } - // Check again after all async validation so concurrent Gloas proposals cannot both pass. - if (isForkPostGloas(fork) && chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { + // Check again after all async validation and the early-block delay so concurrent proposals cannot both pass. + if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); } 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 4152a42cf32f..a4c178fd5989 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -290,6 +290,35 @@ describe("gossip block validation", () => { await validateGossipBlock(config, chain, job, ForkName.phase0); }); + it("rejects a concurrent pre-Gloas proposal observed during the early-block delay", async () => { + const now = 1_000_000; + vi.useFakeTimers({now}); + + try { + Object.defineProperty(chain, "genesisTime", { + value: now / 1000 - clockSlot * (config.SLOT_DURATION_MS / 1000) + 0.1, + }); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + const state = new BeaconStateView(generateCachedState()); + regen.getPreState.mockResolvedValue(state); + vi.spyOn(state.cachedState.epochCtx, "getBeaconProposer").mockReturnValue(proposerIndex); + + const validation = expectRejectedWithLodestarError( + validateGossipBlock(config, chain, job, ForkName.phase0), + BlockErrorCode.REPEAT_PROPOSAL + ); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(1); + + chain.seenBlockProposers.add(clockSlot, proposerIndex); + await vi.advanceTimersByTimeAsync(100); + await validation; + } finally { + vi.useRealTimers(); + } + }); + it("deneb - TOO_MANY_KZG_COMMITMENTS", async () => { // Fill up with kzg commitments block.body.blobKzgCommitments = Array.from( From f80e52d23a7da0d405ce3e9fe3a1b9b495488097 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:17:09 +0100 Subject: [PATCH 09/24] remove periods from single sentence comments --- .../beacon-node/src/chain/seenCache/seenBlockProposers.ts | 6 +++--- packages/beacon-node/src/chain/validation/block.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 2c74c2c60624..150aedf593c2 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -13,7 +13,7 @@ const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; * later valid block for the same slot and proposer to be ignored as a repeat proposal. * * The cache is pruned on finalization and stores at most two roots per proposer and slot, since two roots are sufficient - * to establish an equivocation. + * to establish an equivocation */ export class SeenBlockProposers { private readonly proposerIndexesBySlot = new MapDef>(() => new Set()); @@ -39,7 +39,7 @@ export class SeenBlockProposers { return roots === undefined ? [] : Array.from(roots).filter((root) => root !== blockRoot); } - /** Record a block only after its proposer signature has been verified. */ + /** Record a block only after its proposer signature has been verified */ observeBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void { if (blockSlot < this.finalizedSlot) { throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); @@ -51,7 +51,7 @@ export class SeenBlockProposers { } } - /** Mark a block as known from gossip or another block import path. */ + /** Mark a block as known from gossip or another block import path */ add(blockSlot: Slot, proposerIndex: ValidatorIndex): void { if (blockSlot < this.finalizedSlot) { throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index f58d2e11fe58..6b9f7aadaa69 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -295,7 +295,7 @@ export async function validateGossipBlock( await sleep(msToBlockSlot); } - // Check again after all async validation and the early-block delay so concurrent proposals cannot both pass. + // Check again after all async validation and the early-block delay so concurrent proposals cannot both pass if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); } From f3805fb54a7e7282853f2631513dd166465f7f43 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:18:38 +0100 Subject: [PATCH 10/24] document proposer block root limit --- .../beacon-node/src/chain/seenCache/seenBlockProposers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 150aedf593c2..469bd76d48d1 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -2,6 +2,7 @@ import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; +// Two distinct signature-verified roots are sufficient to establish a proposer equivocation const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; /** @@ -12,8 +13,7 @@ const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; * block may still fail other validation. Such a block must not mark the proposal as known, since that would cause a * later valid block for the same slot and proposer to be ignored as a repeat proposal. * - * The cache is pruned on finalization and stores at most two roots per proposer and slot, since two roots are sufficient - * to establish an equivocation + * The cache is pruned on finalization and bounds the number of roots stored per proposer and slot */ export class SeenBlockProposers { private readonly proposerIndexesBySlot = new MapDef>(() => new Set()); From 5ba5e9acece2ee8b35f2da75dbd685f695dcfa33 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:20:21 +0100 Subject: [PATCH 11/24] use jsdoc for proposer block root limit --- packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 469bd76d48d1..157cf2eef2d4 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -2,7 +2,7 @@ import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; -// Two distinct signature-verified roots are sufficient to establish a proposer equivocation +/** Two distinct signature-verified roots are sufficient to establish a proposer equivocation */ const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; /** From 84329fa5420f490fc7df6a0a014b683b4f5270f0 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:20:59 +0100 Subject: [PATCH 12/24] clarify proposer equivocation evidence wording --- .../beacon-node/src/chain/seenCache/seenBlockProposers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 157cf2eef2d4..5f3591d48a1e 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -2,16 +2,16 @@ import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; -/** Two distinct signature-verified roots are sufficient to establish a proposer equivocation */ +/** Two distinct block roots signed by the same proposer for the same slot are sufficient to establish an equivocation */ const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; /** * Keeps a cache to filter block proposals from the same validator in the same slot. * * Block roots with a signature verified against the block's proposer index are tracked separately from proposals - * accepted by gossip validation or block import. A signature-verified root is potential equivocation evidence, but the - * block may still fail other validation. Such a block must not mark the proposal as known, since that would cause a - * later valid block for the same slot and proposer to be ignored as a repeat proposal. + * accepted by gossip validation or block import. A root from a block signed by its declared proposer is potential + * equivocation evidence, but the block may still fail other validation. Such a block must not mark the proposal as + * known, since that would cause a later valid block for the same slot and proposer to be ignored as a repeat proposal. * * The cache is pruned on finalization and bounds the number of roots stored per proposer and slot */ From fcca661d4cdc47d726d305a32feb3734d2d47928 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:27:41 +0100 Subject: [PATCH 13/24] clarify equivocation test descriptions --- .../unit/api/impl/beacon/blocks/publishBlock.test.ts | 6 +++--- .../blocks/publishExecutionPayloadEnvelope.test.ts | 2 +- .../test/unit/chain/blocks/processBlocks.test.ts | 4 ++-- .../unit/chain/seenCache/seenBlockProposers.test.ts | 8 ++++---- .../test/unit/chain/validation/block.test.ts | 10 +++++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index 0834db01d44c..bb35c27766c0 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -28,7 +28,7 @@ describe("api - beacon - publishBlockV2", () => { modules.chain.processBlock = vi.fn().mockResolvedValue(undefined); }); - it("publishes a locally produced pre-Gloas block after equivocation validation", async () => { + it("publishes a locally produced block after verifying its proposer signature and recording its root", async () => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); signedBlock.message.slot = 1; signedBlock.message.proposerIndex = 2; @@ -64,7 +64,7 @@ describe("api - beacon - publishBlockV2", () => { expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); }); - it("rejects a non-local pre-Gloas block equivocation before publishing", async () => { + it("does not publish or import a non-local block that conflicts with an observed proposal", async () => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); signedBlock.message.slot = 1; signedBlock.message.proposerIndex = 2; @@ -102,7 +102,7 @@ describe("api - beacon - publishBlockV2", () => { }); it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( - "verifies all signatures for a non-local block with %s validation", + "verifies all signatures for a non-local block with %s broadcast validation", async (broadcastValidation) => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); signedBlock.message.slot = 1; diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts index d890a1717432..3f70fc043862 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts @@ -40,7 +40,7 @@ describe("api - beacon - publishExecutionPayloadEnvelope", () => { modules.chain.processExecutionPayload = vi.fn(); }); - it("rejects an envelope for a proposer equivocation without publishing or importing it", async () => { + it("rejects an envelope when another block root is observed for its proposer and slot", async () => { const signedBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); const slot = signedBlock.message.slot; const proposerIndex = signedBlock.message.proposerIndex; diff --git a/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts index 705fb19a0f82..bdededb09289 100644 --- a/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts @@ -60,7 +60,7 @@ describe("chain / blocks / processBlocks", () => { vi.mocked(importBlock).mockResolvedValue(undefined); }); - it("does not mark a proposal known when execution verification aborts", async () => { + it("does not mark a proposal as known when execution verification aborts", async () => { const block = blockInput.getBlock(); const execError = new BlockError(block, { code: BlockErrorCode.EXECUTION_ENGINE_ERROR, @@ -86,7 +86,7 @@ describe("chain / blocks / processBlocks", () => { expect(importBlock).not.toHaveBeenCalled(); }); - it("marks a proposal known after execution verification succeeds", async () => { + it("marks a proposal as known after execution verification succeeds", async () => { vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); return { diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts index ae155e0bc4b2..5d65c34b10a4 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts @@ -9,7 +9,7 @@ describe("SeenBlockProposers", () => { const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 2)); const additionalBlockRoot = toRootHex(Buffer.alloc(32, 3)); - it("tracks signature-verified roots separately from gossip-accepted proposals", () => { + it("tracks observed block roots separately from known proposals", () => { const cache = new SeenBlockProposers(); cache.observeBlockRoot(slot, proposerIndex, blockRoot); @@ -26,7 +26,7 @@ describe("SeenBlockProposers", () => { expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([blockRoot]); }); - it("stores at most two roots per proposal", () => { + it("stores at most two roots per slot and proposer", () => { const cache = new SeenBlockProposers(); cache.observeBlockRoot(slot, proposerIndex, blockRoot); @@ -41,7 +41,7 @@ describe("SeenBlockProposers", () => { ]); }); - it("prunes accepted proposals and observed roots", () => { + it("prunes known proposals and observed roots", () => { const cache = new SeenBlockProposers(); cache.observeBlockRoot(slot, proposerIndex, blockRoot); cache.add(slot, proposerIndex); @@ -53,7 +53,7 @@ describe("SeenBlockProposers", () => { expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([]); }); - it("rejects proposals older than the finalized slot", () => { + it("rejects updates for slots before the finalized slot", () => { const cache = new SeenBlockProposers(); cache.prune(slot + 1); 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 a4c178fd5989..0e76534ae30f 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -110,7 +110,7 @@ describe("gossip block validation", () => { ); }); - it("pre-Gloas REPEAT_PROPOSAL records a conflicting root after verifying its proposer signature", async () => { + it("verifies the proposer signature and records a conflicting pre-Gloas block root before returning REPEAT_PROPOSAL", async () => { const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); @@ -133,7 +133,7 @@ describe("gossip block validation", () => { ]); }); - it("does not verify additional roots after observing an equivocation", async () => { + it("does not verify another proposer signature after observing an equivocation", async () => { const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); chain.seenBlockProposers.observeBlockRoot( @@ -154,7 +154,7 @@ describe("gossip block validation", () => { expect(verifySignature).not.toHaveBeenCalled(); }); - it("Gloas REPEAT_PROPOSAL records a conflicting root after verifying its proposer signature", async () => { + it("verifies the proposer signature and records a conflicting Gloas block root before returning REPEAT_PROPOSAL", async () => { Object.defineProperty(chain, "config", {value: gloasConfig}); const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); gloasBlock.message.slot = clockSlot; @@ -180,7 +180,7 @@ describe("gossip block validation", () => { ]); }); - it("does not record a Gloas conflicting root with an invalid proposer signature", async () => { + it("does not record a conflicting Gloas block root with an invalid proposer signature", async () => { Object.defineProperty(chain, "config", {value: gloasConfig}); const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); gloasBlock.message.slot = clockSlot; @@ -290,7 +290,7 @@ describe("gossip block validation", () => { await validateGossipBlock(config, chain, job, ForkName.phase0); }); - it("rejects a concurrent pre-Gloas proposal observed during the early-block delay", async () => { + it("rejects a block when another proposal becomes known during the early-block delay", async () => { const now = 1_000_000; vi.useFakeTimers({now}); From 5a1661d43d91425adf22ca52d12f5310a85222c1 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:30:36 +0100 Subject: [PATCH 14/24] qualify equivocation validation tests --- .../test/unit/api/impl/beacon/blocks/publishBlock.test.ts | 4 ++-- .../beacon/blocks/publishExecutionPayloadEnvelope.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index bb35c27766c0..a1de39a69788 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -28,7 +28,7 @@ describe("api - beacon - publishBlockV2", () => { modules.chain.processBlock = vi.fn().mockResolvedValue(undefined); }); - it("publishes a locally produced block after verifying its proposer signature and recording its root", async () => { + it("with consensus_and_equivocation, verifies the proposer signature and records the root before publishing a local block", async () => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); signedBlock.message.slot = 1; signedBlock.message.proposerIndex = 2; @@ -64,7 +64,7 @@ describe("api - beacon - publishBlockV2", () => { expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); }); - it("does not publish or import a non-local block that conflicts with an observed proposal", async () => { + it("with consensus_and_equivocation, does not publish or import a non-local block that conflicts with an observed proposal", async () => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); signedBlock.message.slot = 1; signedBlock.message.proposerIndex = 2; diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts index 3f70fc043862..2375fb5e277e 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts @@ -40,7 +40,7 @@ describe("api - beacon - publishExecutionPayloadEnvelope", () => { modules.chain.processExecutionPayload = vi.fn(); }); - it("rejects an envelope when another block root is observed for its proposer and slot", async () => { + it("with consensus_and_equivocation, rejects an envelope for an observed proposer equivocation", async () => { const signedBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); const slot = signedBlock.message.slot; const proposerIndex = signedBlock.message.proposerIndex; From 14f8b445bb2dabfba454342268c3493709477ea5 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:34:03 +0100 Subject: [PATCH 15/24] group tests by broadcast validation strategy --- .../impl/beacon/blocks/publishBlock.test.ts | 158 +++++++++--------- .../publishExecutionPayloadEnvelope.test.ts | 70 ++++---- 2 files changed, 117 insertions(+), 111 deletions(-) diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index a1de39a69788..2b3b2a0c4b6c 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -28,88 +28,51 @@ describe("api - beacon - publishBlockV2", () => { modules.chain.processBlock = vi.fn().mockResolvedValue(undefined); }); - it("with consensus_and_equivocation, verifies the proposer signature and records the root before publishing a local block", async () => { - const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = 1; - signedBlock.message.proposerIndex = 2; - const blockRoot = toRootHex( - modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) - ); - const blockInput = BlockInputPreData.createFromBlock({ - forkName: ForkName.phase0, - block: signedBlock, - blockRootHex: blockRoot, - source: BlockInputSource.api, - seenTimestampSec: 0, - daOutOfRange: false, - }); - vi.spyOn(modules.chain.blockProductionCache, "has").mockReturnValue(true); - modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); - - const api = getBeaconBlockApi(modules); - await api.publishBlockV2({ - signedBlockContents: {signedBlock}, - broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, - }); - - expect(modules.chain.bls.verifySignatureSets).toHaveBeenCalledOnce(); - expect( - modules.chain.seenBlockProposers.hasBlockRoot( - signedBlock.message.slot, - signedBlock.message.proposerIndex, - blockRoot - ) - ).toBe(true); - expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); - expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); - }); - - it("with consensus_and_equivocation, does not publish or import a non-local block that conflicts with an observed proposal", async () => { - const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = 1; - signedBlock.message.proposerIndex = 2; - const blockRoot = toRootHex( - modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) - ); - const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); - const blockInput = BlockInputPreData.createFromBlock({ - forkName: ForkName.phase0, - block: signedBlock, - blockRootHex: blockRoot, - source: BlockInputSource.api, - seenTimestampSec: 0, - daOutOfRange: false, - }); - modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(generateProtoBlock({slot: 0})); - modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); - modules.chain.seenBlockProposers.observeBlockRoot( - signedBlock.message.slot, - signedBlock.message.proposerIndex, - conflictingBlockRoot - ); + describe("broadcast_validation=consensus_and_equivocation", () => { + it("verifies the proposer signature and records the root before publishing a local block", async () => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = 1; + signedBlock.message.proposerIndex = 2; + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + vi.spyOn(modules.chain.blockProductionCache, "has").mockReturnValue(true); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); - const api = getBeaconBlockApi(modules); - await expect( - api.publishBlockV2({ + const api = getBeaconBlockApi(modules); + await api.publishBlockV2({ signedBlockContents: {signedBlock}, broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, - }) - ).rejects.toThrow(/proposer equivocation/); + }); - expect(verifyBlocksInEpoch).toHaveBeenCalledOnce(); - expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); - expect(modules.chain.processBlock).not.toHaveBeenCalled(); - }); + expect(modules.chain.bls.verifySignatureSets).toHaveBeenCalledOnce(); + expect( + modules.chain.seenBlockProposers.hasBlockRoot( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + blockRoot + ) + ).toBe(true); + expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); + expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); + }); - it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( - "verifies all signatures for a non-local block with %s broadcast validation", - async (broadcastValidation) => { + it("does not publish or import a non-local block that conflicts with an observed proposal", async () => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); signedBlock.message.slot = 1; signedBlock.message.proposerIndex = 2; const blockRoot = toRootHex( modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) ); + const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); const blockInput = BlockInputPreData.createFromBlock({ forkName: ForkName.phase0, block: signedBlock, @@ -120,14 +83,55 @@ describe("api - beacon - publishBlockV2", () => { }); modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(generateProtoBlock({slot: 0})); modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + modules.chain.seenBlockProposers.observeBlockRoot( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + conflictingBlockRoot + ); const api = getBeaconBlockApi(modules); - await api.publishBlockV2({signedBlockContents: {signedBlock}, broadcastValidation}); + await expect( + api.publishBlockV2({ + signedBlockContents: {signedBlock}, + broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, + }) + ).rejects.toThrow(/proposer equivocation/); expect(verifyBlocksInEpoch).toHaveBeenCalledOnce(); - const verifyOpts = vi.mocked(verifyBlocksInEpoch).mock.calls[0][3]; - expect(verifyOpts.skipVerifyBlockSignatures).not.toBe(true); - expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); - } - ); + expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); + expect(modules.chain.processBlock).not.toHaveBeenCalled(); + }); + }); + + describe("consensus validation strategies", () => { + it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( + "verifies all signatures for a non-local block with broadcast_validation=%s", + async (broadcastValidation) => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = 1; + signedBlock.message.proposerIndex = 2; + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(generateProtoBlock({slot: 0})); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + + const api = getBeaconBlockApi(modules); + await api.publishBlockV2({signedBlockContents: {signedBlock}, broadcastValidation}); + + expect(verifyBlocksInEpoch).toHaveBeenCalledOnce(); + const verifyOpts = vi.mocked(verifyBlocksInEpoch).mock.calls[0][3]; + expect(verifyOpts.skipVerifyBlockSignatures).not.toBe(true); + expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); + } + ); + }); }); diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts index 2375fb5e277e..5820ad63a615 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts @@ -40,42 +40,44 @@ describe("api - beacon - publishExecutionPayloadEnvelope", () => { modules.chain.processExecutionPayload = vi.fn(); }); - it("with consensus_and_equivocation, rejects an envelope for an observed proposer equivocation", async () => { - const signedBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); - const slot = signedBlock.message.slot; - const proposerIndex = signedBlock.message.proposerIndex; - const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(signedBlock.message)); - const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); - const payloadInput = PayloadEnvelopeInput.createFromBlock({ - blockRootHex: blockRoot, - block: signedBlock, - forkName: ForkName.gloas, - sampledColumns: [], - custodyColumns: [], - timeCreatedSec: 0, - daOutOfRange: false, - }); - const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - signedEnvelope.message.beaconBlockRoot = fromHex(blockRoot); - signedEnvelope.message.payload.slotNumber = slot; + describe("broadcast_validation=consensus_and_equivocation", () => { + it("rejects an envelope for an observed proposer equivocation", async () => { + const signedBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + const slot = signedBlock.message.slot; + const proposerIndex = signedBlock.message.proposerIndex; + const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(signedBlock.message)); + const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); + const payloadInput = PayloadEnvelopeInput.createFromBlock({ + blockRootHex: blockRoot, + block: signedBlock, + forkName: ForkName.gloas, + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: 0, + daOutOfRange: false, + }); + const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + signedEnvelope.message.beaconBlockRoot = fromHex(blockRoot); + signedEnvelope.message.payload.slotNumber = slot; - modules.forkChoice.getBlockHex.mockReturnValue(generateProtoBlock({slot})); - vi.mocked(modules.chain.seenPayloadEnvelopeInputCache.get).mockReturnValue(payloadInput); - modules.chain.regen.getBlockSlotState.mockResolvedValue({forkName: ForkName.gloas} as IBeaconStateView); - modules.chain.seenBlockProposers.add(slot, proposerIndex); - modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockRoot); - modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); + modules.forkChoice.getBlockHex.mockReturnValue(generateProtoBlock({slot})); + vi.mocked(modules.chain.seenPayloadEnvelopeInputCache.get).mockReturnValue(payloadInput); + modules.chain.regen.getBlockSlotState.mockResolvedValue({forkName: ForkName.gloas} as IBeaconStateView); + modules.chain.seenBlockProposers.add(slot, proposerIndex); + modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockRoot); + modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); - const api = getBeaconBlockApi(modules); - await expect( - api.publishExecutionPayloadEnvelope({ - signedEnvelopeOrContents: signedEnvelope, - broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, - }) - ).rejects.toThrow(/proposer equivocation/); + const api = getBeaconBlockApi(modules); + await expect( + api.publishExecutionPayloadEnvelope({ + signedEnvelopeOrContents: signedEnvelope, + broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, + }) + ).rejects.toThrow(/proposer equivocation/); - expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled(); - expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled(); - expect(payloadInput.hasPayloadEnvelope()).toBe(false); + expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled(); + expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled(); + expect(payloadInput.hasPayloadEnvelope()).toBe(false); + }); }); }); From 8607cf066aac9534ea0f1fc7de73d60ae05c7f9b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:39:27 +0100 Subject: [PATCH 16/24] group repeat proposal validation tests --- .../test/unit/chain/validation/block.test.ts | 214 +++++++++--------- 1 file changed, 108 insertions(+), 106 deletions(-) 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 0e76534ae30f..0d93f2b11dac 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -110,95 +110,126 @@ describe("gossip block validation", () => { ); }); - it("verifies the proposer signature and records a conflicting pre-Gloas block root before returning REPEAT_PROPOSAL", async () => { - const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); - chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); - chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); - - const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - const conflictingBlockRoot = toRootHex( - config.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) - ); + describe("repeat proposal handling", () => { + it("verifies the proposer signature before recording a conflicting pre-Gloas block root", async () => { + const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); + chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); + chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); + + const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex( + config.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) + ); - await expectRejectedWithLodestarError( - validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), - BlockErrorCode.REPEAT_PROPOSAL - ); + await expectRejectedWithLodestarError( + validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), + BlockErrorCode.REPEAT_PROPOSAL + ); - expect(verifySignature).toHaveBeenCalledOnce(); - expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ - conflictingBlockRoot, - ]); - }); + expect(verifySignature).toHaveBeenCalledOnce(); + expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + conflictingBlockRoot, + ]); + }); - it("does not verify another proposer signature after observing an equivocation", async () => { - const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); - chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); - chain.seenBlockProposers.observeBlockRoot( - job.message.slot, - job.message.proposerIndex, - toRootHex(Buffer.alloc(32, 1)) - ); - chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); + it("skips proposer signature verification after observing an equivocation", async () => { + const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); + chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); + chain.seenBlockProposers.observeBlockRoot( + job.message.slot, + job.message.proposerIndex, + toRootHex(Buffer.alloc(32, 1)) + ); + chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); - const additionalBlock = ssz.deneb.SignedBeaconBlock.clone(job); - additionalBlock.message.stateRoot = Buffer.alloc(32, 2); + const additionalBlock = ssz.deneb.SignedBeaconBlock.clone(job); + additionalBlock.message.stateRoot = Buffer.alloc(32, 2); - await expectRejectedWithLodestarError( - validateGossipBlock(config, chain, additionalBlock, ForkName.phase0), - BlockErrorCode.REPEAT_PROPOSAL - ); + await expectRejectedWithLodestarError( + validateGossipBlock(config, chain, additionalBlock, ForkName.phase0), + BlockErrorCode.REPEAT_PROPOSAL + ); - expect(verifySignature).not.toHaveBeenCalled(); - }); + expect(verifySignature).not.toHaveBeenCalled(); + }); - it("verifies the proposer signature and records a conflicting Gloas block root before returning REPEAT_PROPOSAL", async () => { - Object.defineProperty(chain, "config", {value: gloasConfig}); - const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); - gloasBlock.message.slot = clockSlot; - gloasBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(gloasConfig.getForkTypes(clockSlot).BeaconBlock.hashTreeRoot(gloasBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); - - const conflictingBlock = ssz.gloas.SignedBeaconBlock.clone(gloasBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - const conflictingBlockRoot = toRootHex( - gloasConfig.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) - ); + it("verifies the proposer signature before recording a conflicting Gloas block root", async () => { + Object.defineProperty(chain, "config", {value: gloasConfig}); + const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + gloasBlock.message.slot = clockSlot; + gloasBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(gloasConfig.getForkTypes(clockSlot).BeaconBlock.hashTreeRoot(gloasBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - await expectRejectedWithLodestarError( - validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), - BlockErrorCode.REPEAT_PROPOSAL - ); + const conflictingBlock = ssz.gloas.SignedBeaconBlock.clone(gloasBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex( + gloasConfig.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) + ); - expect(verifySignature).toHaveBeenCalledOnce(); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ - conflictingBlockRoot, - ]); - }); + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.REPEAT_PROPOSAL + ); - it("does not record a conflicting Gloas block root with an invalid proposer signature", async () => { - Object.defineProperty(chain, "config", {value: gloasConfig}); - const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); - gloasBlock.message.slot = clockSlot; - gloasBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(gloasConfig.getForkTypes(clockSlot).BeaconBlock.hashTreeRoot(gloasBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); - - const conflictingBlock = ssz.gloas.SignedBeaconBlock.clone(gloasBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - verifySignature.mockResolvedValue(false); + expect(verifySignature).toHaveBeenCalledOnce(); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + conflictingBlockRoot, + ]); + }); - await expectRejectedWithLodestarError( - validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), - BlockErrorCode.PROPOSAL_SIGNATURE_INVALID - ); + it("does not record a conflicting Gloas block root when the proposer signature is invalid", async () => { + Object.defineProperty(chain, "config", {value: gloasConfig}); + const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + gloasBlock.message.slot = clockSlot; + gloasBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(gloasConfig.getForkTypes(clockSlot).BeaconBlock.hashTreeRoot(gloasBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); + + const conflictingBlock = ssz.gloas.SignedBeaconBlock.clone(gloasBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + verifySignature.mockResolvedValue(false); + + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.PROPOSAL_SIGNATURE_INVALID + ); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); + }); + + it("detects another proposal that becomes known during the early-block delay", async () => { + const now = 1_000_000; + vi.useFakeTimers({now}); + + try { + Object.defineProperty(chain, "genesisTime", { + value: now / 1000 - clockSlot * (config.SLOT_DURATION_MS / 1000) + 0.1, + }); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + const state = new BeaconStateView(generateCachedState()); + regen.getPreState.mockResolvedValue(state); + vi.spyOn(state.cachedState.epochCtx, "getBeaconProposer").mockReturnValue(proposerIndex); + + const validation = expectRejectedWithLodestarError( + validateGossipBlock(config, chain, job, ForkName.phase0), + BlockErrorCode.REPEAT_PROPOSAL + ); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(1); + + chain.seenBlockProposers.add(clockSlot, proposerIndex); + await vi.advanceTimersByTimeAsync(100); + await validation; + } finally { + vi.useRealTimers(); + } + }); }); it("PARENT_BLOCK_UNKNOWN (fork-choice)", async () => { @@ -290,35 +321,6 @@ describe("gossip block validation", () => { await validateGossipBlock(config, chain, job, ForkName.phase0); }); - it("rejects a block when another proposal becomes known during the early-block delay", async () => { - const now = 1_000_000; - vi.useFakeTimers({now}); - - try { - Object.defineProperty(chain, "genesisTime", { - value: now / 1000 - clockSlot * (config.SLOT_DURATION_MS / 1000) + 0.1, - }); - forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); - forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); - const state = new BeaconStateView(generateCachedState()); - regen.getPreState.mockResolvedValue(state); - vi.spyOn(state.cachedState.epochCtx, "getBeaconProposer").mockReturnValue(proposerIndex); - - const validation = expectRejectedWithLodestarError( - validateGossipBlock(config, chain, job, ForkName.phase0), - BlockErrorCode.REPEAT_PROPOSAL - ); - await vi.advanceTimersByTimeAsync(0); - expect(vi.getTimerCount()).toBe(1); - - chain.seenBlockProposers.add(clockSlot, proposerIndex); - await vi.advanceTimersByTimeAsync(100); - await validation; - } finally { - vi.useRealTimers(); - } - }); - it("deneb - TOO_MANY_KZG_COMMITMENTS", async () => { // Fill up with kzg commitments block.body.blobKzgCommitments = Array.from( From 3fd4d10ffcf3184b24e617f38c6276c74711e807 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:43:36 +0100 Subject: [PATCH 17/24] share repeat proposal tests across forks --- .../test/unit/chain/validation/block.test.ts | 113 ++++++++---------- 1 file changed, 48 insertions(+), 65 deletions(-) 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 0d93f2b11dac..d436b32b83f6 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -111,27 +111,57 @@ describe("gossip block validation", () => { }); describe("repeat proposal handling", () => { - it("verifies the proposer signature before recording a conflicting pre-Gloas block root", async () => { - const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); - chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); - chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); + describe.each([ + [ForkName.phase0, config], + [ForkName.gloas, gloasConfig], + ] as const)("fork=%s", (forkName, forkConfig) => { + it("verifies the proposer signature before recording a conflicting block root", async () => { + Object.defineProperty(chain, "config", {value: forkConfig}); + const forkTypes = forkConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - const conflictingBlock = ssz.deneb.SignedBeaconBlock.clone(job); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - const conflictingBlockRoot = toRootHex( - config.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) - ); + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); - await expectRejectedWithLodestarError( - validateGossipBlock(config, chain, conflictingBlock, ForkName.phase0), - BlockErrorCode.REPEAT_PROPOSAL - ); + await expectRejectedWithLodestarError( + validateGossipBlock(forkConfig, chain, conflictingBlock, forkName), + BlockErrorCode.REPEAT_PROPOSAL + ); + + expect(verifySignature).toHaveBeenCalledOnce(); + expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + conflictingBlockRoot, + ]); + }); + + it("does not record a conflicting block root when the proposer signature is invalid", async () => { + Object.defineProperty(chain, "config", {value: forkConfig}); + const forkTypes = forkConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); + + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + verifySignature.mockResolvedValue(false); + + await expectRejectedWithLodestarError( + validateGossipBlock(forkConfig, chain, conflictingBlock, forkName), + BlockErrorCode.PROPOSAL_SIGNATURE_INVALID + ); - expect(verifySignature).toHaveBeenCalledOnce(); - expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ - conflictingBlockRoot, - ]); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); + }); }); it("skips proposer signature verification after observing an equivocation", async () => { @@ -155,53 +185,6 @@ describe("gossip block validation", () => { expect(verifySignature).not.toHaveBeenCalled(); }); - it("verifies the proposer signature before recording a conflicting Gloas block root", async () => { - Object.defineProperty(chain, "config", {value: gloasConfig}); - const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); - gloasBlock.message.slot = clockSlot; - gloasBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(gloasConfig.getForkTypes(clockSlot).BeaconBlock.hashTreeRoot(gloasBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); - - const conflictingBlock = ssz.gloas.SignedBeaconBlock.clone(gloasBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - const conflictingBlockRoot = toRootHex( - gloasConfig.getForkTypes(conflictingBlock.message.slot).BeaconBlock.hashTreeRoot(conflictingBlock.message) - ); - - await expectRejectedWithLodestarError( - validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), - BlockErrorCode.REPEAT_PROPOSAL - ); - - expect(verifySignature).toHaveBeenCalledOnce(); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ - conflictingBlockRoot, - ]); - }); - - it("does not record a conflicting Gloas block root when the proposer signature is invalid", async () => { - Object.defineProperty(chain, "config", {value: gloasConfig}); - const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); - gloasBlock.message.slot = clockSlot; - gloasBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(gloasConfig.getForkTypes(clockSlot).BeaconBlock.hashTreeRoot(gloasBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); - - const conflictingBlock = ssz.gloas.SignedBeaconBlock.clone(gloasBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - verifySignature.mockResolvedValue(false); - - await expectRejectedWithLodestarError( - validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), - BlockErrorCode.PROPOSAL_SIGNATURE_INVALID - ); - - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); - }); - it("detects another proposal that becomes known during the early-block delay", async () => { const now = 1_000_000; vi.useFakeTimers({now}); From 6654ca682423a1cda2c2038e8a550a57bc374d9a Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:45:47 +0100 Subject: [PATCH 18/24] simplify repeat proposal validation tests --- .../test/unit/chain/validation/block.test.ts | 85 +++++++++---------- 1 file changed, 40 insertions(+), 45 deletions(-) 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 d436b32b83f6..0040d07758c2 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -111,57 +111,52 @@ describe("gossip block validation", () => { }); describe("repeat proposal handling", () => { - describe.each([ - [ForkName.phase0, config], - [ForkName.gloas, gloasConfig], - ] as const)("fork=%s", (forkName, forkConfig) => { - it("verifies the proposer signature before recording a conflicting block root", async () => { - Object.defineProperty(chain, "config", {value: forkConfig}); - const forkTypes = forkConfig.getForkTypes(clockSlot); - const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = clockSlot; - signedBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); + it("verifies the proposer signature before recording a conflicting block root", async () => { + Object.defineProperty(chain, "config", {value: gloasConfig}); + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); + + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); - const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.REPEAT_PROPOSAL + ); - await expectRejectedWithLodestarError( - validateGossipBlock(forkConfig, chain, conflictingBlock, forkName), - BlockErrorCode.REPEAT_PROPOSAL - ); + expect(verifySignature).toHaveBeenCalledOnce(); + expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + conflictingBlockRoot, + ]); + }); - expect(verifySignature).toHaveBeenCalledOnce(); - expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ - conflictingBlockRoot, - ]); - }); - - it("does not record a conflicting block root when the proposer signature is invalid", async () => { - Object.defineProperty(chain, "config", {value: forkConfig}); - const forkTypes = forkConfig.getForkTypes(clockSlot); - const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = clockSlot; - signedBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); + it("does not record a conflicting block root when the proposer signature is invalid", async () => { + Object.defineProperty(chain, "config", {value: gloasConfig}); + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - verifySignature.mockResolvedValue(false); + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + verifySignature.mockResolvedValue(false); - await expectRejectedWithLodestarError( - validateGossipBlock(forkConfig, chain, conflictingBlock, forkName), - BlockErrorCode.PROPOSAL_SIGNATURE_INVALID - ); + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.PROPOSAL_SIGNATURE_INVALID + ); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); - }); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); }); it("skips proposer signature verification after observing an equivocation", async () => { From 8a5573910b7a67cce604e9eeb274330f1904f136 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:47:31 +0100 Subject: [PATCH 19/24] construct test chain with gloas config --- .../test/unit/chain/validation/block.test.ts | 92 ++++++++++--------- 1 file changed, 50 insertions(+), 42 deletions(-) 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 0040d07758c2..ed3b25d43f34 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -48,8 +48,8 @@ describe("gossip block validation", () => { Buffer.alloc(32, 0xaa) ); - beforeEach(() => { - chain = getMockedBeaconChain({config}); + function setupChain(chainConfig = config): void { + chain = getMockedBeaconChain({config: chainConfig}); vi.spyOn(chain.clock, "currentSlotWithGossipDisparity", "get").mockReturnValue(clockSlot); forkChoice = chain.forkChoice; forkChoice.getBlockHexDefaultStatus.mockReturnValue(null); @@ -74,6 +74,10 @@ describe("gossip block validation", () => { ).seenBlockProposers = new SeenBlockProposers(); job = {signature, message: block}; + } + + beforeEach(() => { + setupChain(); }); it("FUTURE_SLOT", async () => { @@ -111,52 +115,56 @@ describe("gossip block validation", () => { }); describe("repeat proposal handling", () => { - it("verifies the proposer signature before recording a conflicting block root", async () => { - Object.defineProperty(chain, "config", {value: gloasConfig}); - const forkTypes = gloasConfig.getForkTypes(clockSlot); - const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = clockSlot; - signedBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); - - const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); + describe("proposer signature validation", () => { + beforeEach(() => { + setupChain(gloasConfig); + }); + + it("records a conflicting block root after verifying the proposer signature", async () => { + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - await expectRejectedWithLodestarError( - validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), - BlockErrorCode.REPEAT_PROPOSAL - ); + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); - expect(verifySignature).toHaveBeenCalledOnce(); - expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ - conflictingBlockRoot, - ]); - }); + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.REPEAT_PROPOSAL + ); - it("does not record a conflicting block root when the proposer signature is invalid", async () => { - Object.defineProperty(chain, "config", {value: gloasConfig}); - const forkTypes = gloasConfig.getForkTypes(clockSlot); - const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = clockSlot; - signedBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); + expect(verifySignature).toHaveBeenCalledOnce(); + expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + conflictingBlockRoot, + ]); + }); + + it("does not record a conflicting block root when the proposer signature is invalid", async () => { + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - verifySignature.mockResolvedValue(false); + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + verifySignature.mockResolvedValue(false); - await expectRejectedWithLodestarError( - validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), - BlockErrorCode.PROPOSAL_SIGNATURE_INVALID - ); + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.PROPOSAL_SIGNATURE_INVALID + ); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); + }); }); it("skips proposer signature verification after observing an equivocation", async () => { From 64a632f52d8398ba9ea0e8de030b7afc20720e0a Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:51:32 +0100 Subject: [PATCH 20/24] use consistent repeat proposal test setup --- .../test/unit/chain/validation/block.test.ts | 120 +++++++++--------- 1 file changed, 61 insertions(+), 59 deletions(-) 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 ed3b25d43f34..c88a3f08074b 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -48,8 +48,8 @@ describe("gossip block validation", () => { Buffer.alloc(32, 0xaa) ); - function setupChain(chainConfig = config): void { - chain = getMockedBeaconChain({config: chainConfig}); + function setupChain(chainConfig = config, genesisTime = 0): void { + chain = getMockedBeaconChain({config: chainConfig, genesisTime}); vi.spyOn(chain.clock, "currentSlotWithGossipDisparity", "get").mockReturnValue(clockSlot); forkChoice = chain.forkChoice; forkChoice.getBlockHexDefaultStatus.mockReturnValue(null); @@ -115,73 +115,71 @@ describe("gossip block validation", () => { }); describe("repeat proposal handling", () => { - describe("proposer signature validation", () => { - beforeEach(() => { - setupChain(gloasConfig); - }); + beforeEach(() => { + setupChain(gloasConfig); + }); - it("records a conflicting block root after verifying the proposer signature", async () => { - const forkTypes = gloasConfig.getForkTypes(clockSlot); - const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = clockSlot; - signedBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); + it("records a conflicting block root after verifying the proposer signature", async () => { + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); - await expectRejectedWithLodestarError( - validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), - BlockErrorCode.REPEAT_PROPOSAL - ); + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.REPEAT_PROPOSAL + ); - expect(verifySignature).toHaveBeenCalledOnce(); - expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ - conflictingBlockRoot, - ]); - }); + expect(verifySignature).toHaveBeenCalledOnce(); + expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + conflictingBlockRoot, + ]); + }); - it("does not record a conflicting block root when the proposer signature is invalid", async () => { - const forkTypes = gloasConfig.getForkTypes(clockSlot); - const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = clockSlot; - signedBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); - chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); - chain.seenBlockProposers.add(clockSlot, proposerIndex); + it("does not record a conflicting block root when the proposer signature is invalid", async () => { + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); - const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - verifySignature.mockResolvedValue(false); + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + verifySignature.mockResolvedValue(false); - await expectRejectedWithLodestarError( - validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), - BlockErrorCode.PROPOSAL_SIGNATURE_INVALID - ); + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.PROPOSAL_SIGNATURE_INVALID + ); - expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); - }); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); }); it("skips proposer signature verification after observing an equivocation", async () => { - const blockRoot = toRootHex(config.getForkTypes(job.message.slot).BeaconBlock.hashTreeRoot(job.message)); - chain.seenBlockProposers.observeBlockRoot(job.message.slot, job.message.proposerIndex, blockRoot); - chain.seenBlockProposers.observeBlockRoot( - job.message.slot, - job.message.proposerIndex, - toRootHex(Buffer.alloc(32, 1)) - ); - chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); - - const additionalBlock = ssz.deneb.SignedBeaconBlock.clone(job); + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, toRootHex(Buffer.alloc(32, 1))); + chain.seenBlockProposers.add(clockSlot, proposerIndex); + + const additionalBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); additionalBlock.message.stateRoot = Buffer.alloc(32, 2); await expectRejectedWithLodestarError( - validateGossipBlock(config, chain, additionalBlock, ForkName.phase0), + validateGossipBlock(gloasConfig, chain, additionalBlock, ForkName.gloas), BlockErrorCode.REPEAT_PROPOSAL ); @@ -193,17 +191,21 @@ describe("gossip block validation", () => { vi.useFakeTimers({now}); try { - Object.defineProperty(chain, "genesisTime", { - value: now / 1000 - clockSlot * (config.SLOT_DURATION_MS / 1000) + 0.1, - }); + const genesisTime = now / 1000 - clockSlot * (gloasConfig.SLOT_DURATION_MS / 1000) + 0.1; + setupChain(gloasConfig, genesisTime); + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getBlockHexAndBlockHash.mockReturnValue({slot: clockSlot - 1} as ProtoBlock); const state = new BeaconStateView(generateCachedState()); regen.getPreState.mockResolvedValue(state); vi.spyOn(state.cachedState.epochCtx, "getBeaconProposer").mockReturnValue(proposerIndex); const validation = expectRejectedWithLodestarError( - validateGossipBlock(config, chain, job, ForkName.phase0), + validateGossipBlock(gloasConfig, chain, signedBlock, ForkName.gloas), BlockErrorCode.REPEAT_PROPOSAL ); await vi.advanceTimersByTimeAsync(0); From 76461b1f46c3198f4c66e3075eb5cfeebdb95aec Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:55:58 +0100 Subject: [PATCH 21/24] verify local proposer signatures for consensus validation --- .../src/api/impl/beacon/blocks/index.ts | 7 +- .../impl/beacon/blocks/publishBlock.test.ts | 72 +++++++++---------- 2 files changed, 38 insertions(+), 41 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index cb9d58e51234..556f72100325 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -286,10 +286,7 @@ export function getBeaconBlockApi({ // Non-local blocks had all signatures checked and their root observed by verifyBlocksInEpoch above. // Locally produced blocks skip that path, so verify their proposer signature and observe the root here. - if ( - broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation && - blockLocallyProduced - ) { + if (blockLocallyProduced) { try { await verifyBlockProposerSignature(chain, signedBlock, blockRoot); chain.seenBlockProposers.observeBlockRoot(slot, signedBlock.message.proposerIndex, blockRoot); @@ -302,7 +299,7 @@ export function getBeaconBlockApi({ chain.persistInvalidSszValue( chain.config.getForkTypes(slot).SignedBeaconBlock, signedBlock, - "api_reject_consensus_and_equivocation_failure" + "api_reject_consensus_failure" ); throw e; } diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index 2b3b2a0c4b6c..8aca7865a389 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -29,42 +29,6 @@ describe("api - beacon - publishBlockV2", () => { }); describe("broadcast_validation=consensus_and_equivocation", () => { - it("verifies the proposer signature and records the root before publishing a local block", async () => { - const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = 1; - signedBlock.message.proposerIndex = 2; - const blockRoot = toRootHex( - modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) - ); - const blockInput = BlockInputPreData.createFromBlock({ - forkName: ForkName.phase0, - block: signedBlock, - blockRootHex: blockRoot, - source: BlockInputSource.api, - seenTimestampSec: 0, - daOutOfRange: false, - }); - vi.spyOn(modules.chain.blockProductionCache, "has").mockReturnValue(true); - modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); - - const api = getBeaconBlockApi(modules); - await api.publishBlockV2({ - signedBlockContents: {signedBlock}, - broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, - }); - - expect(modules.chain.bls.verifySignatureSets).toHaveBeenCalledOnce(); - expect( - modules.chain.seenBlockProposers.hasBlockRoot( - signedBlock.message.slot, - signedBlock.message.proposerIndex, - blockRoot - ) - ).toBe(true); - expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); - expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); - }); - it("does not publish or import a non-local block that conflicts with an observed proposal", async () => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); signedBlock.message.slot = 1; @@ -104,6 +68,42 @@ describe("api - beacon - publishBlockV2", () => { }); describe("consensus validation strategies", () => { + it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( + "verifies the proposer signature and records the root before publishing a local block with broadcast_validation=%s", + async (broadcastValidation) => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = 1; + signedBlock.message.proposerIndex = 2; + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + vi.spyOn(modules.chain.blockProductionCache, "has").mockReturnValue(true); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + + const api = getBeaconBlockApi(modules); + await api.publishBlockV2({signedBlockContents: {signedBlock}, broadcastValidation}); + + expect(modules.chain.bls.verifySignatureSets).toHaveBeenCalledOnce(); + expect( + modules.chain.seenBlockProposers.hasBlockRoot( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + blockRoot + ) + ).toBe(true); + expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); + expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); + } + ); + it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( "verifies all signatures for a non-local block with broadcast_validation=%s", async (broadcastValidation) => { From 2c2d723b0d6273a0521b47a46cb2c2e2921369df Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 18:59:00 +0100 Subject: [PATCH 22/24] clarify local block signature validation --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 556f72100325..d83aba4d39f6 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -284,8 +284,9 @@ export function getBeaconBlockApi({ chain.logger.debug("Consensus validated while publishing block", valLogMeta); - // Non-local blocks had all signatures checked and their root observed by verifyBlocksInEpoch above. - // Locally produced blocks skip that path, so verify their proposer signature and observe the root here. + // Non-local blocks had their proposer and block-body signatures checked by verifyBlocksInEpoch above + // Locally produced blocks already passed block production validation, but receive their proposer signature later + // Verify that signature and observe the block root here if (blockLocallyProduced) { try { await verifyBlockProposerSignature(chain, signedBlock, blockRoot); From 7360da24164e61929e2d8fa76fc7c83b2f108c1d Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 19:00:26 +0100 Subject: [PATCH 23/24] refine local signature validation comment --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index d83aba4d39f6..a12b718fd231 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -285,7 +285,7 @@ export function getBeaconBlockApi({ chain.logger.debug("Consensus validated while publishing block", valLogMeta); // Non-local blocks had their proposer and block-body signatures checked by verifyBlocksInEpoch above - // Locally produced blocks already passed block production validation, but receive their proposer signature later + // Locally produced blocks already passed block production validation, so only their proposer signature is unchecked // Verify that signature and observe the block root here if (blockLocallyProduced) { try { From 308d3afa78e49b451cb73f64aa35d194e167ccad Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 5 Aug 2026 19:02:00 +0100 Subject: [PATCH 24/24] restore generic signed block test type --- packages/beacon-node/test/unit/chain/validation/block.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c88a3f08074b..59bb0280c635 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -4,7 +4,7 @@ import {config as configDef} from "@lodestar/config/default"; import {ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, ForkPostDeneb, ForkPreFulu} from "@lodestar/params"; import {BeaconStateView} from "@lodestar/state-transition"; -import {SignedBeaconBlock, deneb, ssz} from "@lodestar/types"; +import {SignedBeaconBlock, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; import {QueuedStateRegenerator} from "../../../../src/chain/regen/index.js"; @@ -20,7 +20,7 @@ describe("gossip block validation", () => { let forkChoice: MockedBeaconChain["forkChoice"]; let regen: Mocked; let verifySignature: Mock<() => boolean>; - let job: deneb.SignedBeaconBlock; + let job: SignedBeaconBlock; const proposerIndex = 0; const clockSlot = 32; const block = ssz.deneb.BeaconBlock.defaultValue();