Skip to content

fix: reject proposer preferences with dependent root at/after lookahead epoch#9630

Open
lodekeeper wants to merge 4 commits into
ChainSafe:unstablefrom
lodekeeper:fix/gloas-proposer-preferences-dependent-root
Open

fix: reject proposer preferences with dependent root at/after lookahead epoch#9630
lodekeeper wants to merge 4 commits into
ChainSafe:unstablefrom
lodekeeper:fix/gloas-proposer-preferences-dependent-root

Conversation

@lodekeeper

Copy link
Copy Markdown
Contributor

Motivation

Running the consensus-specs PR #5294 gloas gossip validation reference tests against Lodestar surfaced a conformance gap in proposer_preferences gossip validation.

The gloas validate_proposer_preferences_gossip spec has a [REJECT] for the dependent root:

# [REJECT] The dependent root is a valid dependent block for the proposal slot
lookahead_epoch = Epoch(proposal_epoch - MIN_SEED_LOOKAHEAD)
lookahead_epoch_start_slot = compute_start_slot_at_epoch(lookahead_epoch)
lookahead_state = store.block_states[preferences.dependent_root].copy()
if lookahead_state.slot >= lookahead_epoch_start_slot:
    raise GossipReject("dependent root is not before the proposer lookahead epoch")

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 SignedProposerPreferences whose dependent_root points at a block at or after start_slot(proposal_epoch - MIN_SEED_LOOKAHEAD) was accepted as valid instead 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; its ProtoBlock.slot equals the spec's store.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 new INVALID_DEPENDENT_ROOT code.

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. The dependentBlock !== null guard keeps the non-over-rejecting direction if the block isn't in fork-choice.

Verification

Against the PR #5294 gloas gossip reftests, gossip_proposer_preferences goes from 9/10 → 10/10 on both minimal and mainnet presets — reject_dependent_root_at_lookahead_epoch_start now rejects (was accepted as valid). The other 9 cases (4 ignore, 2 reject, valid, duplicate) are unchanged.

Local: biome clean, tsgo type-check clean on the changed files.


🤖 Generated with AI assistance

…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
@lodekeeper lodekeeper requested a review from a team as a code owner July 9, 2026 13:17

@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: 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) {

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

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.

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.

Comment on lines +79 to +80
const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex);
if (dependentBlock !== null && dependentBlock.slot >= lookaheadEpochStartSlot) {

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

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.

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.

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.

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.

@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 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.

Comment on lines +78 to +80
const lookaheadEpochStartSlot = computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD);
const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex);
if (dependentBlock !== null && dependentBlock.slot >= lookaheadEpochStartSlot) {

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

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.

Suggested change
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,
});
}
}

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.

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.

lodekeeper and others added 2 commits July 9, 2026 14:13
…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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we make the comment less verbose please

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.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@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

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.

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.

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.

5 participants