From c86a6696101b4417dd0ff841824d85de0553e285 Mon Sep 17 00:00:00 2001 From: g11tech Date: Sun, 31 Dec 2023 13:37:50 +0530 Subject: [PATCH] feat: allow validator to request blinded versions for locally produced and selected blocks (#6227) * feat: allow validator to request blinded versions for locally produced and selected blocks lint fix the types and tests * add blinded local combinations to sim * debug and fix issues --- packages/api/src/beacon/routes/validator.ts | 29 +- .../test/unit/beacon/testData/validator.ts | 3 +- .../src/api/impl/validator/index.ts | 389 ++++++++++-------- packages/cli/src/cmds/validator/handler.ts | 1 + packages/cli/src/cmds/validator/options.ts | 7 + packages/cli/test/sim/multi_fork.test.ts | 36 +- .../simulation/validator_clients/lodestar.ts | 3 +- packages/validator/src/services/block.ts | 35 +- .../validator/src/services/validatorStore.ts | 2 + packages/validator/src/validator.ts | 2 + .../test/unit/services/block.test.ts | 57 ++- 11 files changed, 378 insertions(+), 186 deletions(-) diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index f7a731783681..78fc1a67d32c 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -19,6 +19,7 @@ import { SubcommitteeIndex, Wei, Gwei, + ProducedBlockSource, } from "@lodestar/types"; import {ApiClientResponse} from "../../interfaces.js"; import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; @@ -53,6 +54,7 @@ export type ExtraProduceBlockOps = { feeRecipient?: string; builderSelection?: BuilderSelection; strictFeeRecipientCheck?: boolean; + blindedLocal?: boolean; }; export type ProduceBlockOrContentsRes = {executionPayloadValue: Wei; consensusBlockValue: Gwei} & ( @@ -64,9 +66,10 @@ export type ProduceBlindedBlockRes = {executionPayloadValue: Wei; consensusBlock version: ForkExecution; }; -export type ProduceFullOrBlindedBlockOrContentsRes = +export type ProduceFullOrBlindedBlockOrContentsRes = {executionPayloadSource: ProducedBlockSource} & ( | (ProduceBlockOrContentsRes & {executionPayloadBlinded: false}) - | (ProduceBlindedBlockRes & {executionPayloadBlinded: true}); + | (ProduceBlindedBlockRes & {executionPayloadBlinded: true}) +); // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes @@ -485,6 +488,7 @@ export type ReqTypes = { fee_recipient?: string; builder_selection?: string; strict_fee_recipient_check?: boolean; + blinded_local?: boolean; }; }; produceBlindedBlock: {params: {slot: number}; query: {randao_reveal: string; graffiti: string}}; @@ -552,6 +556,7 @@ export function getReqSerializers(): ReqSerializers { skip_randao_verification: skipRandaoVerification, builder_selection: opts?.builderSelection, strict_fee_recipient_check: opts?.strictFeeRecipientCheck, + blinded_local: opts?.blindedLocal, }, }), parseReq: ({params, query}) => [ @@ -563,6 +568,7 @@ export function getReqSerializers(): ReqSerializers { feeRecipient: query.fee_recipient, builderSelection: query.builder_selection as BuilderSelection, strictFeeRecipientCheck: query.strict_fee_recipient_check, + blindedLocal: query.blinded_local, }, ], schema: { @@ -574,6 +580,7 @@ export function getReqSerializers(): ReqSerializers { skip_randao_verification: Schema.Boolean, builder_selection: Schema.String, strict_fee_recipient_check: Schema.Boolean, + blinded_local: Schema.Boolean, }, }, }; @@ -739,20 +746,32 @@ export function getReturnTypes(): ReturnTypes { if (data.executionPayloadBlinded) { return { execution_payload_blinded: true, + execution_payload_source: data.executionPayloadSource, ...(produceBlindedBlock.toJson(data) as Record), }; } else { return { execution_payload_blinded: false, + execution_payload_source: data.executionPayloadSource, ...(produceBlockOrContents.toJson(data) as Record), }; } }, fromJson: (data) => { - if ((data as {execution_payload_blinded: true}).execution_payload_blinded) { - return {executionPayloadBlinded: true, ...produceBlindedBlock.fromJson(data)}; + const executionPayloadBlinded = (data as {execution_payload_blinded: boolean}).execution_payload_blinded; + if (executionPayloadBlinded === undefined) { + throw Error(`Invalid executionPayloadBlinded=${executionPayloadBlinded} for fromJson deserialization`); + } + + // extract source from the data and assign defaults in the spec complaint manner if not present in response + const executionPayloadSource = + (data as {execution_payload_source: ProducedBlockSource}).execution_payload_source ?? + (executionPayloadBlinded ? ProducedBlockSource.builder : ProducedBlockSource.engine); + + if (executionPayloadBlinded) { + return {executionPayloadBlinded, executionPayloadSource, ...produceBlindedBlock.fromJson(data)}; } else { - return {executionPayloadBlinded: false, ...produceBlockOrContents.fromJson(data)}; + return {executionPayloadBlinded, executionPayloadSource, ...produceBlockOrContents.fromJson(data)}; } }, }, diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index b827bad0be90..c10f67fa4095 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -1,5 +1,5 @@ import {ForkName} from "@lodestar/params"; -import {ssz} from "@lodestar/types"; +import {ssz, ProducedBlockSource} from "@lodestar/types"; import {Api} from "../../../../src/beacon/routes/validator.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; @@ -83,6 +83,7 @@ export const testData: GenericServerTestCases = { executionPayloadValue: ssz.Wei.defaultValue(), consensusBlockValue: ssz.Gwei.defaultValue(), executionPayloadBlinded: false, + executionPayloadSource: ProducedBlockSource.engine, }, }, produceBlindedBlock: { diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 3f813f32a3fd..395684960c7f 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -416,193 +416,204 @@ export function getValidatorApi({ } }; - const produceBlockV3: ServerApi["produceBlockV3"] = async function produceBlockV3( - slot, - randaoReveal, - graffiti, - // TODO deneb: skip randao verification - _skipRandaoVerification?: boolean, - {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOps = {} - ) { - notWhileSyncing(); - await waitForSlot(slot); // Must never request for a future slot > currentSlot - - // Process the queued attestations in the forkchoice for correct head estimation - // forkChoice.updateTime() might have already been called by the onSlot clock - // handler, in which case this should just return. - chain.forkChoice.updateTime(slot); - chain.recomputeForkChoiceHead(); - - const fork = config.getForkName(slot); - // set some sensible opts - builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; - const isBuilderEnabled = - ForkSeq[fork] >= ForkSeq.bellatrix && - chain.executionBuilder !== undefined && - builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; - - logger.verbose("Assembling block with produceBlockV3 ", { - fork, - builderSelection, + const produceEngineOrBuilderBlock: ServerApi["produceBlockV3"] = + async function produceEngineOrBuilderBlock( slot, - isBuilderEnabled, - strictFeeRecipientCheck, - }); - // Start calls for building execution and builder blocks - const blindedBlockPromise = isBuilderEnabled - ? // can't do fee recipient checks as builder bid doesn't return feeRecipient as of now - produceBuilderBlindedBlock(slot, randaoReveal, graffiti, { - feeRecipient, - // skip checking and recomputing head in these individual produce calls - skipHeadChecksAndUpdate: true, - }).catch((e) => { - logger.error("produceBuilderBlindedBlock failed to produce block", {slot}, e); - return null; - }) - : null; + randaoReveal, + graffiti, + // TODO deneb: skip randao verification + _skipRandaoVerification?: boolean, + {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOps = {} + ) { + notWhileSyncing(); + await waitForSlot(slot); // Must never request for a future slot > currentSlot - const fullBlockPromise = - // At any point either the builder or execution or both flows should be active. - // - // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager - // configurations could cause a validator pubkey to have builder disabled with builder selection builder only - // (TODO: independently make sure such an options update is not successful for a validator pubkey) - // - // So if builder is disabled ignore builder selection of builderonly if caused by user mistake - !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly - ? // TODO deneb: builderSelection needs to be figured out if to be done beacon side - // || builderSelection !== BuilderSelection.BuilderOnly - produceEngineFullBlockOrContents(slot, randaoReveal, graffiti, { + // Process the queued attestations in the forkchoice for correct head estimation + // forkChoice.updateTime() might have already been called by the onSlot clock + // handler, in which case this should just return. + chain.forkChoice.updateTime(slot); + chain.recomputeForkChoiceHead(); + + const fork = config.getForkName(slot); + // set some sensible opts + builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; + const isBuilderEnabled = + ForkSeq[fork] >= ForkSeq.bellatrix && + chain.executionBuilder !== undefined && + builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; + + logger.verbose("Assembling block with produceEngineOrBuilderBlock ", { + fork, + builderSelection, + slot, + isBuilderEnabled, + strictFeeRecipientCheck, + }); + // Start calls for building execution and builder blocks + const blindedBlockPromise = isBuilderEnabled + ? // can't do fee recipient checks as builder bid doesn't return feeRecipient as of now + produceBuilderBlindedBlock(slot, randaoReveal, graffiti, { feeRecipient, - strictFeeRecipientCheck, // skip checking and recomputing head in these individual produce calls skipHeadChecksAndUpdate: true, }).catch((e) => { - logger.error("produceEngineFullBlockOrContents failed to produce block", {slot}, e); + logger.error("produceBuilderBlindedBlock failed to produce block", {slot}, e); return null; }) : null; - let blindedBlock, fullBlock; - if (blindedBlockPromise !== null && fullBlockPromise !== null) { - // reference index of promises in the race - const promisesOrder = [ProducedBlockSource.builder, ProducedBlockSource.engine]; - [blindedBlock, fullBlock] = await racePromisesWithCutoff< - routes.validator.ProduceBlockOrContentsRes | routes.validator.ProduceBlindedBlockRes | null - >( - [blindedBlockPromise, fullBlockPromise], - BLOCK_PRODUCTION_RACE_CUTOFF_MS, - BLOCK_PRODUCTION_RACE_TIMEOUT_MS, - // Callback to log the race events for better debugging capability - (event: RaceEvent, delayMs: number, index?: number) => { - const eventRef = index !== undefined ? {source: promisesOrder[index]} : {}; - logger.verbose("Block production race (builder vs execution)", { - event, - ...eventRef, - delayMs, - cutoffMs: BLOCK_PRODUCTION_RACE_CUTOFF_MS, - timeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, - slot, - }); + const fullBlockPromise = + // At any point either the builder or execution or both flows should be active. + // + // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager + // configurations could cause a validator pubkey to have builder disabled with builder selection builder only + // (TODO: independently make sure such an options update is not successful for a validator pubkey) + // + // So if builder is disabled ignore builder selection of builderonly if caused by user mistake + !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly + ? // TODO deneb: builderSelection needs to be figured out if to be done beacon side + // || builderSelection !== BuilderSelection.BuilderOnly + produceEngineFullBlockOrContents(slot, randaoReveal, graffiti, { + feeRecipient, + strictFeeRecipientCheck, + // skip checking and recomputing head in these individual produce calls + skipHeadChecksAndUpdate: true, + }).catch((e) => { + logger.error("produceEngineFullBlockOrContents failed to produce block", {slot}, e); + return null; + }) + : null; + + let blindedBlock, fullBlock; + if (blindedBlockPromise !== null && fullBlockPromise !== null) { + // reference index of promises in the race + const promisesOrder = [ProducedBlockSource.builder, ProducedBlockSource.engine]; + [blindedBlock, fullBlock] = await racePromisesWithCutoff< + routes.validator.ProduceBlockOrContentsRes | routes.validator.ProduceBlindedBlockRes | null + >( + [blindedBlockPromise, fullBlockPromise], + BLOCK_PRODUCTION_RACE_CUTOFF_MS, + BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + // Callback to log the race events for better debugging capability + (event: RaceEvent, delayMs: number, index?: number) => { + const eventRef = index !== undefined ? {source: promisesOrder[index]} : {}; + logger.verbose("Block production race (builder vs execution)", { + event, + ...eventRef, + delayMs, + cutoffMs: BLOCK_PRODUCTION_RACE_CUTOFF_MS, + timeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + slot, + }); + } + ); + if (blindedBlock instanceof Error) { + // error here means race cutoff exceeded + logger.error("Failed to produce builder block", {slot}, blindedBlock); + blindedBlock = null; } - ); - if (blindedBlock instanceof Error) { - // error here means race cutoff exceeded - logger.error("Failed to produce builder block", {slot}, blindedBlock); - blindedBlock = null; - } - if (fullBlock instanceof Error) { - logger.error("Failed to produce execution block", {slot}, fullBlock); + if (fullBlock instanceof Error) { + logger.error("Failed to produce execution block", {slot}, fullBlock); + fullBlock = null; + } + } else if (blindedBlockPromise !== null && fullBlockPromise === null) { + blindedBlock = await blindedBlockPromise; fullBlock = null; + } else if (blindedBlockPromise === null && fullBlockPromise !== null) { + blindedBlock = null; + fullBlock = await fullBlockPromise; + } else { + throw Error( + `Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}` + ); } - } else if (blindedBlockPromise !== null && fullBlockPromise === null) { - blindedBlock = await blindedBlockPromise; - fullBlock = null; - } else if (blindedBlockPromise === null && fullBlockPromise !== null) { - blindedBlock = null; - fullBlock = await fullBlockPromise; - } else { - throw Error( - `Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}` - ); - } - const builderPayloadValue = blindedBlock?.executionPayloadValue ?? BigInt(0); - const enginePayloadValue = fullBlock?.executionPayloadValue ?? BigInt(0); - const consensusBlockValueBuilder = blindedBlock?.consensusBlockValue ?? BigInt(0); - const consensusBlockValueEngine = fullBlock?.consensusBlockValue ?? BigInt(0); + const builderPayloadValue = blindedBlock?.executionPayloadValue ?? BigInt(0); + const enginePayloadValue = fullBlock?.executionPayloadValue ?? BigInt(0); + const consensusBlockValueBuilder = blindedBlock?.consensusBlockValue ?? BigInt(0); + const consensusBlockValueEngine = fullBlock?.consensusBlockValue ?? BigInt(0); - const blockValueBuilder = builderPayloadValue + gweiToWei(consensusBlockValueBuilder); // Total block value is in wei - const blockValueEngine = enginePayloadValue + gweiToWei(consensusBlockValueEngine); // Total block value is in wei + const blockValueBuilder = builderPayloadValue + gweiToWei(consensusBlockValueBuilder); // Total block value is in wei + const blockValueEngine = enginePayloadValue + gweiToWei(consensusBlockValueEngine); // Total block value is in wei - let selectedSource: ProducedBlockSource | null = null; + let executionPayloadSource: ProducedBlockSource | null = null; - if (fullBlock && blindedBlock) { - switch (builderSelection) { - case routes.validator.BuilderSelection.MaxProfit: { - if (blockValueEngine >= blockValueBuilder) { - selectedSource = ProducedBlockSource.engine; - } else { - selectedSource = ProducedBlockSource.builder; + if (fullBlock && blindedBlock) { + switch (builderSelection) { + case routes.validator.BuilderSelection.MaxProfit: { + if (blockValueEngine >= blockValueBuilder) { + executionPayloadSource = ProducedBlockSource.engine; + } else { + executionPayloadSource = ProducedBlockSource.builder; + } + break; } - break; - } - case routes.validator.BuilderSelection.ExecutionOnly: { - selectedSource = ProducedBlockSource.engine; - break; - } + case routes.validator.BuilderSelection.ExecutionOnly: { + executionPayloadSource = ProducedBlockSource.engine; + break; + } - // For everything else just select the builder - default: { - selectedSource = ProducedBlockSource.builder; + // For everything else just select the builder + default: { + executionPayloadSource = ProducedBlockSource.builder; + } } + logger.verbose(`Selected executionPayloadSource=${executionPayloadSource} block`, { + builderSelection, + // winston logger doesn't like bigint + enginePayloadValue: `${enginePayloadValue}`, + builderPayloadValue: `${builderPayloadValue}`, + consensusBlockValueEngine: `${consensusBlockValueEngine}`, + consensusBlockValueBuilder: `${consensusBlockValueBuilder}`, + blockValueEngine: `${blockValueEngine}`, + blockValueBuilder: `${blockValueBuilder}`, + slot, + }); + } else if (fullBlock && !blindedBlock) { + executionPayloadSource = ProducedBlockSource.engine; + logger.verbose("Selected engine block: no builder block produced", { + // winston logger doesn't like bigint + enginePayloadValue: `${enginePayloadValue}`, + consensusBlockValueEngine: `${consensusBlockValueEngine}`, + blockValueEngine: `${blockValueEngine}`, + slot, + }); + } else if (blindedBlock && !fullBlock) { + executionPayloadSource = ProducedBlockSource.builder; + logger.verbose("Selected builder block: no engine block produced", { + // winston logger doesn't like bigint + builderPayloadValue: `${builderPayloadValue}`, + consensusBlockValueBuilder: `${consensusBlockValueBuilder}`, + blockValueBuilder: `${blockValueBuilder}`, + slot, + }); } - logger.verbose(`Selected ${selectedSource} block`, { - builderSelection, - // winston logger doesn't like bigint - enginePayloadValue: `${enginePayloadValue}`, - builderPayloadValue: `${builderPayloadValue}`, - consensusBlockValueEngine: `${consensusBlockValueEngine}`, - consensusBlockValueBuilder: `${consensusBlockValueBuilder}`, - blockValueEngine: `${blockValueEngine}`, - blockValueBuilder: `${blockValueBuilder}`, - slot, - }); - } else if (fullBlock && !blindedBlock) { - selectedSource = ProducedBlockSource.engine; - logger.verbose("Selected engine block: no builder block produced", { - // winston logger doesn't like bigint - enginePayloadValue: `${enginePayloadValue}`, - consensusBlockValueEngine: `${consensusBlockValueEngine}`, - blockValueEngine: `${blockValueEngine}`, - slot, - }); - } else if (blindedBlock && !fullBlock) { - selectedSource = ProducedBlockSource.builder; - logger.verbose("Selected builder block: no engine block produced", { - // winston logger doesn't like bigint - builderPayloadValue: `${builderPayloadValue}`, - consensusBlockValueBuilder: `${consensusBlockValueBuilder}`, - blockValueBuilder: `${blockValueBuilder}`, - slot, - }); - } - if (selectedSource === null) { - throw Error(`Failed to produce engine or builder block for slot=${slot}`); - } + if (executionPayloadSource === null) { + throw Error(`Failed to produce engine or builder block for slot=${slot}`); + } - if (selectedSource === ProducedBlockSource.engine) { - return {...fullBlock, executionPayloadBlinded: false} as routes.validator.ProduceBlockOrContentsRes & { - executionPayloadBlinded: false; - }; - } else { - return {...blindedBlock, executionPayloadBlinded: true} as routes.validator.ProduceBlindedBlockRes & { - executionPayloadBlinded: true; - }; - } - }; + if (executionPayloadSource === ProducedBlockSource.engine) { + return { + ...fullBlock, + executionPayloadBlinded: false, + executionPayloadSource, + } as routes.validator.ProduceBlockOrContentsRes & { + executionPayloadBlinded: false; + executionPayloadSource: ProducedBlockSource; + }; + } else { + return { + ...blindedBlock, + executionPayloadBlinded: true, + executionPayloadSource, + } as routes.validator.ProduceBlindedBlockRes & { + executionPayloadBlinded: true; + executionPayloadSource: ProducedBlockSource; + }; + } + }; const produceBlock: ServerApi["produceBlock"] = async function produceBlock( slot, @@ -621,7 +632,7 @@ export function getValidatorApi({ const produceEngineOrBuilderBlindedBlock: ServerApi["produceBlindedBlock"] = async function produceEngineOrBuilderBlindedBlock(slot, randaoReveal, graffiti) { - const {data, executionPayloadValue, consensusBlockValue, version} = await produceBlockV3( + const {data, executionPayloadValue, consensusBlockValue, version} = await produceEngineOrBuilderBlock( slot, randaoReveal, graffiti @@ -643,6 +654,56 @@ export function getValidatorApi({ } }; + const produceBlockV3: ServerApi["produceBlockV3"] = async function produceBlockV3( + slot, + randaoReveal, + graffiti, + skipRandaoVerification?: boolean, + opts: routes.validator.ExtraProduceBlockOps = {} + ) { + const produceBlockEngineOrBuilderRes = await produceEngineOrBuilderBlock( + slot, + randaoReveal, + graffiti, + skipRandaoVerification, + opts + ); + + if (opts.blindedLocal === true && ForkSeq[produceBlockEngineOrBuilderRes.version] >= ForkSeq.bellatrix) { + if (produceBlockEngineOrBuilderRes.executionPayloadBlinded) { + return produceBlockEngineOrBuilderRes; + } else { + if (isBlockContents(produceBlockEngineOrBuilderRes.data)) { + const {block} = produceBlockEngineOrBuilderRes.data; + const blindedBlock = beaconBlockToBlinded(config, block as allForks.AllForksExecution["BeaconBlock"]); + return { + ...produceBlockEngineOrBuilderRes, + data: blindedBlock, + executionPayloadBlinded: true, + } as routes.validator.ProduceBlindedBlockRes & { + executionPayloadBlinded: true; + executionPayloadSource: ProducedBlockSource; + }; + } else { + const blindedBlock = beaconBlockToBlinded( + config, + produceBlockEngineOrBuilderRes.data as allForks.AllForksExecution["BeaconBlock"] + ); + return { + ...produceBlockEngineOrBuilderRes, + data: blindedBlock, + executionPayloadBlinded: true, + } as routes.validator.ProduceBlindedBlockRes & { + executionPayloadBlinded: true; + executionPayloadSource: ProducedBlockSource; + }; + } + } + } else { + return produceBlockEngineOrBuilderRes; + } + }; + return { produceBlock, produceBlockV2: produceEngineFullBlockOrContents, diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index fe14cedbcca1..7713bf5a6dd1 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -170,6 +170,7 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr distributed: args.distributed, useProduceBlockV3: args.useProduceBlockV3, broadcastValidation: parseBroadcastValidation(args.broadcastValidation), + blindedLocal: args.blindedLocal, }, metrics ); diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 4f0ec476f01c..41069cfbdd34 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -48,6 +48,7 @@ export type IValidatorCliArgs = AccountValidatorArgs & useProduceBlockV3?: boolean; broadcastValidation?: string; + blindedLocal?: boolean; importKeystores?: string[]; importKeystoresPassword?: string; @@ -257,6 +258,12 @@ export const validatorOptions: CliCommandOptions = { defaultDescription: `${defaultOptions.broadcastValidation}`, }, + blindedLocal: { + type: "string", + description: "Request fetching local block in blinded format for produceBlockV3", + defaultDescription: `${defaultOptions.blindedLocal}`, + }, + importKeystores: { alias: ["keystore"], // Backwards compatibility with old `validator import` cmdx description: "Path(s) to a directory or single file path to validator keystores, i.e. Launchpad validators", diff --git a/packages/cli/test/sim/multi_fork.test.ts b/packages/cli/test/sim/multi_fork.test.ts index 71d6eb4a42ea..734ae5c5a380 100644 --- a/packages/cli/test/sim/multi_fork.test.ts +++ b/packages/cli/test/sim/multi_fork.test.ts @@ -65,10 +65,11 @@ const env = await SimulationEnvironment.initWithDefaults( validator: { type: ValidatorClient.Lodestar, options: { + // this will cause race in beacon but since builder is not attached will + // return with engine full block and publish via publishBlockV2 clientOptions: { useProduceBlockV3: true, - // default builder selection will cause a race try in beacon even if builder is not set - // but not to worry, execution block will be selected as fallback anyway + "builder.selection": "maxprofit", }, }, }, @@ -82,12 +83,12 @@ const env = await SimulationEnvironment.initWithDefaults( validator: { type: ValidatorClient.Lodestar, options: { + // this will make the beacon respond with blinded version of the local block as no + // builder is attached to beacon, and publish via publishBlindedBlockV2 clientOptions: { - useProduceBlockV3: false, - // default builder selection of max profit will make it use produceBlindedBlock - // but not to worry, execution block will be selected as fallback anyway - // but returned in blinded format for validator to use publish blinded block - // which assembles block beacon side from local cache before publishing + useProduceBlockV3: true, + "builder.selection": "maxprofit", + blindedLocal: true, }, }, }, @@ -101,9 +102,9 @@ const env = await SimulationEnvironment.initWithDefaults( validator: { type: ValidatorClient.Lodestar, options: { + // this builder selection will make it use produceBlockV2 and respond with full block clientOptions: { useProduceBlockV3: false, - // this builder selection will make it use produceBlockV2 "builder.selection": "executiononly", }, }, @@ -111,7 +112,24 @@ const env = await SimulationEnvironment.initWithDefaults( execution: ExecutionClient.Nethermind, keysCount: 32, }, - {id: "node-4", beacon: BeaconClient.Lighthouse, execution: ExecutionClient.Geth, keysCount: 32}, + { + id: "node-4", + beacon: BeaconClient.Lodestar, + validator: { + type: ValidatorClient.Lodestar, + options: { + // this builder selection will make it use produceBlindedBlockV2 and respond with blinded version + // of local block and subsequent publishing via publishBlindedBlock + clientOptions: { + useProduceBlockV3: false, + "builder.selection": "maxprofit", + }, + }, + }, + execution: ExecutionClient.Nethermind, + keysCount: 32, + }, + {id: "node-5", beacon: BeaconClient.Lighthouse, execution: ExecutionClient.Geth, keysCount: 32}, ] ); diff --git a/packages/cli/test/utils/simulation/validator_clients/lodestar.ts b/packages/cli/test/utils/simulation/validator_clients/lodestar.ts index a85347d780c5..7c0c9e3537b1 100644 --- a/packages/cli/test/utils/simulation/validator_clients/lodestar.ts +++ b/packages/cli/test/utils/simulation/validator_clients/lodestar.ts @@ -15,7 +15,7 @@ import {getNodePorts} from "../utils/ports.js"; export const generateLodestarValidatorNode: ValidatorNodeGenerator = (opts, runner) => { const {paths, id, keys, forkConfig, genesisTime, nodeIndex, beaconUrls, clientOptions} = opts; const {rootDir, keystoresDir, keystoresSecretFilePath, logFilePath} = paths; - const {useProduceBlockV3, "builder.selection": builderSelection} = clientOptions ?? {}; + const {useProduceBlockV3, "builder.selection": builderSelection, blindedLocal} = clientOptions ?? {}; const ports = getNodePorts(nodeIndex); const rcConfigPath = path.join(rootDir, "rc_config.json"); const paramsPath = path.join(rootDir, "params.json"); @@ -41,6 +41,7 @@ export const generateLodestarValidatorNode: ValidatorNodeGenerator}; +type BlockProposalOpts = { + useProduceBlockV3: boolean; + broadcastValidation: routes.beacon.BroadcastValidation; + blindedLocal: boolean; +}; /** * Service that sets up and handles validator block proposal duties. */ @@ -64,7 +72,7 @@ export class BlockProposingService { private readonly clock: IClock, private readonly validatorStore: ValidatorStore, private readonly metrics: Metrics | null, - private readonly opts: {useProduceBlockV3: boolean; broadcastValidation: routes.beacon.BroadcastValidation} + private readonly opts: BlockProposalOpts ) { this.dutiesService = new BlockDutiesService( config, @@ -115,6 +123,7 @@ export class BlockProposingService { const strictFeeRecipientCheck = this.validatorStore.strictFeeRecipientCheck(pubkeyHex); const builderSelection = this.validatorStore.getBuilderSelection(pubkeyHex); const feeRecipient = this.validatorStore.getFeeRecipient(pubkeyHex); + const blindedLocal = this.opts.blindedLocal; this.logger.debug("Producing block", { ...debugLogCtx, @@ -122,6 +131,7 @@ export class BlockProposingService { feeRecipient, strictFeeRecipientCheck, useProduceBlockV3: this.opts.useProduceBlockV3, + blindedLocal, }); this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot)); @@ -130,6 +140,7 @@ export class BlockProposingService { feeRecipient, strictFeeRecipientCheck, builderSelection, + blindedLocal, }; const blockContents = await produceBlockFn(this.config, slot, randaoReveal, graffiti, produceOpts).catch( (e: Error) => { @@ -184,18 +195,20 @@ export class BlockProposingService { slot: Slot, randaoReveal: BLSSignature, graffiti: string, - {feeRecipient, strictFeeRecipientCheck, builderSelection}: routes.validator.ExtraProduceBlockOps + {feeRecipient, strictFeeRecipientCheck, builderSelection, blindedLocal}: routes.validator.ExtraProduceBlockOps ): Promise => { const res = await this.api.validator.produceBlockV3(slot, randaoReveal, graffiti, false, { feeRecipient, builderSelection, strictFeeRecipientCheck, + blindedLocal, }); ApiError.assert(res, "Failed to produce block: validator.produceBlockV2"); const {response} = res; const debugLogCtx = { - source: response.executionPayloadBlinded ? ProducedBlockSource.builder : ProducedBlockSource.engine, + executionPayloadSource: response.executionPayloadSource, + executionPayloadBlinded: response.executionPayloadBlinded, // winston logger doesn't like bigint executionPayloadValue: `${formatBigDecimal(response.executionPayloadValue, ETH_TO_WEI, MAX_DECIMAL_FACTOR)} ETH`, consensusBlockValue: `${formatBigDecimal(response.consensusBlockValue, ETH_TO_GWEI, MAX_DECIMAL_FACTOR)} ETH`, @@ -231,14 +244,23 @@ export class BlockProposingService { const res = await this.api.validator.produceBlockV2(slot, randaoReveal, graffiti); ApiError.assert(res, "Failed to produce block: validator.produceBlockV2"); const {response} = res; - return parseProduceBlockResponse({executionPayloadBlinded: false, ...response}, debugLogCtx); + const executionPayloadSource = ProducedBlockSource.engine; + + return parseProduceBlockResponse( + {executionPayloadBlinded: false, executionPayloadSource, ...response}, + debugLogCtx + ); } else { Object.assign(debugLogCtx, {api: "produceBlindedBlock"}); const res = await this.api.validator.produceBlindedBlock(slot, randaoReveal, graffiti); ApiError.assert(res, "Failed to produce block: validator.produceBlockV2"); const {response} = res; + const executionPayloadSource = ProducedBlockSource.builder; - return parseProduceBlockResponse({executionPayloadBlinded: true, ...response}, debugLogCtx); + return parseProduceBlockResponse( + {executionPayloadBlinded: true, executionPayloadSource, ...response}, + debugLogCtx + ); } }; } @@ -253,6 +275,7 @@ function parseProduceBlockResponse( contents: null, version: response.version, executionPayloadBlinded: true, + executionPayloadSource: response.executionPayloadSource, debugLogCtx, } as FullOrBlindedBlockWithContents & DebugLogCtx; } else { @@ -262,6 +285,7 @@ function parseProduceBlockResponse( contents: {blobs: response.data.blobs, kzgProofs: response.data.kzgProofs}, version: response.version, executionPayloadBlinded: false, + executionPayloadSource: response.executionPayloadSource, debugLogCtx, } as FullOrBlindedBlockWithContents & DebugLogCtx; } else { @@ -270,6 +294,7 @@ function parseProduceBlockResponse( contents: null, version: response.version, executionPayloadBlinded: false, + executionPayloadSource: response.executionPayloadSource, debugLogCtx, } as FullOrBlindedBlockWithContents & DebugLogCtx; } diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 698de1dc7053..8cafaa5b14b6 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -127,6 +127,8 @@ export const defaultOptions = { useProduceBlockV3: false, // spec asks for gossip validation by default broadcastValidation: routes.beacon.BroadcastValidation.gossip, + // should request fetching the locally produced block in blinded format + blindedLocal: false, }; /** diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index 571aec71e019..f28a9afbaff6 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -58,6 +58,7 @@ export type ValidatorOptions = { distributed?: boolean; useProduceBlockV3?: boolean; broadcastValidation?: routes.beacon.BroadcastValidation; + blindedLocal?: boolean; }; // TODO: Extend the timeout, and let it be customizable @@ -210,6 +211,7 @@ export class Validator { const blockProposingService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, metrics, { useProduceBlockV3: opts.useProduceBlockV3 ?? defaultOptions.useProduceBlockV3, broadcastValidation: opts.broadcastValidation ?? defaultOptions.broadcastValidation, + blindedLocal: opts.blindedLocal ?? defaultOptions.blindedLocal, }); const attestationService = new AttestationService( diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index 745ada3de9ca..f879017c95f1 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -5,7 +5,7 @@ import {toHexString} from "@chainsafe/ssz"; import {createChainForkConfig} from "@lodestar/config"; import {config as mainnetConfig} from "@lodestar/config/default"; import {sleep} from "@lodestar/utils"; -import {ssz} from "@lodestar/types"; +import {ssz, ProducedBlockSource} from "@lodestar/types"; import {HttpStatusCode, routes} from "@lodestar/api"; import {ForkName} from "@lodestar/params"; import {BlockProposingService} from "../../../src/services/block.js"; @@ -53,6 +53,7 @@ describe("BlockDutiesService", function () { const blockService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, null, { useProduceBlockV3: true, broadcastValidation: routes.beacon.BroadcastValidation.consensus, + blindedLocal: false, }); const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); @@ -65,6 +66,7 @@ describe("BlockDutiesService", function () { executionPayloadValue: BigInt(1), consensusBlockValue: BigInt(1), executionPayloadBlinded: false, + executionPayloadSource: ProducedBlockSource.engine, }, ok: true, status: HttpStatusCode.OK, @@ -85,4 +87,57 @@ describe("BlockDutiesService", function () { "wrong publishBlock() args" ); }); + + it("Should produce, sign, and publish a blinded block", async function () { + // Reply with some duties + const slot = 0; // genesisTime is right now, so test with slot = currentSlot + api.validator.getProposerDuties.resolves({ + response: { + dependentRoot: ZERO_HASH_HEX, + executionOptimistic: false, + data: [{slot: slot, validatorIndex: 0, pubkey: pubkeys[0]}], + }, + ok: true, + status: HttpStatusCode.OK, + }); + + const clock = new ClockMock(); + // use produceBlockV3 + const blockService = new BlockProposingService(config, loggerVc, api, clock, validatorStore, null, { + useProduceBlockV3: true, + broadcastValidation: routes.beacon.BroadcastValidation.consensus, + blindedLocal: true, + }); + + const signedBlock = ssz.bellatrix.SignedBlindedBeaconBlock.defaultValue(); + validatorStore.signRandao.resolves(signedBlock.message.body.randaoReveal); + validatorStore.signBlock.callsFake(async (_, block) => ({message: block, signature: signedBlock.signature})); + api.validator.produceBlockV3.resolves({ + response: { + data: signedBlock.message, + version: ForkName.bellatrix, + executionPayloadValue: BigInt(1), + consensusBlockValue: BigInt(1), + executionPayloadBlinded: true, + executionPayloadSource: ProducedBlockSource.engine, + }, + ok: true, + status: HttpStatusCode.OK, + }); + api.beacon.publishBlindedBlockV2.resolves(); + + // Trigger block production for slot 1 + const notifyBlockProductionFn = blockService["dutiesService"]["notifyBlockProductionFn"]; + notifyBlockProductionFn(1, [pubkeys[0]]); + + // Resolve all promises + await sleep(20, controller.signal); + + // Must have submitted the block received on signBlock() + expect(api.beacon.publishBlindedBlockV2.callCount).to.equal(1, "publishBlindedBlockV2() must be called once"); + expect(api.beacon.publishBlindedBlockV2.getCall(0).args).to.deep.equal( + [signedBlock, {broadcastValidation: routes.beacon.BroadcastValidation.consensus}], + "wrong publishBlock() args" + ); + }); });