fix: reject proposer preferences with dependent root at/after lookahead epoch#9630
Conversation
…ad epoch
The gloas `validate_proposer_preferences_gossip` REJECTs when the dependent
block's state slot is `>= compute_start_slot_at_epoch(proposal_epoch -
MIN_SEED_LOOKAHEAD)` ("dependent root is not before the proposer lookahead
epoch"). Lodestar resolved the proposer lookahead via decision-root matching
and had no equivalent check, so a dependent root pointing at a block at the
lookahead epoch start slot was accepted instead of rejected.
Add the explicit dependent-block-slot REJECT before the proposer check,
mapping the spec's `lookahead_state.slot >= lookahead_epoch_start_slot`. The
dependent block is resolved from fork-choice; its ProtoBlock slot equals the
spec's `store.block_states[dependent_root].slot`.
Verified against the consensus-specs PR ChainSafe#5294 gloas gossip reftests:
gossip_proposer_preferences now 10/10 on both minimal and mainnet presets
(previously 9/10 — reject_dependent_root_at_lookahead_epoch_start was
accepted as valid).
🤖 Generated with AI assistance
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2499e3a17a
ℹ️ 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".
| // A dependent block at or after that slot cannot be the block the lookahead was derived from. | ||
| const lookaheadEpochStartSlot = computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD); | ||
| const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); | ||
| if (dependentBlock !== null && dependentBlock.slot >= lookaheadEpochStartSlot) { |
There was a problem hiding this comment.
Preserve genesis dependent roots before rejecting
When proposalEpoch <= MIN_SEED_LOOKAHEAD (for example Gloas-from-genesis/minimal devnets during epochs 0 or 1), the valid proposer dependent root is the genesis block because the rest of the duty logic clamps the underflowing decision slot to slot 0. Here lookaheadEpochStartSlot becomes -32 or 0, so the genesis block at slot 0 satisfies this condition and every otherwise valid proposer preference is rejected as INVALID_DEPENDENT_ROOT. Please keep the genesis underflow case valid, e.g. by guarding this check until proposalEpoch > MIN_SEED_LOOKAHEAD or by using the same clamped decision-slot logic.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7996f4d2. Went with the get_proposer_dependent_root decision slot rather than guarding the whole check:
const decisionSlot = Math.max(GENESIS_SLOT, computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD) - 1);
if (dependentBlock !== null && dependentBlock.slot > decisionSlot) { /* REJECT INVALID_DEPENDENT_ROOT */ }For proposalEpoch <= MIN_SEED_LOOKAHEAD this clamps to GENESIS_SLOT, so the genesis block (slot 0) stays valid instead of every early-epoch preference being rejected, while a too-recent block is still REJECTed. Good catch on the == MIN_SEED_LOOKAHEAD boundary specifically — a naive >= MIN_SEED_LOOKAHEAD guard would miss it.
| const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); | ||
| if (dependentBlock !== null && dependentBlock.slot >= lookaheadEpochStartSlot) { |
There was a problem hiding this comment.
Reject seen post-lookahead roots before proposer lookup
This check only runs after proposers has been resolved, so a preference whose dependent_root is a known block at or after lookaheadEpochStartSlot but is not also cached as the checkpoint root for proposalEpoch - 1 exits earlier with UNKNOWN_DEPENDENT_ROOT (IGNORE) and never reaches this REJECT branch. For example, a seen block at lookaheadEpochStartSlot + 1 is deterministically not a valid dependent root but will be treated as ignorable/queueable gossip rather than downscored; perform the block-slot validation immediately after confirming the block is known.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7996f4d2. Moved the dependent-block slot REJECT ahead of the proposer lookup, so a seen block at/after the decision slot is now REJECTed (downscored) instead of exiting early as ignorable UNKNOWN_DEPENDENT_ROOT. The dependentBlock !== null guard preserves spec precedence — an unseen root still falls through to the IGNORE path.
There was a problem hiding this comment.
Correction to my previous reply — I reverted this reorder in f1137d0e. Moving the slot REJECT ahead of the proposer-resolution IGNORE fails the gossip_proposer_preferences__ignore_dependent_root_state_unavailable spec fixture: it expects IGNORE (not REJECT) when the dependent root's state is unavailable, even for a seen block past the decision slot. So state-availability intentionally precedes the block-slot check (reverting took it 9/10 → 10/10 minimal, 10/10 mainnet). The genesis decision-slot clamp stays; only the ordering was reverted.
There was a problem hiding this comment.
Code Review
This pull request introduces validation for the dependent root of proposer preferences, ensuring the dependent block's state is strictly before the proposer lookahead epoch start. It adds a new error code INVALID_DEPENDENT_ROOT and implements the check in validateGossipProposerPreferences. The review feedback highlights a potential bug where proposalEpoch - MIN_SEED_LOOKAHEAD can evaluate to a negative number during early epochs, causing incorrect rejections. A code suggestion is provided to guard against this by checking if proposalEpoch >= MIN_SEED_LOOKAHEAD before executing the validation.
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 lookaheadEpochStartSlot = computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD); | ||
| const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); | ||
| if (dependentBlock !== null && dependentBlock.slot >= lookaheadEpochStartSlot) { |
There was a problem hiding this comment.
If proposalEpoch < MIN_SEED_LOOKAHEAD (which can happen during the first few epochs of the chain, e.g., in unit tests or custom testnets where proposalEpoch is 0 and MIN_SEED_LOOKAHEAD is 1 or more), the expression proposalEpoch - MIN_SEED_LOOKAHEAD will evaluate to a negative number.
Passing a negative number to computeStartSlotAtEpoch will return a negative slot number (e.g., -32). Consequently, the condition dependentBlock.slot >= lookaheadEpochStartSlot will always evaluate to true (since dependentBlock.slot is always >= 0), resulting in the unexpected rejection of all proposer preferences during these early epochs.
To prevent this, we should guard against negative epochs by using Math.max(0, ...) or by checking if proposalEpoch >= MIN_SEED_LOOKAHEAD before performing the check.
| const lookaheadEpochStartSlot = computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD); | |
| const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); | |
| if (dependentBlock !== null && dependentBlock.slot >= lookaheadEpochStartSlot) { | |
| if (proposalEpoch >= MIN_SEED_LOOKAHEAD) { | |
| const lookaheadEpochStartSlot = computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD); | |
| const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); | |
| if (dependentBlock !== null && dependentBlock.slot >= lookaheadEpochStartSlot) { | |
| throw new ProposerPreferencesError(GossipAction.REJECT, { | |
| code: ProposerPreferencesErrorCode.INVALID_DEPENDENT_ROOT, | |
| proposalSlot, | |
| dependentRoot: dependentRootHex, | |
| }); | |
| } | |
| } |
There was a problem hiding this comment.
Fixed in 7996f4d2, though not with the proposalEpoch >= MIN_SEED_LOOKAHEAD guard — that still rejects everything at proposalEpoch == MIN_SEED_LOOKAHEAD, where computeStartSlotAtEpoch(0) == 0 makes dependentBlock.slot >= 0 always true. Instead we clamp to the actual decision slot:
const decisionSlot = Math.max(GENESIS_SLOT, computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD) - 1);
if (dependentBlock !== null && dependentBlock.slot > decisionSlot) { /* REJECT */ }which keeps the genesis dependent valid across all early epochs while still rejecting deterministically-invalid roots.
…okup Address review on ChainSafe#9630: - Genesis underflow (Gemini HIGH, Codex): when proposalEpoch <= MIN_SEED_LOOKAHEAD the decision slot underflowed to a negative value, so `dependentBlock.slot >= lookaheadEpochStartSlot` was always true and every early-epoch proposer preference was wrongly REJECTed as INVALID_DEPENDENT_ROOT. Use the `get_proposer_dependent_root` decision slot `max(GENESIS_SLOT, start_slot(proposalEpoch - MIN_SEED_LOOKAHEAD) - 1)` and reject only blocks strictly after it, keeping the genesis-epoch dependent (the genesis block) valid. - Check ordering (Codex): run the dependent-block slot REJECT before the proposer lookup so a seen-but-too-recent dependent root is downscored (REJECT) instead of exiting early as ignorable UNKNOWN_DEPENDENT_ROOT gossip. 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revert the check reorder from the previous commit (Codex r3551903193 suggestion). The `gossip_proposer_preferences__ignore_dependent_root_state_unavailable` spec fixture requires IGNORE (not REJECT) when the dependent root's state is unavailable, even for a seen-but-too-recent block -- state-availability precedes the block-slot check. Running the slot REJECT before the proposer-resolution IGNORE flips that case to REJECT and fails the fixture (9/10). The genesis decision-slot clamp from the previous commit is unaffected and kept. Verified: gossip_proposer_preferences 10/10 minimal + 10/10 mainnet, tsgo + biome clean. 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // underflowing to a negative slot and rejecting every early-epoch preference. Kept after the | ||
| // proposer-resolution IGNORE so a dependent root whose state is unavailable stays IGNORE (per the | ||
| // `ignore_dependent_root_state_unavailable` spec fixture), not REJECT. | ||
| const decisionSlot = Math.max(GENESIS_SLOT, computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD) - 1); |
There was a problem hiding this comment.
can we make the comment less verbose please
There was a problem hiding this comment.
Trimmed to 2 lines in bd6b61ac72 (kept the GENESIS_SLOT clamp + the after-IGNORE ordering note).
Per review (nflaig): condense the 8-line comment on the proposer-preferences dependent-root REJECT to 2, keeping the two key points — the `GENESIS_SLOT` clamp (early-epoch underflow) and the after-state-IGNORE ordering (`ignore_dependent_root_state_unavailable` fixture). Comment-only. 🤖 Generated with AI assistance Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| }); | ||
| } | ||
|
|
||
| // [REJECT] Dependent block after the lookahead decision slot (clamped to `GENESIS_SLOT` so early epochs |
There was a problem hiding this comment.
@jtraglia what's your plan for bringing new checks like this to the spec, my gut feeling is those should be separate PRs and not hidden in the large executable spec PR, but curious what are your thoughts
There was a problem hiding this comment.
Yeah I should add it in a separate PR to the existing natural language specs. I forget the details, but I must have added it here because it was necessary when testing or identified it while reviewing.
Motivation
Running the consensus-specs PR #5294 gloas gossip validation reference tests against Lodestar surfaced a conformance gap in
proposer_preferencesgossip validation.The gloas
validate_proposer_preferences_gossipspec has a[REJECT]for the dependent root:Lodestar resolved the proposer lookahead via decision-root matching (head-state fast path or the previous-epoch checkpoint state) and had no equivalent check. A
SignedProposerPreferenceswhosedependent_rootpoints at a block at or afterstart_slot(proposal_epoch - MIN_SEED_LOOKAHEAD)was accepted asvalidinstead of being rejected — a checkpoint state keyed at that block still matches the decision root, so the downstream proposer check passes.Description
Add the explicit dependent-block-slot
[REJECT]before the proposer check. The dependent block is resolved from fork-choice; itsProtoBlock.slotequals the spec'sstore.block_states[dependent_root].slot(a block's post-state is at the block's slot). If it is>= start_slot(proposal_epoch - MIN_SEED_LOOKAHEAD), reject with a newINVALID_DEPENDENT_ROOTcode.The check is placed after the existing dependent-root resolution (so a genuinely unseen/unavailable dependent root still
[IGNORE]s, not[REJECT]s) and before the proposer-match[REJECT], matching the spec's ordering of the two rejects. ThedependentBlock !== nullguard keeps the non-over-rejecting direction if the block isn't in fork-choice.Verification
Against the PR #5294 gloas gossip reftests,
gossip_proposer_preferencesgoes from 9/10 → 10/10 on both minimal and mainnet presets —reject_dependent_root_at_lookahead_epoch_startnow rejects (was accepted as valid). The other 9 cases (4 ignore, 2 reject, valid, duplicate) are unchanged.Local:
biomeclean,tsgotype-check clean on the changed files.🤖 Generated with AI assistance