Skip to content

fix: derive proposer duties dependent root from state#9498

Closed
lodekeeper wants to merge 1 commit into
ChainSafe:unstablefrom
lodekeeper:fix/proposer-duties-fulu-dependent-root
Closed

fix: derive proposer duties dependent root from state#9498
lodekeeper wants to merge 1 commit into
ChainSafe:unstablefrom
lodekeeper:fix/proposer-duties-fulu-dependent-root

Conversation

@lodekeeper

Copy link
Copy Markdown
Contributor

Motivation

GET /eth/v1/validator/duties/proposer/{epoch} can be called for the next epoch while the node is still mid-epoch. After #9380, proposerShufflingDecisionRoot() computed the dependent root from the requested proposal epoch. For a v1 next-epoch request this points at start(nextEpoch) - 1, which is still in the future for most of the current epoch and trips getBlockRootAtSlot() with:

Can only get block root in the past currentSlot=14518170 slot=14518175

This showed up from Vero polling proposer duties against a Lodestar BN. Lodestar VC uses the v2 route post-Fulu, but the BN should serve the v1 next-epoch route without a 500 too.

Changes

  • Derive proposer-duty dependent root from the state used to serve the proposer list, instead of from the requested epoch.
  • Keep the post-Fulu offset behavior by applying it to state.epoch; near-boundary currentEpoch + 2 v2 duties are still served from the checkpoint state advanced to nextEpoch.
  • Add a regression test for v1 next-epoch proposer duties mid-epoch, asserting we use the current state decision slot and do not read start(nextEpoch) - 1.

Spec / client audit

  • Fulu proposer_lookahead starts from the beginning of the state current epoch and covers MIN_SEED_LOOKAHEAD + 1 epochs.
  • process_proposer_lookahead() computes the newly appended epoch at the epoch transition, so the state epoch is the right anchor for the dependent root of the proposer list being returned.
  • Vero-style next-epoch prefetch is valid validator-client behavior; the BN response must not depend on the caller arriving in the last slot of the epoch.

Verification

  • pnpm build
  • pnpm vitest run --project unit packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts
  • pnpm check-types
  • pnpm lint

Authored with AI assistance (OpenAI Codex / Lodekeeper).

@lodekeeper lodekeeper requested a review from a team as a code owner June 10, 2026 00:12

@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: 936d2d15cc

ℹ️ 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".

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) ||
proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the v2 next-epoch dependent root

When v2 post-Fulu duties for epoch N+1 are served from a state at epoch N, this call now derives the root from state.epoch and returns block_root(start(N-1)-1). Those duties come from state.nextProposers, which is keyed by state.nextDecisionRoot / the v2 spec root block_root(start(N)-1); the Gloas proposer-preference validator also checks headState.nextDecisionRoot for proposalEpoch - 1. As a result, validator clients that poll next-epoch v2 duties will sign preferences with a dependent root the beacon node rejects as unknown.

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.

Confirmed — closing #9498 in favor of #9499 which keeps proposalEpoch as a parameter and adopts the Lighthouse-style head-root fallback for the future-decision-slot case. That preserves the v2 post-Fulu block_root(start(N) - 1) semantics you flagged here.

@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 modifies the proposer shuffling decision root calculation by removing the proposalEpoch parameter and relying on state.epoch instead, and adds corresponding unit tests. The reviewer points out that removing epoch introduces a bug when requesting proposer duties for the previous epoch (currentEpoch - 1), where state.epoch is currentEpoch, leading to an incorrect dependent root. The reviewer suggests keeping the proposalEpoch parameter and capping the computed decision slot if it is in the future to resolve the mid-epoch next-epoch future slot issue.

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.

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) ||
proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state) ||

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.

high

By removing epoch from proposerShufflingDecisionRoot and relying solely on state.epoch, we introduce a bug when requesting proposer duties for the previous epoch (currentEpoch - 1). In that case, state.epoch is currentEpoch, so the returned dependent root will incorrectly correspond to the current epoch instead of the previous epoch. We should keep passing epoch here and handle the mid-epoch next-epoch future slot issue inside proposerShufflingDecisionRoot by capping the decision slot.

Suggested change
proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state) ||
proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state, epoch) ||

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.

Confirmed — closing #9498. The prev-epoch breakage you flagged is real, and state.epoch was the wrong anchor. #9499 keeps proposalEpoch as a parameter.

Comment on lines +20 to 39
* Block root that decided the proposer shuffling for the state used to serve proposer duties.
*
* Returns `null` when the genesis block decides its own shuffling (caller falls back to the
* genesis block root).
*/
export function proposerShufflingDecisionRoot(
fork: ForkName,
state: IBeaconStateView,
proposalEpoch: Epoch
): Root | null {
const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch);
export function proposerShufflingDecisionRoot(fork: ForkName, state: IBeaconStateView): Root | null {
const decisionSlot = proposerShufflingDecisionSlot(fork, state);
if (state.slot === decisionSlot) {
return null;
}
return state.getBlockRootAtSlot(decisionSlot);
}

/** Slot whose block root keys the proposer shuffling for `proposalEpoch`. */
function proposerShufflingDecisionSlot(fork: ForkName, proposalEpoch: Epoch): Slot {
/** Slot whose block root keys the proposer shuffling for `state`. */
function proposerShufflingDecisionSlot(fork: ForkName, state: IBeaconStateView): Slot {
// Post-Fulu the shuffling is decided one epoch earlier (deterministic proposer lookahead,
// MIN_SEED_LOOKAHEAD = 1); pre-Fulu it is the last block before `proposalEpoch`.
const decisionEpoch = isForkPostFulu(fork) ? proposalEpoch - 1 : proposalEpoch;
// MIN_SEED_LOOKAHEAD = 1); pre-Fulu it is the last block before `state.epoch`.
const decisionEpoch = isForkPostFulu(fork) ? state.epoch - 1 : state.epoch;
return Math.max(computeStartSlotAtEpoch(decisionEpoch) - 1, 0);
}

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.

high

To prevent returning the wrong dependent root for previous epoch requests (where state.epoch is currentEpoch but the requested epoch is currentEpoch - 1), we should keep proposalEpoch in the signature. To resolve the original issue where requesting the next epoch mid-epoch pre-Fulu attempts to read a future block root, we can cap the computed decisionSlot to the last slot of the state's current epoch if it is in the future.

 * Block root that decided the proposer shuffling for `proposalEpoch` (keys that shuffling).
 * 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).
 */
export function proposerShufflingDecisionRoot(
  fork: ForkName,
  state: IBeaconStateView,
  proposalEpoch: Epoch
): Root | null {
  const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch, state.slot);
  if (state.slot === decisionSlot) {
    return null;
  }
  return state.getBlockRootAtSlot(decisionSlot);
}

/** Slot whose block root keys the proposer shuffling for `proposalEpoch`. */
function proposerShufflingDecisionSlot(fork: ForkName, proposalEpoch: Epoch, currentSlot: Slot): Slot {
  // Post-Fulu the shuffling is decided one epoch earlier (deterministic proposer lookahead,
  // MIN_SEED_LOOKAHEAD = 1); pre-Fulu it is the last block before `proposalEpoch`.
  const decisionEpoch = isForkPostFulu(fork) ? proposalEpoch - 1 : proposalEpoch;
  const decisionSlot = Math.max(computeStartSlotAtEpoch(decisionEpoch) - 1, 0);
  if (decisionSlot >= currentSlot) {
    const currentEpoch = Math.floor(currentSlot / SLOTS_PER_EPOCH);
    return Math.max(computeStartSlotAtEpoch(currentEpoch) - 1, 0);
  }
  return decisionSlot;
}

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.

Closing #9498#9499 takes the same direction your suggestion pointed at (keep proposalEpoch, handle the future-decision-slot case at the helper boundary) but adopts the Lighthouse-style head-root fallback rather than a cap, matching the beacon-APIs V1 event.block rule for that case. Genesis edge case is preserved via a Root | null return + getGenesisBlockRoot(state) fallback at the call site (per Codex's P2 on #9499).

@lodekeeper

Copy link
Copy Markdown
Contributor Author

Closing — superseded by #9499. Both Codex (discussion_r3384629139) and Gemini (discussion_r3384629956) correctly flagged that dropping proposalEpoch and deriving from state.epoch breaks two distinct call paths: prev-epoch v1 queries return block_root(start(state.epoch) - 1) instead of the prev-epoch boundary, and v2 post-Fulu next-epoch queries return block_root(start(state.epoch - 1) - 1) instead of the spec-required block_root(start(state.epoch) - 1).

#9499 takes the Lighthouse-style approach: keep proposalEpoch as a parameter, fall back to the head block root only when the decision slot is at or after state.slot (the original currentEpoch + 1 v1 throw that motivated this PR). That matches the beacon-APIs V1 spec rule of using event.block when the literal decision slot is not yet in state.block_roots. Genesis edge case from Codex's P2 on #9499 is fixed in commit 995db8b.

@lodekeeper lodekeeper closed this Jun 10, 2026
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.

1 participant