From 2499e3a17ab7d5ef62034c3dc472b211a9673b48 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Thu, 9 Jul 2026 13:16:37 +0000 Subject: [PATCH 1/4] fix: reject proposer preferences with dependent root at/after lookahead epoch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 --- .../src/chain/errors/proposerPreferences.ts | 6 ++++++ .../src/chain/validation/proposerPreferences.ts | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/errors/proposerPreferences.ts b/packages/beacon-node/src/chain/errors/proposerPreferences.ts index 32804c47f10b..d7be500a858a 100644 --- a/packages/beacon-node/src/chain/errors/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/errors/proposerPreferences.ts @@ -5,6 +5,7 @@ export enum ProposerPreferencesErrorCode { INVALID_EPOCH = "PROPOSER_PREFERENCES_ERROR_INVALID_EPOCH", PROPOSAL_SLOT_PASSED = "PROPOSER_PREFERENCES_ERROR_PROPOSAL_SLOT_PASSED", UNKNOWN_DEPENDENT_ROOT = "PROPOSER_PREFERENCES_ERROR_UNKNOWN_DEPENDENT_ROOT", + INVALID_DEPENDENT_ROOT = "PROPOSER_PREFERENCES_ERROR_INVALID_DEPENDENT_ROOT", INVALID_PROPOSER = "PROPOSER_PREFERENCES_ERROR_INVALID_PROPOSER", ALREADY_KNOWN = "PROPOSER_PREFERENCES_ERROR_ALREADY_KNOWN", INVALID_SIGNATURE = "PROPOSER_PREFERENCES_ERROR_INVALID_SIGNATURE", @@ -26,6 +27,11 @@ export type ProposerPreferencesErrorType = proposalSlot: Slot; dependentRoot: RootHex; } + | { + code: ProposerPreferencesErrorCode.INVALID_DEPENDENT_ROOT; + proposalSlot: Slot; + dependentRoot: RootHex; + } | { code: ProposerPreferencesErrorCode.INVALID_PROPOSER; proposalSlot: Slot; diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts index 8c2fbe1689ab..a4e95fac6697 100644 --- a/packages/beacon-node/src/chain/validation/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -1,6 +1,7 @@ -import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; import { computeEpochAtSlot, + computeStartSlotAtEpoch, createSingleSignatureSetFromComponents, getProposerPreferencesSigningRoot, } from "@lodestar/state-transition"; @@ -70,6 +71,20 @@ export async function validateGossipProposerPreferences( }); } + // [REJECT] The dependent root is a valid dependent block for the proposal slot: the dependent + // block's state must be strictly before the proposer lookahead epoch start. Spec: + // `lookahead_state.slot >= compute_start_slot_at_epoch(proposal_epoch - MIN_SEED_LOOKAHEAD)` rejects. + // 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) { + throw new ProposerPreferencesError(GossipAction.REJECT, { + code: ProposerPreferencesErrorCode.INVALID_DEPENDENT_ROOT, + proposalSlot, + dependentRoot: dependentRootHex, + }); + } + // [REJECT] `is_valid_proposal_slot(state, preferences)` returns True. if (proposers[proposalSlot % SLOTS_PER_EPOCH] !== validatorIndex) { throw new ProposerPreferencesError(GossipAction.REJECT, { From 7996f4d2135b49ffecc77b5f5e69a2fb76a283a5 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Thu, 9 Jul 2026 14:13:44 +0000 Subject: [PATCH 2/4] fix: clamp dependent-root decision slot and reject before proposer lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #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 --- .../chain/validation/proposerPreferences.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts index a4e95fac6697..a5bdac2edd89 100644 --- a/packages/beacon-node/src/chain/validation/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -1,4 +1,4 @@ -import {MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {GENESIS_SLOT, MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; import { computeEpochAtSlot, computeStartSlotAtEpoch, @@ -43,6 +43,24 @@ export async function validateGossipProposerPreferences( }); } + // [REJECT] A dependent block after the lookahead decision slot cannot be the block the proposer + // lookahead was derived from. `get_proposer_dependent_root` is `block_root_at_slot( + // start_slot(proposal_epoch - MIN_SEED_LOOKAHEAD) - 1)` clamped to `GENESIS_SLOT`, so a valid + // dependent block is at or before that (clamped) decision slot. Clamping keeps genesis-epoch + // dependents (`proposalEpoch <= MIN_SEED_LOOKAHEAD` → the genesis block) valid instead of + // underflowing to a negative slot and rejecting every early-epoch preference. Checked before the + // proposer lookup below so a seen-but-too-recent dependent root is REJECTed (downscored) rather + // than exiting as ignorable UNKNOWN_DEPENDENT_ROOT gossip. + const decisionSlot = Math.max(GENESIS_SLOT, computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD) - 1); + const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); + if (dependentBlock !== null && dependentBlock.slot > decisionSlot) { + throw new ProposerPreferencesError(GossipAction.REJECT, { + code: ProposerPreferencesErrorCode.INVALID_DEPENDENT_ROOT, + proposalSlot, + dependentRoot: dependentRootHex, + }); + } + // [IGNORE] The block with root `dependent_root` has been seen by the node. // Resolve the proposer lookahead for the message's branch via head state (fast path) or // the previous-root checkpoint state (populated by `processSlotsToNearestCheckpoint` for @@ -71,20 +89,6 @@ export async function validateGossipProposerPreferences( }); } - // [REJECT] The dependent root is a valid dependent block for the proposal slot: the dependent - // block's state must be strictly before the proposer lookahead epoch start. Spec: - // `lookahead_state.slot >= compute_start_slot_at_epoch(proposal_epoch - MIN_SEED_LOOKAHEAD)` rejects. - // 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) { - throw new ProposerPreferencesError(GossipAction.REJECT, { - code: ProposerPreferencesErrorCode.INVALID_DEPENDENT_ROOT, - proposalSlot, - dependentRoot: dependentRootHex, - }); - } - // [REJECT] `is_valid_proposal_slot(state, preferences)` returns True. if (proposers[proposalSlot % SLOTS_PER_EPOCH] !== validatorIndex) { throw new ProposerPreferencesError(GossipAction.REJECT, { From f1137d0e0ccb5b0c04ec2f6b585867ad85bfa595 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Thu, 9 Jul 2026 14:24:44 +0000 Subject: [PATCH 3/4] fix: keep dependent-root REJECT after the state-availability IGNORE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../chain/validation/proposerPreferences.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts index a5bdac2edd89..9a4a67b00fa2 100644 --- a/packages/beacon-node/src/chain/validation/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -43,24 +43,6 @@ export async function validateGossipProposerPreferences( }); } - // [REJECT] A dependent block after the lookahead decision slot cannot be the block the proposer - // lookahead was derived from. `get_proposer_dependent_root` is `block_root_at_slot( - // start_slot(proposal_epoch - MIN_SEED_LOOKAHEAD) - 1)` clamped to `GENESIS_SLOT`, so a valid - // dependent block is at or before that (clamped) decision slot. Clamping keeps genesis-epoch - // dependents (`proposalEpoch <= MIN_SEED_LOOKAHEAD` → the genesis block) valid instead of - // underflowing to a negative slot and rejecting every early-epoch preference. Checked before the - // proposer lookup below so a seen-but-too-recent dependent root is REJECTed (downscored) rather - // than exiting as ignorable UNKNOWN_DEPENDENT_ROOT gossip. - const decisionSlot = Math.max(GENESIS_SLOT, computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD) - 1); - const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); - if (dependentBlock !== null && dependentBlock.slot > decisionSlot) { - throw new ProposerPreferencesError(GossipAction.REJECT, { - code: ProposerPreferencesErrorCode.INVALID_DEPENDENT_ROOT, - proposalSlot, - dependentRoot: dependentRootHex, - }); - } - // [IGNORE] The block with root `dependent_root` has been seen by the node. // Resolve the proposer lookahead for the message's branch via head state (fast path) or // the previous-root checkpoint state (populated by `processSlotsToNearestCheckpoint` for @@ -89,6 +71,24 @@ export async function validateGossipProposerPreferences( }); } + // [REJECT] A dependent block after the lookahead decision slot cannot be the block the proposer + // lookahead was derived from. `get_proposer_dependent_root` is `block_root_at_slot( + // start_slot(proposal_epoch - MIN_SEED_LOOKAHEAD) - 1)` clamped to `GENESIS_SLOT`, so a valid + // dependent block is at or before that (clamped) decision slot. Clamping keeps genesis-epoch + // dependents (`proposalEpoch <= MIN_SEED_LOOKAHEAD` → the genesis block) valid instead of + // 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); + const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); + if (dependentBlock !== null && dependentBlock.slot > decisionSlot) { + throw new ProposerPreferencesError(GossipAction.REJECT, { + code: ProposerPreferencesErrorCode.INVALID_DEPENDENT_ROOT, + proposalSlot, + dependentRoot: dependentRootHex, + }); + } + // [REJECT] `is_valid_proposal_slot(state, preferences)` returns True. if (proposers[proposalSlot % SLOTS_PER_EPOCH] !== validatorIndex) { throw new ProposerPreferencesError(GossipAction.REJECT, { From bd6b61ac729f638f82636773f06e61fdfadd5f94 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Fri, 10 Jul 2026 00:15:11 +0000 Subject: [PATCH 4/4] docs(beacon-node): trim verbose dependent-root REJECT comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/chain/validation/proposerPreferences.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts index 9a4a67b00fa2..43dae26ebb7a 100644 --- a/packages/beacon-node/src/chain/validation/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -71,14 +71,8 @@ export async function validateGossipProposerPreferences( }); } - // [REJECT] A dependent block after the lookahead decision slot cannot be the block the proposer - // lookahead was derived from. `get_proposer_dependent_root` is `block_root_at_slot( - // start_slot(proposal_epoch - MIN_SEED_LOOKAHEAD) - 1)` clamped to `GENESIS_SLOT`, so a valid - // dependent block is at or before that (clamped) decision slot. Clamping keeps genesis-epoch - // dependents (`proposalEpoch <= MIN_SEED_LOOKAHEAD` → the genesis block) valid instead of - // 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. + // [REJECT] Dependent block after the lookahead decision slot (clamped to `GENESIS_SLOT` so early epochs + // don't underflow). Kept after the proposer-resolution IGNORE so state-unavailable roots stay IGNORE (`ignore_dependent_root_state_unavailable`). const decisionSlot = Math.max(GENESIS_SLOT, computeStartSlotAtEpoch(proposalEpoch - MIN_SEED_LOOKAHEAD) - 1); const dependentBlock = chain.forkChoice.getBlockHexDefaultStatus(dependentRootHex); if (dependentBlock !== null && dependentBlock.slot > decisionSlot) {