From e4ba5259e221eb4e9d79c501c430074b5a3cae31 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 6 Aug 2026 14:40:25 +0100 Subject: [PATCH 1/6] feat: produce proposer slashings from observed equivocations --- .../src/api/impl/beacon/blocks/index.ts | 7 ++- .../src/chain/blocks/verifyBlock.ts | 22 +++++--- packages/beacon-node/src/chain/chain.ts | 52 +++++++++++++++++++ packages/beacon-node/src/chain/emitter.ts | 7 +++ packages/beacon-node/src/chain/interface.ts | 3 ++ .../src/chain/seenCache/seenBlockProposers.ts | 38 +++++++++++--- .../beacon-node/src/chain/validation/block.ts | 12 +++-- .../src/metrics/metrics/lodestar.ts | 4 ++ packages/beacon-node/src/network/network.ts | 17 +++++- .../src/network/processor/gossipHandlers.ts | 7 +++ .../test/mocks/mockedBeaconChain.ts | 1 + .../impl/beacon/blocks/publishBlock.test.ts | 3 +- .../publishExecutionPayloadEnvelope.test.ts | 14 ++++- .../unit/chain/blocks/processBlocks.test.ts | 14 ++++- .../seenCache/seenBlockProposers.test.ts | 41 ++++++++++++--- .../test/unit/chain/validation/block.test.ts | 34 ++++++++++-- 16 files changed, 239 insertions(+), 37 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 a12b718fd231..6b0fb515c9e0 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -290,7 +290,12 @@ export function getBeaconBlockApi({ if (blockLocallyProduced) { try { await verifyBlockProposerSignature(chain, signedBlock, blockRoot); - chain.seenBlockProposers.observeBlockRoot(slot, signedBlock.message.proposerIndex, blockRoot); + chain.seenBlockProposers.observeBlockRoot( + slot, + signedBlock.message.proposerIndex, + blockRoot, + signedBlockToSignedHeader(config, signedBlock) + ); } catch (e) { chain.logger.error( "Proposer signature validation failed while publishing the block", diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index cd7ff6d5892d..71edca41c89e 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -1,7 +1,12 @@ 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 { + DataAvailabilityStatus, + IBeaconStateView, + computeEpochAtSlot, + signedBlockToSignedHeader, +} from "@lodestar/state-transition"; +import {IndexedAttestation, Slot, deneb, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {getBlobKzgCommitments} from "../../util/dataColumns.js"; import type {BeaconChain} from "../chain.js"; @@ -207,10 +212,15 @@ export async function verifyBlocksInEpoch( if (opts.skipVerifyBlockSignatures !== true) { for (const block of blocks) { - const blockRoot = toRootHex( - this.config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message) - ); - this.seenBlockProposers.observeBlockRoot(block.message.slot, block.message.proposerIndex, blockRoot); + const {slot, proposerIndex} = block.message; + const signedBlockHeader = signedBlockToSignedHeader(this.config, block); + const blockRoot = toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(signedBlockHeader.message)); + this.seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockRoot, signedBlockHeader); + // Only produce a slashing while importing the block. A block that is verified before it is published + // must not be treated as equivocation evidence since it may never be seen by the network + if (opts.verifyOnly !== true && this.seenBlockProposers.isEquivocating(slot, proposerIndex)) { + this.processProposerEquivocation(slot, proposerIndex); + } } } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 8be3c2c644f5..39eaafb2c200 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -130,6 +130,7 @@ import {CPStateDatastore} from "./stateCache/datastore/types.js"; import {FIFOBlockStateCache} from "./stateCache/fifoBlockStateCache.js"; import {PersistentCheckpointStateCache} from "./stateCache/persistentCheckpointsCache.js"; import {CheckpointStateCache} from "./stateCache/types.js"; +import {validateApiProposerSlashing} from "./validation/proposerSlashing.js"; import {ValidatorMonitor} from "./validatorMonitor.js"; /** @@ -200,6 +201,8 @@ export class BeaconChain implements IBeaconChain { readonly seenAggregatedAttestations: SeenAggregatedAttestations; readonly seenExecutionPayloadBids = new SeenExecutionPayloadBids(); readonly seenBlockProposers = new SeenBlockProposers(); + /** Proposer indexes with an in-flight proposer slashing production */ + private readonly producingProposerSlashing = new Set(); readonly seenSyncCommitteeMessages = new SeenSyncCommitteeMessages(); readonly seenContributionAndProof: SeenContributionAndProof; readonly seenAttestationDatas: SeenAttestationDatas; @@ -1163,6 +1166,55 @@ export class BeaconChain implements IBeaconChain { return this.payloadEnvelopeProcessor.processPayloadEnvelopeJob(payloadInput, opts); } + processProposerEquivocation(blockSlot: Slot, proposerIndex: ValidatorIndex): void { + this.produceProposerSlashing(blockSlot, proposerIndex).catch((e) => { + this.logger.error("Error producing proposer slashing", {slot: blockSlot, proposerIndex}, e as Error); + }); + } + + /** Produce a proposer slashing from two conflicting signed block headers observed for the same slot and proposer */ + private async produceProposerSlashing(blockSlot: Slot, proposerIndex: ValidatorIndex): Promise { + if (this.opPool.hasSeenProposerSlashing(proposerIndex) || this.producingProposerSlashing.has(proposerIndex)) { + return; + } + + const headers = this.seenBlockProposers.getEquivocationHeaders(blockSlot, proposerIndex); + if (headers === null) { + return; + } + + this.producingProposerSlashing.add(proposerIndex); + try { + const [header1, header2] = headers; + // ProposerSlashing uses the bigint variant of the signed block header, see types package for details + const proposerSlashing: phase0.ProposerSlashing = { + signedHeader1: { + message: {...header1.message, slot: BigInt(header1.message.slot)}, + signature: header1.signature, + }, + signedHeader2: { + message: {...header2.message, slot: BigInt(header2.message.slot)}, + signature: header2.signature, + }, + }; + + try { + await validateApiProposerSlashing(this, proposerSlashing); + } catch (e) { + this.logger.debug("Produced proposer slashing is not valid", {slot: blockSlot, proposerIndex}, e as Error); + return; + } + + this.opPool.insertProposerSlashing(proposerSlashing); + this.emitter.emit(routes.events.EventType.proposerSlashing, proposerSlashing); + this.emitter.emit(ChainEvent.publishProposerSlashing, proposerSlashing); + this.metrics?.opPool.proposerSlashingsProduced.inc(); + this.logger.info("Produced proposer slashing from observed equivocation", {slot: blockSlot, proposerIndex}); + } finally { + this.producingProposerSlashing.delete(proposerIndex); + } + } + getStatus(): Status { const head = this.forkChoice.getHead(); const finalizedCheckpoint = this.forkChoice.getFinalizedCheckpoint(); diff --git a/packages/beacon-node/src/chain/emitter.ts b/packages/beacon-node/src/chain/emitter.ts index 9900f8d00c44..9fe966a25d1b 100644 --- a/packages/beacon-node/src/chain/emitter.ts +++ b/packages/beacon-node/src/chain/emitter.ts @@ -50,6 +50,11 @@ export enum ChainEvent { * and are ready to be published. */ publishBlobSidecars = "publishBlobSidecars", + /** + * This event signals that a proposer slashing has been produced from an observed equivocation + * and is ready to be published. + */ + publishProposerSlashing = "publishProposerSlashing", /** * Trigger an update of status so reqresp by peers have current earliestAvailableSlot */ @@ -110,6 +115,8 @@ export type IChainEvents = ApiEvents & { [ChainEvent.publishBlobSidecars]: (sidecars: deneb.BlobSidecar[]) => void; + [ChainEvent.publishProposerSlashing]: (proposerSlashing: phase0.ProposerSlashing) => void; + [ChainEvent.updateStatus]: () => void; // Sync events that are chain->chain. Initiated from network requests but do not cross the network diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 74fc30821c36..8d4610cffb67 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -262,6 +262,9 @@ export interface IBeaconChain { /** Process execution payload envelope: verify, import to fork choice, and persist to DB */ processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; + /** Produce and publish a proposer slashing from an observed equivocation. Does not throw, only logs errors */ + processProposerEquivocation(blockSlot: Slot, proposerIndex: ValidatorIndex): void; + getStatus(): Status; recomputeForkChoiceHead(caller: ForkchoiceCaller): ProtoBlock; diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 5f3591d48a1e..a2d4aafa6a1d 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -1,5 +1,5 @@ import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; +import {Epoch, RootHex, Slot, ValidatorIndex, phase0} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; /** Two distinct block roots signed by the same proposer for the same slot are sufficient to establish an equivocation */ @@ -13,13 +13,17 @@ const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; * 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 signed block header of each observed root is kept so a proposer slashing can be produced from the two + * conflicting headers once an equivocation is established. + * * 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()); - private readonly blockRootsBySlot = new MapDef>>( - () => new MapDef>(() => new Set()) - ); + private readonly blockRootsBySlot = new MapDef< + Slot, + MapDef> + >(() => new MapDef>(() => new Map())); private finalizedSlot: Slot = 0; isKnown(blockSlot: Slot, proposerIndex: ValidatorIndex): boolean { @@ -36,18 +40,36 @@ export class SeenBlockProposers { 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); + return roots === undefined ? [] : Array.from(roots.keys()).filter((root) => root !== blockRoot); + } + + /** Return the two signed block headers that establish an equivocation, or null if there is none */ + getEquivocationHeaders( + blockSlot: Slot, + proposerIndex: ValidatorIndex + ): [phase0.SignedBeaconBlockHeader, phase0.SignedBeaconBlockHeader] | null { + const headers = this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex); + if (headers === undefined || headers.size < MAX_BLOCK_ROOTS_PER_PROPOSAL) { + return null; + } + const [signedHeader1, signedHeader2] = headers.values(); + return [signedHeader1, signedHeader2]; } /** Record a block only after its proposer signature has been verified */ - observeBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void { + observeBlockRoot( + blockSlot: Slot, + proposerIndex: ValidatorIndex, + blockRoot: RootHex, + signedBlockHeader: phase0.SignedBeaconBlockHeader + ): void { if (blockSlot < this.finalizedSlot) { throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); } const blockRoots = this.blockRootsBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex); - if (blockRoots.size < MAX_BLOCK_ROOTS_PER_PROPOSAL) { - blockRoots.add(blockRoot); + if (blockRoots.size < MAX_BLOCK_ROOTS_PER_PROPOSAL && !blockRoots.has(blockRoot)) { + blockRoots.set(blockRoot, signedBlockHeader); } } diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 6b9f7aadaa69..11ec46526e55 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -23,8 +23,9 @@ import { getBlockProposerSignatureSet, isExecutionBlockBodyType, isStatePostBellatrix, + signedBlockToSignedHeader, } from "@lodestar/state-transition"; -import {RootHex, SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; +import {RootHex, SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock, ssz} from "@lodestar/types"; import {byteArrayEquals, sleep, toRootHex} from "@lodestar/utils"; import {BlockErrorCode, BlockGossipError, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../interface.js"; @@ -75,7 +76,10 @@ export async function validateGossipBlock( // reboot if the `observed_block_producers` cache is empty. In that case, without this // check, we will load the parent and state from disk only to find out later that we // already know this block. - const blockRoot = toRootHex(config.getForkTypes(blockSlot).BeaconBlock.hashTreeRoot(block)); + // A block's hash tree root is identical to its header's, so the root is derived from the header + // which is also used as potential equivocation evidence + const signedBlockHeader = signedBlockToSignedHeader(config, signedBlock); + const blockRoot = toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(signedBlockHeader.message)); if (chain.forkChoice.getBlockHexDefaultStatus(blockRoot) !== null) { throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.ALREADY_KNOWN, root: blockRoot}); } @@ -89,7 +93,7 @@ export async function validateGossipBlock( if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { if (!hasBlockRoot && !chain.seenBlockProposers.isEquivocating(blockSlot, proposerIndex)) { await verifyBlockProposerSignature(chain, signedBlock, blockRoot, {verifyOnMainThread: false}); - chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot, signedBlockHeader); } throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); } @@ -276,7 +280,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); - chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot, signedBlockHeader); // [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 diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index f1a13b33064b..596eb7c6c67c 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -1179,6 +1179,10 @@ export function createLodestarMetrics( name: "lodestar_oppool_proposer_slashing_pool_size", help: "Current size of the ProposerSlashingPool", }), + proposerSlashingsProduced: register.counter({ + name: "lodestar_oppool_proposer_slashings_produced_total", + help: "Total number of proposer slashings produced from observed equivocations", + }), voluntaryExitPoolSize: register.gauge({ name: "lodestar_oppool_voluntary_exit_pool_size", help: "Current size of the VoluntaryExitPool", diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index d10b879ab498..b3bc97695e50 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -147,6 +147,7 @@ export class Network implements INetwork { this.chain.emitter.on(ChainEvent.updateTargetCustodyGroupCount, this.onTargetGroupCountUpdated); this.chain.emitter.on(ChainEvent.publishDataColumns, this.onPublishDataColumns); this.chain.emitter.on(ChainEvent.publishBlobSidecars, this.onPublishBlobSidecars); + this.chain.emitter.on(ChainEvent.publishProposerSlashing, this.onPublishProposerSlashing); this.chain.emitter.on(ChainEvent.updateStatus, this.onUpdateStatus); } @@ -244,6 +245,7 @@ export class Network implements INetwork { this.chain.emitter.off(ChainEvent.updateTargetCustodyGroupCount, this.onTargetGroupCountUpdated); this.chain.emitter.off(ChainEvent.publishDataColumns, this.onPublishDataColumns); this.chain.emitter.off(ChainEvent.publishBlobSidecars, this.onPublishBlobSidecars); + this.chain.emitter.off(ChainEvent.publishProposerSlashing, this.onPublishProposerSlashing); this.chain.emitter.off(ChainEvent.updateStatus, this.onUpdateStatus); await this.core.close(); @@ -455,7 +457,8 @@ export class Network implements INetwork { return this.publishGossip( {type: GossipType.proposer_slashing, boundary}, - proposerSlashing + proposerSlashing, + {ignoreDuplicatePublishError: true} ); } @@ -877,6 +880,18 @@ export class Network implements INetwork { return promiseAllMaybeAsync(sidecars.map((sidecar) => () => this.publishBlobSidecar(sidecar))); }; + private onPublishProposerSlashing = async (proposerSlashing: phase0.ProposerSlashing): Promise => { + try { + await this.publishProposerSlashing(proposerSlashing); + } catch (e) { + this.logger.error( + "Error publishing proposer slashing", + {proposerIndex: proposerSlashing.signedHeader1.message.proposerIndex}, + e as Error + ); + } + }; + private onUpdateStatus = async (): Promise => { await this.core.updateStatus(this.chain.getStatus()); }; diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index b57b7a33bdae..6f79da94b2db 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -257,6 +257,13 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand chain.seenPayloadEnvelopeInputCache.prune(blockRootHex); } throw e; + } finally { + // The block received from the network may have established an equivocation, either by conflicting + // with a previously observed block root (REPEAT_PROPOSAL) or with a root observed during validation + const proposerIndex = signedBlock.message.proposerIndex; + if (chain.seenBlockProposers.isEquivocating(slot, proposerIndex)) { + chain.processProposerEquivocation(slot, proposerIndex); + } } } diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 0c1f9b77bbfe..fcff0b9a21b3 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -189,6 +189,7 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { getStateBySlot: vi.fn(), updateBuilderStatus: vi.fn(), processBlock: vi.fn(), + processProposerEquivocation: vi.fn(), persistInvalidSszValue: vi.fn(), regenStateForAttestationVerification: vi.fn(), close: vi.fn(), 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 8aca7865a389..e846e50510e5 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 @@ -50,7 +50,8 @@ describe("api - beacon - publishBlockV2", () => { modules.chain.seenBlockProposers.observeBlockRoot( signedBlock.message.slot, signedBlock.message.proposerIndex, - conflictingBlockRoot + conflictingBlockRoot, + ssz.phase0.SignedBeaconBlockHeader.defaultValue() ); const api = getBeaconBlockApi(modules); 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 5820ad63a615..a3207a9bceed 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 @@ -64,8 +64,18 @@ describe("api - beacon - publishExecutionPayloadEnvelope", () => { 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.chain.seenBlockProposers.observeBlockRoot( + slot, + proposerIndex, + blockRoot, + ssz.phase0.SignedBeaconBlockHeader.defaultValue() + ); + modules.chain.seenBlockProposers.observeBlockRoot( + slot, + proposerIndex, + conflictingBlockRoot, + ssz.phase0.SignedBeaconBlockHeader.defaultValue() + ); const api = getBeaconBlockApi(modules); await expect( 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 bdededb09289..d72a340d218f 100644 --- a/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts @@ -68,7 +68,12 @@ describe("chain / blocks / processBlocks", () => { errorMessage: "test execution error", }); vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { - seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); + seenBlockProposers.observeBlockRoot( + slot, + proposerIndex, + blockInput.blockRootHex, + ssz.phase0.SignedBeaconBlockHeader.defaultValue() + ); return { postStates: [{forkName: ForkName.deneb} as IBeaconStateView], proposerBalanceDeltas: [0], @@ -88,7 +93,12 @@ describe("chain / blocks / processBlocks", () => { it("marks a proposal as known after execution verification succeeds", async () => { vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { - seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); + seenBlockProposers.observeBlockRoot( + slot, + proposerIndex, + blockInput.blockRootHex, + ssz.phase0.SignedBeaconBlockHeader.defaultValue() + ); return { postStates: [{forkName: ForkName.deneb} as IBeaconStateView], proposerBalanceDeltas: [0], 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 5d65c34b10a4..105a7dfbe640 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts @@ -1,4 +1,5 @@ import {describe, expect, it} from "vitest"; +import {phase0, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {SeenBlockProposers} from "../../../../src/chain/seenCache/seenBlockProposers.js"; @@ -9,29 +10,43 @@ describe("SeenBlockProposers", () => { const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 2)); const additionalBlockRoot = toRootHex(Buffer.alloc(32, 3)); + function signedHeader(seed: number): phase0.SignedBeaconBlockHeader { + const header = ssz.phase0.SignedBeaconBlockHeader.defaultValue(); + header.message.slot = slot; + header.message.proposerIndex = proposerIndex; + header.message.bodyRoot = Buffer.alloc(32, seed); + return header; + } + + const header1 = signedHeader(1); + const header2 = signedHeader(2); + const header3 = signedHeader(3); + it("tracks observed block roots separately from known proposals", () => { const cache = new SeenBlockProposers(); - cache.observeBlockRoot(slot, proposerIndex, blockRoot); + cache.observeBlockRoot(slot, proposerIndex, blockRoot, header1); expect(cache.isKnown(slot, proposerIndex)).toBe(false); expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(true); + expect(cache.getEquivocationHeaders(slot, proposerIndex)).toBe(null); cache.add(slot, proposerIndex); - cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); + cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot, header2); 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]); + expect(cache.getEquivocationHeaders(slot, proposerIndex)).toEqual([header1, header2]); }); it("stores at most two roots per slot and proposer", () => { const cache = new SeenBlockProposers(); - cache.observeBlockRoot(slot, proposerIndex, blockRoot); - cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); - cache.observeBlockRoot(slot, proposerIndex, additionalBlockRoot); + cache.observeBlockRoot(slot, proposerIndex, blockRoot, header1); + cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot, header2); + cache.observeBlockRoot(slot, proposerIndex, additionalBlockRoot, header3); expect(cache.isEquivocating(slot, proposerIndex)).toBe(true); expect(cache.hasBlockRoot(slot, proposerIndex, additionalBlockRoot)).toBe(false); @@ -39,11 +54,22 @@ describe("SeenBlockProposers", () => { blockRoot, conflictingBlockRoot, ]); + expect(cache.getEquivocationHeaders(slot, proposerIndex)).toEqual([header1, header2]); + }); + + it("does not overwrite the header of an already observed root", () => { + const cache = new SeenBlockProposers(); + + cache.observeBlockRoot(slot, proposerIndex, blockRoot, header1); + cache.observeBlockRoot(slot, proposerIndex, blockRoot, header3); + cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot, header2); + + expect(cache.getEquivocationHeaders(slot, proposerIndex)).toEqual([header1, header2]); }); it("prunes known proposals and observed roots", () => { const cache = new SeenBlockProposers(); - cache.observeBlockRoot(slot, proposerIndex, blockRoot); + cache.observeBlockRoot(slot, proposerIndex, blockRoot, header1); cache.add(slot, proposerIndex); cache.prune(slot + 1); @@ -51,6 +77,7 @@ describe("SeenBlockProposers", () => { expect(cache.isKnown(slot, proposerIndex)).toBe(false); expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(false); expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([]); + expect(cache.getEquivocationHeaders(slot, proposerIndex)).toBe(null); }); it("rejects updates for slots before the finalized slot", () => { @@ -58,7 +85,7 @@ describe("SeenBlockProposers", () => { cache.prune(slot + 1); expect(() => cache.add(slot, proposerIndex)).toThrow(`blockSlot ${slot} < finalizedSlot ${slot + 1}`); - expect(() => cache.observeBlockRoot(slot, proposerIndex, blockRoot)).toThrow( + expect(() => cache.observeBlockRoot(slot, proposerIndex, blockRoot, header1)).toThrow( `blockSlot ${slot} < finalizedSlot ${slot + 1}` ); expect(cache.isKnown(slot, proposerIndex)).toBe(false); 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 59bb0280c635..60b499ce583d 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -3,7 +3,7 @@ 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 {BeaconStateView, signedBlockToSignedHeader} from "@lodestar/state-transition"; import {SignedBeaconBlock, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; @@ -125,7 +125,12 @@ describe("gossip block validation", () => { 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, + blockRoot, + signedBlockToSignedHeader(gloasConfig, signedBlock) + ); chain.seenBlockProposers.add(clockSlot, proposerIndex); const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); @@ -142,6 +147,10 @@ describe("gossip block validation", () => { expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ conflictingBlockRoot, ]); + const equivocationHeaders = chain.seenBlockProposers.getEquivocationHeaders(clockSlot, proposerIndex); + expect( + equivocationHeaders?.map((header) => toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(header.message))) + ).toEqual([blockRoot, conflictingBlockRoot]); }); it("does not record a conflicting block root when the proposer signature is invalid", async () => { @@ -150,7 +159,12 @@ describe("gossip block validation", () => { 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, + blockRoot, + signedBlockToSignedHeader(gloasConfig, signedBlock) + ); chain.seenBlockProposers.add(clockSlot, proposerIndex); const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); @@ -171,8 +185,18 @@ describe("gossip block validation", () => { 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.observeBlockRoot( + clockSlot, + proposerIndex, + blockRoot, + signedBlockToSignedHeader(gloasConfig, signedBlock) + ); + chain.seenBlockProposers.observeBlockRoot( + clockSlot, + proposerIndex, + toRootHex(Buffer.alloc(32, 1)), + ssz.phase0.SignedBeaconBlockHeader.defaultValue() + ); chain.seenBlockProposers.add(clockSlot, proposerIndex); const additionalBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); From ba1732279bffe0654f550071532a5ec1ba8b1bdb Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 7 Aug 2026 14:47:44 +0100 Subject: [PATCH 2/6] feat: add chain.disableProposerSlashings flag --- packages/beacon-node/src/chain/chain.ts | 6 +++++- packages/beacon-node/src/chain/options.ts | 3 +++ .../src/chain/produceBlock/produceBlockBody.ts | 2 +- packages/cli/src/options/beaconNodeOptions/chain.ts | 11 +++++++++++ .../cli/test/unit/options/beaconNodeOptions.test.ts | 2 ++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 39eaafb2c200..88c27f6da349 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1174,7 +1174,11 @@ export class BeaconChain implements IBeaconChain { /** Produce a proposer slashing from two conflicting signed block headers observed for the same slot and proposer */ private async produceProposerSlashing(blockSlot: Slot, proposerIndex: ValidatorIndex): Promise { - if (this.opPool.hasSeenProposerSlashing(proposerIndex) || this.producingProposerSlashing.has(proposerIndex)) { + if ( + this.opts.disableProposerSlashings === true || + this.opPool.hasSeenProposerSlashing(proposerIndex) || + this.producingProposerSlashing.has(proposerIndex) + ) { return; } diff --git a/packages/beacon-node/src/chain/options.ts b/packages/beacon-node/src/chain/options.ts index 844aa8e8baa6..1400c63be4cf 100644 --- a/packages/beacon-node/src/chain/options.ts +++ b/packages/beacon-node/src/chain/options.ts @@ -37,6 +37,8 @@ export type IChainOptions = BlockProcessOpts & suggestedFeeRecipient: string; graffitiAppend?: boolean; maxSkipSlots?: number; + /** Do not produce proposer slashings from observed equivocations and do not include proposer slashings in produced blocks */ + disableProposerSlashings?: boolean; /** Ensure blobs returned by the execution engine are valid */ sanityCheckExecutionEngineBlobs?: boolean; /** Max number of produced blobs by local validators to cache */ @@ -122,6 +124,7 @@ export const defaultChainOptions: IChainOptions = { // for gossip block validation, it's unlikely we see a reorg with 32 slots // for attestation validation, having this value ensures we don't have to regen states most of the time maxSkipSlots: 32, + disableProposerSlashings: false, broadcastValidationStrictness: "warn", // should be less than or equal to MIN_SIGNATURE_SETS_TO_BATCH_VERIFY // batching too much may block the I/O thread so if useWorker=false, suggest this value to be 32 diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 20592fa5cc12..577acd92e2a5 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -1037,7 +1037,7 @@ export async function produceCommonBlockBody( graffiti, // Eth1 data voting is no longer required since electra eth1Data: currentState.eth1Data, - proposerSlashings, + proposerSlashings: this.opts.disableProposerSlashings === true ? [] : proposerSlashings, attesterSlashings, attestations, // Since electra, deposits are processed by the execution layer, diff --git a/packages/cli/src/options/beaconNodeOptions/chain.ts b/packages/cli/src/options/beaconNodeOptions/chain.ts index a43dca5a39b2..1cb436866161 100644 --- a/packages/cli/src/options/beaconNodeOptions/chain.ts +++ b/packages/cli/src/options/beaconNodeOptions/chain.ts @@ -26,6 +26,7 @@ export type ChainArgs = { "chain.fastConfirmation"?: boolean; "chain.assertCorrectProgressiveBalances"?: boolean; "chain.maxSkipSlots"?: number; + "chain.disableProposerSlashings"?: boolean; emitPayloadAttributes?: boolean; broadcastValidationStrictness?: string; "chain.minSameMessageSignatureSetsToBatch"?: number; @@ -67,6 +68,7 @@ export function parseArgs(args: ChainArgs & CircuitBreakerArgs): IBeaconNodeOpti fastConfirmation: args["chain.fastConfirmation"], assertCorrectProgressiveBalances: args["chain.assertCorrectProgressiveBalances"], maxSkipSlots: args["chain.maxSkipSlots"], + disableProposerSlashings: args["chain.disableProposerSlashings"], emitPayloadAttributes: args.emitPayloadAttributes, broadcastValidationStrictness: args.broadcastValidationStrictness, minSameMessageSignatureSetsToBatch: @@ -242,6 +244,15 @@ Will double processing times. Use only for debugging purposes.", group: "chain", }, + "chain.disableProposerSlashings": { + hidden: true, + type: "boolean", + description: + "Do not produce proposer slashings from observed equivocations and do not include proposer slashings in produced blocks", + defaultDescription: String(defaultOptions.chain.disableProposerSlashings), + group: "chain", + }, + "chain.assertCorrectProgressiveBalances": { hidden: true, description: "Enable asserting the progressive balances", diff --git a/packages/cli/test/unit/options/beaconNodeOptions.test.ts b/packages/cli/test/unit/options/beaconNodeOptions.test.ts index 3080cb0765a0..598880754baf 100644 --- a/packages/cli/test/unit/options/beaconNodeOptions.test.ts +++ b/packages/cli/test/unit/options/beaconNodeOptions.test.ts @@ -32,6 +32,7 @@ describe("options / beaconNodeOptions", () => { suggestedFeeRecipient: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "chain.assertCorrectProgressiveBalances": true, "chain.maxSkipSlots": 100, + "chain.disableProposerSlashings": true, "chain.archiveStateEpochFrequency": 1024, "chain.minSameMessageSignatureSetsToBatch": 32, "chain.maxShufflingCacheEpochs": 100, @@ -133,6 +134,7 @@ describe("options / beaconNodeOptions", () => { suggestedFeeRecipient: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", assertCorrectProgressiveBalances: true, maxSkipSlots: 100, + disableProposerSlashings: true, archiveStateEpochFrequency: 1024, emitPayloadAttributes: false, minSameMessageSignatureSetsToBatch: 32, From 17b1cf1eba52277c0a0ef844dbb44e93d4652e38 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 7 Aug 2026 15:11:20 +0100 Subject: [PATCH 3/6] fix: add missing chain mocks in gossip handler tests --- .../test/unit/network/processor/gossipHandlers.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts b/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts index 7afba1535cbd..2b059b733e84 100644 --- a/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts +++ b/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts @@ -9,6 +9,7 @@ import {BlockInputBlobs} from "../../../../src/chain/blocks/blockInput/blockInpu import {BlockInputSource} from "../../../../src/chain/blocks/blockInput/types.js"; import {BlockError, BlockErrorCode} from "../../../../src/chain/errors/blockError.js"; import {ChainEventEmitter, IBeaconChain} from "../../../../src/chain/index.js"; +import {SeenBlockProposers} from "../../../../src/chain/seenCache/seenBlockProposers.js"; import {SeenBlockInput} from "../../../../src/chain/seenCache/seenGossipBlockInput.js"; import {validateGossipBlock} from "../../../../src/chain/validation/index.js"; import {ExecutionPayloadStatus} from "../../../../src/execution/index.js"; @@ -93,6 +94,8 @@ async function runBeaconBlockProcessingError( getBlobsTracker: {triggerGetBlobs: vi.fn()}, logger, processBlock: vi.fn().mockRejectedValue(error), + processProposerEquivocation: vi.fn(), + seenBlockProposers: new SeenBlockProposers(), seenBlockInputCache: { getByBlock: vi.fn().mockReturnValue(blockInput), prune: vi.fn(), From f23f425585075c4a404577de7bc529e4089fb8d3 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 7 Aug 2026 15:35:13 +0100 Subject: [PATCH 4/6] adjust log level Co-authored-by: Nico Flaig --- packages/beacon-node/src/network/network.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index b3bc97695e50..b28822e95324 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -884,7 +884,7 @@ export class Network implements INetwork { try { await this.publishProposerSlashing(proposerSlashing); } catch (e) { - this.logger.error( + this.logger.debug( "Error publishing proposer slashing", {proposerIndex: proposerSlashing.signedHeader1.message.proposerIndex}, e as Error From d660c20e193c80c23db0cca8a06685df2964d9e7 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 7 Aug 2026 15:39:11 +0100 Subject: [PATCH 5/6] Update packages/beacon-node/src/chain/chain.ts --- packages/beacon-node/src/chain/chain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 88c27f6da349..ec3ba0dcf7d4 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1168,7 +1168,7 @@ export class BeaconChain implements IBeaconChain { processProposerEquivocation(blockSlot: Slot, proposerIndex: ValidatorIndex): void { this.produceProposerSlashing(blockSlot, proposerIndex).catch((e) => { - this.logger.error("Error producing proposer slashing", {slot: blockSlot, proposerIndex}, e as Error); + this.logger.debug("Error producing proposer slashing", {slot: blockSlot, proposerIndex}, e as Error); }); } From 0e4dd917b0991ff7d7f87d5ce1fe75913c5c076e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 7 Aug 2026 15:44:56 +0100 Subject: [PATCH 6/6] refactor: rename block proposer seen cache map to reflect stored headers --- .../src/chain/seenCache/seenBlockProposers.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index a2d4aafa6a1d..309497cf2a92 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -20,7 +20,7 @@ const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; */ export class SeenBlockProposers { private readonly proposerIndexesBySlot = new MapDef>(() => new Set()); - private readonly blockRootsBySlot = new MapDef< + private readonly signedBlockHeadersBySlot = new MapDef< Slot, MapDef> >(() => new MapDef>(() => new Map())); @@ -31,16 +31,20 @@ export class SeenBlockProposers { } hasBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): boolean { - return this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex)?.has(blockRoot) === true; + return this.signedBlockHeadersBySlot.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; + return ( + (this.signedBlockHeadersBySlot.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.keys()).filter((root) => root !== blockRoot); + const signedBlockHeaderByRoot = this.signedBlockHeadersBySlot.get(blockSlot)?.get(proposerIndex); + return signedBlockHeaderByRoot === undefined + ? [] + : Array.from(signedBlockHeaderByRoot.keys()).filter((root) => root !== blockRoot); } /** Return the two signed block headers that establish an equivocation, or null if there is none */ @@ -48,11 +52,11 @@ export class SeenBlockProposers { blockSlot: Slot, proposerIndex: ValidatorIndex ): [phase0.SignedBeaconBlockHeader, phase0.SignedBeaconBlockHeader] | null { - const headers = this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex); - if (headers === undefined || headers.size < MAX_BLOCK_ROOTS_PER_PROPOSAL) { + const signedBlockHeaderByRoot = this.signedBlockHeadersBySlot.get(blockSlot)?.get(proposerIndex); + if (signedBlockHeaderByRoot === undefined || signedBlockHeaderByRoot.size < MAX_BLOCK_ROOTS_PER_PROPOSAL) { return null; } - const [signedHeader1, signedHeader2] = headers.values(); + const [signedHeader1, signedHeader2] = signedBlockHeaderByRoot.values(); return [signedHeader1, signedHeader2]; } @@ -67,9 +71,9 @@ export class SeenBlockProposers { throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); } - const blockRoots = this.blockRootsBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex); - if (blockRoots.size < MAX_BLOCK_ROOTS_PER_PROPOSAL && !blockRoots.has(blockRoot)) { - blockRoots.set(blockRoot, signedBlockHeader); + const signedBlockHeaderByRoot = this.signedBlockHeadersBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex); + if (signedBlockHeaderByRoot.size < MAX_BLOCK_ROOTS_PER_PROPOSAL && !signedBlockHeaderByRoot.has(blockRoot)) { + signedBlockHeaderByRoot.set(blockRoot, signedBlockHeader); } } @@ -89,9 +93,9 @@ export class SeenBlockProposers { this.proposerIndexesBySlot.delete(slot); } } - for (const slot of this.blockRootsBySlot.keys()) { + for (const slot of this.signedBlockHeadersBySlot.keys()) { if (slot < finalizedSlot) { - this.blockRootsBySlot.delete(slot); + this.signedBlockHeadersBySlot.delete(slot); } } }