diff --git a/packages/api/src/beacon/routes/debug.ts b/packages/api/src/beacon/routes/debug.ts index 84eed0af04c9..99403e61fe58 100644 --- a/packages/api/src/beacon/routes/debug.ts +++ b/packages/api/src/beacon/routes/debug.ts @@ -34,6 +34,7 @@ const protoNodeSszType = new ContainerType( parentRoot: stringType, stateRoot: stringType, targetRoot: stringType, + timeliness: ssz.Boolean, justifiedEpoch: ssz.Epoch, justifiedRoot: stringType, finalizedEpoch: ssz.Epoch, diff --git a/packages/api/test/unit/beacon/testData/debug.ts b/packages/api/test/unit/beacon/testData/debug.ts index 6b65d610d16f..3ceda4574605 100644 --- a/packages/api/test/unit/beacon/testData/debug.ts +++ b/packages/api/test/unit/beacon/testData/debug.ts @@ -40,6 +40,7 @@ export const testData: GenericServerTestCases = { weight: 1, bestChild: "1", bestDescendant: "1", + timeliness: false, }, ], }, diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 553146a6cc58..a4f5dccbb561 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -323,10 +323,20 @@ export function getValidatorApi({ { skipHeadChecksAndUpdate, commonBlockBody, - }: Omit & { - skipHeadChecksAndUpdate?: boolean; - commonBlockBody?: CommonBlockBody; - } = {} + parentBlockRoot: inParentBlockRoot, + }: Omit & + ( + | { + skipHeadChecksAndUpdate: true; + commonBlockBody: CommonBlockBody; + parentBlockRoot: Root; + } + | { + skipHeadChecksAndUpdate?: false | undefined; + commonBlockBody?: undefined; + parentBlockRoot?: undefined; + } + ) = {} ): Promise { const version = config.getForkName(slot); if (!isForkExecution(version)) { @@ -344,6 +354,7 @@ export function getValidatorApi({ throw Error("Execution builder disabled"); } + let parentBlockRoot: Root; if (skipHeadChecksAndUpdate !== true) { notWhileSyncing(); await waitForSlot(slot); // Must never request for a future slot > currentSlot @@ -352,7 +363,9 @@ export function getValidatorApi({ // 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(); + parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); + } else { + parentBlockRoot = inParentBlockRoot; } let timer; @@ -360,6 +373,7 @@ export function getValidatorApi({ timer = metrics?.blockProductionTime.startTimer(); const {block, executionPayloadValue, consensusBlockValue} = await chain.produceBlindedBlock({ slot, + parentBlockRoot, randaoReveal, graffiti: toGraffitiBuffer(graffiti || ""), commonBlockBody, @@ -393,14 +407,21 @@ export function getValidatorApi({ strictFeeRecipientCheck, skipHeadChecksAndUpdate, commonBlockBody, - }: Omit & { - skipHeadChecksAndUpdate?: boolean; - commonBlockBody?: CommonBlockBody; - } = {} + parentBlockRoot: inParentBlockRoot, + }: Omit & + ( + | { + skipHeadChecksAndUpdate: true; + commonBlockBody: CommonBlockBody; + parentBlockRoot: Root; + } + | {skipHeadChecksAndUpdate?: false | undefined; commonBlockBody?: undefined; parentBlockRoot?: undefined} + ) = {} ): Promise { const source = ProducedBlockSource.engine; metrics?.blockProductionRequests.inc({source}); + let parentBlockRoot: Root; if (skipHeadChecksAndUpdate !== true) { notWhileSyncing(); await waitForSlot(slot); // Must never request for a future slot > currentSlot @@ -409,7 +430,9 @@ export function getValidatorApi({ // 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(); + parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); + } else { + parentBlockRoot = inParentBlockRoot; } let timer; @@ -417,6 +440,7 @@ export function getValidatorApi({ timer = metrics?.blockProductionTime.startTimer(); const {block, executionPayloadValue, consensusBlockValue, shouldOverrideBuilder} = await chain.produceBlock({ slot, + parentBlockRoot, randaoReveal, graffiti: toGraffitiBuffer(graffiti || ""), feeRecipient, @@ -484,7 +508,6 @@ export function getValidatorApi({ // 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 @@ -529,8 +552,11 @@ export function getValidatorApi({ }; logger.verbose("Assembling block with produceEngineOrBuilderBlock", loggerContext); + const parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); + const commonBlockBody = await chain.produceCommonBlockBody({ slot, + parentBlockRoot, randaoReveal, graffiti: toGraffitiBuffer(graffiti || ""), }); @@ -555,6 +581,7 @@ export function getValidatorApi({ // skip checking and recomputing head in these individual produce calls skipHeadChecksAndUpdate: true, commonBlockBody, + parentBlockRoot, }) : Promise.reject(new Error("Builder disabled")); @@ -565,6 +592,7 @@ export function getValidatorApi({ // skip checking and recomputing head in these individual produce calls skipHeadChecksAndUpdate: true, commonBlockBody, + parentBlockRoot, }).then((engineBlock) => { // Once the engine returns a block, in the event of either: // - suspected builder censorship diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 20a6ca343565..ecb3ba31abc1 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -28,7 +28,7 @@ import { bellatrix, isBlindedBeaconBlock, } from "@lodestar/types"; -import {CheckpointWithHex, ExecutionStatus, IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; +import {CheckpointWithHex, ExecutionStatus, IForkChoice, ProtoBlock, UpdateHeadOpt} from "@lodestar/fork-choice"; import {ProcessShutdownCallback} from "@lodestar/validator"; import {Logger, gweiToWei, isErrorAborted, pruneSetToMax, sleep, toHex} from "@lodestar/utils"; import {ForkSeq, SLOTS_PER_EPOCH} from "@lodestar/params"; @@ -43,7 +43,14 @@ import {ensureDir, writeIfNotExist} from "../util/file.js"; import {isOptimisticBlock} from "../util/forkChoice.js"; import {BlockProcessor, ImportBlockOpts} from "./blocks/index.js"; import {ChainEventEmitter, ChainEvent} from "./emitter.js"; -import {IBeaconChain, ProposerPreparationData, BlockHash, StateGetOpts, CommonBlockBody} from "./interface.js"; +import { + IBeaconChain, + ProposerPreparationData, + BlockHash, + StateGetOpts, + CommonBlockBody, + FindHeadFnName, +} from "./interface.js"; import {IChainOptions} from "./options.js"; import {QueuedStateRegenerator, RegenCaller} from "./regen/index.js"; import {initializeForkChoice} from "./forkChoice/index.js"; @@ -252,7 +259,8 @@ export class BeaconChain implements IBeaconChain { clock.currentSlot, cachedState, opts, - this.justifiedBalancesGetter.bind(this) + this.justifiedBalancesGetter.bind(this), + logger ); const regen = new QueuedStateRegenerator({ config, @@ -475,22 +483,19 @@ export class BeaconChain implements IBeaconChain { } async produceCommonBlockBody(blockAttributes: BlockAttributes): Promise { - const {slot} = blockAttributes; - const head = this.forkChoice.getHead(); + const {slot, parentBlockRoot} = blockAttributes; const state = await this.regen.getBlockSlotState( - head.blockRoot, + toHexString(parentBlockRoot), slot, {dontTransferCache: true}, RegenCaller.produceBlock ); - const parentBlockRoot = fromHexString(head.blockRoot); // TODO: To avoid breaking changes for metric define this attribute const blockType = BlockType.Full; return produceCommonBlockBody.call(this, blockType, state, { ...blockAttributes, - parentBlockRoot, parentSlot: slot - 1, }); } @@ -514,21 +519,26 @@ export class BeaconChain implements IBeaconChain { async produceBlockWrapper( blockType: T, - {randaoReveal, graffiti, slot, feeRecipient, commonBlockBody}: BlockAttributes & {commonBlockBody?: CommonBlockBody} + { + randaoReveal, + graffiti, + slot, + feeRecipient, + commonBlockBody, + parentBlockRoot, + }: BlockAttributes & {commonBlockBody?: CommonBlockBody} ): Promise<{ block: AssembledBlockType; executionPayloadValue: Wei; consensusBlockValue: Wei; shouldOverrideBuilder?: boolean; }> { - const head = this.forkChoice.getHead(); const state = await this.regen.getBlockSlotState( - head.blockRoot, + toHexString(parentBlockRoot), slot, {dontTransferCache: true}, RegenCaller.produceBlock ); - const parentBlockRoot = fromHexString(head.blockRoot); const proposerIndex = state.epochCtx.getBeaconProposer(slot); const proposerPubKey = state.epochCtx.index2pubkey[proposerIndex].toBytes(); @@ -648,10 +658,38 @@ export class BeaconChain implements IBeaconChain { recomputeForkChoiceHead(): ProtoBlock { this.metrics?.forkChoice.requests.inc(); - const timer = this.metrics?.forkChoice.findHead.startTimer(); + const timer = this.metrics?.forkChoice.findHead.startTimer({entrypoint: FindHeadFnName.recomputeForkChoiceHead}); + + try { + return this.forkChoice.updateAndGetHead({mode: UpdateHeadOpt.GetCanonicialHead}); + } catch (e) { + this.metrics?.forkChoice.errors.inc(); + throw e; + } finally { + timer?.(); + } + } + + predictProposerHead(slot: Slot): ProtoBlock { + this.metrics?.forkChoice.requests.inc(); + const timer = this.metrics?.forkChoice.findHead.startTimer({entrypoint: FindHeadFnName.predictProposerHead}); + + try { + return this.forkChoice.updateAndGetHead({mode: UpdateHeadOpt.GetPredictedProposerHead, slot}); + } catch (e) { + this.metrics?.forkChoice.errors.inc(); + throw e; + } finally { + timer?.(); + } + } + + getProposerHead(slot: Slot): ProtoBlock { + this.metrics?.forkChoice.requests.inc(); + const timer = this.metrics?.forkChoice.findHead.startTimer({entrypoint: FindHeadFnName.getProposerHead}); try { - return this.forkChoice.updateHead(); + return this.forkChoice.updateAndGetHead({mode: UpdateHeadOpt.GetProposerHead, slot}); } catch (e) { this.metrics?.forkChoice.errors.inc(); throw e; diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 7e195a84922d..739bb7686097 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -7,7 +7,7 @@ import { ForkChoiceStore, ExecutionStatus, JustifiedBalancesGetter, - ForkChoiceOpts, + ForkChoiceOpts as RawForkChoiceOpts, } from "@lodestar/fork-choice"; import { CachedBeaconStateAllForks, @@ -16,12 +16,16 @@ import { isMergeTransitionComplete, } from "@lodestar/state-transition"; +import {Logger} from "@lodestar/utils"; import {computeAnchorCheckpoint} from "../initState.js"; import {ChainEventEmitter} from "../emitter.js"; import {ChainEvent} from "../emitter.js"; import {GENESIS_SLOT} from "../../constants/index.js"; -export type {ForkChoiceOpts}; +export type ForkChoiceOpts = RawForkChoiceOpts & { + // for testing only + forkchoiceConstructor?: typeof ForkChoice; +}; /** * Fork Choice extended with a ChainEventEmitter @@ -32,7 +36,8 @@ export function initializeForkChoice( currentSlot: Slot, state: CachedBeaconStateAllForks, opts: ForkChoiceOpts, - justifiedBalancesGetter: JustifiedBalancesGetter + justifiedBalancesGetter: JustifiedBalancesGetter, + logger?: Logger ): ForkChoice { const {blockHeader, checkpoint} = computeAnchorCheckpoint(config, state); const finalizedCheckpoint = {...checkpoint}; @@ -47,7 +52,11 @@ export function initializeForkChoice( const justifiedBalances = getEffectiveBalanceIncrementsZeroInactive(state); - return new ForkChoice( + // forkchoiceConstructor is only used for some test cases + // production code use ForkChoice constructor directly + const forkchoiceConstructor = opts.forkchoiceConstructor ?? ForkChoice; + + return new forkchoiceConstructor( config, new ForkChoiceStore( @@ -68,6 +77,7 @@ export function initializeForkChoice( parentRoot: toHexString(blockHeader.parentRoot), stateRoot: toHexString(blockHeader.stateRoot), blockRoot: toHexString(checkpoint.root), + timeliness: true, // Optimisitcally assume is timely justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toHexString(justifiedCheckpoint.root), @@ -89,6 +99,7 @@ export function initializeForkChoice( currentSlot ), + logger, opts ); } diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 99c1b7ea0c4a..2c0c602fcce9 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -62,6 +62,12 @@ export type StateGetOpts = { allowRegen: boolean; }; +export enum FindHeadFnName { + recomputeForkChoiceHead = "recomputeForkChoiceHead", + predictProposerHead = "predictProposerHead", + getProposerHead = "getProposerHead", +} + /** * The IBeaconChain service deals with processing incoming blocks, advancing a state transition * and applying the fork choice rule to update the chain head @@ -180,6 +186,12 @@ export interface IBeaconChain { recomputeForkChoiceHead(): ProtoBlock; + /** When proposerBoostReorg is enabled, this is called at slot n-1 to predict the head block to build on if we are proposing at slot n */ + predictProposerHead(slot: Slot): ProtoBlock; + + /** When proposerBoostReorg is enabled and we are proposing a block, this is called to determine which head block to build on */ + getProposerHead(slot: Slot): ProtoBlock; + waitForBlock(slot: Slot, root: RootHex): Promise; updateBeaconProposerData(epoch: Epoch, proposers: ProposerPreparationData[]): Promise; diff --git a/packages/beacon-node/src/chain/options.ts b/packages/beacon-node/src/chain/options.ts index e687099a0cb4..a5f6543dee26 100644 --- a/packages/beacon-node/src/chain/options.ts +++ b/packages/beacon-node/src/chain/options.ts @@ -89,6 +89,7 @@ export const defaultChainOptions: IChainOptions = { blsVerifyAllMultiThread: false, disableBlsBatchVerify: false, proposerBoostEnabled: true, + proposerBoostReorgEnabled: false, computeUnrealized: true, safeSlotsToImportOptimistically: SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, suggestedFeeRecipient: defaultValidatorOptions.suggestedFeeRecipient, diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index 60658b69ca98..f5e99595c5c5 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -2,6 +2,7 @@ import { computeEpochAtSlot, isExecutionStateType, computeTimeAtSlot, + CachedBeaconStateExecutions, StateHashTreeRootSource, } from "@lodestar/state-transition"; import {ChainForkConfig} from "@lodestar/config"; @@ -143,7 +144,29 @@ export class PrepareNextSlotScheduler { if (isExecutionStateType(prepareState)) { const proposerIndex = prepareState.epochCtx.getBeaconProposer(prepareSlot); const feeRecipient = this.chain.beaconProposerCache.get(proposerIndex); + let updatedPrepareState = prepareState; + let updatedHeadRoot = headRoot; + if (feeRecipient) { + // If we are proposing next slot, we need to predict if we can proposer-boost-reorg or not + const {slot: proposerHeadSlot, blockRoot: proposerHeadRoot} = this.chain.predictProposerHead(clockSlot); + + // If we predict we can reorg, update prepareState with proposer head block + if (proposerHeadRoot !== headRoot || proposerHeadSlot !== headSlot) { + this.logger.verbose("Weak head detected. May build on this block instead:", { + proposerHeadSlot, + proposerHeadRoot, + }); + this.metrics?.weakHeadDetected.inc(); + updatedPrepareState = (await this.chain.regen.getBlockSlotState( + proposerHeadRoot, + prepareSlot, + {dontTransferCache: !isEpochTransition}, + RegenCaller.precomputeEpoch + )) as CachedBeaconStateExecutions; + updatedHeadRoot = proposerHeadRoot; + } + // Update the builder status, if enabled shoot an api call to check status this.chain.updateBuilderStatus(clockSlot); if (this.chain.executionBuilder?.status) { @@ -166,10 +189,10 @@ export class PrepareNextSlotScheduler { this.chain, this.logger, fork as ForkExecution, // State is of execution type - fromHex(headRoot), + fromHex(updatedHeadRoot), safeBlockHash, finalizedBlockHash, - prepareState, + updatedPrepareState, feeRecipient ); this.logger.verbose("PrepareNextSlotScheduler prepared new payload", { @@ -182,7 +205,7 @@ export class PrepareNextSlotScheduler { // If emitPayloadAttributes is true emit a SSE payloadAttributes event if (this.chain.opts.emitPayloadAttributes === true) { const data = await getPayloadAttributesForSSE(fork as ForkExecution, this.chain, { - prepareState, + prepareState: updatedPrepareState, prepareSlot, parentBlockRoot: fromHex(headRoot), // The likely consumers of this API are builders and will anyway ignore the diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index d598ddcb9688..fcef8452a21e 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -64,6 +64,7 @@ export type BlockAttributes = { randaoReveal: BLSSignature; graffiti: Bytes32; slot: Slot; + parentBlockRoot: Root; feeRecipient?: string; }; @@ -95,7 +96,6 @@ export async function produceBlockBody( currentState: CachedBeaconStateAllForks, blockAttr: BlockAttributes & { parentSlot: Slot; - parentBlockRoot: Root; proposerIndex: ValidatorIndex; proposerPubKey: BLSPubkey; commonBlockBody?: CommonBlockBody; @@ -580,7 +580,6 @@ export async function produceCommonBlockBody( parentBlockRoot, }: BlockAttributes & { parentSlot: Slot; - parentBlockRoot: Root; } ): Promise { const stepsMetrics = diff --git a/packages/beacon-node/src/metrics/metrics/beacon.ts b/packages/beacon-node/src/metrics/metrics/beacon.ts index 9366174ef6c6..4547027bf8d0 100644 --- a/packages/beacon-node/src/metrics/metrics/beacon.ts +++ b/packages/beacon-node/src/metrics/metrics/beacon.ts @@ -57,10 +57,11 @@ export function createBeaconMetrics(register: RegistryMetricCreator) { // Non-spec'ed forkChoice: { - findHead: register.histogram({ + findHead: register.histogram<{entrypoint: string}>({ name: "beacon_fork_choice_find_head_seconds", help: "Time taken to find head in seconds", buckets: [0.1, 1, 10], + labelNames: ["entrypoint"], }), requests: register.gauge({ name: "beacon_fork_choice_requests_total", @@ -198,5 +199,10 @@ export function createBeaconMetrics(register: RegistryMetricCreator) { name: "beacon_clock_epoch", help: "Current clock epoch", }), + + weakHeadDetected: register.gauge({ + name: "beacon_weak_head_detected", + help: "Detected current head block is weak. May reorg it out when proposing next slot. See proposer boost reorg for more", + }), }; } diff --git a/packages/beacon-node/test/e2e/chain/proposerBoostReorg.test.ts b/packages/beacon-node/test/e2e/chain/proposerBoostReorg.test.ts new file mode 100644 index 000000000000..0c18a5b73528 --- /dev/null +++ b/packages/beacon-node/test/e2e/chain/proposerBoostReorg.test.ts @@ -0,0 +1,140 @@ +import {describe, it, afterEach, expect} from "vitest"; +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {TimestampFormatCode} from "@lodestar/logger"; +import {ChainConfig} from "@lodestar/config"; +import {RootHex, Slot} from "@lodestar/types"; +import {routes} from "@lodestar/api"; +import {toHexString} from "@lodestar/utils"; +import {LogLevel, TestLoggerOpts, testLogger} from "../../utils/logger.js"; +import {getDevBeaconNode} from "../../utils/node/beacon.js"; +import {TimelinessForkChoice} from "../../mocks/fork-choice/timeliness.js"; +import {getAndInitDevValidators} from "../../utils/node/validator.js"; +import {waitForEvent} from "../../utils/events/resolver.js"; +import {ReorgEventData} from "../../../src/chain/emitter.js"; + +describe( + "proposer boost reorg", + function () { + const validatorCount = 8; + const testParams: Pick = + { + // eslint-disable-next-line @typescript-eslint/naming-convention + SECONDS_PER_SLOT: 2, + // need this to make block `reorgSlot - 1` strong enough + // eslint-disable-next-line @typescript-eslint/naming-convention + REORG_PARENT_WEIGHT_THRESHOLD: 80, + // need this to make block `reorgSlot + 1` to become the head + // eslint-disable-next-line @typescript-eslint/naming-convention + PROPOSER_SCORE_BOOST: 120, + }; + + const afterEachCallbacks: (() => Promise | void)[] = []; + afterEach(async () => { + while (afterEachCallbacks.length > 0) { + const callback = afterEachCallbacks.pop(); + if (callback) await callback(); + } + }); + + const reorgSlot = 10; + const proposerBoostReorgEnabled = true; + /** + * reorgSlot + * / + * reorgSlot - 1 ------------ reorgSlot + 1 + * + * Note that in additional of being not timely, there are other criterion that + * the block needs to satisfied before being re-orged out. This test assumes + * other criterion are satisfied except timeliness. + * Note that in additional of being not timely, there are other criterion that + * the block needs to satisfy before being re-orged out. This test assumes + * other criterion are already satisfied + */ + it(`should reorg a late block at slot ${reorgSlot}`, async () => { + // the node needs time to transpile/initialize bls worker threads + const genesisSlotsDelay = 7; + const genesisTime = Math.floor(Date.now() / 1000) + genesisSlotsDelay * testParams.SECONDS_PER_SLOT; + const testLoggerOpts: TestLoggerOpts = { + level: LogLevel.debug, + timestampFormat: { + format: TimestampFormatCode.EpochSlot, + genesisTime, + slotsPerEpoch: SLOTS_PER_EPOCH, + secondsPerSlot: testParams.SECONDS_PER_SLOT, + }, + }; + const logger = testLogger("BeaconNode", testLoggerOpts); + const bn = await getDevBeaconNode({ + params: testParams, + options: { + sync: {isSingleNode: true}, + network: {allowPublishToZeroPeers: true, mdns: true, useWorker: false}, + // run the first bn with ReorgedForkChoice, no nHistoricalStates flag so it does not have to reload + chain: { + blsVerifyAllMainThread: true, + forkchoiceConstructor: TimelinessForkChoice, + proposerBoostEnabled: true, + proposerBoostReorgEnabled, + }, + }, + validatorCount, + genesisTime, + logger, + }); + + (bn.chain.forkChoice as TimelinessForkChoice).lateSlot = reorgSlot; + afterEachCallbacks.push(async () => bn.close()); + const {validators} = await getAndInitDevValidators({ + node: bn, + logPrefix: "vc-0", + validatorsPerClient: validatorCount, + validatorClientCount: 1, + startIndex: 0, + useRestApi: false, + testLoggerOpts, + }); + afterEachCallbacks.push(() => Promise.all(validators.map((v) => v.close()))); + + const commonAncestor = await waitForEvent<{slot: Slot; block: RootHex}>( + bn.chain.emitter, + routes.events.EventType.head, + 240000, + ({slot}) => slot === reorgSlot - 1 + ); + // reorgSlot + // / + // commonAncestor ------------ newBlock + const commonAncestorRoot = commonAncestor.block; + const reorgBlockEventData = await waitForEvent<{slot: Slot; block: RootHex}>( + bn.chain.emitter, + routes.events.EventType.head, + 240000, + ({slot}) => slot === reorgSlot + ); + const reorgBlockRoot = reorgBlockEventData.block; + const [newBlockEventData, reorgEventData] = await Promise.all([ + waitForEvent<{slot: Slot; block: RootHex}>( + bn.chain.emitter, + routes.events.EventType.block, + 240000, + ({slot}) => slot === reorgSlot + 1 + ), + waitForEvent(bn.chain.emitter, routes.events.EventType.chainReorg, 240000), + ]); + expect(reorgEventData.slot).toEqual(reorgSlot + 1); + const newBlock = await bn.chain.getBlockByRoot(newBlockEventData.block); + if (newBlock == null) { + throw Error(`Block ${reorgSlot + 1} not found`); + } + expect(reorgEventData.oldHeadBlock).toEqual(reorgBlockRoot); + expect(reorgEventData.newHeadBlock).toEqual(newBlockEventData.block); + expect(reorgEventData.depth).toEqual(2); + expect(toHexString(newBlock?.block.message.parentRoot)).toEqual(commonAncestorRoot); + logger.info("New block", { + slot: newBlock.block.message.slot, + parentRoot: toHexString(newBlock.block.message.parentRoot), + }); + }); + }, + {timeout: 60000} +); diff --git a/packages/beacon-node/test/mocks/fork-choice/timeliness.ts b/packages/beacon-node/test/mocks/fork-choice/timeliness.ts new file mode 100644 index 000000000000..72b3ff66a084 --- /dev/null +++ b/packages/beacon-node/test/mocks/fork-choice/timeliness.ts @@ -0,0 +1,24 @@ +import {ForkChoice} from "@lodestar/fork-choice"; +import {Slot, allForks} from "@lodestar/types"; + +/** + * A specific forkchoice implementation to mark some blocks as timely or not. + */ +export class TimelinessForkChoice extends ForkChoice { + /** + * These need to be in the constructor, however we want to keep the constructor signature the same. + * So they are set after construction in the test instead. + */ + lateSlot: Slot | undefined; + + /** + * This is to mark the `lateSlot` as not timely. + */ + protected isBlockTimely(block: allForks.BeaconBlock, blockDelaySec: number): boolean { + if (block.slot === this.lateSlot) { + return false; + } + + return super.isBlockTimely(block, blockDelaySec); + } +} diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 21881875ba61..58093be936f4 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -133,10 +133,12 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { beaconProposerCache: new BeaconProposerCache(), shufflingCache: new ShufflingCache(), produceCommonBlockBody: vi.fn(), + getProposerHead: vi.fn(), produceBlock: vi.fn(), produceBlindedBlock: vi.fn(), getCanonicalBlockAtSlot: vi.fn(), recomputeForkChoiceHead: vi.fn(), + predictProposerHead: vi.fn(), getHeadStateAtCurrentEpoch: vi.fn(), getHeadState: vi.fn(), updateBuilderStatus: vi.fn(), diff --git a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts index 4ed8215ac85b..e47ce48f57c1 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -64,6 +64,8 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { unrealizedFinalizedRoot: toHexString(finalizedCheckpoint.root), executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, + + timeliness: false, }, originalState.slot ); @@ -87,6 +89,7 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { unrealizedFinalizedRoot: toHexString(finalizedCheckpoint.root), executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, + timeliness: false, }, slot ); diff --git a/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts b/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts index 96dda3acaece..4cbdd9cebcab 100644 --- a/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts +++ b/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts @@ -29,6 +29,7 @@ describe("produceBlockBody", () => { chain = new BeaconChain( { proposerBoostEnabled: true, + proposerBoostReorgEnabled: false, computeUnrealized: false, safeSlotsToImportOptimistically: SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, disableArchiveOnCheckpoint: true, @@ -74,9 +75,9 @@ describe("produceBlockBody", () => { await produceBlockBody.call(chain, BlockType.Full, state, { parentSlot: slot, slot: slot + 1, + parentBlockRoot: fromHexString(head.blockRoot), graffiti: Buffer.alloc(32), randaoReveal: Buffer.alloc(96), - parentBlockRoot: fromHexString(head.blockRoot), proposerIndex, proposerPubKey, }); diff --git a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts index 41d8aa76865b..c9b22667fc5a 100644 --- a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts +++ b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts @@ -85,6 +85,7 @@ describe.skip("verify+import blocks - range sync perf test", () => { const chain = new BeaconChain( { proposerBoostEnabled: true, + proposerBoostReorgEnabled: false, computeUnrealized: false, safeSlotsToImportOptimistically: SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, disableArchiveOnCheckpoint: true, diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV2.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV2.test.ts index deb148d34b5a..1ca952fdab70 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV2.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV2.test.ts @@ -1,4 +1,4 @@ -import {fromHexString} from "@chainsafe/ssz"; +import {fromHexString, toHexString} from "@chainsafe/ssz"; import {describe, it, expect, beforeEach, afterEach, vi} from "vitest"; import {ssz} from "@lodestar/types"; import {ProtoBlock} from "@lodestar/fork-choice"; @@ -43,9 +43,12 @@ describe("api/validator - produceBlockV2", function () { // Set the node's state to way back from current slot const slot = 100000; const randaoReveal = fullBlock.body.randaoReveal; + const parentBlockRoot = fullBlock.parentRoot; const graffiti = "a".repeat(32); const feeRecipient = "0xcccccccccccccccccccccccccccccccccccccccc"; + // mock whatever value as we also mock produceBlock below + modules.chain["getProposerHead"].mockReturnValue(generateProtoBlock({blockRoot: toHexString(parentBlockRoot)})); modules.chain.produceBlock.mockResolvedValue({ block: fullBlock, executionPayloadValue, @@ -58,6 +61,7 @@ describe("api/validator - produceBlockV2", function () { randaoReveal, graffiti: toGraffitiBuffer(graffiti), slot, + parentBlockRoot, feeRecipient, }); @@ -68,6 +72,7 @@ describe("api/validator - produceBlockV2", function () { randaoReveal, graffiti: toGraffitiBuffer(graffiti), slot, + parentBlockRoot, feeRecipient: undefined, }); }); @@ -81,7 +86,7 @@ describe("api/validator - produceBlockV2", function () { const feeRecipient = "0xccccccccccccccccccccccccccccccccccccccaa"; const headSlot = 0; - modules.forkChoice.getHead.mockReturnValue(generateProtoBlock({slot: headSlot})); + modules.chain["getProposerHead"].mockReturnValue(generateProtoBlock({slot: headSlot})); modules.chain["opPool"].getSlashingsAndExits.mockReturnValue([[], [], [], []]); modules.chain["aggregatedAttestationPool"].getAttestationsForBlock.mockReturnValue([]); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index ba0267fc5810..587eb758d66c 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -1,8 +1,10 @@ import {describe, it, expect, beforeEach, afterEach, vi} from "vitest"; +import {toHexString} from "@chainsafe/ssz"; import {ssz} from "@lodestar/types"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {routes} from "@lodestar/api"; import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lodestar/config"; +import {ProtoBlock} from "@lodestar/fork-choice"; import {ApiTestModules, getApiTestModules} from "../../../../utils/api.js"; import {SyncState} from "../../../../../src/sync/interface.js"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; @@ -83,6 +85,8 @@ describe("api/validator - produceBlockV3", function () { vi.spyOn(modules.chain.clock, "currentSlot", "get").mockReturnValue(currentSlot); vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.Synced); + modules.chain.getProposerHead.mockReturnValue({blockRoot: toHexString(fullBlock.parentRoot)} as ProtoBlock); + if (enginePayloadValue !== null) { const commonBlockBody: CommonBlockBody = { attestations: fullBlock.body.attestations, diff --git a/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts b/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts index 6d1be3fa8dd5..652749492240 100644 --- a/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts +++ b/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts @@ -120,6 +120,7 @@ describe("PrepareNextSlot scheduler", () => { chainStub.emitter.on(routes.events.EventType.payloadAttributes, spy); getForkStub.mockReturnValue(ForkName.bellatrix); chainStub.recomputeForkChoiceHead.mockReturnValue({...zeroProtoBlock, slot: SLOTS_PER_EPOCH - 3} as ProtoBlock); + chainStub.predictProposerHead.mockReturnValue({...zeroProtoBlock, slot: SLOTS_PER_EPOCH - 3} as ProtoBlock); forkChoiceStub.getJustifiedBlock.mockReturnValue({} as ProtoBlock); forkChoiceStub.getFinalizedBlock.mockReturnValue({} as ProtoBlock); updateBuilderStatus.mockReturnValue(void 0); diff --git a/packages/beacon-node/test/utils/logger.ts b/packages/beacon-node/test/utils/logger.ts index 1c1526514565..10f27565216f 100644 --- a/packages/beacon-node/test/utils/logger.ts +++ b/packages/beacon-node/test/utils/logger.ts @@ -21,6 +21,6 @@ export const testLogger = (module?: string, opts?: TestLoggerOpts): LoggerNode = opts.module = module; } const level = getEnvLogLevel(); - opts.level = level ?? LogLevel.info; + opts.level = level ?? opts.level ?? LogLevel.info; return getNodeLogger(opts); }; diff --git a/packages/beacon-node/test/utils/state.ts b/packages/beacon-node/test/utils/state.ts index a0fa42be555e..c9dc79d34ed0 100644 --- a/packages/beacon-node/test/utils/state.ts +++ b/packages/beacon-node/test/utils/state.ts @@ -153,5 +153,7 @@ export const zeroProtoBlock: ProtoBlock = { unrealizedFinalizedEpoch: 0, unrealizedFinalizedRoot: ZERO_HASH_HEX, + timeliness: false, + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, }; diff --git a/packages/beacon-node/test/utils/typeGenerator.ts b/packages/beacon-node/test/utils/typeGenerator.ts index cdaccd005c8e..90986a60eb4b 100644 --- a/packages/beacon-node/test/utils/typeGenerator.ts +++ b/packages/beacon-node/test/utils/typeGenerator.ts @@ -40,6 +40,8 @@ export function generateProtoBlock(overrides: Partial = {}): ProtoBl unrealizedFinalizedEpoch: 0, unrealizedFinalizedRoot: ZERO_HASH_HEX, + timeliness: false, + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, ...overrides, diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index fa3c4d479ade..24e599852d86 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -75,6 +75,8 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { unrealizedFinalizedEpoch: 0, unrealizedFinalizedRoot: ZERO_HASH_HEX, + timeliness: false, + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, }; diff --git a/packages/cli/src/options/beaconNodeOptions/chain.ts b/packages/cli/src/options/beaconNodeOptions/chain.ts index 390ffb3ad2f6..2ac4457cad2c 100644 --- a/packages/cli/src/options/beaconNodeOptions/chain.ts +++ b/packages/cli/src/options/beaconNodeOptions/chain.ts @@ -13,6 +13,7 @@ export type ChainArgs = { // as this is defined as part of BeaconPaths // "chain.persistInvalidSszObjectsDir": string; "chain.proposerBoostEnabled"?: boolean; + "chain.proposerBoostReorgEnabled"?: boolean; "chain.disableImportExecutionFcU"?: boolean; "chain.preaggregateSlotDistance"?: number; "chain.attDataCacheSlotDistance"?: number; @@ -40,6 +41,7 @@ export function parseArgs(args: ChainArgs): IBeaconNodeOptions["chain"] { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any persistInvalidSszObjectsDir: undefined as any, proposerBoostEnabled: args["chain.proposerBoostEnabled"], + proposerBoostReorgEnabled: args["chain.proposerBoostReorgEnabled"], disableImportExecutionFcU: args["chain.disableImportExecutionFcU"], preaggregateSlotDistance: args["chain.preaggregateSlotDistance"], attDataCacheSlotDistance: args["chain.attDataCacheSlotDistance"], @@ -122,6 +124,14 @@ Will double processing times. Use only for debugging purposes.", group: "chain", }, + "chain.proposerBoostReorgEnabled": { + hidden: true, + type: "boolean", + description: "Enable proposer boost reorg to reorg out a late block", + defaultDescription: String(defaultOptions.chain.proposerBoostReorgEnabled), + group: "chain", + }, + "chain.disableImportExecutionFcU": { hidden: true, type: "boolean", diff --git a/packages/cli/test/unit/options/beaconNodeOptions.test.ts b/packages/cli/test/unit/options/beaconNodeOptions.test.ts index 1f36b3c9751c..d65f4aa28162 100644 --- a/packages/cli/test/unit/options/beaconNodeOptions.test.ts +++ b/packages/cli/test/unit/options/beaconNodeOptions.test.ts @@ -24,6 +24,7 @@ describe("options / beaconNodeOptions", () => { "chain.persistProducedBlocks": true, "chain.persistInvalidSszObjects": true, "chain.proposerBoostEnabled": false, + "chain.proposerBoostReorgEnabled": false, "chain.disableImportExecutionFcU": false, "chain.preaggregateSlotDistance": 1, "chain.attDataCacheSlotDistance": 2, @@ -127,6 +128,7 @@ describe("options / beaconNodeOptions", () => { persistProducedBlocks: true, persistInvalidSszObjects: true, proposerBoostEnabled: false, + proposerBoostReorgEnabled: false, disableImportExecutionFcU: false, preaggregateSlotDistance: 1, attDataCacheSlotDistance: 2, diff --git a/packages/config/src/chainConfig/configs/mainnet.ts b/packages/config/src/chainConfig/configs/mainnet.ts index 9d060330d201..883688ca821b 100644 --- a/packages/config/src/chainConfig/configs/mainnet.ts +++ b/packages/config/src/chainConfig/configs/mainnet.ts @@ -81,6 +81,9 @@ export const chainConfig: ChainConfig = { // --------------------------------------------------------------- // 40% PROPOSER_SCORE_BOOST: 40, + REORG_HEAD_WEIGHT_THRESHOLD: 20, + REORG_PARENT_WEIGHT_THRESHOLD: 160, + REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2, // Deposit contract // --------------------------------------------------------------- diff --git a/packages/config/src/chainConfig/configs/minimal.ts b/packages/config/src/chainConfig/configs/minimal.ts index 6c0a13d8abb2..23cd14e763ec 100644 --- a/packages/config/src/chainConfig/configs/minimal.ts +++ b/packages/config/src/chainConfig/configs/minimal.ts @@ -78,6 +78,9 @@ export const chainConfig: ChainConfig = { // --------------------------------------------------------------- // 40% PROPOSER_SCORE_BOOST: 40, + REORG_HEAD_WEIGHT_THRESHOLD: 20, + REORG_PARENT_WEIGHT_THRESHOLD: 160, + REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2, // Deposit contract // --------------------------------------------------------------- diff --git a/packages/config/src/chainConfig/types.ts b/packages/config/src/chainConfig/types.ts index 3e0844118290..657c8a6c14b4 100644 --- a/packages/config/src/chainConfig/types.ts +++ b/packages/config/src/chainConfig/types.ts @@ -58,6 +58,9 @@ export type ChainConfig = { // Fork choice PROPOSER_SCORE_BOOST: number; + REORG_HEAD_WEIGHT_THRESHOLD: number; + REORG_PARENT_WEIGHT_THRESHOLD: number; + REORG_MAX_EPOCHS_SINCE_FINALIZATION: number; // Deposit contract DEPOSIT_CHAIN_ID: number; @@ -114,6 +117,9 @@ export const chainConfigTypes: SpecTypes = { // Fork choice PROPOSER_SCORE_BOOST: "number", + REORG_HEAD_WEIGHT_THRESHOLD: "number", + REORG_PARENT_WEIGHT_THRESHOLD: "number", + REORG_MAX_EPOCHS_SINCE_FINALIZATION: "number", // Deposit contract DEPOSIT_CHAIN_ID: "number", diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 374bc65542ee..93c5c7aaf76f 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1,5 +1,5 @@ import {toHexString} from "@chainsafe/ssz"; -import {fromHex} from "@lodestar/utils"; +import {Logger, fromHex} from "@lodestar/utils"; import {SLOTS_PER_HISTORICAL_ROOT, SLOTS_PER_EPOCH, INTERVALS_PER_SLOT} from "@lodestar/params"; import {bellatrix, Slot, ValidatorIndex, phase0, allForks, ssz, RootHex, Epoch, Root} from "@lodestar/types"; import { @@ -45,9 +45,21 @@ import {IForkChoiceStore, CheckpointWithHex, toCheckpointWithHex, JustifiedBalan export type ForkChoiceOpts = { proposerBoostEnabled?: boolean; + proposerBoostReorgEnabled?: boolean; computeUnrealized?: boolean; }; +export enum UpdateHeadOpt { + GetCanonicialHead, // Skip getProposerHead + GetProposerHead, // With getProposerHead + GetPredictedProposerHead, // With predictProposerHead +} + +export type UpdateAndGetHeadOpt = + | {mode: UpdateHeadOpt.GetCanonicialHead} + | {mode: UpdateHeadOpt.GetProposerHead; slot: Slot} + | {mode: UpdateHeadOpt.GetPredictedProposerHead; slot: Slot}; + /** * Provides an implementation of "Ethereum Consensus -- Beacon Chain Fork Choice": * @@ -107,6 +119,7 @@ export class ForkChoice implements IForkChoice { private readonly fcStore: IForkChoiceStore, /** The underlying representation of the block DAG. */ private readonly protoArray: ProtoArray, + private readonly logger?: Logger, private readonly opts?: ForkChoiceOpts ) { this.head = this.updateHead(); @@ -154,6 +167,29 @@ export class ForkChoice implements IForkChoice { return this.head; } + /** + * + * A multiplexer to wrap around the traditional `updateHead()` according to the scenario + * Scenarios as follow: + * Prepare to propose in the next slot: getHead() -> predictProposerHead() + * Proposing in the current slot: updateHead() -> getProposerHead() + * Others eg. initializing forkchoice, importBlock: updateHead() + */ + updateAndGetHead(opt: UpdateAndGetHeadOpt): ProtoBlock { + const {mode} = opt; + + const canonicialHeadBlock = mode === UpdateHeadOpt.GetPredictedProposerHead ? this.getHead() : this.updateHead(); + switch (mode) { + case UpdateHeadOpt.GetPredictedProposerHead: + return this.predictProposerHead(canonicialHeadBlock, opt.slot); + case UpdateHeadOpt.GetProposerHead: + return this.getProposerHead(canonicialHeadBlock, opt.slot); + case UpdateHeadOpt.GetCanonicialHead: + default: + return canonicialHeadBlock; + } + } + /** * Get the proposer boost root */ @@ -161,6 +197,119 @@ export class ForkChoice implements IForkChoice { return this.proposerBoostRoot ?? HEX_ZERO_HASH; } + /** + * To predict the proposer head of the next slot. That is, to predict if proposer-boost-reorg could happen. + * Reason why we can't be certain is because information of the head block is not fully available yet + * since the current slot hasn't ended especially the attesters' votes. + * + * There is a chance we mispredict. + * + * By calling this function, we assume we are the proposer of next slot + * + * https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/bellatrix/fork-choice.md#should_override_forkchoice_update + */ + predictProposerHead(headBlock: ProtoBlock, currentSlot?: Slot): ProtoBlock { + // Skip re-org attempt if proposer boost (reorg) are disabled + if (!this.opts?.proposerBoostEnabled || !this.opts?.proposerBoostReorgEnabled) { + this.logger?.verbose("No proposer boot reorg prediction since the related flags are disabled"); + return headBlock; + } + + const parentBlock = this.protoArray.getBlock(headBlock.parentRoot); + const proposalSlot = headBlock.slot + 1; + currentSlot = currentSlot ?? this.fcStore.currentSlot; + + // No reorg if parentBlock isn't available + if (parentBlock === undefined) { + return headBlock; + } + + const prelimProposerHeadBlock = this.getPreliminaryProposerHead(headBlock, parentBlock, proposalSlot); + + if (prelimProposerHeadBlock === headBlock) { + return headBlock; + } + + const currentTimeOk = headBlock.slot === currentSlot; + if (!currentTimeOk) { + return headBlock; + } + + this.logger?.info("Current head is weak. Predicting next block to be built on parent of head"); + return parentBlock; + } + + /** + * + * This function takes in the canonical head block and determine the proposer head (canonical head block or its parent) + * https://github.com/ethereum/consensus-specs/pull/3034 for info about proposer boost reorg + * This function should only be called during block proposal and only be called after `updateHead()` in `updateAndGetHead()` + * + * Same as https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#get_proposer_head + */ + getProposerHead(headBlock: ProtoBlock, slot: Slot): ProtoBlock { + // Skip re-org attempt if proposer boost (reorg) are disabled + if (!this.opts?.proposerBoostEnabled || !this.opts?.proposerBoostReorgEnabled) { + this.logger?.verbose("No proposer boot reorg attempt since the related flags are disabled"); + return headBlock; + } + + const parentBlock = this.protoArray.getBlock(headBlock.parentRoot); + + // No reorg if parentBlock isn't available + if (parentBlock === undefined) { + return headBlock; + } + + const prelimProposerHeadBlock = this.getPreliminaryProposerHead(headBlock, parentBlock, slot); + + if (prelimProposerHeadBlock === headBlock) { + return headBlock; + } + + // No reorg if attempted reorg is more than a single slot + // Half of single_slot_reorg check in the spec is done in getPreliminaryProposerHead() + const currentTimeOk = headBlock.slot + 1 === slot; + if (!currentTimeOk) { + return headBlock; + } + + // No reorg if proposer boost is still in effect + const isProposerBoostWornOff = this.proposerBoostRoot !== headBlock.blockRoot; + if (!isProposerBoostWornOff) { + return headBlock; + } + + // No reorg if headBlock is "not weak" ie. headBlock's weight exceeds (REORG_HEAD_WEIGHT_THRESHOLD = 20)% of total attester weight + // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_head_weak + const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, + }); + const headNode = this.protoArray.getNode(headBlock.blockRoot); + // If headNode is unavailable, give up reorg + if (headNode === undefined || headNode.weight >= reorgThreshold) { + return headBlock; + } + + // No reorg if parentBlock is "not strong" ie. parentBlock's weight is less than or equal to (REORG_PARENT_WEIGHT_THRESHOLD = 160)% of total attester weight + // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#is_parent_strong + const parentThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.REORG_PARENT_WEIGHT_THRESHOLD, + }); + const parentNode = this.protoArray.getNode(parentBlock.blockRoot); + // If parentNode is unavailable, give up reorg + if (parentNode === undefined || parentNode.weight <= parentThreshold) { + return headBlock; + } + + // Reorg if all above checks fail + this.logger?.info("Will perform single-slot reorg to reorg out current weak head"); + + return parentBlock; + } + /** * Run the fork choice rule to determine the head. * Update the head cache. @@ -201,9 +350,9 @@ export class ForkChoice implements IForkChoice { if (this.opts?.proposerBoostEnabled && this.proposerBoostRoot) { const proposerBoostScore = this.justifiedProposerBoostScore ?? - getProposerScore(this.fcStore.justified.totalBalance, { + getCommitteeFraction(this.fcStore.justified.totalBalance, { slotsPerEpoch: SLOTS_PER_EPOCH, - proposerScoreBoost: this.config.PROPOSER_SCORE_BOOST, + committeePercent: this.config.PROPOSER_SCORE_BOOST, }); proposerBoost = {root: this.proposerBoostRoot, score: proposerBoostScore}; this.justifiedProposerBoostScore = proposerBoostScore; @@ -352,12 +501,12 @@ export class ForkChoice implements IForkChoice { const blockRoot = this.config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block); const blockRootHex = toHexString(blockRoot); - // Add proposer score boost if the block is timely + // Assign proposer score boost if the block is timely // before attesting interval = before 1st interval + const isTimely = this.isBlockTimely(block, blockDelaySec); if ( this.opts?.proposerBoostEnabled && - this.fcStore.currentSlot === slot && - blockDelaySec < this.config.SECONDS_PER_SLOT / INTERVALS_PER_SLOT && + isTimely && // only boost the first block we see this.proposerBoostRoot === null ) { @@ -451,6 +600,7 @@ export class ForkChoice implements IForkChoice { parentRoot: parentRootHex, targetRoot: toHexString(targetRoot), stateRoot: toHexString(block.stateRoot), + timeliness: isTimely, justifiedEpoch: stateJustifiedEpoch, justifiedRoot: toHexString(state.currentJustifiedCheckpoint.root), @@ -899,6 +1049,15 @@ export class ForkChoice implements IForkChoice { throw Error(`Not found dependent root for block slot ${block.slot}, epoch difference ${epochDifference}`); } + /** + * Return true if the block is timely for the current slot. + * Child class can overwrite this for testing purpose. + */ + protected isBlockTimely(block: allForks.BeaconBlock, blockDelaySec: number): boolean { + const isBeforeAttestingInterval = blockDelaySec < this.config.SECONDS_PER_SLOT / INTERVALS_PER_SLOT; + return this.fcStore.currentSlot === block.slot && isBeforeAttestingInterval; + } + private getPreMergeExecStatus(executionStatus: MaybeValidExecutionStatus): ExecutionStatus.PreMerge { if (executionStatus !== ExecutionStatus.PreMerge) throw Error(`Invalid pre-merge execution status: expected: ${ExecutionStatus.PreMerge}, got ${executionStatus}`); @@ -1202,6 +1361,57 @@ export class ForkChoice implements IForkChoice { () => this.fcStore.unrealizedJustified.balances ); } + + /** + * + * Common logic of get_proposer_head() and should_override_forkchoice_update() + * No one should be calling this function except these two + * + */ + private getPreliminaryProposerHead(headBlock: ProtoBlock, parentBlock: ProtoBlock, slot: Slot): ProtoBlock { + // No reorg if headBlock is on time + // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_head_late + const isHeadLate = !headBlock.timeliness; + if (!isHeadLate) { + return headBlock; + } + + // No reorg if we are at epoch boundary where proposer shuffling could change + // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_shuffling_stable + const isShufflingStable = slot % SLOTS_PER_EPOCH !== 0; + if (!isShufflingStable) { + return headBlock; + } + + // No reorg if headBlock and parentBlock are not ffg competitive + // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_ffg_competitive + const {unrealizedJustifiedEpoch: headBlockCpEpoch, unrealizedJustifiedRoot: headBlockCpRoot} = headBlock; + const {unrealizedJustifiedEpoch: parentBlockCpEpoch, unrealizedJustifiedRoot: parentBlockCpRoot} = parentBlock; + const isFFGCompetitive = headBlockCpEpoch === parentBlockCpEpoch && headBlockCpRoot === parentBlockCpRoot; + if (!isFFGCompetitive) { + return headBlock; + } + + // No reorg if chain is not finalizing within REORG_MAX_EPOCHS_SINCE_FINALIZATION + // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_finalization_ok + const epochsSinceFinalization = computeEpochAtSlot(slot) - this.getFinalizedCheckpoint().epoch; + const isFinalizationOk = epochsSinceFinalization <= this.config.REORG_MAX_EPOCHS_SINCE_FINALIZATION; + if (!isFinalizationOk) { + return headBlock; + } + + // -No reorg if we are not proposing on time.- + // Note: Skipping this check as store.time in Lodestar is stored in slot and not unix time + // https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.4/specs/phase0/fork-choice.md#is_proposing_on_time + + // No reorg if this reorg spans more than a single slot + const parentSlotOk = parentBlock.slot + 1 === headBlock.slot; + if (!parentSlotOk) { + return headBlock; + } + + return parentBlock; + } } /** @@ -1261,11 +1471,12 @@ export function assertValidTerminalPowBlock( } } } - -export function getProposerScore( +// Approximate https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#calculate_committee_fraction +// Calculates proposer boost score when committeePercent = config.PROPOSER_SCORE_BOOST +export function getCommitteeFraction( justifiedTotalActiveBalanceByIncrement: number, - config: {slotsPerEpoch: number; proposerScoreBoost: number} + config: {slotsPerEpoch: number; committeePercent: number} ): number { const committeeWeight = Math.floor(justifiedTotalActiveBalanceByIncrement / config.slotsPerEpoch); - return Math.floor((committeeWeight * config.proposerScoreBoost) / 100); + return Math.floor((committeeWeight * config.committeePercent) / 100); } diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index fffbc3e4007f..b40e37f7568c 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -3,6 +3,7 @@ import {CachedBeaconStateAllForks} from "@lodestar/state-transition"; import {Epoch, Slot, ValidatorIndex, phase0, allForks, Root, RootHex} from "@lodestar/types"; import {ProtoBlock, MaybeValidExecutionStatus, LVHExecResponse, ProtoNode} from "../protoArray/interface.js"; import {CheckpointWithHex} from "./store.js"; +import {UpdateAndGetHeadOpt} from "./forkChoice.js"; export type CheckpointHex = { epoch: Epoch; @@ -77,6 +78,7 @@ export interface IForkChoice { getHeadRoot(): RootHex; getHead(): ProtoBlock; updateHead(): ProtoBlock; + updateAndGetHead(mode: UpdateAndGetHeadOpt): ProtoBlock; /** * Retrieves all possible chain heads (leaves of fork choice tree). */ diff --git a/packages/fork-choice/src/index.ts b/packages/fork-choice/src/index.ts index ff0711599a54..12b678d7db2b 100644 --- a/packages/fork-choice/src/index.ts +++ b/packages/fork-choice/src/index.ts @@ -9,7 +9,7 @@ export type { } from "./protoArray/interface.js"; export {ExecutionStatus} from "./protoArray/interface.js"; -export {ForkChoice, type ForkChoiceOpts, assertValidTerminalPowBlock} from "./forkChoice/forkChoice.js"; +export {ForkChoice, type ForkChoiceOpts, UpdateHeadOpt, assertValidTerminalPowBlock} from "./forkChoice/forkChoice.js"; export { type IForkChoice, type PowBlockHex, diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 003a3c8f9f1e..e59c366cbf59 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -78,6 +78,9 @@ export type ProtoBlock = BlockExecution & { unrealizedJustifiedRoot: RootHex; unrealizedFinalizedEpoch: Epoch; unrealizedFinalizedRoot: RootHex; + + // Indicate whether block arrives in a timely manner ie. before the 4 second mark + timeliness: boolean; }; /** diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 1ad97d5b6c54..eace72d4d800 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -75,6 +75,8 @@ export function initializeForkChoice(opts: Opts): ForkChoice { executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, + + timeliness: false, }; protoArr.onBlock(block, block.slot); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index 93aa28f8f2e5..fb72a6705c37 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -101,6 +101,8 @@ describe("Forkchoice", function () { executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, + + timeliness: false, }; }; @@ -170,14 +172,14 @@ describe("Forkchoice", function () { // TODO: more unit tests for other apis }); -function getStateRoot(slot: number): RootHex { +export function getStateRoot(slot: number): RootHex { const root = Buffer.alloc(32, 0x00); root[0] = rootStateBytePrefix; root[31] = slot; return toHex(root); } -function getBlockRoot(slot: number): RootHex { +export function getBlockRoot(slot: number): RootHex { const root = Buffer.alloc(32, 0x00); root[0] = rootBlockBytePrefix; root[31] = slot; diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts new file mode 100644 index 000000000000..40108bd748c3 --- /dev/null +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -0,0 +1,218 @@ +import {describe, it, expect, beforeEach} from "vitest"; +import {fromHexString} from "@chainsafe/ssz"; +import {config} from "@lodestar/config/default"; +import {Slot} from "@lodestar/types"; +import {toHex} from "@lodestar/utils"; +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {ForkChoice, IForkChoiceStore, ProtoArray, ExecutionStatus, ProtoBlock} from "../../../src/index.js"; +import {getBlockRoot, getStateRoot} from "./forkChoice.test.js"; + +type ProtoBlockWithWeight = ProtoBlock & {weight: number}; // weight of the block itself + +describe("Forkchoice / GetProposerHead", function () { + const genesisSlot = 0; + const genesisEpoch = 0; + const genesisRoot = "0x0000000000000000000000000000000000000000000000000000000000000000"; + + const parentSlot = genesisSlot + 1; + const headSlot = genesisSlot + 2; + + let protoArr: ProtoArray; + + const genesisBlock: Omit = { + slot: genesisSlot, + stateRoot: getStateRoot(genesisSlot), + parentRoot: toHex(Buffer.alloc(32, 0xff)), + blockRoot: getBlockRoot(genesisSlot), + + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + unrealizedJustifiedEpoch: genesisEpoch, + unrealizedJustifiedRoot: genesisRoot, + unrealizedFinalizedEpoch: genesisEpoch, + unrealizedFinalizedRoot: genesisRoot, + + executionPayloadBlockHash: null, + executionStatus: ExecutionStatus.PreMerge, + + timeliness: false, + }; + + const baseHeadBlock: ProtoBlockWithWeight = { + slot: headSlot, + stateRoot: getStateRoot(headSlot), + parentRoot: getBlockRoot(parentSlot), + blockRoot: getBlockRoot(headSlot), + targetRoot: getBlockRoot(headSlot), + + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + unrealizedJustifiedEpoch: genesisEpoch, + unrealizedJustifiedRoot: genesisRoot, + unrealizedFinalizedEpoch: genesisEpoch, + unrealizedFinalizedRoot: genesisRoot, + + executionPayloadBlockHash: null, + executionStatus: ExecutionStatus.PreMerge, + + timeliness: false, + + weight: 29, + }; + + const baseParentHeadBlock: ProtoBlockWithWeight = { + slot: parentSlot, + stateRoot: getStateRoot(parentSlot), + parentRoot: getBlockRoot(genesisSlot), + blockRoot: getBlockRoot(parentSlot), + targetRoot: getBlockRoot(parentSlot), + + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + unrealizedJustifiedEpoch: genesisEpoch, + unrealizedJustifiedRoot: genesisRoot, + unrealizedFinalizedEpoch: genesisEpoch, + unrealizedFinalizedRoot: genesisRoot, + + executionPayloadBlockHash: null, + executionStatus: ExecutionStatus.PreMerge, + + timeliness: false, + weight: 212, // 240 - 29 + 1 + }; + + const fcStore: IForkChoiceStore = { + currentSlot: genesisSlot + 1, + justified: { + checkpoint: {epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot}, + balances: new Uint8Array(Array(32).fill(150)), + totalBalance: 32 * 150, + }, + unrealizedJustified: { + checkpoint: {epoch: genesisEpoch, root: fromHexString(genesisBlock.blockRoot), rootHex: genesisBlock.blockRoot}, + balances: new Uint8Array(Array(32).fill(150)), + }, + finalizedCheckpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisBlock.blockRoot), + rootHex: genesisBlock.blockRoot, + }, + unrealizedFinalizedCheckpoint: { + epoch: genesisEpoch, + root: fromHexString(genesisBlock.blockRoot), + rootHex: genesisBlock.blockRoot, + }, + justifiedBalancesGetter: () => new Uint8Array(Array(32).fill(150)), + equivocatingIndices: new Set(), + }; + + // head block's weight < 30 is considered weak. parent block's total weight > 240 is considered strong + const testCases: { + id: string; + parentBlock: ProtoBlockWithWeight; + headBlock: ProtoBlockWithWeight; + expectReorg: boolean; + currentSlot?: Slot; + }[] = [ + { + id: "Case that meets all conditions to be re-orged", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock}, + expectReorg: true, + }, + { + id: "No reorg when head block is timly", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock, timeliness: true}, + expectReorg: false, + }, + { + id: "No reorg when currenSlot is at epoch boundary", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock}, + expectReorg: false, + currentSlot: SLOTS_PER_EPOCH * 2, + }, + { + id: "No reorg when the blocks are not ffg competitive", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock, unrealizedJustifiedEpoch: 1}, + expectReorg: false, + }, + { + id: "No reorg when the blocks are not ffg competitive 2", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock, unrealizedJustifiedRoot: "-"}, + expectReorg: false, + }, + { + id: "No reorg if long unfinality", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock}, + expectReorg: false, + currentSlot: (genesisEpoch + 2) * SLOTS_PER_EPOCH + 1, + }, + { + id: "No reorg if reorg spans more than a single slot", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock, slot: headSlot + 1}, + expectReorg: false, + }, + { + id: "No reorg if current slot is more than one slot from head block", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock}, + expectReorg: false, + currentSlot: headSlot + 2, + }, + { + id: "No reorg if head is strong", + parentBlock: {...baseParentHeadBlock}, + headBlock: {...baseHeadBlock, weight: 30}, + expectReorg: false, + }, + { + id: "No reorg if parent is weak", + parentBlock: {...baseParentHeadBlock, weight: 211}, + headBlock: {...baseHeadBlock}, + expectReorg: false, + }, + ]; + + beforeEach(() => { + protoArr = ProtoArray.initialize(genesisBlock, genesisSlot); + }); + + for (const {id, parentBlock, headBlock, expectReorg, currentSlot: proposalSlot} of testCases) { + it(`${id}`, async () => { + protoArr.onBlock(parentBlock, parentBlock.slot); + protoArr.onBlock(headBlock, headBlock.slot); + + const currentSlot = proposalSlot ?? headBlock.slot + 1; + protoArr.applyScoreChanges({ + deltas: [0, parentBlock.weight, headBlock.weight], + proposerBoost: null, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + currentSlot, + }); + + const forkChoice = new ForkChoice(config, fcStore, protoArr, undefined, { + proposerBoostEnabled: true, + proposerBoostReorgEnabled: true, + }); + + const proposerHead = forkChoice.getProposerHead(headBlock, currentSlot); + + expect(proposerHead.blockRoot).toBe(expectReorg ? parentBlock.blockRoot : headBlock.blockRoot); + }); + } +}); diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index e6916f24800f..9d007f1c27e9 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -106,6 +106,8 @@ function setupForkChoice(): ProtoArray { unrealizedFinalizedEpoch: 0, unrealizedFinalizedRoot: "-", + timeliness: false, + ...executionData, }, block.slot diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index 766c02a15a23..e014615fd312 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -40,6 +40,8 @@ describe("getCommonAncestor", () => { unrealizedFinalizedEpoch: 0, unrealizedFinalizedRoot: "-", + timeliness: false, + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, }, 0 @@ -63,6 +65,8 @@ describe("getCommonAncestor", () => { unrealizedFinalizedEpoch: 0, unrealizedFinalizedRoot: "-", + timeliness: false, + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, }, block.slot diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index c3bf8a0f439a..fdebd4bba645 100644 --- a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts +++ b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts @@ -30,6 +30,8 @@ describe("ProtoArray", () => { unrealizedFinalizedEpoch: genesisEpoch, unrealizedFinalizedRoot: stateRoot, + timeliness: false, + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, }, genesisSlot @@ -53,6 +55,8 @@ describe("ProtoArray", () => { unrealizedFinalizedEpoch: genesisEpoch, unrealizedFinalizedRoot: stateRoot, + timeliness: false, + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, }, genesisSlot + 1 @@ -76,6 +80,8 @@ describe("ProtoArray", () => { unrealizedFinalizedEpoch: genesisEpoch, unrealizedFinalizedRoot: stateRoot, + timeliness: false, + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, }, genesisSlot + 1 diff --git a/packages/validator/src/util/params.ts b/packages/validator/src/util/params.ts index 006ae3fadbbb..8ccaf9fe75ba 100644 --- a/packages/validator/src/util/params.ts +++ b/packages/validator/src/util/params.ts @@ -123,6 +123,9 @@ function getSpecCriticalParams(localConfig: ChainConfig): Record