Skip to content
Closed
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
51 changes: 43 additions & 8 deletions packages/beacon-node/src/api/impl/beacon/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ import {ImportBlockOpts} from "../../../../chain/blocks/types.js";
import {verifyBlocksInEpoch} from "../../../../chain/blocks/verifyBlock.js";
import {BeaconChain} from "../../../../chain/chain.js";
import {ChainEvent} from "../../../../chain/emitter.js";
import {BlockError, BlockErrorCode, BlockGossipError} from "../../../../chain/errors/index.js";
import {
BlockError,
BlockErrorCode,
BlockGossipError,
ExecutionPayloadEnvelopeError,
ExecutionPayloadEnvelopeErrorCode,
} from "../../../../chain/errors/index.js";
import {
BlockType,
ProduceFullBellatrix,
Expand Down Expand Up @@ -683,7 +689,34 @@ export function getBeaconBlockApi({
throw new ApiError(400, `Envelope slot ${slot} does not match block slot ${block.slot}`);
}

await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope);
try {
await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope);

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.

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.

will follow up after it lands 👍

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.

Confirmed now that #9595 is merged: GitHub marks #9504 as DIRTY, and merge-tree conflicts in blocks/index.ts around this exact validation/add-envelope area. I posted the situation analysis and recommended rebasing/reworking on current unstable rather than stacking more changes onto this draft branch.

} catch (e) {
// idempotent behavior towards an already received envelope
if (
e instanceof ExecutionPayloadEnvelopeError &&
e.type.code === ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN
) {
const existing = chain.seenPayloadEnvelopeInputCache.get(blockRootHex);
const existingEnvelope = existing?.hasPayloadEnvelope() ? existing.getPayloadEnvelope() : undefined;
const existingBlockHashHex = existingEnvelope && toRootHex(existingEnvelope.message.payload.blockHash);
if (existingBlockHashHex !== undefined && existingBlockHashHex !== blockHashHex) {
chain.logger.warn("Execution payload envelope already known with a DIFFERENT block hash", {
slot,
blockRoot: blockRootHex,
existingBlockHash: existingBlockHashHex,
newBlockHash: blockHashHex,
});
} else {
chain.logger.debug("Execution payload envelope already known, skipping duplicate", {
slot,
blockRoot: blockRootHex,
});
}
return; // 200 OK
Comment thread
markolazic01 marked this conversation as resolved.
}
throw e;
}

const isSelfBuild = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD;
let dataColumnSidecars: gloas.DataColumnSidecar[] = [];
Expand Down Expand Up @@ -732,12 +765,14 @@ export function getBeaconBlockApi({
throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`);
}

payloadInput.addPayloadEnvelope({
envelope: signedExecutionPayloadEnvelope,
source: PayloadEnvelopeInputSource.api,
seenTimestampSec,
peerIdStr: undefined,
});
if (!payloadInput.hasPayloadEnvelope()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle known envelopes before validation

When the duplicate envelope is already in seenPayloadEnvelopeInputCache before this API call starts validation, the earlier validateApiExecutionPayloadEnvelope() call still throws ENVELOPE_ALREADY_KNOWN, so execution never reaches this new idempotency guard. This only fixes the narrow race where another path sets the envelope after validation but before addPayloadEnvelope(); VC retries or gossip-first arrivals still get an error instead of an idempotent success. Consider checking/returning success for the already-known case before validation, or mapping that validation result to the intended duplicate behavior.

Useful? React with 👍 / 👎.

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.

This one is still relevant. The current branch catches ENVELOPE_ALREADY_KNOWN before reaching the add, but that catch returns immediately, so it avoids the validation throw while also bypassing later publish/process side effects. After #9595 this should be reworked on top of the new signedEnvelopeOrContents / broadcastValidation flow rather than patched in place.

payloadInput.addPayloadEnvelope({
envelope: signedExecutionPayloadEnvelope,
source: PayloadEnvelopeInputSource.api,
seenTimestampSec,
peerIdStr: undefined,
});
}
Comment thread
markolazic01 marked this conversation as resolved.

if (dataColumnSidecars.length > 0) {
for (const columnSidecar of dataColumnSidecars) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";
import {when} from "vitest-when";
import {createChainForkConfig, defaultChainConfig} from "@lodestar/config";
import {PayloadStatus} from "@lodestar/fork-choice";
import {ssz} from "@lodestar/types";
import {toRootHex} from "@lodestar/utils";
import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js";
import {
ExecutionPayloadEnvelopeError,
ExecutionPayloadEnvelopeErrorCode,
GossipAction,
} from "../../../../../../src/chain/errors/index.js";
import {validateApiExecutionPayloadEnvelope} from "../../../../../../src/chain/validation/executionPayloadEnvelope.js";
import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js";
import {generateProtoBlock} from "../../../../../utils/typeGenerator.js";

vi.mock("../../../../../../src/chain/validation/executionPayloadEnvelope.js", async (importActual) => {
const mod = await importActual<typeof import("../../../../../../src/chain/validation/executionPayloadEnvelope.js")>();
return {...mod, validateApiExecutionPayloadEnvelope: vi.fn()};
});

const gloasConfig = createChainForkConfig({
...defaultChainConfig,
GLOAS_FORK_EPOCH: 0,
});

describe("api - beacon - publishExecutionPayloadEnvelope", () => {
let modules: ApiTestModules;
let api: ReturnType<typeof getBeaconBlockApi>;

const slot = 1;
const blockRoot = Buffer.alloc(32, 1);
const blockHash = Buffer.alloc(32, 2);
const blockRootHex = toRootHex(blockRoot);
const blockHashHex = toRootHex(blockHash);

function buildEnvelope(beaconBlockRoot: Uint8Array, payloadBlockHash: Uint8Array) {
const signed = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue();
signed.message.beaconBlockRoot = beaconBlockRoot;
signed.message.payload.slotNumber = slot;
signed.message.payload.blockHash = payloadBlockHash;
return signed;
}

const signedEnvelope = buildEnvelope(blockRoot, blockHash);

function makeInput(opts: {hasEnvelope: boolean; storedEnvelope?: ReturnType<typeof buildEnvelope>}) {
return {
hasPayloadEnvelope: vi.fn().mockReturnValue(opts.hasEnvelope),
getPayloadEnvelope: vi.fn().mockReturnValue(opts.storedEnvelope ?? signedEnvelope),
addPayloadEnvelope: vi.fn(),
addColumn: vi.fn(),
};
}

function throwAlreadyKnown(): void {
vi.mocked(validateApiExecutionPayloadEnvelope).mockRejectedValue(
new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, {
code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN,
blockRoot: blockRootHex,
slot,
})
);
}

beforeEach(() => {
modules = getApiTestModules({config: gloasConfig});
api = getBeaconBlockApi(modules);

when(modules.forkChoice.getBlockHex)
.calledWith(blockRootHex, PayloadStatus.EMPTY)
.thenReturn(generateProtoBlock({slot}));

(modules.network as any).publishSignedExecutionPayloadEnvelope = vi.fn().mockResolvedValue(1);
modules.chain.processExecutionPayload = vi.fn().mockResolvedValue(undefined) as any;
});

afterEach(() => {
vi.clearAllMocks();
});

it("happy path: unknown envelope is added and published (200)", async () => {
vi.mocked(validateApiExecutionPayloadEnvelope).mockResolvedValue(undefined);
const input = makeInput({hasEnvelope: false});
vi.mocked(modules.chain.seenPayloadEnvelopeInputCache).get.mockReturnValue(input as any);

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).resolves.toBeUndefined();

expect(input.addPayloadEnvelope).toHaveBeenCalledTimes(1);
expect(modules.chain.processExecutionPayload).toHaveBeenCalledTimes(1);
expect(modules.network.publishSignedExecutionPayloadEnvelope).toHaveBeenCalledTimes(1);
});

it("VC retry: envelope already in cache → 200, not added again", async () => {
throwAlreadyKnown();
const input = makeInput({hasEnvelope: true}); // same block hash as incoming
vi.mocked(modules.chain.seenPayloadEnvelopeInputCache).get.mockReturnValue(input as any);

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).resolves.toBeUndefined();

expect(input.addPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled();
expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.logger.debug).toHaveBeenCalledWith(
"Execution payload envelope already known, skipping duplicate",
expect.objectContaining({slot, blockRoot: blockRootHex})
);
expect(modules.chain.logger.warn).not.toHaveBeenCalled();
});

it("gossip-first: already known, cache pruned → 200 (null-safe lookup)", async () => {
throwAlreadyKnown();
vi.mocked(modules.chain.seenPayloadEnvelopeInputCache).get.mockReturnValue(undefined); // pruned

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).resolves.toBeUndefined();

expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled();
expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.logger.debug).toHaveBeenCalledWith(
"Execution payload envelope already known, skipping duplicate",
expect.objectContaining({slot, blockRoot: blockRootHex})
);
expect(modules.chain.logger.warn).not.toHaveBeenCalled();
});

it("divergent hash: known envelope with a DIFFERENT block hash → warn + 200", async () => {
throwAlreadyKnown();
const stored = buildEnvelope(blockRoot, Buffer.alloc(32, 0xff));
const input = makeInput({hasEnvelope: true, storedEnvelope: stored});
vi.mocked(modules.chain.seenPayloadEnvelopeInputCache).get.mockReturnValue(input as any);

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).resolves.toBeUndefined();

expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled();
expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled();
expect(input.addPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.logger.warn).toHaveBeenCalledWith(
"Execution payload envelope already known with a DIFFERENT block hash",
expect.objectContaining({
slot,
blockRoot: blockRootHex,
existingBlockHash: toRootHex(stored.message.payload.blockHash),
newBlockHash: blockHashHex,
})
);
});

it("race path: envelope set between validation and the guard → not added twice, still published", async () => {
vi.mocked(validateApiExecutionPayloadEnvelope).mockResolvedValue(undefined);
const input = makeInput({hasEnvelope: true});
vi.mocked(modules.chain.seenPayloadEnvelopeInputCache).get.mockReturnValue(input as any);

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).resolves.toBeUndefined();

expect(input.addPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.processExecutionPayload).toHaveBeenCalledTimes(1);
expect(modules.network.publishSignedExecutionPayloadEnvelope).toHaveBeenCalledTimes(1);
});

it("propagates a non-ENVELOPE_ALREADY_KNOWN validation error (different code)", async () => {
vi.mocked(validateApiExecutionPayloadEnvelope).mockRejectedValue(
new ExecutionPayloadEnvelopeError(GossipAction.REJECT, {
code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE,
})
);
const input = makeInput({hasEnvelope: false});
vi.mocked(modules.chain.seenPayloadEnvelopeInputCache).get.mockReturnValue(input as any);

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).rejects.toThrow();

expect(input.addPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled();
expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled();
});

it("propagates a non-envelope validation error (plain Error)", async () => {
vi.mocked(validateApiExecutionPayloadEnvelope).mockRejectedValue(new Error("boom"));
const input = makeInput({hasEnvelope: false});
vi.mocked(modules.chain.seenPayloadEnvelopeInputCache).get.mockReturnValue(input as any);

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).rejects.toThrow();

expect(input.addPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled();
expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled();
});

it("rejects 404 when the block is unknown (before validation)", async () => {
when(modules.forkChoice.getBlockHex).calledWith(blockRootHex, PayloadStatus.EMPTY).thenReturn(null);

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).rejects.toMatchObject({statusCode: 404});

expect(validateApiExecutionPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled();
expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled();
});

it("rejects 400 on slot mismatch (before validation)", async () => {
when(modules.forkChoice.getBlockHex)
.calledWith(blockRootHex, PayloadStatus.EMPTY)
.thenReturn(generateProtoBlock({slot: slot + 1}));

await expect(
api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope})
).rejects.toMatchObject({statusCode: 400});

expect(validateApiExecutionPayloadEnvelope).not.toHaveBeenCalled();
expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled();
expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled();
});
});
Loading