From b25ef0e847bf45b994852cf1616ed73cac99a1cb Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:49:33 -0700 Subject: [PATCH 1/3] feat: gate proposer boost on matching shuffling dependent root Implements `is_same_dependent_root` condition in `update_proposer_boost_root` (consensus-specs #5306). Proposer boost is now granted only when the imported block shares the canonical head's proposer-shuffling dependent root for the current epoch, withholding boost from blocks built on a divergent shuffling branch. Falls back to granting the boost on ancestor-lookup failure to preserve prior behavior. Unskips the 3 altair fork_choice spec tests that exercised the changed condition (justified_update_always_if_better, justified_update_not_realized_finality, voting_source_beyond_two_epoch). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/spec/presets/fork_choice.test.ts | 8 +--- .../fork-choice/src/forkChoice/forkChoice.ts | 37 ++++++++++++++++++- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index cdd8cb3109cc..bdc72a6b4e61 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -739,13 +739,7 @@ const forkChoiceTest = (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_previous_epoch") || - name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))) || - // TODO: re-enable after "apply proposer boost if dependent roots match" (consensus-specs #5306) - // is implemented. New behavior in v1.7.0-alpha.9; Lodestar still applies the boost - // unconditionally. Only the altair vectors exercise the changed condition (other forks pass). - name.endsWith("altair/fork_choice/on_block/pyspec_tests/justified_update_always_if_better") || - name.endsWith("altair/fork_choice/on_block/pyspec_tests/justified_update_not_realized_finality") || - name.endsWith("altair/fork_choice/get_head/pyspec_tests/voting_source_beyond_two_epoch"), + name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))), }, }; }; diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index bd0923a11093..e18824880378 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1,5 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; -import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; import { DataAvailabilityStatus, EffectiveBalanceIncrements, @@ -707,7 +707,9 @@ export class ForkChoice implements IForkChoice { // only boost the first block we see this.proposerBoostRoot === null && expectedProposerIndex !== null && - block.proposerIndex === expectedProposerIndex + block.proposerIndex === expectedProposerIndex && + // Spec: only boost when the block shares the canonical head's proposer-shuffling dependent root + this.isProposerBoostSameDependentRoot(this.head.blockRoot, parentRootHex) ) { this.proposerBoostRoot = blockRootHex; } @@ -1494,6 +1496,37 @@ export class ForkChoice implements IForkChoice { throw Error(`Not found dependent root for block slot ${block.slot}, epoch difference ${epochDifference}`); } + /** + * Spec: phase0/fork-choice.md#update_proposer_boost_root (`is_same_dependent_root` condition, + * consensus-specs #5306). Proposer boost is only granted when the imported block shares the same + * proposer-shuffling dependent root for the current epoch as the canonical head computed before + * the block was imported. This withholds the boost from a block built on a different shuffling + * branch than the head. + * + * The block is not yet in the proto-array when this runs, so its dependent root is traced from + * its parent — equivalent because the dependent slot is always in an earlier epoch than the + * (timely, current-slot) block. + */ + private isProposerBoostSameDependentRoot(headRootHex: RootHex, blockParentRootHex: RootHex): boolean { + const epoch = computeEpochAtSlot(this.fcStore.currentSlot); + // get_shuffling_dependent_root returns the genesis Root() for both head and block when + // epoch <= MIN_SEED_LOOKAHEAD, so the dependent roots trivially match. + if (epoch <= MIN_SEED_LOOKAHEAD) { + return true; + } + + const dependentSlot = computeStartSlotAtEpoch(epoch - MIN_SEED_LOOKAHEAD) - 1; + const headDependentRoot = this.protoArray.getAncestorOrNull(headRootHex, dependentSlot)?.blockRoot; + const blockDependentRoot = this.protoArray.getAncestorOrNull(blockParentRootHex, dependentSlot)?.blockRoot; + // On lookup failure (eg. ancestor pruned near non-finality) fall back to granting the boost, + // preserving the prior unconditional behavior rather than crashing block import. + if (headDependentRoot === undefined || blockDependentRoot === undefined) { + return true; + } + + return headDependentRoot === blockDependentRoot; + } + /** * Return true if the block is timely for the current slot. * Child class can overwrite this for testing purpose. From 22203c06065676d16b4c53dc4e3ddcaa88948c4a Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:59:55 -0700 Subject: [PATCH 2/3] docs: trim should_apply dependent-root comments Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/fork-choice/src/forkChoice/forkChoice.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index e18824880378..470e791cf509 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -708,7 +708,6 @@ export class ForkChoice implements IForkChoice { this.proposerBoostRoot === null && expectedProposerIndex !== null && block.proposerIndex === expectedProposerIndex && - // Spec: only boost when the block shares the canonical head's proposer-shuffling dependent root this.isProposerBoostSameDependentRoot(this.head.blockRoot, parentRootHex) ) { this.proposerBoostRoot = blockRootHex; @@ -1504,13 +1503,11 @@ export class ForkChoice implements IForkChoice { * branch than the head. * * The block is not yet in the proto-array when this runs, so its dependent root is traced from - * its parent — equivalent because the dependent slot is always in an earlier epoch than the - * (timely, current-slot) block. + * its parent */ private isProposerBoostSameDependentRoot(headRootHex: RootHex, blockParentRootHex: RootHex): boolean { const epoch = computeEpochAtSlot(this.fcStore.currentSlot); - // get_shuffling_dependent_root returns the genesis Root() for both head and block when - // epoch <= MIN_SEED_LOOKAHEAD, so the dependent roots trivially match. + // Genesis block parent if (epoch <= MIN_SEED_LOOKAHEAD) { return true; } @@ -1518,8 +1515,7 @@ export class ForkChoice implements IForkChoice { const dependentSlot = computeStartSlotAtEpoch(epoch - MIN_SEED_LOOKAHEAD) - 1; const headDependentRoot = this.protoArray.getAncestorOrNull(headRootHex, dependentSlot)?.blockRoot; const blockDependentRoot = this.protoArray.getAncestorOrNull(blockParentRootHex, dependentSlot)?.blockRoot; - // On lookup failure (eg. ancestor pruned near non-finality) fall back to granting the boost, - // preserving the prior unconditional behavior rather than crashing block import. + // On lookup failure, we lean on the conservative side and withold the boost if (headDependentRoot === undefined || blockDependentRoot === undefined) { return true; } From 15b2eab6c4e0d6f56054293e6f4fef662ba3a0a4 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:05:19 -0700 Subject: [PATCH 3/3] fix --- packages/fork-choice/src/forkChoice/forkChoice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 470e791cf509..51d358000c4d 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1517,7 +1517,7 @@ export class ForkChoice implements IForkChoice { const blockDependentRoot = this.protoArray.getAncestorOrNull(blockParentRootHex, dependentSlot)?.blockRoot; // On lookup failure, we lean on the conservative side and withold the boost if (headDependentRoot === undefined || blockDependentRoot === undefined) { - return true; + return false; } return headDependentRoot === blockDependentRoot;