From 8819e9a79b2af0380e748bb62761d0467400fe7f Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 12:19:18 +0100 Subject: [PATCH 01/15] refactor: use builder boost factor for gloas block production --- .../validator-management/vc-configuration.md | 6 ++- packages/api/src/beacon/routes/validator.ts | 7 +-- .../test/unit/beacon/testData/validator.ts | 1 - .../src/api/impl/validator/index.ts | 44 ++++++------------ .../src/api/impl/validator/utils.ts | 45 ++++++++++++------- .../api/impl/validator/produceBlockV4.test.ts | 44 +++++++++++++----- packages/cli/src/cmds/validator/options.ts | 2 +- packages/validator/src/services/block.ts | 1 - .../validator/src/services/validatorStore.ts | 22 ++++++--- .../test/unit/validatorStore.test.ts | 8 ++-- 10 files changed, 101 insertions(+), 79 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 923cfcb7ccc1..67445dc4add8 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 are received in-protocol over p2p. 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 standard `builder_boost_factor`. 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..e3de4c4d11da 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -446,7 +446,7 @@ export type Endpoints = { builderBoostFactor?: UintBn64; /** Include execution payload envelope and blobs in the response when self-building */ includePayload: boolean; - } & Omit, + } & Omit, { params: {slot: number}; query: { @@ -454,7 +454,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 +917,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..81d63d72b826 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. @@ -846,26 +851,13 @@ export function getValidatorApi( return {data, meta}; }, - async produceBlockV4({ - slot, - randaoReveal, - graffiti, - feeRecipient, - includePayload, - builderSelection, - builderBoostFactor, - }) { + async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload, builderBoostFactor}) { const fork = config.getForkName(slot); if (!isForkPostGloas(fork)) { 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 +888,10 @@ 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); + const builderBid = circuitBreakerActive + ? null + : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); const logCtx = { slot, @@ -911,7 +899,6 @@ export function getValidatorApi( parentBlockRoot: parentBlockRootHex, parentBlockHash: parentBlock.executionPayloadBlockHash, fork, - builderSelection, builderBoostFactor, circuitBreakerActive, ...(builderBid !== null @@ -960,12 +947,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 +977,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 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/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index e5ae586f8580..b297b905c341 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,39 @@ 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("produces local block when no bid is available", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); @@ -157,10 +182,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,12 +198,9 @@ 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); }); 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..b37097eee806 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -205,7 +205,6 @@ export class BlockProposingService { graffiti, feeRecipient, 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..f0a067c6e4de 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -284,17 +284,25 @@ export class ValidatorStore { 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 = + // (as if `--builder` was set). 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 the standardized builder boost factor. Normalize legacy + // "only" selections to their fallback-safe "always" equivalents before deriving that 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/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), }); }); From 800d0719d95a1205b19dc2fca4c0e67ebcea3c0e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 12:22:24 +0100 Subject: [PATCH 02/15] docs: clarify gloas builder bid sources --- docs/pages/run/validator-management/vc-configuration.md | 2 +- packages/validator/src/services/validatorStore.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 67445dc4add8..854ed492266a 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -80,7 +80,7 @@ If you would like to set unique proposer metadata (e.g. fee recipient address) f ### Configure your builder selection and/or builder boost factor -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 are received in-protocol over p2p. +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 are in-protocol and may be received over p2p or 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). diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index f0a067c6e4de..94e6a765c7b7 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -283,9 +283,9 @@ 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). Pre-gloas there is no in-protocol builder, so the default - // remains local-only (executiononly). + // 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 = From 6770dc8e2afae19d3dd5a9c81c9f89da3d7b28f3 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 15:54:14 +0100 Subject: [PATCH 03/15] docs: format produce block API names --- docs/pages/run/validator-management/vc-configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 854ed492266a..7fa87e98e0aa 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -82,9 +82,9 @@ If you would like to set unique proposer metadata (e.g. fee recipient address) f 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 are in-protocol and may be received over p2p or 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 standard `builder_boost_factor`. 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 `produceBlockV4` introduced in Gloas, the validator client converts [`--builder.selection`](./validator-cli.md#--builderselection) aliases to a standard `builder_boost_factor`. 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: From 585762504f067615584fc52e4f0da714687d0670 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 16:08:58 +0100 Subject: [PATCH 04/15] fix: validate fee recipient for gloas block production --- packages/api/src/beacon/routes/validator.ts | 8 +- .../src/api/impl/validator/index.ts | 40 +++++++++- .../api/impl/validator/produceBlockV4.test.ts | 79 +++++++++++++++++++ packages/validator/src/services/block.ts | 11 ++- .../test/unit/services/block.test.ts | 79 +++++++++++++++++++ packages/validator/test/utils/apiStub.ts | 1 + 6 files changed, 212 insertions(+), 6 deletions(-) diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index e3de4c4d11da..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: { diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 81d63d72b826..d7c311078b9c 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -851,7 +851,15 @@ export function getValidatorApi( return {data, meta}; }, - async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient, includePayload, builderBoostFactor}) { + async produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + strictFeeRecipientCheck, + includePayload, + builderBoostFactor, + }) { const fork = config.getForkName(slot); if (!isForkPostGloas(fork)) { @@ -899,7 +907,9 @@ export function getValidatorApi( parentBlockRoot: parentBlockRootHex, parentBlockHash: parentBlock.executionPayloadBlockHash, fork, - builderBoostFactor, + // winston logger doesn't like bigint + builderBoostFactor: `${builderBoostFactor}`, + strictFeeRecipientCheck, circuitBreakerActive, ...(builderBid !== null ? { @@ -926,6 +936,19 @@ export function getValidatorApi( commonBlockBodyPromise, }; + const assertFeeRecipient = (block: BeaconBlock): void => { + if (strictFeeRecipientCheck && feeRecipient) { + const blockFeeRecipient = toHex( + (block as gloas.BeaconBlock).body.signedExecutionPayloadBid.message.feeRecipient + ); + if (blockFeeRecipient !== feeRecipient) { + throw Error( + `Invalid feeRecipient set in execution payload bid expected=${feeRecipient} actual=${blockFeeRecipient}` + ); + } + } + }; + metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); if (builderBid !== null) { metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); @@ -946,6 +969,7 @@ export function getValidatorApi( const enginePromise: ReturnType = timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs) ).then((engineBlock) => { + assertFeeRecipient(engineBlock.block); // 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)) { @@ -955,7 +979,12 @@ export function getValidatorApi( }); const bidPromise: ReturnType = builderBid !== null - ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})) + ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})).then( + (bidBlock) => { + assertFeeRecipient(bidBlock.block); + return bidBlock; + } + ) : Promise.reject(new Error("No builder bid available")); const [engineResult, bidResult] = await resolveOrRacePromises([enginePromise, bidPromise], { @@ -1043,7 +1072,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/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index b297b905c341..628cec23e930 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 @@ -160,6 +160,66 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(bidBlock); }); + it("uses a builder bid as fallback when the local fee recipient does not match", async () => { + const expectedFeeRecipient = Buffer.from(feeRecipient.slice(2), "hex"); + const mismatchedFeeRecipient = Buffer.alloc(20, 0xdd); + const localBlock = ssz.gloas.BeaconBlock.defaultValue(); + localBlock.body.signedExecutionPayloadBid.message.feeRecipient = mismatchedFeeRecipient; + const builderBlock = ssz.gloas.BeaconBlock.defaultValue(); + builderBlock.body.signedExecutionPayloadBid.message.feeRecipient = expectedFeeRecipient; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? builderBlock : localBlock, + 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(block).toEqual(builderBlock); + }); + + it("uses the local payload as fallback when the builder fee recipient does not match", async () => { + const expectedFeeRecipient = Buffer.from(feeRecipient.slice(2), "hex"); + const mismatchedFeeRecipient = Buffer.alloc(20, 0xdd); + const localBlock = ssz.gloas.BeaconBlock.defaultValue(); + localBlock.body.signedExecutionPayloadBid.message.feeRecipient = expectedFeeRecipient; + const builderBlock = ssz.gloas.BeaconBlock.defaultValue(); + builderBlock.body.signedExecutionPayloadBid.message.feeRecipient = mismatchedFeeRecipient; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? builderBlock : localBlock, + executionPayloadValue: BigInt(0), + consensusBlockValue: BigInt(0), + })); + + const {data: block} = await api.produceBlockV4({ + slot, + randaoReveal, + graffiti, + feeRecipient, + strictFeeRecipientCheck: true, + includePayload: false, + builderBoostFactor: maxBuilderBoostFactor, + }); + + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(block).toEqual(localBlock); + }); + it("produces local block when no bid is available", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); @@ -205,6 +265,25 @@ describe("api/validator - produceBlockV4", () => { 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/validator/src/services/block.ts b/packages/validator/src/services/block.ts index b37097eee806..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,6 +212,7 @@ export class BlockProposingService { randaoReveal, graffiti, feeRecipient, + strictFeeRecipientCheck, includePayload: !payloadLocal, builderBoostFactor, }) 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/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(), From 795a1fd92d50281ab78295cc97220df0ec7a2f92 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 17:18:03 +0100 Subject: [PATCH 05/15] docs: clarify gloas builder boost factor --- docs/pages/run/validator-management/vc-configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages/run/validator-management/vc-configuration.md b/docs/pages/run/validator-management/vc-configuration.md index 7fa87e98e0aa..7b03349198d6 100644 --- a/docs/pages/run/validator-management/vc-configuration.md +++ b/docs/pages/run/validator-management/vc-configuration.md @@ -80,11 +80,11 @@ If you would like to set unique proposer metadata (e.g. fee recipient address) f ### Configure your builder selection and/or builder boost factor -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 are in-protocol and may be received over p2p or through a builder API. +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 `produceBlockV4` introduced in Gloas, the validator client converts [`--builder.selection`](./validator-cli.md#--builderselection) aliases to a standard `builder_boost_factor`. 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 `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: From d86978c4429d1fab88a34e8fb3023b0fed4b19c0 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 17:23:31 +0100 Subject: [PATCH 06/15] refactor: log builder boost factor directly --- packages/beacon-node/src/api/impl/validator/index.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index d7c311078b9c..a2cd8525799f 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -582,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); @@ -907,8 +906,7 @@ export function getValidatorApi( parentBlockRoot: parentBlockRootHex, parentBlockHash: parentBlock.executionPayloadBlockHash, fork, - // winston logger doesn't like bigint - builderBoostFactor: `${builderBoostFactor}`, + builderBoostFactor, strictFeeRecipientCheck, circuitBreakerActive, ...(builderBid !== null From afdb897fc07b94cc4d59a80e6196c69e2f0742c7 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 17:30:46 +0100 Subject: [PATCH 07/15] fix: only check local gloas fee recipient --- .../src/api/impl/validator/index.ts | 31 +++++++------------ .../api/impl/validator/produceBlockV4.test.ts | 4 +-- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index a2cd8525799f..ad167692b0ec 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -934,19 +934,6 @@ export function getValidatorApi( commonBlockBodyPromise, }; - const assertFeeRecipient = (block: BeaconBlock): void => { - if (strictFeeRecipientCheck && feeRecipient) { - const blockFeeRecipient = toHex( - (block as gloas.BeaconBlock).body.signedExecutionPayloadBid.message.feeRecipient - ); - if (blockFeeRecipient !== feeRecipient) { - throw Error( - `Invalid feeRecipient set in execution payload bid expected=${feeRecipient} actual=${blockFeeRecipient}` - ); - } - } - }; - metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); if (builderBid !== null) { metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder}); @@ -967,7 +954,16 @@ export function getValidatorApi( const enginePromise: ReturnType = timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs) ).then((engineBlock) => { - assertFeeRecipient(engineBlock.block); + if (strictFeeRecipientCheck && feeRecipient) { + const blockFeeRecipient = toHex( + (engineBlock.block as gloas.BeaconBlock).body.signedExecutionPayloadBid.message.feeRecipient + ); + if (blockFeeRecipient !== feeRecipient) { + throw Error( + `Invalid feeRecipient set in execution payload bid expected=${feeRecipient} actual=${blockFeeRecipient}` + ); + } + } // 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)) { @@ -977,12 +973,7 @@ export function getValidatorApi( }); const bidPromise: ReturnType = builderBid !== null - ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})).then( - (bidBlock) => { - assertFeeRecipient(bidBlock.block); - return bidBlock; - } - ) + ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid})) : Promise.reject(new Error("No builder bid available")); const [engineResult, bidResult] = await resolveOrRacePromises([enginePromise, bidPromise], { 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 628cec23e930..6a0ccf2d6682 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 @@ -190,7 +190,7 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(builderBlock); }); - it("uses the local payload as fallback when the builder fee recipient does not match", async () => { + it("does not recheck the fee recipient of a validated builder bid", async () => { const expectedFeeRecipient = Buffer.from(feeRecipient.slice(2), "hex"); const mismatchedFeeRecipient = Buffer.alloc(20, 0xdd); const localBlock = ssz.gloas.BeaconBlock.defaultValue(); @@ -217,7 +217,7 @@ describe("api/validator - produceBlockV4", () => { }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); - expect(block).toEqual(localBlock); + expect(block).toEqual(builderBlock); }); it("produces local block when no bid is available", async () => { From 8b79473ab56bba369e10aef32ac4efaafae38f07 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 17:32:55 +0100 Subject: [PATCH 08/15] docs: clarify gloas builder fallback --- packages/beacon-node/src/api/impl/validator/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index ad167692b0ec..3880526aa4ab 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -896,6 +896,7 @@ export function getValidatorApi( const isBuildingOnFull = chain.forkChoice.shouldBuildOnFull(parentBlock, slot); const bidParentBlockHash = isBuildingOnFull ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash; const circuitBreakerActive = chain.builderCircuitBreaker.isActive(slot); + // Only the circuit breaker disables builder bids; a zero boost factor still keeps the best bid as a fallback. const builderBid = circuitBreakerActive ? null : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); From a3eeb8313144424ece6c1dd9fbadc8962e25187b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 17:37:52 +0100 Subject: [PATCH 09/15] fix: check gloas payload fee recipient --- .../src/api/impl/validator/index.ts | 11 +--- packages/beacon-node/src/chain/chain.ts | 2 + .../chain/produceBlock/produceBlockBody.ts | 12 +++++ .../api/impl/validator/produceBlockV4.test.ts | 50 ++++--------------- 4 files changed, 25 insertions(+), 50 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 3880526aa4ab..3ef2f2006a20 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -932,6 +932,7 @@ export function getValidatorApi( randaoReveal, graffiti: graffitiBytes, feeRecipient, + strictFeeRecipientCheck, commonBlockBodyPromise, }; @@ -955,16 +956,6 @@ export function getValidatorApi( const enginePromise: ReturnType = timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs) ).then((engineBlock) => { - if (strictFeeRecipientCheck && feeRecipient) { - const blockFeeRecipient = toHex( - (engineBlock.block as gloas.BeaconBlock).body.signedExecutionPayloadBid.message.feeRecipient - ); - if (blockFeeRecipient !== feeRecipient) { - throw Error( - `Invalid feeRecipient set in execution payload bid expected=${feeRecipient} actual=${blockFeeRecipient}` - ); - } - } // 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)) { 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 0cbe5141b337..3719c35edd00 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,15 @@ export async function produceBlockBody( executionPayloadValue = payloadRes.executionPayloadValue; shouldOverrideBuilder = payloadRes.shouldOverrideBuilder; + if (strictFeeRecipientCheck && requestedFeeRecipient) { + const payloadFeeRecipient = toHex(executionPayload.feeRecipient); + if (payloadFeeRecipient !== requestedFeeRecipient) { + throw Error( + `Invalid feeRecipient set in engine payload expected=${requestedFeeRecipient} actual=${payloadFeeRecipient}` + ); + } + } + 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 6a0ccf2d6682..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 @@ -161,50 +161,19 @@ describe("api/validator - produceBlockV4", () => { }); it("uses a builder bid as fallback when the local fee recipient does not match", async () => { - const expectedFeeRecipient = Buffer.from(feeRecipient.slice(2), "hex"); - const mismatchedFeeRecipient = Buffer.alloc(20, 0xdd); - const localBlock = ssz.gloas.BeaconBlock.defaultValue(); - localBlock.body.signedExecutionPayloadBid.message.feeRecipient = mismatchedFeeRecipient; const builderBlock = ssz.gloas.BeaconBlock.defaultValue(); - builderBlock.body.signedExecutionPayloadBid.message.feeRecipient = expectedFeeRecipient; modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); - modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ - block: attrs.builderBid !== undefined ? builderBlock : localBlock, - 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(block).toEqual(builderBlock); - }); - - it("does not recheck the fee recipient of a validated builder bid", async () => { - const expectedFeeRecipient = Buffer.from(feeRecipient.slice(2), "hex"); - const mismatchedFeeRecipient = Buffer.alloc(20, 0xdd); - const localBlock = ssz.gloas.BeaconBlock.defaultValue(); - localBlock.body.signedExecutionPayloadBid.message.feeRecipient = expectedFeeRecipient; - const builderBlock = ssz.gloas.BeaconBlock.defaultValue(); - builderBlock.body.signedExecutionPayloadBid.message.feeRecipient = mismatchedFeeRecipient; + 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"); + } - modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); - modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(builderBid); - modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ - block: attrs.builderBid !== undefined ? builderBlock : localBlock, - executionPayloadValue: BigInt(0), - consensusBlockValue: BigInt(0), - })); + return {block: builderBlock, executionPayloadValue: BigInt(0), consensusBlockValue: BigInt(0)}; + } + ); const {data: block} = await api.produceBlockV4({ slot, @@ -213,10 +182,11 @@ describe("api/validator - produceBlockV4", () => { feeRecipient, strictFeeRecipientCheck: true, includePayload: false, - builderBoostFactor: maxBuilderBoostFactor, + builderBoostFactor: BigInt(0), }); expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({strictFeeRecipientCheck: true})); expect(block).toEqual(builderBlock); }); From fce5625f49e5f6c6caf2fee69fcb0aed3be44016 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 17:41:10 +0100 Subject: [PATCH 10/15] docs: simplify builder fallback comment --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 3ef2f2006a20..2385904b0576 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -896,7 +896,7 @@ export function getValidatorApi( const isBuildingOnFull = chain.forkChoice.shouldBuildOnFull(parentBlock, slot); const bidParentBlockHash = isBuildingOnFull ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash; const circuitBreakerActive = chain.builderCircuitBreaker.isActive(slot); - // Only the circuit breaker disables builder bids; a zero boost factor still keeps the best bid as a fallback. + // Keep a builder bid as fallback unless the circuit breaker is active. const builderBid = circuitBreakerActive ? null : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); From bc251e502ebc1d89740bc7213f147d7644c80922 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 17:43:51 +0100 Subject: [PATCH 11/15] nit --- packages/beacon-node/src/api/impl/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 2385904b0576..ebec783a5c70 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -896,7 +896,7 @@ export function getValidatorApi( const isBuildingOnFull = chain.forkChoice.shouldBuildOnFull(parentBlock, slot); const bidParentBlockHash = isBuildingOnFull ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash; const circuitBreakerActive = chain.builderCircuitBreaker.isActive(slot); - // Keep a builder bid as fallback unless the circuit breaker is active. + // Keep a builder bid as fallback unless the circuit breaker is active const builderBid = circuitBreakerActive ? null : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); From 2413c6973195c5146c24d3aa321d81f6d6242261 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 18:09:45 +0100 Subject: [PATCH 12/15] fix: normalize gloas fee recipient check --- .../chain/produceBlock/produceBlockBody.ts | 15 ++-- .../api/impl/validator/produceBlockV4.test.ts | 72 ++++++++++++++++++- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 3719c35edd00..db518eccd18b 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -337,13 +337,14 @@ export async function produceBlockBody( executionPayloadValue = payloadRes.executionPayloadValue; shouldOverrideBuilder = payloadRes.shouldOverrideBuilder; - if (strictFeeRecipientCheck && requestedFeeRecipient) { - const payloadFeeRecipient = toHex(executionPayload.feeRecipient); - if (payloadFeeRecipient !== requestedFeeRecipient) { - throw Error( - `Invalid feeRecipient set in engine payload expected=${requestedFeeRecipient} actual=${payloadFeeRecipient}` - ); - } + 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) { 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 c14febaec2e3..963c98c33a42 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -2,12 +2,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 { + BeaconStateGloas, + BeaconStateView, + createCachedBeaconState, + createPubkeyCache, +} from "@lodestar/state-transition"; import {ssz} from "@lodestar/types"; +import {fromHex} from "@lodestar/utils"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; +import {BeaconChain} from "../../../../../src/chain/chain.js"; +import {BlockType, produceBlockBody} from "../../../../../src/chain/produceBlock/index.js"; +import {PayloadIdCache} from "../../../../../src/execution/index.js"; import {SyncState} from "../../../../../src/sync/interface.js"; import {ApiTestModules, getApiTestModules} from "../../../../utils/api.js"; -import {zeroProtoBlock} from "../../../../utils/state.js"; +import {generateState, zeroProtoBlock} from "../../../../utils/state.js"; describe("api/validator - produceBlockV4", () => { let modules: ApiTestModules; @@ -190,6 +200,66 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(builderBlock); }); + it("accepts a mixed-case local fee recipient with strict checking", async () => { + const requestedFeeRecipient = "0xCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcAa"; + const executionPayload = ssz.gloas.ExecutionPayload.defaultValue(); + executionPayload.feeRecipient = fromHex(requestedFeeRecipient); + + const state = generateState({slot}, chainConfig) as BeaconStateGloas; + state.latestExecutionPayloadBid.blockHash = new Uint8Array(32).fill(1); + const stateView = new BeaconStateView( + createCachedBeaconState(state, { + config, + pubkeyCache: createPubkeyCache(), + }) + ); + const blockBody = ssz.gloas.BeaconBlockBody.defaultValue(); + + modules.chain.forkChoice.shouldBuildOnFull.mockReturnValue(false); + modules.chain.forkChoice.getConfirmedBlock.mockReturnValue(zeroProtoBlock); + modules.chain.forkChoice.getFinalizedBlock.mockReturnValue(zeroProtoBlock); + modules.chain.forkChoice.getBlockHexDefaultStatus.mockReturnValue(null); + modules.chain.forkChoice.getBlockHexAndBlockHash.mockReturnValue({ + ...zeroProtoBlock, + executionPayloadBlockHash: zeroProtoBlock.blockRoot, + executionPayloadGasLimit: 30_000_000, + } as ProtoBlock); + modules.chain["executionEngine"].payloadIdCache = new PayloadIdCache(); + modules.chain.executionEngine.notifyForkchoiceUpdate.mockResolvedValue("0x01"); + modules.chain.executionEngine.getPayload.mockResolvedValue({ + executionPayload, + executionPayloadValue: BigInt(0), + blobsBundle: {commitments: [], proofs: [], blobs: []}, + executionRequests: ssz.gloas.ExecutionRequests.defaultValue(), + }); + modules.chain.payloadAttestationPool.getPayloadAttestationsForBlock = vi.fn().mockReturnValue([]); + + await expect( + produceBlockBody.call(modules.chain as unknown as BeaconChain, BlockType.Full, stateView, { + randaoReveal, + graffiti: blockBody.graffiti, + slot, + feeRecipient: requestedFeeRecipient, + strictFeeRecipientCheck: true, + parentBlock, + proposerIndex: 0, + proposerPubKey: new Uint8Array(48), + commonBlockBodyPromise: Promise.resolve({ + randaoReveal: blockBody.randaoReveal, + eth1Data: blockBody.eth1Data, + graffiti: blockBody.graffiti, + proposerSlashings: blockBody.proposerSlashings, + attesterSlashings: blockBody.attesterSlashings, + attestations: blockBody.attestations, + deposits: blockBody.deposits, + voluntaryExits: blockBody.voluntaryExits, + syncAggregate: blockBody.syncAggregate, + blsToExecutionChanges: blockBody.blsToExecutionChanges, + }), + }) + ).resolves.toMatchObject({executionPayloadValue: BigInt(0)}); + }); + it("produces local block when no bid is available", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); From c246e0d1e16bfa5651783c1f3ed0e65ea3afa04d Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Tue, 11 Aug 2026 18:17:06 +0100 Subject: [PATCH 13/15] test: remove redundant gloas fee recipient test --- .../api/impl/validator/produceBlockV4.test.ts | 72 +------------------ 1 file changed, 1 insertion(+), 71 deletions(-) diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 963c98c33a42..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 @@ -2,22 +2,12 @@ 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 { - BeaconStateGloas, - BeaconStateView, - createCachedBeaconState, - createPubkeyCache, -} from "@lodestar/state-transition"; import {ssz} from "@lodestar/types"; -import {fromHex} from "@lodestar/utils"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; -import {BeaconChain} from "../../../../../src/chain/chain.js"; -import {BlockType, produceBlockBody} from "../../../../../src/chain/produceBlock/index.js"; -import {PayloadIdCache} from "../../../../../src/execution/index.js"; import {SyncState} from "../../../../../src/sync/interface.js"; import {ApiTestModules, getApiTestModules} from "../../../../utils/api.js"; -import {generateState, zeroProtoBlock} from "../../../../utils/state.js"; +import {zeroProtoBlock} from "../../../../utils/state.js"; describe("api/validator - produceBlockV4", () => { let modules: ApiTestModules; @@ -200,66 +190,6 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(builderBlock); }); - it("accepts a mixed-case local fee recipient with strict checking", async () => { - const requestedFeeRecipient = "0xCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcAa"; - const executionPayload = ssz.gloas.ExecutionPayload.defaultValue(); - executionPayload.feeRecipient = fromHex(requestedFeeRecipient); - - const state = generateState({slot}, chainConfig) as BeaconStateGloas; - state.latestExecutionPayloadBid.blockHash = new Uint8Array(32).fill(1); - const stateView = new BeaconStateView( - createCachedBeaconState(state, { - config, - pubkeyCache: createPubkeyCache(), - }) - ); - const blockBody = ssz.gloas.BeaconBlockBody.defaultValue(); - - modules.chain.forkChoice.shouldBuildOnFull.mockReturnValue(false); - modules.chain.forkChoice.getConfirmedBlock.mockReturnValue(zeroProtoBlock); - modules.chain.forkChoice.getFinalizedBlock.mockReturnValue(zeroProtoBlock); - modules.chain.forkChoice.getBlockHexDefaultStatus.mockReturnValue(null); - modules.chain.forkChoice.getBlockHexAndBlockHash.mockReturnValue({ - ...zeroProtoBlock, - executionPayloadBlockHash: zeroProtoBlock.blockRoot, - executionPayloadGasLimit: 30_000_000, - } as ProtoBlock); - modules.chain["executionEngine"].payloadIdCache = new PayloadIdCache(); - modules.chain.executionEngine.notifyForkchoiceUpdate.mockResolvedValue("0x01"); - modules.chain.executionEngine.getPayload.mockResolvedValue({ - executionPayload, - executionPayloadValue: BigInt(0), - blobsBundle: {commitments: [], proofs: [], blobs: []}, - executionRequests: ssz.gloas.ExecutionRequests.defaultValue(), - }); - modules.chain.payloadAttestationPool.getPayloadAttestationsForBlock = vi.fn().mockReturnValue([]); - - await expect( - produceBlockBody.call(modules.chain as unknown as BeaconChain, BlockType.Full, stateView, { - randaoReveal, - graffiti: blockBody.graffiti, - slot, - feeRecipient: requestedFeeRecipient, - strictFeeRecipientCheck: true, - parentBlock, - proposerIndex: 0, - proposerPubKey: new Uint8Array(48), - commonBlockBodyPromise: Promise.resolve({ - randaoReveal: blockBody.randaoReveal, - eth1Data: blockBody.eth1Data, - graffiti: blockBody.graffiti, - proposerSlashings: blockBody.proposerSlashings, - attesterSlashings: blockBody.attesterSlashings, - attestations: blockBody.attestations, - deposits: blockBody.deposits, - voluntaryExits: blockBody.voluntaryExits, - syncAggregate: blockBody.syncAggregate, - blsToExecutionChanges: blockBody.blsToExecutionChanges, - }), - }) - ).resolves.toMatchObject({executionPayloadValue: BigInt(0)}); - }); - it("produces local block when no bid is available", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(null); From 5f172843f4084143773a32928564fda7ee007f7b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 13 Aug 2026 13:13:46 +0100 Subject: [PATCH 14/15] Update packages/validator/src/services/validatorStore.ts Co-authored-by: Matthew Keil --- packages/validator/src/services/validatorStore.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 94e6a765c7b7..5d78c1ed6934 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -293,8 +293,9 @@ export class ValidatorStore { this.defaultProposerConfig.builder.selection ?? defaultSelection; - // Post-Gloas block production uses the standardized builder boost factor. Normalize legacy - // "only" selections to their fallback-safe "always" equivalents before deriving that factor. + // Post-Gloas block production uses standard builder boost factor. Need to normalize the + // gloas-deprecated "builderonly" to the gloas fallback "builderalways" equivalent before + // deriving the boost factor. if (isPostGloas) { if (selection === routes.validator.BuilderSelection.BuilderOnly) { selection = routes.validator.BuilderSelection.BuilderAlways; From ad08c8cfa6de6681f4af94d4b86afb8497512f23 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 13 Aug 2026 13:17:52 +0100 Subject: [PATCH 15/15] Apply suggestion from me --- packages/validator/src/services/validatorStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 5d78c1ed6934..7cc72895361b 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -294,8 +294,8 @@ export class ValidatorStore { defaultSelection; // Post-Gloas block production uses standard builder boost factor. Need to normalize the - // gloas-deprecated "builderonly" to the gloas fallback "builderalways" equivalent before - // deriving the boost factor. + // 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;