diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 8bedebfbcdbc..5e27dd80e0b1 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -536,6 +536,54 @@ export type Endpoints = { ProduceBlockV4Meta >; + /** + * Requests a beacon node to produce a valid block with best-effort inclusion of the supplied + * execution payload bid, which the validator client selected itself among the bids it received. + * + * The beacon node solicits no builder API bids and considers no p2p bids. The supplied bid is + * validated like a builder API bid, with its execution payment counted in full, and weighed by + * `builderBoostFactor` against the local payload. An invalid bid or an active circuit breaker + * falls back to the local payload, the validator client can tell by the bid in the returned block. + * + * The response follows `produceBlockV4` without the `Eth-Builder-Url` header, the validator client + * already knows which builder to submit the signed block to. + * This endpoint is specific to the post-Gloas forks and is not backwards compatible with previous forks. + */ + produceBlockV4WithBid: Endpoint< + "POST", + { + /** The slot for which the block should be proposed */ + slot: Slot; + /** The validator's randao reveal value */ + randaoReveal: BLSSignature; + /** Arbitrary data validator wants to include in block */ + graffiti?: string; + skipRandaoVerification?: boolean; + /** Percentage multiplier applied to the supplied bid when choosing between it and the local payload */ + builderBoostFactor: bigint; + /** Include execution payload envelope and blobs in the response when self-building */ + includePayload: boolean; + /** The bid selected by the validator client */ + signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid; + } & ExtraProduceBlockV4Opts, + { + params: {slot: number}; + query: { + randao_reveal: string; + graffiti?: string; + skip_randao_verification?: string; + fee_recipient?: string; + strict_fee_recipient_check?: boolean; + builder_boost_factor: string; + include_payload: boolean; + }; + body: unknown; + headers: {[MetaHeader.Version]: string}; + }, + BeaconBlock | BlockContents, + ProduceBlockV4Meta + >; + /** * Get execution payload envelope. * Retrieves the cached execution payload envelope for a given slot and beacon block root, @@ -1116,6 +1164,131 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ + params: {slot}, + query: { + randao_reveal: toHex(randaoReveal), + graffiti: toGraffitiHex(graffiti), + skip_randao_verification: writeSkipRandaoVerification(skipRandaoVerification), + fee_recipient: feeRecipient, + strict_fee_recipient_check: strictFeeRecipientCheck, + builder_boost_factor: builderBoostFactor.toString(), + include_payload: includePayload, + }, + 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, + builderBoostFactor: BigInt(query.builder_boost_factor), + includePayload: query.include_payload, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.fromJson(body), + }; + }, + writeReqSsz: ({ + slot, + randaoReveal, + graffiti, + skipRandaoVerification, + feeRecipient, + strictFeeRecipientCheck, + builderBoostFactor, + includePayload, + signedExecutionPayloadBid, + }) => ({ + params: {slot}, + query: { + randao_reveal: toHex(randaoReveal), + graffiti: toGraffitiHex(graffiti), + skip_randao_verification: writeSkipRandaoVerification(skipRandaoVerification), + fee_recipient: feeRecipient, + strict_fee_recipient_check: strictFeeRecipientCheck, + builder_boost_factor: builderBoostFactor.toString(), + include_payload: includePayload, + }, + 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, + builderBoostFactor: BigInt(query.builder_boost_factor), + includePayload: query.include_payload, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.deserialize(body), + }; + }, + 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, + builder_boost_factor: Schema.StringRequired, + include_payload: Schema.BooleanRequired, + }, + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + init: { + requestWireFormat: WireFormat.ssz, + }, + resp: { + data: WithMeta( + ({version, executionPayloadIncluded}) => + (executionPayloadIncluded + ? getPostGloasForkTypes(version).BlockContents + : getPostGloasForkTypes(version).BeaconBlock) as Type< + BeaconBlock | BlockContents + > + ), + meta: { + toJson: (meta) => ProduceBlockV4MetaType.toJson(meta), + fromJson: (val) => ProduceBlockV4MetaType.fromJson(val), + toHeadersObject: (meta) => ({ + [MetaHeader.Version]: meta.version, + [MetaHeader.ConsensusBlockValue]: meta.consensusBlockValue.toString(), + [MetaHeader.ExecutionPayloadValue]: meta.executionPayloadValue.toString(), + [MetaHeader.ExecutionPayloadIncluded]: meta.executionPayloadIncluded.toString(), + }), + 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)), + }), + }, + }, + }, getExecutionPayloadEnvelope: { url: "/eth/v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}", method: "GET", diff --git a/packages/api/test/unit/beacon/oapiSpec.test.ts b/packages/api/test/unit/beacon/oapiSpec.test.ts index 885b3e25f14d..89eef907192a 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 version 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/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index e221ba059406..dcf5da825737 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, + builderBoostFactor: 100n, + includePayload: true, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.defaultValue(), + }, + res: { + data: ssz.gloas.BlockContents.defaultValue(), + meta: { + version: ForkName.gloas, + consensusBlockValue: ssz.Wei.defaultValue(), + executionPayloadValue: ssz.Wei.defaultValue(), + executionPayloadIncluded: true, + }, + }, + }, 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 7e824f11ac88..a7d8e29fd1e7 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, @@ -127,6 +128,7 @@ type BidCandidate = { /** Total payment in Gwei, counting the execution payment at most at the entry's cap */ totalGwei: bigint; boostFactor: bigint; + source: "direct" | "p2p" | "supplied"; url?: string; /** Time in milliseconds from the slot start when the bid was received */ receivedMs: number; @@ -876,8 +878,9 @@ export function getValidatorApi( } /** - * Gloas block production behind `produceBlockV4`, the local payload build is raced against - * the best builder bid + * Gloas block production shared by produceBlockV4 and produceBlockV4WithBid. A valid bid supplied by + * the validator client takes the place of builder API and p2p bids, competing only with the + * local payload. */ async function produceGloasBlock({ slot, @@ -887,6 +890,7 @@ export function getValidatorApi( strictFeeRecipientCheck, includePayload, builderConfig, + suppliedBid, }: { slot: Slot; randaoReveal: BLSSignature; @@ -895,16 +899,17 @@ export function getValidatorApi( strictFeeRecipientCheck?: boolean; includePayload: boolean; builderConfig: routes.validator.BuilderConfig; + suppliedBid?: gloas.SignedExecutionPayloadBid; }) { const fork = config.getForkName(slot); if (!isForkPostGloas(fork)) { - throw new ApiError(400, `produceBlockV4 not supported for pre-gloas fork=${fork}`); + throw new ApiError(400, `Gloas block production not supported for pre-gloas fork=${fork}`); } const builderBoostFactor = builderConfig.builderBoostFactor; - if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { - throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); + if (builderBoostFactor < 0n || builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { + throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor}, must be a uint64`); } notWhileSyncing(chain, sync.state); @@ -956,38 +961,31 @@ 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 + // A builder bid is expected if builders are configured, a bid was supplied or a p2p bid was + // already received. Used by the censorship override which may run before the bid deadline const builderBidExpected = builderConfig.builders.length > 0 || + suppliedBid !== undefined || (!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; - }); + // on builder API bid requests, unless the circuit breaker is active or the validator + // client supplied its own bid, having already picked from the bids it saw + const p2pBidPromise: Promise = + circuitBreakerActive || suppliedBid !== undefined + ? Promise.resolve(null) + : sleep(Math.max(0, BUILDER_BID_DEADLINE_MS - chain.clock.msFromSlot(slot))).then(() => + chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex) + ); // Candidates are ranked by their boosted counted total payment, the p2p bid is governed // by the top-level factors and each builder API bid by its own entry. Ties prefer the // earliest received builder API bid, so the signed block can be routed back to its // builder directly. const bestBidPromise: Promise = (async () => { - const [builderApiBids, p2pBid] = await Promise.all([builderApiBidsPromise, p2pBidPromise]); + const [builderApiBids, selectedP2pBid] = await Promise.all([builderApiBidsPromise, p2pBidPromise]); + let p2pBid = selectedP2pBid; let parentExecutionRequestsPromise: Promise | null = null; const candidates = ( @@ -999,7 +997,9 @@ export function getValidatorApi( parentBlock, parentBlockHash: bidParentBlockHash, parentBlockRoot: parentBlockRootHex, - entry, + builderPubkeys: entry.builderPubkeys, + maxExecutionPayment: entry.maxExecutionPayment, + minBid: entry.minBid, getParentExecutionRequests: () => { parentExecutionRequestsPromise ??= chain.getParentExecutionRequests(parentSlot, parentBlockRootHex); return parentExecutionRequestsPromise; @@ -1009,6 +1009,7 @@ export function getValidatorApi( signedBid, totalGwei: getBuilderBidTotalGwei(signedBid.message, entry.maxExecutionPayment), boostFactor: entry.builderBoostFactor, + source: "direct", url, receivedMs, }; @@ -1021,11 +1022,59 @@ export function getValidatorApi( ) ).filter((candidate): candidate is BidCandidate => candidate !== null); + // A supplied bid takes the place of builder API and p2p bids, validated like a builder API + // bid from any builder and counting the execution payment in full as the validator client + // applied its own cap when selecting it + if (suppliedBid !== undefined && !circuitBreakerActive) { + const receivedMs = chain.clock.msFromSlot(slot); + try { + await validateBuilderApiExecutionPayloadBid(chain, suppliedBid, { + slot, + parentBlock, + parentBlockHash: bidParentBlockHash, + parentBlockRoot: parentBlockRootHex, + builderPubkeys: [], + maxExecutionPayment: MAX_EXECUTION_PAYMENT, + minBid: builderConfig.minBid, + getParentExecutionRequests: () => { + parentExecutionRequestsPromise ??= chain.getParentExecutionRequests(parentSlot, parentBlockRootHex); + return parentExecutionRequestsPromise; + }, + }); + candidates.push({ + signedBid: suppliedBid, + totalGwei: getBuilderBidTotalGwei(suppliedBid.message, MAX_EXECUTION_PAYMENT), + boostFactor: builderConfig.builderBoostFactor, + source: "supplied", + receivedMs, + }); + } catch (e) { + metrics?.blockProductionSuppliedBidsDiscarded.inc(); + logger.warn( + "Ignoring invalid supplied bid", + {slot, builderIndex: suppliedBid.message.builderIndex}, + e as Error + ); + p2pBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex); + } + } + + // Gossip validation enforces executionPayment=0, so only the value counts toward the floor. + 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), + }); + p2pBid = null; + } + if (p2pBid !== null) { candidates.push({ signedBid: p2pBid.signedBid, totalGwei: BigInt(p2pBid.signedBid.message.value), boostFactor: builderConfig.builderBoostFactor, + source: "p2p", receivedMs: p2pBid.receivedMs, }); } @@ -1039,7 +1088,7 @@ export function getValidatorApi( logger.debug("Builder bid candidate", { slot, rank: index + 1, - source: candidate.url !== undefined ? toPrintableUrl(candidate.url) : "p2p", + source: candidate.url !== undefined ? toPrintableUrl(candidate.url) : candidate.source, builder: candidate.signedBid.message.builderIndex, total: prettyGweiToEth(candidate.totalGwei), boost: candidate.boostFactor, @@ -1124,9 +1173,10 @@ export function getValidatorApi( strictFeeRecipientCheck, circuitBreakerActive, builderEntries: builderConfig.builders.length, + bidSupplied: suppliedBid !== undefined, ...(bestBid !== null ? { - bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : "p2p", + bidSource: bestBid.url !== undefined ? toPrintableUrl(bestBid.url) : bestBid.source, bidValue: prettyGweiToEth(bestBid.signedBid.message.value), bidExecutionPayment: prettyGweiToEth(bestBid.signedBid.message.executionPayment), // The full bid total and the counted total used during bid selection @@ -1318,6 +1368,14 @@ export function getValidatorApi( return produceGloasBlock(args); }, + async produceBlockV4WithBid({builderBoostFactor, signedExecutionPayloadBid, ...args}) { + return produceGloasBlock({ + ...args, + builderConfig: {minBid: 0n, builderBoostFactor, builders: []}, + suppliedBid: signedExecutionPayloadBid, + }); + }, + async produceAttestationData({committeeIndex, slot}) { notWhileSyncing(chain, sync.state); diff --git a/packages/beacon-node/src/execution/builder/validateBid.ts b/packages/beacon-node/src/execution/builder/validateBid.ts index 4a3740e97791..b131ef184c3e 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, 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"; @@ -22,9 +21,10 @@ export function getBuilderBidTotalGwei(bid: gloas.ExecutionPayloadBid, maxExecut /** * Validate a bid received from a builder over the builder API in response to a bid request - * made during block production. Unlike gossip validation, the bid must match the requested - * slot and parent exactly, may carry a non-zero `executionPayment` which is counted at most at - * the entry's `maxExecutionPayment`, and is not subject to gossip anti-spam rules. + * made during block production, or supplied by the validator client. Unlike gossip validation, + * the bid must match the requested slot and parent exactly, may carry a non-zero + * `executionPayment` which is counted at most at `maxExecutionPayment`, and is not + * subject to gossip anti-spam rules. * * Throws with a description of the failure, the caller drops the bid. */ @@ -36,12 +36,24 @@ export async function validateBuilderApiExecutionPayloadBid( parentBlock: ProtoBlock; parentBlockHash: RootHex; parentBlockRoot: RootHex; - entry: routes.validator.BuilderEntry; + /** Builder pubkeys to accept bids from, empty accepts any builder */ + builderPubkeys: BLSPubkey[]; + maxExecutionPayment: bigint; + minBid: bigint; getParentExecutionRequests: () => Promise; } ): Promise { const bid = signedExecutionPayloadBid.message; - const {slot, parentBlock, parentBlockHash, parentBlockRoot, entry, getParentExecutionRequests} = request; + const { + slot, + parentBlock, + parentBlockHash, + parentBlockRoot, + builderPubkeys, + maxExecutionPayment, + minBid, + getParentExecutionRequests, + } = request; if (bid.slot !== slot) { throw Error(`Bid slot=${bid.slot} does not match requested slot=${slot}`); @@ -66,13 +78,13 @@ export async function validateBuilderApiExecutionPayloadBid( throw Error(`Bid has too many KZG commitments len=${blobKzgCommitmentsLen} limit=${maxBlobsPerBlock}`); } - const totalPayment = getBuilderBidTotalGwei(bid, entry.maxExecutionPayment); - if (totalPayment < entry.minBid) { + const totalPayment = getBuilderBidTotalGwei(bid, maxExecutionPayment); + if (totalPayment < minBid) { throw Error( `Bid total payment=${prettyGweiToEth(totalPayment)} ` + `(value=${prettyGweiToEth(bid.value)} ` + `executionPayment=${prettyGweiToEth(bid.executionPayment)}) ` + - `is below minBid=${prettyGweiToEth(entry.minBid)}` + `is below minBid=${prettyGweiToEth(minBid)}` ); } @@ -99,14 +111,11 @@ export async function validateBuilderApiExecutionPayloadBid( throw Error(`Invalid builder version=${builder.version} expected=${PAYLOAD_BUILDER_VERSION}`); } - // A bid not signed by one of the builder pubkeys the entry accepts bids from must not be accepted - if ( - entry.builderPubkeys.length > 0 && - !entry.builderPubkeys.some((pubkey) => byteArrayEquals(pubkey, builder.pubkey)) - ) { + // A bid not signed by one of the accepted builder pubkeys must not be accepted + if (builderPubkeys.length > 0 && !builderPubkeys.some((pubkey) => byteArrayEquals(pubkey, builder.pubkey))) { throw Error( - `Bid builder pubkey=${toHex(builder.pubkey)} is not in the entry's ` + - `builderPubkeys=${entry.builderPubkeys.map(toHex).join(",")}` + `Bid builder pubkey=${toHex(builder.pubkey)} is not in the accepted ` + + `builderPubkeys=${builderPubkeys.map(toHex).join(",")}` ); } diff --git a/packages/beacon-node/src/metrics/metrics/beacon.ts b/packages/beacon-node/src/metrics/metrics/beacon.ts index a951dc41e941..919edc800193 100644 --- a/packages/beacon-node/src/metrics/metrics/beacon.ts +++ b/packages/beacon-node/src/metrics/metrics/beacon.ts @@ -111,6 +111,10 @@ export function createBeaconMetrics(register: RegistryMetricCreator) { help: "Count of all block production selection results", labelNames: ["source", "reason"], }), + blockProductionSuppliedBidsDiscarded: register.counter({ + name: "beacon_block_production_supplied_bids_discarded_total", + help: "Count of execution payload bids supplied by the validator client discarded due to failed validation", + }), blockProductionNumAggregated: register.histogram<{source: ProducedBlockSource}>({ name: "beacon_block_production_num_aggregated_total", help: "Count of all aggregated attestations in our produced block", 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..797411c59f1c 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 @@ -580,6 +580,230 @@ describe("api/validator - produceBlockV4", () => { expect(block).toEqual(engineBlock); }); + it("uses a supplied bid without consulting p2p or builder API bids", async () => { + const suppliedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + suppliedBid.message.value = 2; + suppliedBid.message.builderIndex = 7; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); + + const {data: block, meta} = await api.produceBlockV4WithBid({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderBoostFactor: 100n, + signedExecutionPayloadBid: suppliedBid, + }); + + expect(modules.chain.builderApiClient.getExecutionPayloadBids).not.toHaveBeenCalled(); + expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); + expect(validateBuilderApiExecutionPayloadBid).toHaveBeenCalledWith( + modules.chain, + suppliedBid, + expect.objectContaining({slot, builderPubkeys: [], maxExecutionPayment: MAX_EXECUTION_PAYMENT, minBid: 0n}) + ); + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid: suppliedBid})); + expect(block).toEqual(bidBlock); + expect(meta.builderUrl).toBeUndefined(); + }); + + it("counts a supplied bid's execution payment in full", async () => { + const suppliedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + suppliedBid.message.value = 1; + suppliedBid.message.executionPayment = 2n; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + // Local payload value (2 gwei) beats the bid value alone (1 gwei) but not its total (3 gwei) + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: 2_000_000_000n, + consensusBlockValue: 0n, + })); + + const {data: block} = await api.produceBlockV4WithBid({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderBoostFactor: 100n, + signedExecutionPayloadBid: suppliedBid, + }); + + expect(block).toEqual(bidBlock); + }); + + it("prefers the local payload over a supplied bid with a zero boost factor", async () => { + const suppliedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + suppliedBid.message.value = 2; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + + const {data: block} = await api.produceBlockV4WithBid({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderBoostFactor: 0n, + signedExecutionPayloadBid: suppliedBid, + }); + + expect(block).toEqual(engineBlock); + }); + + it.each([true, false])("falls back to a p2p bid when the supplied bid fails validation (full=%s)", async (full) => { + modules.chain.forkChoice.shouldBuildOnFull.mockReturnValue(full); + const suppliedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + suppliedBid.message.value = 2; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); + vi.mocked(validateBuilderApiExecutionPayloadBid).mockRejectedValueOnce(new Error("Invalid bid")); + + const {data: block} = await api.produceBlockV4WithBid({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderBoostFactor: maxBuilderBoostFactor, + signedExecutionPayloadBid: suppliedBid, + }); + + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalledWith( + slot, + full ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash, + parentBlock.blockRoot + ); + expect(modules.chain.produceBlock).toHaveBeenCalledWith(expect.objectContaining({builderBid})); + expect(block).toEqual(bidBlock); + }); + + it.each([ + {p2pBid: null, localValue: 0n, boost: 100n}, + {p2pBid: toPooledBid(builderBid), localValue: 2_000_000_000n, boost: 100n}, + {p2pBid: toPooledBid(builderBid), localValue: 1n, boost: 0n}, + ])("keeps the local fallback when it wins: %s", async ({p2pBid, localValue, boost}) => { + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(p2pBid); + vi.mocked(validateBuilderApiExecutionPayloadBid).mockRejectedValueOnce(new Error("Invalid bid")); + modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ + block: attrs.builderBid !== undefined ? bidBlock : engineBlock, + executionPayloadValue: localValue, + consensusBlockValue: 0n, + })); + + const {data: block} = await api.produceBlockV4WithBid({ + slot, + randaoReveal, + includePayload: false, + builderBoostFactor: boost, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.defaultValue(), + }); + + expect(modules.chain.executionPayloadBidPool.getBestBid).toHaveBeenCalled(); + expect(block).toEqual(engineBlock); + }); + + it("uses the p2p fallback before the bid deadline without waiting", async () => { + vi.useFakeTimers(); + try { + vi.mocked(modules.chain.clock.msFromSlot).mockReturnValue(0); + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); + vi.mocked(validateBuilderApiExecutionPayloadBid).mockRejectedValueOnce(new Error("Invalid bid")); + + const {data: block} = await api.produceBlockV4WithBid({ + slot, + randaoReveal, + includePayload: false, + builderBoostFactor: 100n, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.defaultValue(), + }); + + expect(block).toEqual(bidBlock); + } finally { + vi.useRealTimers(); + } + }); + + it("returns the p2p fallback at the cutoff while local production is pending", async () => { + vi.useFakeTimers(); + const engineResult = {block: engineBlock, executionPayloadValue: 0n, consensusBlockValue: 0n}; + const engineDeferred = defer(); + try { + vi.mocked(modules.chain.clock.msFromSlot).mockReturnValue(600); + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); + modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); + vi.mocked(validateBuilderApiExecutionPayloadBid).mockRejectedValueOnce(new Error("Invalid bid")); + modules.chain.produceBlock.mockImplementation((attrs: {builderBid?: unknown}) => + attrs.builderBid !== undefined + ? Promise.resolve({block: bidBlock, executionPayloadValue: 0n, consensusBlockValue: 0n}) + : engineDeferred.promise + ); + + const blockPromise = api.produceBlockV4WithBid({ + slot, + randaoReveal, + includePayload: false, + builderBoostFactor: 100n, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.defaultValue(), + }); + await vi.advanceTimersByTimeAsync(1_400); + const {data: block} = await blockPromise; + + expect(block).toEqual(bidBlock); + } finally { + engineDeferred.resolve(engineResult); + vi.useRealTimers(); + } + }); + + it("ignores the supplied bid when the builder circuit breaker is active", async () => { + const suppliedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + suppliedBid.message.value = 2; + + modules.chain.builderCircuitBreaker.isActive.mockReturnValue(true); + + const {data: block} = await api.produceBlockV4WithBid({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderBoostFactor: maxBuilderBoostFactor, + signedExecutionPayloadBid: suppliedBid, + }); + + expect(validateBuilderApiExecutionPayloadBid).not.toHaveBeenCalled(); + expect(modules.chain.executionPayloadBidPool.getBestBid).not.toHaveBeenCalled(); + expect(modules.chain.produceBlock).toHaveBeenCalledTimes(1); + expect(block).toEqual(engineBlock); + }); + + it.each([maxBuilderBoostFactor + 1n, -1n])( + "rejects a supplied bid request with a boost factor outside the uint64 range: %s", + async (builderBoostFactor) => { + await expect( + api.produceBlockV4WithBid({ + slot, + randaoReveal, + graffiti, + feeRecipient, + includePayload: false, + builderBoostFactor, + signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.defaultValue(), + }) + ).rejects.toThrow("Invalid builderBoostFactor"); + + expect(modules.chain.produceBlock).not.toHaveBeenCalled(); + } + ); + it("prefers the builder bid with the maximum builder boost factor", async () => { modules.chain.builderCircuitBreaker.isActive.mockReturnValue(false); modules.chain.executionPayloadBidPool.getBestBid.mockReturnValue(toPooledBid(builderBid)); diff --git a/packages/validator/test/utils/apiStub.ts b/packages/validator/test/utils/apiStub.ts index 202d70dc6be8..d49cafa37088 100644 --- a/packages/validator/test/utils/apiStub.ts +++ b/packages/validator/test/utils/apiStub.ts @@ -27,6 +27,7 @@ export function getApiClientStub(): ApiClientStub { prepareBeaconCommitteeSubnet: vi.fn(), produceBlockV3: vi.fn(), produceBlockV4: vi.fn(), + produceBlockV4WithBid: vi.fn(), getSyncCommitteeDuties: vi.fn(), prepareSyncCommitteeSubnets: vi.fn(), produceSyncCommitteeContribution: vi.fn(),