diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index dac0b486c374..b3cdc0561e39 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -198,6 +198,10 @@ export async function importExecutionPayload( break; case ExecutionPayloadStatus.INVALID: + // Flag the payload input so gossip validation can REJECT index-1 "payload present" + // attestations for this block: we throw here before `onExecutionPayload`, so no FULL + // fork-choice variant is created to carry the Invalid execution status. + payloadInput.markPayloadInvalid(); throw new PayloadError({ code: PayloadErrorCode.EXECUTION_ENGINE_INVALID, execStatus: execResult.status, diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index 7eeee4f33240..5bbc9293da01 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -85,6 +85,14 @@ export class PayloadEnvelopeInput { state: PayloadEnvelopeInputState; + /** + * Set when the EL rejects this block's execution payload during import (`newPayload` -> + * INVALID). An immediate EL-INVALID throws before `onExecutionPayload`, so no FULL fork-choice + * variant is ever created; this flag is the only record that the payload failed validation and + * lets gossip validation still REJECT index-1 "payload present" attestations for it. + */ + private payloadInvalid = false; + private constructor(props: { blockRootHex: RootHex; slot: Slot; @@ -290,6 +298,14 @@ export class PayloadEnvelopeInput { return this.state.hasPayload; } + markPayloadInvalid(): void { + this.payloadInvalid = true; + } + + isPayloadInvalid(): boolean { + return this.payloadInvalid; + } + getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope { if (!this.state.hasPayload) throw new Error("Payload envelope not set"); return this.state.payloadEnvelope; diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index caf578cbe47a..426247a3fcdb 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -537,6 +537,10 @@ export class BeaconChain implements IBeaconChain { return this.seenPayloadEnvelopeInputCache.hasPayload(blockRoot) || this.forkChoice.hasPayloadHexUnsafe(blockRoot); } + payloadFailedValidation(blockRoot: RootHex): boolean { + return this.seenPayloadEnvelopeInputCache.get(blockRoot)?.isPayloadInvalid() ?? false; + } + regenCanAcceptWork(): boolean { return this.regen.canAcceptWork(); } diff --git a/packages/beacon-node/src/chain/errors/attestationError.ts b/packages/beacon-node/src/chain/errors/attestationError.ts index 94cfde56b852..7e3c586df12a 100644 --- a/packages/beacon-node/src/chain/errors/attestationError.ts +++ b/packages/beacon-node/src/chain/errors/attestationError.ts @@ -151,6 +151,10 @@ export enum AttestationErrorCode { * Gloas: index-1 attestation but the execution payload has not been seen yet */ EXECUTION_PAYLOAD_NOT_SEEN = "ATTESTATION_ERROR_EXECUTION_PAYLOAD_NOT_SEEN", + /** + * Gloas: index-1 attestation but the execution payload failed EL validation (invalidated) + */ + EXECUTION_PAYLOAD_FAILED_VALIDATION = "ATTESTATION_ERROR_EXECUTION_PAYLOAD_FAILED_VALIDATION", } export type AttestationErrorType = @@ -190,7 +194,8 @@ export type AttestationErrorType = | {code: AttestationErrorCode.ATTESTER_NOT_IN_COMMITTEE} | {code: AttestationErrorCode.INVALID_PAYLOAD_STATUS_VALUE; attDataIndex: number} | {code: AttestationErrorCode.PREMATURELY_INDICATED_PAYLOAD_PRESENT} - | {code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN; beaconBlockRoot: RootHex}; + | {code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN; beaconBlockRoot: RootHex} + | {code: AttestationErrorCode.EXECUTION_PAYLOAD_FAILED_VALIDATION; beaconBlockRoot: RootHex}; export class AttestationError extends GossipActionError { getMetadata(): Record { diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index effeaa8fb627..f6e5d36888c7 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -164,6 +164,8 @@ export interface IBeaconChain { seenBlock(blockRoot: RootHex): boolean; /** Chain has seen a SignedExecutionPayloadEnvelope for this block root (via seenCache or fork choice FULL variant) */ seenPayloadEnvelope(blockRoot: RootHex): boolean; + /** [Gloas] EL rejected this block's execution payload at import (`newPayload` -> INVALID); no FULL variant exists */ + payloadFailedValidation(blockRoot: RootHex): boolean; /** Populate in-memory caches with persisted data. Call at least once on startup */ loadFromDisk(): Promise; /** Persist in-memory data to the DB. Call at least once before stopping the process */ diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index 25cb272cb8fa..9e049301365e 100644 --- a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts +++ b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts @@ -1,3 +1,4 @@ +import {ExecutionStatus, PayloadStatus} from "@lodestar/fork-choice"; import {ForkName, ForkSeq} from "@lodestar/params"; import { computeEpochAtSlot, @@ -90,17 +91,36 @@ async function validateAggregateAndProof( }); } - // [REJECT] If `aggregate.data.index == 1` (payload present for a past - // block), the execution payload for `block` passes validation. - // [IGNORE] When `aggregate.data.index == 1` (payload present for a past block), - // the corresponding execution payload for `block` has been seen (a client MAY queue - // attestations for processing once the payload is retrieved and SHOULD request the - // payload envelope via `ExecutionPayloadEnvelopesByRoot`). - if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { - throw new AttestationError(GossipAction.IGNORE, { - code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, - beaconBlockRoot: toRootHex(attData.beaconBlockRoot), - }); + if (block !== null && attData.index === 1) { + const beaconBlockRootHex = toRootHex(attData.beaconBlockRoot); + + // [REJECT] If `aggregate.data.index == 1` (payload present for a past block), the corresponding + // execution payload for `block` passes validation. A payload can fail EL validation with two + // distinct fork-choice representations, both of which must reject the aggregate: + // - imported (VALID/SYNCING) then invalidated via latest-valid-hash: the FULL variant exists + // with `executionStatus === Invalid`. + // - rejected immediately by the EL at import (`newPayload` -> INVALID): `importExecutionPayload` + // throws before `onExecutionPayload`, so no FULL variant is created, but the envelope was + // already seen on gossip. The payload input is flagged via `markPayloadInvalid`, surfaced + // here by `chain.payloadFailedValidation`. + const fullBlock = chain.forkChoice.getBlockHex(beaconBlockRootHex, PayloadStatus.FULL); + if (fullBlock?.executionStatus === ExecutionStatus.Invalid || chain.payloadFailedValidation(beaconBlockRootHex)) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.EXECUTION_PAYLOAD_FAILED_VALIDATION, + beaconBlockRoot: beaconBlockRootHex, + }); + } + + // [IGNORE] When `aggregate.data.index == 1` (payload present for a past block), the + // corresponding execution payload for `block` has been seen (a client MAY queue attestations + // for processing once the payload is retrieved and SHOULD request the payload envelope via + // `ExecutionPayloadEnvelopesByRoot`). + if (!chain.seenPayloadEnvelope(beaconBlockRootHex)) { + throw new AttestationError(GossipAction.IGNORE, { + code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, + beaconBlockRoot: beaconBlockRootHex, + }); + } } // [REJECT] len(committee_indices) == 1, where committee_indices = get_committee_indices(aggregate) diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index f3b7b8863f57..8ca4ee98154d 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -1,6 +1,6 @@ import {BitArray} from "@chainsafe/ssz"; import {BeaconConfig} from "@lodestar/config"; -import {ProtoBlock} from "@lodestar/fork-choice"; +import {ExecutionStatus, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import { ATTESTATION_SUBNET_COUNT, DOMAIN_BEACON_ATTESTER, @@ -291,48 +291,73 @@ async function validateAttestationNoSignatureCheck( const attEpoch = computeEpochAtSlot(attSlot); const attTarget = attData.target; const targetEpoch = attTarget.epoch; + // [Gloas] These payload-status checks depend only on `attData` plus live fork-choice / seen-payload + // state, so they must run for every attestation — including one whose `AttestationData` was already + // cached in `seenAttestationDatas`. Running them only on the freshly-deserialized path let a payload + // that was EL-invalidated *after* the `AttestationData` was first cached be bypassed by subsequent + // unaggregated attestations with the same data. This mirrors `validateAggregateAndProof`, which runs + // the same checks before its cache lookup. + if (isForkPostGloas(fork)) { + // [REJECT] `attestation.data.index < 2`. + if (attData.index >= 2) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.INVALID_PAYLOAD_STATUS_VALUE, + attDataIndex: attData.index, + }); + } + + // [REJECT] `attestation.data.index == 0` if `block.slot == attestation.data.slot`. + const block = chain.forkChoice.getBlockDefaultStatus(attData.beaconBlockRoot); + + // block being null will be handled by `verifyHeadBlockAndTargetRoot` + if (block !== null && block.slot === attSlot && attData.index !== 0) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.PREMATURELY_INDICATED_PAYLOAD_PRESENT, + }); + } + + if (block !== null && attData.index === 1) { + const beaconBlockRootHex = toRootHex(attData.beaconBlockRoot); + + // [REJECT] If `attestation.data.index == 1` (payload present for a past block), the execution + // payload for `block` passes validation. A payload can fail EL validation with two distinct + // fork-choice representations, both of which must reject the attestation: + // - imported (VALID/SYNCING) then invalidated via latest-valid-hash: the FULL variant exists + // with `executionStatus === Invalid`. + // - rejected immediately by the EL at import (`newPayload` -> INVALID): `importExecutionPayload` + // throws before `onExecutionPayload`, so no FULL variant is created, but the envelope was + // already seen on gossip (so the IGNORE below would not fire). The payload input is flagged + // via `markPayloadInvalid`, surfaced here by `chain.payloadFailedValidation`. + const fullBlock = chain.forkChoice.getBlockHex(beaconBlockRootHex, PayloadStatus.FULL); + if (fullBlock?.executionStatus === ExecutionStatus.Invalid || chain.payloadFailedValidation(beaconBlockRootHex)) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.EXECUTION_PAYLOAD_FAILED_VALIDATION, + beaconBlockRoot: beaconBlockRootHex, + }); + } + + // [IGNORE] When `attestation.data.index == 1` (payload present for a past block), the + // corresponding execution payload for `block` has been seen (a client MAY queue + // attestations for processing once the payload is retrieved and SHOULD request the + // payload envelope via `ExecutionPayloadEnvelopesByRoot`). + if (!chain.seenPayloadEnvelope(beaconBlockRootHex)) { + throw new AttestationError(GossipAction.IGNORE, { + code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, + beaconBlockRoot: beaconBlockRootHex, + }); + } + } + } + let committeeIndex: number | null; if (attestationOrCache.attestation) { if (isElectraSingleAttestation(attestationOrCache.attestation)) { // api or first time validation of a gossip attestation committeeIndex = attestationOrCache.attestation.committeeIndex; - if (isForkPostGloas(fork)) { - // [REJECT] `attestation.data.index < 2`. - if (attData.index >= 2) { - throw new AttestationError(GossipAction.REJECT, { - code: AttestationErrorCode.INVALID_PAYLOAD_STATUS_VALUE, - attDataIndex: attData.index, - }); - } - - // [REJECT] `attestation.data.index == 0` if `block.slot == attestation.data.slot`. - const block = chain.forkChoice.getBlockDefaultStatus(attData.beaconBlockRoot); - - // block being null will be handled by `verifyHeadBlockAndTargetRoot` - if (block !== null && block.slot === attSlot && attData.index !== 0) { - throw new AttestationError(GossipAction.REJECT, { - code: AttestationErrorCode.PREMATURELY_INDICATED_PAYLOAD_PRESENT, - }); - } - - // [REJECT] If `attestation.data.index == 1` (payload present for a past - // block), the execution payload for `block` passes validation. - // [IGNORE] When `attestation.data.index == 1` (payload present for a past block), - // the corresponding execution payload for `block` has been seen (a client MAY queue - // attestations for processing once the payload is retrieved and SHOULD request the - // payload envelope via `ExecutionPayloadEnvelopesByRoot`). - if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { - throw new AttestationError(GossipAction.IGNORE, { - code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, - beaconBlockRoot: toRootHex(attData.beaconBlockRoot), - }); - } - } else { - // [REJECT] attestation.data.index == 0 - if (attData.index !== 0) { - throw new AttestationError(GossipAction.REJECT, {code: AttestationErrorCode.NON_ZERO_ATTESTATION_DATA_INDEX}); - } + // [REJECT] `attestation.data.index == 0` (electra, pre-gloas; the gloas index checks run above) + if (!isForkPostGloas(fork) && attData.index !== 0) { + throw new AttestationError(GossipAction.REJECT, {code: AttestationErrorCode.NON_ZERO_ATTESTATION_DATA_INDEX}); } } else { // phase0 attestation diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index ab74100273ca..d720e9d9f380 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -158,6 +158,7 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { get: vi.fn(), }, seenPayloadEnvelope: vi.fn(), + payloadFailedValidation: vi.fn(), shufflingCache: new ShufflingCache(), pubkeyCache: createPubkeyCache(), produceCommonBlockBody: vi.fn(), diff --git a/packages/beacon-node/test/unit/chain/blocks/importExecutionPayloadInvalid.test.ts b/packages/beacon-node/test/unit/chain/blocks/importExecutionPayloadInvalid.test.ts new file mode 100644 index 000000000000..baeee4093b96 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/importExecutionPayloadInvalid.test.ts @@ -0,0 +1,110 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {ProtoBlock} from "@lodestar/fork-choice"; +import {testLogger} from "@lodestar/logger/test-utils"; +import {ForkName} from "@lodestar/params"; +import {DataAvailabilityStatus} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import {PayloadEnvelopeInputSource} from "../../../../src/chain/blocks/payloadEnvelopeInput/types.js"; +import type {BeaconChain} from "../../../../src/chain/chain.js"; +import {ChainEventEmitter} from "../../../../src/chain/emitter.js"; +import {SeenPayloadEnvelopeInput} from "../../../../src/chain/seenCache/seenPayloadEnvelopeInput.js"; +import {ExecutionPayloadStatus} from "../../../../src/execution/index.js"; +import {SerializedCache} from "../../../../src/util/serializedCache.js"; +import {getMockedClock} from "../../../mocks/clock.js"; +import {config, generateBlock} from "../../../utils/blocksAndData.js"; + +// verifyExecutionPayloadEnvelope + signature run before the EL call; stub the CL-side verification so +// the test can isolate the EL response handling (the envelope is "CL-valid"). +vi.mock("../../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js", () => ({ + verifyExecutionPayloadEnvelope: vi.fn(), + verifyExecutionPayloadEnvelopeSignature: vi.fn().mockResolvedValue(true), +})); + +// Import after vi.mock so the mocked module is wired in. +const {importExecutionPayload} = await import("../../../../src/chain/blocks/importExecutionPayload.js"); + +describe("importExecutionPayload / EL INVALID marks payload invalid", () => { + let cache: SeenPayloadEnvelopeInput; + let abortController: AbortController; + + beforeEach(() => { + abortController = new AbortController(); + cache = new SeenPayloadEnvelopeInput({ + config, + clock: getMockedClock(), + forkChoice: {getAllAncestorBlocks: vi.fn()} as any, + chainEvents: new ChainEventEmitter(), + signal: abortController.signal, + serializedCache: new SerializedCache(), + metrics: null, + logger: testLogger(), + }); + }); + + afterEach(() => { + abortController.abort(); + vi.clearAllMocks(); + }); + + function makeInput() { + const {block, rootHex, blockRoot} = generateBlock({forkName: ForkName.gloas, slot: 0}); + const input = cache.add({ + blockRootHex: rootHex, + block, + forkName: ForkName.gloas, + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: 0, + }); + const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + signedEnvelope.message.beaconBlockRoot = blockRoot; + input.addPayloadEnvelope({ + envelope: signedEnvelope, + source: PayloadEnvelopeInputSource.gossip, + seenTimestampSec: 0, + peerIdStr: "peer", + }); + return {input, rootHex}; + } + + function makeChain(status: ExecutionPayloadStatus): BeaconChain { + return { + config, + clock: {currentSlot: 0} as any, + emitter: {emit: vi.fn()} as any, + forkChoice: { + getBlockHexDefaultStatus: (_root: string) => ({slot: 0}) as ProtoBlock, + } as any, + regen: { + getBlockSlotState: () => Promise.resolve({forkName: ForkName.gloas} as any), + } as any, + executionEngine: { + notifyNewPayload: vi.fn().mockResolvedValue({status, validationError: "boom"}), + } as any, + } as unknown as BeaconChain; + } + + it("EL INVALID -> throws EXECUTION_ENGINE_INVALID and flags the payload input invalid", async () => { + const {input} = makeInput(); + expect(input.isPayloadInvalid()).toBe(false); + + const chain = makeChain(ExecutionPayloadStatus.INVALID); + await expect( + importExecutionPayload.call(chain, input, DataAvailabilityStatus.PreData, {validSignature: true}) + ).rejects.toMatchObject({type: {code: "PAYLOAD_ERROR_EXECUTION_ENGINE_INVALID"}}); + + expect(input.isPayloadInvalid()).toBe(true); + }); + + it("EL transient error (INVALID_BLOCK_HASH) does NOT flag the payload input invalid", async () => { + const {input} = makeInput(); + + const chain = makeChain(ExecutionPayloadStatus.INVALID_BLOCK_HASH); + await expect( + importExecutionPayload.call(chain, input, DataAvailabilityStatus.PreData, {validSignature: true}) + ).rejects.toMatchObject({type: {code: "PAYLOAD_ERROR_EXECUTION_ENGINE_ERROR"}}); + + // Transient EL errors are not "payload failed validation" — must not be marked invalid. + expect(input.isPayloadInvalid()).toBe(false); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/validation/attestation/gloasPayloadInvalidReject.test.ts b/packages/beacon-node/test/unit/chain/validation/attestation/gloasPayloadInvalidReject.test.ts new file mode 100644 index 000000000000..ffdff15b3303 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/validation/attestation/gloasPayloadInvalidReject.test.ts @@ -0,0 +1,100 @@ +import {describe, expect, it, vi} from "vitest"; +import {toHexString} from "@chainsafe/ssz"; +import {ExecutionStatus, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {ssz} from "@lodestar/types"; +import {AttestationErrorCode} from "../../../../../src/chain/errors/index.js"; +import {IBeaconChain} from "../../../../../src/chain/index.js"; +import {validateApiAttestation} from "../../../../../src/chain/validation/index.js"; +import {config, slots} from "../../../../utils/blocksAndData.js"; +import {expectRejectedWithLodestarError} from "../../../../utils/errors.js"; + +// Gloas: an index==1 ("payload present for a past block") attestation whose referenced payload was +// rejected by the EL at import must be gossip-REJECTed, not accepted. The EL can reject a payload with +// two distinct fork-choice representations, both covered here: +// - imported (VALID/SYNCING) then invalidated via latest-valid-hash -> FULL variant is `Invalid`. +// - rejected immediately at import (`newPayload` -> INVALID) -> no FULL variant, but the payload +// input is flagged (`payloadFailedValidation`). This is the adversarial-builder case: a CL-valid +// envelope the EL says is invalid, so `importExecutionPayload` throws before `onExecutionPayload`. +describe("validateAttestation / gloas index-1 payload-failed REJECT", () => { + const gloasSlot = slots.gloas; + const attSlot = gloasSlot + 1; + const beaconBlockRoot = Buffer.alloc(32, 0xd1); + const beaconBlockRootHex = toHexString(beaconBlockRoot); + + function pastBlock(): ProtoBlock { + // Past block (slot < attSlot) so the index-0 same-slot REJECT does not fire and we reach the + // payload-status checks for a past block. + return {slot: gloasSlot, blockRoot: beaconBlockRootHex} as ProtoBlock; + } + + function getChain({ + fullExecutionStatus, + payloadFailedValidation, + }: { + fullExecutionStatus: ExecutionStatus | null; + payloadFailedValidation: boolean; + }): IBeaconChain { + const block = pastBlock(); + const forkChoice = { + getBlockDefaultStatus: (root) => (toHexString(root) === beaconBlockRootHex ? block : null), + getBlockHex: (rootHex, payloadStatus) => { + if (rootHex !== beaconBlockRootHex || payloadStatus !== PayloadStatus.FULL) return null; + if (fullExecutionStatus === null) return null; + return {...block, executionStatus: fullExecutionStatus} as ProtoBlock; + }, + } as Partial as IForkChoice; + + return { + config, + forkChoice, + seenPayloadEnvelope: vi.fn().mockReturnValue(true), + payloadFailedValidation: vi.fn().mockReturnValue(payloadFailedValidation), + } as unknown as IBeaconChain; + } + + function getAttestation(index: number) { + const attestation = ssz.electra.SingleAttestation.defaultValue(); + attestation.data.slot = attSlot; + attestation.data.index = index; + attestation.data.beaconBlockRoot = beaconBlockRoot; + return attestation; + } + + it("REJECT when the payload was rejected by the EL at import (immediate INVALID, no FULL variant)", async () => { + // Sanity: this is a gloas slot + expect(config.getForkName(attSlot)).toBe("gloas"); + + const chain = getChain({fullExecutionStatus: null, payloadFailedValidation: true}); + const fork = config.getForkName(attSlot); + + await expectRejectedWithLodestarError( + validateApiAttestation(fork, chain, {attestation: getAttestation(1), serializedData: null}), + AttestationErrorCode.EXECUTION_PAYLOAD_FAILED_VALIDATION + ); + expect(chain.payloadFailedValidation).toHaveBeenCalledWith(beaconBlockRootHex); + }); + + it("REJECT when the imported FULL variant was invalidated via latest-valid-hash", async () => { + const chain = getChain({fullExecutionStatus: ExecutionStatus.Invalid, payloadFailedValidation: false}); + const fork = config.getForkName(attSlot); + + await expectRejectedWithLodestarError( + validateApiAttestation(fork, chain, {attestation: getAttestation(1), serializedData: null}), + AttestationErrorCode.EXECUTION_PAYLOAD_FAILED_VALIDATION + ); + }); + + it("does NOT reject with payload-failed when payload is valid and seen (passes to later checks)", async () => { + const chain = getChain({fullExecutionStatus: ExecutionStatus.Valid, payloadFailedValidation: false}); + const fork = config.getForkName(attSlot); + + // Should not throw EXECUTION_PAYLOAD_FAILED_VALIDATION; some later check will fail instead. + let code: string | undefined; + try { + await validateApiAttestation(fork, chain, {attestation: getAttestation(1), serializedData: null}); + } catch (e) { + code = (e as {type?: {code?: string}}).type?.code; + } + expect(code).not.toBe(AttestationErrorCode.EXECUTION_PAYLOAD_FAILED_VALIDATION); + }); +});