diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index ea94cb4b49f7..776034bac020 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1291,12 +1291,20 @@ export function getValidatorApi( duties.push({slot: startSlot + i, validatorIndex: indexes[i], pubkey: pubkeys[i]}); } - // Returns `null` on the one-off scenario where the genesis block decides its own shuffling. - // It should be set to the latest block applied to `self` or the genesis block root. + // In v2 the dependent root is different after fulu due to deterministic proposer lookahead. + // `head.blockRoot` is used as the dep_root when the decision slot is not yet in + // `state.block_roots` (pre-Fulu next-epoch duties from a mid-epoch head state). For the + // genesis-decides-its-own-shuffling case (`state.slot === 0 && decisionSlot === 0`), + // `proposerShufflingDecisionRoot` returns `null` and we fall back to the canonical + // genesis root, because `head.blockRoot` is the current fork-choice head and may not + // equal the genesis root if the chain has advanced. 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) || - (await getGenesisBlockRoot(state)); + proposerShufflingDecisionRoot( + opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, + state, + epoch, + fromHex(head.blockRoot) + ) ?? (await getGenesisBlockRoot(state)); return { data: duties, 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..0069a0bd66dc 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 @@ -107,6 +107,23 @@ describe("get proposers api impl", () => { ); }); + // Regression for the V1 path: pre-Fulu decision-slot logic asks for the block root at + // `startSlot(nextEpoch) - 1`, which is in the future relative to a mid-epoch head state. + // The dep_root must fall back to the head block root instead of throwing. + it("should get proposers for next epoch via V1 from a mid-epoch head state", async () => { + const nextEpoch = currentEpoch + 1; + const {data: result, meta} = (await api.getProposerDuties({epoch: nextEpoch})) as { + data: routes.validator.ProposerDutyList; + meta: {dependentRoot: string}; + }; + + expect(result.length).toBe(SLOTS_PER_EPOCH); + expect(meta.dependentRoot).toBe(zeroProtoBlock.blockRoot); + 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..440414733a04 100644 --- a/packages/state-transition/src/util/shuffling.ts +++ b/packages/state-transition/src/util/shuffling.ts @@ -21,18 +21,32 @@ import {EpochShuffling} from "./epochShuffling.js"; * 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). * - * Returns `null` when the genesis block decides its own shuffling (caller falls back to the - * genesis block root). + * `headBlockRoot` is the canonical head block root applied to `state`. When the decision + * slot is at or after `state.slot` (e.g. serving pre-Fulu `currentEpoch + 1` duties from + * a mid-epoch head state), the head block root is returned as the dependent root. This + * matches the beacon-API V1 rule of using the latest block applied to state when the + * decision slot is not yet in `block_roots`, and aligns with Lighthouse's + * `proposer_shuffling_decision_root_at_epoch` behavior. + * + * Returns `null` for the genesis-decides-its-own-shuffling case (`state.slot === 0 && + * decisionSlot === 0`); the caller must fall back to the canonical genesis block root + * (typically via `getGenesisBlockRoot(state)`), because `headBlockRoot` may not equal + * the genesis root when the chain has advanced and we are serving epoch-0 duties from a + * loaded genesis state. */ export function proposerShufflingDecisionRoot( fork: ForkName, state: IBeaconStateView, - proposalEpoch: Epoch + proposalEpoch: Epoch, + headBlockRoot: Root ): Root | null { const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch); - if (state.slot === decisionSlot) { + if (state.slot === 0 && decisionSlot === 0) { return null; } + if (state.slot <= decisionSlot) { + return headBlockRoot; + } return state.getBlockRootAtSlot(decisionSlot); }