From b6f7c3a57db416de46032f1114d4c8cf1832a4f3 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 12:01:24 +0100 Subject: [PATCH 01/47] feat: stateless flow for local block production post-gloas --- .../api/src/beacon/routes/beacon/block.ts | 70 +++++-- packages/api/src/beacon/routes/validator.ts | 34 +++- packages/api/src/utils/metadata.ts | 2 + .../api/test/unit/beacon/testData/beacon.ts | 5 +- .../test/unit/beacon/testData/validator.ts | 4 +- packages/api/test/utils/checkAgainstSpec.ts | 12 +- .../src/api/impl/beacon/blocks/index.ts | 175 ++++++++++++++---- .../src/api/impl/validator/index.ts | 43 ++++- packages/cli/src/cmds/validator/handler.ts | 1 + packages/cli/src/cmds/validator/options.ts | 8 + packages/types/src/gloas/sszTypes.ts | 20 ++ packages/types/src/gloas/types.ts | 2 + packages/types/src/types.ts | 2 +- packages/types/src/utils/typeguards.ts | 7 + packages/validator/src/services/block.ts | 108 +++++++---- .../validator/src/services/validatorStore.ts | 2 + packages/validator/src/validator.ts | 2 + .../test/unit/services/block.test.ts | 2 + 18 files changed, 399 insertions(+), 100 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index c038747d4e4e..b2c61c1a63c9 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -1,6 +1,7 @@ import {ContainerType, ListCompositeType, ValueOf} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; import { + ForkName, ForkPostDeneb, ForkPostGloas, ForkPreBellatrix, @@ -18,6 +19,7 @@ import { Slot, deneb, gloas, + isSignedExecutionPayloadEnvelopeContents, ssz, sszTypesFor, } from "@lodestar/types"; @@ -32,6 +34,7 @@ import { ExecutionOptimisticFinalizedAndVersionMeta, MetaHeader, } from "../../../utils/metadata.js"; +import {toBoolean} from "../../../utils/serdes.js"; import {WireFormat} from "../../../utils/wireFormat.js"; export const BlockHeaderResponseType = new ContainerType({ @@ -187,11 +190,22 @@ 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. + * + * The request body is either a `SignedExecutionPayloadEnvelopeContents` (envelope with blobs + * and KZG proofs, stateless flow) or a `SignedExecutionPayloadEnvelope` (stateful flow, + * the beacon node attaches blobs and KZG proofs from its block production cache). */ publishExecutionPayloadEnvelope: Endpoint< "POST", - {signedExecutionPayloadEnvelope: gloas.SignedExecutionPayloadEnvelope}, - {body: unknown; headers: {[MetaHeader.Version]: string}}, + { + signedEnvelope: gloas.SignedExecutionPayloadEnvelopeContents | gloas.SignedExecutionPayloadEnvelope; + broadcastValidation?: BroadcastValidation; + }, + { + body: unknown; + headers: {[MetaHeader.Version]: string; [MetaHeader.BlobDataIncluded]: string}; + query: {broadcast_validation?: string}; + }, EmptyResponseData, EmptyMeta >; @@ -266,6 +280,16 @@ const blockIdOnlyReq: RequestCodec { return { getBlockV2: { @@ -453,40 +477,58 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); + writeReqJson: ({signedEnvelope, broadcastValidation}) => { + const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelope); + const fork = getEnvelopeFork(config, signedEnvelope); return { - body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedExecutionPayloadEnvelope), + body: blobDataIncluded + ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.toJson(signedEnvelope) + : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedEnvelope), headers: { [MetaHeader.Version]: fork, + [MetaHeader.BlobDataIncluded]: blobDataIncluded.toString(), }, + query: {broadcast_validation: broadcastValidation}, }; }, - parseReqJson: ({body, headers}) => { + parseReqJson: ({body, headers, query}) => { const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + const blobDataIncluded = toBoolean(fromHeaders(headers, MetaHeader.BlobDataIncluded)); return { - signedExecutionPayloadEnvelope: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.fromJson(body), + signedEnvelope: blobDataIncluded + ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.fromJson(body) + : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.fromJson(body), + broadcastValidation: query.broadcast_validation as BroadcastValidation, }; }, - writeReqSsz: ({signedExecutionPayloadEnvelope}) => { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); + writeReqSsz: ({signedEnvelope, broadcastValidation}) => { + const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelope); + const fork = getEnvelopeFork(config, signedEnvelope); return { - body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedExecutionPayloadEnvelope), + body: blobDataIncluded + ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.serialize(signedEnvelope) + : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedEnvelope), headers: { [MetaHeader.Version]: fork, + [MetaHeader.BlobDataIncluded]: blobDataIncluded.toString(), }, + query: {broadcast_validation: broadcastValidation}, }; }, - parseReqSsz: ({body, headers}) => { + parseReqSsz: ({body, headers, query}) => { const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + const blobDataIncluded = toBoolean(fromHeaders(headers, MetaHeader.BlobDataIncluded)); return { - signedExecutionPayloadEnvelope: - getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.deserialize(body), + signedEnvelope: blobDataIncluded + ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.deserialize(body) + : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.deserialize(body), + broadcastValidation: query.broadcast_validation as BroadcastValidation, }; }, schema: { body: Schema.Object, - headers: {[MetaHeader.Version]: Schema.String}, + query: {broadcast_validation: Schema.String}, + headers: {[MetaHeader.Version]: Schema.String, [MetaHeader.BlobDataIncluded]: Schema.String}, }, }, resp: EmptyResponseCodec, diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 446424f8996c..c39246067737 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -36,6 +36,7 @@ import { EmptyResponseCodec, EmptyResponseData, JsonOnlyReq, + WithMeta, WithVersion, } from "../../utils/codecs.js"; import {getPostBellatrixForkTypes, getPostGloasForkTypes, toForkName} from "../../utils/fork.js"; @@ -96,6 +97,8 @@ export const ProduceBlockV4MetaType = new ContainerType( ...VersionType.fields, /** Consensus rewards paid to the proposer for this block, in Wei */ consensusBlockValue: ssz.UintBn64, + /** Specifies whether the response contains full block contents or only the beacon block */ + executionPayloadIncluded: ssz.Boolean, }, {jsonCase: "eth2"} ); @@ -408,6 +411,12 @@ export type Endpoints = { * Post-Gloas, proposers submit execution payload bids rather than full execution payloads, * so there is no longer a concept of blinded or unblinded blocks. Builders release the payload later. * This endpoint is specific to the post-Gloas forks and is not backwards compatible with previous forks. + * + * When self-building and `includePayload` is true (default), the response contains the full + * `BlockContents` (block, execution payload envelope, KZG proofs and blobs) which enables + * stateless envelope publishing via any beacon node. When `includePayload` is false, only the + * `BeaconBlock` is returned and the beacon node caches the envelope and blobs internally. + * When committing to a builder bid, only the `BeaconBlock` is returned in either case. */ produceBlockV4: Endpoint< "GET", @@ -420,6 +429,8 @@ export type Endpoints = { graffiti?: string; skipRandaoVerification?: boolean; builderBoostFactor?: UintBn64; + /** Include execution payload envelope and blobs in the response when self-building */ + includePayload?: boolean; } & Omit, { params: {slot: number}; @@ -431,16 +442,18 @@ export type Endpoints = { builder_selection?: string; builder_boost_factor?: string; strict_fee_recipient_check?: boolean; + include_payload?: boolean; }; }, - BeaconBlock, + BeaconBlock | BlockContents, ProduceBlockV4Meta >; /** * Get execution payload envelope. - * Retrieves execution payload envelope for a given slot and beacon block root. - * The envelope contains the full execution payload along with associated metadata. + * Retrieves the cached execution payload envelope for a given slot and beacon block root, + * to be signed and published via `publishExecutionPayloadEnvelope`. + * Used in the stateful (`includePayload=false`) local build flow. */ getExecutionPayloadEnvelope: Endpoint< "GET", @@ -879,6 +892,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ params: {slot}, query: { @@ -889,6 +903,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ @@ -900,6 +915,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions getPostGloasForkTypes(fork).BeaconBlock), + data: WithMeta( + ({version, executionPayloadIncluded}) => + (executionPayloadIncluded + ? getPostGloasForkTypes(version).BlockContents + : getPostGloasForkTypes(version).BeaconBlock) as Type< + BeaconBlock | BlockContents + > + ), meta: { toJson: (meta) => ProduceBlockV4MetaType.toJson(meta), fromJson: (val) => ProduceBlockV4MetaType.fromJson(val), toHeadersObject: (meta) => ({ [MetaHeader.Version]: meta.version, [MetaHeader.ConsensusBlockValue]: meta.consensusBlockValue.toString(), + [MetaHeader.ExecutionPayloadIncluded]: meta.executionPayloadIncluded.toString(), }), fromHeaders: (headers) => ({ version: toForkName(headers.getRequired(MetaHeader.Version)), consensusBlockValue: BigInt(headers.getRequired(MetaHeader.ConsensusBlockValue)), + executionPayloadIncluded: toBoolean(headers.getRequired(MetaHeader.ExecutionPayloadIncluded)), }), }, }, diff --git a/packages/api/src/utils/metadata.ts b/packages/api/src/utils/metadata.ts index 6186c06a7047..ce89e8484fab 100644 --- a/packages/api/src/utils/metadata.ts +++ b/packages/api/src/utils/metadata.ts @@ -74,8 +74,10 @@ export type ExecutionOptimisticAndDependentRootMeta = ValueOf = { res: undefined, }, publishExecutionPayloadEnvelope: { - args: {signedExecutionPayloadEnvelope: ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue()}, + args: { + signedEnvelope: ssz.gloas.SignedExecutionPayloadEnvelopeContents.defaultValue(), + broadcastValidation: BroadcastValidation.gossip, + }, res: undefined, }, publishExecutionPayloadBid: { diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index ac7aa4272a77..7f152f4bc6e2 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -89,12 +89,14 @@ export const testData: GenericServerTestCases = { feeRecipient, builderSelection: BuilderSelection.ExecutionAlways, strictFeeRecipientCheck: true, + includePayload: true, }, res: { - data: ssz.gloas.BeaconBlock.defaultValue(), + data: ssz.gloas.BlockContents.defaultValue(), meta: { version: ForkName.gloas, consensusBlockValue: ssz.Wei.defaultValue(), + executionPayloadIncluded: true, }, }, }, diff --git a/packages/api/test/utils/checkAgainstSpec.ts b/packages/api/test/utils/checkAgainstSpec.ts index b6dadf688f8c..64b0bf0cf90d 100644 --- a/packages/api/test/utils/checkAgainstSpec.ts +++ b/packages/api/test/utils/checkAgainstSpec.ts @@ -113,6 +113,11 @@ export function runTestCheckAgainstSpec>( stringifyProperties(reqJson.params ?? {}); stringifyProperties(reqJson.query ?? {}); + // Parse headers back to typed values as the spec defines boolean headers as `{schema: type: boolean}` + if (reqJson.headers) { + reqJson.headers = parseHeaders(reqJson.headers as Record) as typeof reqJson.headers; + } + const ignoredProperties = ignoredProperty?.request; if (ignoredProperties) { // Remove ignored properties from schema validation @@ -218,13 +223,18 @@ function prettyAjvErrors(errors: ErrorObject[] | null | undefined): string { return errors.map((e) => `${e.instancePath ?? "."} - ${e.message}`).join("\n"); } -type StringifiedProperty = string | StringifiedProperty[]; +type StringifiedProperty = string | boolean | StringifiedProperty[]; function stringifyProperty(value: unknown): StringifiedProperty { if (typeof value === "number") { return value.toString(10); } + // The spec defines boolean query params as `{schema: type: boolean}`, keep them as booleans + if (typeof value === "boolean") { + return value; + } + if (Array.isArray(value)) { return value.map(stringifyProperty); } 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 8a2c82192618..a7e79de2b129 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -17,6 +17,7 @@ import { import { computeEpochAtSlot, computeTimeAtSlot, + isStatePostGloas, reconstructSignedBlockContents, signedBeaconBlockToBlinded, signedBlockToSignedHeader, @@ -31,6 +32,7 @@ import { fulu, gloas, isDenebBlockContents, + isSignedExecutionPayloadEnvelopeContents, sszTypesFor, } from "@lodestar/types"; import {fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; @@ -38,6 +40,7 @@ import {BlockInputSource, isBlockInputBlobs, isBlockInputColumns} from "../../.. import {PayloadEnvelopeInputSource} from "../../../../chain/blocks/payloadEnvelopeInput/index.js"; import {ImportBlockOpts} from "../../../../chain/blocks/types.js"; import {verifyBlocksInEpoch} from "../../../../chain/blocks/verifyBlock.js"; +import {verifyExecutionPayloadEnvelope} from "../../../../chain/blocks/verifyExecutionPayloadEnvelope.js"; import {BeaconChain} from "../../../../chain/chain.js"; import {ChainEvent} from "../../../../chain/emitter.js"; import {BlockError, BlockErrorCode, BlockGossipError} from "../../../../chain/errors/index.js"; @@ -48,6 +51,7 @@ import { ProduceFullFulu, ProduceFullGloas, } from "../../../../chain/produceBlock/index.js"; +import {RegenCaller} from "../../../../chain/regen/index.js"; import {validateGossipBlock} from "../../../../chain/validation/block.js"; import {validateApiExecutionPayloadBid} from "../../../../chain/validation/executionPayloadBid.js"; import {validateApiExecutionPayloadEnvelope} from "../../../../chain/validation/executionPayloadEnvelope.js"; @@ -651,8 +655,22 @@ export function getBeaconBlockApi({ publishBlockV2, publishBlindedBlockV2, - async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope}) { + async publishExecutionPayloadEnvelope({signedEnvelope, broadcastValidation}) { const seenTimestampSec = Date.now() / 1000; + + const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelope); + let signedExecutionPayloadEnvelope: gloas.SignedExecutionPayloadEnvelope; + // Blobs and KZG proofs submitted alongside the envelope in the stateless flow + let submittedContents: {kzgProofs: deneb.KZGProofs; blobs: deneb.Blobs} | null = null; + + if (blobDataIncluded) { + signedExecutionPayloadEnvelope = signedEnvelope.signedExecutionPayloadEnvelope; + submittedContents = {kzgProofs: signedEnvelope.kzgProofs, blobs: signedEnvelope.blobs}; + } else { + // Stateful flow, blobs and KZG proofs are attached from the block production cache below + signedExecutionPayloadEnvelope = signedEnvelope; + } + const envelope = signedExecutionPayloadEnvelope.message; const slot = envelope.payload.slotNumber; const fork = config.getForkName(slot); @@ -663,7 +681,6 @@ export function getBeaconBlockApi({ throw new ApiError(400, `publishExecutionPayloadEnvelope not supported for pre-gloas fork=${fork}`); } - // TODO GLOAS: review checks, do we want to implement `broadcast_validation`? let block = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.EMPTY); if (block === null) { // Only wait if the envelope is for the current slot @@ -683,39 +700,123 @@ export function getBeaconBlockApi({ throw new ApiError(400, `Envelope slot ${slot} does not match block slot ${block.slot}`); } - await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); - const isSelfBuild = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD; - let dataColumnSidecars: gloas.DataColumnSidecar[] = []; + const cachedResult = chain.blockProductionCache.get(blockRootHex); + const cachedGloasResult = + cachedResult !== undefined && isForkPostGloas(cachedResult.fork) && cachedResult.type === BlockType.Full + ? (cachedResult as ProduceFullGloas) + : undefined; - if (isSelfBuild) { - // For self-builds, construct and publish data column sidecars from cached block production data - 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}`); + broadcastValidation = broadcastValidation ?? routes.beacon.BroadcastValidation.gossip; + const valLogMeta = { + slot, + blockRoot: blockRootHex, + blockHash: blockHashHex, + builderIndex: envelope.builderIndex, + isSelfBuild, + blobDataIncluded, + broadcastValidation, + }; + // Signature is verified for all validation levels except `none`, import can skip re-verification + let envelopeValidated = true; + + switch (broadcastValidation) { + case routes.beacon.BroadcastValidation.none: { + chain.logger.debug("Skipping broadcast validation of execution payload envelope", valLogMeta); + envelopeValidated = false; + break; } - if (!isForkPostGloas(cachedResult.fork)) { - throw new ApiError(400, `Cached block production result is for pre-gloas fork=${cachedResult.fork}`); + + case routes.beacon.BroadcastValidation.gossip: { + await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + break; } - if (cachedResult.type !== BlockType.Full) { - throw new ApiError(400, "Cached block production result is not full block"); + + case routes.beacon.BroadcastValidation.consensusAndEquivocation: + case routes.beacon.BroadcastValidation.consensus: { + await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + + // If the payload was produced by this node it already went through the state transition + if (cachedGloasResult === undefined) { + const blockState = await chain.regen + .getBlockSlotState(block, block.slot, {dontTransferCache: true}, RegenCaller.restApi) + .catch(() => null); + if (blockState === null || !isStatePostGloas(blockState)) { + throw new ApiError( + 400, + `Unable to regenerate block state for consensus checks blockRoot=${blockRootHex}` + ); + } + try { + // Signature and executionRequestsRoot are already verified by gossip validation above + verifyExecutionPayloadEnvelope(chain.config, blockState, envelope, {verifyExecutionRequestsRoot: false}); + } catch (error) { + chain.logger.error( + "Consensus checks failed while publishing execution payload envelope", + valLogMeta, + error as Error + ); + throw new ApiError(400, (error as Error).message); + } + } + chain.logger.debug("Consensus validated while publishing execution payload envelope", valLogMeta); + + if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; + if (chain.opts.broadcastValidationStrictness === "error") { + throw Error(message); + } + chain.logger.warn(message, valLogMeta); + } + break; } - if (cachedResult.cells && cachedResult.blobsBundle.commitments.length > 0) { - const timer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); - const cellsAndProofs = cachedResult.cells.map((rowCells, rowIndex) => ({ - cells: rowCells, - proofs: cachedResult.blobsBundle.proofs.slice( - rowIndex * NUMBER_OF_COLUMNS, - (rowIndex + 1) * NUMBER_OF_COLUMNS - ), - })); - - dataColumnSidecars = getGloasDataColumnSidecars(slot, envelope.beaconBlockRoot, cellsAndProofs); - timer?.(); + default: { + const message = `Broadcast validation of ${broadcastValidation} type not implemented yet`; + if (chain.opts.broadcastValidationStrictness === "error") { + throw Error(message); + } + chain.logger.warn(message, valLogMeta); + } + } + + let dataColumnSidecars: gloas.DataColumnSidecar[] = []; + let cells: fulu.Cell[][] | undefined; + let kzgProofs: deneb.KZGProofs | undefined; + + if (submittedContents !== null) { + if (submittedContents.blobs.length > 0) { + // If the block was produced by this node, we will already have computed cells + cells = cachedGloasResult?.cells ?? submittedContents.blobs.map((blob) => kzg.computeCells(blob)); + kzgProofs = submittedContents.kzgProofs; + } + } else if (cachedGloasResult !== undefined) { + if (cachedGloasResult.cells && cachedGloasResult.blobsBundle.commitments.length > 0) { + cells = cachedGloasResult.cells; + kzgProofs = cachedGloasResult.blobsBundle.proofs; } } else { - // TODO GLOAS: will this api be used by builders or only for self-building? + // An envelope without blob data can only be published via the beacon node that cached them at block production + const expectedBlobCount = + chain.seenPayloadEnvelopeInputCache.get(blockRootHex)?.getVersionedHashes().length ?? 0; + if (expectedBlobCount > 0) { + throw new ApiError( + 400, + `No cached blob data to attach to execution payload envelope for block root ${blockRootHex}` + ); + } + } + + if (cells !== undefined && kzgProofs !== undefined && cells.length > 0) { + const timer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); + const proofs = kzgProofs; + const cellsAndProofs = cells.map((rowCells, rowIndex) => ({ + cells: rowCells, + proofs: proofs.slice(rowIndex * NUMBER_OF_COLUMNS, (rowIndex + 1) * NUMBER_OF_COLUMNS), + })); + + dataColumnSidecars = getGloasDataColumnSidecars(slot, envelope.beaconBlockRoot, cellsAndProofs); + timer?.(); } // If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N. @@ -750,28 +851,22 @@ export function getBeaconBlockApi({ } } - const valLogMeta = { - slot, - blockRoot: blockRootHex, - blockHash: blockHashHex, - builderIndex: envelope.builderIndex, - isSelfBuild, - dataColumns: dataColumnSidecars.length, - }; - const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.api}, delaySec); chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.api, delaySec, signedExecutionPayloadEnvelope); - chain.logger.info("Publishing execution payload envelope", valLogMeta); + chain.logger.info("Publishing execution payload envelope", { + ...valLogMeta, + dataColumns: dataColumnSidecars.length, + }); const publishPromises = [ // Gossip the signed execution payload envelope first () => network.publishSignedExecutionPayloadEnvelope(signedExecutionPayloadEnvelope), - // For self-builds, publish all data column sidecars + // Publish all data column sidecars ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), - // Import execution payload. Signature already verified above - () => chain.processExecutionPayload(payloadInput, {validSignature: true}), + // Import execution payload. Signature verified above unless broadcast validation was skipped + () => chain.processExecutionPayload(payloadInput, {validSignature: envelopeValidated}), ]; const publishPromise = promiseAllMaybeAsync(publishPromises); diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 54516c7c2902..fcd2c1365608 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -904,7 +904,7 @@ export function getValidatorApi( return {data, meta}; }, - async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient}) { + async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload}) { const fork = config.getForkName(slot); if (!isForkPostGloas(fork)) { @@ -1019,9 +1019,41 @@ export function getValidatorApi( void chain.persistBlock(block, "produced_engine_block"); } + // Include the payload for self-builds unless explicitly disabled (stateless flow, default) + const isSelfBuild = source === ProducedBlockSource.engine; + if (isSelfBuild && includePayload !== false) { + const produceResult = chain.blockProductionCache.get(blockRoot); + if ( + produceResult === undefined || + !isForkPostGloas(produceResult.fork) || + produceResult.type !== BlockType.Full + ) { + throw Error(`Missing cached block production result for produced block root=${blockRoot}`); + } + const {executionPayload, executionRequests, blobsBundle, parentBlockRoot} = produceResult as ProduceFullGloas; + + const blockContents: gloas.BlockContents = { + block: block as gloas.BeaconBlock, + executionPayloadEnvelope: { + payload: executionPayload, + executionRequests, + builderIndex: BUILDER_INDEX_SELF_BUILD, + beaconBlockRoot: fromHex(blockRoot), + parentBeaconBlockRoot: parentBlockRoot, + }, + kzgProofs: blobsBundle.proofs, + blobs: blobsBundle.blobs, + }; + + return { + data: blockContents, + meta: {version: fork, consensusBlockValue, executionPayloadIncluded: true}, + }; + } + return { data: block as gloas.BeaconBlock, - meta: {version: fork, consensusBlockValue}, + meta: {version: fork, consensusBlockValue, executionPayloadIncluded: false}, }; }, @@ -1790,6 +1822,13 @@ export function getValidatorApi( const {executionPayload, executionRequests, parentBlockRoot} = produceResult as ProduceFullGloas; + if (executionPayload.slotNumber !== slot) { + throw new ApiError( + 404, + `Cached execution payload is for slot=${executionPayload.slotNumber}, requested slot=${slot}` + ); + } + const envelope: gloas.ExecutionPayloadEnvelope = { payload: executionPayload, executionRequests: executionRequests, diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index f2aafef98717..926125e2f337 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -179,6 +179,7 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr distributed: args.distributed, broadcastValidation: parseBroadcastValidation(args.broadcastValidation), blindedLocal: args.blindedLocal, + includePayload: args.includePayload, externalSigner: { urls: args["externalSigner.urls"], fetch: args["externalSigner.fetch"], diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index a73b3821b118..909bca5e81af 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -53,6 +53,7 @@ export type IValidatorCliArgs = AccountValidatorArgs & useProduceBlockV3?: boolean; broadcastValidation?: string; blindedLocal?: boolean; + includePayload?: boolean; importKeystores?: string[]; importKeystoresPassword?: string; @@ -292,6 +293,13 @@ export const validatorOptions: CliCommandOptions = { defaultDescription: `${defaultOptions.blindedLocal}`, }, + includePayload: { + type: "boolean", + description: + "Request full block contents (execution payload envelope and blobs) when self-building post-Gloas. Allows publishing the execution payload envelope via any beacon node (stateless flow). If set to false, the envelope must be published via the same beacon node that produced the block", + defaultDescription: `${defaultOptions.includePayload}`, + }, + importKeystores: { alias: ["keystore"], // Backwards compatibility with old `validator import` cmdx description: "Path(s) to a directory or single file path to validator keystores, i.e. Launchpad validators", diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index c0cac9f3ceb2..95dca137fa09 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -235,6 +235,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, @@ -273,6 +282,17 @@ export const SignedBeaconBlock = new ContainerType( {typeName: "SignedBeaconBlock", jsonCase: "eth2"} ); +// Full block production response for self-builds, enables stateless envelope publishing +export const BlockContents = new ContainerType( + { + block: BeaconBlock, + executionPayloadEnvelope: ExecutionPayloadEnvelope, + kzgProofs: fuluSsz.KZGProofs, + blobs: denebSsz.Blobs, + }, + {typeName: "BlockContents", jsonCase: "eth2"} +); + export const BeaconState = new ContainerType( { genesisTime: UintNum64, diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index c562c59e3e60..a6d6edf5246c 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -23,9 +23,11 @@ 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; +export type BlockContents = ValueOf; export type BeaconState = ValueOf; export type DataColumnSidecar = ValueOf; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 781f36dcc473..95b919a5ea8d 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -308,7 +308,7 @@ type TypesByFork = { BuilderBid: electra.BuilderBid; SignedBuilderBid: electra.SignedBuilderBid; SSEPayloadAttributes: gloas.SSEPayloadAttributes; - BlockContents: fulu.BlockContents; + BlockContents: gloas.BlockContents; SignedBlockContents: fulu.SignedBlockContents; ExecutionPayloadAndBlobsBundle: fulu.ExecutionPayloadAndBlobsBundle; BlobsBundle: fulu.BlobsBundle; diff --git a/packages/types/src/utils/typeguards.ts b/packages/types/src/utils/typeguards.ts index fa8ffe49d17d..5ced55f39139 100644 --- a/packages/types/src/utils/typeguards.ts +++ b/packages/types/src/utils/typeguards.ts @@ -5,6 +5,7 @@ import { ForkPostElectra, ForkPostGloas, } from "@lodestar/params"; +import {SignedExecutionPayloadEnvelope, SignedExecutionPayloadEnvelopeContents} from "../gloas/types.js"; import { Attestation, BeaconBlock, @@ -111,3 +112,9 @@ export function isGloasBeaconBlock(block: BeaconBlock): block is BeaconBlock { return (sidecar as DataColumnSidecar).beaconBlockRoot !== undefined; } + +export function isSignedExecutionPayloadEnvelopeContents( + signedEnvelope: SignedExecutionPayloadEnvelopeContents | SignedExecutionPayloadEnvelope +): signedEnvelope is SignedExecutionPayloadEnvelopeContents { + return (signedEnvelope as SignedExecutionPayloadEnvelopeContents).signedExecutionPayloadEnvelope !== undefined; +} diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index c916b22bd52d..6cf060da5d7c 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -1,6 +1,6 @@ import {ApiClient, routes} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; -import {BUILDER_INDEX_SELF_BUILD, isForkPostGloas} from "@lodestar/params"; +import {BUILDER_INDEX_SELF_BUILD, ForkPostGloas, isForkPostGloas} from "@lodestar/params"; import { BLSPubkey, BLSSignature, @@ -39,6 +39,7 @@ type DebugLogCtx = {debugLogCtx: Record}; type BlockProposalOpts = { broadcastValidation: routes.beacon.BroadcastValidation; blindedLocal: boolean; + includePayload: boolean; }; /** * Service that sets up and handles validator block proposal duties. @@ -166,11 +167,15 @@ export class BlockProposingService { } /** - * Gloas stateful block production flow: - * 1. Produce beacon block with execution payload bid + * Gloas block production flow: + * 1. Produce beacon block with execution payload bid, by default with full block contents + * (execution payload envelope, KZG proofs and blobs) if self-building (stateless flow) * 2. Sign and publish the beacon block - * 3. Get the execution payload envelope - * 4. Sign and publish the envelope + * 3. If self-building, sign and publish the execution payload envelope + * - Stateless (default): envelope and blobs are available from step 1, publish + * `SignedExecutionPayloadEnvelopeContents` which works via any beacon node + * - Stateful (`includePayload=false`): fetch the envelope from the same beacon node + * that produced the block, which attaches cached blobs and KZG proofs on publish */ private async createAndPublishBlockGloas(pubkey: BLSPubkey, slot: Slot): Promise { const pubkeyHex = toPubkeyHex(pubkey); @@ -180,8 +185,9 @@ export class BlockProposingService { const randaoReveal = await this.validatorStore.signRandao(pubkey, slot); const graffiti = this.validatorStore.getGraffiti(pubkeyHex); const feeRecipient = this.validatorStore.getFeeRecipient(pubkeyHex); + const {broadcastValidation, includePayload} = this.opts; - this.logger.debug("Producing block", {...debugLogCtx, feeRecipient}); + this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, includePayload}); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); // Step 1: Produce beacon block with execution payload bid @@ -191,19 +197,25 @@ export class BlockProposingService { randaoReveal, graffiti, feeRecipient, + includePayload, }) .catch((e: Error) => { this.metrics?.blockProposingErrors.inc({error: "produce"}); throw extendError(e, "Failed to produce block"); }); - const block = blockRes.value(); + const blockOrContents = blockRes.value(); const blockMeta = blockRes.meta(); + const {executionPayloadIncluded} = blockMeta; + const block = executionPayloadIncluded + ? (blockOrContents as BlockContents).block + : (blockOrContents as BeaconBlock); const beaconBlockRoot = this.config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block); const blockRootHex = toRootHex(beaconBlockRoot); this.logger.debug("Produced block", { ...debugLogCtx, consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), + executionPayloadIncluded, blockRoot: blockRootHex, }); this.metrics?.blocksProduced.inc(); @@ -211,8 +223,6 @@ export class BlockProposingService { // Step 2: Sign and publish the beacon block const signedBlock = await this.validatorStore.signBlock(pubkey, block, slot, this.logger); - const {broadcastValidation} = this.opts; - // Publish the block first so it propagates as soon as possible. This reduces the chance other nodes // see the payload envelope before the block over gossip and have to queue it. There's also plenty of // time left in the slot to propagate the payload, so publishing it in parallel is unnecessary. @@ -234,33 +244,59 @@ export class BlockProposingService { if (isSelfBuild) { // Self-build: proposer is responsible for building and publishing the execution payload envelope - // Step 3: Get the execution payload envelope - const envelopeRes = await this.api.validator.getExecutionPayloadEnvelope({ - slot, - beaconBlockRoot, - }); - const envelope = envelopeRes.value(); - - this.logger.debug("Retrieved execution payload envelope", debugLogCtx); - - // Step 4: Sign and publish the envelope - const signedEnvelope = await this.validatorStore.signExecutionPayloadEnvelope( - pubkey, - envelope, - slot, - this.logger - ); - - ( - await this.api.beacon - .publishExecutionPayloadEnvelope({ - signedExecutionPayloadEnvelope: signedEnvelope, - }) - .catch((e: Error) => { - this.metrics?.blockProposingErrors.inc({error: "publish"}); - throw extendError(e, "Failed to publish execution payload envelope"); - }) - ).assertOk(); + if (executionPayloadIncluded) { + // Stateless flow: envelope and blobs are already available from block production + const {executionPayloadEnvelope, kzgProofs, blobs} = blockOrContents as BlockContents; + + // Step 3: Sign and publish the envelope with blobs and KZG proofs + const signedEnvelope = await this.validatorStore.signExecutionPayloadEnvelope( + pubkey, + executionPayloadEnvelope, + slot, + this.logger + ); + + ( + await this.api.beacon + .publishExecutionPayloadEnvelope({ + signedEnvelope: {signedExecutionPayloadEnvelope: signedEnvelope, kzgProofs, blobs}, + broadcastValidation, + }) + .catch((e: Error) => { + this.metrics?.blockProposingErrors.inc({error: "publish"}); + throw extendError(e, "Failed to publish execution payload envelope"); + }) + ).assertOk(); + } else { + // Stateful flow: fetch the envelope from the same beacon node that produced the block + const envelopeRes = await this.api.validator.getExecutionPayloadEnvelope({ + slot, + beaconBlockRoot, + }); + const envelope = envelopeRes.value(); + + this.logger.debug("Retrieved execution payload envelope", debugLogCtx); + + // Step 3: Sign and publish the envelope, beacon node attaches blobs and KZG proofs from its cache + const signedEnvelope = await this.validatorStore.signExecutionPayloadEnvelope( + pubkey, + envelope, + slot, + this.logger + ); + + ( + await this.api.beacon + .publishExecutionPayloadEnvelope({ + signedEnvelope, + broadcastValidation, + }) + .catch((e: Error) => { + this.metrics?.blockProposingErrors.inc({error: "publish"}); + throw extendError(e, "Failed to publish execution payload envelope"); + }) + ).assertOk(); + } this.logger.info("Published block and execution payload envelope", { ...logCtx, diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index e37c2e5047e5..5c58bb58a5d7 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -143,6 +143,8 @@ export const defaultOptions = { broadcastValidation: routes.beacon.BroadcastValidation.gossip, // should request fetching the locally produced block in blinded format blindedLocal: false, + // request full block contents from produceBlockV4 when self-building (stateless flow) + includePayload: true, }; export const MAX_BUILDER_BOOST_FACTOR = 2n ** 64n - 1n; diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index 57a2c35da44c..c81c4e61f392 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -66,6 +66,7 @@ export type ValidatorOptions = { distributed?: boolean; broadcastValidation?: routes.beacon.BroadcastValidation; blindedLocal?: boolean; + includePayload?: boolean; externalSigner?: ExternalSignerOptions; clock?: ClockOptions; }; @@ -256,6 +257,7 @@ export class Validator { { broadcastValidation: opts.broadcastValidation ?? defaultOptions.broadcastValidation, blindedLocal: opts.blindedLocal ?? defaultOptions.blindedLocal, + includePayload: opts.includePayload ?? defaultOptions.includePayload, } ); diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index cb23e43a0fb6..b6aef1c6beb0 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -71,6 +71,7 @@ describe("BlockDutiesService", () => { const blockService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, dutiesService, null, { broadcastValidation: routes.beacon.BroadcastValidation.consensus, blindedLocal: false, + includePayload: true, }); const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); @@ -153,6 +154,7 @@ describe("BlockDutiesService", () => { const blockService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, dutiesService, null, { broadcastValidation: routes.beacon.BroadcastValidation.consensus, blindedLocal: true, + includePayload: true, }); const signedBlock = ssz.bellatrix.SignedBlindedBeaconBlock.defaultValue(); From ddcd1467cd11200476046807a47a158ff7528f5c Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 12:30:03 +0100 Subject: [PATCH 02/47] inline envelope fork lookup --- .../api/src/beacon/routes/beacon/block.ts | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index b2c61c1a63c9..ab511015c1ab 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -1,7 +1,6 @@ import {ContainerType, ListCompositeType, ValueOf} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; import { - ForkName, ForkPostDeneb, ForkPostGloas, ForkPreBellatrix, @@ -280,16 +279,6 @@ const blockIdOnlyReq: RequestCodec { return { getBlockV2: { @@ -479,7 +468,10 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelope); - const fork = getEnvelopeFork(config, signedEnvelope); + const fork = config.getForkName( + (blobDataIncluded ? signedEnvelope.signedExecutionPayloadEnvelope : signedEnvelope).message.payload + .slotNumber + ); return { body: blobDataIncluded ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.toJson(signedEnvelope) @@ -503,7 +495,10 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelope); - const fork = getEnvelopeFork(config, signedEnvelope); + const fork = config.getForkName( + (blobDataIncluded ? signedEnvelope.signedExecutionPayloadEnvelope : signedEnvelope).message.payload + .slotNumber + ); return { body: blobDataIncluded ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.serialize(signedEnvelope) From 374b5358e0424f46849bf8f094694a45b59d2aaf Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 12:30:04 +0100 Subject: [PATCH 03/47] rename flag to statelessBlockProduction and set default based on configured beacon nodes --- packages/cli/src/cmds/validator/handler.ts | 2 +- packages/cli/src/cmds/validator/options.ts | 8 ++++---- packages/validator/src/services/block.ts | 12 ++++++------ packages/validator/src/services/validatorStore.ts | 2 -- packages/validator/src/validator.ts | 5 +++-- packages/validator/test/unit/services/block.test.ts | 4 ++-- 6 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index 926125e2f337..8683858eca53 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -179,7 +179,7 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr distributed: args.distributed, broadcastValidation: parseBroadcastValidation(args.broadcastValidation), blindedLocal: args.blindedLocal, - includePayload: args.includePayload, + statelessBlockProduction: args.statelessBlockProduction, externalSigner: { urls: args["externalSigner.urls"], fetch: args["externalSigner.fetch"], diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 909bca5e81af..224c7118e34d 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -53,7 +53,7 @@ export type IValidatorCliArgs = AccountValidatorArgs & useProduceBlockV3?: boolean; broadcastValidation?: string; blindedLocal?: boolean; - includePayload?: boolean; + statelessBlockProduction?: boolean; importKeystores?: string[]; importKeystoresPassword?: string; @@ -293,11 +293,11 @@ export const validatorOptions: CliCommandOptions = { defaultDescription: `${defaultOptions.blindedLocal}`, }, - includePayload: { + statelessBlockProduction: { type: "boolean", description: - "Request full block contents (execution payload envelope and blobs) when self-building post-Gloas. Allows publishing the execution payload envelope via any beacon node (stateless flow). If set to false, the envelope must be published via the same beacon node that produced the block", - defaultDescription: `${defaultOptions.includePayload}`, + "Use stateless block production flow post-Gloas. Requests full block contents (execution payload envelope and blobs) when self-building which allows publishing the execution payload envelope via any beacon node. If set to false, the envelope must be published via the same beacon node that produced the block", + defaultDescription: "true if multiple beacon nodes are configured, otherwise false", }, importKeystores: { diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 6cf060da5d7c..4d7a065fd33f 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -39,7 +39,7 @@ type DebugLogCtx = {debugLogCtx: Record}; type BlockProposalOpts = { broadcastValidation: routes.beacon.BroadcastValidation; blindedLocal: boolean; - includePayload: boolean; + statelessBlockProduction: boolean; }; /** * Service that sets up and handles validator block proposal duties. @@ -174,8 +174,8 @@ export class BlockProposingService { * 3. If self-building, sign and publish the execution payload envelope * - Stateless (default): envelope and blobs are available from step 1, publish * `SignedExecutionPayloadEnvelopeContents` which works via any beacon node - * - Stateful (`includePayload=false`): fetch the envelope from the same beacon node - * that produced the block, which attaches cached blobs and KZG proofs on publish + * - Stateful (`statelessBlockProduction=false`): fetch the envelope from the same beacon + * node that produced the block, which attaches cached blobs and KZG proofs on publish */ private async createAndPublishBlockGloas(pubkey: BLSPubkey, slot: Slot): Promise { const pubkeyHex = toPubkeyHex(pubkey); @@ -185,9 +185,9 @@ export class BlockProposingService { const randaoReveal = await this.validatorStore.signRandao(pubkey, slot); const graffiti = this.validatorStore.getGraffiti(pubkeyHex); const feeRecipient = this.validatorStore.getFeeRecipient(pubkeyHex); - const {broadcastValidation, includePayload} = this.opts; + const {broadcastValidation, statelessBlockProduction} = this.opts; - this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, includePayload}); + this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, statelessBlockProduction}); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); // Step 1: Produce beacon block with execution payload bid @@ -197,7 +197,7 @@ export class BlockProposingService { randaoReveal, graffiti, feeRecipient, - includePayload, + includePayload: statelessBlockProduction, }) .catch((e: Error) => { this.metrics?.blockProposingErrors.inc({error: "produce"}); diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 5c58bb58a5d7..e37c2e5047e5 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -143,8 +143,6 @@ export const defaultOptions = { broadcastValidation: routes.beacon.BroadcastValidation.gossip, // should request fetching the locally produced block in blinded format blindedLocal: false, - // request full block contents from produceBlockV4 when self-building (stateless flow) - includePayload: true, }; export const MAX_BUILDER_BOOST_FACTOR = 2n ** 64n - 1n; diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index c81c4e61f392..e45643b5f995 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -66,7 +66,7 @@ export type ValidatorOptions = { distributed?: boolean; broadcastValidation?: routes.beacon.BroadcastValidation; blindedLocal?: boolean; - includePayload?: boolean; + statelessBlockProduction?: boolean; externalSigner?: ExternalSignerOptions; clock?: ClockOptions; }; @@ -257,7 +257,8 @@ export class Validator { { broadcastValidation: opts.broadcastValidation ?? defaultOptions.broadcastValidation, blindedLocal: opts.blindedLocal ?? defaultOptions.blindedLocal, - includePayload: opts.includePayload ?? defaultOptions.includePayload, + // Default to stateless flow if multiple beacon nodes are configured to allow failover + statelessBlockProduction: opts.statelessBlockProduction ?? api.httpClient.urlsInits.length > 1, } ); diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index b6aef1c6beb0..f086fb8a9889 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -71,7 +71,7 @@ describe("BlockDutiesService", () => { const blockService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, dutiesService, null, { broadcastValidation: routes.beacon.BroadcastValidation.consensus, blindedLocal: false, - includePayload: true, + statelessBlockProduction: true, }); const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); @@ -154,7 +154,7 @@ describe("BlockDutiesService", () => { const blockService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, dutiesService, null, { broadcastValidation: routes.beacon.BroadcastValidation.consensus, blindedLocal: true, - includePayload: true, + statelessBlockProduction: true, }); const signedBlock = ssz.bellatrix.SignedBlindedBeaconBlock.defaultValue(); From e8244d145412df2b5270b0fcf6409a1a2dda4f13 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 12:30:05 +0100 Subject: [PATCH 04/47] validate submitted blob data against bid commitments --- .../src/api/impl/beacon/blocks/index.ts | 17 +++++++++++++++++ 1 file changed, 17 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 a7e79de2b129..7a15dc496309 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -785,6 +785,23 @@ export function getBeaconBlockApi({ let kzgProofs: deneb.KZGProofs | undefined; if (submittedContents !== null) { + // Validate submitted blob data against bid commitments before computing data column sidecars + const expectedBlobCount = chain.seenPayloadEnvelopeInputCache.get(blockRootHex)?.getVersionedHashes().length; + if (expectedBlobCount !== undefined) { + if (submittedContents.blobs.length !== expectedBlobCount) { + throw new ApiError( + 400, + `Submitted blob count does not match bid commitments submitted=${submittedContents.blobs.length} expected=${expectedBlobCount}` + ); + } + const expectedProofCount = expectedBlobCount * NUMBER_OF_COLUMNS; + if (submittedContents.kzgProofs.length !== expectedProofCount) { + throw new ApiError( + 400, + `Submitted KZG proof count does not match bid commitments submitted=${submittedContents.kzgProofs.length} expected=${expectedProofCount}` + ); + } + } if (submittedContents.blobs.length > 0) { // If the block was produced by this node, we will already have computed cells cells = cachedGloasResult?.cells ?? submittedContents.blobs.map((blob) => kzg.computeCells(blob)); From 930d89048ad9c27c281d1d65e82433c8280f03ba Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 12:30:06 +0100 Subject: [PATCH 05/47] add Gloas to docs wordlist --- .wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.wordlist.txt b/.wordlist.txt index 5700afd898ab..ff184f59de9a 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -45,6 +45,7 @@ Flamegraphs GPG Geth Github +Gloas Goerli Golang Gossipsub From f65b099f884082460a1a5197f9e405c82100ab16 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 16:16:26 +0100 Subject: [PATCH 06/47] require include_payload param in produceBlockV4 --- packages/api/src/beacon/routes/validator.ts | 14 +++++++------- packages/api/src/utils/schema.ts | 3 +++ .../beacon-node/src/api/impl/validator/index.ts | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 4a40d6ef5b7f..ff9724f11daf 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -425,10 +425,10 @@ export type Endpoints = { * so there is no longer a concept of blinded or unblinded blocks. Builders release the payload later. * This endpoint is specific to the post-Gloas forks and is not backwards compatible with previous forks. * - * When self-building and `includePayload` is true (default), the response contains the full - * `BlockContents` (block, execution payload envelope, KZG proofs and blobs) which enables - * stateless envelope publishing via any beacon node. When `includePayload` is false, only the - * `BeaconBlock` is returned and the beacon node caches the envelope and blobs internally. + * When self-building and `includePayload` is true, the response contains the full `BlockContents` + * (block, execution payload envelope, KZG proofs and blobs) which enables stateless envelope + * publishing via any beacon node. When `includePayload` is false, only the `BeaconBlock` is + * returned and the beacon node caches the envelope and blobs internally. * When committing to a builder bid, only the `BeaconBlock` is returned in either case. */ produceBlockV4: Endpoint< @@ -443,7 +443,7 @@ export type Endpoints = { skipRandaoVerification?: boolean; builderBoostFactor?: UintBn64; /** Include execution payload envelope and blobs in the response when self-building */ - includePayload?: boolean; + includePayload: boolean; } & Omit, { params: {slot: number}; @@ -455,7 +455,7 @@ export type Endpoints = { builder_selection?: string; builder_boost_factor?: string; strict_fee_recipient_check?: boolean; - include_payload?: boolean; + include_payload: boolean; }; }, BeaconBlock | BlockContents, @@ -954,7 +954,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions Date: Tue, 14 Jul 2026 16:17:29 +0100 Subject: [PATCH 07/47] support builder selection params in produceBlockV4 --- .../src/api/impl/validator/index.ts | 123 +++++++++++++++--- packages/validator/src/services/block.ts | 2 + 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index cb905202fb7e..a66fefa80514 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -50,6 +50,7 @@ import { ssz, } from "@lodestar/types"; import { + GWEI_TO_WEI, TimeoutError, defer, formatWeiToEth, @@ -116,7 +117,7 @@ export const SYNC_TOLERANCE_EPOCHS = 1; * A cutoff of 2 seconds gives enough time and if there are unexpected delays it ensures we publish * in time as proposals post 4 seconds into the slot will likely be orphaned due to proposer boost reorg. * - * TODO GLOAS: re-evaluate cutoff timing + * TODO GLOAS: re-evaluate cutoff timing due to attestation deadline changes in gloas */ const BLOCK_PRODUCTION_RACE_CUTOFF_MS = 2_000; /** Overall timeout for execution and block production apis */ @@ -910,13 +911,27 @@ export function getValidatorApi( return {data, meta}; }, - async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload}) { + async produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload, + builderSelection, + builderBoostFactor, + }) { const fork = config.getForkName(slot); if (!isForkPostGloas(fork)) { throw new ApiError(400, `produceBlockV4 not supported for pre-gloas fork=${fork}`); } + builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; + builderBoostFactor = builderBoostFactor ?? BigInt(100); + if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { + throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); + } + notWhileSyncing(); await waitForSlot(slot); @@ -933,12 +948,20 @@ export function getValidatorApi( }) ); - // TODO GLOAS: respect builderSelection (MaxProfit, BuilderAlways, ExecutionAlways, etc.) to let - // the user control bid source preferences and value comparison. Also add external builder api - // support when it is implemented. + // TODO GLOAS: add external builder api support when it is implemented const isBuildingOnFull = chain.forkChoice.shouldBuildOnFull(parentBlock, slot); const bidParentBlockHash = isBuildingOnFull ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash; - const builderBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + // Bids are only skipped entirely with executiononly, other engine-preferring selections + // still build a block with the best bid as fallback in case local production fails + const builderBid = + builderSelection === routes.validator.BuilderSelection.ExecutionOnly + ? null + : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + + if (builderBid === null && builderSelection === routes.validator.BuilderSelection.BuilderOnly) { + throw new ApiError(400, `No builder bid available for slot=${slot} with builderSelection=builderonly`); + } + const buildLocalBlock = builderSelection !== routes.validator.BuilderSelection.BuilderOnly; const logCtx = { slot, @@ -946,6 +969,8 @@ export function getValidatorApi( parentBlockRoot: parentBlockRootHex, parentBlockHash: parentBlock.executionPayloadBlockHash, fork, + builderSelection, + builderBoostFactor, ...(builderBid !== null ? { bidValue: builderBid.message.value, @@ -971,7 +996,9 @@ export function getValidatorApi( commonBlockBodyPromise, }; - metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); + if (buildLocalBlock) { + metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); + } if (builderBid !== null) { metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); } @@ -981,33 +1008,91 @@ export function getValidatorApi( return fn().finally(() => t?.({source})); }; - // Always build local block. If builder bid available, also build with it in parallel and prefer it. - const [engineResult, bidResult] = await Promise.allSettled([ - timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)), + // Calculate cutoff time based on start of the slot, ensures a slow local payload build does + // not delay the proposal when a builder bid block is available (and vice versa) + const cutoffMs = Math.max(0, BLOCK_PRODUCTION_RACE_CUTOFF_MS - chain.clock.msFromSlot(slot)); + + const enginePromise: ReturnType = buildLocalBlock + ? timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)) + : Promise.reject(new Error("Local block production disabled by builderonly selection")); + const bidPromise: ReturnType = builderBid !== null ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})) - : Promise.reject(), - ]); + : Promise.reject(new Error("No builder bid available")); + + const [engineResult, bidResult] = await resolveOrRacePromises([enginePromise, bidPromise], { + resolveTimeoutMs: cutoffMs, + raceTimeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + }); let bestResult: typeof engineResult | null = null; let source: ProducedBlockSource = ProducedBlockSource.engine; - if (builderBid !== null && bidResult.status === "fulfilled") { + + if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { + if (engineResult.value.shouldOverrideBuilder) { + source = ProducedBlockSource.engine; + metrics?.blockProductionSelectionResults.inc({ + source: ProducedBlockSource.engine, + reason: EngineBlockSelectionReason.BuilderCensorship, + }); + logger.info("Selected local block, engine suggested to ignore builder bid", logCtx); + } else { + const result = selectBlockProductionSource({ + builderSelection, + builderBoostFactor, + engineExecutionPayloadValue: engineResult.value.executionPayloadValue, + // The bid value is the payment promised to the proposer, in Gwei + builderExecutionPayloadValue: BigInt(builderBid?.message.value ?? 0) * GWEI_TO_WEI, + }); + source = result.source; + metrics?.blockProductionSelectionResults.inc(result); + logger.info(`Selected ${source} block`, {reason: result.reason, ...logCtx}); + } + bestResult = source === ProducedBlockSource.builder ? bidResult : engineResult; + } else if (bidResult.status === "fulfilled") { source = ProducedBlockSource.builder; bestResult = bidResult; - logger.info("Selected builder bid block", logCtx); + const reason = !buildLocalBlock + ? BuilderBlockSelectionReason.EngineDisabled + : engineResult.status === "pending" + ? BuilderBlockSelectionReason.EnginePending + : BuilderBlockSelectionReason.EngineError; + metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.builder, reason}); + if (buildLocalBlock) { + logger.warn("Local block production did not complete, using builder bid block", { + ...logCtx, + reason, + error: engineResult.status === "rejected" ? (engineResult.reason as Error).message : undefined, + }); + } } else if (engineResult.status === "fulfilled") { source = ProducedBlockSource.engine; bestResult = engineResult; + const reason = + builderBid === null + ? EngineBlockSelectionReason.BuilderNoBid + : bidResult.status === "pending" + ? EngineBlockSelectionReason.BuilderPending + : EngineBlockSelectionReason.BuilderError; + metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.engine, reason}); if (builderBid !== null) { - logger.warn("Builder bid block production failed, using local block", logCtx); + logger.warn("Builder bid block production did not complete, using local block", { + ...logCtx, + reason, + error: bidResult.status === "rejected" ? (bidResult.reason as Error).message : undefined, + }); } } if (bestResult === null || bestResult.status !== "fulfilled") { - const engineReason = engineResult.status === "rejected" ? engineResult.reason : undefined; - const bidReason = builderBid !== null && bidResult.status === "rejected" ? bidResult.reason : undefined; - logger.error("Block production failed", {...logCtx, engineReason, bidReason}); - throw Error(`Block production failed: engine=${engineReason ?? "n/a"} builder=${bidReason ?? "n/a"}`); + const engineReason = engineResult.status === "rejected" ? engineResult.reason : engineResult.status; + const bidReason = bidResult.status === "rejected" ? bidResult.reason : bidResult.status; + logger.error("Block production failed", { + ...logCtx, + engineReason: String(engineReason), + bidReason: String(bidReason), + }); + throw Error(`Block production failed: engine=${String(engineReason)} builder=${String(bidReason)}`); } const {block, executionPayloadValue, consensusBlockValue} = bestResult.value; diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 4d7a065fd33f..db51861ce575 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -190,6 +190,8 @@ export class BlockProposingService { this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, statelessBlockProduction}); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); + // Builder selection params are not forwarded post-gloas for now, the pre-gloas defaults + // (executiononly) target the builder api flow and would disable p2p bids for most users // Step 1: Produce beacon block with execution payload bid const blockRes = await this.api.validator .produceBlockV4({ From ba362d6e543bbdbeff254625220ce22c7bf1115a Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 16:17:49 +0100 Subject: [PATCH 08/47] rename statelessBlockProduction flag to payloadLocal --- packages/cli/src/cmds/validator/handler.ts | 2 +- packages/cli/src/cmds/validator/options.ts | 8 ++++---- packages/validator/src/services/block.ts | 12 ++++++------ packages/validator/src/validator.ts | 7 ++++--- packages/validator/test/unit/services/block.test.ts | 4 ++-- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index 8683858eca53..3be87e1b4492 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -179,7 +179,7 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr distributed: args.distributed, broadcastValidation: parseBroadcastValidation(args.broadcastValidation), blindedLocal: args.blindedLocal, - statelessBlockProduction: args.statelessBlockProduction, + payloadLocal: args.payloadLocal, externalSigner: { urls: args["externalSigner.urls"], fetch: args["externalSigner.fetch"], diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 224c7118e34d..5ba3a2f1801d 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -53,7 +53,7 @@ export type IValidatorCliArgs = AccountValidatorArgs & useProduceBlockV3?: boolean; broadcastValidation?: string; blindedLocal?: boolean; - statelessBlockProduction?: boolean; + payloadLocal?: boolean; importKeystores?: string[]; importKeystoresPassword?: string; @@ -293,11 +293,11 @@ export const validatorOptions: CliCommandOptions = { defaultDescription: `${defaultOptions.blindedLocal}`, }, - statelessBlockProduction: { + payloadLocal: { type: "boolean", description: - "Use stateless block production flow post-Gloas. Requests full block contents (execution payload envelope and blobs) when self-building which allows publishing the execution payload envelope via any beacon node. If set to false, the envelope must be published via the same beacon node that produced the block", - defaultDescription: "true if multiple beacon nodes are configured, otherwise false", + "Request keeping the execution payload (envelope and blobs) local to the beacon node during post-Gloas block production. Reduces bandwidth but the envelope must be published via the same beacon node that produced the block", + defaultDescription: "true if a single beacon node is configured, otherwise false", }, importKeystores: { diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index db51861ce575..50eb5c49a6f1 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -39,7 +39,7 @@ type DebugLogCtx = {debugLogCtx: Record}; type BlockProposalOpts = { broadcastValidation: routes.beacon.BroadcastValidation; blindedLocal: boolean; - statelessBlockProduction: boolean; + payloadLocal: boolean; }; /** * Service that sets up and handles validator block proposal duties. @@ -174,8 +174,8 @@ export class BlockProposingService { * 3. If self-building, sign and publish the execution payload envelope * - Stateless (default): envelope and blobs are available from step 1, publish * `SignedExecutionPayloadEnvelopeContents` which works via any beacon node - * - Stateful (`statelessBlockProduction=false`): fetch the envelope from the same beacon - * node that produced the block, which attaches cached blobs and KZG proofs on publish + * - Stateful (`payloadLocal=true`): fetch the envelope from the same beacon node that + * produced the block, which attaches cached blobs and KZG proofs on publish */ private async createAndPublishBlockGloas(pubkey: BLSPubkey, slot: Slot): Promise { const pubkeyHex = toPubkeyHex(pubkey); @@ -185,9 +185,9 @@ export class BlockProposingService { const randaoReveal = await this.validatorStore.signRandao(pubkey, slot); const graffiti = this.validatorStore.getGraffiti(pubkeyHex); const feeRecipient = this.validatorStore.getFeeRecipient(pubkeyHex); - const {broadcastValidation, statelessBlockProduction} = this.opts; + const {broadcastValidation, payloadLocal} = this.opts; - this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, statelessBlockProduction}); + this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, payloadLocal}); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); // Builder selection params are not forwarded post-gloas for now, the pre-gloas defaults @@ -199,7 +199,7 @@ export class BlockProposingService { randaoReveal, graffiti, feeRecipient, - includePayload: statelessBlockProduction, + includePayload: !payloadLocal, }) .catch((e: Error) => { this.metrics?.blockProposingErrors.inc({error: "produce"}); diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index e45643b5f995..5fd70c3c53ca 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -66,7 +66,7 @@ export type ValidatorOptions = { distributed?: boolean; broadcastValidation?: routes.beacon.BroadcastValidation; blindedLocal?: boolean; - statelessBlockProduction?: boolean; + payloadLocal?: boolean; externalSigner?: ExternalSignerOptions; clock?: ClockOptions; }; @@ -257,8 +257,9 @@ export class Validator { { broadcastValidation: opts.broadcastValidation ?? defaultOptions.broadcastValidation, blindedLocal: opts.blindedLocal ?? defaultOptions.blindedLocal, - // Default to stateless flow if multiple beacon nodes are configured to allow failover - statelessBlockProduction: opts.statelessBlockProduction ?? api.httpClient.urlsInits.length > 1, + // Default to keeping the payload local to the beacon node if only a single node is + // configured, with multiple nodes the stateless flow allows publishing via any of them + payloadLocal: opts.payloadLocal ?? api.httpClient.urlsInits.length <= 1, } ); diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index f086fb8a9889..f49ca77dc3f4 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -71,7 +71,7 @@ describe("BlockDutiesService", () => { const blockService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, dutiesService, null, { broadcastValidation: routes.beacon.BroadcastValidation.consensus, blindedLocal: false, - statelessBlockProduction: true, + payloadLocal: false, }); const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); @@ -154,7 +154,7 @@ describe("BlockDutiesService", () => { const blockService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, dutiesService, null, { broadcastValidation: routes.beacon.BroadcastValidation.consensus, blindedLocal: true, - statelessBlockProduction: true, + payloadLocal: false, }); const signedBlock = ssz.bellatrix.SignedBlindedBeaconBlock.defaultValue(); From b65420b1d3316b71be7ed7718196885b83e6e590 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 16:17:49 +0100 Subject: [PATCH 09/47] harden broadcast validation when publishing execution payload envelope --- .../src/api/impl/beacon/blocks/index.ts | 79 ++++++++++++------- 1 file changed, 51 insertions(+), 28 deletions(-) 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 7a15dc496309..c867b0a3c6f9 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -43,7 +43,13 @@ import {verifyBlocksInEpoch} from "../../../../chain/blocks/verifyBlock.js"; import {verifyExecutionPayloadEnvelope} from "../../../../chain/blocks/verifyExecutionPayloadEnvelope.js"; import {BeaconChain} from "../../../../chain/chain.js"; import {ChainEvent} from "../../../../chain/emitter.js"; -import {BlockError, BlockErrorCode, BlockGossipError} from "../../../../chain/errors/index.js"; +import { + BlockError, + BlockErrorCode, + BlockGossipError, + ExecutionPayloadEnvelopeError, + ExecutionPayloadEnvelopeErrorCode, +} from "../../../../chain/errors/index.js"; import { BlockType, ProduceFullBellatrix, @@ -720,27 +726,31 @@ export function getBeaconBlockApi({ // Signature is verified for all validation levels except `none`, import can skip re-verification let envelopeValidated = true; - switch (broadcastValidation) { - case routes.beacon.BroadcastValidation.none: { - chain.logger.debug("Skipping broadcast validation of execution payload envelope", valLogMeta); - envelopeValidated = false; - break; - } + try { + switch (broadcastValidation) { + case routes.beacon.BroadcastValidation.none: { + chain.logger.debug("Skipping broadcast validation of execution payload envelope", valLogMeta); + envelopeValidated = false; + break; + } - case routes.beacon.BroadcastValidation.gossip: { - await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); - break; - } + case routes.beacon.BroadcastValidation.gossip: { + await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + break; + } - case routes.beacon.BroadcastValidation.consensusAndEquivocation: - case routes.beacon.BroadcastValidation.consensus: { - await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + case routes.beacon.BroadcastValidation.consensusAndEquivocation: + case routes.beacon.BroadcastValidation.consensus: { + await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); - // If the payload was produced by this node it already went through the state transition - if (cachedGloasResult === undefined) { + // Unlike blocks, the block production cache key (block root) does not bind the envelope + // content, so consensus checks must run even if the payload was produced by this node const blockState = await chain.regen .getBlockSlotState(block, block.slot, {dontTransferCache: true}, RegenCaller.restApi) - .catch(() => null); + .catch((e) => { + chain.logger.debug("Failed to regenerate block state for consensus checks", valLogMeta, e as Error); + return null; + }); if (blockState === null || !isStatePostGloas(blockState)) { throw new ApiError( 400, @@ -758,26 +768,39 @@ export function getBeaconBlockApi({ ); throw new ApiError(400, (error as Error).message); } + chain.logger.debug("Consensus validated while publishing execution payload envelope", valLogMeta); + + if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; + if (chain.opts.broadcastValidationStrictness === "error") { + throw Error(message); + } + chain.logger.warn(message, valLogMeta); + } + break; } - chain.logger.debug("Consensus validated while publishing execution payload envelope", valLogMeta); - if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { - const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; + default: { + const message = `Broadcast validation of ${broadcastValidation} type not implemented yet`; if (chain.opts.broadcastValidationStrictness === "error") { throw Error(message); } chain.logger.warn(message, valLogMeta); + // No validation was performed, the envelope signature must be verified on import + envelopeValidated = false; } - break; } - - default: { - const message = `Broadcast validation of ${broadcastValidation} type not implemented yet`; - if (chain.opts.broadcastValidationStrictness === "error") { - throw Error(message); - } - chain.logger.warn(message, valLogMeta); + } catch (error) { + if ( + error instanceof ExecutionPayloadEnvelopeError && + error.type.code === ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN + ) { + // The envelope may already be known, e.g. received via gossip from another node in a + // multi node setup, this is benign and treated as a successful publish (same as blocks) + chain.logger.debug("Ignoring already-known execution payload envelope during publishing", valLogMeta); + return; } + throw error; } let dataColumnSidecars: gloas.DataColumnSidecar[] = []; From 469a1a653ba27b894c485ad69e9635b6477a0d2b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 16:48:00 +0100 Subject: [PATCH 10/47] clarify why consensus checks always run for published envelopes --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c867b0a3c6f9..6703be921365 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -743,8 +743,8 @@ export function getBeaconBlockApi({ case routes.beacon.BroadcastValidation.consensus: { await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); - // Unlike blocks, the block production cache key (block root) does not bind the envelope - // content, so consensus checks must run even if the payload was produced by this node + // Unlike blocks, the published envelope is not identified by the block root, a locally + // produced payload cannot vouch for the submitted envelope, consensus checks always run const blockState = await chain.regen .getBlockSlotState(block, block.slot, {dontTransferCache: true}, RegenCaller.restApi) .catch((e) => { From 8c67de64d55890e620cfb26648762853d3e1dc0f Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 16:56:40 +0100 Subject: [PATCH 11/47] align bid selection logs and censorship handling with produceBlockV3 --- .../src/api/impl/validator/index.ts | 81 +++++++++++-------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index a66fefa80514..5f7b72281490 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1012,8 +1012,22 @@ export function getValidatorApi( // not delay the proposal when a builder bid block is available (and vice versa) const cutoffMs = Math.max(0, BLOCK_PRODUCTION_RACE_CUTOFF_MS - chain.clock.msFromSlot(slot)); + // use abort controller to stop waiting for the bid block if the engine block will be selected + const controller = new AbortController(); + const enginePromise: ReturnType = buildLocalBlock - ? timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)) + ? timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)).then((engineBlock) => { + // No need to wait for the bid block if the engine block will always be selected due to + // suspected builder censorship, a builder boost factor of 0 or executionalways selection + if ( + engineBlock.shouldOverrideBuilder || + builderBoostFactor === BigInt(0) || + builderSelection === routes.validator.BuilderSelection.ExecutionAlways + ) { + controller.abort(); + } + return engineBlock; + }) : Promise.reject(new Error("Local block production disabled by builderonly selection")); const bidPromise: ReturnType = builderBid !== null @@ -1023,31 +1037,32 @@ export function getValidatorApi( const [engineResult, bidResult] = await resolveOrRacePromises([enginePromise, bidPromise], { resolveTimeoutMs: cutoffMs, raceTimeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + signal: controller.signal, }); let bestResult: typeof engineResult | null = null; let source: ProducedBlockSource = ProducedBlockSource.engine; - if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { - if (engineResult.value.shouldOverrideBuilder) { - source = ProducedBlockSource.engine; - metrics?.blockProductionSelectionResults.inc({ - source: ProducedBlockSource.engine, - reason: EngineBlockSelectionReason.BuilderCensorship, - }); - logger.info("Selected local block, engine suggested to ignore builder bid", logCtx); - } else { - const result = selectBlockProductionSource({ - builderSelection, - builderBoostFactor, - engineExecutionPayloadValue: engineResult.value.executionPayloadValue, - // The bid value is the payment promised to the proposer, in Gwei - builderExecutionPayloadValue: BigInt(builderBid?.message.value ?? 0) * GWEI_TO_WEI, - }); - source = result.source; - metrics?.blockProductionSelectionResults.inc(result); - logger.info(`Selected ${source} block`, {reason: result.reason, ...logCtx}); - } + // handle shouldOverrideBuilder separately + if (engineResult.status === "fulfilled" && engineResult.value.shouldOverrideBuilder && builderBid !== null) { + source = ProducedBlockSource.engine; + bestResult = engineResult; + metrics?.blockProductionSelectionResults.inc({ + source: ProducedBlockSource.engine, + reason: EngineBlockSelectionReason.BuilderCensorship, + }); + logger.info("Selected local block: censorship suspected in builder bid", logCtx); + } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { + const result = selectBlockProductionSource({ + builderSelection, + builderBoostFactor, + engineExecutionPayloadValue: engineResult.value.executionPayloadValue, + // The bid value is the payment promised to the proposer, in Gwei + builderExecutionPayloadValue: BigInt(builderBid?.message.value ?? 0) * GWEI_TO_WEI, + }); + source = result.source; + metrics?.blockProductionSelectionResults.inc(result); + logger.info(`Selected ${source} block`, {reason: result.reason, ...logCtx}); bestResult = source === ProducedBlockSource.builder ? bidResult : engineResult; } else if (bidResult.status === "fulfilled") { source = ProducedBlockSource.builder; @@ -1058,13 +1073,11 @@ export function getValidatorApi( ? BuilderBlockSelectionReason.EnginePending : BuilderBlockSelectionReason.EngineError; metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.builder, reason}); - if (buildLocalBlock) { - logger.warn("Local block production did not complete, using builder bid block", { - ...logCtx, - reason, - error: engineResult.status === "rejected" ? (engineResult.reason as Error).message : undefined, - }); - } + logger.info("Selected builder bid block: no local block produced", { + reason, + ...logCtx, + error: engineResult.status === "rejected" ? (engineResult.reason as Error).message : undefined, + }); } else if (engineResult.status === "fulfilled") { source = ProducedBlockSource.engine; bestResult = engineResult; @@ -1075,13 +1088,11 @@ export function getValidatorApi( ? EngineBlockSelectionReason.BuilderPending : EngineBlockSelectionReason.BuilderError; metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.engine, reason}); - if (builderBid !== null) { - logger.warn("Builder bid block production did not complete, using local block", { - ...logCtx, - reason, - error: bidResult.status === "rejected" ? (bidResult.reason as Error).message : undefined, - }); - } + logger.info("Selected local block: no builder bid block produced", { + reason, + ...logCtx, + error: bidResult.status === "rejected" ? (bidResult.reason as Error).message : undefined, + }); } if (bestResult === null || bestResult.status !== "fulfilled") { From 866b52f6605df3d66a3be0a204cb5957a34008e9 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 17:07:34 +0100 Subject: [PATCH 12/47] add todo for envelope equivocation checks --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 1 + 1 file changed, 1 insertion(+) 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 6703be921365..e1eb9b7c44ef 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -770,6 +770,7 @@ export function getBeaconBlockApi({ } chain.logger.debug("Consensus validated while publishing execution payload envelope", valLogMeta); + // TODO GLOAS: implement equivocation checks for published blocks and envelopes if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; if (chain.opts.broadcastValidationStrictness === "error") { From 9eb96e0cc5b3fb0704266750c3ee4a780d54ce6b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 17:15:56 +0100 Subject: [PATCH 13/47] clarify bid value payment is protocol-enforced --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 5f7b72281490..484cf7f7b26b 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1057,7 +1057,7 @@ export function getValidatorApi( builderSelection, builderBoostFactor, engineExecutionPayloadValue: engineResult.value.executionPayloadValue, - // The bid value is the payment promised to the proposer, in Gwei + // The bid value is the protocol-enforced payment to the proposer, in Gwei builderExecutionPayloadValue: BigInt(builderBid?.message.value ?? 0) * GWEI_TO_WEI, }); source = result.source; From 03b46b66e05960bd80d9ce5d7fb6c174ce7d1c14 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 17:16:59 +0100 Subject: [PATCH 14/47] simplify bid value comment --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 484cf7f7b26b..b19229a0855c 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1057,7 +1057,7 @@ export function getValidatorApi( builderSelection, builderBoostFactor, engineExecutionPayloadValue: engineResult.value.executionPayloadValue, - // The bid value is the protocol-enforced payment to the proposer, in Gwei + // The bid value is the payment to the proposer, in Gwei builderExecutionPayloadValue: BigInt(builderBid?.message.value ?? 0) * GWEI_TO_WEI, }); source = result.source; From 025b68e77247f24f891e3b14790c506eca4f878c Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 17:19:49 +0100 Subject: [PATCH 15/47] log suspected builder censorship as warn --- packages/beacon-node/src/api/impl/validator/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index b19229a0855c..294e2c84ddaf 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -780,7 +780,7 @@ export function getValidatorApi( // handle shouldOverrideBuilder separately if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder) { - logger.info("Selected engine block: censorship suspected in builder blocks", { + logger.warn("Selected engine block: censorship suspected in builder blocks", { ...loggerContext, durationMs: engine.durationMs, shouldOverrideBuilder: engine.value.shouldOverrideBuilder, @@ -1051,7 +1051,7 @@ export function getValidatorApi( source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.BuilderCensorship, }); - logger.info("Selected local block: censorship suspected in builder bid", logCtx); + logger.warn("Selected local block: censorship suspected in builder bid", logCtx); } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { const result = selectBlockProductionSource({ builderSelection, From 7b04cb9e76b5f1d6f00abdf37e861e9d18016aa9 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 17:24:11 +0100 Subject: [PATCH 16/47] fix stale comment on stateless flow default --- packages/validator/src/services/block.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 50eb5c49a6f1..8dc9613374b1 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -168,11 +168,11 @@ export class BlockProposingService { /** * Gloas block production flow: - * 1. Produce beacon block with execution payload bid, by default with full block contents + * 1. Produce beacon block with execution payload bid, with full block contents * (execution payload envelope, KZG proofs and blobs) if self-building (stateless flow) * 2. Sign and publish the beacon block * 3. If self-building, sign and publish the execution payload envelope - * - Stateless (default): envelope and blobs are available from step 1, publish + * - Stateless (`payloadLocal=false`): envelope and blobs are available from step 1, publish * `SignedExecutionPayloadEnvelopeContents` which works via any beacon node * - Stateful (`payloadLocal=true`): fetch the envelope from the same beacon node that * produced the block, which attaches cached blobs and KZG proofs on publish From 23f061b12d0c17c923abd49d186b2419c8d9103b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 18:22:15 +0100 Subject: [PATCH 17/47] harden execution payload envelope publishing edge cases --- .../src/api/impl/beacon/blocks/index.ts | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) 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 e1eb9b7c44ef..b0834e6b6fa7 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -708,8 +708,12 @@ export function getBeaconBlockApi({ const isSelfBuild = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD; const cachedResult = chain.blockProductionCache.get(blockRootHex); + // Bid-based blocks are cached without payload data, only a self-build entry can vouch for blob data const cachedGloasResult = - cachedResult !== undefined && isForkPostGloas(cachedResult.fork) && cachedResult.type === BlockType.Full + cachedResult !== undefined && + isForkPostGloas(cachedResult.fork) && + cachedResult.type === BlockType.Full && + (cachedResult as ProduceFullGloas).executionPayload !== undefined ? (cachedResult as ProduceFullGloas) : undefined; @@ -725,6 +729,7 @@ export function getBeaconBlockApi({ }; // Signature is verified for all validation levels except `none`, import can skip re-verification let envelopeValidated = true; + let envelopeAlreadyKnown = false; try { switch (broadcastValidation) { @@ -798,15 +803,23 @@ export function getBeaconBlockApi({ ) { // The envelope may already be known, e.g. received via gossip from another node in a // multi node setup, this is benign and treated as a successful publish (same as blocks) - chain.logger.debug("Ignoring already-known execution payload envelope during publishing", valLogMeta); - return; + if (submittedContents === null) { + chain.logger.debug("Ignoring already-known execution payload envelope during publishing", valLogMeta); + return; + } + // The envelope may have been gossiped without its data columns being published, e.g. if + // another beacon node failed mid-publish, still publish columns from the submitted blobs + chain.logger.debug("Publishing data columns of already-known execution payload envelope", valLogMeta); + envelopeAlreadyKnown = true; + } else { + throw error; } - throw error; } let dataColumnSidecars: gloas.DataColumnSidecar[] = []; let cells: fulu.Cell[][] | undefined; let kzgProofs: deneb.KZGProofs | undefined; + let dataColumnTimer: (() => number) | undefined; if (submittedContents !== null) { // Validate submitted blob data against bid commitments before computing data column sidecars @@ -827,6 +840,7 @@ export function getBeaconBlockApi({ } } if (submittedContents.blobs.length > 0) { + dataColumnTimer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); // If the block was produced by this node, we will already have computed cells cells = cachedGloasResult?.cells ?? submittedContents.blobs.map((blob) => kzg.computeCells(blob)); kzgProofs = submittedContents.kzgProofs; @@ -849,7 +863,7 @@ export function getBeaconBlockApi({ } if (cells !== undefined && kzgProofs !== undefined && cells.length > 0) { - const timer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); + dataColumnTimer ??= metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); const proofs = kzgProofs; const cellsAndProofs = cells.map((rowCells, rowIndex) => ({ cells: rowCells, @@ -857,7 +871,7 @@ export function getBeaconBlockApi({ })); dataColumnSidecars = getGloasDataColumnSidecars(slot, envelope.beaconBlockRoot, cellsAndProofs); - timer?.(); + dataColumnTimer?.(); } // If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N. @@ -874,12 +888,18 @@ export function getBeaconBlockApi({ throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); } - payloadInput.addPayloadEnvelope({ - envelope: signedExecutionPayloadEnvelope, - source: PayloadEnvelopeInputSource.api, - seenTimestampSec, - peerIdStr: undefined, - }); + if (payloadInput.hasPayloadEnvelope()) { + // The envelope may have been added while this request was being validated, e.g. via gossip + chain.logger.debug("Execution payload envelope already added during publishing", valLogMeta); + envelopeAlreadyKnown = true; + } else { + payloadInput.addPayloadEnvelope({ + envelope: signedExecutionPayloadEnvelope, + source: PayloadEnvelopeInputSource.api, + seenTimestampSec, + peerIdStr: undefined, + }); + } if (dataColumnSidecars.length > 0) { for (const columnSidecar of dataColumnSidecars) { @@ -907,7 +927,8 @@ export function getBeaconBlockApi({ // Publish all data column sidecars ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), // Import execution payload. Signature verified above unless broadcast validation was skipped - () => chain.processExecutionPayload(payloadInput, {validSignature: envelopeValidated}), + // or the cached envelope was added by another source and might differ from the submitted one + () => chain.processExecutionPayload(payloadInput, {validSignature: envelopeValidated && !envelopeAlreadyKnown}), ]; const publishPromise = promiseAllMaybeAsync(publishPromises); From e7ac8f049982651eded1e7a91f4b3e6a87cdba8d Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 18:25:29 +0100 Subject: [PATCH 18/47] only report builder censorship if builder flow is enabled --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 294e2c84ddaf..acf73379288c 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -779,7 +779,7 @@ export function getValidatorApi( } // handle shouldOverrideBuilder separately - if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder) { + if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder && isBuilderEnabled) { logger.warn("Selected engine block: censorship suspected in builder blocks", { ...loggerContext, durationMs: engine.durationMs, From f1b2b9f0a85afe9a0c9cd3b041919821afc29798 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 18:32:35 +0100 Subject: [PATCH 19/47] clarify import signature verification comment --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b0834e6b6fa7..77d68bc6b11e 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -926,8 +926,8 @@ export function getBeaconBlockApi({ () => network.publishSignedExecutionPayloadEnvelope(signedExecutionPayloadEnvelope), // Publish all data column sidecars ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), - // Import execution payload. Signature verified above unless broadcast validation was skipped - // or the cached envelope was added by another source and might differ from the submitted one + // Import processes the envelope stored in the payload input, signature verification can only + // be skipped if that is the submitted envelope and it was verified during broadcast validation () => chain.processExecutionPayload(payloadInput, {validSignature: envelopeValidated && !envelopeAlreadyKnown}), ]; From 7331786be3efe3af4c29c126ac920d3a5803a576 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 21:37:55 +0100 Subject: [PATCH 20/47] clarify equivocation check todo --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 2 +- 1 file changed, 1 insertion(+), 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 77d68bc6b11e..1da5190d6c59 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -775,7 +775,7 @@ export function getBeaconBlockApi({ } chain.logger.debug("Consensus validated while publishing execution payload envelope", valLogMeta); - // TODO GLOAS: implement equivocation checks for published blocks and envelopes + // TODO GLOAS: check the block is not a proposer equivocation when publishing blocks and envelopes if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; if (chain.opts.broadcastValidationStrictness === "error") { From 39264baa6bc530fe285fa368af91a244ac24eccb Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 21:39:13 +0100 Subject: [PATCH 21/47] scope equivocation todo to envelope publishing --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 2 +- 1 file changed, 1 insertion(+), 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 1da5190d6c59..5f83de78ac35 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -775,7 +775,7 @@ export function getBeaconBlockApi({ } chain.logger.debug("Consensus validated while publishing execution payload envelope", valLogMeta); - // TODO GLOAS: check the block is not a proposer equivocation when publishing blocks and envelopes + // TODO GLOAS: check the block is not a proposer equivocation before publishing the envelope if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; if (chain.opts.broadcastValidationStrictness === "error") { From 0282a41934567b2f9951486ddca30cda738167dd Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 14 Jul 2026 22:31:32 +0100 Subject: [PATCH 22/47] always treat envelope signature as verified on api import --- .../src/api/impl/beacon/blocks/index.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) 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 5f83de78ac35..26e16ffc0883 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -727,15 +727,10 @@ export function getBeaconBlockApi({ blobDataIncluded, broadcastValidation, }; - // Signature is verified for all validation levels except `none`, import can skip re-verification - let envelopeValidated = true; - let envelopeAlreadyKnown = false; - try { switch (broadcastValidation) { case routes.beacon.BroadcastValidation.none: { chain.logger.debug("Skipping broadcast validation of execution payload envelope", valLogMeta); - envelopeValidated = false; break; } @@ -792,8 +787,6 @@ export function getBeaconBlockApi({ throw Error(message); } chain.logger.warn(message, valLogMeta); - // No validation was performed, the envelope signature must be verified on import - envelopeValidated = false; } } } catch (error) { @@ -810,7 +803,6 @@ export function getBeaconBlockApi({ // The envelope may have been gossiped without its data columns being published, e.g. if // another beacon node failed mid-publish, still publish columns from the submitted blobs chain.logger.debug("Publishing data columns of already-known execution payload envelope", valLogMeta); - envelopeAlreadyKnown = true; } else { throw error; } @@ -891,7 +883,6 @@ export function getBeaconBlockApi({ if (payloadInput.hasPayloadEnvelope()) { // The envelope may have been added while this request was being validated, e.g. via gossip chain.logger.debug("Execution payload envelope already added during publishing", valLogMeta); - envelopeAlreadyKnown = true; } else { payloadInput.addPayloadEnvelope({ envelope: signedExecutionPayloadEnvelope, @@ -926,9 +917,9 @@ export function getBeaconBlockApi({ () => network.publishSignedExecutionPayloadEnvelope(signedExecutionPayloadEnvelope), // Publish all data column sidecars ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), - // Import processes the envelope stored in the payload input, signature verification can only - // be skipped if that is the submitted envelope and it was verified during broadcast validation - () => chain.processExecutionPayload(payloadInput, {validSignature: envelopeValidated && !envelopeAlreadyKnown}), + // Import execution payload. Signature is verified during broadcast validation, an already + // known envelope was verified by its original source and `none` deliberately skips validation + () => chain.processExecutionPayload(payloadInput, {validSignature: true}), ]; const publishPromise = promiseAllMaybeAsync(publishPromises); From 24eabeb1577affa010fff4c6e8085388f72424c2 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 15 Jul 2026 09:40:44 +0100 Subject: [PATCH 23/47] tweak comments --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 26e16ffc0883..6072515a8fe4 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -708,7 +708,7 @@ export function getBeaconBlockApi({ const isSelfBuild = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD; const cachedResult = chain.blockProductionCache.get(blockRootHex); - // Bid-based blocks are cached without payload data, only a self-build entry can vouch for blob data + // Only use the cached result if it contains payload data, blocks committing to a bid are cached without it const cachedGloasResult = cachedResult !== undefined && isForkPostGloas(cachedResult.fork) && @@ -743,8 +743,7 @@ export function getBeaconBlockApi({ case routes.beacon.BroadcastValidation.consensus: { await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); - // Unlike blocks, the published envelope is not identified by the block root, a locally - // produced payload cannot vouch for the submitted envelope, consensus checks always run + // Verify the envelope against the post-block state const blockState = await chain.regen .getBlockSlotState(block, block.slot, {dontTransferCache: true}, RegenCaller.restApi) .catch((e) => { From c0e5ad9103621a2ce04fa8f62ac60301e586ed25 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 17:13:09 +0100 Subject: [PATCH 24/47] fix: only time data column sidecar computation when cells are computed Reusing cached cells from local block production skips computeCells, so timing that path recorded partial measurements. Only start the timer when cells are actually computed from submitted blobs. --- .../beacon-node/src/api/impl/beacon/blocks/index.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 6072515a8fe4..f9a7c81dc471 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -831,9 +831,14 @@ export function getBeaconBlockApi({ } } if (submittedContents.blobs.length > 0) { - dataColumnTimer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); - // If the block was produced by this node, we will already have computed cells - cells = cachedGloasResult?.cells ?? submittedContents.blobs.map((blob) => kzg.computeCells(blob)); + // If the block was produced by this node, reuse the cached cells and only time the + // metric when cells are actually computed from the submitted blobs + if (cachedGloasResult?.cells) { + cells = cachedGloasResult.cells; + } else { + dataColumnTimer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); + cells = submittedContents.blobs.map((blob) => kzg.computeCells(blob)); + } kzgProofs = submittedContents.kzgProofs; } } else if (cachedGloasResult !== undefined) { @@ -854,7 +859,6 @@ export function getBeaconBlockApi({ } if (cells !== undefined && kzgProofs !== undefined && cells.length > 0) { - dataColumnTimer ??= metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); const proofs = kzgProofs; const cellsAndProofs = cells.map((rowCells, rowIndex) => ({ cells: rowCells, From c03f2953b7f5d835515151e4b35dc1eb0b9dcb80 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 17:34:31 +0100 Subject: [PATCH 25/47] return 404 when requesting envelope for a builder bid block --- packages/beacon-node/src/api/impl/validator/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index acf73379288c..e070094147c1 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1967,6 +1967,11 @@ export function getValidatorApi( const {executionPayload, executionRequests, parentBlockRoot} = produceResult as ProduceFullGloas; + if (executionPayload === undefined) { + // Blocks committing to a builder bid are cached as full but without payload data + throw new ApiError(404, `No self-build execution payload cached for block root ${blockRootHex}`); + } + if (executionPayload.slotNumber !== slot) { throw new ApiError( 404, From 975693c88654100e2c8eab8eb477f654bdad8cc9 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 17:56:37 +0100 Subject: [PATCH 26/47] Apply suggestion from myself --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 6ac5c272d035..4d966506b844 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1932,7 +1932,7 @@ export function getValidatorApi( if (executionPayload === undefined) { // Blocks committing to a builder bid are cached as full but without payload data - throw new ApiError(404, `No self-build execution payload cached for block root ${blockRootHex}`); + throw new ApiError(404, `No local execution payload cached for block root ${blockRootHex}`); } if (executionPayload.slotNumber !== slot) { From b7b1b4388dd48317d6277dee71055aeb17a48b0e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 18:20:47 +0100 Subject: [PATCH 27/47] cover circuit breaker scenarios in unit tests --- .../unit/chain/builderCircuitBreaker.test.ts | 24 +++++++++++++++++++ .../test/unit/chain/prepareNextSlot.test.ts | 20 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/beacon-node/test/unit/chain/builderCircuitBreaker.test.ts b/packages/beacon-node/test/unit/chain/builderCircuitBreaker.test.ts index a6d1ccbd928d..cfde576e7f44 100644 --- a/packages/beacon-node/test/unit/chain/builderCircuitBreaker.test.ts +++ b/packages/beacon-node/test/unit/chain/builderCircuitBreaker.test.ts @@ -1,7 +1,9 @@ import {describe, expect, it, vi} from "vitest"; import {IForkChoice} from "@lodestar/fork-choice"; import {testLogger} from "@lodestar/logger/test-utils"; +import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {BuilderCircuitBreaker} from "../../../src/chain/builderCircuitBreaker.js"; +import {getFaultInspectionParams} from "../../../src/execution/builder/http.js"; describe("BuilderCircuitBreaker", () => { const faultInspectionWindow = 32; @@ -52,4 +54,26 @@ describe("BuilderCircuitBreaker", () => { expect(breaker.isActive(101)).toBe(true); expect(getPayloadRevealCounts).toHaveBeenCalledTimes(2); }); + + describe("getFaultInspectionParams", () => { + it("caps allowed faults at a quarter of the fault inspection window", () => { + expect(getFaultInspectionParams({faultInspectionWindow: 64, allowedFaults: 32})).toEqual({ + faultInspectionWindow: 64, + allowedFaults: 16, + }); + }); + + it("enforces a minimum window of SLOTS_PER_EPOCH", () => { + const params = getFaultInspectionParams({faultInspectionWindow: 1, allowedFaults: 1}); + expect(params.faultInspectionWindow).toBe(SLOTS_PER_EPOCH); + expect(params.allowedFaults).toBe(1); + }); + + it("randomizes defaults within the recommended ranges", () => { + const params = getFaultInspectionParams({}); + expect(params.faultInspectionWindow).toBeGreaterThanOrEqual(SLOTS_PER_EPOCH); + expect(params.faultInspectionWindow).toBeLessThan(2 * SLOTS_PER_EPOCH); + expect(params.allowedFaults).toBe(Math.floor(params.faultInspectionWindow / 4)); + }); + }); }); diff --git a/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts b/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts index b7850fb494ac..de7dde2ca3e9 100644 --- a/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts +++ b/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts @@ -142,4 +142,24 @@ describe("PrepareNextSlot scheduler", () => { expect(executionEngineStub.notifyForkchoiceUpdate).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1); }); + + it("gloas - should update builder circuit breaker instead of builder status", async () => { + getForkStub.mockReturnValue(ForkName.gloas); + chainStub.recomputeForkChoiceHead.mockReturnValue({...zeroProtoBlock, slot: SLOTS_PER_EPOCH - 3} as ProtoBlock); + chainStub.predictProposerHead.mockReturnValue({...zeroProtoBlock, slot: SLOTS_PER_EPOCH - 3} as ProtoBlock); + forkChoiceStub.getFinalizedBlock.mockReturnValue({} as ProtoBlock); + const state = generateCachedBellatrixState(); + vi.spyOn(state.epochCtx, "getBeaconProposer").mockReturnValue(proposerIndex); + regenStub.getBlockSlotState.mockResolvedValue(new BeaconStateView(state)); + beaconProposerCacheStub.get.mockReturnValue("0x fee recipient address"); + (executionEngineStub as unknown as {payloadIdCache: PayloadIdCache}).payloadIdCache = new PayloadIdCache(); + + await Promise.all([ + scheduler.prepareForNextSlot(SLOTS_PER_EPOCH - 2), + vi.advanceTimersByTimeAsync((config.SLOT_DURATION_MS * 2) / 3), + ]); + + expect(chainStub.builderCircuitBreaker.update).toHaveBeenCalledWith(SLOTS_PER_EPOCH - 2); + expect(updateBuilderStatus).not.toHaveBeenCalled(); + }); }); From 6f2bd9dffddc6bd4a7f2b63ea9f29cd01f1c5113 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 18:20:48 +0100 Subject: [PATCH 28/47] treat builderonly as builderalways post-gloas --- .../validator-management/vc-configuration.md | 2 +- .../src/api/impl/validator/index.ts | 51 +++++------- .../api/impl/validator/produceBlockV4.test.ts | 83 ++++++++++++++++--- packages/cli/src/cmds/validator/options.ts | 2 +- 4 files changed, 93 insertions(+), 45 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index ea2e03ef4222..4ce2147ce49f 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -91,7 +91,7 @@ With Lodestar's [`--builder.selection`](./validator-cli.md#--builderselection) v - `executionalways`: An alias of `--builder.boostFactor=0`, which will select the local execution block, unless it fails to produce due to an error or a delay in the response from the execution client. - `executiononly`: Beacon node will be requested to produce local execution block even if builder relays are configured. This option will always select the local execution block and will error if it couldn't produce one. - `builderalways`: An alias of `--builder.boostFactor=18446744073709551615` (2\*\*64 - 1), which will select the builder block, unless the builder block fails to produce. The builder block may fail to produce if it's not available, not timely or there is an indication of censorship via `shouldOverrideBuilder` from the execution payload response. -- `builderonly`: Generally used for distributed validators (DVs). No execution block production will be triggered. Therefore, if a builder block is not produced, the API will fail and _no block will be produced_. +- `builderonly`: Generally used for distributed validators (DVs). No execution block production will be triggered. Therefore, if a builder block is not produced, the API will fail and _no block will be produced_. Starting with the Gloas hard fork, this option is treated the same as `builderalways` since a local block is always built as fallback. #### Calculating builder boost factor with examples diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 4d966506b844..fc7dd6d1b46d 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -882,6 +882,10 @@ export function getValidatorApi( } builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; + if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { + // The local block is always built post-gloas as fallback, treat builderonly as builderalways + builderSelection = routes.validator.BuilderSelection.BuilderAlways; + } builderBoostFactor = builderBoostFactor ?? BigInt(100); if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); @@ -915,16 +919,6 @@ export function getValidatorApi( ? null : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); - if (builderBid === null && builderSelection === routes.validator.BuilderSelection.BuilderOnly) { - throw new ApiError( - 400, - circuitBreakerActive - ? `Builder circuit breaker is active, refusing to produce block with builderSelection=builderonly for slot=${slot}` - : `No builder bid available for slot=${slot} with builderSelection=builderonly` - ); - } - const buildLocalBlock = builderSelection !== routes.validator.BuilderSelection.BuilderOnly; - const logCtx = { slot, parentSlot, @@ -959,9 +953,7 @@ export function getValidatorApi( commonBlockBodyPromise, }; - if (buildLocalBlock) { - metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); - } + metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); if (builderBid !== null) { metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); } @@ -978,20 +970,20 @@ export function getValidatorApi( // use abort controller to stop waiting for the bid block if the engine block will be selected const controller = new AbortController(); - const enginePromise: ReturnType = buildLocalBlock - ? timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)).then((engineBlock) => { - // No need to wait for the bid block if the engine block will always be selected due to - // suspected builder censorship, a builder boost factor of 0 or executionalways selection - if ( - engineBlock.shouldOverrideBuilder || - builderBoostFactor === BigInt(0) || - builderSelection === routes.validator.BuilderSelection.ExecutionAlways - ) { - controller.abort(); - } - return engineBlock; - }) - : Promise.reject(new Error("Local block production disabled by builderonly selection")); + const enginePromise: ReturnType = timed(ProducedBlockSource.engine, () => + chain.produceBlock(baseAttrs) + ).then((engineBlock) => { + // No need to wait for the bid block if the engine block will always be selected due to + // suspected builder censorship, a builder boost factor of 0 or executionalways selection + if ( + engineBlock.shouldOverrideBuilder || + builderBoostFactor === BigInt(0) || + builderSelection === routes.validator.BuilderSelection.ExecutionAlways + ) { + controller.abort(); + } + return engineBlock; + }); const bidPromise: ReturnType = builderBid !== null ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})) @@ -1030,9 +1022,8 @@ export function getValidatorApi( } else if (bidResult.status === "fulfilled") { source = ProducedBlockSource.builder; bestResult = bidResult; - const reason = !buildLocalBlock - ? BuilderBlockSelectionReason.EngineDisabled - : engineResult.status === "pending" + const reason = + engineResult.status === "pending" ? BuilderBlockSelectionReason.EnginePending : BuilderBlockSelectionReason.EngineError; metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.builder, reason}); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index c5a0bc30b64c..40ef88360d96 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -72,7 +72,7 @@ describe("api/validator - produceBlockV4", () => { vi.clearAllMocks(); }); - it("builds with the builder bid when a bid is available", async () => { + it("picks builder bid block when bid value is higher", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); @@ -94,6 +94,40 @@ describe("api/validator - produceBlockV4", () => { expect(meta.version).toBe(ForkName.gloas); }); + it("picks local block when local payload value is higher", async () => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + // Local payload value (2 gwei) exceeds the bid value (1 gwei) + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: BigInt(2e9), + consensusBlockValue: BigInt(0), + })); + + const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload: false}); + + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(block).toEqual(engineBlock); + }); + + it("skips builder bids with executiononly selection", async () => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderSelection: routes.validator.BuilderSelection.ExecutionOnly, + }); + + expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + expect(block).toEqual(engineBlock); + }); + it("produces local block when no bid is available", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); @@ -116,21 +150,44 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(engineBlock); }); - it("fails builderonly proposals while the builder circuit breaker is active", async () => { + it("treats builderonly as builderalways", async () => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + // Bid block is preferred despite the higher local payload value, but local block is still built + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: BigInt(2e9), + consensusBlockValue: BigInt(0), + })); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderSelection: routes.validator.BuilderSelection.BuilderOnly, + }); + + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(block).toEqual(bidBlock); + }); + + it("produces local block for builderonly proposals while the circuit breaker is active", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); - await expect( - api.produceBlockV4({ - slot, - randaoReveal, - graffiti, - feeRecipient, - includePayload: false, - builderSelection: routes.validator.BuilderSelection.BuilderOnly, - }) - ).rejects.toThrow("Builder circuit breaker is active"); + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderSelection: routes.validator.BuilderSelection.BuilderOnly, + }); + expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); - expect(modules.chain.produceBlock).not.toHaveBeenCalled(); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + expect(block).toEqual(engineBlock); }); }); diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 5ba3a2f1801d..a7fe755cbb8a 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -262,7 +262,7 @@ export const validatorOptions: CliCommandOptions = { "builder.selection": { type: "string", description: - "Builder block selection strategy `default`, `maxprofit`, `builderalways`, `builderonly`, `executionalways`, or `executiononly`", + "Builder block selection strategy `default`, `maxprofit`, `builderalways`, `builderonly`, `executionalways`, or `executiononly`. Post-gloas `builderonly` is treated the same as `builderalways`", defaultDescription: `${defaultOptions.builderSelection}`, group: "builder", }, From f5ea4493a2b0bae6f060d2e3fb7be5650b7e6ff0 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 19:41:19 +0100 Subject: [PATCH 29/47] remove support for builderonly block selection --- .../validator-management/vc-configuration.md | 1 - .../src/api/impl/validator/index.ts | 124 +++++++----------- .../api/impl/validator/produceBlockV3.test.ts | 26 +++- .../api/impl/validator/produceBlockV4.test.ts | 51 ++----- packages/cli/src/cmds/validator/options.ts | 2 +- packages/cli/src/util/proposerConfig.ts | 2 +- packages/validator/src/services/block.ts | 6 +- 7 files changed, 84 insertions(+), 128 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 4ce2147ce49f..923cfcb7ccc1 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -91,7 +91,6 @@ With Lodestar's [`--builder.selection`](./validator-cli.md#--builderselection) v - `executionalways`: An alias of `--builder.boostFactor=0`, which will select the local execution block, unless it fails to produce due to an error or a delay in the response from the execution client. - `executiononly`: Beacon node will be requested to produce local execution block even if builder relays are configured. This option will always select the local execution block and will error if it couldn't produce one. - `builderalways`: An alias of `--builder.boostFactor=18446744073709551615` (2\*\*64 - 1), which will select the builder block, unless the builder block fails to produce. The builder block may fail to produce if it's not available, not timely or there is an indication of censorship via `shouldOverrideBuilder` from the execution payload response. -- `builderonly`: Generally used for distributed validators (DVs). No execution block production will be triggered. Therefore, if a builder block is not produced, the API will fail and _no block will be produced_. Starting with the Gloas hard fork, this option is treated the same as `builderalways` since a local block is always built as fallback. #### Calculating builder boost factor with examples diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index fc7dd6d1b46d..0560f7a2ccf9 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -529,6 +529,18 @@ export function getValidatorApi( builderBoostFactor?: bigint, {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOpts = {} ): Promise { + // set some sensible opts + // builderSelection will be deprecated and will run in mode MaxProfit if builder is enabled + // and the actual selection will be determined using builderBoostFactor passed by the validator + builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; + if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { + throw new ApiError(400, "Builder selection builderonly is no longer supported, use builderalways instead"); + } + builderBoostFactor = builderBoostFactor ?? BigInt(100); + if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { + throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); + } + notWhileSyncing(chain, sync.state); await waitForSlot(slot); // Must never request for a future slot > currentSlot @@ -539,36 +551,12 @@ export function getValidatorApi( metrics?.blockProductionSlotDelta.set(slot - parentSlot); const fork = config.getForkName(slot); - // set some sensible opts - // builderSelection will be deprecated and will run in mode MaxProfit if builder is enabled - // and the actual selection will be determined using builderBoostFactor passed by the validator - builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; - builderBoostFactor = builderBoostFactor ?? BigInt(100); - if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { - throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); - } const isBuilderEnabled = ForkSeq[fork] >= ForkSeq.bellatrix && chain.executionBuilder !== undefined && builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; - // At any point either the builder or execution or both flows should be active. - // - // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager - // configurations could cause a validator pubkey to have builder disabled with builder selection builder only - // (TODO: independently make sure such an options update is not successful for a validator pubkey) - // - // So if builder is disabled ignore builder selection of builder only if caused by user mistake - // https://github.com/ChainSafe/lodestar/issues/6338 - const isEngineEnabled = !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly; - - if (!isEngineEnabled && !isBuilderEnabled) { - throw Error( - `Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}` - ); - } - const graffitiBytes = toGraffitiBytes( getBlockGraffiti(graffiti, getLodestarClientVersion(opts), chain.executionEngine.clientVersion, { private: opts.private, @@ -583,7 +571,6 @@ export function getValidatorApi( fork, builderSelection, isBuilderEnabled, - isEngineEnabled, strictFeeRecipientCheck, // winston logger doesn't like bigint builderBoostFactor: `${builderBoostFactor}`, @@ -609,27 +596,25 @@ export function getValidatorApi( }) : Promise.reject(new Error("Builder disabled")); - const enginePromise = isEngineEnabled - ? produceEngineBlockContents(slot, randaoReveal, graffitiBytes, { - feeRecipient, - strictFeeRecipientCheck, - commonBlockBodyPromise, - parentBlock, - }).then((engineBlock) => { - // Once the engine returns a block, in the event of either: - // - suspected builder censorship - // - builder boost factor set to 0 or builder selection `executionalways` - // we don't need to wait for builder block as engine block will always be selected - if ( - engineBlock.shouldOverrideBuilder || - builderBoostFactor === BigInt(0) || - builderSelection === routes.validator.BuilderSelection.ExecutionAlways - ) { - controller.abort(); - } - return engineBlock; - }) - : Promise.reject(new Error("Engine disabled")); + const enginePromise = produceEngineBlockContents(slot, randaoReveal, graffitiBytes, { + feeRecipient, + strictFeeRecipientCheck, + commonBlockBodyPromise, + parentBlock, + }).then((engineBlock) => { + // Once the engine returns a block, in the event of either: + // - suspected builder censorship + // - builder boost factor set to 0 or builder selection `executionalways` + // we don't need to wait for builder block as engine block will always be selected + if ( + engineBlock.shouldOverrideBuilder || + builderBoostFactor === BigInt(0) || + builderSelection === routes.validator.BuilderSelection.ExecutionAlways + ) { + controller.abort(); + } + return engineBlock; + }); // Calculate cutoff time based on start of the slot const cutoffMs = Math.max(0, BLOCK_PRODUCTION_RACE_CUTOFF_MS - chain.clock.msFromSlot(slot)); @@ -676,30 +661,24 @@ export function getValidatorApi( throw Error("Builder and engine both failed to produce the block within timeout"); } - if (builder.status === "pending" && !isEngineEnabled) { - throw Error("Builder failed to produce the block within timeout"); - } - if (engine.status === "pending" && !isBuilderEnabled) { throw Error("Engine failed to produce the block within timeout"); } - if (isEngineEnabled) { - if (engine.status === "rejected") { - logger.warn( - "Engine failed to produce the block", - { - ...loggerContext, - durationMs: engine.durationMs, - }, - engine.reason - ); - } else if (engine.status === "pending") { - logger.warn("Engine failed to produce the block within cutoff time", { + if (engine.status === "rejected") { + logger.warn( + "Engine failed to produce the block", + { ...loggerContext, - cutoffMs, - }); - } + durationMs: engine.durationMs, + }, + engine.reason + ); + } else if (engine.status === "pending") { + logger.warn("Engine failed to produce the block within cutoff time", { + ...loggerContext, + cutoffMs, + }); } if (isBuilderEnabled) { @@ -728,9 +707,7 @@ export function getValidatorApi( } if (builder.status === "rejected" && engine.status === "rejected") { - throw Error( - `${isBuilderEnabled && isEngineEnabled ? "Builder and engine both" : isBuilderEnabled ? "Builder" : "Engine"} failed to produce the block` - ); + throw Error(`${isBuilderEnabled ? "Builder and engine both" : "Engine"} failed to produce the block`); } // handle shouldOverrideBuilder separately @@ -752,11 +729,9 @@ export function getValidatorApi( if (builder.status === "fulfilled" && engine.status !== "fulfilled") { const reason = - isEngineEnabled === false - ? BuilderBlockSelectionReason.EngineDisabled - : engine.status === "pending" - ? BuilderBlockSelectionReason.EnginePending - : BuilderBlockSelectionReason.EngineError; + engine.status === "pending" + ? BuilderBlockSelectionReason.EnginePending + : BuilderBlockSelectionReason.EngineError; logger.info("Selected builder block: no engine block produced", { reason, @@ -883,8 +858,7 @@ export function getValidatorApi( builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { - // The local block is always built post-gloas as fallback, treat builderonly as builderalways - builderSelection = routes.validator.BuilderSelection.BuilderAlways; + throw new ApiError(400, "Builder selection builderonly is no longer supported, use builderalways instead"); } builderBoostFactor = builderBoostFactor ?? BigInt(100); if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index aedb4eb09899..7ac2a11a02f2 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -66,9 +66,7 @@ describe("api/validator - produceBlockV3", () => { [routes.validator.BuilderSelection.ExecutionAlways, null, 0, 1, false, "engine"], [routes.validator.BuilderSelection.ExecutionAlways, 1, 1, 1, true, "engine"], - [routes.validator.BuilderSelection.BuilderOnly, 0, 2, 0, false, "builder"], [routes.validator.BuilderSelection.ExecutionOnly, 2, 0, 1, false, "engine"], - [routes.validator.BuilderSelection.BuilderOnly, 1, 1, 0, true, "builder"], [routes.validator.BuilderSelection.ExecutionOnly, 1, 1, 1, true, "engine"], ]; @@ -158,14 +156,28 @@ describe("api/validator - produceBlockV3", () => { expect(modules.chain.produceBlindedBlock).toBeCalledTimes(1); } - if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { - expect(modules.chain.produceBlock).toBeCalledTimes(0); - } else { - expect(modules.chain.produceBlock).toBeCalledTimes(1); - } + expect(modules.chain.produceBlock).toBeCalledTimes(1); }); } + it("rejects builderonly selection", async () => { + const fullBlock = ssz.bellatrix.BeaconBlock.defaultValue(); + const slot = 1 * SLOTS_PER_EPOCH; + + vi.spyOn(modules.chain.clock, "currentSlot", "get").mockReturnValue(slot); + vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.Synced); + + await expect( + api.produceBlockV3({ + slot, + randaoReveal: fullBlock.body.randaoReveal, + graffiti: "a".repeat(32), + skipRandaoVerification: false, + builderSelection: routes.validator.BuilderSelection.BuilderOnly, + }) + ).rejects.toThrow("Builder selection builderonly is no longer supported"); + }); + it("correctly pass feeRecipient to produceBlock", async () => { const fullBlock = ssz.bellatrix.BeaconBlock.defaultValue(); const executionPayloadValue = ssz.Wei.defaultValue(); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 40ef88360d96..d038968b3694 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -150,44 +150,17 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(engineBlock); }); - it("treats builderonly as builderalways", async () => { - modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); - // Bid block is preferred despite the higher local payload value, but local block is still built - modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ - block: attrs.builderBid !== undefined ? bidBlock : engineBlock, - executionPayloadValue: BigInt(2e9), - consensusBlockValue: BigInt(0), - })); - - const {data: block} = await api.produceBlockV4({ - slot, - randaoReveal, - graffiti, - feeRecipient, - includePayload: false, - builderSelection: routes.validator.BuilderSelection.BuilderOnly, - }); - - expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); - expect(block).toEqual(bidBlock); - }); - - it("produces local block for builderonly proposals while the circuit breaker is active", async () => { - modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); - - const {data: block} = await api.produceBlockV4({ - slot, - randaoReveal, - graffiti, - feeRecipient, - includePayload: false, - builderSelection: routes.validator.BuilderSelection.BuilderOnly, - }); - - expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); - expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); - expect(block).toEqual(engineBlock); + it("rejects builderonly selection", async () => { + await expect( + api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderSelection: routes.validator.BuilderSelection.BuilderOnly, + }) + ).rejects.toThrow("Builder selection builderonly is no longer supported"); + expect(modules.chain.produceBlock).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index a7fe755cbb8a..231169c4ccee 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -262,7 +262,7 @@ export const validatorOptions: CliCommandOptions = { "builder.selection": { type: "string", description: - "Builder block selection strategy `default`, `maxprofit`, `builderalways`, `builderonly`, `executionalways`, or `executiononly`. Post-gloas `builderonly` is treated the same as `builderalways`", + "Builder block selection strategy `default`, `maxprofit`, `builderalways`, `executionalways`, or `executiononly`", defaultDescription: `${defaultOptions.builderSelection}`, group: "builder", }, diff --git a/packages/cli/src/util/proposerConfig.ts b/packages/cli/src/util/proposerConfig.ts index a346aa9acf96..1fbf51495fee 100644 --- a/packages/cli/src/util/proposerConfig.ts +++ b/packages/cli/src/util/proposerConfig.ts @@ -113,7 +113,7 @@ export function parseBuilderSelection(builderSelection?: string): routes.validat case "builderalways": break; case "builderonly": - break; + throw Error("Builder selection builderonly is no longer supported, use builderalways instead"); case "executionalways": break; case "executiononly": diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 8dc9613374b1..f72425ef5660 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -391,10 +391,8 @@ function parseProduceBlockResponse( const executionPayloadSource = response.executionPayloadSource; if ( - (builderSelection === routes.validator.BuilderSelection.BuilderOnly && - executionPayloadSource === ProducedBlockSource.engine) || - (builderSelection === routes.validator.BuilderSelection.ExecutionOnly && - executionPayloadSource === ProducedBlockSource.builder) + builderSelection === routes.validator.BuilderSelection.ExecutionOnly && + executionPayloadSource === ProducedBlockSource.builder ) { throw Error( `Block not produced as per desired builderSelection=${builderSelection} executionPayloadSource=${executionPayloadSource}` From ddca7b2b7ac0800df512bb5daa98b9e9016c1f18 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 21:43:03 +0100 Subject: [PATCH 30/47] warn and rewrite builderonly to builderalways on beacon node --- .../src/api/impl/validator/index.ts | 6 ++- .../api/impl/validator/produceBlockV3.test.ts | 40 ++++++++++++++----- .../api/impl/validator/produceBlockV4.test.ts | 36 +++++++++++------ 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 0560f7a2ccf9..3980cd481fb2 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -534,7 +534,8 @@ export function getValidatorApi( // and the actual selection will be determined using builderBoostFactor passed by the validator builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { - throw new ApiError(400, "Builder selection builderonly is no longer supported, use builderalways instead"); + logger.warn("Builder selection builderonly is no longer supported, treating as builderalways"); + builderSelection = routes.validator.BuilderSelection.BuilderAlways; } builderBoostFactor = builderBoostFactor ?? BigInt(100); if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { @@ -858,7 +859,8 @@ export function getValidatorApi( builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { - throw new ApiError(400, "Builder selection builderonly is no longer supported, use builderalways instead"); + logger.warn("Builder selection builderonly is no longer supported, treating as builderalways"); + builderSelection = routes.validator.BuilderSelection.BuilderAlways; } builderBoostFactor = builderBoostFactor ?? BigInt(100); if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index 7ac2a11a02f2..0318653f8327 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -160,22 +160,42 @@ describe("api/validator - produceBlockV3", () => { }); } - it("rejects builderonly selection", async () => { + it("treats deprecated builderonly selection as builderalways", async () => { const fullBlock = ssz.bellatrix.BeaconBlock.defaultValue(); + const blindedBlock = ssz.bellatrix.BlindedBeaconBlock.defaultValue(); const slot = 1 * SLOTS_PER_EPOCH; vi.spyOn(modules.chain.clock, "currentSlot", "get").mockReturnValue(slot); vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.Synced); + modules.chain.recomputeForkChoiceHead.mockReturnValue({blockRoot: toRootHex(fullBlock.parentRoot)} as ProtoBlock); + modules.chain.getProposerHead.mockReturnValue({blockRoot: toRootHex(fullBlock.parentRoot)} as ProtoBlock); + modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(zeroProtoBlock); + modules.chain.produceCommonBlockBody.mockResolvedValue(fullBlock.body as never); + // Local payload value (2) exceeds the builder value (1), builderalways still selects the builder block + modules.chain.produceBlock.mockResolvedValue({ + block: fullBlock, + executionPayloadValue: BigInt(2), + consensusBlockValue: BigInt(0), + } as never); + modules.chain.produceBlindedBlock.mockResolvedValue({ + block: blindedBlock, + executionPayloadValue: BigInt(1), + consensusBlockValue: BigInt(0), + } as never); + + const {data: block, meta} = await api.produceBlockV3({ + slot, + randaoReveal: fullBlock.body.randaoReveal, + graffiti: "a".repeat(32), + skipRandaoVerification: false, + builderSelection: routes.validator.BuilderSelection.BuilderOnly, + }); - await expect( - api.produceBlockV3({ - slot, - randaoReveal: fullBlock.body.randaoReveal, - graffiti: "a".repeat(32), - skipRandaoVerification: false, - builderSelection: routes.validator.BuilderSelection.BuilderOnly, - }) - ).rejects.toThrow("Builder selection builderonly is no longer supported"); + expect(modules.logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Builder selection builderonly is no longer supported") + ); + expect(block).toEqual(blindedBlock); + expect(meta.executionPayloadBlinded).toBe(true); }); it("correctly pass feeRecipient to produceBlock", async () => { diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index d038968b3694..fb9ce5e6994f 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -150,17 +150,29 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(engineBlock); }); - it("rejects builderonly selection", async () => { - await expect( - api.produceBlockV4({ - slot, - randaoReveal, - graffiti, - feeRecipient, - includePayload: false, - builderSelection: routes.validator.BuilderSelection.BuilderOnly, - }) - ).rejects.toThrow("Builder selection builderonly is no longer supported"); - expect(modules.chain.produceBlock).not.toHaveBeenCalled(); + it("treats deprecated builderonly selection as builderalways", async () => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + // Bid (1 gwei) is preferred over the higher local payload value (2 gwei) since builderalways + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: BigInt(2e9), + consensusBlockValue: BigInt(0), + })); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderSelection: routes.validator.BuilderSelection.BuilderOnly, + }); + + expect(modules.logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Builder selection builderonly is no longer supported") + ); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(block).toEqual(bidBlock); }); }); From 0474b0942adf46729f56828a2cb5b9489a0a3734 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 21:48:51 +0100 Subject: [PATCH 31/47] clean up builder selection defaulting comment and test mocks --- .../src/api/impl/validator/index.ts | 3 --- .../api/impl/validator/produceBlockV3.test.ts | 18 +++++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 3980cd481fb2..a52a3e3910a3 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -529,9 +529,6 @@ export function getValidatorApi( builderBoostFactor?: bigint, {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOpts = {} ): Promise { - // set some sensible opts - // builderSelection will be deprecated and will run in mode MaxProfit if builder is enabled - // and the actual selection will be determined using builderBoostFactor passed by the validator builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { logger.warn("Builder selection builderonly is no longer supported, treating as builderalways"); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index 0318653f8327..27416f7794dd 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -170,18 +170,30 @@ describe("api/validator - produceBlockV3", () => { modules.chain.recomputeForkChoiceHead.mockReturnValue({blockRoot: toRootHex(fullBlock.parentRoot)} as ProtoBlock); modules.chain.getProposerHead.mockReturnValue({blockRoot: toRootHex(fullBlock.parentRoot)} as ProtoBlock); modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(zeroProtoBlock); - modules.chain.produceCommonBlockBody.mockResolvedValue(fullBlock.body as never); + modules.chain.produceCommonBlockBody.mockResolvedValue({ + attestations: fullBlock.body.attestations, + attesterSlashings: fullBlock.body.attesterSlashings, + deposits: fullBlock.body.deposits, + proposerSlashings: fullBlock.body.proposerSlashings, + eth1Data: fullBlock.body.eth1Data, + graffiti: fullBlock.body.graffiti, + randaoReveal: fullBlock.body.randaoReveal, + voluntaryExits: fullBlock.body.voluntaryExits, + blsToExecutionChanges: [], + syncAggregate: fullBlock.body.syncAggregate, + }); // Local payload value (2) exceeds the builder value (1), builderalways still selects the builder block modules.chain.produceBlock.mockResolvedValue({ block: fullBlock, executionPayloadValue: BigInt(2), consensusBlockValue: BigInt(0), - } as never); + shouldOverrideBuilder: false, + }); modules.chain.produceBlindedBlock.mockResolvedValue({ block: blindedBlock, executionPayloadValue: BigInt(1), consensusBlockValue: BigInt(0), - } as never); + }); const {data: block, meta} = await api.produceBlockV3({ slot, From b2d14ade485851a205904377ae4a970faa2ac22c Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 17 Jul 2026 22:23:35 +0100 Subject: [PATCH 32/47] forward builder selection post-gloas with fork-aware default --- packages/validator/src/services/block.ts | 8 ++-- .../validator/src/services/validatorStore.ts | 21 ++++++++-- .../test/unit/validatorStore.test.ts | 41 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index f72425ef5660..773eca3e47c2 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -186,12 +186,12 @@ export class BlockProposingService { const graffiti = this.validatorStore.getGraffiti(pubkeyHex); const feeRecipient = this.validatorStore.getFeeRecipient(pubkeyHex); const {broadcastValidation, payloadLocal} = this.opts; + const {selection: builderSelection, boostFactor: builderBoostFactor} = + this.validatorStore.getBuilderSelectionParams(pubkeyHex, slot); - this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, payloadLocal}); + this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, payloadLocal, builderSelection}); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); - // Builder selection params are not forwarded post-gloas for now, the pre-gloas defaults - // (executiononly) target the builder api flow and would disable p2p bids for most users // Step 1: Produce beacon block with execution payload bid const blockRes = await this.api.validator .produceBlockV4({ @@ -200,6 +200,8 @@ export class BlockProposingService { graffiti, feeRecipient, includePayload: !payloadLocal, + builderSelection, + builderBoostFactor, }) .catch((e: Error) => { this.metrics?.blockProposingErrors.inc({error: "produce"}); diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index e37c2e5047e5..9c5174de0d0c 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -81,7 +81,8 @@ type DefaultProposerConfig = { feeRecipient: ExecutionAddress; builder: { gasLimit: number; - selection: routes.validator.BuilderSelection; + // Left undefined when not configured so the fork-appropriate default can be resolved per slot + selection?: routes.validator.BuilderSelection; boostFactor: bigint; }; }; @@ -182,7 +183,7 @@ export class ValidatorStore { feeRecipient: defaultConfig.feeRecipient ?? defaultOptions.suggestedFeeRecipient, builder: { gasLimit: defaultConfig.builder?.gasLimit ?? defaultOptions.defaultGasLimit, - selection: defaultConfig.builder?.selection ?? defaultOptions.builderSelection, + selection: defaultConfig.builder?.selection, boostFactor: builderBoostFactor, }, }; @@ -278,9 +279,21 @@ export class ValidatorStore { delete validatorData.graffiti; } - getBuilderSelectionParams(pubkeyHex: PubkeyHex): {selection: routes.validator.BuilderSelection; boostFactor: bigint} { + getBuilderSelectionParams( + pubkeyHex: PubkeyHex, + slot?: Slot + ): {selection: routes.validator.BuilderSelection; boostFactor: bigint} { + // Builder bids post-gloas are in-protocol over p2p, so the default strategy uses them + // (as if `--builder` was set), unless the validator explicitly opted out. Pre-gloas + // there is no in-protocol builder, so the default remains local-only (executiononly). + const defaultSelection = + slot !== undefined && this.config.getForkSeq(slot) >= ForkSeq.gloas + ? defaultOptions.builderAliasSelection + : defaultOptions.builderSelection; const selection = - this.validators.get(pubkeyHex)?.builder?.selection ?? this.defaultProposerConfig.builder.selection; + this.validators.get(pubkeyHex)?.builder?.selection ?? + this.defaultProposerConfig.builder.selection ?? + defaultSelection; let boostFactor: bigint; switch (selection) { diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index 60823fe9e068..d119ca4ac67c 100644 --- a/packages/validator/test/unit/validatorStore.test.ts +++ b/packages/validator/test/unit/validatorStore.test.ts @@ -70,6 +70,47 @@ describe("ValidatorStore", () => { expect(validatorStore.getGasLimit(toHexString(pubkeys[1]))).toBe(valProposerConfig.defaultConfig.builder?.gasLimit); }); + it("getBuilderSelectionParams honors explicit selection and resolves fork-aware default", async () => { + const preGloasSlot = 0; + // pubkeys[0] explicitly configured executiononly, honored regardless of fork + expect(validatorStore.getBuilderSelectionParams(toHexString(pubkeys[0]), preGloasSlot)).toEqual({ + selection: routes.validator.BuilderSelection.ExecutionOnly, + boostFactor: BigInt(0), + }); + // pubkeys[1] has no selection configured, pre-gloas default is executiononly + expect(validatorStore.getBuilderSelectionParams(toHexString(pubkeys[1]), preGloasSlot)).toEqual({ + selection: routes.validator.BuilderSelection.ExecutionOnly, + boostFactor: BigInt(0), + }); + + // Post-gloas the unconfigured default becomes `default` (as if `--builder` was set) + const gloasStore = await initValidatorStore( + secretKeys, + api, + { + ...chainConfig, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + }, + valProposerConfig + ); + const gloasSlot = 0; + expect(gloasStore.getBuilderSelectionParams(toHexString(pubkeys[1]), gloasSlot)).toEqual({ + selection: routes.validator.BuilderSelection.Default, + boostFactor: BigInt(90), + }); + // Explicit executiononly is still honored post-gloas + expect(gloasStore.getBuilderSelectionParams(toHexString(pubkeys[0]), gloasSlot)).toEqual({ + selection: routes.validator.BuilderSelection.ExecutionOnly, + boostFactor: BigInt(0), + }); + }); + it("Should create/update builder data and return from cache next time", async () => { let slot = 0; const testCases: [bellatrix.SignedValidatorRegistrationV1, string, number][] = [ From ebebebb80a1a061aa444bd34e76bf66bc2629492 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 19:42:35 +0100 Subject: [PATCH 33/47] rename signedEnvelope to signedEnvelopeOrContents --- .../api/src/beacon/routes/beacon/block.ts | 26 +++++++++---------- .../api/test/unit/beacon/testData/beacon.ts | 2 +- .../src/api/impl/beacon/blocks/index.ts | 10 +++---- packages/validator/src/services/block.ts | 4 +-- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index ab511015c1ab..2eee8c8422bd 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -197,7 +197,7 @@ export type Endpoints = { publishExecutionPayloadEnvelope: Endpoint< "POST", { - signedEnvelope: gloas.SignedExecutionPayloadEnvelopeContents | gloas.SignedExecutionPayloadEnvelope; + signedEnvelopeOrContents: gloas.SignedExecutionPayloadEnvelopeContents | gloas.SignedExecutionPayloadEnvelope; broadcastValidation?: BroadcastValidation; }, { @@ -466,16 +466,16 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelope); + writeReqJson: ({signedEnvelopeOrContents, broadcastValidation}) => { + const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelopeOrContents); const fork = config.getForkName( - (blobDataIncluded ? signedEnvelope.signedExecutionPayloadEnvelope : signedEnvelope).message.payload + (blobDataIncluded ? signedEnvelopeOrContents.signedExecutionPayloadEnvelope : signedEnvelopeOrContents).message.payload .slotNumber ); return { body: blobDataIncluded - ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.toJson(signedEnvelope) - : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedEnvelope), + ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.toJson(signedEnvelopeOrContents) + : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedEnvelopeOrContents), headers: { [MetaHeader.Version]: fork, [MetaHeader.BlobDataIncluded]: blobDataIncluded.toString(), @@ -487,22 +487,22 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelope); + writeReqSsz: ({signedEnvelopeOrContents, broadcastValidation}) => { + const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelopeOrContents); const fork = config.getForkName( - (blobDataIncluded ? signedEnvelope.signedExecutionPayloadEnvelope : signedEnvelope).message.payload + (blobDataIncluded ? signedEnvelopeOrContents.signedExecutionPayloadEnvelope : signedEnvelopeOrContents).message.payload .slotNumber ); return { body: blobDataIncluded - ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.serialize(signedEnvelope) - : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedEnvelope), + ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.serialize(signedEnvelopeOrContents) + : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedEnvelopeOrContents), headers: { [MetaHeader.Version]: fork, [MetaHeader.BlobDataIncluded]: blobDataIncluded.toString(), @@ -514,7 +514,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions = { }, publishExecutionPayloadEnvelope: { args: { - signedEnvelope: ssz.gloas.SignedExecutionPayloadEnvelopeContents.defaultValue(), + signedEnvelopeOrContents: ssz.gloas.SignedExecutionPayloadEnvelopeContents.defaultValue(), broadcastValidation: BroadcastValidation.gossip, }, res: undefined, 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 b3a878f0d1de..db537bc4b1dc 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -662,20 +662,20 @@ export function getBeaconBlockApi({ publishBlockV2, publishBlindedBlockV2, - async publishExecutionPayloadEnvelope({signedEnvelope, broadcastValidation}) { + async publishExecutionPayloadEnvelope({signedEnvelopeOrContents, broadcastValidation}) { const seenTimestampSec = Date.now() / 1000; - const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelope); + const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelopeOrContents); let signedExecutionPayloadEnvelope: gloas.SignedExecutionPayloadEnvelope; // Blobs and KZG proofs submitted alongside the envelope in the stateless flow let submittedContents: {kzgProofs: deneb.KZGProofs; blobs: deneb.Blobs} | null = null; if (blobDataIncluded) { - signedExecutionPayloadEnvelope = signedEnvelope.signedExecutionPayloadEnvelope; - submittedContents = {kzgProofs: signedEnvelope.kzgProofs, blobs: signedEnvelope.blobs}; + signedExecutionPayloadEnvelope = signedEnvelopeOrContents.signedExecutionPayloadEnvelope; + submittedContents = {kzgProofs: signedEnvelopeOrContents.kzgProofs, blobs: signedEnvelopeOrContents.blobs}; } else { // Stateful flow, blobs and KZG proofs are attached from the block production cache below - signedExecutionPayloadEnvelope = signedEnvelope; + signedExecutionPayloadEnvelope = signedEnvelopeOrContents; } const envelope = signedExecutionPayloadEnvelope.message; diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 773eca3e47c2..23bf8e09339f 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -263,7 +263,7 @@ export class BlockProposingService { ( await this.api.beacon .publishExecutionPayloadEnvelope({ - signedEnvelope: {signedExecutionPayloadEnvelope: signedEnvelope, kzgProofs, blobs}, + signedEnvelopeOrContents: {signedExecutionPayloadEnvelope: signedEnvelope, kzgProofs, blobs}, broadcastValidation, }) .catch((e: Error) => { @@ -292,7 +292,7 @@ export class BlockProposingService { ( await this.api.beacon .publishExecutionPayloadEnvelope({ - signedEnvelope, + signedEnvelopeOrContents: signedEnvelope, broadcastValidation, }) .catch((e: Error) => { From 97e539541cc1871bbc8e09fa685f00cc912ed910 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 19:57:56 +0100 Subject: [PATCH 34/47] chore: add slot to missing cached block production error --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index a52a3e3910a3..07aedaf198a0 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1060,7 +1060,7 @@ export function getValidatorApi( !isForkPostGloas(produceResult.fork) || produceResult.type !== BlockType.Full ) { - throw Error(`Missing cached block production result for produced block root=${blockRoot}`); + throw Error(`Missing cached block production result for produced block slot=${slot} root=${blockRoot}`); } const {executionPayload, executionRequests, blobsBundle, parentBlockRoot} = produceResult as ProduceFullGloas; From 519af8119b1bfbd1b37698e48ad2243119b8851b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 19:57:58 +0100 Subject: [PATCH 35/47] fix: return 500 for block state regeneration failure on envelope publish Regenerating the block state is a beacon node internal failure, not a client error. Also include the slot in the message. --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 db537bc4b1dc..22daa7434819 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -753,8 +753,8 @@ export function getBeaconBlockApi({ }); if (blockState === null || !isStatePostGloas(blockState)) { throw new ApiError( - 400, - `Unable to regenerate block state for consensus checks blockRoot=${blockRootHex}` + 500, + `Unable to regenerate block state for consensus checks slot=${slot} blockRoot=${blockRootHex}` ); } try { From c80042e63882e353ce6dc0d6e4230b052ed594ce Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 20:18:46 +0100 Subject: [PATCH 36/47] reuse single payload envelope input lookup on envelope publish Also include the slot in the not-found error message. --- .../src/api/impl/beacon/blocks/index.ts | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) 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 22daa7434819..b96010fd099c 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -808,6 +808,14 @@ export function getBeaconBlockApi({ } } + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (!payloadInput) { + // The block is awaited above (queuing if the envelope arrived first), and both the API and + // gossip import paths seed the PayloadEnvelopeInput before importing the block, so the input + // should exist here. + throw new ApiError(404, `PayloadEnvelopeInput not found for slot=${slot} block root=${blockRootHex}`); + } + let dataColumnSidecars: gloas.DataColumnSidecar[] = []; let cells: fulu.Cell[][] | undefined; let kzgProofs: deneb.KZGProofs | undefined; @@ -815,21 +823,19 @@ export function getBeaconBlockApi({ if (submittedContents !== null) { // Validate submitted blob data against bid commitments before computing data column sidecars - const expectedBlobCount = chain.seenPayloadEnvelopeInputCache.get(blockRootHex)?.getVersionedHashes().length; - if (expectedBlobCount !== undefined) { - if (submittedContents.blobs.length !== expectedBlobCount) { - throw new ApiError( - 400, - `Submitted blob count does not match bid commitments submitted=${submittedContents.blobs.length} expected=${expectedBlobCount}` - ); - } - const expectedProofCount = expectedBlobCount * NUMBER_OF_COLUMNS; - if (submittedContents.kzgProofs.length !== expectedProofCount) { - throw new ApiError( - 400, - `Submitted KZG proof count does not match bid commitments submitted=${submittedContents.kzgProofs.length} expected=${expectedProofCount}` - ); - } + const expectedBlobCount = payloadInput.getVersionedHashes().length; + if (submittedContents.blobs.length !== expectedBlobCount) { + throw new ApiError( + 400, + `Submitted blob count does not match bid commitments submitted=${submittedContents.blobs.length} expected=${expectedBlobCount}` + ); + } + const expectedProofCount = expectedBlobCount * NUMBER_OF_COLUMNS; + if (submittedContents.kzgProofs.length !== expectedProofCount) { + throw new ApiError( + 400, + `Submitted KZG proof count does not match bid commitments submitted=${submittedContents.kzgProofs.length} expected=${expectedProofCount}` + ); } if (submittedContents.blobs.length > 0) { // If the block was produced by this node, reuse the cached cells and only time the @@ -849,9 +855,7 @@ export function getBeaconBlockApi({ } } else { // An envelope without blob data can only be published via the beacon node that cached them at block production - const expectedBlobCount = - chain.seenPayloadEnvelopeInputCache.get(blockRootHex)?.getVersionedHashes().length ?? 0; - if (expectedBlobCount > 0) { + if (payloadInput.getVersionedHashes().length > 0) { throw new ApiError( 400, `No cached blob data to attach to execution payload envelope for block root ${blockRootHex}` @@ -876,14 +880,6 @@ export function getBeaconBlockApi({ await sleep(msToBlockSlot); } - const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); - if (!payloadInput) { - // The block is awaited above (queuing if the envelope arrived first), and both the API and - // gossip import paths seed the PayloadEnvelopeInput before importing the block, so the input - // should exist here. - throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); - } - if (payloadInput.hasPayloadEnvelope()) { // The envelope may have been added while this request was being validated, e.g. via gossip chain.logger.debug("Execution payload envelope already added during publishing", valLogMeta); From daa3c3596de021718a32ff81a4bb8c6025e6c74f Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 20:21:50 +0100 Subject: [PATCH 37/47] fix log --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 2 +- 1 file changed, 1 insertion(+), 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 b96010fd099c..909e87b4526d 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -813,7 +813,7 @@ export function getBeaconBlockApi({ // The block is awaited above (queuing if the envelope arrived first), and both the API and // gossip import paths seed the PayloadEnvelopeInput before importing the block, so the input // should exist here. - throw new ApiError(404, `PayloadEnvelopeInput not found for slot=${slot} block root=${blockRootHex}`); + throw new ApiError(404, `PayloadEnvelopeInput not found for slot=${slot} blockRoot=${blockRootHex}`); } let dataColumnSidecars: gloas.DataColumnSidecar[] = []; From 7ffe48324e7467d52fdb6cf693d7f6aa7f540a3e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 20:28:33 +0100 Subject: [PATCH 38/47] include submitted blob count in envelope publish logs --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 1 + 1 file changed, 1 insertion(+) 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 909e87b4526d..f335f4b857ce 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -727,6 +727,7 @@ export function getBeaconBlockApi({ isSelfBuild, blobDataIncluded, broadcastValidation, + ...(submittedContents !== null ? {submittedBlobs: submittedContents.blobs.length} : {}), }; try { switch (broadcastValidation) { From 3390ca11cbb47141b86c5544fa89ae244e073127 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 20:28:47 +0100 Subject: [PATCH 39/47] use blockRoot key in missing cached block production error --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 07aedaf198a0..566d447b15a5 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1060,7 +1060,7 @@ export function getValidatorApi( !isForkPostGloas(produceResult.fork) || produceResult.type !== BlockType.Full ) { - throw Error(`Missing cached block production result for produced block slot=${slot} root=${blockRoot}`); + throw Error(`Missing cached block production result for produced block slot=${slot} blockRoot=${blockRoot}`); } const {executionPayload, executionRequests, blobsBundle, parentBlockRoot} = produceResult as ProduceFullGloas; From 51da9f79f9ec27b9a5835275cb9406e041cde51f Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 20:35:49 +0100 Subject: [PATCH 40/47] track payload publish errors and enrich envelope publish error messages --- packages/validator/src/metrics.ts | 4 ++-- packages/validator/src/services/block.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/validator/src/metrics.ts b/packages/validator/src/metrics.ts index 86b252979c88..27482dbd70f3 100644 --- a/packages/validator/src/metrics.ts +++ b/packages/validator/src/metrics.ts @@ -188,9 +188,9 @@ export function getMetrics(register: MetricsRegisterExtra, gitData: LodestarGitD help: "Total count of blocks published", }), - blockProposingErrors: register.gauge<{error: "produce" | "publish"}>({ + blockProposingErrors: register.gauge<{error: "produce" | "publish" | "publish_payload"}>({ name: "vc_block_proposing_errors_total", - help: "Total count of errors producing or publishing a block", + help: "Total count of errors producing or publishing a block or execution payload envelope", labelNames: ["error"], }), diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 23bf8e09339f..fdf4fb6411a7 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -267,8 +267,11 @@ export class BlockProposingService { broadcastValidation, }) .catch((e: Error) => { - this.metrics?.blockProposingErrors.inc({error: "publish"}); - throw extendError(e, "Failed to publish execution payload envelope"); + this.metrics?.blockProposingErrors.inc({error: "publish_payload"}); + throw extendError( + e, + `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} executionPayloadIncluded=${executionPayloadIncluded}` + ); }) ).assertOk(); } else { @@ -296,8 +299,11 @@ export class BlockProposingService { broadcastValidation, }) .catch((e: Error) => { - this.metrics?.blockProposingErrors.inc({error: "publish"}); - throw extendError(e, "Failed to publish execution payload envelope"); + this.metrics?.blockProposingErrors.inc({error: "publish_payload"}); + throw extendError( + e, + `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} executionPayloadIncluded=${executionPayloadIncluded}` + ); }) ).assertOk(); } From a51900078c6797da747535a92ae1f414b62c1f26 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 20:36:11 +0100 Subject: [PATCH 41/47] include executionPayloadIncluded in published envelope log --- packages/validator/src/services/block.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index fdf4fb6411a7..4430d159d5b3 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -313,6 +313,7 @@ export class BlockProposingService { graffiti, consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), blockRoot: blockRootHex, + executionPayloadIncluded, }); } else { // Builder is responsible for broadcasting the execution payload envelope From 03121081d2109b469fba36c7d2660dfaa85546b6 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 21:24:12 +0100 Subject: [PATCH 42/47] separate metrics --- dashboards/lodestar_validator_client.json | 12 ++++++++++++ packages/validator/src/metrics.ts | 10 ++++++++-- packages/validator/src/services/block.ts | 17 +++++++++++------ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/dashboards/lodestar_validator_client.json b/dashboards/lodestar_validator_client.json index 595616199c53..113c8d998149 100644 --- a/dashboards/lodestar_validator_client.json +++ b/dashboards/lodestar_validator_client.json @@ -1848,6 +1848,18 @@ "interval": "", "legendFormat": "block proposing errors {{error}}", "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "exemplar": false, + "expr": "rate(vc_payload_envelope_proposing_errors_total[$rate_interval])", + "hide": false, + "interval": "", + "legendFormat": "payload envelope proposing errors {{error}}", + "refId": "D" } ], "title": "Block produced, published, errored", diff --git a/packages/validator/src/metrics.ts b/packages/validator/src/metrics.ts index 27482dbd70f3..c3a3a64ef081 100644 --- a/packages/validator/src/metrics.ts +++ b/packages/validator/src/metrics.ts @@ -188,9 +188,15 @@ export function getMetrics(register: MetricsRegisterExtra, gitData: LodestarGitD help: "Total count of blocks published", }), - blockProposingErrors: register.gauge<{error: "produce" | "publish" | "publish_payload"}>({ + blockProposingErrors: register.gauge<{error: "produce" | "publish"}>({ name: "vc_block_proposing_errors_total", - help: "Total count of errors producing or publishing a block or execution payload envelope", + help: "Total count of errors producing or publishing a block", + labelNames: ["error"], + }), + + payloadEnvelopeProposingErrors: register.gauge<{error: "produce" | "publish"}>({ + name: "vc_payload_envelope_proposing_errors_total", + help: "Total count of errors producing or publishing an execution payload envelope", labelNames: ["error"], }), diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 4430d159d5b3..7ce3b48e1236 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -267,7 +267,7 @@ export class BlockProposingService { broadcastValidation, }) .catch((e: Error) => { - this.metrics?.blockProposingErrors.inc({error: "publish_payload"}); + this.metrics?.payloadEnvelopeProposingErrors.inc({error: "publish"}); throw extendError( e, `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} executionPayloadIncluded=${executionPayloadIncluded}` @@ -276,10 +276,15 @@ export class BlockProposingService { ).assertOk(); } else { // Stateful flow: fetch the envelope from the same beacon node that produced the block - const envelopeRes = await this.api.validator.getExecutionPayloadEnvelope({ - slot, - beaconBlockRoot, - }); + const envelopeRes = await this.api.validator + .getExecutionPayloadEnvelope({ + slot, + beaconBlockRoot, + }) + .catch((e: Error) => { + this.metrics?.payloadEnvelopeProposingErrors.inc({error: "produce"}); + throw extendError(e, `Failed to get execution payload envelope slot=${slot} blockRoot=${blockRootHex}`); + }); const envelope = envelopeRes.value(); this.logger.debug("Retrieved execution payload envelope", debugLogCtx); @@ -299,7 +304,7 @@ export class BlockProposingService { broadcastValidation, }) .catch((e: Error) => { - this.metrics?.blockProposingErrors.inc({error: "publish_payload"}); + this.metrics?.payloadEnvelopeProposingErrors.inc({error: "publish"}); throw extendError( e, `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} executionPayloadIncluded=${executionPayloadIncluded}` From 8c69c54219a0bd5ff8674ca824e7e2f543ae3bdf Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Jul 2026 21:32:41 +0100 Subject: [PATCH 43/47] lint --- packages/api/src/beacon/routes/beacon/block.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 2eee8c8422bd..fee4962ebb2a 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -469,8 +469,8 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelopeOrContents); const fork = config.getForkName( - (blobDataIncluded ? signedEnvelopeOrContents.signedExecutionPayloadEnvelope : signedEnvelopeOrContents).message.payload - .slotNumber + (blobDataIncluded ? signedEnvelopeOrContents.signedExecutionPayloadEnvelope : signedEnvelopeOrContents) + .message.payload.slotNumber ); return { body: blobDataIncluded @@ -496,8 +496,8 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelopeOrContents); const fork = config.getForkName( - (blobDataIncluded ? signedEnvelopeOrContents.signedExecutionPayloadEnvelope : signedEnvelopeOrContents).message.payload - .slotNumber + (blobDataIncluded ? signedEnvelopeOrContents.signedExecutionPayloadEnvelope : signedEnvelopeOrContents) + .message.payload.slotNumber ); return { body: blobDataIncluded From 09f35c885f7f957df9eb6eb26a9b3694071500d4 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 25 Jul 2026 11:53:08 +0100 Subject: [PATCH 44/47] improve gloas block proposal logs Split the combined publish log into separate beacon block and execution payload envelope logs, use a clear stateless/stateful flow label instead of executionPayloadIncluded, add slot/blockRoot to the block publish error, and log the builder payment value for builder-bid blocks. --- packages/validator/src/services/block.ts | 34 ++++++++++++++---------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 7ce3b48e1236..c15e27a4305c 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -13,7 +13,7 @@ import { Slot, isBlindedSignedBeaconBlock, } from "@lodestar/types"; -import {extendError, prettyBytes, prettyWeiToEth, toPubkeyHex, toRootHex} from "@lodestar/utils"; +import {GWEI_TO_WEI, extendError, prettyBytes, prettyWeiToEth, toPubkeyHex, toRootHex} from "@lodestar/utils"; import {Metrics} from "../metrics.js"; import {PubkeyHex} from "../types.js"; import {IClock, LoggerVc} from "../util/index.js"; @@ -238,16 +238,23 @@ export class BlockProposingService { }) .catch((e: Error) => { this.metrics?.blockProposingErrors.inc({error: "publish"}); - throw extendError(e, "Failed to publish block"); + throw extendError(e, `Failed to publish block slot=${slot} blockRoot=${blockRootHex}`); }) ).assertOk(); - this.logger.debug("Published beacon block", {...debugLogCtx, broadcastValidation}); + this.logger.info("Published beacon block", { + ...logCtx, + graffiti, + consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), + blockRoot: blockRootHex, + broadcastValidation, + }); const isSelfBuild = block.body.signedExecutionPayloadBid.message.builderIndex === BUILDER_INDEX_SELF_BUILD; if (isSelfBuild) { // Self-build: proposer is responsible for building and publishing the execution payload envelope + const flow = executionPayloadIncluded ? "stateless" : "stateful"; if (executionPayloadIncluded) { // Stateless flow: envelope and blobs are already available from block production const {executionPayloadEnvelope, kzgProofs, blobs} = blockOrContents as BlockContents; @@ -270,7 +277,7 @@ export class BlockProposingService { this.metrics?.payloadEnvelopeProposingErrors.inc({error: "publish"}); throw extendError( e, - `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} executionPayloadIncluded=${executionPayloadIncluded}` + `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} flow=${flow}` ); }) ).assertOk(); @@ -307,26 +314,25 @@ export class BlockProposingService { this.metrics?.payloadEnvelopeProposingErrors.inc({error: "publish"}); throw extendError( e, - `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} executionPayloadIncluded=${executionPayloadIncluded}` + `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} flow=${flow}` ); }) ).assertOk(); } - this.logger.info("Published block and execution payload envelope", { + this.logger.info("Published execution payload envelope", { ...logCtx, - graffiti, - consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), blockRoot: blockRootHex, - executionPayloadIncluded, + flow, }); } else { - // Builder is responsible for broadcasting the execution payload envelope - this.logger.info("Published block with builder bid, envelope expected from builder", { + // Committed to a builder bid, the builder is responsible for revealing the execution payload envelope + const bid = block.body.signedExecutionPayloadBid.message; + this.logger.info("Execution payload envelope to be revealed by builder", { ...logCtx, - graffiti, - builderIndex: block.body.signedExecutionPayloadBid.message.builderIndex, - consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), + builderIndex: bid.builderIndex, + // Payment the builder committed to pay the proposer for the block + executionPayloadValue: prettyWeiToEth(BigInt(bid.value) * GWEI_TO_WEI), blockRoot: blockRootHex, }); } From 1093c43dc9b644a86b20411f2903a87f17b86fc8 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 25 Jul 2026 12:42:04 +0100 Subject: [PATCH 45/47] re-add execution payload value to produceBlockV4 produceBlockV4 dropped the execution_payload_value that produceBlockV3 returned, which is a regression: multi-BN validator clients (Vero, Vouch) sum consensus + execution value to pick the best block across beacon nodes, and for self-builds the value is otherwise unavailable (the bid value is zero). Re-add it to the meta and Eth-Execution-Payload-Value header, and log it in the block proposal flow. --- packages/api/src/beacon/routes/validator.ts | 4 ++++ packages/api/test/unit/beacon/testData/validator.ts | 1 + packages/beacon-node/src/api/impl/validator/index.ts | 4 ++-- .../unit/api/impl/validator/produceBlockV4.test.ts | 9 ++++++++- packages/validator/src/services/block.ts | 11 ++++++----- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index ff9724f11daf..d9811daeedbd 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -99,6 +99,8 @@ export const ProduceBlockV4MetaType = new ContainerType( ...VersionType.fields, /** Consensus rewards paid to the proposer for this block, in Wei */ consensusBlockValue: ssz.UintBn64, + /** Local execution payload value when self-building, or builder bid value when committing to a bid, in Wei */ + executionPayloadValue: ssz.UintBn64, /** Specifies whether the response contains full block contents or only the beacon block */ executionPayloadIncluded: ssz.Boolean, }, @@ -973,11 +975,13 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ [MetaHeader.Version]: meta.version, [MetaHeader.ConsensusBlockValue]: meta.consensusBlockValue.toString(), + [MetaHeader.ExecutionPayloadValue]: meta.executionPayloadValue.toString(), [MetaHeader.ExecutionPayloadIncluded]: meta.executionPayloadIncluded.toString(), }), fromHeaders: (headers) => ({ version: toForkName(headers.getRequired(MetaHeader.Version)), consensusBlockValue: BigInt(headers.getRequired(MetaHeader.ConsensusBlockValue)), + executionPayloadValue: BigInt(headers.getRequired(MetaHeader.ExecutionPayloadValue)), executionPayloadIncluded: toBoolean(headers.getRequired(MetaHeader.ExecutionPayloadIncluded)), }), }, diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index 5e50c9e4ea2f..719308ceba00 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -96,6 +96,7 @@ export const testData: GenericServerTestCases = { meta: { version: ForkName.gloas, consensusBlockValue: ssz.Wei.defaultValue(), + executionPayloadValue: ssz.Wei.defaultValue(), executionPayloadIncluded: true, }, }, diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 566d447b15a5..371ab178c14e 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1079,13 +1079,13 @@ export function getValidatorApi( return { data: blockContents, - meta: {version: fork, consensusBlockValue, executionPayloadIncluded: true}, + meta: {version: fork, consensusBlockValue, executionPayloadValue, executionPayloadIncluded: true}, }; } return { data: block as gloas.BeaconBlock, - meta: {version: fork, consensusBlockValue, executionPayloadIncluded: false}, + meta: {version: fork, consensusBlockValue, executionPayloadValue, executionPayloadIncluded: false}, }; }, diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index fb9ce5e6994f..2b21dd14020e 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -104,10 +104,17 @@ describe("api/validator - produceBlockV4", () => { consensusBlockValue: BigInt(0), })); - const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload: false}); + const {data: block, meta} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); expect(block).toEqual(engineBlock); + expect(meta.executionPayloadValue).toBe(BigInt(2e9)); }); it("skips builder bids with executiononly selection", async () => { diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index c15e27a4305c..692b09b83056 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -13,7 +13,7 @@ import { Slot, isBlindedSignedBeaconBlock, } from "@lodestar/types"; -import {GWEI_TO_WEI, extendError, prettyBytes, prettyWeiToEth, toPubkeyHex, toRootHex} from "@lodestar/utils"; +import {extendError, prettyBytes, prettyWeiToEth, toPubkeyHex, toRootHex} from "@lodestar/utils"; import {Metrics} from "../metrics.js"; import {PubkeyHex} from "../types.js"; import {IClock, LoggerVc} from "../util/index.js"; @@ -218,7 +218,9 @@ export class BlockProposingService { this.logger.debug("Produced block", { ...debugLogCtx, + executionPayloadValue: prettyWeiToEth(blockMeta.executionPayloadValue), consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), + totalBlockValue: prettyWeiToEth(blockMeta.executionPayloadValue + blockMeta.consensusBlockValue), executionPayloadIncluded, blockRoot: blockRootHex, }); @@ -245,7 +247,9 @@ export class BlockProposingService { this.logger.info("Published beacon block", { ...logCtx, graffiti, + executionPayloadValue: prettyWeiToEth(blockMeta.executionPayloadValue), consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), + totalBlockValue: prettyWeiToEth(blockMeta.executionPayloadValue + blockMeta.consensusBlockValue), blockRoot: blockRootHex, broadcastValidation, }); @@ -327,12 +331,9 @@ export class BlockProposingService { }); } else { // Committed to a builder bid, the builder is responsible for revealing the execution payload envelope - const bid = block.body.signedExecutionPayloadBid.message; this.logger.info("Execution payload envelope to be revealed by builder", { ...logCtx, - builderIndex: bid.builderIndex, - // Payment the builder committed to pay the proposer for the block - executionPayloadValue: prettyWeiToEth(BigInt(bid.value) * GWEI_TO_WEI), + builderIndex: block.body.signedExecutionPayloadBid.message.builderIndex, blockRoot: blockRootHex, }); } From ef4f390b6e209c718bb3f295b94a440fd0b1e575 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 25 Jul 2026 15:24:18 +0100 Subject: [PATCH 46/47] tweak comment --- packages/validator/src/services/block.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 692b09b83056..c2a8d6aef21e 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -168,14 +168,18 @@ export class BlockProposingService { /** * Gloas block production flow: - * 1. Produce beacon block with execution payload bid, with full block contents - * (execution payload envelope, KZG proofs and blobs) if self-building (stateless flow) - * 2. Sign and publish the beacon block - * 3. If self-building, sign and publish the execution payload envelope - * - Stateless (`payloadLocal=false`): envelope and blobs are available from step 1, publish - * `SignedExecutionPayloadEnvelopeContents` which works via any beacon node - * - Stateful (`payloadLocal=true`): fetch the envelope from the same beacon node that - * produced the block, which attaches cached blobs and KZG proofs on publish + * 1. Produce the beacon block, which commits to an execution payload bid. When self-building with + * the stateless flow (`payloadLocal=false`), the response also includes the full block contents + * (execution payload envelope, KZG proofs and blobs). + * 2. Sign and publish the beacon block. + * 3. Reveal the execution payload envelope: + * - Self-build: the proposer signs and publishes the envelope + * - Stateless (`payloadLocal=false`): envelope and blobs are already available from step 1, + * publish `SignedExecutionPayloadEnvelopeContents` which can be sent via any beacon node + * - Stateful (`payloadLocal=true`): fetch the envelope from the beacon node that produced the + * block, then publish the bare `SignedExecutionPayloadEnvelope` back to it; that node + * attaches the cached blobs and KZG proofs + * - Builder bid: the builder reveals the envelope, so the proposer does nothing further */ private async createAndPublishBlockGloas(pubkey: BLSPubkey, slot: Slot): Promise { const pubkeyHex = toPubkeyHex(pubkey); From 21d1956f26e7e70d80aab16373a9f3a34da75725 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 25 Jul 2026 17:49:26 +0100 Subject: [PATCH 47/47] fix metrics --- packages/validator/src/services/block.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index c2a8d6aef21e..0bc6deee9686 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -257,6 +257,8 @@ export class BlockProposingService { blockRoot: blockRootHex, broadcastValidation, }); + this.metrics?.proposerStepCallPublishBlock.observe(this.clock.secFromSlot(slot)); + this.metrics?.blocksPublished.inc(); const isSelfBuild = block.body.signedExecutionPayloadBid.message.builderIndex === BUILDER_INDEX_SELF_BUILD; @@ -341,9 +343,6 @@ export class BlockProposingService { blockRoot: blockRootHex, }); } - - this.metrics?.proposerStepCallPublishBlock.observe(this.clock.secFromSlot(slot)); - this.metrics?.blocksPublished.inc(); } private publishBlockWrapper = async (