diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 923cfcb7ccc1..7b03349198d6 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -80,16 +80,18 @@ If you would like to set unique proposer metadata (e.g. fee recipient address) f ### Configure your builder selection and/or builder boost factor -If you are running a beacon node with connected builder relays, you may use these validator configurations to signal which block (builder vs. local execution) the beacon node should produce. +These validator configurations signal whether the beacon node should prefer a builder bid or a local execution payload. Before Gloas, builder bids require configured builder relays. Starting with Gloas, builder bids may be received in-protocol over p2p or out-of-protocol through a builder API. -With produceBlockV3 introduced in Deneb hard fork, the [`--builder.boostFactor`](./validator-cli.md#--builderboostfactor) is a 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`](./validator-cli.md#--builderselection) is set to anything other than `maxprofit`. Even though this is set on the validator client, the calculation is requested and applied on the beacon node itself. For more information, see the [produceBlockV3 Beacon API](https://ethereum.github.io/beacon-APIs/#/ValidatorRequiredApi/produceBlockV3). +With `produceBlockV3` introduced in Deneb hard fork, the [`--builder.boostFactor`](./validator-cli.md#--builderboostfactor) is a 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`](./validator-cli.md#--builderselection) is set to anything other than `maxprofit`. Even though this is set on the validator client, the calculation is requested and applied on the beacon node itself. For more information, see the [produceBlockV3 Beacon API](https://ethereum.github.io/beacon-APIs/#/ValidatorRequiredApi/produceBlockV3). + +With `produceBlockV4` introduced in Gloas, the validator client converts [`--builder.selection`](./validator-cli.md#--builderselection) aliases to a global `builder_boost_factor`, which applies to viable builder bids regardless of whether they were received over p2p or through a builder API. A value of `0` prefers the local payload but uses a viable builder bid if local production fails or is delayed. A value of `100` selects by profit, and `18446744073709551615` (2\*\*64 - 1) prefers the builder bid with local production as fallback. For more information, see the [produceBlockV4 Beacon API](https://ethereum.github.io/beacon-APIs/#/ValidatorRequiredApi/produceBlockV4). With Lodestar's [`--builder.selection`](./validator-cli.md#--builderselection) validator options, you can select: - `default`: Default setting for Lodestar set at `--builder.boostFactor=90`. This default setting will have a local block boost of ~10%. Note that this value might change in the future depending on what we think is the most appropriate value to help improve censorship resistance of Ethereum. - `maxprofit`: An alias of `--builder.boostFactor=100`, which will always choose the more profitable block. Using this option, you may customize your `--builder.boostFactor` to your preference. Examples of its usage are below. - `executionalways`: An alias of `--builder.boostFactor=0`, which will select the local execution block, unless it fails to produce due to an error or a delay in the response from the execution client. -- `executiononly`: Beacon node will be requested to produce local execution block even if builder relays are configured. This option will always select the local execution block and will error if it couldn't produce one. +- `executiononly`: Pre-Gloas only. The beacon node will produce a local execution block even if builder relays are configured and will error if it cannot produce one. Starting with Gloas, this is treated as `executionalways` so a viable builder bid can prevent a missed proposal when local production fails or is delayed. - `builderalways`: An alias of `--builder.boostFactor=18446744073709551615` (2\*\*64 - 1), which will select the builder block, unless the builder block fails to produce. The builder block may fail to produce if it's not available, not timely or there is an indication of censorship via `shouldOverrideBuilder` from the execution payload response. #### Calculating builder boost factor with examples diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index d9811daeedbd..9e40bda2821c 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -76,6 +76,12 @@ export type ExtraProduceBlockOpts = { blindedLocal?: boolean; }; +/** Lodestar-specific (non-standardized) options */ +export type ExtraProduceBlockV4Opts = { + feeRecipient?: string; + strictFeeRecipientCheck?: boolean; +}; + export const ProduceBlockV3MetaType = new ContainerType( { ...VersionType.fields, @@ -446,7 +452,7 @@ export type Endpoints = { builderBoostFactor?: UintBn64; /** Include execution payload envelope and blobs in the response when self-building */ includePayload: boolean; - } & Omit, + } & ExtraProduceBlockV4Opts, { params: {slot: number}; query: { @@ -454,7 +460,6 @@ export type Endpoints = { graffiti?: string; skip_randao_verification?: string; fee_recipient?: string; - builder_selection?: string; builder_boost_factor?: string; strict_fee_recipient_check?: boolean; include_payload: boolean; @@ -918,7 +923,6 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions = { skipRandaoVerification: true, builderBoostFactor: 0n, feeRecipient, - builderSelection: BuilderSelection.ExecutionAlways, strictFeeRecipientCheck: true, includePayload: true, }, diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index efee73fd1e3a..ebec783a5c70 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -92,7 +92,12 @@ import {getStateResponseWithRegen} from "../beacon/state/utils.js"; import {ApiError, FailureList, IndexedError, NodeIsSyncing, OnlySupportedByDVT} from "../errors.js"; import {ApiModules} from "../types.js"; import {notWhileSyncing} from "../utils.js"; -import {computeSubnetForCommitteesAtSlot, getPubkeysForIndices, selectBlockProductionSource} from "./utils.js"; +import { + computeSubnetForCommitteesAtSlot, + getPubkeysForIndices, + selectBlockProductionSource, + selectBlockProductionSourceByBoostFactor, +} from "./utils.js"; /** * Cutoff time to wait from start of the slot for execution and builder block production apis to resolve. @@ -577,8 +582,7 @@ export function getValidatorApi( builderSelection, isBuilderEnabled, strictFeeRecipientCheck, - // winston logger doesn't like bigint - builderBoostFactor: `${builderBoostFactor}`, + builderBoostFactor, }; logger.verbose("Assembling block with produceEngineOrBuilderBlock", loggerContext); @@ -851,8 +855,8 @@ export function getValidatorApi( randaoReveal, graffiti, feeRecipient, + strictFeeRecipientCheck, includePayload, - builderSelection, builderBoostFactor, }) { const fork = config.getForkName(slot); @@ -861,11 +865,6 @@ export function getValidatorApi( throw new ApiError(400, `produceBlockV4 not supported for pre-gloas fork=${fork}`); } - builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; - if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) { - logger.warn("Builder selection builderonly is no longer supported, treating as builderalways"); - builderSelection = routes.validator.BuilderSelection.BuilderAlways; - } builderBoostFactor = builderBoostFactor ?? BigInt(100); if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); @@ -896,14 +895,11 @@ 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; - // Bids are only skipped entirely with executiononly or while the circuit breaker is active, - // other engine-preferring selections still build a block with the best bid as fallback in - // case local production fails const circuitBreakerActive = chain.builderCircuitBreaker.isActive(slot); - const builderBid = - builderSelection === routes.validator.BuilderSelection.ExecutionOnly || circuitBreakerActive - ? null - : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + // 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, @@ -911,8 +907,8 @@ export function getValidatorApi( parentBlockRoot: parentBlockRootHex, parentBlockHash: parentBlock.executionPayloadBlockHash, fork, - builderSelection, builderBoostFactor, + strictFeeRecipientCheck, circuitBreakerActive, ...(builderBid !== null ? { @@ -936,6 +932,7 @@ export function getValidatorApi( randaoReveal, graffiti: graffitiBytes, feeRecipient, + strictFeeRecipientCheck, commonBlockBodyPromise, }; @@ -960,12 +957,8 @@ export function getValidatorApi( chain.produceBlock(baseAttrs) ).then((engineBlock) => { // No need to wait for the bid block if the engine block will always be selected due to - // suspected builder censorship, a builder boost factor of 0 or executionalways selection - if ( - engineBlock.shouldOverrideBuilder || - builderBoostFactor === BigInt(0) || - builderSelection === routes.validator.BuilderSelection.ExecutionAlways - ) { + // suspected builder censorship or a builder boost factor of 0 + if (engineBlock.shouldOverrideBuilder || builderBoostFactor === BigInt(0)) { controller.abort(); } return engineBlock; @@ -994,8 +987,7 @@ export function getValidatorApi( }); logger.warn("Selected local block: censorship suspected in builder bid", logCtx); } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") { - const result = selectBlockProductionSource({ - builderSelection, + const result = selectBlockProductionSourceByBoostFactor({ builderBoostFactor, engineExecutionPayloadValue: engineResult.value.executionPayloadValue, // The bid value is the payment to the proposer, in Gwei @@ -1061,7 +1053,10 @@ export function getValidatorApi( root: blockRoot, }); if (chain.opts.persistProducedBlocks) { - void chain.persistBlock(block, "produced_engine_block"); + void chain.persistBlock( + block, + source === ProducedBlockSource.builder ? "produced_builder_block" : "produced_engine_block" + ); } // Include the payload for self-builds unless disabled (stateless flow) diff --git a/packages/beacon-node/src/api/impl/validator/utils.ts b/packages/beacon-node/src/api/impl/validator/utils.ts index 81717fcbfd50..4fb9d3fc1114 100644 --- a/packages/beacon-node/src/api/impl/validator/utils.ts +++ b/packages/beacon-node/src/api/impl/validator/utils.ts @@ -59,24 +59,39 @@ export function selectBlockProductionSource({ return {source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.EnginePreferred}; case routes.validator.BuilderSelection.Default: - case routes.validator.BuilderSelection.MaxProfit: { - if (builderBoostFactor === BigInt(0)) { - return {source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.EnginePreferred}; - } - - if (builderBoostFactor === MAX_BUILDER_BOOST_FACTOR) { - return {source: ProducedBlockSource.builder, reason: BuilderBlockSelectionReason.BuilderPreferred}; - } - - if (engineExecutionPayloadValue >= (builderExecutionPayloadValue * builderBoostFactor) / BigInt(100)) { - return {source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.BlockValue}; - } - - return {source: ProducedBlockSource.builder, reason: BuilderBlockSelectionReason.BlockValue}; - } + case routes.validator.BuilderSelection.MaxProfit: + return selectBlockProductionSourceByBoostFactor({ + engineExecutionPayloadValue, + builderExecutionPayloadValue, + builderBoostFactor, + }); case routes.validator.BuilderSelection.BuilderAlways: case routes.validator.BuilderSelection.BuilderOnly: return {source: ProducedBlockSource.builder, reason: BuilderBlockSelectionReason.BuilderPreferred}; } } + +export function selectBlockProductionSourceByBoostFactor({ + engineExecutionPayloadValue, + builderExecutionPayloadValue, + builderBoostFactor, +}: { + engineExecutionPayloadValue: bigint; + builderExecutionPayloadValue: bigint; + builderBoostFactor: bigint; +}): BlockSelectionResult { + if (builderBoostFactor === BigInt(0)) { + return {source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.EnginePreferred}; + } + + if (builderBoostFactor === MAX_BUILDER_BOOST_FACTOR) { + return {source: ProducedBlockSource.builder, reason: BuilderBlockSelectionReason.BuilderPreferred}; + } + + if (engineExecutionPayloadValue >= (builderExecutionPayloadValue * builderBoostFactor) / BigInt(100)) { + return {source: ProducedBlockSource.engine, reason: EngineBlockSelectionReason.BlockValue}; + } + + return {source: ProducedBlockSource.builder, reason: BuilderBlockSelectionReason.BlockValue}; +} diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 73424830e98e..87f0a1e7272c 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1072,6 +1072,7 @@ export class BeaconChain implements IBeaconChain { graffiti, slot, feeRecipient, + strictFeeRecipientCheck, commonBlockBodyPromise, parentBlock, builderBid, @@ -1100,6 +1101,7 @@ export class BeaconChain implements IBeaconChain { graffiti, slot, feeRecipient, + strictFeeRecipientCheck, parentBlock, proposerIndex, proposerPubKey, diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 8350a7ad4707..fe8c4f80df4e 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -100,6 +100,8 @@ export type BlockAttributes = { slot: Slot; parentBlock: ProtoBlock; feeRecipient?: string; + /** Verify that a locally produced execution payload uses `feeRecipient`. */ + strictFeeRecipientCheck?: boolean; /** When provided, build block with this builder bid instead of a self-build bid */ builderBid?: gloas.SignedExecutionPayloadBid; }; @@ -201,6 +203,7 @@ export async function produceBlockBody( const { slot: blockSlot, feeRecipient: requestedFeeRecipient, + strictFeeRecipientCheck, parentBlock, proposerIndex, proposerPubKey, @@ -334,6 +337,16 @@ export async function produceBlockBody( executionPayloadValue = payloadRes.executionPayloadValue; shouldOverrideBuilder = payloadRes.shouldOverrideBuilder; + if ( + strictFeeRecipientCheck && + requestedFeeRecipient && + !byteArrayEquals(executionPayload.feeRecipient, fromHex(requestedFeeRecipient)) + ) { + throw Error( + `Invalid feeRecipient set in engine payload expected=${requestedFeeRecipient} actual=${toHex(executionPayload.feeRecipient)}` + ); + } + if (blobsBundle === undefined) { throw Error(`Missing blobsBundle response from getPayload at fork=${fork}`); } 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 e5ae586f8580..c14febaec2e3 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,5 +1,4 @@ import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; -import {routes} from "@lodestar/api"; import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName} from "@lodestar/params"; @@ -30,6 +29,7 @@ describe("api/validator - produceBlockV4", () => { const slot = 1; const feeRecipient = "0xccccccccccccccccccccccccccccccccccccccaa"; const graffiti = "a".repeat(32); + const maxBuilderBoostFactor = 2n ** 64n - 1n; const engineBlock = ssz.gloas.BeaconBlock.defaultValue(); engineBlock.slot = slot; @@ -117,7 +117,7 @@ describe("api/validator - produceBlockV4", () => { expect(meta.executionPayloadValue).toBe(BigInt(2e9)); }); - it("skips builder bids with executiononly selection", async () => { + it("prefers the local payload with a zero builder boost factor", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); @@ -127,14 +127,69 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderSelection: routes.validator.BuilderSelection.ExecutionOnly, + builderBoostFactor: BigInt(0), }); - expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); - expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); 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.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)}; + }); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderBoostFactor: BigInt(0), + }); + + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledOnce(); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(block).toEqual(bidBlock); + }); + + it("uses a builder bid as fallback when the local fee recipient does not match", async () => { + const builderBlock = ssz.gloas.BeaconBlock.defaultValue(); + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(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)}; + } + ); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + strictFeeRecipientCheck: true, + includePayload: false, + builderBoostFactor: BigInt(0), + }); + + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({strictFeeRecipientCheck: true})); + expect(block).toEqual(builderBlock); + }); + it("produces local block when no bid is available", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); @@ -157,10 +212,10 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(engineBlock); }); - it("treats deprecated builderonly selection as builderalways", async () => { + it("prefers the builder bid with the maximum builder boost factor", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); - // Bid (1 gwei) is preferred over the higher local payload value (2 gwei) since builderalways + // 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), @@ -173,16 +228,32 @@ describe("api/validator - produceBlockV4", () => { graffiti, feeRecipient, includePayload: false, - builderSelection: routes.validator.BuilderSelection.BuilderOnly, + builderBoostFactor: maxBuilderBoostFactor, }); - expect(modules.logger.warn).toHaveBeenCalledWith( - expect.stringContaining("Builder selection builderonly is no longer supported") - ); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); expect(block).toEqual(bidBlock); }); + it("persists a builder bid block with the builder source", async () => { + const persistBlock = vi.fn(); + Object.defineProperty(modules.chain, "persistBlock", {value: persistBlock}); + modules.chain.opts.persistProducedBlocks = true; + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + }); + + expect(block).toEqual(bidBlock); + expect(persistBlock).toHaveBeenCalledWith(bidBlock, "produced_builder_block"); + }); + it("rejects block production if parent block is optimistic", async () => { modules.chain.getProposerHead.mockReturnValue({ ...parentBlock, diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 231169c4ccee..f67dd92e50b6 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -262,7 +262,7 @@ export const validatorOptions: CliCommandOptions = { "builder.selection": { type: "string", description: - "Builder block selection strategy `default`, `maxprofit`, `builderalways`, `executionalways`, or `executiononly`", + "Builder block selection strategy `default`, `maxprofit`, `builderalways`, `executionalways`, or pre-Gloas `executiononly`", defaultDescription: `${defaultOptions.builderSelection}`, group: "builder", }, diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index 733528c39cc4..4e545f7527d9 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -190,11 +190,19 @@ export class BlockProposingService { const randaoReveal = await this.validatorStore.signRandao(pubkey, slot); const graffiti = this.validatorStore.getGraffiti(pubkeyHex); const feeRecipient = this.validatorStore.getFeeRecipient(pubkeyHex); + const strictFeeRecipientCheck = this.validatorStore.strictFeeRecipientCheck(pubkeyHex); const {broadcastValidation, payloadLocal} = this.opts; const {selection: builderSelection, boostFactor: builderBoostFactor} = this.validatorStore.getBuilderSelectionParams(pubkeyHex, slot); - this.logger.debug("Producing block", {...debugLogCtx, feeRecipient, payloadLocal, builderSelection}); + this.logger.debug("Producing block", { + ...debugLogCtx, + feeRecipient, + strictFeeRecipientCheck, + payloadLocal, + builderSelection, + builderBoostFactor, + }); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); // Step 1: Produce beacon block with execution payload bid @@ -204,8 +212,8 @@ export class BlockProposingService { randaoReveal, graffiti, feeRecipient, + strictFeeRecipientCheck, includePayload: !payloadLocal, - builderSelection, builderBoostFactor, }) .catch((e: Error) => { diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 4e69c2740526..7cc72895361b 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -283,18 +283,27 @@ export class ValidatorStore { pubkeyHex: PubkeyHex, slot?: Slot ): {selection: routes.validator.BuilderSelection; boostFactor: bigint} { - // Builder bids post-gloas are in-protocol over p2p, so the default strategy uses them - // (as if `--builder` was set), unless the validator explicitly opted out. Pre-gloas - // there is no in-protocol builder, so the default remains local-only (executiononly). - const defaultSelection = - slot !== undefined && this.config.getForkSeq(slot) >= ForkSeq.gloas - ? defaultOptions.builderAliasSelection - : defaultOptions.builderSelection; - const selection = + // Builder bids post-gloas are in-protocol, so the default strategy uses them regardless of + // 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; + const defaultSelection = isPostGloas ? defaultOptions.builderAliasSelection : defaultOptions.builderSelection; + let selection = this.validators.get(pubkeyHex)?.builder?.selection ?? this.defaultProposerConfig.builder.selection ?? defaultSelection; + // Post-Gloas block production uses standard builder boost factor. Need to normalize the + // gloas-deprecated "builderonly" and "executiononly" to the gloas fallback "builderalways" + // and "executionalways" equivalent before deriving the boost factor. + if (isPostGloas) { + if (selection === routes.validator.BuilderSelection.BuilderOnly) { + selection = routes.validator.BuilderSelection.BuilderAlways; + } else if (selection === routes.validator.BuilderSelection.ExecutionOnly) { + selection = routes.validator.BuilderSelection.ExecutionAlways; + } + } + let boostFactor: bigint; switch (selection) { case routes.validator.BuilderSelection.Default: diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index 2619286b9f96..ffc3d4e2dbe3 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -189,4 +189,83 @@ describe("BlockDutiesService", () => { {signedBlindedBlock: signedBlock, broadcastValidation: routes.beacon.BroadcastValidation.consensus}, ]); }); + + it("Should pass strict fee recipient check when producing a Gloas block", async () => { + const gloasConfig = createChainForkConfig({...mainnetConfig, GLOAS_FORK_EPOCH: 0}); + const slot = 0; + api.validator.getProposerDuties.mockResolvedValue( + mockApiResponse({ + data: [{slot, validatorIndex: 0, pubkey: pubkeys[0]}], + meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}, + }) + ); + + const clock = new ClockMock(); + const dutiesService = new BlockDutiesService( + gloasConfig, + loggerVc, + api, + clock, + validatorStore, + chainHeaderTracker, + null + ); + const blockService = new BlockProposingService( + gloasConfig, + loggerVc, + api, + clock, + validatorStore, + dutiesService, + null, + { + broadcastValidation: routes.beacon.BroadcastValidation.consensus, + blindedLocal: false, + payloadLocal: false, + } + ); + + const signedBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + signedBlock.message.body.signedExecutionPayloadBid.message.builderIndex = 1; + const feeRecipient = "0xcccccccccccccccccccccccccccccccccccccccc"; + validatorStore.signRandao.mockResolvedValue(signedBlock.message.body.randaoReveal); + validatorStore.signBlock.mockImplementation(async (_, block) => ({ + message: block, + signature: signedBlock.signature, + })); + validatorStore.getBuilderSelectionParams.mockReturnValue({ + selection: routes.validator.BuilderSelection.ExecutionAlways, + boostFactor: BigInt(0), + }); + validatorStore.getGraffiti.mockReturnValue("aaaa"); + validatorStore.getFeeRecipient.mockReturnValue(feeRecipient); + validatorStore.strictFeeRecipientCheck.mockReturnValue(true); + + api.validator.produceBlockV4.mockResolvedValue( + mockApiResponse({ + data: signedBlock.message, + meta: { + version: ForkName.gloas, + executionPayloadValue: BigInt(1), + consensusBlockValue: BigInt(1), + executionPayloadIncluded: false, + }, + }) + ); + api.beacon.publishBlockV2.mockResolvedValue(mockApiResponse({})); + + const notifyBlockProductionFn = blockService["dutiesService"]["notifyBlockProductionFn"]; + notifyBlockProductionFn(1, [pubkeys[0]]); + await sleep(20, controller.signal); + + expect(api.validator.produceBlockV4).toHaveBeenCalledWith({ + slot: 1, + randaoReveal: signedBlock.message.body.randaoReveal, + graffiti: "aaaa", + feeRecipient, + strictFeeRecipientCheck: true, + includePayload: true, + builderBoostFactor: BigInt(0), + }); + }); }); diff --git a/packages/validator/test/unit/validatorStore.test.ts b/packages/validator/test/unit/validatorStore.test.ts index d119ca4ac67c..f482d91d3cad 100644 --- a/packages/validator/test/unit/validatorStore.test.ts +++ b/packages/validator/test/unit/validatorStore.test.ts @@ -70,9 +70,9 @@ describe("ValidatorStore", () => { expect(validatorStore.getGasLimit(toHexString(pubkeys[1]))).toBe(valProposerConfig.defaultConfig.builder?.gasLimit); }); - it("getBuilderSelectionParams honors explicit selection and resolves fork-aware default", async () => { + it("getBuilderSelectionParams resolves fork-aware defaults and aliases", async () => { const preGloasSlot = 0; - // pubkeys[0] explicitly configured executiononly, honored regardless of fork + // pubkeys[0] explicitly configured executiononly, honored pre-gloas expect(validatorStore.getBuilderSelectionParams(toHexString(pubkeys[0]), preGloasSlot)).toEqual({ selection: routes.validator.BuilderSelection.ExecutionOnly, boostFactor: BigInt(0), @@ -104,9 +104,9 @@ describe("ValidatorStore", () => { selection: routes.validator.BuilderSelection.Default, boostFactor: BigInt(90), }); - // Explicit executiononly is still honored post-gloas + // Post-gloas executiononly is a backwards-compatible alias for executionalways expect(gloasStore.getBuilderSelectionParams(toHexString(pubkeys[0]), gloasSlot)).toEqual({ - selection: routes.validator.BuilderSelection.ExecutionOnly, + selection: routes.validator.BuilderSelection.ExecutionAlways, boostFactor: BigInt(0), }); }); diff --git a/packages/validator/test/utils/apiStub.ts b/packages/validator/test/utils/apiStub.ts index 1c57ba0be662..435132404746 100644 --- a/packages/validator/test/utils/apiStub.ts +++ b/packages/validator/test/utils/apiStub.ts @@ -35,6 +35,7 @@ export function getApiClientStub(): ApiClientStub { getPtcDuties: vi.fn(), prepareBeaconCommitteeSubnet: vi.fn(), produceBlockV3: vi.fn(), + produceBlockV4: vi.fn(), getSyncCommitteeDuties: vi.fn(), prepareSyncCommitteeSubnets: vi.fn(), produceSyncCommitteeContribution: vi.fn(),