fix: fall back to head root for proposer dep_root on future decision slot#9499
fix: fall back to head root for proposer dep_root on future decision slot#9499lodekeeper wants to merge 2 commits into
Conversation
…ision slot is in the future ChainSafe#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
There was a problem hiding this comment.
Code Review
This pull request updates the calculation of the proposer shuffling dependent root to prevent errors when the decision slot is in the future relative to a mid-epoch head state (e.g., pre-Fulu next-epoch duties). It modifies proposerShufflingDecisionRoot to accept and return the canonical head block root when the state slot is less than or equal to the decision slot, aligning with the beacon-API V1 fallback rules. Additionally, a regression unit test has been added to verify this behavior. There are no review comments, so no further feedback is provided.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dcba59f39b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, | ||
| state, | ||
| epoch, | ||
| fromHex(head.blockRoot) |
There was a problem hiding this comment.
Use the genesis root for genesis-duty fallback
When serving proposer duties for epoch 0 after the node head has advanced, this passes the current fork-choice head.blockRoot into the new fallback path even though the loaded historical state is at slot 0. Since the helper returns the supplied root when state.slot <= decisionSlot and epoch 0's decision slot is 0, dependentRoot is reported as the current head rather than the genesis block root that the previous getGenesisBlockRoot(state) fallback returned, breaking the Beacon API metadata for historical genesis duties.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — applied in commit 995db8b. Restored the genesis special case at the helper boundary: proposerShufflingDecisionRoot now 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 you flagged. Updated comments on both the helper and the call site to reflect the split.
Codex P2 (discussion_r3384632453) on ChainSafe#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
| // 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 |
There was a problem hiding this comment.
@lodekeeper using head.blockRoot is wrong for majority of the time
suggest to fallback to get_proposer_duties() v1, which is how it worked in v1.43 #9518
There was a problem hiding this comment.
You're right — thanks, I'm convinced. head.blockRoot changes every slot, so for the pre-Fulu currentEpoch + 1 request from a mid-epoch head state it makes the V1 dependent_root churn every block instead of being a stable decision root. That defeats the point of dependent_root — a VC would see it change constantly and re-poll every slot.
And my Lighthouse citation doesn't actually apply here: LH's head_block_root branch is the genesis / at-decision edge of the current-epoch computation, not a future requested epoch.
#9518's approach is the correct one: when the requested epoch's decision slot is in the future, fall back to the state-epoch decision root (last block of the previous epoch). It's stable, it flips to the real decision root at the epoch boundary (correctly signaling a re-fetch), and it matches v1.43 — your test asserting V1 == V2 and a stable root captures exactly that.
|
Closing in favor of #9518 (twoeths), which fixes the same #9380 regression by falling back to the state-epoch decision root (the v1.43 behavior) instead of the head block root. His approach is the better call here:
My only rationale for head-root was to match the beacon-APIs |
Motivation
After #9380,
getProposerDuties(V1) returns500withfor
currentEpoch + 1whenever the head state is mid-epoch. Reproduced on mainnet against a Vero VC; the BN spams the error once per V1 duty request (~once per epoch per VC), and the VC never gets next-epoch proposer duties from this BN.getProposerDuties(V1) on Lodestar reachesproposerShufflingDecisionRoot(ForkName.phase0, state, epoch=stateEpoch+1, …). Pre-Fulu, the decision slot isstartSlot(stateEpoch + 1) - 1(the last slot of the current epoch), which is in the future relative to a mid-epoch head state —state.getBlockRootAtSlotthrows.The
validator/index.ts:1219cp-state branch only loads the upcoming-epoch checkpoint state whennearNextEpochis true, so any VC that polls before the last second of the epoch (the Vero / spec-conformant pattern of fetching(current, current + 1)at the epoch boundary) falls through tochain.getHeadStateAtCurrentEpoch()and trips the throw.Description
Beacon-API V1 spec for
dependent_root:i.e. the dep_root falls back to the head block root when the literal slot is not in
state.block_roots. Lighthouse'slegacy_proposer_shuffling_decision_root_at_epochimplements exactly that:This PR adopts the same approach for Lodestar:
proposerShufflingDecisionRoottakes the head block root and returns it whenstate.slot <= decisionSlot.state.slot === decisionSlot === 0) collapses into the same branch —head.blockRootat genesis is the genesis block root — so the oldnull+getGenesisBlockRootfallback at the V1/V2 call site is no longer needed. (getGenesisBlockRootis still used by the sync-committee duties paths.)decisionEpoch = proposalEpoch - 1) is unchanged: the decision slot stays well in the past, so the new fallback branch isn't entered.Vero (the affected VC) is spec-conformant — it fetches
(current_epoch, current_epoch + 1)via V1 once per epoch + on SSE head events, exactly the pattern beacon-APIs recommend.References
fix: download proposer duties for the next epoch post-fulu, c5efeb6)apis/validator/duties/proposer.yaml(V1 dep_root spec)consensus/types/src/state/beacon_state.rslegacy_proposer_shuffling_decision_root_at_epochSteps to test or reproduce
unstable(orv1.43.0) on mainnet with any VC that callsGET /eth/v1/validator/duties/proposer/{currentEpoch + 1}mid-epoch (e.g. Vero, orcurldirectly).500and the BN log:getProposerDuties error - Can only get block root in the past currentSlot=… slot=….dependentRoot = head.blockRoot, matching the V1 spec'sevent.blockrule.Unit test added:
should get proposers for next epoch via V1 from a mid-epoch head stateinpackages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts.🤖 Generated with AI assistance