diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 98b0035e2771..90a8400ca1cc 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, @@ -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 >; @@ -619,39 +625,98 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { + 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) { + 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}, }, }, 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..f742a8d74915 --- /dev/null +++ b/packages/api/test/unit/beacon/publishExecutionPayloadEnvelope.test.ts @@ -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>(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/ + ); + }); + }); +}); 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..191425e311fd 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"; @@ -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; @@ -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}`); + } + + 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; 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)); + 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}`); @@ -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. @@ -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, 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..d0cba6c152fc 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -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, 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;