diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 0e9c4ac67e67..21917ca74115 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1112,23 +1112,21 @@ export function getValidatorApi( notWhileSyncing(); await waitForSlot(slot); - const block = chain.forkChoice.getCanonicalBlockClosestLteSlot(slot); + const block = chain.forkChoice.getCanonicalBlockAtSlot(slot); if (!block) { - throw new ApiError(404, `No canonical block found at or before slot=${slot}`); + // No block is seen at slot. Return 404 so vc can skip casting payload attestation. + throw new ApiError(404, `No canonical block found at slot=${slot}`); } - const blockIsForSlot = block.slot === slot; const payloadInput = chain.seenPayloadEnvelopeInputCache.get(block.blockRoot); // Spec: set payload_present only if the envelope was seen before get_payload_due_ms() // into the slot. Use the envelope's own arrival time (getPayloadEnvelopeSource), not // the input's creation time. const payloadDueSec = config.getPayloadDueMs() / 1000; const payloadPresent = - blockIsForSlot && - payloadInput !== undefined && - payloadInput.hasPayloadEnvelope() && + payloadInput?.hasPayloadEnvelope() === true && chain.clock.secFromSlot(slot, payloadInput.getPayloadEnvelopeSource().seenTimestampSec) < payloadDueSec; - const blobDataAvailable = blockIsForSlot && (payloadInput?.hasAllData() ?? false); + const blobDataAvailable = payloadInput?.hasAllData() === true; return { data: { diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceAttestationData.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceAttestationData.test.ts index 791dddd868df..1a4635190d72 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceAttestationData.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceAttestationData.test.ts @@ -65,7 +65,7 @@ describe("api - validator - produceAttestationData", () => { modules.config = gloasConfig; api = getValidatorApi(defaultApiOptions, modules); - modules.forkChoice.getCanonicalBlockClosestLteSlot.mockReturnValue({ + modules.forkChoice.getCanonicalBlockAtSlot.mockReturnValue({ slot: 0, blockRoot: ZERO_HASH_HEX, } as ProtoBlock); @@ -86,5 +86,25 @@ describe("api - validator - produceAttestationData", () => { expect(res.data.payloadPresent).toBe(true); expect(res.data.blobDataAvailable).toBe(true); }); + + it("Should throw 404 when no canonical block has been seen for the assigned slot", async () => { + const gloasConfig = createChainForkConfig({ + ...defaultChainConfig, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + }); + modules = getApiTestModules({config: gloasConfig}); + modules.config = gloasConfig; + api = getValidatorApi(defaultApiOptions, modules); + + modules.forkChoice.getCanonicalBlockAtSlot.mockReturnValue(null); + + await expect(api.producePayloadAttestationData({slot: 1})).rejects.toThrow("No canonical block found at slot=1"); + }); }); }); diff --git a/packages/validator/src/services/ptc.ts b/packages/validator/src/services/ptc.ts index 5fb8a563e9ca..e6b539cbb1b9 100644 --- a/packages/validator/src/services/ptc.ts +++ b/packages/validator/src/services/ptc.ts @@ -1,4 +1,4 @@ -import {ApiClient, routes} from "@lodestar/api"; +import {ApiClient, HttpStatusCode, routes} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; import {isForkPostGloas} from "@lodestar/params"; import {Slot, gloas} from "@lodestar/types"; @@ -70,14 +70,23 @@ export class PtcService { try { const payloadAttestationData = await this.producePayloadAttestationData(slot); + // If no beacon block was seen for the assigned slot, do not submit a payload attestation + if (payloadAttestationData === null) { + this.logger.debug("Skipping payload attestation, no beacon block seen for slot", {slot}); + return; + } await this.signAndPublishPayloadAttestations(slot, payloadAttestationData, duties); } catch (e) { this.logger.error("Error on PTC routine", {slot}, e as Error); } }; - private async producePayloadAttestationData(slot: Slot): Promise { - return (await this.api.validator.producePayloadAttestationData({slot})).value(); + private async producePayloadAttestationData(slot: Slot): Promise { + const res = await this.api.validator.producePayloadAttestationData({slot}); + if (!res.ok && res.status === HttpStatusCode.NOT_FOUND) { + return null; + } + return res.value(); } private async signAndPublishPayloadAttestations( diff --git a/packages/validator/test/unit/services/ptc.test.ts b/packages/validator/test/unit/services/ptc.test.ts index b47ec810df37..5c20e4997355 100644 --- a/packages/validator/test/unit/services/ptc.test.ts +++ b/packages/validator/test/unit/services/ptc.test.ts @@ -1,7 +1,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {SecretKey} from "@chainsafe/blst"; import {toHexString} from "@chainsafe/ssz"; -import {routes} from "@lodestar/api"; +import {HttpStatusCode, routes} from "@lodestar/api"; import {createChainForkConfig} from "@lodestar/config"; import {config as defaultConfig} from "@lodestar/config/default"; import {gloas, ssz} from "@lodestar/types"; @@ -11,7 +11,7 @@ import {PtcService} from "../../../src/services/ptc.js"; import {PtcDutiesService} from "../../../src/services/ptcDuties.js"; import {SyncingStatusTracker} from "../../../src/services/syncingStatusTracker.js"; import {ValidatorStore} from "../../../src/services/validatorStore.js"; -import {getApiClientStub, mockApiResponse} from "../../utils/apiStub.js"; +import {getApiClientStub, mockApiErrorResponse, mockApiResponse} from "../../utils/apiStub.js"; import {ClockMock} from "../../utils/clock.js"; import {loggerVc} from "../../utils/logger.js"; import {ZERO_HASH, ZERO_HASH_HEX} from "../../utils/types.js"; @@ -112,6 +112,38 @@ describe("PtcService", () => { }); }); + it("Should skip submission when no beacon block has been seen for the assigned slot", async () => { + const slot = 0; + const clock = new ClockMock(); + const config = createChainForkConfig({...defaultConfig, GLOAS_FORK_EPOCH: 0}); + const ptcService = new PtcService( + config, + loggerVc, + api, + clock, + validatorStore, + emitter, + chainHeadTracker, + syncingStatusTracker, + null + ); + + const duty: routes.validator.PtcDuty = { + slot, + validatorIndex: 0, + pubkey: pubkeys[0], + }; + + vi.spyOn(ptcService["dutiesService"], "getDutiesAtSlot").mockReturnValue([duty]); + api.validator.producePayloadAttestationData.mockResolvedValue(mockApiErrorResponse(HttpStatusCode.NOT_FOUND)); + + await clock.tickSlotFns(slot, controller.signal); + + expect(api.validator.producePayloadAttestationData).toHaveBeenCalledWith({slot}); + expect(validatorStore.signPayloadAttestation).not.toHaveBeenCalled(); + expect(api.beacon.submitPayloadAttestationMessages).not.toHaveBeenCalled(); + }); + it("Should redownload PTC duties when dependent root changes", async () => { const slot = 0; const newDependentRoot = "0x1111111111111111111111111111111111111111111111111111111111111111"; diff --git a/packages/validator/test/utils/apiStub.ts b/packages/validator/test/utils/apiStub.ts index 3375ee09e3a5..fef3dab70e30 100644 --- a/packages/validator/test/utils/apiStub.ts +++ b/packages/validator/test/utils/apiStub.ts @@ -62,3 +62,7 @@ export function mockApiResponse>({ apiResponse.meta = () => meta as M; return apiResponse; } + +export function mockApiErrorResponse(status: HttpStatusCode): ApiResponse { + return new ApiResponse({} as any, null, new Response(null, {status})); +}