Skip to content

fix: fall back to head root for proposer dep_root on future decision slot#9499

Closed
lodekeeper wants to merge 2 commits into
ChainSafe:unstablefrom
lodekeeper:fix/proposer-duties-decision-root-future-slot
Closed

fix: fall back to head root for proposer dep_root on future decision slot#9499
lodekeeper wants to merge 2 commits into
ChainSafe:unstablefrom
lodekeeper:fix/proposer-duties-decision-root-future-slot

Conversation

@lodekeeper

Copy link
Copy Markdown
Contributor

Motivation

After #9380, getProposerDuties (V1) returns 500 with

error: getProposerDuties error - Can only get block root in the past currentSlot=<S> slot=<S+k>
    at getBlockRootAtSlot (state-transition/src/util/blockRoot.ts:21)
    at proposerShufflingDecisionRoot (state-transition/src/util/shuffling.ts:36)
    at getProposerDuties (beacon-node/src/api/impl/validator/index.ts:1298)

for currentEpoch + 1 whenever 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 reaches proposerShufflingDecisionRoot(ForkName.phase0, state, epoch=stateEpoch+1, …). Pre-Fulu, the decision slot is startSlot(stateEpoch + 1) - 1 (the last slot of the current epoch), which is in the future relative to a mid-epoch head state — state.getBlockRootAtSlot throws.

The validator/index.ts:1219 cp-state branch only loads the upcoming-epoch checkpoint state when nearNextEpoch is 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 to chain.getHeadStateAtCurrentEpoch() and trips the throw.

Description

Beacon-API V1 spec for dependent_root:

get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1) … VCs match against event.current_duty_dependent_root when compute_epoch_at_slot(event.slot) == epoch, and against event.block otherwise.

i.e. the dep_root falls back to the head block root when the literal slot is not in state.block_roots. Lighthouse's legacy_proposer_shuffling_decision_root_at_epoch implements exactly that:

if self.slot() <= decision_slot { Ok(head_block_root) } else { self.get_block_root(decision_slot).copied() }

This PR adopts the same approach for Lodestar:

  • proposerShufflingDecisionRoot takes the head block root and returns it when state.slot <= decisionSlot.
  • The genesis special-case (state.slot === decisionSlot === 0) collapses into the same branch — head.blockRoot at genesis is the genesis block root — so the old null + getGenesisBlockRoot fallback at the V1/V2 call site is no longer needed. (getGenesisBlockRoot is still used by the sync-committee duties paths.)
  • V2 / post-Fulu (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

Steps to test or reproduce

  1. Run Lodestar unstable (or v1.43.0) on mainnet with any VC that calls GET /eth/v1/validator/duties/proposer/{currentEpoch + 1} mid-epoch (e.g. Vero, or curl directly).
  2. Observe the per-request 500 and the BN log: getProposerDuties error - Can only get block root in the past currentSlot=… slot=….
  3. With this PR, the request returns the duties with dependentRoot = head.blockRoot, matching the V1 spec's event.block rule.

Unit test added: should get proposers for next epoch via V1 from a mid-epoch head state in packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts.

🤖 Generated with AI assistance

…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
@lodekeeper lodekeeper requested a review from a team as a code owner June 10, 2026 00:14

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 #9499 in favor of #9518. 👍

@lodekeeper

Copy link
Copy Markdown
Contributor Author

Closing in favor of #9518 (@twoeths), which restores the v1.43 fallback to the state-epoch decision root instead of the head-block-root approach I used here (which churns every block for the next-epoch V1 case). Full reasoning in the review thread. Thanks for the catch.

@lodekeeper lodekeeper closed this Jun 17, 2026
@lodekeeper

Copy link
Copy Markdown
Contributor Author

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:

  • Post-Fulu it returns the same dependentRoot as V2 for the next-epoch query, so V1/V2 stay consistent — this PR's head-root deliberately differed.
  • It's the proven v1.43 behavior, so lower risk for the v1.44.0 release.

My only rationale for head-root was to match the beacon-APIs event.block rule for future-epoch duty comparison — but since a spec-conformant VC compares next-epoch duties against the head block anyway, the stable state-epoch root loses nothing in practice. Agreed it's the cleaner fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants