diff --git a/packages/validator/src/services/blockDuties.ts b/packages/validator/src/services/blockDuties.ts index 418240f8d86e..543c6f58e09d 100644 --- a/packages/validator/src/services/blockDuties.ts +++ b/packages/validator/src/services/blockDuties.ts @@ -1,6 +1,6 @@ import {ApiClient, routes} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; -import {isForkPostFulu} from "@lodestar/params"; +import {isForkPostGloas} from "@lodestar/params"; import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {BLSPubkey, Epoch, RootHex, Slot} from "@lodestar/types"; import {sleep, toPubkeyHex} from "@lodestar/utils"; @@ -11,8 +11,11 @@ import {ChainHeaderTracker, HeadEventData} from "./chainHeaderTracker.js"; import {ValidatorStore} from "./validatorStore.js"; /** - * Pre-Fulu only: poll next-epoch proposer duties ~1s before the boundary. Post-Fulu the 1-epoch - * deterministic lookahead lets us pre-fetch via `runEveryEpoch`, so this fast path is unused. + * Pre-Gloas: poll next-epoch proposer duties ~1s before the boundary. The deterministic 1-epoch + * lookahead (EIP-7917) is available post-Fulu, but consuming it requires `getProposerDutiesV2`, + * which was added to beacon-APIs only after Fulu shipped. To avoid depending on an endpoint not + * all clients implement yet, the VC keeps using v1 + this boundary poll until Gloas; only then + * does it switch to the lookahead-based pre-fetch in `runEveryEpoch`. * * Historical context: starting Jul 2023 we poll 1s before the next epoch because * `PrepareNextSlotScheduler` (BN-side) usually finishes the upcoming-epoch transition in ~3s, @@ -123,9 +126,10 @@ export class BlockDutiesService { } /** - * Baseline per-epoch fetch. Fires at epoch boundaries (and once at startup). Post-Fulu the - * deterministic 1-epoch lookahead lets us also pre-fetch `epoch + 1`; pre-Fulu the next - * epoch's dep_root only stabilizes at the boundary and is handled by `runEverySlotTask`. + * Baseline per-epoch fetch. Fires at epoch boundaries (and once at startup). Post-Gloas the + * deterministic 1-epoch lookahead (consumed via v2) lets us also pre-fetch `epoch + 1`; + * pre-Gloas the next epoch's dep_root only stabilizes at the boundary and is handled by + * `runEverySlotTask`. * * Mid-epoch refreshes (e.g. reorgs) are driven by `onNewHead` instead of polling every slot. */ @@ -144,7 +148,7 @@ export class BlockDutiesService { await this.pollBeaconProposers(epoch); const nextEpoch = epoch + 1; - if (isForkPostFulu(this.config.getForkName(computeStartSlotAtEpoch(nextEpoch)))) { + if (isForkPostGloas(this.config.getForkName(computeStartSlotAtEpoch(nextEpoch)))) { await this.pollBeaconProposers(nextEpoch); } } catch (e) { @@ -156,7 +160,7 @@ export class BlockDutiesService { /** * Slot-tick handler. Notifies block production for cached proposers in this slot, and on - * the last slot of a pre-Fulu epoch schedules the boundary fetch for `nextEpoch` duties. + * the last slot of a pre-Gloas epoch schedules the boundary fetch for `nextEpoch` duties. * Reorg detection is handled by `onNewHead`, so this task does not re-poll on every slot. */ private runEverySlotTask = async (slot: Slot, signal: AbortSignal): Promise => { @@ -169,10 +173,10 @@ export class BlockDutiesService { const nextEpoch = computeEpochAtSlot(slot) + 1; const isLastSlotOfEpoch = computeStartSlotAtEpoch(nextEpoch) === slot + 1; - if (isLastSlotOfEpoch && !isForkPostFulu(this.config.getForkName(slot + 1))) { - // Pre-Fulu: 0-epoch proposer lookahead, so the next-epoch dep_root only becomes stable - // as the last block of the current epoch lands. Sleep until ~1s before the boundary - // then fetch — same timing as before this refactor. + if (isLastSlotOfEpoch && !isForkPostGloas(this.config.getForkName(slot + 1))) { + // Pre-Gloas the VC uses v1 and does not pre-fetch the next epoch on the epoch tick, so + // fetch it ~1s before the boundary instead. Pre-Fulu this is required (0-epoch lookahead); + // for Fulu the lookahead exists but the v2 path is deferred to Gloas (see top of file). this.pollBeaconProposersBeforeBoundary(slot, nextEpoch, signal).catch((e) => { this.logger.error("Error on pollBeaconProposersBeforeBoundary", {nextEpoch}, e); }); @@ -184,14 +188,15 @@ export class BlockDutiesService { /** * SSE head-event handler. The beacon-API `head` event carries attester-duty dep_roots, - * which coincide with the proposer dep_roots at a fork-dependent offset: + * which coincide with the proposer dep_roots the VC caches. The offset depends on the + * proposer-duties endpoint the VC uses, which is gated on Gloas (v1 before, v2 after): * - * Pre-Fulu (proposer dep_root(E) = block@startSlot(E) - 1): + * Pre-Gloas (v1 proposer dep_root(E) = block@startSlot(E) - 1): * currentDutyDependentRoot ≡ proposer_dep_root(currentEpoch) - * (next-epoch proposer dep_root is not exposed; pre-Fulu falls back to the + * (next-epoch proposer dep_root is not exposed; pre-Gloas falls back to the * `runEverySlotTask` boundary poll.) * - * Post-Fulu (proposer dep_root(E) = block@startSlot(E - 1) - 1, EIP-7917): + * Post-Gloas (v2 proposer dep_root(E) = block@startSlot(E - 1) - 1, EIP-7917): * previousDutyDependentRoot ≡ proposer_dep_root(currentEpoch) * currentDutyDependentRoot ≡ proposer_dep_root(nextEpoch) * @@ -204,9 +209,9 @@ export class BlockDutiesService { currentDutyDependentRoot, }: HeadEventData): Promise => { const currentEpoch = computeEpochAtSlot(slot); - const isPostFulu = isForkPostFulu(this.config.getForkName(slot)); + const isPostGloas = isForkPostGloas(this.config.getForkName(slot)); - if (isPostFulu) { + if (isPostGloas) { await this.refetchIfDepRootChanged(currentEpoch, previousDutyDependentRoot); await this.refetchIfDepRootChanged(currentEpoch + 1, currentDutyDependentRoot); } else { @@ -229,11 +234,11 @@ export class BlockDutiesService { } /** - * Pre-Fulu boundary fetch. Because pre-Fulu proposer shuffling has 0-epoch look-ahead, a - * proposal for the first slot of the new epoch otherwise goes through the slow path every - * time: the proposal can only happen *after* we download and process the new duties from - * the BN. Polling ~1s before the boundary, while `PrepareNextSlotScheduler` is finishing - * the upcoming-epoch transition, lets us land on a hot BN cache and avoid the miss. + * Pre-Gloas boundary fetch. Without a pre-fetched next epoch, a proposal for the first slot + * of the new epoch otherwise goes through the slow path every time: the proposal can only + * happen *after* we download and process the new duties from the BN. Polling ~1s before the + * boundary, while `PrepareNextSlotScheduler` is finishing the upcoming-epoch transition, lets + * us land on a hot BN cache and avoid the miss. * * See https://github.com/ChainSafe/lodestar/issues/5792. */ @@ -303,8 +308,9 @@ export class BlockDutiesService { return; } - // Post-Fulu the proposer dependent root changed (deterministic proposer lookahead) - const res = isForkPostFulu(this.config.getForkName(computeStartSlotAtEpoch(epoch))) + // Use v2 only post-Gloas: it exposes the post-Fulu deterministic proposer dependent root, + // but was added to beacon-APIs after Fulu shipped, so we defer to it until Gloas (see top). + const res = isForkPostGloas(this.config.getForkName(computeStartSlotAtEpoch(epoch))) ? await this.api.validator.getProposerDutiesV2({epoch}) : await this.api.validator.getProposerDuties({epoch}); const proposerDuties = res.value(); diff --git a/packages/validator/test/unit/services/blockDuties.test.ts b/packages/validator/test/unit/services/blockDuties.test.ts index 89406059c05a..e4e0301126c5 100644 --- a/packages/validator/test/unit/services/blockDuties.test.ts +++ b/packages/validator/test/unit/services/blockDuties.test.ts @@ -22,7 +22,8 @@ vi.mock("../../../src/services/chainHeaderTracker.js"); describe("BlockDutiesService", () => { const api = getApiClientStub(); const preFuluConfig = createChainForkConfig(defaultConfig); - const postFuluConfig = getConfig(ForkName.fulu); + const fuluConfig = getConfig(ForkName.fulu); + const postGloasConfig = getConfig(ForkName.gloas); let validatorStore: ValidatorStore; let pubkeys: Uint8Array[]; // Initialize pubkeys in before() so bls is already initialized @@ -114,7 +115,7 @@ describe("BlockDutiesService", () => { expect(notifyBlockProductionFn).toHaveBeenCalledWith(slot, [pubkeys[0]]); }); - it("Post-Fulu epoch tick fetches current and next epoch proposer duties", async () => { + it("Post-Gloas epoch tick fetches current and next epoch proposer duties", async () => { const dutiesEpoch0: routes.validator.ProposerDutyList = [{slot: 0, validatorIndex: 0, pubkey: pubkeys[0]}]; const dutiesEpoch1: routes.validator.ProposerDutyList = [{slot: 32, validatorIndex: 1, pubkey: pubkeys[1]}]; const depRootEpoch0 = ZERO_HASH_HEX; @@ -128,7 +129,7 @@ describe("BlockDutiesService", () => { const clock = new ClockMock(); const dutiesService = new BlockDutiesService( - postFuluConfig, + postGloasConfig, loggerVc, api, clock, @@ -233,7 +234,7 @@ describe("BlockDutiesService", () => { expect(api.validator.getProposerDuties).toHaveBeenCalledTimes(1); }); - it("Post-Fulu head event detects dep_root change for both current and next epoch", async () => { + it("Post-Gloas head event detects dep_root change for both current and next epoch", async () => { const depRootEpoch0 = toHex(Buffer.alloc(32, 10)); const depRootEpoch1 = toHex(Buffer.alloc(32, 11)); const depRootEpoch0Reorg = toHex(Buffer.alloc(32, 20)); @@ -249,7 +250,7 @@ describe("BlockDutiesService", () => { ); const clock = new ClockMock(); - new BlockDutiesService(postFuluConfig, loggerVc, api, clock, validatorStore, chainHeaderTracker, null); + new BlockDutiesService(postGloasConfig, loggerVc, api, clock, validatorStore, chainHeaderTracker, null); await clock.tickEpochFns(0, controller.signal); expect(api.validator.getProposerDutiesV2).toHaveBeenCalledTimes(2); @@ -260,8 +261,8 @@ describe("BlockDutiesService", () => { await onNewHeadCallback({ slot: 0, head: ZERO_HASH_HEX, - previousDutyDependentRoot: depRootEpoch0Reorg, // post-Fulu maps to proposer_dep_root(currentEpoch) - currentDutyDependentRoot: depRootEpoch1Reorg, // post-Fulu maps to proposer_dep_root(nextEpoch) + previousDutyDependentRoot: depRootEpoch0Reorg, // post-Gloas (v2) maps to proposer_dep_root(currentEpoch) + currentDutyDependentRoot: depRootEpoch1Reorg, // post-Gloas (v2) maps to proposer_dep_root(nextEpoch) }); expect(api.validator.getProposerDutiesV2).toHaveBeenCalledTimes(4); @@ -316,13 +317,13 @@ describe("BlockDutiesService", () => { expect(api.validator.getProposerDuties).toHaveBeenCalledTimes(1); }); - it("Post-Fulu last slot of epoch does NOT schedule a pre-Fulu boundary fetch", async () => { + it("Post-Gloas last slot of epoch does NOT schedule a boundary fetch", async () => { api.validator.getProposerDutiesV2.mockResolvedValue( mockApiResponse({data: [], meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}}) ); const clock = new ClockMock(); - new BlockDutiesService(postFuluConfig, loggerVc, api, clock, validatorStore, chainHeaderTracker, null); + new BlockDutiesService(postGloasConfig, loggerVc, api, clock, validatorStore, chainHeaderTracker, null); await clock.tickEpochFns(0, controller.signal); const callsAfterEpochTick = api.validator.getProposerDutiesV2.mock.calls.length; @@ -330,10 +331,38 @@ describe("BlockDutiesService", () => { await clock.tickSlotFns(31, controller.signal); await new Promise((resolve) => setTimeout(resolve, 10)); - // No extra fetch beyond what `runEveryEpoch` already did — boundary poll is pre-Fulu only + // No extra fetch beyond what `runEveryEpoch` already did, the boundary poll is pre-Gloas only expect(api.validator.getProposerDutiesV2.mock.calls.length).toBe(callsAfterEpochTick); }); + it("Fulu (pre-Gloas) uses the v1 endpoint and the boundary-poll path, not v2", async () => { + const epoch0Duties: routes.validator.ProposerDutyList = []; + const epoch1Duties: routes.validator.ProposerDutyList = [{slot: 32, validatorIndex: 0, pubkey: pubkeys[0]}]; + + api.validator.getProposerDuties.mockImplementation(async ({epoch}) => + mockApiResponse({ + data: epoch === 0 ? epoch0Duties : epoch1Duties, + meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}, + }) + ); + + const clock = new ClockMock(); + new BlockDutiesService(fuluConfig, loggerVc, api, clock, validatorStore, chainHeaderTracker, null); + + // Epoch tick fetches only the current epoch via v1 (no v2, no next-epoch pre-fetch) + await clock.tickEpochFns(0, controller.signal); + expect(api.validator.getProposerDutiesV2).not.toHaveBeenCalled(); + expect(api.validator.getProposerDuties).toHaveBeenCalledTimes(1); + expect(api.validator.getProposerDuties.mock.calls[0][0]).toEqual({epoch: 0}); + + // Last slot of the epoch schedules the boundary fetch for nextEpoch, still via v1 + await clock.tickSlotFns(31, controller.signal); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(api.validator.getProposerDutiesV2).not.toHaveBeenCalled(); + expect(api.validator.getProposerDuties).toHaveBeenCalledTimes(2); + expect(api.validator.getProposerDuties.mock.calls[1][0]).toEqual({epoch: 1}); + }); + it("Should remove signer from duty across epochs", async () => { const duties: routes.validator.ProposerDutyList = [ {slot: 0, validatorIndex: 0, pubkey: pubkeys[0]},