From 268607cb5df9cc812565567091eb2e5247c60c9d 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 98b0035e2771..ef160f60ffce 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 1891663a7d97..8cf2da38b7ec 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -665,7 +665,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; @@ -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; 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}`); @@ -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(); 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 6d78f2fa4ca060eab9f7ec6f87e9c63d650aad7e 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 8cf2da38b7ec..20170721b6cd 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -665,7 +665,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 7b5460e04bf99b4bfda504a370cf4061d6301367 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 edb8c8d88e62ffdb58c2f53d5c5d094f385681cd 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 ef160f60ffce..6103fb262739 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 20170721b6cd..66daeee0a0cb 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -690,6 +690,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. @@ -698,6 +705,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, @@ -749,13 +760,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 1f555b91ea46851029032f8b3236fae5d3db1b85 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 6103fb262739..a6dbda86fa18 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 cafccc14433f..451f4a7f383d 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"; @@ -721,6 +722,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 fd38e7c2be0d550a3e9dfbd1a35d27bb5fc9ab7c 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 a6dbda86fa18..90a8400ca1cc 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 451f4a7f383d..191425e311fd 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -698,9 +698,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"); } @@ -764,7 +761,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,