Skip to content
87 changes: 76 additions & 11 deletions packages/api/src/beacon/routes/beacon/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
SignedBlockContents,
Slot,
deneb,
fulu,
gloas,
ssz,
sszTypesFor,
Expand Down Expand Up @@ -220,8 +221,13 @@ export type Endpoints = {
*/
publishExecutionPayloadEnvelope: Endpoint<
"POST",
{signedExecutionPayloadEnvelope: gloas.SignedExecutionPayloadEnvelope},
{body: unknown; headers: {[MetaHeader.Version]: string}},
{
signedExecutionPayloadEnvelope: gloas.SignedExecutionPayloadEnvelope;
blobs?: deneb.Blobs;
kzgProofs?: fulu.KZGProofs;
broadcastValidation?: BroadcastValidation;
},
{body: unknown; headers: {[MetaHeader.Version]: string}; query: {broadcast_validation?: string}},
EmptyResponseData,
EmptyMeta
>;
Expand Down Expand Up @@ -619,39 +625,98 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions<Endpoi
url: "/eth/v1/beacon/execution_payload_envelope",
method: "POST",
req: {
writeReqJson: ({signedExecutionPayloadEnvelope}) => {
writeReqJson: ({signedExecutionPayloadEnvelope, blobs, kzgProofs, broadcastValidation}) => {
const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber);
const types = getPostGloasForkTypes(fork);
if ((blobs === undefined) !== (kzgProofs === undefined)) {
throw Error("blobs and kzgProofs must both be supplied or both omitted");
}
const hasBlobs = blobs !== undefined && kzgProofs !== undefined;
const body = hasBlobs
? types.SignedExecutionPayloadEnvelopeContents.toJson({
signedExecutionPayloadEnvelope,
kzgProofs,
blobs,
})
: types.SignedExecutionPayloadEnvelope.toJson(signedExecutionPayloadEnvelope);
return {
body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedExecutionPayloadEnvelope),
body,
headers: {
[MetaHeader.Version]: fork,
},
query: {broadcast_validation: broadcastValidation},
};
},
parseReqJson: ({body, headers}) => {
parseReqJson: ({body, headers, query}) => {
const fork = toForkName(fromHeaders(headers, MetaHeader.Version));
const types = getPostGloasForkTypes(fork);
// Discriminate by the wrapper's top-level key. SignedExecutionPayloadEnvelopeContents
// serializes to {signed_execution_payload_envelope, kzg_proofs, blobs}; the bare envelope
// serializes to {message, signature}.
const isContents = body !== null && typeof body === "object" && "signed_execution_payload_envelope" in body;
if (isContents) {
const contents = types.SignedExecutionPayloadEnvelopeContents.fromJson(body);
return {
signedExecutionPayloadEnvelope: contents.signedExecutionPayloadEnvelope,
blobs: contents.blobs,
kzgProofs: contents.kzgProofs,
broadcastValidation: query.broadcast_validation as BroadcastValidation,
};
}
return {
signedExecutionPayloadEnvelope: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.fromJson(body),
signedExecutionPayloadEnvelope: types.SignedExecutionPayloadEnvelope.fromJson(body),
broadcastValidation: query.broadcast_validation as BroadcastValidation,
};
},
writeReqSsz: ({signedExecutionPayloadEnvelope}) => {
writeReqSsz: ({signedExecutionPayloadEnvelope, blobs, kzgProofs, broadcastValidation}) => {
const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber);
const types = getPostGloasForkTypes(fork);
if ((blobs === undefined) !== (kzgProofs === undefined)) {
throw Error("blobs and kzgProofs must both be supplied or both omitted");
}
const hasBlobs = blobs !== undefined && kzgProofs !== undefined;
const body = hasBlobs
? types.SignedExecutionPayloadEnvelopeContents.serialize({
signedExecutionPayloadEnvelope,
kzgProofs,
blobs,
})
: types.SignedExecutionPayloadEnvelope.serialize(signedExecutionPayloadEnvelope);
return {
body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedExecutionPayloadEnvelope),
body,
headers: {
[MetaHeader.Version]: fork,
},
query: {broadcast_validation: broadcastValidation},
};
},
parseReqSsz: ({body, headers}) => {
parseReqSsz: ({body, headers, query}) => {
const fork = toForkName(fromHeaders(headers, MetaHeader.Version));
const types = getPostGloasForkTypes(fork);
// SSZ has no in-band Contents-vs-envelope discriminator; spec uses a single
// `application/octet-stream` Content-Type for both shapes. Discriminate by the
// first SSZ offset: SignedExecutionPayloadEnvelopeContents has 3 variable fields
// so its first offset is 12; SignedExecutionPayloadEnvelope's variable `message`
// sits after a 96-byte signature plus the 4-byte offset header, so its first
// offset is 100.
const firstOffset = body.length >= 4 ? body[0] | (body[1] << 8) | (body[2] << 16) | (body[3] << 24) : 0;
if (firstOffset === 12) {
Copy link
Copy Markdown
Member

@nflaig nflaig May 27, 2026

Choose a reason for hiding this comment

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

this doesn't make any sense, we need a metadata header to differentiate this, the spec is not complete, left a comment here ethereum/beacon-APIs#580 (comment), I think we should push for introducing a BlindedExecutionPayloadEnvelope for the stateful flow

Copy link
Copy Markdown
Contributor Author

@ensi321 ensi321 May 27, 2026

Choose a reason for hiding this comment

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

I agree. I believe other clients implemented this way. The original plan was we just need something transient so we can test external bids with buildoor in devnet 4 which is now dead.

const contents = types.SignedExecutionPayloadEnvelopeContents.deserialize(body);
return {
signedExecutionPayloadEnvelope: contents.signedExecutionPayloadEnvelope,
blobs: contents.blobs,
kzgProofs: contents.kzgProofs,
broadcastValidation: query.broadcast_validation as BroadcastValidation,
};
}
return {
signedExecutionPayloadEnvelope:
getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.deserialize(body),
signedExecutionPayloadEnvelope: types.SignedExecutionPayloadEnvelope.deserialize(body),
broadcastValidation: query.broadcast_validation as BroadcastValidation,
};
},
schema: {
body: Schema.Object,
query: {broadcast_validation: Schema.String},
headers: {[MetaHeader.Version]: Schema.String},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {describe, expect, it} from "vitest";
import {createChainForkConfig, defaultChainConfig} from "@lodestar/config";
import {ssz} from "@lodestar/types";
import {BroadcastValidation, getDefinitions} from "../../../src/beacon/routes/beacon/block.js";
import {WireFormat} from "../../../src/utils/wireFormat.js";

function lowercaseKeys<T extends Record<string, string>>(headers: T): T {
return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) as T;
}

describe("publishExecutionPayloadEnvelope route", () => {
const config = createChainForkConfig({...defaultChainConfig, GLOAS_FORK_EPOCH: 0});
const definitions = getDefinitions(config);
const route = definitions.publishExecutionPayloadEnvelope;

const signedExecutionPayloadEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue();

describe("JSON wire format", () => {
it("round-trips a bare SignedExecutionPayloadEnvelope", () => {
const written = route.req.writeReqJson({signedExecutionPayloadEnvelope});
expect(written.body).toHaveProperty("message");
expect(written.body).toHaveProperty("signature");
expect(written.body).not.toHaveProperty("signed_execution_payload_envelope");

const parsed = route.req.parseReqJson({
body: written.body,
headers: lowercaseKeys(written.headers),
query: written.query ?? {},
});
expect(parsed.signedExecutionPayloadEnvelope).toEqual(signedExecutionPayloadEnvelope);
expect(parsed.blobs).toBeUndefined();
expect(parsed.kzgProofs).toBeUndefined();
});

it("round-trips a SignedExecutionPayloadEnvelopeContents wrapper when blobs are supplied", () => {
const blobs = [ssz.deneb.Blob.defaultValue()];
const kzgProofs = Array.from({length: 128}, () => ssz.deneb.KZGProof.defaultValue());

const written = route.req.writeReqJson({
signedExecutionPayloadEnvelope,
blobs,
kzgProofs,
broadcastValidation: BroadcastValidation.consensus,
});
expect(written.body).toHaveProperty("signed_execution_payload_envelope");
expect(written.body).toHaveProperty("kzg_proofs");
expect(written.body).toHaveProperty("blobs");
expect(written.query?.broadcast_validation).toBe(BroadcastValidation.consensus);

const parsed = route.req.parseReqJson({
body: written.body,
headers: lowercaseKeys(written.headers),
query: written.query ?? {},
});
expect(parsed.signedExecutionPayloadEnvelope).toEqual(signedExecutionPayloadEnvelope);
expect(parsed.blobs).toEqual(blobs);
expect(parsed.kzgProofs).toEqual(kzgProofs);
expect(parsed.broadcastValidation).toBe(BroadcastValidation.consensus);
});
});

describe("SSZ wire format", () => {
it("round-trips a bare SignedExecutionPayloadEnvelope", () => {
const written = route.req.writeReqSsz({signedExecutionPayloadEnvelope});
const parsed = route.req.parseReqSsz({
body: written.body,
headers: lowercaseKeys(written.headers),
query: written.query ?? {},
});
expect(parsed.signedExecutionPayloadEnvelope).toEqual(signedExecutionPayloadEnvelope);
expect(parsed.blobs).toBeUndefined();
expect(parsed.kzgProofs).toBeUndefined();
});

it("round-trips a SignedExecutionPayloadEnvelopeContents wrapper", () => {
const blobs = [ssz.deneb.Blob.defaultValue()];
const kzgProofs = Array.from({length: 128}, () => ssz.deneb.KZGProof.defaultValue());

const written = route.req.writeReqSsz({signedExecutionPayloadEnvelope, blobs, kzgProofs});
const parsed = route.req.parseReqSsz({
body: written.body,
headers: lowercaseKeys(written.headers),
query: written.query ?? {},
});
expect(parsed.signedExecutionPayloadEnvelope).toEqual(signedExecutionPayloadEnvelope);
expect(parsed.blobs).toEqual(blobs);
expect(parsed.kzgProofs).toEqual(kzgProofs);
});
});

it("uses SSZ as the default request wire format", () => {
expect(route.init?.requestWireFormat).toBe(WireFormat.ssz);
});

describe("partial blobs/kzgProofs rejection", () => {
const blobs = [ssz.deneb.Blob.defaultValue()];
const kzgProofs = Array.from({length: 128}, () => ssz.deneb.KZGProof.defaultValue());

it("writeReqJson throws when only blobs are supplied", () => {
expect(() => route.req.writeReqJson({signedExecutionPayloadEnvelope, blobs})).toThrow(
/blobs and kzgProofs must both be supplied/
);
});

it("writeReqJson throws when only kzgProofs are supplied", () => {
expect(() => route.req.writeReqJson({signedExecutionPayloadEnvelope, kzgProofs})).toThrow(
/blobs and kzgProofs must both be supplied/
);
});

it("writeReqSsz throws when only blobs are supplied", () => {
expect(() => route.req.writeReqSsz({signedExecutionPayloadEnvelope, blobs})).toThrow(
/blobs and kzgProofs must both be supplied/
);
});

it("writeReqSsz throws when only kzgProofs are supplied", () => {
expect(() => route.req.writeReqSsz({signedExecutionPayloadEnvelope, kzgProofs})).toThrow(
/blobs and kzgProofs must both be supplied/
);
});
});
});
58 changes: 46 additions & 12 deletions packages/beacon-node/src/api/impl/beacon/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
ProduceFullFulu,
ProduceFullGloas,
} from "../../../../chain/produceBlock/index.js";
import {validateCellsAndKzgCommitments} from "../../../../chain/produceBlock/validateBlobsAndKzgCommitments.js";
import {validateGossipBlock} from "../../../../chain/validation/block.js";
import {validateApiExecutionPayloadBid} from "../../../../chain/validation/executionPayloadBid.js";
import {validateApiExecutionPayloadEnvelope} from "../../../../chain/validation/executionPayloadEnvelope.js";
Expand Down Expand Up @@ -665,7 +666,9 @@ export function getBeaconBlockApi({
await publishBlock(args, context, opts);
},

async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope}) {
async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope, blobs, kzgProofs, broadcastValidation}) {
// TODO GLOAS: honor broadcastValidation (gossip|consensus|consensus_and_equivocation) per beacon-APIs PR #580
void broadcastValidation;
const seenTimestampSec = Date.now() / 1000;
const envelope = signedExecutionPayloadEnvelope.message;
const slot = envelope.payload.slotNumber;
Expand All @@ -688,11 +691,51 @@ export function getBeaconBlockApi({

await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope);

// TODO GLOAS: if block and payload are submitted in parallel, payloadInput may not yet exist.
// A queuing mechanism is needed to handle this case. See https://github.com/ChainSafe/lodestar/issues/8915
const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex);
if (!payloadInput) {
throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`);
Comment on lines +696 to +698
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Defer payload cache lookup until after slot-alignment wait

Move the seenPayloadEnvelopeInputCache lookup back to after the msToBlockSlot sleep; doing it here makes publishExecutionPayloadEnvelope fail fast with 404 in near-slot submissions where the block/payload pipelines race by a few hundred milliseconds. In the previous ordering, the handler could wait up to MAX_API_CLOCK_DISPARITY_MS and then find the cache entry, but this change rejects those same requests before that grace window, which can break the new stateless external-builder path under normal timing jitter.

Useful? React with 👍 / 👎.

}

if ((blobs === undefined) !== (kzgProofs === undefined)) {
throw new ApiError(400, "blobs and kzgProofs must both be supplied or both omitted");
}
const hasSuppliedBlobs = blobs !== undefined && kzgProofs !== undefined;
Comment thread
ensi321 marked this conversation as resolved.
const isSelfBuild = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD;
let dataColumnSidecars: gloas.DataColumnSidecar[] = [];

if (isSelfBuild) {
// For self-builds, construct and publish data column sidecars from cached block production data
if (hasSuppliedBlobs) {
const expectedBlobs = payloadInput.getBlobKzgCommitments().length;
if (blobs.length !== expectedBlobs) {
throw new ApiError(400, `Expected ${expectedBlobs} blobs to match bid kzg_commitments, got ${blobs.length}`);
}
if (kzgProofs.length !== blobs.length * NUMBER_OF_COLUMNS) {
throw new ApiError(
400,
`Expected ${blobs.length * NUMBER_OF_COLUMNS} kzg_proofs for ${blobs.length} blobs, got ${kzgProofs.length}`
);
}
if (blobs.length > 0) {
const timer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer();
const cells = blobs.map((blob) => kzg.computeCells(blob));
Comment thread
ensi321 marked this conversation as resolved.
try {
await validateCellsAndKzgCommitments(payloadInput.getBlobKzgCommitments(), kzgProofs, cells);
} catch (e) {
throw new ApiError(
400,
`Invalid supplied blobs/kzg_proofs against bid kzg_commitments: ${(e as Error).message}`
);
}
const cellsAndProofs = cells.map((rowCells, rowIndex) => ({
cells: rowCells,
proofs: kzgProofs.slice(rowIndex * NUMBER_OF_COLUMNS, (rowIndex + 1) * NUMBER_OF_COLUMNS),
}));
dataColumnSidecars = getGloasDataColumnSidecars(slot, envelope.beaconBlockRoot, cellsAndProofs);
timer?.();
}
} else if (isSelfBuild) {
// Stateful self-build path: reconstruct data column sidecars from cached cells + proofs.
const cachedResult = chain.blockProductionCache.get(blockRootHex) as ProduceFullGloas | undefined;
if (cachedResult === undefined) {
throw new ApiError(404, `No cached block production result found for block root ${blockRootHex}`);
Expand All @@ -717,8 +760,6 @@ export function getBeaconBlockApi({
dataColumnSidecars = getGloasDataColumnSidecars(slot, envelope.beaconBlockRoot, cellsAndProofs);
timer?.();
}
} else {
// TODO GLOAS: will this api be used by builders or only for self-building?
}

// If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N.
Expand All @@ -727,13 +768,6 @@ export function getBeaconBlockApi({
await sleep(msToBlockSlot);
}

// TODO GLOAS: if block and payload are submitted in parallel, payloadInput may not yet exist.
// A queuing mechanism is needed to handle this case. See https://github.com/ChainSafe/lodestar/issues/8915
const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex);
if (!payloadInput) {
throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`);
}

payloadInput.addPayloadEnvelope({
envelope: signedExecutionPayloadEnvelope,
source: PayloadEnvelopeInputSource.api,
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/fulu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {ValueOf} from "@chainsafe/ssz";
import * as ssz from "./sszTypes.js";

export type KZGProof = ValueOf<typeof ssz.KZGProof>;
export type KZGProofs = ValueOf<typeof ssz.KZGProofs>;
export type Blob = ValueOf<typeof ssz.Blob>;

export type Metadata = ValueOf<typeof ssz.Metadata>;
Expand Down
9 changes: 9 additions & 0 deletions packages/types/src/gloas/sszTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,15 @@ export const SignedExecutionPayloadEnvelope = new ContainerType(
{typeName: "SignedExecutionPayloadEnvelope", jsonCase: "eth2"}
);

export const SignedExecutionPayloadEnvelopeContents = new ContainerType(
{
signedExecutionPayloadEnvelope: SignedExecutionPayloadEnvelope,
kzgProofs: fuluSsz.KZGProofs,
blobs: denebSsz.Blobs,
},
{typeName: "SignedExecutionPayloadEnvelopeContents", jsonCase: "eth2"}
);

export const BeaconBlockBody = new ContainerType(
{
randaoReveal: phase0Ssz.BeaconBlockBody.fields.randaoReveal,
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/gloas/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type SignedExecutionPayloadBid = ValueOf<typeof ssz.SignedExecutionPayloa
export type BlockAccessList = ValueOf<typeof ssz.BlockAccessList>;
export type ExecutionPayloadEnvelope = ValueOf<typeof ssz.ExecutionPayloadEnvelope>;
export type SignedExecutionPayloadEnvelope = ValueOf<typeof ssz.SignedExecutionPayloadEnvelope>;
export type SignedExecutionPayloadEnvelopeContents = ValueOf<typeof ssz.SignedExecutionPayloadEnvelopeContents>;
export type BeaconBlockBody = ValueOf<typeof ssz.BeaconBlockBody>;
export type BeaconBlock = ValueOf<typeof ssz.BeaconBlock>;
export type SignedBeaconBlock = ValueOf<typeof ssz.SignedBeaconBlock>;
Expand Down
Loading