diff --git a/.wordlist.txt b/.wordlist.txt index 248f3fe20b27..050bed5ca455 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -45,6 +45,7 @@ Flamegraphs GPG Geth Github +Gloas Goerli Golang Gossipsub 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/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index ea2e03ef4222..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_. #### Calculating builder boost factor with examples diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index c038747d4e4e..fee4962ebb2a 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -18,6 +18,7 @@ import { Slot, deneb, gloas, + isSignedExecutionPayloadEnvelopeContents, ssz, sszTypesFor, } from "@lodestar/types"; @@ -32,6 +33,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 +189,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}}, + { + signedEnvelopeOrContents: gloas.SignedExecutionPayloadEnvelopeContents | gloas.SignedExecutionPayloadEnvelope; + broadcastValidation?: BroadcastValidation; + }, + { + body: unknown; + headers: {[MetaHeader.Version]: string; [MetaHeader.BlobDataIncluded]: string}; + query: {broadcast_validation?: string}; + }, EmptyResponseData, EmptyMeta >; @@ -453,40 +466,64 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); + writeReqJson: ({signedEnvelopeOrContents, broadcastValidation}) => { + const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelopeOrContents); + const fork = config.getForkName( + (blobDataIncluded ? signedEnvelopeOrContents.signedExecutionPayloadEnvelope : signedEnvelopeOrContents) + .message.payload.slotNumber + ); return { - body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedExecutionPayloadEnvelope), + body: blobDataIncluded + ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.toJson(signedEnvelopeOrContents) + : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedEnvelopeOrContents), 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), + signedEnvelopeOrContents: 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: ({signedEnvelopeOrContents, broadcastValidation}) => { + const blobDataIncluded = isSignedExecutionPayloadEnvelopeContents(signedEnvelopeOrContents); + const fork = config.getForkName( + (blobDataIncluded ? signedEnvelopeOrContents.signedExecutionPayloadEnvelope : signedEnvelopeOrContents) + .message.payload.slotNumber + ); return { - body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedExecutionPayloadEnvelope), + body: blobDataIncluded + ? getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelopeContents.serialize(signedEnvelopeOrContents) + : getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedEnvelopeOrContents), 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), + signedEnvelopeOrContents: 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 8fad9aa678cf..d9811daeedbd 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -38,6 +38,7 @@ import { EmptyResponseCodec, EmptyResponseData, JsonOnlyReq, + WithMeta, WithVersion, } from "../../utils/codecs.js"; import {getPostBellatrixForkTypes, getPostGloasForkTypes, toForkName} from "../../utils/fork.js"; @@ -98,6 +99,10 @@ 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, }, {jsonCase: "eth2"} ); @@ -421,6 +426,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, 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", @@ -433,6 +444,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}; @@ -444,16 +457,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", @@ -906,6 +921,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ params: {slot}, query: { @@ -916,6 +932,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ @@ -927,6 +944,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.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/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: { + signedEnvelopeOrContents: 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 0a783e0948b2..719308ceba00 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -89,12 +89,15 @@ 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(), + executionPayloadValue: 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 c994e7726f6a..005a812d19ab 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,9 +40,16 @@ 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"; +import { + BlockError, + BlockErrorCode, + BlockGossipError, + ExecutionPayloadEnvelopeError, + ExecutionPayloadEnvelopeErrorCode, +} from "../../../../chain/errors/index.js"; import { BlockType, ProduceFullBellatrix, @@ -48,6 +57,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"; @@ -657,8 +667,22 @@ export function getBeaconBlockApi({ publishBlockV2, publishBlindedBlockV2, - async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope}) { + async publishExecutionPayloadEnvelope({signedEnvelopeOrContents, broadcastValidation}) { const seenTimestampSec = Date.now() / 1000; + + 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 = 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 = signedEnvelopeOrContents; + } + const envelope = signedExecutionPayloadEnvelope.message; const slot = envelope.payload.slotNumber; const fork = config.getForkName(slot); @@ -669,7 +693,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 @@ -689,39 +712,172 @@ 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); + // 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) && + cachedResult.type === BlockType.Full && + (cachedResult as ProduceFullGloas).executionPayload !== undefined + ? (cachedResult as ProduceFullGloas) + : undefined; + + broadcastValidation = broadcastValidation ?? routes.beacon.BroadcastValidation.gossip; + const valLogMeta = { + slot, + blockRoot: blockRootHex, + blockHash: blockHashHex, + builderIndex: envelope.builderIndex, + isSelfBuild, + blobDataIncluded, + broadcastValidation, + ...(submittedContents !== null ? {submittedBlobs: submittedContents.blobs.length} : {}), + }; + try { + switch (broadcastValidation) { + case routes.beacon.BroadcastValidation.none: { + chain.logger.debug("Skipping broadcast validation of execution payload envelope", valLogMeta); + break; + } - 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}`); - } - 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; + } + + case routes.beacon.BroadcastValidation.consensusAndEquivocation: + case routes.beacon.BroadcastValidation.consensus: { + await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + + // Verify the envelope against the post-block state + const blockState = await chain.regen + .getBlockSlotState(block, block.slot, {dontTransferCache: true}, RegenCaller.restApi) + .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( + 500, + `Unable to regenerate block state for consensus checks slot=${slot} 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); + + // 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") { + throw Error(message); + } + chain.logger.warn(message, valLogMeta); + } + 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); + } } - if (cachedResult.type !== BlockType.Full) { - throw new ApiError(400, "Cached block production result is not full block"); + } 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) + 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); + } else { + throw error; } + } - 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?.(); + 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} blockRoot=${blockRootHex}`); + } + + 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 + 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 + // 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) { + 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 + if (payloadInput.getVersionedHashes().length > 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 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); + dataColumnTimer?.(); } // If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N. @@ -730,21 +886,18 @@ 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); + } else { + payloadInput.addPayloadEnvelope({ + envelope: signedExecutionPayloadEnvelope, + source: PayloadEnvelopeInputSource.api, + seenTimestampSec, + peerIdStr: undefined, + }); } - payloadInput.addPayloadEnvelope({ - envelope: signedExecutionPayloadEnvelope, - source: PayloadEnvelopeInputSource.api, - seenTimestampSec, - peerIdStr: undefined, - }); - if (dataColumnSidecars.length > 0) { for (const columnSidecar of dataColumnSidecars) { payloadInput.addColumn({ @@ -756,27 +909,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 + // 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}), ]; diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 445cee08d5a2..371ab178c14e 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, @@ -101,7 +102,7 @@ import {computeSubnetForCommitteesAtSlot, getPubkeysForIndices, selectBlockProdu * 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 */ @@ -528,6 +529,16 @@ export function getValidatorApi( builderBoostFactor?: bigint, {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOpts = {} ): Promise { + builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; + if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { + 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) { + 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 @@ -538,36 +549,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, @@ -582,7 +569,6 @@ export function getValidatorApi( fork, builderSelection, isBuilderEnabled, - isEngineEnabled, strictFeeRecipientCheck, // winston logger doesn't like bigint builderBoostFactor: `${builderBoostFactor}`, @@ -608,27 +594,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)); @@ -675,30 +659,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) { @@ -727,14 +705,12 @@ 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 - if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder) { - logger.info("Selected engine block: censorship suspected in builder blocks", { + if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder && isBuilderEnabled) { + logger.warn("Selected engine block: censorship suspected in builder blocks", { ...loggerContext, durationMs: engine.durationMs, shouldOverrideBuilder: engine.value.shouldOverrideBuilder, @@ -751,11 +727,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, @@ -865,13 +839,31 @@ export function getValidatorApi( return {data, meta}; }, - async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient}) { + 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; + if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { + 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) { + throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); + } + notWhileSyncing(chain, sync.state); await waitForSlot(slot); @@ -888,15 +880,17 @@ 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; + // Bids are only skipped entirely with executiononly or while the circuit breaker is active, + // other engine-preferring selections still build a block with the best bid as fallback in + // case local production fails const circuitBreakerActive = chain.builderCircuitBreaker.isActive(slot); - const builderBid = circuitBreakerActive - ? null - : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + const builderBid = + builderSelection === routes.validator.BuilderSelection.ExecutionOnly || circuitBreakerActive + ? null + : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); const logCtx = { slot, @@ -904,6 +898,8 @@ export function getValidatorApi( parentBlockRoot: parentBlockRootHex, parentBlockHash: parentBlock.executionPayloadBlockHash, fork, + builderSelection, + builderBoostFactor, circuitBreakerActive, ...(builderBid !== null ? { @@ -940,33 +936,101 @@ 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)); + + // use abort controller to stop waiting for the bid block if the engine block will be selected + const controller = new AbortController(); + + 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})) - : 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, + signal: controller.signal, + }); let bestResult: typeof engineResult | null = null; let source: ProducedBlockSource = ProducedBlockSource.engine; - if (builderBid !== null && bidResult.status === "fulfilled") { + + // 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.warn("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 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 = + engineResult.status === "pending" + ? BuilderBlockSelectionReason.EnginePending + : BuilderBlockSelectionReason.EngineError; + metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.builder, reason}); + 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; - if (builderBid !== null) { - logger.warn("Builder bid block production failed, using local block", logCtx); - } + const reason = + builderBid === null + ? EngineBlockSelectionReason.BuilderNoBid + : bidResult.status === "pending" + ? EngineBlockSelectionReason.BuilderPending + : EngineBlockSelectionReason.BuilderError; + metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.engine, reason}); + 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") { - 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; @@ -987,9 +1051,41 @@ export function getValidatorApi( void chain.persistBlock(block, "produced_engine_block"); } + // Include the payload for self-builds unless disabled (stateless flow) + const isSelfBuild = source === ProducedBlockSource.engine; + if (isSelfBuild && includePayload) { + 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 slot=${slot} blockRoot=${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, executionPayloadValue, executionPayloadIncluded: true}, + }; + } + return { data: block as gloas.BeaconBlock, - meta: {version: fork, consensusBlockValue}, + meta: {version: fork, consensusBlockValue, executionPayloadValue, executionPayloadIncluded: false}, }; }, @@ -1798,6 +1894,18 @@ 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 local execution payload cached for block root ${blockRootHex}`); + } + + 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/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index aedb4eb09899..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 @@ -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,60 @@ 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("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({ + 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), + shouldOverrideBuilder: false, + }); + modules.chain.produceBlindedBlock.mockResolvedValue({ + block: blindedBlock, + executionPayloadValue: BigInt(1), + consensusBlockValue: BigInt(0), + }); + + const {data: block, meta} = await api.produceBlockV3({ + slot, + randaoReveal: fullBlock.body.randaoReveal, + graffiti: "a".repeat(32), + skipRandaoVerification: false, + builderSelection: routes.validator.BuilderSelection.BuilderOnly, + }); + + 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 () => { 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 e453312f4f55..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 @@ -1,4 +1,5 @@ import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {ProtoBlock} from "@lodestar/fork-choice"; import {ForkName} from "@lodestar/params"; @@ -55,6 +56,7 @@ describe("api/validator - produceBlockV4", () => { api = getValidatorApi(defaultApiOptions, {...modules, config}); vi.spyOn(modules.chain.clock, "currentSlot", "get").mockReturnValue(slot); + vi.mocked(modules.chain.clock.msFromSlot).mockReturnValue(0); vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.Synced); modules.chain.getProposerHead.mockReturnValue(parentBlock); modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(zeroProtoBlock); @@ -70,11 +72,17 @@ 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); - const {data: block, meta} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient}); + const {data: block, meta} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + }); expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledWith( slot, @@ -86,11 +94,52 @@ 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, 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 () => { + 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); - const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient}); + const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload: false}); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); expect(block).toEqual(engineBlock); @@ -100,11 +149,37 @@ describe("api/validator - produceBlockV4", () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); - const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient}); + const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload: false}); expect(modules.chain.builderCircuitBreaker.isActive).toHaveBeenCalledWith(slot); expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); expect(block).toEqual(engineBlock); }); + + 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); + }); }); 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(); + }); }); diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index f2aafef98717..3be87e1b4492 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, + 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 a73b3821b118..231169c4ccee 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; + payloadLocal?: boolean; importKeystores?: string[]; importKeystoresPassword?: string; @@ -261,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`, `executionalways`, or `executiononly`", defaultDescription: `${defaultOptions.builderSelection}`, group: "builder", }, @@ -292,6 +293,13 @@ export const validatorOptions: CliCommandOptions = { defaultDescription: `${defaultOptions.blindedLocal}`, }, + payloadLocal: { + type: "boolean", + description: + "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: { 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/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/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index e2c4e4e0c589..94af2b61c9e7 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -377,6 +377,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 ProgressiveContainerType( { randaoReveal: phase0Ssz.BeaconBlockBody.fields.randaoReveal, @@ -416,6 +425,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 LightClientHeader = new ContainerType( { beacon: phase0Ssz.BeaconBlockHeader, diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index efa2e54a6732..e7e9b9dd12be 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -47,9 +47,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 LightClientHeader = ValueOf; export type LightClientBootstrap = ValueOf; export type LightClientUpdate = ValueOf; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 8485aa7ffd5c..cc127d8bcde3 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 22633b3b2f87..9236e9c254ff 100644 --- a/packages/types/src/utils/typeguards.ts +++ b/packages/types/src/utils/typeguards.ts @@ -6,6 +6,7 @@ import { ForkPostElectra, ForkPostGloas, } from "@lodestar/params"; +import {SignedExecutionPayloadEnvelope, SignedExecutionPayloadEnvelopeContents} from "../gloas/types.js"; import { Attestation, BeaconBlock, @@ -114,3 +115,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/metrics.ts b/packages/validator/src/metrics.ts index 86b252979c88..c3a3a64ef081 100644 --- a/packages/validator/src/metrics.ts +++ b/packages/validator/src/metrics.ts @@ -194,6 +194,12 @@ export function getMetrics(register: MetricsRegisterExtra, gitData: LodestarGitD 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"], + }), + // BlockDutiesService proposerDutiesEpochCount: register.gauge({ diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index c916b22bd52d..0bc6deee9686 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; + payloadLocal: boolean; }; /** * Service that sets up and handles validator block proposal duties. @@ -166,11 +167,19 @@ export class BlockProposingService { } /** - * Gloas stateful block production flow: - * 1. Produce beacon block with execution payload bid - * 2. Sign and publish the beacon block - * 3. Get the execution payload envelope - * 4. Sign and publish the envelope + * Gloas block production flow: + * 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); @@ -180,8 +189,11 @@ 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, payloadLocal} = this.opts; + const {selection: builderSelection, boostFactor: builderBoostFactor} = + this.validatorStore.getBuilderSelectionParams(pubkeyHex, slot); - this.logger.debug("Producing block", {...debugLogCtx, feeRecipient}); + this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, payloadLocal, builderSelection}); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); // Step 1: Produce beacon block with execution payload bid @@ -191,19 +203,29 @@ export class BlockProposingService { randaoReveal, graffiti, feeRecipient, + includePayload: !payloadLocal, + builderSelection, + builderBoostFactor, }) .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, + executionPayloadValue: prettyWeiToEth(blockMeta.executionPayloadValue), consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), + totalBlockValue: prettyWeiToEth(blockMeta.executionPayloadValue + blockMeta.consensusBlockValue), + executionPayloadIncluded, blockRoot: blockRootHex, }); this.metrics?.blocksProduced.inc(); @@ -211,8 +233,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. @@ -224,63 +244,105 @@ 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, + executionPayloadValue: prettyWeiToEth(blockMeta.executionPayloadValue), + consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), + totalBlockValue: prettyWeiToEth(blockMeta.executionPayloadValue + blockMeta.consensusBlockValue), + 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; 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, + 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; + + // 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({ + signedEnvelopeOrContents: {signedExecutionPayloadEnvelope: signedEnvelope, kzgProofs, blobs}, + broadcastValidation, + }) + .catch((e: Error) => { + this.metrics?.payloadEnvelopeProposingErrors.inc({error: "publish"}); + throw extendError( + e, + `Failed to publish execution payload envelope slot=${slot} blockRoot=${blockRootHex} flow=${flow}` + ); + }) + ).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, }) .catch((e: Error) => { - this.metrics?.blockProposingErrors.inc({error: "publish"}); - throw extendError(e, "Failed to publish execution payload envelope"); - }) - ).assertOk(); + 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); + + // 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({ + signedEnvelopeOrContents: signedEnvelope, + broadcastValidation, + }) + .catch((e: Error) => { + this.metrics?.payloadEnvelopeProposingErrors.inc({error: "publish"}); + throw extendError( + e, + `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, + 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 + this.logger.info("Execution payload envelope to be revealed by builder", { ...logCtx, - graffiti, builderIndex: block.body.signedExecutionPayloadBid.message.builderIndex, - consensusBlockValue: prettyWeiToEth(blockMeta.consensusBlockValue), blockRoot: blockRootHex, }); } - - this.metrics?.proposerStepCallPublishBlock.observe(this.clock.secFromSlot(slot)); - this.metrics?.blocksPublished.inc(); } private publishBlockWrapper = async ( @@ -353,10 +415,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}` diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 286730a6efda..95c80754af20 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/src/validator.ts b/packages/validator/src/validator.ts index 57a2c35da44c..5fd70c3c53ca 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; + payloadLocal?: boolean; externalSigner?: ExternalSignerOptions; clock?: ClockOptions; }; @@ -256,6 +257,9 @@ export class Validator { { broadcastValidation: opts.broadcastValidation ?? defaultOptions.broadcastValidation, blindedLocal: opts.blindedLocal ?? defaultOptions.blindedLocal, + // 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 cb23e43a0fb6..f49ca77dc3f4 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, + payloadLocal: false, }); 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, + payloadLocal: false, }); const signedBlock = ssz.bellatrix.SignedBlindedBeaconBlock.defaultValue(); 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][] = [