From 10fbe146ebf9b49fb0dddd5229e95ca9631521e2 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:01:30 +0200 Subject: [PATCH 1/4] fix: only give proposer boost to canonical proposer (#9230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per consensus-specs PR #4807, `update_proposer_boost_root` now only sets `proposer_boost_root` when the imported block's `proposer_index` matches the head state's expected proposer for the slot, so a divergent proposer shuffling on a non-canonical fork cannot nullify proposer boost. `onBlock` gains an `expectedProposerIndex` argument; `importBlock` resolves it via `headState.currentProposers` / `nextProposers` (O(1), backed by `state.proposerLookahead` post-fulu) — same fast path used by the proposer-preferences gossip validator. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/chain/blocks/importBlock.ts | 12 +- .../test/spec/utils/gossipValidation.ts | 19 +++- .../unit/chain/forkChoice/forkChoice.test.ts | 106 ++++++++++++++---- .../fork-choice/src/forkChoice/forkChoice.ts | 10 +- .../fork-choice/src/forkChoice/interface.ts | 14 ++- 5 files changed, 134 insertions(+), 27 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 8cd27f99f7ac..2d4f5412f193 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -116,13 +116,23 @@ export async function importBlock( } executionStatus = parentBlock.executionStatus; } + + let expectedProposerIndex: number | null = null; + const headState = this.getHeadState(); + if (headState.epoch === blockEpoch) { + expectedProposerIndex = headState.currentProposers[blockSlot % SLOTS_PER_EPOCH]; + } else if (headState.epoch + 1 === blockEpoch) { + expectedProposerIndex = headState.nextProposers[blockSlot % SLOTS_PER_EPOCH]; + } + const blockSummary = this.forkChoice.onBlock( block.message, postState, blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + expectedProposerIndex ); // This adds the state necessary to process the next block diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index 961400e7fb76..22416a457dd9 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -9,7 +9,7 @@ import {chainConfigFromJson, chainConfigTypes, createBeaconConfig} from "@lodest import {getConfig} from "@lodestar/config/test-utils"; import {ExecutionStatus} from "@lodestar/fork-choice"; import {testLogger} from "@lodestar/logger/test-utils"; -import {ForkName} from "@lodestar/params"; +import {ForkName, SLOTS_PER_EPOCH} from "@lodestar/params"; import { BeaconStateAllForks, BeaconStateView, @@ -486,7 +486,8 @@ export async function runGossipValidationTest( 0, slot, ExecutionStatus.Valid, - getDataAvailabilityStatusForFork(fork) + getDataAvailabilityStatusForFork(fork), + getHeadProposerIndex(chain.getHeadState(), slot) ); blockStatesByRoot.set(blockRootHex, postState); continue; @@ -501,7 +502,8 @@ export async function runGossipValidationTest( 0, slot, ExecutionStatus.Syncing, - getDataAvailabilityStatusForFork(fork) + getDataAvailabilityStatusForFork(fork), + getHeadProposerIndex(chain.getHeadState(), slot) ); blockStatesByRoot.set(blockRootHex, postState); invalidateImportedBlock(chain, blockRootHex, parentRootHex); @@ -727,6 +729,17 @@ async function validateMessageForTopic( } } +function getHeadProposerIndex(headState: IBeaconStateView, slot: number): number | null { + const slotEpoch = computeEpochAtSlot(slot); + if (headState.epoch === slotEpoch) { + return headState.currentProposers[slot % SLOTS_PER_EPOCH]; + } + if (headState.epoch + 1 === slotEpoch) { + return headState.nextProposers[slot % SLOTS_PER_EPOCH]; + } + return null; +} + function rejectOnInvalidSerializedBytes(fn: () => T): T { try { return fn(); diff --git a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts index 8ea7c3a4d0c2..c4dd183e6af2 100644 --- a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts +++ b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts @@ -106,7 +106,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + targetBlock.message.proposerIndex ); forkChoice.onBlock( orphanedBlock.message, @@ -114,7 +115,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + orphanedBlock.message.proposerIndex ); let head = forkChoice.getHead(); expect(head.slot).toBe(orphanedBlock.message.slot); @@ -124,7 +126,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + parentBlock.message.proposerIndex ); // tie break condition causes head to be orphaned block (based on hex root comparison) head = forkChoice.getHead(); @@ -135,7 +138,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + childBlock.message.proposerIndex ); head = forkChoice.getHead(); // without vote, head gets stuck at orphaned block @@ -195,19 +199,75 @@ describe("LodestarForkChoice", () => { const currentSlot = 128; forkChoice.updateTime(currentSlot); - forkChoice.onBlock(block08.message, state08, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); - forkChoice.onBlock(block12.message, state12, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); - forkChoice.onBlock(block16.message, state16, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); - forkChoice.onBlock(block20.message, state20, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); - forkChoice.onBlock(block24.message, state24, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); - forkChoice.onBlock(block28.message, state28, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); + forkChoice.onBlock( + block08.message, + state08, + blockDelaySec, + currentSlot, + executionStatus, + dataAvailabilityStatus, + block08.message.proposerIndex + ); + forkChoice.onBlock( + block12.message, + state12, + blockDelaySec, + currentSlot, + executionStatus, + dataAvailabilityStatus, + block12.message.proposerIndex + ); + forkChoice.onBlock( + block16.message, + state16, + blockDelaySec, + currentSlot, + executionStatus, + dataAvailabilityStatus, + block16.message.proposerIndex + ); + forkChoice.onBlock( + block20.message, + state20, + blockDelaySec, + currentSlot, + executionStatus, + dataAvailabilityStatus, + block20.message.proposerIndex + ); + forkChoice.onBlock( + block24.message, + state24, + blockDelaySec, + currentSlot, + executionStatus, + dataAvailabilityStatus, + block24.message.proposerIndex + ); + forkChoice.onBlock( + block28.message, + state28, + blockDelaySec, + currentSlot, + executionStatus, + dataAvailabilityStatus, + block28.message.proposerIndex + ); expect(forkChoice.getAllAncestorBlocks(hashBlock(block16.message), PayloadStatus.FULL)).toHaveLength(4); expect(forkChoice.getAllAncestorBlocks(hashBlock(block24.message), PayloadStatus.FULL)).toHaveLength(6); expect(forkChoice.getBlockHexDefaultStatus(hashBlock(block08.message))).not.toBeNull(); expect(forkChoice.getBlockHexDefaultStatus(hashBlock(block12.message))).not.toBeNull(); expect(forkChoice.hasBlockHex(hashBlock(block08.message))).toBe(true); expect(forkChoice.hasBlockHex(hashBlock(block12.message))).toBe(true); - forkChoice.onBlock(block32.message, state32, blockDelaySec, currentSlot, executionStatus, dataAvailabilityStatus); + forkChoice.onBlock( + block32.message, + state32, + blockDelaySec, + currentSlot, + executionStatus, + dataAvailabilityStatus, + block32.message.proposerIndex + ); forkChoice.prune(hashBlock(block16.message)); expect(forkChoice.getAllAncestorBlocks(hashBlock(block16.message), PayloadStatus.FULL).length).toBeWithMessage( 1, @@ -243,7 +303,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + targetBlock.message.proposerIndex ); forkChoice.onBlock( orphanedBlock.message, @@ -251,7 +312,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + orphanedBlock.message.proposerIndex ); forkChoice.onBlock( parentBlock.message, @@ -259,7 +321,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + parentBlock.message.proposerIndex ); forkChoice.onBlock( childBlock.message, @@ -267,7 +330,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + childBlock.message.proposerIndex ); const childBlockRoot = toHexString(ssz.phase0.BeaconBlock.hashTreeRoot(childBlock.message)); // the old way to get non canonical blocks @@ -314,7 +378,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, blockW.message.slot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + blockW.message.proposerIndex ); // X @@ -326,7 +391,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, blockX.message.slot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + blockX.message.proposerIndex ); // Y, same epoch to X @@ -338,7 +404,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, blockY.message.slot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + blockY.message.proposerIndex ); // Y and Z are candidates for new head, make more attestations on Y @@ -379,7 +446,8 @@ describe("LodestarForkChoice", () => { blockDelaySec, blockZ.message.slot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + blockZ.message.proposerIndex ); const head = forkChoice.updateHead(); diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index abca3df86437..03e57d95178c 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -596,7 +596,11 @@ export class ForkChoice implements IForkChoice { blockDelaySec: number, currentSlot: Slot, executionStatus: BlockExecutionStatus, - dataAvailabilityStatus: DataAvailabilityStatus + dataAvailabilityStatus: DataAvailabilityStatus, + // The expected proposer index on the canonical chain we are following. + // Calculated by our head state. We use it as part of the proposer + // boost decision making. No boost will be set if this is null. + expectedProposerIndex: ValidatorIndex | null ): ProtoBlock { const {parentRoot, slot} = block; const parentRootHex = toRootHex(parentRoot); @@ -669,7 +673,9 @@ export class ForkChoice implements IForkChoice { this.opts?.proposerBoost && isTimely && // only boost the first block we see - this.proposerBoostRoot === null + this.proposerBoostRoot === null && + expectedProposerIndex !== null && + block.proposerIndex === expectedProposerIndex ) { this.proposerBoostRoot = blockRootHex; } diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 534f5fa51524..2b2104e89832 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -1,5 +1,14 @@ import {DataAvailabilityStatus, EffectiveBalanceIncrements, IBeaconStateView} from "@lodestar/state-transition"; -import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types"; +import { + AttesterSlashing, + BeaconBlock, + Epoch, + IndexedAttestation, + Root, + RootHex, + Slot, + ValidatorIndex, +} from "@lodestar/types"; import { BlockExecutionStatus, LVHExecResponse, @@ -145,7 +154,8 @@ export interface IForkChoice { blockDelaySec: number, currentSlot: Slot, executionStatus: BlockExecutionStatus, - dataAvailabilityStatus: DataAvailabilityStatus + dataAvailabilityStatus: DataAvailabilityStatus, + expectedProposerIndex: ValidatorIndex | null ): ProtoBlock; /** * Register `attestation` with the fork choice DAG so that it may influence future calls to `getHead`. From 64137ef9c88187b14ec78551a7483fa52df61d2e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 1 May 2026 09:45:47 +0200 Subject: [PATCH 2/4] test: unskip proposer-boost spec tests fixed by #9230 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retroactive proposer-boost spec change from v1.7.0-alpha.1 is now implemented, so `voting_source_beyond_two_epoch`, `justified_update_always_if_better`, and `justified_update_not_realized_finality` pass across all forks (altair → gloas, 21 fixtures total). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/beacon-node/test/spec/presets/fork_choice.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 1a19815d05b1..ed642270e4e1 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -622,11 +622,6 @@ const forkChoiceTest = // integrated shouldSkip: (_testcase, name, _index) => name.includes("invalid_incorrect_proof") || - // TODO GLOAS: Proposer boost specs have been changed retroactively in v1.7.0-alpha.1, - // and these tests are failing until we update our implementation. - name.includes("voting_source_beyond_two_epoch") || - name.includes("justified_update_always_if_better") || - name.includes("justified_update_not_realized_finality") || // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 (name.includes("gloas") && (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || From 9b9cfec85e515e180c5bb85c4ed67c28621e0e2a Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 5 May 2026 12:54:31 +0200 Subject: [PATCH 3/4] address @twoeths' comments --- .../src/chain/blocks/importBlock.ts | 10 ++--- .../test/spec/utils/gossipValidation.ts | 25 ++++++------ .../unit/chain/forkChoice/forkChoice.test.ts | 38 +++++++++---------- .../state-transition/src/cache/epochCache.ts | 17 +++++---- 4 files changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 2d4f5412f193..cd75999fc657 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -118,11 +118,11 @@ export async function importBlock( } let expectedProposerIndex: number | null = null; - const headState = this.getHeadState(); - if (headState.epoch === blockEpoch) { - expectedProposerIndex = headState.currentProposers[blockSlot % SLOTS_PER_EPOCH]; - } else if (headState.epoch + 1 === blockEpoch) { - expectedProposerIndex = headState.nextProposers[blockSlot % SLOTS_PER_EPOCH]; + try { + expectedProposerIndex = this.getHeadState().getBeaconProposer(blockSlot); + } catch { + // headState is more than one epoch away from blockSlot; cannot determine the + // canonical proposer, so skip the proposer-boost canonical check. } const blockSummary = this.forkChoice.onBlock( diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index 22416a457dd9..4947bee51f0a 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -9,7 +9,7 @@ import {chainConfigFromJson, chainConfigTypes, createBeaconConfig} from "@lodest import {getConfig} from "@lodestar/config/test-utils"; import {ExecutionStatus} from "@lodestar/fork-choice"; import {testLogger} from "@lodestar/logger/test-utils"; -import {ForkName, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {ForkName} from "@lodestar/params"; import { BeaconStateAllForks, BeaconStateView, @@ -476,6 +476,14 @@ export async function runGossipValidationTest( const postState = computePostState(parentState, signedBlock, fork); + let expectedProposerIndex: number | null = null; + try { + expectedProposerIndex = chain.getHeadState().getBeaconProposer(slot); + } catch { + // headState is more than one epoch away from slot; cannot determine the + // canonical proposer, so skip the proposer-boost canonical check. + } + if (blockEntry.failed) { // payload_status === "VALID" (filtered above) clock.setSlot(slot); @@ -487,7 +495,7 @@ export async function runGossipValidationTest( slot, ExecutionStatus.Valid, getDataAvailabilityStatusForFork(fork), - getHeadProposerIndex(chain.getHeadState(), slot) + expectedProposerIndex ); blockStatesByRoot.set(blockRootHex, postState); continue; @@ -503,7 +511,7 @@ export async function runGossipValidationTest( slot, ExecutionStatus.Syncing, getDataAvailabilityStatusForFork(fork), - getHeadProposerIndex(chain.getHeadState(), slot) + expectedProposerIndex ); blockStatesByRoot.set(blockRootHex, postState); invalidateImportedBlock(chain, blockRootHex, parentRootHex); @@ -729,17 +737,6 @@ async function validateMessageForTopic( } } -function getHeadProposerIndex(headState: IBeaconStateView, slot: number): number | null { - const slotEpoch = computeEpochAtSlot(slot); - if (headState.epoch === slotEpoch) { - return headState.currentProposers[slot % SLOTS_PER_EPOCH]; - } - if (headState.epoch + 1 === slotEpoch) { - return headState.nextProposers[slot % SLOTS_PER_EPOCH]; - } - return null; -} - function rejectOnInvalidSerializedBytes(fn: () => T): T { try { return fn(); diff --git a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts index c4dd183e6af2..29d512c5e256 100644 --- a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts +++ b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts @@ -107,7 +107,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - targetBlock.message.proposerIndex + null ); forkChoice.onBlock( orphanedBlock.message, @@ -116,7 +116,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - orphanedBlock.message.proposerIndex + null ); let head = forkChoice.getHead(); expect(head.slot).toBe(orphanedBlock.message.slot); @@ -127,7 +127,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - parentBlock.message.proposerIndex + null ); // tie break condition causes head to be orphaned block (based on hex root comparison) head = forkChoice.getHead(); @@ -139,7 +139,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - childBlock.message.proposerIndex + null ); head = forkChoice.getHead(); // without vote, head gets stuck at orphaned block @@ -206,7 +206,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - block08.message.proposerIndex + null ); forkChoice.onBlock( block12.message, @@ -215,7 +215,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - block12.message.proposerIndex + null ); forkChoice.onBlock( block16.message, @@ -224,7 +224,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - block16.message.proposerIndex + null ); forkChoice.onBlock( block20.message, @@ -233,7 +233,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - block20.message.proposerIndex + null ); forkChoice.onBlock( block24.message, @@ -242,7 +242,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - block24.message.proposerIndex + null ); forkChoice.onBlock( block28.message, @@ -251,7 +251,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - block28.message.proposerIndex + null ); expect(forkChoice.getAllAncestorBlocks(hashBlock(block16.message), PayloadStatus.FULL)).toHaveLength(4); expect(forkChoice.getAllAncestorBlocks(hashBlock(block24.message), PayloadStatus.FULL)).toHaveLength(6); @@ -266,7 +266,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - block32.message.proposerIndex + null ); forkChoice.prune(hashBlock(block16.message)); expect(forkChoice.getAllAncestorBlocks(hashBlock(block16.message), PayloadStatus.FULL).length).toBeWithMessage( @@ -304,7 +304,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - targetBlock.message.proposerIndex + null ); forkChoice.onBlock( orphanedBlock.message, @@ -313,7 +313,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - orphanedBlock.message.proposerIndex + null ); forkChoice.onBlock( parentBlock.message, @@ -322,7 +322,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - parentBlock.message.proposerIndex + null ); forkChoice.onBlock( childBlock.message, @@ -331,7 +331,7 @@ describe("LodestarForkChoice", () => { currentSlot, executionStatus, dataAvailabilityStatus, - childBlock.message.proposerIndex + null ); const childBlockRoot = toHexString(ssz.phase0.BeaconBlock.hashTreeRoot(childBlock.message)); // the old way to get non canonical blocks @@ -379,7 +379,7 @@ describe("LodestarForkChoice", () => { blockW.message.slot, executionStatus, dataAvailabilityStatus, - blockW.message.proposerIndex + null ); // X @@ -392,7 +392,7 @@ describe("LodestarForkChoice", () => { blockX.message.slot, executionStatus, dataAvailabilityStatus, - blockX.message.proposerIndex + null ); // Y, same epoch to X @@ -405,7 +405,7 @@ describe("LodestarForkChoice", () => { blockY.message.slot, executionStatus, dataAvailabilityStatus, - blockY.message.proposerIndex + null ); // Y and Z are candidates for new head, make more attestations on Y @@ -447,7 +447,7 @@ describe("LodestarForkChoice", () => { blockZ.message.slot, executionStatus, dataAvailabilityStatus, - blockZ.message.proposerIndex + null ); const head = forkChoice.updateHead(); diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index 7be17881c753..8acaa7271f96 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -782,14 +782,17 @@ export class EpochCache { */ getBeaconProposer(slot: Slot): ValidatorIndex { const epoch = computeEpochAtSlot(slot); - if (epoch !== this.currentShuffling.epoch) { - throw new EpochCacheError({ - code: EpochCacheErrorCode.PROPOSER_EPOCH_MISMATCH, - currentEpoch: this.currentShuffling.epoch, - requestedEpoch: epoch, - }); + if (epoch === this.currentShuffling.epoch) { + return this.proposers[slot % SLOTS_PER_EPOCH]; + } + if (epoch === this.nextEpoch) { + return this.getBeaconProposersNextEpoch()[slot % SLOTS_PER_EPOCH]; } - return this.proposers[slot % SLOTS_PER_EPOCH]; + throw new EpochCacheError({ + code: EpochCacheErrorCode.PROPOSER_EPOCH_MISMATCH, + currentEpoch: this.currentShuffling.epoch, + requestedEpoch: epoch, + }); } getBeaconProposers(): ValidatorIndex[] { From f24193f1d63df80f378bb41451239bf4ab9aa6c4 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 21 May 2026 21:30:43 -0700 Subject: [PATCH 4/4] address @twoeths's comments --- packages/beacon-node/src/chain/blocks/importBlock.ts | 10 +++------- .../beacon-node/test/spec/utils/gossipValidation.ts | 9 +-------- .../state-transition/src/stateView/beaconStateView.ts | 8 ++++++++ packages/state-transition/src/stateView/interface.ts | 1 + 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 6af1acabcc38..ed5c66e6634d 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -117,13 +117,9 @@ export async function importBlock( executionStatus = parentBlock.executionStatus; } - let expectedProposerIndex: number | null = null; - try { - expectedProposerIndex = this.getHeadState().getBeaconProposer(blockSlot); - } catch { - // headState is more than one epoch away from blockSlot; cannot determine the - // canonical proposer, so skip the proposer-boost canonical check. - } + // getBeaconProposerOrNull will return null if head state is more than one epoch away + // from block slot. We skip proposer boost canonical check as we cannot determine the canonical proposer + const expectedProposerIndex: number | null = this.getHeadState().getBeaconProposerOrNull(blockSlot); const blockSummary = this.forkChoice.onBlock( block.message, diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts index 4947bee51f0a..448cad42b2e1 100644 --- a/packages/beacon-node/test/spec/utils/gossipValidation.ts +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -475,14 +475,7 @@ export async function runGossipValidationTest( } const postState = computePostState(parentState, signedBlock, fork); - - let expectedProposerIndex: number | null = null; - try { - expectedProposerIndex = chain.getHeadState().getBeaconProposer(slot); - } catch { - // headState is more than one epoch away from slot; cannot determine the - // canonical proposer, so skip the proposer-boost canonical check. - } + const expectedProposerIndex: number | null = chain.getHeadState().getBeaconProposerOrNull(slot); if (blockEntry.failed) { // payload_status === "VALID" (filtered above) diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 5fcdea7258f0..88c8046549c3 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -498,6 +498,14 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return this.cachedState.epochCtx.getBeaconProposer(slot); } + getBeaconProposerOrNull(slot: Slot): ValidatorIndex | null { + try { + return this.cachedState.epochCtx.getBeaconProposer(slot); + } catch { + return null; + } + } + computeAnchorCheckpoint(): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { return computeAnchorCheckpoint(this.config, this.cachedState); } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index 7f5fd7006939..5dead7e6dffa 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -89,6 +89,7 @@ export interface IBeaconStateView { currentProposers: ValidatorIndex[]; nextProposers: ValidatorIndex[]; getBeaconProposer(slot: Slot): ValidatorIndex; + getBeaconProposerOrNull(slot: Slot): ValidatorIndex | null; // Validators and balances effectiveBalanceIncrements: EffectiveBalanceIncrements;