From b49677cb0822dcd8fe4ac0dc9cbcf06e7e972b1f Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Fri, 10 Jul 2026 07:44:47 +0000 Subject: [PATCH] fix: reject gloas attestations/aggregates for EL-invalidated payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gloas p2p-interface mandates [REJECT] for an index==1 ("payload present for a past block") attestation/aggregate whose execution payload failed EL validation. A payload can fail two ways, each with a distinct fork-choice representation: - 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 check would not fire). The payload input is now flagged via markPayloadInvalid (a narrow boolean on the existing, finalization-pruned PayloadEnvelopeInput cache entry), surfaced by chain.payloadFailedValidation. Both validateGossipAttestation and validateAggregateAndProof REJECT with a new EXECUTION_PAYLOAD_FAILED_VALIDATION error code covering both cases. The gloas index checks are hoisted above the AttestationData cache so a payload invalidated after its AttestationData was first cached cannot be bypassed by later unaggregated attestations. Adds unit tests: importExecutionPayload EL-INVALID sets the flag (and transient EL errors do not); the gossip validator REJECTs an index==1 attestation for both the immediate-INVALID and FULL-invalidated cases. 🤖 Generated with AI assistance --- .../chain/blocks/importExecutionPayload.ts | 4 + .../payloadEnvelopeInput.ts | 16 +++ packages/beacon-node/src/chain/chain.ts | 4 + .../src/chain/errors/attestationError.ts | 7 +- packages/beacon-node/src/chain/interface.ts | 2 + .../src/chain/validation/aggregateAndProof.ts | 42 +++++-- .../src/chain/validation/attestation.ts | 99 ++++++++++------ .../test/mocks/mockedBeaconChain.ts | 1 + .../importExecutionPayloadInvalid.test.ts | 110 ++++++++++++++++++ .../gloasPayloadInvalidReject.test.ts | 100 ++++++++++++++++ 10 files changed, 336 insertions(+), 49 deletions(-) create mode 100644 packages/beacon-node/test/unit/chain/blocks/importExecutionPayloadInvalid.test.ts create mode 100644 packages/beacon-node/test/unit/chain/validation/attestation/gloasPayloadInvalidReject.test.ts 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); + }); +});