diff --git a/.wordlist.txt b/.wordlist.txt index b2ec01f4247e..33157f667da6 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -3,6 +3,7 @@ APIs Andreas Antonopoulos AssemblyScript +Auth BLS BeaconNode Besu @@ -51,6 +52,7 @@ Golang Gossipsub Grafana Grandine +Gwei HTTPS HackMD Hashicorp @@ -119,6 +121,7 @@ addons api args async +auth backfill beaconcha blockRoot @@ -246,8 +249,10 @@ tcp testnet testnets todo +trustless typesafe udp +uint unpkg util utils diff --git a/docs/pages/run/validator-management/proposer-config.md b/docs/pages/run/validator-management/proposer-config.md index 40899767daab..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 @@ -36,6 +38,22 @@ default_config: boost_factor: "90" ``` +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 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 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: + 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/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 7b03349198d6..e187d76c526e 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -110,6 +110,17 @@ 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. 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 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 [builder config keymanager endpoint](https://ethereum.github.io/keymanager-APIs/#/Builder%20Config) 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..613bcc4b90a0 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -163,8 +163,18 @@ 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 help disseminate the block and learn that + * its bid won 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 +355,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 +369,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { + writeReqSsz: ({signedBlockContents, broadcastValidation, builderUrl}) => { const slot = signedBlockContents.signedBlock.message.slot; const fork = config.getForkName(slot); @@ -388,6 +400,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,59 @@ 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") { + throw Error("Builder url must be a string"); + } + 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("utf8", {fatal: true}).decode(value); + } +} + +export const BuilderEntryType = new ContainerType( + { + url: new BuilderUrlType(MAX_BUILDER_URL_SIZE), + auth: ssz.gloas.SignedBuilderRequestAuth, + 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.SignedBuilderRequestAuth, + 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 +350,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 +496,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 +506,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 +515,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 +526,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 +760,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 +995,17 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ params: {slot}, query: { @@ -933,21 +1013,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: 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: BuilderConfigType.deserialize(body), + }; + }, schema: { params: {slot: Schema.UintRequired}, query: { @@ -955,12 +1074,16 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions @@ -972,18 +1095,23 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ProduceBlockV4MetaType.toJson(meta), - fromJson: (val) => ProduceBlockV4MetaType.fromJson(val), + fromJson: (val, headers) => ({ + ...ProduceBlockV4MetaType.fromJson(val), + builderUrl: headers?.get(MetaHeader.BuilderUrl) ?? undefined, + }), 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 +1423,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, + }, + }, }; } diff --git a/packages/api/src/builder/routes.ts b/packages/api/src/builder/routes.ts index edf7df6be4d7..4b0d0f3f79f9 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.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` */ + 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,186 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ + params: { + slot, + parent_hash: toRootHex(parentHash), + parent_root: toRootHex(parentRoot), + proposer_pubkey: toPubkeyHex(proposerPubkey), + }, + body: ssz.gloas.SignedBuilderRequestAuth.toJson(requestAuth), + headers: { + [MetaHeader.Version]: config.getForkName(slot), + [MetaHeader.DateMilliseconds]: dateMilliseconds.toString(), + [MetaHeader.TimeoutMs]: timeoutMs.toString(), + }, + }), + 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.SignedBuilderRequestAuth.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, + parent_hash: toRootHex(parentHash), + parent_root: toRootHex(parentRoot), + proposer_pubkey: toPubkeyHex(proposerPubkey), + }, + body: ssz.gloas.SignedBuilderRequestAuth.serialize(requestAuth), + headers: { + [MetaHeader.Version]: config.getForkName(slot), + [MetaHeader.DateMilliseconds]: dateMilliseconds.toString(), + [MetaHeader.TimeoutMs]: timeoutMs.toString(), + }, + }), + 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.SignedBuilderRequestAuth.deserialize(body), + dateMilliseconds: parseRequiredUintHeader( + fromHeaders(headers, MetaHeader.DateMilliseconds), + MetaHeader.DateMilliseconds + ), + timeoutMs: parseRequiredUintHeader(fromHeaders(headers, MetaHeader.TimeoutMs), 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, 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, 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, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, + }, + }, }; } + +function parseRequiredUintHeader(value: string, header: MetaHeader): number { + if (!/^\d+$/.test(value)) { + throw Error(`${header} must be a non-negative integer`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw Error(`${header} must be a safe integer`); + } + return parsed; +} diff --git a/packages/api/src/keymanager/index.ts b/packages/api/src/keymanager/index.ts index 33b62e57c51f..98fb1d6ea7b7 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, @@ -19,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 05eddeefd604..e7427931f96b 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -1,6 +1,13 @@ import {ContainerType, ValueOf} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; +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 {isValidAsciiHttpUrl} from "@lodestar/utils"; import { EmptyArgs, EmptyMeta, @@ -96,6 +103,137 @@ export type GraffitiData = ValueOf; export type GasLimitData = ValueOf; export type BuilderBoostFactorData = ValueOf; +/** Configuration for a single builder to request bids from */ +export type BuilderEntryConfig = { + /** URL the bid requests for this entry are sent to */ + url: string; + /** 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; +}; + +/** 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; + /** 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[]; +}; + +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; + +function parseGweiAmount(value: unknown, field: string): bigint | undefined { + if (value === undefined) return undefined; + 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) { + throw Error(`${field} must not exceed 2**64 - 1`); + } + return parsed; +} + +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 > 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) { + 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 (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` + ); + } + let builderPubkeys: string[] | undefined; + if (builder_pubkeys !== undefined) { + 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)) { + 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; /** @@ -117,9 +255,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. * ``` @@ -367,6 +517,33 @@ export type Endpoints = { EmptyMeta >; + /** Get the builder configuration in effect for a validator public key, with omitted values resolved */ + getBuilderConfig: Endpoint< + // ⏎ + "GET", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + BuilderConfigData, + EmptyMeta + >; + /** Set the builder configuration for a validator public key, replacing any stored configuration in full */ + setBuilderConfig: 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 */ + deleteBuilderConfig: Endpoint< + // ⏎ + "DELETE", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + EmptyResponseData, + EmptyMeta + >; + getProposerConfig: Endpoint< // ⏎ "GET", @@ -654,6 +831,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, + }, + }, + setBuilderConfig: { + url: "/eth/v1/validator/{pubkey}/builder_config", + 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, + }, + deleteBuilderConfig: { + url: "/eth/v1/validator/{pubkey}/builder_config", + 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/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/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/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 new file mode 100644 index 000000000000..e72b4e5a1b54 --- /dev/null +++ b/packages/api/test/unit/beacon/builderConfig.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, it} from "vitest"; +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", () => { + 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" + ); + }); +}); + +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); + }); +}); 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/beacon.ts b/packages/api/test/unit/beacon/testData/beacon.ts index b996b9c4fc78..94d92e7fd5f0 100644 --- a/packages/api/test/unit/beacon/testData/beacon.ts +++ b/packages/api/test/unit/beacon/testData/beacon.ts @@ -69,6 +69,7 @@ export const testData: GenericServerTestCases = { args: { signedBlockContents: {signedBlock: ssz.gloas.SignedBeaconBlock.defaultValue()}, broadcastValidation: BroadcastValidation.consensus, + builderUrl: "https://builder.example.com", }, res: undefined, }, diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index bb70686fa7cf..e221ba059406 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.SignedBuilderRequestAuth.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.SignedBuilderRequestAuth.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..fae2aa23cb7c 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.SignedBuilderRequestAuth.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/builderConfig.test.ts b/packages/api/test/unit/keymanager/builderConfig.test.ts new file mode 100644 index 000000000000..32a1bec58131 --- /dev/null +++ b/packages/api/test/unit/keymanager/builderConfig.test.ts @@ -0,0 +1,22 @@ +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(); + } + }); + + 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/api/test/unit/keymanager/oapiSpec.test.ts b/packages/api/test/unit/keymanager/oapiSpec.test.ts index d3817598029b..86dc424efa6d 100644 --- a/packages/api/test/unit/keymanager/oapiSpec.test.ts +++ b/packages/api/test/unit/keymanager/oapiSpec.test.ts @@ -11,7 +11,7 @@ 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"), diff --git a/packages/api/test/unit/keymanager/testData.ts b/packages/api/test/unit/keymanager/testData.ts index c2e0b2017ae4..de025ea85b88 100644 --- a/packages/api/test/unit/keymanager/testData.ts +++ b/packages/api/test/unit/keymanager/testData.ts @@ -112,6 +112,44 @@ export const testData: GenericServerTestCases = { args: {pubkey: pubkeyRand}, res: undefined, }, + getBuilderConfig: { + 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, + }, + ], + }, + }, + }, + setBuilderConfig: { + args: { + pubkey: pubkeyRand, + builderConfig: { + minBid: 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, + }, + deleteBuilderConfig: { + args: {pubkey: pubkeyRand}, + res: undefined, + }, getProposerConfig: { args: {pubkey: pubkeyRand}, res: { 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. 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 a43320eae36e..777f3071f2f1 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -104,8 +104,8 @@ export function getBeaconBlockApi({ "chain" | "config" | "metrics" | "network" | "db" >): ApplicationMethods { const publishBlockV2: ApplicationMethods["publishBlockV2"] = async ( - {signedBlockContents, broadcastValidation}, - _context, + {signedBlockContents, broadcastValidation, builderUrl}, + context, opts: PublishBlockOpts = {} ) => { const seenTimestampSec = Date.now() / 1000; @@ -209,6 +209,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 + chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); + return; + } + switch (broadcastValidation) { case routes.beacon.BroadcastValidation.gossip: { if (!blockLocallyProduced) { @@ -356,6 +362,24 @@ 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 + // 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) { + 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); + }); + } + } + 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. @@ -374,16 +398,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 + 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; }), @@ -1042,7 +1070,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.floor(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 24de92273c51..7133d49d398e 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -52,12 +52,16 @@ import { import { GWEI_TO_WEI, TimeoutError, + byteArrayEquals, defer, formatWeiToEth, fromHex, + prettyGweiToEth, prettyWeiToEth, resolveOrRacePromises, + sleep, toHex, + toPrintableUrl, toRootHex, } from "@lodestar/utils"; import {MAX_BUILDER_BOOST_FACTOR} from "@lodestar/validator"; @@ -72,6 +76,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"; @@ -80,7 +85,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 {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"; @@ -112,6 +119,55 @@ 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 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; + /** Time in milliseconds from the slot start when the bid was received */ + 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 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) { + if (best === null) { + best = candidate; + continue; + } + // Preserve max boost preference before comparing bid values + 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.url === undefined || (candidate.url !== undefined && candidate.receivedMs < best.receivedMs)) + ) { + best = candidate; + } + } + return best; +} type ProduceBlockContentsRes = {executionPayloadValue: Wei; consensusBlockValue: Wei} & { data: BlockContents; @@ -857,7 +913,7 @@ export function getValidatorApi( feeRecipient, strictFeeRecipientCheck, includePayload, - builderBoostFactor, + builderConfig, }) { const fork = config.getForkName(slot); @@ -865,7 +921,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,32 +948,117 @@ 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 - ? null - : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); - const logCtx = { - slot, - parentSlot, - parentBlockRoot: parentBlockRootHex, - parentBlockHash: parentBlock.executionPayloadBlockHash, - fork, - builderBoostFactor, - strictFeeRecipientCheck, - circuitBreakerActive, - ...(builderBid !== null - ? { - bidValue: builderBid.message.value, - builderIndex: builderBid.message.builderIndex, - bidBlockHash: toRootHex(builderBid.message.blockHash), + // 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 + ); + } catch (e) { + logger.warn("Unable to request builder API bids", {slot}, e as Error); + } + } + + // A builder bid is expected if builders are configured or a p2p bid was already received. + // Used by the censorship override which may run before 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 + 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.signedBid.message.value) < builderConfig.minBid) { + logger.info("Best p2p bid below configured minimum", { + slot, + bidValue: prettyGweiToEth(p2pBid.signedBid.message.value), + minBid: prettyGweiToEth(builderConfig.minBid), + }); + 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 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 = ( + 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({ + signedBid: p2pBid.signedBid, + totalGwei: BigInt(p2pBid.signedBid.message.value), + boostFactor: builderConfig.builderBoostFactor, + receivedMs: p2pBid.receivedMs, + }); + } + + const best = selectBestBid(candidates); + if (candidates.length > 0) { + logger.debug("Ranked builder bid candidates", { + slot, + candidates: candidates + .map( + (candidate) => + `${candidate.url ?? "p2p"}:total=${prettyGweiToEth(candidate.totalGwei)}:boost=${candidate.boostFactor}:received=${candidate.receivedMs}ms` + ) + .join(","), + bidSource: best?.url ?? "p2p", + }); + } + return best; + })(); const commonBlockBodyPromise = chain.produceCommonBlockBody({ slot, @@ -937,9 +1078,6 @@ export function getValidatorApi( }; metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); - if (builderBid !== null) { - metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); - } const timed = (source: ProducedBlockSource, fn: () => Promise): Promise => { const t = metrics?.blockProductionTime.startTimer(); @@ -956,19 +1094,25 @@ 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 builder boost factor of 0 - if (engineBlock.shouldOverrideBuilder || builderBoostFactor === BigInt(0)) { + // 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(); } return engineBlock; }); - const bidPromise: ReturnType = - builderBid !== null - ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})) - : Promise.reject(new Error("No builder bid available")); + const bidBlockPromise: ReturnType = bestBidPromise.then((candidate) => { + 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}) + ); + }); - 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, @@ -977,29 +1121,74 @@ export function getValidatorApi( let bestResult: typeof engineResult | null = null; let source: ProducedBlockSource = ProducedBlockSource.engine; + // Resolved instantly whenever the bid branch produced a block + const bestBid = bidBlockResult.status === "fulfilled" ? await bestBidPromise : null; + + const logCtx = { + slot, + parentSlot, + parentBlockRoot: parentBlockRootHex, + parentBlockHash: parentBlock.executionPayloadBlockHash, + fork, + builderBoostFactor, + strictFeeRecipientCheck, + circuitBreakerActive, + builderEntries: builderConfig.builders.length, + ...(bestBid !== null + ? { + bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : "p2p", + 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: prettyGweiToEth( + BigInt(bestBid.signedBid.message.value) + bestBid.signedBid.message.executionPayment + ), + bidCountedTotal: prettyGweiToEth(bestBid.totalGwei), + bidBoostFactor: bestBid.boostFactor, + builderIndex: bestBid.signedBid.message.builderIndex, + bidBlockHash: toRootHex(bestBid.signedBid.message.blockHash), + bidReceivedMs: bestBid.receivedMs, + } + : {}), + }; + // handle shouldOverrideBuilder separately - if (engineResult.status === "fulfilled" && engineResult.value.shouldOverrideBuilder && builderBid !== null) { + if ( + engineResult.status === "fulfilled" && + engineResult.value.shouldOverrideBuilder && + (builderBidExpected || bidBlockResult.status === "fulfilled") + ) { source = ProducedBlockSource.engine; bestResult = engineResult; metrics?.blockProductionSelectionResults.inc({ source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.BuilderCensorship, }); - logger.warn("Selected local block: censorship suspected in builder bid", logCtx); - } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { + logger.warn("Selected local block: censorship suspected in builder bid", { + ...logCtx, + durationMs: engineResult.durationMs, + ...getBlockValueLogInfo(engineResult.value), + }); + } else if (engineResult.status === "fulfilled" && bidBlockResult.status === "fulfilled") { const result = selectBlockProductionSourceByBoostFactor({ - builderBoostFactor, + builderBoostFactor: bestBid?.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 its counted executionPayment, in Gwei + builderExecutionPayloadValue: (bestBid?.totalGwei ?? 0n) * GWEI_TO_WEI, }); source = result.source; metrics?.blockProductionSelectionResults.inc(result); - logger.info(`Selected ${source} block`, {reason: result.reason, ...logCtx}); - bestResult = source === ProducedBlockSource.builder ? bidResult : engineResult; - } else if (bidResult.status === "fulfilled") { + logger.info(`Selected ${source} block`, { + reason: result.reason, + ...logCtx, + engineDurationMs: engineResult.durationMs, + ...getBlockValueLogInfo(engineResult.value, ProducedBlockSource.engine), + builderDurationMs: bidBlockResult.durationMs, + }); + 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 @@ -1008,28 +1197,32 @@ export function getValidatorApi( logger.info("Selected builder bid block: no local block produced", { reason, ...logCtx, + 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 = - builderBid === null + 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}); logger.info("Selected local block: no builder bid block produced", { reason, ...logCtx, - error: bidResult.status === "rejected" ? (bidResult.reason as Error).message : undefined, + durationMs: engineResult.durationMs, + ...getBlockValueLogInfo(engineResult.value), + 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), @@ -1093,7 +1286,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 ? bestBid?.url : undefined, + }, }; }, @@ -1877,6 +2076,43 @@ export function getValidatorApi( } }, + async submitBuilderPreferences({builderPreferences}) { + const failures: FailureList = []; + + await Promise.all( + builderPreferences.map(async (entry, i) => { + let builder = Buffer.from(entry.url).toString("utf8"); + try { + const url = decodeBuilderUrl(entry.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, + }); + } catch (e) { + failures.push({index: i, message: (e as Error).message}); + logger.verbose( + `Error on submitBuilderPreferences [${i}]`, + {slot: entry.auth.message.slot, builder}, + 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/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/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 0e5f26b1fb62..fabcfbd0b8c8 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, 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"; @@ -165,6 +166,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; @@ -275,6 +277,7 @@ export class BeaconChain implements IBeaconChain { isAnchorStateFinalized, executionEngine, executionBuilder, + builderApiClientOpts, }: { privateKey: PrivateKey; config: BeaconConfig; @@ -292,6 +295,7 @@ export class BeaconChain implements IBeaconChain { isAnchorStateFinalized: boolean; executionEngine: IExecutionEngine; executionBuilder?: IExecutionBuilder; + builderApiClientOpts?: BuilderApiClientOpts; } ) { this.opts = opts; @@ -448,6 +452,8 @@ export class BeaconChain implements IBeaconChain { {forkChoice, logger, metrics} ); + this.builderApiClient = new BuilderApiClient(builderApiClientOpts ?? {}, config, clock, bls, metrics, logger); + this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({ config, clock, diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index cc56f8eee10d..ed8e1c5d9cc0 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -24,6 +24,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"; @@ -99,6 +100,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/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/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; 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/execution/builder/apiClient.ts b/packages/beacon-node/src/execution/builder/apiClient.ts new file mode 100644 index 000000000000..643e6cc93ac3 --- /dev/null +++ b/packages/beacon-node/src/execution/builder/apiClient.ts @@ -0,0 +1,244 @@ +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 {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 {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 + * 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, 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_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` */ + timeout?: number; + // 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. */ +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; + signedBid: gloas.SignedExecutionPayloadBid; + /** Time in milliseconds from the slot start when the bid was received */ + receivedMs: number; +}; + +/** + * 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`. + */ +export class BuilderApiClient { + private readonly clients = new Map(); + + 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 + ) {} + + /** + * 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 + ): Promise { + const seenRequests = new Set(); + const requests: {url: BuilderUrl; entry: routes.validator.BuilderEntry}[] = []; + + for (const entry of entries) { + 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; + } + + 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) { + 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}); + } + + // 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); + const res = await client.getExecutionPayloadBid( + { + slot, + parentHash, + parentRoot, + proposerPubkey, + requestAuth: entry.auth, + dateMilliseconds: Date.now(), + 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; + } + this.metrics?.builderApi.bidsReceived.inc(); + 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); + } + }) + ); + + return bids; + } + + /** Forward a proposer's builder preferences to the builder at the given url */ + async submitBuilderPreferences( + url: BuilderUrl, + proposerPubkey: BLSPubkey, + request: gloas.BuilderPreferencesRequest + ): Promise { + try { + 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"}); + 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 { + 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 client.submitSignedBeaconBlock({signedBlock}, {redirect: "manual"})).assertOk(); + this.metrics?.builderApi.blockSubmissions.inc({status: "success"}); + } catch (e) { + this.metrics?.builderApi.blockSubmissions.inc({status: "error"}); + throw e; + } + } + + private async getOrCreateClient( + url: BuilderUrl, + 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); + 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, + globalInit: { + timeoutMs: this.opts.timeout, + headers: this.opts.userAgent ? {"User-Agent": this.opts.userAgent} : undefined, + }, + }, + {config: this.config, metrics: this.metrics?.builderHttpClient, logger: this.logger} + ); + this.clients.set(url, client); + this.logger?.info("External builder registered", {url: toPrintableUrl(url)}); + } + return client; + } +} diff --git a/packages/beacon-node/src/execution/builder/validateBid.ts b/packages/beacon-node/src/execution/builder/validateBid.ts new file mode 100644 index 000000000000..78b422c58e39 --- /dev/null +++ b/packages/beacon-node/src/execution/builder/validateBid.ts @@ -0,0 +1,166 @@ +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, 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"; + +/** 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=${prettyGweiToEth(totalPayment)} ` + + `(value=${prettyGweiToEth(bid.value)} ` + + `executionPayment=${prettyGweiToEth(bid.executionPayment)}) ` + + `is below minBid=${prettyGweiToEth(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=${entry.builderPubkeys.map(toHex).join(",")}` + ); + } + + 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=${prettyGweiToEth(bid.value)} ` + `balance=${prettyGweiToEth(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/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 6c034f9206b5..33adde705d28 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -2029,6 +2029,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/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index efd660b9ef55..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); + 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); diff --git a/packages/beacon-node/src/node/nodejs.ts b/packages/beacon-node/src/node/nodejs.ts index f360315ee00a..c16f75669e98 100644 --- a/packages/beacon-node/src/node/nodejs.ts +++ b/packages/beacon-node/src/node/nodejs.ts @@ -260,6 +260,11 @@ export class BeaconNode { executionBuilder: opts.executionBuilder.enabled ? initializeExecutionBuilder(opts.executionBuilder, config, metrics, logger) : undefined, + 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 diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 5e35df573beb..cb3da0d61162 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,11 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { add: vi.fn(), getBestBid: vi.fn(), }, + builderApiClient: { + getExecutionPayloadBids: vi.fn().mockResolvedValue([]), + submitBuilderPreferences: vi.fn(), + submitSignedBeaconBlock: 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/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index a6482b2e66ae..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 {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"; +import {config as forkConfig, generateBlockWithColumnSidecars} from "../../../../../utils/blocksAndData.js"; import {generateProtoBlock} from "../../../../../utils/typeGenerator.js"; vi.mock("../../../../../../src/chain/blocks/verifyBlock.js"); @@ -67,6 +72,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( @@ -211,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); + }); }); 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..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 @@ -1,14 +1,22 @@ 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 {ssz} from "@lodestar/types"; +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"; +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/execution/builder/validateBid.js", async (importActual) => ({ + ...(await importActual()), + validateBuilderApiExecutionPayloadBid: vi.fn().mockResolvedValue(undefined), +})); + describe("api/validator - produceBlockV4", () => { let modules: ApiTestModules; let api: ReturnType; @@ -31,6 +39,10 @@ describe("api/validator - produceBlockV4", () => { const graffiti = "a".repeat(32); const maxBuilderBoostFactor = 2n ** 64n - 1n; + function getBuilderConfig(overrides: {minBid?: bigint; builderBoostFactor?: bigint} = {}) { + return {minBid: 0n, builderBoostFactor: 100n, builders: [], ...overrides}; + } + const engineBlock = ssz.gloas.BeaconBlock.defaultValue(); engineBlock.slot = slot; engineBlock.proposerIndex = 1; @@ -56,15 +68,16 @@ 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); 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, })); }); @@ -74,7 +87,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, @@ -82,6 +95,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, + builderConfig: getBuilderConfig(), }); expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledWith( @@ -96,12 +110,12 @@ 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, - executionPayloadValue: BigInt(2e9), - consensusBlockValue: BigInt(0), + executionPayloadValue: 2_000_000_000n, + consensusBlockValue: 0n, })); const {data: block, meta} = await api.produceBlockV4({ @@ -110,16 +124,17 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, + builderConfig: getBuilderConfig(), }); 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 () => { 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, @@ -127,23 +142,22 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderBoostFactor: BigInt(0), + 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); }); 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"); } - return {block: bidBlock, executionPayloadValue: BigInt(0), consensusBlockValue: BigInt(0)}; + return {block: bidBlock, executionPayloadValue: 0n, consensusBlockValue: 0n}; }); const {data: block} = await api.produceBlockV4({ @@ -152,10 +166,10 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderBoostFactor: BigInt(0), + 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); }); @@ -164,14 +178,14 @@ 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) { 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}; } ); @@ -182,7 +196,7 @@ describe("api/validator - produceBlockV4", () => { feeRecipient, strictFeeRecipientCheck: true, includePayload: false, - builderBoostFactor: BigInt(0), + builderConfig: getBuilderConfig({builderBoostFactor: 0n}), }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); @@ -190,11 +204,334 @@ 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 = { + 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 = 2; + apiBid.message.builderIndex = 7; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + 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([ + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, + ]); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: 0n, builderBoostFactor: 100n, 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); + }); + + 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(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([ + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, + ]); + + 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("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, receivedMs: 0}, + {url: secondUrl, entry: secondEntry, signedBid: secondBid, receivedMs: 0}, + ]); + + 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 = { + url: new TextEncoder().encode(builderUrl), + auth: ssz.gloas.SignedBuilderRequestAuth.defaultValue(), + builderPubkeys: [], + 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(); + apiBid.message.value = builderBid.message.value; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + 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([ + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, + ]); + + const {data: block} = 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(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(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([ + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, + ]); + + 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 = { + url: new TextEncoder().encode(builderUrl), + auth: ssz.gloas.SignedBuilderRequestAuth.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(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([ + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, + ]); + + 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})); + }); + + 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.SignedBuilderRequestAuth.defaultValue(), + builderPubkeys: [], + maxExecutionPayment: 0n, + minBid: 0n, + builderBoostFactor: 100n, + }; + const apiBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + apiBid.message.value = 2; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + 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([ + {url: builderUrl, entry, signedBid: apiBid, receivedMs: 0}, + ]); + vi.mocked(validateBuilderApiExecutionPayloadBid).mockRejectedValueOnce(new Error("Invalid bid")); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: {minBid: 0n, builderBoostFactor: 100n, builders: [entry]}, + }); + + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid})); + expect(block).toEqual(bidBlock); + }); + + 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(toPooledBid(builderBid)); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderConfig: getBuilderConfig({minBid: 2n}), + }); + + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalled(); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + expect(block).toEqual(engineBlock); + }); + it("produces local block when no bid is available", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); - const {data: block} = await api.produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, 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); @@ -202,11 +539,33 @@ 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)); + 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, randaoReveal, graffiti, feeRecipient, includePayload: false}); + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + 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); @@ -214,12 +573,12 @@ 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, - executionPayloadValue: BigInt(2e9), - consensusBlockValue: BigInt(0), + executionPayloadValue: 2_000_000_000n, + consensusBlockValue: 0n, })); const {data: block} = await api.produceBlockV4({ @@ -228,7 +587,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderBoostFactor: maxBuilderBoostFactor, + builderConfig: getBuilderConfig({builderBoostFactor: maxBuilderBoostFactor}), }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); @@ -240,7 +599,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, @@ -248,6 +607,7 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, + builderConfig: getBuilderConfig(), }); expect(block).toEqual(bidBlock); @@ -261,9 +621,196 @@ 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(); }); + + type MatrixEntry = { + value: number; + executionPayment?: bigint; + maxExecutionPayment?: bigint; + boostFactor?: bigint; + receivedMs?: number; + }; + 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: "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}], + 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, + receivedMs: e.receivedMs ?? 1000 + i, + }; + }); + 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(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); + 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})); + } + }); + } }); + +function toPooledBid(signedBid: gloas.SignedExecutionPayloadBid | null) { + return signedBid === null ? null : {signedBid, receivedMs: 0}; +} 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..c0dfff5612e1 --- /dev/null +++ b/packages/beacon-node/test/unit/api/impl/validator/submitBuilderPreferences.test.ts @@ -0,0 +1,105 @@ +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"; +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(); + 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 UTF-8 url by index while submitting the other entries", async () => { + const validEntry = getEntry("https://builder.example.com"); + const invalidEntry = getEntry("https://invalid.example.com"); + invalidEntry.url = new Uint8Array([0xff]); + + 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: "Builder url must be valid UTF-8"}]); + expect(modules.chain.builderApiClient.submitBuilderPreferences).toHaveBeenCalledOnce(); + expect(modules.chain.builderApiClient.submitBuilderPreferences).toHaveBeenCalledWith( + "https://builder.example.com", + validEntry.proposerPubkey, + {preferences: {maxExecutionPayment: 0n}, auth: validEntry.auth} + ); + expect(modules.logger.verbose).toHaveBeenCalledWith( + "Error on submitBuilderPreferences [0]", + {slot: 1, builder: "�"}, + expect.any(Error) + ); + }); + + 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; + + 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) { + const auth = ssz.gloas.SignedBuilderRequestAuth.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/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); + }); }); 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..ac8b89af82d6 --- /dev/null +++ b/packages/beacon-node/test/unit/execution/builder/apiClient.test.ts @@ -0,0 +1,199 @@ +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 {IClock} from "../../../../src/util/clock.js"; +import {getMockedLogger} from "../../../mocks/loggerMock.js"; + +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, submitBuilderPreferences, submitSignedBeaconBlock}), +})); + +const bls = { + verifySignatureSets, + verifySignatureSetsSameMessage: vi.fn(), + close: vi.fn(), + canAcceptWork: vi.fn(), +} satisfies IBlsVerifier; +const clock = {msFromSlot: () => 250} as unknown as IClock; + +describe("execution/builder/apiClient", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + 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 logger = getMockedLogger(); + const client = new BuilderApiClient({}, config, clock, bls, null, logger); + const bids = await client.getExecutionPayloadBids( + [getBuilderEntry(invalidUrl, slot), validEntry], + slot, + new Uint8Array(32), + new Uint8Array(32), + new Uint8Array(48) + ); + + 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}); + }); + + 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, clock, bls); + const bids = await client.getExecutionPayloadBids( + [invalidUtf8Entry, nonAsciiEntry, validEntry], + slot, + new Uint8Array(32), + new Uint8Array(32), + new Uint8Array(48) + ); + + expect(bids).toEqual([ + {url: "https://builder.example.com", entry: validEntry, signedBid, receivedMs: expect.any(Number)}, + ]); + 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()}; + const logger = getMockedLogger(); + const client = new BuilderApiClient({}, config, clock, 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(); + preferences.auth.message.data = new Uint8Array([1]); + const signedBlock = {data: ssz.gloas.SignedBeaconBlock.defaultValue()}; + submitBuilderPreferences.mockResolvedValue({assertOk: vi.fn()}); + submitSignedBeaconBlock.mockResolvedValue({assertOk: vi.fn()}); + + const client = new BuilderApiClient({}, config, clock, bls); + await client.submitBuilderPreferences(url, proposerPubkey, preferences); + await client.submitSignedBeaconBlock(url, signedBlock); + + expect(verifySignatureSets).toHaveBeenCalledOnce(); + 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, clock, bls); + await client.submitBuilderPreferences(url, proposerPubkey, preferences); + const bids = await client.getExecutionPayloadBids( + [entry], + slot, + new Uint8Array(32), + new Uint8Array(32), + proposerPubkey + ); + + 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); + 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, 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, receivedMs: expect.any(Number)}]); + + 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, clock, 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 { + const auth = ssz.gloas.SignedBuilderRequestAuth.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, + }; +} diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index 3be87e1b4492..294d11f93dfb 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"]), + builders: parseBuilderUrls(args["builder.urls"]), }, }; @@ -273,6 +283,23 @@ 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 ?? {})]; + 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" + ); + } + } + return valProposerConfig; } diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index 6974de68d770..ed7e289782f5 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/lodestar-z/blst"; import { BuilderBoostFactorData, + BuilderConfigData, DeleteRemoteKeyStatus, DeletionStatus, FeeRecipientData, @@ -32,12 +33,13 @@ 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 { if (this.proposerConfigWriteDisabled === true) { - throw Error("proposerSettingsFile option activated"); + throw new ApiError(403, "proposerSettingsFile option activated"); } } @@ -379,6 +381,48 @@ export class KeymanagerApi implements Api { return {status: 204}; } + async getBuilderConfig({pubkey}: {pubkey: PubkeyHex}): ReturnType { + this.assertValidKnownPubkey(pubkey); + return {data: this.validator.validatorStore.getBuilderConfig(pubkey)}; + } + + async setBuilderConfig({ + pubkey, + builderConfig, + }: { + pubkey: PubkeyHex; + builderConfig: BuilderConfigData; + }): ReturnType { + this.checkIfProposerWriteEnabled(); + this.assertValidKnownPubkey(pubkey); + + if ( + this.allowDangerousTrustedPayments !== true && + builderConfig.builders?.some((entry) => (entry.maxExecutionPayment ?? 0n) > 0n) + ) { + 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 deleteBuilderConfig({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}; + } + async getProposerConfig({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.assertValidKnownPubkey(pubkey); @@ -390,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/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..406dd4bed879 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,38 @@ export const validatorOptions: CliCommandOptions = { group: "builder", }, + "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. Only used post-Gloas", + defaultDescription: `${defaultOptions.builderMinBid}`, + group: "builder", + }, + + "builder.urls": { + 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(",")), + group: "builder", + }, + + "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", + 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..89d98d49d0cf 100644 --- a/packages/cli/src/util/proposerConfig.ts +++ b/packages/cli/src/util/proposerConfig.ts @@ -1,10 +1,16 @@ 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_AUTH_DATA_SIZE, MAX_BUILDER_ENTRIES, MAX_BUILDER_URL_SIZE} from "@lodestar/params"; +import {fromHex, isValidAsciiHttpUrl, 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 BUILDER_AUTH_DATA_PATTERN = new RegExp(`^0x(?:[a-fA-F0-9]{2}){1,${MAX_BUILDER_AUTH_DATA_SIZE}}$`); + type ProposerConfig = ValidatorProposerConfig["defaultConfig"]; type ProposerConfigFileSection = { @@ -17,6 +23,9 @@ type ProposerConfigFileSection = { gas_limit?: number; selection?: routes.validator.BuilderSelection; boost_factor?: bigint; + min_bid?: bigint; + max_execution_payment?: bigint; + builders?: unknown; }; }; @@ -54,7 +63,14 @@ 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, + builders, + } = builder || {}; if (graffiti !== undefined && typeof graffiti !== "string") { throw Error("graffiti is not 'string"); @@ -79,6 +95,29 @@ 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"); + } + + 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), + builders: overrideConfig?.builder?.builders ?? parseBuilderEntries(builders), + } + : undefined; + + if (overrideConfig?.builder?.builders !== undefined && builders !== undefined) { + throw Error("Cannot configure both --builder.urls and builders in the proposer settings file"); + } return { graffiti: overrideConfig?.graffiti ?? graffiti, @@ -86,21 +125,27 @@ function parseProposerConfigSection( 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), - } - : undefined, + builder: parsedBuilder, }; } -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 { @@ -131,6 +176,100 @@ 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 parsed; +} + +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"); + } + const parsed = BigInt(minBid); + if (parsed > UINT64_MAX) { + throw Error("Invalid input for builder min bid, must not exceed 2**64 - 1"); + } - return BigInt(boostFactor); + return parsed; +} + +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"); + } + const parsed = BigInt(amount); + if (parsed > UINT64_MAX) { + throw Error("Invalid input for builder Gwei amount, must not exceed 2**64 - 1"); + } + + return parsed; +} + +/** + * 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 ?? []) { + 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 entryKey = `${entry.url}|${authData}`; + if (seenEntries.has(entryKey)) { + throw Error(`Duplicate builder entry url=${entry.url}`); + } + seenEntries.add(entryKey); + } + return entries; +} + +/** + * 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 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); + if (!isValidAsciiHttpUrl(url)) { + 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 (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_BUILDER_AUTH_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}`); + } + seenEntries.add(entryKey); + entries.push({url, authData}); + } + if (entries.length > MAX_BUILDER_ENTRIES) { + throw Error(`Number of builder urls must not exceed ${MAX_BUILDER_ENTRIES}, got ${entries.length}`); + } + return entries; } 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(); + }); +}); 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..b795536ea348 --- /dev/null +++ b/packages/cli/test/unit/validator/parseBuilderUrls.test.ts @@ -0,0 +1,33 @@ +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(/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/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/params/src/index.ts b/packages/params/src/index.ts index 579166dc0cbd..f1df3edc0b6e 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_BUILDER_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_BUILDER_AUTH_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 6b3d14fd7af6..8e60c8c1208a 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_BUILDER_AUTH_DATA_SIZE, MIN_SEED_LOOKAHEAD, NEXT_SYNC_COMMITTEE_DEPTH_GLOAS, NUMBER_OF_COLUMNS, @@ -357,6 +359,39 @@ export const SignedExecutionPayloadBid = new ContainerType( {typeName: "SignedExecutionPayloadBid", jsonCase: "eth2"} ); +// Builder API types (builder-specs) + +export const BuilderRequestAuth = new ContainerType( + { + data: new ByteListType(MAX_BUILDER_AUTH_DATA_SIZE), + slot: Slot, + }, + {typeName: "BuilderRequestAuth", jsonCase: "eth2"} +); + +export const SignedBuilderRequestAuth = new ContainerType( + { + message: BuilderRequestAuth, + signature: BLSSignature, + }, + {typeName: "SignedBuilderRequestAuth", jsonCase: "eth2"} +); + +export const BuilderPreferences = new ContainerType( + { + maxExecutionPayment: Gwei, + }, + {typeName: "BuilderPreferences", jsonCase: "eth2"} +); + +export const BuilderPreferencesRequest = new ContainerType( + { + preferences: BuilderPreferences, + auth: SignedBuilderRequestAuth, + }, + {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..d4b29bc182d6 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 BuilderRequestAuth = ValueOf; +export type SignedBuilderRequestAuth = 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/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 */ 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/block.ts b/packages/validator/src/services/block.ts index 4e545f7527d9..81f85642fb1b 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.getBuilderRequestAuth(pubkey, entry.authData, slot, slot); + return { + url: new TextEncoder().encode(entry.url), + 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..91380d946a2e --- /dev/null +++ b/packages/validator/src/services/builderPreferences.ts @@ -0,0 +1,142 @@ +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.getBuilderRequestAuth(duty.pubkey, entry.authData, duty.slot, slot); + dutyEntries.push({ + proposerPubkey: duty.pubkey, + url: new TextEncoder().encode(entry.url), + 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, 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); + } + 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 77460fea250c..f383decc8574 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -1,6 +1,7 @@ import {SecretKey} from "@chainsafe/lodestar-z/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, @@ -8,6 +9,7 @@ import { DOMAIN_BEACON_ATTESTER, DOMAIN_BEACON_BUILDER, DOMAIN_BEACON_PROPOSER, + DOMAIN_BUILDER_REQUEST_AUTH, DOMAIN_CONTRIBUTION_AND_PROOF, DOMAIN_PROPOSER_PREFERENCES, DOMAIN_PTC_ATTESTER, @@ -16,6 +18,7 @@ import { DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, ForkSeq, + MAX_BUILDER_AUTH_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, isValidAsciiHttpUrl, 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; + builders?: BuilderEntryConfig[]; }; }; @@ -95,9 +101,23 @@ export type ProposerConfig = { gasLimit?: number; selection?: routes.validator.BuilderSelection; boostFactor?: bigint; + minBid?: bigint; + maxExecutionPayment?: bigint; + /** Per-key builder entries, replacing the validator client's builders */ + 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 +152,8 @@ export type Signer = SignerLocal | SignerRemote; type ValidatorData = ProposerConfig & { signer: Signer; builderData?: BuilderData; + /** Pre-signed builder request auths keyed by proposal slot and auth data, pruned by proposal slot */ + builderRequestAuths?: Map; }; export const defaultOptions = { @@ -139,7 +161,10 @@ export const defaultOptions = { defaultGasLimit: 60_000_000, builderSelection: routes.validator.BuilderSelection.ExecutionOnly, builderAliasSelection: routes.validator.BuilderSelection.Default, - builderBoostFactor: BigInt(100), + builderBoostFactor: 100n, + builderMinBid: 0n, + // 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, // should request fetching the locally produced block in blinded format @@ -185,6 +210,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, + builders: defaultConfig.builder?.builders, }, }; @@ -287,11 +315,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 the 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" @@ -313,8 +352,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: @@ -409,6 +447,126 @@ 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; + } + + /** + * Resolve the builder entries for this key. Per-key entries replace the validator client's + * 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); + 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; + + // 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 TextEncoder().encode(entry.url), + builderPubkeys: (entry.builderPubkeys ?? []).map(fromHex), + maxExecutionPayment: entry.maxExecutionPayment ?? keyMaxExecutionPayment, + minBid: entry.minBid ?? keyMinBid, + builderBoostFactor: entry.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`); + } + const {boostFactor} = this.resolveBuilderSelectionParams(pubkeyHex, true); + + return { + minBid: validatorData.builder?.minBid ?? this.defaultProposerConfig.builder.minBid, + builderBoostFactor: boostFactor, + builders: this.getResolvedBuilderEntries(pubkeyHex, boostFactor).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 ?? []) { + if (!isValidAsciiHttpUrl(entry.url)) { + throw Error(`Invalid builder url: ${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} authData=${authData}`); + } + 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, and revert to the validator client configuration */ + 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 +588,10 @@ export class ValidatorStore { feeRecipient !== undefined || builder?.gasLimit !== undefined || builder?.selection !== undefined || - builder?.boostFactor !== undefined + builder?.boostFactor !== undefined || + builder?.minBid !== undefined || + builder?.maxExecutionPayment !== undefined || + builder?.builders !== undefined ) { proposerConfig = {graffiti, strictFeeRecipientCheck, feeRecipient, builder}; } @@ -879,6 +1040,71 @@ export class ValidatorStore { }; } + async signBuilderRequestAuth( + pubkeyMaybeHex: BLSPubkeyMaybeHex, + data: Uint8Array, + proposalSlot: Slot + ): Promise { + 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_BUILDER_AUTH_DATA_SIZE} bytes` + ); + } + + const message: gloas.BuilderRequestAuth = {data, slot: proposalSlot}; + + const signingSlot = 0; + 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.BUILDER_REQUEST_AUTH, + data: message, + }; + + return { + message, + signature: await this.getSignature(pubkeyMaybeHex, signingRoot, signingSlot, signableMessage), + }; + } + + /** + * 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 getBuilderRequestAuth( + 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?.builderRequestAuths?.get(authKey); + if (cached !== undefined) { + return cached; + } + + const signedRequestAuth = await this.signBuilderRequestAuth(pubkeyMaybeHex, data, proposalSlot); + + if (validatorData !== undefined) { + const builderRequestAuths = + validatorData.builderRequestAuths ?? new Map(); + // Prune auths for proposal slots that are already in the past + for (const key of builderRequestAuths.keys()) { + if (Number(key.slice(0, key.indexOf("-"))) < currentSlot) { + builderRequestAuths.delete(key); + } + } + builderRequestAuths.set(authKey, signedRequestAuth); + validatorData.builderRequestAuths = builderRequestAuths; + } + + 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..9582540b4121 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", + BUILDER_REQUEST_AUTH = "BUILDER_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.BUILDER_REQUEST_AUTH; data: gloas.BuilderRequestAuth}; const requiresForkInfo: Record = { [SignableMessageType.AGGREGATION_SLOT]: true, @@ -105,6 +107,7 @@ const requiresForkInfo: Record = { [SignableMessageType.EXECUTION_PAYLOAD_ENVELOPE]: true, [SignableMessageType.PAYLOAD_ATTESTATION]: true, [SignableMessageType.PROPOSER_PREFERENCES]: true, + [SignableMessageType.BUILDER_REQUEST_AUTH]: false, }; type Web3SignerSerializedRequest = { @@ -285,6 +288,9 @@ function serializerSignableMessagePayload(config: BeaconConfig, payload: Signabl case SignableMessageType.PROPOSER_PREFERENCES: return {proposer_preferences: ssz.gloas.ProposerPreferences.toJson(payload.data)}; + + case SignableMessageType.BUILDER_REQUEST_AUTH: + return {builder_request_auth: ssz.gloas.BuilderRequestAuth.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 768e8d51ac3d..94c405840ff2 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(0n); + 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: 0n, builderBoostFactor: 0n, builders: []}, }); }); }); diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index 5abcf862aa36..dc646c465294 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/lodestar-z/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_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"; import {getApiClientStub} from "../utils/apiStub.js"; import {getMockedLogger} from "../utils/logger.js"; @@ -113,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 () => { @@ -192,6 +204,114 @@ describe("ValidatorStore", () => { recommendedGasLimit, }); }); + + 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.signBuilderRequestAuth(pubkeys[0], data, proposalSlot); + + expect(toHexString(signedRequestAuth.message.data)).toBe(toHexString(data)); + expect(signedRequestAuth.message.slot).toBe(proposalSlot); + + 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.BuilderRequestAuth, + {data: Buffer.from("other"), slot: proposalSlot}, + 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 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", () => { + 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: 10n, + builders: [ + {url: builderUrl, maxExecutionPayment: 5n}, + {url: builderUrl, authData: "0x1234", minBid: 20n, builderBoostFactor: 120n}, + ], + }); + + 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(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(20n); + expect(entries[1].builderBoostFactor).toBe(120n); + + // GET returns the configuration fully resolved + const config = validatorStore.getBuilderConfig(pubkey); + expect(config.minBid).toBe(10n); + 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(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}, + {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(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: []}); + expect(store.getResolvedBuilderEntries(pubkey)).toEqual([]); + }); }); const secretKeys = Array.from({length: 3}, (_, i) => SecretKey.fromBytes(toBufferBE(BigInt(i + 1), 32)));