Skip to content
87 changes: 74 additions & 13 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 @@ -217,11 +218,21 @@ export type Endpoints = {
* Instructs the beacon node to broadcast a signed execution payload envelope to the network,
* to be gossiped for payload validation. A success response (20x) indicates that the envelope
* passed gossip validation and was successfully broadcast onto the network.
*
* Body is either a bare `SignedExecutionPayloadEnvelope` (stateful: the receiving beacon node
* already has blobs cached from block production) or a `SignedExecutionPayloadEnvelopeContents`
* wrapper (stateless: envelope bundled with blobs and KZG proofs for multi-BN, DVT, or failover).
* See beacon-APIs PR #580.
*/
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 +630,89 @@ 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);
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);
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));
return {
signedExecutionPayloadEnvelope:
getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.deserialize(body),
};
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. Try the wrapper first
// (catches the stateless variant) then fall back to the bare envelope.
try {
const contents = types.SignedExecutionPayloadEnvelopeContents.deserialize(body);
return {
signedExecutionPayloadEnvelope: contents.signedExecutionPayloadEnvelope,
blobs: contents.blobs,
kzgProofs: contents.kzgProofs,
broadcastValidation: query.broadcast_validation as BroadcastValidation,
};
} catch {
return {
signedExecutionPayloadEnvelope: types.SignedExecutionPayloadEnvelope.deserialize(body),
broadcastValidation: query.broadcast_validation as BroadcastValidation,
};
}
Comment thread
ensi321 marked this conversation as resolved.
Outdated
},
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,94 @@
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): Record<string, string> {
return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
}

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);
});
});
30 changes: 25 additions & 5 deletions packages/beacon-node/src/api/impl/beacon/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ export function getBeaconBlockApi({
await publishBlock(args, context, opts);
},

async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope}) {
async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope, blobs, kzgProofs}) {
Comment thread
ensi321 marked this conversation as resolved.
Outdated
const seenTimestampSec = Date.now() / 1000;
const envelope = signedExecutionPayloadEnvelope.message;
const slot = envelope.payload.slotNumber;
Expand All @@ -688,11 +688,32 @@ export function getBeaconBlockApi({

await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope);

// Stateless mode (beacon-APIs PR #580): the validator client supplied blobs + KZG proofs
// alongside the envelope. Always trust those over any cached block-production data; this is
// the path external builders and multi-BN/DVT setups use, where the cache may be empty.
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) {
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.
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,9 +738,8 @@ 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?
}
// External builder + no supplied blobs: nothing to publish (builder gossips columns separately).

// If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N.
const msToBlockSlot = computeTimeAtSlot(config, slot, chain.genesisTime) * 1000 - Date.now();
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
12 changes: 12 additions & 0 deletions packages/types/src/gloas/sszTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ export const SignedExecutionPayloadEnvelope = new ContainerType(
{typeName: "SignedExecutionPayloadEnvelope", jsonCase: "eth2"}
);

// Stateless publish wrapper for `POST /eth/v1/beacon/execution_payload_envelope`
// (beacon-APIs PR #580). Lets the validator client supply blobs + KZG proofs to
// a beacon node that does not have them cached (multi-BN, DVT, failover).
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