diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 78fc1a67d32c..0746797cbf0e 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -13,6 +13,7 @@ import { Slot, ssz, UintNum64, + UintBn64, ValidatorIndex, RootHex, StringType, @@ -53,6 +54,7 @@ export enum BuilderSelection { export type ExtraProduceBlockOps = { feeRecipient?: string; builderSelection?: BuilderSelection; + builderBoostFactor?: UintBn64; strictFeeRecipientCheck?: boolean; blindedLocal?: boolean; }; @@ -487,6 +489,7 @@ export type ReqTypes = { skip_randao_verification?: boolean; fee_recipient?: string; builder_selection?: string; + builder_boost_factor?: string; strict_fee_recipient_check?: boolean; blinded_local?: boolean; }; @@ -555,6 +558,7 @@ export function getReqSerializers(): ReqSerializers { fee_recipient: opts?.feeRecipient, skip_randao_verification: skipRandaoVerification, builder_selection: opts?.builderSelection, + builder_boost_factor: opts?.builderBoostFactor?.toString(), strict_fee_recipient_check: opts?.strictFeeRecipientCheck, blinded_local: opts?.blindedLocal, }, @@ -567,6 +571,7 @@ export function getReqSerializers(): ReqSerializers { { feeRecipient: query.fee_recipient, builderSelection: query.builder_selection as BuilderSelection, + builderBoostFactor: parseBuilderBoostFactor(query.builder_boost_factor), strictFeeRecipientCheck: query.strict_fee_recipient_check, blindedLocal: query.blinded_local, }, @@ -579,6 +584,7 @@ export function getReqSerializers(): ReqSerializers { fee_recipient: Schema.String, skip_randao_verification: Schema.Boolean, builder_selection: Schema.String, + builder_boost_factor: Schema.String, strict_fee_recipient_check: Schema.Boolean, blinded_local: Schema.Boolean, }, @@ -785,3 +791,7 @@ export function getReturnTypes(): ReturnTypes { getLiveness: jsonType("snake"), }; } + +function parseBuilderBoostFactor(builderBoostFactorInput?: string | number | bigint): bigint | undefined { + return builderBoostFactorInput !== undefined ? BigInt(builderBoostFactorInput) : undefined; +} diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 09f5e7610604..48f928e86100 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -72,6 +72,10 @@ export type GasLimitData = { pubkey: string; gasLimit: number; }; +export type BuilderBoostFactorData = { + pubkey: string; + builderBoostFactor: bigint; +}; export type SignerDefinition = { pubkey: PubkeyHex; @@ -247,6 +251,27 @@ export type Api = { > >; + getBuilderBoostFactor( + pubkey: string + ): Promise>; + setBuilderBoostFactor( + pubkey: string, + builderBoostFactor: bigint + ): Promise< + ApiClientResponse< + {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, + HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND + > + >; + deleteBuilderBoostFactor( + pubkey: string + ): Promise< + ApiClientResponse< + {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, + HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND + > + >; + /** * Create a signed voluntary exit message for an active validator, identified by a public key known to the validator * client. This endpoint returns a `SignedVoluntaryExit` object, which can be used to initiate voluntary exit via the @@ -290,6 +315,10 @@ export const routesData: RoutesData = { setGasLimit: {url: "/eth/v1/validator/{pubkey}/gas_limit", method: "POST", statusOk: 202}, deleteGasLimit: {url: "/eth/v1/validator/{pubkey}/gas_limit", method: "DELETE", statusOk: 204}, + getBuilderBoostFactor: {url: "/eth/v1/validator/{pubkey}/builder_boost_factor", method: "GET"}, + setBuilderBoostFactor: {url: "/eth/v1/validator/{pubkey}/builder_boost_factor", method: "POST", statusOk: 202}, + deleteBuilderBoostFactor: {url: "/eth/v1/validator/{pubkey}/builder_boost_factor", method: "DELETE", statusOk: 204}, + signVoluntaryExit: {url: "/eth/v1/validator/{pubkey}/voluntary_exit", method: "POST"}, }; @@ -326,6 +355,10 @@ export type ReqTypes = { setGasLimit: {params: {pubkey: string}; body: {gas_limit: string}}; deleteGasLimit: {params: {pubkey: string}}; + getBuilderBoostFactor: {params: {pubkey: string}}; + setBuilderBoostFactor: {params: {pubkey: string}; body: {builder_boost_factor: string}}; + deleteBuilderBoostFactor: {params: {pubkey: string}}; + signVoluntaryExit: {params: {pubkey: string}; query: {epoch?: number}}; }; @@ -423,6 +456,33 @@ export function getReqSerializers(): ReqSerializers { params: {pubkey: Schema.StringRequired}, }, }, + + getBuilderBoostFactor: { + writeReq: (pubkey) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => [pubkey], + schema: { + params: {pubkey: Schema.StringRequired}, + }, + }, + setBuilderBoostFactor: { + writeReq: (pubkey, builderBoostFactor) => ({ + params: {pubkey}, + body: {builder_boost_factor: builderBoostFactor.toString(10)}, + }), + parseReq: ({params: {pubkey}, body: {builder_boost_factor}}) => [pubkey, BigInt(builder_boost_factor)], + schema: { + params: {pubkey: Schema.StringRequired}, + body: Schema.Object, + }, + }, + deleteBuilderBoostFactor: { + writeReq: (pubkey) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => [pubkey], + schema: { + params: {pubkey: Schema.StringRequired}, + }, + }, + signVoluntaryExit: { writeReq: (pubkey, epoch) => ({params: {pubkey}, query: epoch !== undefined ? {epoch} : {}}), parseReq: ({params: {pubkey}, query: {epoch}}) => [pubkey, epoch], @@ -455,6 +515,15 @@ export function getReturnTypes(): ReturnTypes { {jsonCase: "eth2"} ) ), + getBuilderBoostFactor: ContainerData( + new ContainerType( + { + pubkey: stringType, + builderBoostFactor: ssz.UintBn64, + }, + {jsonCase: "eth2"} + ) + ), signVoluntaryExit: ContainerData(ssz.phase0.SignedVoluntaryExit), }; } diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index c10f67fa4095..2688f2080eba 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -50,7 +50,13 @@ export const testData: GenericServerTestCases = { randaoReveal, graffiti, undefined, - {feeRecipient: undefined, builderSelection: undefined, strictFeeRecipientCheck: undefined}, + { + feeRecipient, + builderSelection: undefined, + strictFeeRecipientCheck: undefined, + blindedLocal: undefined, + builderBoostFactor: 100n, + }, ] as unknown as GenericServerTestCases["produceBlock"]["args"], res: {data: ssz.phase0.BeaconBlock.defaultValue()}, }, @@ -60,7 +66,13 @@ export const testData: GenericServerTestCases = { randaoReveal, graffiti, undefined, - {feeRecipient: undefined, builderSelection: undefined, strictFeeRecipientCheck: undefined}, + { + feeRecipient, + builderSelection: undefined, + strictFeeRecipientCheck: undefined, + blindedLocal: undefined, + builderBoostFactor: 100n, + }, ] as unknown as GenericServerTestCases["produceBlockV2"]["args"], res: { data: ssz.altair.BeaconBlock.defaultValue(), @@ -75,7 +87,13 @@ export const testData: GenericServerTestCases = { randaoReveal, graffiti, true, - {feeRecipient, builderSelection: undefined, strictFeeRecipientCheck: undefined}, + { + feeRecipient, + builderSelection: undefined, + strictFeeRecipientCheck: undefined, + blindedLocal: undefined, + builderBoostFactor: 100n, + }, ], res: { data: ssz.altair.BeaconBlock.defaultValue(), @@ -92,7 +110,13 @@ export const testData: GenericServerTestCases = { randaoReveal, graffiti, undefined, - {feeRecipient: undefined, builderSelection: undefined, strictFeeRecipientCheck: undefined}, + { + feeRecipient, + builderSelection: undefined, + strictFeeRecipientCheck: undefined, + blindedLocal: undefined, + builderBoostFactor: 100n, + }, ] as unknown as GenericServerTestCases["produceBlindedBlock"]["args"], res: { data: ssz.bellatrix.BlindedBeaconBlock.defaultValue(), diff --git a/packages/api/test/unit/keymanager/testData.ts b/packages/api/test/unit/keymanager/testData.ts index a4fc72fc8e2d..2c66610c8733 100644 --- a/packages/api/test/unit/keymanager/testData.ts +++ b/packages/api/test/unit/keymanager/testData.ts @@ -13,6 +13,7 @@ const pubkeyRand = "0x84105a985058fc8740a48bf1ede9d223ef09e8c6b1735ba0a55cf4a9ff const ethaddressRand = "0xabcf8e0d4e9587369b2301d0790347320302cc09"; const graffitiRandUtf8 = "636861696e736166652f6c6f64657374"; const gasLimitRand = 30_000_000; +const builderBoostFactorRand = BigInt(100); export const testData: GenericServerTestCases = { listKeys: { @@ -99,4 +100,16 @@ export const testData: GenericServerTestCases = { args: [pubkeyRand, 1], res: {data: ssz.phase0.SignedVoluntaryExit.defaultValue()}, }, + getBuilderBoostFactor: { + args: [pubkeyRand], + res: {data: {pubkey: pubkeyRand, builderBoostFactor: builderBoostFactorRand}}, + }, + setBuilderBoostFactor: { + args: [pubkeyRand, builderBoostFactorRand], + res: undefined, + }, + deleteBuilderBoostFactor: { + args: [pubkeyRand], + res: undefined, + }, }; diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 395684960c7f..9f98c606d525 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -19,6 +19,7 @@ import { isForkExecution, ForkSeq, } from "@lodestar/params"; +import {MAX_BUILDER_BOOST_FACTOR} from "@lodestar/validator"; import { Root, Slot, @@ -423,7 +424,12 @@ export function getValidatorApi({ graffiti, // TODO deneb: skip randao verification _skipRandaoVerification?: boolean, - {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOps = {} + { + feeRecipient, + builderSelection, + builderBoostFactor, + strictFeeRecipientCheck, + }: routes.validator.ExtraProduceBlockOps = {} ) { notWhileSyncing(); await waitForSlot(slot); // Must never request for a future slot > currentSlot @@ -436,7 +442,14 @@ export function getValidatorApi({ const fork = config.getForkName(slot); // set some sensible opts + // builderSelection will be deprecated and will run in mode MaxProfit if builder is enabled + // and the actual selection will be determined using builderBoostFactor passed by the validator builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; + builderBoostFactor = builderBoostFactor ?? BigInt(100); + if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { + throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); + } + const isBuilderEnabled = ForkSeq[fork] >= ForkSeq.bellatrix && chain.executionBuilder !== undefined && @@ -448,6 +461,8 @@ export function getValidatorApi({ slot, isBuilderEnabled, strictFeeRecipientCheck, + // winston logger doesn't like bigint + builderBoostFactor: `${builderBoostFactor}`, }); // Start calls for building execution and builder blocks const blindedBlockPromise = isBuilderEnabled @@ -541,7 +556,12 @@ export function getValidatorApi({ if (fullBlock && blindedBlock) { switch (builderSelection) { case routes.validator.BuilderSelection.MaxProfit: { - if (blockValueEngine >= blockValueBuilder) { + if ( + // explicitly handle the two special values mentioned in spec for builder preferred / engine preferred + builderBoostFactor !== MAX_BUILDER_BOOST_FACTOR && + (builderBoostFactor === BigInt(0) || + blockValueEngine >= (blockValueBuilder * builderBoostFactor) / BigInt(100)) + ) { executionPayloadSource = ProducedBlockSource.engine; } else { executionPayloadSource = ProducedBlockSource.builder; @@ -562,6 +582,7 @@ export function getValidatorApi({ logger.verbose(`Selected executionPayloadSource=${executionPayloadSource} block`, { builderSelection, // winston logger doesn't like bigint + builderBoostFactor: `${builderBoostFactor}`, enginePayloadValue: `${enginePayloadValue}`, builderPayloadValue: `${builderPayloadValue}`, consensusBlockValueEngine: `${consensusBlockValueEngine}`, diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index 69e4610bff0e..57b00d78c706 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -227,6 +227,7 @@ function getProposerConfigFromArgs( selection: parseBuilderSelection( args["builder.selection"] ?? (args["builder"] ? defaultOptions.builderAliasSelection : undefined) ), + boostFactor: args["builder.boostFactor"], }, }; diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index 2abda3c9642e..4628c96285df 100644 --- a/packages/cli/src/cmds/validator/keymanager/impl.ts +++ b/packages/cli/src/cmds/validator/keymanager/impl.ts @@ -390,6 +390,29 @@ export class KeymanagerApi implements Api { }; } + async getBuilderBoostFactor(pubkeyHex: string): ReturnType { + const builderBoostFactor = this.validator.validatorStore.getBuilderBoostFactor(pubkeyHex); + return {data: {pubkey: pubkeyHex, builderBoostFactor}}; + } + + async setBuilderBoostFactor(pubkeyHex: string, builderBoostFactor: bigint): Promise { + this.checkIfProposerWriteEnabled(); + this.validator.validatorStore.setBuilderBoostFactor(pubkeyHex, builderBoostFactor); + this.persistedKeysBackend.writeProposerConfig( + pubkeyHex, + this.validator.validatorStore.getProposerConfig(pubkeyHex) + ); + } + + async deleteBuilderBoostFactor(pubkeyHex: string): Promise { + this.checkIfProposerWriteEnabled(); + this.validator.validatorStore.deleteBuilderBoostFactor(pubkeyHex); + this.persistedKeysBackend.writeProposerConfig( + pubkeyHex, + this.validator.validatorStore.getProposerConfig(pubkeyHex) + ); + } + /** * Create and sign a voluntary exit message for an active validator */ diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 41069cfbdd34..e68e04a4b884 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -45,6 +45,7 @@ export type IValidatorCliArgs = AccountValidatorArgs & builder?: boolean; "builder.selection"?: string; + "builder.boostFactor"?: bigint; useProduceBlockV3?: boolean; broadcastValidation?: string; @@ -246,6 +247,14 @@ export const validatorOptions: CliCommandOptions = { group: "builder", }, + "builder.boostFactor": { + type: "number", + description: + "Percentage multiplier the block producing beacon node must apply to boost (>100) or dampen (<100) builder block value for selection against execution block. The multiplier is ignored if `--builder.selection` is set to anything other than `maxprofit`", + defaultDescription: `${defaultOptions.builderBoostFactor}`, + group: "builder", + }, + useProduceBlockV3: { type: "boolean", description: "Enable/disable usage of produceBlockV3 that might not be supported by all beacon clients yet", diff --git a/packages/validator/src/index.ts b/packages/validator/src/index.ts index 381db59b7c85..4619b924ef63 100644 --- a/packages/validator/src/index.ts +++ b/packages/validator/src/index.ts @@ -1,5 +1,5 @@ export {Validator, type ValidatorOptions} from "./validator.js"; -export {ValidatorStore, SignerType, defaultOptions} from "./services/validatorStore.js"; +export {ValidatorStore, SignerType, defaultOptions, MAX_BUILDER_BOOST_FACTOR} from "./services/validatorStore.js"; export type { Signer, SignerLocal, diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index b59902870c92..c11af904b34d 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -121,13 +121,15 @@ export class BlockProposingService { const debugLogCtx = {...logCtx, validator: pubkeyHex}; const strictFeeRecipientCheck = this.validatorStore.strictFeeRecipientCheck(pubkeyHex); - const builderSelection = this.validatorStore.getBuilderSelection(pubkeyHex); + const {selection: builderSelection, boostFactor: builderBoostFactor} = + this.validatorStore.getBuilderSelectionParams(pubkeyHex); const feeRecipient = this.validatorStore.getFeeRecipient(pubkeyHex); const blindedLocal = this.opts.blindedLocal; this.logger.debug("Producing block", { ...debugLogCtx, builderSelection, + builderBoostFactor, feeRecipient, strictFeeRecipientCheck, useProduceBlockV3: this.opts.useProduceBlockV3, @@ -139,15 +141,20 @@ export class BlockProposingService { const produceOpts = { feeRecipient, strictFeeRecipientCheck, - builderSelection, + builderBoostFactor, blindedLocal, }; - const blockContents = await produceBlockFn(this.config, slot, randaoReveal, graffiti, produceOpts).catch( - (e: Error) => { - this.metrics?.blockProposingErrors.inc({error: "produce"}); - throw extendError(e, "Failed to produce block"); - } - ); + const blockContents = await produceBlockFn( + this.config, + slot, + randaoReveal, + graffiti, + produceOpts, + builderSelection + ).catch((e: Error) => { + this.metrics?.blockProposingErrors.inc({error: "produce"}); + throw extendError(e, "Failed to produce block"); + }); this.logger.debug("Produced block", {...debugLogCtx, ...blockContents.debugLogCtx}); this.metrics?.blocksProduced.inc(); @@ -195,13 +202,15 @@ export class BlockProposingService { slot: Slot, randaoReveal: BLSSignature, graffiti: string, - {feeRecipient, strictFeeRecipientCheck, builderSelection, blindedLocal}: routes.validator.ExtraProduceBlockOps + {feeRecipient, strictFeeRecipientCheck, builderBoostFactor, blindedLocal}: routes.validator.ExtraProduceBlockOps, + builderSelection: routes.validator.BuilderSelection ): Promise => { const res = await this.api.validator.produceBlockV3(slot, randaoReveal, graffiti, false, { feeRecipient, builderSelection, strictFeeRecipientCheck, blindedLocal, + builderBoostFactor, }); ApiError.assert(res, "Failed to produce block: validator.produceBlockV2"); const {response} = res; @@ -223,7 +232,7 @@ export class BlockProposingService { api: "produceBlockV3", }; - return parseProduceBlockResponse(response, debugLogCtx); + return parseProduceBlockResponse(response, debugLogCtx, builderSelection); }; /** a wrapper function used for backward compatibility with the clients who don't have v3 implemented yet */ @@ -232,7 +241,8 @@ export class BlockProposingService { slot: Slot, randaoReveal: BLSSignature, graffiti: string, - {builderSelection}: routes.validator.ExtraProduceBlockOps + _opts: routes.validator.ExtraProduceBlockOps, + builderSelection: routes.validator.BuilderSelection ): Promise => { // other clients have always implemented builder vs execution race in produce blinded block // so if builderSelection is executiononly then only we call produceBlockV2 else produceBlockV3 always @@ -248,7 +258,8 @@ export class BlockProposingService { return parseProduceBlockResponse( {executionPayloadBlinded: false, executionPayloadSource, ...response}, - debugLogCtx + debugLogCtx, + builderSelection ); } else { Object.assign(debugLogCtx, {api: "produceBlindedBlock"}); @@ -259,7 +270,8 @@ export class BlockProposingService { return parseProduceBlockResponse( {executionPayloadBlinded: true, executionPayloadSource, ...response}, - debugLogCtx + debugLogCtx, + builderSelection ); } }; @@ -267,15 +279,29 @@ export class BlockProposingService { function parseProduceBlockResponse( response: routes.validator.ProduceFullOrBlindedBlockOrContentsRes, - debugLogCtx: Record + debugLogCtx: Record, + builderSelection: routes.validator.BuilderSelection ): FullOrBlindedBlockWithContents & DebugLogCtx { + const executionPayloadSource = response.executionPayloadSource; + + if ( + (builderSelection === routes.validator.BuilderSelection.BuilderOnly && + executionPayloadSource === ProducedBlockSource.engine) || + (builderSelection === routes.validator.BuilderSelection.ExecutionOnly && + executionPayloadSource === ProducedBlockSource.builder) + ) { + throw Error( + `Block not produced as per desired builderSelection=${builderSelection} executionPayloadSource=${executionPayloadSource}` + ); + } + if (response.executionPayloadBlinded) { return { block: response.data, contents: null, version: response.version, executionPayloadBlinded: true, - executionPayloadSource: response.executionPayloadSource, + executionPayloadSource, debugLogCtx, } as FullOrBlindedBlockWithContents & DebugLogCtx; } else { @@ -285,7 +311,7 @@ function parseProduceBlockResponse( contents: {blobs: response.data.blobs, kzgProofs: response.data.kzgProofs}, version: response.version, executionPayloadBlinded: false, - executionPayloadSource: response.executionPayloadSource, + executionPayloadSource, debugLogCtx, } as FullOrBlindedBlockWithContents & DebugLogCtx; } else { @@ -294,7 +320,7 @@ function parseProduceBlockResponse( contents: null, version: response.version, executionPayloadBlinded: false, - executionPayloadSource: response.executionPayloadSource, + executionPayloadSource, debugLogCtx, } as FullOrBlindedBlockWithContents & DebugLogCtx; } diff --git a/packages/validator/src/services/prepareBeaconProposer.ts b/packages/validator/src/services/prepareBeaconProposer.ts index 7ca939fb0c41..7d7907a4592d 100644 --- a/packages/validator/src/services/prepareBeaconProposer.ts +++ b/packages/validator/src/services/prepareBeaconProposer.ts @@ -86,7 +86,8 @@ export function pollBuilderValidatorRegistration( .filter( (pubkeyHex): pubkeyHex is string => pubkeyHex !== undefined && - validatorStore.getBuilderSelection(pubkeyHex) !== routes.validator.BuilderSelection.ExecutionOnly + validatorStore.getBuilderSelectionParams(pubkeyHex).selection !== + routes.validator.BuilderSelection.ExecutionOnly ); if (pubkeyHexes.length > 0) { diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 8cafaa5b14b6..809ca0c8a7c6 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -69,6 +69,7 @@ type DefaultProposerConfig = { builder: { gasLimit: number; selection: routes.validator.BuilderSelection; + boostFactor: bigint; }; }; @@ -79,6 +80,7 @@ export type ProposerConfig = { builder?: { gasLimit?: number; selection?: routes.validator.BuilderSelection; + boostFactor?: bigint; }; }; @@ -123,6 +125,7 @@ export const defaultOptions = { defaultGasLimit: 30_000_000, builderSelection: routes.validator.BuilderSelection.ExecutionOnly, builderAliasSelection: routes.validator.BuilderSelection.MaxProfit, + builderBoostFactor: BigInt(100), // turn it off by default, turn it back on once other clients support v3 api useProduceBlockV3: false, // spec asks for gossip validation by default @@ -131,6 +134,8 @@ export const defaultOptions = { blindedLocal: false, }; +export const MAX_BUILDER_BOOST_FACTOR = BigInt(2 ** 64 - 1); + /** * Service that sets up and handles validator attester duties. */ @@ -155,6 +160,11 @@ export class ValidatorStore { this.metrics = metrics; const defaultConfig = valProposerConfig.defaultConfig; + const builderBoostFactor = defaultConfig.builder?.boostFactor ?? defaultOptions.builderBoostFactor; + if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { + throw Error(`Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR for defaultConfig`); + } + this.defaultProposerConfig = { graffiti: defaultConfig.graffiti ?? "", strictFeeRecipientCheck: defaultConfig.strictFeeRecipientCheck ?? false, @@ -162,6 +172,7 @@ export class ValidatorStore { builder: { gasLimit: defaultConfig.builder?.gasLimit ?? defaultOptions.defaultGasLimit, selection: defaultConfig.builder?.selection ?? defaultOptions.builderSelection, + boostFactor: builderBoostFactor, }, }; @@ -252,8 +263,27 @@ export class ValidatorStore { delete validatorData["graffiti"]; } - getBuilderSelection(pubkeyHex: PubkeyHex): routes.validator.BuilderSelection { - return (this.validators.get(pubkeyHex)?.builder || {}).selection ?? this.defaultProposerConfig.builder.selection; + getBuilderSelectionParams(pubkeyHex: PubkeyHex): {selection: routes.validator.BuilderSelection; boostFactor: bigint} { + const selection = + (this.validators.get(pubkeyHex)?.builder || {}).selection ?? this.defaultProposerConfig.builder.selection; + + let boostFactor; + switch (selection) { + case routes.validator.BuilderSelection.MaxProfit: + boostFactor = + (this.validators.get(pubkeyHex)?.builder || {}).boostFactor ?? this.defaultProposerConfig.builder.boostFactor; + break; + + case routes.validator.BuilderSelection.BuilderAlways: + case routes.validator.BuilderSelection.BuilderOnly: + boostFactor = MAX_BUILDER_BOOST_FACTOR; + break; + + case routes.validator.BuilderSelection.ExecutionOnly: + boostFactor = BigInt(0); + } + + return {selection, boostFactor}; } strictFeeRecipientCheck(pubkeyHex: PubkeyHex): boolean { @@ -286,6 +316,34 @@ export class ValidatorStore { delete validatorData.builder?.gasLimit; } + getBuilderBoostFactor(pubkeyHex: PubkeyHex): bigint { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + return validatorData?.builder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor; + } + + setBuilderBoostFactor(pubkeyHex: PubkeyHex, boostFactor: bigint): void { + if (boostFactor > MAX_BUILDER_BOOST_FACTOR) { + throw Error(`Invalid builderBoostFactor=${boostFactor} > MAX_BUILDER_BOOST_FACTOR`); + } + + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + validatorData.builder = {...validatorData.builder, boostFactor}; + } + + deleteBuilderBoostFactor(pubkeyHex: PubkeyHex): void { + const validatorData = this.validators.get(pubkeyHex); + if (validatorData === undefined) { + throw Error(`Validator pubkey ${pubkeyHex} not known`); + } + delete validatorData.builder?.boostFactor; + } + /** Return true if `index` is active part of this validator client */ hasValidatorIndex(index: ValidatorIndex): boolean { return this.indicesService.index2pubkey.has(index); @@ -315,6 +373,10 @@ export class ValidatorStore { async addSigner(signer: Signer, valProposerConfig?: ValidatorProposerConfig): Promise { const pubkey = getSignerPubkeyHex(signer); const proposerConfig = (valProposerConfig?.proposerConfig ?? {})[pubkey]; + const builderBoostFactor = proposerConfig?.builder?.boostFactor; + if (builderBoostFactor !== undefined && builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { + throw Error(`Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR for pubkey=${pubkey}`); + } if (!this.validators.has(pubkey)) { // Doppelganger registration must be done before adding validator to signers diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index f879017c95f1..3677cdac3a7a 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -59,6 +59,14 @@ describe("BlockDutiesService", function () { const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); validatorStore.signRandao.resolves(signedBlock.message.body.randaoReveal); validatorStore.signBlock.callsFake(async (_, block) => ({message: block, signature: signedBlock.signature})); + validatorStore.getBuilderSelectionParams.returns({ + selection: routes.validator.BuilderSelection.MaxProfit, + boostFactor: BigInt(100), + }); + validatorStore.getGraffiti.returns("aaaa"); + validatorStore.getFeeRecipient.returns("0x00"); + validatorStore.strictFeeRecipientCheck.returns(false); + api.validator.produceBlockV3.resolves({ response: { data: signedBlock.message, @@ -86,6 +94,24 @@ describe("BlockDutiesService", function () { [signedBlock, {broadcastValidation: routes.beacon.BroadcastValidation.consensus}], "wrong publishBlock() args" ); + + // ProduceBlockV3 is called with all correct arguments + expect(api.validator.produceBlockV3.getCall(0).args).to.deep.equal( + [ + 1, + signedBlock.message.body.randaoReveal, + "aaaa", + false, + { + feeRecipient: "0x00", + builderSelection: routes.validator.BuilderSelection.MaxProfit, + strictFeeRecipientCheck: false, + blindedLocal: false, + builderBoostFactor: BigInt(100), + }, + ], + "wrong produceBlockV3() args" + ); }); it("Should produce, sign, and publish a blinded block", async function () {