From 6ff9833288bab4938cfad2c26001381b0aea353d Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 3 Apr 2026 10:18:20 +0700 Subject: [PATCH 1/2] fix: pass prepareSlot to fork choice head for Gloas FULL vs EMPTY tie-breaker Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/beacon-node/src/chain/chain.ts | 4 ++-- packages/beacon-node/src/chain/interface.ts | 3 ++- packages/beacon-node/src/chain/prepareNextSlot.ts | 2 +- packages/fork-choice/src/forkChoice/forkChoice.ts | 14 ++++++++++---- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index ea2b9870eea1..59280a457ce5 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1080,12 +1080,12 @@ export class BeaconChain implements IBeaconChain { }; } - recomputeForkChoiceHead(caller: ForkchoiceCaller): ProtoBlock { + recomputeForkChoiceHead(caller: ForkchoiceCaller, slot?: Slot): ProtoBlock { this.metrics?.forkChoice.requests.inc(); const timer = this.metrics?.forkChoice.findHead.startTimer({caller}); try { - return this.forkChoice.updateAndGetHead({mode: UpdateHeadOpt.GetCanonicalHead}).head; + return this.forkChoice.updateAndGetHead({mode: UpdateHeadOpt.GetCanonicalHead, slot}).head; } catch (e) { this.metrics?.forkChoice.errors.inc({entrypoint: UpdateHeadOpt.GetCanonicalHead}); throw e; diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index d6d31e6a2a4e..b4f9b6f0c5e2 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -248,7 +248,8 @@ export interface IBeaconChain { getStatus(): Status; - recomputeForkChoiceHead(caller: ForkchoiceCaller): ProtoBlock; + /** @param slot - If provided, overrides fcStore.currentSlot for Gloas FULL vs EMPTY tie-breaker logic */ + recomputeForkChoiceHead(caller: ForkchoiceCaller, slot?: Slot): ProtoBlock; /** When proposerBoostReorg is enabled, this is called at slot n-1 to predict the head block to build on if we are proposing at slot n */ predictProposerHead(slot: Slot): ProtoBlock; diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index cb5773eb2da0..af076efa3fed 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -78,7 +78,7 @@ export class PrepareNextSlotScheduler { await sleep(this.config.getSlotComponentDurationMs(PREPARE_NEXT_SLOT_BPS), this.signal); // calling updateHead() here before we produce a block to reduce reorg possibility - const headBlock = this.chain.recomputeForkChoiceHead(ForkchoiceCaller.prepareNextSlot); + const headBlock = this.chain.recomputeForkChoiceHead(ForkchoiceCaller.prepareNextSlot, prepareSlot); const {slot: headSlot, blockRoot: headRoot} = headBlock; // PS: previously this was comparing slots, but that gave no leway on the skipped diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 9f4c1f4ecb57..74d258f4466c 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -65,7 +65,8 @@ export enum UpdateHeadOpt { } export type UpdateAndGetHeadOpt = - | {mode: UpdateHeadOpt.GetCanonicalHead} + // When slot is provided, it overrides fcStore.currentSlot for Gloas FULL vs EMPTY tie-breaker logic + | {mode: UpdateHeadOpt.GetCanonicalHead; slot?: Slot} | {mode: UpdateHeadOpt.GetProposerHead; secFromSlot: number; slot: Slot} | {mode: UpdateHeadOpt.GetPredictedProposerHead; secFromSlot: number; slot: Slot}; @@ -222,7 +223,8 @@ export class ForkChoice implements IForkChoice { } { const {mode} = opt; - const canonicalHeadBlock = mode === UpdateHeadOpt.GetPredictedProposerHead ? this.getHead() : this.updateHead(); + const canonicalHeadBlock = + mode === UpdateHeadOpt.GetPredictedProposerHead ? this.getHead() : this.updateHead(opt.slot); switch (mode) { case UpdateHeadOpt.GetPredictedProposerHead: return {head: this.predictProposerHead(canonicalHeadBlock, opt.secFromSlot, opt.slot)}; @@ -458,8 +460,10 @@ export class ForkChoice implements IForkChoice { * Is equivalent to: * * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_head + * + * @param slot - If provided, overrides fcStore.currentSlot for Gloas FULL vs EMPTY tie-breaker logic */ - updateHead(): ProtoBlock { + updateHead(slot?: Slot): ProtoBlock { // balances is not changed but votes are changed // NOTE: In current Lodestar metrics, 100% of forkChoiceRequests this.synced = false. @@ -516,7 +520,9 @@ export class ForkChoice implements IForkChoice { this.justifiedProposerBoostScore = proposerBoostScore; } - const currentSlot = this.fcStore.currentSlot; + // When preparing for the next slot, pass slot as currentSlot + 1 to choose FULL vs EMPTY + // This is important for Gloas tie-breaker logic + const currentSlot = slot ?? this.fcStore.currentSlot; this.protoArray.applyScoreChanges({ deltas, proposerBoost, From ed699a8edf96a8f11906c22cf4c0eccdac71b615 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Wed, 8 Apr 2026 09:53:35 +0700 Subject: [PATCH 2/2] fix: adjust PREPARE_NEXT_SLOT_BPS for Gloas --- .../src/api/impl/validator/index.ts | 5 ++-- .../beacon-node/src/chain/prepareNextSlot.ts | 24 ++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 9af41b5b40bc..47d826e90854 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -66,7 +66,7 @@ import { SyncCommitteeErrorCode, } from "../../../chain/errors/index.js"; import {ChainEvent, CommonBlockBody} from "../../../chain/index.js"; -import {PREPARE_NEXT_SLOT_BPS} from "../../../chain/prepareNextSlot.js"; +import {getPrepareNextSlotMs} from "../../../chain/prepareNextSlot.js"; import {BlockType, ProduceFullDeneb, ProduceFullGloas} from "../../../chain/produceBlock/index.js"; import {RegenCaller} from "../../../chain/regen/index.js"; import {CheckpointHexPayload} from "../../../chain/stateCache/types.js"; @@ -1102,8 +1102,7 @@ export function getValidatorApi( const head = chain.forkChoice.getHead(); let state: IBeaconStateView | undefined = undefined; const startSlot = computeStartSlotAtEpoch(epoch); - const prepareNextSlotLookAheadMs = - config.SLOT_DURATION_MS - config.getSlotComponentDurationMs(PREPARE_NEXT_SLOT_BPS); + const prepareNextSlotLookAheadMs = config.SLOT_DURATION_MS - getPrepareNextSlotMs(config, startSlot); const toNextEpochMs = msToNextEpoch(); // validators may request next epoch's duties when it's close to next epoch // this is to avoid missed block proposal due to 0 epoch look ahead diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index af076efa3fed..33d9493a5808 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -20,9 +20,22 @@ import {IBeaconChain} from "./interface.js"; import {getPayloadAttributesForSSE, prepareExecutionPayload} from "./produceBlock/produceBlockBody.js"; import {RegenCaller} from "./regen/index.js"; -// TODO GLOAS: re-evaluate this timing -/* With 12s slot times, this scheduler will run 4s before the start of each slot (`12 - 0.6667 * 12 = 4`). */ -export const PREPARE_NEXT_SLOT_BPS = 6667; +/* Pre-Gloas: prepare at 6667 BPS (~8s into a 12s slot), leaving 4s for preparation. */ +const PREPARE_NEXT_SLOT_BPS = 6667; + +/** + * Gloas+: prepare at 8333 BPS (~10s into a 12s slot), leaving 2s for preparation. + * This is after PAYLOAD_ATTESTATION_DUE_BPS (7500 / 9s) so that fork choice + * incorporates PTC votes (payload PRESENT/ABSENT) before computing the head + * and preparing the execution payload for the next slot. + * TODO GLOAS: re-check before Gloas mainnet + */ +const PREPARE_NEXT_SLOT_BPS_GLOAS = 8333; + +export function getPrepareNextSlotMs(config: ChainForkConfig, slot: Slot): number { + const bps = ForkSeq[config.getForkName(slot)] >= ForkSeq.gloas ? PREPARE_NEXT_SLOT_BPS_GLOAS : PREPARE_NEXT_SLOT_BPS; + return config.getSlotComponentDurationMs(bps); +} /* We don't want to do more epoch transition than this */ const PREPARE_EPOCH_LIMIT = 1; @@ -73,9 +86,8 @@ export class PrepareNextSlotScheduler { } try { - // At PREPARE_NEXT_SLOT_BPS (~67%) of the current slot we prepare payload for the next slot - // or precompute epoch transition - await sleep(this.config.getSlotComponentDurationMs(PREPARE_NEXT_SLOT_BPS), this.signal); + // Wait until the appropriate point in the slot to prepare payload or precompute epoch transition + await sleep(getPrepareNextSlotMs(this.config, prepareSlot), this.signal); // calling updateHead() here before we produce a block to reduce reorg possibility const headBlock = this.chain.recomputeForkChoiceHead(ForkchoiceCaller.prepareNextSlot, prepareSlot);