diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index f2a3f1faca0a..5a10b8337e9c 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -363,7 +363,7 @@ 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); - parentBlockRoot = fromHexString(chain.recomputeForkChoiceHead().blockRoot); + parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); } else { parentBlockRoot = inParentBlockRoot; } @@ -430,7 +430,7 @@ 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); - parentBlockRoot = fromHexString(chain.recomputeForkChoiceHead().blockRoot); + parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); } else { parentBlockRoot = inParentBlockRoot; } @@ -508,7 +508,7 @@ 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); - const parentBlockRoot = fromHexString(chain.recomputeForkChoiceHead().blockRoot); + const parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); const fork = config.getForkName(slot); // set some sensible opts diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 047f7741c2f2..2f58962f3cc5 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -29,7 +29,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"; @@ -45,7 +45,14 @@ import {isOptimisticBlock} from "../util/forkChoice.js"; import {BufferPool} from "../util/bufferPool.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"; @@ -279,7 +286,8 @@ export class BeaconChain implements IBeaconChain { clock.currentSlot, cachedState, opts, - this.justifiedBalancesGetter.bind(this) + this.justifiedBalancesGetter.bind(this), + logger ); const regen = new QueuedStateRegenerator({ config, @@ -703,12 +711,50 @@ 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.updateHead(); + return this.forkChoice.updateAndGetHead({mode: UpdateHeadOpt.GetCanonicialHead}).head; + } catch (e) { + this.metrics?.forkChoice.errors.inc({entrypoint: UpdateHeadOpt.GetCanonicialHead}); + 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}).head; + } catch (e) { + this.metrics?.forkChoice.errors.inc({entrypoint: UpdateHeadOpt.GetPredictedProposerHead}); + throw e; + } finally { + timer?.(); + } + } + + getProposerHead(slot: Slot): ProtoBlock { + this.metrics?.forkChoice.requests.inc(); + const timer = this.metrics?.forkChoice.findHead.startTimer({entrypoint: FindHeadFnName.getProposerHead}); + const secFromSlot = this.clock.secFromSlot(slot); + + try { + const {head, isHeadTimely, notReorgedReason} = this.forkChoice.updateAndGetHead({ + mode: UpdateHeadOpt.GetProposerHead, + secFromSlot, + slot, + }); + + if (isHeadTimely && notReorgedReason !== undefined) { + this.metrics?.forkChoice.notReorgedReason.inc({reason: notReorgedReason}); + } + return head; } catch (e) { - this.metrics?.forkChoice.errors.inc(); + this.metrics?.forkChoice.errors.inc({entrypoint: UpdateHeadOpt.GetProposerHead}); throw e; } finally { timer?.(); diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index a277834d76b7..af7aeb47dd9c 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -64,6 +64,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 @@ -188,6 +194,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 c0d32449b072..7c7cfcdde75b 100644 --- a/packages/beacon-node/src/chain/options.ts +++ b/packages/beacon-node/src/chain/options.ts @@ -95,7 +95,8 @@ export const defaultChainOptions: IChainOptions = { blsVerifyAllMainThread: false, blsVerifyAllMultiThread: false, disableBlsBatchVerify: false, - proposerBoostEnabled: true, + proposerBoost: true, + proposerBoostReorg: 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 c155c3198269..3f730df3bf1d 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -2,7 +2,9 @@ import { computeEpochAtSlot, isExecutionStateType, computeTimeAtSlot, + CachedBeaconStateExecutions, StateHashTreeRootSource, + CachedBeaconStateAllForks, } from "@lodestar/state-transition"; import {ChainForkConfig} from "@lodestar/config"; import {ForkSeq, SLOTS_PER_EPOCH, ForkExecution} from "@lodestar/params"; @@ -113,14 +115,6 @@ export class PrepareNextSlotScheduler { RegenCaller.precomputeEpoch ); - // cache HashObjects for faster hashTreeRoot() later, especially for computeNewStateRoot() if we need to produce a block at slot 0 of epoch - // see https://github.com/ChainSafe/lodestar/issues/6194 - const hashTreeRootTimer = this.metrics?.stateHashTreeRootTime.startTimer({ - source: StateHashTreeRootSource.prepareNextSlot, - }); - prepareState.hashTreeRoot(); - hashTreeRootTimer?.(); - // assuming there is no reorg, it caches the checkpoint state & helps avoid doing a full state transition in the next slot // + when gossip block comes, we need to validate and run state transition // + if next slot is a skipped slot, it'd help getting target checkpoint state faster to validate attestations @@ -144,7 +138,31 @@ 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, + headSlot, + headRoot, + }); + this.metrics?.weakHeadDetected.inc(); + updatedPrepareState = (await this.chain.regen.getBlockSlotState( + proposerHeadRoot, + prepareSlot, + {dontTransferCache: !isEpochTransition}, + RegenCaller.predictProposerHead + )) 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) { @@ -167,10 +185,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", { @@ -180,10 +198,12 @@ export class PrepareNextSlotScheduler { }); } + this.computeStateHashTreeRoot(updatedPrepareState); + // 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 @@ -192,6 +212,8 @@ export class PrepareNextSlotScheduler { }); this.chain.emitter.emit(routes.events.EventType.payloadAttributes, {data, version: fork}); } + } else { + this.computeStateHashTreeRoot(prepareState); } } catch (e) { if (!isErrorAborted(e) && !isQueueErrorAborted(e)) { @@ -200,4 +222,14 @@ export class PrepareNextSlotScheduler { } } }; + + computeStateHashTreeRoot(state: CachedBeaconStateAllForks): void { + // cache HashObjects for faster hashTreeRoot() later, especially for computeNewStateRoot() if we need to produce a block at slot 0 of epoch + // see https://github.com/ChainSafe/lodestar/issues/6194 + const hashTreeRootTimer = this.metrics?.stateHashTreeRootTime.startTimer({ + source: StateHashTreeRootSource.prepareNextSlot, + }); + state.hashTreeRoot(); + hashTreeRootTimer?.(); + } } diff --git a/packages/beacon-node/src/chain/regen/interface.ts b/packages/beacon-node/src/chain/regen/interface.ts index 650d92143a8e..a1021de4aeab 100644 --- a/packages/beacon-node/src/chain/regen/interface.ts +++ b/packages/beacon-node/src/chain/regen/interface.ts @@ -11,6 +11,7 @@ export enum RegenCaller { validateGossipBlock = "validateGossipBlock", validateGossipBlob = "validateGossipBlob", precomputeEpoch = "precomputeEpoch", + predictProposerHead = "predictProposerHead", produceAttestationData = "produceAttestationData", processBlocksInEpoch = "processBlocksInEpoch", validateGossipAggregateAndProof = "validateGossipAggregateAndProof", diff --git a/packages/beacon-node/src/metrics/metrics/beacon.ts b/packages/beacon-node/src/metrics/metrics/beacon.ts index 9366174ef6c6..141121de9079 100644 --- a/packages/beacon-node/src/metrics/metrics/beacon.ts +++ b/packages/beacon-node/src/metrics/metrics/beacon.ts @@ -1,4 +1,6 @@ import {ProducedBlockSource} from "@lodestar/types"; +import {NotReorgedReason} from "@lodestar/fork-choice/lib/forkChoice/interface.js"; +import {UpdateHeadOpt} from "@lodestar/fork-choice"; import {RegistryMetricCreator} from "../utils/registryMetricCreator.js"; import {BlockProductionStep, PayloadPreparationType} from "../../chain/produceBlock/index.js"; @@ -57,18 +59,20 @@ 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", help: "Count of occasions where fork choice has tried to find a head", }), - errors: register.gauge({ + errors: register.gauge<{entrypoint: UpdateHeadOpt}>({ name: "beacon_fork_choice_errors_total", help: "Count of occasions where fork choice has returned an error when trying to find a head", + labelNames: ["entrypoint"], }), changedHead: register.gauge({ name: "beacon_fork_choice_changed_head_total", @@ -109,6 +113,11 @@ export function createBeaconMetrics(register: RegistryMetricCreator) { name: "beacon_fork_choice_indices_count", help: "Current count of indices in fork choice data structures", }), + notReorgedReason: register.gauge<{reason: NotReorgedReason}>({ + name: "beacon_fork_choice_not_reorged_reason_total", + help: "Reason why the current head is not re-orged out", + labelNames: ["reason"], + }), }, parentBlockDistance: register.histogram({ @@ -198,5 +207,10 @@ export function createBeaconMetrics(register: RegistryMetricCreator) { name: "beacon_clock_epoch", help: "Current clock epoch", }), + + weakHeadDetected: register.gauge({ + name: "beacon_weak_head_detected_total", + 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..145f378935fe --- /dev/null +++ b/packages/beacon-node/test/e2e/chain/proposerBoostReorg.test.ts @@ -0,0 +1,136 @@ +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 proposerBoostReorg = true; + /** + * reorgSlot + * / + * reorgSlot - 1 ------------ reorgSlot + 1 + * + * Note that in addition 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}, + chain: { + blsVerifyAllMainThread: true, + forkchoiceConstructor: TimelinessForkChoice, + proposerBoost: true, + proposerBoostReorg, + }, + }, + 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/e2e/chain/stateCache/nHistoricalStates.test.ts b/packages/beacon-node/test/e2e/chain/stateCache/nHistoricalStates.test.ts index 7de3f14435e9..170081e4dcd8 100644 --- a/packages/beacon-node/test/e2e/chain/stateCache/nHistoricalStates.test.ts +++ b/packages/beacon-node/test/e2e/chain/stateCache/nHistoricalStates.test.ts @@ -295,7 +295,7 @@ describe( chain: { blsVerifyAllMainThread: true, forkchoiceConstructor: ReorgedForkChoice, - proposerBoostEnabled: true, + proposerBoost: true, }, }, validatorCount, @@ -318,7 +318,7 @@ describe( nHistoricalStates: true, maxBlockStates, maxCPStateEpochsInMemory, - proposerBoostEnabled: true, + proposerBoost: true, }, metrics: {enabled: true}, }, 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 aa8228dcece0..39b62b597076 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -128,10 +128,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/produceBlock/produceBlockBody.test.ts b/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts index 96dda3acaece..7bf8c2f7252f 100644 --- a/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts +++ b/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts @@ -28,7 +28,8 @@ describe("produceBlockBody", () => { state = stateOg.clone(); chain = new BeaconChain( { - proposerBoostEnabled: true, + proposerBoost: true, + proposerBoostReorg: false, computeUnrealized: false, safeSlotsToImportOptimistically: SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, disableArchiveOnCheckpoint: true, diff --git a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts index 41d8aa76865b..19d33072bd7b 100644 --- a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts +++ b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts @@ -84,7 +84,8 @@ describe.skip("verify+import blocks - range sync perf test", () => { const state = stateOg.value.clone(); const chain = new BeaconChain( { - proposerBoostEnabled: true, + proposerBoost: true, + proposerBoostReorg: false, computeUnrealized: false, safeSlotsToImportOptimistically: SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY, disableArchiveOnCheckpoint: true, diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 997299c4e0e7..3b5d83bbeb95 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -97,6 +97,8 @@ const forkChoiceTest = // we don't use these in fork choice spec tests disablePrepareNextSlot: true, assertCorrectProgressiveBalances, + proposerBoost: true, + proposerBoostReorg: true, }, { config: createBeaconConfig(config, state.genesisValidatorsRoot), @@ -270,7 +272,7 @@ const forkChoiceTest = logger.debug(`Step ${i}/${stepsLen} check`); // Forkchoice head is computed lazily only on request - const head = chain.forkChoice.updateHead(); + const head = (chain.forkChoice as ForkChoice).updateHead(); const proposerBootRoot = (chain.forkChoice as ForkChoice).getProposerBoostRoot(); if (step.checks.head !== undefined) { 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 1ec3f738669d..370a25d7cc92 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 @@ -47,6 +47,7 @@ describe("api/validator - produceBlockV2", function () { const graffiti = "a".repeat(32); const feeRecipient = "0xcccccccccccccccccccccccccccccccccccccccc"; + modules.chain.getProposerHead.mockReturnValue(generateProtoBlock({blockRoot: toHexString(parentBlockRoot)})); modules.chain.recomputeForkChoiceHead.mockReturnValue( generateProtoBlock({blockRoot: toHexString(parentBlockRoot)}) ); @@ -87,7 +88,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.recomputeForkChoiceHead.mockReturnValue(generateProtoBlock({slot: headSlot})); modules.chain["opPool"].getSlashingsAndExits.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 d182cfeb537e..4adb07cd154b 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 @@ -87,6 +87,7 @@ describe("api/validator - produceBlockV3", function () { modules.chain.recomputeForkChoiceHead.mockReturnValue({ blockRoot: toHexString(fullBlock.parentRoot), } as ProtoBlock); + modules.chain.getProposerHead.mockReturnValue({blockRoot: toHexString(fullBlock.parentRoot)} as ProtoBlock); if (enginePayloadValue !== null) { const commonBlockBody: CommonBlockBody = { 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/cli/src/options/beaconNodeOptions/chain.ts b/packages/cli/src/options/beaconNodeOptions/chain.ts index 0fee440a792b..aae97b6db68f 100644 --- a/packages/cli/src/options/beaconNodeOptions/chain.ts +++ b/packages/cli/src/options/beaconNodeOptions/chain.ts @@ -12,7 +12,8 @@ export type ChainArgs = { // No need to define chain.persistInvalidSszObjects as part of ChainArgs // as this is defined as part of BeaconPaths // "chain.persistInvalidSszObjectsDir": string; - "chain.proposerBoostEnabled"?: boolean; + "chain.proposerBoost"?: boolean; + "chain.proposerBoostReorg"?: boolean; "chain.disableImportExecutionFcU"?: boolean; "chain.preaggregateSlotDistance"?: number; "chain.attDataCacheSlotDistance"?: number; @@ -43,7 +44,8 @@ export function parseArgs(args: ChainArgs): IBeaconNodeOptions["chain"] { persistInvalidSszObjects: args["chain.persistInvalidSszObjects"], // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any persistInvalidSszObjectsDir: undefined as any, - proposerBoostEnabled: args["chain.proposerBoostEnabled"], + proposerBoost: args["chain.proposerBoost"], + proposerBoostReorg: args["chain.proposerBoostReorg"], disableImportExecutionFcU: args["chain.disableImportExecutionFcU"], preaggregateSlotDistance: args["chain.preaggregateSlotDistance"], attDataCacheSlotDistance: args["chain.attDataCacheSlotDistance"], @@ -123,11 +125,20 @@ Will double processing times. Use only for debugging purposes.", group: "chain", }, - "chain.proposerBoostEnabled": { + "chain.proposerBoost": { + alias: ["chain.proposerBoostEnabled"], hidden: true, type: "boolean", description: "Enable proposer boost to reward a timely block", - defaultDescription: String(defaultOptions.chain.proposerBoostEnabled), + defaultDescription: String(defaultOptions.chain.proposerBoost), + group: "chain", + }, + + "chain.proposerBoostReorg": { + hidden: true, + type: "boolean", + description: "Enable proposer boost reorg to reorg out a late block", + defaultDescription: String(defaultOptions.chain.proposerBoostReorg), group: "chain", }, diff --git a/packages/cli/test/unit/options/beaconNodeOptions.test.ts b/packages/cli/test/unit/options/beaconNodeOptions.test.ts index f31cc604f775..d74ae73b966f 100644 --- a/packages/cli/test/unit/options/beaconNodeOptions.test.ts +++ b/packages/cli/test/unit/options/beaconNodeOptions.test.ts @@ -23,7 +23,8 @@ describe("options / beaconNodeOptions", () => { "chain.disableBlsBatchVerify": true, "chain.persistProducedBlocks": true, "chain.persistInvalidSszObjects": true, - "chain.proposerBoostEnabled": false, + "chain.proposerBoost": false, + "chain.proposerBoostReorg": false, "chain.disableImportExecutionFcU": false, "chain.preaggregateSlotDistance": 1, "chain.attDataCacheSlotDistance": 2, @@ -129,7 +130,8 @@ describe("options / beaconNodeOptions", () => { disableBlsBatchVerify: true, persistProducedBlocks: true, persistInvalidSszObjects: true, - proposerBoostEnabled: false, + proposerBoost: false, + proposerBoostReorg: false, disableImportExecutionFcU: false, preaggregateSlotDistance: 1, attDataCacheSlotDistance: 2, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 8ba3156a1b9a..b2e1a5314012 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -45,10 +45,22 @@ import { import {IForkChoiceStore, CheckpointWithHex, toCheckpointWithHex, JustifiedBalances} from "./store.js"; export type ForkChoiceOpts = { - proposerBoostEnabled?: boolean; + proposerBoost?: boolean; + proposerBoostReorg?: boolean; computeUnrealized?: boolean; }; +export enum UpdateHeadOpt { + GetCanonicialHead = "getCanonicialHead", // Skip getProposerHead + GetProposerHead = "getProposerHead", // With getProposerHead + GetPredictedProposerHead = "getPredictedProposerHead", // With predictProposerHead +} + +export type UpdateAndGetHeadOpt = + | {mode: UpdateHeadOpt.GetCanonicialHead} + | {mode: UpdateHeadOpt.GetProposerHead; secFromSlot: number; slot: Slot} + | {mode: UpdateHeadOpt.GetPredictedProposerHead; slot: Slot}; + /** * Provides an implementation of "Ethereum Consensus -- Beacon Chain Fork Choice": * @@ -156,6 +168,41 @@ 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() + * + * Only `GetProposerHead` returns additional field `isHeadTimely` and `notReorgedReason` for metrics purpose + */ + updateAndGetHead(opt: UpdateAndGetHeadOpt): { + head: ProtoBlock; + isHeadTimely?: boolean; + notReorgedReason?: NotReorgedReason; + } { + const {mode} = opt; + + const canonicialHeadBlock = mode === UpdateHeadOpt.GetPredictedProposerHead ? this.getHead() : this.updateHead(); + switch (mode) { + case UpdateHeadOpt.GetPredictedProposerHead: + return {head: this.predictProposerHead(canonicialHeadBlock, opt.slot)}; + case UpdateHeadOpt.GetProposerHead: { + const { + proposerHead: head, + isHeadTimely, + notReorgedReason, + } = this.getProposerHead(canonicialHeadBlock, opt.secFromSlot, opt.slot); + return {head, isHeadTimely, notReorgedReason}; + } + case UpdateHeadOpt.GetCanonicialHead: + default: + return {head: canonicialHeadBlock}; + } + } + /** * Get the proposer boost root */ @@ -176,7 +223,7 @@ export class ForkChoice implements IForkChoice { */ predictProposerHead(headBlock: ProtoBlock, currentSlot?: Slot): ProtoBlock { // Skip re-org attempt if proposer boost (reorg) are disabled - if (!this.opts?.proposerBoostEnabled) { + if (!this.opts?.proposerBoost || !this.opts?.proposerBoostReorg) { this.logger?.verbose("No proposer boot reorg prediction since the related flags are disabled"); return headBlock; } @@ -209,7 +256,7 @@ export class ForkChoice implements IForkChoice { * * 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()` + * 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 */ @@ -222,7 +269,7 @@ export class ForkChoice implements IForkChoice { let proposerHead = headBlock; // Skip re-org attempt if proposer boost (reorg) are disabled - if (!this.opts?.proposerBoostEnabled) { + if (!this.opts?.proposerBoost || !this.opts?.proposerBoostReorg) { this.logger?.verbose("No proposer boot reorg attempt since the related flags are disabled"); return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ProposerBoostReorgDisabled}; } @@ -328,7 +375,7 @@ export class ForkChoice implements IForkChoice { * starting from the proposerIndex */ let proposerBoost: {root: RootHex; score: number} | null = null; - if (this.opts?.proposerBoostEnabled && this.proposerBoostRoot) { + if (this.opts?.proposerBoost && this.proposerBoostRoot) { const proposerBoostScore = this.justifiedProposerBoostScore ?? getCommitteeFraction(this.fcStore.justified.totalBalance, { @@ -486,7 +533,7 @@ export class ForkChoice implements IForkChoice { // before attesting interval = before 1st interval const isTimely = this.isBlockTimely(block, blockDelaySec); if ( - this.opts?.proposerBoostEnabled && + this.opts?.proposerBoost && isTimely && // only boost the first block we see this.proposerBoostRoot === null diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index e4d087deab3a..d91a338bbca5 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; @@ -43,18 +44,18 @@ export type AncestorResult = // Reason for not proposer boost reorging export enum NotReorgedReason { - HeadBlockIsTimely, - ParentBlockNotAvailable, - ProposerBoostReorgDisabled, - NotShufflingStable, - NotFFGCompetitive, - ChainLongUnfinality, - ParentBlockDistanceMoreThanOneSlot, - ReorgMoreThanOneSlot, - ProposerBoostNotWornOff, - HeadBlockNotWeak, - ParentBlockNotStrong, - NotProposingOnTime, + HeadBlockIsTimely = "headBlockIsTimely", + ParentBlockNotAvailable = "parentBlockNotAvailable", + ProposerBoostReorgDisabled = "proposerBoostReorgDisabled", + NotShufflingStable = "notShufflingStable", + NotFFGCompetitive = "notFFGCompetitive", + ChainLongUnfinality = "chainLongUnfinality", + ParentBlockDistanceMoreThanOneSlot = "parentBlockDistanceMoreThanOneSlot", + ReorgMoreThanOneSlot = "reorgMoreThanOneSlot", + ProposerBoostNotWornOff = "proposerBoostNotWornOff", + HeadBlockNotWeak = "headBlockNotWeak", + ParentBlockNotStrong = "ParentBlockNotStrong", + NotProposingOnTime = "notProposingOnTime", } export type ForkChoiceMetrics = { @@ -92,7 +93,11 @@ export interface IForkChoice { */ getHeadRoot(): RootHex; getHead(): ProtoBlock; - updateHead(): ProtoBlock; + updateAndGetHead(mode: UpdateAndGetHeadOpt): { + head: ProtoBlock; + isHeadTimely?: boolean; + notReorgedReason?: NotReorgedReason; + }; /** * 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/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 661d3b45ed68..25d539a5e33b 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -235,8 +235,8 @@ describe("Forkchoice / GetProposerHead", function () { }); const forkChoice = new ForkChoice(config, fcStore, protoArr, { - proposerBoostEnabled: true, - // proposerBoostReorgEnabled: true, + proposerBoost: true, + proposerBoostReorg: true, }); const {proposerHead, isHeadTimely, notReorgedReason} = forkChoice.getProposerHead(