From 9ca1bc7f2ecf1daecf8775e3c812cc3227fe1e9b Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sat, 23 May 2026 10:58:36 -0700 Subject: [PATCH 1/7] feat: accept SignedExecutionPayloadEnvelopeContents on envelope publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External builders and stateless validator clients (multi-BN, DVT, failover) submit the envelope wrapped with blobs and KZG proofs per beacon-APIs PR #580. Lodestar previously parsed the body as a bare SignedExecutionPayloadEnvelope, so the wrapped request failed with "JSON is not an array" and the resulting payloads were orphaned because no data column sidecars were gossiped. - Add Gloas.SignedExecutionPayloadEnvelopeContents SSZ container - Extend POST /eth/v1/beacon/execution_payload_envelope to accept either shape (JSON discriminates by `signed_execution_payload_envelope` key; SSZ tries the wrapper first then falls back to the bare envelope) and forward an optional broadcast_validation query - Wire supplied blobs + kzg_proofs through getGloasDataColumnSidecars in the publish handler so data columns are derived and gossiped on the external-builder path 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/src/beacon/routes/beacon/block.ts | 87 ++++++++++++++--- .../publishExecutionPayloadEnvelope.test.ts | 94 +++++++++++++++++++ .../src/api/impl/beacon/blocks/index.ts | 30 +++++- packages/types/src/fulu/types.ts | 1 + packages/types/src/gloas/sszTypes.ts | 12 +++ packages/types/src/gloas/types.ts | 1 + 6 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index b370b523608d..748f371de026 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -19,6 +19,7 @@ import { SignedBlockContents, Slot, deneb, + fulu, gloas, ssz, sszTypesFor, @@ -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 >; @@ -619,39 +630,89 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { + 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, + }; + } }, schema: { body: Schema.Object, + query: {broadcast_validation: Schema.String}, headers: {[MetaHeader.Version]: Schema.String}, }, }, diff --git a/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts b/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts new file mode 100644 index 000000000000..efa4dc4d2487 --- /dev/null +++ b/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts @@ -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>(headers: T): Record { + 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); + }); +}); diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 385eeca7e09f..ab591ef64399 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -673,7 +673,7 @@ export function getBeaconBlockApi({ await publishBlock(args, context, opts); }, - async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope}) { + async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope, blobs, kzgProofs}) { const seenTimestampSec = Date.now() / 1000; const envelope = signedExecutionPayloadEnvelope.message; const slot = envelope.payload.slotNumber; @@ -696,11 +696,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; 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)); + 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}`); @@ -725,9 +746,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(); diff --git a/packages/types/src/fulu/types.ts b/packages/types/src/fulu/types.ts index cd2c01c3bb97..9aa769df8674 100644 --- a/packages/types/src/fulu/types.ts +++ b/packages/types/src/fulu/types.ts @@ -2,6 +2,7 @@ import {ValueOf} from "@chainsafe/ssz"; import * as ssz from "./sszTypes.js"; export type KZGProof = ValueOf; +export type KZGProofs = ValueOf; export type Blob = ValueOf; export type Metadata = ValueOf; diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index f6661f25aa43..f7eb80b1c756 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -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, diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index de7949e54651..c75297f227b7 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -18,6 +18,7 @@ export type SignedExecutionPayloadBid = ValueOf; export type ExecutionPayloadEnvelope = ValueOf; export type SignedExecutionPayloadEnvelope = ValueOf; +export type SignedExecutionPayloadEnvelopeContents = ValueOf; export type BeaconBlockBody = ValueOf; export type BeaconBlock = ValueOf; export type SignedBeaconBlock = ValueOf; From 697e867e80a5a714732e1e8813030ac7d03b3d1d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sat, 23 May 2026 11:07:18 -0700 Subject: [PATCH 2/7] fix: destructure broadcastValidation in publishExecutionPayloadEnvelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the route's exposed argument shape so the handler signature remains stable once broadcast_validation enforcement is implemented. Behavior unchanged (still defaults to gossip-level validation). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index ab591ef64399..12755cb656a5 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -673,7 +673,9 @@ export function getBeaconBlockApi({ await publishBlock(args, context, opts); }, - async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope, blobs, kzgProofs}) { + 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; From cc13d3b19d1a95ea96090be37b2ae7fd869546b1 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 25 May 2026 19:32:46 -0700 Subject: [PATCH 3/7] fix: preserve header key type in publishExecutionPayloadEnvelope test Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/unit/beacon/publishExecutionPayloadEnvelope.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts b/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts index efa4dc4d2487..c9663de165be 100644 --- a/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts +++ b/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts @@ -4,8 +4,8 @@ import {ssz} from "@lodestar/types"; import {BroadcastValidation, getDefinitions} from "../../../src/beacon/routes/beacon/block.js"; import {WireFormat} from "../../../src/utils/wireFormat.js"; -function lowercaseKeys>(headers: T): Record { - return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])); +function lowercaseKeys>(headers: T): T { + return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])) as T; } describe("publishExecutionPayloadEnvelope route", () => { From 43a3cc48d525afa3585b0284bd2f51a0aef6e985 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 25 May 2026 20:08:54 -0700 Subject: [PATCH 4/7] fix: validate supplied blobs against bid commitments and reject partial wrapper inputs - publishExecutionPayloadEnvelope handler now checks supplied blob count matches bid.blobKzgCommitments before building data column sidecars - writeReqJson/writeReqSsz throw when only one of blobs/kzgProofs is supplied Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/src/beacon/routes/beacon/block.ts | 6 ++++ .../publishExecutionPayloadEnvelope.test.ts | 29 +++++++++++++++++++ .../src/api/impl/beacon/blocks/index.ts | 18 +++++++----- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 748f371de026..11ce62597e6f 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -633,6 +633,9 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { 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({ @@ -673,6 +676,9 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { 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({ diff --git a/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts b/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts index c9663de165be..f742a8d74915 100644 --- a/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts +++ b/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts @@ -91,4 +91,33 @@ describe("publishExecutionPayloadEnvelope route", () => { 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/ + ); + }); + }); }); diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 12755cb656a5..b7d0c98ddcd3 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -698,6 +698,13 @@ 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}`); + } + // 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. @@ -706,6 +713,10 @@ export function getBeaconBlockApi({ let dataColumnSidecars: gloas.DataColumnSidecar[] = []; 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, @@ -757,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, From 726a2c0291eab3178084e892895b0976d716d434 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 25 May 2026 20:21:57 -0700 Subject: [PATCH 5/7] refactor: discriminate ssz envelope wrapper by first offset and reject partial blob inputs in handler - parseReqSsz checks first 4 bytes (offset 12 = Contents, 100 = bare envelope) instead of try/catch deserialize for faster, deterministic dispatch - publishExecutionPayloadEnvelope handler throws on partial blobs/kzgProofs so a client that supplies only one field fails fast instead of silently falling back to the stateful path Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/src/beacon/routes/beacon/block.ts | 19 +++++++++++-------- .../src/api/impl/beacon/blocks/index.ts | 3 +++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 11ce62597e6f..d90b7d5b54f2 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -699,9 +699,13 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions= 4 ? body[0] | (body[1] << 8) | (body[2] << 16) | (body[3] << 24) : 0; + if (firstOffset === 12) { const contents = types.SignedExecutionPayloadEnvelopeContents.deserialize(body); return { signedExecutionPayloadEnvelope: contents.signedExecutionPayloadEnvelope, @@ -709,12 +713,11 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions Date: Mon, 25 May 2026 20:27:58 -0700 Subject: [PATCH 6/7] fix: kzg-verify supplied blobs and proofs against bid commitments publishExecutionPayloadEnvelope handler now calls validateCellsAndKzgCommitments against payloadInput.getBlobKzgCommitments() before building data column sidecars. Previously only counts were checked, so malformed proofs could be added to local state and gossiped. Crypto verification now matches the gossip ingest path. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index d22f161f0c1d..9ed0f892db04 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -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"; @@ -729,6 +730,14 @@ export function getBeaconBlockApi({ if (blobs.length > 0) { const timer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); const cells = blobs.map((blob) => kzg.computeCells(blob)); + 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), From 4fdfd811f9c5e7ceb9aab28527800199ab041583 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 25 May 2026 21:09:58 -0700 Subject: [PATCH 7/7] chore: remove redundant comments around envelope publish wrapper Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/beacon/routes/beacon/block.ts | 5 ----- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 4 ---- packages/types/src/gloas/sszTypes.ts | 3 --- 3 files changed, 12 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index d90b7d5b54f2..f097c663178b 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -218,11 +218,6 @@ 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", diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 9ed0f892db04..f1c9f7fd6da4 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -706,9 +706,6 @@ export function getBeaconBlockApi({ throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); } - // 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. if ((blobs === undefined) !== (kzgProofs === undefined)) { throw new ApiError(400, "blobs and kzgProofs must both be supplied or both omitted"); } @@ -772,7 +769,6 @@ export function getBeaconBlockApi({ timer?.(); } } - // 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(); diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index f7eb80b1c756..d0cba6c152fc 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -195,9 +195,6 @@ 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,