From dcba59f39b89b3d86e46f35ecd08d3eb70657df9 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Wed, 10 Jun 2026 00:13:57 +0000 Subject: [PATCH 1/2] fix: fall back to head block root for proposer duty dep_root when decision slot is in the future MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9380 changed `proposerShufflingDecisionRoot` to derive the decision slot from `proposalEpoch` instead of `state.epoch`, which is correct for the deterministic post-Fulu lookahead. Pre-Fulu, the decision slot for `proposalEpoch = state.epoch + 1` is `startSlot(state.epoch + 1) - 1` (the last slot of the current epoch), which is in the future relative to a mid-epoch head state. `state.getBlockRootAtSlot` then throws "Can only get block root in the past", returning 500 from `getProposerDuties` (V1) for `currentEpoch + 1` requests. Spec-wise, the beacon-API V1 dep_root is `event.block` (head block root) when the requested epoch is not the state's epoch. Lighthouse implements the same fallback in `legacy_proposer_shuffling_decision_root_at_epoch`: when `state.slot <= decision_slot`, return the canonical head block root. This commit adopts the same approach: `proposerShufflingDecisionRoot` takes the head block root and returns it when the decision slot is at or after `state.slot`. The genesis case (`state.slot === decisionSlot === 0`) collapses into the same branch since the head block root at genesis is the genesis block root, so the old `null` + `getGenesisBlockRoot` fallback becomes unnecessary at this call site. Reproduces on mainnet against a Vero VC, which fetches V1 proposer duties for `(currentEpoch, currentEpoch + 1)` at every epoch boundary. 🤖 Generated with AI assistance --- .../beacon-node/src/api/impl/validator/index.ts | 16 ++++++++++------ .../api/impl/validator/duties/proposer.test.ts | 17 +++++++++++++++++ packages/state-transition/src/util/shuffling.ts | 17 +++++++++++------ 3 files changed, 38 insertions(+), 12 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..6688298cd669 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1291,12 +1291,16 @@ 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. + // `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, or the + // genesis-decides-its-own-shuffling case at slot 0). + const dependentRoot = proposerShufflingDecisionRoot( + opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, + state, + epoch, + fromHex(head.blockRoot) + ); 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..8c95bf632717 100644 --- a/packages/state-transition/src/util/shuffling.ts +++ b/packages/state-transition/src/util/shuffling.ts @@ -21,17 +21,22 @@ 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, or the genesis-decides-its-own-shuffling case), 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. */ export function proposerShufflingDecisionRoot( fork: ForkName, state: IBeaconStateView, - proposalEpoch: Epoch -): Root | null { + proposalEpoch: Epoch, + headBlockRoot: Root +): Root { const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch); - if (state.slot === decisionSlot) { - return null; + if (state.slot <= decisionSlot) { + return headBlockRoot; } return state.getBlockRootAtSlot(decisionSlot); } From 995db8b8e3df4340195b01e3dd73d930ac820d52 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Wed, 10 Jun 2026 00:24:51 +0000 Subject: [PATCH 2/2] fix: preserve genesis root fallback for proposer dep_root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (discussion_r3384632453) on #9499: passing `head.blockRoot` as the unconditional fallback breaks the historical genesis-duty path. When the caller loads a state at slot 0 to serve epoch-0 proposer duties and the chain has advanced past genesis, `head.blockRoot` is the current head — not the genesis block root the V1 spec calls for. Restore the genesis special case at the helper boundary: - `proposerShufflingDecisionRoot` returns `Root | null`, with `null` reserved for `state.slot === 0 && decisionSlot === 0`. - The call site falls back to `getGenesisBlockRoot(state)` (the cached canonical genesis root computed from the loaded state) on `null`. This keeps the Lighthouse-style head-root fallback for the future-decision-slot case (pre-Fulu next-epoch duties from mid-epoch head) while restoring correctness for the genesis edge case. 🤖 Generated with AI assistance --- .../src/api/impl/validator/index.ts | 20 +++++++++++-------- .../state-transition/src/util/shuffling.ts | 19 +++++++++++++----- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 6688298cd669..776034bac020 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1293,14 +1293,18 @@ export function getValidatorApi( // 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, or the - // genesis-decides-its-own-shuffling case at slot 0). - const dependentRoot = proposerShufflingDecisionRoot( - opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, - state, - epoch, - fromHex(head.blockRoot) - ); + // `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 = + proposerShufflingDecisionRoot( + opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, + state, + epoch, + fromHex(head.blockRoot) + ) ?? (await getGenesisBlockRoot(state)); return { data: duties, diff --git a/packages/state-transition/src/util/shuffling.ts b/packages/state-transition/src/util/shuffling.ts index 8c95bf632717..440414733a04 100644 --- a/packages/state-transition/src/util/shuffling.ts +++ b/packages/state-transition/src/util/shuffling.ts @@ -23,18 +23,27 @@ import {EpochShuffling} from "./epochShuffling.js"; * * `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, or the genesis-decides-its-own-shuffling case), 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. + * 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, headBlockRoot: Root -): Root { +): Root | null { const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch); + if (state.slot === 0 && decisionSlot === 0) { + return null; + } if (state.slot <= decisionSlot) { return headBlockRoot; }