-
-
Notifications
You must be signed in to change notification settings - Fork 467
fix: reject gloas attestations/aggregates for EL-invalidated payloads #9637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: unstable
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is identical validation logic duplicated between 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);
}
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 ( |
||
| } | ||
| } | ||
|
|
||
| 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 | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
newPayloadreturnsINVALID,importExecutionPayloadthrows at step 4c before step 6'sonExecutionPayload(the only thing that creates a FULL variant), andtoForkChoiceExecutionStatusmaps onlyVALID/SYNCING/ACCEPTED—INVALIDhits thedefaultthrow. So there's no FULL variant at all:getBlockHex(root, FULL)isnulland fork choice has no record of it.(The LVH-invalidated case — imported
VALID/SYNCINGthen invalidated — does live in fork choice as a FULL variant withexecutionStatus === Invalid, which the validators already check viagetBlockHex(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/toForkChoiceExecutionStatusto acceptInvalidand creating a FULL-Invalid variant on immediate-INVALID (before the throw) — then both cases collapse to the existinggetBlockHex(FULL)?.executionStatus === Invalidcheck and the cache flag goes away. That's a fork-choice contract change (it rejectsInvalidby 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.