From 1593eb552795768a4ffaa03492c4846e8e8a6357 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 11:38:38 +0100 Subject: [PATCH 1/4] feat: implement gloas builder api --- packages/api/src/beacon/routes/validator.ts | 47 ++++ packages/api/src/builder/routes.ts | 181 +++++++++++++++- .../test/unit/beacon/testData/validator.ts | 11 + .../api/test/unit/builder/builder.test.ts | 2 +- packages/api/test/unit/builder/testData.ts | 32 ++- .../src/api/impl/beacon/blocks/index.ts | 40 +++- .../src/api/impl/validator/index.ts | 199 ++++++++++++++--- .../src/api/impl/validator/utils.ts | 12 +- packages/beacon-node/src/chain/chain.ts | 7 +- packages/beacon-node/src/chain/interface.ts | 3 +- .../chain/validation/executionPayloadBid.ts | 120 +++++++++- .../src/execution/builder/gloas.ts | 205 ++++++++++++++++++ .../src/execution/builder/index.ts | 1 + .../src/metrics/metrics/lodestar.ts | 29 +++ packages/beacon-node/src/node/nodejs.ts | 8 +- .../test/mocks/mockedBeaconChain.ts | 9 + .../unit/api/impl/validator/utils.test.ts | 23 +- .../test/unit/execution/builder/gloas.test.ts | 113 ++++++++++ packages/cli/src/cmds/validator/handler.ts | 9 +- packages/cli/src/cmds/validator/options.ts | 18 ++ packages/cli/src/util/proposerConfig.ts | 49 ++++- .../validator/parseProposerConfig.test.ts | 9 + .../validator/proposerConfigs/validData.yaml | 8 + packages/params/src/index.ts | 5 + packages/types/src/gloas/sszTypes.ts | 34 +++ packages/types/src/gloas/types.ts | 4 + packages/validator/src/services/block.ts | 6 +- .../src/services/builderPreferences.ts | 128 +++++++++++ .../src/services/prepareBeaconProposer.ts | 4 + .../validator/src/services/validatorStore.ts | 95 ++++++++ .../src/util/externalSignerClient.ts | 9 +- packages/validator/src/validator.ts | 3 + .../test/unit/validatorStore.test.ts | 57 ++++- 33 files changed, 1427 insertions(+), 53 deletions(-) create mode 100644 packages/beacon-node/src/execution/builder/gloas.ts create mode 100644 packages/beacon-node/test/unit/execution/builder/gloas.test.ts create mode 100644 packages/validator/src/services/builderPreferences.ts diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 446424f8996c..d62e48f11ef6 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -228,6 +228,16 @@ export const LivenessResponseDataType = new ContainerType( {jsonCase: "eth2"} ); +export const BuilderPreferencesSubmissionType = new ContainerType( + { + /** The BLS public key of the validator expressing these preferences */ + validatorPubkey: ssz.BLSPubkey, + /** Preferences and auth to be submitted to the builder identified by `request.auth.message.data` */ + request: ssz.gloas.BuilderPreferencesRequestV1, + }, + {jsonCase: "eth2"} +); + export const ValidatorIndicesType = ArrayOf(ssz.ValidatorIndex); export const AttesterDutyListType = ArrayOf(AttesterDutyType); export const ProposerDutyListType = ArrayOf(ProposerDutyType); @@ -246,6 +256,7 @@ export const SignedValidatorRegistrationV1ListType = ArrayOf( ssz.bellatrix.SignedValidatorRegistrationV1, VALIDATOR_REGISTRY_LIMIT ); +export const BuilderPreferencesSubmissionListType = ArrayOf(BuilderPreferencesSubmissionType, VALIDATOR_REGISTRY_LIMIT); export type ValidatorIndices = ValueOf; export type AttesterDuty = ValueOf; @@ -273,6 +284,8 @@ export type SyncCommitteeSelectionList = ValueOf; export type LivenessResponseDataList = ValueOf; export type SignedValidatorRegistrationV1List = ValueOf; +export type BuilderPreferencesSubmission = ValueOf; +export type BuilderPreferencesSubmissionList = ValueOf; export type Endpoints = { /** @@ -646,6 +659,23 @@ export type Endpoints = { EmptyResponseData, EmptyMeta >; + + /** + * Submit per-builder preferences to be forwarded to external builders + * + * The beacon node forwards each request to the builder identified by `request.auth.message.data` + * and caches the auth to authenticate bid requests at proposal time. + * + * Note: this is a Lodestar-specific (non-standardized) endpoint, + * see https://github.com/ethereum/beacon-APIs/issues/600 + */ + submitBuilderPreferences: Endpoint< + "POST", + {submissions: BuilderPreferencesSubmissionList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; }; export function getDefinitions(config: ChainForkConfig): RouteDefinitions { @@ -1210,6 +1240,23 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({body: BuilderPreferencesSubmissionListType.toJson(submissions)}), + parseReqJson: ({body}) => ({submissions: BuilderPreferencesSubmissionListType.fromJson(body)}), + writeReqSsz: ({submissions}) => ({body: BuilderPreferencesSubmissionListType.serialize(submissions)}), + parseReqSsz: ({body}) => ({submissions: BuilderPreferencesSubmissionListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, + }, + }, }; } diff --git a/packages/api/src/builder/routes.ts b/packages/api/src/builder/routes.ts index edf7df6be4d7..28ff7012b6c1 100644 --- a/packages/api/src/builder/routes.ts +++ b/packages/api/src/builder/routes.ts @@ -1,16 +1,18 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ForkName, VALIDATOR_REGISTRY_LIMIT, isForkPostDeneb} from "@lodestar/params"; +import {ForkName, ForkPostGloas, VALIDATOR_REGISTRY_LIMIT, isForkPostDeneb} from "@lodestar/params"; import { ArrayOf, BLSPubkey, ExecutionPayload, ExecutionPayloadAndBlobsBundle, Root, + SignedBeaconBlock, SignedBlindedBeaconBlock, SignedBuilderBid, Slot, WithOptionalBytes, bellatrix, + gloas, ssz, } from "@lodestar/types"; import {fromHex, toPubkeyHex, toRootHex} from "@lodestar/utils"; @@ -23,7 +25,7 @@ import { EmptyResponseData, WithVersion, } from "../utils/codecs.js"; -import {getPostBellatrixForkTypes, getPostDenebForkTypes, toForkName} from "../utils/fork.js"; +import {getPostBellatrixForkTypes, getPostDenebForkTypes, getPostGloasForkTypes, toForkName} from "../utils/fork.js"; import {fromHeaders} from "../utils/headers.js"; import {Endpoint, RouteDefinitions, Schema} from "../utils/index.js"; import {MetaHeader, VersionCodec, VersionMeta} from "../utils/metadata.js"; @@ -35,6 +37,9 @@ import {WireFormat} from "../utils/wireFormat.js"; // It is important that this type indicates that there might be no value to ensure it is properly handled downstream. export type MaybeSignedBuilderBid = SignedBuilderBid | undefined; +// Same as `MaybeSignedBuilderBid`, the builder responds with 204 if no bid is available +export type MaybeSignedExecutionPayloadBid = gloas.SignedExecutionPayloadBid | undefined; + const RegistrationsType = ArrayOf(ssz.bellatrix.SignedValidatorRegistrationV1, VALIDATOR_REGISTRY_LIMIT); export type Endpoints = { @@ -82,6 +87,41 @@ export type Endpoints = { EmptyResponseData, EmptyMeta >; + + getExecutionPayloadBid: Endpoint< + "POST", + { + slot: Slot; + parentHash: Root; + parentRoot: Root; + proposerPubkey: BLSPubkey; + /** Optional auth to allow the builder to verify that the request was sent by the proposer */ + requestAuth?: gloas.SignedRequestAuthV1; + }, + { + params: {slot: Slot; parent_hash: string; parent_root: string; proposer_pubkey: string}; + body: unknown; + headers: {[MetaHeader.Version]: string}; + }, + MaybeSignedExecutionPayloadBid, + VersionMeta + >; + + submitSignedBeaconBlock: Endpoint< + "POST", + {signedBlock: WithOptionalBytes>}, + {body: unknown; headers: {[MetaHeader.Version]: string}}, + EmptyResponseData, + EmptyMeta + >; + + submitBuilderPreferences: Endpoint< + "POST", + {validatorPubkey: BLSPubkey; request: gloas.BuilderPreferencesRequestV1}, + {params: {validator_pubkey: string}; body: unknown; headers: {[MetaHeader.Version]: string}}, + EmptyResponseData, + EmptyMeta + >; }; export function getDefinitions(config: ChainForkConfig): RouteDefinitions { @@ -223,5 +263,142 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ + params: { + slot, + parent_hash: toRootHex(parentHash), + parent_root: toRootHex(parentRoot), + proposer_pubkey: toPubkeyHex(proposerPubkey), + }, + body: requestAuth && ssz.gloas.SignedRequestAuthV1.toJson(requestAuth), + headers: {[MetaHeader.Version]: config.getForkName(slot)}, + }), + parseReqJson: ({params, body}) => ({ + slot: params.slot, + parentHash: fromHex(params.parent_hash), + parentRoot: fromHex(params.parent_root), + proposerPubkey: fromHex(params.proposer_pubkey), + requestAuth: body != null ? ssz.gloas.SignedRequestAuthV1.fromJson(body) : undefined, + }), + writeReqSsz: ({slot, parentHash, parentRoot, proposerPubkey, requestAuth}) => ({ + params: { + slot, + parent_hash: toRootHex(parentHash), + parent_root: toRootHex(parentRoot), + proposer_pubkey: toPubkeyHex(proposerPubkey), + }, + // If there is no request auth, the request will be sent without a body + body: (requestAuth && ssz.gloas.SignedRequestAuthV1.serialize(requestAuth)) as Uint8Array, + headers: {[MetaHeader.Version]: config.getForkName(slot)}, + }), + parseReqSsz: ({params, body}) => ({ + slot: params.slot, + parentHash: fromHex(params.parent_hash), + parentRoot: fromHex(params.parent_root), + proposerPubkey: fromHex(params.proposer_pubkey), + requestAuth: ssz.gloas.SignedRequestAuthV1.deserialize(body), + }), + schema: { + params: { + slot: Schema.UintRequired, + parent_hash: Schema.StringRequired, + parent_root: Schema.StringRequired, + proposer_pubkey: Schema.StringRequired, + }, + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: { + data: WithVersion( + (fork: ForkName) => getPostGloasForkTypes(fork).SignedExecutionPayloadBid + ), + meta: VersionCodec, + }, + init: { + requestWireFormat: WireFormat.ssz, + }, + }, + submitSignedBeaconBlock: { + url: "/eth/v1/builder/beacon_blocks", + method: "POST", + req: { + writeReqJson: ({signedBlock}) => { + const fork = config.getForkName(signedBlock.data.message.slot); + return { + body: getPostGloasForkTypes(fork).SignedBeaconBlock.toJson(signedBlock.data), + headers: { + [MetaHeader.Version]: fork, + }, + }; + }, + parseReqJson: ({body, headers}) => { + const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + signedBlock: {data: getPostGloasForkTypes(fork).SignedBeaconBlock.fromJson(body)}, + }; + }, + writeReqSsz: ({signedBlock}) => { + const fork = config.getForkName(signedBlock.data.message.slot); + return { + body: signedBlock.bytes ?? getPostGloasForkTypes(fork).SignedBeaconBlock.serialize(signedBlock.data), + headers: { + [MetaHeader.Version]: fork, + }, + }; + }, + parseReqSsz: ({body, headers}) => { + const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + signedBlock: {data: getPostGloasForkTypes(fork).SignedBeaconBlock.deserialize(body)}, + }; + }, + schema: { + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, + }, + }, + submitBuilderPreferences: { + url: "/eth/v1/builder/builder_preferences/{validator_pubkey}", + method: "POST", + req: { + writeReqJson: ({validatorPubkey, request}) => ({ + params: {validator_pubkey: toPubkeyHex(validatorPubkey)}, + body: ssz.gloas.BuilderPreferencesRequestV1.toJson(request), + headers: {[MetaHeader.Version]: config.getForkName(request.auth.message.slot)}, + }), + parseReqJson: ({params, body}) => ({ + validatorPubkey: fromHex(params.validator_pubkey), + request: ssz.gloas.BuilderPreferencesRequestV1.fromJson(body), + }), + writeReqSsz: ({validatorPubkey, request}) => ({ + params: {validator_pubkey: toPubkeyHex(validatorPubkey)}, + body: ssz.gloas.BuilderPreferencesRequestV1.serialize(request), + headers: {[MetaHeader.Version]: config.getForkName(request.auth.message.slot)}, + }), + parseReqSsz: ({params, body}) => ({ + validatorPubkey: fromHex(params.validator_pubkey), + request: ssz.gloas.BuilderPreferencesRequestV1.deserialize(body), + }), + schema: { + params: {validator_pubkey: Schema.StringRequired}, + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, + }, + }, }; } diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index ac7aa4272a77..4a94a671f90f 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -157,4 +157,15 @@ export const testData: GenericServerTestCases = { args: {registrations: [ssz.bellatrix.SignedValidatorRegistrationV1.defaultValue()]}, res: undefined, }, + submitBuilderPreferences: { + args: { + submissions: [ + { + validatorPubkey: new Uint8Array(48).fill(1), + request: ssz.gloas.BuilderPreferencesRequestV1.defaultValue(), + }, + ], + }, + res: undefined, + }, }; diff --git a/packages/api/test/unit/builder/builder.test.ts b/packages/api/test/unit/builder/builder.test.ts index 53818590a7d7..23e4baf20222 100644 --- a/packages/api/test/unit/builder/builder.test.ts +++ b/packages/api/test/unit/builder/builder.test.ts @@ -8,7 +8,7 @@ import {testData} from "./testData.js"; describe("builder", () => { runGenericServerTest( - createChainForkConfig({...defaultChainConfig, ELECTRA_FORK_EPOCH: 0}), + createChainForkConfig({...defaultChainConfig, ELECTRA_FORK_EPOCH: 0, GLOAS_FORK_EPOCH: 1}), getClient, getRoutes, testData diff --git a/packages/api/test/unit/builder/testData.ts b/packages/api/test/unit/builder/testData.ts index 149e164187f5..e1c29e3d927b 100644 --- a/packages/api/test/unit/builder/testData.ts +++ b/packages/api/test/unit/builder/testData.ts @@ -1,5 +1,5 @@ import {fromHexString} from "@chainsafe/ssz"; -import {ForkName} from "@lodestar/params"; +import {ForkName, SLOTS_PER_EPOCH} from "@lodestar/params"; import {ssz} from "@lodestar/types"; import {Endpoints} from "../../../src/builder/routes.js"; import {GenericServerTestCases} from "../../utils/genericServerTest.js"; @@ -8,6 +8,18 @@ import {GenericServerTestCases} from "../../utils/genericServerTest.js"; const pubkeyRand = "0x84105a985058fc8740a48bf1ede9d223ef09e8c6b1735ba0a55cf4a9ff2ff92376b778798365e488dab07a652eb04576"; const root = new Uint8Array(32).fill(1); +// GLOAS_FORK_EPOCH is set to 1 in test config +const gloasSlot = SLOTS_PER_EPOCH; + +const requestAuth = ssz.gloas.SignedRequestAuthV1.defaultValue(); +requestAuth.message.slot = gloasSlot; + +const signedBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); +signedBlock.message.slot = gloasSlot; + +const builderPreferencesRequest = ssz.gloas.BuilderPreferencesRequestV1.defaultValue(); +builderPreferencesRequest.auth.message.slot = gloasSlot; + export const testData: GenericServerTestCases = { status: { args: undefined, @@ -29,4 +41,22 @@ export const testData: GenericServerTestCases = { args: {signedBlindedBlock: {data: ssz.fulu.SignedBlindedBeaconBlock.defaultValue()}}, res: undefined, }, + getExecutionPayloadBid: { + args: { + slot: gloasSlot, + parentHash: root, + parentRoot: root, + proposerPubkey: fromHexString(pubkeyRand), + requestAuth, + }, + res: {data: ssz.gloas.SignedExecutionPayloadBid.defaultValue(), meta: {version: ForkName.gloas}}, + }, + submitSignedBeaconBlock: { + args: {signedBlock: {data: signedBlock}}, + res: undefined, + }, + submitBuilderPreferences: { + args: {validatorPubkey: fromHexString(pubkeyRand), request: builderPreferencesRequest}, + 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 8a2c82192618..ae9020e62556 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -33,7 +33,7 @@ import { isDenebBlockContents, sszTypesFor, } from "@lodestar/types"; -import {fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; +import {fromHex, sleep, toHex, toPrintableUrl, toRootHex} from "@lodestar/utils"; import {BlockInputSource, isBlockInputBlobs, isBlockInputColumns} from "../../../../chain/blocks/blockInput/index.js"; import {PayloadEnvelopeInputSource} from "../../../../chain/blocks/payloadEnvelopeInput/index.js"; import {ImportBlockOpts} from "../../../../chain/blocks/types.js"; @@ -95,7 +95,7 @@ export function getBeaconBlockApi({ >): ApplicationMethods { const publishBlockV2: ApplicationMethods["publishBlockV2"] = async ( {signedBlockContents, broadcastValidation}, - _context, + context, opts: PublishBlockOpts = {} ) => { const seenTimestampSec = Date.now() / 1000; @@ -313,6 +313,17 @@ export function getBeaconBlockApi({ metrics?.gossipBlock.elapsedTimeTillReceived.observe({source: OpSource.api}, delaySec); chain.validatorMonitor?.registerBeaconBlock(OpSource.api, delaySec, signedBlock.message); + // If the included bid was sourced from the builder api, the signed block must be routed + // back to the winning builder which then broadcasts the execution payload envelope + const bidSource = isForkPostGloas(fork) ? chain.gloasExecutionBuilder.getBidSource(slot) : undefined; + const winningBuilder = + bidSource !== undefined && + toRootHex( + (signedBlock as SignedBeaconBlock).message.body.signedExecutionPayloadBid.message.blockHash + ) === bidSource.bidBlockHash + ? bidSource + : undefined; + chain.logger.info("Publishing block", valLogMeta); const publishPromises = [ // Send the block, regardless of whether or not it is valid. The API @@ -325,6 +336,31 @@ export function getBeaconBlockApi({ // import latency and hopefully bandwidth // () => network.publishBeaconBlock(signedBlock), + // Submission failures are not fatal to block publishing, builders can also see the block on gossip + ...(winningBuilder !== undefined + ? [ + () => + chain.gloasExecutionBuilder + .submitSignedBeaconBlock(winningBuilder.url, { + data: signedBlock as SignedBeaconBlock, + bytes: context?.sszBytes, + }) + .then(() => { + chain.logger.info("Submitted signed block to builder", { + slot, + blockRoot, + builder: toPrintableUrl(winningBuilder.url), + }); + }) + .catch((e) => { + chain.logger.error( + "Failed to submit signed block to builder", + {slot, blockRoot, builder: toPrintableUrl(winningBuilder.url)}, + e as Error + ); + }), + ] + : []), ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), ...blobSidecars.map((blobSidecar) => () => network.publishBlobSidecar(blobSidecar)), () => diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 54516c7c2902..7944fa342feb 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, @@ -57,6 +58,7 @@ import { prettyWeiToEth, resolveOrRacePromises, toHex, + toPrintableUrl, toRootHex, } from "@lodestar/utils"; import {MAX_BUILDER_BOOST_FACTOR} from "@lodestar/validator"; @@ -73,6 +75,7 @@ import {PREPARE_NEXT_SLOT_BPS} from "../../../chain/prepareNextSlot.js"; import {BlockType, ProduceFullDeneb, ProduceFullGloas} from "../../../chain/produceBlock/index.js"; import {RegenCaller} from "../../../chain/regen/index.js"; import {CheckpointHex} from "../../../chain/stateCache/types.js"; +import {validateBuilderApiExecutionPayloadBid} from "../../../chain/validation/executionPayloadBid.js"; import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; @@ -88,7 +91,12 @@ import {ApiOptions} from "../../options.js"; import {getStateResponseWithRegen} from "../beacon/state/utils.js"; import {ApiError, FailureList, IndexedError, NodeIsSyncing, OnlySupportedByDVT} from "../errors.js"; import {ApiModules} from "../types.js"; -import {computeSubnetForCommitteesAtSlot, getPubkeysForIndices, selectBlockProductionSource} from "./utils.js"; +import { + computeSubnetForCommitteesAtSlot, + effectiveBidValueGwei, + getPubkeysForIndices, + selectBlockProductionSource, +} from "./utils.js"; /** * If the node is within this many epochs from the head, we declare it to be synced regardless of @@ -904,7 +912,7 @@ export function getValidatorApi( return {data, meta}; }, - async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient}) { + async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, builderSelection, builderBoostFactor}) { const fork = config.getForkName(slot); if (!isForkPostGloas(fork)) { @@ -924,27 +932,19 @@ export function getValidatorApi( graffiti ?? getDefaultGraffiti(getLodestarClientVersion(opts), chain.executionEngine.clientVersion, opts) ); - // 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. + 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`); + } + + // External bids from the p2p network and the builder api are treated uniformly as the + // builder source, builderSelection and builderBoostFactor apply vs the local self-build + const isBuilderEnabled = builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; + const isEngineEnabled = builderSelection !== routes.validator.BuilderSelection.BuilderOnly; + const isBuildingOnFull = chain.forkChoice.shouldBuildOnFull(parentBlock, slot); const bidParentBlockHash = isBuildingOnFull ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash; - const builderBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); - - const logCtx = { - slot, - parentSlot, - parentBlockRoot: parentBlockRootHex, - parentBlockHash: parentBlock.executionPayloadBlockHash, - fork, - ...(builderBid !== null - ? { - bidValue: builderBid.message.value, - builderIndex: builderBid.message.builderIndex, - bidBlockHash: toRootHex(builderBid.message.blockHash), - } - : {}), - }; const commonBlockBodyPromise = chain.produceCommonBlockBody({ slot, @@ -962,45 +962,151 @@ export function getValidatorApi( commonBlockBodyPromise, }; - metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); - if (builderBid !== null) { - metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); - } - const timed = (source: ProducedBlockSource, fn: () => Promise): Promise => { const t = metrics?.blockProductionTime.startTimer(); return fn().finally(() => t?.({source})); }; - // Always build local block. If builder bid available, also build with it in parallel and prefer it. + // Start local block production immediately, external bids are fetched in parallel + let enginePromise: ReturnType | null = null; + if (isEngineEnabled) { + metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); + enginePromise = timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)); + // Rejections are handled after the external bid fetch via Promise.allSettled + enginePromise.catch(() => {}); + } + + let bestBid: { + signedBid: gloas.SignedExecutionPayloadBid; + effectiveValueGwei: bigint; + builderUrl?: string; + } | null = null; + + if (isBuilderEnabled) { + const p2pBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + if (p2pBid !== null) { + bestBid = {signedBid: p2pBid, effectiveValueGwei: BigInt(p2pBid.message.value)}; + } + + if (bidParentBlockHash !== null) { + const state = await chain.regen.getBlockSlotState( + parentBlock, + slot, + {dontTransferCache: true}, + RegenCaller.produceBlock + ); + const proposerIndex = state.getBeaconProposer(slot); + const proposerPubkey = chain.pubkeyCache.getOrThrow(proposerIndex).toBytes(); + + if (chain.gloasExecutionBuilder.hasRegisteredBuilders(slot, proposerPubkey)) { + const expectedFeeRecipient = feeRecipient ?? chain.beaconProposerCache.getOrDefault(proposerIndex); + const builderApiBids = await chain.gloasExecutionBuilder.getExecutionPayloadBids( + slot, + fromHex(bidParentBlockHash), + parentBlockRoot, + proposerPubkey + ); + + for (const {url, maxExecutionPayment, signedBid} of builderApiBids) { + try { + await validateBuilderApiExecutionPayloadBid(chain, signedBid, { + slot, + state, + parentBlock, + parentBlockHashHex: bidParentBlockHash, + expectedFeeRecipient, + maxExecutionPayment, + }); + } catch (e) { + metrics?.gloasBuilder.bidsDiscarded.inc(); + logger.warn( + "Discarded builder api bid", + {slot, builder: toPrintableUrl(url), builderIndex: signedBid.message.builderIndex}, + e as Error + ); + continue; + } + + const effectiveValueGwei = effectiveBidValueGwei(signedBid.message, maxExecutionPayment); + if (bestBid === null || effectiveValueGwei > bestBid.effectiveValueGwei) { + bestBid = {signedBid, effectiveValueGwei, builderUrl: url}; + } + } + } + } + } + + const logCtx = { + slot, + parentSlot, + parentBlockRoot: parentBlockRootHex, + parentBlockHash: parentBlock.executionPayloadBlockHash, + fork, + ...(bestBid !== null + ? { + bidSource: bestBid.builderUrl !== undefined ? toPrintableUrl(bestBid.builderUrl) : "p2p", + bidValue: bestBid.signedBid.message.value, + bidExecutionPayment: bestBid.signedBid.message.executionPayment, + builderIndex: bestBid.signedBid.message.builderIndex, + bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), + } + : {}), + }; + + let bidPromise: ReturnType | null = null; + if (bestBid !== null) { + metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); + const builderBid = bestBid.signedBid; + bidPromise = timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})); + } + const [engineResult, bidResult] = await Promise.allSettled([ - timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)), - builderBid !== null - ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})) - : Promise.reject(), + enginePromise ?? Promise.reject(Error("Engine disabled via builderSelection")), + bidPromise ?? Promise.reject(Error("No external bid available")), ]); let bestResult: typeof engineResult | null = null; let source: ProducedBlockSource = ProducedBlockSource.engine; - if (builderBid !== null && bidResult.status === "fulfilled") { + if (bestBid !== null && bidResult.status === "fulfilled" && engineResult.status === "fulfilled") { + ({source} = selectBlockProductionSource({ + builderSelection, + builderBoostFactor, + engineExecutionPayloadValue: engineResult.value.executionPayloadValue, + builderExecutionPayloadValue: bestBid.effectiveValueGwei * GWEI_TO_WEI, + })); + bestResult = source === ProducedBlockSource.builder ? bidResult : engineResult; + logger.info(`Selected ${source === ProducedBlockSource.builder ? "builder bid" : "local"} block`, logCtx); + } else if (bidResult.status === "fulfilled") { source = ProducedBlockSource.builder; bestResult = bidResult; - logger.info("Selected builder bid block", logCtx); + if (isEngineEnabled) { + logger.warn("Local block production failed, using builder bid block", logCtx); + } else { + logger.info("Selected builder bid block", logCtx); + } } else if (engineResult.status === "fulfilled") { source = ProducedBlockSource.engine; bestResult = engineResult; - if (builderBid !== null) { + if (bestBid !== null) { logger.warn("Builder bid block production failed, using local block", logCtx); } } if (bestResult === null || bestResult.status !== "fulfilled") { const engineReason = engineResult.status === "rejected" ? engineResult.reason : undefined; - const bidReason = builderBid !== null && bidResult.status === "rejected" ? bidResult.reason : undefined; + const bidReason = 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"}`); } + // Route the signed block back to the winning builder at publish time + if (source === ProducedBlockSource.builder && bestBid?.builderUrl !== undefined) { + chain.gloasExecutionBuilder.recordBidSource(slot, { + url: bestBid.builderUrl, + bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), + }); + } + const {block, executionPayloadValue, consensusBlockValue} = bestResult.value; metrics?.blockProductionSuccess.inc({source}); @@ -1765,6 +1871,29 @@ export function getValidatorApi( }); }, + async submitBuilderPreferences({submissions}) { + const failures: FailureList = []; + + await Promise.all( + submissions.map(async ({validatorPubkey, request}, i) => { + const slot = request.auth.message.slot; + try { + if (!isForkPostGloas(config.getForkName(slot))) { + throw Error(`Builder preferences not supported for pre-gloas slot=${slot}`); + } + await chain.gloasExecutionBuilder.submitBuilderPreferences(validatorPubkey, request); + } catch (e) { + failures.push({index: i, message: (e as Error).message}); + logger.verbose(`Error on submitBuilderPreferences [${i}]`, {slot}, e as Error); + } + }) + ); + + if (failures.length > 0) { + throw new IndexedError("Error processing builder preferences", failures); + } + }, + async getExecutionPayloadEnvelope({slot, beaconBlockRoot}) { const fork = config.getForkName(slot); diff --git a/packages/beacon-node/src/api/impl/validator/utils.ts b/packages/beacon-node/src/api/impl/validator/utils.ts index 81717fcbfd50..2108911ac8d1 100644 --- a/packages/beacon-node/src/api/impl/validator/utils.ts +++ b/packages/beacon-node/src/api/impl/validator/utils.ts @@ -1,7 +1,7 @@ import {routes} from "@lodestar/api"; import {ATTESTATION_SUBNET_COUNT} from "@lodestar/params"; import {IBeaconStateView, computeSlotsSinceEpochStart} from "@lodestar/state-transition"; -import {BLSPubkey, CommitteeIndex, ProducedBlockSource, Slot, SubnetID, ValidatorIndex} from "@lodestar/types"; +import {BLSPubkey, CommitteeIndex, ProducedBlockSource, Slot, SubnetID, ValidatorIndex, gloas} from "@lodestar/types"; import {MAX_BUILDER_BOOST_FACTOR} from "@lodestar/validator"; import {BlockSelectionResult, BuilderBlockSelectionReason, EngineBlockSelectionReason} from "./index.js"; @@ -42,6 +42,16 @@ export function getPubkeysForIndices(state: IBeaconStateView, indexes: Validator return pubkeys; } +/** + * The proposer's total take from a bid. The execution payment only counts up to the + * `max_execution_payment` the proposer submitted to the builder, any excess is not + * credited by the builder specs and must not influence bid selection. + */ +export function effectiveBidValueGwei(bid: gloas.ExecutionPayloadBid, maxExecutionPayment: bigint): bigint { + const executionPayment = BigInt(bid.executionPayment); + return BigInt(bid.value) + (executionPayment < maxExecutionPayment ? executionPayment : maxExecutionPayment); +} + export function selectBlockProductionSource({ builderSelection, engineExecutionPayloadValue, diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 28286f926f1e..ca257d26bdfc 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -53,7 +53,7 @@ import {GENESIS_EPOCH, ZERO_HASH} from "../constants/index.js"; import {IBeaconDb} from "../db/index.js"; import {BLOB_SIDECARS_IN_WRAPPER_INDEX} from "../db/repositories/blobSidecars.js"; import {BuilderStatus} from "../execution/builder/http.js"; -import {IExecutionBuilder, IExecutionEngine} from "../execution/index.js"; +import {GloasExecutionBuilder, IExecutionBuilder, IExecutionEngine} from "../execution/index.js"; import {Metrics} from "../metrics/index.js"; import {computeNodeIdFromPrivateKey} from "../network/subnets/interface.js"; import {BufferPool} from "../util/bufferPool.js"; @@ -152,6 +152,7 @@ export class BeaconChain implements IBeaconChain { readonly genesisValidatorsRoot: Root; readonly executionEngine: IExecutionEngine; readonly executionBuilder?: IExecutionBuilder; + readonly gloasExecutionBuilder: GloasExecutionBuilder; // Expose config for convenience in modularized functions readonly config: BeaconConfig; readonly custodyConfig: CustodyConfig; @@ -260,6 +261,7 @@ export class BeaconChain implements IBeaconChain { isAnchorStateFinalized, executionEngine, executionBuilder, + gloasExecutionBuilder, }: { privateKey: PrivateKey; config: BeaconConfig; @@ -277,6 +279,7 @@ export class BeaconChain implements IBeaconChain { isAnchorStateFinalized: boolean; executionEngine: IExecutionEngine; executionBuilder?: IExecutionBuilder; + gloasExecutionBuilder?: GloasExecutionBuilder; } ) { this.opts = opts; @@ -291,6 +294,7 @@ export class BeaconChain implements IBeaconChain { this.genesisValidatorsRoot = anchorState.genesisValidatorsRoot; this.executionEngine = executionEngine; this.executionBuilder = executionBuilder; + this.gloasExecutionBuilder = gloasExecutionBuilder ?? new GloasExecutionBuilder({}, config, metrics, logger); const signal = this.abortController.signal; const emitter = new ChainEventEmitter(); // by default, verify signatures on both main threads and worker threads @@ -1472,6 +1476,7 @@ export class BeaconChain implements IBeaconChain { this.seenSyncCommitteeMessages.prune(slot); this.payloadAttestationPool.prune(slot); this.executionPayloadBidPool.prune(slot); + this.gloasExecutionBuilder.prune(slot); this.seenExecutionPayloadBids.prune(slot); this.seenProposerPreferences.prune(slot); this.proposerPreferencesPool.prune(slot); diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 077b2e87b4ba..4e2fb84420d3 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -23,7 +23,7 @@ import { rewards, } from "@lodestar/types"; import {Logger} from "@lodestar/utils"; -import {IExecutionBuilder, IExecutionEngine} from "../execution/index.js"; +import {GloasExecutionBuilder, IExecutionBuilder, IExecutionEngine} from "../execution/index.js"; import {Metrics} from "../metrics/metrics.js"; import {BufferPool} from "../util/bufferPool.js"; import {IClock} from "../util/clock.js"; @@ -96,6 +96,7 @@ export interface IBeaconChain { readonly earliestAvailableSlot: Slot; readonly executionEngine: IExecutionEngine; readonly executionBuilder?: IExecutionBuilder; + readonly gloasExecutionBuilder: GloasExecutionBuilder; // Expose config for convenience in modularized functions readonly config: BeaconConfig; readonly custodyConfig: CustodyConfig; diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index 6129140513d4..706c087803b6 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -1,6 +1,8 @@ import {PublicKey} from "@chainsafe/blst"; +import {ProtoBlock} from "@lodestar/fork-choice"; import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import { + IBeaconStateView, computeEpochAtSlot, createSingleSignatureSetFromComponents, getExecutionPayloadBidSigningRoot, @@ -8,8 +10,8 @@ import { isGasLimitTargetCompatible, isStatePostGloas, } from "@lodestar/state-transition"; -import {ValidatorIndex, gloas} from "@lodestar/types"; -import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import {RootHex, Slot, ValidatorIndex, gloas} from "@lodestar/types"; +import {byteArrayEquals, fromHex, toHex, toRootHex} from "@lodestar/utils"; import {getShufflingDependentRoot} from "../../util/dependentRoot.js"; import {ExecutionPayloadBidError, ExecutionPayloadBidErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; @@ -22,6 +24,120 @@ export async function validateApiExecutionPayloadBid( return validateExecutionPayloadBid(chain, signedExecutionPayloadBid); } +export type BuilderApiBidContext = { + slot: Slot; + /** Pre-state of the proposal, must be advanced to `slot` on the parent block's branch */ + state: IBeaconStateView; + parentBlock: ProtoBlock; + parentBlockHashHex: RootHex; + expectedFeeRecipient: string; + maxExecutionPayment: bigint; +}; + +/** + * Validate a bid received from an external builder via `getExecutionPayloadBid` against the + * proposal context, mirrors `validate_bid` from the builder specs. Ensures a chosen bid never + * invalidates the proposer's own block. Throws if the bid must be discarded. + */ +export async function validateBuilderApiExecutionPayloadBid( + chain: IBeaconChain, + signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid, + {slot, state, parentBlock, parentBlockHashHex, expectedFeeRecipient, maxExecutionPayment}: BuilderApiBidContext +): Promise { + const bid = signedExecutionPayloadBid.message; + + if (!isStatePostGloas(state)) { + throw Error(`Expected gloas+ state for builder api bid validation, got fork=${state.forkName}`); + } + + if (bid.slot !== slot) { + throw Error(`Bid slot=${bid.slot} does not match proposal slot=${slot}`); + } + + const bidParentBlockRootHex = toRootHex(bid.parentBlockRoot); + if (bidParentBlockRootHex !== parentBlock.blockRoot) { + throw Error(`Bid parentBlockRoot=${bidParentBlockRootHex} does not match expected=${parentBlock.blockRoot}`); + } + + const bidParentBlockHashHex = toRootHex(bid.parentBlockHash); + if (bidParentBlockHashHex !== parentBlockHashHex) { + throw Error(`Bid parentBlockHash=${bidParentBlockHashHex} does not match expected=${parentBlockHashHex}`); + } + + if (BigInt(bid.executionPayment) > maxExecutionPayment) { + throw Error(`Bid executionPayment=${bid.executionPayment} exceeds maxExecutionPayment=${maxExecutionPayment}`); + } + + if (!byteArrayEquals(bid.feeRecipient, fromHex(expectedFeeRecipient))) { + throw Error(`Bid feeRecipient=${toHex(bid.feeRecipient)} does not match expected=${expectedFeeRecipient}`); + } + + let builder: gloas.Builder; + try { + builder = state.getBuilder(bid.builderIndex); + } catch { + throw Error(`Bid builderIndex=${bid.builderIndex} is unknown`); + } + if (!isActiveBuilder(builder, state.finalizedCheckpoint.epoch)) { + throw Error(`Bid builderIndex=${bid.builderIndex} is not an active builder`); + } + if (builder.version !== PAYLOAD_BUILDER_VERSION) { + throw Error(`Bid builderIndex=${bid.builderIndex} has invalid version=${builder.version}`); + } + + // Gas limit target compatibility is a proposer preference, only enforced if the + // target gas limit is known from a gossiped `SignedProposerPreferences` + const parentPayloadVariant = chain.forkChoice.getBlockHexAndBlockHash(parentBlock.blockRoot, parentBlockHashHex); + const dependentRootHex = (() => { + try { + return getShufflingDependentRoot( + chain.forkChoice, + computeEpochAtSlot(bid.slot), + computeEpochAtSlot(parentBlock.slot), + parentBlock + ); + } catch { + return null; + } + })(); + const proposerPreferences = + dependentRootHex !== null ? chain.proposerPreferencesPool.get(bid.slot, dependentRootHex) : null; + if (proposerPreferences !== null && parentPayloadVariant?.executionPayloadBlockHash != null) { + const parentGasLimit = parentPayloadVariant.executionPayloadGasLimit; + const targetGasLimit = proposerPreferences.message.targetGasLimit; + if (!isGasLimitTargetCompatible(parentGasLimit, bid.gasLimit, targetGasLimit)) { + throw Error( + `Bid gasLimit=${bid.gasLimit} not compatible with parentGasLimit=${parentGasLimit} targetGasLimit=${targetGasLimit}` + ); + } + } + + const blobKzgCommitmentsLen = bid.blobKzgCommitments.length; + const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot)); + if (blobKzgCommitmentsLen > maxBlobsPerBlock) { + throw Error(`Bid blobKzgCommitments=${blobKzgCommitmentsLen} exceeds limit=${maxBlobsPerBlock}`); + } + + if (bid.value > 0 && !state.canBuilderCoverBid(bid.builderIndex, bid.value)) { + throw Error(`Bid value=${bid.value} cannot be covered by builderIndex=${bid.builderIndex}`); + } + + const randaoMix = state.getRandaoMix(computeEpochAtSlot(state.slot)); + if (!byteArrayEquals(bid.prevRandao, randaoMix)) { + throw Error(`Bid prevRandao=${toHex(bid.prevRandao)} does not match expected=${toHex(randaoMix)}`); + } + + const signatureSet = createSingleSignatureSetFromComponents( + PublicKey.fromBytes(builder.pubkey), + getExecutionPayloadBidSigningRoot(chain.config, state.slot, bid), + signedExecutionPayloadBid.signature + ); + + if (!(await chain.bls.verifySignatureSets([signatureSet]))) { + throw Error(`Bid signature is invalid for builderIndex=${bid.builderIndex}`); + } +} + export async function validateGossipExecutionPayloadBid( chain: IBeaconChain, signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid diff --git a/packages/beacon-node/src/execution/builder/gloas.ts b/packages/beacon-node/src/execution/builder/gloas.ts new file mode 100644 index 000000000000..e2a6cf13d68b --- /dev/null +++ b/packages/beacon-node/src/execution/builder/gloas.ts @@ -0,0 +1,205 @@ +import {ApiClient as BuilderApi, getClient} from "@lodestar/api/builder"; +import {ChainForkConfig} from "@lodestar/config"; +import {Logger} from "@lodestar/logger"; +import {ForkPostGloas} from "@lodestar/params"; +import {BLSPubkey, Root, SignedBeaconBlock, Slot, WithOptionalBytes, gloas} from "@lodestar/types"; +import {MapDef, toPrintableUrl, toPubkeyHex} from "@lodestar/utils"; +import {Metrics} from "../../metrics/metrics.js"; + +export type GloasExecutionBuilderOpts = { + timeout?: number; + // Add User-Agent header to all requests + userAgent?: string; +}; + +/** + * Additional duration to account for potential event loop lag which causes + * builder bids to be rejected even though the response was sent in time. + */ +const EVENT_LOOP_LAG_BUFFER = 250; + +/** + * Duration given to the builder to provide a `SignedExecutionPayloadBid` before the deadline + * is reached, only considering bids from the p2p network and the local build process. + */ +const BUILDER_BID_DELAY_TOLERANCE = 1000 + EVENT_LOOP_LAG_BUFFER; + +type PubkeyHex = string; +type BuilderUrl = string; + +type BuilderEntry = { + auth: gloas.SignedRequestAuthV1; + maxExecutionPayment: bigint; +}; + +export type BuilderApiBid = { + url: BuilderUrl; + maxExecutionPayment: bigint; + signedBid: gloas.SignedExecutionPayloadBid; +}; + +export type BidSource = {url: BuilderUrl; bidBlockHash: string}; + +/** + * External builder integration post-gloas (ePBS), see https://github.com/ethereum/builder-specs/pull/138. + * + * The builder set is driven by validator-signed request auths submitted via `submitBuilderPreferences`, + * clients are dialed on demand based on the builder url the validator signed over (`auth.message.data`). + */ +export class GloasExecutionBuilder { + private readonly clients = new Map(); + private readonly buildersByPubkeyBySlot = new MapDef>>( + () => new MapDef>(() => new Map()) + ); + /** Builder api bid included in a produced block, used to route the signed block back to the builder */ + private readonly bidSourceBySlot = new Map(); + private lowestPermissibleSlot = 0; + + constructor( + private readonly opts: GloasExecutionBuilderOpts, + private readonly config: ChainForkConfig, + private readonly metrics: Metrics | null = null, + private readonly logger?: Logger + ) {} + + /** + * Forward a proposer's builder preferences to the builder identified by `auth.message.data` + * and cache the auth to authenticate the bid request at proposal time. + */ + async submitBuilderPreferences( + validatorPubkey: BLSPubkey, + request: gloas.BuilderPreferencesRequestV1 + ): Promise { + const url = Buffer.from(request.auth.message.data).toString("utf8"); + + try { + new URL(url); + } catch { + throw Error("Invalid builder url in auth.message.data"); + } + + const slot = request.auth.message.slot; + if (slot < this.lowestPermissibleSlot) { + throw Error(`Builder preferences for past slot=${slot} lowestPermissibleSlot=${this.lowestPermissibleSlot}`); + } + + this.buildersByPubkeyBySlot + .getOrDefault(slot) + .getOrDefault(toPubkeyHex(validatorPubkey)) + .set(url, {auth: request.auth, maxExecutionPayment: request.preferences.maxExecutionPayment}); + + try { + (await this.getClientForUrl(url).submitBuilderPreferences({validatorPubkey, request})).assertOk(); + this.metrics?.gloasBuilder.preferencesForwarded.inc({status: "success"}); + } catch (e) { + this.metrics?.gloasBuilder.preferencesForwarded.inc({status: "error"}); + throw e; + } + } + + /** Return true if there are registered builders for this proposal slot and proposer */ + hasRegisteredBuilders(slot: Slot, proposerPubkey: BLSPubkey): boolean { + const builders = this.buildersByPubkeyBySlot.get(slot)?.get(toPubkeyHex(proposerPubkey)); + return builders !== undefined && builders.size > 0; + } + + /** + * Fan out bid requests to all builders registered for this proposal slot and proposer. + * Errors and empty responses (204) are logged and filtered out. + */ + async getExecutionPayloadBids( + slot: Slot, + parentHash: Root, + parentRoot: Root, + proposerPubkey: BLSPubkey + ): Promise { + const builders = this.buildersByPubkeyBySlot.get(slot)?.get(toPubkeyHex(proposerPubkey)); + if (builders === undefined || builders.size === 0) { + return []; + } + + const bids = await Promise.all( + Array.from(builders.entries()).map(async ([url, {auth, maxExecutionPayment}]): Promise => { + this.metrics?.gloasBuilder.bidRequests.inc(); + try { + const res = await this.getClientForUrl(url).getExecutionPayloadBid( + {slot, parentHash, parentRoot, proposerPubkey, requestAuth: auth}, + {timeoutMs: BUILDER_BID_DELAY_TOLERANCE, headers: {"X-Timeout-Ms": String(BUILDER_BID_DELAY_TOLERANCE)}} + ); + const signedBid = res.value(); + if (signedBid === undefined) { + this.logger?.debug("No bid received from builder", {slot, builder: toPrintableUrl(url)}); + return null; + } + this.metrics?.gloasBuilder.bidsReceived.inc(); + return {url, maxExecutionPayment, signedBid}; + } catch (e) { + this.metrics?.gloasBuilder.bidRequestErrors.inc(); + this.logger?.warn("Failed to get bid from builder", {slot, builder: toPrintableUrl(url)}, e as Error); + return null; + } + }) + ); + + return bids.filter((bid): bid is BuilderApiBid => bid !== null); + } + + /** + * Submit the signed beacon block to the builder whose bid was included. The builder is + * then responsible for constructing and broadcasting the corresponding + * `SignedExecutionPayloadEnvelope`, no further action is required by the proposer. + */ + async submitSignedBeaconBlock( + url: BuilderUrl, + signedBlock: WithOptionalBytes> + ): Promise { + try { + (await this.getClientForUrl(url).submitSignedBeaconBlock({signedBlock}, {retries: 2})).assertOk(); + this.metrics?.gloasBuilder.blockSubmissions.inc({status: "success"}); + } catch (e) { + this.metrics?.gloasBuilder.blockSubmissions.inc({status: "error"}); + throw e; + } + } + + recordBidSource(slot: Slot, source: BidSource): void { + this.bidSourceBySlot.set(slot, source); + } + + getBidSource(slot: Slot): BidSource | undefined { + return this.bidSourceBySlot.get(slot); + } + + prune(clockSlot: Slot): void { + this.lowestPermissibleSlot = clockSlot; + for (const slot of this.buildersByPubkeyBySlot.keys()) { + if (slot < clockSlot) { + this.buildersByPubkeyBySlot.delete(slot); + } + } + for (const slot of this.bidSourceBySlot.keys()) { + if (slot < clockSlot) { + this.bidSourceBySlot.delete(slot); + } + } + } + + private getClientForUrl(url: BuilderUrl): BuilderApi { + let client = this.clients.get(url); + if (client === undefined) { + client = getClient( + { + baseUrl: url, + globalInit: { + timeoutMs: this.opts.timeout, + headers: this.opts.userAgent ? {"User-Agent": this.opts.userAgent} : undefined, + }, + }, + {config: this.config, metrics: this.metrics?.builderHttpClient, logger: this.logger} + ); + this.clients.set(url, client); + this.logger?.info("External builder registered", {url: toPrintableUrl(url)}); + } + return client; + } +} diff --git a/packages/beacon-node/src/execution/builder/index.ts b/packages/beacon-node/src/execution/builder/index.ts index 19c2905fd832..b6b26823337f 100644 --- a/packages/beacon-node/src/execution/builder/index.ts +++ b/packages/beacon-node/src/execution/builder/index.ts @@ -5,6 +5,7 @@ import {ExecutionBuilderHttp, ExecutionBuilderHttpOpts, defaultExecutionBuilderH import {IExecutionBuilder} from "./interface.js"; export {ExecutionBuilderHttp, defaultExecutionBuilderHttpOpts}; +export * from "./gloas.js"; export type ExecutionBuilderOpts = {mode?: "http"} & ExecutionBuilderHttpOpts; export const defaultExecutionBuilderOpts: ExecutionBuilderOpts = defaultExecutionBuilderHttpOpts; diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 745223553f59..a9823f85f608 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -1905,6 +1905,35 @@ export function createLodestarMetrics( }), }, + gloasBuilder: { + bidRequests: register.counter({ + name: "lodestar_gloas_builder_bid_requests_total", + help: "Total count of execution payload bid requests sent to external builders", + }), + bidRequestErrors: register.counter({ + name: "lodestar_gloas_builder_bid_request_errors_total", + help: "Total count of failed execution payload bid requests to external builders", + }), + bidsReceived: register.counter({ + name: "lodestar_gloas_builder_bids_received_total", + help: "Total count of execution payload bids received from external builders", + }), + bidsDiscarded: register.counter({ + name: "lodestar_gloas_builder_bids_discarded_total", + help: "Total count of execution payload bids from external builders discarded due to failed validation", + }), + blockSubmissions: register.counter<{status: "success" | "error"}>({ + name: "lodestar_gloas_builder_block_submissions_total", + help: "Total count of signed beacon blocks submitted to external builders", + labelNames: ["status"], + }), + preferencesForwarded: register.counter<{status: "success" | "error"}>({ + name: "lodestar_gloas_builder_preferences_forwarded_total", + help: "Total count of builder preferences forwarded to external builders", + labelNames: ["status"], + }), + }, + db: { dbReadReq: register.gauge<{bucket: string}>({ name: "lodestar_db_read_req_total", diff --git a/packages/beacon-node/src/node/nodejs.ts b/packages/beacon-node/src/node/nodejs.ts index 29189ce3bcce..71312114e623 100644 --- a/packages/beacon-node/src/node/nodejs.ts +++ b/packages/beacon-node/src/node/nodejs.ts @@ -14,7 +14,7 @@ import {BeaconRestApiServer, getApi} from "../api/index.js"; import {BeaconChain, IBeaconChain, initBeaconMetrics} from "../chain/index.js"; import {ValidatorMonitor, createValidatorMonitor} from "../chain/validatorMonitor.js"; import {IBeaconDb} from "../db/index.js"; -import {initializeExecutionBuilder, initializeExecutionEngine} from "../execution/index.js"; +import {GloasExecutionBuilder, initializeExecutionBuilder, initializeExecutionEngine} from "../execution/index.js"; import {HttpMetricsServer, Metrics, createMetrics, getHttpMetricsServer} from "../metrics/index.js"; import {MonitoringService} from "../monitoring/index.js"; import {Network, getReqRespHandlers} from "../network/index.js"; @@ -258,6 +258,12 @@ export class BeaconNode { executionBuilder: opts.executionBuilder.enabled ? initializeExecutionBuilder(opts.executionBuilder, config, metrics, logger) : undefined, + gloasExecutionBuilder: new GloasExecutionBuilder( + {timeout: opts.executionBuilder.timeout, userAgent: opts.executionBuilder.userAgent}, + config, + metrics, + logger + ), }); // Load persisted data from disk to in-memory caches diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index ab74100273ca..3e2a212cd83e 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -143,6 +143,15 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { getClientVersion: vi.fn(), }, executionBuilder: {}, + gloasExecutionBuilder: { + submitBuilderPreferences: vi.fn(), + hasRegisteredBuilders: vi.fn(), + getExecutionPayloadBids: vi.fn(), + submitSignedBeaconBlock: vi.fn(), + recordBidSource: vi.fn(), + getBidSource: vi.fn(), + prune: vi.fn(), + }, opPool: new OpPool(config as BeaconConfig), aggregatedAttestationPool: new AggregatedAttestationPool(config as BeaconConfig), syncContributionAndProofPool: new SyncContributionAndProofPool(config, clock), diff --git a/packages/beacon-node/test/unit/api/impl/validator/utils.test.ts b/packages/beacon-node/test/unit/api/impl/validator/utils.test.ts index c4d0058f892e..4afe261310b0 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/utils.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/utils.test.ts @@ -8,7 +8,7 @@ import { createPubkeyCache, } from "@lodestar/state-transition"; import {BLSPubkey, ValidatorIndex, ssz} from "@lodestar/types"; -import {getPubkeysForIndices} from "../../../../../src/api/impl/validator/utils.js"; +import {effectiveBidValueGwei, getPubkeysForIndices} from "../../../../../src/api/impl/validator/utils.js"; describe("api / impl / validator / utils", () => { const vc = 32; @@ -41,4 +41,25 @@ describe("api / impl / validator / utils", () => { const pubkeysRes = getPubkeysForIndices(new BeaconStateView(cachedState), indexes); expect(pubkeysRes.map(toHexString)).toEqual(pubkeys.map(toHexString)); }); + + it("effectiveBidValueGwei", () => { + const bid = ssz.gloas.ExecutionPayloadBid.defaultValue(); + + const testCases: {value: number; executionPayment: number; maxExecutionPayment: bigint; expected: bigint}[] = [ + // no execution payment, only the bid value counts + {value: 100, executionPayment: 0, maxExecutionPayment: BigInt(0), expected: BigInt(100)}, + // execution payment within max is credited in full + {value: 100, executionPayment: 50, maxExecutionPayment: BigInt(50), expected: BigInt(150)}, + // execution payment above max only counts up to the max + {value: 100, executionPayment: 80, maxExecutionPayment: BigInt(50), expected: BigInt(150)}, + // execution payment is not credited if no max was submitted + {value: 0, executionPayment: 80, maxExecutionPayment: BigInt(0), expected: BigInt(0)}, + ]; + + for (const {value, executionPayment, maxExecutionPayment, expected} of testCases) { + bid.value = value; + bid.executionPayment = executionPayment; + expect(effectiveBidValueGwei(bid, maxExecutionPayment)).toBe(expected); + } + }); }); diff --git a/packages/beacon-node/test/unit/execution/builder/gloas.test.ts b/packages/beacon-node/test/unit/execution/builder/gloas.test.ts new file mode 100644 index 000000000000..b45752994f4b --- /dev/null +++ b/packages/beacon-node/test/unit/execution/builder/gloas.test.ts @@ -0,0 +1,113 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; +import {ssz} from "@lodestar/types"; +import {GloasExecutionBuilder} from "../../../../src/execution/builder/gloas.js"; + +const mockClient = vi.hoisted(() => ({ + submitBuilderPreferences: vi.fn(), + getExecutionPayloadBid: vi.fn(), + submitSignedBeaconBlock: vi.fn(), +})); + +vi.mock("@lodestar/api/builder", () => ({ + getClient: () => mockClient, +})); + +describe("GloasExecutionBuilder", () => { + const config = createChainForkConfig(defaultChainConfig); + const builderUrl = "https://builder.example.com"; + const pubkey = Buffer.alloc(48, 1); + const root = new Uint8Array(32).fill(1); + const slot = 10; + + const request = ssz.gloas.BuilderPreferencesRequestV1.defaultValue(); + request.auth.message.data = Buffer.from(builderUrl, "utf8"); + request.auth.message.slot = slot; + request.preferences.maxExecutionPayment = BigInt(100); + + let executionBuilder: GloasExecutionBuilder; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient.submitBuilderPreferences.mockResolvedValue({assertOk: () => {}}); + mockClient.getExecutionPayloadBid.mockResolvedValue({value: () => undefined}); + mockClient.submitSignedBeaconBlock.mockResolvedValue({assertOk: () => {}}); + + executionBuilder = new GloasExecutionBuilder({}, config, null); + }); + + it("should cache builder entry and forward preferences", async () => { + await executionBuilder.submitBuilderPreferences(pubkey, request); + + expect(mockClient.submitBuilderPreferences).toHaveBeenCalledOnce(); + expect(executionBuilder.hasRegisteredBuilders(slot, pubkey)).toBe(true); + expect(executionBuilder.hasRegisteredBuilders(slot + 1, pubkey)).toBe(false); + expect(executionBuilder.hasRegisteredBuilders(slot, Buffer.alloc(48, 2))).toBe(false); + }); + + it("should reject preferences with invalid builder url", async () => { + const invalidRequest = ssz.gloas.BuilderPreferencesRequestV1.defaultValue(); + invalidRequest.auth.message.data = Buffer.from("not a url", "utf8"); + + await expect(executionBuilder.submitBuilderPreferences(pubkey, invalidRequest)).rejects.toThrow( + /Invalid builder url/ + ); + }); + + it("should reject preferences for past slots", async () => { + executionBuilder.prune(slot + 1); + + await expect(executionBuilder.submitBuilderPreferences(pubkey, request)).rejects.toThrow(/past slot/); + }); + + it("should fan out bid requests to registered builders", async () => { + await executionBuilder.submitBuilderPreferences(pubkey, request); + + const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + mockClient.getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); + + const bids = await executionBuilder.getExecutionPayloadBids(slot, root, root, pubkey); + + expect(mockClient.getExecutionPayloadBid).toHaveBeenCalledOnce(); + expect(mockClient.getExecutionPayloadBid).toHaveBeenCalledWith( + {slot, parentHash: root, parentRoot: root, proposerPubkey: pubkey, requestAuth: request.auth}, + expect.any(Object) + ); + expect(bids).toEqual([{url: builderUrl, maxExecutionPayment: BigInt(100), signedBid}]); + }); + + it("should filter out empty bid responses", async () => { + await executionBuilder.submitBuilderPreferences(pubkey, request); + + expect(await executionBuilder.getExecutionPayloadBids(slot, root, root, pubkey)).toEqual([]); + }); + + it("should filter out failed bid requests", async () => { + await executionBuilder.submitBuilderPreferences(pubkey, request); + mockClient.getExecutionPayloadBid.mockRejectedValue(Error("builder unavailable")); + + expect(await executionBuilder.getExecutionPayloadBids(slot, root, root, pubkey)).toEqual([]); + }); + + it("should not request bids if no builders are registered", async () => { + expect(await executionBuilder.getExecutionPayloadBids(slot, root, root, pubkey)).toEqual([]); + expect(mockClient.getExecutionPayloadBid).not.toHaveBeenCalled(); + }); + + it("should track bid source until pruned", () => { + const bidSource = {url: builderUrl, bidBlockHash: "0x1234"}; + executionBuilder.recordBidSource(slot, bidSource); + + expect(executionBuilder.getBidSource(slot)).toEqual(bidSource); + + executionBuilder.prune(slot + 1); + expect(executionBuilder.getBidSource(slot)).toBeUndefined(); + }); + + it("should prune registered builders for past slots", async () => { + await executionBuilder.submitBuilderPreferences(pubkey, request); + executionBuilder.prune(slot + 1); + + expect(executionBuilder.hasRegisteredBuilders(slot, pubkey)).toBe(false); + }); +}); diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index f2aafef98717..87a3debcb09f 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -28,7 +28,12 @@ import { parseLoggerArgs, parseProposerConfig, } from "../../util/index.js"; -import {parseBuilderBoostFactor, parseBuilderSelection} from "../../util/proposerConfig.js"; +import { + parseBuilderBoostFactor, + parseBuilderSelection, + parseBuilderUrls, + parseMaxExecutionPayment, +} from "../../util/proposerConfig.js"; import {getVersionData} from "../../util/version.js"; import {KeymanagerApi} from "./keymanager/impl.js"; import {IPersistedKeysBackend} from "./keymanager/interface.js"; @@ -249,6 +254,8 @@ function getProposerConfigFromArgs( args["builder.selection"] ?? (args.builder ? defaultOptions.builderAliasSelection : undefined) ), boostFactor: parseBuilderBoostFactor(args["builder.boostFactor"]), + maxExecutionPayment: parseMaxExecutionPayment(args["builder.maxExecutionPayment"]), + builders: parseBuilderUrls(args["builder.urls"]), }, }; diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index a73b3821b118..12be8288f192 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -48,6 +48,8 @@ export type IValidatorCliArgs = AccountValidatorArgs & builder?: boolean; "builder.selection"?: string; "builder.boostFactor"?: string; + "builder.urls"?: string[]; + "builder.maxExecutionPayment"?: string; /** @deprecated */ useProduceBlockV3?: boolean; @@ -274,6 +276,22 @@ export const validatorOptions: CliCommandOptions = { group: "builder", }, + "builder.urls": { + description: "URL(s) of external builders to request execution payload bids from. Only used post gloas.", + type: "array", + string: true, + coerce: (urls: string[]): string[] => urls.flatMap((url) => url.split(",")), + group: "builder", + }, + + "builder.maxExecutionPayment": { + type: "string", + description: + "Maximum execution layer payment (in Gwei) the proposer will accept from a builder. A value of 0 means only trustless payments via the builder's staked collateral are accepted. Only used post gloas.", + defaultDescription: `${defaultOptions.maxExecutionPayment}`, + group: "builder", + }, + useProduceBlockV3: { hidden: true, deprecated: true, diff --git a/packages/cli/src/util/proposerConfig.ts b/packages/cli/src/util/proposerConfig.ts index a346aa9acf96..860720c455f4 100644 --- a/packages/cli/src/util/proposerConfig.ts +++ b/packages/cli/src/util/proposerConfig.ts @@ -17,6 +17,8 @@ type ProposerConfigFileSection = { gas_limit?: number; selection?: routes.validator.BuilderSelection; boost_factor?: bigint; + max_execution_payment?: string; + builders?: {[url: string]: {max_execution_payment?: string} | null}; }; }; @@ -54,7 +56,7 @@ function parseProposerConfigSection( overrideConfig?: ProposerConfig ): ProposerConfig { const {graffiti, strict_fee_recipient_check, fee_recipient, builder} = proposerFileSection; - const {gas_limit, selection: builderSelection, boost_factor} = builder || {}; + const {gas_limit, selection: builderSelection, boost_factor, max_execution_payment, builders} = builder || {}; if (graffiti !== undefined && typeof graffiti !== "string") { throw Error("graffiti is not 'string"); @@ -92,11 +94,27 @@ function parseProposerConfigSection( gasLimit: overrideConfig?.builder?.gasLimit ?? (gas_limit !== undefined ? Number(gas_limit) : undefined), selection: overrideConfig?.builder?.selection ?? parseBuilderSelection(builderSelection), boostFactor: overrideConfig?.builder?.boostFactor ?? parseBuilderBoostFactor(boost_factor), + maxExecutionPayment: + overrideConfig?.builder?.maxExecutionPayment ?? parseMaxExecutionPayment(max_execution_payment), + builders: overrideConfig?.builder?.builders ?? parseBuilders(builders), } : undefined, }; } +function parseBuilders(builders?: { + [url: string]: {max_execution_payment?: string} | null; +}): Record | undefined { + if (builders === undefined) return undefined; + + const parsed: Record = {}; + for (const [url, preferences] of Object.entries(builders)) { + parseBuilderUrl(url); + parsed[url] = {maxExecutionPayment: parseMaxExecutionPayment(preferences?.max_execution_payment)}; + } + return parsed; +} + export function readProposerConfigDir(filepath: string, filename: string): ProposerConfigFileSection { const proposerConfigStr = fs.readFileSync(path.join(filepath, filename), "utf8"); const proposerConfigJSON = JSON.parse(proposerConfigStr) as ProposerConfigFileSection; @@ -134,3 +152,32 @@ export function parseBuilderBoostFactor(boostFactor?: string): bigint | undefine return BigInt(boostFactor); } + +export function parseMaxExecutionPayment(maxExecutionPayment?: string): bigint | undefined { + if (maxExecutionPayment === undefined) return; + + if (!/^\d+$/.test(maxExecutionPayment)) { + throw Error("Invalid input for max execution payment, must be a valid number in Gwei without decimals"); + } + + return BigInt(maxExecutionPayment); +} + +export function parseBuilderUrls(urls?: string[]): Record | undefined { + if (urls === undefined) return undefined; + + const builders: Record = {}; + for (const url of urls) { + builders[parseBuilderUrl(url)] = {}; + } + return builders; +} + +function parseBuilderUrl(url: string): string { + try { + new URL(url); + } catch { + throw Error(`Invalid builder url: ${url}`); + } + return url; +} diff --git a/packages/cli/test/unit/validator/parseProposerConfig.test.ts b/packages/cli/test/unit/validator/parseProposerConfig.test.ts index c1cd1d2dfb13..63a60ee2a33c 100644 --- a/packages/cli/test/unit/validator/parseProposerConfig.test.ts +++ b/packages/cli/test/unit/validator/parseProposerConfig.test.ts @@ -26,6 +26,11 @@ const testValue = { gasLimit: 35000000, selection: routes.validator.BuilderSelection.BuilderAlways, boostFactor: 18446744073709551616n, + maxExecutionPayment: 1000000000n, + builders: { + "https://builder.example.com": {maxExecutionPayment: 2000000000n}, + "https://other-builder.example.com": {maxExecutionPayment: undefined}, + }, }, }, }, @@ -37,6 +42,10 @@ const testValue = { gasLimit: 60000000, selection: routes.validator.BuilderSelection.MaxProfit, boostFactor: BigInt(50), + maxExecutionPayment: BigInt(0), + builders: { + "https://default-builder.example.com": {maxExecutionPayment: undefined}, + }, }, }, }; diff --git a/packages/cli/test/unit/validator/proposerConfigs/validData.yaml b/packages/cli/test/unit/validator/proposerConfigs/validData.yaml index 2ba508454d0f..355cc086d449 100644 --- a/packages/cli/test/unit/validator/proposerConfigs/validData.yaml +++ b/packages/cli/test/unit/validator/proposerConfigs/validData.yaml @@ -11,6 +11,11 @@ proposer_config: gas_limit: "35000000" selection: "builderalways" boost_factor: "18446744073709551616" + max_execution_payment: "1000000000" + builders: + "https://builder.example.com": + max_execution_payment: "2000000000" + "https://other-builder.example.com": default_config: graffiti: "default graffiti" strict_fee_recipient_check: "true" @@ -19,3 +24,6 @@ default_config: gas_limit: "60000000" selection: "maxprofit" boost_factor: "50" + max_execution_payment: "0" + builders: + "https://default-builder.example.com": {} diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index 2e54b5d7ac6a..52da490aec0e 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -179,6 +179,7 @@ export const DOMAIN_BUILDER_DEPOSIT = Uint8Array.from([14, 0, 0, 0]); */ export const DOMAIN_APPLICATION_MASK = Uint8Array.from([0, 0, 0, 1]); export const DOMAIN_APPLICATION_BUILDER = Uint8Array.from([0, 0, 0, 1]); +export const DOMAIN_REQUEST_AUTH = Uint8Array.from([11, 0, 0, 1]); // Participation flag indices @@ -328,3 +329,7 @@ export const BUILDER_PAYMENT_THRESHOLD_NUMERATOR = 6; export const BUILDER_PAYMENT_THRESHOLD_DENOMINATOR = 10; export const BUILDER_DEPOSIT_REQUEST_TYPE = 0x03; export const BUILDER_EXIT_REQUEST_TYPE = 0x04; + +// Gloas builder specs +export const MAX_DATA_SIZE = 4096; +export const MAX_EXECUTION_PAYMENT = 2n ** 64n - 1n; diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index c0cac9f3ceb2..edd8c3f8d36a 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -14,6 +14,7 @@ import { MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD, MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD, MAX_BYTES_PER_TRANSACTION, + MAX_DATA_SIZE, MAX_PAYLOAD_ATTESTATIONS, MIN_SEED_LOOKAHEAD, NUMBER_OF_COLUMNS, @@ -205,6 +206,39 @@ export const SignedExecutionPayloadBid = new ContainerType( {typeName: "SignedExecutionPayloadBid", jsonCase: "eth2"} ); +// Builder API types (builder-specs) + +export const RequestAuthV1 = new ContainerType( + { + data: new ByteListType(MAX_DATA_SIZE), + slot: Slot, + }, + {typeName: "RequestAuthV1", jsonCase: "eth2"} +); + +export const SignedRequestAuthV1 = new ContainerType( + { + message: RequestAuthV1, + signature: BLSSignature, + }, + {typeName: "SignedRequestAuthV1", jsonCase: "eth2"} +); + +export const BuilderPreferencesV1 = new ContainerType( + { + maxExecutionPayment: Gwei, + }, + {typeName: "BuilderPreferencesV1", jsonCase: "eth2"} +); + +export const BuilderPreferencesRequestV1 = new ContainerType( + { + preferences: BuilderPreferencesV1, + auth: SignedRequestAuthV1, + }, + {typeName: "BuilderPreferencesRequestV1", jsonCase: "eth2"} +); + export const BlockAccessList = new ByteListType(MAX_BYTES_PER_TRANSACTION); export const ExecutionPayload = new ContainerType( diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index c562c59e3e60..b99b8ded405d 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -20,6 +20,10 @@ export type SignedProposerPreferences = ValueOf; export type ExecutionPayloadBid = ValueOf; export type SignedExecutionPayloadBid = ValueOf; +export type RequestAuthV1 = ValueOf; +export type SignedRequestAuthV1 = ValueOf; +export type BuilderPreferencesV1 = ValueOf; +export type BuilderPreferencesRequestV1 = ValueOf; export type BlockAccessList = ValueOf; export type ExecutionPayloadEnvelope = ValueOf; export type SignedExecutionPayloadEnvelope = ValueOf; diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index c916b22bd52d..e5dea94a11a5 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -180,8 +180,10 @@ export class BlockProposingService { const randaoReveal = await this.validatorStore.signRandao(pubkey, slot); const graffiti = this.validatorStore.getGraffiti(pubkeyHex); const feeRecipient = this.validatorStore.getFeeRecipient(pubkeyHex); + const {selection: builderSelection, boostFactor: builderBoostFactor} = + this.validatorStore.getBuilderSelectionParams(pubkeyHex); - this.logger.debug("Producing block", {...debugLogCtx, feeRecipient}); + this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, builderSelection, builderBoostFactor}); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); // Step 1: Produce beacon block with execution payload bid @@ -191,6 +193,8 @@ export class BlockProposingService { randaoReveal, graffiti, feeRecipient, + builderSelection, + builderBoostFactor, }) .catch((e: Error) => { this.metrics?.blockProposingErrors.inc({error: "produce"}); diff --git a/packages/validator/src/services/builderPreferences.ts b/packages/validator/src/services/builderPreferences.ts new file mode 100644 index 000000000000..15cecc161d1e --- /dev/null +++ b/packages/validator/src/services/builderPreferences.ts @@ -0,0 +1,128 @@ +import {ApiClient, routes} from "@lodestar/api"; +import {ChainForkConfig} from "@lodestar/config"; +import {SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params"; +import {computeEpochAtSlot} from "@lodestar/state-transition"; +import {Epoch, RootHex, Slot} from "@lodestar/types"; +import {toPubkeyHex} from "@lodestar/utils"; +import {Metrics} from "../metrics.js"; +import {IClock, LoggerVc} from "../util/index.js"; +import {BlockDutiesService} from "./blockDuties.js"; +import {ValidatorStore} from "./validatorStore.js"; + +/** + * Submit a proposer's builder preferences this many slots before the proposal slot. + * Same pacing as `ProposerPreferencesService`, builders must have the preferences + * and the beacon node must have the request auths before the bid request at proposal time. + */ +const SUBMIT_BEFORE_PROPOSAL_SLOTS = Math.floor(SLOTS_PER_EPOCH / 4); + +/** Per-epoch tracking of preferences already submitted under the current dependent_root. */ +type SubmittedAtEpoch = {dependentRoot: RootHex; slots: Set}; + +/** + * Signs and submits `BuilderPreferencesRequestV1` for any local validator that will propose + * within the next `SUBMIT_BEFORE_PROPOSAL_SLOTS` and has external builders configured. + * + * The beacon node forwards each request to the builder the validator signed over + * (`auth.message.data`) and caches the auth to authenticate the bid request at proposal time. + * Re-submits automatically when the proposer dependent root for an epoch shifts (e.g. after + * a reorg) since the proposer for a slot may have changed. + */ +export class BuilderPreferencesService { + private readonly submitted = new Map(); + + constructor( + private readonly config: ChainForkConfig, + private readonly logger: LoggerVc, + private readonly api: ApiClient, + clock: IClock, + private readonly validatorStore: ValidatorStore, + private readonly blockDutiesService: BlockDutiesService, + _metrics: Metrics | null + ) { + clock.runEverySlot(this.runBuilderPreferencesTask); + } + + private runBuilderPreferencesTask = async (slot: Slot): Promise => { + // Start running once the submission window (`slot + SUBMIT_BEFORE_PROPOSAL_SLOTS`) reaches + // Gloas, i.e. already in the epoch before the fork. This allows builders to prepare and + // submit bids for the first Gloas slots. + if (!isForkPostGloas(this.config.getForkName(slot + SUBMIT_BEFORE_PROPOSAL_SLOTS))) { + return; + } + + const currentEpoch = computeEpochAtSlot(slot); + const submissions: routes.validator.BuilderPreferencesSubmissionList = []; + // Track which `(submission, slot)` pairs are pending an API submission so we can mark + // them only after the network call succeeds. Marking before would silently drop a + // preference on transient API failure (no retry until dependent_root shifts). + const pending: {submission: SubmittedAtEpoch; slot: Slot}[] = []; + + for (const epoch of [currentEpoch, currentEpoch + 1]) { + const dutiesAtEpoch = this.blockDutiesService.getProposersAtEpoch(epoch); + if (!dutiesAtEpoch) continue; + + // Reset submission tracking if the dependent root for this epoch has shifted + // (e.g. due to a reorg). The proposer for a slot may have changed. + let submission = this.submitted.get(epoch); + if (submission === undefined || submission.dependentRoot !== dutiesAtEpoch.dependentRoot) { + if (submission !== undefined) { + this.logger.info("Proposer-shuffling dependent root shifted; resubmitting builder preferences", { + epoch, + priorDependentRoot: submission.dependentRoot, + dependentRoot: dutiesAtEpoch.dependentRoot, + }); + } + submission = {dependentRoot: dutiesAtEpoch.dependentRoot, slots: new Set()}; + this.submitted.set(epoch, submission); + } + + for (const duty of dutiesAtEpoch.data) { + if (duty.slot <= slot) continue; + if (duty.slot > slot + SUBMIT_BEFORE_PROPOSAL_SLOTS) continue; + if (!isForkPostGloas(this.config.getForkName(duty.slot))) continue; + if (submission.slots.has(duty.slot)) continue; + + const pubkeyHex = toPubkeyHex(duty.pubkey); + const {selection} = this.validatorStore.getBuilderSelectionParams(pubkeyHex); + if (selection === routes.validator.BuilderSelection.ExecutionOnly) continue; + + const builders = this.validatorStore.getRegisteredBuilders(pubkeyHex); + if (builders.length === 0) continue; + + try { + for (const {url, maxExecutionPayment} of builders) { + const auth = await this.validatorStore.getRequestAuth(duty.pubkey, url, duty.slot, slot); + submissions.push({ + validatorPubkey: duty.pubkey, + request: {preferences: {maxExecutionPayment}, auth}, + }); + } + pending.push({submission, slot: duty.slot}); + } catch (e) { + this.logger.error( + "Error signing builder preferences", + {slot: duty.slot, validatorIndex: duty.validatorIndex}, + e as Error + ); + } + } + } + + if (submissions.length === 0) { + return; + } + + try { + await this.api.validator.submitBuilderPreferences({submissions}); + // Only mark as submitted after the API call succeeds; a thrown error leaves the + // slot eligible for retry on the next tick. + for (const {submission, slot: submittedSlot} of pending) { + submission.slots.add(submittedSlot); + } + this.logger.debug("Submitted builder preferences", {count: submissions.length}); + } catch (e) { + this.logger.error("Error submitting builder preferences", {count: submissions.length}, e as Error); + } + }; +} diff --git a/packages/validator/src/services/prepareBeaconProposer.ts b/packages/validator/src/services/prepareBeaconProposer.ts index a2637c412294..568b871f5223 100644 --- a/packages/validator/src/services/prepareBeaconProposer.ts +++ b/packages/validator/src/services/prepareBeaconProposer.ts @@ -77,6 +77,10 @@ export function pollBuilderValidatorRegistration( // Before bellatrix we don't need to update this data on bn/builder if (epoch < config.BELLATRIX_FORK_EPOCH - 1) return; + + // Validator registrations are deprecated post-gloas, fee recipient and gas limit are + // communicated via the proposer_preferences gossip topic instead + if (epoch >= config.GLOAS_FORK_EPOCH) return; const slot = epoch * SLOTS_PER_EPOCH; // registerValidator is not as time sensitive as attesting. diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index e37c2e5047e5..0eba867515ed 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -12,10 +12,12 @@ import { DOMAIN_PROPOSER_PREFERENCES, DOMAIN_PTC_ATTESTER, DOMAIN_RANDAO, + DOMAIN_REQUEST_AUTH, DOMAIN_SELECTION_PROOF, DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, ForkSeq, + MAX_DATA_SIZE, } from "@lodestar/params"; import { ZERO_HASH, @@ -75,6 +77,12 @@ export type SignerRemote = { pubkey: PubkeyHex; }; +/** Per-builder preferences for external builders (gloas builder api) */ +export type BuilderPreferences = { + /** Max execution layer payment (in Gwei) the proposer will accept from this builder */ + maxExecutionPayment?: bigint; +}; + type DefaultProposerConfig = { graffiti?: string; strictFeeRecipientCheck: boolean; @@ -83,6 +91,8 @@ type DefaultProposerConfig = { gasLimit: number; selection: routes.validator.BuilderSelection; boostFactor: bigint; + maxExecutionPayment: bigint; + builders: Record; }; }; @@ -94,6 +104,9 @@ export type ProposerConfig = { gasLimit?: number; selection?: routes.validator.BuilderSelection; boostFactor?: bigint; + maxExecutionPayment?: bigint; + /** External builders to request bids from, keyed by builder url (gloas builder api) */ + builders?: Record; }; }; @@ -131,6 +144,8 @@ export type Signer = SignerLocal | SignerRemote; type ValidatorData = ProposerConfig & { signer: Signer; builderData?: BuilderData; + /** Pre-signed request auths for upcoming proposal slots, keyed by `${proposalSlot}-${builderUrl}` */ + requestAuths?: Map; }; export const defaultOptions = { @@ -139,6 +154,8 @@ export const defaultOptions = { builderSelection: routes.validator.BuilderSelection.ExecutionOnly, builderAliasSelection: routes.validator.BuilderSelection.Default, builderBoostFactor: BigInt(100), + // Do not accept execution payments by default, all payments must use the trustless on-chain mechanism + maxExecutionPayment: BigInt(0), // spec asks for gossip validation by default broadcastValidation: routes.beacon.BroadcastValidation.gossip, // should request fetching the locally produced block in blinded format @@ -184,6 +201,8 @@ export class ValidatorStore { gasLimit: defaultConfig.builder?.gasLimit ?? defaultOptions.defaultGasLimit, selection: defaultConfig.builder?.selection ?? defaultOptions.builderSelection, boostFactor: builderBoostFactor, + maxExecutionPayment: defaultConfig.builder?.maxExecutionPayment ?? defaultOptions.maxExecutionPayment, + builders: defaultConfig.builder?.builders ?? {}, }, }; @@ -856,6 +875,82 @@ export class ValidatorStore { return validatorRegistration; } + /** External builders to request bids from with resolved per-builder preferences (gloas builder api) */ + getRegisteredBuilders(pubkeyHex: PubkeyHex): {url: string; maxExecutionPayment: bigint}[] { + const validatorBuilder = this.validators.get(pubkeyHex)?.builder; + const builders = validatorBuilder?.builders ?? this.defaultProposerConfig.builder.builders; + const defaultMaxExecutionPayment = + validatorBuilder?.maxExecutionPayment ?? this.defaultProposerConfig.builder.maxExecutionPayment; + + return Object.entries(builders).map(([url, preferences]) => ({ + url, + maxExecutionPayment: preferences.maxExecutionPayment ?? defaultMaxExecutionPayment, + })); + } + + async signRequestAuth( + pubkeyMaybeHex: BLSPubkeyMaybeHex, + builderUrl: string, + proposalSlot: Slot + ): Promise { + const data = Buffer.from(builderUrl, "utf8"); + if (data.length > MAX_DATA_SIZE) { + throw Error(`Builder url exceeds MAX_DATA_SIZE=${MAX_DATA_SIZE} bytes: ${builderUrl}`); + } + + const message: gloas.RequestAuthV1 = {data, slot: proposalSlot}; + + const signingSlot = proposalSlot; + const domain = computeDomain(DOMAIN_REQUEST_AUTH, this.config.GENESIS_FORK_VERSION, ZERO_HASH); + const signingRoot = computeSigningRoot(ssz.gloas.RequestAuthV1, message, domain); + + const signableMessage: SignableMessage = { + type: SignableMessageType.REQUEST_AUTH, + data: message, + }; + + return { + message, + signature: await this.getSignature(pubkeyMaybeHex, signingRoot, signingSlot, signableMessage), + }; + } + + /** + * Return a pre-signed request auth for the builder and proposal slot, or sign and cache a new one. + * Signing happens off the block proposal hot path, cached auths can be used just-in-time when + * requesting bids at proposal time. + */ + async getRequestAuth( + pubkeyMaybeHex: BLSPubkeyMaybeHex, + builderUrl: string, + proposalSlot: Slot, + currentSlot: Slot + ): Promise { + const pubkeyHex = typeof pubkeyMaybeHex === "string" ? pubkeyMaybeHex : toPubkeyHex(pubkeyMaybeHex); + const authKey = `${proposalSlot}-${builderUrl}`; + const validatorData = this.validators.get(pubkeyHex); + const cached = validatorData?.requestAuths?.get(authKey); + if (cached !== undefined) { + return cached; + } + + const signedRequestAuth = await this.signRequestAuth(pubkeyMaybeHex, builderUrl, proposalSlot); + + if (validatorData !== undefined) { + const requestAuths = validatorData.requestAuths ?? new Map(); + // Prune auths for proposal slots that are already in the past + for (const key of requestAuths.keys()) { + if (Number(key.slice(0, key.indexOf("-"))) < currentSlot) { + requestAuths.delete(key); + } + } + requestAuths.set(authKey, signedRequestAuth); + validatorData.requestAuths = requestAuths; + } + + return signedRequestAuth; + } + private async getSignature( pubkey: BLSPubkeyMaybeHex, signingRoot: Uint8Array, diff --git a/packages/validator/src/util/externalSignerClient.ts b/packages/validator/src/util/externalSignerClient.ts index 9903c0bec5b4..fac6cf987cb2 100644 --- a/packages/validator/src/util/externalSignerClient.ts +++ b/packages/validator/src/util/externalSignerClient.ts @@ -36,6 +36,7 @@ export enum SignableMessageType { EXECUTION_PAYLOAD_ENVELOPE = "EXECUTION_PAYLOAD_ENVELOPE", PAYLOAD_ATTESTATION = "PAYLOAD_ATTESTATION", PROPOSER_PREFERENCES = "PROPOSER_PREFERENCES", + REQUEST_AUTH = "REQUEST_AUTH", } const AggregationSlotType = new ContainerType({ @@ -87,7 +88,8 @@ export type SignableMessage = | {type: SignableMessageType.VALIDATOR_REGISTRATION; data: ValidatorRegistrationV1} | {type: SignableMessageType.EXECUTION_PAYLOAD_ENVELOPE; data: gloas.ExecutionPayloadEnvelope} | {type: SignableMessageType.PAYLOAD_ATTESTATION; data: gloas.PayloadAttestationData} - | {type: SignableMessageType.PROPOSER_PREFERENCES; data: gloas.ProposerPreferences}; + | {type: SignableMessageType.PROPOSER_PREFERENCES; data: gloas.ProposerPreferences} + | {type: SignableMessageType.REQUEST_AUTH; data: gloas.RequestAuthV1}; const requiresForkInfo: Record = { [SignableMessageType.AGGREGATION_SLOT]: true, @@ -105,6 +107,8 @@ const requiresForkInfo: Record = { [SignableMessageType.EXECUTION_PAYLOAD_ENVELOPE]: true, [SignableMessageType.PAYLOAD_ATTESTATION]: true, [SignableMessageType.PROPOSER_PREFERENCES]: true, + // Fork-independent domain: compute_domain(DOMAIN_REQUEST_AUTH) with genesis fork version and zero genesis validators root + [SignableMessageType.REQUEST_AUTH]: false, }; type Web3SignerSerializedRequest = { @@ -285,6 +289,9 @@ function serializerSignableMessagePayload(config: BeaconConfig, payload: Signabl case SignableMessageType.PROPOSER_PREFERENCES: return {proposer_preferences: ssz.gloas.ProposerPreferences.toJson(payload.data)}; + + case SignableMessageType.REQUEST_AUTH: + return {request_auth: ssz.gloas.RequestAuthV1.toJson(payload.data)}; } } diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index 57a2c35da44c..a254b5153774 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -10,6 +10,7 @@ import {MetaDataRepository} from "./repositories/metaDataRepository.js"; import {AttestationService} from "./services/attestation.js"; import {BlockProposingService} from "./services/block.js"; import {BlockDutiesService} from "./services/blockDuties.js"; +import {BuilderPreferencesService} from "./services/builderPreferences.js"; import {ChainHeaderTracker} from "./services/chainHeaderTracker.js"; import {DoppelgangerService} from "./services/doppelgangerService.js"; import {ValidatorEventEmitter} from "./services/emitter.js"; @@ -305,6 +306,8 @@ export class Validator { new ProposerPreferencesService(config, loggerVc, api, clock, validatorStore, blockDutiesService, metrics); + new BuilderPreferencesService(config, loggerVc, api, clock, validatorStore, blockDutiesService, metrics); + return new Validator({ opts, genesis, diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index 60823fe9e068..988cea276c09 100644 --- a/packages/validator/test/unit/validatorStore.test.ts +++ b/packages/validator/test/unit/validatorStore.test.ts @@ -4,7 +4,9 @@ import {SecretKey} from "@chainsafe/blst"; import {fromHexString, toHexString} from "@chainsafe/ssz"; import {routes} from "@lodestar/api"; import {chainConfig} from "@lodestar/config/default"; -import {bellatrix} from "@lodestar/types"; +import {DOMAIN_REQUEST_AUTH} from "@lodestar/params"; +import {ZERO_HASH, computeDomain, computeSigningRoot} from "@lodestar/state-transition"; +import {bellatrix, ssz} from "@lodestar/types"; import {ValidatorProposerConfig, ValidatorStore} from "../../src/services/validatorStore.js"; import {getApiClientStub} from "../utils/apiStub.js"; import {initValidatorStore} from "../utils/validatorStore.js"; @@ -26,6 +28,11 @@ describe("ValidatorStore", () => { builder: { gasLimit: 45000000, selection: routes.validator.BuilderSelection.ExecutionOnly, + maxExecutionPayment: BigInt(1), + builders: { + "https://builder.example.com": {maxExecutionPayment: BigInt(100)}, + "https://other-builder.example.com": {}, + }, }, }, }, @@ -35,6 +42,10 @@ describe("ValidatorStore", () => { feeRecipient: "0xcccccccccccccccccccccccccccccccccccccccc", builder: { gasLimit: 35000000, + maxExecutionPayment: BigInt(5), + builders: { + "https://default-builder.example.com": {}, + }, }, }, }; @@ -89,6 +100,50 @@ describe("ValidatorStore", () => { expect(validatorStore.signValidatorRegistration).toHaveBeenCalledOnce(); } }); + + it("Should resolve registered builders with per-builder preferences", () => { + // per-builder value wins, missing value falls back to the per-key maxExecutionPayment + expect(validatorStore.getRegisteredBuilders(toHexString(pubkeys[0]))).toEqual([ + {url: "https://builder.example.com", maxExecutionPayment: BigInt(100)}, + {url: "https://other-builder.example.com", maxExecutionPayment: BigInt(1)}, + ]); + + // default values + expect(validatorStore.getRegisteredBuilders(toHexString(pubkeys[1]))).toEqual([ + {url: "https://default-builder.example.com", maxExecutionPayment: BigInt(5)}, + ]); + }); + + it("Should sign request auth with fork-independent domain", async () => { + const builderUrl = "https://builder.example.com"; + const proposalSlot = 10; + + const signedRequestAuth = await validatorStore.signRequestAuth(pubkeys[0], builderUrl, proposalSlot); + + expect(Buffer.from(signedRequestAuth.message.data).toString("utf8")).toBe(builderUrl); + expect(signedRequestAuth.message.slot).toBe(proposalSlot); + + const domain = computeDomain(DOMAIN_REQUEST_AUTH, chainConfig.GENESIS_FORK_VERSION, ZERO_HASH); + const signingRoot = computeSigningRoot(ssz.gloas.RequestAuthV1, signedRequestAuth.message, domain); + expect(toHexString(signedRequestAuth.signature)).toBe(toHexString(secretKeys[0].sign(signingRoot).toBytes())); + }); + + it("Should cache request auths and prune auths for past proposal slots", async () => { + const builderUrl = "https://builder.example.com"; + vi.spyOn(validatorStore, "signRequestAuth"); + + const auth1 = await validatorStore.getRequestAuth(pubkeys[0], builderUrl, 10, 5); + const auth2 = await validatorStore.getRequestAuth(pubkeys[0], builderUrl, 10, 5); + expect(auth2).toBe(auth1); + expect(validatorStore.signRequestAuth).toHaveBeenCalledOnce(); + + // Signing for a later proposal slot prunes the auth for the now-past slot 10 + await validatorStore.getRequestAuth(pubkeys[0], builderUrl, 20, 15); + expect(validatorStore.signRequestAuth).toHaveBeenCalledTimes(2); + + await validatorStore.getRequestAuth(pubkeys[0], builderUrl, 10, 15); + expect(validatorStore.signRequestAuth).toHaveBeenCalledTimes(3); + }); }); const secretKeys = Array.from({length: 3}, (_, i) => SecretKey.fromBytes(toBufferBE(BigInt(i + 1), 32))); From 394e9769aaa06cd9d8a62eafd5557da09d513374 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 11:54:50 +0100 Subject: [PATCH 2/4] chore: add builder api terms to wordlist --- .wordlist.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.wordlist.txt b/.wordlist.txt index 5700afd898ab..8aaee071bbdf 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -50,6 +50,7 @@ Golang Gossipsub Grafana Grandine +Gwei HTTPS HackMD Hashicorp @@ -169,6 +170,7 @@ flamegraphs floodsub fsSL getNetworkIdentity +gloas gnosis gpg heapdump @@ -242,6 +244,7 @@ tcp testnet testnets todo +trustless typesafe udp unpkg From 978bdfffcacb38a04a31d00ebc119a71b65e7688 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 11:54:51 +0100 Subject: [PATCH 3/4] perf: validate builder api bids in parallel --- .../src/api/impl/validator/index.ts | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 7944fa342feb..5fe1ad2047a4 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1007,29 +1007,35 @@ export function getValidatorApi( proposerPubkey ); - for (const {url, maxExecutionPayment, signedBid} of builderApiBids) { - try { - await validateBuilderApiExecutionPayloadBid(chain, signedBid, { - slot, - state, - parentBlock, - parentBlockHashHex: bidParentBlockHash, - expectedFeeRecipient, - maxExecutionPayment, - }); - } catch (e) { - metrics?.gloasBuilder.bidsDiscarded.inc(); - logger.warn( - "Discarded builder api bid", - {slot, builder: toPrintableUrl(url), builderIndex: signedBid.message.builderIndex}, - e as Error - ); - continue; - } + const validatedBids = await Promise.all( + builderApiBids.map(async (bid) => { + try { + await validateBuilderApiExecutionPayloadBid(chain, bid.signedBid, { + slot, + state, + parentBlock, + parentBlockHashHex: bidParentBlockHash, + expectedFeeRecipient, + maxExecutionPayment: bid.maxExecutionPayment, + }); + return bid; + } catch (e) { + metrics?.gloasBuilder.bidsDiscarded.inc(); + logger.warn( + "Discarded builder api bid", + {slot, builder: toPrintableUrl(bid.url), builderIndex: bid.signedBid.message.builderIndex}, + e as Error + ); + return null; + } + }) + ); - const effectiveValueGwei = effectiveBidValueGwei(signedBid.message, maxExecutionPayment); + for (const bid of validatedBids) { + if (bid === null) continue; + const effectiveValueGwei = effectiveBidValueGwei(bid.signedBid.message, bid.maxExecutionPayment); if (bestBid === null || effectiveValueGwei > bestBid.effectiveValueGwei) { - bestBid = {signedBid, effectiveValueGwei, builderUrl: url}; + bestBid = {signedBid: bid.signedBid, effectiveValueGwei, builderUrl: bid.url}; } } } From 11f590e885d9998d6845cb42cbdba4c8cfc7f350 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 11:54:52 +0100 Subject: [PATCH 4/4] fix: avoid duplicate builder preference submissions on partial signing failure --- packages/validator/src/services/builderPreferences.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/validator/src/services/builderPreferences.ts b/packages/validator/src/services/builderPreferences.ts index 15cecc161d1e..8c0eb156381b 100644 --- a/packages/validator/src/services/builderPreferences.ts +++ b/packages/validator/src/services/builderPreferences.ts @@ -91,13 +91,17 @@ export class BuilderPreferencesService { if (builders.length === 0) continue; try { + // Collect submissions per duty and only add them to the batch if signing + // succeeded for all builders, else the duty is retried on the next tick + const dutySubmissions: routes.validator.BuilderPreferencesSubmissionList = []; for (const {url, maxExecutionPayment} of builders) { const auth = await this.validatorStore.getRequestAuth(duty.pubkey, url, duty.slot, slot); - submissions.push({ + dutySubmissions.push({ validatorPubkey: duty.pubkey, request: {preferences: {maxExecutionPayment}, auth}, }); } + submissions.push(...dutySubmissions); pending.push({submission, slot: duty.slot}); } catch (e) { this.logger.error(