Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we keep track of this in fork choice, why do you wanna add this to our payload cache?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The immediate EL-INVALID case never reaches fork choice today. When newPayload returns INVALID, importExecutionPayload throws at step 4c before step 6's onExecutionPayload (the only thing that creates a FULL variant), and toForkChoiceExecutionStatus maps only VALID/SYNCING/ACCEPTEDINVALID hits the default throw. So there's no FULL variant at all: getBlockHex(root, FULL) is null and fork choice has no record of it.

(The LVH-invalidated case — imported VALID/SYNCING then invalidated — does live in fork choice as a FULL variant with executionStatus === Invalid, which the validators already check via getBlockHex(FULL). The cache flag only covers the immediate case.)

Your instinct is fair though: we could make fork choice the single source of truth by teaching onExecutionPayload / toForkChoiceExecutionStatus to accept Invalid and creating a FULL-Invalid variant on immediate-INVALID (before the throw) — then both cases collapse to the existing getBlockHex(FULL)?.executionStatus === Invalid check and the cache flag goes away. That's a fork-choice contract change (it rejects Invalid by design today), which is why I kept it on the payload cache — but I'm happy to move it into fork choice if you'd prefer that. Your call since it's your area.


private constructor(props: {
blockRootHex: RootHex;
slot: Slot;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
7 changes: 6 additions & 1 deletion packages/beacon-node/src/chain/errors/attestationError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<AttestationErrorType> {
getMetadata(): Record<string, string | number | null> {
Expand Down
2 changes: 2 additions & 0 deletions packages/beacon-node/src/chain/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/** Persist in-memory data to the DB. Call at least once before stopping the process */
Expand Down
42 changes: 31 additions & 11 deletions packages/beacon-node/src/chain/validation/aggregateAndProof.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ExecutionStatus, PayloadStatus} from "@lodestar/fork-choice";
import {ForkName, ForkSeq} from "@lodestar/params";
import {
computeEpochAtSlot,
Expand Down Expand Up @@ -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)
Expand Down
99 changes: 62 additions & 37 deletions packages/beacon-node/src/chain/validation/attestation.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
});
}
Comment on lines +322 to +348

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is identical validation logic duplicated between validateAttestationNoSignatureCheck (in attestation.ts) and validateAggregateAndProof (in aggregateAndProof.ts) for checking if the execution payload passes validation or has been seen.

To improve maintainability and avoid duplication, consider extracting this logic into a shared helper function, for example:

export function verifyExecutionPayloadStatus(
  chain: IBeaconChain,
  beaconBlockRootHex: RootHex
): void {
  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,
    });
  }

  if (!chain.seenPayloadEnvelope(beaconBlockRootHex)) {
    throw new AttestationError(GossipAction.IGNORE, {
      code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN,
      beaconBlockRoot: beaconBlockRootHex,
    });
  }
}

This helper can then be imported and called in both files:

if (block !== null && attData.index === 1) {
  const beaconBlockRootHex = toRootHex(attData.beaconBlockRoot);
  verifyExecutionPayloadStatus(chain, beaconBlockRootHex);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — that block is byte-identical in both validators.

I kept it inline in each deliberately: these gossip validators read as a linear sequence of [REJECT]/[IGNORE] checks mapped 1:1 to the spec steps, so inlining keeps each check traceable to its spec line in place — pulling the whole check into a helper trades that spec-traceability for DRY.

The part that genuinely benefits from a single source is the subtle detection: the two fork-choice representations of a failed payload — the invalidated FULL variant (executionStatus === Invalid) vs. the payloadFailedValidation flag when newPayload rejects before any FULL variant exists. I'm happy to extract that into a shared predicate (e.g. isExecutionPayloadInvalid(chain, beaconBlockRootHex)), keeping the [REJECT]/[IGNORE] throws inline — or the full verifyExecutionPayloadStatus helper as suggested — if a maintainer prefers DRY over the inline spec-flow. Happy to do either.

}
}

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
Expand Down
1 change: 1 addition & 0 deletions packages/beacon-node/test/mocks/mockedBeaconChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading