From 3983cd2b0b583559a3d1eb62338382a44a8a6689 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 15 Aug 2026 21:37:49 +0100 Subject: [PATCH 01/67] feat: implement gloas builder api --- .../validator-management/proposer-config.md | 2 + .../validator-management/vc-configuration.md | 9 + .../api/src/beacon/routes/beacon/block.ts | 21 +- packages/api/src/beacon/routes/validator.ts | 208 +++++++++++++-- packages/api/src/builder/routes.ts | 200 ++++++++++++++- packages/api/src/keymanager/index.ts | 2 + packages/api/src/keymanager/routes.ts | 182 ++++++++++++++ packages/api/src/utils/metadata.ts | 5 + .../api/test/unit/beacon/oapiSpec.test.ts | 5 + .../test/unit/beacon/testData/validator.ts | 28 ++- .../api/test/unit/builder/builder.test.ts | 8 +- .../api/test/unit/builder/oapiSpec.test.ts | 9 +- packages/api/test/unit/builder/testData.ts | 24 ++ .../api/test/unit/keymanager/oapiSpec.test.ts | 9 +- packages/api/test/unit/keymanager/testData.ts | 33 +++ .../src/api/impl/beacon/blocks/index.ts | 18 +- .../src/api/impl/validator/index.ts | 210 ++++++++++++++-- packages/beacon-node/src/chain/chain.ts | 5 + packages/beacon-node/src/chain/interface.ts | 2 + .../chain/validation/executionPayloadBid.ts | 136 ++++++++++ .../src/execution/builder/apiClient.ts | 201 +++++++++++++++ .../src/metrics/metrics/lodestar.ts | 29 +++ .../test/mocks/mockedBeaconChain.ts | 9 + .../api/impl/validator/produceBlockV4.test.ts | 181 ++++++++++++- packages/cli/src/cmds/validator/handler.ts | 26 +- .../cli/src/cmds/validator/keymanager/impl.ts | 45 +++- .../validator/keymanager/persistedKeys.ts | 6 +- packages/cli/src/cmds/validator/options.ts | 35 +++ packages/cli/src/util/proposerConfig.ts | 66 ++++- packages/params/src/index.ts | 8 + packages/types/src/gloas/sszTypes.ts | 35 +++ packages/types/src/gloas/types.ts | 4 + packages/validator/src/services/block.ts | 36 ++- .../src/services/builderPreferences.ts | 141 +++++++++++ .../validator/src/services/validatorStore.ts | 238 +++++++++++++++++- .../src/util/externalSignerClient.ts | 9 +- packages/validator/src/validator.ts | 2 + .../test/unit/services/block.test.ts | 6 +- .../test/unit/validatorStore.test.ts | 79 +++++- 39 files changed, 2196 insertions(+), 76 deletions(-) create mode 100644 packages/beacon-node/src/execution/builder/apiClient.ts create mode 100644 packages/validator/src/services/builderPreferences.ts diff --git a/docs/pages/run/validator-management/proposer-config.md b/docs/pages/run/validator-management/proposer-config.md index 40899767daab..24d5ca276cab 100644 --- a/docs/pages/run/validator-management/proposer-config.md +++ b/docs/pages/run/validator-management/proposer-config.md @@ -36,6 +36,8 @@ default_config: boost_factor: "90" ``` +Starting with Gloas, the builder section additionally supports `min_bid` (floor in Gwei on the total payment accepted from a builder bid) and `max_execution_payment` (ceiling in Gwei on the trusted execution layer payment accepted from a builder, values above `0` require `--allowDangerousTrustedPayments`). + ### Enable Proposer Configuration After you have configured your proposer configuration YAML file, you can start Lodestar with an additional CLI flag option pointing to the file: `--proposerSettingsFile /path/to/proposer_config.yaml`. diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 7b03349198d6..60cafd09ed70 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -110,6 +110,15 @@ Example 2: Setting a `--builder.boostFactor=0` will always prefer the local exec Example 3: Setting a `--builder.boostFactor=100` is the same as signaling `--builder.selection maxprofit` where the validator will always select the most profitable block between the local execution engine and the builder block from the relay. +### Configure external builders (Gloas) + +Starting with Gloas, external builders are configured on the validator client and the beacon node requests bids on its behalf. Use [`--builder.urls`](./validator-cli.md#--builderurls) to name the builders to request bids from. Bids received over p2p are always considered alongside them, governed by the same selection settings. + +- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. +- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei accepted from a builder. The default of `0` only accepts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. + +Builders can also be configured per validator key with per-builder overrides via the [Set Builders keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builders) or the [proposer configuration file](./proposer-config.md). + ### Submit a validator deposit Please use the official Ethereum Launchpad to perform your deposits. Ensure your deposits are sent to the proper beacon chain deposit address on the correct network. diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index fee4962ebb2a..97c4bc3405c8 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -163,8 +163,17 @@ export type Endpoints = { { signedBlockContents: SignedBlockContents; broadcastValidation?: BroadcastValidation; + /** + * The url of the winning builder as returned by `produceBlockV4`. The beacon node forwards + * the signed block to this builder so it can release the payload without waiting for gossip. + */ + builderUrl?: string; + }, + { + body: unknown; + headers: {[MetaHeader.Version]: string; [MetaHeader.BuilderUrl]?: string}; + query: {broadcast_validation?: string}; }, - {body: unknown; headers: {[MetaHeader.Version]: string}; query: {broadcast_validation?: string}}, EmptyResponseData, EmptyMeta >; @@ -345,7 +354,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { + writeReqJson: ({signedBlockContents, broadcastValidation, builderUrl}) => { const slot = signedBlockContents.signedBlock.message.slot; const fork = config.getForkName(slot); return { @@ -359,6 +368,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { + writeReqSsz: ({signedBlockContents, broadcastValidation, builderUrl}) => { const slot = signedBlockContents.signedBlock.message.slot; const fork = config.getForkName(slot); @@ -388,6 +399,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions; +export type ProduceBlockV4Meta = ValueOf & { + /** The url of the winning builder when the bid came through the builder API channel */ + builderUrl?: string; +}; export const AttesterDutyType = new ContainerType( { @@ -264,6 +270,55 @@ export const SignedProposerPreferencesListType = ArrayOf( (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH ); +// The url is UTF-8 bytes on the SSZ wire but a plain string in JSON +class BuilderUrlType extends ByteListType { + fromJson(json: unknown): Uint8Array { + if (typeof json !== "string" || json.length === 0) { + throw Error("Builder url must be a non-empty string"); + } + return new TextEncoder().encode(json); + } + toJson(value: Uint8Array): unknown { + return new TextDecoder().decode(value); + } +} + +export const BuilderEntryType = new ContainerType( + { + url: new BuilderUrlType(MAX_BUILDER_URL_SIZE), + auth: ssz.gloas.SignedRequestAuth, + builderPubkeys: ArrayOf(ssz.BLSPubkey, MAX_BUILDER_PUBKEYS), + maxExecutionPayment: ssz.Gwei, + minBid: ssz.Gwei, + builderBoostFactor: ssz.UintBn64, + }, + {typeName: "BuilderEntry", jsonCase: "eth2"} +); + +export const BuilderConfigType = new ContainerType( + { + minBid: ssz.Gwei, + builderBoostFactor: ssz.UintBn64, + builders: ArrayOf(BuilderEntryType, MAX_BUILDER_ENTRIES), + }, + {typeName: "BuilderConfig", jsonCase: "eth2"} +); + +export const BuilderPreferencesEntryType = new ContainerType( + { + proposerPubkey: ssz.BLSPubkey, + url: new BuilderUrlType(MAX_BUILDER_URL_SIZE), + auth: ssz.gloas.SignedRequestAuth, + maxExecutionPayment: ssz.Gwei, + }, + {typeName: "BuilderPreferencesEntry", jsonCase: "eth2"} +); + +export const BuilderPreferencesEntryListType = ArrayOf( + BuilderPreferencesEntryType, + MAX_BUILDER_ENTRIES * (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH +); + export type ValidatorIndices = ValueOf; export type AttesterDuty = ValueOf; export type AttesterDutyList = ValueOf; @@ -291,6 +346,10 @@ export type LivenessResponseData = ValueOf; export type LivenessResponseDataList = ValueOf; export type SignedValidatorRegistrationV1List = ValueOf; export type SignedProposerPreferencesList = ValueOf; +export type BuilderEntry = ValueOf; +export type BuilderConfig = ValueOf; +export type BuilderPreferencesEntry = ValueOf; +export type BuilderPreferencesEntryList = ValueOf; // The beacon node does not return any data if there is no canonical block at the requested slot (missed slot). // In this case, we receive a success response (204) which is not handled as an error. The generic response @@ -433,6 +492,9 @@ export type Endpoints = { * so there is no longer a concept of blinded or unblinded blocks. Builders release the payload later. * This endpoint is specific to the post-Gloas forks and is not backwards compatible with previous forks. * + * The required `BuilderConfig` request body carries the builder entries to solicit builder API + * bids from, plus the top-level `minBid` and `builderBoostFactor` that apply to p2p bids. + * * When self-building and `includePayload` is true, the response contains the full `BlockContents` * (block, execution payload envelope, KZG proofs and blobs) which enables stateless envelope * publishing via any beacon node. When `includePayload` is false, only the `BeaconBlock` is @@ -440,7 +502,7 @@ export type Endpoints = { * When committing to a builder bid, only the `BeaconBlock` is returned in either case. */ produceBlockV4: Endpoint< - "GET", + "POST", { /** The slot for which the block should be proposed */ slot: Slot; @@ -449,9 +511,9 @@ export type Endpoints = { /** Arbitrary data validator wants to include in block */ graffiti?: string; skipRandaoVerification?: boolean; - builderBoostFactor?: UintBn64; /** Include execution payload envelope and blobs in the response when self-building */ includePayload: boolean; + builderConfig: BuilderConfig; } & ExtraProduceBlockV4Opts, { params: {slot: number}; @@ -460,10 +522,11 @@ export type Endpoints = { graffiti?: string; skip_randao_verification?: string; fee_recipient?: string; - builder_boost_factor?: string; strict_fee_recipient_check?: boolean; include_payload: boolean; }; + body: unknown; + headers: {[MetaHeader.Version]: string}; }, BeaconBlock | BlockContents, ProduceBlockV4Meta @@ -693,6 +756,19 @@ export type Endpoints = { EmptyResponseData, EmptyMeta >; + + /** + * Submit per-builder preferences for one or more proposers ahead of their proposal slot. + * The beacon node submits each entry to the builder API endpoint at the entry's url so + * builders hold the preferences before the bid request arrives. + */ + submitBuilderPreferences: Endpoint< + "POST", + {builderPreferences: BuilderPreferencesEntryList}, + {body: unknown; headers: {[MetaHeader.Version]: string}}, + EmptyResponseData, + EmptyMeta + >; }; export function getDefinitions(config: ChainForkConfig): RouteDefinitions { @@ -915,17 +991,17 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ params: {slot}, query: { @@ -933,21 +1009,60 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ - slot: params.slot, - randaoReveal: fromHex(query.randao_reveal), - graffiti: fromGraffitiHex(query.graffiti), - skipRandaoVerification: parseSkipRandaoVerification(query.skip_randao_verification), - feeRecipient: query.fee_recipient, - builderBoostFactor: parseBuilderBoostFactor(query.builder_boost_factor), - strictFeeRecipientCheck: query.strict_fee_recipient_check, - includePayload: query.include_payload, + parseReqJson: ({params, query, body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + slot: params.slot, + randaoReveal: fromHex(query.randao_reveal), + graffiti: fromGraffitiHex(query.graffiti), + skipRandaoVerification: parseSkipRandaoVerification(query.skip_randao_verification), + feeRecipient: query.fee_recipient, + strictFeeRecipientCheck: query.strict_fee_recipient_check, + includePayload: query.include_payload, + builderConfig: assertValidBuilderConfig(BuilderConfigType.fromJson(body)), + }; + }, + writeReqSsz: ({ + slot, + randaoReveal, + graffiti, + skipRandaoVerification, + feeRecipient, + strictFeeRecipientCheck, + includePayload, + builderConfig, + }) => ({ + params: {slot}, + query: { + randao_reveal: toHex(randaoReveal), + graffiti: toGraffitiHex(graffiti), + skip_randao_verification: writeSkipRandaoVerification(skipRandaoVerification), + fee_recipient: feeRecipient, + strict_fee_recipient_check: strictFeeRecipientCheck, + include_payload: includePayload, + }, + body: BuilderConfigType.serialize(builderConfig), + headers: {[MetaHeader.Version]: config.getForkName(slot)}, }), + parseReqSsz: ({params, query, body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + slot: params.slot, + randaoReveal: fromHex(query.randao_reveal), + graffiti: fromGraffitiHex(query.graffiti), + skipRandaoVerification: parseSkipRandaoVerification(query.skip_randao_verification), + feeRecipient: query.fee_recipient, + strictFeeRecipientCheck: query.strict_fee_recipient_check, + includePayload: query.include_payload, + builderConfig: assertValidBuilderConfig(BuilderConfigType.deserialize(body)), + }; + }, schema: { params: {slot: Schema.UintRequired}, query: { @@ -955,12 +1070,16 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions @@ -971,19 +1090,27 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ), meta: { - toJson: (meta) => ProduceBlockV4MetaType.toJson(meta), - fromJson: (val) => ProduceBlockV4MetaType.fromJson(val), + toJson: ({builderUrl, ...meta}) => ({ + ...(ProduceBlockV4MetaType.toJson(meta) as Record), + ...(builderUrl !== undefined ? {builder_url: builderUrl} : {}), + }), + fromJson: (val) => ({ + ...ProduceBlockV4MetaType.fromJson(val), + builderUrl: (val as {builder_url?: string}).builder_url, + }), toHeadersObject: (meta) => ({ [MetaHeader.Version]: meta.version, [MetaHeader.ConsensusBlockValue]: meta.consensusBlockValue.toString(), [MetaHeader.ExecutionPayloadValue]: meta.executionPayloadValue.toString(), [MetaHeader.ExecutionPayloadIncluded]: meta.executionPayloadIncluded.toString(), + ...(meta.builderUrl !== undefined ? {[MetaHeader.BuilderUrl]: meta.builderUrl} : {}), }), fromHeaders: (headers) => ({ version: toForkName(headers.getRequired(MetaHeader.Version)), consensusBlockValue: BigInt(headers.getRequired(MetaHeader.ConsensusBlockValue)), executionPayloadValue: BigInt(headers.getRequired(MetaHeader.ExecutionPayloadValue)), executionPayloadIncluded: toBoolean(headers.getRequired(MetaHeader.ExecutionPayloadIncluded)), + builderUrl: headers.get(MetaHeader.BuilderUrl) ?? undefined, }), }, }, @@ -1295,6 +1422,36 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ + body: BuilderPreferencesEntryListType.toJson(builderPreferences), + headers: {[MetaHeader.Version]: config.getForkName(builderPreferences[0]?.auth.message.slot ?? 0)}, + }), + parseReqJson: ({body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return {builderPreferences: BuilderPreferencesEntryListType.fromJson(body)}; + }, + writeReqSsz: ({builderPreferences}) => ({ + body: BuilderPreferencesEntryListType.serialize(builderPreferences), + headers: {[MetaHeader.Version]: config.getForkName(builderPreferences[0]?.auth.message.slot ?? 0)}, + }), + parseReqSsz: ({body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return {builderPreferences: BuilderPreferencesEntryListType.deserialize(body)}; + }, + schema: { + body: Schema.ObjectArray, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, + }, + }, }; } @@ -1302,6 +1459,15 @@ function parseBuilderBoostFactor(builderBoostFactorInput?: string | number | big return builderBoostFactorInput !== undefined ? BigInt(builderBoostFactorInput) : undefined; } +function assertValidBuilderConfig(builderConfig: BuilderConfig): BuilderConfig { + for (const entry of builderConfig.builders) { + if (entry.url.length === 0) { + throw Error("Builder entry url must not be empty"); + } + } + return builderConfig; +} + function writeSkipRandaoVerification(skipRandaoVerification?: boolean): string | undefined { return skipRandaoVerification === true ? "" : undefined; } diff --git a/packages/api/src/builder/routes.ts b/packages/api/src/builder/routes.ts index edf7df6be4d7..1e4746e115fe 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,45 @@ export type Endpoints = { EmptyResponseData, EmptyMeta >; + + getExecutionPayloadBid: Endpoint< + "POST", + { + slot: Slot; + parentHash: Root; + parentRoot: Root; + proposerPubkey: BLSPubkey; + /** Authenticates the requesting proposer to the builder */ + requestAuth: gloas.SignedRequestAuth; + /** Unix timestamp in milliseconds at which the request was sent */ + dateMilliseconds: number; + /** The proposer's timeout for the request in milliseconds, measured from `dateMilliseconds` */ + timeoutMs: number; + }, + { + params: {slot: Slot; parent_hash: string; parent_root: string; proposer_pubkey: string}; + body: unknown; + headers: {[MetaHeader.Version]: string; [MetaHeader.DateMilliseconds]: string; [MetaHeader.TimeoutMs]: string}; + }, + MaybeSignedExecutionPayloadBid, + VersionMeta + >; + + submitSignedBeaconBlock: Endpoint< + "POST", + {signedBlock: WithOptionalBytes>}, + {body: unknown; headers: {[MetaHeader.Version]: string}}, + EmptyResponseData, + EmptyMeta + >; + + submitBuilderPreferences: Endpoint< + "POST", + {proposerPubkey: BLSPubkey; request: gloas.BuilderPreferencesRequest}, + {params: {proposer_pubkey: string}; body: unknown; headers: {[MetaHeader.Version]: string}}, + EmptyResponseData, + EmptyMeta + >; }; export function getDefinitions(config: ChainForkConfig): RouteDefinitions { @@ -223,5 +267,157 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ + params: { + slot, + parent_hash: toRootHex(parentHash), + parent_root: toRootHex(parentRoot), + proposer_pubkey: toPubkeyHex(proposerPubkey), + }, + body: ssz.gloas.SignedRequestAuth.toJson(requestAuth), + headers: { + [MetaHeader.Version]: config.getForkName(slot), + [MetaHeader.DateMilliseconds]: dateMilliseconds.toString(), + [MetaHeader.TimeoutMs]: timeoutMs.toString(), + }, + }), + parseReqJson: ({params, body, headers}) => ({ + slot: params.slot, + parentHash: fromHex(params.parent_hash), + parentRoot: fromHex(params.parent_root), + proposerPubkey: fromHex(params.proposer_pubkey), + requestAuth: ssz.gloas.SignedRequestAuth.fromJson(body), + dateMilliseconds: Number(fromHeaders(headers, MetaHeader.DateMilliseconds)), + timeoutMs: Number(fromHeaders(headers, MetaHeader.TimeoutMs)), + }), + writeReqSsz: ({slot, parentHash, parentRoot, proposerPubkey, requestAuth, dateMilliseconds, timeoutMs}) => ({ + params: { + slot, + parent_hash: toRootHex(parentHash), + parent_root: toRootHex(parentRoot), + proposer_pubkey: toPubkeyHex(proposerPubkey), + }, + body: ssz.gloas.SignedRequestAuth.serialize(requestAuth), + headers: { + [MetaHeader.Version]: config.getForkName(slot), + [MetaHeader.DateMilliseconds]: dateMilliseconds.toString(), + [MetaHeader.TimeoutMs]: timeoutMs.toString(), + }, + }), + parseReqSsz: ({params, body, headers}) => ({ + slot: params.slot, + parentHash: fromHex(params.parent_hash), + parentRoot: fromHex(params.parent_root), + proposerPubkey: fromHex(params.proposer_pubkey), + requestAuth: ssz.gloas.SignedRequestAuth.deserialize(body), + dateMilliseconds: Number(fromHeaders(headers, MetaHeader.DateMilliseconds)), + timeoutMs: Number(fromHeaders(headers, MetaHeader.TimeoutMs)), + }), + 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, + [MetaHeader.DateMilliseconds]: Schema.String, + [MetaHeader.TimeoutMs]: 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/{proposer_pubkey}", + method: "POST", + req: { + writeReqJson: ({proposerPubkey, request}) => ({ + params: {proposer_pubkey: toPubkeyHex(proposerPubkey)}, + body: ssz.gloas.BuilderPreferencesRequest.toJson(request), + headers: {[MetaHeader.Version]: config.getForkName(request.auth.message.slot)}, + }), + parseReqJson: ({params, body}) => ({ + proposerPubkey: fromHex(params.proposer_pubkey), + request: ssz.gloas.BuilderPreferencesRequest.fromJson(body), + }), + writeReqSsz: ({proposerPubkey, request}) => ({ + params: {proposer_pubkey: toPubkeyHex(proposerPubkey)}, + body: ssz.gloas.BuilderPreferencesRequest.serialize(request), + headers: {[MetaHeader.Version]: config.getForkName(request.auth.message.slot)}, + }), + parseReqSsz: ({params, body}) => ({ + proposerPubkey: fromHex(params.proposer_pubkey), + request: ssz.gloas.BuilderPreferencesRequest.deserialize(body), + }), + schema: { + params: {proposer_pubkey: Schema.StringRequired}, + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, + }, + }, }; } diff --git a/packages/api/src/keymanager/index.ts b/packages/api/src/keymanager/index.ts index 33b62e57c51f..3e58a497c3ac 100644 --- a/packages/api/src/keymanager/index.ts +++ b/packages/api/src/keymanager/index.ts @@ -7,6 +7,8 @@ import * as keymanager from "./client.js"; export type { BuilderBoostFactorData, + BuilderConfigData, + BuilderEntryConfig, Endpoints, FeeRecipientData, GasLimitData, diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 05eddeefd604..0d73a88cfa79 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -96,6 +96,118 @@ export type GraffitiData = ValueOf; export type GasLimitData = ValueOf; export type BuilderBoostFactorData = ValueOf; +/** One builder a validator public key may source blocks from */ +export type BuilderEntryConfig = { + url: string; + /** Opaque auth data hex string, defaults to the UTF-8 bytes of the builder url when omitted */ + authData?: string; + /** Builder BLS pubkeys this entry accepts bids from, empty or omitted accepts any builder */ + builderPubkeys?: string[]; + maxExecutionPayment?: bigint; + minBid?: bigint; + builderBoostFactor?: bigint; +}; + +/** How a validator public key sources blocks from builders */ +export type BuilderConfigData = { + minBid?: bigint; + builderBoostFactor?: bigint; + /** Omitted means use the validator client's builders, empty means request bids from none */ + builders?: BuilderEntryConfig[]; +}; + +const AUTH_DATA_PATTERN = /^0x(?:[a-fA-F0-9]{2}){1,4096}$/; +const PUBKEY_PATTERN = /^0x[a-fA-F0-9]{96}$/; + +function parseGweiAmount(value: unknown, field: string): bigint | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || !/^\d+$/.test(value)) { + throw Error(`${field} must be a string number without decimals`); + } + return BigInt(value); +} + +export function builderConfigDataToJson(config: BuilderConfigData): Record { + return { + ...(config.minBid !== undefined ? {min_bid: config.minBid.toString()} : {}), + ...(config.builderBoostFactor !== undefined ? {builder_boost_factor: config.builderBoostFactor.toString()} : {}), + ...(config.builders !== undefined + ? { + builders: config.builders.map((entry) => ({ + url: entry.url, + ...(entry.authData !== undefined ? {auth_data: entry.authData} : {}), + ...(entry.builderPubkeys !== undefined ? {builder_pubkeys: entry.builderPubkeys} : {}), + ...(entry.maxExecutionPayment !== undefined + ? {max_execution_payment: entry.maxExecutionPayment.toString()} + : {}), + ...(entry.minBid !== undefined ? {min_bid: entry.minBid.toString()} : {}), + ...(entry.builderBoostFactor !== undefined + ? {builder_boost_factor: entry.builderBoostFactor.toString()} + : {}), + })), + } + : {}), + }; +} + +export function builderConfigDataFromJson(json: unknown): BuilderConfigData { + if (typeof json !== "object" || json === null || Array.isArray(json)) { + throw Error("Builder config must be an object"); + } + const {min_bid, builder_boost_factor, builders} = json as Record; + + let parsedBuilders: BuilderEntryConfig[] | undefined; + if (builders !== undefined) { + if (!Array.isArray(builders)) { + throw Error("builders must be an array"); + } + if (builders.length > 64) { + throw Error(`builders must not contain more than 64 entries, got ${builders.length}`); + } + parsedBuilders = builders.map((entry, i): BuilderEntryConfig => { + if (typeof entry !== "object" || entry === null) { + throw Error(`builders[${i}] must be an object`); + } + const {url, auth_data, builder_pubkeys, max_execution_payment} = entry as Record; + if (typeof url !== "string" || url.length === 0) { + throw Error(`builders[${i}].url must be a non-empty string`); + } + if (auth_data !== undefined && (typeof auth_data !== "string" || !AUTH_DATA_PATTERN.test(auth_data))) { + throw Error(`builders[${i}].auth_data must be a non-empty hex string of at most 4096 bytes`); + } + let builderPubkeys: string[] | undefined; + if (builder_pubkeys !== undefined) { + if (!Array.isArray(builder_pubkeys) || builder_pubkeys.length > 64) { + throw Error(`builders[${i}].builder_pubkeys must be an array of at most 64 pubkeys`); + } + builderPubkeys = builder_pubkeys.map((pubkey) => { + if (typeof pubkey !== "string" || !PUBKEY_PATTERN.test(pubkey)) { + throw Error(`builders[${i}].builder_pubkeys must contain 48-byte hex pubkeys`); + } + return pubkey; + }); + } + return { + url, + authData: auth_data as string | undefined, + builderPubkeys, + maxExecutionPayment: parseGweiAmount(max_execution_payment, `builders[${i}].max_execution_payment`), + minBid: parseGweiAmount((entry as Record).min_bid, `builders[${i}].min_bid`), + builderBoostFactor: parseGweiAmount( + (entry as Record).builder_boost_factor, + `builders[${i}].builder_boost_factor` + ), + }; + }); + } + + return { + minBid: parseGweiAmount(min_bid, "min_bid"), + builderBoostFactor: parseGweiAmount(builder_boost_factor, "builder_boost_factor"), + builders: parsedBuilders, + }; +} + export type SignerDefinition = { pubkey: PubkeyHex; /** @@ -367,6 +479,26 @@ export type Endpoints = { EmptyMeta >; + /** Get the builder configuration in effect for a validator public key, with omitted values resolved */ + getBuilders: Endpoint< + // ⏎ + "GET", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + BuilderConfigData, + EmptyMeta + >; + /** Set the builder configuration for a validator public key, replacing any stored configuration in full */ + setBuilders: Endpoint< + "POST", + {pubkey: PubkeyHex; builderConfig: BuilderConfigData}, + {params: {pubkey: string}; body: unknown}, + EmptyResponseData, + EmptyMeta + >; + /** Remove the builder configuration for a validator public key, it then follows the validator client again */ + deleteBuilders: Endpoint<"DELETE", {pubkey: PubkeyHex}, {params: {pubkey: string}}, EmptyResponseData, EmptyMeta>; + getProposerConfig: Endpoint< // ⏎ "GET", @@ -654,6 +786,56 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, + }, + resp: { + onlySupport: WireFormat.json, + data: { + toJson: (data) => builderConfigDataToJson(data), + fromJson: (data) => builderConfigDataFromJson(data), + serialize: () => { + throw Error("SSZ not supported"); + }, + deserialize: () => { + throw Error("SSZ not supported"); + }, + }, + meta: EmptyMetaCodec, + }, + }, + setBuilders: { + url: "/eth/v1/validator/{pubkey}/builders", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({pubkey, builderConfig}) => ({params: {pubkey}, body: builderConfigDataToJson(builderConfig)}), + parseReqJson: ({params: {pubkey}, body}) => ({pubkey, builderConfig: builderConfigDataFromJson(body)}), + schema: { + params: {pubkey: Schema.StringRequired}, + body: Schema.Object, + }, + }), + resp: EmptyResponseCodec, + }, + deleteBuilders: { + url: "/eth/v1/validator/{pubkey}/builders", + method: "DELETE", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, + }, + resp: EmptyResponseCodec, + }, getProposerConfig: { url: "/eth/v0/validator/{pubkey}/proposer_config", diff --git a/packages/api/src/utils/metadata.ts b/packages/api/src/utils/metadata.ts index ce89e8484fab..dad4e5473f0d 100644 --- a/packages/api/src/utils/metadata.ts +++ b/packages/api/src/utils/metadata.ts @@ -80,6 +80,11 @@ export enum MetaHeader { ExecutionPayloadIncluded = "Eth-Execution-Payload-Included", ExecutionPayloadValue = "Eth-Execution-Payload-Value", + /* Builder API headers */ + BuilderUrl = "Eth-Builder-Url", + DateMilliseconds = "Date-Milliseconds", + TimeoutMs = "X-Timeout-Ms", + /* Lodestar-specific (non-standardized) headers */ Finalized = "Eth-Consensus-Finalized", DependentRoot = "Eth-Consensus-Dependent-Root", diff --git a/packages/api/test/unit/beacon/oapiSpec.test.ts b/packages/api/test/unit/beacon/oapiSpec.test.ts index 8014bdde4cf0..885b3e25f14d 100644 --- a/packages/api/test/unit/beacon/oapiSpec.test.ts +++ b/packages/api/test/unit/beacon/oapiSpec.test.ts @@ -58,6 +58,11 @@ const ignoredOperations = [ // (slot path param -> query param). Pinned v5.0.0-alpha.2 still defines slot as a // path param, so ignore this op to avoid a false conformance mismatch until the bump. "producePayloadAttestationData", + // TODO: remove once the pinned beacon-APIs spec version includes beacon-APIs#630 + // (GET -> POST with a required BuilderConfig request body) + "produceBlockV4", + // TODO: remove once the pinned beacon-APIs spec version includes beacon-APIs#630 + "submitBuilderPreferences", ]; const ignoredProperties: Record = { diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index bb70686fa7cf..22b7ee4ef009 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -85,10 +85,23 @@ export const testData: GenericServerTestCases = { randaoReveal, graffiti, skipRandaoVerification: true, - builderBoostFactor: 0n, feeRecipient, strictFeeRecipientCheck: true, includePayload: true, + builderConfig: { + minBid: 0n, + builderBoostFactor: 100n, + builders: [ + { + url: new TextEncoder().encode("https://builder.example.com"), + auth: ssz.gloas.SignedRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, + }, + ], + }, }, res: { data: ssz.gloas.BlockContents.defaultValue(), @@ -163,4 +176,17 @@ export const testData: GenericServerTestCases = { args: {signedProposerPreferences: [ssz.gloas.SignedProposerPreferences.defaultValue()]}, res: undefined, }, + submitBuilderPreferences: { + args: { + builderPreferences: [ + { + proposerPubkey: new Uint8Array(48).fill(1), + url: new TextEncoder().encode("https://builder.example.com"), + auth: ssz.gloas.SignedRequestAuth.defaultValue(), + maxExecutionPayment: 0n, + }, + ], + }, + res: undefined, + }, }; diff --git a/packages/api/test/unit/builder/builder.test.ts b/packages/api/test/unit/builder/builder.test.ts index 53818590a7d7..de9e266c0f1f 100644 --- a/packages/api/test/unit/builder/builder.test.ts +++ b/packages/api/test/unit/builder/builder.test.ts @@ -8,7 +8,13 @@ import {testData} from "./testData.js"; describe("builder", () => { runGenericServerTest( - createChainForkConfig({...defaultChainConfig, ELECTRA_FORK_EPOCH: 0}), + // Gloas at a later epoch so pre-gloas test data (slot 0) and gloas test data (slot 32000) both resolve + createChainForkConfig({ + ...defaultChainConfig, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 1000, + GLOAS_FORK_EPOCH: 1000, + }), getClient, getRoutes, testData diff --git a/packages/api/test/unit/builder/oapiSpec.test.ts b/packages/api/test/unit/builder/oapiSpec.test.ts index 36875cbb93f5..a097476e8eea 100644 --- a/packages/api/test/unit/builder/oapiSpec.test.ts +++ b/packages/api/test/unit/builder/oapiSpec.test.ts @@ -22,5 +22,12 @@ const openApiFile: OpenApiFile = { const definitions = getDefinitions(createChainForkConfig({...defaultChainConfig, ELECTRA_FORK_EPOCH: 0})); +const ignoredOperations = [ + // TODO: remove once a builder-specs release includes the gloas endpoints (builder-specs#165) + "getExecutionPayloadBid", + "submitSignedBeaconBlock", + "submitBuilderPreferences", +]; + const openApiJson = await fetchOpenApiSpec(openApiFile); -runTestCheckAgainstSpec(openApiJson, definitions, testData); +runTestCheckAgainstSpec(openApiJson, definitions, testData, ignoredOperations); diff --git a/packages/api/test/unit/builder/testData.ts b/packages/api/test/unit/builder/testData.ts index 149e164187f5..bbad22288cd9 100644 --- a/packages/api/test/unit/builder/testData.ts +++ b/packages/api/test/unit/builder/testData.ts @@ -8,6 +8,10 @@ import {GenericServerTestCases} from "../../utils/genericServerTest.js"; const pubkeyRand = "0x84105a985058fc8740a48bf1ede9d223ef09e8c6b1735ba0a55cf4a9ff2ff92376b778798365e488dab07a652eb04576"; const root = new Uint8Array(32).fill(1); +// Slot within the gloas fork per the fork schedule configured in builder.test.ts +const signedBeaconBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); +signedBeaconBlock.message.slot = 32000; + export const testData: GenericServerTestCases = { status: { args: undefined, @@ -29,4 +33,24 @@ export const testData: GenericServerTestCases = { args: {signedBlindedBlock: {data: ssz.fulu.SignedBlindedBeaconBlock.defaultValue()}}, res: undefined, }, + getExecutionPayloadBid: { + args: { + slot: 1, + parentHash: root, + parentRoot: root, + proposerPubkey: fromHexString(pubkeyRand), + requestAuth: ssz.gloas.SignedRequestAuth.defaultValue(), + dateMilliseconds: 1710338135000, + timeoutMs: 1000, + }, + res: {data: ssz.gloas.SignedExecutionPayloadBid.defaultValue(), meta: {version: ForkName.gloas}}, + }, + submitSignedBeaconBlock: { + args: {signedBlock: {data: signedBeaconBlock}}, + res: undefined, + }, + submitBuilderPreferences: { + args: {proposerPubkey: fromHexString(pubkeyRand), request: ssz.gloas.BuilderPreferencesRequest.defaultValue()}, + res: undefined, + }, }; diff --git a/packages/api/test/unit/keymanager/oapiSpec.test.ts b/packages/api/test/unit/keymanager/oapiSpec.test.ts index d3817598029b..1f5e3ef24a18 100644 --- a/packages/api/test/unit/keymanager/oapiSpec.test.ts +++ b/packages/api/test/unit/keymanager/oapiSpec.test.ts @@ -18,5 +18,12 @@ const openApiFile: OpenApiFile = { version: RegExp(version), }; +const ignoredOperations = [ + // TODO: remove once a keymanager-APIs release includes the builders endpoints (keymanager-APIs#88) + "getBuilders", + "setBuilders", + "deleteBuilders", +]; + const openApiJson = await fetchOpenApiSpec(openApiFile); -runTestCheckAgainstSpec(openApiJson, getDefinitions(config), testData); +runTestCheckAgainstSpec(openApiJson, getDefinitions(config), testData, ignoredOperations); diff --git a/packages/api/test/unit/keymanager/testData.ts b/packages/api/test/unit/keymanager/testData.ts index c2e0b2017ae4..45e6600533e1 100644 --- a/packages/api/test/unit/keymanager/testData.ts +++ b/packages/api/test/unit/keymanager/testData.ts @@ -112,6 +112,39 @@ export const testData: GenericServerTestCases = { args: {pubkey: pubkeyRand}, res: undefined, }, + getBuilders: { + args: {pubkey: pubkeyRand}, + res: { + data: { + minBid: 0n, + builderBoostFactor: 100n, + builders: [ + { + url: "https://builder.example.com", + authData: "0x68747470733a2f2f6275696c6465722e6578616d706c652e636f6d", + builderPubkeys: [pubkeyRand], + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, + }, + ], + }, + }, + }, + setBuilders: { + args: { + pubkey: pubkeyRand, + builderConfig: { + minBid: 0n, + builders: [{url: "https://builder.example.com", maxExecutionPayment: 0n}], + }, + }, + res: undefined, + }, + deleteBuilders: { + args: {pubkey: pubkeyRand}, + res: undefined, + }, getProposerConfig: { args: {pubkey: pubkeyRand}, res: { 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 6b0fb515c9e0..f5e1de70e2d1 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -104,7 +104,7 @@ export function getBeaconBlockApi({ "chain" | "config" | "metrics" | "network" | "db" >): ApplicationMethods { const publishBlockV2: ApplicationMethods["publishBlockV2"] = async ( - {signedBlockContents, broadcastValidation}, + {signedBlockContents, broadcastValidation, builderUrl}, _context, opts: PublishBlockOpts = {} ) => { @@ -372,6 +372,22 @@ export function getBeaconBlockApi({ // import latency and hopefully bandwidth // () => network.publishBeaconBlock(signedBlock), + // Forward the signed block to the winning builder so it can release the payload without + // waiting for block gossip. Failures are non-fatal, the builder also sees the block on gossip. + async () => { + if (!isForkPostGloas(fork)) return; + const gloasBlock = signedBlock as SignedBeaconBlock; + const bid = gloasBlock.message.body.signedExecutionPayloadBid.message; + if (bid.builderIndex === BUILDER_INDEX_SELF_BUILD) return; + // Use the echoed builder url, or the locally recorded bid source if this node ran the auction + const bidSource = chain.builderApiClient.getBidSource(slot); + const forwardUrl = + builderUrl ?? (bidSource?.bidBlockHash === toRootHex(bid.blockHash) ? bidSource.url : undefined); + if (forwardUrl === undefined) return; + await chain.builderApiClient.submitSignedBeaconBlock(forwardUrl, {data: gloasBlock}).catch((e) => { + chain.logger.warn("Failed to submit signed block to builder", {...valLogMeta, builderUrl: forwardUrl}, e); + }); + }, ...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 24de92273c51..418db9ab08d2 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -58,6 +58,7 @@ import { prettyWeiToEth, resolveOrRacePromises, toHex, + toPrintableUrl, toRootHex, } from "@lodestar/utils"; import {MAX_BUILDER_BOOST_FACTOR} from "@lodestar/validator"; @@ -76,10 +77,12 @@ 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 {validateGossipProposerPreferences} from "../../../chain/validation/proposerPreferences.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; +import {BUILDER_BID_REQUEST_TIMEOUT_MS, BuilderApiBid} from "../../../execution/builder/apiClient.js"; import {BuilderStatus, NoBidReceived} from "../../../execution/builder/http.js"; import {validateGossipFnRetryUnknownRoot} from "../../../network/processor/gossipHandlers.js"; import {CommitteeSubscription} from "../../../network/subnets/index.js"; @@ -112,6 +115,8 @@ import { const BLOCK_PRODUCTION_RACE_CUTOFF_MS = 2_000; /** Overall timeout for execution and block production apis */ const BLOCK_PRODUCTION_RACE_TIMEOUT_MS = 12_000; +/** Rejection message of the bid block branch when there is no viable bid to commit to */ +const NO_BID_AVAILABLE = "No builder bid available"; type ProduceBlockContentsRes = {executionPayloadValue: Wei; consensusBlockValue: Wei} & { data: BlockContents; @@ -857,7 +862,7 @@ export function getValidatorApi( feeRecipient, strictFeeRecipientCheck, includePayload, - builderBoostFactor, + builderConfig, }) { const fork = config.getForkName(slot); @@ -865,7 +870,7 @@ export function getValidatorApi( throw new ApiError(400, `produceBlockV4 not supported for pre-gloas fork=${fork}`); } - builderBoostFactor = builderBoostFactor ?? BigInt(100); + const builderBoostFactor = builderConfig.builderBoostFactor; if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); } @@ -892,15 +897,50 @@ export function getValidatorApi( }) ); - // TODO GLOAS: add external builder api support when it is implemented const isBuildingOnFull = chain.forkChoice.shouldBuildOnFull(parentBlock, slot); const bidParentBlockHash = isBuildingOnFull ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash; + // Post-gloas every proposal parent has an execution payload hash + if (bidParentBlockHash === null) { + throw new ApiError(500, `Unknown parent block hash for proposal parent ${parentBlockRootHex}`); + } const circuitBreakerActive = chain.builderCircuitBreaker.isActive(slot, parentBlock); - // Keep a builder bid as fallback unless the circuit breaker is active - const builderBid = circuitBreakerActive + + // Fire builder API bid requests while the local payload is built, one request per entry. + // Any entry failure yields no bid and never fails block production. + let builderApiBidsPromise: Promise = Promise.resolve([]); + if (builderConfig.builders.length > 0 && !circuitBreakerActive) { + try { + const proposerIndex = chain.getHeadState().getBeaconProposer(slot); + const proposerPubkey = chain.pubkeyCache.getOrThrow(proposerIndex).toBytes(); + builderApiBidsPromise = chain.builderApiClient.getExecutionPayloadBids( + builderConfig.builders, + slot, + fromHex(bidParentBlockHash), + parentBlockRoot, + proposerPubkey, + BUILDER_BID_REQUEST_TIMEOUT_MS + ); + } catch (e) { + logger.warn("Unable to request builder API bids", {slot}, e as Error); + } + } + + // Keep a p2p builder bid as fallback unless the circuit breaker is active + let p2pBid = circuitBreakerActive ? null : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + // Reject a p2p bid below the proposer's configured floor on the total payment. + // A p2p bid's total is just its value since gossip validation enforces executionPayment=0. + if (p2pBid !== null && BigInt(p2pBid.message.value) < builderConfig.minBid) { + logger.debug("Ignoring p2p bid below min bid", { + slot, + bidValue: p2pBid.message.value, + minBid: builderConfig.minBid, + }); + p2pBid = null; + } + const logCtx = { slot, parentSlot, @@ -910,15 +950,83 @@ export function getValidatorApi( builderBoostFactor, strictFeeRecipientCheck, circuitBreakerActive, - ...(builderBid !== null + builderEntries: builderConfig.builders.length, + ...(p2pBid !== null ? { - bidValue: builderBid.message.value, - builderIndex: builderBid.message.builderIndex, - bidBlockHash: toRootHex(builderBid.message.blockHash), + bidValue: p2pBid.message.value, + builderIndex: p2pBid.message.builderIndex, + bidBlockHash: toRootHex(p2pBid.message.blockHash), } : {}), }; + // Candidates are ranked by their boosted total payment, the p2p bid is governed by the + // top-level factors and each builder API bid by its own entry. Ties keep the earlier + // candidate with builder API bids ranked first, so when the same bid arrives over both + // channels the builder API copy wins and the signed block can be routed back directly. + type BidCandidate = { + signedBid: gloas.SignedExecutionPayloadBid; + totalGwei: bigint; + boostFactor: bigint; + url?: string; + }; + const bestCandidatePromise: Promise = (async () => { + const candidates: BidCandidate[] = []; + + const builderApiBids = await builderApiBidsPromise; + await Promise.all( + builderApiBids.map(async ({url, entry, signedBid}) => { + try { + await validateBuilderApiExecutionPayloadBid(chain, signedBid, { + slot, + parentBlock, + parentBlockHash: bidParentBlockHash, + parentBlockRoot: parentBlockRootHex, + entry, + }); + candidates.push({ + signedBid, + totalGwei: BigInt(signedBid.message.value) + signedBid.message.executionPayment, + boostFactor: entry.builderBoostFactor, + url, + }); + } catch (e) { + metrics?.builderApi.bidsDiscarded.inc(); + logger.warn("Ignoring invalid builder API bid", {slot, builder: toPrintableUrl(url)}, e as Error); + } + }) + ); + + if (p2pBid !== null) { + candidates.push({ + signedBid: p2pBid, + totalGwei: BigInt(p2pBid.message.value), + boostFactor: builderConfig.builderBoostFactor, + }); + } + + const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => + (boostFactor * totalGwei) / BigInt(100); + let best: BidCandidate | null = null; + for (const candidate of candidates) { + if (best === null || boostedValue(candidate) > boostedValue(best)) { + best = candidate; + } + } + if (candidates.length > 0) { + logger.debug("Ranked builder bid candidates", { + slot, + candidates: candidates + .map( + (candidate) => `${candidate.url ?? "p2p"}:total=${candidate.totalGwei}:boost=${candidate.boostFactor}` + ) + .join(","), + bidSource: best?.url ?? "p2p", + }); + } + return best; + })(); + const commonBlockBodyPromise = chain.produceCommonBlockBody({ slot, parentBlock, @@ -937,7 +1045,7 @@ export function getValidatorApi( }; metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); - if (builderBid !== null) { + if (p2pBid !== null || builderConfig.builders.length > 0) { metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); } @@ -957,16 +1065,24 @@ export function getValidatorApi( chain.produceBlock(baseAttrs) ).then((engineBlock) => { // No need to wait for the bid block if the engine block will always be selected due to - // suspected builder censorship or a builder boost factor of 0 - if (engineBlock.shouldOverrideBuilder || builderBoostFactor === BigInt(0)) { + // suspected builder censorship, or a boost factor of 0 while no builder API bid may + // still arrive with its own entry boost factor + if ( + engineBlock.shouldOverrideBuilder || + (builderConfig.builders.length === 0 && builderBoostFactor === BigInt(0)) + ) { controller.abort(); } return engineBlock; }); - const bidPromise: ReturnType = - builderBid !== null - ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})) - : Promise.reject(new Error("No builder bid available")); + const bidPromise: ReturnType = bestCandidatePromise.then((candidate) => { + if (candidate === null) { + throw new Error(NO_BID_AVAILABLE); + } + return timed(ProducedBlockSource.builder, () => + chain.produceBlock({...baseAttrs, builderBid: candidate.signedBid}) + ); + }); const [engineResult, bidResult] = await resolveOrRacePromises([enginePromise, bidPromise], { resolveTimeoutMs: cutoffMs, @@ -977,8 +1093,15 @@ export function getValidatorApi( let bestResult: typeof engineResult | null = null; let source: ProducedBlockSource = ProducedBlockSource.engine; + // Resolved instantly whenever the bid branch produced a block + const bestCandidate = bidResult.status === "fulfilled" ? await bestCandidatePromise : null; + // handle shouldOverrideBuilder separately - if (engineResult.status === "fulfilled" && engineResult.value.shouldOverrideBuilder && builderBid !== null) { + if ( + engineResult.status === "fulfilled" && + engineResult.value.shouldOverrideBuilder && + (p2pBid !== null || builderConfig.builders.length > 0) + ) { source = ProducedBlockSource.engine; bestResult = engineResult; metrics?.blockProductionSelectionResults.inc({ @@ -988,10 +1111,10 @@ export function getValidatorApi( logger.warn("Selected local block: censorship suspected in builder bid", logCtx); } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { const result = selectBlockProductionSourceByBoostFactor({ - builderBoostFactor, + builderBoostFactor: bestCandidate?.boostFactor ?? builderBoostFactor, engineExecutionPayloadValue: engineResult.value.executionPayloadValue, - // The bid value is the payment to the proposer, in Gwei - builderExecutionPayloadValue: BigInt(builderBid?.message.value ?? 0) * GWEI_TO_WEI, + // The bid total payment is its value plus executionPayment, in Gwei + builderExecutionPayloadValue: (bestCandidate?.totalGwei ?? BigInt(0)) * GWEI_TO_WEI, }); source = result.source; metrics?.blockProductionSelectionResults.inc(result); @@ -1014,7 +1137,7 @@ export function getValidatorApi( source = ProducedBlockSource.engine; bestResult = engineResult; const reason = - builderBid === null + bidResult.status === "rejected" && (bidResult.reason as Error).message === NO_BID_AVAILABLE ? EngineBlockSelectionReason.BuilderNoBid : bidResult.status === "pending" ? EngineBlockSelectionReason.BuilderPending @@ -1040,6 +1163,15 @@ export function getValidatorApi( const {block, executionPayloadValue, consensusBlockValue} = bestResult.value; + // Remember the winning builder API bid source to route the signed block back to the builder + if (source === ProducedBlockSource.builder && bestCandidate?.url !== undefined) { + chain.builderApiClient.recordBidSource(slot, { + url: bestCandidate.url, + bidBlockHash: toRootHex(bestCandidate.signedBid.message.blockHash), + }); + logger.debug("Builder API bid included in block", {slot, builder: toPrintableUrl(bestCandidate.url)}); + } + metrics?.blockProductionSuccess.inc({source}); metrics?.blockProductionNumAggregated.observe({source}, block.body.attestations.length); metrics?.blockProductionConsensusBlockValue.observe({source}, Number(formatWeiToEth(consensusBlockValue))); @@ -1093,7 +1225,13 @@ export function getValidatorApi( return { data: block as gloas.BeaconBlock, - meta: {version: fork, consensusBlockValue, executionPayloadValue, executionPayloadIncluded: false}, + meta: { + version: fork, + consensusBlockValue, + executionPayloadValue, + executionPayloadIncluded: false, + builderUrl: source === ProducedBlockSource.builder ? bestCandidate?.url : undefined, + }, }; }, @@ -1877,6 +2015,34 @@ export function getValidatorApi( } }, + async submitBuilderPreferences({builderPreferences}) { + const failures: FailureList = []; + + await Promise.all( + builderPreferences.map(async (entry, i) => { + const url = Buffer.from(entry.url).toString("utf8"); + try { + new URL(url); + await chain.builderApiClient.submitBuilderPreferences(url, entry.proposerPubkey, { + preferences: {maxExecutionPayment: entry.maxExecutionPayment}, + auth: entry.auth, + }); + } catch (e) { + failures.push({index: i, message: (e as Error).message}); + logger.verbose( + `Error on submitBuilderPreferences [${i}]`, + {slot: entry.auth.message.slot, builder: toPrintableUrl(url)}, + e as Error + ); + } + }) + ); + + if (failures.length > 0) { + throw new IndexedError("Error submitting builder preferences", failures); + } + }, + async getExecutionPayloadEnvelope({slot, beaconBlockRoot}) { const fork = config.getForkName(slot); diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 87f0a1e7272c..8790ce77677d 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -61,6 +61,7 @@ import {ProcessShutdownCallback} from "@lodestar/validator"; 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 {BuilderApiClient} from "../execution/builder/apiClient.js"; import {BuilderStatus} from "../execution/builder/http.js"; import {IExecutionBuilder, IExecutionEngine} from "../execution/index.js"; import {Metrics} from "../metrics/index.js"; @@ -163,6 +164,7 @@ export class BeaconChain implements IBeaconChain { readonly executionEngine: IExecutionEngine; readonly executionBuilder?: IExecutionBuilder; readonly builderCircuitBreaker: BuilderCircuitBreaker; + readonly builderApiClient: BuilderApiClient; // Expose config for convenience in modularized functions readonly config: BeaconConfig; readonly custodyConfig: CustodyConfig; @@ -444,6 +446,8 @@ export class BeaconChain implements IBeaconChain { {forkChoice, logger, metrics} ); + this.builderApiClient = new BuilderApiClient({}, config, metrics, logger); + this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({ config, clock, @@ -1605,6 +1609,7 @@ export class BeaconChain implements IBeaconChain { this.executionPayloadBidPool.prune(slot); this.seenExecutionPayloadBids.prune(slot); this.proposerPreferencesPool.prune(slot); + this.builderApiClient.prune(slot); this.seenAttestationDatas.onSlot(slot); this.reprocessController.onSlot(slot); diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 8d4610cffb67..d1e90d066704 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -23,6 +23,7 @@ import { rewards, } from "@lodestar/types"; import {Logger} from "@lodestar/utils"; +import {BuilderApiClient} from "../execution/builder/apiClient.js"; import {IExecutionBuilder, IExecutionEngine} from "../execution/index.js"; import {Metrics} from "../metrics/metrics.js"; import {BufferPool} from "../util/bufferPool.js"; @@ -97,6 +98,7 @@ export interface IBeaconChain { readonly executionEngine: IExecutionEngine; readonly executionBuilder?: IExecutionBuilder; readonly builderCircuitBreaker: BuilderCircuitBreaker; + readonly builderApiClient: BuilderApiClient; // 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 2fc25bb9d40f..2021c5cd19fa 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -1,4 +1,5 @@ import {PublicKey} from "@chainsafe/blst"; +import {routes} from "@lodestar/api"; import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import { @@ -86,6 +87,141 @@ export async function validateApiExecutionPayloadBid( return validateExecutionPayloadBid(chain, signedExecutionPayloadBid); } +/** + * Validate a bid received from a builder over the builder API in response to a bid request + * made during block production. Unlike gossip validation, the bid must match the requested + * slot and parent exactly, may carry a non-zero `executionPayment` bounded by the entry's + * `maxExecutionPayment`, and is not subject to gossip anti-spam rules. + * + * Throws with a description of the failure, the caller drops the bid. + */ +export async function validateBuilderApiExecutionPayloadBid( + chain: IBeaconChain, + signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid, + request: { + slot: Slot; + parentBlock: ProtoBlock; + parentBlockHash: RootHex; + parentBlockRoot: RootHex; + entry: routes.validator.BuilderEntry; + } +): Promise { + const bid = signedExecutionPayloadBid.message; + const {slot, parentBlock, parentBlockHash, parentBlockRoot, entry} = request; + + if (bid.slot !== slot) { + throw Error(`Bid slot=${bid.slot} does not match requested slot=${slot}`); + } + + const bidParentBlockHash = toRootHex(bid.parentBlockHash); + const bidParentBlockRoot = toRootHex(bid.parentBlockRoot); + if (bidParentBlockHash !== parentBlockHash || bidParentBlockRoot !== parentBlockRoot) { + throw Error( + `Bid parent parentBlockHash=${bidParentBlockHash} parentBlockRoot=${bidParentBlockRoot} does not match ` + + `requested parentBlockHash=${parentBlockHash} parentBlockRoot=${parentBlockRoot}` + ); + } + + if (bid.executionPayment > entry.maxExecutionPayment) { + throw Error( + `Bid executionPayment=${bid.executionPayment} exceeds maxExecutionPayment=${entry.maxExecutionPayment}` + ); + } + + const totalPayment = BigInt(bid.value) + bid.executionPayment; + if (totalPayment < entry.minBid) { + throw Error(`Bid total payment=${totalPayment} is below minBid=${entry.minBid}`); + } + + const state = await chain.regen + .getBlockSlotState(parentBlock, slot, {dontTransferCache: true}, RegenCaller.validateGossipExecutionPayloadBid) + .catch((e: Error) => { + throw Error(`Unable to regenerate state to validate bid: ${e.message}`); + }); + + if (!isStatePostGloas(state)) { + throw Error(`Expected gloas+ state for execution payload bid validation, got fork=${state.forkName}`); + } + + if (bid.builderIndex >= state.getBuildersLength()) { + throw Error(`Bid builderIndex=${bid.builderIndex} is out of bounds`); + } + + const builder = state.getBuilder(bid.builderIndex); + 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(`Invalid builder version=${builder.version} expected=${PAYLOAD_BUILDER_VERSION}`); + } + + // A bid not signed by one of the builder pubkeys the entry accepts bids from must not be accepted + if ( + entry.builderPubkeys.length > 0 && + !entry.builderPubkeys.some((pubkey) => byteArrayEquals(pubkey, builder.pubkey)) + ) { + throw Error(`Bid builder pubkey=${toHex(builder.pubkey)} is not in the entry's builderPubkeys`); + } + + const blobKzgCommitmentsLen = bid.blobKzgCommitments.length; + const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot)); + if (blobKzgCommitmentsLen > maxBlobsPerBlock) { + throw Error(`Bid has too many KZG commitments len=${blobKzgCommitmentsLen} limit=${maxBlobsPerBlock}`); + } + + if (!state.canBuilderCoverBid(bid.builderIndex, bid.value)) { + throw Error(`Builder cannot cover bid value=${bid.value} balance=${builder.balance}`); + } + + const randaoMix = state.getRandaoMix(computeEpochAtSlot(state.slot)); + if (!byteArrayEquals(bid.prevRandao, randaoMix)) { + throw Error(`Invalid bid prevRandao=${toHex(bid.prevRandao)} expected=${toHex(randaoMix)}`); + } + + // The builder must honor the proposer preferences it learned over gossip + const bidEpoch = computeEpochAtSlot(bid.slot); + const dependentRootHex = (() => { + try { + return getShufflingDependentRoot(chain.forkChoice, bidEpoch, computeEpochAtSlot(parentBlock.slot), parentBlock); + } catch { + return null; + } + })(); + const proposerPreferences = + dependentRootHex !== null ? chain.proposerPreferencesPool.get(bid.slot, dependentRootHex) : null; + if (proposerPreferences !== null) { + if (!byteArrayEquals(bid.feeRecipient, proposerPreferences.message.feeRecipient)) { + throw Error( + `Bid feeRecipient=${toHex(bid.feeRecipient)} does not match ` + + `proposer preferences feeRecipient=${toHex(proposerPreferences.message.feeRecipient)}` + ); + } + + const parentPayloadVariant = chain.forkChoice.getBlockHexAndBlockHash(bidParentBlockRoot, bidParentBlockHash); + if (parentPayloadVariant !== null && parentPayloadVariant.executionPayloadBlockHash !== null) { + const parentGasLimit = BigInt(parentPayloadVariant.executionPayloadGasLimit); + const targetGasLimit = proposerPreferences.message.targetGasLimit; + if (!isGasLimitTargetCompatible(parentGasLimit, bid.gasLimit, targetGasLimit)) { + throw Error( + `Bid gasLimit=${bid.gasLimit} is not compatible with ` + + `parentGasLimit=${parentGasLimit} targetGasLimit=${targetGasLimit}` + ); + } + } + } + + const signatureSet = createSingleSignatureSetFromComponents( + PublicKey.fromBytes(builder.pubkey), + getExecutionPayloadBidSigningRoot(chain.config, bid), + signedExecutionPayloadBid.signature + ); + + if (!(await chain.bls.verifySignatureSets([signatureSet]))) { + throw Error(`Invalid bid signature builderIndex=${bid.builderIndex}`); + } +} + export async function validateGossipExecutionPayloadBid( chain: IBeaconChain, signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts new file mode 100644 index 000000000000..c643528e5aba --- /dev/null +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -0,0 +1,201 @@ +import {routes} from "@lodestar/api"; +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 {toHex, toPrintableUrl} from "@lodestar/utils"; +import {Metrics} from "../../metrics/metrics.js"; + +export type BuilderApiClientOpts = { + 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 a builder to provide a `SignedExecutionPayloadBid` before the deadline + * is reached, only considering bids from the p2p network and the local build process. + */ +export const BUILDER_BID_REQUEST_TIMEOUT_MS = 1000 + EVENT_LOOP_LAG_BUFFER; + +type BuilderUrl = string; + +export type BuilderApiBid = { + url: BuilderUrl; + entry: routes.validator.BuilderEntry; + signedBid: gloas.SignedExecutionPayloadBid; +}; + +export type BidSource = {url: BuilderUrl; bidBlockHash: string}; + +/** + * External builder integration post-gloas (ePBS). + * + * The builder set is driven by the resolved `BuilderConfig` the validator client supplies on + * each block production request, clients are dialed on demand based on the entry `url`. + */ +export class BuilderApiClient { + private readonly clients = 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(); + + constructor( + private readonly opts: BuilderApiClientOpts, + private readonly config: ChainForkConfig, + private readonly metrics: Metrics | null = null, + private readonly logger?: Logger + ) {} + + /** + * Fan out bid requests to the builders named by the entries, one request per unique + * `(url, auth.message.data)` pair. Errors and empty responses (204) are logged and filtered + * out so a single unresponsive builder never fails block production. + */ + async getExecutionPayloadBids( + entries: routes.validator.BuilderEntry[], + slot: Slot, + parentHash: Root, + parentRoot: Root, + proposerPubkey: BLSPubkey, + timeoutMs: number + ): Promise { + const seenRequests = new Set(); + const requests: {url: BuilderUrl; entry: routes.validator.BuilderEntry}[] = []; + + for (const entry of entries) { + const url = Buffer.from(entry.url).toString("utf8"); + const requestKey = `${url}-${toHex(entry.auth.message.data)}`; + if (seenRequests.has(requestKey)) { + continue; + } + seenRequests.add(requestKey); + + try { + new URL(url); + } catch { + this.logger?.warn("Ignoring builder entry with invalid url", {slot, url: toPrintableUrl(url)}); + continue; + } + + // The builder rejects a mismatch, an entry naming a different slot is not used for a bid request + if (entry.auth.message.slot !== slot) { + this.logger?.warn("Ignoring builder entry with auth for different slot", { + slot, + authSlot: entry.auth.message.slot, + builder: toPrintableUrl(url), + }); + continue; + } + + requests.push({url, entry}); + } + + const bids = await Promise.all( + requests.map(async ({url, entry}): Promise => { + this.metrics?.builderApi.bidRequests.inc(); + try { + const res = await this.getClientForUrl(url).getExecutionPayloadBid( + { + slot, + parentHash, + parentRoot, + proposerPubkey, + requestAuth: entry.auth, + dateMilliseconds: Date.now(), + timeoutMs, + }, + {timeoutMs} + ); + const signedBid = res.value(); + if (signedBid === undefined) { + this.logger?.debug("No bid received from builder", {slot, builder: toPrintableUrl(url)}); + return null; + } + this.metrics?.builderApi.bidsReceived.inc(); + return {url, entry, signedBid}; + } catch (e) { + this.metrics?.builderApi.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); + } + + /** Forward a proposer's builder preferences to the builder at the given url */ + async submitBuilderPreferences( + url: BuilderUrl, + proposerPubkey: BLSPubkey, + request: gloas.BuilderPreferencesRequest + ): Promise { + try { + (await this.getClientForUrl(url).submitBuilderPreferences({proposerPubkey, request})).assertOk(); + this.metrics?.builderApi.preferencesForwarded.inc({status: "success"}); + } catch (e) { + this.metrics?.builderApi.preferencesForwarded.inc({status: "error"}); + throw e; + } + } + + /** + * 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?.builderApi.blockSubmissions.inc({status: "success"}); + } catch (e) { + this.metrics?.builderApi.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 { + 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/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 08410bb436eb..3e1498ad1c96 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -1944,6 +1944,35 @@ export function createLodestarMetrics( }), }, + builderApi: { + bidRequests: register.counter({ + name: "lodestar_builder_api_bid_requests_total", + help: "Total count of execution payload bid requests sent to external builders", + }), + bidRequestErrors: register.counter({ + name: "lodestar_builder_api_bid_request_errors_total", + help: "Total count of failed execution payload bid requests to external builders", + }), + bidsReceived: register.counter({ + name: "lodestar_builder_api_bids_received_total", + help: "Total count of execution payload bids received from external builders", + }), + bidsDiscarded: register.counter({ + name: "lodestar_builder_api_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_builder_api_block_submissions_total", + help: "Total count of signed beacon blocks submitted to external builders", + labelNames: ["status"], + }), + preferencesForwarded: register.counter<{status: "success" | "error"}>({ + name: "lodestar_builder_api_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/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 0516a5e353af..fa77a3009e6f 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -14,6 +14,7 @@ import {AggregatedAttestationPool, OpPool, SyncContributionAndProofPool} from ". import {QueuedStateRegenerator} from "../../src/chain/regen/index.js"; import {SeenBlockInput} from "../../src/chain/seenCache/seenGossipBlockInput.js"; import {ShufflingCache} from "../../src/chain/shufflingCache.js"; +import {BuilderApiClient} from "../../src/execution/builder/apiClient.js"; import {ExecutionBuilderHttp} from "../../src/execution/builder/http.js"; import {ExecutionEngineHttp} from "../../src/execution/engine/index.js"; import {Clock} from "../../src/util/clock.js"; @@ -27,6 +28,7 @@ export type MockedBeaconChain = Mocked & { executionBuilder: Mocked; builderCircuitBreaker: Mocked; executionPayloadBidPool: Mocked; + builderApiClient: Mocked; opPool: Mocked; aggregatedAttestationPool: Mocked; syncContributionAndProofPool: Mocked; @@ -159,6 +161,13 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { add: vi.fn(), getBestBid: vi.fn(), }, + builderApiClient: { + getExecutionPayloadBids: vi.fn().mockResolvedValue([]), + 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/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 1acf2412330d..7ba57e0cef7d 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -5,10 +5,16 @@ import {ForkName} from "@lodestar/params"; import {ssz} from "@lodestar/types"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; +import {validateBuilderApiExecutionPayloadBid} from "../../../../../src/chain/validation/executionPayloadBid.js"; import {SyncState} from "../../../../../src/sync/interface.js"; import {ApiTestModules, getApiTestModules} from "../../../../utils/api.js"; import {zeroProtoBlock} from "../../../../utils/state.js"; +vi.mock("../../../../../src/chain/validation/executionPayloadBid.js", async (importActual) => ({ + ...(await importActual()), + validateBuilderApiExecutionPayloadBid: vi.fn().mockResolvedValue(undefined), +})); + describe("api/validator - produceBlockV4", () => { let modules: ApiTestModules; let api: ReturnType; @@ -31,6 +37,10 @@ describe("api/validator - produceBlockV4", () => { const graffiti = "a".repeat(32); const maxBuilderBoostFactor = 2n ** 64n - 1n; + function getBuilderConfig(overrides: {minBid?: bigint; builderBoostFactor?: bigint} = {}) { + return {minBid: BigInt(0), builderBoostFactor: BigInt(100), builders: [], ...overrides}; + } + const engineBlock = ssz.gloas.BeaconBlock.defaultValue(); engineBlock.slot = slot; engineBlock.proposerIndex = 1; @@ -82,6 +92,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, + builderConfig: getBuilderConfig(), }); expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledWith( @@ -110,6 +121,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, + builderConfig: getBuilderConfig(), }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); @@ -127,7 +139,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderBoostFactor: BigInt(0), + builderConfig: getBuilderConfig({builderBoostFactor: BigInt(0)}), }); expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); @@ -152,7 +164,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderBoostFactor: BigInt(0), + builderConfig: getBuilderConfig({builderBoostFactor: BigInt(0)}), }); expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); @@ -182,7 +194,7 @@ describe("api/validator - produceBlockV4", () => { feeRecipient, strictFeeRecipientCheck: true, includePayload: false, - builderBoostFactor: BigInt(0), + builderConfig: getBuilderConfig({builderBoostFactor: BigInt(0)}), }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); @@ -190,11 +202,151 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(builderBlock); }); + it("picks a builder API bid over a lower p2p bid and records the bid source", async () => { + const builderUrl = "https://builder.example.com"; + const entry = { + url: new TextEncoder().encode(builderUrl), + auth: ssz.gloas.SignedRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: BigInt(0), + minBid: BigInt(0), + builderBoostFactor: BigInt(100), + }; + const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + apiBid.message.value = 2; + apiBid.message.builderIndex = 7; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); + modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ + {url: builderUrl, entry, signedBid: apiBid}, + ]); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: BigInt(0), builderBoostFactor: BigInt(100), builders: [entry]}, + }); + + expect(modules.chain.builderApiClient.getExecutionPayloadBids).toHaveBeenCalledOnce(); + expect(validateBuilderApiExecutionPayloadBid).toHaveBeenCalledOnce(); + // The bid block commits to the builder API bid since its boosted total (2) beats the p2p bid (1) + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); + expect(block).toEqual(bidBlock); + expect(modules.chain.builderApiClient.recordBidSource).toHaveBeenCalledWith(slot, { + url: builderUrl, + bidBlockHash: expect.any(String), + }); + }); + + it("prefers the builder API bid over an equally boosted p2p bid", async () => { + const builderUrl = "https://builder.example.com"; + const entry = { + url: new TextEncoder().encode(builderUrl), + auth: ssz.gloas.SignedRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: BigInt(0), + minBid: BigInt(0), + builderBoostFactor: BigInt(100), + }; + // Same bid value as the p2p bid, the builder API copy must win the tie + const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + apiBid.message.value = builderBid.message.value; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); + modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ + {url: builderUrl, entry, signedBid: apiBid}, + ]); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: BigInt(0), builderBoostFactor: BigInt(100), builders: [entry]}, + }); + + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); + expect(block).toEqual(bidBlock); + expect(modules.chain.builderApiClient.recordBidSource).toHaveBeenCalledOnce(); + }); + + it("falls back to the p2p bid when the builder API bid fails validation", async () => { + const builderUrl = "https://builder.example.com"; + const entry = { + url: new TextEncoder().encode(builderUrl), + auth: ssz.gloas.SignedRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: BigInt(0), + minBid: BigInt(0), + builderBoostFactor: BigInt(100), + }; + const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + apiBid.message.value = 2; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); + modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ + {url: builderUrl, entry, signedBid: apiBid}, + ]); + vi.mocked(validateBuilderApiExecutionPayloadBid).mockRejectedValueOnce(new Error("Invalid bid")); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: BigInt(0), builderBoostFactor: BigInt(100), builders: [entry]}, + }); + + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid})); + expect(block).toEqual(bidBlock); + expect(modules.chain.builderApiClient.recordBidSource).not.toHaveBeenCalled(); + }); + + it("ignores a p2p bid below the configured min bid", async () => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + // Bid total payment is 1 gwei, below the configured floor of 2 gwei + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: getBuilderConfig({minBid: BigInt(2)}), + }); + + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + expect(block).toEqual(engineBlock); + }); + it("produces local block when no bid is available", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); - const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload: false}); + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: getBuilderConfig(), + }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); expect(block).toEqual(engineBlock); @@ -204,7 +356,14 @@ describe("api/validator - produceBlockV4", () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); - const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload: false}); + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: getBuilderConfig(), + }); expect(modules.chain.builderCircuitBreaker.isActive).toHaveBeenCalledWith(slot, parentBlock); expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); @@ -228,7 +387,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderBoostFactor: maxBuilderBoostFactor, + builderConfig: getBuilderConfig({builderBoostFactor: maxBuilderBoostFactor}), }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); @@ -248,6 +407,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, + builderConfig: getBuilderConfig(), }); expect(block).toEqual(bidBlock); @@ -261,7 +421,14 @@ describe("api/validator - produceBlockV4", () => { } as ProtoBlock); await expect( - api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload: false}) + api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: getBuilderConfig(), + }) ).rejects.toThrow("Node is syncing"); expect(modules.chain.produceBlock).not.toHaveBeenCalled(); diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index 3be87e1b4492..0dd4a4418a41 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -28,7 +28,13 @@ import { parseLoggerArgs, parseProposerConfig, } from "../../util/index.js"; -import {parseBuilderBoostFactor, parseBuilderSelection} from "../../util/proposerConfig.js"; +import { + parseBuilderBoostFactor, + parseBuilderGweiAmount, + parseBuilderMinBid, + parseBuilderSelection, + parseBuilderUrls, +} from "../../util/proposerConfig.js"; import {getVersionData} from "../../util/version.js"; import {KeymanagerApi} from "./keymanager/impl.js"; import {IPersistedKeysBackend} from "./keymanager/interface.js"; @@ -212,7 +218,8 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr validator, persistedKeysBackend, abortController.signal, - proposerConfigWriteDisabled + proposerConfigWriteDisabled, + args.allowDangerousTrustedPayments ); const keymanagerServer = new KeymanagerRestApiServer( { @@ -250,6 +257,9 @@ function getProposerConfigFromArgs( args["builder.selection"] ?? (args.builder ? defaultOptions.builderAliasSelection : undefined) ), boostFactor: parseBuilderBoostFactor(args["builder.boostFactor"]), + minBid: parseBuilderMinBid(args["builder.minBid"]), + maxExecutionPayment: parseBuilderGweiAmount(args["builder.maxExecutionPayment"]), + urls: parseBuilderUrls(args["builder.urls"]), }, }; @@ -273,6 +283,18 @@ function getProposerConfigFromArgs( valProposerConfig = {defaultConfig} as ValidatorProposerConfig; } } + + // Trusted execution payments are only backed by the builder's promise to pay, require an + // explicit opt-in before any configuration source can set a max execution payment above 0 + if (args.allowDangerousTrustedPayments !== true) { + const configs = [valProposerConfig.defaultConfig, ...Object.values(valProposerConfig.proposerConfig ?? {})]; + if (configs.some((config) => (config.builder?.maxExecutionPayment ?? BigInt(0)) > BigInt(0))) { + throw new YargsError( + "Configuring a builder max execution payment above 0 requires --allowDangerousTrustedPayments" + ); + } + } + return valProposerConfig; } diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index ec9fe5bfc8d7..7582a7d11be6 100644 --- a/packages/cli/src/cmds/validator/keymanager/impl.ts +++ b/packages/cli/src/cmds/validator/keymanager/impl.ts @@ -2,6 +2,7 @@ import {Keystore} from "@chainsafe/bls-keystore"; import {SecretKey} from "@chainsafe/blst"; import { BuilderBoostFactorData, + BuilderConfigData, DeleteRemoteKeyStatus, DeletionStatus, FeeRecipientData, @@ -32,7 +33,8 @@ export class KeymanagerApi implements Api { private readonly validator: Validator, private readonly persistedKeysBackend: IPersistedKeysBackend, private readonly signal: AbortSignal, - private readonly proposerConfigWriteDisabled?: boolean + private readonly proposerConfigWriteDisabled?: boolean, + private readonly allowDangerousTrustedPayments?: boolean ) {} private checkIfProposerWriteEnabled(): void { @@ -379,6 +381,47 @@ export class KeymanagerApi implements Api { return {status: 204}; } + async getBuilders({pubkey}: {pubkey: PubkeyHex}): ReturnType { + this.assertValidKnownPubkey(pubkey); + return {data: this.validator.validatorStore.getBuilderConfig(pubkey)}; + } + + async setBuilders({ + pubkey, + builderConfig, + }: { + pubkey: PubkeyHex; + builderConfig: BuilderConfigData; + }): ReturnType { + this.checkIfProposerWriteEnabled(); + this.assertValidKnownPubkey(pubkey); + + if ( + this.allowDangerousTrustedPayments !== true && + builderConfig.builders?.some((entry) => (entry.maxExecutionPayment ?? BigInt(0)) > BigInt(0)) + ) { + throw new ApiError( + 400, + "Configuring a builder max execution payment above 0 requires --allowDangerousTrustedPayments" + ); + } + + try { + this.validator.validatorStore.setBuilderConfig(pubkey, builderConfig); + } catch (e) { + throw new ApiError(400, (e as Error).message); + } + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 202}; + } + + async deleteBuilders({pubkey}: {pubkey: PubkeyHex}): ReturnType { + this.checkIfProposerWriteEnabled(); + this.validator.validatorStore.deleteBuilderConfig(pubkey); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 204}; + } + async getProposerConfig({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.assertValidKnownPubkey(pubkey); diff --git a/packages/cli/src/cmds/validator/keymanager/persistedKeys.ts b/packages/cli/src/cmds/validator/keymanager/persistedKeys.ts index 56d508fa4e2f..d47852aeda5b 100644 --- a/packages/cli/src/cmds/validator/keymanager/persistedKeys.ts +++ b/packages/cli/src/cmds/validator/keymanager/persistedKeys.ts @@ -53,7 +53,11 @@ export class PersistedKeysBackend implements IPersistedKeysBackend { if (proposerConfig !== null) { // if proposerConfig is not empty write or update the json to file const {proposerDirPath} = this.getValidatorPaths(pubkeyHex); - writeFile600Perm(proposerDirPath, JSON.stringify(proposerConfig)); + writeFile600Perm( + proposerDirPath, + // Default JSON serialization can't handle BigInt + JSON.stringify(proposerConfig, (_key, value) => (typeof value === "bigint" ? value.toString() : value)) + ); } else { this.deleteProposerConfig(pubkeyHex); } diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index c598061ee011..93431d8fd494 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -48,6 +48,10 @@ export type IValidatorCliArgs = AccountValidatorArgs & builder?: boolean; "builder.selection"?: string; "builder.boostFactor"?: string; + "builder.minBid"?: string; + "builder.urls"?: string[]; + "builder.maxExecutionPayment"?: string; + allowDangerousTrustedPayments?: boolean; /** @deprecated */ useProduceBlockV3?: boolean; @@ -276,6 +280,37 @@ export const validatorOptions: CliCommandOptions = { group: "builder", }, + "builder.minBid": { + type: "string", + description: + "Minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Only used post-Gloas", + defaultDescription: `${defaultOptions.builderMinBid}`, + 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. Values above 0 require --allowDangerousTrustedPayments. Only used post-Gloas", + defaultDescription: `${defaultOptions.builderMaxExecutionPayment}`, + group: "builder", + }, + + allowDangerousTrustedPayments: { + type: "boolean", + description: + "Allow configuring a builder max execution payment above 0. Trusted execution layer payments are only backed by the builder's promise to pay, not by its staked collateral, a builder that fails to pay cannot be penalized in protocol", + group: "builder", + }, + useProduceBlockV3: { hidden: true, deprecated: true, diff --git a/packages/cli/src/util/proposerConfig.ts b/packages/cli/src/util/proposerConfig.ts index 1fbf51495fee..0e9e625cf2e1 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; + min_bid?: bigint; + max_execution_payment?: bigint; }; }; @@ -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, min_bid, max_execution_payment} = builder || {}; if (graffiti !== undefined && typeof graffiti !== "string") { throw Error("graffiti is not 'string"); @@ -79,6 +81,12 @@ function parseProposerConfigSection( if (boost_factor !== undefined && typeof boost_factor !== "string") { throw Error("boost_factor is not 'string"); } + if (min_bid !== undefined && typeof min_bid !== "string") { + throw Error("min_bid is not 'string"); + } + if (max_execution_payment !== undefined && typeof max_execution_payment !== "string") { + throw Error("max_execution_payment is not 'string"); + } return { graffiti: overrideConfig?.graffiti ?? graffiti, @@ -92,15 +100,32 @@ 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), + minBid: overrideConfig?.builder?.minBid ?? parseBuilderMinBid(min_bid), + maxExecutionPayment: + overrideConfig?.builder?.maxExecutionPayment ?? parseBuilderGweiAmount(max_execution_payment), + urls: overrideConfig?.builder?.urls, } : undefined, }; } -export function readProposerConfigDir(filepath: string, filename: string): ProposerConfigFileSection { +export function readProposerConfigDir(filepath: string, filename: string): ProposerConfig { const proposerConfigStr = fs.readFileSync(path.join(filepath, filename), "utf8"); - const proposerConfigJSON = JSON.parse(proposerConfigStr) as ProposerConfigFileSection; - return proposerConfigJSON; + // Persisted via `writeProposerConfig` with BigInt values serialized as strings + const persisted = JSON.parse(proposerConfigStr) as ProposerConfig; + if (persisted.builder) { + const {boostFactor, minBid, maxExecutionPayment, builders} = persisted.builder; + persisted.builder.boostFactor = boostFactor !== undefined ? BigInt(boostFactor) : undefined; + persisted.builder.minBid = minBid !== undefined ? BigInt(minBid) : undefined; + persisted.builder.maxExecutionPayment = maxExecutionPayment !== undefined ? BigInt(maxExecutionPayment) : undefined; + persisted.builder.builders = builders?.map((entry) => ({ + ...entry, + maxExecutionPayment: entry.maxExecutionPayment !== undefined ? BigInt(entry.maxExecutionPayment) : undefined, + minBid: entry.minBid !== undefined ? BigInt(entry.minBid) : undefined, + builderBoostFactor: entry.builderBoostFactor !== undefined ? BigInt(entry.builderBoostFactor) : undefined, + })); + } + return persisted; } export function parseBuilderSelection(builderSelection?: string): routes.validator.BuilderSelection | undefined { @@ -134,3 +159,36 @@ export function parseBuilderBoostFactor(boostFactor?: string): bigint | undefine return BigInt(boostFactor); } + +export function parseBuilderMinBid(minBid?: string | bigint): bigint | undefined { + if (minBid === undefined) return; + + if (!/^\d+$/.test(minBid.toString())) { + throw Error("Invalid input for builder min bid, must be a valid number without decimals"); + } + + return BigInt(minBid); +} + +export function parseBuilderGweiAmount(amount?: string | bigint): bigint | undefined { + if (amount === undefined) return; + + if (!/^\d+$/.test(amount.toString())) { + throw Error("Invalid input for builder Gwei amount, must be a valid number without decimals"); + } + + return BigInt(amount); +} + +export function parseBuilderUrls(urls?: string[]): string[] | undefined { + if (urls === undefined) return undefined; + + for (const url of urls) { + try { + new URL(url); + } catch { + throw Error(`Invalid builder url: ${url}`); + } + } + return [...new Set(urls)]; +} diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index 579166dc0cbd..3b01d109d2a9 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -187,6 +187,7 @@ export const DOMAIN_INCLUSION_LIST_COMMITTEE = Uint8Array.from([16, 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 @@ -372,3 +373,10 @@ 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_BUILDER_ENTRIES = 64; +export const MAX_BUILDER_URL_SIZE = 2048; +export const MAX_BUILDER_PUBKEYS = 64; +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 8d389ef51885..0a08fb2f9178 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -1,5 +1,6 @@ import { BitVectorType, + ByteListType, ContainerType, ListBasicType, ListCompositeType, @@ -17,6 +18,7 @@ import { EXECUTION_BLOCK_HASH_DEPTH_GLOAS, FINALIZED_ROOT_DEPTH_GLOAS, HISTORICAL_ROOTS_LIMIT, + MAX_DATA_SIZE, MIN_SEED_LOOKAHEAD, NEXT_SYNC_COMMITTEE_DEPTH_GLOAS, NUMBER_OF_COLUMNS, @@ -351,6 +353,39 @@ export const SignedExecutionPayloadBid = new ContainerType( {typeName: "SignedExecutionPayloadBid", jsonCase: "eth2"} ); +// Builder API types (builder-specs) + +export const RequestAuth = new ContainerType( + { + data: new ByteListType(MAX_DATA_SIZE), + slot: Slot, + }, + {typeName: "RequestAuth", jsonCase: "eth2"} +); + +export const SignedRequestAuth = new ContainerType( + { + message: RequestAuth, + signature: BLSSignature, + }, + {typeName: "SignedRequestAuth", jsonCase: "eth2"} +); + +export const BuilderPreferences = new ContainerType( + { + maxExecutionPayment: Gwei, + }, + {typeName: "BuilderPreferences", jsonCase: "eth2"} +); + +export const BuilderPreferencesRequest = new ContainerType( + { + preferences: BuilderPreferences, + auth: SignedRequestAuth, + }, + {typeName: "BuilderPreferencesRequest", jsonCase: "eth2"} +); + export const BlockAccessList = new ProgressiveByteListType({typeName: "BlockAccessList"}); export const ExecutionPayload = new ProgressiveContainerType( diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index e7e9b9dd12be..5e5694f22646 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -44,6 +44,10 @@ export type SignedProposerPreferences = ValueOf; export type ExecutionPayloadBid = ValueOf; export type SignedExecutionPayloadBid = ValueOf; +export type RequestAuth = ValueOf; +export type SignedRequestAuth = ValueOf; +export type BuilderPreferences = ValueOf; +export type BuilderPreferencesRequest = 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 4e545f7527d9..2a723f8e6f82 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -194,6 +194,8 @@ export class BlockProposingService { const {broadcastValidation, payloadLocal} = this.opts; const {selection: builderSelection, boostFactor: builderBoostFactor} = this.validatorStore.getBuilderSelectionParams(pubkeyHex, slot); + const builderMinBid = this.validatorStore.getBuilderMinBid(pubkeyHex); + const builderEntries = this.validatorStore.getResolvedBuilderEntries(pubkeyHex, builderBoostFactor); this.logger.debug("Producing block", { ...debugLogCtx, @@ -202,9 +204,35 @@ export class BlockProposingService { payloadLocal, builderSelection, builderBoostFactor, + builderMinBid, + builderUrls: builderEntries.map((entry) => entry.url).join(","), }); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); + // One entry per resolved builder, authenticated by a request auth signed over the entry's + // auth data. The auth is usually pre-signed when preferences are submitted ahead of the + // proposal. An entry whose auth cannot be signed is skipped so it never fails the proposal. + const builders: routes.validator.BuilderEntry[] = ( + await Promise.all( + builderEntries.map(async (entry) => { + try { + const auth = await this.validatorStore.getRequestAuth(pubkey, entry.authData, slot, slot); + return { + url: new Uint8Array(Buffer.from(entry.url, "utf8")), + auth, + builderPubkeys: entry.builderPubkeys, + maxExecutionPayment: entry.maxExecutionPayment, + minBid: entry.minBid, + builderBoostFactor: entry.builderBoostFactor, + }; + } catch (e) { + this.logger.warn("Failed to sign builder request auth", {...logCtx, builderUrl: entry.url}, e as Error); + return null; + } + }) + ) + ).filter((entry) => entry !== null); + // Step 1: Produce beacon block with execution payload bid const blockRes = await this.api.validator .produceBlockV4({ @@ -214,7 +242,11 @@ export class BlockProposingService { feeRecipient, strictFeeRecipientCheck, includePayload: !payloadLocal, - builderBoostFactor, + builderConfig: { + minBid: builderMinBid, + builderBoostFactor, + builders, + }, }) .catch((e: Error) => { this.metrics?.blockProposingErrors.inc({error: "produce"}); @@ -250,6 +282,8 @@ export class BlockProposingService { .publishBlockV2({ signedBlockContents: {signedBlock}, broadcastValidation, + // Echo the winning builder url so any beacon node can forward the block to the builder + builderUrl: blockMeta.builderUrl, }) .catch((e: Error) => { this.metrics?.blockProposingErrors.inc({error: "publish"}); diff --git a/packages/validator/src/services/builderPreferences.ts b/packages/validator/src/services/builderPreferences.ts new file mode 100644 index 000000000000..47b2180548a6 --- /dev/null +++ b/packages/validator/src/services/builderPreferences.ts @@ -0,0 +1,141 @@ +import {ApiClient, routes} from "@lodestar/api"; +import {ChainForkConfig} from "@lodestar/config"; +import {SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params"; +import {IClock, computeEpochAtSlot} from "@lodestar/state-transition"; +import {Epoch, RootHex, Slot} from "@lodestar/types"; +import {toPubkeyHex} from "@lodestar/utils"; +import {Metrics} from "../metrics.js"; +import {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 + * before the bid request arrives 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 builder preferences for any local validator that will propose within the + * next `SUBMIT_BEFORE_PROPOSAL_SLOTS` and has external builders configured. Signing the request + * auths here also pre-fills the auth cache used at proposal time. + * + * The beacon node submits each entry to the builder at the entry's url. 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 entries: routes.validator.BuilderPreferencesEntry[] = []; + // 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, duty.slot); + if (selection === routes.validator.BuilderSelection.ExecutionOnly) continue; + + const builderEntries = this.validatorStore.getResolvedBuilderEntries(pubkeyHex); + if (builderEntries.length === 0) continue; + + try { + // Collect entries 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 dutyEntries: routes.validator.BuilderPreferencesEntry[] = []; + for (const entry of builderEntries) { + const auth = await this.validatorStore.getRequestAuth(duty.pubkey, entry.authData, duty.slot, slot); + dutyEntries.push({ + proposerPubkey: duty.pubkey, + url: new Uint8Array(Buffer.from(entry.url, "utf8")), + auth, + maxExecutionPayment: entry.maxExecutionPayment, + }); + } + entries.push(...dutyEntries); + pending.push({submission, slot: duty.slot}); + } catch (e) { + this.logger.error( + "Error signing builder preferences", + {slot: duty.slot, validatorIndex: duty.validatorIndex}, + e as Error + ); + } + } + } + + // Prune tracking for past epochs + for (const epoch of this.submitted.keys()) { + if (epoch < currentEpoch) { + this.submitted.delete(epoch); + } + } + + if (entries.length === 0) { + return; + } + + try { + (await this.api.validator.submitBuilderPreferences({builderPreferences: entries})).assertOk(); + // 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: entries.length}); + } catch (e) { + this.logger.error("Error submitting builder preferences", {count: entries.length}, e as Error); + } + }; +} diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 47339c33181f..3e5262816783 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -1,6 +1,7 @@ import {SecretKey} from "@chainsafe/blst"; import {BitArray} from "@chainsafe/ssz"; import {routes} from "@lodestar/api"; +import {BuilderConfigData, BuilderEntryConfig} from "@lodestar/api/keymanager"; import {BeaconConfig} from "@lodestar/config"; import { DOMAIN_AGGREGATE_AND_PROOF, @@ -12,10 +13,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, @@ -46,7 +49,7 @@ import { phase0, ssz, } from "@lodestar/types"; -import {fromHex, toPubkeyHex, toRootHex} from "@lodestar/utils"; +import {fromHex, toHex, toPubkeyHex, toRootHex} from "@lodestar/utils"; import {Metrics} from "../metrics.js"; import {ISlashingProtection} from "../slashingProtection/index.js"; import {PubkeyHex} from "../types.js"; @@ -84,6 +87,9 @@ type DefaultProposerConfig = { gasLimit?: number; selection?: routes.validator.BuilderSelection; boostFactor: bigint; + minBid: bigint; + maxExecutionPayment: bigint; + urls: string[]; }; }; @@ -95,9 +101,24 @@ export type ProposerConfig = { gasLimit?: number; selection?: routes.validator.BuilderSelection; boostFactor?: bigint; + minBid?: bigint; + maxExecutionPayment?: bigint; + urls?: string[]; + /** Per-key builder entries set via the keymanager api, replacing the configured builder urls */ + builders?: BuilderEntryConfig[]; }; }; +/** A builder entry with every omitted value resolved against the key and validator client defaults */ +export type ResolvedBuilderEntry = { + url: string; + authData: Uint8Array; + builderPubkeys: Uint8Array[]; + maxExecutionPayment: bigint; + minBid: bigint; + builderBoostFactor: bigint; +}; + export type ValidatorProposerConfig = { proposerConfig: {[index: PubkeyHex]: ProposerConfig}; defaultConfig: ProposerConfig; @@ -132,6 +153,8 @@ export type Signer = SignerLocal | SignerRemote; type ValidatorData = ProposerConfig & { signer: Signer; builderData?: BuilderData; + /** Pre-signed request auths keyed by proposal slot and auth data, pruned by proposal slot */ + requestAuths?: Map; }; export const defaultOptions = { @@ -140,6 +163,9 @@ export const defaultOptions = { builderSelection: routes.validator.BuilderSelection.ExecutionOnly, builderAliasSelection: routes.validator.BuilderSelection.Default, builderBoostFactor: BigInt(100), + builderMinBid: BigInt(0), + // Only trustless payments via the builder's staked collateral are accepted by default + builderMaxExecutionPayment: BigInt(0), // spec asks for gossip validation by default broadcastValidation: routes.beacon.BroadcastValidation.gossip, // should request fetching the locally produced block in blinded format @@ -185,6 +211,9 @@ export class ValidatorStore { gasLimit: defaultConfig.builder?.gasLimit, selection: defaultConfig.builder?.selection, boostFactor: builderBoostFactor, + minBid: defaultConfig.builder?.minBid ?? defaultOptions.builderMinBid, + maxExecutionPayment: defaultConfig.builder?.maxExecutionPayment ?? defaultOptions.builderMaxExecutionPayment, + urls: defaultConfig.builder?.urls ?? [], }, }; @@ -409,6 +438,145 @@ export class ValidatorStore { delete validatorData.builder?.boostFactor; } + getBuilderMinBid(pubkeyHex: PubkeyHex): bigint { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + return validatorData?.builder?.minBid ?? this.defaultProposerConfig.builder.minBid; + } + + getBuilderMaxExecutionPayment(pubkeyHex: PubkeyHex): bigint { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + return validatorData?.builder?.maxExecutionPayment ?? this.defaultProposerConfig.builder.maxExecutionPayment; + } + + getBuilderUrls(pubkeyHex: PubkeyHex): string[] { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + return validatorData?.builder?.urls ?? this.defaultProposerConfig.builder.urls; + } + + /** + * Resolve the builder entries for this key. Per-key entries set via the keymanager api replace + * the configured builder urls, an omitted entry value takes this key's default and then the + * validator client's own configuration. An omitted auth data is derived from the entry url. + */ + getResolvedBuilderEntries(pubkeyHex: PubkeyHex, boostFactor?: bigint): ResolvedBuilderEntry[] { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + + const keyMinBid = validatorData.builder?.minBid ?? this.defaultProposerConfig.builder.minBid; + const keyBoostFactor = + boostFactor ?? validatorData.builder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor; + const keyMaxExecutionPayment = + validatorData.builder?.maxExecutionPayment ?? this.defaultProposerConfig.builder.maxExecutionPayment; + + const builders = validatorData.builder?.builders; + if (builders !== undefined) { + return builders.map((entry) => ({ + url: entry.url, + authData: entry.authData !== undefined ? fromHex(entry.authData) : new Uint8Array(Buffer.from(entry.url)), + builderPubkeys: (entry.builderPubkeys ?? []).map(fromHex), + maxExecutionPayment: entry.maxExecutionPayment ?? keyMaxExecutionPayment, + minBid: entry.minBid ?? keyMinBid, + builderBoostFactor: entry.builderBoostFactor ?? keyBoostFactor, + })); + } + + // The key's defaults apply to the validator client's configured builders all the same + return this.getBuilderUrls(pubkeyHex).map((url) => ({ + url, + authData: new Uint8Array(Buffer.from(url)), + builderPubkeys: [], + maxExecutionPayment: keyMaxExecutionPayment, + minBid: keyMinBid, + builderBoostFactor: keyBoostFactor, + })); + } + + /** Return the builder configuration in effect for this key, with omitted values resolved */ + getBuilderConfig(pubkeyHex: PubkeyHex): BuilderConfigData { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + + return { + minBid: validatorData.builder?.minBid ?? this.defaultProposerConfig.builder.minBid, + builderBoostFactor: validatorData.builder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor, + builders: this.getResolvedBuilderEntries(pubkeyHex).map((entry) => ({ + url: entry.url, + authData: toHex(entry.authData), + builderPubkeys: entry.builderPubkeys.map(toPubkeyHex), + maxExecutionPayment: entry.maxExecutionPayment, + minBid: entry.minBid, + builderBoostFactor: entry.builderBoostFactor, + })), + }; + } + + /** Set the builder configuration for this key, replacing any stored configuration in full */ + setBuilderConfig(pubkeyHex: PubkeyHex, config: BuilderConfigData): void { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + + for (const value of [config.minBid, config.builderBoostFactor]) { + if (value !== undefined && value > MAX_BUILDER_BOOST_FACTOR) { + throw Error(`Invalid builder config value=${value} exceeds uint64`); + } + } + + // No two entries may share both their url and their auth data, an omitted auth data is + // compared as the value derived from the entry url + const seenEntries = new Set(); + for (const entry of config.builders ?? []) { + try { + new URL(entry.url); + } catch { + throw Error(`Invalid builder url: ${entry.url}`); + } + const authData = entry.authData !== undefined ? toHex(fromHex(entry.authData)) : toHex(Buffer.from(entry.url)); + const entryKey = `${entry.url}|${authData}`; + if (seenEntries.has(entryKey)) { + throw Error(`Duplicate builder entry url=${entry.url}`); + } + seenEntries.add(entryKey); + for (const value of [entry.maxExecutionPayment, entry.minBid, entry.builderBoostFactor]) { + if (value !== undefined && value > MAX_BUILDER_BOOST_FACTOR) { + throw Error(`Invalid builder entry value=${value} exceeds uint64`); + } + } + } + + validatorData.builder = { + ...validatorData.builder, + minBid: config.minBid, + boostFactor: config.builderBoostFactor, + builders: config.builders, + }; + } + + /** Remove the builder configuration for this key, it then follows the validator client again */ + deleteBuilderConfig(pubkeyHex: PubkeyHex): void { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + delete validatorData.builder?.minBid; + delete validatorData.builder?.boostFactor; + delete validatorData.builder?.builders; + } + /** Return true if `index` is active part of this validator client */ hasValidatorIndex(index: ValidatorIndex): boolean { return this.indicesService.index2pubkey.has(index); @@ -430,7 +598,11 @@ export class ValidatorStore { feeRecipient !== undefined || builder?.gasLimit !== undefined || builder?.selection !== undefined || - builder?.boostFactor !== undefined + builder?.boostFactor !== undefined || + builder?.minBid !== undefined || + builder?.maxExecutionPayment !== undefined || + builder?.urls !== undefined || + builder?.builders !== undefined ) { proposerConfig = {graffiti, strictFeeRecipientCheck, feeRecipient, builder}; } @@ -879,6 +1051,68 @@ export class ValidatorStore { }; } + async signRequestAuth( + pubkeyMaybeHex: BLSPubkeyMaybeHex, + data: Uint8Array, + proposalSlot: Slot + ): Promise { + if (data.length === 0 || data.length > MAX_DATA_SIZE) { + throw Error(`Invalid request auth data length=${data.length}, must be within 1 and ${MAX_DATA_SIZE} bytes`); + } + + const message: gloas.RequestAuth = {data, slot: proposalSlot}; + + const signingSlot = 0; + const domain = computeDomain(DOMAIN_REQUEST_AUTH, this.config.GENESIS_FORK_VERSION, ZERO_HASH); + const signingRoot = computeSigningRoot(ssz.gloas.RequestAuth, 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 auth data and proposal slot, or sign and cache a new + * one. Signing happens off the block proposal hot path when preferences are submitted ahead of + * time, cached auths are then used just-in-time when requesting bids at proposal time. + */ + async getRequestAuth( + pubkeyMaybeHex: BLSPubkeyMaybeHex, + data: Uint8Array, + proposalSlot: Slot, + currentSlot: Slot + ): Promise { + const pubkeyHex = typeof pubkeyMaybeHex === "string" ? pubkeyMaybeHex : toPubkeyHex(pubkeyMaybeHex); + const authKey = `${proposalSlot}-${toHex(data)}`; + const validatorData = this.validators.get(pubkeyHex); + const cached = validatorData?.requestAuths?.get(authKey); + if (cached !== undefined) { + return cached; + } + + const signedRequestAuth = await this.signRequestAuth(pubkeyMaybeHex, data, 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; + } + async getValidatorRegistration( pubkeyMaybeHex: BLSPubkeyMaybeHex, regAttributes: {feeRecipient: ExecutionAddress; gasLimit: number}, diff --git a/packages/validator/src/util/externalSignerClient.ts b/packages/validator/src/util/externalSignerClient.ts index 9903c0bec5b4..aabce7e08007 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.RequestAuth}; 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, + // Signed with compute_domain(DOMAIN_REQUEST_AUTH) using 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.RequestAuth.toJson(payload.data)}; } } diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index a80459b9b3b6..3ced0c2ece0e 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -16,6 +16,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"; @@ -313,6 +314,7 @@ 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, diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index ffc3d4e2dbe3..0ed4e6e5e222 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -39,6 +39,8 @@ describe("BlockDutiesService", () => { vi.spyOn(validatorStore, "signRandao"); vi.spyOn(validatorStore, "signBlock"); vi.spyOn(validatorStore, "getBuilderSelectionParams"); + vi.spyOn(validatorStore, "getBuilderMinBid"); + vi.spyOn(validatorStore, "getResolvedBuilderEntries"); vi.spyOn(validatorStore, "getGraffiti"); vi.spyOn(validatorStore, "getFeeRecipient"); vi.spyOn(validatorStore, "strictFeeRecipientCheck"); @@ -237,6 +239,8 @@ describe("BlockDutiesService", () => { selection: routes.validator.BuilderSelection.ExecutionAlways, boostFactor: BigInt(0), }); + validatorStore.getBuilderMinBid.mockReturnValue(BigInt(0)); + validatorStore.getResolvedBuilderEntries.mockReturnValue([]); validatorStore.getGraffiti.mockReturnValue("aaaa"); validatorStore.getFeeRecipient.mockReturnValue(feeRecipient); validatorStore.strictFeeRecipientCheck.mockReturnValue(true); @@ -265,7 +269,7 @@ describe("BlockDutiesService", () => { feeRecipient, strictFeeRecipientCheck: true, includePayload: true, - builderBoostFactor: BigInt(0), + builderConfig: {minBid: BigInt(0), builderBoostFactor: BigInt(0), builders: []}, }); }); }); diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index 774c9e44a230..318f2762dbd7 100644 --- a/packages/validator/test/unit/validatorStore.test.ts +++ b/packages/validator/test/unit/validatorStore.test.ts @@ -4,8 +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 {SLOTS_PER_EPOCH} from "@lodestar/params"; -import {bellatrix} from "@lodestar/types"; +import {DOMAIN_REQUEST_AUTH, SLOTS_PER_EPOCH} 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 {getMockedLogger} from "../utils/logger.js"; @@ -192,6 +193,80 @@ describe("ValidatorStore", () => { recommendedGasLimit, }); }); + + it("Should sign request auth with fork-independent domain", async () => { + const data = Buffer.from("https://builder.example.com", "utf8"); + const proposalSlot = 10; + + const signedRequestAuth = await validatorStore.signRequestAuth(pubkeys[0], data, proposalSlot); + + expect(toHexString(signedRequestAuth.message.data)).toBe(toHexString(data)); + expect(signedRequestAuth.message.slot).toBe(proposalSlot); + + const domain = computeDomain(DOMAIN_REQUEST_AUTH, chainConfig.GENESIS_FORK_VERSION, ZERO_HASH); + const signingRoot = computeSigningRoot(ssz.gloas.RequestAuth, signedRequestAuth.message, domain); + expect(toHexString(signedRequestAuth.signature)).toBe(toHexString(secretKeys[0].sign(signingRoot).toBytes())); + + // Signing root must bind both the auth data and the proposal slot + const otherData = computeSigningRoot( + ssz.gloas.RequestAuth, + {data: Buffer.from("other"), slot: proposalSlot}, + domain + ); + const otherSlot = computeSigningRoot(ssz.gloas.RequestAuth, {data, slot: proposalSlot + 1}, domain); + expect(toHexString(otherData)).not.toBe(toHexString(signingRoot)); + expect(toHexString(otherSlot)).not.toBe(toHexString(signingRoot)); + }); + + it("Should reject request auth data with invalid length", async () => { + await expect(validatorStore.signRequestAuth(pubkeys[0], new Uint8Array(0), 10)).rejects.toThrow(); + await expect(validatorStore.signRequestAuth(pubkeys[0], new Uint8Array(4097), 10)).rejects.toThrow(); + }); + + it("Should resolve builder entries against key and validator client defaults", () => { + const pubkey = toHexString(pubkeys[0]); + const builderUrl = "https://builder.example.com"; + + // No per-key config resolves to the validator client's builders (none configured) + expect(validatorStore.getResolvedBuilderEntries(pubkey)).toEqual([]); + + validatorStore.setBuilderConfig(pubkey, { + minBid: BigInt(10), + builders: [ + {url: builderUrl, maxExecutionPayment: BigInt(5)}, + {url: builderUrl, authData: "0x1234", minBid: BigInt(20), builderBoostFactor: BigInt(120)}, + ], + }); + + const entries = validatorStore.getResolvedBuilderEntries(pubkey); + expect(entries).toHaveLength(2); + // Omitted auth data derives from the entry url, omitted min bid takes the key default + expect(Buffer.from(entries[0].authData).toString("utf8")).toBe(builderUrl); + expect(entries[0].minBid).toBe(BigInt(10)); + expect(entries[0].maxExecutionPayment).toBe(BigInt(5)); + // Per-entry values win over the key defaults + expect(toHexString(entries[1].authData)).toBe("0x1234"); + expect(entries[1].minBid).toBe(BigInt(20)); + expect(entries[1].builderBoostFactor).toBe(BigInt(120)); + + // GET returns the configuration fully resolved + const config = validatorStore.getBuilderConfig(pubkey); + expect(config.minBid).toBe(BigInt(10)); + expect(config.builders?.[0].authData).toBeDefined(); + expect(config.builders?.[0].builderPubkeys).toEqual([]); + + // Duplicate (url, auth data) entries are rejected, an omitted auth data compares as derived + expect(() => + validatorStore.setBuilderConfig(pubkey, { + builders: [{url: builderUrl}, {url: builderUrl}], + }) + ).toThrow(); + + // Delete reverts the key to the validator client's own configuration + validatorStore.deleteBuilderConfig(pubkey); + expect(validatorStore.getResolvedBuilderEntries(pubkey)).toEqual([]); + expect(validatorStore.getBuilderMinBid(pubkey)).toBe(BigInt(0)); + }); }); const secretKeys = Array.from({length: 3}, (_, i) => SecretKey.fromBytes(toBufferBE(BigInt(i + 1), 32))); From adc784306536be82419a146e0c9ee0809ceccc7c Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 15 Aug 2026 22:17:31 +0100 Subject: [PATCH 02/67] fix(api): preserve request content length --- packages/api/src/utils/client/httpClient.ts | 4 ++-- packages/api/src/utils/client/request.ts | 19 ++++++++++++------- .../api/test/unit/client/httpClient.test.ts | 2 ++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/api/src/utils/client/httpClient.ts b/packages/api/src/utils/client/httpClient.ts index 1f1b97163309..df0f48d4479d 100644 --- a/packages/api/src/utils/client/httpClient.ts +++ b/packages/api/src/utils/client/httpClient.ts @@ -367,8 +367,8 @@ export class HttpClient implements IHttpClient { try { this.logger?.debug("API request", {routeId, requestWireFormat, responseWireFormat}); - const request = createApiRequest(definition, args, init); - const response = await this.fetch(request.url, request); + const {url, requestInit} = createApiRequest(definition, args, init); + const response = await this.fetch(url, requestInit); const apiResponse = new ApiResponse(definition, response.body, response); if (!apiResponse.ok) { diff --git a/packages/api/src/utils/client/request.ts b/packages/api/src/utils/client/request.ts index 2040f1bbc5a9..075566df1811 100644 --- a/packages/api/src/utils/client/request.ts +++ b/packages/api/src/utils/client/request.ts @@ -47,7 +47,7 @@ export function createApiRequest( definition: RouteDefinitionExtra, args: E["args"], init: ApiRequestInitRequired -): Request { +): {url: URL; requestInit: RequestInit} { const headers = new Headers(); let req: E["request"]; @@ -104,10 +104,15 @@ export function createApiRequest( } } - return new Request(url, { - ...init, - method: definition.method, - headers: mergeHeaders(headers, req.headers, init.headers), - body: req.body as BodyInit, - }); + // Keep the serialized body intact so fetch can determine its length. Passing a `Request` + // as `RequestInit` exposes its body as a stream, which Node.js sends with chunked encoding. + return { + url, + requestInit: { + ...init, + method: definition.method, + headers: mergeHeaders(headers, req.headers, init.headers), + body: req.body as BodyInit, + }, + }; } diff --git a/packages/api/test/unit/client/httpClient.test.ts b/packages/api/test/unit/client/httpClient.test.ts index 028278ad8ca2..1553c95d4358 100644 --- a/packages/api/test/unit/client/httpClient.test.ts +++ b/packages/api/test/unit/client/httpClient.test.ts @@ -297,6 +297,8 @@ describe("httpClient json client", () => { method: "POST", handler: async (req) => { expect(req.headers[HttpHeader.ContentType]).toBe(MediaType.ssz); + expect(req.headers["content-length"]).toBe(String(container.serialize(payload).byteLength)); + expect(req.headers["transfer-encoding"]).toBeUndefined(); expect(req.body).toBeInstanceOf(Uint8Array); expect(container.deserialize(req.body as Uint8Array)).toEqual(payload); }, From 65616ada6571992841771920e4d5ec608b48c0c5 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 15 Aug 2026 22:23:41 +0100 Subject: [PATCH 03/67] Revert "fix(api): preserve request content length" This reverts commit adc784306536be82419a146e0c9ee0809ceccc7c. --- packages/api/src/utils/client/httpClient.ts | 4 ++-- packages/api/src/utils/client/request.ts | 19 +++++++------------ .../api/test/unit/client/httpClient.test.ts | 2 -- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/api/src/utils/client/httpClient.ts b/packages/api/src/utils/client/httpClient.ts index df0f48d4479d..1f1b97163309 100644 --- a/packages/api/src/utils/client/httpClient.ts +++ b/packages/api/src/utils/client/httpClient.ts @@ -367,8 +367,8 @@ export class HttpClient implements IHttpClient { try { this.logger?.debug("API request", {routeId, requestWireFormat, responseWireFormat}); - const {url, requestInit} = createApiRequest(definition, args, init); - const response = await this.fetch(url, requestInit); + const request = createApiRequest(definition, args, init); + const response = await this.fetch(request.url, request); const apiResponse = new ApiResponse(definition, response.body, response); if (!apiResponse.ok) { diff --git a/packages/api/src/utils/client/request.ts b/packages/api/src/utils/client/request.ts index 075566df1811..2040f1bbc5a9 100644 --- a/packages/api/src/utils/client/request.ts +++ b/packages/api/src/utils/client/request.ts @@ -47,7 +47,7 @@ export function createApiRequest( definition: RouteDefinitionExtra, args: E["args"], init: ApiRequestInitRequired -): {url: URL; requestInit: RequestInit} { +): Request { const headers = new Headers(); let req: E["request"]; @@ -104,15 +104,10 @@ export function createApiRequest( } } - // Keep the serialized body intact so fetch can determine its length. Passing a `Request` - // as `RequestInit` exposes its body as a stream, which Node.js sends with chunked encoding. - return { - url, - requestInit: { - ...init, - method: definition.method, - headers: mergeHeaders(headers, req.headers, init.headers), - body: req.body as BodyInit, - }, - }; + return new Request(url, { + ...init, + method: definition.method, + headers: mergeHeaders(headers, req.headers, init.headers), + body: req.body as BodyInit, + }); } diff --git a/packages/api/test/unit/client/httpClient.test.ts b/packages/api/test/unit/client/httpClient.test.ts index 1553c95d4358..028278ad8ca2 100644 --- a/packages/api/test/unit/client/httpClient.test.ts +++ b/packages/api/test/unit/client/httpClient.test.ts @@ -297,8 +297,6 @@ describe("httpClient json client", () => { method: "POST", handler: async (req) => { expect(req.headers[HttpHeader.ContentType]).toBe(MediaType.ssz); - expect(req.headers["content-length"]).toBe(String(container.serialize(payload).byteLength)); - expect(req.headers["transfer-encoding"]).toBeUndefined(); expect(req.body).toBeInstanceOf(Uint8Array); expect(container.deserialize(req.body as Uint8Array)).toEqual(payload); }, From 4270c6f3d2621039efc9f365630aa94cdce5f9f6 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 15 Aug 2026 22:48:34 +0100 Subject: [PATCH 04/67] docs: fix builder option spellcheck --- .wordlist.txt | 1 + docs/pages/run/validator-management/proposer-config.md | 2 +- docs/pages/run/validator-management/vc-configuration.md | 4 ++-- packages/cli/src/cmds/validator/options.ts | 4 ++-- packages/validator/src/services/validatorStore.ts | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.wordlist.txt b/.wordlist.txt index 4610ec0ab3c3..75f0e53b44ae 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -173,6 +173,7 @@ getNetworkIdentity gloas gnosis gpg +gwei heapdump heaptrack holesky diff --git a/docs/pages/run/validator-management/proposer-config.md b/docs/pages/run/validator-management/proposer-config.md index 24d5ca276cab..19087b5da46d 100644 --- a/docs/pages/run/validator-management/proposer-config.md +++ b/docs/pages/run/validator-management/proposer-config.md @@ -36,7 +36,7 @@ default_config: boost_factor: "90" ``` -Starting with Gloas, the builder section additionally supports `min_bid` (floor in Gwei on the total payment accepted from a builder bid) and `max_execution_payment` (ceiling in Gwei on the trusted execution layer payment accepted from a builder, values above `0` require `--allowDangerousTrustedPayments`). +Starting with Gloas, the builder section additionally supports `min_bid` (floor in gwei on the total payment accepted from a builder bid) and `max_execution_payment` (ceiling in gwei on the trusted execution layer payment accepted from a builder, values above `0` require `--allowDangerousTrustedPayments`). ### Enable Proposer Configuration diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 60cafd09ed70..29bf9b10ce69 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -114,8 +114,8 @@ Example 3: Setting a `--builder.boostFactor=100` is the same as signaling `--bui Starting with Gloas, external builders are configured on the validator client and the beacon node requests bids on its behalf. Use [`--builder.urls`](./validator-cli.md#--builderurls) to name the builders to request bids from. Bids received over p2p are always considered alongside them, governed by the same selection settings. -- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. -- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei accepted from a builder. The default of `0` only accepts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. +- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. +- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in gwei accepted from a builder. The default of `0` only accepts payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. Builders can also be configured per validator key with per-builder overrides via the [Set Builders keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builders) or the [proposer configuration file](./proposer-config.md). diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 93431d8fd494..bb02ce852c68 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -283,7 +283,7 @@ export const validatorOptions: CliCommandOptions = { "builder.minBid": { type: "string", description: - "Minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Only used post-Gloas", + "Minimum total payment in gwei accepted from a builder bid, counting the bid value plus its execution payment. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMinBid}`, group: "builder", }, @@ -299,7 +299,7 @@ export const validatorOptions: CliCommandOptions = { "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. Values above 0 require --allowDangerousTrustedPayments. Only used post-Gloas", + "Maximum execution layer payment in gwei the proposer will accept from a builder. A value of 0 means only payments backed by the builder's staked collateral are accepted. Values above 0 require --allowDangerousTrustedPayments. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMaxExecutionPayment}`, group: "builder", }, diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 3e5262816783..cbfc5968dab2 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -164,7 +164,7 @@ export const defaultOptions = { builderAliasSelection: routes.validator.BuilderSelection.Default, builderBoostFactor: BigInt(100), builderMinBid: BigInt(0), - // Only trustless payments via the builder's staked collateral are accepted by default + // Only payments backed by the builder's staked collateral are accepted by default builderMaxExecutionPayment: BigInt(0), // spec asks for gossip validation by default broadcastValidation: routes.beacon.BroadcastValidation.gossip, From 67282d85e801b167c5f92db9bc007ce913e407c7 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 15 Aug 2026 22:51:52 +0100 Subject: [PATCH 05/67] docs: preserve builder payment terminology --- .wordlist.txt | 3 ++- docs/pages/run/validator-management/proposer-config.md | 2 +- docs/pages/run/validator-management/vc-configuration.md | 4 ++-- packages/cli/src/cmds/validator/options.ts | 4 ++-- packages/validator/src/services/validatorStore.ts | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.wordlist.txt b/.wordlist.txt index 75f0e53b44ae..e6465ad3356e 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -51,6 +51,7 @@ Golang Gossipsub Grafana Grandine +Gwei HTTPS HackMD Hashicorp @@ -173,7 +174,6 @@ getNetworkIdentity gloas gnosis gpg -gwei heapdump heaptrack holesky @@ -246,6 +246,7 @@ tcp testnet testnets todo +trustless typesafe udp unpkg diff --git a/docs/pages/run/validator-management/proposer-config.md b/docs/pages/run/validator-management/proposer-config.md index 19087b5da46d..24d5ca276cab 100644 --- a/docs/pages/run/validator-management/proposer-config.md +++ b/docs/pages/run/validator-management/proposer-config.md @@ -36,7 +36,7 @@ default_config: boost_factor: "90" ``` -Starting with Gloas, the builder section additionally supports `min_bid` (floor in gwei on the total payment accepted from a builder bid) and `max_execution_payment` (ceiling in gwei on the trusted execution layer payment accepted from a builder, values above `0` require `--allowDangerousTrustedPayments`). +Starting with Gloas, the builder section additionally supports `min_bid` (floor in Gwei on the total payment accepted from a builder bid) and `max_execution_payment` (ceiling in Gwei on the trusted execution layer payment accepted from a builder, values above `0` require `--allowDangerousTrustedPayments`). ### Enable Proposer Configuration diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 29bf9b10ce69..60cafd09ed70 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -114,8 +114,8 @@ Example 3: Setting a `--builder.boostFactor=100` is the same as signaling `--bui Starting with Gloas, external builders are configured on the validator client and the beacon node requests bids on its behalf. Use [`--builder.urls`](./validator-cli.md#--builderurls) to name the builders to request bids from. Bids received over p2p are always considered alongside them, governed by the same selection settings. -- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. -- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in gwei accepted from a builder. The default of `0` only accepts payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. +- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. +- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei accepted from a builder. The default of `0` only accepts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. Builders can also be configured per validator key with per-builder overrides via the [Set Builders keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builders) or the [proposer configuration file](./proposer-config.md). diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index bb02ce852c68..93431d8fd494 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -283,7 +283,7 @@ export const validatorOptions: CliCommandOptions = { "builder.minBid": { type: "string", description: - "Minimum total payment in gwei accepted from a builder bid, counting the bid value plus its execution payment. Only used post-Gloas", + "Minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMinBid}`, group: "builder", }, @@ -299,7 +299,7 @@ export const validatorOptions: CliCommandOptions = { "builder.maxExecutionPayment": { type: "string", description: - "Maximum execution layer payment in gwei the proposer will accept from a builder. A value of 0 means only payments backed by the builder's staked collateral are accepted. Values above 0 require --allowDangerousTrustedPayments. Only used post-Gloas", + "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. Values above 0 require --allowDangerousTrustedPayments. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMaxExecutionPayment}`, group: "builder", }, diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index cbfc5968dab2..3e5262816783 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -164,7 +164,7 @@ export const defaultOptions = { builderAliasSelection: routes.validator.BuilderSelection.Default, builderBoostFactor: BigInt(100), builderMinBid: BigInt(0), - // Only payments backed by the builder's staked collateral are accepted by default + // Only trustless payments via the builder's staked collateral are accepted by default builderMaxExecutionPayment: BigInt(0), // spec asks for gossip validation by default broadcastValidation: routes.beacon.BroadcastValidation.gossip, From 630636e8b44ecc28108c60771e585b22a9579d59 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 16 Aug 2026 11:53:59 +0100 Subject: [PATCH 06/67] include execution payment in payload value --- packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index fe8c4f80df4e..be55b22bdf2a 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -241,7 +241,7 @@ export async function produceBlockBody( const parentExecutionRequests = isExtendingPayload ? await this.getParentExecutionRequests(parentBlock.slot, parentBlock.blockRoot) : ssz.gloas.ExecutionRequests.defaultValue(); - executionPayloadValue = BigInt(builderBid.message.value) * GWEI_TO_WEI; + executionPayloadValue = (BigInt(builderBid.message.value) + builderBid.message.executionPayment) * GWEI_TO_WEI; const commonBlockBody = await commonBlockBodyPromise; const gloasBody = Object.assign({}, commonBlockBody) as gloas.BeaconBlockBody; From 3c8d67462b4269b9b354fbd147c6235fca1052ef Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 16 Aug 2026 16:42:07 +0100 Subject: [PATCH 07/67] review --- .../validator-management/proposer-config.md | 12 ++ packages/api/src/keymanager/index.ts | 8 +- packages/api/src/keymanager/routes.ts | 11 +- .../src/api/impl/validator/index.ts | 13 +-- .../chain/validation/executionPayloadBid.ts | 14 ++- .../api/impl/validator/produceBlockV4.test.ts | 58 +++++----- packages/cli/src/cmds/validator/handler.ts | 7 +- .../cli/src/cmds/validator/keymanager/impl.ts | 3 +- packages/cli/src/util/proposerConfig.ts | 103 +++++++++++++++--- .../validator/parseProposerConfig.test.ts | 12 +- .../proposerConfigs/duplicateBuilders.yaml | 6 + .../validator/proposerConfigs/validData.yaml | 8 +- .../validator/src/services/validatorStore.ts | 16 +-- .../test/unit/services/block.test.ts | 4 +- .../test/unit/validatorStore.test.ts | 46 ++++++-- 15 files changed, 245 insertions(+), 76 deletions(-) create mode 100644 packages/cli/test/unit/validator/proposerConfigs/duplicateBuilders.yaml diff --git a/docs/pages/run/validator-management/proposer-config.md b/docs/pages/run/validator-management/proposer-config.md index 24d5ca276cab..7497340325cd 100644 --- a/docs/pages/run/validator-management/proposer-config.md +++ b/docs/pages/run/validator-management/proposer-config.md @@ -38,6 +38,18 @@ default_config: Starting with Gloas, the builder section additionally supports `min_bid` (floor in Gwei on the total payment accepted from a builder bid) and `max_execution_payment` (ceiling in Gwei on the trusted execution layer payment accepted from a builder, values above `0` require `--allowDangerousTrustedPayments`). +The builder section also supports a `builders` list with the same per-builder entries as the keymanager builders API. Each entry has a required `url` and optional `auth_data`, `builder_pubkeys`, `max_execution_payment`, `min_bid` and `builder_boost_factor`. Multiple entries may share a `url` only if they have distinct `auth_data`. Per-key entries replace the builders the validator client is configured with; setting both `--builder.urls` and `builders` in `default_config` is an error. + +```yaml +builder: + min_bid: "10000000" + builders: + - url: "https://builder-a.example.com" + - url: "https://builder-b.example.com" + auth_data: "0x0123" + builder_boost_factor: "200" +``` + ### Enable Proposer Configuration After you have configured your proposer configuration YAML file, you can start Lodestar with an additional CLI flag option pointing to the file: `--proposerSettingsFile /path/to/proposer_config.yaml`. diff --git a/packages/api/src/keymanager/index.ts b/packages/api/src/keymanager/index.ts index 3e58a497c3ac..98fb1d6ea7b7 100644 --- a/packages/api/src/keymanager/index.ts +++ b/packages/api/src/keymanager/index.ts @@ -21,7 +21,13 @@ export type { SignerDefinition, SlashingProtectionData, } from "./routes.js"; -export {DeleteRemoteKeyStatus, DeletionStatus, ImportRemoteKeyStatus, ImportStatus} from "./routes.js"; +export { + DeleteRemoteKeyStatus, + DeletionStatus, + ImportRemoteKeyStatus, + ImportStatus, + builderConfigDataFromJson, +} from "./routes.js"; export type {ApiClient}; diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 0d73a88cfa79..6b0252ebcc59 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -1,5 +1,6 @@ import {ContainerType, ValueOf} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; +import {MAX_BUILDER_URL_SIZE} from "@lodestar/params"; import {Epoch, phase0, ssz, stringType} from "@lodestar/types"; import { EmptyArgs, @@ -118,13 +119,18 @@ export type BuilderConfigData = { const AUTH_DATA_PATTERN = /^0x(?:[a-fA-F0-9]{2}){1,4096}$/; const PUBKEY_PATTERN = /^0x[a-fA-F0-9]{96}$/; +const UINT64_MAX = 2n ** 64n - 1n; function parseGweiAmount(value: unknown, field: string): bigint | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || !/^\d+$/.test(value)) { throw Error(`${field} must be a string number without decimals`); } - return BigInt(value); + const parsed = BigInt(value); + if (parsed > UINT64_MAX) { + throw Error(`${field} must not exceed 2**64 - 1`); + } + return parsed; } export function builderConfigDataToJson(config: BuilderConfigData): Record { @@ -172,6 +178,9 @@ export function builderConfigDataFromJson(json: unknown): BuilderConfigData { if (typeof url !== "string" || url.length === 0) { throw Error(`builders[${i}].url must be a non-empty string`); } + if (new TextEncoder().encode(url).length > MAX_BUILDER_URL_SIZE) { + throw Error(`builders[${i}].url must not exceed ${MAX_BUILDER_URL_SIZE} bytes`); + } if (auth_data !== undefined && (typeof auth_data !== "string" || !AUTH_DATA_PATTERN.test(auth_data))) { throw Error(`builders[${i}].auth_data must be a non-empty hex string of at most 4096 bytes`); } diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 418db9ab08d2..a817d742e5cf 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -908,10 +908,12 @@ export function getValidatorApi( // Fire builder API bid requests while the local payload is built, one request per entry. // Any entry failure yields no bid and never fails block production. let builderApiBidsPromise: Promise = Promise.resolve([]); + let requestedFeeRecipient: Uint8Array | undefined; if (builderConfig.builders.length > 0 && !circuitBreakerActive) { try { const proposerIndex = chain.getHeadState().getBeaconProposer(slot); const proposerPubkey = chain.pubkeyCache.getOrThrow(proposerIndex).toBytes(); + requestedFeeRecipient = feeRecipient !== undefined ? fromHex(feeRecipient) : undefined; builderApiBidsPromise = chain.builderApiClient.getExecutionPayloadBids( builderConfig.builders, slot, @@ -982,6 +984,7 @@ export function getValidatorApi( parentBlock, parentBlockHash: bidParentBlockHash, parentBlockRoot: parentBlockRootHex, + feeRecipient: requestedFeeRecipient, entry, }); candidates.push({ @@ -1005,8 +1008,7 @@ export function getValidatorApi( }); } - const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => - (boostFactor * totalGwei) / BigInt(100); + const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => (boostFactor * totalGwei) / 100n; let best: BidCandidate | null = null; for (const candidate of candidates) { if (best === null || boostedValue(candidate) > boostedValue(best)) { @@ -1067,10 +1069,7 @@ export function getValidatorApi( // No need to wait for the bid block if the engine block will always be selected due to // suspected builder censorship, or a boost factor of 0 while no builder API bid may // still arrive with its own entry boost factor - if ( - engineBlock.shouldOverrideBuilder || - (builderConfig.builders.length === 0 && builderBoostFactor === BigInt(0)) - ) { + if (engineBlock.shouldOverrideBuilder || (builderConfig.builders.length === 0 && builderBoostFactor === 0n)) { controller.abort(); } return engineBlock; @@ -1114,7 +1113,7 @@ export function getValidatorApi( builderBoostFactor: bestCandidate?.boostFactor ?? builderBoostFactor, engineExecutionPayloadValue: engineResult.value.executionPayloadValue, // The bid total payment is its value plus executionPayment, in Gwei - builderExecutionPayloadValue: (bestCandidate?.totalGwei ?? BigInt(0)) * GWEI_TO_WEI, + builderExecutionPayloadValue: (bestCandidate?.totalGwei ?? 0n) * GWEI_TO_WEI, }); source = result.source; metrics?.blockProductionSelectionResults.inc(result); diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index 2021c5cd19fa..30de689746cf 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -103,11 +103,13 @@ export async function validateBuilderApiExecutionPayloadBid( parentBlock: ProtoBlock; parentBlockHash: RootHex; parentBlockRoot: RootHex; + /** Fee recipient explicitly requested by the caller, the bid must match it when set */ + feeRecipient?: Uint8Array; entry: routes.validator.BuilderEntry; } ): Promise { const bid = signedExecutionPayloadBid.message; - const {slot, parentBlock, parentBlockHash, parentBlockRoot, entry} = request; + const {slot, parentBlock, parentBlockHash, parentBlockRoot, feeRecipient, entry} = request; if (bid.slot !== slot) { throw Error(`Bid slot=${bid.slot} does not match requested slot=${slot}`); @@ -122,6 +124,12 @@ export async function validateBuilderApiExecutionPayloadBid( ); } + if (feeRecipient !== undefined && !byteArrayEquals(bid.feeRecipient, feeRecipient)) { + throw Error( + `Bid feeRecipient=${toHex(bid.feeRecipient)} does not match requested feeRecipient=${toHex(feeRecipient)}` + ); + } + if (bid.executionPayment > entry.maxExecutionPayment) { throw Error( `Bid executionPayment=${bid.executionPayment} exceeds maxExecutionPayment=${entry.maxExecutionPayment}` @@ -170,7 +178,9 @@ export async function validateBuilderApiExecutionPayloadBid( throw Error(`Bid has too many KZG commitments len=${blobKzgCommitmentsLen} limit=${maxBlobsPerBlock}`); } - if (!state.canBuilderCoverBid(bid.builderIndex, bid.value)) { + // The coverage check only applies to the staked collateral payment, a pure execution + // layer payment bid has nothing to cover on-chain + if (bid.value > 0 && !state.canBuilderCoverBid(bid.builderIndex, bid.value)) { throw Error(`Builder cannot cover bid value=${bid.value} balance=${builder.balance}`); } diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 7ba57e0cef7d..30d74a3ad09f 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -3,6 +3,7 @@ import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lo import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName} from "@lodestar/params"; import {ssz} from "@lodestar/types"; +import {fromHex} from "@lodestar/utils"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; import {validateBuilderApiExecutionPayloadBid} from "../../../../../src/chain/validation/executionPayloadBid.js"; @@ -38,7 +39,7 @@ describe("api/validator - produceBlockV4", () => { const maxBuilderBoostFactor = 2n ** 64n - 1n; function getBuilderConfig(overrides: {minBid?: bigint; builderBoostFactor?: bigint} = {}) { - return {minBid: BigInt(0), builderBoostFactor: BigInt(100), builders: [], ...overrides}; + return {minBid: 0n, builderBoostFactor: 100n, builders: [], ...overrides}; } const engineBlock = ssz.gloas.BeaconBlock.defaultValue(); @@ -73,8 +74,8 @@ describe("api/validator - produceBlockV4", () => { modules.chain.forkChoice.shouldBuildOnFull.mockReturnValue(true); modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ block: attrs.builderBid !== undefined ? bidBlock : engineBlock, - executionPayloadValue: BigInt(0), - consensusBlockValue: BigInt(0), + executionPayloadValue: 0n, + consensusBlockValue: 0n, })); }); @@ -111,8 +112,8 @@ describe("api/validator - produceBlockV4", () => { // Local payload value (2 gwei) exceeds the bid value (1 gwei) modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ block: attrs.builderBid !== undefined ? bidBlock : engineBlock, - executionPayloadValue: BigInt(2e9), - consensusBlockValue: BigInt(0), + executionPayloadValue: 2_000_000_000n, + consensusBlockValue: 0n, })); const {data: block, meta} = await api.produceBlockV4({ @@ -126,7 +127,7 @@ describe("api/validator - produceBlockV4", () => { expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); expect(block).toEqual(engineBlock); - expect(meta.executionPayloadValue).toBe(BigInt(2e9)); + expect(meta.executionPayloadValue).toBe(2_000_000_000n); }); it("prefers the local payload with a zero builder boost factor", async () => { @@ -139,7 +140,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderConfig: getBuilderConfig({builderBoostFactor: BigInt(0)}), + builderConfig: getBuilderConfig({builderBoostFactor: 0n}), }); expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); @@ -155,7 +156,7 @@ describe("api/validator - produceBlockV4", () => { throw new Error("Local block production failed"); } - return {block: bidBlock, executionPayloadValue: BigInt(0), consensusBlockValue: BigInt(0)}; + return {block: bidBlock, executionPayloadValue: 0n, consensusBlockValue: 0n}; }); const {data: block} = await api.produceBlockV4({ @@ -164,7 +165,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderConfig: getBuilderConfig({builderBoostFactor: BigInt(0)}), + builderConfig: getBuilderConfig({builderBoostFactor: 0n}), }); expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); @@ -183,7 +184,7 @@ describe("api/validator - produceBlockV4", () => { throw new Error("Invalid feeRecipient set in engine payload"); } - return {block: builderBlock, executionPayloadValue: BigInt(0), consensusBlockValue: BigInt(0)}; + return {block: builderBlock, executionPayloadValue: 0n, consensusBlockValue: 0n}; } ); @@ -194,7 +195,7 @@ describe("api/validator - produceBlockV4", () => { feeRecipient, strictFeeRecipientCheck: true, includePayload: false, - builderConfig: getBuilderConfig({builderBoostFactor: BigInt(0)}), + builderConfig: getBuilderConfig({builderBoostFactor: 0n}), }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); @@ -208,9 +209,9 @@ describe("api/validator - produceBlockV4", () => { url: new TextEncoder().encode(builderUrl), auth: ssz.gloas.SignedRequestAuth.defaultValue(), builderPubkeys: [], - maxExecutionPayment: BigInt(0), - minBid: BigInt(0), - builderBoostFactor: BigInt(100), + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, }; const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); apiBid.message.value = 2; @@ -230,11 +231,16 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderConfig: {minBid: BigInt(0), builderBoostFactor: BigInt(100), builders: [entry]}, + builderConfig: {minBid: 0n, builderBoostFactor: 100n, builders: [entry]}, }); expect(modules.chain.builderApiClient.getExecutionPayloadBids).toHaveBeenCalledOnce(); expect(validateBuilderApiExecutionPayloadBid).toHaveBeenCalledOnce(); + expect(validateBuilderApiExecutionPayloadBid).toHaveBeenCalledWith( + modules.chain, + apiBid, + expect.objectContaining({feeRecipient: fromHex(feeRecipient)}) + ); // The bid block commits to the builder API bid since its boosted total (2) beats the p2p bid (1) expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); expect(block).toEqual(bidBlock); @@ -250,9 +256,9 @@ describe("api/validator - produceBlockV4", () => { url: new TextEncoder().encode(builderUrl), auth: ssz.gloas.SignedRequestAuth.defaultValue(), builderPubkeys: [], - maxExecutionPayment: BigInt(0), - minBid: BigInt(0), - builderBoostFactor: BigInt(100), + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, }; // Same bid value as the p2p bid, the builder API copy must win the tie const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); @@ -272,7 +278,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderConfig: {minBid: BigInt(0), builderBoostFactor: BigInt(100), builders: [entry]}, + builderConfig: {minBid: 0n, builderBoostFactor: 100n, builders: [entry]}, }); expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); @@ -286,9 +292,9 @@ describe("api/validator - produceBlockV4", () => { url: new TextEncoder().encode(builderUrl), auth: ssz.gloas.SignedRequestAuth.defaultValue(), builderPubkeys: [], - maxExecutionPayment: BigInt(0), - minBid: BigInt(0), - builderBoostFactor: BigInt(100), + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, }; const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); apiBid.message.value = 2; @@ -308,7 +314,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderConfig: {minBid: BigInt(0), builderBoostFactor: BigInt(100), builders: [entry]}, + builderConfig: {minBid: 0n, builderBoostFactor: 100n, builders: [entry]}, }); expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid})); @@ -327,7 +333,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderConfig: getBuilderConfig({minBid: BigInt(2)}), + builderConfig: getBuilderConfig({minBid: 2n}), }); expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); @@ -377,8 +383,8 @@ describe("api/validator - produceBlockV4", () => { // Bid (1 gwei) is preferred over the higher local payload value (2 gwei) modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ block: attrs.builderBid !== undefined ? bidBlock : engineBlock, - executionPayloadValue: BigInt(2e9), - consensusBlockValue: BigInt(0), + executionPayloadValue: 2_000_000_000n, + consensusBlockValue: 0n, })); const {data: block} = await api.produceBlockV4({ diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index 0dd4a4418a41..4c8d3d35ece1 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -288,7 +288,12 @@ function getProposerConfigFromArgs( // explicit opt-in before any configuration source can set a max execution payment above 0 if (args.allowDangerousTrustedPayments !== true) { const configs = [valProposerConfig.defaultConfig, ...Object.values(valProposerConfig.proposerConfig ?? {})]; - if (configs.some((config) => (config.builder?.maxExecutionPayment ?? BigInt(0)) > BigInt(0))) { + const hasTrustedPayment = configs.some( + (config) => + (config.builder?.maxExecutionPayment ?? 0n) > 0n || + (config.builder?.builders ?? []).some((entry) => (entry.maxExecutionPayment ?? 0n) > 0n) + ); + if (hasTrustedPayment) { throw new YargsError( "Configuring a builder max execution payment above 0 requires --allowDangerousTrustedPayments" ); diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index 7582a7d11be6..e5fc0783a67d 100644 --- a/packages/cli/src/cmds/validator/keymanager/impl.ts +++ b/packages/cli/src/cmds/validator/keymanager/impl.ts @@ -398,7 +398,7 @@ export class KeymanagerApi implements Api { if ( this.allowDangerousTrustedPayments !== true && - builderConfig.builders?.some((entry) => (entry.maxExecutionPayment ?? BigInt(0)) > BigInt(0)) + builderConfig.builders?.some((entry) => (entry.maxExecutionPayment ?? 0n) > 0n) ) { throw new ApiError( 400, @@ -417,6 +417,7 @@ export class KeymanagerApi implements Api { async deleteBuilders({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); + this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.deleteBuilderConfig(pubkey); this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); return {status: 204}; diff --git a/packages/cli/src/util/proposerConfig.ts b/packages/cli/src/util/proposerConfig.ts index 0e9e625cf2e1..d38dca59d7f7 100644 --- a/packages/cli/src/util/proposerConfig.ts +++ b/packages/cli/src/util/proposerConfig.ts @@ -1,10 +1,15 @@ import fs from "node:fs"; import path from "node:path"; import {routes} from "@lodestar/api"; +import {BuilderEntryConfig, builderConfigDataFromJson} from "@lodestar/api/keymanager"; +import {MAX_BUILDER_ENTRIES, MAX_BUILDER_URL_SIZE} from "@lodestar/params"; +import {fromHex, toHex} from "@lodestar/utils"; import {ValidatorProposerConfig} from "@lodestar/validator"; import {parseFeeRecipient} from "./feeRecipient.js"; import {readFile} from "./file.js"; +const UINT64_MAX = 2n ** 64n - 1n; + type ProposerConfig = ValidatorProposerConfig["defaultConfig"]; type ProposerConfigFileSection = { @@ -19,6 +24,7 @@ type ProposerConfigFileSection = { boost_factor?: bigint; min_bid?: bigint; max_execution_payment?: bigint; + builders?: unknown; }; }; @@ -56,7 +62,14 @@ function parseProposerConfigSection( overrideConfig?: ProposerConfig ): ProposerConfig { const {graffiti, strict_fee_recipient_check, fee_recipient, builder} = proposerFileSection; - const {gas_limit, selection: builderSelection, boost_factor, min_bid, max_execution_payment} = builder || {}; + const { + gas_limit, + selection: builderSelection, + boost_factor, + min_bid, + max_execution_payment, + builders, + } = builder || {}; if (graffiti !== undefined && typeof graffiti !== "string") { throw Error("graffiti is not 'string"); @@ -88,24 +101,31 @@ function parseProposerConfigSection( throw Error("max_execution_payment is not 'string"); } + const parsedBuilder = + overrideConfig?.builder || builder + ? { + gasLimit: overrideConfig?.builder?.gasLimit ?? (gas_limit !== undefined ? Number(gas_limit) : undefined), + selection: overrideConfig?.builder?.selection ?? parseBuilderSelection(builderSelection), + boostFactor: overrideConfig?.builder?.boostFactor ?? parseBuilderBoostFactor(boost_factor), + minBid: overrideConfig?.builder?.minBid ?? parseBuilderMinBid(min_bid), + maxExecutionPayment: + overrideConfig?.builder?.maxExecutionPayment ?? parseBuilderGweiAmount(max_execution_payment), + urls: overrideConfig?.builder?.urls, + builders: overrideConfig?.builder?.builders ?? parseBuilderEntries(builders), + } + : undefined; + + if (parsedBuilder?.urls !== undefined && parsedBuilder?.builders !== undefined) { + throw Error("Cannot configure both --builder.urls and builders in the proposer settings file"); + } + return { graffiti: overrideConfig?.graffiti ?? graffiti, strictFeeRecipientCheck: overrideConfig?.strictFeeRecipientCheck ?? (strict_fee_recipient_check ? stringtoBool(strict_fee_recipient_check) : undefined), feeRecipient: overrideConfig?.feeRecipient ?? (fee_recipient ? parseFeeRecipient(fee_recipient) : undefined), - builder: - overrideConfig?.builder || builder - ? { - gasLimit: overrideConfig?.builder?.gasLimit ?? (gas_limit !== undefined ? Number(gas_limit) : undefined), - selection: overrideConfig?.builder?.selection ?? parseBuilderSelection(builderSelection), - boostFactor: overrideConfig?.builder?.boostFactor ?? parseBuilderBoostFactor(boost_factor), - minBid: overrideConfig?.builder?.minBid ?? parseBuilderMinBid(min_bid), - maxExecutionPayment: - overrideConfig?.builder?.maxExecutionPayment ?? parseBuilderGweiAmount(max_execution_payment), - urls: overrideConfig?.builder?.urls, - } - : undefined, + builder: parsedBuilder, }; } @@ -156,8 +176,12 @@ export function parseBuilderBoostFactor(boostFactor?: string): bigint | undefine if (!/^\d+$/.test(boostFactor)) { throw Error("Invalid input for builder boost factor, must be a valid number without decimals"); } + const parsed = BigInt(boostFactor); + if (parsed > UINT64_MAX) { + throw Error("Invalid input for builder boost factor, must not exceed 2**64 - 1"); + } - return BigInt(boostFactor); + return parsed; } export function parseBuilderMinBid(minBid?: string | bigint): bigint | undefined { @@ -166,8 +190,12 @@ export function parseBuilderMinBid(minBid?: string | bigint): bigint | undefined if (!/^\d+$/.test(minBid.toString())) { throw Error("Invalid input for builder min bid, must be a valid number without decimals"); } + const parsed = BigInt(minBid); + if (parsed > UINT64_MAX) { + throw Error("Invalid input for builder min bid, must not exceed 2**64 - 1"); + } - return BigInt(minBid); + return parsed; } export function parseBuilderGweiAmount(amount?: string | bigint): bigint | undefined { @@ -176,19 +204,60 @@ export function parseBuilderGweiAmount(amount?: string | bigint): bigint | undef if (!/^\d+$/.test(amount.toString())) { throw Error("Invalid input for builder Gwei amount, must be a valid number without decimals"); } + const parsed = BigInt(amount); + if (parsed > UINT64_MAX) { + throw Error("Invalid input for builder Gwei amount, must not exceed 2**64 - 1"); + } + + return parsed; +} - return BigInt(amount); +/** + * Parse per-builder entries from the proposer settings file, same shape and validation as the + * keymanager builders api. No two entries may share both their url and their auth data, an + * omitted auth data is compared as the value derived from the entry url. + */ +export function parseBuilderEntries(builders?: unknown): BuilderEntryConfig[] | undefined { + if (builders === undefined) return undefined; + + const {builders: entries} = builderConfigDataFromJson({builders}); + const seenEntries = new Set(); + for (const entry of entries ?? []) { + try { + new URL(entry.url); + } catch { + throw Error(`Invalid builder url: ${entry.url}`); + } + const authData = entry.authData !== undefined ? toHex(fromHex(entry.authData)) : toHex(Buffer.from(entry.url)); + const entryKey = `${entry.url}|${authData}`; + if (seenEntries.has(entryKey)) { + throw Error(`Duplicate builder entry url=${entry.url}`); + } + seenEntries.add(entryKey); + } + return entries; } export function parseBuilderUrls(urls?: string[]): string[] | undefined { if (urls === undefined) return undefined; + const seen = new Set(); for (const url of urls) { try { new URL(url); } catch { throw Error(`Invalid builder url: ${url}`); } + if (Buffer.byteLength(url, "utf8") > MAX_BUILDER_URL_SIZE) { + throw Error(`Invalid builder url, must not exceed ${MAX_BUILDER_URL_SIZE} bytes: ${url}`); + } + if (seen.has(url)) { + throw Error(`Duplicate builder url: ${url}`); + } + seen.add(url); + } + if (urls.length > MAX_BUILDER_ENTRIES) { + throw Error(`Number of builder urls must not exceed ${MAX_BUILDER_ENTRIES}, got ${urls.length}`); } - return [...new Set(urls)]; + return urls; } diff --git a/packages/cli/test/unit/validator/parseProposerConfig.test.ts b/packages/cli/test/unit/validator/parseProposerConfig.test.ts index c1cd1d2dfb13..f11ece5fd23b 100644 --- a/packages/cli/test/unit/validator/parseProposerConfig.test.ts +++ b/packages/cli/test/unit/validator/parseProposerConfig.test.ts @@ -25,7 +25,11 @@ const testValue = { builder: { gasLimit: 35000000, selection: routes.validator.BuilderSelection.BuilderAlways, - boostFactor: 18446744073709551616n, + boostFactor: 18446744073709551615n, + builders: [ + {url: "https://builder-a.example.com", minBid: 1000n}, + {url: "https://builder-a.example.com", authData: "0x0123", builderBoostFactor: 200n}, + ], }, }, }, @@ -51,4 +55,10 @@ describe("validator / invalid Proposer", () => { it("should throw error", () => { expect(() => parseProposerConfig(path.join(__dirname, "./proposerConfigs/invalidData.yaml"))).toThrow(); }); + + it("should throw on duplicate builder entries", () => { + expect(() => parseProposerConfig(path.join(__dirname, "./proposerConfigs/duplicateBuilders.yaml"))).toThrow( + /Duplicate builder entry/ + ); + }); }); diff --git a/packages/cli/test/unit/validator/proposerConfigs/duplicateBuilders.yaml b/packages/cli/test/unit/validator/proposerConfigs/duplicateBuilders.yaml new file mode 100644 index 000000000000..0ea22844aab3 --- /dev/null +++ b/packages/cli/test/unit/validator/proposerConfigs/duplicateBuilders.yaml @@ -0,0 +1,6 @@ +proposer_config: + "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c": + builder: + builders: + - url: "https://builder.example.com" + - url: "https://builder.example.com" diff --git a/packages/cli/test/unit/validator/proposerConfigs/validData.yaml b/packages/cli/test/unit/validator/proposerConfigs/validData.yaml index 2ba508454d0f..a9b7ad1794e7 100644 --- a/packages/cli/test/unit/validator/proposerConfigs/validData.yaml +++ b/packages/cli/test/unit/validator/proposerConfigs/validData.yaml @@ -10,7 +10,13 @@ proposer_config: builder: gas_limit: "35000000" selection: "builderalways" - boost_factor: "18446744073709551616" + boost_factor: "18446744073709551615" + builders: + - url: "https://builder-a.example.com" + min_bid: "1000" + - url: "https://builder-a.example.com" + auth_data: "0x0123" + builder_boost_factor: "200" default_config: graffiti: "default graffiti" strict_fee_recipient_check: "true" diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 3e5262816783..075d96683545 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -90,6 +90,7 @@ type DefaultProposerConfig = { minBid: bigint; maxExecutionPayment: bigint; urls: string[]; + builders?: BuilderEntryConfig[]; }; }; @@ -162,10 +163,10 @@ export const defaultOptions = { defaultGasLimit: 60_000_000, builderSelection: routes.validator.BuilderSelection.ExecutionOnly, builderAliasSelection: routes.validator.BuilderSelection.Default, - builderBoostFactor: BigInt(100), - builderMinBid: BigInt(0), + builderBoostFactor: 100n, + builderMinBid: 0n, // Only trustless payments via the builder's staked collateral are accepted by default - builderMaxExecutionPayment: BigInt(0), + builderMaxExecutionPayment: 0n, // spec asks for gossip validation by default broadcastValidation: routes.beacon.BroadcastValidation.gossip, // should request fetching the locally produced block in blinded format @@ -214,6 +215,7 @@ export class ValidatorStore { minBid: defaultConfig.builder?.minBid ?? defaultOptions.builderMinBid, maxExecutionPayment: defaultConfig.builder?.maxExecutionPayment ?? defaultOptions.builderMaxExecutionPayment, urls: defaultConfig.builder?.urls ?? [], + builders: defaultConfig.builder?.builders, }, }; @@ -463,9 +465,9 @@ export class ValidatorStore { } /** - * Resolve the builder entries for this key. Per-key entries set via the keymanager api replace - * the configured builder urls, an omitted entry value takes this key's default and then the - * validator client's own configuration. An omitted auth data is derived from the entry url. + * Resolve the builder entries for this key. Per-key entries replace the validator client's + * builders, an omitted entry value takes this key's default and then the validator client's + * own configuration. An omitted auth data is derived from the entry url. */ getResolvedBuilderEntries(pubkeyHex: PubkeyHex, boostFactor?: bigint): ResolvedBuilderEntry[] { const validatorData = this.validators.get(pubkeyHex); @@ -479,7 +481,7 @@ export class ValidatorStore { const keyMaxExecutionPayment = validatorData.builder?.maxExecutionPayment ?? this.defaultProposerConfig.builder.maxExecutionPayment; - const builders = validatorData.builder?.builders; + const builders = validatorData.builder?.builders ?? this.defaultProposerConfig.builder.builders; if (builders !== undefined) { return builders.map((entry) => ({ url: entry.url, diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index 0ed4e6e5e222..c65e74ec3d63 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -239,7 +239,7 @@ describe("BlockDutiesService", () => { selection: routes.validator.BuilderSelection.ExecutionAlways, boostFactor: BigInt(0), }); - validatorStore.getBuilderMinBid.mockReturnValue(BigInt(0)); + validatorStore.getBuilderMinBid.mockReturnValue(0n); validatorStore.getResolvedBuilderEntries.mockReturnValue([]); validatorStore.getGraffiti.mockReturnValue("aaaa"); validatorStore.getFeeRecipient.mockReturnValue(feeRecipient); @@ -269,7 +269,7 @@ describe("BlockDutiesService", () => { feeRecipient, strictFeeRecipientCheck: true, includePayload: true, - builderConfig: {minBid: BigInt(0), builderBoostFactor: BigInt(0), builders: []}, + builderConfig: {minBid: 0n, builderBoostFactor: 0n, builders: []}, }); }); }); diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index 318f2762dbd7..e9dd78abc79a 100644 --- a/packages/validator/test/unit/validatorStore.test.ts +++ b/packages/validator/test/unit/validatorStore.test.ts @@ -231,10 +231,10 @@ describe("ValidatorStore", () => { expect(validatorStore.getResolvedBuilderEntries(pubkey)).toEqual([]); validatorStore.setBuilderConfig(pubkey, { - minBid: BigInt(10), + minBid: 10n, builders: [ - {url: builderUrl, maxExecutionPayment: BigInt(5)}, - {url: builderUrl, authData: "0x1234", minBid: BigInt(20), builderBoostFactor: BigInt(120)}, + {url: builderUrl, maxExecutionPayment: 5n}, + {url: builderUrl, authData: "0x1234", minBid: 20n, builderBoostFactor: 120n}, ], }); @@ -242,16 +242,16 @@ describe("ValidatorStore", () => { expect(entries).toHaveLength(2); // Omitted auth data derives from the entry url, omitted min bid takes the key default expect(Buffer.from(entries[0].authData).toString("utf8")).toBe(builderUrl); - expect(entries[0].minBid).toBe(BigInt(10)); - expect(entries[0].maxExecutionPayment).toBe(BigInt(5)); + expect(entries[0].minBid).toBe(10n); + expect(entries[0].maxExecutionPayment).toBe(5n); // Per-entry values win over the key defaults expect(toHexString(entries[1].authData)).toBe("0x1234"); - expect(entries[1].minBid).toBe(BigInt(20)); - expect(entries[1].builderBoostFactor).toBe(BigInt(120)); + expect(entries[1].minBid).toBe(20n); + expect(entries[1].builderBoostFactor).toBe(120n); // GET returns the configuration fully resolved const config = validatorStore.getBuilderConfig(pubkey); - expect(config.minBid).toBe(BigInt(10)); + expect(config.minBid).toBe(10n); expect(config.builders?.[0].authData).toBeDefined(); expect(config.builders?.[0].builderPubkeys).toEqual([]); @@ -265,7 +265,35 @@ describe("ValidatorStore", () => { // Delete reverts the key to the validator client's own configuration validatorStore.deleteBuilderConfig(pubkey); expect(validatorStore.getResolvedBuilderEntries(pubkey)).toEqual([]); - expect(validatorStore.getBuilderMinBid(pubkey)).toBe(BigInt(0)); + expect(validatorStore.getBuilderMinBid(pubkey)).toBe(0n); + }); + + it("Should resolve the validator client's default builder entries with key defaults applied", async () => { + const pubkey = toHexString(pubkeys[0]); + const builderUrl = "https://builder.example.com"; + const store = await initValidatorStore(secretKeys, api, chainConfig, { + proposerConfig: { + [pubkey]: { + builder: {minBid: 30n}, + }, + }, + defaultConfig: { + builder: { + builders: [{url: builderUrl, builderBoostFactor: 150n}], + }, + }, + }); + + // A key without its own builders follows the default entries, its key defaults still apply + const entries = store.getResolvedBuilderEntries(pubkey); + expect(entries).toHaveLength(1); + expect(Buffer.from(entries[0].authData).toString("utf8")).toBe(builderUrl); + expect(entries[0].minBid).toBe(30n); + expect(entries[0].builderBoostFactor).toBe(150n); + + // Per-key builders replace the default entries + store.setBuilderConfig(pubkey, {builders: []}); + expect(store.getResolvedBuilderEntries(pubkey)).toEqual([]); }); }); From 95bd6cc0f2c94e8d9b5081e532260e8ccedf0b32 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 16 Aug 2026 18:09:12 +0100 Subject: [PATCH 08/67] prefer max boost builder bids --- .../src/api/impl/validator/index.ts | 9 ++++- .../api/impl/validator/produceBlockV4.test.ts | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index a817d742e5cf..7cc7e23352e8 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1011,7 +1011,14 @@ export function getValidatorApi( const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => (boostFactor * totalGwei) / 100n; let best: BidCandidate | null = null; for (const candidate of candidates) { - if (best === null || boostedValue(candidate) > boostedValue(best)) { + const candidateIsMaxBoost = candidate.boostFactor === MAX_BUILDER_BOOST_FACTOR; + const bestIsMaxBoost = best?.boostFactor === MAX_BUILDER_BOOST_FACTOR; + // Preserve max boost preference before comparing bid values + if ( + best === null || + (candidateIsMaxBoost && !bestIsMaxBoost) || + (candidateIsMaxBoost === bestIsMaxBoost && boostedValue(candidate) > boostedValue(best)) + ) { best = candidate; } } diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 30d74a3ad09f..1bf5df08ebd3 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -286,6 +286,42 @@ describe("api/validator - produceBlockV4", () => { expect(modules.chain.builderApiClient.recordBidSource).toHaveBeenCalledOnce(); }); + it("prefers a max boost builder entry before comparing bid values", async () => { + const builderUrl = "https://builder.example.com"; + const entry = { + url: new TextEncoder().encode(builderUrl), + auth: ssz.gloas.SignedRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: maxBuilderBoostFactor, + }; + const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + apiBid.message.value = 0; + const p2pBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + p2pBid.message.value = 1; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); + modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ + {url: builderUrl, entry, signedBid: apiBid}, + ]); + + await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: 0n, builderBoostFactor: 100n, builders: [entry]}, + }); + + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); + expect(modules.chain.builderApiClient.recordBidSource).toHaveBeenCalledOnce(); + }); + it("falls back to the p2p bid when the builder API bid fails validation", async () => { const builderUrl = "https://builder.example.com"; const entry = { From 05de82d5ad127201426148cb39f1d6533cf0f2d6 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 16 Aug 2026 18:21:30 +0100 Subject: [PATCH 09/67] handle invalid builder urls per entry --- packages/api/src/beacon/routes/validator.ts | 17 ++---- .../test/unit/beacon/builderConfig.test.ts | 8 +++ .../src/api/impl/validator/index.ts | 4 +- .../src/execution/builder/apiClient.ts | 2 +- .../test/mocks/mockedBeaconChain.ts | 1 + .../submitBuilderPreferences.test.ts | 48 +++++++++++++++++ .../unit/execution/builder/apiClient.test.ts | 52 +++++++++++++++++++ 7 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 packages/api/test/unit/beacon/builderConfig.test.ts create mode 100644 packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts create mode 100644 packages/beacon-node/test/unit/execution/builder/apiClient.test.ts diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 7a7d73ad831b..96d7312a924c 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -273,8 +273,8 @@ export const SignedProposerPreferencesListType = ArrayOf( // The url is UTF-8 bytes on the SSZ wire but a plain string in JSON class BuilderUrlType extends ByteListType { fromJson(json: unknown): Uint8Array { - if (typeof json !== "string" || json.length === 0) { - throw Error("Builder url must be a non-empty string"); + if (typeof json !== "string") { + throw Error("Builder url must be a string"); } return new TextEncoder().encode(json); } @@ -1025,7 +1025,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { + it("decodes an empty url so the beacon node can reject only that entry", () => { + expect(BuilderEntryType.fields.url.fromJson("")).toEqual(new Uint8Array()); + }); +}); diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 7cc7e23352e8..db33aa140235 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -2027,8 +2027,10 @@ export function getValidatorApi( await Promise.all( builderPreferences.map(async (entry, i) => { const url = Buffer.from(entry.url).toString("utf8"); + let builder = ""; try { new URL(url); + builder = toPrintableUrl(url); await chain.builderApiClient.submitBuilderPreferences(url, entry.proposerPubkey, { preferences: {maxExecutionPayment: entry.maxExecutionPayment}, auth: entry.auth, @@ -2037,7 +2039,7 @@ export function getValidatorApi( failures.push({index: i, message: (e as Error).message}); logger.verbose( `Error on submitBuilderPreferences [${i}]`, - {slot: entry.auth.message.slot, builder: toPrintableUrl(url)}, + {slot: entry.auth.message.slot, builder}, e as Error ); } diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index c643528e5aba..0bb1f65be720 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -80,7 +80,7 @@ export class BuilderApiClient { try { new URL(url); } catch { - this.logger?.warn("Ignoring builder entry with invalid url", {slot, url: toPrintableUrl(url)}); + this.logger?.warn("Ignoring builder entry with invalid url", {slot, url: ""}); continue; } diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index fa77a3009e6f..00327e9b889b 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -163,6 +163,7 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { }, builderApiClient: { getExecutionPayloadBids: vi.fn().mockResolvedValue([]), + submitBuilderPreferences: vi.fn(), submitSignedBeaconBlock: vi.fn(), recordBidSource: vi.fn(), getBidSource: vi.fn(), diff --git a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts new file mode 100644 index 000000000000..2ec8b077176b --- /dev/null +++ b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts @@ -0,0 +1,48 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {ssz} from "@lodestar/types"; +import {IndexedError} from "../../../../../src/api/impl/errors.js"; +import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; +import {defaultApiOptions} from "../../../../../src/api/options.js"; +import {ApiTestModules, getApiTestModules} from "../../../../utils/api.js"; + +describe("api/validator - submitBuilderPreferences", () => { + let modules: ApiTestModules; + let api: ReturnType; + + beforeEach(() => { + modules = getApiTestModules(); + api = getValidatorApi(defaultApiOptions, modules); + }); + + it("reports an invalid url by index while submitting the other entries", async () => { + const validEntry = getEntry("https://builder.example.com"); + const invalidEntry = getEntry(""); + + let error: unknown; + try { + await api.submitBuilderPreferences({builderPreferences: [invalidEntry, validEntry]}); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(IndexedError); + expect((error as IndexedError).failures).toEqual([{index: 0, message: "Invalid URL"}]); + expect(modules.chain.builderApiClient.submitBuilderPreferences).toHaveBeenCalledOnce(); + expect(modules.chain.builderApiClient.submitBuilderPreferences).toHaveBeenCalledWith( + "https://builder.example.com", + validEntry.proposerPubkey, + {preferences: {maxExecutionPayment: 0n}, auth: validEntry.auth} + ); + }); +}); + +function getEntry(url: string) { + const auth = ssz.gloas.SignedRequestAuth.defaultValue(); + auth.message.slot = 1; + return { + proposerPubkey: new Uint8Array(48), + url: new TextEncoder().encode(url), + auth, + maxExecutionPayment: 0n, + }; +} diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts new file mode 100644 index 000000000000..2436b68a98b9 --- /dev/null +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -0,0 +1,52 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; +import {config} from "@lodestar/config/default"; +import {ssz} from "@lodestar/types"; +import {BuilderApiClient} from "../../../../src/execution/builder/apiClient.js"; +import {getMockedLogger} from "../../../mocks/loggerMock.js"; + +const {getExecutionPayloadBid} = vi.hoisted(() => ({getExecutionPayloadBid: vi.fn()})); + +vi.mock("@lodestar/api/builder", () => ({ + getClient: () => ({getExecutionPayloadBid}), +})); + +describe("execution/builder/apiClient", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("ignores an entry with an empty url without failing the other entries", async () => { + const slot = 1; + const validEntry = getBuilderEntry("https://builder.example.com", slot); + const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); + + const client = new BuilderApiClient({}, config, null, getMockedLogger()); + const bids = await client.getExecutionPayloadBids( + [getBuilderEntry("", slot), validEntry], + slot, + new Uint8Array(32), + new Uint8Array(32), + new Uint8Array(48), + 1_000 + ); + + expect(bids).toEqual([{url: "https://builder.example.com", entry: validEntry, signedBid}]); + expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); + }); +}); + +function getBuilderEntry(url: string, slot: number): routes.validator.BuilderEntry { + const auth = ssz.gloas.SignedRequestAuth.defaultValue(); + auth.message.data = new Uint8Array([1]); + auth.message.slot = slot; + return { + url: new TextEncoder().encode(url), + auth, + builderPubkeys: [], + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, + }; +} From a581cd169a1d81dbe604bc1ad14520748bd209c4 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 16 Aug 2026 18:24:36 +0100 Subject: [PATCH 10/67] log invalid builder urls --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- packages/beacon-node/src/execution/builder/apiClient.ts | 2 +- .../api/impl/validator/submitBuilderPreferences.test.ts | 8 +++++++- .../test/unit/execution/builder/apiClient.test.ts | 9 ++++++--- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index db33aa140235..918e1227605a 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -2027,7 +2027,7 @@ export function getValidatorApi( await Promise.all( builderPreferences.map(async (entry, i) => { const url = Buffer.from(entry.url).toString("utf8"); - let builder = ""; + let builder = url; try { new URL(url); builder = toPrintableUrl(url); diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index 0bb1f65be720..ed4b59d6ab3a 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -80,7 +80,7 @@ export class BuilderApiClient { try { new URL(url); } catch { - this.logger?.warn("Ignoring builder entry with invalid url", {slot, url: ""}); + this.logger?.warn("Ignoring builder entry with invalid url", {slot, url}); continue; } diff --git a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts index 2ec8b077176b..edf67d915f0d 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts @@ -15,8 +15,9 @@ describe("api/validator - submitBuilderPreferences", () => { }); it("reports an invalid url by index while submitting the other entries", async () => { + const invalidUrl = "not a url"; const validEntry = getEntry("https://builder.example.com"); - const invalidEntry = getEntry(""); + const invalidEntry = getEntry(invalidUrl); let error: unknown; try { @@ -33,6 +34,11 @@ describe("api/validator - submitBuilderPreferences", () => { validEntry.proposerPubkey, {preferences: {maxExecutionPayment: 0n}, auth: validEntry.auth} ); + expect(modules.logger.verbose).toHaveBeenCalledWith( + "Error on submitBuilderPreferences [0]", + {slot: 1, builder: invalidUrl}, + expect.any(Error) + ); }); }); diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index 2436b68a98b9..e97069d9d1b4 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -16,15 +16,17 @@ describe("execution/builder/apiClient", () => { vi.clearAllMocks(); }); - it("ignores an entry with an empty url without failing the other entries", async () => { + it("ignores an entry with an invalid url without failing the other entries", async () => { const slot = 1; + const invalidUrl = "not a url"; const validEntry = getBuilderEntry("https://builder.example.com", slot); const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); - const client = new BuilderApiClient({}, config, null, getMockedLogger()); + const logger = getMockedLogger(); + const client = new BuilderApiClient({}, config, null, logger); const bids = await client.getExecutionPayloadBids( - [getBuilderEntry("", slot), validEntry], + [getBuilderEntry(invalidUrl, slot), validEntry], slot, new Uint8Array(32), new Uint8Array(32), @@ -34,6 +36,7 @@ describe("execution/builder/apiClient", () => { expect(bids).toEqual([{url: "https://builder.example.com", entry: validEntry, signedBid}]); expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith("Ignoring builder entry with invalid url", {slot, url: invalidUrl}); }); }); From c80b4ee1a53daecd00b6ff4c74a34b13d3482092 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 16 Aug 2026 18:32:07 +0100 Subject: [PATCH 11/67] enforce builder url byte limit --- packages/api/src/beacon/routes/validator.ts | 6 +++++- packages/api/test/unit/beacon/builderConfig.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 96d7312a924c..e7298e9d9914 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -276,7 +276,11 @@ class BuilderUrlType extends ByteListType { if (typeof json !== "string") { throw Error("Builder url must be a string"); } - return new TextEncoder().encode(json); + const value = new TextEncoder().encode(json); + if (value.length > this.limitBytes) { + throw Error(`Builder url must not exceed ${this.limitBytes} bytes`); + } + return value; } toJson(value: Uint8Array): unknown { return new TextDecoder().decode(value); diff --git a/packages/api/test/unit/beacon/builderConfig.test.ts b/packages/api/test/unit/beacon/builderConfig.test.ts index 358b7a492cdf..bc0b59b274e1 100644 --- a/packages/api/test/unit/beacon/builderConfig.test.ts +++ b/packages/api/test/unit/beacon/builderConfig.test.ts @@ -5,4 +5,10 @@ describe("BuilderEntryType", () => { it("decodes an empty url so the beacon node can reject only that entry", () => { expect(BuilderEntryType.fields.url.fromJson("")).toEqual(new Uint8Array()); }); + + it("enforces the SSZ url byte limit for JSON", () => { + expect(() => BuilderEntryType.fields.url.fromJson("é".repeat(1025))).toThrow( + "Builder url must not exceed 2048 bytes" + ); + }); }); From 6f456e07914632ec154c0b3f17b9ac2a26cb1590 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 16 Aug 2026 18:41:35 +0100 Subject: [PATCH 12/67] validate builder request headers --- packages/api/src/builder/routes.ts | 81 ++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/packages/api/src/builder/routes.ts b/packages/api/src/builder/routes.ts index 1e4746e115fe..1dfd49273c0f 100644 --- a/packages/api/src/builder/routes.ts +++ b/packages/api/src/builder/routes.ts @@ -285,15 +285,21 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ - slot: params.slot, - parentHash: fromHex(params.parent_hash), - parentRoot: fromHex(params.parent_root), - proposerPubkey: fromHex(params.proposer_pubkey), - requestAuth: ssz.gloas.SignedRequestAuth.fromJson(body), - dateMilliseconds: Number(fromHeaders(headers, MetaHeader.DateMilliseconds)), - timeoutMs: Number(fromHeaders(headers, MetaHeader.TimeoutMs)), - }), + parseReqJson: ({params, body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + slot: params.slot, + parentHash: fromHex(params.parent_hash), + parentRoot: fromHex(params.parent_root), + proposerPubkey: fromHex(params.proposer_pubkey), + requestAuth: ssz.gloas.SignedRequestAuth.fromJson(body), + dateMilliseconds: parseRequiredUintHeader( + fromHeaders(headers, MetaHeader.DateMilliseconds), + MetaHeader.DateMilliseconds + ), + timeoutMs: parseRequiredUintHeader(fromHeaders(headers, MetaHeader.TimeoutMs), MetaHeader.TimeoutMs), + }; + }, writeReqSsz: ({slot, parentHash, parentRoot, proposerPubkey, requestAuth, dateMilliseconds, timeoutMs}) => ({ params: { slot, @@ -308,15 +314,21 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ - slot: params.slot, - parentHash: fromHex(params.parent_hash), - parentRoot: fromHex(params.parent_root), - proposerPubkey: fromHex(params.proposer_pubkey), - requestAuth: ssz.gloas.SignedRequestAuth.deserialize(body), - dateMilliseconds: Number(fromHeaders(headers, MetaHeader.DateMilliseconds)), - timeoutMs: Number(fromHeaders(headers, MetaHeader.TimeoutMs)), - }), + parseReqSsz: ({params, body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + slot: params.slot, + parentHash: fromHex(params.parent_hash), + parentRoot: fromHex(params.parent_root), + proposerPubkey: fromHex(params.proposer_pubkey), + requestAuth: ssz.gloas.SignedRequestAuth.deserialize(body), + dateMilliseconds: parseRequiredUintHeader( + fromHeaders(headers, MetaHeader.DateMilliseconds), + MetaHeader.DateMilliseconds + ), + timeoutMs: parseRequiredUintHeader(fromHeaders(headers, MetaHeader.TimeoutMs), MetaHeader.TimeoutMs), + }; + }, schema: { params: { slot: Schema.UintRequired, @@ -395,19 +407,25 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ - proposerPubkey: fromHex(params.proposer_pubkey), - request: ssz.gloas.BuilderPreferencesRequest.fromJson(body), - }), + parseReqJson: ({params, body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + proposerPubkey: fromHex(params.proposer_pubkey), + request: ssz.gloas.BuilderPreferencesRequest.fromJson(body), + }; + }, writeReqSsz: ({proposerPubkey, request}) => ({ params: {proposer_pubkey: toPubkeyHex(proposerPubkey)}, body: ssz.gloas.BuilderPreferencesRequest.serialize(request), headers: {[MetaHeader.Version]: config.getForkName(request.auth.message.slot)}, }), - parseReqSsz: ({params, body}) => ({ - proposerPubkey: fromHex(params.proposer_pubkey), - request: ssz.gloas.BuilderPreferencesRequest.deserialize(body), - }), + parseReqSsz: ({params, body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + proposerPubkey: fromHex(params.proposer_pubkey), + request: ssz.gloas.BuilderPreferencesRequest.deserialize(body), + }; + }, schema: { params: {proposer_pubkey: Schema.StringRequired}, body: Schema.Object, @@ -421,3 +439,14 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions Date: Sun, 16 Aug 2026 18:51:53 +0100 Subject: [PATCH 13/67] honor per-key builder boost post-gloas --- .../validator-management/proposer-config.md | 2 ++ .../validator/src/services/validatorStore.ts | 27 +++++++++++++------ .../test/unit/validatorStore.test.ts | 11 ++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/pages/run/validator-management/proposer-config.md b/docs/pages/run/validator-management/proposer-config.md index 7497340325cd..c25df8f286c6 100644 --- a/docs/pages/run/validator-management/proposer-config.md +++ b/docs/pages/run/validator-management/proposer-config.md @@ -38,6 +38,8 @@ default_config: Starting with Gloas, the builder section additionally supports `min_bid` (floor in Gwei on the total payment accepted from a builder bid) and `max_execution_payment` (ceiling in Gwei on the trusted execution layer payment accepted from a builder, values above `0` require `--allowDangerousTrustedPayments`). +Post-Gloas, an explicitly configured `boost_factor` takes precedence over `selection`. + The builder section also supports a `builders` list with the same per-builder entries as the keymanager builders API. Each entry has a required `url` and optional `auth_data`, `builder_pubkeys`, `max_execution_payment`, `min_bid` and `builder_boost_factor`. Multiple entries may share a `url` only if they have distinct `auth_data`. Per-key entries replace the builders the validator client is configured with; setting both `--builder.urls` and `builders` in `default_config` is an error. ```yaml diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 075d96683545..45cd9226adfd 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -318,11 +318,22 @@ export class ValidatorStore { // whether they are received over p2p or through a builder API. Pre-gloas there is no // in-protocol builder, so the default remains local-only (executiononly). const isPostGloas = slot !== undefined && this.config.getForkSeq(slot) >= ForkSeq.gloas; + return this.resolveBuilderSelectionParams(pubkeyHex, isPostGloas); + } + + private resolveBuilderSelectionParams( + pubkeyHex: PubkeyHex, + isPostGloas: boolean + ): {selection: routes.validator.BuilderSelection; boostFactor: bigint} { + const validatorBuilder = this.validators.get(pubkeyHex)?.builder; const defaultSelection = isPostGloas ? defaultOptions.builderAliasSelection : defaultOptions.builderSelection; - let selection = - this.validators.get(pubkeyHex)?.builder?.selection ?? - this.defaultProposerConfig.builder.selection ?? - defaultSelection; + let selection = validatorBuilder?.selection ?? this.defaultProposerConfig.builder.selection ?? defaultSelection; + + // The standard per-key builder config directly controls the post-Gloas boost. It takes + // precedence over Lodestar's legacy selection aliases when explicitly configured. + if (isPostGloas && validatorBuilder?.boostFactor !== undefined) { + return {selection: routes.validator.BuilderSelection.MaxProfit, boostFactor: validatorBuilder.boostFactor}; + } // Post-Gloas block production uses standard builder boost factor. Need to normalize the // gloas-deprecated "builderonly" and "executiononly" to the gloas fallback "builderalways" @@ -344,8 +355,7 @@ export class ValidatorStore { break; case routes.validator.BuilderSelection.MaxProfit: - boostFactor = - this.validators.get(pubkeyHex)?.builder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor; + boostFactor = validatorBuilder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor; break; case routes.validator.BuilderSelection.BuilderAlways: @@ -510,11 +520,12 @@ export class ValidatorStore { if (validatorData === undefined) { throw Error(`Validator pubkey ${pubkeyHex} not known`); } + const {boostFactor} = this.resolveBuilderSelectionParams(pubkeyHex, true); return { minBid: validatorData.builder?.minBid ?? this.defaultProposerConfig.builder.minBid, - builderBoostFactor: validatorData.builder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor, - builders: this.getResolvedBuilderEntries(pubkeyHex).map((entry) => ({ + builderBoostFactor: boostFactor, + builders: this.getResolvedBuilderEntries(pubkeyHex, boostFactor).map((entry) => ({ url: entry.url, authData: toHex(entry.authData), builderPubkeys: entry.builderPubkeys.map(toPubkeyHex), diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index e9dd78abc79a..536efa5c662d 100644 --- a/packages/validator/test/unit/validatorStore.test.ts +++ b/packages/validator/test/unit/validatorStore.test.ts @@ -114,6 +114,17 @@ describe("ValidatorStore", () => { selection: routes.validator.BuilderSelection.ExecutionAlways, boostFactor: BigInt(0), }); + + // A standard per-key builder config directly sets the Gloas boost, regardless of legacy selection aliases + gloasStore.setBuilderConfig(toHexString(pubkeys[0]), {builderBoostFactor: 120n}); + expect(gloasStore.getBuilderSelectionParams(toHexString(pubkeys[0]), gloasSlot)).toEqual({ + selection: routes.validator.BuilderSelection.MaxProfit, + boostFactor: 120n, + }); + expect(gloasStore.getBuilderConfig(toHexString(pubkeys[0])).builderBoostFactor).toBe(120n); + + // GET resolves an unconfigured key to the effective post-Gloas default + expect(gloasStore.getBuilderConfig(toHexString(pubkeys[1])).builderBoostFactor).toBe(90n); }); it("Should create/update builder data and return from cache next time", async () => { From e76769a09b5e6eb62e66726486786a385d9bfde8 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 16 Aug 2026 18:56:44 +0100 Subject: [PATCH 14/67] validate builder api bids against proposer preferences --- .../chain/validation/executionPayloadBid.ts | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index 30de689746cf..8a117eb0a539 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -198,27 +198,31 @@ export async function validateBuilderApiExecutionPayloadBid( return null; } })(); - const proposerPreferences = - dependentRootHex !== null ? chain.proposerPreferencesPool.get(bid.slot, dependentRootHex) : null; - if (proposerPreferences !== null) { - if (!byteArrayEquals(bid.feeRecipient, proposerPreferences.message.feeRecipient)) { - throw Error( - `Bid feeRecipient=${toHex(bid.feeRecipient)} does not match ` + - `proposer preferences feeRecipient=${toHex(proposerPreferences.message.feeRecipient)}` - ); - } + if (dependentRootHex === null) { + throw Error(`Unable to resolve proposer preferences dependent root for bid slot=${bid.slot}`); + } + const proposerPreferences = chain.proposerPreferencesPool.get(bid.slot, dependentRootHex); + if (proposerPreferences === null) { + throw Error(`No proposer preferences found for bid slot=${bid.slot} dependentRoot=${dependentRootHex}`); + } + if (!byteArrayEquals(bid.feeRecipient, proposerPreferences.message.feeRecipient)) { + throw Error( + `Bid feeRecipient=${toHex(bid.feeRecipient)} does not match ` + + `proposer preferences feeRecipient=${toHex(proposerPreferences.message.feeRecipient)}` + ); + } - const parentPayloadVariant = chain.forkChoice.getBlockHexAndBlockHash(bidParentBlockRoot, bidParentBlockHash); - if (parentPayloadVariant !== null && parentPayloadVariant.executionPayloadBlockHash !== null) { - const parentGasLimit = BigInt(parentPayloadVariant.executionPayloadGasLimit); - const targetGasLimit = proposerPreferences.message.targetGasLimit; - if (!isGasLimitTargetCompatible(parentGasLimit, bid.gasLimit, targetGasLimit)) { - throw Error( - `Bid gasLimit=${bid.gasLimit} is not compatible with ` + - `parentGasLimit=${parentGasLimit} targetGasLimit=${targetGasLimit}` - ); - } - } + const parentPayloadVariant = chain.forkChoice.getBlockHexAndBlockHash(bidParentBlockRoot, bidParentBlockHash); + if (parentPayloadVariant === null || parentPayloadVariant.executionPayloadBlockHash === null) { + throw Error(`Unable to resolve parent payload gas limit for bid parentBlockHash=${bidParentBlockHash}`); + } + const parentGasLimit = BigInt(parentPayloadVariant.executionPayloadGasLimit); + const targetGasLimit = proposerPreferences.message.targetGasLimit; + if (!isGasLimitTargetCompatible(parentGasLimit, bid.gasLimit, targetGasLimit)) { + throw Error( + `Bid gasLimit=${bid.gasLimit} is not compatible with ` + + `parentGasLimit=${parentGasLimit} targetGasLimit=${targetGasLimit}` + ); } const signatureSet = createSingleSignatureSetFromComponents( From c0f3b54e360a8e1cfff9ecddc58d73cd6637dcbf Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 17 Aug 2026 12:34:54 +0100 Subject: [PATCH 15/67] support auth data in builder urls --- .../validator-management/vc-configuration.md | 2 + packages/cli/src/cmds/validator/handler.ts | 2 +- packages/cli/src/cmds/validator/options.ts | 3 +- packages/cli/src/util/proposerConfig.ts | 38 +++++++++++----- .../unit/validator/parseBuilderUrls.test.ts | 32 ++++++++++++++ .../validator/src/services/validatorStore.ts | 43 +++++-------------- .../test/unit/validatorStore.test.ts | 10 ++++- 7 files changed, 82 insertions(+), 48 deletions(-) create mode 100644 packages/cli/test/unit/validator/parseBuilderUrls.test.ts diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 60cafd09ed70..465e4bf7a86f 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -114,6 +114,8 @@ Example 3: Setting a `--builder.boostFactor=100` is the same as signaling `--bui Starting with Gloas, external builders are configured on the validator client and the beacon node requests bids on its behalf. Use [`--builder.urls`](./validator-cli.md#--builderurls) to name the builders to request bids from. Bids received over p2p are always considered alongside them, governed by the same selection settings. +Every bid request is authenticated with data the builder expects, by default the UTF-8 bytes of the builder URL exactly as configured. If a builder requires different auth data agreed out of band, append it as a hex fragment to its URL, e.g. `--builder.urls https://builder.example.com#0x0123`. The fragment is stripped before the URL is used and never sent to the builder. Auth data that must stay secret is better kept in the [proposer configuration file](./proposer-config.md), as command line arguments are visible to other processes. + - [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. - [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei accepted from a builder. The default of `0` only accepts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index 4c8d3d35ece1..294d11f93dfb 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -259,7 +259,7 @@ function getProposerConfigFromArgs( boostFactor: parseBuilderBoostFactor(args["builder.boostFactor"]), minBid: parseBuilderMinBid(args["builder.minBid"]), maxExecutionPayment: parseBuilderGweiAmount(args["builder.maxExecutionPayment"]), - urls: parseBuilderUrls(args["builder.urls"]), + builders: parseBuilderUrls(args["builder.urls"]), }, }; diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 93431d8fd494..94ae8a36a162 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -289,7 +289,8 @@ export const validatorOptions: CliCommandOptions = { }, "builder.urls": { - description: "URL(s) of external builders to request execution payload bids from. Only used post-Gloas", + description: + "URL(s) of external builders to request execution payload bids from. Auth data agreed with a builder may be appended as a hex fragment, e.g. https://builder.example.com#0x0123, otherwise the UTF-8 bytes of the URL are used. Only used post-Gloas", type: "array", string: true, coerce: (urls: string[]): string[] => urls.flatMap((url) => url.split(",")), diff --git a/packages/cli/src/util/proposerConfig.ts b/packages/cli/src/util/proposerConfig.ts index d38dca59d7f7..d6cbfd2bfdf4 100644 --- a/packages/cli/src/util/proposerConfig.ts +++ b/packages/cli/src/util/proposerConfig.ts @@ -2,13 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import {routes} from "@lodestar/api"; import {BuilderEntryConfig, builderConfigDataFromJson} from "@lodestar/api/keymanager"; -import {MAX_BUILDER_ENTRIES, MAX_BUILDER_URL_SIZE} from "@lodestar/params"; +import {MAX_BUILDER_ENTRIES, MAX_BUILDER_URL_SIZE, MAX_DATA_SIZE} from "@lodestar/params"; import {fromHex, toHex} from "@lodestar/utils"; import {ValidatorProposerConfig} from "@lodestar/validator"; import {parseFeeRecipient} from "./feeRecipient.js"; import {readFile} from "./file.js"; const UINT64_MAX = 2n ** 64n - 1n; +const AUTH_DATA_PATTERN = new RegExp(`^0x(?:[a-fA-F0-9]{2}){1,${MAX_DATA_SIZE}}$`); type ProposerConfig = ValidatorProposerConfig["defaultConfig"]; @@ -110,12 +111,11 @@ function parseProposerConfigSection( minBid: overrideConfig?.builder?.minBid ?? parseBuilderMinBid(min_bid), maxExecutionPayment: overrideConfig?.builder?.maxExecutionPayment ?? parseBuilderGweiAmount(max_execution_payment), - urls: overrideConfig?.builder?.urls, builders: overrideConfig?.builder?.builders ?? parseBuilderEntries(builders), } : undefined; - if (parsedBuilder?.urls !== undefined && parsedBuilder?.builders !== undefined) { + if (overrideConfig?.builder?.builders !== undefined && builders !== undefined) { throw Error("Cannot configure both --builder.urls and builders in the proposer settings file"); } @@ -238,11 +238,20 @@ export function parseBuilderEntries(builders?: unknown): BuilderEntryConfig[] | return entries; } -export function parseBuilderUrls(urls?: string[]): string[] | undefined { +/** + * Parse builder urls into builder entries. Auth data agreed with a builder out of band may be + * appended as a hex fragment (`https://builder.example.com#0x0123`), it is stripped from the url + * and never sent on the wire. Without a fragment the auth data derives from the url. + */ +export function parseBuilderUrls(urls?: string[]): BuilderEntryConfig[] | undefined { if (urls === undefined) return undefined; - const seen = new Set(); - for (const url of urls) { + const entries: BuilderEntryConfig[] = []; + const seenEntries = new Set(); + for (const value of urls) { + const fragmentIndex = value.indexOf("#"); + const url = fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); + const authData = fragmentIndex === -1 ? undefined : value.slice(fragmentIndex + 1); try { new URL(url); } catch { @@ -251,13 +260,20 @@ export function parseBuilderUrls(urls?: string[]): string[] | undefined { if (Buffer.byteLength(url, "utf8") > MAX_BUILDER_URL_SIZE) { throw Error(`Invalid builder url, must not exceed ${MAX_BUILDER_URL_SIZE} bytes: ${url}`); } - if (seen.has(url)) { + if (authData !== undefined && !AUTH_DATA_PATTERN.test(authData)) { + throw Error( + `Invalid builder url auth data, must be a 0x-prefixed hex string of 1 to ${MAX_DATA_SIZE} bytes: ${url}` + ); + } + const entryKey = `${url}|${authData !== undefined ? toHex(fromHex(authData)) : toHex(Buffer.from(url))}`; + if (seenEntries.has(entryKey)) { throw Error(`Duplicate builder url: ${url}`); } - seen.add(url); + seenEntries.add(entryKey); + entries.push({url, authData}); } - if (urls.length > MAX_BUILDER_ENTRIES) { - throw Error(`Number of builder urls must not exceed ${MAX_BUILDER_ENTRIES}, got ${urls.length}`); + if (entries.length > MAX_BUILDER_ENTRIES) { + throw Error(`Number of builder urls must not exceed ${MAX_BUILDER_ENTRIES}, got ${entries.length}`); } - return urls; + return entries; } diff --git a/packages/cli/test/unit/validator/parseBuilderUrls.test.ts b/packages/cli/test/unit/validator/parseBuilderUrls.test.ts new file mode 100644 index 000000000000..39b0c730c6af --- /dev/null +++ b/packages/cli/test/unit/validator/parseBuilderUrls.test.ts @@ -0,0 +1,32 @@ +import {describe, expect, it} from "vitest"; +import {parseBuilderUrls} from "../../../src/util/proposerConfig.js"; + +describe("validator / parseBuilderUrls", () => { + it("parses urls into builder entries with optional fragment auth data", () => { + expect( + parseBuilderUrls(["https://builder-a.example.com", "https://builder-b.example.com/path?x=1#0x0123"]) + ).toEqual([ + {url: "https://builder-a.example.com", authData: undefined}, + {url: "https://builder-b.example.com/path?x=1", authData: "0x0123"}, + ]); + }); + + it("allows the same url with distinct auth data", () => { + expect(parseBuilderUrls(["https://builder.example.com#0x01", "https://builder.example.com#0x02"])).toHaveLength(2); + }); + + it("rejects duplicate entries, comparing an omitted auth data as derived from the url", () => { + const url = "https://builder.example.com"; + const derived = `0x${Buffer.from(url).toString("hex")}`; + expect(() => parseBuilderUrls([url, url])).toThrow(/Duplicate builder url/); + expect(() => parseBuilderUrls([url, `${url}#${derived}`])).toThrow(/Duplicate builder url/); + }); + + it("rejects invalid urls and auth data", () => { + expect(() => parseBuilderUrls(["builder.example.com"])).toThrow(/Invalid builder url/); + expect(() => parseBuilderUrls(["https://builder.example.com#"])).toThrow(/auth data/); + expect(() => parseBuilderUrls(["https://builder.example.com#0x"])).toThrow(/auth data/); + expect(() => parseBuilderUrls(["https://builder.example.com#secret"])).toThrow(/auth data/); + expect(() => parseBuilderUrls(["https://builder.example.com#0x123"])).toThrow(/auth data/); + }); +}); diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 45cd9226adfd..026eabb26e22 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -89,7 +89,6 @@ type DefaultProposerConfig = { boostFactor: bigint; minBid: bigint; maxExecutionPayment: bigint; - urls: string[]; builders?: BuilderEntryConfig[]; }; }; @@ -104,8 +103,7 @@ export type ProposerConfig = { boostFactor?: bigint; minBid?: bigint; maxExecutionPayment?: bigint; - urls?: string[]; - /** Per-key builder entries set via the keymanager api, replacing the configured builder urls */ + /** Per-key builder entries, replacing the validator client's builders */ builders?: BuilderEntryConfig[]; }; }; @@ -214,7 +212,6 @@ export class ValidatorStore { boostFactor: builderBoostFactor, minBid: defaultConfig.builder?.minBid ?? defaultOptions.builderMinBid, maxExecutionPayment: defaultConfig.builder?.maxExecutionPayment ?? defaultOptions.builderMaxExecutionPayment, - urls: defaultConfig.builder?.urls ?? [], builders: defaultConfig.builder?.builders, }, }; @@ -466,14 +463,6 @@ export class ValidatorStore { return validatorData?.builder?.maxExecutionPayment ?? this.defaultProposerConfig.builder.maxExecutionPayment; } - getBuilderUrls(pubkeyHex: PubkeyHex): string[] { - const validatorData = this.validators.get(pubkeyHex); - if (validatorData === undefined) { - throw Error(`Validator pubkey ${pubkeyHex} not known`); - } - return validatorData?.builder?.urls ?? this.defaultProposerConfig.builder.urls; - } - /** * Resolve the builder entries for this key. Per-key entries replace the validator client's * builders, an omitted entry value takes this key's default and then the validator client's @@ -491,26 +480,15 @@ export class ValidatorStore { const keyMaxExecutionPayment = validatorData.builder?.maxExecutionPayment ?? this.defaultProposerConfig.builder.maxExecutionPayment; - const builders = validatorData.builder?.builders ?? this.defaultProposerConfig.builder.builders; - if (builders !== undefined) { - return builders.map((entry) => ({ - url: entry.url, - authData: entry.authData !== undefined ? fromHex(entry.authData) : new Uint8Array(Buffer.from(entry.url)), - builderPubkeys: (entry.builderPubkeys ?? []).map(fromHex), - maxExecutionPayment: entry.maxExecutionPayment ?? keyMaxExecutionPayment, - minBid: entry.minBid ?? keyMinBid, - builderBoostFactor: entry.builderBoostFactor ?? keyBoostFactor, - })); - } - - // The key's defaults apply to the validator client's configured builders all the same - return this.getBuilderUrls(pubkeyHex).map((url) => ({ - url, - authData: new Uint8Array(Buffer.from(url)), - builderPubkeys: [], - maxExecutionPayment: keyMaxExecutionPayment, - minBid: keyMinBid, - builderBoostFactor: keyBoostFactor, + // The key's defaults apply to the validator client's own builders all the same + const builders = validatorData.builder?.builders ?? this.defaultProposerConfig.builder.builders ?? []; + return builders.map((entry) => ({ + url: entry.url, + authData: entry.authData !== undefined ? fromHex(entry.authData) : new Uint8Array(Buffer.from(entry.url)), + builderPubkeys: (entry.builderPubkeys ?? []).map(fromHex), + maxExecutionPayment: entry.maxExecutionPayment ?? keyMaxExecutionPayment, + minBid: entry.minBid ?? keyMinBid, + builderBoostFactor: entry.builderBoostFactor ?? keyBoostFactor, })); } @@ -614,7 +592,6 @@ export class ValidatorStore { builder?.boostFactor !== undefined || builder?.minBid !== undefined || builder?.maxExecutionPayment !== undefined || - builder?.urls !== undefined || builder?.builders !== undefined ) { proposerConfig = {graffiti, strictFeeRecipientCheck, feeRecipient, builder}; diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index 536efa5c662d..ed7e73de19e7 100644 --- a/packages/validator/test/unit/validatorStore.test.ts +++ b/packages/validator/test/unit/validatorStore.test.ts @@ -290,17 +290,23 @@ describe("ValidatorStore", () => { }, defaultConfig: { builder: { - builders: [{url: builderUrl, builderBoostFactor: 150n}], + builders: [ + {url: builderUrl, builderBoostFactor: 150n}, + {url: builderUrl, authData: "0x0123"}, + ], }, }, }); // A key without its own builders follows the default entries, its key defaults still apply const entries = store.getResolvedBuilderEntries(pubkey); - expect(entries).toHaveLength(1); + expect(entries).toHaveLength(2); expect(Buffer.from(entries[0].authData).toString("utf8")).toBe(builderUrl); expect(entries[0].minBid).toBe(30n); expect(entries[0].builderBoostFactor).toBe(150n); + // Explicit auth data (e.g. from a --builder.urls fragment) is used as is + expect(toHexString(entries[1].authData)).toBe("0x0123"); + expect(entries[1].minBid).toBe(30n); // Per-key builders replace the default entries store.setBuilderConfig(pubkey, {builders: []}); From 4182e2bad12b68de873fd5aca2a05dc5980e7fef Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 17 Aug 2026 12:59:07 +0100 Subject: [PATCH 16/67] validate bid fee recipient against proposer preferences only --- packages/beacon-node/src/api/impl/validator/index.ts | 3 --- .../src/chain/validation/executionPayloadBid.ts | 10 +--------- .../unit/api/impl/validator/produceBlockV4.test.ts | 6 ------ 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 918e1227605a..bec924190a20 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -908,12 +908,10 @@ export function getValidatorApi( // Fire builder API bid requests while the local payload is built, one request per entry. // Any entry failure yields no bid and never fails block production. let builderApiBidsPromise: Promise = Promise.resolve([]); - let requestedFeeRecipient: Uint8Array | undefined; if (builderConfig.builders.length > 0 && !circuitBreakerActive) { try { const proposerIndex = chain.getHeadState().getBeaconProposer(slot); const proposerPubkey = chain.pubkeyCache.getOrThrow(proposerIndex).toBytes(); - requestedFeeRecipient = feeRecipient !== undefined ? fromHex(feeRecipient) : undefined; builderApiBidsPromise = chain.builderApiClient.getExecutionPayloadBids( builderConfig.builders, slot, @@ -984,7 +982,6 @@ export function getValidatorApi( parentBlock, parentBlockHash: bidParentBlockHash, parentBlockRoot: parentBlockRootHex, - feeRecipient: requestedFeeRecipient, entry, }); candidates.push({ diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index 8a117eb0a539..4cd2b5b383b8 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -103,13 +103,11 @@ export async function validateBuilderApiExecutionPayloadBid( parentBlock: ProtoBlock; parentBlockHash: RootHex; parentBlockRoot: RootHex; - /** Fee recipient explicitly requested by the caller, the bid must match it when set */ - feeRecipient?: Uint8Array; entry: routes.validator.BuilderEntry; } ): Promise { const bid = signedExecutionPayloadBid.message; - const {slot, parentBlock, parentBlockHash, parentBlockRoot, feeRecipient, entry} = request; + const {slot, parentBlock, parentBlockHash, parentBlockRoot, entry} = request; if (bid.slot !== slot) { throw Error(`Bid slot=${bid.slot} does not match requested slot=${slot}`); @@ -124,12 +122,6 @@ export async function validateBuilderApiExecutionPayloadBid( ); } - if (feeRecipient !== undefined && !byteArrayEquals(bid.feeRecipient, feeRecipient)) { - throw Error( - `Bid feeRecipient=${toHex(bid.feeRecipient)} does not match requested feeRecipient=${toHex(feeRecipient)}` - ); - } - if (bid.executionPayment > entry.maxExecutionPayment) { throw Error( `Bid executionPayment=${bid.executionPayment} exceeds maxExecutionPayment=${entry.maxExecutionPayment}` diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 1bf5df08ebd3..0417fdf03a4c 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -3,7 +3,6 @@ import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lo import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName} from "@lodestar/params"; import {ssz} from "@lodestar/types"; -import {fromHex} from "@lodestar/utils"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; import {validateBuilderApiExecutionPayloadBid} from "../../../../../src/chain/validation/executionPayloadBid.js"; @@ -236,11 +235,6 @@ describe("api/validator - produceBlockV4", () => { expect(modules.chain.builderApiClient.getExecutionPayloadBids).toHaveBeenCalledOnce(); expect(validateBuilderApiExecutionPayloadBid).toHaveBeenCalledOnce(); - expect(validateBuilderApiExecutionPayloadBid).toHaveBeenCalledWith( - modules.chain, - apiBid, - expect.objectContaining({feeRecipient: fromHex(feeRecipient)}) - ); // The bid block commits to the builder API bid since its boosted total (2) beats the p2p bid (1) expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); expect(block).toEqual(bidBlock); From b6677e3f25c2e8ff3d0fe3c53f3b6a2b070e167b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 17 Aug 2026 14:02:43 +0100 Subject: [PATCH 17/67] update wordlist --- .wordlist.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.wordlist.txt b/.wordlist.txt index e6465ad3356e..b24d7a901fd8 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -3,6 +3,7 @@ APIs Andreas Antonopoulos AssemblyScript +Auth BLS BeaconNode Besu @@ -119,6 +120,7 @@ addons api args async +auth backfill beaconcha blockRoot From 0d344091580a25e0d0d4d55a9fbd6fa556311473 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 18 Aug 2026 12:28:51 +0100 Subject: [PATCH 18/67] read builder url header when publishing a block --- packages/api/src/beacon/routes/beacon/block.ts | 4 ++-- packages/api/test/unit/beacon/testData/beacon.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 97c4bc3405c8..cafdb0cd158a 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -381,7 +381,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { @@ -412,7 +412,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions = { args: { signedBlockContents: {signedBlock: ssz.gloas.SignedBeaconBlock.defaultValue()}, broadcastValidation: BroadcastValidation.consensus, + builderUrl: "https://builder.example.com", }, res: undefined, }, From d0537e71749aa961e96f97eab40d3d88ee84ebdf Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 18 Aug 2026 12:30:36 +0100 Subject: [PATCH 19/67] publish already known block succeeds without republishing it --- .../src/api/impl/beacon/blocks/index.ts | 30 ++++++--- .../impl/beacon/blocks/publishBlock.test.ts | 64 ++++++++++++++++++- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index b2fb3e8c00a1..fcd6955be1a2 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -208,6 +208,12 @@ export function getBeaconBlockApi({ const blockLocallyProduced = chain.blockProductionCache.has(blockRoot); const valLogMeta = {slot, blockRoot, bodyRoot, broadcastValidation, blockLocallyProduced}; + if (chain.forkChoice.hasBlockHex(blockRoot)) { + // Block was already imported, e.g. published again by the validator client or received via gossip. Benign. + chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); + return; + } + switch (broadcastValidation) { case routes.beacon.BroadcastValidation.gossip: { if (!blockLocallyProduced) { @@ -389,16 +395,20 @@ export function getBeaconBlockApi({ chain .processBlock(blockForImport, opts) .catch((e) => { - if ( - e instanceof BlockError && - (e.type.code === BlockErrorCode.PARENT_BLOCK_UNKNOWN || - e.type.code === BlockErrorCode.PARENT_PAYLOAD_UNKNOWN) - ) { - chain.emitter.emit(ChainEvent.blockUnknownParent, { - blockInput: blockForImport, - peer: IDENTITY_PEER_ID, - source: BlockInputSource.api, - }); + if (e instanceof BlockError) { + switch (e.type.code) { + case BlockErrorCode.ALREADY_KNOWN: + // Block was imported while publishing, e.g. received via gossip. Benign. + chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); + return; + case BlockErrorCode.PARENT_BLOCK_UNKNOWN: + case BlockErrorCode.PARENT_PAYLOAD_UNKNOWN: + chain.emitter.emit(ChainEvent.blockUnknownParent, { + blockInput: blockForImport, + peer: IDENTITY_PEER_ID, + source: BlockInputSource.api, + }); + } } throw e; }), diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index a6482b2e66ae..806f4792cf68 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -8,7 +8,7 @@ import {toRootHex} from "@lodestar/utils"; import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; import {BlockInputPreData, BlockInputSource} from "../../../../../../src/chain/blocks/blockInput/index.js"; import {verifyBlocksInEpoch} from "../../../../../../src/chain/blocks/verifyBlock.js"; -import {BlockErrorCode, BlockGossipError, GossipAction} from "../../../../../../src/chain/errors/index.js"; +import {BlockError, BlockErrorCode, BlockGossipError, GossipAction} from "../../../../../../src/chain/errors/index.js"; import {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; import {validateGossipBlock} from "../../../../../../src/chain/validation/block.js"; import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; @@ -67,6 +67,68 @@ describe("api - beacon - publishBlockV2", () => { expect(modules.chain.processBlock).not.toHaveBeenCalled(); }); + it("returns successfully without publishing a block that is already imported", async () => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue( + BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }) + ); + modules.chain.forkChoice.hasBlockHex.mockReturnValue(true); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishBlockV2({ + signedBlockContents: {signedBlock}, + broadcastValidation: routes.beacon.BroadcastValidation.consensus, + }) + ).resolves.toBeUndefined(); + + expect(modules.chain.forkChoice.hasBlockHex).toHaveBeenCalledWith(blockRoot); + expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); + expect(modules.chain.processBlock).not.toHaveBeenCalled(); + }); + + it("returns successfully for a locally produced block imported while publishing", async () => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + // Locally produced blocks skip gossip validation, a duplicate publish can still race the import + modules.chain.blockProductionCache.set(blockRoot, {} as never); + modules.chain.processBlock = vi + .fn() + .mockRejectedValue(new BlockError(signedBlock, {code: BlockErrorCode.ALREADY_KNOWN, root: blockRoot})); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishBlockV2({ + signedBlockContents: {signedBlock}, + broadcastValidation: routes.beacon.BroadcastValidation.gossip, + }) + ).resolves.toBeUndefined(); + + expect(validateGossipBlock).not.toHaveBeenCalled(); + expect(modules.chain.processBlock).toHaveBeenCalledOnce(); + }); + it("rejects a repeat proposal", async () => { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); const blockRoot = toRootHex( From 2c46bf2e6a153e67bcede53633de1a64feb11eee Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 18 Aug 2026 12:33:52 +0100 Subject: [PATCH 20/67] only forward signed block to builder url echoed by validator client --- .../src/api/impl/beacon/blocks/index.ts | 20 ++++++++----------- .../src/api/impl/validator/index.ts | 5 ----- packages/beacon-node/src/chain/chain.ts | 1 - .../src/execution/builder/apiClient.ts | 20 ------------------- .../test/mocks/mockedBeaconChain.ts | 3 --- .../api/impl/validator/produceBlockV4.test.ts | 7 ------- 6 files changed, 8 insertions(+), 48 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index fcd6955be1a2..3313a827eb52 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -372,20 +372,16 @@ export function getBeaconBlockApi({ // import latency and hopefully bandwidth // () => network.publishBeaconBlock(signedBlock), - // Forward the signed block to the winning builder so it can release the payload without - // waiting for block gossip. Failures are non-fatal, the builder also sees the block on gossip. + // Forward the signed block to the winning builder echoed by the validator client so it can + // release the payload without waiting for block gossip. Failures are non-fatal, the builder + // also sees the block on gossip. async () => { - if (!isForkPostGloas(fork)) return; + if (builderUrl === undefined || !isForkPostGloas(fork)) return; const gloasBlock = signedBlock as SignedBeaconBlock; - const bid = gloasBlock.message.body.signedExecutionPayloadBid.message; - if (bid.builderIndex === BUILDER_INDEX_SELF_BUILD) return; - // Use the echoed builder url, or the locally recorded bid source if this node ran the auction - const bidSource = chain.builderApiClient.getBidSource(slot); - const forwardUrl = - builderUrl ?? (bidSource?.bidBlockHash === toRootHex(bid.blockHash) ? bidSource.url : undefined); - if (forwardUrl === undefined) return; - await chain.builderApiClient.submitSignedBeaconBlock(forwardUrl, {data: gloasBlock}).catch((e) => { - chain.logger.warn("Failed to submit signed block to builder", {...valLogMeta, builderUrl: forwardUrl}, e); + if (gloasBlock.message.body.signedExecutionPayloadBid.message.builderIndex === BUILDER_INDEX_SELF_BUILD) return; + // Not awaited, publishing the block must not wait on the builder + chain.builderApiClient.submitSignedBeaconBlock(builderUrl, {data: gloasBlock}).catch((e) => { + chain.logger.warn("Failed to submit signed block to builder", {...valLogMeta, builderUrl}, e); }); }, ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index bec924190a20..d0d30da075d0 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1166,12 +1166,7 @@ export function getValidatorApi( const {block, executionPayloadValue, consensusBlockValue} = bestResult.value; - // Remember the winning builder API bid source to route the signed block back to the builder if (source === ProducedBlockSource.builder && bestCandidate?.url !== undefined) { - chain.builderApiClient.recordBidSource(slot, { - url: bestCandidate.url, - bidBlockHash: toRootHex(bestCandidate.signedBid.message.blockHash), - }); logger.debug("Builder API bid included in block", {slot, builder: toPrintableUrl(bestCandidate.url)}); } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 8790ce77677d..b1a988056472 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1609,7 +1609,6 @@ export class BeaconChain implements IBeaconChain { this.executionPayloadBidPool.prune(slot); this.seenExecutionPayloadBids.prune(slot); this.proposerPreferencesPool.prune(slot); - this.builderApiClient.prune(slot); this.seenAttestationDatas.onSlot(slot); this.reprocessController.onSlot(slot); diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index ed4b59d6ab3a..614d5f0aefa4 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -33,8 +33,6 @@ export type BuilderApiBid = { signedBid: gloas.SignedExecutionPayloadBid; }; -export type BidSource = {url: BuilderUrl; bidBlockHash: string}; - /** * External builder integration post-gloas (ePBS). * @@ -43,8 +41,6 @@ export type BidSource = {url: BuilderUrl; bidBlockHash: string}; */ export class BuilderApiClient { private readonly clients = 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(); constructor( private readonly opts: BuilderApiClientOpts, @@ -164,22 +160,6 @@ export class BuilderApiClient { } } - 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 { - 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) { diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 00327e9b889b..c65cde5f69e6 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -165,9 +165,6 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { getExecutionPayloadBids: vi.fn().mockResolvedValue([]), submitBuilderPreferences: 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), diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 0417fdf03a4c..e053c145a0fc 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -238,10 +238,6 @@ describe("api/validator - produceBlockV4", () => { // The bid block commits to the builder API bid since its boosted total (2) beats the p2p bid (1) expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); expect(block).toEqual(bidBlock); - expect(modules.chain.builderApiClient.recordBidSource).toHaveBeenCalledWith(slot, { - url: builderUrl, - bidBlockHash: expect.any(String), - }); }); it("prefers the builder API bid over an equally boosted p2p bid", async () => { @@ -277,7 +273,6 @@ describe("api/validator - produceBlockV4", () => { expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); expect(block).toEqual(bidBlock); - expect(modules.chain.builderApiClient.recordBidSource).toHaveBeenCalledOnce(); }); it("prefers a max boost builder entry before comparing bid values", async () => { @@ -313,7 +308,6 @@ describe("api/validator - produceBlockV4", () => { }); expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: apiBid})); - expect(modules.chain.builderApiClient.recordBidSource).toHaveBeenCalledOnce(); }); it("falls back to the p2p bid when the builder API bid fails validation", async () => { @@ -349,7 +343,6 @@ describe("api/validator - produceBlockV4", () => { expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid})); expect(block).toEqual(bidBlock); - expect(modules.chain.builderApiClient.recordBidSource).not.toHaveBeenCalled(); }); it("ignores a p2p bid below the configured min bid", async () => { From 64344b52e32ede6302678127d74bd8548c3cc236 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 18 Aug 2026 12:39:34 +0100 Subject: [PATCH 21/67] log selected bid source and details when producing a block --- .../src/api/impl/validator/index.ts | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index d0d30da075d0..d2a587bdd891 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -941,25 +941,6 @@ export function getValidatorApi( p2pBid = null; } - const logCtx = { - slot, - parentSlot, - parentBlockRoot: parentBlockRootHex, - parentBlockHash: parentBlock.executionPayloadBlockHash, - fork, - builderBoostFactor, - strictFeeRecipientCheck, - circuitBreakerActive, - builderEntries: builderConfig.builders.length, - ...(p2pBid !== null - ? { - bidValue: p2pBid.message.value, - builderIndex: p2pBid.message.builderIndex, - bidBlockHash: toRootHex(p2pBid.message.blockHash), - } - : {}), - }; - // Candidates are ranked by their boosted total payment, the p2p bid is governed by the // top-level factors and each builder API bid by its own entry. Ties keep the earlier // candidate with builder API bids ranked first, so when the same bid arrives over both @@ -1099,6 +1080,28 @@ export function getValidatorApi( // Resolved instantly whenever the bid branch produced a block const bestCandidate = bidResult.status === "fulfilled" ? await bestCandidatePromise : null; + const logCtx = { + slot, + parentSlot, + parentBlockRoot: parentBlockRootHex, + parentBlockHash: parentBlock.executionPayloadBlockHash, + fork, + builderBoostFactor, + strictFeeRecipientCheck, + circuitBreakerActive, + builderEntries: builderConfig.builders.length, + ...(bestCandidate !== null + ? { + bidSource: bestCandidate.url !== undefined ? toPrintableUrl(bestCandidate.url) : "p2p", + bidValue: bestCandidate.signedBid.message.value, + bidExecutionPayment: bestCandidate.signedBid.message.executionPayment, + bidBoostFactor: bestCandidate.boostFactor, + builderIndex: bestCandidate.signedBid.message.builderIndex, + bidBlockHash: toRootHex(bestCandidate.signedBid.message.blockHash), + } + : {}), + }; + // handle shouldOverrideBuilder separately if ( engineResult.status === "fulfilled" && @@ -1166,10 +1169,6 @@ export function getValidatorApi( const {block, executionPayloadValue, consensusBlockValue} = bestResult.value; - if (source === ProducedBlockSource.builder && bestCandidate?.url !== undefined) { - logger.debug("Builder API bid included in block", {slot, builder: toPrintableUrl(bestCandidate.url)}); - } - metrics?.blockProductionSuccess.inc({source}); metrics?.blockProductionNumAggregated.observe({source}, block.body.attestations.length); metrics?.blockProductionConsensusBlockValue.observe({source}, Number(formatWeiToEth(consensusBlockValue))); From 1584302161fdacaeb05f612f1d8619b93995e881 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 18 Aug 2026 12:42:44 +0100 Subject: [PATCH 22/67] wording --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 3313a827eb52..882ae740702d 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -209,7 +209,7 @@ export function getBeaconBlockApi({ const valLogMeta = {slot, blockRoot, bodyRoot, broadcastValidation, blockLocallyProduced}; if (chain.forkChoice.hasBlockHex(blockRoot)) { - // Block was already imported, e.g. published again by the validator client or received via gossip. Benign. + // Block was already imported, e.g. published again by the validator client or received via gossip chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); return; } @@ -394,7 +394,7 @@ export function getBeaconBlockApi({ if (e instanceof BlockError) { switch (e.type.code) { case BlockErrorCode.ALREADY_KNOWN: - // Block was imported while publishing, e.g. received via gossip. Benign. + // Block was imported while publishing, e.g. received via gossip chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); return; case BlockErrorCode.PARENT_BLOCK_UNKNOWN: From 82438dc25d594ccb6a6f3bc36e493f7ef9b3ef65 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 18 Aug 2026 12:46:20 +0100 Subject: [PATCH 23/67] naming nits --- .../src/api/impl/validator/index.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index d2a587bdd891..7ef00553a17a 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -951,7 +951,7 @@ export function getValidatorApi( boostFactor: bigint; url?: string; }; - const bestCandidatePromise: Promise = (async () => { + const bestBidPromise: Promise = (async () => { const candidates: BidCandidate[] = []; const builderApiBids = await builderApiBidsPromise; @@ -1059,7 +1059,7 @@ export function getValidatorApi( } return engineBlock; }); - const bidPromise: ReturnType = bestCandidatePromise.then((candidate) => { + const bidPromise: ReturnType = bestBidPromise.then((candidate) => { if (candidate === null) { throw new Error(NO_BID_AVAILABLE); } @@ -1078,7 +1078,7 @@ export function getValidatorApi( let source: ProducedBlockSource = ProducedBlockSource.engine; // Resolved instantly whenever the bid branch produced a block - const bestCandidate = bidResult.status === "fulfilled" ? await bestCandidatePromise : null; + const bestBid = bidResult.status === "fulfilled" ? await bestBidPromise : null; const logCtx = { slot, @@ -1090,14 +1090,14 @@ export function getValidatorApi( strictFeeRecipientCheck, circuitBreakerActive, builderEntries: builderConfig.builders.length, - ...(bestCandidate !== null + ...(bestBid !== null ? { - bidSource: bestCandidate.url !== undefined ? toPrintableUrl(bestCandidate.url) : "p2p", - bidValue: bestCandidate.signedBid.message.value, - bidExecutionPayment: bestCandidate.signedBid.message.executionPayment, - bidBoostFactor: bestCandidate.boostFactor, - builderIndex: bestCandidate.signedBid.message.builderIndex, - bidBlockHash: toRootHex(bestCandidate.signedBid.message.blockHash), + bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : "p2p", + bidValue: bestBid.signedBid.message.value, + bidExecutionPayment: bestBid.signedBid.message.executionPayment, + bidBoostFactor: bestBid.boostFactor, + builderIndex: bestBid.signedBid.message.builderIndex, + bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), } : {}), }; @@ -1117,10 +1117,10 @@ export function getValidatorApi( logger.warn("Selected local block: censorship suspected in builder bid", logCtx); } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { const result = selectBlockProductionSourceByBoostFactor({ - builderBoostFactor: bestCandidate?.boostFactor ?? builderBoostFactor, + builderBoostFactor: bestBid?.boostFactor ?? builderBoostFactor, engineExecutionPayloadValue: engineResult.value.executionPayloadValue, // The bid total payment is its value plus executionPayment, in Gwei - builderExecutionPayloadValue: (bestCandidate?.totalGwei ?? 0n) * GWEI_TO_WEI, + builderExecutionPayloadValue: (bestBid?.totalGwei ?? 0n) * GWEI_TO_WEI, }); source = result.source; metrics?.blockProductionSelectionResults.inc(result); @@ -1227,7 +1227,7 @@ export function getValidatorApi( consensusBlockValue, executionPayloadValue, executionPayloadIncluded: false, - builderUrl: source === ProducedBlockSource.builder ? bestCandidate?.url : undefined, + builderUrl: source === ProducedBlockSource.builder ? bestBid?.url : undefined, }, }; }, From 05f03f965bb17d89c2cc6e44f187df8a2dde59eb Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 18 Aug 2026 20:07:08 +0100 Subject: [PATCH 24/67] rename keymanager builders endpoints to builder config --- .../validator-management/vc-configuration.md | 2 +- packages/api/src/keymanager/routes.ts | 24 ++++++++++++------- .../api/test/unit/keymanager/oapiSpec.test.ts | 6 ++--- packages/api/test/unit/keymanager/testData.ts | 6 ++--- .../cli/src/cmds/validator/keymanager/impl.ts | 8 +++---- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 465e4bf7a86f..953f44321806 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -119,7 +119,7 @@ Every bid request is authenticated with data the builder expects, by default the - [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. - [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei accepted from a builder. The default of `0` only accepts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. -Builders can also be configured per validator key with per-builder overrides via the [Set Builders keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builders) or the [proposer configuration file](./proposer-config.md). +Builders can also be configured per validator key with per-builder overrides via the [Set Builder Configuration keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builder%20Config) or the [proposer configuration file](./proposer-config.md). ### Submit a validator deposit diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 6b0252ebcc59..e1e3de9719e1 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -489,7 +489,7 @@ export type Endpoints = { >; /** Get the builder configuration in effect for a validator public key, with omitted values resolved */ - getBuilders: Endpoint< + getBuilderConfig: Endpoint< // ⏎ "GET", {pubkey: PubkeyHex}, @@ -498,7 +498,7 @@ export type Endpoints = { EmptyMeta >; /** Set the builder configuration for a validator public key, replacing any stored configuration in full */ - setBuilders: Endpoint< + setBuilderConfig: Endpoint< "POST", {pubkey: PubkeyHex; builderConfig: BuilderConfigData}, {params: {pubkey: string}; body: unknown}, @@ -506,7 +506,13 @@ export type Endpoints = { EmptyMeta >; /** Remove the builder configuration for a validator public key, it then follows the validator client again */ - deleteBuilders: Endpoint<"DELETE", {pubkey: PubkeyHex}, {params: {pubkey: string}}, EmptyResponseData, EmptyMeta>; + deleteBuilderConfig: Endpoint< + "DELETE", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + EmptyResponseData, + EmptyMeta + >; getProposerConfig: Endpoint< // ⏎ @@ -795,8 +801,8 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({params: {pubkey}}), @@ -820,8 +826,8 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({params: {pubkey}, body: builderConfigDataToJson(builderConfig)}), @@ -833,8 +839,8 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({params: {pubkey}}), diff --git a/packages/api/test/unit/keymanager/oapiSpec.test.ts b/packages/api/test/unit/keymanager/oapiSpec.test.ts index 1f5e3ef24a18..6a1ae3b90c7f 100644 --- a/packages/api/test/unit/keymanager/oapiSpec.test.ts +++ b/packages/api/test/unit/keymanager/oapiSpec.test.ts @@ -20,9 +20,9 @@ const openApiFile: OpenApiFile = { const ignoredOperations = [ // TODO: remove once a keymanager-APIs release includes the builders endpoints (keymanager-APIs#88) - "getBuilders", - "setBuilders", - "deleteBuilders", + "getBuilderConfig", + "setBuilderConfig", + "deleteBuilderConfig", ]; const openApiJson = await fetchOpenApiSpec(openApiFile); diff --git a/packages/api/test/unit/keymanager/testData.ts b/packages/api/test/unit/keymanager/testData.ts index 45e6600533e1..0c493decf8d7 100644 --- a/packages/api/test/unit/keymanager/testData.ts +++ b/packages/api/test/unit/keymanager/testData.ts @@ -112,7 +112,7 @@ export const testData: GenericServerTestCases = { args: {pubkey: pubkeyRand}, res: undefined, }, - getBuilders: { + getBuilderConfig: { args: {pubkey: pubkeyRand}, res: { data: { @@ -131,7 +131,7 @@ export const testData: GenericServerTestCases = { }, }, }, - setBuilders: { + setBuilderConfig: { args: { pubkey: pubkeyRand, builderConfig: { @@ -141,7 +141,7 @@ export const testData: GenericServerTestCases = { }, res: undefined, }, - deleteBuilders: { + deleteBuilderConfig: { args: {pubkey: pubkeyRand}, res: undefined, }, diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index e5fc0783a67d..5bf3c4034767 100644 --- a/packages/cli/src/cmds/validator/keymanager/impl.ts +++ b/packages/cli/src/cmds/validator/keymanager/impl.ts @@ -381,18 +381,18 @@ export class KeymanagerApi implements Api { return {status: 204}; } - async getBuilders({pubkey}: {pubkey: PubkeyHex}): ReturnType { + async getBuilderConfig({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.assertValidKnownPubkey(pubkey); return {data: this.validator.validatorStore.getBuilderConfig(pubkey)}; } - async setBuilders({ + async setBuilderConfig({ pubkey, builderConfig, }: { pubkey: PubkeyHex; builderConfig: BuilderConfigData; - }): ReturnType { + }): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); @@ -415,7 +415,7 @@ export class KeymanagerApi implements Api { return {status: 202}; } - async deleteBuilders({pubkey}: {pubkey: PubkeyHex}): ReturnType { + async deleteBuilderConfig({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); this.assertValidKnownPubkey(pubkey); this.validator.validatorStore.deleteBuilderConfig(pubkey); From 286d828d8bb0433f8a8802d1a5492d3ca3b1b460 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 19 Aug 2026 14:06:09 +0100 Subject: [PATCH 25/67] update keymanager api spec tests to v1.2.0-alpha.0 --- packages/api/test/unit/keymanager/oapiSpec.test.ts | 11 ++--------- packages/api/test/utils/checkAgainstSpec.ts | 1 + 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/api/test/unit/keymanager/oapiSpec.test.ts b/packages/api/test/unit/keymanager/oapiSpec.test.ts index 6a1ae3b90c7f..86dc424efa6d 100644 --- a/packages/api/test/unit/keymanager/oapiSpec.test.ts +++ b/packages/api/test/unit/keymanager/oapiSpec.test.ts @@ -11,19 +11,12 @@ import {testData} from "./testData.js"; // Solutions: https://stackoverflow.com/questions/46745014/alternative-for-dirname-in-node-js-when-using-es6-modules const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const version = "v1.1.0"; +const version = "v1.2.0-alpha.0"; const openApiFile: OpenApiFile = { url: `https://github.com/ethereum/keymanager-APIs/releases/download/${version}/keymanager-oapi.json`, filepath: path.join(__dirname, "../../../oapi-schemas/keymanager-oapi.json"), version: RegExp(version), }; -const ignoredOperations = [ - // TODO: remove once a keymanager-APIs release includes the builders endpoints (keymanager-APIs#88) - "getBuilderConfig", - "setBuilderConfig", - "deleteBuilderConfig", -]; - const openApiJson = await fetchOpenApiSpec(openApiFile); -runTestCheckAgainstSpec(openApiJson, getDefinitions(config), testData, ignoredOperations); +runTestCheckAgainstSpec(openApiJson, getDefinitions(config), testData); diff --git a/packages/api/test/utils/checkAgainstSpec.ts b/packages/api/test/utils/checkAgainstSpec.ts index e3ca733683d4..14b56ca06c10 100644 --- a/packages/api/test/utils/checkAgainstSpec.ts +++ b/packages/api/test/utils/checkAgainstSpec.ts @@ -21,6 +21,7 @@ ajv.addKeyword({ }); ajv.addFormat("hex", /^0x[a-fA-F0-9]*$/); +ajv.addFormat("uri", {type: "string", validate: (value) => URL.canParse(value)}); /** * A set of properties that will be ignored during tests execution. From 9c31ef5da336d05808d8038259f1dd8d9c640a6e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 19 Aug 2026 14:06:34 +0100 Subject: [PATCH 26/67] validate builder configuration uint64 values --- packages/api/src/keymanager/routes.ts | 4 ++-- .../api/test/unit/keymanager/builderConfig.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 packages/api/test/unit/keymanager/builderConfig.test.ts diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index e1e3de9719e1..697a932f3018 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -123,8 +123,8 @@ const UINT64_MAX = 2n ** 64n - 1n; function parseGweiAmount(value: unknown, field: string): bigint | undefined { if (value === undefined) return undefined; - if (typeof value !== "string" || !/^\d+$/.test(value)) { - throw Error(`${field} must be a string number without decimals`); + if (typeof value !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(value)) { + throw Error(`${field} must be an unsigned 64-bit integer encoded as a decimal string`); } const parsed = BigInt(value); if (parsed > UINT64_MAX) { diff --git a/packages/api/test/unit/keymanager/builderConfig.test.ts b/packages/api/test/unit/keymanager/builderConfig.test.ts new file mode 100644 index 000000000000..282b9a34470d --- /dev/null +++ b/packages/api/test/unit/keymanager/builderConfig.test.ts @@ -0,0 +1,10 @@ +import {describe, expect, it} from "vitest"; +import {builderConfigDataFromJson} from "../../../src/keymanager/routes.js"; + +describe("builderConfigDataFromJson", () => { + it("rejects invalid uint64 strings", () => { + for (const value of ["00", "01", "-1", "1.0", "18446744073709551616"]) { + expect(() => builderConfigDataFromJson({min_bid: value})).toThrow(); + } + }); +}); From c88dcb4d8e9e1e1cfd1d948d5eee52f3e07bdfe2 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 19 Aug 2026 14:06:58 +0100 Subject: [PATCH 27/67] return 403 when proposer configuration writes are disabled --- packages/cli/src/cmds/validator/keymanager/impl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index 5bf3c4034767..b08f9867a011 100644 --- a/packages/cli/src/cmds/validator/keymanager/impl.ts +++ b/packages/cli/src/cmds/validator/keymanager/impl.ts @@ -39,7 +39,7 @@ export class KeymanagerApi implements Api { private checkIfProposerWriteEnabled(): void { if (this.proposerConfigWriteDisabled === true) { - throw Error("proposerSettingsFile option activated"); + throw new ApiError(403, "proposerSettingsFile option activated"); } } From caf48fc1502c6b4063d4e1775732c8ca2d25a73d Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 22 Aug 2026 20:15:48 +0100 Subject: [PATCH 28/67] serialize builder config in proposer response --- packages/api/src/keymanager/routes.ts | 12 ++++ .../cli/src/cmds/validator/keymanager/impl.ts | 10 ++- .../cmds/validator/keymanager/impl.test.ts | 68 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/unit/cmds/validator/keymanager/impl.test.ts diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 697a932f3018..28655e9d145f 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -238,9 +238,21 @@ export type ProposerConfigResponse = { gasLimit?: number; selection?: string; boostFactor?: string; + minBid?: string; + maxExecutionPayment?: string; + builders?: BuilderEntryConfigResponse[]; }; }; +export type BuilderEntryConfigResponse = { + url: string; + authData?: string; + builderPubkeys?: string[]; + maxExecutionPayment?: string; + minBid?: string; + builderBoostFactor?: string; +}; + /** * JSON serialized representation of a single keystore in EIP-2335: BLS12-381 Keystore format. * ``` diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index b08f9867a011..7542a228cfea 100644 --- a/packages/cli/src/cmds/validator/keymanager/impl.ts +++ b/packages/cli/src/cmds/validator/keymanager/impl.ts @@ -434,7 +434,15 @@ export class KeymanagerApi implements Api { ? { ...config.builder, // Default JSON serialization can't handle BigInt - boostFactor: config.builder.boostFactor ? config.builder.boostFactor.toString() : undefined, + boostFactor: config.builder.boostFactor?.toString(), + minBid: config.builder.minBid?.toString(), + maxExecutionPayment: config.builder.maxExecutionPayment?.toString(), + builders: config.builder.builders?.map((entry) => ({ + ...entry, + maxExecutionPayment: entry.maxExecutionPayment?.toString(), + minBid: entry.minBid?.toString(), + builderBoostFactor: entry.builderBoostFactor?.toString(), + })), } : undefined, }; diff --git a/packages/cli/test/unit/cmds/validator/keymanager/impl.test.ts b/packages/cli/test/unit/cmds/validator/keymanager/impl.test.ts new file mode 100644 index 000000000000..264ede77548f --- /dev/null +++ b/packages/cli/test/unit/cmds/validator/keymanager/impl.test.ts @@ -0,0 +1,68 @@ +import {describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; +import {PubkeyHex} from "@lodestar/api/keymanager"; +import {Validator} from "@lodestar/validator"; +import {KeymanagerApi} from "../../../../../src/cmds/validator/keymanager/impl.js"; +import {IPersistedKeysBackend} from "../../../../../src/cmds/validator/keymanager/interface.js"; + +describe("KeymanagerApi", () => { + it("returns all proposer config fields as JSON-safe values", async () => { + const pubkey = `0x${"11".repeat(48)}` as PubkeyHex; + const validator = { + validatorStore: { + hasVotingPubkey: vi.fn().mockReturnValue(true), + getProposerConfig: vi.fn().mockReturnValue({ + graffiti: "graffiti", + strictFeeRecipientCheck: true, + feeRecipient: "0x2222222222222222222222222222222222222222", + builder: { + gasLimit: 30_000_000, + selection: routes.validator.BuilderSelection.MaxProfit, + boostFactor: 0n, + minBid: 1n, + maxExecutionPayment: 2n, + builders: [ + { + url: "https://builder.example.com", + authData: "0x1234", + builderPubkeys: [pubkey], + maxExecutionPayment: 3n, + minBid: 4n, + builderBoostFactor: 5n, + }, + ], + }, + }), + }, + } as unknown as Validator; + const api = new KeymanagerApi(validator, {} as IPersistedKeysBackend, new AbortController().signal); + + const response = await api.getProposerConfig({pubkey}); + + expect(response).toEqual({ + data: { + graffiti: "graffiti", + strictFeeRecipientCheck: true, + feeRecipient: "0x2222222222222222222222222222222222222222", + builder: { + gasLimit: 30_000_000, + selection: routes.validator.BuilderSelection.MaxProfit, + boostFactor: "0", + minBid: "1", + maxExecutionPayment: "2", + builders: [ + { + url: "https://builder.example.com", + authData: "0x1234", + builderPubkeys: [pubkey], + maxExecutionPayment: "3", + minBid: "4", + builderBoostFactor: "5", + }, + ], + }, + }, + }); + expect(() => JSON.stringify(response)).not.toThrow(); + }); +}); From 8f6b6493125665f43f35f994875f9bde5ae9315f Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 22 Aug 2026 20:44:56 +0100 Subject: [PATCH 29/67] rename request auth to builder request auth --- packages/api/src/beacon/routes/validator.ts | 4 +- packages/api/src/builder/routes.ts | 10 ++--- .../test/unit/beacon/testData/validator.ts | 4 +- packages/api/test/unit/builder/testData.ts | 2 +- .../api/impl/validator/produceBlockV4.test.ts | 8 ++-- .../submitBuilderPreferences.test.ts | 2 +- .../unit/execution/builder/apiClient.test.ts | 2 +- packages/params/src/index.ts | 2 +- packages/types/src/gloas/sszTypes.ts | 12 +++--- packages/types/src/gloas/types.ts | 4 +- packages/validator/src/services/block.ts | 2 +- .../src/services/builderPreferences.ts | 2 +- .../validator/src/services/validatorStore.ts | 43 ++++++++++--------- .../src/util/externalSignerClient.ts | 12 +++--- .../test/unit/validatorStore.test.ts | 20 ++++----- 15 files changed, 66 insertions(+), 63 deletions(-) diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 5487a8b1b3df..c80f328b24b5 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -290,7 +290,7 @@ class BuilderUrlType extends ByteListType { export const BuilderEntryType = new ContainerType( { url: new BuilderUrlType(MAX_BUILDER_URL_SIZE), - auth: ssz.gloas.SignedRequestAuth, + auth: ssz.gloas.SignedBuilderRequestAuth, builderPubkeys: ArrayOf(ssz.BLSPubkey, MAX_BUILDER_PUBKEYS), maxExecutionPayment: ssz.Gwei, minBid: ssz.Gwei, @@ -312,7 +312,7 @@ export const BuilderPreferencesEntryType = new ContainerType( { proposerPubkey: ssz.BLSPubkey, url: new BuilderUrlType(MAX_BUILDER_URL_SIZE), - auth: ssz.gloas.SignedRequestAuth, + auth: ssz.gloas.SignedBuilderRequestAuth, maxExecutionPayment: ssz.Gwei, }, {typeName: "BuilderPreferencesEntry", jsonCase: "eth2"} diff --git a/packages/api/src/builder/routes.ts b/packages/api/src/builder/routes.ts index 1dfd49273c0f..4b0d0f3f79f9 100644 --- a/packages/api/src/builder/routes.ts +++ b/packages/api/src/builder/routes.ts @@ -96,7 +96,7 @@ export type Endpoints = { parentRoot: Root; proposerPubkey: BLSPubkey; /** Authenticates the requesting proposer to the builder */ - requestAuth: gloas.SignedRequestAuth; + requestAuth: gloas.SignedBuilderRequestAuth; /** Unix timestamp in milliseconds at which the request was sent */ dateMilliseconds: number; /** The proposer's timeout for the request in milliseconds, measured from `dateMilliseconds` */ @@ -278,7 +278,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions = { builders: [ { url: new TextEncoder().encode("https://builder.example.com"), - auth: ssz.gloas.SignedRequestAuth.defaultValue(), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), builderPubkeys: [], maxExecutionPayment: 0n, minBid: 0n, @@ -182,7 +182,7 @@ export const testData: GenericServerTestCases = { { proposerPubkey: new Uint8Array(48).fill(1), url: new TextEncoder().encode("https://builder.example.com"), - auth: ssz.gloas.SignedRequestAuth.defaultValue(), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), maxExecutionPayment: 0n, }, ], diff --git a/packages/api/test/unit/builder/testData.ts b/packages/api/test/unit/builder/testData.ts index bbad22288cd9..fae2aa23cb7c 100644 --- a/packages/api/test/unit/builder/testData.ts +++ b/packages/api/test/unit/builder/testData.ts @@ -39,7 +39,7 @@ export const testData: GenericServerTestCases = { parentHash: root, parentRoot: root, proposerPubkey: fromHexString(pubkeyRand), - requestAuth: ssz.gloas.SignedRequestAuth.defaultValue(), + requestAuth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), dateMilliseconds: 1710338135000, timeoutMs: 1000, }, diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index e053c145a0fc..0aa5b26d6d2a 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -206,7 +206,7 @@ describe("api/validator - produceBlockV4", () => { const builderUrl = "https://builder.example.com"; const entry = { url: new TextEncoder().encode(builderUrl), - auth: ssz.gloas.SignedRequestAuth.defaultValue(), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), builderPubkeys: [], maxExecutionPayment: 0n, minBid: 0n, @@ -244,7 +244,7 @@ describe("api/validator - produceBlockV4", () => { const builderUrl = "https://builder.example.com"; const entry = { url: new TextEncoder().encode(builderUrl), - auth: ssz.gloas.SignedRequestAuth.defaultValue(), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), builderPubkeys: [], maxExecutionPayment: 0n, minBid: 0n, @@ -279,7 +279,7 @@ describe("api/validator - produceBlockV4", () => { const builderUrl = "https://builder.example.com"; const entry = { url: new TextEncoder().encode(builderUrl), - auth: ssz.gloas.SignedRequestAuth.defaultValue(), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), builderPubkeys: [], maxExecutionPayment: 0n, minBid: 0n, @@ -314,7 +314,7 @@ describe("api/validator - produceBlockV4", () => { const builderUrl = "https://builder.example.com"; const entry = { url: new TextEncoder().encode(builderUrl), - auth: ssz.gloas.SignedRequestAuth.defaultValue(), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), builderPubkeys: [], maxExecutionPayment: 0n, minBid: 0n, diff --git a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts index edf67d915f0d..c74f6e904aae 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts @@ -43,7 +43,7 @@ describe("api/validator - submitBuilderPreferences", () => { }); function getEntry(url: string) { - const auth = ssz.gloas.SignedRequestAuth.defaultValue(); + const auth = ssz.gloas.SignedBuilderRequestAuth.defaultValue(); auth.message.slot = 1; return { proposerPubkey: new Uint8Array(48), diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index e97069d9d1b4..428506f48498 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -41,7 +41,7 @@ describe("execution/builder/apiClient", () => { }); function getBuilderEntry(url: string, slot: number): routes.validator.BuilderEntry { - const auth = ssz.gloas.SignedRequestAuth.defaultValue(); + const auth = ssz.gloas.SignedBuilderRequestAuth.defaultValue(); auth.message.data = new Uint8Array([1]); auth.message.slot = slot; return { diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index 3b01d109d2a9..fbb28a6496c9 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -187,7 +187,7 @@ export const DOMAIN_INCLUSION_LIST_COMMITTEE = Uint8Array.from([16, 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]); +export const DOMAIN_BUILDER_REQUEST_AUTH = Uint8Array.from([11, 0, 0, 1]); // Participation flag indices diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index c880ff8ef592..7e4d7192b4e0 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -361,20 +361,20 @@ export const SignedExecutionPayloadBid = new ContainerType( // Builder API types (builder-specs) -export const RequestAuth = new ContainerType( +export const BuilderRequestAuth = new ContainerType( { data: new ByteListType(MAX_DATA_SIZE), slot: Slot, }, - {typeName: "RequestAuth", jsonCase: "eth2"} + {typeName: "BuilderRequestAuth", jsonCase: "eth2"} ); -export const SignedRequestAuth = new ContainerType( +export const SignedBuilderRequestAuth = new ContainerType( { - message: RequestAuth, + message: BuilderRequestAuth, signature: BLSSignature, }, - {typeName: "SignedRequestAuth", jsonCase: "eth2"} + {typeName: "SignedBuilderRequestAuth", jsonCase: "eth2"} ); export const BuilderPreferences = new ContainerType( @@ -387,7 +387,7 @@ export const BuilderPreferences = new ContainerType( export const BuilderPreferencesRequest = new ContainerType( { preferences: BuilderPreferences, - auth: SignedRequestAuth, + auth: SignedBuilderRequestAuth, }, {typeName: "BuilderPreferencesRequest", jsonCase: "eth2"} ); diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index 5e5694f22646..d4b29bc182d6 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -44,8 +44,8 @@ export type SignedProposerPreferences = ValueOf; export type ExecutionPayloadBid = ValueOf; export type SignedExecutionPayloadBid = ValueOf; -export type RequestAuth = ValueOf; -export type SignedRequestAuth = ValueOf; +export type BuilderRequestAuth = ValueOf; +export type SignedBuilderRequestAuth = ValueOf; export type BuilderPreferences = ValueOf; export type BuilderPreferencesRequest = ValueOf; export type BlockAccessList = ValueOf; diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 2a723f8e6f82..71837436335d 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -216,7 +216,7 @@ export class BlockProposingService { await Promise.all( builderEntries.map(async (entry) => { try { - const auth = await this.validatorStore.getRequestAuth(pubkey, entry.authData, slot, slot); + const auth = await this.validatorStore.getBuilderRequestAuth(pubkey, entry.authData, slot, slot); return { url: new Uint8Array(Buffer.from(entry.url, "utf8")), auth, diff --git a/packages/validator/src/services/builderPreferences.ts b/packages/validator/src/services/builderPreferences.ts index 47b2180548a6..74b46f95fde9 100644 --- a/packages/validator/src/services/builderPreferences.ts +++ b/packages/validator/src/services/builderPreferences.ts @@ -95,7 +95,7 @@ export class BuilderPreferencesService { // succeeded for all builders, else the duty is retried on the next tick const dutyEntries: routes.validator.BuilderPreferencesEntry[] = []; for (const entry of builderEntries) { - const auth = await this.validatorStore.getRequestAuth(duty.pubkey, entry.authData, duty.slot, slot); + const auth = await this.validatorStore.getBuilderRequestAuth(duty.pubkey, entry.authData, duty.slot, slot); dutyEntries.push({ proposerPubkey: duty.pubkey, url: new Uint8Array(Buffer.from(entry.url, "utf8")), diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 60b0c91b9b4c..b63a0225103f 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -9,11 +9,11 @@ import { DOMAIN_BEACON_ATTESTER, DOMAIN_BEACON_BUILDER, DOMAIN_BEACON_PROPOSER, + DOMAIN_BUILDER_REQUEST_AUTH, DOMAIN_CONTRIBUTION_AND_PROOF, DOMAIN_PROPOSER_PREFERENCES, DOMAIN_PTC_ATTESTER, DOMAIN_RANDAO, - DOMAIN_REQUEST_AUTH, DOMAIN_SELECTION_PROOF, DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, @@ -152,8 +152,8 @@ export type Signer = SignerLocal | SignerRemote; type ValidatorData = ProposerConfig & { signer: Signer; builderData?: BuilderData; - /** Pre-signed request auths keyed by proposal slot and auth data, pruned by proposal slot */ - requestAuths?: Map; + /** Pre-signed builder request auths keyed by proposal slot and auth data, pruned by proposal slot */ + builderRequestAuths?: Map; }; export const defaultOptions = { @@ -1041,23 +1041,25 @@ export class ValidatorStore { }; } - async signRequestAuth( + async signBuilderRequestAuth( pubkeyMaybeHex: BLSPubkeyMaybeHex, data: Uint8Array, proposalSlot: Slot - ): Promise { + ): Promise { if (data.length === 0 || data.length > MAX_DATA_SIZE) { - throw Error(`Invalid request auth data length=${data.length}, must be within 1 and ${MAX_DATA_SIZE} bytes`); + throw Error( + `Invalid builder request auth data length=${data.length}, must be within 1 and ${MAX_DATA_SIZE} bytes` + ); } - const message: gloas.RequestAuth = {data, slot: proposalSlot}; + const message: gloas.BuilderRequestAuth = {data, slot: proposalSlot}; const signingSlot = 0; - const domain = computeDomain(DOMAIN_REQUEST_AUTH, this.config.GENESIS_FORK_VERSION, ZERO_HASH); - const signingRoot = computeSigningRoot(ssz.gloas.RequestAuth, message, domain); + const domain = computeDomain(DOMAIN_BUILDER_REQUEST_AUTH, this.config.GENESIS_FORK_VERSION, ZERO_HASH); + const signingRoot = computeSigningRoot(ssz.gloas.BuilderRequestAuth, message, domain); const signableMessage: SignableMessage = { - type: SignableMessageType.REQUEST_AUTH, + type: SignableMessageType.BUILDER_REQUEST_AUTH, data: message, }; @@ -1068,36 +1070,37 @@ export class ValidatorStore { } /** - * Return a pre-signed request auth for the auth data and proposal slot, or sign and cache a new + * Return a pre-signed builder request auth for the auth data and proposal slot, or sign and cache a new * one. Signing happens off the block proposal hot path when preferences are submitted ahead of * time, cached auths are then used just-in-time when requesting bids at proposal time. */ - async getRequestAuth( + async getBuilderRequestAuth( pubkeyMaybeHex: BLSPubkeyMaybeHex, data: Uint8Array, proposalSlot: Slot, currentSlot: Slot - ): Promise { + ): Promise { const pubkeyHex = typeof pubkeyMaybeHex === "string" ? pubkeyMaybeHex : toPubkeyHex(pubkeyMaybeHex); const authKey = `${proposalSlot}-${toHex(data)}`; const validatorData = this.validators.get(pubkeyHex); - const cached = validatorData?.requestAuths?.get(authKey); + const cached = validatorData?.builderRequestAuths?.get(authKey); if (cached !== undefined) { return cached; } - const signedRequestAuth = await this.signRequestAuth(pubkeyMaybeHex, data, proposalSlot); + const signedRequestAuth = await this.signBuilderRequestAuth(pubkeyMaybeHex, data, proposalSlot); if (validatorData !== undefined) { - const requestAuths = validatorData.requestAuths ?? new Map(); + const builderRequestAuths = + validatorData.builderRequestAuths ?? new Map(); // Prune auths for proposal slots that are already in the past - for (const key of requestAuths.keys()) { + for (const key of builderRequestAuths.keys()) { if (Number(key.slice(0, key.indexOf("-"))) < currentSlot) { - requestAuths.delete(key); + builderRequestAuths.delete(key); } } - requestAuths.set(authKey, signedRequestAuth); - validatorData.requestAuths = requestAuths; + builderRequestAuths.set(authKey, signedRequestAuth); + validatorData.builderRequestAuths = builderRequestAuths; } return signedRequestAuth; diff --git a/packages/validator/src/util/externalSignerClient.ts b/packages/validator/src/util/externalSignerClient.ts index aabce7e08007..bc18bf69479c 100644 --- a/packages/validator/src/util/externalSignerClient.ts +++ b/packages/validator/src/util/externalSignerClient.ts @@ -36,7 +36,7 @@ export enum SignableMessageType { EXECUTION_PAYLOAD_ENVELOPE = "EXECUTION_PAYLOAD_ENVELOPE", PAYLOAD_ATTESTATION = "PAYLOAD_ATTESTATION", PROPOSER_PREFERENCES = "PROPOSER_PREFERENCES", - REQUEST_AUTH = "REQUEST_AUTH", + BUILDER_REQUEST_AUTH = "BUILDER_REQUEST_AUTH", } const AggregationSlotType = new ContainerType({ @@ -89,7 +89,7 @@ export type SignableMessage = | {type: SignableMessageType.EXECUTION_PAYLOAD_ENVELOPE; data: gloas.ExecutionPayloadEnvelope} | {type: SignableMessageType.PAYLOAD_ATTESTATION; data: gloas.PayloadAttestationData} | {type: SignableMessageType.PROPOSER_PREFERENCES; data: gloas.ProposerPreferences} - | {type: SignableMessageType.REQUEST_AUTH; data: gloas.RequestAuth}; + | {type: SignableMessageType.BUILDER_REQUEST_AUTH; data: gloas.BuilderRequestAuth}; const requiresForkInfo: Record = { [SignableMessageType.AGGREGATION_SLOT]: true, @@ -107,8 +107,8 @@ const requiresForkInfo: Record = { [SignableMessageType.EXECUTION_PAYLOAD_ENVELOPE]: true, [SignableMessageType.PAYLOAD_ATTESTATION]: true, [SignableMessageType.PROPOSER_PREFERENCES]: true, - // Signed with compute_domain(DOMAIN_REQUEST_AUTH) using genesis fork version and zero genesis validators root - [SignableMessageType.REQUEST_AUTH]: false, + // Signed with compute_domain(DOMAIN_BUILDER_REQUEST_AUTH) using genesis fork version and zero genesis validators root + [SignableMessageType.BUILDER_REQUEST_AUTH]: false, }; type Web3SignerSerializedRequest = { @@ -290,8 +290,8 @@ 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.RequestAuth.toJson(payload.data)}; + case SignableMessageType.BUILDER_REQUEST_AUTH: + return {builder_request_auth: ssz.gloas.BuilderRequestAuth.toJson(payload.data)}; } } diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index 52bbbac1873c..dc646c465294 100644 --- a/packages/validator/test/unit/validatorStore.test.ts +++ b/packages/validator/test/unit/validatorStore.test.ts @@ -4,7 +4,7 @@ import {SecretKey} from "@chainsafe/lodestar-z/blst"; import {fromHexString, toHexString} from "@chainsafe/ssz"; import {routes} from "@lodestar/api"; import {chainConfig} from "@lodestar/config/default"; -import {DOMAIN_REQUEST_AUTH, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {DOMAIN_BUILDER_REQUEST_AUTH, SLOTS_PER_EPOCH} 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"; @@ -205,33 +205,33 @@ describe("ValidatorStore", () => { }); }); - it("Should sign request auth with fork-independent domain", async () => { + it("Should sign builder request auth with fork-independent domain", async () => { const data = Buffer.from("https://builder.example.com", "utf8"); const proposalSlot = 10; - const signedRequestAuth = await validatorStore.signRequestAuth(pubkeys[0], data, proposalSlot); + const signedRequestAuth = await validatorStore.signBuilderRequestAuth(pubkeys[0], data, proposalSlot); expect(toHexString(signedRequestAuth.message.data)).toBe(toHexString(data)); expect(signedRequestAuth.message.slot).toBe(proposalSlot); - const domain = computeDomain(DOMAIN_REQUEST_AUTH, chainConfig.GENESIS_FORK_VERSION, ZERO_HASH); - const signingRoot = computeSigningRoot(ssz.gloas.RequestAuth, signedRequestAuth.message, domain); + const domain = computeDomain(DOMAIN_BUILDER_REQUEST_AUTH, chainConfig.GENESIS_FORK_VERSION, ZERO_HASH); + const signingRoot = computeSigningRoot(ssz.gloas.BuilderRequestAuth, signedRequestAuth.message, domain); expect(toHexString(signedRequestAuth.signature)).toBe(toHexString(secretKeys[0].sign(signingRoot).toBytes())); // Signing root must bind both the auth data and the proposal slot const otherData = computeSigningRoot( - ssz.gloas.RequestAuth, + ssz.gloas.BuilderRequestAuth, {data: Buffer.from("other"), slot: proposalSlot}, domain ); - const otherSlot = computeSigningRoot(ssz.gloas.RequestAuth, {data, slot: proposalSlot + 1}, domain); + const otherSlot = computeSigningRoot(ssz.gloas.BuilderRequestAuth, {data, slot: proposalSlot + 1}, domain); expect(toHexString(otherData)).not.toBe(toHexString(signingRoot)); expect(toHexString(otherSlot)).not.toBe(toHexString(signingRoot)); }); - it("Should reject request auth data with invalid length", async () => { - await expect(validatorStore.signRequestAuth(pubkeys[0], new Uint8Array(0), 10)).rejects.toThrow(); - await expect(validatorStore.signRequestAuth(pubkeys[0], new Uint8Array(4097), 10)).rejects.toThrow(); + it("Should reject builder request auth data with invalid length", async () => { + await expect(validatorStore.signBuilderRequestAuth(pubkeys[0], new Uint8Array(0), 10)).rejects.toThrow(); + await expect(validatorStore.signBuilderRequestAuth(pubkeys[0], new Uint8Array(4097), 10)).rejects.toThrow(); }); it("Should resolve builder entries against key and validator client defaults", () => { From 24eb511ab663489d57ef013d5b60fca080edfdcb Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 22 Aug 2026 21:17:21 +0100 Subject: [PATCH 30/67] rename auth data constants to builder auth data --- packages/api/src/keymanager/routes.ts | 10 ++++++---- packages/cli/src/util/proposerConfig.ts | 8 ++++---- packages/params/src/index.ts | 2 +- packages/types/src/gloas/sszTypes.ts | 4 ++-- packages/validator/src/services/validatorStore.ts | 6 +++--- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 28655e9d145f..6f754f58e46c 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -1,6 +1,6 @@ import {ContainerType, ValueOf} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; -import {MAX_BUILDER_URL_SIZE} from "@lodestar/params"; +import {MAX_BUILDER_AUTH_DATA_SIZE, MAX_BUILDER_URL_SIZE} from "@lodestar/params"; import {Epoch, phase0, ssz, stringType} from "@lodestar/types"; import { EmptyArgs, @@ -117,7 +117,7 @@ export type BuilderConfigData = { builders?: BuilderEntryConfig[]; }; -const AUTH_DATA_PATTERN = /^0x(?:[a-fA-F0-9]{2}){1,4096}$/; +const BUILDER_AUTH_DATA_PATTERN = new RegExp(`^0x(?:[a-fA-F0-9]{2}){1,${MAX_BUILDER_AUTH_DATA_SIZE}}$`); const PUBKEY_PATTERN = /^0x[a-fA-F0-9]{96}$/; const UINT64_MAX = 2n ** 64n - 1n; @@ -181,8 +181,10 @@ export function builderConfigDataFromJson(json: unknown): BuilderConfigData { if (new TextEncoder().encode(url).length > MAX_BUILDER_URL_SIZE) { throw Error(`builders[${i}].url must not exceed ${MAX_BUILDER_URL_SIZE} bytes`); } - if (auth_data !== undefined && (typeof auth_data !== "string" || !AUTH_DATA_PATTERN.test(auth_data))) { - throw Error(`builders[${i}].auth_data must be a non-empty hex string of at most 4096 bytes`); + if (auth_data !== undefined && (typeof auth_data !== "string" || !BUILDER_AUTH_DATA_PATTERN.test(auth_data))) { + throw Error( + `builders[${i}].auth_data must be a non-empty hex string of at most ${MAX_BUILDER_AUTH_DATA_SIZE} bytes` + ); } let builderPubkeys: string[] | undefined; if (builder_pubkeys !== undefined) { diff --git a/packages/cli/src/util/proposerConfig.ts b/packages/cli/src/util/proposerConfig.ts index d6cbfd2bfdf4..779d3554f9ac 100644 --- a/packages/cli/src/util/proposerConfig.ts +++ b/packages/cli/src/util/proposerConfig.ts @@ -2,14 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import {routes} from "@lodestar/api"; import {BuilderEntryConfig, builderConfigDataFromJson} from "@lodestar/api/keymanager"; -import {MAX_BUILDER_ENTRIES, MAX_BUILDER_URL_SIZE, MAX_DATA_SIZE} from "@lodestar/params"; +import {MAX_BUILDER_AUTH_DATA_SIZE, MAX_BUILDER_ENTRIES, MAX_BUILDER_URL_SIZE} from "@lodestar/params"; import {fromHex, toHex} from "@lodestar/utils"; import {ValidatorProposerConfig} from "@lodestar/validator"; import {parseFeeRecipient} from "./feeRecipient.js"; import {readFile} from "./file.js"; const UINT64_MAX = 2n ** 64n - 1n; -const AUTH_DATA_PATTERN = new RegExp(`^0x(?:[a-fA-F0-9]{2}){1,${MAX_DATA_SIZE}}$`); +const BUILDER_AUTH_DATA_PATTERN = new RegExp(`^0x(?:[a-fA-F0-9]{2}){1,${MAX_BUILDER_AUTH_DATA_SIZE}}$`); type ProposerConfig = ValidatorProposerConfig["defaultConfig"]; @@ -260,9 +260,9 @@ export function parseBuilderUrls(urls?: string[]): BuilderEntryConfig[] | undefi if (Buffer.byteLength(url, "utf8") > MAX_BUILDER_URL_SIZE) { throw Error(`Invalid builder url, must not exceed ${MAX_BUILDER_URL_SIZE} bytes: ${url}`); } - if (authData !== undefined && !AUTH_DATA_PATTERN.test(authData)) { + if (authData !== undefined && !BUILDER_AUTH_DATA_PATTERN.test(authData)) { throw Error( - `Invalid builder url auth data, must be a 0x-prefixed hex string of 1 to ${MAX_DATA_SIZE} bytes: ${url}` + `Invalid builder url auth data, must be a 0x-prefixed hex string of 1 to ${MAX_BUILDER_AUTH_DATA_SIZE} bytes: ${url}` ); } const entryKey = `${url}|${authData !== undefined ? toHex(fromHex(authData)) : toHex(Buffer.from(url))}`; diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index fbb28a6496c9..f1df3edc0b6e 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -375,7 +375,7 @@ 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_BUILDER_AUTH_DATA_SIZE = 4096; export const MAX_BUILDER_ENTRIES = 64; export const MAX_BUILDER_URL_SIZE = 2048; export const MAX_BUILDER_PUBKEYS = 64; diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 7e4d7192b4e0..8e60c8c1208a 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -18,7 +18,7 @@ import { EXECUTION_BLOCK_HASH_DEPTH_GLOAS, FINALIZED_ROOT_DEPTH_GLOAS, HISTORICAL_ROOTS_LIMIT, - MAX_DATA_SIZE, + MAX_BUILDER_AUTH_DATA_SIZE, MIN_SEED_LOOKAHEAD, NEXT_SYNC_COMMITTEE_DEPTH_GLOAS, NUMBER_OF_COLUMNS, @@ -363,7 +363,7 @@ export const SignedExecutionPayloadBid = new ContainerType( export const BuilderRequestAuth = new ContainerType( { - data: new ByteListType(MAX_DATA_SIZE), + data: new ByteListType(MAX_BUILDER_AUTH_DATA_SIZE), slot: Slot, }, {typeName: "BuilderRequestAuth", jsonCase: "eth2"} diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index b63a0225103f..0a187da5abcb 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -18,7 +18,7 @@ import { DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, ForkSeq, - MAX_DATA_SIZE, + MAX_BUILDER_AUTH_DATA_SIZE, } from "@lodestar/params"; import { ZERO_HASH, @@ -1046,9 +1046,9 @@ export class ValidatorStore { data: Uint8Array, proposalSlot: Slot ): Promise { - if (data.length === 0 || data.length > MAX_DATA_SIZE) { + if (data.length === 0 || data.length > MAX_BUILDER_AUTH_DATA_SIZE) { throw Error( - `Invalid builder request auth data length=${data.length}, must be within 1 and ${MAX_DATA_SIZE} bytes` + `Invalid builder request auth data length=${data.length}, must be within 1 and ${MAX_BUILDER_AUTH_DATA_SIZE} bytes` ); } From cf544054708dc7413ce48b63a14acfade05bdcb9 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 22 Aug 2026 22:19:33 +0100 Subject: [PATCH 31/67] use builder constants for keymanager config bounds --- packages/api/src/keymanager/routes.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 6f754f58e46c..165583238c6e 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -1,6 +1,11 @@ import {ContainerType, ValueOf} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; -import {MAX_BUILDER_AUTH_DATA_SIZE, MAX_BUILDER_URL_SIZE} from "@lodestar/params"; +import { + MAX_BUILDER_AUTH_DATA_SIZE, + MAX_BUILDER_ENTRIES, + MAX_BUILDER_PUBKEYS, + MAX_BUILDER_URL_SIZE, +} from "@lodestar/params"; import {Epoch, phase0, ssz, stringType} from "@lodestar/types"; import { EmptyArgs, @@ -167,8 +172,8 @@ export function builderConfigDataFromJson(json: unknown): BuilderConfigData { if (!Array.isArray(builders)) { throw Error("builders must be an array"); } - if (builders.length > 64) { - throw Error(`builders must not contain more than 64 entries, got ${builders.length}`); + if (builders.length > MAX_BUILDER_ENTRIES) { + throw Error(`builders must not contain more than ${MAX_BUILDER_ENTRIES} entries, got ${builders.length}`); } parsedBuilders = builders.map((entry, i): BuilderEntryConfig => { if (typeof entry !== "object" || entry === null) { @@ -188,8 +193,8 @@ export function builderConfigDataFromJson(json: unknown): BuilderConfigData { } let builderPubkeys: string[] | undefined; if (builder_pubkeys !== undefined) { - if (!Array.isArray(builder_pubkeys) || builder_pubkeys.length > 64) { - throw Error(`builders[${i}].builder_pubkeys must be an array of at most 64 pubkeys`); + if (!Array.isArray(builder_pubkeys) || builder_pubkeys.length > MAX_BUILDER_PUBKEYS) { + throw Error(`builders[${i}].builder_pubkeys must be an array of at most ${MAX_BUILDER_PUBKEYS} pubkeys`); } builderPubkeys = builder_pubkeys.map((pubkey) => { if (typeof pubkey !== "string" || !PUBKEY_PATTERN.test(pubkey)) { From e05cd06d199abe590fb70725be205b8ef366381c Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 22 Aug 2026 22:33:42 +0100 Subject: [PATCH 32/67] count builder bid execution payment up to the entry cap --- .../validator-management/vc-configuration.md | 2 +- .../src/api/impl/validator/index.ts | 8 +++- .../chain/validation/executionPayloadBid.ts | 15 +++---- .../api/impl/validator/produceBlockV4.test.ts | 40 +++++++++++++++++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 953f44321806..e2dc688d0c2a 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -117,7 +117,7 @@ Starting with Gloas, external builders are configured on the validator client an Every bid request is authenticated with data the builder expects, by default the UTF-8 bytes of the builder URL exactly as configured. If a builder requires different auth data agreed out of band, append it as a hex fragment to its URL, e.g. `--builder.urls https://builder.example.com#0x0123`. The fragment is stripped before the URL is used and never sent to the builder. Auth data that must stay secret is better kept in the [proposer configuration file](./proposer-config.md), as command line arguments are visible to other processes. - [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. -- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei accepted from a builder. The default of `0` only accepts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. +- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei counted from a builder bid, any payment above it adds nothing to the bid when comparing it with other bids. The default of `0` only accepts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. Builders can also be configured per validator key with per-builder overrides via the [Set Builder Configuration keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builder%20Config) or the [proposer configuration file](./proposer-config.md). diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 7ef00553a17a..8989b0a0a8a5 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -52,6 +52,7 @@ import { import { GWEI_TO_WEI, TimeoutError, + bigIntMin, defer, formatWeiToEth, fromHex, @@ -967,7 +968,10 @@ export function getValidatorApi( }); candidates.push({ signedBid, - totalGwei: BigInt(signedBid.message.value) + signedBid.message.executionPayment, + // Execution payment above the entry's cap adds nothing to the bid + totalGwei: + BigInt(signedBid.message.value) + + bigIntMin(signedBid.message.executionPayment, entry.maxExecutionPayment), boostFactor: entry.builderBoostFactor, url, }); @@ -1119,7 +1123,7 @@ export function getValidatorApi( const result = selectBlockProductionSourceByBoostFactor({ builderBoostFactor: bestBid?.boostFactor ?? builderBoostFactor, engineExecutionPayloadValue: engineResult.value.executionPayloadValue, - // The bid total payment is its value plus executionPayment, in Gwei + // The bid total payment is its value plus its counted executionPayment, in Gwei builderExecutionPayloadValue: (bestBid?.totalGwei ?? 0n) * GWEI_TO_WEI, }); source = result.source; diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index d2ac5c27a972..81ef5c650d8d 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -11,7 +11,7 @@ import { isStatePostGloas, } from "@lodestar/state-transition"; import {RootHex, Slot, ValidatorIndex, gloas} from "@lodestar/types"; -import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import {bigIntMin, byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; import {getShufflingDependentRoot} from "../../util/dependentRoot.js"; import {ExecutionPayloadBidError, ExecutionPayloadBidErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; @@ -89,8 +89,8 @@ export async function validateApiExecutionPayloadBid( /** * Validate a bid received from a builder over the builder API in response to a bid request * made during block production. Unlike gossip validation, the bid must match the requested - * slot and parent exactly, may carry a non-zero `executionPayment` bounded by the entry's - * `maxExecutionPayment`, and is not subject to gossip anti-spam rules. + * slot and parent exactly, may carry a non-zero `executionPayment` which is counted at most at + * the entry's `maxExecutionPayment`, and is not subject to gossip anti-spam rules. * * Throws with a description of the failure, the caller drops the bid. */ @@ -121,13 +121,8 @@ export async function validateBuilderApiExecutionPayloadBid( ); } - if (bid.executionPayment > entry.maxExecutionPayment) { - throw Error( - `Bid executionPayment=${bid.executionPayment} exceeds maxExecutionPayment=${entry.maxExecutionPayment}` - ); - } - - const totalPayment = BigInt(bid.value) + bid.executionPayment; + // Execution payment above the entry's cap adds nothing to the bid + const totalPayment = BigInt(bid.value) + bigIntMin(bid.executionPayment, entry.maxExecutionPayment); if (totalPayment < entry.minBid) { throw Error(`Bid total payment=${totalPayment} is below minBid=${entry.minBid}`); } diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 0aa5b26d6d2a..4237056d189c 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -240,6 +240,46 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(bidBlock); }); + it("counts a builder API bid's execution payment only up to its entry cap", async () => { + const builderUrl = "https://builder.example.com"; + const entry = { + url: new TextEncoder().encode(builderUrl), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: 1n, + minBid: 0n, + builderBoostFactor: 100n, + }; + const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + apiBid.message.value = 1; + apiBid.message.executionPayment = 5n; + apiBid.message.builderIndex = 7; + const p2pBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + p2pBid.message.value = 3; + p2pBid.message.builderIndex = 42; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); + modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ + {url: builderUrl, entry, signedBid: apiBid}, + ]); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: 0n, builderBoostFactor: 100n, builders: [entry]}, + }); + + // The builder API bid counts as 1 + min(5, 1) = 2, so the p2p bid (3) wins + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: p2pBid})); + expect(block).toEqual(bidBlock); + }); + it("prefers the builder API bid over an equally boosted p2p bid", async () => { const builderUrl = "https://builder.example.com"; const entry = { From 3bccfac15966608a6901a24074909267a7ee5337 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 11:19:51 +0100 Subject: [PATCH 33/67] preserve precision when comparing boosted bids --- .../src/api/impl/validator/index.ts | 4 +-- .../src/api/impl/validator/utils.ts | 2 +- .../api/impl/validator/produceBlockV4.test.ts | 35 +++++++++++++++++++ .../unit/api/impl/validator/utils.test.ts | 17 +++++++-- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 8989b0a0a8a5..b21d63cda591 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -990,7 +990,7 @@ export function getValidatorApi( }); } - const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => (boostFactor * totalGwei) / 100n; + const boostedValueNumerator = ({totalGwei, boostFactor}: BidCandidate): bigint => boostFactor * totalGwei; let best: BidCandidate | null = null; for (const candidate of candidates) { const candidateIsMaxBoost = candidate.boostFactor === MAX_BUILDER_BOOST_FACTOR; @@ -999,7 +999,7 @@ export function getValidatorApi( if ( best === null || (candidateIsMaxBoost && !bestIsMaxBoost) || - (candidateIsMaxBoost === bestIsMaxBoost && boostedValue(candidate) > boostedValue(best)) + (candidateIsMaxBoost === bestIsMaxBoost && boostedValueNumerator(candidate) > boostedValueNumerator(best)) ) { best = candidate; } diff --git a/packages/beacon-node/src/api/impl/validator/utils.ts b/packages/beacon-node/src/api/impl/validator/utils.ts index 4fb9d3fc1114..17996c788144 100644 --- a/packages/beacon-node/src/api/impl/validator/utils.ts +++ b/packages/beacon-node/src/api/impl/validator/utils.ts @@ -89,7 +89,7 @@ export function selectBlockProductionSourceByBoostFactor({ return {source: ProducedBlockSource.builder, reason: BuilderBlockSelectionReason.BuilderPreferred}; } - if (engineExecutionPayloadValue >= (builderExecutionPayloadValue * builderBoostFactor) / BigInt(100)) { + if (engineExecutionPayloadValue * 100n >= builderExecutionPayloadValue * builderBoostFactor) { return {source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.BlockValue}; } diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 4237056d189c..4c1330fcd95c 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -315,6 +315,41 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(bidBlock); }); + it("ranks builder bids without truncating boosted values", async () => { + const builderUrl = "https://builder.example.com"; + const entry = { + url: new TextEncoder().encode(builderUrl), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, + }; + const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + apiBid.message.value = 1; + const p2pBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + p2pBid.message.value = 1; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); + modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ + {url: builderUrl, entry, signedBid: apiBid}, + ]); + + await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: 0n, builderBoostFactor: 150n, builders: [entry]}, + }); + + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: p2pBid})); + }); + it("prefers a max boost builder entry before comparing bid values", async () => { const builderUrl = "https://builder.example.com"; const entry = { 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 7412df26323d..4860b6df55f8 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 @@ -3,8 +3,11 @@ import {pubkeyCache} from "@chainsafe/lodestar-z/pubkeys"; import {toHexString} from "@chainsafe/ssz"; import {createBeaconConfig, defaultChainConfig} from "@lodestar/config"; import {BeaconStateAllForks, BeaconStateView, createCachedBeaconState} from "@lodestar/state-transition"; -import {BLSPubkey, ValidatorIndex, ssz} from "@lodestar/types"; -import {getPubkeysForIndices} from "../../../../../src/api/impl/validator/utils.js"; +import {BLSPubkey, ProducedBlockSource, ValidatorIndex, ssz} from "@lodestar/types"; +import { + getPubkeysForIndices, + selectBlockProductionSourceByBoostFactor, +} from "../../../../../src/api/impl/validator/utils.js"; describe("api / impl / validator / utils", () => { const vc = 32; @@ -37,4 +40,14 @@ describe("api / impl / validator / utils", () => { const pubkeysRes = getPubkeysForIndices(new BeaconStateView(cachedState), indexes); expect(pubkeysRes.map(toHexString)).toEqual(pubkeys.map(toHexString)); }); + + it("compares boosted values without truncating", () => { + expect( + selectBlockProductionSourceByBoostFactor({ + engineExecutionPayloadValue: 1n, + builderExecutionPayloadValue: 1n, + builderBoostFactor: 150n, + }).source + ).toBe(ProducedBlockSource.builder); + }); }); From 2a5c93a999844990adcad223abe37b0755283b9e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 12:02:11 +0100 Subject: [PATCH 34/67] call it boostedValue --- packages/beacon-node/src/api/impl/validator/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index b21d63cda591..7bb978511402 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -990,7 +990,7 @@ export function getValidatorApi( }); } - const boostedValueNumerator = ({totalGwei, boostFactor}: BidCandidate): bigint => boostFactor * totalGwei; + const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => boostFactor * totalGwei; let best: BidCandidate | null = null; for (const candidate of candidates) { const candidateIsMaxBoost = candidate.boostFactor === MAX_BUILDER_BOOST_FACTOR; @@ -999,7 +999,7 @@ export function getValidatorApi( if ( best === null || (candidateIsMaxBoost && !bestIsMaxBoost) || - (candidateIsMaxBoost === bestIsMaxBoost && boostedValueNumerator(candidate) > boostedValueNumerator(best)) + (candidateIsMaxBoost === bestIsMaxBoost && boostedValue(candidate) > boostedValue(best)) ) { best = candidate; } From 2d557dd6a910da78229b48ec62bfc8b3ac13d2c2 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 20:24:59 +0100 Subject: [PATCH 35/67] fix block publication result indexing --- .../src/api/impl/beacon/blocks/index.ts | 25 +++++----- .../impl/beacon/blocks/publishBlock.test.ts | 48 ++++++++++++++++++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 8a0ee181b341..f69c1c7efa6f 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -361,6 +361,19 @@ export function getBeaconBlockApi({ chain.validatorMonitor?.registerBeaconBlock(OpSource.api, delaySec, signedBlock.message); chain.logger.info("Publishing block", valLogMeta); + + // Forward the signed block to the winning builder echoed by the validator client so it can + // release the payload without waiting for block gossip. Failures are non-fatal, the builder + // also sees the block on gossip. + if (builderUrl !== undefined && isForkPostGloas(fork)) { + const gloasBlock = signedBlock as SignedBeaconBlock; + if (gloasBlock.message.body.signedExecutionPayloadBid.message.builderIndex !== BUILDER_INDEX_SELF_BUILD) { + chain.builderApiClient.submitSignedBeaconBlock(builderUrl, {data: gloasBlock}).catch((e) => { + chain.logger.warn("Failed to submit signed block to builder", {...valLogMeta, builderUrl}, e); + }); + } + } + const publishPromises = [ // Send the block, regardless of whether or not it is valid. The API // specification is very clear that this is the desired behavior. @@ -372,18 +385,6 @@ export function getBeaconBlockApi({ // import latency and hopefully bandwidth // () => network.publishBeaconBlock(signedBlock), - // Forward the signed block to the winning builder echoed by the validator client so it can - // release the payload without waiting for block gossip. Failures are non-fatal, the builder - // also sees the block on gossip. - async () => { - if (builderUrl === undefined || !isForkPostGloas(fork)) return; - const gloasBlock = signedBlock as SignedBeaconBlock; - if (gloasBlock.message.body.signedExecutionPayloadBid.message.builderIndex === BUILDER_INDEX_SELF_BUILD) return; - // Not awaited, publishing the block must not wait on the builder - chain.builderApiClient.submitSignedBeaconBlock(builderUrl, {data: gloasBlock}).catch((e) => { - chain.logger.warn("Failed to submit signed block to builder", {...valLogMeta, builderUrl}, e); - }); - }, ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), ...blobSidecars.map((blobSidecar) => () => network.publishBlobSidecar(blobSidecar)), () => diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index 806f4792cf68..6fb172e49985 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -6,12 +6,17 @@ import {ForkName} from "@lodestar/params"; import {ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; -import {BlockInputPreData, BlockInputSource} from "../../../../../../src/chain/blocks/blockInput/index.js"; +import { + BlockInputColumns, + BlockInputPreData, + BlockInputSource, +} from "../../../../../../src/chain/blocks/blockInput/index.js"; import {verifyBlocksInEpoch} from "../../../../../../src/chain/blocks/verifyBlock.js"; import {BlockError, BlockErrorCode, BlockGossipError, GossipAction} from "../../../../../../src/chain/errors/index.js"; import {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; import {validateGossipBlock} from "../../../../../../src/chain/validation/block.js"; import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; +import {config as forkConfig, generateBlockWithColumnSidecars} from "../../../../../utils/blocksAndData.js"; import {generateProtoBlock} from "../../../../../utils/typeGenerator.js"; vi.mock("../../../../../../src/chain/blocks/verifyBlock.js"); @@ -273,4 +278,45 @@ describe("api - beacon - publishBlockV2", () => { } ); }); + + it("tracks data column publication results", async () => { + const {block, blobs, columnSidecars, rootHex} = generateBlockWithColumnSidecars({ + forkName: ForkName.fulu, + returnBlobs: true, + }); + if (blobs === undefined) { + throw Error("Missing generated blobs"); + } + const kzgProofs = blobs.flatMap((_, rowIndex) => + columnSidecars.map((columnSidecar) => columnSidecar.kzgProofs[rowIndex]) + ); + const blockInput = BlockInputColumns.createFromBlock({ + forkName: ForkName.fulu, + block, + blockRootHex: rootHex, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + sampledColumns: [0], + custodyColumns: [0], + }); + + modules = getApiTestModules({config: forkConfig}); + Object.defineProperty(modules.chain, "blockProductionCache", {value: new Map()}); + Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + modules.chain.processBlock = vi.fn().mockResolvedValue(undefined); + modules.network.publishBeaconBlock = vi.fn(); + modules.network.publishDataColumnSidecar = vi.fn().mockResolvedValue({sentPeers: 1, alreadyPublished: false}); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishBlockV2({ + signedBlockContents: {signedBlock: block, blobs, kzgProofs}, + broadcastValidation: routes.beacon.BroadcastValidation.none, + }) + ).resolves.toBeUndefined(); + + expect(modules.network.publishDataColumnSidecar).toHaveBeenCalledTimes(columnSidecars.length); + }); }); From 74f6be80a6b6eea300623956b83b607bd563c5ba Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 20:28:02 +0100 Subject: [PATCH 36/67] read builder URL from response header --- packages/api/src/beacon/routes/validator.ts | 9 ++---- packages/api/src/utils/client/response.ts | 2 +- packages/api/src/utils/types.ts | 2 +- .../test/unit/beacon/builderConfig.test.ts | 29 ++++++++++++++++++- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index c80f328b24b5..c21ee5d1fde0 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -1094,13 +1094,10 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ), meta: { - toJson: ({builderUrl, ...meta}) => ({ - ...(ProduceBlockV4MetaType.toJson(meta) as Record), - ...(builderUrl !== undefined ? {builder_url: builderUrl} : {}), - }), - fromJson: (val) => ({ + toJson: (meta) => ProduceBlockV4MetaType.toJson(meta), + fromJson: (val, headers) => ({ ...ProduceBlockV4MetaType.fromJson(val), - builderUrl: (val as {builder_url?: string}).builder_url, + builderUrl: headers?.get(MetaHeader.BuilderUrl) ?? undefined, }), toHeadersObject: (meta) => ({ [MetaHeader.Version]: meta.version, diff --git a/packages/api/src/utils/client/response.ts b/packages/api/src/utils/client/response.ts index 626252b9aaca..b691c2d08983 100644 --- a/packages/api/src/utils/client/response.ts +++ b/packages/api/src/utils/client/response.ts @@ -90,7 +90,7 @@ export class ApiResponse extends Response { const metaJson = this.definition.resp.transform ? this.definition.resp.transform.fromResponse(rawBody.value).meta : rawBody.value; - this._meta = this.definition.resp.meta.fromJson(metaJson); + this._meta = this.definition.resp.meta.fromJson(metaJson, new HeadersExtra(this.headers)); break; } case WireFormat.ssz: diff --git a/packages/api/src/utils/types.ts b/packages/api/src/utils/types.ts index 78588373226e..d074c760bca6 100644 --- a/packages/api/src/utils/types.ts +++ b/packages/api/src/utils/types.ts @@ -126,7 +126,7 @@ export type ResponseDataCodec = { export type ResponseMetadataCodec = { toJson: (val: T) => unknown; // server - fromJson: (val: unknown) => T; // client + fromJson: (val: unknown, headers?: HeadersExtra) => T; // client toHeadersObject: (val: T) => Record; // server fromHeaders: (headers: HeadersExtra) => T; // server }; diff --git a/packages/api/test/unit/beacon/builderConfig.test.ts b/packages/api/test/unit/beacon/builderConfig.test.ts index bc0b59b274e1..e72b4e5a1b54 100644 --- a/packages/api/test/unit/beacon/builderConfig.test.ts +++ b/packages/api/test/unit/beacon/builderConfig.test.ts @@ -1,5 +1,8 @@ import {describe, expect, it} from "vitest"; -import {BuilderEntryType} from "../../../src/beacon/routes/validator.js"; +import {config} from "@lodestar/config/default"; +import {BuilderEntryType, Endpoints, getDefinitions} from "../../../src/beacon/routes/validator.js"; +import {ApiResponse} from "../../../src/utils/client/response.js"; +import {MetaHeader} from "../../../src/utils/metadata.js"; describe("BuilderEntryType", () => { it("decodes an empty url so the beacon node can reject only that entry", () => { @@ -12,3 +15,27 @@ describe("BuilderEntryType", () => { ); }); }); + +describe("produceBlockV4 metadata", () => { + it("reads the builder url from a JSON response header", async () => { + const builderUrl = "https://builder.example.com"; + const response = new ApiResponse( + { + ...getDefinitions(config).produceBlockV4, + operationId: "produceBlockV4", + urlFormatter: () => "/eth/v4/validator/blocks/1", + }, + JSON.stringify({ + version: "gloas", + consensus_block_value: "1", + execution_payload_value: "2", + execution_payload_included: false, + }), + {headers: {"Content-Type": "application/json", [MetaHeader.BuilderUrl]: builderUrl}} + ); + + await response.rawBody(); + + expect(response.meta().builderUrl).toBe(builderUrl); + }); +}); From d75428a67638b4d9bc6f418c059073d0d43eb066 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 20:36:28 +0100 Subject: [PATCH 37/67] verify builder auth before creating clients --- .../src/api/impl/validator/index.ts | 9 +++ packages/beacon-node/src/chain/chain.ts | 2 +- .../src/execution/builder/apiClient.ts | 47 +++++++++++-- .../submitBuilderPreferences.test.ts | 26 ++++++- .../unit/execution/builder/apiClient.test.ts | 69 ++++++++++++++++++- 5 files changed, 142 insertions(+), 11 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 7bb978511402..bba695788e5d 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -53,6 +53,7 @@ import { GWEI_TO_WEI, TimeoutError, bigIntMin, + byteArrayEquals, defer, formatWeiToEth, fromHex, @@ -2026,6 +2027,14 @@ export function getValidatorApi( try { new URL(url); builder = toPrintableUrl(url); + const proposerIndex = chain.getHeadState().getBeaconProposer(entry.auth.message.slot); + const expectedProposerPubkey = chain.pubkeyCache.getOrThrow(proposerIndex).toBytes(); + if (!byteArrayEquals(entry.proposerPubkey, expectedProposerPubkey)) { + throw new ApiError( + 400, + `Invalid proposer pubkey for builder preferences slot=${entry.auth.message.slot}` + ); + } await chain.builderApiClient.submitBuilderPreferences(url, entry.proposerPubkey, { preferences: {maxExecutionPayment: entry.maxExecutionPayment}, auth: entry.auth, diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 9aa2e138fef6..17b4374d52ce 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -446,7 +446,7 @@ export class BeaconChain implements IBeaconChain { {forkChoice, logger, metrics} ); - this.builderApiClient = new BuilderApiClient({}, config, metrics, logger); + this.builderApiClient = new BuilderApiClient({}, config, bls, metrics, logger); this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({ config, diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index 614d5f0aefa4..d0610f1664f3 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -2,9 +2,16 @@ import {routes} from "@lodestar/api"; 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 {DOMAIN_BUILDER_REQUEST_AUTH, ForkPostGloas} from "@lodestar/params"; +import { + ZERO_HASH, + computeDomain, + computeSigningRoot, + createSingleSignatureSetFromComponents, +} from "@lodestar/state-transition"; +import {BLSPubkey, Root, SignedBeaconBlock, Slot, WithOptionalBytes, gloas, ssz} from "@lodestar/types"; import {toHex, toPrintableUrl} from "@lodestar/utils"; +import type {IBlsVerifier} from "../../chain/bls/index.js"; import {Metrics} from "../../metrics/metrics.js"; export type BuilderApiClientOpts = { @@ -45,6 +52,7 @@ export class BuilderApiClient { constructor( private readonly opts: BuilderApiClientOpts, private readonly config: ChainForkConfig, + private readonly bls: IBlsVerifier, private readonly metrics: Metrics | null = null, private readonly logger?: Logger ) {} @@ -97,7 +105,8 @@ export class BuilderApiClient { requests.map(async ({url, entry}): Promise => { this.metrics?.builderApi.bidRequests.inc(); try { - const res = await this.getClientForUrl(url).getExecutionPayloadBid( + const client = await this.getOrCreateClient(url, proposerPubkey, entry.auth); + const res = await client.getExecutionPayloadBid( { slot, parentHash, @@ -134,7 +143,8 @@ export class BuilderApiClient { request: gloas.BuilderPreferencesRequest ): Promise { try { - (await this.getClientForUrl(url).submitBuilderPreferences({proposerPubkey, request})).assertOk(); + const client = await this.getOrCreateClient(url, proposerPubkey, request.auth); + (await client.submitBuilderPreferences({proposerPubkey, request})).assertOk(); this.metrics?.builderApi.preferencesForwarded.inc({status: "success"}); } catch (e) { this.metrics?.builderApi.preferencesForwarded.inc({status: "error"}); @@ -151,8 +161,15 @@ export class BuilderApiClient { url: BuilderUrl, signedBlock: WithOptionalBytes> ): Promise { + const client = this.clients.get(url); + if (client === undefined) { + this.metrics?.builderApi.blockSubmissions.inc({status: "error"}); + this.logger?.warn("Ignoring signed block submission to unauthenticated builder", {url}); + return; + } + try { - (await this.getClientForUrl(url).submitSignedBeaconBlock({signedBlock}, {retries: 2})).assertOk(); + (await client.submitSignedBeaconBlock({signedBlock}, {retries: 2})).assertOk(); this.metrics?.builderApi.blockSubmissions.inc({status: "success"}); } catch (e) { this.metrics?.builderApi.blockSubmissions.inc({status: "error"}); @@ -160,9 +177,27 @@ export class BuilderApiClient { } } - private getClientForUrl(url: BuilderUrl): BuilderApi { + private async getOrCreateClient( + url: BuilderUrl, + proposerPubkey: BLSPubkey, + auth: gloas.SignedBuilderRequestAuth + ): Promise { let client = this.clients.get(url); if (client === undefined) { + const domain = computeDomain(DOMAIN_BUILDER_REQUEST_AUTH, this.config.GENESIS_FORK_VERSION, ZERO_HASH); + const signingRoot = computeSigningRoot(ssz.gloas.BuilderRequestAuth, auth.message, domain); + const signatureSet = createSingleSignatureSetFromComponents(proposerPubkey, signingRoot, auth.signature); + + let isValid = false; + try { + isValid = await this.bls.verifySignatureSets([signatureSet]); + } catch { + // Malformed signatures are invalid request authentication. + } + if (!isValid) { + throw Error("Invalid builder request auth"); + } + client = getClient( { baseUrl: url, diff --git a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts index c74f6e904aae..c87116d2c9e4 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, it} from "vitest"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {ssz} from "@lodestar/types"; import {IndexedError} from "../../../../../src/api/impl/errors.js"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; @@ -11,9 +11,15 @@ describe("api/validator - submitBuilderPreferences", () => { beforeEach(() => { modules = getApiTestModules(); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); api = getValidatorApi(defaultApiOptions, modules); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("reports an invalid url by index while submitting the other entries", async () => { const invalidUrl = "not a url"; const validEntry = getEntry("https://builder.example.com"); @@ -40,6 +46,24 @@ describe("api/validator - submitBuilderPreferences", () => { expect.any(Error) ); }); + + it("rejects preferences not signed by the slot proposer", async () => { + const entry = getEntry("https://builder.example.com"); + entry.proposerPubkey[0] = 1; + + let error: unknown; + try { + await api.submitBuilderPreferences({builderPreferences: [entry]}); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(IndexedError); + expect((error as IndexedError).failures).toEqual([ + {index: 0, message: "Invalid proposer pubkey for builder preferences slot=1"}, + ]); + expect(modules.chain.builderApiClient.submitBuilderPreferences).not.toHaveBeenCalled(); + }); }); function getEntry(url: string) { diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index 428506f48498..8fa580930880 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -2,15 +2,30 @@ import {afterEach, describe, expect, it, vi} from "vitest"; import {routes} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {ssz} from "@lodestar/types"; +import type {IBlsVerifier} from "../../../../src/chain/bls/index.js"; import {BuilderApiClient} from "../../../../src/execution/builder/apiClient.js"; import {getMockedLogger} from "../../../mocks/loggerMock.js"; -const {getExecutionPayloadBid} = vi.hoisted(() => ({getExecutionPayloadBid: vi.fn()})); +const {getExecutionPayloadBid, submitBuilderPreferences, submitSignedBeaconBlock, verifySignatureSets} = vi.hoisted( + () => ({ + getExecutionPayloadBid: vi.fn(), + submitBuilderPreferences: vi.fn(), + submitSignedBeaconBlock: vi.fn(), + verifySignatureSets: vi.fn().mockResolvedValue(true), + }) +); vi.mock("@lodestar/api/builder", () => ({ - getClient: () => ({getExecutionPayloadBid}), + getClient: () => ({getExecutionPayloadBid, submitBuilderPreferences, submitSignedBeaconBlock}), })); +const bls = { + verifySignatureSets, + verifySignatureSetsSameMessage: vi.fn(), + close: vi.fn(), + canAcceptWork: vi.fn(), +} satisfies IBlsVerifier; + describe("execution/builder/apiClient", () => { afterEach(() => { vi.clearAllMocks(); @@ -24,7 +39,7 @@ describe("execution/builder/apiClient", () => { getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); const logger = getMockedLogger(); - const client = new BuilderApiClient({}, config, null, logger); + const client = new BuilderApiClient({}, config, bls, null, logger); const bids = await client.getExecutionPayloadBids( [getBuilderEntry(invalidUrl, slot), validEntry], slot, @@ -38,6 +53,54 @@ describe("execution/builder/apiClient", () => { expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); expect(logger.warn).toHaveBeenCalledWith("Ignoring builder entry with invalid url", {slot, url: invalidUrl}); }); + + it("ignores signed block submissions to unauthenticated builder urls", async () => { + const url = "https://builder.example.com"; + const signedBlock = {data: ssz.gloas.SignedBeaconBlock.defaultValue()}; + const logger = getMockedLogger(); + const client = new BuilderApiClient({}, config, bls, null, logger); + + await client.submitSignedBeaconBlock(url, signedBlock); + + expect(submitSignedBeaconBlock).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith("Ignoring signed block submission to unauthenticated builder", {url}); + }); + + it("submits signed blocks to builders registered through preferences", async () => { + const url = "https://builder.example.com"; + const proposerPubkey = new Uint8Array(48); + const preferences = ssz.gloas.BuilderPreferencesRequest.defaultValue(); + const signedBlock = {data: ssz.gloas.SignedBeaconBlock.defaultValue()}; + submitBuilderPreferences.mockResolvedValue({assertOk: vi.fn()}); + submitSignedBeaconBlock.mockResolvedValue({assertOk: vi.fn()}); + + const client = new BuilderApiClient({}, config, bls); + await client.submitBuilderPreferences(url, proposerPubkey, preferences); + await client.submitSignedBeaconBlock(url, signedBlock); + + expect(verifySignatureSets).toHaveBeenCalledOnce(); + expect(submitSignedBeaconBlock).toHaveBeenCalledWith({signedBlock}, {retries: 2}); + }); + + it("does not cache a builder client when request auth is invalid", async () => { + const slot = 1; + const entry = getBuilderEntry("https://builder.example.com", slot); + const proposerPubkey = new Uint8Array(48); + const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + verifySignatureSets.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); + + const client = new BuilderApiClient({}, config, bls); + expect( + await client.getExecutionPayloadBids([entry], slot, new Uint8Array(32), new Uint8Array(32), proposerPubkey, 1_000) + ).toEqual([]); + expect( + await client.getExecutionPayloadBids([entry], slot, new Uint8Array(32), new Uint8Array(32), proposerPubkey, 1_000) + ).toEqual([{url: "https://builder.example.com", entry, signedBid}]); + + expect(verifySignatureSets).toHaveBeenCalledTimes(2); + expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); + }); }); function getBuilderEntry(url: string, slot: number): routes.validator.BuilderEntry { From d7cef5eff7155c8d4c2cd039e98847a8c0d65965 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 20:40:37 +0100 Subject: [PATCH 38/67] disable redirects when forwarding blocks to builders --- packages/beacon-node/src/execution/builder/apiClient.ts | 2 +- .../beacon-node/test/unit/execution/builder/apiClient.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index d0610f1664f3..08afa87916ac 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -169,7 +169,7 @@ export class BuilderApiClient { } try { - (await client.submitSignedBeaconBlock({signedBlock}, {retries: 2})).assertOk(); + (await client.submitSignedBeaconBlock({signedBlock}, {retries: 2, redirect: "manual"})).assertOk(); this.metrics?.builderApi.blockSubmissions.inc({status: "success"}); } catch (e) { this.metrics?.builderApi.blockSubmissions.inc({status: "error"}); diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index 8fa580930880..e5f6af75993f 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -79,7 +79,7 @@ describe("execution/builder/apiClient", () => { await client.submitSignedBeaconBlock(url, signedBlock); expect(verifySignatureSets).toHaveBeenCalledOnce(); - expect(submitSignedBeaconBlock).toHaveBeenCalledWith({signedBlock}, {retries: 2}); + expect(submitSignedBeaconBlock).toHaveBeenCalledWith({signedBlock}, {retries: 2, redirect: "manual"}); }); it("does not cache a builder client when request auth is invalid", async () => { From 877bfd53ed505c9a73a6ba2ed1292d96835c76f3 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 20:43:12 +0100 Subject: [PATCH 39/67] no retries --- packages/beacon-node/src/execution/builder/apiClient.ts | 2 +- .../beacon-node/test/unit/execution/builder/apiClient.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index 08afa87916ac..bd4b1dc8298e 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -169,7 +169,7 @@ export class BuilderApiClient { } try { - (await client.submitSignedBeaconBlock({signedBlock}, {retries: 2, redirect: "manual"})).assertOk(); + (await client.submitSignedBeaconBlock({signedBlock}, {redirect: "manual"})).assertOk(); this.metrics?.builderApi.blockSubmissions.inc({status: "success"}); } catch (e) { this.metrics?.builderApi.blockSubmissions.inc({status: "error"}); diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index e5f6af75993f..8a00527b1a38 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -79,7 +79,7 @@ describe("execution/builder/apiClient", () => { await client.submitSignedBeaconBlock(url, signedBlock); expect(verifySignatureSets).toHaveBeenCalledOnce(); - expect(submitSignedBeaconBlock).toHaveBeenCalledWith({signedBlock}, {retries: 2, redirect: "manual"}); + expect(submitSignedBeaconBlock).toHaveBeenCalledWith({signedBlock}, {redirect: "manual"}); }); it("does not cache a builder client when request auth is invalid", async () => { From 3d2df07153f093c3a210f5b6c30d21b51e2c1d8b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 22:34:36 +0100 Subject: [PATCH 40/67] validate builder URLs before use --- packages/api/src/beacon/routes/validator.ts | 2 +- packages/api/src/keymanager/routes.ts | 4 +++ .../unit/keymanager/builderConfig.test.ts | 12 +++++++ .../src/api/impl/validator/index.ts | 7 ++-- .../src/execution/builder/apiClient.ts | 33 ++++++++++++++----- .../submitBuilderPreferences.test.ts | 10 +++--- .../unit/execution/builder/apiClient.test.ts | 23 +++++++++++++ packages/cli/src/util/proposerConfig.ts | 10 ++---- .../unit/validator/parseBuilderUrls.test.ts | 1 + packages/utils/src/url.ts | 12 +++++++ .../validator/src/services/validatorStore.ts | 6 ++-- 11 files changed, 90 insertions(+), 30 deletions(-) diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index c21ee5d1fde0..8bedebfbcdbc 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -283,7 +283,7 @@ class BuilderUrlType extends ByteListType { return value; } toJson(value: Uint8Array): unknown { - return new TextDecoder().decode(value); + return new TextDecoder("utf8", {fatal: true}).decode(value); } } diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 165583238c6e..4cdc04a83f26 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -7,6 +7,7 @@ import { MAX_BUILDER_URL_SIZE, } from "@lodestar/params"; import {Epoch, phase0, ssz, stringType} from "@lodestar/types"; +import {isValidAsciiHttpUrl} from "@lodestar/utils"; import { EmptyArgs, EmptyMeta, @@ -186,6 +187,9 @@ export function builderConfigDataFromJson(json: unknown): BuilderConfigData { if (new TextEncoder().encode(url).length > MAX_BUILDER_URL_SIZE) { throw Error(`builders[${i}].url must not exceed ${MAX_BUILDER_URL_SIZE} bytes`); } + if (!isValidAsciiHttpUrl(url)) { + throw Error(`builders[${i}].url must be a valid HTTP or HTTPS URL using only ASCII characters`); + } if (auth_data !== undefined && (typeof auth_data !== "string" || !BUILDER_AUTH_DATA_PATTERN.test(auth_data))) { throw Error( `builders[${i}].auth_data must be a non-empty hex string of at most ${MAX_BUILDER_AUTH_DATA_SIZE} bytes` diff --git a/packages/api/test/unit/keymanager/builderConfig.test.ts b/packages/api/test/unit/keymanager/builderConfig.test.ts index 282b9a34470d..32a1bec58131 100644 --- a/packages/api/test/unit/keymanager/builderConfig.test.ts +++ b/packages/api/test/unit/keymanager/builderConfig.test.ts @@ -7,4 +7,16 @@ describe("builderConfigDataFromJson", () => { expect(() => builderConfigDataFromJson({min_bid: value})).toThrow(); } }); + + it("rejects invalid builder urls", () => { + for (const url of [ + "ftp://builder.example.com", + "https://builder.example.com/é", + "https://builder.example.com/\npath", + ]) { + expect(() => builderConfigDataFromJson({builders: [{url}]}), url).toThrow( + "builders[0].url must be a valid HTTP or HTTPS URL using only ASCII characters" + ); + } + }); }); diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index bba695788e5d..6d09079b4d55 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -84,7 +84,7 @@ import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; import {validateGossipProposerPreferences} from "../../../chain/validation/proposerPreferences.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; -import {BUILDER_BID_REQUEST_TIMEOUT_MS, BuilderApiBid} from "../../../execution/builder/apiClient.js"; +import {BUILDER_BID_REQUEST_TIMEOUT_MS, BuilderApiBid, decodeBuilderUrl} from "../../../execution/builder/apiClient.js"; import {BuilderStatus, NoBidReceived} from "../../../execution/builder/http.js"; import {validateGossipFnRetryUnknownRoot} from "../../../network/processor/gossipHandlers.js"; import {CommitteeSubscription} from "../../../network/subnets/index.js"; @@ -2022,10 +2022,9 @@ export function getValidatorApi( await Promise.all( builderPreferences.map(async (entry, i) => { - const url = Buffer.from(entry.url).toString("utf8"); - let builder = url; + let builder = Buffer.from(entry.url).toString("utf8"); try { - new URL(url); + const url = decodeBuilderUrl(entry.url); builder = toPrintableUrl(url); const proposerIndex = chain.getHeadState().getBeaconProposer(entry.auth.message.slot); const expectedProposerPubkey = chain.pubkeyCache.getOrThrow(proposerIndex).toBytes(); diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index bd4b1dc8298e..7e894d1a5eb4 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -10,7 +10,7 @@ import { createSingleSignatureSetFromComponents, } from "@lodestar/state-transition"; import {BLSPubkey, Root, SignedBeaconBlock, Slot, WithOptionalBytes, gloas, ssz} from "@lodestar/types"; -import {toHex, toPrintableUrl} from "@lodestar/utils"; +import {isValidAsciiHttpUrl, toHex, toPrintableUrl} from "@lodestar/utils"; import type {IBlsVerifier} from "../../chain/bls/index.js"; import {Metrics} from "../../metrics/metrics.js"; @@ -34,6 +34,20 @@ export const BUILDER_BID_REQUEST_TIMEOUT_MS = 1000 + EVENT_LOOP_LAG_BUFFER; type BuilderUrl = string; +/** Decode the SSZ URL bytes without allowing replacement characters or unsafe header values. */ +export function decodeBuilderUrl(value: Uint8Array): BuilderUrl { + let url: string; + try { + url = new TextDecoder("utf8", {fatal: true}).decode(value); + } catch { + throw Error("Builder url must be valid UTF-8"); + } + if (!isValidAsciiHttpUrl(url)) { + throw Error("Invalid builder url"); + } + return url; +} + export type BuilderApiBid = { url: BuilderUrl; entry: routes.validator.BuilderEntry; @@ -74,19 +88,20 @@ export class BuilderApiClient { const requests: {url: BuilderUrl; entry: routes.validator.BuilderEntry}[] = []; for (const entry of entries) { - const url = Buffer.from(entry.url).toString("utf8"); - const requestKey = `${url}-${toHex(entry.auth.message.data)}`; - if (seenRequests.has(requestKey)) { + const urlForLog = Buffer.from(entry.url).toString("utf8"); + let url: BuilderUrl; + try { + url = decodeBuilderUrl(entry.url); + } catch { + this.logger?.warn("Ignoring builder entry with invalid url", {slot, url: urlForLog}); continue; } - seenRequests.add(requestKey); - try { - new URL(url); - } catch { - this.logger?.warn("Ignoring builder entry with invalid url", {slot, url}); + const requestKey = `${url}-${toHex(entry.auth.message.data)}`; + if (seenRequests.has(requestKey)) { continue; } + seenRequests.add(requestKey); // The builder rejects a mismatch, an entry naming a different slot is not used for a bid request if (entry.auth.message.slot !== slot) { diff --git a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts index c87116d2c9e4..3aba2f5c785f 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts @@ -20,10 +20,10 @@ describe("api/validator - submitBuilderPreferences", () => { vi.restoreAllMocks(); }); - it("reports an invalid url by index while submitting the other entries", async () => { - const invalidUrl = "not a url"; + it("reports an invalid UTF-8 url by index while submitting the other entries", async () => { const validEntry = getEntry("https://builder.example.com"); - const invalidEntry = getEntry(invalidUrl); + const invalidEntry = getEntry("https://invalid.example.com"); + invalidEntry.url = new Uint8Array([0xff]); let error: unknown; try { @@ -33,7 +33,7 @@ describe("api/validator - submitBuilderPreferences", () => { } expect(error).toBeInstanceOf(IndexedError); - expect((error as IndexedError).failures).toEqual([{index: 0, message: "Invalid URL"}]); + expect((error as IndexedError).failures).toEqual([{index: 0, message: "Builder url must be valid UTF-8"}]); expect(modules.chain.builderApiClient.submitBuilderPreferences).toHaveBeenCalledOnce(); expect(modules.chain.builderApiClient.submitBuilderPreferences).toHaveBeenCalledWith( "https://builder.example.com", @@ -42,7 +42,7 @@ describe("api/validator - submitBuilderPreferences", () => { ); expect(modules.logger.verbose).toHaveBeenCalledWith( "Error on submitBuilderPreferences [0]", - {slot: 1, builder: invalidUrl}, + {slot: 1, builder: "�"}, expect.any(Error) ); }); diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index 8a00527b1a38..0ce0bb270c63 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -54,6 +54,29 @@ describe("execution/builder/apiClient", () => { expect(logger.warn).toHaveBeenCalledWith("Ignoring builder entry with invalid url", {slot, url: invalidUrl}); }); + it("ignores builder urls that cannot be safely returned in the response header", async () => { + const slot = 1; + const invalidUtf8Entry = getBuilderEntry("https://builder.example.com", slot); + invalidUtf8Entry.url = new Uint8Array([0xff]); + const nonAsciiEntry = getBuilderEntry("https://builder.example.com/é", slot); + const validEntry = getBuilderEntry("https://builder.example.com", slot); + const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); + + const client = new BuilderApiClient({}, config, bls); + const bids = await client.getExecutionPayloadBids( + [invalidUtf8Entry, nonAsciiEntry, validEntry], + slot, + new Uint8Array(32), + new Uint8Array(32), + new Uint8Array(48), + 1_000 + ); + + expect(bids).toEqual([{url: "https://builder.example.com", entry: validEntry, signedBid}]); + expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); + }); + it("ignores signed block submissions to unauthenticated builder urls", async () => { const url = "https://builder.example.com"; const signedBlock = {data: ssz.gloas.SignedBeaconBlock.defaultValue()}; diff --git a/packages/cli/src/util/proposerConfig.ts b/packages/cli/src/util/proposerConfig.ts index 779d3554f9ac..89d98d49d0cf 100644 --- a/packages/cli/src/util/proposerConfig.ts +++ b/packages/cli/src/util/proposerConfig.ts @@ -3,7 +3,7 @@ import path from "node:path"; import {routes} from "@lodestar/api"; import {BuilderEntryConfig, builderConfigDataFromJson} from "@lodestar/api/keymanager"; import {MAX_BUILDER_AUTH_DATA_SIZE, MAX_BUILDER_ENTRIES, MAX_BUILDER_URL_SIZE} from "@lodestar/params"; -import {fromHex, toHex} from "@lodestar/utils"; +import {fromHex, isValidAsciiHttpUrl, toHex} from "@lodestar/utils"; import {ValidatorProposerConfig} from "@lodestar/validator"; import {parseFeeRecipient} from "./feeRecipient.js"; import {readFile} from "./file.js"; @@ -223,9 +223,7 @@ export function parseBuilderEntries(builders?: unknown): BuilderEntryConfig[] | const {builders: entries} = builderConfigDataFromJson({builders}); const seenEntries = new Set(); for (const entry of entries ?? []) { - try { - new URL(entry.url); - } catch { + if (!isValidAsciiHttpUrl(entry.url)) { throw Error(`Invalid builder url: ${entry.url}`); } const authData = entry.authData !== undefined ? toHex(fromHex(entry.authData)) : toHex(Buffer.from(entry.url)); @@ -252,9 +250,7 @@ export function parseBuilderUrls(urls?: string[]): BuilderEntryConfig[] | undefi const fragmentIndex = value.indexOf("#"); const url = fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); const authData = fragmentIndex === -1 ? undefined : value.slice(fragmentIndex + 1); - try { - new URL(url); - } catch { + if (!isValidAsciiHttpUrl(url)) { throw Error(`Invalid builder url: ${url}`); } if (Buffer.byteLength(url, "utf8") > MAX_BUILDER_URL_SIZE) { diff --git a/packages/cli/test/unit/validator/parseBuilderUrls.test.ts b/packages/cli/test/unit/validator/parseBuilderUrls.test.ts index 39b0c730c6af..b795536ea348 100644 --- a/packages/cli/test/unit/validator/parseBuilderUrls.test.ts +++ b/packages/cli/test/unit/validator/parseBuilderUrls.test.ts @@ -24,6 +24,7 @@ describe("validator / parseBuilderUrls", () => { it("rejects invalid urls and auth data", () => { expect(() => parseBuilderUrls(["builder.example.com"])).toThrow(/Invalid builder url/); + expect(() => parseBuilderUrls(["https://builder.example.com/é"])).toThrow(/Invalid builder url/); expect(() => parseBuilderUrls(["https://builder.example.com#"])).toThrow(/auth data/); expect(() => parseBuilderUrls(["https://builder.example.com#0x"])).toThrow(/auth data/); expect(() => parseBuilderUrls(["https://builder.example.com#secret"])).toThrow(/auth data/); diff --git a/packages/utils/src/url.ts b/packages/utils/src/url.ts index d4eba3bd67cf..e53f44019d83 100644 --- a/packages/utils/src/url.ts +++ b/packages/utils/src/url.ts @@ -19,6 +19,18 @@ export function isValidHttpUrl(urlStr: string): boolean { return url.protocol === "http:" || url.protocol === "https:"; } +/** Return whether a URL is HTTP(S) and contains only visible ASCII characters. */ +export function isValidAsciiHttpUrl(urlStr: string): boolean { + for (let i = 0; i < urlStr.length; i++) { + const charCode = urlStr.charCodeAt(i); + if (charCode < 0x21 || charCode > 0x7e) { + return false; + } + } + + return isValidHttpUrl(urlStr); +} + /** * Sanitize URL to prevent leaking user credentials in logs or metrics * diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 0a187da5abcb..e37c3032b9c5 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -49,7 +49,7 @@ import { phase0, ssz, } from "@lodestar/types"; -import {fromHex, toHex, toPubkeyHex, toRootHex} from "@lodestar/utils"; +import {fromHex, isValidAsciiHttpUrl, toHex, toPubkeyHex, toRootHex} from "@lodestar/utils"; import {Metrics} from "../metrics.js"; import {ISlashingProtection} from "../slashingProtection/index.js"; import {PubkeyHex} from "../types.js"; @@ -531,9 +531,7 @@ export class ValidatorStore { // compared as the value derived from the entry url const seenEntries = new Set(); for (const entry of config.builders ?? []) { - try { - new URL(entry.url); - } catch { + if (!isValidAsciiHttpUrl(entry.url)) { throw Error(`Invalid builder url: ${entry.url}`); } const authData = entry.authData !== undefined ? toHex(fromHex(entry.authData)) : toHex(Buffer.from(entry.url)); From acc26a3255bfc6d553f9481786b955e2be5f6580 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 23 Aug 2026 22:35:14 +0100 Subject: [PATCH 41/67] reject empty builder request auth data --- .../src/execution/builder/apiClient.ts | 4 +++ .../unit/execution/builder/apiClient.test.ts | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index 7e894d1a5eb4..69340efb8bec 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -197,6 +197,10 @@ export class BuilderApiClient { proposerPubkey: BLSPubkey, auth: gloas.SignedBuilderRequestAuth ): Promise { + if (auth.message.data.length === 0) { + throw Error("Builder request auth data must not be empty"); + } + let client = this.clients.get(url); if (client === undefined) { const domain = computeDomain(DOMAIN_BUILDER_REQUEST_AUTH, this.config.GENESIS_FORK_VERSION, ZERO_HASH); diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index 0ce0bb270c63..a7ae58ac4acf 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -93,6 +93,7 @@ describe("execution/builder/apiClient", () => { const url = "https://builder.example.com"; const proposerPubkey = new Uint8Array(48); const preferences = ssz.gloas.BuilderPreferencesRequest.defaultValue(); + preferences.auth.message.data = new Uint8Array([1]); const signedBlock = {data: ssz.gloas.SignedBeaconBlock.defaultValue()}; submitBuilderPreferences.mockResolvedValue({assertOk: vi.fn()}); submitSignedBeaconBlock.mockResolvedValue({assertOk: vi.fn()}); @@ -105,6 +106,34 @@ describe("execution/builder/apiClient", () => { expect(submitSignedBeaconBlock).toHaveBeenCalledWith({signedBlock}, {redirect: "manual"}); }); + it("rejects empty request auth data even when the builder client is cached", async () => { + const slot = 1; + const url = "https://builder.example.com"; + const proposerPubkey = new Uint8Array(48); + const preferences = ssz.gloas.BuilderPreferencesRequest.defaultValue(); + preferences.auth.message.data = new Uint8Array([1]); + preferences.auth.message.slot = slot; + submitBuilderPreferences.mockResolvedValue({assertOk: vi.fn()}); + + const entry = getBuilderEntry(url, slot); + entry.auth.message.data = new Uint8Array(); + + const client = new BuilderApiClient({}, config, bls); + await client.submitBuilderPreferences(url, proposerPubkey, preferences); + const bids = await client.getExecutionPayloadBids( + [entry], + slot, + new Uint8Array(32), + new Uint8Array(32), + proposerPubkey, + 1_000 + ); + + expect(bids).toEqual([]); + expect(verifySignatureSets).toHaveBeenCalledOnce(); + expect(getExecutionPayloadBid).not.toHaveBeenCalled(); + }); + it("does not cache a builder client when request auth is invalid", async () => { const slot = 1; const entry = getBuilderEntry("https://builder.example.com", slot); From 5d4a13c3ac718a578a993e1999b4ac89c7f07f85 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 10:07:35 +0100 Subject: [PATCH 42/67] saturate builder bid values before weighting --- .../src/api/impl/validator/index.ts | 11 +++-- .../chain/validation/executionPayloadBid.ts | 10 +++-- .../api/impl/validator/produceBlockV4.test.ts | 42 ++++++++++++++++++- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 6d09079b4d55..6117ae6b46d6 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -52,7 +52,6 @@ import { import { GWEI_TO_WEI, TimeoutError, - bigIntMin, byteArrayEquals, defer, formatWeiToEth, @@ -79,7 +78,10 @@ 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 { + getBuilderBidTotalGwei, + validateBuilderApiExecutionPayloadBid, +} from "../../../chain/validation/executionPayloadBid.js"; import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; import {validateGossipProposerPreferences} from "../../../chain/validation/proposerPreferences.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; @@ -969,10 +971,7 @@ export function getValidatorApi( }); candidates.push({ signedBid, - // Execution payment above the entry's cap adds nothing to the bid - totalGwei: - BigInt(signedBid.message.value) + - bigIntMin(signedBid.message.executionPayment, entry.maxExecutionPayment), + totalGwei: getBuilderBidTotalGwei(signedBid.message, entry.maxExecutionPayment), boostFactor: entry.builderBoostFactor, url, }); diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index 81ef5c650d8d..a8cb6dd722b2 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -1,6 +1,6 @@ import {routes} from "@lodestar/api"; import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; -import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; +import {MAX_EXECUTION_PAYMENT, PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import { computeEpochAtSlot, createSingleSignatureSetFromComponents, @@ -34,6 +34,11 @@ const BID_INCREMENT_FLOOR_GWEI = 100_000; */ const BID_INCREMENT_CAP_GWEI = 10_000_000; +/** Return the counted bid payment in Gwei, saturated at uint64 max. */ +export function getBuilderBidTotalGwei(bid: gloas.ExecutionPayloadBid, maxExecutionPayment: bigint): bigint { + return bigIntMin(MAX_EXECUTION_PAYMENT, BigInt(bid.value) + bigIntMin(bid.executionPayment, maxExecutionPayment)); +} + /** * Return the minimum value a new bid must have to be forwarded given the current highest bid. * Division before multiplication to stay within safe integer range for max gwei values. @@ -121,8 +126,7 @@ export async function validateBuilderApiExecutionPayloadBid( ); } - // Execution payment above the entry's cap adds nothing to the bid - const totalPayment = BigInt(bid.value) + bigIntMin(bid.executionPayment, entry.maxExecutionPayment); + const totalPayment = getBuilderBidTotalGwei(bid, entry.maxExecutionPayment); if (totalPayment < entry.minBid) { throw Error(`Bid total payment=${totalPayment} is below minBid=${entry.minBid}`); } diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 4c1330fcd95c..6eacb0bbacfa 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -1,7 +1,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; -import {ForkName} from "@lodestar/params"; +import {ForkName, MAX_EXECUTION_PAYMENT} from "@lodestar/params"; import {ssz} from "@lodestar/types"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; @@ -280,6 +280,46 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(bidBlock); }); + it("saturates builder API bid totals before ranking", async () => { + const firstUrl = "https://first-builder.example.com"; + const secondUrl = "https://second-builder.example.com"; + const firstEntry = { + url: new TextEncoder().encode(firstUrl), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: MAX_EXECUTION_PAYMENT, + minBid: 0n, + builderBoostFactor: 100n, + }; + const secondEntry = {...firstEntry, url: new TextEncoder().encode(secondUrl)}; + const firstBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + firstBid.message.executionPayment = MAX_EXECUTION_PAYMENT; + const secondBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + secondBid.message.value = 1; + secondBid.message.executionPayment = MAX_EXECUTION_PAYMENT; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); + modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ + {url: firstUrl, entry: firstEntry, signedBid: firstBid}, + {url: secondUrl, entry: secondEntry, signedBid: secondBid}, + ]); + + await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: 0n, builderBoostFactor: 100n, builders: [firstEntry, secondEntry]}, + }); + + // Both totals saturate at uint64 max, so the earlier bid wins the tie + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: firstBid})); + }); + it("prefers the builder API bid over an equally boosted p2p bid", async () => { const builderUrl = "https://builder.example.com"; const entry = { From f56ba27844963996075aae01360d6b8e3c3bee5b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 10:56:32 +0100 Subject: [PATCH 43/67] align builder documentation with bid selection behavior --- docs/pages/run/validator-management/proposer-config.md | 4 ++-- docs/pages/run/validator-management/vc-configuration.md | 6 +++--- packages/cli/src/cmds/validator/options.ts | 4 ++-- packages/validator/src/services/validatorStore.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/pages/run/validator-management/proposer-config.md b/docs/pages/run/validator-management/proposer-config.md index c25df8f286c6..c1aac886dee1 100644 --- a/docs/pages/run/validator-management/proposer-config.md +++ b/docs/pages/run/validator-management/proposer-config.md @@ -36,9 +36,9 @@ default_config: boost_factor: "90" ``` -Starting with Gloas, the builder section additionally supports `min_bid` (floor in Gwei on the total payment accepted from a builder bid) and `max_execution_payment` (ceiling in Gwei on the trusted execution layer payment accepted from a builder, values above `0` require `--allowDangerousTrustedPayments`). +Starting with Gloas, the builder section additionally supports `min_bid` (floor in Gwei on the counted total payment from a builder bid) and `max_execution_payment` (ceiling in Gwei on the execution layer payment counted toward a builder bid, values above `0` require `--allowDangerousTrustedPayments`). -Post-Gloas, an explicitly configured `boost_factor` takes precedence over `selection`. +Post-Gloas, an explicitly configured per-validator `boost_factor` takes precedence over `selection`. The builder section also supports a `builders` list with the same per-builder entries as the keymanager builders API. Each entry has a required `url` and optional `auth_data`, `builder_pubkeys`, `max_execution_payment`, `min_bid` and `builder_boost_factor`. Multiple entries may share a `url` only if they have distinct `auth_data`. Per-key entries replace the builders the validator client is configured with; setting both `--builder.urls` and `builders` in `default_config` is an error. diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index e2dc688d0c2a..57c44860dc4b 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -112,12 +112,12 @@ Example 3: Setting a `--builder.boostFactor=100` is the same as signaling `--bui ### Configure external builders (Gloas) -Starting with Gloas, external builders are configured on the validator client and the beacon node requests bids on its behalf. Use [`--builder.urls`](./validator-cli.md#--builderurls) to name the builders to request bids from. Bids received over p2p are always considered alongside them, governed by the same selection settings. +Starting with Gloas, external builders are configured on the validator client and the beacon node requests bids on its behalf. Use [`--builder.urls`](./validator-cli.md#--builderurls) to name the builders to request bids from. Viable bids received over p2p are considered alongside them unless the builder circuit breaker is active, governed by the same selection settings. Every bid request is authenticated with data the builder expects, by default the UTF-8 bytes of the builder URL exactly as configured. If a builder requires different auth data agreed out of band, append it as a hex fragment to its URL, e.g. `--builder.urls https://builder.example.com#0x0123`. The fragment is stripped before the URL is used and never sent to the builder. Auth data that must stay secret is better kept in the [proposer configuration file](./proposer-config.md), as command line arguments are visible to other processes. -- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Bids below the floor are rejected. -- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei counted from a builder bid, any payment above it adds nothing to the bid when comparing it with other bids. The default of `0` only accepts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. +- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum counted total payment in Gwei accepted from a builder bid. The total is `value + min(execution_payment, max_execution_payment)`, capped at max uint64. Bids below the floor are rejected. +- [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei counted from a builder bid, any payment above it adds nothing to the bid when comparing it with other bids. The default of `0` only counts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. Builders can also be configured per validator key with per-builder overrides via the [Set Builder Configuration keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builder%20Config) or the [proposer configuration file](./proposer-config.md). diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 94ae8a36a162..a96e88e40294 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -283,7 +283,7 @@ export const validatorOptions: CliCommandOptions = { "builder.minBid": { type: "string", description: - "Minimum total payment in Gwei accepted from a builder bid, counting the bid value plus its execution payment. Only used post-Gloas", + "Minimum counted total payment in Gwei accepted from a builder bid. The total is the bid value plus its execution payment up to --builder.maxExecutionPayment, capped at max uint64. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMinBid}`, group: "builder", }, @@ -300,7 +300,7 @@ export const validatorOptions: CliCommandOptions = { "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. Values above 0 require --allowDangerousTrustedPayments. Only used post-Gloas", + "Maximum execution layer payment in Gwei counted toward a builder bid. A value of 0 means only trustless payments via the builder's staked collateral count toward the bid. Values above 0 require --allowDangerousTrustedPayments. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMaxExecutionPayment}`, group: "builder", }, diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index e37c3032b9c5..5c756143b1a5 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -163,7 +163,7 @@ export const defaultOptions = { builderAliasSelection: routes.validator.BuilderSelection.Default, builderBoostFactor: 100n, builderMinBid: 0n, - // Only trustless payments via the builder's staked collateral are accepted by default + // Only trustless payments via the builder's staked collateral are counted by default builderMaxExecutionPayment: 0n, // spec asks for gossip validation by default broadcastValidation: routes.beacon.BroadcastValidation.gossip, From 23a1a8e684faf6029199b5175ea7ba51e86e1bbb Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 11:02:02 +0100 Subject: [PATCH 44/67] update min bid description --- packages/cli/src/cmds/validator/options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index a96e88e40294..462a1d013a6a 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -283,7 +283,7 @@ export const validatorOptions: CliCommandOptions = { "builder.minBid": { type: "string", description: - "Minimum counted total payment in Gwei accepted from a builder bid. The total is the bid value plus its execution payment up to --builder.maxExecutionPayment, capped at max uint64. Only used post-Gloas", + "Minimum counted total payment in Gwei accepted from a builder bid. The total is the bid value plus its execution payment up to the configured cap, capped at max uint64. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMinBid}`, group: "builder", }, From 4f25c453e8114dc0edcf275fedd2a90d3503fbe7 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 11:04:02 +0100 Subject: [PATCH 45/67] update wordlist --- .wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.wordlist.txt b/.wordlist.txt index 9dab4eabc301..33157f667da6 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -252,6 +252,7 @@ todo trustless typesafe udp +uint unpkg util utils From 05e8cace5d4c3b753cff185ad005dc1b59376c59 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 13:46:07 +0100 Subject: [PATCH 46/67] move builder api bid validation next to the builder client --- .../src/api/impl/validator/index.ts | 5 +- .../chain/validation/executionPayloadBid.ts | 145 +--------------- .../src/execution/builder/validateBid.ts | 156 ++++++++++++++++++ .../api/impl/validator/produceBlockV4.test.ts | 4 +- 4 files changed, 161 insertions(+), 149 deletions(-) create mode 100644 packages/beacon-node/src/execution/builder/validateBid.ts diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 6117ae6b46d6..3e65f1d6c120 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -78,15 +78,12 @@ 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 { - getBuilderBidTotalGwei, - validateBuilderApiExecutionPayloadBid, -} from "../../../chain/validation/executionPayloadBid.js"; import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; import {validateGossipProposerPreferences} from "../../../chain/validation/proposerPreferences.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; import {BUILDER_BID_REQUEST_TIMEOUT_MS, BuilderApiBid, decodeBuilderUrl} from "../../../execution/builder/apiClient.js"; +import {getBuilderBidTotalGwei, validateBuilderApiExecutionPayloadBid} from "../../../execution/builder/validateBid.js"; import {BuilderStatus, NoBidReceived} from "../../../execution/builder/http.js"; import {validateGossipFnRetryUnknownRoot} from "../../../network/processor/gossipHandlers.js"; import {CommitteeSubscription} from "../../../network/subnets/index.js"; diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index a8cb6dd722b2..f17f33929286 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -1,6 +1,5 @@ -import {routes} from "@lodestar/api"; import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; -import {MAX_EXECUTION_PAYMENT, PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; +import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import { computeEpochAtSlot, createSingleSignatureSetFromComponents, @@ -11,7 +10,7 @@ import { isStatePostGloas, } from "@lodestar/state-transition"; import {RootHex, Slot, ValidatorIndex, gloas} from "@lodestar/types"; -import {bigIntMin, byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; import {getShufflingDependentRoot} from "../../util/dependentRoot.js"; import {ExecutionPayloadBidError, ExecutionPayloadBidErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; @@ -34,11 +33,6 @@ const BID_INCREMENT_FLOOR_GWEI = 100_000; */ const BID_INCREMENT_CAP_GWEI = 10_000_000; -/** Return the counted bid payment in Gwei, saturated at uint64 max. */ -export function getBuilderBidTotalGwei(bid: gloas.ExecutionPayloadBid, maxExecutionPayment: bigint): bigint { - return bigIntMin(MAX_EXECUTION_PAYMENT, BigInt(bid.value) + bigIntMin(bid.executionPayment, maxExecutionPayment)); -} - /** * Return the minimum value a new bid must have to be forwarded given the current highest bid. * Division before multiplication to stay within safe integer range for max gwei values. @@ -91,141 +85,6 @@ export async function validateApiExecutionPayloadBid( return validateExecutionPayloadBid(chain, signedExecutionPayloadBid); } -/** - * Validate a bid received from a builder over the builder API in response to a bid request - * made during block production. Unlike gossip validation, the bid must match the requested - * slot and parent exactly, may carry a non-zero `executionPayment` which is counted at most at - * the entry's `maxExecutionPayment`, and is not subject to gossip anti-spam rules. - * - * Throws with a description of the failure, the caller drops the bid. - */ -export async function validateBuilderApiExecutionPayloadBid( - chain: IBeaconChain, - signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid, - request: { - slot: Slot; - parentBlock: ProtoBlock; - parentBlockHash: RootHex; - parentBlockRoot: RootHex; - entry: routes.validator.BuilderEntry; - } -): Promise { - const bid = signedExecutionPayloadBid.message; - const {slot, parentBlock, parentBlockHash, parentBlockRoot, entry} = request; - - if (bid.slot !== slot) { - throw Error(`Bid slot=${bid.slot} does not match requested slot=${slot}`); - } - - const bidParentBlockHash = toRootHex(bid.parentBlockHash); - const bidParentBlockRoot = toRootHex(bid.parentBlockRoot); - if (bidParentBlockHash !== parentBlockHash || bidParentBlockRoot !== parentBlockRoot) { - throw Error( - `Bid parent parentBlockHash=${bidParentBlockHash} parentBlockRoot=${bidParentBlockRoot} does not match ` + - `requested parentBlockHash=${parentBlockHash} parentBlockRoot=${parentBlockRoot}` - ); - } - - const totalPayment = getBuilderBidTotalGwei(bid, entry.maxExecutionPayment); - if (totalPayment < entry.minBid) { - throw Error(`Bid total payment=${totalPayment} is below minBid=${entry.minBid}`); - } - - const state = await chain.regen - .getBlockSlotState(parentBlock, slot, {dontTransferCache: true}, RegenCaller.validateGossipExecutionPayloadBid) - .catch((e: Error) => { - throw Error(`Unable to regenerate state to validate bid: ${e.message}`); - }); - - if (!isStatePostGloas(state)) { - throw Error(`Expected gloas+ state for execution payload bid validation, got fork=${state.forkName}`); - } - - if (bid.builderIndex >= state.getBuildersLength()) { - throw Error(`Bid builderIndex=${bid.builderIndex} is out of bounds`); - } - - const builder = state.getBuilder(bid.builderIndex); - 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(`Invalid builder version=${builder.version} expected=${PAYLOAD_BUILDER_VERSION}`); - } - - // A bid not signed by one of the builder pubkeys the entry accepts bids from must not be accepted - if ( - entry.builderPubkeys.length > 0 && - !entry.builderPubkeys.some((pubkey) => byteArrayEquals(pubkey, builder.pubkey)) - ) { - throw Error(`Bid builder pubkey=${toHex(builder.pubkey)} is not in the entry's builderPubkeys`); - } - - const blobKzgCommitmentsLen = bid.blobKzgCommitments.length; - const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot)); - if (blobKzgCommitmentsLen > maxBlobsPerBlock) { - throw Error(`Bid has too many KZG commitments len=${blobKzgCommitmentsLen} limit=${maxBlobsPerBlock}`); - } - - // The coverage check only applies to the staked collateral payment, a pure execution - // layer payment bid has nothing to cover on-chain - if (bid.value > 0 && !state.canBuilderCoverBid(bid.builderIndex, bid.value)) { - throw Error(`Builder cannot cover bid value=${bid.value} balance=${builder.balance}`); - } - - const randaoMix = state.getRandaoMix(computeEpochAtSlot(state.slot)); - if (!byteArrayEquals(bid.prevRandao, randaoMix)) { - throw Error(`Invalid bid prevRandao=${toHex(bid.prevRandao)} expected=${toHex(randaoMix)}`); - } - - // The builder must honor the proposer preferences it learned over gossip - const bidEpoch = computeEpochAtSlot(bid.slot); - const dependentRootHex = (() => { - try { - return getShufflingDependentRoot(chain.forkChoice, bidEpoch, computeEpochAtSlot(parentBlock.slot), parentBlock); - } catch { - return null; - } - })(); - if (dependentRootHex === null) { - throw Error(`Unable to resolve proposer preferences dependent root for bid slot=${bid.slot}`); - } - const proposerPreferences = chain.proposerPreferencesPool.get(bid.slot, dependentRootHex); - if (proposerPreferences === null) { - throw Error(`No proposer preferences found for bid slot=${bid.slot} dependentRoot=${dependentRootHex}`); - } - if (!byteArrayEquals(bid.feeRecipient, proposerPreferences.message.feeRecipient)) { - throw Error( - `Bid feeRecipient=${toHex(bid.feeRecipient)} does not match ` + - `proposer preferences feeRecipient=${toHex(proposerPreferences.message.feeRecipient)}` - ); - } - - const parentPayloadVariant = chain.forkChoice.getBlockHexAndBlockHash(bidParentBlockRoot, bidParentBlockHash); - if (parentPayloadVariant === null || parentPayloadVariant.executionPayloadBlockHash === null) { - throw Error(`Unable to resolve parent payload gas limit for bid parentBlockHash=${bidParentBlockHash}`); - } - const parentGasLimit = BigInt(parentPayloadVariant.executionPayloadGasLimit); - const targetGasLimit = proposerPreferences.message.targetGasLimit; - if (!isGasLimitTargetCompatible(parentGasLimit, bid.gasLimit, targetGasLimit)) { - throw Error( - `Bid gasLimit=${bid.gasLimit} is not compatible with ` + - `parentGasLimit=${parentGasLimit} targetGasLimit=${targetGasLimit}` - ); - } - - const signatureSet = createSingleSignatureSetFromComponents( - builder.pubkey, - getExecutionPayloadBidSigningRoot(chain.config, bid), - signedExecutionPayloadBid.signature - ); - - if (!(await chain.bls.verifySignatureSets([signatureSet]))) { - throw Error(`Invalid bid signature builderIndex=${bid.builderIndex}`); - } -} - export async function validateGossipExecutionPayloadBid( chain: IBeaconChain, signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid diff --git a/packages/beacon-node/src/execution/builder/validateBid.ts b/packages/beacon-node/src/execution/builder/validateBid.ts new file mode 100644 index 000000000000..f21c91629160 --- /dev/null +++ b/packages/beacon-node/src/execution/builder/validateBid.ts @@ -0,0 +1,156 @@ +import {routes} from "@lodestar/api"; +import {ProtoBlock} from "@lodestar/fork-choice"; +import {MAX_EXECUTION_PAYMENT, PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; +import { + computeEpochAtSlot, + createSingleSignatureSetFromComponents, + getExecutionPayloadBidSigningRoot, + isActiveBuilder, + isGasLimitTargetCompatible, + isStatePostGloas, +} from "@lodestar/state-transition"; +import {RootHex, Slot, gloas} from "@lodestar/types"; +import {bigIntMin, byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import {IBeaconChain} from "../../chain/index.js"; +import {RegenCaller} from "../../chain/regen/index.js"; +import {getShufflingDependentRoot} from "../../util/dependentRoot.js"; + +/** Return the counted bid payment in Gwei, saturated at uint64 max. */ +export function getBuilderBidTotalGwei(bid: gloas.ExecutionPayloadBid, maxExecutionPayment: bigint): bigint { + return bigIntMin(MAX_EXECUTION_PAYMENT, BigInt(bid.value) + bigIntMin(bid.executionPayment, maxExecutionPayment)); +} + +/** + * Validate a bid received from a builder over the builder API in response to a bid request + * made during block production. Unlike gossip validation, the bid must match the requested + * slot and parent exactly, may carry a non-zero `executionPayment` which is counted at most at + * the entry's `maxExecutionPayment`, and is not subject to gossip anti-spam rules. + * + * Throws with a description of the failure, the caller drops the bid. + */ +export async function validateBuilderApiExecutionPayloadBid( + chain: IBeaconChain, + signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid, + request: { + slot: Slot; + parentBlock: ProtoBlock; + parentBlockHash: RootHex; + parentBlockRoot: RootHex; + entry: routes.validator.BuilderEntry; + } +): Promise { + const bid = signedExecutionPayloadBid.message; + const {slot, parentBlock, parentBlockHash, parentBlockRoot, entry} = request; + + if (bid.slot !== slot) { + throw Error(`Bid slot=${bid.slot} does not match requested slot=${slot}`); + } + + const bidParentBlockHash = toRootHex(bid.parentBlockHash); + const bidParentBlockRoot = toRootHex(bid.parentBlockRoot); + if (bidParentBlockHash !== parentBlockHash || bidParentBlockRoot !== parentBlockRoot) { + throw Error( + `Bid parent parentBlockHash=${bidParentBlockHash} parentBlockRoot=${bidParentBlockRoot} does not match ` + + `requested parentBlockHash=${parentBlockHash} parentBlockRoot=${parentBlockRoot}` + ); + } + + const totalPayment = getBuilderBidTotalGwei(bid, entry.maxExecutionPayment); + if (totalPayment < entry.minBid) { + throw Error(`Bid total payment=${totalPayment} is below minBid=${entry.minBid}`); + } + + const state = await chain.regen + .getBlockSlotState(parentBlock, slot, {dontTransferCache: true}, RegenCaller.validateGossipExecutionPayloadBid) + .catch((e: Error) => { + throw Error(`Unable to regenerate state to validate bid: ${e.message}`); + }); + + if (!isStatePostGloas(state)) { + throw Error(`Expected gloas+ state for execution payload bid validation, got fork=${state.forkName}`); + } + + if (bid.builderIndex >= state.getBuildersLength()) { + throw Error(`Bid builderIndex=${bid.builderIndex} is out of bounds`); + } + + const builder = state.getBuilder(bid.builderIndex); + 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(`Invalid builder version=${builder.version} expected=${PAYLOAD_BUILDER_VERSION}`); + } + + // A bid not signed by one of the builder pubkeys the entry accepts bids from must not be accepted + if ( + entry.builderPubkeys.length > 0 && + !entry.builderPubkeys.some((pubkey) => byteArrayEquals(pubkey, builder.pubkey)) + ) { + throw Error(`Bid builder pubkey=${toHex(builder.pubkey)} is not in the entry's builderPubkeys`); + } + + const blobKzgCommitmentsLen = bid.blobKzgCommitments.length; + const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot)); + if (blobKzgCommitmentsLen > maxBlobsPerBlock) { + throw Error(`Bid has too many KZG commitments len=${blobKzgCommitmentsLen} limit=${maxBlobsPerBlock}`); + } + + // The coverage check only applies to the staked collateral payment, a pure execution + // layer payment bid has nothing to cover on-chain + if (bid.value > 0 && !state.canBuilderCoverBid(bid.builderIndex, bid.value)) { + throw Error(`Builder cannot cover bid value=${bid.value} balance=${builder.balance}`); + } + + const randaoMix = state.getRandaoMix(computeEpochAtSlot(state.slot)); + if (!byteArrayEquals(bid.prevRandao, randaoMix)) { + throw Error(`Invalid bid prevRandao=${toHex(bid.prevRandao)} expected=${toHex(randaoMix)}`); + } + + // The builder must honor the proposer preferences it learned over gossip + const bidEpoch = computeEpochAtSlot(bid.slot); + const dependentRootHex = (() => { + try { + return getShufflingDependentRoot(chain.forkChoice, bidEpoch, computeEpochAtSlot(parentBlock.slot), parentBlock); + } catch { + return null; + } + })(); + if (dependentRootHex === null) { + throw Error(`Unable to resolve proposer preferences dependent root for bid slot=${bid.slot}`); + } + const proposerPreferences = chain.proposerPreferencesPool.get(bid.slot, dependentRootHex); + if (proposerPreferences === null) { + throw Error(`No proposer preferences found for bid slot=${bid.slot} dependentRoot=${dependentRootHex}`); + } + if (!byteArrayEquals(bid.feeRecipient, proposerPreferences.message.feeRecipient)) { + throw Error( + `Bid feeRecipient=${toHex(bid.feeRecipient)} does not match ` + + `proposer preferences feeRecipient=${toHex(proposerPreferences.message.feeRecipient)}` + ); + } + + const parentPayloadVariant = chain.forkChoice.getBlockHexAndBlockHash(bidParentBlockRoot, bidParentBlockHash); + if (parentPayloadVariant === null || parentPayloadVariant.executionPayloadBlockHash === null) { + throw Error(`Unable to resolve parent payload gas limit for bid parentBlockHash=${bidParentBlockHash}`); + } + const parentGasLimit = BigInt(parentPayloadVariant.executionPayloadGasLimit); + const targetGasLimit = proposerPreferences.message.targetGasLimit; + if (!isGasLimitTargetCompatible(parentGasLimit, bid.gasLimit, targetGasLimit)) { + throw Error( + `Bid gasLimit=${bid.gasLimit} is not compatible with ` + + `parentGasLimit=${parentGasLimit} targetGasLimit=${targetGasLimit}` + ); + } + + const signatureSet = createSingleSignatureSetFromComponents( + builder.pubkey, + getExecutionPayloadBidSigningRoot(chain.config, bid), + signedExecutionPayloadBid.signature + ); + + if (!(await chain.bls.verifySignatureSets([signatureSet]))) { + throw Error(`Invalid bid signature builderIndex=${bid.builderIndex}`); + } +} diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 6eacb0bbacfa..9691605f9693 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -5,12 +5,12 @@ import {ForkName, MAX_EXECUTION_PAYMENT} from "@lodestar/params"; import {ssz} from "@lodestar/types"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; -import {validateBuilderApiExecutionPayloadBid} from "../../../../../src/chain/validation/executionPayloadBid.js"; +import {validateBuilderApiExecutionPayloadBid} from "../../../../../src/execution/builder/validateBid.js"; import {SyncState} from "../../../../../src/sync/interface.js"; import {ApiTestModules, getApiTestModules} from "../../../../utils/api.js"; import {zeroProtoBlock} from "../../../../utils/state.js"; -vi.mock("../../../../../src/chain/validation/executionPayloadBid.js", async (importActual) => ({ +vi.mock("../../../../../src/execution/builder/validateBid.js", async (importActual) => ({ ...(await importActual()), validateBuilderApiExecutionPayloadBid: vi.fn().mockResolvedValue(undefined), })); From 265fc041ae9075ce78ebe988840ca7b8f79f51e8 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 13:54:40 +0100 Subject: [PATCH 47/67] builder api client owns the bid request timeout --- .../src/api/impl/validator/index.ts | 5 ++-- packages/beacon-node/src/chain/chain.ts | 5 +++- .../src/execution/builder/apiClient.ts | 25 ++++++++----------- packages/beacon-node/src/node/nodejs.ts | 1 + .../unit/execution/builder/apiClient.test.ts | 13 ++++------ 5 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 3e65f1d6c120..5078999aa4ad 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -82,7 +82,7 @@ import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; import {validateGossipProposerPreferences} from "../../../chain/validation/proposerPreferences.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; -import {BUILDER_BID_REQUEST_TIMEOUT_MS, BuilderApiBid, decodeBuilderUrl} from "../../../execution/builder/apiClient.js"; +import {BuilderApiBid, decodeBuilderUrl} from "../../../execution/builder/apiClient.js"; import {getBuilderBidTotalGwei, validateBuilderApiExecutionPayloadBid} from "../../../execution/builder/validateBid.js"; import {BuilderStatus, NoBidReceived} from "../../../execution/builder/http.js"; import {validateGossipFnRetryUnknownRoot} from "../../../network/processor/gossipHandlers.js"; @@ -918,8 +918,7 @@ export function getValidatorApi( slot, fromHex(bidParentBlockHash), parentBlockRoot, - proposerPubkey, - BUILDER_BID_REQUEST_TIMEOUT_MS + proposerPubkey ); } catch (e) { logger.warn("Unable to request builder API bids", {slot}, e as Error); diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 8976ef293be2..aae5be8beb92 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -277,6 +277,7 @@ export class BeaconChain implements IBeaconChain { isAnchorStateFinalized, executionEngine, executionBuilder, + builderUserAgent, }: { privateKey: PrivateKey; config: BeaconConfig; @@ -294,6 +295,8 @@ export class BeaconChain implements IBeaconChain { isAnchorStateFinalized: boolean; executionEngine: IExecutionEngine; executionBuilder?: IExecutionBuilder; + /** Sent with all builder api requests, unless the node runs in private mode */ + builderUserAgent?: string; } ) { this.opts = opts; @@ -450,7 +453,7 @@ export class BeaconChain implements IBeaconChain { {forkChoice, logger, metrics} ); - this.builderApiClient = new BuilderApiClient({}, config, bls, metrics, logger); + this.builderApiClient = new BuilderApiClient({userAgent: builderUserAgent}, config, bls, metrics, logger); this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({ config, diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index 69340efb8bec..92516353961c 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -14,12 +14,6 @@ import {isValidAsciiHttpUrl, toHex, toPrintableUrl} from "@lodestar/utils"; import type {IBlsVerifier} from "../../chain/bls/index.js"; import {Metrics} from "../../metrics/metrics.js"; -export type BuilderApiClientOpts = { - 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. @@ -32,6 +26,11 @@ const EVENT_LOOP_LAG_BUFFER = 250; */ export const BUILDER_BID_REQUEST_TIMEOUT_MS = 1000 + EVENT_LOOP_LAG_BUFFER; +export type BuilderApiClientOpts = { + // Add User-Agent header to all requests + userAgent?: string; +}; + type BuilderUrl = string; /** Decode the SSZ URL bytes without allowing replacement characters or unsafe header values. */ @@ -55,7 +54,7 @@ export type BuilderApiBid = { }; /** - * External builder integration post-gloas (ePBS). + * External builder integration post-gloas * * The builder set is driven by the resolved `BuilderConfig` the validator client supplies on * each block production request, clients are dialed on demand based on the entry `url`. @@ -81,8 +80,7 @@ export class BuilderApiClient { slot: Slot, parentHash: Root, parentRoot: Root, - proposerPubkey: BLSPubkey, - timeoutMs: number + proposerPubkey: BLSPubkey ): Promise { const seenRequests = new Set(); const requests: {url: BuilderUrl; entry: routes.validator.BuilderEntry}[] = []; @@ -129,9 +127,9 @@ export class BuilderApiClient { proposerPubkey, requestAuth: entry.auth, dateMilliseconds: Date.now(), - timeoutMs, + timeoutMs: BUILDER_BID_REQUEST_TIMEOUT_MS, }, - {timeoutMs} + {timeoutMs: BUILDER_BID_REQUEST_TIMEOUT_MS} ); const signedBid = res.value(); if (signedBid === undefined) { @@ -220,10 +218,7 @@ export class BuilderApiClient { client = getClient( { baseUrl: url, - globalInit: { - timeoutMs: this.opts.timeout, - headers: this.opts.userAgent ? {"User-Agent": this.opts.userAgent} : undefined, - }, + globalInit: {headers: this.opts.userAgent ? {"User-Agent": this.opts.userAgent} : undefined}, }, {config: this.config, metrics: this.metrics?.builderHttpClient, logger: this.logger} ); diff --git a/packages/beacon-node/src/node/nodejs.ts b/packages/beacon-node/src/node/nodejs.ts index f360315ee00a..044d6686c2f2 100644 --- a/packages/beacon-node/src/node/nodejs.ts +++ b/packages/beacon-node/src/node/nodejs.ts @@ -260,6 +260,7 @@ export class BeaconNode { executionBuilder: opts.executionBuilder.enabled ? initializeExecutionBuilder(opts.executionBuilder, config, metrics, logger) : undefined, + builderUserAgent: opts.executionBuilder.userAgent, }); // Load persisted data from disk to in-memory caches diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index a7ae58ac4acf..dacf0e8a19ca 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -45,8 +45,7 @@ describe("execution/builder/apiClient", () => { slot, new Uint8Array(32), new Uint8Array(32), - new Uint8Array(48), - 1_000 + new Uint8Array(48) ); expect(bids).toEqual([{url: "https://builder.example.com", entry: validEntry, signedBid}]); @@ -69,8 +68,7 @@ describe("execution/builder/apiClient", () => { slot, new Uint8Array(32), new Uint8Array(32), - new Uint8Array(48), - 1_000 + new Uint8Array(48) ); expect(bids).toEqual([{url: "https://builder.example.com", entry: validEntry, signedBid}]); @@ -125,8 +123,7 @@ describe("execution/builder/apiClient", () => { slot, new Uint8Array(32), new Uint8Array(32), - proposerPubkey, - 1_000 + proposerPubkey ); expect(bids).toEqual([]); @@ -144,10 +141,10 @@ describe("execution/builder/apiClient", () => { const client = new BuilderApiClient({}, config, bls); expect( - await client.getExecutionPayloadBids([entry], slot, new Uint8Array(32), new Uint8Array(32), proposerPubkey, 1_000) + await client.getExecutionPayloadBids([entry], slot, new Uint8Array(32), new Uint8Array(32), proposerPubkey) ).toEqual([]); expect( - await client.getExecutionPayloadBids([entry], slot, new Uint8Array(32), new Uint8Array(32), proposerPubkey, 1_000) + await client.getExecutionPayloadBids([entry], slot, new Uint8Array(32), new Uint8Array(32), proposerPubkey) ).toEqual([{url: "https://builder.example.com", entry, signedBid}]); expect(verifySignatureSets).toHaveBeenCalledTimes(2); From 9f9e0233315b48ee04359bdad405001c2823a6ba Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 14:05:28 +0100 Subject: [PATCH 48/67] apply configured builder timeout to builder api requests --- packages/beacon-node/src/chain/chain.ts | 9 ++++----- packages/beacon-node/src/execution/builder/apiClient.ts | 7 ++++++- packages/beacon-node/src/node/nodejs.ts | 6 +++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index aae5be8beb92..e3f4a82c6585 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -61,7 +61,7 @@ import {ProcessShutdownCallback} from "@lodestar/validator"; 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 {BuilderApiClient} from "../execution/builder/apiClient.js"; +import {BuilderApiClient, BuilderApiClientOpts} from "../execution/builder/apiClient.js"; import {BuilderStatus} from "../execution/builder/http.js"; import {IExecutionBuilder, IExecutionEngine} from "../execution/index.js"; import {Metrics} from "../metrics/index.js"; @@ -277,7 +277,7 @@ export class BeaconChain implements IBeaconChain { isAnchorStateFinalized, executionEngine, executionBuilder, - builderUserAgent, + builderApiClientOpts, }: { privateKey: PrivateKey; config: BeaconConfig; @@ -295,8 +295,7 @@ export class BeaconChain implements IBeaconChain { isAnchorStateFinalized: boolean; executionEngine: IExecutionEngine; executionBuilder?: IExecutionBuilder; - /** Sent with all builder api requests, unless the node runs in private mode */ - builderUserAgent?: string; + builderApiClientOpts?: BuilderApiClientOpts; } ) { this.opts = opts; @@ -453,7 +452,7 @@ export class BeaconChain implements IBeaconChain { {forkChoice, logger, metrics} ); - this.builderApiClient = new BuilderApiClient({userAgent: builderUserAgent}, config, bls, metrics, logger); + this.builderApiClient = new BuilderApiClient(builderApiClientOpts ?? {}, config, bls, metrics, logger); this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({ config, diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index 92516353961c..21cf344a33d9 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -27,6 +27,8 @@ const EVENT_LOOP_LAG_BUFFER = 250; export const BUILDER_BID_REQUEST_TIMEOUT_MS = 1000 + EVENT_LOOP_LAG_BUFFER; export type BuilderApiClientOpts = { + /** Timeout for builder api requests, bid requests always use `BUILDER_BID_REQUEST_TIMEOUT_MS` */ + timeout?: number; // Add User-Agent header to all requests userAgent?: string; }; @@ -218,7 +220,10 @@ export class BuilderApiClient { client = getClient( { baseUrl: url, - globalInit: {headers: this.opts.userAgent ? {"User-Agent": this.opts.userAgent} : undefined}, + 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} ); diff --git a/packages/beacon-node/src/node/nodejs.ts b/packages/beacon-node/src/node/nodejs.ts index 044d6686c2f2..c16f75669e98 100644 --- a/packages/beacon-node/src/node/nodejs.ts +++ b/packages/beacon-node/src/node/nodejs.ts @@ -260,7 +260,11 @@ export class BeaconNode { executionBuilder: opts.executionBuilder.enabled ? initializeExecutionBuilder(opts.executionBuilder, config, metrics, logger) : undefined, - builderUserAgent: opts.executionBuilder.userAgent, + builderApiClientOpts: { + timeout: opts.executionBuilder.timeout, + // Sent with all builder api requests, unless the node runs in private mode + userAgent: opts.executionBuilder.userAgent, + }, }); // Load persisted data from disk to in-memory caches From 37e81ba28186f09213e24b93301ab8134dd9525c Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 14:08:30 +0100 Subject: [PATCH 49/67] select p2p bid after the builder bid deadline --- .../src/api/impl/validator/index.ts | 159 ++++++++++-------- .../src/execution/builder/apiClient.ts | 23 ++- .../api/impl/validator/produceBlockV4.test.ts | 137 ++++++++++++++- .../unit/execution/builder/apiClient.test.ts | 26 +++ 4 files changed, 261 insertions(+), 84 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 5078999aa4ad..68c116cd2b9a 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -58,6 +58,7 @@ import { fromHex, prettyWeiToEth, resolveOrRacePromises, + sleep, toHex, toPrintableUrl, toRootHex, @@ -82,9 +83,9 @@ import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; import {validateGossipProposerPreferences} from "../../../chain/validation/proposerPreferences.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; -import {BuilderApiBid, decodeBuilderUrl} from "../../../execution/builder/apiClient.js"; -import {getBuilderBidTotalGwei, validateBuilderApiExecutionPayloadBid} from "../../../execution/builder/validateBid.js"; +import {BUILDER_BID_DEADLINE_MS, BuilderApiBid, decodeBuilderUrl} from "../../../execution/builder/apiClient.js"; import {BuilderStatus, NoBidReceived} from "../../../execution/builder/http.js"; +import {getBuilderBidTotalGwei, validateBuilderApiExecutionPayloadBid} from "../../../execution/builder/validateBid.js"; import {validateGossipFnRetryUnknownRoot} from "../../../network/processor/gossipHandlers.js"; import {CommitteeSubscription} from "../../../network/subnets/index.js"; import {callInNextEventLoop} from "../../../util/eventLoop.js"; @@ -119,6 +120,36 @@ const BLOCK_PRODUCTION_RACE_TIMEOUT_MS = 12_000; /** Rejection message of the bid block branch when there is no viable bid to commit to */ const NO_BID_AVAILABLE = "No builder bid available"; +type BidCandidate = { + signedBid: gloas.SignedExecutionPayloadBid; + /** Total payment in Gwei, counting the execution payment at most at the entry's cap */ + totalGwei: bigint; + boostFactor: bigint; + url?: string; +}; + +/** + * Return the best bid by boosted total payment. A candidate with the max boost factor is + * preferred over any other regardless of value, ties keep the earlier candidate. + */ +function selectBestBid(candidates: BidCandidate[]): BidCandidate | null { + const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => boostFactor * totalGwei; + let best: BidCandidate | null = null; + for (const candidate of candidates) { + const candidateIsMaxBoost = candidate.boostFactor === MAX_BUILDER_BOOST_FACTOR; + const bestIsMaxBoost = best?.boostFactor === MAX_BUILDER_BOOST_FACTOR; + // Preserve max boost preference before comparing bid values + if ( + best === null || + (candidateIsMaxBoost && !bestIsMaxBoost) || + (candidateIsMaxBoost === bestIsMaxBoost && boostedValue(candidate) > boostedValue(best)) + ) { + best = candidate; + } + } + return best; +} + type ProduceBlockContentsRes = {executionPayloadValue: Wei; consensusBlockValue: Wei} & { data: BlockContents; version: ForkName; @@ -925,58 +956,58 @@ export function getValidatorApi( } } - // Keep a p2p builder bid as fallback unless the circuit breaker is active - let p2pBid = circuitBreakerActive - ? null - : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); - - // Reject a p2p bid below the proposer's configured floor on the total payment. - // A p2p bid's total is just its value since gossip validation enforces executionPayment=0. - if (p2pBid !== null && BigInt(p2pBid.message.value) < builderConfig.minBid) { - logger.debug("Ignoring p2p bid below min bid", { - slot, - bidValue: p2pBid.message.value, - minBid: builderConfig.minBid, - }); - p2pBid = null; - } - - // Candidates are ranked by their boosted total payment, the p2p bid is governed by the - // top-level factors and each builder API bid by its own entry. Ties keep the earlier - // candidate with builder API bids ranked first, so when the same bid arrives over both - // channels the builder API copy wins and the signed block can be routed back directly. - type BidCandidate = { - signedBid: gloas.SignedExecutionPayloadBid; - totalGwei: bigint; - boostFactor: bigint; - url?: string; - }; - const bestBidPromise: Promise = (async () => { - const candidates: BidCandidate[] = []; - - const builderApiBids = await builderApiBidsPromise; - await Promise.all( - builderApiBids.map(async ({url, entry, signedBid}) => { - try { - await validateBuilderApiExecutionPayloadBid(chain, signedBid, { + const hasEarlyP2pBid = + !circuitBreakerActive && + chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex) !== null; + + // Select the p2p bid once builders had time to bid up, matching the deadline advertised + // on builder API bid requests, unless the circuit breaker is active + const p2pBidPromise: Promise = circuitBreakerActive + ? Promise.resolve(null) + : sleep(Math.max(0, BUILDER_BID_DEADLINE_MS - chain.clock.msFromSlot(slot))).then(() => { + const p2pBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + // Discard p2p bids below the proposer's configured floor on the total payment. + // A p2p bid's total is just its value since gossip validation enforces executionPayment=0. + if (p2pBid !== null && BigInt(p2pBid.message.value) < builderConfig.minBid) { + logger.info("Ignoring p2p bid below min bid", { slot, - parentBlock, - parentBlockHash: bidParentBlockHash, - parentBlockRoot: parentBlockRootHex, - entry, - }); - candidates.push({ - signedBid, - totalGwei: getBuilderBidTotalGwei(signedBid.message, entry.maxExecutionPayment), - boostFactor: entry.builderBoostFactor, - url, + bidValue: p2pBid.message.value, + minBid: builderConfig.minBid, }); - } catch (e) { - metrics?.builderApi.bidsDiscarded.inc(); - logger.warn("Ignoring invalid builder API bid", {slot, builder: toPrintableUrl(url)}, e as Error); + return null; } - }) - ); + return p2pBid; + }); + + // Candidates are ranked by their boosted counted total payment, the p2p bid is governed + // by the top-level factors and each builder API bid by its own entry. Ties keep the + // earlier candidate: builder API bids are ordered by arrival before the p2p bid, so the + // earliest received builder API bid wins a tie and the signed block can be routed back + // to its builder directly. + const bestBidPromise: Promise = (async () => { + const [builderApiBids, p2pBid] = await Promise.all([builderApiBidsPromise, p2pBidPromise]); + + const candidates: BidCandidate[] = []; + for (const {url, entry, signedBid} of builderApiBids) { + try { + await validateBuilderApiExecutionPayloadBid(chain, signedBid, { + slot, + parentBlock, + parentBlockHash: bidParentBlockHash, + parentBlockRoot: parentBlockRootHex, + entry, + }); + candidates.push({ + signedBid, + totalGwei: getBuilderBidTotalGwei(signedBid.message, entry.maxExecutionPayment), + boostFactor: entry.builderBoostFactor, + url, + }); + } catch (e) { + metrics?.builderApi.bidsDiscarded.inc(); + logger.warn("Ignoring invalid builder API bid", {slot, builder: toPrintableUrl(url)}, e as Error); + } + } if (p2pBid !== null) { candidates.push({ @@ -986,20 +1017,7 @@ export function getValidatorApi( }); } - const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => boostFactor * totalGwei; - let best: BidCandidate | null = null; - for (const candidate of candidates) { - const candidateIsMaxBoost = candidate.boostFactor === MAX_BUILDER_BOOST_FACTOR; - const bestIsMaxBoost = best?.boostFactor === MAX_BUILDER_BOOST_FACTOR; - // Preserve max boost preference before comparing bid values - if ( - best === null || - (candidateIsMaxBoost && !bestIsMaxBoost) || - (candidateIsMaxBoost === bestIsMaxBoost && boostedValue(candidate) > boostedValue(best)) - ) { - best = candidate; - } - } + const best = selectBestBid(candidates); if (candidates.length > 0) { logger.debug("Ranked builder bid candidates", { slot, @@ -1032,7 +1050,7 @@ export function getValidatorApi( }; metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); - if (p2pBid !== null || builderConfig.builders.length > 0) { + if (hasEarlyP2pBid || builderConfig.builders.length > 0) { metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); } @@ -1051,9 +1069,9 @@ export function getValidatorApi( const enginePromise: ReturnType = timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs) ).then((engineBlock) => { - // No need to wait for the bid block if the engine block will always be selected due to - // suspected builder censorship, or a boost factor of 0 while no builder API bid may - // still arrive with its own entry boost factor + // No need to wait for the bid block if the engine block will always be selected, either + // due to suspected builder censorship, or because no builders are configured and the + // boost factor of 0 always prefers the local block over p2p bids if (engineBlock.shouldOverrideBuilder || (builderConfig.builders.length === 0 && builderBoostFactor === 0n)) { controller.abort(); } @@ -1095,6 +1113,7 @@ export function getValidatorApi( bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : "p2p", bidValue: bestBid.signedBid.message.value, bidExecutionPayment: bestBid.signedBid.message.executionPayment, + bidTotal: bestBid.totalGwei, bidBoostFactor: bestBid.boostFactor, builderIndex: bestBid.signedBid.message.builderIndex, bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), @@ -1106,7 +1125,7 @@ export function getValidatorApi( if ( engineResult.status === "fulfilled" && engineResult.value.shouldOverrideBuilder && - (p2pBid !== null || builderConfig.builders.length > 0) + (hasEarlyP2pBid || builderConfig.builders.length > 0) ) { source = ProducedBlockSource.engine; bestResult = engineResult; diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index 21cf344a33d9..d238d65f4b12 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -22,9 +22,13 @@ const EVENT_LOOP_LAG_BUFFER = 250; /** * Duration given to a builder to provide a `SignedExecutionPayloadBid` before the deadline - * is reached, only considering bids from the p2p network and the local build process. + * is reached, advertised on each bid request via the `X-Timeout-Ms` header. The p2p bid is + * selected after the same duration, only considering bids received up to that point. */ -export const BUILDER_BID_REQUEST_TIMEOUT_MS = 1000 + EVENT_LOOP_LAG_BUFFER; +export const BUILDER_BID_DEADLINE_MS = 500; + +/** Local timeout for bid requests, event loop lag must not discard bids that arrived in time */ +export const BUILDER_BID_REQUEST_TIMEOUT_MS = BUILDER_BID_DEADLINE_MS + EVENT_LOOP_LAG_BUFFER; export type BuilderApiClientOpts = { /** Timeout for builder api requests, bid requests always use `BUILDER_BID_REQUEST_TIMEOUT_MS` */ @@ -116,8 +120,10 @@ export class BuilderApiClient { requests.push({url, entry}); } - const bids = await Promise.all( - requests.map(async ({url, entry}): Promise => { + // Collected in arrival order so an earlier received bid wins ties during candidate ranking + const bids: BuilderApiBid[] = []; + await Promise.all( + requests.map(async ({url, entry}) => { this.metrics?.builderApi.bidRequests.inc(); try { const client = await this.getOrCreateClient(url, proposerPubkey, entry.auth); @@ -129,26 +135,25 @@ export class BuilderApiClient { proposerPubkey, requestAuth: entry.auth, dateMilliseconds: Date.now(), - timeoutMs: BUILDER_BID_REQUEST_TIMEOUT_MS, + timeoutMs: BUILDER_BID_DEADLINE_MS, }, {timeoutMs: BUILDER_BID_REQUEST_TIMEOUT_MS} ); const signedBid = res.value(); if (signedBid === undefined) { this.logger?.debug("No bid received from builder", {slot, builder: toPrintableUrl(url)}); - return null; + return; } this.metrics?.builderApi.bidsReceived.inc(); - return {url, entry, signedBid}; + bids.push({url, entry, signedBid}); } catch (e) { this.metrics?.builderApi.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); + return bids; } /** Forward a proposer's builder preferences to the builder at the given url */ diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 9691605f9693..b9183986611c 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -5,6 +5,7 @@ import {ForkName, MAX_EXECUTION_PAYMENT} from "@lodestar/params"; import {ssz} from "@lodestar/types"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; +import {BUILDER_BID_DEADLINE_MS} from "../../../../../src/execution/builder/apiClient.js"; import {validateBuilderApiExecutionPayloadBid} from "../../../../../src/execution/builder/validateBid.js"; import {SyncState} from "../../../../../src/sync/interface.js"; import {ApiTestModules, getApiTestModules} from "../../../../utils/api.js"; @@ -66,7 +67,8 @@ describe("api/validator - produceBlockV4", () => { api = getValidatorApi(defaultApiOptions, {...modules, config}); vi.spyOn(modules.chain.clock, "currentSlot", "get").mockReturnValue(slot); - vi.mocked(modules.chain.clock.msFromSlot).mockReturnValue(0); + // Move past the bid deadline so the p2p bid is selected without waiting + vi.mocked(modules.chain.clock.msFromSlot).mockReturnValue(BUILDER_BID_DEADLINE_MS); vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.Synced); modules.chain.getProposerHead.mockReturnValue(parentBlock); modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(zeroProtoBlock); @@ -142,8 +144,7 @@ describe("api/validator - produceBlockV4", () => { builderConfig: getBuilderConfig({builderBoostFactor: 0n}), }); - expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); - expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalled(); expect(block).toEqual(engineBlock); }); @@ -167,7 +168,7 @@ describe("api/validator - produceBlockV4", () => { builderConfig: getBuilderConfig({builderBoostFactor: 0n}), }); - expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalled(); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); expect(block).toEqual(bidBlock); }); @@ -474,7 +475,7 @@ describe("api/validator - produceBlockV4", () => { builderConfig: getBuilderConfig({minBid: 2n}), }); - expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalled(); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); expect(block).toEqual(engineBlock); }); @@ -577,4 +578,130 @@ describe("api/validator - produceBlockV4", () => { expect(modules.chain.produceBlock).not.toHaveBeenCalled(); }); + + type MatrixEntry = {value: number; executionPayment?: bigint; maxExecutionPayment?: bigint; boostFactor?: bigint}; + const selectionTestCases: { + id: string; + entries: MatrixEntry[]; + p2pValue: number | null; + minBid?: bigint; + builderBoostFactor?: bigint; + engineValueGwei: number; + /** Expected winner, the entry index of an api bid, the p2p bid or the local block */ + expected: number | "p2p" | "engine"; + }[] = [ + {id: "api bid outbids the p2p bid", entries: [{value: 2}], p2pValue: 1, engineValueGwei: 0, expected: 0}, + {id: "p2p bid outbids the api bid", entries: [{value: 2}], p2pValue: 3, engineValueGwei: 0, expected: "p2p"}, + {id: "tie prefers the api bid", entries: [{value: 2}], p2pValue: 2, engineValueGwei: 0, expected: 0}, + { + id: "execution payment is counted up to the entry cap", + entries: [{value: 1, executionPayment: 5n, maxExecutionPayment: 2n}], + p2pValue: 2, + engineValueGwei: 0, + expected: 0, + }, + { + id: "execution payment above a zero cap is not counted", + entries: [{value: 1, executionPayment: 5n, maxExecutionPayment: 0n}], + p2pValue: 2, + engineValueGwei: 0, + expected: "p2p", + }, + { + id: "zero entry boost factor loses against the p2p bid", + entries: [{value: 100, boostFactor: 0n}], + p2pValue: 1, + engineValueGwei: 0, + expected: "p2p", + }, + { + id: "max entry boost factor wins regardless of value", + entries: [{value: 0, boostFactor: maxBuilderBoostFactor}], + p2pValue: 5, + engineValueGwei: 0, + expected: 0, + }, + { + id: "higher boosted api bid wins between builders", + entries: [ + {value: 10, boostFactor: 100n}, + {value: 10, boostFactor: 200n}, + ], + p2pValue: null, + engineValueGwei: 0, + expected: 1, + }, + { + id: "p2p bid below min bid is discarded", + entries: [], + p2pValue: 1, + minBid: 2n, + engineValueGwei: 0, + expected: "engine", + }, + { + id: "local block beats a dampened api bid", + entries: [{value: 100, boostFactor: 50n}], + p2pValue: null, + engineValueGwei: 100, + expected: "engine", + }, + ]; + + for (const tc of selectionTestCases) { + it(`bid selection - ${tc.id}`, async () => { + const entries = tc.entries.map((_, i) => ({ + url: new TextEncoder().encode(`https://builder-${i}.example.com`), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: tc.entries[i].maxExecutionPayment ?? 0n, + minBid: 0n, + builderBoostFactor: tc.entries[i].boostFactor ?? 100n, + })); + const apiBids = tc.entries.map((e, i) => { + const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + signedBid.message.value = e.value; + signedBid.message.executionPayment = e.executionPayment ?? 0n; + signedBid.message.builderIndex = i; + return {url: `https://builder-${i}.example.com`, entry: entries[i], signedBid}; + }); + const p2pBid = tc.p2pValue !== null ? ssz.gloas.SignedExecutionPayloadBid.defaultValue() : null; + if (p2pBid !== null && tc.p2pValue !== null) { + p2pBid.message.value = tc.p2pValue; + p2pBid.message.builderIndex = 42; + } + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); + modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue(apiBids); + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: BigInt(tc.engineValueGwei) * 10n ** 9n, + consensusBlockValue: 0n, + })); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: { + minBid: tc.minBid ?? 0n, + builderBoostFactor: tc.builderBoostFactor ?? 100n, + builders: entries, + }, + }); + + if (tc.expected === "engine") { + expect(block).toEqual(engineBlock); + } else { + const expectedBid = tc.expected === "p2p" ? p2pBid : apiBids[tc.expected].signedBid; + expect(block).toEqual(bidBlock); + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: expectedBid})); + } + }); + } }); diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index dacf0e8a19ca..1332ad8f68c5 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -150,6 +150,32 @@ describe("execution/builder/apiClient", () => { expect(verifySignatureSets).toHaveBeenCalledTimes(2); expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); }); + + it("returns bids in arrival order", async () => { + const slot = 1; + const entryA = getBuilderEntry("https://builder-a.example.com", slot); + const entryB = getBuilderEntry("https://builder-b.example.com", slot); + const slowBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + slowBid.message.builderIndex = 0; + const fastBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + fastBid.message.builderIndex = 1; + + // The first entry responds after the second one + getExecutionPayloadBid + .mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve({value: () => slowBid}), 20))) + .mockImplementationOnce(async () => ({value: () => fastBid})); + + const client = new BuilderApiClient({}, config, bls); + const bids = await client.getExecutionPayloadBids( + [entryA, entryB], + slot, + new Uint8Array(32), + new Uint8Array(32), + new Uint8Array(48) + ); + + expect(bids.map((bid) => bid.signedBid.message.builderIndex)).toEqual([1, 0]); + }); }); function getBuilderEntry(url: string, slot: number): routes.validator.BuilderEntry { From 36d2b9faaaa7415526e7687baa3867985eab0846 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 14:10:22 +0100 Subject: [PATCH 50/67] forward publish request body bytes to the builder --- packages/api/src/beacon/routes/beacon/block.ts | 3 ++- .../src/api/impl/beacon/blocks/index.ts | 16 +++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index cafdb0cd158a..b993ac2d41cb 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -165,7 +165,8 @@ export type Endpoints = { broadcastValidation?: BroadcastValidation; /** * The url of the winning builder as returned by `produceBlockV4`. The beacon node forwards - * the signed block to this builder so it can release the payload without waiting for gossip. + * the signed block to this builder so it can help disseminate the block and learns timely + * that its bid won without waiting for gossip. */ builderUrl?: string; }, 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 5c9be6c0c72a..e9d4ef605201 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -105,7 +105,7 @@ export function getBeaconBlockApi({ >): ApplicationMethods { const publishBlockV2: ApplicationMethods["publishBlockV2"] = async ( {signedBlockContents, broadcastValidation, builderUrl}, - _context, + context, opts: PublishBlockOpts = {} ) => { const seenTimestampSec = Date.now() / 1000; @@ -364,14 +364,16 @@ export function getBeaconBlockApi({ chain.logger.info("Publishing block", valLogMeta); // Forward the signed block to the winning builder echoed by the validator client so it can - // release the payload without waiting for block gossip. Failures are non-fatal, the builder - // also sees the block on gossip. - if (builderUrl !== undefined && isForkPostGloas(fork)) { + // help disseminate the block and learns timely that its bid won without waiting for block + // gossip. Failures are non-fatal, the builder also sees the block on gossip. + if (isForkPostGloas(fork) && builderUrl !== undefined) { const gloasBlock = signedBlock as SignedBeaconBlock; if (gloasBlock.message.body.signedExecutionPayloadBid.message.builderIndex !== BUILDER_INDEX_SELF_BUILD) { - chain.builderApiClient.submitSignedBeaconBlock(builderUrl, {data: gloasBlock}).catch((e) => { - chain.logger.warn("Failed to submit signed block to builder", {...valLogMeta, builderUrl}, e); - }); + chain.builderApiClient + .submitSignedBeaconBlock(builderUrl, {data: gloasBlock, bytes: context?.sszBytes ?? undefined}) + .catch((e) => { + chain.logger.warn("Failed to submit signed block to builder", {...valLogMeta, builderUrl}, e); + }); } } From 6fc5f0615c0fa4eab4d67fae9101b273adae2608 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 14:11:34 +0100 Subject: [PATCH 51/67] address builder config review comments --- packages/api/src/keymanager/routes.ts | 9 ++++++- packages/api/test/unit/keymanager/testData.ts | 7 ++++- .../src/execution/builder/validateBid.ts | 10 +++++-- .../submitBuilderPreferences.test.ts | 27 +++++++++++++++++++ packages/validator/src/services/block.ts | 2 +- .../src/services/builderPreferences.ts | 2 +- .../validator/src/services/validatorStore.ts | 9 ++++--- .../src/util/externalSignerClient.ts | 1 - 8 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 4cdc04a83f26..226577b09bf8 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -105,19 +105,25 @@ export type BuilderBoostFactorData = ValueOf; /** One builder a validator public key may source blocks from */ export type BuilderEntryConfig = { + /** URL the bid requests for this entry are sent to */ url: string; - /** Opaque auth data hex string, defaults to the UTF-8 bytes of the builder url when omitted */ + /** Auth data hex string as agreed with the builder, derived from the UTF-8 bytes of the builder url when omitted */ authData?: string; /** Builder BLS pubkeys this entry accepts bids from, empty or omitted accepts any builder */ builderPubkeys?: string[]; + /** Cap in Gwei on the execution payment counted from this builder's bids */ maxExecutionPayment?: bigint; + /** Floor in Gwei on the counted total payment accepted from this builder's bids */ minBid?: bigint; + /** Percentage multiplier weighting this builder's bids during selection */ builderBoostFactor?: bigint; }; /** How a validator public key sources blocks from builders */ export type BuilderConfigData = { + /** Default for entries that do not set their own `minBid`, also applies to p2p bids */ minBid?: bigint; + /** Default for entries that do not set their own `builderBoostFactor`, also applies to p2p bids */ builderBoostFactor?: bigint; /** Omitted means use the validator client's builders, empty means request bids from none */ builders?: BuilderEntryConfig[]; @@ -530,6 +536,7 @@ export type Endpoints = { >; /** Remove the builder configuration for a validator public key, it then follows the validator client again */ deleteBuilderConfig: Endpoint< + // ⏎ "DELETE", {pubkey: PubkeyHex}, {params: {pubkey: string}}, diff --git a/packages/api/test/unit/keymanager/testData.ts b/packages/api/test/unit/keymanager/testData.ts index 0c493decf8d7..de025ea85b88 100644 --- a/packages/api/test/unit/keymanager/testData.ts +++ b/packages/api/test/unit/keymanager/testData.ts @@ -136,7 +136,12 @@ export const testData: GenericServerTestCases = { pubkey: pubkeyRand, builderConfig: { minBid: 0n, - builders: [{url: "https://builder.example.com", maxExecutionPayment: 0n}], + builders: [ + {url: "https://builder.example.com", maxExecutionPayment: 0n}, + {url: "https://builder.example.com", authData: "0x0123", builderBoostFactor: 200n}, + {url: "https://builder-b.example.com", builderPubkeys: [pubkeyRand], minBid: 1n}, + {url: "https://builder-c.example.com"}, + ], }, }, res: undefined, diff --git a/packages/beacon-node/src/execution/builder/validateBid.ts b/packages/beacon-node/src/execution/builder/validateBid.ts index f21c91629160..5d0322fc1289 100644 --- a/packages/beacon-node/src/execution/builder/validateBid.ts +++ b/packages/beacon-node/src/execution/builder/validateBid.ts @@ -57,7 +57,10 @@ export async function validateBuilderApiExecutionPayloadBid( const totalPayment = getBuilderBidTotalGwei(bid, entry.maxExecutionPayment); if (totalPayment < entry.minBid) { - throw Error(`Bid total payment=${totalPayment} is below minBid=${entry.minBid}`); + throw Error( + `Bid total payment=${totalPayment} (value=${bid.value} executionPayment=${bid.executionPayment}) ` + + `is below minBid=${entry.minBid}` + ); } const state = await chain.regen @@ -88,7 +91,10 @@ export async function validateBuilderApiExecutionPayloadBid( entry.builderPubkeys.length > 0 && !entry.builderPubkeys.some((pubkey) => byteArrayEquals(pubkey, builder.pubkey)) ) { - throw Error(`Bid builder pubkey=${toHex(builder.pubkey)} is not in the entry's builderPubkeys`); + throw Error( + `Bid builder pubkey=${toHex(builder.pubkey)} is not in the entry's ` + + `builderPubkeys=${entry.builderPubkeys.map(toHex).join(",")}` + ); } const blobKzgCommitmentsLen = bid.blobKzgCommitments.length; diff --git a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts index 3aba2f5c785f..c0dfff5612e1 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts @@ -47,6 +47,33 @@ describe("api/validator - submitBuilderPreferences", () => { ); }); + it("submits each entry to its builder and reports a failed submission by index", async () => { + const entryA = getEntry("https://builder-a.example.com"); + const entryB = getEntry("https://builder-b.example.com"); + modules.chain.builderApiClient.submitBuilderPreferences + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("builder unavailable")); + + let error: unknown; + try { + await api.submitBuilderPreferences({builderPreferences: [entryA, entryB]}); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(IndexedError); + expect((error as IndexedError).failures).toEqual([{index: 1, message: "builder unavailable"}]); + expect(modules.chain.builderApiClient.submitBuilderPreferences).toHaveBeenCalledTimes(2); + }); + + it("submits all entries without error when every builder accepts", async () => { + const entryA = getEntry("https://builder-a.example.com"); + const entryB = getEntry("https://builder-b.example.com"); + + await expect(api.submitBuilderPreferences({builderPreferences: [entryA, entryB]})).resolves.toBeUndefined(); + expect(modules.chain.builderApiClient.submitBuilderPreferences).toHaveBeenCalledTimes(2); + }); + it("rejects preferences not signed by the slot proposer", async () => { const entry = getEntry("https://builder.example.com"); entry.proposerPubkey[0] = 1; diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 71837436335d..81f85642fb1b 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -218,7 +218,7 @@ export class BlockProposingService { try { const auth = await this.validatorStore.getBuilderRequestAuth(pubkey, entry.authData, slot, slot); return { - url: new Uint8Array(Buffer.from(entry.url, "utf8")), + url: new TextEncoder().encode(entry.url), auth, builderPubkeys: entry.builderPubkeys, maxExecutionPayment: entry.maxExecutionPayment, diff --git a/packages/validator/src/services/builderPreferences.ts b/packages/validator/src/services/builderPreferences.ts index 74b46f95fde9..a8cd267dcb8d 100644 --- a/packages/validator/src/services/builderPreferences.ts +++ b/packages/validator/src/services/builderPreferences.ts @@ -98,7 +98,7 @@ export class BuilderPreferencesService { const auth = await this.validatorStore.getBuilderRequestAuth(duty.pubkey, entry.authData, duty.slot, slot); dutyEntries.push({ proposerPubkey: duty.pubkey, - url: new Uint8Array(Buffer.from(entry.url, "utf8")), + url: new TextEncoder().encode(entry.url), auth, maxExecutionPayment: entry.maxExecutionPayment, }); diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 5c756143b1a5..ee0ac451f6d7 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -327,7 +327,7 @@ export class ValidatorStore { let selection = validatorBuilder?.selection ?? this.defaultProposerConfig.builder.selection ?? defaultSelection; // The standard per-key builder config directly controls the post-Gloas boost. It takes - // precedence over Lodestar's legacy selection aliases when explicitly configured. + // precedence over the legacy selection aliases when explicitly configured. if (isPostGloas && validatorBuilder?.boostFactor !== undefined) { return {selection: routes.validator.BuilderSelection.MaxProfit, boostFactor: validatorBuilder.boostFactor}; } @@ -484,7 +484,7 @@ export class ValidatorStore { const builders = validatorData.builder?.builders ?? this.defaultProposerConfig.builder.builders ?? []; return builders.map((entry) => ({ url: entry.url, - authData: entry.authData !== undefined ? fromHex(entry.authData) : new Uint8Array(Buffer.from(entry.url)), + authData: entry.authData !== undefined ? fromHex(entry.authData) : new TextEncoder().encode(entry.url), builderPubkeys: (entry.builderPubkeys ?? []).map(fromHex), maxExecutionPayment: entry.maxExecutionPayment ?? keyMaxExecutionPayment, minBid: entry.minBid ?? keyMinBid, @@ -534,10 +534,11 @@ export class ValidatorStore { if (!isValidAsciiHttpUrl(entry.url)) { throw Error(`Invalid builder url: ${entry.url}`); } - const authData = entry.authData !== undefined ? toHex(fromHex(entry.authData)) : toHex(Buffer.from(entry.url)); + const authData = + entry.authData !== undefined ? toHex(fromHex(entry.authData)) : toHex(new TextEncoder().encode(entry.url)); const entryKey = `${entry.url}|${authData}`; if (seenEntries.has(entryKey)) { - throw Error(`Duplicate builder entry url=${entry.url}`); + throw Error(`Duplicate builder entry url=${entry.url} authData=${authData}`); } seenEntries.add(entryKey); for (const value of [entry.maxExecutionPayment, entry.minBid, entry.builderBoostFactor]) { diff --git a/packages/validator/src/util/externalSignerClient.ts b/packages/validator/src/util/externalSignerClient.ts index bc18bf69479c..9582540b4121 100644 --- a/packages/validator/src/util/externalSignerClient.ts +++ b/packages/validator/src/util/externalSignerClient.ts @@ -107,7 +107,6 @@ const requiresForkInfo: Record = { [SignableMessageType.EXECUTION_PAYLOAD_ENVELOPE]: true, [SignableMessageType.PAYLOAD_ATTESTATION]: true, [SignableMessageType.PROPOSER_PREFERENCES]: true, - // Signed with compute_domain(DOMAIN_BUILDER_REQUEST_AUTH) using genesis fork version and zero genesis validators root [SignableMessageType.BUILDER_REQUEST_AUTH]: false, }; From e9bc9d89c0d66442cb450409ca4f6897f6d01115 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 14:13:11 +0100 Subject: [PATCH 52/67] clarify gloas builder settings --- docs/pages/run/validator-management/proposer-config.md | 4 +++- docs/pages/run/validator-management/vc-configuration.md | 6 +++--- packages/cli/src/cmds/validator/options.ts | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/pages/run/validator-management/proposer-config.md b/docs/pages/run/validator-management/proposer-config.md index c1aac886dee1..b22152ad5a6a 100644 --- a/docs/pages/run/validator-management/proposer-config.md +++ b/docs/pages/run/validator-management/proposer-config.md @@ -26,6 +26,8 @@ proposer_config: gas_limit: "45000000" selection: "maxprofit" boost_factor: "100" + min_bid: "10000000" + max_execution_payment: "0" default_config: graffiti: "default graffiti" strict_fee_recipient_check: true @@ -40,7 +42,7 @@ Starting with Gloas, the builder section additionally supports `min_bid` (floor Post-Gloas, an explicitly configured per-validator `boost_factor` takes precedence over `selection`. -The builder section also supports a `builders` list with the same per-builder entries as the keymanager builders API. Each entry has a required `url` and optional `auth_data`, `builder_pubkeys`, `max_execution_payment`, `min_bid` and `builder_boost_factor`. Multiple entries may share a `url` only if they have distinct `auth_data`. Per-key entries replace the builders the validator client is configured with; setting both `--builder.urls` and `builders` in `default_config` is an error. +The builder section also supports a `builders` list with the same per-builder entries as the keymanager builder config API. Each entry has a required `url` and optional `auth_data`, `builder_pubkeys`, `max_execution_payment`, `min_bid` and `builder_boost_factor`. Multiple entries may share a `url` only if they have distinct `auth_data`. Per-key entries replace the builders the validator client is configured with; setting both `--builder.urls` and `builders` in `default_config` is an error. ```yaml builder: diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 57c44860dc4b..e187d76c526e 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -112,14 +112,14 @@ Example 3: Setting a `--builder.boostFactor=100` is the same as signaling `--bui ### Configure external builders (Gloas) -Starting with Gloas, external builders are configured on the validator client and the beacon node requests bids on its behalf. Use [`--builder.urls`](./validator-cli.md#--builderurls) to name the builders to request bids from. Viable bids received over p2p are considered alongside them unless the builder circuit breaker is active, governed by the same selection settings. +Starting with Gloas, external builders are configured on the validator client and the beacon node requests bids on its behalf. Use [`--builder.urls`](./validator-cli.md#--builderurls) to name the builders to request bids from. Viable bids received over p2p are considered alongside them unless the builder circuit breaker is active, governed by the same selection settings. These are additional settings, the pre-Gloas builder flags still apply. Every bid request is authenticated with data the builder expects, by default the UTF-8 bytes of the builder URL exactly as configured. If a builder requires different auth data agreed out of band, append it as a hex fragment to its URL, e.g. `--builder.urls https://builder.example.com#0x0123`. The fragment is stripped before the URL is used and never sent to the builder. Auth data that must stay secret is better kept in the [proposer configuration file](./proposer-config.md), as command line arguments are visible to other processes. -- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum counted total payment in Gwei accepted from a builder bid. The total is `value + min(execution_payment, max_execution_payment)`, capped at max uint64. Bids below the floor are rejected. +- [`--builder.minBid`](./validator-cli.md#--builderminbid): minimum counted total payment in Gwei accepted from a builder bid. The total is `value + min(execution_payment, max_execution_payment)`, capped at max uint64. Bids below the floor are discarded. - [`--builder.maxExecutionPayment`](./validator-cli.md#--buildermaxexecutionpayment): maximum execution layer payment in Gwei counted from a builder bid, any payment above it adds nothing to the bid when comparing it with other bids. The default of `0` only counts trustless payments backed by the builder's staked collateral. An execution layer payment is only a promise by the builder to pay as part of the block, so values above `0` require the explicit [`--allowDangerousTrustedPayments`](./validator-cli.md#--allowdangeroustrustedpayments) opt-in. -Builders can also be configured per validator key with per-builder overrides via the [Set Builder Configuration keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builder%20Config) or the [proposer configuration file](./proposer-config.md). +Builders can also be configured per validator key with per-builder overrides via the [builder config keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builder%20Config) or the [proposer configuration file](./proposer-config.md). ### Submit a validator deposit diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 462a1d013a6a..406dd4bed879 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -283,7 +283,7 @@ export const validatorOptions: CliCommandOptions = { "builder.minBid": { type: "string", description: - "Minimum counted total payment in Gwei accepted from a builder bid. The total is the bid value plus its execution payment up to the configured cap, capped at max uint64. Only used post-Gloas", + "Minimum counted total payment in Gwei accepted from a builder bid. The total is the bid value plus its execution payment up to the configured cap. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMinBid}`, group: "builder", }, @@ -300,7 +300,7 @@ export const validatorOptions: CliCommandOptions = { "builder.maxExecutionPayment": { type: "string", description: - "Maximum execution layer payment in Gwei counted toward a builder bid. A value of 0 means only trustless payments via the builder's staked collateral count toward the bid. Values above 0 require --allowDangerousTrustedPayments. Only used post-Gloas", + "Maximum execution layer payment in Gwei counted toward a builder bid. A value of 0 means only trustless payments via the builder's staked collateral count toward the bid. Values above 0 require `--allowDangerousTrustedPayments`. Only used post-Gloas", defaultDescription: `${defaultOptions.builderMaxExecutionPayment}`, group: "builder", }, From 0343ca64e6148d32e6fea9916b6daa83c1567af8 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 17:54:58 +0100 Subject: [PATCH 53/67] consolidate upfront builder bid condition --- .../beacon-node/src/api/impl/validator/index.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 68c116cd2b9a..612a0fea9cf2 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -956,9 +956,12 @@ export function getValidatorApi( } } - const hasEarlyP2pBid = - !circuitBreakerActive && - chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex) !== null; + // A builder bid is expected if builders are configured or a p2p bid was already received. + // Used by metrics and the censorship override which cannot wait until the bid deadline + const builderBidExpected = + builderConfig.builders.length > 0 || + (!circuitBreakerActive && + chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex) !== null); // Select the p2p bid once builders had time to bid up, matching the deadline advertised // on builder API bid requests, unless the circuit breaker is active @@ -1050,7 +1053,7 @@ export function getValidatorApi( }; metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); - if (hasEarlyP2pBid || builderConfig.builders.length > 0) { + if (builderBidExpected) { metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); } @@ -1122,11 +1125,7 @@ export function getValidatorApi( }; // handle shouldOverrideBuilder separately - if ( - engineResult.status === "fulfilled" && - engineResult.value.shouldOverrideBuilder && - (hasEarlyP2pBid || builderConfig.builders.length > 0) - ) { + if (engineResult.status === "fulfilled" && engineResult.value.shouldOverrideBuilder && builderBidExpected) { source = ProducedBlockSource.engine; bestResult = engineResult; metrics?.blockProductionSelectionResults.inc({ From 45d0661e9e302f26b14b8bf178bee15079601cad Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 18:15:46 +0100 Subject: [PATCH 54/67] track when builder api bids are received --- .../src/api/impl/validator/index.ts | 90 ++++++++++++------- packages/beacon-node/src/chain/chain.ts | 2 +- .../src/execution/builder/apiClient.ts | 6 +- .../api/impl/validator/produceBlockV4.test.ts | 41 ++++++--- .../unit/execution/builder/apiClient.test.ts | 26 +++--- 5 files changed, 110 insertions(+), 55 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 612a0fea9cf2..49337e9dc016 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -126,23 +126,40 @@ type BidCandidate = { totalGwei: bigint; boostFactor: bigint; url?: string; + /** Time in milliseconds from the slot start when the bid was received, unset for the p2p bid */ + receivedMs?: number; }; /** * Return the best bid by boosted total payment. A candidate with the max boost factor is - * preferred over any other regardless of value, ties keep the earlier candidate. + * preferred over any other regardless of value, ties prefer a builder api bid over the + * p2p bid, and the earlier received bid between builder api bids. */ function selectBestBid(candidates: BidCandidate[]): BidCandidate | null { const boostedValue = ({totalGwei, boostFactor}: BidCandidate): bigint => boostFactor * totalGwei; let best: BidCandidate | null = null; for (const candidate of candidates) { - const candidateIsMaxBoost = candidate.boostFactor === MAX_BUILDER_BOOST_FACTOR; - const bestIsMaxBoost = best?.boostFactor === MAX_BUILDER_BOOST_FACTOR; + if (best === null) { + best = candidate; + continue; + } // Preserve max boost preference before comparing bid values - if ( - best === null || - (candidateIsMaxBoost && !bestIsMaxBoost) || - (candidateIsMaxBoost === bestIsMaxBoost && boostedValue(candidate) > boostedValue(best)) + const candidateIsMaxBoost = candidate.boostFactor === MAX_BUILDER_BOOST_FACTOR; + const bestIsMaxBoost = best.boostFactor === MAX_BUILDER_BOOST_FACTOR; + if (candidateIsMaxBoost !== bestIsMaxBoost) { + if (candidateIsMaxBoost) { + best = candidate; + } + continue; + } + const candidateValue = boostedValue(candidate); + const bestValue = boostedValue(best); + if (candidateValue > bestValue) { + best = candidate; + } else if ( + candidateValue === bestValue && + // A tie prefers a builder api bid over the p2p bid, and the earlier received bid otherwise + (best.receivedMs === undefined || (candidate.receivedMs !== undefined && candidate.receivedMs < best.receivedMs)) ) { best = candidate; } @@ -983,34 +1000,38 @@ export function getValidatorApi( }); // Candidates are ranked by their boosted counted total payment, the p2p bid is governed - // by the top-level factors and each builder API bid by its own entry. Ties keep the - // earlier candidate: builder API bids are ordered by arrival before the p2p bid, so the - // earliest received builder API bid wins a tie and the signed block can be routed back - // to its builder directly. + // by the top-level factors and each builder API bid by its own entry. Ties prefer the + // earliest received builder API bid, so the signed block can be routed back to its + // builder directly. const bestBidPromise: Promise = (async () => { const [builderApiBids, p2pBid] = await Promise.all([builderApiBidsPromise, p2pBidPromise]); - const candidates: BidCandidate[] = []; - for (const {url, entry, signedBid} of builderApiBids) { - try { - await validateBuilderApiExecutionPayloadBid(chain, signedBid, { - slot, - parentBlock, - parentBlockHash: bidParentBlockHash, - parentBlockRoot: parentBlockRootHex, - entry, - }); - candidates.push({ - signedBid, - totalGwei: getBuilderBidTotalGwei(signedBid.message, entry.maxExecutionPayment), - boostFactor: entry.builderBoostFactor, - url, - }); - } catch (e) { - metrics?.builderApi.bidsDiscarded.inc(); - logger.warn("Ignoring invalid builder API bid", {slot, builder: toPrintableUrl(url)}, e as Error); - } - } + const candidates = ( + await Promise.all( + builderApiBids.map(async ({url, entry, signedBid, receivedMs}): Promise => { + try { + await validateBuilderApiExecutionPayloadBid(chain, signedBid, { + slot, + parentBlock, + parentBlockHash: bidParentBlockHash, + parentBlockRoot: parentBlockRootHex, + entry, + }); + return { + signedBid, + totalGwei: getBuilderBidTotalGwei(signedBid.message, entry.maxExecutionPayment), + boostFactor: entry.builderBoostFactor, + url, + receivedMs, + }; + } catch (e) { + metrics?.builderApi.bidsDiscarded.inc(); + logger.warn("Ignoring invalid builder API bid", {slot, builder: toPrintableUrl(url)}, e as Error); + return null; + } + }) + ) + ).filter((candidate): candidate is BidCandidate => candidate !== null); if (p2pBid !== null) { candidates.push({ @@ -1026,7 +1047,9 @@ export function getValidatorApi( slot, candidates: candidates .map( - (candidate) => `${candidate.url ?? "p2p"}:total=${candidate.totalGwei}:boost=${candidate.boostFactor}` + (candidate) => + `${candidate.url ?? "p2p"}:total=${candidate.totalGwei}:boost=${candidate.boostFactor}` + + (candidate.receivedMs !== undefined ? `:received=${candidate.receivedMs}ms` : "") ) .join(","), bidSource: best?.url ?? "p2p", @@ -1120,6 +1143,7 @@ export function getValidatorApi( bidBoostFactor: bestBid.boostFactor, builderIndex: bestBid.signedBid.message.builderIndex, bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), + ...(bestBid.receivedMs !== undefined ? {bidReceivedMs: bestBid.receivedMs} : {}), } : {}), }; diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index e3f4a82c6585..fabcfbd0b8c8 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -452,7 +452,7 @@ export class BeaconChain implements IBeaconChain { {forkChoice, logger, metrics} ); - this.builderApiClient = new BuilderApiClient(builderApiClientOpts ?? {}, config, bls, metrics, logger); + this.builderApiClient = new BuilderApiClient(builderApiClientOpts ?? {}, config, clock, bls, metrics, logger); this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({ config, diff --git a/packages/beacon-node/src/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts index d238d65f4b12..643e6cc93ac3 100644 --- a/packages/beacon-node/src/execution/builder/apiClient.ts +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -13,6 +13,7 @@ import {BLSPubkey, Root, SignedBeaconBlock, Slot, WithOptionalBytes, gloas, ssz} import {isValidAsciiHttpUrl, toHex, toPrintableUrl} from "@lodestar/utils"; import type {IBlsVerifier} from "../../chain/bls/index.js"; import {Metrics} from "../../metrics/metrics.js"; +import {IClock} from "../../util/clock.js"; /** * Additional duration to account for potential event loop lag which causes @@ -57,6 +58,8 @@ export type BuilderApiBid = { url: BuilderUrl; entry: routes.validator.BuilderEntry; signedBid: gloas.SignedExecutionPayloadBid; + /** Time in milliseconds from the slot start when the bid was received */ + receivedMs: number; }; /** @@ -71,6 +74,7 @@ export class BuilderApiClient { constructor( private readonly opts: BuilderApiClientOpts, private readonly config: ChainForkConfig, + private readonly clock: IClock, private readonly bls: IBlsVerifier, private readonly metrics: Metrics | null = null, private readonly logger?: Logger @@ -145,7 +149,7 @@ export class BuilderApiClient { return; } this.metrics?.builderApi.bidsReceived.inc(); - bids.push({url, entry, signedBid}); + bids.push({url, entry, signedBid, receivedMs: this.clock.msFromSlot(slot)}); } catch (e) { this.metrics?.builderApi.bidRequestErrors.inc(); this.logger?.warn("Failed to get bid from builder", {slot, builder: toPrintableUrl(url)}, e as Error); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index b9183986611c..eb6cbf01a579 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -222,7 +222,7 @@ describe("api/validator - produceBlockV4", () => { modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ - {url: builderUrl, entry, signedBid: apiBid}, + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, ]); const {data: block} = await api.produceBlockV4({ @@ -264,7 +264,7 @@ describe("api/validator - produceBlockV4", () => { modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ - {url: builderUrl, entry, signedBid: apiBid}, + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, ]); const {data: block} = await api.produceBlockV4({ @@ -304,8 +304,8 @@ describe("api/validator - produceBlockV4", () => { modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ - {url: firstUrl, entry: firstEntry, signedBid: firstBid}, - {url: secondUrl, entry: secondEntry, signedBid: secondBid}, + {url: firstUrl, entry: firstEntry, signedBid: firstBid, receivedMs: 0}, + {url: secondUrl, entry: secondEntry, signedBid: secondBid, receivedMs: 0}, ]); await api.produceBlockV4({ @@ -340,7 +340,7 @@ describe("api/validator - produceBlockV4", () => { modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ - {url: builderUrl, entry, signedBid: apiBid}, + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, ]); const {data: block} = await api.produceBlockV4({ @@ -376,7 +376,7 @@ describe("api/validator - produceBlockV4", () => { modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ - {url: builderUrl, entry, signedBid: apiBid}, + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, ]); await api.produceBlockV4({ @@ -411,7 +411,7 @@ describe("api/validator - produceBlockV4", () => { modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ - {url: builderUrl, entry, signedBid: apiBid}, + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, ]); await api.produceBlockV4({ @@ -444,7 +444,7 @@ describe("api/validator - produceBlockV4", () => { modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ - {url: builderUrl, entry, signedBid: apiBid}, + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, ]); vi.mocked(validateBuilderApiExecutionPayloadBid).mockRejectedValueOnce(new Error("Invalid bid")); @@ -579,7 +579,13 @@ describe("api/validator - produceBlockV4", () => { expect(modules.chain.produceBlock).not.toHaveBeenCalled(); }); - type MatrixEntry = {value: number; executionPayment?: bigint; maxExecutionPayment?: bigint; boostFactor?: bigint}; + type MatrixEntry = { + value: number; + executionPayment?: bigint; + maxExecutionPayment?: bigint; + boostFactor?: bigint; + receivedMs?: number; + }; const selectionTestCases: { id: string; entries: MatrixEntry[]; @@ -593,6 +599,16 @@ describe("api/validator - produceBlockV4", () => { {id: "api bid outbids the p2p bid", entries: [{value: 2}], p2pValue: 1, engineValueGwei: 0, expected: 0}, {id: "p2p bid outbids the api bid", entries: [{value: 2}], p2pValue: 3, engineValueGwei: 0, expected: "p2p"}, {id: "tie prefers the api bid", entries: [{value: 2}], p2pValue: 2, engineValueGwei: 0, expected: 0}, + { + id: "tie prefers the earlier received api bid", + entries: [ + {value: 2, receivedMs: 2000}, + {value: 2, receivedMs: 1500}, + ], + p2pValue: null, + engineValueGwei: 0, + expected: 1, + }, { id: "execution payment is counted up to the entry cap", entries: [{value: 1, executionPayment: 5n, maxExecutionPayment: 2n}], @@ -663,7 +679,12 @@ describe("api/validator - produceBlockV4", () => { signedBid.message.value = e.value; signedBid.message.executionPayment = e.executionPayment ?? 0n; signedBid.message.builderIndex = i; - return {url: `https://builder-${i}.example.com`, entry: entries[i], signedBid}; + return { + url: `https://builder-${i}.example.com`, + entry: entries[i], + signedBid, + receivedMs: e.receivedMs ?? 1000 + i, + }; }); const p2pBid = tc.p2pValue !== null ? ssz.gloas.SignedExecutionPayloadBid.defaultValue() : null; if (p2pBid !== null && tc.p2pValue !== null) { diff --git a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts index 1332ad8f68c5..ac8b89af82d6 100644 --- a/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -4,6 +4,7 @@ import {config} from "@lodestar/config/default"; import {ssz} from "@lodestar/types"; import type {IBlsVerifier} from "../../../../src/chain/bls/index.js"; import {BuilderApiClient} from "../../../../src/execution/builder/apiClient.js"; +import {IClock} from "../../../../src/util/clock.js"; import {getMockedLogger} from "../../../mocks/loggerMock.js"; const {getExecutionPayloadBid, submitBuilderPreferences, submitSignedBeaconBlock, verifySignatureSets} = vi.hoisted( @@ -25,6 +26,7 @@ const bls = { close: vi.fn(), canAcceptWork: vi.fn(), } satisfies IBlsVerifier; +const clock = {msFromSlot: () => 250} as unknown as IClock; describe("execution/builder/apiClient", () => { afterEach(() => { @@ -39,7 +41,7 @@ describe("execution/builder/apiClient", () => { getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); const logger = getMockedLogger(); - const client = new BuilderApiClient({}, config, bls, null, logger); + const client = new BuilderApiClient({}, config, clock, bls, null, logger); const bids = await client.getExecutionPayloadBids( [getBuilderEntry(invalidUrl, slot), validEntry], slot, @@ -48,7 +50,9 @@ describe("execution/builder/apiClient", () => { new Uint8Array(48) ); - expect(bids).toEqual([{url: "https://builder.example.com", entry: validEntry, signedBid}]); + expect(bids).toEqual([ + {url: "https://builder.example.com", entry: validEntry, signedBid, receivedMs: expect.any(Number)}, + ]); expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); expect(logger.warn).toHaveBeenCalledWith("Ignoring builder entry with invalid url", {slot, url: invalidUrl}); }); @@ -62,7 +66,7 @@ describe("execution/builder/apiClient", () => { const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); - const client = new BuilderApiClient({}, config, bls); + const client = new BuilderApiClient({}, config, clock, bls); const bids = await client.getExecutionPayloadBids( [invalidUtf8Entry, nonAsciiEntry, validEntry], slot, @@ -71,7 +75,9 @@ describe("execution/builder/apiClient", () => { new Uint8Array(48) ); - expect(bids).toEqual([{url: "https://builder.example.com", entry: validEntry, signedBid}]); + expect(bids).toEqual([ + {url: "https://builder.example.com", entry: validEntry, signedBid, receivedMs: expect.any(Number)}, + ]); expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); }); @@ -79,7 +85,7 @@ describe("execution/builder/apiClient", () => { const url = "https://builder.example.com"; const signedBlock = {data: ssz.gloas.SignedBeaconBlock.defaultValue()}; const logger = getMockedLogger(); - const client = new BuilderApiClient({}, config, bls, null, logger); + const client = new BuilderApiClient({}, config, clock, bls, null, logger); await client.submitSignedBeaconBlock(url, signedBlock); @@ -96,7 +102,7 @@ describe("execution/builder/apiClient", () => { submitBuilderPreferences.mockResolvedValue({assertOk: vi.fn()}); submitSignedBeaconBlock.mockResolvedValue({assertOk: vi.fn()}); - const client = new BuilderApiClient({}, config, bls); + const client = new BuilderApiClient({}, config, clock, bls); await client.submitBuilderPreferences(url, proposerPubkey, preferences); await client.submitSignedBeaconBlock(url, signedBlock); @@ -116,7 +122,7 @@ describe("execution/builder/apiClient", () => { const entry = getBuilderEntry(url, slot); entry.auth.message.data = new Uint8Array(); - const client = new BuilderApiClient({}, config, bls); + const client = new BuilderApiClient({}, config, clock, bls); await client.submitBuilderPreferences(url, proposerPubkey, preferences); const bids = await client.getExecutionPayloadBids( [entry], @@ -139,13 +145,13 @@ describe("execution/builder/apiClient", () => { verifySignatureSets.mockResolvedValueOnce(false).mockResolvedValueOnce(true); getExecutionPayloadBid.mockResolvedValue({value: () => signedBid}); - const client = new BuilderApiClient({}, config, bls); + const client = new BuilderApiClient({}, config, clock, bls); expect( await client.getExecutionPayloadBids([entry], slot, new Uint8Array(32), new Uint8Array(32), proposerPubkey) ).toEqual([]); expect( await client.getExecutionPayloadBids([entry], slot, new Uint8Array(32), new Uint8Array(32), proposerPubkey) - ).toEqual([{url: "https://builder.example.com", entry, signedBid}]); + ).toEqual([{url: "https://builder.example.com", entry, signedBid, receivedMs: expect.any(Number)}]); expect(verifySignatureSets).toHaveBeenCalledTimes(2); expect(getExecutionPayloadBid).toHaveBeenCalledOnce(); @@ -165,7 +171,7 @@ describe("execution/builder/apiClient", () => { .mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve({value: () => slowBid}), 20))) .mockImplementationOnce(async () => ({value: () => fastBid})); - const client = new BuilderApiClient({}, config, bls); + const client = new BuilderApiClient({}, config, clock, bls); const bids = await client.getExecutionPayloadBids( [entryA, entryB], slot, From 17f418101f0ddf73c3cc7fa5c24150a69e496619 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 18:31:19 +0100 Subject: [PATCH 55/67] track when p2p bids are received --- .../src/api/impl/beacon/blocks/index.ts | 5 ++- .../src/api/impl/validator/index.ts | 23 +++++------ .../chain/opPools/executionPayloadBidPool.ts | 20 ++++++---- .../beacon-node/src/chain/opPools/index.ts | 2 +- .../chain/validation/executionPayloadBid.ts | 4 +- .../src/network/processor/gossipHandlers.ts | 2 +- .../api/impl/validator/produceBlockV4.test.ts | 38 ++++++++++--------- 7 files changed, 54 insertions(+), 40 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index e9d4ef605201..7548db29d700 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -1067,7 +1067,10 @@ export function getBeaconBlockApi({ metrics?.gossipExecutionPayloadBid.elapsedTimeTillReceived.observe({source: OpSource.api}, elapsedSec); try { - const insertOutcome = chain.executionPayloadBidPool.add(signedExecutionPayloadBid); + const insertOutcome = chain.executionPayloadBidPool.add( + signedExecutionPayloadBid, + Math.round(elapsedSec * 1000) + ); metrics?.opPool.executionPayloadBidPool.apiInsertOutcome.inc({insertOutcome}); } catch (e) { chain.logger.error("Error adding to executionPayloadBid pool", {}, e as Error); diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 49337e9dc016..c45af66723ba 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -75,6 +75,7 @@ import { SyncCommitteeErrorCode, } from "../../../chain/errors/index.js"; import {ChainEvent, CommonBlockBody} from "../../../chain/index.js"; +import {PooledExecutionPayloadBid} from "../../../chain/opPools/index.js"; 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"; @@ -126,8 +127,8 @@ type BidCandidate = { totalGwei: bigint; boostFactor: bigint; url?: string; - /** Time in milliseconds from the slot start when the bid was received, unset for the p2p bid */ - receivedMs?: number; + /** Time in milliseconds from the slot start when the bid was received */ + receivedMs: number; }; /** @@ -159,7 +160,7 @@ function selectBestBid(candidates: BidCandidate[]): BidCandidate | null { } else if ( candidateValue === bestValue && // A tie prefers a builder api bid over the p2p bid, and the earlier received bid otherwise - (best.receivedMs === undefined || (candidate.receivedMs !== undefined && candidate.receivedMs < best.receivedMs)) + (best.url === undefined || (candidate.url !== undefined && candidate.receivedMs < best.receivedMs)) ) { best = candidate; } @@ -982,16 +983,16 @@ export function getValidatorApi( // Select the p2p bid once builders had time to bid up, matching the deadline advertised // on builder API bid requests, unless the circuit breaker is active - const p2pBidPromise: Promise = circuitBreakerActive + const p2pBidPromise: Promise = circuitBreakerActive ? Promise.resolve(null) : sleep(Math.max(0, BUILDER_BID_DEADLINE_MS - chain.clock.msFromSlot(slot))).then(() => { const p2pBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); // Discard p2p bids below the proposer's configured floor on the total payment. // A p2p bid's total is just its value since gossip validation enforces executionPayment=0. - if (p2pBid !== null && BigInt(p2pBid.message.value) < builderConfig.minBid) { + if (p2pBid !== null && BigInt(p2pBid.signedBid.message.value) < builderConfig.minBid) { logger.info("Ignoring p2p bid below min bid", { slot, - bidValue: p2pBid.message.value, + bidValue: p2pBid.signedBid.message.value, minBid: builderConfig.minBid, }); return null; @@ -1035,9 +1036,10 @@ export function getValidatorApi( if (p2pBid !== null) { candidates.push({ - signedBid: p2pBid, - totalGwei: BigInt(p2pBid.message.value), + signedBid: p2pBid.signedBid, + totalGwei: BigInt(p2pBid.signedBid.message.value), boostFactor: builderConfig.builderBoostFactor, + receivedMs: p2pBid.receivedMs, }); } @@ -1048,8 +1050,7 @@ export function getValidatorApi( candidates: candidates .map( (candidate) => - `${candidate.url ?? "p2p"}:total=${candidate.totalGwei}:boost=${candidate.boostFactor}` + - (candidate.receivedMs !== undefined ? `:received=${candidate.receivedMs}ms` : "") + `${candidate.url ?? "p2p"}:total=${candidate.totalGwei}:boost=${candidate.boostFactor}:received=${candidate.receivedMs}ms` ) .join(","), bidSource: best?.url ?? "p2p", @@ -1143,7 +1144,7 @@ export function getValidatorApi( bidBoostFactor: bestBid.boostFactor, builderIndex: bestBid.signedBid.message.builderIndex, bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), - ...(bestBid.receivedMs !== undefined ? {bidReceivedMs: bestBid.receivedMs} : {}), + bidReceivedMs: bestBid.receivedMs, } : {}), }; diff --git a/packages/beacon-node/src/chain/opPools/executionPayloadBidPool.ts b/packages/beacon-node/src/chain/opPools/executionPayloadBidPool.ts index 14df0ae337c0..ed316252e9e1 100644 --- a/packages/beacon-node/src/chain/opPools/executionPayloadBidPool.ts +++ b/packages/beacon-node/src/chain/opPools/executionPayloadBidPool.ts @@ -11,14 +11,20 @@ const SLOTS_RETAINED = 2; type BlockRootHex = string; type BlockHashHex = string; +export type PooledExecutionPayloadBid = { + signedBid: gloas.SignedExecutionPayloadBid; + /** Time in milliseconds from the slot start when the bid was received */ + receivedMs: number; +}; + /** * Store the best signed execution payload bid per slot / (parent block root, parent block hash). */ export class ExecutionPayloadBidPool { private readonly bidByParentHashByParentRootBySlot = new MapDef< Slot, - MapDef> - >(() => new MapDef>(() => new Map())); + MapDef> + >(() => new MapDef>(() => new Map())); private lowestPermissibleSlot = 0; get size(): number { @@ -31,7 +37,7 @@ export class ExecutionPayloadBidPool { return count; } - add(bid: gloas.SignedExecutionPayloadBid): InsertOutcome { + add(bid: gloas.SignedExecutionPayloadBid, receivedMs: number): InsertOutcome { const {slot, parentBlockRoot, parentBlockHash, value} = bid.message; const lowestPermissibleSlot = this.lowestPermissibleSlot; @@ -45,16 +51,16 @@ export class ExecutionPayloadBidPool { const existing = bidByParentHash.get(parentHashHex); if (existing) { - const existingValue = existing.message.value; + const existingValue = existing.signedBid.message.value; const newValue = value; if (newValue > existingValue) { - bidByParentHash.set(parentHashHex, bid); + bidByParentHash.set(parentHashHex, {signedBid: bid, receivedMs}); return InsertOutcome.NewData; } return newValue === existingValue ? InsertOutcome.AlreadyKnown : InsertOutcome.NotBetterThan; } - bidByParentHash.set(parentHashHex, bid); + bidByParentHash.set(parentHashHex, {signedBid: bid, receivedMs}); return InsertOutcome.NewData; } @@ -66,7 +72,7 @@ export class ExecutionPayloadBidPool { slot: Slot, parentBlockHash: BlockHashHex | null, parentBlockRoot: BlockRootHex - ): gloas.SignedExecutionPayloadBid | null { + ): PooledExecutionPayloadBid | null { if (parentBlockHash === null) return null; const bidByParentHash = this.bidByParentHashByParentRootBySlot.get(slot)?.get(parentBlockRoot); return bidByParentHash?.get(parentBlockHash) ?? null; diff --git a/packages/beacon-node/src/chain/opPools/index.ts b/packages/beacon-node/src/chain/opPools/index.ts index ae7132e6bb7b..8cdd50899f82 100644 --- a/packages/beacon-node/src/chain/opPools/index.ts +++ b/packages/beacon-node/src/chain/opPools/index.ts @@ -1,7 +1,7 @@ export {AggregatedAttestationPool} from "./aggregatedAttestationPool.js"; export {AttestationPool} from "./attestationPool.js"; export {DeferredVoluntaryExitPool} from "./deferredVoluntaryExitPool.js"; -export {ExecutionPayloadBidPool} from "./executionPayloadBidPool.js"; +export {ExecutionPayloadBidPool, type PooledExecutionPayloadBid} from "./executionPayloadBidPool.js"; export {OpPool} from "./opPool.js"; export {PayloadAttestationPool} from "./payloadAttestationPool.js"; export {ProposerPreferencesPool} from "./proposerPreferencesPool.js"; diff --git a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index f17f33929286..4f25150c259d 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -313,11 +313,11 @@ async function validateExecutionPayloadBid( // increment, see https://github.com/ethereum/consensus-specs/pull/4831. This prevents spam // from builders submitting numerous bids with minimal value increments. const bestBid = chain.executionPayloadBidPool.getBestBid(bid.slot, bidParentBlockHash, bidParentBlockRoot); - if (bestBid !== null && bid.value < getMinBidValue(bestBid.message.value)) { + if (bestBid !== null && bid.value < getMinBidValue(bestBid.signedBid.message.value)) { throw new ExecutionPayloadBidError(GossipAction.IGNORE, { code: ExecutionPayloadBidErrorCode.BID_TOO_LOW, bidValue: bid.value, - currentHighestBid: bestBid.message.value, + currentHighestBid: bestBid.signedBid.message.value, }); } // [IGNORE] `bid.value` is less or equal than the builder's excess balance -- diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index efd660b9ef55..9127188aff71 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1306,7 +1306,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand // Handle valid payload bid by storing in a bid pool try { - const insertOutcome = chain.executionPayloadBidPool.add(executionPayloadBid); + const insertOutcome = chain.executionPayloadBidPool.add(executionPayloadBid, Math.round(elapsedSec * 1000)); metrics?.opPool.executionPayloadBidPool.gossipInsertOutcome.inc({insertOutcome}); } catch (e) { logger.error("Error adding to executionPayloadBid pool", {}, e as Error); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index eb6cbf01a579..7ba9a72b03c9 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -2,7 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, MAX_EXECUTION_PAYMENT} from "@lodestar/params"; -import {ssz} from "@lodestar/types"; +import {gloas, ssz} from "@lodestar/types"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; import {BUILDER_BID_DEADLINE_MS} from "../../../../../src/execution/builder/apiClient.js"; @@ -86,7 +86,7 @@ describe("api/validator - produceBlockV4", () => { it("picks builder bid block when bid value is higher", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); const {data: block, meta} = await api.produceBlockV4({ slot, @@ -109,7 +109,7 @@ describe("api/validator - produceBlockV4", () => { it("picks local block when local payload value is higher", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); // Local payload value (2 gwei) exceeds the bid value (1 gwei) modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ block: attrs.builderBid !== undefined ? bidBlock : engineBlock, @@ -133,7 +133,7 @@ describe("api/validator - produceBlockV4", () => { it("prefers the local payload with a zero builder boost factor", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); const {data: block} = await api.produceBlockV4({ slot, @@ -150,7 +150,7 @@ describe("api/validator - produceBlockV4", () => { it("uses a builder bid as fallback when local production fails with a zero boost factor", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => { if (attrs.builderBid === undefined) { throw new Error("Local block production failed"); @@ -177,7 +177,7 @@ describe("api/validator - produceBlockV4", () => { const builderBlock = ssz.gloas.BeaconBlock.defaultValue(); modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); modules.chain.produceBlock.mockImplementation( async (attrs: {builderBid?: unknown; strictFeeRecipientCheck?: boolean}) => { if (attrs.builderBid === undefined && attrs.strictFeeRecipientCheck) { @@ -218,7 +218,7 @@ describe("api/validator - produceBlockV4", () => { apiBid.message.builderIndex = 7; modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ @@ -260,7 +260,7 @@ describe("api/validator - produceBlockV4", () => { p2pBid.message.builderIndex = 42; modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(p2pBid)); modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ @@ -336,7 +336,7 @@ describe("api/validator - produceBlockV4", () => { apiBid.message.value = builderBid.message.value; modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ @@ -372,7 +372,7 @@ describe("api/validator - produceBlockV4", () => { p2pBid.message.value = 1; modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(p2pBid)); modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ @@ -407,7 +407,7 @@ describe("api/validator - produceBlockV4", () => { p2pBid.message.value = 1; modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(p2pBid)); modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ @@ -440,7 +440,7 @@ describe("api/validator - produceBlockV4", () => { apiBid.message.value = 2; modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue([ @@ -464,7 +464,7 @@ describe("api/validator - produceBlockV4", () => { it("ignores a p2p bid below the configured min bid", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); // Bid total payment is 1 gwei, below the configured floor of 2 gwei - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); const {data: block} = await api.produceBlockV4({ slot, @@ -499,7 +499,7 @@ describe("api/validator - produceBlockV4", () => { it("ignores builder bids when the builder circuit breaker is active", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); const {data: block} = await api.produceBlockV4({ slot, @@ -518,7 +518,7 @@ describe("api/validator - produceBlockV4", () => { it("prefers the builder bid with the maximum builder boost factor", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); // Bid (1 gwei) is preferred over the higher local payload value (2 gwei) modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ block: attrs.builderBid !== undefined ? bidBlock : engineBlock, @@ -544,7 +544,7 @@ describe("api/validator - produceBlockV4", () => { Object.defineProperty(modules.chain, "persistBlock", {value: persistBlock}); modules.chain.opts.persistProducedBlocks = true; modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); const {data: block} = await api.produceBlockV4({ slot, @@ -693,7 +693,7 @@ describe("api/validator - produceBlockV4", () => { } modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(p2pBid)); modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); modules.chain.builderApiClient.getExecutionPayloadBids.mockResolvedValue(apiBids); @@ -726,3 +726,7 @@ describe("api/validator - produceBlockV4", () => { }); } }); + +function toPooledBid(signedBid: gloas.SignedExecutionPayloadBid | null) { + return signedBid === null ? null : {signedBid, receivedMs: 0}; +} From 9bbcf99961659dd707fc5c53846739803ea2af45 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 19:14:25 +0100 Subject: [PATCH 56/67] log local block value in gloas block selection --- .../src/api/impl/validator/index.ts | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index c45af66723ba..e0d3409c38e1 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1050,7 +1050,7 @@ export function getValidatorApi( candidates: candidates .map( (candidate) => - `${candidate.url ?? "p2p"}:total=${candidate.totalGwei}:boost=${candidate.boostFactor}:received=${candidate.receivedMs}ms` + `${candidate.url ?? "p2p"}:total=${prettyWeiToEth(candidate.totalGwei * GWEI_TO_WEI)}:boost=${candidate.boostFactor}:received=${candidate.receivedMs}ms` ) .join(","), bidSource: best?.url ?? "p2p", @@ -1138,9 +1138,9 @@ export function getValidatorApi( ...(bestBid !== null ? { bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : "p2p", - bidValue: bestBid.signedBid.message.value, - bidExecutionPayment: bestBid.signedBid.message.executionPayment, - bidTotal: bestBid.totalGwei, + bidValue: prettyWeiToEth(BigInt(bestBid.signedBid.message.value) * GWEI_TO_WEI), + bidExecutionPayment: prettyWeiToEth(bestBid.signedBid.message.executionPayment * GWEI_TO_WEI), + bidTotal: prettyWeiToEth(bestBid.totalGwei * GWEI_TO_WEI), bidBoostFactor: bestBid.boostFactor, builderIndex: bestBid.signedBid.message.builderIndex, bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), @@ -1157,7 +1157,11 @@ export function getValidatorApi( source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.BuilderCensorship, }); - logger.warn("Selected local block: censorship suspected in builder bid", logCtx); + logger.warn("Selected local block: censorship suspected in builder bid", { + ...logCtx, + durationMs: engineResult.durationMs, + ...getBlockValueLogInfo(engineResult.value), + }); } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { const result = selectBlockProductionSourceByBoostFactor({ builderBoostFactor: bestBid?.boostFactor ?? builderBoostFactor, @@ -1167,7 +1171,22 @@ export function getValidatorApi( }); source = result.source; metrics?.blockProductionSelectionResults.inc(result); - logger.info(`Selected ${source} block`, {reason: result.reason, ...logCtx}); + logger.info(`Selected ${source} block`, { + reason: result.reason, + ...logCtx, + engineDurationMs: engineResult.durationMs, + ...getBlockValueLogInfo(engineResult.value, ProducedBlockSource.engine), + builderDurationMs: bidResult.durationMs, + // Log the counted total used in the comparison, the raw value and execution payment + // are already part of the log context + ...getBlockValueLogInfo( + { + executionPayloadValue: (bestBid?.totalGwei ?? 0n) * GWEI_TO_WEI, + consensusBlockValue: bidResult.value.consensusBlockValue, + }, + ProducedBlockSource.builder + ), + }); bestResult = source === ProducedBlockSource.builder ? bidResult : engineResult; } else if (bidResult.status === "fulfilled") { source = ProducedBlockSource.builder; @@ -1180,6 +1199,8 @@ export function getValidatorApi( logger.info("Selected builder bid block: no local block produced", { reason, ...logCtx, + durationMs: bidResult.durationMs, + ...getBlockValueLogInfo(bidResult.value), error: engineResult.status === "rejected" ? (engineResult.reason as Error).message : undefined, }); } else if (engineResult.status === "fulfilled") { @@ -1195,6 +1216,8 @@ export function getValidatorApi( logger.info("Selected local block: no builder bid block produced", { reason, ...logCtx, + durationMs: engineResult.durationMs, + ...getBlockValueLogInfo(engineResult.value), error: bidResult.status === "rejected" ? (bidResult.reason as Error).message : undefined, }); } From 123a598ce8eabe5e1e8edf42b90697c7addfaf60 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 19:46:05 +0100 Subject: [PATCH 57/67] honor censorship override for late received bids --- .../src/api/impl/validator/index.ts | 12 +++--- .../api/impl/validator/produceBlockV4.test.ts | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index e0d3409c38e1..1e3b96aad446 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -975,7 +975,7 @@ export function getValidatorApi( } // A builder bid is expected if builders are configured or a p2p bid was already received. - // Used by metrics and the censorship override which cannot wait until the bid deadline + // Used by the censorship override which cannot wait until the bid deadline const builderBidExpected = builderConfig.builders.length > 0 || (!circuitBreakerActive && @@ -1077,9 +1077,6 @@ export function getValidatorApi( }; metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); - if (builderBidExpected) { - metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); - } const timed = (source: ProducedBlockSource, fn: () => Promise): Promise => { const t = metrics?.blockProductionTime.startTimer(); @@ -1108,6 +1105,7 @@ export function getValidatorApi( if (candidate === null) { throw new Error(NO_BID_AVAILABLE); } + metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); return timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid: candidate.signedBid}) ); @@ -1150,7 +1148,11 @@ export function getValidatorApi( }; // handle shouldOverrideBuilder separately - if (engineResult.status === "fulfilled" && engineResult.value.shouldOverrideBuilder && builderBidExpected) { + if ( + engineResult.status === "fulfilled" && + engineResult.value.shouldOverrideBuilder && + (builderBidExpected || bidResult.status === "fulfilled") + ) { source = ProducedBlockSource.engine; bestResult = engineResult; metrics?.blockProductionSelectionResults.inc({ diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 7ba9a72b03c9..b7abf9c316b7 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -3,6 +3,7 @@ import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lo import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, MAX_EXECUTION_PAYMENT} from "@lodestar/params"; import {gloas, ssz} from "@lodestar/types"; +import {defer} from "@lodestar/utils"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; import {BUILDER_BID_DEADLINE_MS} from "../../../../../src/execution/builder/apiClient.js"; @@ -203,6 +204,45 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(builderBlock); }); + it("honors the censorship override for a p2p bid received after the initial pool read", async () => { + const engineResult = { + block: engineBlock, + executionPayloadValue: 0n, + consensusBlockValue: 0n, + shouldOverrideBuilder: true, + }; + const bidResult = {block: bidBlock, executionPayloadValue: 0n, consensusBlockValue: 0n}; + const engineDeferred = defer(); + const bidDeferred = defer(); + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid + .mockReturnValueOnce(null) + .mockReturnValueOnce(toPooledBid(builderBid)); + modules.chain.produceBlock.mockImplementation((attrs: {builderBid?: unknown}) => + attrs.builderBid !== undefined ? bidDeferred.promise : engineDeferred.promise + ); + + const blockPromise = api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: getBuilderConfig(), + }); + + await vi.waitFor(() => expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2)); + bidDeferred.resolve(bidResult); + await Promise.resolve(); + engineDeferred.resolve(engineResult); + + const {data: block} = await blockPromise; + + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledTimes(2); + expect(block).toEqual(engineBlock); + }); + it("picks a builder API bid over a lower p2p bid and records the bid source", async () => { const builderUrl = "https://builder.example.com"; const entry = { From b2b496e6799cc445b08662c6b7b1403da1a14504 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 20:09:38 +0100 Subject: [PATCH 58/67] log builder and selection bid totals --- .../beacon-node/src/api/impl/validator/index.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 1e3b96aad446..82810e783a30 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1138,7 +1138,11 @@ export function getValidatorApi( bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : "p2p", bidValue: prettyWeiToEth(BigInt(bestBid.signedBid.message.value) * GWEI_TO_WEI), bidExecutionPayment: prettyWeiToEth(bestBid.signedBid.message.executionPayment * GWEI_TO_WEI), - bidTotal: prettyWeiToEth(bestBid.totalGwei * GWEI_TO_WEI), + // The full bid total and the counted total used during bid selection + bidTotal: prettyWeiToEth( + (BigInt(bestBid.signedBid.message.value) + bestBid.signedBid.message.executionPayment) * GWEI_TO_WEI + ), + bidCountedTotal: prettyWeiToEth(bestBid.totalGwei * GWEI_TO_WEI), bidBoostFactor: bestBid.boostFactor, builderIndex: bestBid.signedBid.message.builderIndex, bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), @@ -1179,15 +1183,6 @@ export function getValidatorApi( engineDurationMs: engineResult.durationMs, ...getBlockValueLogInfo(engineResult.value, ProducedBlockSource.engine), builderDurationMs: bidResult.durationMs, - // Log the counted total used in the comparison, the raw value and execution payment - // are already part of the log context - ...getBlockValueLogInfo( - { - executionPayloadValue: (bestBid?.totalGwei ?? 0n) * GWEI_TO_WEI, - consensusBlockValue: bidResult.value.consensusBlockValue, - }, - ProducedBlockSource.builder - ), }); bestResult = source === ProducedBlockSource.builder ? bidResult : engineResult; } else if (bidResult.status === "fulfilled") { From 1d0fc639cf92130e1ff10431b8a1adbfd4116638 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 26 Aug 2026 22:46:11 +0100 Subject: [PATCH 59/67] log successful signed block submission to builder --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 7548db29d700..f3a7e021b541 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -371,6 +371,9 @@ export function getBeaconBlockApi({ if (gloasBlock.message.body.signedExecutionPayloadBid.message.builderIndex !== BUILDER_INDEX_SELF_BUILD) { chain.builderApiClient .submitSignedBeaconBlock(builderUrl, {data: gloasBlock, bytes: context?.sszBytes ?? undefined}) + .then(() => { + chain.logger.debug("Submitted signed block to builder", {slot, builderUrl}); + }) .catch((e) => { chain.logger.warn("Failed to submit signed block to builder", {...valLogMeta, builderUrl}, e); }); From 0c00b311ed2f3b6f187f4141b677d0654f202619 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 27 Aug 2026 09:02:54 +0100 Subject: [PATCH 60/67] floor p2p bid received time --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 2 +- packages/beacon-node/src/network/processor/gossipHandlers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index f3a7e021b541..fa9cef21ae35 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -1072,7 +1072,7 @@ export function getBeaconBlockApi({ try { const insertOutcome = chain.executionPayloadBidPool.add( signedExecutionPayloadBid, - Math.round(elapsedSec * 1000) + Math.floor(elapsedSec * 1000) ); metrics?.opPool.executionPayloadBidPool.apiInsertOutcome.inc({insertOutcome}); } catch (e) { diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 9127188aff71..57e34feac88a 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1306,7 +1306,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand // Handle valid payload bid by storing in a bid pool try { - const insertOutcome = chain.executionPayloadBidPool.add(executionPayloadBid, Math.round(elapsedSec * 1000)); + const insertOutcome = chain.executionPayloadBidPool.add(executionPayloadBid, Math.floor(elapsedSec * 1000)); metrics?.opPool.executionPayloadBidPool.gossipInsertOutcome.inc({insertOutcome}); } catch (e) { logger.error("Error adding to executionPayloadBid pool", {}, e as Error); From 6d01676f3d3e9094e043b84b2e2266e817de02a7 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 27 Aug 2026 09:26:01 +0100 Subject: [PATCH 61/67] print bid values in ETH Bid values in the p2p bid discard log and bid validation errors are printed in ETH, and the discard log makes clear it is the best p2p bid that did not qualify. --- .../beacon-node/src/api/impl/validator/index.ts | 8 ++++---- .../src/execution/builder/validateBid.ts | 13 +++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 82810e783a30..5451164e3862 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -975,7 +975,7 @@ export function getValidatorApi( } // A builder bid is expected if builders are configured or a p2p bid was already received. - // Used by the censorship override which cannot wait until the bid deadline + // Used by the censorship override which may run before the bid deadline const builderBidExpected = builderConfig.builders.length > 0 || (!circuitBreakerActive && @@ -990,10 +990,10 @@ export function getValidatorApi( // Discard p2p bids below the proposer's configured floor on the total payment. // A p2p bid's total is just its value since gossip validation enforces executionPayment=0. if (p2pBid !== null && BigInt(p2pBid.signedBid.message.value) < builderConfig.minBid) { - logger.info("Ignoring p2p bid below min bid", { + logger.info("Best p2p bid below configured minimum", { slot, - bidValue: p2pBid.signedBid.message.value, - minBid: builderConfig.minBid, + bidValue: prettyWeiToEth(BigInt(p2pBid.signedBid.message.value) * GWEI_TO_WEI), + minBid: prettyWeiToEth(builderConfig.minBid * GWEI_TO_WEI), }); return null; } diff --git a/packages/beacon-node/src/execution/builder/validateBid.ts b/packages/beacon-node/src/execution/builder/validateBid.ts index 5d0322fc1289..34178e32497e 100644 --- a/packages/beacon-node/src/execution/builder/validateBid.ts +++ b/packages/beacon-node/src/execution/builder/validateBid.ts @@ -10,7 +10,7 @@ import { isStatePostGloas, } from "@lodestar/state-transition"; import {RootHex, Slot, gloas} from "@lodestar/types"; -import {bigIntMin, byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import {GWEI_TO_WEI, bigIntMin, byteArrayEquals, prettyWeiToEth, toHex, toRootHex} from "@lodestar/utils"; import {IBeaconChain} from "../../chain/index.js"; import {RegenCaller} from "../../chain/regen/index.js"; import {getShufflingDependentRoot} from "../../util/dependentRoot.js"; @@ -58,8 +58,10 @@ export async function validateBuilderApiExecutionPayloadBid( const totalPayment = getBuilderBidTotalGwei(bid, entry.maxExecutionPayment); if (totalPayment < entry.minBid) { throw Error( - `Bid total payment=${totalPayment} (value=${bid.value} executionPayment=${bid.executionPayment}) ` + - `is below minBid=${entry.minBid}` + `Bid total payment=${prettyWeiToEth(totalPayment * GWEI_TO_WEI)} ` + + `(value=${prettyWeiToEth(BigInt(bid.value) * GWEI_TO_WEI)} ` + + `executionPayment=${prettyWeiToEth(bid.executionPayment * GWEI_TO_WEI)}) ` + + `is below minBid=${prettyWeiToEth(entry.minBid * GWEI_TO_WEI)}` ); } @@ -106,7 +108,10 @@ export async function validateBuilderApiExecutionPayloadBid( // The coverage check only applies to the staked collateral payment, a pure execution // layer payment bid has nothing to cover on-chain if (bid.value > 0 && !state.canBuilderCoverBid(bid.builderIndex, bid.value)) { - throw Error(`Builder cannot cover bid value=${bid.value} balance=${builder.balance}`); + throw Error( + `Builder cannot cover bid value=${prettyWeiToEth(BigInt(bid.value) * GWEI_TO_WEI)} ` + + `balance=${prettyWeiToEth(BigInt(builder.balance) * GWEI_TO_WEI)}` + ); } const randaoMix = state.getRandaoMix(computeEpochAtSlot(state.slot)); From 16d4f488cb6b8db447828769244efa45f1f8a763 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 27 Aug 2026 09:26:02 +0100 Subject: [PATCH 62/67] rename bid block promise and result --- .../src/api/impl/validator/index.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 5451164e3862..0eaa8935472d 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1101,7 +1101,7 @@ export function getValidatorApi( } return engineBlock; }); - const bidPromise: ReturnType = bestBidPromise.then((candidate) => { + const bidBlockPromise: ReturnType = bestBidPromise.then((candidate) => { if (candidate === null) { throw new Error(NO_BID_AVAILABLE); } @@ -1111,7 +1111,7 @@ export function getValidatorApi( ); }); - const [engineResult, bidResult] = await resolveOrRacePromises([enginePromise, bidPromise], { + const [engineResult, bidBlockResult] = await resolveOrRacePromises([enginePromise, bidBlockPromise], { resolveTimeoutMs: cutoffMs, raceTimeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, signal: controller.signal, @@ -1121,7 +1121,7 @@ export function getValidatorApi( let source: ProducedBlockSource = ProducedBlockSource.engine; // Resolved instantly whenever the bid branch produced a block - const bestBid = bidResult.status === "fulfilled" ? await bestBidPromise : null; + const bestBid = bidBlockResult.status === "fulfilled" ? await bestBidPromise : null; const logCtx = { slot, @@ -1155,7 +1155,7 @@ export function getValidatorApi( if ( engineResult.status === "fulfilled" && engineResult.value.shouldOverrideBuilder && - (builderBidExpected || bidResult.status === "fulfilled") + (builderBidExpected || bidBlockResult.status === "fulfilled") ) { source = ProducedBlockSource.engine; bestResult = engineResult; @@ -1168,7 +1168,7 @@ export function getValidatorApi( durationMs: engineResult.durationMs, ...getBlockValueLogInfo(engineResult.value), }); - } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { + } else if (engineResult.status === "fulfilled" && bidBlockResult.status === "fulfilled") { const result = selectBlockProductionSourceByBoostFactor({ builderBoostFactor: bestBid?.boostFactor ?? builderBoostFactor, engineExecutionPayloadValue: engineResult.value.executionPayloadValue, @@ -1182,12 +1182,12 @@ export function getValidatorApi( ...logCtx, engineDurationMs: engineResult.durationMs, ...getBlockValueLogInfo(engineResult.value, ProducedBlockSource.engine), - builderDurationMs: bidResult.durationMs, + builderDurationMs: bidBlockResult.durationMs, }); - bestResult = source === ProducedBlockSource.builder ? bidResult : engineResult; - } else if (bidResult.status === "fulfilled") { + bestResult = source === ProducedBlockSource.builder ? bidBlockResult : engineResult; + } else if (bidBlockResult.status === "fulfilled") { source = ProducedBlockSource.builder; - bestResult = bidResult; + bestResult = bidBlockResult; const reason = engineResult.status === "pending" ? BuilderBlockSelectionReason.EnginePending @@ -1196,17 +1196,17 @@ export function getValidatorApi( logger.info("Selected builder bid block: no local block produced", { reason, ...logCtx, - durationMs: bidResult.durationMs, - ...getBlockValueLogInfo(bidResult.value), + durationMs: bidBlockResult.durationMs, + ...getBlockValueLogInfo(bidBlockResult.value), error: engineResult.status === "rejected" ? (engineResult.reason as Error).message : undefined, }); } else if (engineResult.status === "fulfilled") { source = ProducedBlockSource.engine; bestResult = engineResult; const reason = - bidResult.status === "rejected" && (bidResult.reason as Error).message === NO_BID_AVAILABLE + bidBlockResult.status === "rejected" && (bidBlockResult.reason as Error).message === NO_BID_AVAILABLE ? EngineBlockSelectionReason.BuilderNoBid - : bidResult.status === "pending" + : bidBlockResult.status === "pending" ? EngineBlockSelectionReason.BuilderPending : EngineBlockSelectionReason.BuilderError; metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.engine, reason}); @@ -1215,13 +1215,13 @@ export function getValidatorApi( ...logCtx, durationMs: engineResult.durationMs, ...getBlockValueLogInfo(engineResult.value), - error: bidResult.status === "rejected" ? (bidResult.reason as Error).message : undefined, + error: bidBlockResult.status === "rejected" ? (bidBlockResult.reason as Error).message : undefined, }); } if (bestResult === null || bestResult.status !== "fulfilled") { const engineReason = engineResult.status === "rejected" ? engineResult.reason : engineResult.status; - const bidReason = bidResult.status === "rejected" ? bidResult.reason : bidResult.status; + const bidReason = bidBlockResult.status === "rejected" ? bidBlockResult.reason : bidBlockResult.status; logger.error("Block production failed", { ...logCtx, engineReason: String(engineReason), From 6a5d5db98163c92a73e7d4e3c901ae2c9bffc479 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 27 Aug 2026 09:31:08 +0100 Subject: [PATCH 63/67] add prettyGweiToEth helper --- .../beacon-node/src/api/impl/validator/index.ts | 17 +++++++++-------- .../src/execution/builder/validateBid.ts | 13 ++++++------- packages/utils/src/format.ts | 9 ++++++++- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 0eaa8935472d..7133d49d398e 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -56,6 +56,7 @@ import { defer, formatWeiToEth, fromHex, + prettyGweiToEth, prettyWeiToEth, resolveOrRacePromises, sleep, @@ -992,8 +993,8 @@ export function getValidatorApi( if (p2pBid !== null && BigInt(p2pBid.signedBid.message.value) < builderConfig.minBid) { logger.info("Best p2p bid below configured minimum", { slot, - bidValue: prettyWeiToEth(BigInt(p2pBid.signedBid.message.value) * GWEI_TO_WEI), - minBid: prettyWeiToEth(builderConfig.minBid * GWEI_TO_WEI), + bidValue: prettyGweiToEth(p2pBid.signedBid.message.value), + minBid: prettyGweiToEth(builderConfig.minBid), }); return null; } @@ -1050,7 +1051,7 @@ export function getValidatorApi( candidates: candidates .map( (candidate) => - `${candidate.url ?? "p2p"}:total=${prettyWeiToEth(candidate.totalGwei * GWEI_TO_WEI)}:boost=${candidate.boostFactor}:received=${candidate.receivedMs}ms` + `${candidate.url ?? "p2p"}:total=${prettyGweiToEth(candidate.totalGwei)}:boost=${candidate.boostFactor}:received=${candidate.receivedMs}ms` ) .join(","), bidSource: best?.url ?? "p2p", @@ -1136,13 +1137,13 @@ export function getValidatorApi( ...(bestBid !== null ? { bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : "p2p", - bidValue: prettyWeiToEth(BigInt(bestBid.signedBid.message.value) * GWEI_TO_WEI), - bidExecutionPayment: prettyWeiToEth(bestBid.signedBid.message.executionPayment * GWEI_TO_WEI), + bidValue: prettyGweiToEth(bestBid.signedBid.message.value), + bidExecutionPayment: prettyGweiToEth(bestBid.signedBid.message.executionPayment), // The full bid total and the counted total used during bid selection - bidTotal: prettyWeiToEth( - (BigInt(bestBid.signedBid.message.value) + bestBid.signedBid.message.executionPayment) * GWEI_TO_WEI + bidTotal: prettyGweiToEth( + BigInt(bestBid.signedBid.message.value) + bestBid.signedBid.message.executionPayment ), - bidCountedTotal: prettyWeiToEth(bestBid.totalGwei * GWEI_TO_WEI), + bidCountedTotal: prettyGweiToEth(bestBid.totalGwei), bidBoostFactor: bestBid.boostFactor, builderIndex: bestBid.signedBid.message.builderIndex, bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), diff --git a/packages/beacon-node/src/execution/builder/validateBid.ts b/packages/beacon-node/src/execution/builder/validateBid.ts index 34178e32497e..78b422c58e39 100644 --- a/packages/beacon-node/src/execution/builder/validateBid.ts +++ b/packages/beacon-node/src/execution/builder/validateBid.ts @@ -10,7 +10,7 @@ import { isStatePostGloas, } from "@lodestar/state-transition"; import {RootHex, Slot, gloas} from "@lodestar/types"; -import {GWEI_TO_WEI, bigIntMin, byteArrayEquals, prettyWeiToEth, toHex, toRootHex} from "@lodestar/utils"; +import {bigIntMin, byteArrayEquals, prettyGweiToEth, toHex, toRootHex} from "@lodestar/utils"; import {IBeaconChain} from "../../chain/index.js"; import {RegenCaller} from "../../chain/regen/index.js"; import {getShufflingDependentRoot} from "../../util/dependentRoot.js"; @@ -58,10 +58,10 @@ export async function validateBuilderApiExecutionPayloadBid( const totalPayment = getBuilderBidTotalGwei(bid, entry.maxExecutionPayment); if (totalPayment < entry.minBid) { throw Error( - `Bid total payment=${prettyWeiToEth(totalPayment * GWEI_TO_WEI)} ` + - `(value=${prettyWeiToEth(BigInt(bid.value) * GWEI_TO_WEI)} ` + - `executionPayment=${prettyWeiToEth(bid.executionPayment * GWEI_TO_WEI)}) ` + - `is below minBid=${prettyWeiToEth(entry.minBid * GWEI_TO_WEI)}` + `Bid total payment=${prettyGweiToEth(totalPayment)} ` + + `(value=${prettyGweiToEth(bid.value)} ` + + `executionPayment=${prettyGweiToEth(bid.executionPayment)}) ` + + `is below minBid=${prettyGweiToEth(entry.minBid)}` ); } @@ -109,8 +109,7 @@ export async function validateBuilderApiExecutionPayloadBid( // layer payment bid has nothing to cover on-chain if (bid.value > 0 && !state.canBuilderCoverBid(bid.builderIndex, bid.value)) { throw Error( - `Builder cannot cover bid value=${prettyWeiToEth(BigInt(bid.value) * GWEI_TO_WEI)} ` + - `balance=${prettyWeiToEth(BigInt(builder.balance) * GWEI_TO_WEI)}` + `Builder cannot cover bid value=${prettyGweiToEth(bid.value)} ` + `balance=${prettyGweiToEth(builder.balance)}` ); } diff --git a/packages/utils/src/format.ts b/packages/utils/src/format.ts index 6bcacb717bc2..4ea7ddb4a241 100644 --- a/packages/utils/src/format.ts +++ b/packages/utils/src/format.ts @@ -1,5 +1,5 @@ import {toRootHex} from "#bytes"; -import {ETH_TO_WEI} from "./ethConversion.js"; +import {ETH_TO_WEI, GWEI_TO_WEI} from "./ethConversion.js"; /** * Format bytes as `0x1234…1234` @@ -58,6 +58,13 @@ export function prettyWeiToEth(wei: bigint): string { return `${formatWeiToEth(wei)} ETH`; } +/** + * Format gwei as ETH, with up to 5 decimals and append ' ETH' + */ +export function prettyGweiToEth(gwei: number | bigint): string { + return prettyWeiToEth(BigInt(gwei) * GWEI_TO_WEI); +} + /** * Format milliseconds to time format HH:MM:SS.ms */ From 340801b04a596f6c033f9147da2f3f9fe1400b46 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 27 Aug 2026 09:56:55 +0100 Subject: [PATCH 64/67] cover builder api bids under active circuit breaker The circuit breaker test now configures a builder entry and asserts no bid request is made. Selection test cases are consistently formatted with forced line breaks. --- .../api/impl/validator/produceBlockV4.test.ts | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index b7abf9c316b7..2346d442c447 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -540,6 +540,8 @@ describe("api/validator - produceBlockV4", () => { it("ignores builder bids when the builder circuit breaker is active", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); + modules.chain.getHeadState.mockReturnValue({getBeaconProposer: () => 1} as never); + vi.spyOn(modules.chain.pubkeyCache, "getOrThrow").mockReturnValue({toBytes: () => new Uint8Array(48)} as never); const {data: block} = await api.produceBlockV4({ slot, @@ -547,10 +549,23 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderConfig: getBuilderConfig(), + builderConfig: { + ...getBuilderConfig(), + builders: [ + { + url: new TextEncoder().encode("https://builder.example.com"), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, + }, + ], + }, }); expect(modules.chain.builderCircuitBreaker.isActive).toHaveBeenCalledWith(slot, parentBlock); + expect(modules.chain.builderApiClient.getExecutionPayloadBids).not.toHaveBeenCalled(); expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); expect(block).toEqual(engineBlock); @@ -636,10 +651,32 @@ describe("api/validator - produceBlockV4", () => { /** Expected winner, the entry index of an api bid, the p2p bid or the local block */ expected: number | "p2p" | "engine"; }[] = [ - {id: "api bid outbids the p2p bid", entries: [{value: 2}], p2pValue: 1, engineValueGwei: 0, expected: 0}, - {id: "p2p bid outbids the api bid", entries: [{value: 2}], p2pValue: 3, engineValueGwei: 0, expected: "p2p"}, - {id: "tie prefers the api bid", entries: [{value: 2}], p2pValue: 2, engineValueGwei: 0, expected: 0}, { + // ⏎ + id: "api bid outbids the p2p bid", + entries: [{value: 2}], + p2pValue: 1, + engineValueGwei: 0, + expected: 0, + }, + { + // ⏎ + id: "p2p bid outbids the api bid", + entries: [{value: 2}], + p2pValue: 3, + engineValueGwei: 0, + expected: "p2p", + }, + { + // ⏎ + id: "tie prefers the api bid", + entries: [{value: 2}], + p2pValue: 2, + engineValueGwei: 0, + expected: 0, + }, + { + // ⏎ id: "tie prefers the earlier received api bid", entries: [ {value: 2, receivedMs: 2000}, @@ -650,6 +687,7 @@ describe("api/validator - produceBlockV4", () => { expected: 1, }, { + // ⏎ id: "execution payment is counted up to the entry cap", entries: [{value: 1, executionPayment: 5n, maxExecutionPayment: 2n}], p2pValue: 2, @@ -657,6 +695,7 @@ describe("api/validator - produceBlockV4", () => { expected: 0, }, { + // ⏎ id: "execution payment above a zero cap is not counted", entries: [{value: 1, executionPayment: 5n, maxExecutionPayment: 0n}], p2pValue: 2, @@ -664,6 +703,7 @@ describe("api/validator - produceBlockV4", () => { expected: "p2p", }, { + // ⏎ id: "zero entry boost factor loses against the p2p bid", entries: [{value: 100, boostFactor: 0n}], p2pValue: 1, @@ -671,6 +711,7 @@ describe("api/validator - produceBlockV4", () => { expected: "p2p", }, { + // ⏎ id: "max entry boost factor wins regardless of value", entries: [{value: 0, boostFactor: maxBuilderBoostFactor}], p2pValue: 5, @@ -678,6 +719,7 @@ describe("api/validator - produceBlockV4", () => { expected: 0, }, { + // ⏎ id: "higher boosted api bid wins between builders", entries: [ {value: 10, boostFactor: 100n}, @@ -688,6 +730,7 @@ describe("api/validator - produceBlockV4", () => { expected: 1, }, { + // ⏎ id: "p2p bid below min bid is discarded", entries: [], p2pValue: 1, @@ -696,6 +739,7 @@ describe("api/validator - produceBlockV4", () => { expected: "engine", }, { + // ⏎ id: "local block beats a dampened api bid", entries: [{value: 100, boostFactor: 50n}], p2pValue: null, From a8e218201178143bd10da5e0beab2b138c5c4caf Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 27 Aug 2026 16:02:16 +0100 Subject: [PATCH 65/67] reword builder forward comments --- packages/api/src/beacon/routes/beacon/block.ts | 4 ++-- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index b993ac2d41cb..613bcc4b90a0 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -165,8 +165,8 @@ export type Endpoints = { broadcastValidation?: BroadcastValidation; /** * The url of the winning builder as returned by `produceBlockV4`. The beacon node forwards - * the signed block to this builder so it can help disseminate the block and learns timely - * that its bid won without waiting for gossip. + * the signed block to this builder so it can help disseminate the block and learn that + * its bid won without waiting for gossip. */ builderUrl?: string; }, 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 fa9cef21ae35..777f3071f2f1 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -364,8 +364,8 @@ export function getBeaconBlockApi({ chain.logger.info("Publishing block", valLogMeta); // Forward the signed block to the winning builder echoed by the validator client so it can - // help disseminate the block and learns timely that its bid won without waiting for block - // gossip. Failures are non-fatal, the builder also sees the block on gossip. + // help disseminate the block and learn that its bid won without waiting for block gossip. + // Failures are non-fatal, the builder also sees the block on gossip. if (isForkPostGloas(fork) && builderUrl !== undefined) { const gloasBlock = signedBlock as SignedBeaconBlock; if (gloasBlock.message.body.signedExecutionPayloadBid.message.builderIndex !== BUILDER_INDEX_SELF_BUILD) { From 1260e7a51920cb3a774490a01aa18c5b096613fa Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 27 Aug 2026 20:49:26 +0100 Subject: [PATCH 66/67] comments --- packages/validator/src/services/builderPreferences.ts | 5 +++-- packages/validator/src/services/validatorStore.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/validator/src/services/builderPreferences.ts b/packages/validator/src/services/builderPreferences.ts index a8cd267dcb8d..91380d946a2e 100644 --- a/packages/validator/src/services/builderPreferences.ts +++ b/packages/validator/src/services/builderPreferences.ts @@ -128,8 +128,9 @@ export class BuilderPreferencesService { try { (await this.api.validator.submitBuilderPreferences({builderPreferences: entries})).assertOk(); - // Only mark as submitted after the API call succeeds; a thrown error leaves the - // slot eligible for retry on the next tick. + // Only mark as submitted after the API call succeeds; a thrown error, including per-entry + // failures reported by index, leaves all slots eligible for retry on the next tick. + // Re-submitting preferences a builder already accepted is harmless. for (const {submission, slot: submittedSlot} of pending) { submission.slots.add(submittedSlot); } diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index ee0ac451f6d7..f383decc8574 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -465,8 +465,8 @@ export class ValidatorStore { /** * Resolve the builder entries for this key. Per-key entries replace the validator client's - * builders, an omitted entry value takes this key's default and then the validator client's - * own configuration. An omitted auth data is derived from the entry url. + * builders. A value omitted on an entry takes this key's default, then the validator + * client's configuration, while omitted auth data is derived from the entry url instead. */ getResolvedBuilderEntries(pubkeyHex: PubkeyHex, boostFactor?: bigint): ResolvedBuilderEntry[] { const validatorData = this.validators.get(pubkeyHex); @@ -556,7 +556,7 @@ export class ValidatorStore { }; } - /** Remove the builder configuration for this key, it then follows the validator client again */ + /** Remove the builder configuration for this key, and revert to the validator client configuration */ deleteBuilderConfig(pubkeyHex: PubkeyHex): void { const validatorData = this.validators.get(pubkeyHex); if (validatorData === undefined) { From f9e389c0f77c396af3b60c74eaf039746c0dcac0 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 27 Aug 2026 21:00:47 +0100 Subject: [PATCH 67/67] rephrase --- packages/api/src/keymanager/routes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 226577b09bf8..e7427931f96b 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -103,7 +103,7 @@ export type GraffitiData = ValueOf; export type GasLimitData = ValueOf; export type BuilderBoostFactorData = ValueOf; -/** One builder a validator public key may source blocks from */ +/** Configuration for a single builder to request bids from */ export type BuilderEntryConfig = { /** URL the bid requests for this entry are sent to */ url: string; @@ -119,7 +119,7 @@ export type BuilderEntryConfig = { builderBoostFactor?: bigint; }; -/** How a validator public key sources blocks from builders */ +/** Per-key builder configuration for requesting and selecting bids */ export type BuilderConfigData = { /** Default for entries that do not set their own `minBid`, also applies to p2p bids */ minBid?: bigint;