From 936d2d15cca38ae693f47caecc75397a935347e7 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Wed, 10 Jun 2026 00:12:22 +0000 Subject: [PATCH] fix: derive proposer duties dependent root from state --- .../src/api/impl/validator/index.ts | 2 +- .../impl/validator/duties/proposer.test.ts | 19 +++++++++++++++++- .../state-transition/src/util/shuffling.ts | 20 +++++++------------ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index ea94cb4b49f7..2681cae4aadc 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1295,7 +1295,7 @@ export function getValidatorApi( // It should be set to the latest block applied to `self` or the genesis block root. const dependentRoot = // In v2 the dependent root is different after fulu due to deterministic proposer lookahead - proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state, epoch) || + proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state) || (await getGenesisBlockRoot(state)); return { diff --git a/packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts b/packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts index b389bbb6ca15..3af98a9ac9d3 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts @@ -2,7 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {routes} from "@lodestar/api"; import {getConfig} from "@lodestar/config/test-utils"; import {ForkName, MAX_EFFECTIVE_BALANCE, SLOTS_PER_EPOCH} from "@lodestar/params"; -import {BeaconStateAllForks, BeaconStateView} from "@lodestar/state-transition"; +import {BeaconStateAllForks, BeaconStateView, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Slot} from "@lodestar/types"; import {SYNC_TOLERANCE_EPOCHS, getValidatorApi} from "../../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../../src/api/options.js"; @@ -61,6 +61,7 @@ describe("get proposers api impl", () => { } afterEach(() => { + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -107,6 +108,22 @@ describe("get proposers api impl", () => { ); }); + it("should get v1 proposers for next epoch mid-epoch", async () => { + const nextEpoch = currentEpoch + 1; + const getBlockRootAtSlotSpy = vi.spyOn(BeaconStateView.prototype, "getBlockRootAtSlot"); + + const {data: result} = (await api.getProposerDuties({epoch: nextEpoch})) as { + data: routes.validator.ProposerDutyList; + }; + + expect(result.length).toBe(SLOTS_PER_EPOCH); + expect(getBlockRootAtSlotSpy).toHaveBeenCalledWith(currentSlot - 1); + expect(getBlockRootAtSlotSpy).not.toHaveBeenCalledWith(computeStartSlotAtEpoch(nextEpoch) - 1); + expect(result.map((p) => p.slot)).toEqual( + Array.from({length: SLOTS_PER_EPOCH}, (_, i) => nextEpoch * SLOTS_PER_EPOCH + i) + ); + }); + it("should get proposers for historical epoch", async () => { const historicalEpoch = currentEpoch - 2; initializeState(currentSlot - 2 * SLOTS_PER_EPOCH + 1); diff --git a/packages/state-transition/src/util/shuffling.ts b/packages/state-transition/src/util/shuffling.ts index 9710b2b3bf07..65c265a0f10a 100644 --- a/packages/state-transition/src/util/shuffling.ts +++ b/packages/state-transition/src/util/shuffling.ts @@ -17,30 +17,24 @@ import {computeStartSlotAtEpoch} from "./epoch.js"; import {EpochShuffling} from "./epochShuffling.js"; /** - * Block root that decided the proposer shuffling for `proposalEpoch` (keys that shuffling). - * Computed for the requested epoch, not `state.epoch`, so it is correct when `state` is one - * epoch off the requested epoch (e.g. serving next-epoch duties from the current state). + * Block root that decided the proposer shuffling for the state used to serve proposer duties. * * Returns `null` when the genesis block decides its own shuffling (caller falls back to the * genesis block root). */ -export function proposerShufflingDecisionRoot( - fork: ForkName, - state: IBeaconStateView, - proposalEpoch: Epoch -): Root | null { - const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch); +export function proposerShufflingDecisionRoot(fork: ForkName, state: IBeaconStateView): Root | null { + const decisionSlot = proposerShufflingDecisionSlot(fork, state); if (state.slot === decisionSlot) { return null; } return state.getBlockRootAtSlot(decisionSlot); } -/** Slot whose block root keys the proposer shuffling for `proposalEpoch`. */ -function proposerShufflingDecisionSlot(fork: ForkName, proposalEpoch: Epoch): Slot { +/** Slot whose block root keys the proposer shuffling for `state`. */ +function proposerShufflingDecisionSlot(fork: ForkName, state: IBeaconStateView): Slot { // Post-Fulu the shuffling is decided one epoch earlier (deterministic proposer lookahead, - // MIN_SEED_LOOKAHEAD = 1); pre-Fulu it is the last block before `proposalEpoch`. - const decisionEpoch = isForkPostFulu(fork) ? proposalEpoch - 1 : proposalEpoch; + // MIN_SEED_LOOKAHEAD = 1); pre-Fulu it is the last block before `state.epoch`. + const decisionEpoch = isForkPostFulu(fork) ? state.epoch - 1 : state.epoch; return Math.max(computeStartSlotAtEpoch(decisionEpoch) - 1, 0); }