diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index ea94cb4b49f7..3e193aef47b2 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1207,7 +1207,10 @@ export function getValidatorApi( const isPostFulu = isForkPostFulu(config.getForkName(startSlot)); const maxFutureEpoch = isPostFulu && nearNextEpoch && opts?.v2 ? nextEpoch + 1 : nextEpoch; if (currentEpoch >= 0 && epoch > maxFutureEpoch) { - throw new ApiError(400, `Requested epoch ${epoch} must not be more than one epoch in the future`); + throw new ApiError( + 400, + `Requested epoch ${epoch} must not be more than ${maxFutureEpoch}, currentEpoch=${currentEpoch}, v2=${opts?.v2 ?? false}` + ); } const head = chain.forkChoice.getHead(); @@ -1291,17 +1294,35 @@ 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. - 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)); + // In v2 the dependent root is different after fulu due to deterministic proposer lookahead + let dependentRoot = proposerShufflingDecisionRoot( + opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, + state, + epoch + ); + const logCtx = { + epoch, + stateSlot: state.slot, + stateEpoch: state.epoch, + v2: opts?.v2 ?? false, + }; + if (dependentRoot === null) { + // fallback to get_proposer_duties() v1, also in lodestar v1.43 + logger.verbose("Proposer duties decision root not in state, falling back to state epoch", logCtx); + dependentRoot = proposerShufflingDecisionRoot(ForkName.phase0, state, state.epoch); + } + if (dependentRoot === null) { + logger.verbose("Proposer duties decision root not in state, falling back to genesis block root", logCtx); + dependentRoot = await getGenesisBlockRoot(state); + } + + const dependentRootHex = toRootHex(dependentRoot); + logger.verbose("Computed proposer duties decision root", {...logCtx, dependentRoot: dependentRootHex}); return { data: duties, meta: { - dependentRoot: toRootHex(dependentRoot), + dependentRoot: dependentRootHex, executionOptimistic: isOptimisticBlock(head), }, }; 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..82eb0485847c 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 @@ -4,6 +4,7 @@ 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 {Slot} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; import {SYNC_TOLERANCE_EPOCHS, getValidatorApi} from "../../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../../src/api/options.js"; import {FAR_FUTURE_EPOCH} from "../../../../../../src/constants/index.js"; @@ -107,6 +108,31 @@ describe("get proposers api impl", () => { ); }); + // Regression #9380: v1 getProposerDuties for next epoch must not throw on the dependent root. + // The requested-epoch decision slot is the last slot of the current epoch (in the future for a + // head state), so it falls back to the state-epoch decision root (last block of previous epoch), + // which is stable and, post-Fulu, identical to v2. + it("should get v1 proposers for next epoch with stable dependent root", async () => { + const nextEpoch = currentEpoch + 1; + const v1 = (await api.getProposerDuties({epoch: nextEpoch})) as {meta: {dependentRoot: string}}; + const v2 = (await api.getProposerDutiesV2({epoch: nextEpoch})) as {meta: {dependentRoot: string}}; + + const expected = toRootHex(new BeaconStateView(cachedState).getBlockRootAtSlot(currentSlot - 1)); + expect(v1.meta.dependentRoot).toBe(expected); + expect(v2.meta.dependentRoot).toBe(expected); + }); + + it("should get v1 proposers for next epoch from a mid-epoch head state", async () => { + vi.advanceTimersByTime(15 * config.SLOT_DURATION_MS); + initializeState(currentSlot + 15); + modules.chain.getHeadStateAtCurrentEpoch.mockResolvedValue(new BeaconStateView(cachedState)); + + const v1 = (await api.getProposerDuties({epoch: currentEpoch + 1})) as {meta: {dependentRoot: string}}; + + const expected = toRootHex(new BeaconStateView(cachedState).getBlockRootAtSlot(currentSlot - 1)); + expect(v1.meta.dependentRoot).toBe(expected); + }); + it("should get proposers for historical epoch", async () => { const historicalEpoch = currentEpoch - 2; initializeState(currentSlot - 2 * SLOTS_PER_EPOCH + 1); @@ -129,7 +155,7 @@ describe("get proposers api impl", () => { it("should raise error for more than one epoch in the future", async () => { await expect(api.getProposerDutiesV2({epoch: currentEpoch + 2})).rejects.toThrow( - "Requested epoch 4 must not be more than one epoch in the future" + "Requested epoch 4 must not be more than 3, currentEpoch=2, v2=true" ); }); diff --git a/packages/state-transition/src/util/shuffling.ts b/packages/state-transition/src/util/shuffling.ts index 9710b2b3bf07..9636821be020 100644 --- a/packages/state-transition/src/util/shuffling.ts +++ b/packages/state-transition/src/util/shuffling.ts @@ -21,8 +21,8 @@ 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). + * Returns `null` when the decision block is not in the state's past (the genesis block decides + * its own shuffling, or it has not been produced yet); caller supplies the fallback. */ export function proposerShufflingDecisionRoot( fork: ForkName, @@ -30,7 +30,8 @@ export function proposerShufflingDecisionRoot( proposalEpoch: Epoch ): Root | null { const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch); - if (state.slot === decisionSlot) { + // Return null when the decision block is not in the state's past or the getBlockRootAtSlot() api will throw + if (decisionSlot >= state.slot) { return null; } return state.getBlockRootAtSlot(decisionSlot);