From be333f93cbbfe48d7259494d10ebda1ae2d5d390 Mon Sep 17 00:00:00 2001 From: eth2353 <70237279+eth2353@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:12:05 +0200 Subject: [PATCH] feat: add produceBlockV4WithBid endpoint --- packages/api/src/beacon/routes/validator.ts | 174 ++++++++++++--- .../api/test/unit/beacon/oapiSpec.test.ts | 2 + .../unit/beacon/produceBlockV4WithBid.test.ts | 87 ++++++++ .../test/unit/beacon/testData/validator.ts | 22 ++ .../src/api/impl/validator/index.ts | 90 ++++++-- .../src/execution/builder/validateBid.ts | 9 +- .../api/impl/validator/produceBlockV4.test.ts | 209 +++++++++++++++++- 7 files changed, 539 insertions(+), 54 deletions(-) create mode 100644 packages/api/test/unit/beacon/produceBlockV4WithBid.test.ts diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 8bedebfbcdbc..298add05d961 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -536,6 +536,20 @@ export type Endpoints = { ProduceBlockV4Meta >; + /** Produce a post-Gloas block with best-effort inclusion of a supplied signed bid. */ + produceBlockV4WithBid: Endpoint< + "POST", + Omit & { + signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid; + builderBoostFactor: UintBn64; + }, + Omit & { + query: Endpoints["produceBlockV4"]["request"]["query"] & {builder_boost_factor: string}; + }, + Endpoints["produceBlockV4"]["return"], + ProduceBlockV4Meta + >; + /** * Get execution payload envelope. * Retrieves the cached execution payload envelope for a given slot and beacon block root, @@ -776,6 +790,38 @@ export type Endpoints = { }; export function getDefinitions(config: ChainForkConfig): RouteDefinitions { + const produceBlockV4Response = { + data: WithMeta( + ({version, executionPayloadIncluded}) => + (executionPayloadIncluded + ? getPostGloasForkTypes(version).BlockContents + : getPostGloasForkTypes(version).BeaconBlock) as Type< + BeaconBlock | BlockContents + > + ), + meta: { + toJson: (meta) => ProduceBlockV4MetaType.toJson(meta), + fromJson: (val, headers) => ({ + ...ProduceBlockV4MetaType.fromJson(val), + builderUrl: headers?.get(MetaHeader.BuilderUrl) ?? undefined, + }), + toHeadersObject: (meta) => ({ + [MetaHeader.Version]: meta.version, + [MetaHeader.ConsensusBlockValue]: meta.consensusBlockValue.toString(), + [MetaHeader.ExecutionPayloadValue]: meta.executionPayloadValue.toString(), + [MetaHeader.ExecutionPayloadIncluded]: meta.executionPayloadIncluded.toString(), + ...(meta.builderUrl !== undefined ? {[MetaHeader.BuilderUrl]: meta.builderUrl} : {}), + }), + fromHeaders: (headers) => ({ + version: toForkName(headers.getRequired(MetaHeader.Version)), + consensusBlockValue: BigInt(headers.getRequired(MetaHeader.ConsensusBlockValue)), + executionPayloadValue: BigInt(headers.getRequired(MetaHeader.ExecutionPayloadValue)), + executionPayloadIncluded: toBoolean(headers.getRequired(MetaHeader.ExecutionPayloadIncluded)), + builderUrl: headers.get(MetaHeader.BuilderUrl) ?? undefined, + }), + }, + } satisfies RouteDefinitions["produceBlockV4"]["resp"]; + return { getAttesterDuties: { url: "/eth/v1/validator/duties/attester/{epoch}", @@ -1084,37 +1130,107 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions - (executionPayloadIncluded - ? getPostGloasForkTypes(version).BlockContents - : getPostGloasForkTypes(version).BeaconBlock) as Type< - BeaconBlock | BlockContents - > - ), - meta: { - toJson: (meta) => ProduceBlockV4MetaType.toJson(meta), - fromJson: (val, headers) => ({ - ...ProduceBlockV4MetaType.fromJson(val), - builderUrl: headers?.get(MetaHeader.BuilderUrl) ?? undefined, - }), - toHeadersObject: (meta) => ({ - [MetaHeader.Version]: meta.version, - [MetaHeader.ConsensusBlockValue]: meta.consensusBlockValue.toString(), - [MetaHeader.ExecutionPayloadValue]: meta.executionPayloadValue.toString(), - [MetaHeader.ExecutionPayloadIncluded]: meta.executionPayloadIncluded.toString(), - ...(meta.builderUrl !== undefined ? {[MetaHeader.BuilderUrl]: meta.builderUrl} : {}), - }), - fromHeaders: (headers) => ({ - version: toForkName(headers.getRequired(MetaHeader.Version)), - consensusBlockValue: BigInt(headers.getRequired(MetaHeader.ConsensusBlockValue)), - executionPayloadValue: BigInt(headers.getRequired(MetaHeader.ExecutionPayloadValue)), - executionPayloadIncluded: toBoolean(headers.getRequired(MetaHeader.ExecutionPayloadIncluded)), - builderUrl: headers.get(MetaHeader.BuilderUrl) ?? undefined, - }), + resp: produceBlockV4Response, + }, + produceBlockV4WithBid: { + url: "/eth/v4/validator/blocks/{slot}/with_bid", + method: "POST", + req: { + writeReqJson: ({ + slot, + randaoReveal, + graffiti, + skipRandaoVerification, + feeRecipient, + strictFeeRecipientCheck, + includePayload, + signedExecutionPayloadBid, + builderBoostFactor, + }) => ({ + params: {slot}, + query: { + randao_reveal: toHex(randaoReveal), + graffiti: toGraffitiHex(graffiti), + skip_randao_verification: writeSkipRandaoVerification(skipRandaoVerification), + fee_recipient: feeRecipient, + strict_fee_recipient_check: strictFeeRecipientCheck, + include_payload: includePayload, + builder_boost_factor: builderBoostFactor.toString(), + }, + body: ssz.gloas.SignedExecutionPayloadBid.toJson(signedExecutionPayloadBid), + headers: {[MetaHeader.Version]: config.getForkName(slot)}, + }), + parseReqJson: ({params, query, body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + slot: params.slot, + randaoReveal: fromHex(query.randao_reveal), + graffiti: fromGraffitiHex(query.graffiti), + skipRandaoVerification: parseSkipRandaoVerification(query.skip_randao_verification), + feeRecipient: query.fee_recipient, + strictFeeRecipientCheck: query.strict_fee_recipient_check, + includePayload: query.include_payload, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.fromJson(body), + builderBoostFactor: BigInt(query.builder_boost_factor), + }; }, + writeReqSsz: ({ + slot, + randaoReveal, + graffiti, + skipRandaoVerification, + feeRecipient, + strictFeeRecipientCheck, + includePayload, + signedExecutionPayloadBid, + builderBoostFactor, + }) => ({ + params: {slot}, + query: { + randao_reveal: toHex(randaoReveal), + graffiti: toGraffitiHex(graffiti), + skip_randao_verification: writeSkipRandaoVerification(skipRandaoVerification), + fee_recipient: feeRecipient, + strict_fee_recipient_check: strictFeeRecipientCheck, + include_payload: includePayload, + builder_boost_factor: builderBoostFactor.toString(), + }, + body: ssz.gloas.SignedExecutionPayloadBid.serialize(signedExecutionPayloadBid), + headers: {[MetaHeader.Version]: config.getForkName(slot)}, + }), + parseReqSsz: ({params, query, body, headers}) => { + toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + slot: params.slot, + randaoReveal: fromHex(query.randao_reveal), + graffiti: fromGraffitiHex(query.graffiti), + skipRandaoVerification: parseSkipRandaoVerification(query.skip_randao_verification), + feeRecipient: query.fee_recipient, + strictFeeRecipientCheck: query.strict_fee_recipient_check, + includePayload: query.include_payload, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.deserialize(body), + builderBoostFactor: BigInt(query.builder_boost_factor), + }; + }, + schema: { + params: {slot: Schema.UintRequired}, + query: { + randao_reveal: Schema.StringRequired, + graffiti: Schema.String, + skip_randao_verification: Schema.String, + fee_recipient: Schema.String, + strict_fee_recipient_check: Schema.Boolean, + include_payload: Schema.BooleanRequired, + builder_boost_factor: Schema.StringRequired, + }, + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.StringRequired}, + }, + }, + init: { + requestWireFormat: WireFormat.ssz, }, + resp: produceBlockV4Response, }, getExecutionPayloadEnvelope: { url: "/eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}", diff --git a/packages/api/test/unit/beacon/oapiSpec.test.ts b/packages/api/test/unit/beacon/oapiSpec.test.ts index 885b3e25f14d..075133e261a0 100644 --- a/packages/api/test/unit/beacon/oapiSpec.test.ts +++ b/packages/api/test/unit/beacon/oapiSpec.test.ts @@ -61,6 +61,8 @@ const ignoredOperations = [ // TODO: remove once the pinned beacon-APIs spec version includes beacon-APIs#630 // (GET -> POST with a required BuilderConfig request body) "produceBlockV4", + // TODO: remove once the pinned beacon-APIs spec includes beacon-APIs#627 + "produceBlockV4WithBid", // TODO: remove once the pinned beacon-APIs spec version includes beacon-APIs#630 "submitBuilderPreferences", ]; diff --git a/packages/api/test/unit/beacon/produceBlockV4WithBid.test.ts b/packages/api/test/unit/beacon/produceBlockV4WithBid.test.ts new file mode 100644 index 000000000000..c653905b847e --- /dev/null +++ b/packages/api/test/unit/beacon/produceBlockV4WithBid.test.ts @@ -0,0 +1,87 @@ +import {FastifyInstance} from "fastify"; +import {afterEach, beforeEach, describe, expect, it} from "vitest"; +import {config} from "@lodestar/config/default"; +import {ssz} from "@lodestar/types"; +import {Endpoints, getDefinitions} from "../../../src/beacon/routes/validator.js"; +import {getRoutes} from "../../../src/beacon/server/validator.js"; +import {MetaHeader} from "../../../src/utils/metadata.js"; +import {getMockApi, getTestServer} from "../../utils/utils.js"; +import {testData} from "./testData/validator.js"; + +describe("produceBlockV4WithBid", () => { + let server: FastifyInstance; + const api = getMockApi(testData); + const definition = getDefinitions(config).produceBlockV4WithBid; + + beforeEach(() => { + server = getTestServer().server; + server.route(getRoutes(config, api).produceBlockV4WithBid); + }); + + afterEach(async () => { + await server.close(); + }); + + for (const format of ["json", "ssz"] as const) { + describe(format, () => { + function request() { + const args = {...testData.produceBlockV4WithBid.args, includePayload: true}; + const req = format === "json" ? definition.req.writeReqJson(args) : definition.req.writeReqSsz(args); + return { + method: "POST" as const, + url: `/eth/v4/validator/blocks/${args.slot}/with_bid`, + query: Object.fromEntries( + Object.entries(req.query) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]) + ), + headers: { + ...req.headers, + "content-type": format === "json" ? "application/json" : "application/octet-stream", + accept: format === "json" ? "application/json" : "application/octet-stream", + }, + payload: format === "json" ? JSON.stringify(req.body) : Buffer.from(req.body as Uint8Array), + }; + } + + it("returns full block contents for a local payload", async () => { + const data = ssz.gloas.BlockContents.defaultValue(); + api.produceBlockV4WithBid.mockResolvedValue({ + data, + meta: {...testData.produceBlockV4WithBid.res.meta, executionPayloadIncluded: true}, + }); + const res = await server.inject(request()); + expect(res.statusCode).toBe(200); + expect(res.headers[MetaHeader.ExecutionPayloadIncluded.toLowerCase()]).toBe("true"); + expect( + format === "json" + ? ssz.gloas.BlockContents.fromJson(res.json().data) + : ssz.gloas.BlockContents.deserialize(new Uint8Array(res.rawPayload)) + ).toEqual(data); + }); + + it.each(["builder_boost_factor", "include_payload", "randao_reveal"])("requires %s", async (key) => { + const req = request(); + delete req.query[key]; + const res = await server.inject(req); + expect(res.statusCode).toBe(400); + expect(api.produceBlockV4WithBid).not.toHaveBeenCalled(); + }); + + it("requires the consensus version header", async () => { + const req = request(); + const headers: Record = {...req.headers}; + delete headers[MetaHeader.Version]; + const res = await server.inject({...req, headers}); + expect(res.statusCode).toBe(400); + expect(api.produceBlockV4WithBid).not.toHaveBeenCalled(); + }); + + it("rejects malformed bid bodies", async () => { + const res = await server.inject({...request(), payload: format === "json" ? "{}" : Buffer.alloc(1)}); + expect(res.statusCode).toBe(400); + expect(api.produceBlockV4WithBid).not.toHaveBeenCalled(); + }); + }); + } +}); diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index e221ba059406..ed623f6f4fe7 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -113,6 +113,28 @@ export const testData: GenericServerTestCases = { }, }, }, + produceBlockV4WithBid: { + args: { + slot: 32000, + randaoReveal, + graffiti, + skipRandaoVerification: true, + feeRecipient, + strictFeeRecipientCheck: true, + includePayload: false, + builderBoostFactor: 2n ** 64n - 1n, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.defaultValue(), + }, + res: { + data: ssz.gloas.BeaconBlock.defaultValue(), + meta: { + version: ForkName.gloas, + consensusBlockValue: 1n, + executionPayloadValue: 2n, + executionPayloadIncluded: false, + }, + }, + }, getExecutionPayloadEnvelope: { args: {slot: 32000, beaconBlockRoot: ZERO_HASH}, res: { diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 6768c08481de..3b2ddbff3468 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -8,6 +8,7 @@ import { ForkPreGloas, ForkSeq, GENESIS_SLOT, + MAX_EXECUTION_PAYMENT, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, SYNC_COMMITTEE_SUBNET_SIZE, @@ -875,7 +876,7 @@ export function getValidatorApi( throw Error("Unreachable error occurred during the builder and execution block production"); } - return { + const api = { async produceBlockV3({slot, randaoReveal, graffiti, skipRandaoVerification, builderBoostFactor, ...opts}) { const {data, ...meta} = await produceEngineOrBuilderBlock( slot, @@ -911,7 +912,10 @@ export function getValidatorApi( strictFeeRecipientCheck, includePayload, builderConfig, - }) { + signedExecutionPayloadBid, + }: routes.validator.Endpoints["produceBlockV4"]["args"] & { + signedExecutionPayloadBid?: gloas.SignedExecutionPayloadBid; + }): ReturnType["produceBlockV4"]> { const fork = config.getForkName(slot); if (!isForkPostGloas(fork)) { @@ -975,34 +979,61 @@ export function getValidatorApi( // A builder bid is expected if builders are configured or a p2p bid was already received. // Used by the censorship override which may run before the bid deadline const builderBidExpected = + signedExecutionPayloadBid !== undefined || builderConfig.builders.length > 0 || (!circuitBreakerActive && chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex) !== null); // Select the p2p bid once builders had time to bid up, matching the deadline advertised // on builder API bid requests, unless the circuit breaker is active - const p2pBidPromise: Promise = circuitBreakerActive - ? Promise.resolve(null) - : sleep(Math.max(0, BUILDER_BID_DEADLINE_MS - chain.clock.msFromSlot(slot))).then(() => { - const p2pBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); - // Discard p2p bids below the proposer's configured floor on the total payment. - // A p2p bid's total is just its value since gossip validation enforces executionPayment=0. - if (p2pBid !== null && BigInt(p2pBid.signedBid.message.value) < builderConfig.minBid) { - logger.info("Best p2p bid below configured minimum", { - slot, - bidValue: prettyGweiToEth(p2pBid.signedBid.message.value), - minBid: prettyGweiToEth(builderConfig.minBid), - }); - return null; - } - return p2pBid; - }); + const p2pBidPromise: Promise = + circuitBreakerActive || signedExecutionPayloadBid !== undefined + ? Promise.resolve(null) + : sleep(Math.max(0, BUILDER_BID_DEADLINE_MS - chain.clock.msFromSlot(slot))).then(() => { + const p2pBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + // Discard p2p bids below the proposer's configured floor on the total payment. + // A p2p bid's total is just its value since gossip validation enforces executionPayment=0. + if (p2pBid !== null && BigInt(p2pBid.signedBid.message.value) < builderConfig.minBid) { + logger.info("Best p2p bid below configured minimum", { + slot, + bidValue: prettyGweiToEth(p2pBid.signedBid.message.value), + minBid: prettyGweiToEth(builderConfig.minBid), + }); + return null; + } + return p2pBid; + }); // Candidates are ranked by their boosted counted total payment, the p2p bid is governed // by the top-level factors and each builder API bid by its own entry. Ties prefer the // earliest received builder API bid, so the signed block can be routed back to its // builder directly. const bestBidPromise: Promise = (async () => { + if (signedExecutionPayloadBid !== undefined) { + if (circuitBreakerActive) { + return null; + } + try { + await validateBuilderApiExecutionPayloadBid(chain, signedExecutionPayloadBid, { + slot, + parentBlock, + parentBlockHash: bidParentBlockHash, + parentBlockRoot: parentBlockRootHex, + entry: {builderPubkeys: [], minBid: 0n, maxExecutionPayment: MAX_EXECUTION_PAYMENT}, + getParentExecutionRequests: () => chain.getParentExecutionRequests(parentSlot, parentBlockRootHex), + }); + return { + signedBid: signedExecutionPayloadBid, + totalGwei: getBuilderBidTotalGwei(signedExecutionPayloadBid.message, MAX_EXECUTION_PAYMENT), + boostFactor: builderBoostFactor, + receivedMs: chain.clock.msFromSlot(slot), + }; + } catch (e) { + logger.warn("Ignoring invalid supplied execution payload bid", {slot}, e as Error); + return null; + } + } + const [builderApiBids, p2pBid] = await Promise.all([builderApiBidsPromise, p2pBidPromise]); let parentExecutionRequestsPromise: Promise | null = null; @@ -1142,7 +1173,12 @@ export function getValidatorApi( builderEntries: builderConfig.builders.length, ...(bestBid !== null ? { - bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : "p2p", + bidSource: + signedExecutionPayloadBid !== undefined + ? "supplied" + : bestBid.url !== undefined + ? toPrintableUrl(bestBid.url) + : "p2p", bidValue: prettyGweiToEth(bestBid.signedBid.message.value), bidExecutionPayment: prettyGweiToEth(bestBid.signedBid.message.executionPayment), // The full bid total and the counted total used during bid selection @@ -1302,6 +1338,18 @@ export function getValidatorApi( }; }, + produceBlockV4WithBid({ + signedExecutionPayloadBid, + builderBoostFactor, + ...args + }): ReturnType["produceBlockV4WithBid"]> { + return api.produceBlockV4({ + ...args, + builderConfig: {builders: [], minBid: 0n, builderBoostFactor}, + signedExecutionPayloadBid, + }); + }, + async produceAttestationData({committeeIndex, slot}) { notWhileSyncing(chain, sync.state); @@ -2178,5 +2226,7 @@ export function getValidatorApi( }, }; }, - }; + } satisfies ApplicationMethods; + + return api; } diff --git a/packages/beacon-node/src/execution/builder/validateBid.ts b/packages/beacon-node/src/execution/builder/validateBid.ts index 4a3740e97791..8b9b52e777eb 100644 --- a/packages/beacon-node/src/execution/builder/validateBid.ts +++ b/packages/beacon-node/src/execution/builder/validateBid.ts @@ -1,4 +1,3 @@ -import {routes} from "@lodestar/api"; import {ProtoBlock} from "@lodestar/fork-choice"; import {MAX_EXECUTION_PAYMENT, PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import { @@ -9,7 +8,7 @@ import { isGasLimitTargetCompatible, isStatePostGloas, } from "@lodestar/state-transition"; -import {RootHex, Slot, gloas} from "@lodestar/types"; +import {BLSPubkey, Gwei, RootHex, Slot, gloas} from "@lodestar/types"; import {bigIntMin, byteArrayEquals, prettyGweiToEth, toHex, toRootHex} from "@lodestar/utils"; import {IBeaconChain} from "../../chain/index.js"; import {RegenCaller} from "../../chain/regen/index.js"; @@ -36,7 +35,11 @@ export async function validateBuilderApiExecutionPayloadBid( parentBlock: ProtoBlock; parentBlockHash: RootHex; parentBlockRoot: RootHex; - entry: routes.validator.BuilderEntry; + entry: { + builderPubkeys: BLSPubkey[]; + minBid: Gwei; + maxExecutionPayment: Gwei; + }; getParentExecutionRequests: () => Promise; } ): Promise { 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 dcc754be34d9..e590370a3774 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,11 +1,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, MAX_EXECUTION_PAYMENT} from "@lodestar/params"; +import {BUILDER_INDEX_SELF_BUILD, ForkName, MAX_EXECUTION_PAYMENT} from "@lodestar/params"; import {gloas, ssz} from "@lodestar/types"; -import {defer} from "@lodestar/utils"; +import {defer, fromHex, toRootHex} from "@lodestar/utils"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; +import {BlockType} from "../../../../../src/chain/produceBlock/index.js"; import {BUILDER_BID_DEADLINE_MS} from "../../../../../src/execution/builder/apiClient.js"; import {validateBuilderApiExecutionPayloadBid} from "../../../../../src/execution/builder/validateBid.js"; import {SyncState} from "../../../../../src/sync/interface.js"; @@ -643,6 +644,210 @@ describe("api/validator - produceBlockV4", () => { expect(modules.chain.produceBlock).not.toHaveBeenCalled(); }); + describe("produceBlockV4WithBid", () => { + const args = { + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderBoostFactor: 100n, + signedExecutionPayloadBid: builderBid, + }; + + beforeEach(() => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + }); + + it.each([false, true])("uses the supplied bid with includePayload=%s", async (includePayload) => { + const {data, meta} = await api.produceBlockV4WithBid({...args, includePayload}); + + expect(data).toEqual(bidBlock); + expect(meta.executionPayloadIncluded).toBe(false); + expect(meta.builderUrl).toBeUndefined(); + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid})); + expect(validateBuilderApiExecutionPayloadBid).toHaveBeenCalledWith( + modules.chain, + builderBid, + expect.objectContaining({ + slot, + parentBlock, + parentBlockHash: parentBlock.executionPayloadBlockHash, + parentBlockRoot: parentBlock.blockRoot, + }) + ); + expect(modules.chain.builderApiClient.getExecutionPayloadBids).not.toHaveBeenCalled(); + expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); + }); + + it.each([ + {builderBoostFactor: 0n, builderWins: false}, + {builderBoostFactor: 100n, builderWins: false}, + {builderBoostFactor: 300n, builderWins: true}, + {builderBoostFactor: maxBuilderBoostFactor, builderWins: true}, + ])( + "compares against the local payload with boost $builderBoostFactor", + async ({builderBoostFactor, builderWins}) => { + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: 2_000_000_000n, + consensusBlockValue: 0n, + })); + + const {data} = await api.produceBlockV4WithBid({...args, builderBoostFactor}); + expect(data).toEqual(builderWins ? bidBlock : engineBlock); + } + ); + + it("counts the supplied bid's execution payment", async () => { + const signedExecutionPayloadBid = ssz.gloas.SignedExecutionPayloadBid.clone(builderBid); + signedExecutionPayloadBid.message.executionPayment = 2n; + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: attrs.builderBid !== undefined ? 3_000_000_000n : 2_000_000_000n, + consensusBlockValue: 0n, + })); + + const {data, meta} = await api.produceBlockV4WithBid({...args, signedExecutionPayloadBid}); + expect(data).toEqual(bidBlock); + expect(meta.executionPayloadValue).toBe(3_000_000_000n); + }); + + it("falls back to the local block when bid validation fails", async () => { + vi.mocked(validateBuilderApiExecutionPayloadBid).mockRejectedValueOnce(new Error("Invalid signature")); + const {data} = await api.produceBlockV4WithBid(args); + expect(data).toEqual(engineBlock); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + }); + + it.each(["slot", "parentBlockHash", "parentBlockRoot"] as const)( + "rejects a bid for the wrong %s and builds locally", + async (field) => { + const {validateBuilderApiExecutionPayloadBid: validateBid} = await vi.importActual< + typeof import("../../../../../src/execution/builder/validateBid.js") + >("../../../../../src/execution/builder/validateBid.js"); + vi.mocked(validateBuilderApiExecutionPayloadBid).mockImplementationOnce(validateBid); + const signedExecutionPayloadBid = ssz.gloas.SignedExecutionPayloadBid.clone(builderBid); + Object.assign(signedExecutionPayloadBid.message, { + slot, + parentBlockHash: fromHex(parentBlock.executionPayloadBlockHash ?? ""), + parentBlockRoot: fromHex(parentBlock.blockRoot), + }); + if (field === "slot") { + signedExecutionPayloadBid.message.slot++; + } else { + signedExecutionPayloadBid.message[field].fill(0); + } + + const {data} = await api.produceBlockV4WithBid({...args, signedExecutionPayloadBid}); + expect(data).toEqual(engineBlock); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + } + ); + + it("discards the supplied bid when the circuit breaker is active", async () => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); + const {data} = await api.produceBlockV4WithBid(args); + expect(data).toEqual(engineBlock); + expect(validateBuilderApiExecutionPayloadBid).not.toHaveBeenCalled(); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + }); + + it("uses the supplied bid when local production fails, even with zero boost", async () => { + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => { + if (attrs.builderBid === undefined) { + throw new Error("Local production failed"); + } + return {block: bidBlock, executionPayloadValue: 1_000_000_000n, consensusBlockValue: 0n}; + }); + const {data} = await api.produceBlockV4WithBid({...args, builderBoostFactor: 0n}); + expect(data).toEqual(bidBlock); + }); + + it("falls back locally if producing the supplied bid block fails", async () => { + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => { + if (attrs.builderBid !== undefined) { + throw new Error("Bid block production failed"); + } + return {block: engineBlock, executionPayloadValue: 0n, consensusBlockValue: 0n}; + }); + const {data} = await api.produceBlockV4WithBid(args); + expect(data).toEqual(engineBlock); + }); + + it("honors the censorship override even with maximum boost", async () => { + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => { + if (attrs.builderBid === undefined) { + await new Promise((resolve) => setImmediate(resolve)); + } + return { + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: 0n, + consensusBlockValue: 0n, + shouldOverrideBuilder: attrs.builderBid === undefined, + }; + }); + const {data} = await api.produceBlockV4WithBid({...args, builderBoostFactor: maxBuilderBoostFactor}); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(2); + expect(data).toEqual(engineBlock); + }); + + it("includes cached payload contents when falling back locally", async () => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); + const executionPayload = ssz.gloas.ExecutionPayload.defaultValue(); + const executionRequests = ssz.gloas.ExecutionRequests.defaultValue(); + const parentBlockRoot = fromHex(parentBlock.blockRoot); + const blockRoot = ssz.gloas.BeaconBlock.hashTreeRoot(engineBlock); + Object.defineProperty(modules.chain, "blockProductionCache", {value: new Map()}); + modules.chain.blockProductionCache.set(toRootHex(blockRoot), { + type: BlockType.Full, + fork: ForkName.gloas, + executionPayload, + executionRequests, + parentBlockRoot, + blobsBundle: {commitments: [], proofs: [], blobs: []}, + cells: [], + }); + const {data, meta} = await api.produceBlockV4WithBid({...args, includePayload: true}); + expect(data).toEqual({ + block: engineBlock, + executionPayloadEnvelope: { + payload: executionPayload, + executionRequests, + builderIndex: BUILDER_INDEX_SELF_BUILD, + beaconBlockRoot: blockRoot, + parentBeaconBlockRoot: parentBlockRoot, + }, + kzgProofs: [], + blobs: [], + }); + expect(meta.executionPayloadIncluded).toBe(true); + }); + + it("rejects a boost factor above the maximum", async () => { + await expect(api.produceBlockV4WithBid({...args, builderBoostFactor: 2n ** 64n})).rejects.toMatchObject({ + statusCode: 400, + }); + expect(modules.chain.produceBlock).not.toHaveBeenCalled(); + }); + + it("rejects pre-Gloas requests", async () => { + const preGloasConfig = createBeaconConfig({...chainConfig, GLOAS_FORK_EPOCH: Infinity}, genesisValidatorsRoot); + const preGloasApi = getValidatorApi(defaultApiOptions, {...modules, config: preGloasConfig}); + await expect(preGloasApi.produceBlockV4WithBid(args)).rejects.toMatchObject({statusCode: 400}); + expect(modules.chain.produceBlock).not.toHaveBeenCalled(); + }); + + it("rejects optimistic parents", async () => { + modules.chain.getProposerHead.mockReturnValue({ + ...parentBlock, + executionStatus: ExecutionStatus.Syncing, + } as ProtoBlock); + await expect(api.produceBlockV4WithBid(args)).rejects.toThrow("Node is syncing"); + expect(modules.chain.produceBlock).not.toHaveBeenCalled(); + }); + }); + type MatrixEntry = { value: number; executionPayment?: bigint;