From 7cfa855d56ca7d0c7003df65a90c463888c9fd58 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Thu, 16 Sep 2021 23:27:59 +0200 Subject: [PATCH 1/4] Add block production support for merge blocks --- .../src/merge/utils.ts | 12 +- .../cli/src/cmds/beacon/initBeaconState.ts | 16 +- .../fork-choice/src/forkChoice/forkChoice.ts | 15 +- .../fork-choice/src/forkChoice/interface.ts | 3 - .../src/forkChoice/transitionStore.ts | 7 + .../test/unit/forkChoice/forkChoice.test.ts | 4 +- packages/lodestar/package.json | 1 + packages/lodestar/src/api/impl/types.ts | 2 - .../lodestar/src/api/impl/validator/index.ts | 17 +- .../src/chain/blocks/stateTransition.ts | 2 +- packages/lodestar/src/chain/chain.ts | 15 +- .../lodestar/src/chain/factory/block/body.ts | 102 ++++-- .../lodestar/src/chain/factory/block/index.ts | 10 +- .../lodestar/src/chain/forkChoice/index.ts | 30 +- .../lodestar/src/chain/genesis/genesis.ts | 4 +- packages/lodestar/src/chain/initState.ts | 23 +- packages/lodestar/src/chain/interface.ts | 7 + ...roduction.ts => eth1DepositDataTracker.ts} | 49 ++- .../eth1/eth1ForBlockProductionDisabled.ts | 22 -- .../src/eth1/eth1MergeBlockTracker.ts | 312 ++++++++++++++++++ packages/lodestar/src/eth1/index.ts | 119 ++++++- packages/lodestar/src/eth1/interface.ts | 48 ++- packages/lodestar/src/eth1/options.ts | 6 +- .../src/eth1/provider/eth1Provider.ts | 70 +++- packages/lodestar/src/eth1/stream.ts | 7 +- .../utils/optimizeNextBlockDiffForGenesis.ts | 11 +- .../lodestar/src/executionEngine/index.ts | 69 ++++ packages/lodestar/src/node/nodejs.ts | 22 +- packages/lodestar/src/node/options.ts | 4 +- .../e2e/eth1/eth1ForBlockProduction.test.ts | 16 +- .../test/e2e/eth1/eth1Provider.test.ts | 6 +- .../lodestar/test/e2e/eth1/stream.test.ts | 3 +- .../test/e2e/network/gossipsub.test.ts | 2 +- .../impl/validator/duties/proposer.test.ts | 3 - .../validator/produceAttestationData.test.ts | 3 - .../chain/factory/block/blockAssembly.test.ts | 6 +- .../unit/chain/factory/block/body.test.ts | 18 +- .../unit/chain/forkChoice/forkChoice.test.ts | 4 +- .../test/unit/chain/genesis/genesis.test.ts | 58 ++-- .../unit/network/attestationService.test.ts | 5 +- .../lodestar/test/utils/mocks/chain/chain.ts | 12 +- .../test/spec/allForks/forkChoice.ts | 4 +- 42 files changed, 874 insertions(+), 275 deletions(-) rename packages/lodestar/src/eth1/{eth1ForBlockProduction.ts => eth1DepositDataTracker.ts} (89%) delete mode 100644 packages/lodestar/src/eth1/eth1ForBlockProductionDisabled.ts create mode 100644 packages/lodestar/src/eth1/eth1MergeBlockTracker.ts create mode 100644 packages/lodestar/src/executionEngine/index.ts diff --git a/packages/beacon-state-transition/src/merge/utils.ts b/packages/beacon-state-transition/src/merge/utils.ts index 8016448750a..f7777a33f3f 100644 --- a/packages/beacon-state-transition/src/merge/utils.ts +++ b/packages/beacon-state-transition/src/merge/utils.ts @@ -1,4 +1,4 @@ -import {merge, ssz} from "@chainsafe/lodestar-types"; +import {allForks, merge, ssz} from "@chainsafe/lodestar-types"; /** * Execution enabled = merge is done. @@ -32,3 +32,13 @@ export function isMergeComplete(state: merge.BeaconState): boolean { ssz.merge.ExecutionPayloadHeader.defaultTreeBacked() ); } + +/** Type guard for merge.BeaconState */ +export function isMergeStateType(state: allForks.BeaconState): state is merge.BeaconState { + return (state as merge.BeaconState).latestExecutionPayloadHeader !== undefined; +} + +/** Type guard for merge.BeaconBlockBody */ +export function isMergeBlockBodyType(blockBody: allForks.BeaconBlockBody): blockBody is merge.BeaconBlockBody { + return (blockBody as merge.BeaconBlockBody).executionPayload !== undefined; +} diff --git a/packages/cli/src/cmds/beacon/initBeaconState.ts b/packages/cli/src/cmds/beacon/initBeaconState.ts index a380311e677..0d2cebd85af 100644 --- a/packages/cli/src/cmds/beacon/initBeaconState.ts +++ b/packages/cli/src/cmds/beacon/initBeaconState.ts @@ -4,13 +4,7 @@ import {TreeBacked} from "@chainsafe/ssz"; import {createIBeaconConfig, IBeaconConfig, IChainForkConfig} from "@chainsafe/lodestar-config"; import {fromHex, ILogger} from "@chainsafe/lodestar-utils"; import {computeEpochAtSlot, allForks} from "@chainsafe/lodestar-beacon-state-transition"; -import { - IBeaconDb, - Eth1Provider, - IBeaconNodeOptions, - initStateFromAnchorState, - initStateFromEth1, -} from "@chainsafe/lodestar"; +import {IBeaconDb, IBeaconNodeOptions, initStateFromAnchorState, initStateFromEth1} from "@chainsafe/lodestar"; // eslint-disable-next-line no-restricted-imports import {getStateTypeFromBytes} from "@chainsafe/lodestar/lib/util/multifork"; import {downloadOrLoadFile} from "../../util"; @@ -127,13 +121,7 @@ export async function initBeaconState( const config = createIBeaconConfig(chainForkConfig, anchorState.genesisValidatorsRoot); return await initStateFromAnchorState(config, db, logger, anchorState); } else { - return await initStateFromEth1( - chainForkConfig, - db, - logger, - new Eth1Provider(chainForkConfig, options.eth1, signal), - signal - ); + return await initStateFromEth1({config: chainForkConfig, db, logger, opts: options.eth1, signal}); } } } diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 910170ce6c1..66431d20ebf 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -75,7 +75,7 @@ export class ForkChoice implements IForkChoice { private readonly config: IChainForkConfig, private readonly fcStore: IForkChoiceStore, /** Nullable until merge time comes */ - private transitionStore: ITransitionStore | null, + private readonly transitionStore: ITransitionStore, /** The underlying representation of the block DAG. */ private readonly protoArray: ProtoArray, /** @@ -91,12 +91,6 @@ export class ForkChoice implements IForkChoice { this.head = this.updateHead(); } - /** For merge transition. Initialize transition store when merge fork condition is met */ - initializeTransitionStore(transitionStore: ITransitionStore): void { - if (this.transitionStore !== null) throw Error("transitionStore already initialized"); - this.transitionStore = transitionStore; - } - /** * Returns the block root of an ancestor of `blockRoot` at the given `slot`. * (Note: `slot` refers to the block that is *returned*, not the one that is supplied.) @@ -304,7 +298,12 @@ export class ForkChoice implements IForkChoice { }); } - if (this.transitionStore && merge.isMergeBlock(state as merge.BeaconState, (block as merge.BeaconBlock).body)) { + if ( + this.transitionStore.initialized && + merge.isMergeStateType(state) && + merge.isMergeBlockBodyType(block.body) && + merge.isMergeBlock(state, block.body) + ) { const {powBlock, powBlockParent} = preCachedData || {}; if (!powBlock) throw Error("onBlock preCachedData must include powBlock"); if (!powBlockParent) throw Error("onBlock preCachedData must include powBlock"); diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 48c52f3351a..d443565e598 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -1,7 +1,6 @@ import {Epoch, Slot, ValidatorIndex, phase0, allForks, Root, RootHex} from "@chainsafe/lodestar-types"; import {IProtoBlock} from "../protoArray/interface"; import {CheckpointWithHex} from "./store"; -import {ITransitionStore} from "./transitionStore"; export type CheckpointHex = { epoch: Epoch; @@ -9,8 +8,6 @@ export type CheckpointHex = { }; export interface IForkChoice { - /** For merge transition. Initialize transition store when merge fork condition is met */ - initializeTransitionStore(transitionStore: ITransitionStore): void; /** * Returns the block root of an ancestor of `block_root` at the given `slot`. (Note: `slot` refers * to the block that is *returned*, not the one that is supplied.) diff --git a/packages/fork-choice/src/forkChoice/transitionStore.ts b/packages/fork-choice/src/forkChoice/transitionStore.ts index 236508e9aa0..62f065d0589 100644 --- a/packages/fork-choice/src/forkChoice/transitionStore.ts +++ b/packages/fork-choice/src/forkChoice/transitionStore.ts @@ -1,6 +1,13 @@ export interface ITransitionStore { + /** + * Equivalent to spec check `TransitionStore not null`. + * Since the TransitionStore is used in fork-choice + block production it's simpler for it to be always not null, + * and handle the initialized state internally. + */ + initialized: boolean; /** * Cumulative total difficulty over the entire Ethereum POW network. + * Value may not be always available */ terminalTotalDifficulty: bigint; } diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index 073187c9e7c..314e56a03df 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -1,4 +1,4 @@ -import {ForkChoice, IForkChoiceStore, ProtoArray} from "../../../src"; +import {ForkChoice, IForkChoiceStore, ITransitionStore, ProtoArray} from "../../../src"; import {config} from "@chainsafe/lodestar-config/default"; import {expect} from "chai"; import {fromHexString} from "@chainsafe/ssz"; @@ -44,7 +44,7 @@ describe("Forkchoice", function () { bestJustifiedCheckpoint: {epoch: genesisEpoch, root: fromHexString(finalizedRoot), rootHex: finalizedRoot}, }; - const transitionStore = null; + const transitionStore: ITransitionStore = {initialized: false, terminalTotalDifficulty: BigInt(0)}; it("getAllAncestorBlocks", function () { protoArr.onBlock(block); diff --git a/packages/lodestar/package.json b/packages/lodestar/package.json index 7654bd623fa..76cea55a787 100644 --- a/packages/lodestar/package.json +++ b/packages/lodestar/package.json @@ -62,6 +62,7 @@ "@ethersproject/abi": "^5.0.0", "@types/datastore-level": "^3.0.0", "bl": "^5.0.0", + "buffer-xor": "^2.0.2", "cross-fetch": "^3.1.4", "datastore-level": "^6.0.2", "deepmerge": "^3.2.0", diff --git a/packages/lodestar/src/api/impl/types.ts b/packages/lodestar/src/api/impl/types.ts index c5c6f7b9d83..20ced95bcfa 100644 --- a/packages/lodestar/src/api/impl/types.ts +++ b/packages/lodestar/src/api/impl/types.ts @@ -5,14 +5,12 @@ import {IBeaconChain} from "../../chain"; import {IBeaconDb} from "../../db"; import {IBeaconSync} from "../../sync"; import {INetwork} from "../../network"; -import {IEth1ForBlockProduction} from "../../eth1"; import {IMetrics} from "../../metrics"; export type ApiModules = { config: IChainForkConfig; chain: IBeaconChain; db: IBeaconDb; - eth1: IEth1ForBlockProduction; logger: ILogger; metrics: IMetrics | null; network: INetwork; diff --git a/packages/lodestar/src/api/impl/validator/index.ts b/packages/lodestar/src/api/impl/validator/index.ts index 6267d58d8e1..92bee14894d 100644 --- a/packages/lodestar/src/api/impl/validator/index.ts +++ b/packages/lodestar/src/api/impl/validator/index.ts @@ -55,15 +55,7 @@ const SYNC_TOLERANCE_EPOCHS = 1; * Server implementation for handling validator duties. * See `@chainsafe/lodestar-validator/src/api` for the client implementation). */ -export function getValidatorApi({ - chain, - config, - eth1, - logger, - metrics, - network, - sync, -}: ApiModules): routes.validator.Api { +export function getValidatorApi({chain, config, logger, metrics, network, sync}: ApiModules): routes.validator.Api { let genesisBlockRoot: Root | null = null; /** Compute and cache the genesis block root */ @@ -149,12 +141,7 @@ export function getValidatorApi({ await waitForSlot(slot); // Must never request for a future slot > currentSlot timer = metrics?.blockProductionTime.startTimer(); - const block = await assembleBlock( - {config, chain, eth1, metrics}, - slot, - randaoReveal, - toGraffitiBuffer(graffiti || "") - ); + const block = await assembleBlock({chain, metrics}, slot, randaoReveal, toGraffitiBuffer(graffiti || "")); metrics?.blockProductionSuccess.inc(); return {data: block, version: config.getForkName(block.slot)}; } finally { diff --git a/packages/lodestar/src/chain/blocks/stateTransition.ts b/packages/lodestar/src/chain/blocks/stateTransition.ts index 2b219ba4356..b7beb66aeaf 100644 --- a/packages/lodestar/src/chain/blocks/stateTransition.ts +++ b/packages/lodestar/src/chain/blocks/stateTransition.ts @@ -101,7 +101,7 @@ export async function runStateTransition( // current justified checkpoint should be prev epoch or current epoch if it's just updated // it should always have epochBalances there bc it's a checkpoint state, ie got through processEpoch - let justifiedBalances: number[] = []; + let justifiedBalances: number[] | undefined = undefined; if (postState.currentJustifiedCheckpoint.epoch > forkChoice.getJustifiedCheckpoint().epoch) { const justifiedState = checkpointStateCache.get(toCheckpointHex(postState.currentJustifiedCheckpoint)); if (!justifiedState) { diff --git a/packages/lodestar/src/chain/chain.ts b/packages/lodestar/src/chain/chain.ts index 26e4130d55b..cba9288a77d 100644 --- a/packages/lodestar/src/chain/chain.ts +++ b/packages/lodestar/src/chain/chain.ts @@ -43,10 +43,16 @@ import { import {ForkDigestContext, IForkDigestContext} from "../util/forkDigestContext"; import {LightClientIniter} from "./lightClient"; import {Archiver} from "./archiver"; +import {IEth1ForBlockProduction} from "../eth1"; +import {IExecutionEngine} from "../executionEngine"; export class BeaconChain implements IBeaconChain { readonly genesisTime: Number64; readonly genesisValidatorsRoot: Root; + readonly eth1: IEth1ForBlockProduction; + readonly executionEngine: IExecutionEngine; + // Expose config for convenience in modularized functions + readonly config: IBeaconConfig; bls: IBlsVerifier; forkChoice: IForkChoice; @@ -75,7 +81,6 @@ export class BeaconChain implements IBeaconChain { readonly seenContributionAndProof = new SeenContributionAndProof(); protected readonly blockProcessor: BlockProcessor; - protected readonly config: IBeaconConfig; protected readonly db: IBeaconDb; protected readonly logger: ILogger; protected readonly metrics: IMetrics | null; @@ -97,13 +102,17 @@ export class BeaconChain implements IBeaconChain { metrics, anchorState, transitionStore, + eth1, + executionEngine, }: { config: IBeaconConfig; db: IBeaconDb; logger: ILogger; metrics: IMetrics | null; anchorState: TreeBacked; - transitionStore: ITransitionStore | null; + transitionStore: ITransitionStore; + eth1: IEth1ForBlockProduction; + executionEngine: IExecutionEngine; } ) { this.opts = opts; @@ -113,6 +122,8 @@ export class BeaconChain implements IBeaconChain { this.metrics = metrics; this.genesisTime = anchorState.genesisTime; this.genesisValidatorsRoot = anchorState.genesisValidatorsRoot.valueOf() as Uint8Array; + this.eth1 = eth1; + this.executionEngine = executionEngine; this.forkDigestContext = new ForkDigestContext(config, this.genesisValidatorsRoot); diff --git a/packages/lodestar/src/chain/factory/block/body.ts b/packages/lodestar/src/chain/factory/block/body.ts index 60696d1f528..c7472ef8131 100644 --- a/packages/lodestar/src/chain/factory/block/body.ts +++ b/packages/lodestar/src/chain/factory/block/body.ts @@ -2,17 +2,21 @@ * @module chain/blockAssembly */ -import {List} from "@chainsafe/ssz"; -import {ForkName} from "@chainsafe/lodestar-params"; -import {Bytes96, Bytes32, phase0, allForks, altair, Root, Slot} from "@chainsafe/lodestar-types"; -import {IChainForkConfig} from "@chainsafe/lodestar-config"; -import {CachedBeaconState} from "@chainsafe/lodestar-beacon-state-transition"; - -import {IEth1ForBlockProduction} from "../../../eth1"; +import xor from "buffer-xor"; +import {List, hash} from "@chainsafe/ssz"; +import {Bytes96, Bytes32, phase0, allForks, altair, Root, Slot, BLSSignature, ssz} from "@chainsafe/lodestar-types"; +import { + CachedBeaconState, + computeEpochAtSlot, + computeTimeAtSlot, + getCurrentEpoch, + getRandaoMix, + merge, +} from "@chainsafe/lodestar-beacon-state-transition"; import {IBeaconChain} from "../../interface"; export async function assembleBody( - {chain, config, eth1}: {chain: IBeaconChain; config: IChainForkConfig; eth1: IEth1ForBlockProduction}, + chain: IBeaconChain, currentState: CachedBeaconState, randaoReveal: Bytes96, graffiti: Bytes32, @@ -34,11 +38,11 @@ export async function assembleBody( const [attesterSlashings, proposerSlashings] = chain.opPool.getSlashings(currentState); const voluntaryExits = chain.opPool.getVoluntaryExits(currentState); const attestations = chain.aggregatedAttestationPool.getAttestationsForBlock(currentState); - const {eth1Data, deposits} = await eth1.getEth1DataAndDeposits( + const {eth1Data, deposits} = await chain.eth1.getEth1DataAndDeposits( currentState as CachedBeaconState ); - const blockBodyPhase0: phase0.BeaconBlockBody = { + const blockBody: phase0.BeaconBlockBody = { randaoReveal, graffiti, eth1Data, @@ -49,25 +53,71 @@ export async function assembleBody( voluntaryExits: voluntaryExits as List, }; - const blockFork = config.getForkName(blockSlot); - switch (blockFork) { - case ForkName.phase0: - return blockBodyPhase0; + const blockEpoch = computeEpochAtSlot(blockSlot); - case ForkName.altair: { - const block: altair.BeaconBlockBody = { - ...blockBodyPhase0, - syncAggregate: chain.syncContributionAndProofPool.getAggregate( - syncAggregateData.parentSlot, - syncAggregateData.parentBlockRoot - ), - }; - return block; - } + if (blockEpoch >= chain.config.ALTAIR_FORK_EPOCH) { + (blockBody as altair.BeaconBlockBody).syncAggregate = chain.syncContributionAndProofPool.getAggregate( + syncAggregateData.parentSlot, + syncAggregateData.parentBlockRoot + ); + } + + if (blockEpoch >= chain.config.MERGE_FORK_EPOCH) { + (blockBody as merge.BeaconBlockBody).executionPayload = await getExecutionPayload( + chain, + currentState as merge.BeaconState, + randaoReveal + ); + } + + return blockBody; +} - default: - throw new Error(`Block processing not implemented for fork ${blockFork}`); +/** + * Produce ExecutionPayload for pre-merge, merge, and post-merge. + * + * Expects `eth1MergeBlockFinder` to be actively searching for blocks well in advance to being called. + */ +async function getExecutionPayload( + chain: IBeaconChain, + state: merge.BeaconState, + randaoReveal: BLSSignature +): Promise { + if (!merge.isMergeComplete(state)) { + const terminalPowBlockHash = chain.eth1.getMergeBlockHash(); + if (terminalPowBlockHash === null) { + // Pre-merge, empty payload + ssz.merge.ExecutionPayload.defaultValue(); + } else { + // Signify merge via producing on top of the last PoW block + const parentHash = terminalPowBlockHash; + return produceExecutionPayload(chain, state, parentHash, randaoReveal); + } } + + // Post-merge, normal payload + const parentHash = state.latestExecutionPayloadHeader.blockHash; + return produceExecutionPayload(chain, state, parentHash, randaoReveal); +} + +async function produceExecutionPayload( + chain: IBeaconChain, + state: merge.BeaconState, + parentHash: Root, + randaoReveal: BLSSignature +): Promise { + const timestamp = computeTimeAtSlot(chain.config, state.slot, state.genesisTime); + const randaoMix = computeRandaoMix(state, randaoReveal); + + // NOTE: This is a naive implementation that does not give sufficient time to the eth1 block to produce an optimal + // block. Probably in the future there will exist mechanisms to optimize block production, such as giving a heads + // up to the execution client, then calling assembleBlock. Stay up to spec updates and update accordingly. + return chain.executionEngine.assembleBlock(parentHash, timestamp, randaoMix); +} + +function computeRandaoMix(state: merge.BeaconState, randaoReveal: BLSSignature): Bytes32 { + const epoch = getCurrentEpoch(state); + return xor(Buffer.from(getRandaoMix(state, epoch) as Uint8Array), Buffer.from(hash(randaoReveal as Uint8Array))); } /** process_sync_committee_contributions is implemented in syncCommitteeContribution.getSyncAggregate */ diff --git a/packages/lodestar/src/chain/factory/block/index.ts b/packages/lodestar/src/chain/factory/block/index.ts index 909b698781f..38235fc4a28 100644 --- a/packages/lodestar/src/chain/factory/block/index.ts +++ b/packages/lodestar/src/chain/factory/block/index.ts @@ -6,7 +6,6 @@ import {CachedBeaconState, allForks} from "@chainsafe/lodestar-beacon-state-tran import {IChainForkConfig} from "@chainsafe/lodestar-config"; import {Bytes96, Root, Slot} from "@chainsafe/lodestar-types"; import {ZERO_HASH} from "../../../constants"; -import {IEth1ForBlockProduction} from "../../../eth1"; import {IMetrics} from "../../../metrics"; import {IBeaconChain} from "../../interface"; import {assembleBody} from "./body"; @@ -14,14 +13,12 @@ import {RegenCaller} from "../../regen"; import {fromHexString} from "@chainsafe/ssz"; type AssembleBlockModules = { - config: IChainForkConfig; chain: IBeaconChain; - eth1: IEth1ForBlockProduction; metrics: IMetrics | null; }; export async function assembleBlock( - {config, chain, eth1, metrics}: AssembleBlockModules, + {chain, metrics}: AssembleBlockModules, slot: Slot, randaoReveal: Bytes96, graffiti = ZERO_HASH @@ -35,12 +32,13 @@ export async function assembleBlock( proposerIndex: state.getBeaconProposer(slot), parentRoot: parentBlockRoot, stateRoot: ZERO_HASH, - body: await assembleBody({config, chain, eth1}, state, randaoReveal, graffiti, slot, { + body: await assembleBody(chain, state, randaoReveal, graffiti, slot, { parentSlot: slot - 1, parentBlockRoot, }), }; - block.stateRoot = computeNewStateRoot({config, metrics}, state, block); + + block.stateRoot = computeNewStateRoot({config: chain.config, metrics}, state, block); return block; } diff --git a/packages/lodestar/src/chain/forkChoice/index.ts b/packages/lodestar/src/chain/forkChoice/index.ts index fa18524d321..b6ab18cdb0b 100644 --- a/packages/lodestar/src/chain/forkChoice/index.ts +++ b/packages/lodestar/src/chain/forkChoice/index.ts @@ -23,7 +23,7 @@ export type ForkChoiceOpts = { */ export function initializeForkChoice( config: IChainForkConfig, - transitionStore: ITransitionStore | null, + transitionStore: ITransitionStore, emitter: ChainEventEmitter, currentSlot: Slot, state: CachedBeaconState, @@ -69,18 +69,26 @@ export function initializeForkChoice( ); } -/** - * Initialize TransitionStore with locally persisted value, overriding it with user provided option. - */ -export async function initializeTransitionStore(opts: ForkChoiceOpts, db: IBeaconDb): Promise { - if (opts.terminalTotalDifficulty !== undefined) { - return {terminalTotalDifficulty: opts.terminalTotalDifficulty}; +export class TransitionStore implements ITransitionStore { + // eslint-disable-next-line @typescript-eslint/naming-convention + constructor(private readonly _terminalTotalDifficulty: bigint | null) {} + + get initialized(): boolean { + return this._terminalTotalDifficulty !== null; } - const terminalTotalDifficulty = await db.totalTerminalDifficulty.get(); - if (terminalTotalDifficulty !== null) { - return {terminalTotalDifficulty: opts.terminalTotalDifficulty ?? terminalTotalDifficulty}; + get terminalTotalDifficulty(): bigint { + if (this._terminalTotalDifficulty === null) { + throw Error("TransitionStore not initilized"); + } + return this._terminalTotalDifficulty; } +} - return null; +/** + * Initialize TransitionStore with locally persisted value, overriding it with user provided option. + */ +export async function initializeTransitionStore(opts: ForkChoiceOpts, db: IBeaconDb): Promise { + const terminalTotalDifficulty = opts.terminalTotalDifficulty ?? (await db.totalTerminalDifficulty.get()); + return new TransitionStore(terminalTotalDifficulty); } diff --git a/packages/lodestar/src/chain/genesis/genesis.ts b/packages/lodestar/src/chain/genesis/genesis.ts index ecd08480f06..b395abcc5f5 100644 --- a/packages/lodestar/src/chain/genesis/genesis.ts +++ b/packages/lodestar/src/chain/genesis/genesis.ts @@ -19,7 +19,9 @@ import { createCachedBeaconState, } from "@chainsafe/lodestar-beacon-state-transition"; import {ILogger} from "@chainsafe/lodestar-utils"; -import {IEth1StreamParams, IEth1Provider, getDepositsAndBlockStreamForGenesis, getDepositsStream} from "../../eth1"; +import {IEth1Provider} from "../../eth1"; +import {IEth1StreamParams} from "../../eth1/interface"; +import {getDepositsAndBlockStreamForGenesis, getDepositsStream} from "../../eth1/stream"; import {IGenesisBuilder, IGenesisResult} from "./interface"; export interface IGenesisBuilderKwargs { diff --git a/packages/lodestar/src/chain/initState.ts b/packages/lodestar/src/chain/initState.ts index dec89c7a7fe..1dd0004cbfc 100644 --- a/packages/lodestar/src/chain/initState.ts +++ b/packages/lodestar/src/chain/initState.ts @@ -21,6 +21,7 @@ import {IMetrics} from "../metrics"; import {GenesisBuilder} from "./genesis/genesis"; import {IGenesisResult} from "./genesis/interface"; import {CheckpointStateCache, StateContextCache} from "./stateCache"; +import {Eth1Options} from "../eth1/options"; export async function persistGenesisResult( db: IBeaconDb, @@ -70,13 +71,19 @@ export function createGenesisBlock( /** * Initialize and persist a genesis state and related data */ -export async function initStateFromEth1( - config: IChainForkConfig, - db: IBeaconDb, - logger: ILogger, - eth1Provider: Eth1Provider, - signal: AbortSignal -): Promise> { +export async function initStateFromEth1({ + config, + db, + logger, + opts, + signal, +}: { + config: IChainForkConfig; + db: IBeaconDb; + logger: ILogger; + opts: Eth1Options; + signal: AbortSignal; +}): Promise> { logger.info("Listening to eth1 for genesis state"); const statePreGenesis = await db.preGenesisState.get(); @@ -85,7 +92,7 @@ export async function initStateFromEth1( const builder = new GenesisBuilder({ config, - eth1Provider, + eth1Provider: new Eth1Provider(config, opts, signal), logger, signal, pendingStatus: diff --git a/packages/lodestar/src/chain/interface.ts b/packages/lodestar/src/chain/interface.ts index f1edf039311..3089a037ab8 100644 --- a/packages/lodestar/src/chain/interface.ts +++ b/packages/lodestar/src/chain/interface.ts @@ -3,7 +3,10 @@ import {allForks, Number64, Root, phase0, Slot} from "@chainsafe/lodestar-types" import {CachedBeaconState} from "@chainsafe/lodestar-beacon-state-transition"; import {IForkChoice} from "@chainsafe/lodestar-fork-choice"; import {LightClientUpdater} from "@chainsafe/lodestar-light-client/server"; +import {IBeaconConfig} from "@chainsafe/lodestar-config"; +import {IEth1ForBlockProduction} from "../eth1"; +import {IExecutionEngine} from "../executionEngine"; import {IBeaconClock} from "./clock/interface"; import {ChainEventEmitter} from "./emitter"; import {IStateRegenerator} from "./regen"; @@ -57,6 +60,10 @@ export interface IBlockJob extends IProcessBlock { export interface IBeaconChain { readonly genesisTime: Number64; readonly genesisValidatorsRoot: Root; + readonly eth1: IEth1ForBlockProduction; + readonly executionEngine: IExecutionEngine; + // Expose config for convenience in modularized functions + readonly config: IBeaconConfig; bls: IBlsVerifier; forkChoice: IForkChoice; diff --git a/packages/lodestar/src/eth1/eth1ForBlockProduction.ts b/packages/lodestar/src/eth1/eth1DepositDataTracker.ts similarity index 89% rename from packages/lodestar/src/eth1/eth1ForBlockProduction.ts rename to packages/lodestar/src/eth1/eth1DepositDataTracker.ts index 054b2d84acb..c48880e1023 100644 --- a/packages/lodestar/src/eth1/eth1ForBlockProduction.ts +++ b/packages/lodestar/src/eth1/eth1DepositDataTracker.ts @@ -8,9 +8,10 @@ import {Eth1DepositsCache} from "./eth1DepositsCache"; import {Eth1DataCache} from "./eth1DataCache"; import {getEth1VotesToConsider, pickEth1Vote} from "./utils/eth1Vote"; import {getDeposits} from "./utils/deposits"; -import {IEth1ForBlockProduction, IEth1Provider} from "./interface"; -import {IEth1Options} from "./options"; +import {Eth1DataAndDeposits, IEth1Provider} from "./interface"; +import {Eth1Options} from "./options"; import {HttpRpcError} from "./provider/jsonRpcHttpClient"; +import {parseBlock} from "./provider/eth1Provider"; const MAX_BLOCKS_PER_BLOCK_QUERY = 1000; const MAX_BLOCKS_PER_LOG_QUERY = 1000; @@ -21,11 +22,18 @@ const RATE_LIMITED_WAIT_MS = 30 * 1000; /** Min time to wait on auto update loop on unknown error */ const MIN_WAIT_ON_ERORR_MS = 1 * 1000; +export type Eth1DepositDataTrackerModules = { + config: IChainForkConfig; + db: IBeaconDb; + logger: ILogger; + signal: AbortSignal; +}; + /** * Main class handling eth1 data fetching, processing and storing * Upon instantiation, starts fetcheing deposits and blocks at regular intervals */ -export class Eth1ForBlockProduction implements IEth1ForBlockProduction { +export class Eth1DepositDataTracker { private config: IChainForkConfig; private logger: ILogger; private signal: AbortSignal; @@ -33,24 +41,13 @@ export class Eth1ForBlockProduction implements IEth1ForBlockProduction { // Internal modules, state private depositsCache: Eth1DepositsCache; private eth1DataCache: Eth1DataCache; - private eth1Provider: IEth1Provider; private lastProcessedDepositBlockNumber: number | null; - constructor({ - config, - db, - eth1Provider, - logger, - opts, - signal, - }: { - config: IChainForkConfig; - db: IBeaconDb; - eth1Provider: IEth1Provider; - logger: ILogger; - opts: IEth1Options; - signal: AbortSignal; - }) { + constructor( + opts: Eth1Options, + {config, db, logger, signal}: Eth1DepositDataTrackerModules, + private readonly eth1Provider: IEth1Provider + ) { this.config = config; this.signal = signal; this.logger = logger; @@ -73,12 +70,7 @@ export class Eth1ForBlockProduction implements IEth1ForBlockProduction { /** * Return eth1Data and deposits ready for block production for a given state */ - async getEth1DataAndDeposits( - state: CachedBeaconState - ): Promise<{ - eth1Data: phase0.Eth1Data; - deposits: phase0.Deposit[]; - }> { + async getEth1DataAndDeposits(state: CachedBeaconState): Promise { const eth1Data = await this.getEth1Data(state); const deposits = await this.getDeposits(state, eth1Data); return {eth1Data, deposits}; @@ -197,10 +189,11 @@ export class Eth1ForBlockProduction implements IEth1ForBlockProduction { lastProcessedDepositBlockNumber ); - const eth1Blocks = await this.eth1Provider.getBlocksByNumber(fromBlock, toBlock); - this.logger.verbose("Fetched eth1 blocks", {blockCount: eth1Blocks.length, fromBlock, toBlock}); + const blocksRaw = await this.eth1Provider.getBlocksByNumber(fromBlock, toBlock); + const blocks = blocksRaw.map(parseBlock); + this.logger.verbose("Fetched eth1 blocks", {blockCount: blocks.length, fromBlock, toBlock}); - const eth1Datas = await this.depositsCache.getEth1DataForBlocks(eth1Blocks, lastProcessedDepositBlockNumber); + const eth1Datas = await this.depositsCache.getEth1DataForBlocks(blocks, lastProcessedDepositBlockNumber); await this.eth1DataCache.add(eth1Datas); return toBlock >= remoteFollowBlock; diff --git a/packages/lodestar/src/eth1/eth1ForBlockProductionDisabled.ts b/packages/lodestar/src/eth1/eth1ForBlockProductionDisabled.ts deleted file mode 100644 index 33605f5abe7..00000000000 --- a/packages/lodestar/src/eth1/eth1ForBlockProductionDisabled.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {allForks, phase0} from "@chainsafe/lodestar-types"; -import {IEth1ForBlockProduction} from "./interface"; -import {CachedBeaconState} from "@chainsafe/lodestar-beacon-state-transition"; - -/** - * Disabled version of Eth1ForBlockProduction - * May produce invalid blocks by not adding new deposits and voting for the same eth1Data - */ -export class Eth1ForBlockProductionDisabled implements IEth1ForBlockProduction { - /** - * Returns same eth1Data as in state and no deposits - * May produce invalid blocks if deposits have to be added - */ - async getEth1DataAndDeposits( - state: CachedBeaconState - ): Promise<{ - eth1Data: phase0.Eth1Data; - deposits: phase0.Deposit[]; - }> { - return {eth1Data: state.eth1Data, deposits: []}; - } -} diff --git a/packages/lodestar/src/eth1/eth1MergeBlockTracker.ts b/packages/lodestar/src/eth1/eth1MergeBlockTracker.ts new file mode 100644 index 00000000000..bc2442da7e9 --- /dev/null +++ b/packages/lodestar/src/eth1/eth1MergeBlockTracker.ts @@ -0,0 +1,312 @@ +import {AbortSignal} from "@chainsafe/abort-controller"; +import {IChainConfig} from "@chainsafe/lodestar-config"; +import {ITransitionStore} from "@chainsafe/lodestar-fork-choice"; +import {Epoch} from "@chainsafe/lodestar-types"; +import {IEth1Provider, EthJsonRpcBlockRaw} from "./interface"; +import {hexToBigint, hexToDecimal, validateHexRoot} from "./provider/eth1Provider"; +import {ILogger} from "@chainsafe/lodestar-utils"; +import {SLOTS_PER_EPOCH} from "@chainsafe/lodestar-params"; + +type RootHexPow = string; +type PowMergeBlock = { + number: number; + blockhash: RootHexPow; + parentHash: RootHexPow; + totalDifficulty: bigint; +}; + +enum StatusCode { + PRE_MERGE = "PRE_MERGE", + SEARCHING = "SEARCHING", + POST_MERGE = "POST_MERGE", +} + +/** + * Numbers of epochs in advance of merge fork condition to start looking for merge block + */ +const START_EPOCHS_IN_ADVANCE = 5; + +const MAX_CACHE_POW_HEIGHT_DISTANCE = 1024; + +const MAX_BLOCKS_PER_PAST_REQUEST = 1000; + +export type Eth1MergeBlockTrackerModules = { + transitionStore: ITransitionStore; + config: IChainConfig; + logger: ILogger; + signal: AbortSignal; + clockEpoch: Epoch; + isMergeComplete: boolean; +}; + +/** + * Follows the eth1 chain to find a (or multiple?) merge blocks that cross the threshold of total terminal difficulty + */ +export class Eth1MergeBlockTracker { + private readonly transitionStore: ITransitionStore; + private readonly config: IChainConfig; + private readonly logger: ILogger; + + /** + * List of blocks that meet the merge block conditions and are safe for block inclusion. + * TODO: In the edge case there are multiple, what to do? + */ + private readonly mergeBlocks: PowMergeBlock[] = []; + private readonly blockCache = new Map(); + + private status: StatusCode = StatusCode.PRE_MERGE; + private readonly intervals: NodeJS.Timeout[] = []; + + constructor( + {transitionStore, config, logger, signal, clockEpoch, isMergeComplete}: Eth1MergeBlockTrackerModules, + private readonly eth1Provider: IEth1Provider + ) { + this.transitionStore = transitionStore; + this.config = config; + this.logger = logger; + + // If merge has already happened, disable + if (isMergeComplete) { + this.status = StatusCode.POST_MERGE; + return; + } + + // If merge is still not programed, skip + if (config.MERGE_FORK_EPOCH >= Infinity) { + return; + } + + const startEpoch = this.config.MERGE_FORK_EPOCH - START_EPOCHS_IN_ADVANCE; + if (startEpoch <= clockEpoch) { + // Start now + this.startFinding(); + } else { + // Set a timer to start in the future + const intervalToStart = setInterval(() => { + this.startFinding(); + }, (startEpoch - clockEpoch) * SLOTS_PER_EPOCH * config.SECONDS_PER_SLOT * 1000); + this.intervals.push(intervalToStart); + } + + signal.addEventListener("abort", () => this.close(), {once: true}); + } + + /** + * Returns the most recent POW block that satisfies the merge block condition + */ + getMergeBlock(): PowMergeBlock | null { + const mergeBlock = this.mergeBlocks[this.mergeBlocks.length - 1] ?? null; + + // For better debugging in case this module stops searching too early + if (mergeBlock === null && this.status === StatusCode.POST_MERGE) { + throw Error("Eth1MergeBlockFinder is on POST_MERGE status and found no mergeBlock"); + } + + return mergeBlock; + } + + /** + * Call when merge is irrevocably completed to stop polling unnecessary data from the eth1 node + */ + mergeCompleted(): void { + this.status = StatusCode.POST_MERGE; + this.close(); + } + + /** + * Get a POW block by hash checking the local cache first + */ + async getPowBlock(powBlockHash: string): Promise { + // Check cache first + const cachedBlock = this.blockCache.get(powBlockHash); + if (cachedBlock) return cachedBlock; + + // Fetch from node + const blockRaw = await this.eth1Provider.getBlockByHash(powBlockHash); + if (blockRaw) { + const block = toPowBlock(blockRaw); + this.blockCache.set(block.blockhash, block); + return block; + } + + return null; + } + + private close(): void { + this.intervals.forEach(clearInterval); + } + + private startFinding(): void { + if (this.status !== StatusCode.PRE_MERGE) return; + this.status = StatusCode.SEARCHING; + + // 1. Fetch current head chain until finding a block with total difficulty less than `transitionStore.terminalTotalDifficulty` + this.fetchPreviousBlocks().catch((e) => { + this.logger.error("Error fetching past POW blocks", {}, e as Error); + }); + + // 2. Subscribe to eth1 blocks and recursively fetch potential POW blocks + const intervalPoll = setInterval(() => { + this.pollLatestBlock().catch((e) => { + this.logger.error("Error fetching latest POW block", {}, e as Error); + }); + }, this.config.SECONDS_PER_ETH1_BLOCK); + + // 3. Prune roughly every epoch + const intervalPrune = setInterval(() => { + this.prune(); + }, 32 * this.config.SECONDS_PER_SLOT * 1000); + + // Register interval to clean them on close() + this.intervals.push(intervalPoll, intervalPrune); + } + + private async fetchPreviousBlocks(): Promise { + // If latest block is under TTD, stop. Subscriptions will pick future blocks + // If latest block is over TTD, go backwards until finding a merge block + // Note: Must ensure parent relationship + + // Fast path for pre-merge scenario + const latestBlockRaw = await this.eth1Provider.getBlockByNumber("latest"); + if (!latestBlockRaw) { + throw Error("getBlockByNumber('latest') returned null"); + } + + const latestBlock = toPowBlock(latestBlockRaw); + // TTD not reached yet, stop looking at old blocks and expect the subscription to find merge block + if (latestBlock.totalDifficulty < this.transitionStore.terminalTotalDifficulty) { + return; + } + + // TTD already reached, search blocks backwards + let minFetchedBlockNumber = latestBlock.number; + // eslint-disable-next-line no-constant-condition + while (true) { + const from = minFetchedBlockNumber - MAX_BLOCKS_PER_PAST_REQUEST; + // Re-fetch same block to have the full chain of parent-child nodes + const to = minFetchedBlockNumber; + + try { + const blocksRaw = await this.eth1Provider.getBlocksByNumber(from, to); + const blocks = blocksRaw.map(toPowBlock); + + // Should never happen + if (blocks.length < 2) { + throw Error(`getBlocksByNumber(${from}, ${to}) returned less than 2 results`); + } + + for (let i = 0; i < blocks.length - 1; i++) { + const childBlock = blocks[i + 1]; + const parentBlock = blocks[i]; + if ( + childBlock.totalDifficulty >= this.transitionStore.terminalTotalDifficulty && + parentBlock.totalDifficulty < this.transitionStore.terminalTotalDifficulty + ) { + // Is terminal total difficulty block + if (childBlock.parentHash === parentBlock.blockhash) { + // AND has verified block -> parent relationship + this.mergeBlocks.push(childBlock); + } else { + // WARNING! Re-org while doing getBlocksByNumber() call. Ensure that this block is the merge block + // and not some of its parents. + await this.fetchPotentialMergeBlock(childBlock); + } + + return; + } + } + + // On next round + minFetchedBlockNumber = Math.min(to, ...blocks.map((block) => block.number)); + + // Scanned the entire blockchain + if (minFetchedBlockNumber <= 0) { + return; + } + } catch (e) { + this.logger.error("Error on fetchPreviousBlocks range", {from, to}, e as Error); + } + } + } + + /** + * Fetches the current latest block according the execution client. + * If the latest block has totalDifficulty over TTD, it will backwards recursive search the merge block. + * TODO: How to prevent doing long recursive search after the merge block has happened? + */ + private async pollLatestBlock(): Promise { + const latestBlockRaw = await this.eth1Provider.getBlockByNumber("latest"); + if (!latestBlockRaw) { + throw Error("getBlockByNumber('latest') returned null"); + } + + const latestBlock = toPowBlock(latestBlockRaw); + await this.fetchPotentialMergeBlock(latestBlock); + } + + /** + * Potential merge block, do a backwards search with parent hashes. + * De-duplicates code between pollLatestBlock() and fetchPreviousBlocks(). + */ + private async fetchPotentialMergeBlock(block: PowMergeBlock): Promise { + // Persist block for future searches + this.blockCache.set(block.blockhash, block); + + while (block.totalDifficulty >= this.transitionStore.terminalTotalDifficulty) { + const parent = await this.getPowBlock(block.blockhash); + // Unknown parent + if (!parent) { + return; + } + + if ( + block.totalDifficulty >= this.transitionStore.terminalTotalDifficulty && + parent.totalDifficulty < this.transitionStore.terminalTotalDifficulty + ) { + // Is terminal total difficulty block AND has verified block -> parent relationship + this.mergeBlocks.push(block); + return; + } + + // Fetch parent's parent + block = parent; + } + } + + /** + * Prune blocks to have at max MAX_CACHE_POW_HEIGHT_DISTANCE between the highest block number in the cache + * and the lowest block number in the cache. + * + * Call every once in a while, i.e. once per epoch + */ + private prune(): void { + // Find the heightest block number in the cache + let maxBlockNumber = 0; + for (const block of this.blockCache.values()) { + if (block.number > maxBlockNumber) { + maxBlockNumber = block.number; + } + } + + // Prune blocks below the max distance + const minHeight = maxBlockNumber - MAX_CACHE_POW_HEIGHT_DISTANCE; + for (const [key, block] of this.blockCache.entries()) { + if (block.number < minHeight) { + this.blockCache.delete(key); + } + } + } +} + +function toPowBlock(block: EthJsonRpcBlockRaw): PowMergeBlock { + // Validate untrusted data from API + validateHexRoot(block.hash); + validateHexRoot(block.parentHash); + + return { + number: hexToDecimal(block.number), + blockhash: block.hash, + parentHash: block.parentHash, + totalDifficulty: hexToBigint(block.totalDifficulty), + }; +} diff --git a/packages/lodestar/src/eth1/index.ts b/packages/lodestar/src/eth1/index.ts index 501ea339b70..dbdb26b0daf 100644 --- a/packages/lodestar/src/eth1/index.ts +++ b/packages/lodestar/src/eth1/index.ts @@ -1,9 +1,116 @@ +import {CachedBeaconState, computeEpochAtSlot, getCurrentSlot} from "@chainsafe/lodestar-beacon-state-transition"; +import {allForks, Root} from "@chainsafe/lodestar-types"; +import {merge} from "@chainsafe/lodestar-beacon-state-transition"; +import {fromHexString} from "@chainsafe/ssz"; +import {IEth1ForBlockProduction, Eth1DataAndDeposits, IEth1Provider} from "./interface"; +import {Eth1DepositDataTracker, Eth1DepositDataTrackerModules} from "./eth1DepositDataTracker"; +import {Eth1MergeBlockTracker, Eth1MergeBlockTrackerModules} from "./eth1MergeBlockTracker"; +import {Eth1Options} from "./options"; +import {Eth1Provider} from "./provider/eth1Provider"; +export {IEth1ForBlockProduction, IEth1Provider, Eth1Provider}; + +// This module encapsulates all consumer functionality to the execution node (formerly eth1). The execution client +// has to: +// +// - For genesis, the beacon node must follow the eth1 chain: get all deposit events + blocks within that range. +// Once the genesis conditions are met, start the POS chain with the resulting state. The logic is similar to the +// two points below, but the implementation is specialized for each scenario. +// +// - Follow the eth1 block chain to validate eth1Data votes. It needs all consecutive blocks within a specific range +// and at a distance from the head. +// ETH1_FOLLOW_DISTANCE uint64(2**11) (= 2,048) Eth1 blocks ~8 hours +// EPOCHS_PER_ETH1_VOTING_PERIOD uint64(2**6) (= 64) epochs ~6.8 hours +// +// - Fetch ALL deposit events from the deposit contract to build the deposit tree and validate future merkle proofs. +// Then it must follow deposit events at a distance roughly similar to the `ETH1_FOLLOW_DISTANCE` parameter above. +// +// - [New merge]: When initializing the TransitionStore (on MERGE_FORK_EPOCH), it must fetch the block with hash +// `state.eth1_data.block_hash` to compute `terminal_total_difficulty`. Note this may change with +// https://github.com/ethereum/consensus-specs/issues/2603. +// +// - [New merge]: On block production post MERGE_FORK_EPOCH, pre merge, the beacon node must find the merge block +// crossing the `terminal_total_difficulty` boundary and include it in the block. After the merge block production +// will just use `execution_engine.assemble_block` without fetching individual blocks. +// +// - [New merge]: Fork-choice must validate the merge block ensuring it crossed the `terminal_total_difficulty` +// boundary, so it must fetch the POW block referenced in the merge block + its POW parent block. +// +// With the merge the beacon node has to follow the eth1 chain at two distances: +// 1. At `ETH1_FOLLOW_DISTANCE` for eth1Data to be re-org safe +// 2. At the head to get the first merge block, tolerating possible re-orgs +// +// Then both streams of blocks should not be merged since it's harder to guard against re-orgs from (2) to (1). + +export function initializeEth1ForBlockProduction( + opts: Eth1Options, + modules: Pick & + Pick, + anchorState: allForks.BeaconState +): IEth1ForBlockProduction { + if (opts.enabled) { + return new Eth1ForBlockProduction(opts, { + config: modules.config, + db: modules.db, + logger: modules.logger, + signal: modules.signal, + transitionStore: modules.transitionStore, + clockEpoch: computeEpochAtSlot(getCurrentSlot(modules.config, anchorState.genesisTime)), + isMergeComplete: merge.isMergeStateType(anchorState) && merge.isMergeComplete(anchorState), + }); + } else { + return new Eth1ForBlockProductionDisabled(); + } +} +export class Eth1ForBlockProduction implements IEth1ForBlockProduction { + private readonly eth1DepositDataTracker: Eth1DepositDataTracker; + private readonly eth1MergeBlockTracker: Eth1MergeBlockTracker; + + constructor( + opts: Eth1Options, + modules: Eth1DepositDataTrackerModules & Eth1MergeBlockTrackerModules & {eth1Provider?: IEth1Provider} + ) { + const eth1Provider = modules.eth1Provider || new Eth1Provider(modules.config, opts, modules.signal); + + this.eth1DepositDataTracker = new Eth1DepositDataTracker(opts, modules, eth1Provider); + + this.eth1MergeBlockTracker = new Eth1MergeBlockTracker(modules, eth1Provider); + } + + async getEth1DataAndDeposits(state: CachedBeaconState): Promise { + return this.eth1DepositDataTracker.getEth1DataAndDeposits(state); + } + + getMergeBlockHash(): Root | null { + const block = this.eth1MergeBlockTracker.getMergeBlock(); + return block && fromHexString(block.blockhash); + } + + mergeCompleted(): void { + this.eth1MergeBlockTracker.mergeCompleted(); + } +} + /** - * @module eth1 + * Disabled version of Eth1ForBlockProduction + * May produce invalid blocks by not adding new deposits and voting for the same eth1Data */ +export class Eth1ForBlockProductionDisabled implements IEth1ForBlockProduction { + /** + * Returns same eth1Data as in state and no deposits + * May produce invalid blocks if deposits have to be added + */ + async getEth1DataAndDeposits(state: CachedBeaconState): Promise { + return {eth1Data: state.eth1Data, deposits: []}; + } + + /** + * Will miss the oportunity to propose the merge block but will still produce valid blocks + */ + getMergeBlockHash(): Root | null { + return null; + } -export * from "./interface"; -export * from "./stream"; -export * from "./provider/eth1Provider"; -export * from "./eth1ForBlockProduction"; -export * from "./eth1ForBlockProductionDisabled"; + mergeCompleted(): void { + // Ignore + } +} diff --git a/packages/lodestar/src/eth1/interface.ts b/packages/lodestar/src/eth1/interface.ts index 8ab329e2f53..d25b7b911d3 100644 --- a/packages/lodestar/src/eth1/interface.ts +++ b/packages/lodestar/src/eth1/interface.ts @@ -3,25 +3,55 @@ */ import {IBeaconConfig} from "@chainsafe/lodestar-config"; -import {allForks, phase0} from "@chainsafe/lodestar-types"; +import {allForks, phase0, Root} from "@chainsafe/lodestar-types"; import {CachedBeaconState} from "@chainsafe/lodestar-beacon-state-transition"; +export type EthJsonRpcBlockRaw = { + /** the block number. null when its pending block. `"0x1b4"` */ + number: string; + /** 32 Bytes - hash of the block. null when its pending block. `"0xdc0818cf78f21a8e70579cb46a43643f78291264dda342ae31049421c82d21ae"` */ + hash: string; + /** 32 Bytes - hash of the parent block. `"0xe99e022112df268087ea7eafaf4790497fd21dbeeb6bd7a1721df161a6657a54"` */ + parentHash: string; + /** + * integer of the difficulty for this block. `"0x4ea3f27bc"`. + * Current mainnet value is 0x1f42c087a0d61e, 53 bits so requires a bigint. + */ + difficulty: string; + /** + * integer of the total difficulty of the chain until this block. `"0x78ed983323d"`. + * Current mainnet value is 0x684de10dc5c03f006b6, 75 bits so requires a bigint. + */ + totalDifficulty: string; + /** the unix timestamp for when the block was collated. `"0x55ba467c"` */ + timestamp: string; +}; + export interface IEth1Provider { deployBlock: number; getBlockNumber(): Promise; - getBlockByNumber(blockNumber: number): Promise; - getBlocksByNumber(fromBlock: number, toBlock: number): Promise; + /** Returns HTTP code 200 + value=null if block is not found */ + getBlockByNumber(blockNumber: number | "latest"): Promise; + /** Returns HTTP code 200 + value=null if block is not found */ + getBlockByHash(blockHashHex: string): Promise; + /** null returns are ignored, may return a different number of blocks than expected */ + getBlocksByNumber(fromBlock: number, toBlock: number): Promise; getDepositEvents(fromBlock: number, toBlock: number): Promise; validateContract(): Promise; } +export type Eth1DataAndDeposits = { + eth1Data: phase0.Eth1Data; + deposits: phase0.Deposit[]; +}; + export interface IEth1ForBlockProduction { - getEth1DataAndDeposits( - state: CachedBeaconState - ): Promise<{ - eth1Data: phase0.Eth1Data; - deposits: phase0.Deposit[]; - }>; + getEth1DataAndDeposits(state: CachedBeaconState): Promise; + + /** Returns the most recent POW block that satisfies the merge block condition */ + getMergeBlockHash(): Root | null; + /** Call when merge is irrevocably completed to stop polling unnecessary data from the eth1 node */ + mergeCompleted(): void; } export interface IBatchDepositEvents { diff --git a/packages/lodestar/src/eth1/options.ts b/packages/lodestar/src/eth1/options.ts index 982e3e29247..4cdd4aa1655 100644 --- a/packages/lodestar/src/eth1/options.ts +++ b/packages/lodestar/src/eth1/options.ts @@ -1,10 +1,10 @@ -export interface IEth1Options { +export type Eth1Options = { enabled: boolean; providerUrls: string[]; depositContractDeployBlock: number; -} +}; -export const defaultEth1Options: IEth1Options = { +export const defaultEth1Options: Eth1Options = { enabled: true, providerUrls: ["http://localhost:8545"], depositContractDeployBlock: 0, diff --git a/packages/lodestar/src/eth1/provider/eth1Provider.ts b/packages/lodestar/src/eth1/provider/eth1Provider.ts index 2d777ddbf6d..16f4a5f4d6a 100644 --- a/packages/lodestar/src/eth1/provider/eth1Provider.ts +++ b/packages/lodestar/src/eth1/provider/eth1Provider.ts @@ -8,20 +8,20 @@ import {retry} from "../../util/retry"; import {ErrorParseJson, JsonRpcHttpClient} from "./jsonRpcHttpClient"; import {depositEventTopics, parseDepositLog} from "../utils/depositContract"; import {IEth1Provider} from "../interface"; -import {IEth1Options} from "../options"; +import {Eth1Options} from "../options"; import {isValidAddress} from "../../util/address"; +import {EthJsonRpcBlockRaw} from "../interface"; /* eslint-disable @typescript-eslint/naming-convention */ +export const rootHexRegex = /^0x[a-fA-F0-9]{64}$/; + /** * Binds return types to Ethereum JSON RPC methods */ interface IEthJsonRpcTypes { - eth_getBlockByNumber: { - hash: string; // "0x7f0c419985f2227c546a9c640ee05abb3d279316426e6c79d69f6e317d6bb301"; - number: string; // "0x63"; - timestamp: string; // "0x5c5315bb"; - }; + eth_getBlockByNumber: EthJsonRpcBlockRaw | null; + eth_getBlockByHash: EthJsonRpcBlockRaw | null; eth_blockNumber: string; eth_getCode: string; eth_getLogs: { @@ -42,7 +42,7 @@ export class Eth1Provider implements IEth1Provider { private readonly depositContractAddress: string; private readonly rpc: JsonRpcHttpClient; - constructor(config: IChainForkConfig, opts: IEth1Options, signal: AbortSignal) { + constructor(config: IChainForkConfig, opts: Eth1Options, signal: AbortSignal) { this.deployBlock = opts.depositContractDeployBlock; this.depositContractAddress = toHexString(config.DEPOSIT_CONTRACT_ADDRESS); this.rpc = new JsonRpcHttpClient(opts.providerUrls, { @@ -96,9 +96,9 @@ export class Eth1Provider implements IEth1Provider { /** * Fetches an arbitrary array of block numbers in batch */ - async getBlocksByNumber(fromBlock: number, toBlock: number): Promise { + async getBlocksByNumber(fromBlock: number, toBlock: number): Promise { const method = "eth_getBlockByNumber"; - const blocksRawArr = await retry( + const blocksArr = await retry( (attempt) => { // Large batch requests can return with code 200 but truncated, with broken JSON // This retry will split a given block range into smaller ranges exponentially @@ -120,16 +120,30 @@ export class Eth1Provider implements IEth1Provider { } ); - return blocksRawArr.flat(1).map(parseBlock); + const blocks: EthJsonRpcBlockRaw[] = []; + for (const block of blocksArr.flat(1)) { + if (block) blocks.push(block); + } + return blocks; } - async getBlockByNumber(blockNumber: number): Promise { + async getBlockByNumber(blockNumber: number | "latest"): Promise { const method = "eth_getBlockByNumber"; - const blocksRaw = await this.rpc.fetch({ + const blockNumberHex = typeof blockNumber === "string" ? blockNumber : toHex(blockNumber); + return await this.rpc.fetch({ + method, + // false = include only transaction roots, not full objects + params: [blockNumberHex, false], + }); + } + + async getBlockByHash(blockHashHex: string): Promise { + const method = "eth_getBlockByHash"; + return await this.rpc.fetch({ method, - params: [toHex(blockNumber), false], + // false = include only transaction roots, not full objects + params: [blockHashHex, false], }); - return parseBlock(blocksRaw); } async getBlockNumber(): Promise { @@ -168,11 +182,13 @@ function toHex(n: number): string { return "0x" + n.toString(16); } -function parseBlock(blockRaw: IEthJsonRpcTypes["eth_getBlockByNumber"]): phase0.Eth1Block { +export function parseBlock(blockRaw: EthJsonRpcBlockRaw): phase0.Eth1Block { + if (typeof blockRaw !== "object") throw Error("block is not an object"); + validateHexRoot(blockRaw.hash); return { blockHash: fromHexString(blockRaw.hash), - blockNumber: parseInt(blockRaw.number, 16), - timestamp: parseInt(blockRaw.timestamp, 16), + blockNumber: hexToDecimal(blockRaw.number, "block.number"), + timestamp: hexToDecimal(blockRaw.timestamp, "block.timestamp"), }; } @@ -184,3 +200,23 @@ export function isJsonRpcTruncatedError(error: Error): boolean { (error instanceof Error && error.message.includes("query returned more than 10000 results")) ); } + +/** Safe parser of hex decimal positive integers */ +export function hexToDecimal(hex: string, id = ""): number { + const num = parseInt(hex, 16); + if (isNaN(num) || num < 0) throw Error(`Invalid hex decimal ${id} '${hex}'`); + return num; +} + +/** Typesafe fn to convert hex string to bigint. The BigInt constructor param is any */ +export function hexToBigint(hex: string, id = ""): bigint { + try { + return BigInt(hex); + } catch (e) { + throw Error(`Invalid hex bigint ${id} '${hex}': ${(e as Error).message}`); + } +} + +export function validateHexRoot(hex: string, id = ""): void { + if (!rootHexRegex.test(hex)) throw Error(`Invalid hex root ${id} '${hex}'`); +} diff --git a/packages/lodestar/src/eth1/stream.ts b/packages/lodestar/src/eth1/stream.ts index 46c08ed3fb1..4a6f524750c 100644 --- a/packages/lodestar/src/eth1/stream.ts +++ b/packages/lodestar/src/eth1/stream.ts @@ -8,6 +8,7 @@ import {groupDepositEventsByBlock} from "./utils/groupDepositEventsByBlock"; import {optimizeNextBlockDiffForGenesis} from "./utils/optimizeNextBlockDiffForGenesis"; import {sleep} from "@chainsafe/lodestar-utils"; import {phase0} from "@chainsafe/lodestar-types"; +import {parseBlock} from "./provider/eth1Provider"; /** * Phase 1 of genesis building. @@ -53,10 +54,14 @@ export async function* getDepositsAndBlockStreamForGenesis( let toBlock = fromBlock; // First, fetch only the first block while (true) { - const [logs, block] = await Promise.all([ + const [logs, blockRaw] = await Promise.all([ provider.getDepositEvents(fromBlock, toBlock), provider.getBlockByNumber(toBlock), ]); + + if (!blockRaw) throw Error(`No block found for number ${toBlock}`); + const block = parseBlock(blockRaw); + yield [logs, block]; const remoteFollowBlock = await getRemoteFollowBlock(provider, params); diff --git a/packages/lodestar/src/eth1/utils/optimizeNextBlockDiffForGenesis.ts b/packages/lodestar/src/eth1/utils/optimizeNextBlockDiffForGenesis.ts index 0bd663c3931..82128bb7aeb 100644 --- a/packages/lodestar/src/eth1/utils/optimizeNextBlockDiffForGenesis.ts +++ b/packages/lodestar/src/eth1/utils/optimizeNextBlockDiffForGenesis.ts @@ -1,3 +1,5 @@ +import {IChainConfig} from "@chainsafe/lodestar-config"; + /** * Utility for fetching genesis min genesis time block * Returns an approximation of the next block diff to fetch to progressively @@ -5,14 +7,7 @@ */ export function optimizeNextBlockDiffForGenesis( lastFetchedBlock: {timestamp: number}, - params: { - // eslint-disable-next-line @typescript-eslint/naming-convention - MIN_GENESIS_TIME: number; - // eslint-disable-next-line @typescript-eslint/naming-convention - GENESIS_DELAY: number; - // eslint-disable-next-line @typescript-eslint/naming-convention - SECONDS_PER_ETH1_BLOCK: number; - } + params: Pick ): number { const timeToGenesis = params.MIN_GENESIS_TIME - params.GENESIS_DELAY - lastFetchedBlock.timestamp; const numBlocksToGenesis = Math.floor(timeToGenesis / params.SECONDS_PER_ETH1_BLOCK); diff --git a/packages/lodestar/src/executionEngine/index.ts b/packages/lodestar/src/executionEngine/index.ts new file mode 100644 index 00000000000..a244f86679b --- /dev/null +++ b/packages/lodestar/src/executionEngine/index.ts @@ -0,0 +1,69 @@ +import {Bytes32, merge, Root} from "@chainsafe/lodestar-types"; + +/** + * Execution engine represents an abstract protocol to interact with execution clients. Potential transports include: + * - JSON RPC over network + * - IPC + * - Integrated code into the same binary + */ +export interface IExecutionEngine { + /** + * Returns ``True`` iff ``execution_payload`` is valid with respect to ``self.execution_state``. + * + * Required for block processing in the beacon state transition function. + * https://github.com/ethereum/consensus-specs/blob/0eb0a934a3/specs/merge/beacon-chain.md#on_payload + * + * Should be called in advance before, after or in parallel to block processing + */ + onPayload(executionPayload: merge.ExecutionPayload): Promise; + + /** + * Returns True if the ``block_hash`` was successfully set as head of the execution payload chain. + * + * Required as a side-effect of the fork-choice when setting a new head. + * https://github.com/ethereum/consensus-specs/blob/0eb0a934a3/specs/merge/fork-choice.md#set_head + * + * Can be called in response to a fork-choice head event + */ + setHead(blockHash: Root): Promise; + + /** + * Returns True if the data up to and including ``block_hash`` was successfully finalized. + * + * Required as a side-effect of the fork-choice when setting a new finalized checkpoint. + * https://github.com/ethereum/consensus-specs/blob/0eb0a934a3/specs/merge/fork-choice.md#finalize_block + * + * Can be called in response to a fork-choice finalized event + */ + finalizeBlock(blockHash: Root): Promise; + + /** + * Produces a new instance of an execution payload, with the specified timestamp, on top of the execution payload + * chain tip identified by block_hash. + * + * Required for block producing + * https://github.com/ethereum/consensus-specs/blob/0eb0a934a3/specs/merge/validator.md#assemble_block + * + * Must be called close to the slot associated with the validator's block producing to get the blockHash and + * random correct + */ + assembleBlock(blockHash: Root, timestamp: number, random: Bytes32): Promise; +} + +export class ExecutionEngineDisabled implements IExecutionEngine { + async onPayload(): Promise { + throw Error("Execution engine disabled"); + } + + async setHead(): Promise { + throw Error("Execution engine disabled"); + } + + async finalizeBlock(): Promise { + throw Error("Execution engine disabled"); + } + + async assembleBlock(): Promise { + throw Error("Execution engine disabled"); + } +} diff --git a/packages/lodestar/src/node/nodejs.ts b/packages/lodestar/src/node/nodejs.ts index 20e1b7cbac9..01b5ac255aa 100644 --- a/packages/lodestar/src/node/nodejs.ts +++ b/packages/lodestar/src/node/nodejs.ts @@ -18,9 +18,10 @@ import {BeaconChain, IBeaconChain, initBeaconMetrics, initializeTransitionStore} import {createMetrics, IMetrics, HttpMetricsServer} from "../metrics"; import {getApi, RestApi} from "../api"; import {IBeaconNodeOptions} from "./options"; -import {Eth1ForBlockProduction, Eth1ForBlockProductionDisabled, Eth1Provider} from "../eth1"; +import {initializeEth1ForBlockProduction} from "../eth1"; import {runNodeNotifier} from "./notifier"; import {Registry} from "prom-client"; +import {ExecutionEngineDisabled} from "../executionEngine"; export * from "./options"; @@ -125,13 +126,20 @@ export class BeaconNode { initBeaconMetrics(metrics, anchorState); } + const transitionStore = await initializeTransitionStore(opts.chain, db); const chain = new BeaconChain(opts.chain, { config, db, logger: logger.child(opts.logger.chain), metrics, anchorState, - transitionStore: await initializeTransitionStore(opts.chain, db), + transitionStore, + eth1: initializeEth1ForBlockProduction( + opts.eth1, + {config, db, logger: logger.child(opts.logger.eth1), signal, transitionStore}, + anchorState + ), + executionEngine: new ExecutionEngineDisabled(), }); // Load persisted data from disk to in-memory caches @@ -159,16 +167,6 @@ export class BeaconNode { config, logger: logger.child(opts.logger.api), db, - eth1: opts.eth1.enabled - ? new Eth1ForBlockProduction({ - config, - db, - eth1Provider: new Eth1Provider(config, opts.eth1, controller.signal), - logger: logger.child(opts.logger.eth1), - opts: opts.eth1, - signal, - }) - : new Eth1ForBlockProductionDisabled(), sync, network, chain, diff --git a/packages/lodestar/src/node/options.ts b/packages/lodestar/src/node/options.ts index 66c8aa6fcbe..d3aad628dac 100644 --- a/packages/lodestar/src/node/options.ts +++ b/packages/lodestar/src/node/options.ts @@ -5,7 +5,7 @@ import {defaultApiOptions, IApiOptions} from "../api/options"; import {defaultChainOptions, IChainOptions} from "../chain/options"; import {defaultDbOptions, IDatabaseOptions} from "../db/options"; -import {defaultEth1Options, IEth1Options} from "../eth1/options"; +import {defaultEth1Options, Eth1Options} from "../eth1/options"; import {defaultLoggerOptions, IBeaconLoggerOptions} from "./loggerOptions"; import {defaultMetricsOptions, IMetricsOptions} from "../metrics/options"; import {defaultNetworkOptions, INetworkOptions} from "../network/options"; @@ -17,7 +17,7 @@ export interface IBeaconNodeOptions { api: IApiOptions; chain: IChainOptions; db: IDatabaseOptions; - eth1: IEth1Options; + eth1: Eth1Options; logger: IBeaconLoggerOptions; metrics: IMetricsOptions; network: INetworkOptions; diff --git a/packages/lodestar/test/e2e/eth1/eth1ForBlockProduction.test.ts b/packages/lodestar/test/e2e/eth1/eth1ForBlockProduction.test.ts index 4097e09a05c..eb4d8f71f9e 100644 --- a/packages/lodestar/test/e2e/eth1/eth1ForBlockProduction.test.ts +++ b/packages/lodestar/test/e2e/eth1/eth1ForBlockProduction.test.ts @@ -7,8 +7,8 @@ import {AbortController} from "@chainsafe/abort-controller"; import {sleep} from "@chainsafe/lodestar-utils"; import {LevelDbController} from "@chainsafe/lodestar-db"; -import {Eth1ForBlockProduction, Eth1Provider} from "../../../src/eth1"; -import {IEth1Options} from "../../../src/eth1/options"; +import {Eth1ForBlockProduction} from "../../../src/eth1"; +import {Eth1Options} from "../../../src/eth1/options"; import {getTestnetConfig, testnet} from "../../utils/testnet"; import {testLogger} from "../../utils/logger"; import {BeaconDb} from "../../../src/db"; @@ -16,6 +16,8 @@ import {generateState} from "../../utils/state"; import {fromHexString, List, toHexString} from "@chainsafe/ssz"; import {Root, ssz} from "@chainsafe/lodestar-types"; import {createCachedBeaconState} from "@chainsafe/lodestar-beacon-state-transition"; +import {Eth1Provider} from "../../../src/eth1/provider/eth1Provider"; +import {TransitionStore} from "../../../src/chain"; const dbLocation = "./.__testdb"; @@ -30,7 +32,7 @@ const pyrmontDepositsDataRoot = [ describe("eth1 / Eth1Provider", function () { this.timeout("2 min"); - const eth1Options: IEth1Options = { + const eth1Options: Eth1Options = { enabled: true, providerUrls: [testnet.providerUrl], depositContractDeployBlock: testnet.depositBlock, @@ -66,13 +68,15 @@ describe("eth1 / Eth1Provider", function () { }); it("Should fetch real Pyrmont eth1 data for block proposing", async function () { - const eth1ForBlockProduction = new Eth1ForBlockProduction({ + const eth1ForBlockProduction = new Eth1ForBlockProduction(eth1Options, { config, db, - eth1Provider, logger, - opts: eth1Options, signal: controller.signal, + eth1Provider, + clockEpoch: 0, + isMergeComplete: false, + transitionStore: new TransitionStore(null), }); // Resolves when Eth1ForBlockProduction has fetched both blocks and deposits diff --git a/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts b/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts index 8805d133636..8dd20641f64 100644 --- a/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts +++ b/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts @@ -1,17 +1,17 @@ import "mocha"; import {expect} from "chai"; import {AbortController} from "@chainsafe/abort-controller"; -import {Eth1Provider} from "../../../src/eth1"; -import {IEth1Options} from "../../../src/eth1/options"; +import {Eth1Options} from "../../../src/eth1/options"; import {getTestnetConfig, testnet} from "../../utils/testnet"; import {fromHexString} from "@chainsafe/ssz"; import {phase0} from "@chainsafe/lodestar-types"; import {goerliTestnetDepositEvents} from "../../utils/testnet"; +import {Eth1Provider} from "../../../src/eth1/provider/eth1Provider"; describe("eth1 / Eth1Provider", function () { this.timeout("2 min"); - const eth1Options: IEth1Options = { + const eth1Options: Eth1Options = { enabled: true, providerUrls: [testnet.providerUrl], depositContractDeployBlock: 0, diff --git a/packages/lodestar/test/e2e/eth1/stream.test.ts b/packages/lodestar/test/e2e/eth1/stream.test.ts index 23ed560ec58..26c91439156 100644 --- a/packages/lodestar/test/e2e/eth1/stream.test.ts +++ b/packages/lodestar/test/e2e/eth1/stream.test.ts @@ -2,7 +2,8 @@ import "mocha"; import {expect} from "chai"; import {AbortController} from "@chainsafe/abort-controller"; import {getTestnetConfig, testnet} from "../../utils/testnet"; -import {getDepositsStream, getDepositsAndBlockStreamForGenesis, Eth1Provider} from "../../../src/eth1"; +import {getDepositsStream, getDepositsAndBlockStreamForGenesis} from "../../../src/eth1/stream"; +import {Eth1Provider} from "../../../src/eth1/provider/eth1Provider"; describe("Eth1 streams", function () { this.timeout("2 min"); diff --git a/packages/lodestar/test/e2e/network/gossipsub.test.ts b/packages/lodestar/test/e2e/network/gossipsub.test.ts index 1447270ce93..02c0fcca2c8 100644 --- a/packages/lodestar/test/e2e/network/gossipsub.test.ts +++ b/packages/lodestar/test/e2e/network/gossipsub.test.ts @@ -55,7 +55,7 @@ describe("network", function () { }); const beaconConfig = createIBeaconConfig(config, state.genesisValidatorsRoot); - const chain = new MockBeaconChain({genesisTime: 0, chainId: 0, networkId: BigInt(0), state, config}); + const chain = new MockBeaconChain({genesisTime: 0, chainId: 0, networkId: BigInt(0), state, config: beaconConfig}); const db = new StubbedBeaconDb(sinon, config); const reqRespHandlers = getReqRespHandlers({db, chain}); const gossipHandlers = gossipHandlersPartial as GossipHandlers; diff --git a/packages/lodestar/test/unit/api/impl/validator/duties/proposer.test.ts b/packages/lodestar/test/unit/api/impl/validator/duties/proposer.test.ts index 8873821166e..1b610967bb3 100644 --- a/packages/lodestar/test/unit/api/impl/validator/duties/proposer.test.ts +++ b/packages/lodestar/test/unit/api/impl/validator/duties/proposer.test.ts @@ -8,7 +8,6 @@ import {ForkChoice} from "@chainsafe/lodestar-fork-choice"; import {IBeaconChain} from "../../../../../../src/chain"; import {LocalClock} from "../../../../../../src/chain/clock"; import {FAR_FUTURE_EPOCH} from "../../../../../../src/constants"; -import {IEth1ForBlockProduction} from "../../../../../../src/eth1"; import {getValidatorApi} from "../../../../../../src/api/impl/validator"; import {ApiModules} from "../../../../../../src/api/impl/types"; import {generateInitialMaxBalances} from "../../../../../utils/balances"; @@ -25,7 +24,6 @@ use(chaiAsPromised); describe("get proposers api impl", function () { const logger = testLogger(); - let eth1Stub: SinonStubbedInstance; let chainStub: SinonStubbedInstance, syncStub: SinonStubbedInstance, @@ -47,7 +45,6 @@ describe("get proposers api impl", function () { chain: server.chainStub, config, db: server.dbStub, - eth1: eth1Stub, logger, network: server.networkStub, sync: syncStub, diff --git a/packages/lodestar/test/unit/api/impl/validator/produceAttestationData.test.ts b/packages/lodestar/test/unit/api/impl/validator/produceAttestationData.test.ts index 74b0dfbfa12..e6e2baf3188 100644 --- a/packages/lodestar/test/unit/api/impl/validator/produceAttestationData.test.ts +++ b/packages/lodestar/test/unit/api/impl/validator/produceAttestationData.test.ts @@ -4,7 +4,6 @@ import sinon, {SinonStubbedInstance} from "sinon"; import {IBeaconSync, SyncState} from "../../../../../src/sync/interface"; import {ApiModules} from "../../../../../src/api/impl/types"; import {getValidatorApi} from "../../../../../src/api/impl/validator"; -import {IEth1ForBlockProduction} from "../../../../../src/eth1"; import {LocalClock} from "../../../../../src/chain/clock"; import {testLogger} from "../../../../utils/logger"; import chaiAsPromised from "chai-as-promised"; @@ -15,7 +14,6 @@ use(chaiAsPromised); describe("api - validator - produceAttestationData", function () { const logger = testLogger(); - let eth1Stub: SinonStubbedInstance; let syncStub: SinonStubbedInstance; let modules: ApiModules; let server: ApiImplTestModules; @@ -27,7 +25,6 @@ describe("api - validator - produceAttestationData", function () { chain: server.chainStub, config, db: server.dbStub, - eth1: eth1Stub, logger, network: server.networkStub, sync: syncStub, diff --git a/packages/lodestar/test/unit/chain/factory/block/blockAssembly.test.ts b/packages/lodestar/test/unit/chain/factory/block/blockAssembly.test.ts index 58100def85a..3baa04edde8 100644 --- a/packages/lodestar/test/unit/chain/factory/block/blockAssembly.test.ts +++ b/packages/lodestar/test/unit/chain/factory/block/blockAssembly.test.ts @@ -12,7 +12,7 @@ import {LocalClock} from "../../../../../src/chain/clock"; import {assembleBlock} from "../../../../../src/chain/factory/block"; import * as blockBodyAssembly from "../../../../../src/chain/factory/block/body"; import {StateRegenerator} from "../../../../../src/chain/regen"; -import {Eth1ForBlockProduction} from "../../../../../src/eth1/"; +import {Eth1ForBlockProduction} from "../../../../../src/eth1"; import {generateProtoBlock, generateEmptyBlock} from "../../../../utils/block"; import {generateCachedState} from "../../../../utils/state"; import {StubbedBeaconDb, StubbedChain} from "../../../../utils/stub"; @@ -55,8 +55,10 @@ describe("block assembly", function () { const eth1 = sandbox.createStubInstance(Eth1ForBlockProduction); eth1.getEth1DataAndDeposits.resolves({eth1Data: state.eth1Data, deposits: []}); + ((chainStub as unknown) as {eth1: typeof eth1}).eth1 = eth1; + ((chainStub as unknown) as {config: typeof config}).config = config; - const result = await assembleBlock({config, chain: chainStub, eth1, metrics: null}, 1, Buffer.alloc(96, 0)); + const result = await assembleBlock({chain: chainStub, metrics: null}, 1, Buffer.alloc(96, 0)); expect(result).to.not.be.null; expect(result.slot).to.equal(1); expect(result.proposerIndex).to.equal(2); diff --git a/packages/lodestar/test/unit/chain/factory/block/body.test.ts b/packages/lodestar/test/unit/chain/factory/block/body.test.ts index 9cf8cf16816..9bc7d45de60 100644 --- a/packages/lodestar/test/unit/chain/factory/block/body.test.ts +++ b/packages/lodestar/test/unit/chain/factory/block/body.test.ts @@ -8,7 +8,7 @@ import {generateEmptyAttestation} from "../../../../utils/attestation"; import {generateEmptySignedVoluntaryExit} from "../../../../utils/voluntaryExits"; import {generateDeposit} from "../../../../utils/deposit"; import {StubbedBeaconDb} from "../../../../utils/stub"; -import {Eth1ForBlockProduction} from "../../../../../src/eth1/"; +import {Eth1ForBlockProduction} from "../../../../../src/eth1"; import {BeaconChain} from "../../../../../src/chain"; import {AggregatedAttestationPool, OpPool} from "../../../../../src/chain/opPools"; @@ -28,11 +28,13 @@ describe("blockAssembly - body", function () { ((chain as unknown) as { opPool: SinonStubbedInstance; }).opPool = opPool; + (chain as {config: typeof config}).config = config; + ((chain as unknown) as {eth1: typeof eth1}).eth1 = eth1; return {chain, aggregatedAttestationPool, dbStub: new StubbedBeaconDb(sandbox), eth1, opPool}; } it("should generate block body", async function () { - const {chain, opPool, dbStub, eth1, aggregatedAttestationPool} = getStubs(); + const {chain, opPool, dbStub, aggregatedAttestationPool} = getStubs(); opPool.getSlashings.returns([ [ssz.phase0.AttesterSlashing.defaultValue()], [ssz.phase0.ProposerSlashing.defaultValue()], @@ -41,14 +43,10 @@ describe("blockAssembly - body", function () { opPool.getVoluntaryExits.returns([generateEmptySignedVoluntaryExit()]); dbStub.depositDataRoot.getTreeBacked.resolves(ssz.phase0.DepositDataRootList.defaultTreeBacked()); - const result = await assembleBody( - {chain, config, eth1}, - generateCachedState(), - Buffer.alloc(96, 0), - Buffer.alloc(32, 0), - 1, - {parentSlot: 0, parentBlockRoot: Buffer.alloc(32, 0)} - ); + const result = await assembleBody(chain, generateCachedState(), Buffer.alloc(96, 0), Buffer.alloc(32, 0), 1, { + parentSlot: 0, + parentBlockRoot: Buffer.alloc(32, 0), + }); expect(result).to.not.be.null; expect(result.randaoReveal.length).to.be.equal(96); expect(result.attestations.length).to.be.equal(1); diff --git a/packages/lodestar/test/unit/chain/forkChoice/forkChoice.test.ts b/packages/lodestar/test/unit/chain/forkChoice/forkChoice.test.ts index 8891a466b21..baf38d15544 100644 --- a/packages/lodestar/test/unit/chain/forkChoice/forkChoice.test.ts +++ b/packages/lodestar/test/unit/chain/forkChoice/forkChoice.test.ts @@ -1,4 +1,4 @@ -import {ChainEventEmitter, computeAnchorCheckpoint, initializeForkChoice} from "../../../../src/chain"; +import {ChainEventEmitter, computeAnchorCheckpoint, initializeForkChoice, TransitionStore} from "../../../../src/chain"; import {generateState} from "../../../utils/state"; import {FAR_FUTURE_EPOCH, MAX_EFFECTIVE_BALANCE} from "@chainsafe/lodestar-params"; import {config} from "@chainsafe/lodestar-config/default"; @@ -46,7 +46,7 @@ describe("LodestarForkChoice", function () { beforeEach(() => { const emitter = new ChainEventEmitter(); const currentSlot = 40; - const transitionStore = null; + const transitionStore = new TransitionStore(null); forkChoice = initializeForkChoice(config, transitionStore, emitter, currentSlot, state); }); diff --git a/packages/lodestar/test/unit/chain/genesis/genesis.test.ts b/packages/lodestar/test/unit/chain/genesis/genesis.test.ts index b464c48c686..231efc48e5c 100644 --- a/packages/lodestar/test/unit/chain/genesis/genesis.test.ts +++ b/packages/lodestar/test/unit/chain/genesis/genesis.test.ts @@ -14,9 +14,10 @@ import {ValidatorIndex, phase0, ssz} from "@chainsafe/lodestar-types"; import {ErrorAborted} from "@chainsafe/lodestar-utils"; import {toHexString} from "@chainsafe/ssz"; import {AbortController} from "@chainsafe/abort-controller"; -import {IEth1Provider} from "../../../../src/eth1"; import {GenesisBuilder} from "../../../../src/chain/genesis/genesis"; import {testLogger} from "../../../utils/logger"; +import {ZERO_HASH_HEX} from "../../../../src/constants"; +import {EthJsonRpcBlockRaw, IEth1Provider} from "../../../../src/eth1/interface"; chai.use(chaiAsPromised); @@ -28,12 +29,11 @@ describe("genesis builder", function () { MIN_GENESIS_DELAY: 3600, }); - function generateGenesisBuilderMockData(): { - events: phase0.DepositEvent[]; - blocks: phase0.Eth1Block[]; - } { + type MockData = {events: phase0.DepositEvent[]; blocks: EthJsonRpcBlockRaw[]}; + + function generateGenesisBuilderMockData(): MockData { const events: phase0.DepositEvent[] = []; - const blocks: phase0.Eth1Block[] = []; + const blocks: EthJsonRpcBlockRaw[] = []; for (let i = 0; i < schlesiConfig.MIN_GENESIS_ACTIVE_VALIDATOR_COUNT; i++) { const secretKey = interopSecretKey(i); @@ -46,30 +46,39 @@ describe("genesis builder", function () { events.push(event); // All blocks satisfy MIN_GENESIS_TIME, so genesis will happen when the min validator count is reached blocks.push({ - blockNumber: i, - blockHash: Buffer.alloc(32, 0), - timestamp: schlesiConfig.MIN_GENESIS_TIME + i, + number: i.toString(16), + hash: ZERO_HASH_HEX, + timestamp: schlesiConfig.MIN_GENESIS_TIME + i.toString(16), + // Extra un-used data for this test + parentHash: "0x0", + difficulty: "0x0", + totalDifficulty: "0x0", }); } return {events, blocks}; } - it("should build genesis state", async () => { - const {blocks, events} = generateGenesisBuilderMockData(); - - const eth1Provider: IEth1Provider = { + function getMockEth1Provider({events, blocks}: MockData, eth1Provider?: Partial): IEth1Provider { + return { deployBlock: events[0].blockNumber, getBlockNumber: async () => 2000, - getBlockByNumber: async (number) => blocks[number], + getBlockByNumber: async (number) => blocks[number as number], getBlocksByNumber: async (fromBlock, toBlock) => - blocks.filter((b) => b.blockNumber >= fromBlock && b.blockNumber <= toBlock), + blocks.filter((b) => parseInt(b.number) >= fromBlock && parseInt(b.number) <= toBlock), + getBlockByHash: async () => null, getDepositEvents: async (fromBlock, toBlock) => events.filter((e) => e.blockNumber >= fromBlock && e.blockNumber <= toBlock), validateContract: async () => { return; }, + ...eth1Provider, }; + } + + it("should build genesis state", async () => { + const mockData = generateGenesisBuilderMockData(); + const eth1Provider = getMockEth1Provider(mockData); const genesisBuilder = new GenesisBuilder({ config: schlesiConfig, @@ -82,28 +91,19 @@ describe("genesis builder", function () { expect(state.validators.length).to.be.equal(schlesiConfig.MIN_GENESIS_ACTIVE_VALIDATOR_COUNT); expect(toHexString(state.eth1Data.blockHash)).to.be.equal( - toHexString(blocks[schlesiConfig.MIN_GENESIS_ACTIVE_VALIDATOR_COUNT - 1].blockHash) + mockData.blocks[schlesiConfig.MIN_GENESIS_ACTIVE_VALIDATOR_COUNT - 1].hash ); }); it("should abort building genesis state", async () => { - const {blocks, events} = generateGenesisBuilderMockData(); + const mockData = generateGenesisBuilderMockData(); const controller = new AbortController(); - - const eth1Provider: IEth1Provider = { - deployBlock: events[0].blockNumber, - getBlockNumber: async () => 2000, - getBlockByNumber: async (number) => blocks[number], - getBlocksByNumber: async (fromBlock, toBlock) => - blocks.filter((b) => b.blockNumber >= fromBlock && b.blockNumber <= toBlock), + const eth1Provider = getMockEth1Provider(mockData, { getDepositEvents: async (fromBlock, toBlock) => { controller.abort(); - return events.filter((e) => e.blockNumber >= fromBlock && e.blockNumber <= toBlock); - }, - validateContract: async () => { - return; + return mockData.events.filter((e) => e.blockNumber >= fromBlock && e.blockNumber <= toBlock); }, - }; + }); const genesisBuilder = new GenesisBuilder({ config: schlesiConfig, diff --git a/packages/lodestar/test/unit/network/attestationService.test.ts b/packages/lodestar/test/unit/network/attestationService.test.ts index 61b5649607e..77f7928a888 100644 --- a/packages/lodestar/test/unit/network/attestationService.test.ts +++ b/packages/lodestar/test/unit/network/attestationService.test.ts @@ -5,7 +5,7 @@ import { ForkName, SLOTS_PER_EPOCH, } from "@chainsafe/lodestar-params"; -import {createIChainForkConfig} from "@chainsafe/lodestar-config"; +import {createIBeaconConfig} from "@chainsafe/lodestar-config"; import {getCurrentSlot} from "@chainsafe/lodestar-beacon-state-transition"; // eslint-disable-next-line no-restricted-imports import * as mathUtils from "@chainsafe/lodestar-utils/lib/math"; @@ -21,12 +21,13 @@ import {MetadataController} from "../../../src/network/metadata"; import {Eth2Gossipsub, GossipType} from "../../../src/network/gossip"; import {AttnetsService, CommitteeSubscription} from "../../../src/network/subnets"; import {ChainEvent, IBeaconChain} from "../../../src/chain"; +import {ZERO_HASH} from "../../../src/constants"; describe("AttnetsService", function () { const COMMITTEE_SUBNET_SUBSCRIPTION = 10; const ALTAIR_FORK_EPOCH = 1 * EPOCHS_PER_RANDOM_SUBNET_SUBSCRIPTION; // eslint-disable-next-line @typescript-eslint/naming-convention - const config = createIChainForkConfig({ALTAIR_FORK_EPOCH}); + const config = createIBeaconConfig({ALTAIR_FORK_EPOCH}, ZERO_HASH); const {SECONDS_PER_SLOT} = config; let service: AttnetsService; diff --git a/packages/lodestar/test/utils/mocks/chain/chain.ts b/packages/lodestar/test/utils/mocks/chain/chain.ts index 2e4783a5f02..a1beeeff830 100644 --- a/packages/lodestar/test/utils/mocks/chain/chain.ts +++ b/packages/lodestar/test/utils/mocks/chain/chain.ts @@ -3,7 +3,7 @@ import sinon from "sinon"; import {toHexString, TreeBacked} from "@chainsafe/ssz"; import {allForks, ForkDigest, Number64, Root, Slot, ssz, Uint16, Uint64} from "@chainsafe/lodestar-types"; -import {IChainForkConfig} from "@chainsafe/lodestar-config"; +import {IBeaconConfig} from "@chainsafe/lodestar-config"; import {CachedBeaconState, createCachedBeaconState} from "@chainsafe/lodestar-beacon-state-transition"; import {phase0} from "@chainsafe/lodestar-beacon-state-transition"; import {CheckpointWithHex, ForkChoice, IForkChoice, IProtoBlock} from "@chainsafe/lodestar-fork-choice"; @@ -36,6 +36,8 @@ import { OpPool, } from "../../../../src/chain/opPools"; import {LightClientIniter} from "../../../../src/chain/lightClient"; +import {Eth1ForBlockProductionDisabled} from "../../../../src/eth1"; +import {ExecutionEngineDisabled} from "../../../../src/executionEngine"; /* eslint-disable @typescript-eslint/no-empty-function */ @@ -44,12 +46,16 @@ export interface IMockChainParams { chainId: Uint16; networkId: Uint64; state: TreeBacked; - config: IChainForkConfig; + config: IBeaconConfig; } export class MockBeaconChain implements IBeaconChain { readonly genesisTime: Number64; readonly genesisValidatorsRoot: Root; + readonly eth1 = new Eth1ForBlockProductionDisabled(); + readonly executionEngine = new ExecutionEngineDisabled(); + readonly config: IBeaconConfig; + readonly bls: IBlsVerifier; forkChoice: IForkChoice; stateCache: StateContextCache; @@ -79,7 +85,6 @@ export class MockBeaconChain implements IBeaconChain { readonly seenContributionAndProof = new SeenContributionAndProof(); private state: TreeBacked; - private config: IChainForkConfig; private abortController: AbortController; constructor({genesisTime, chainId, networkId, state, config}: IMockChainParams) { @@ -204,7 +209,6 @@ function mockForkChoice(): IForkChoice { const checkpoint: CheckpointWithHex = {epoch: 0, root, rootHex}; return { - initializeTransitionStore: () => {}, getAncestor: () => rootHex, getHeadRoot: () => rootHex, getHead: () => block, diff --git a/packages/spec-test-runner/test/spec/allForks/forkChoice.ts b/packages/spec-test-runner/test/spec/allForks/forkChoice.ts index c2de6231b17..b45f1148679 100644 --- a/packages/spec-test-runner/test/spec/allForks/forkChoice.ts +++ b/packages/spec-test-runner/test/spec/allForks/forkChoice.ts @@ -20,6 +20,8 @@ import { } from "@chainsafe/lodestar/lib/chain/stateCache/stateContextCheckpointsCache"; // eslint-disable-next-line no-restricted-imports import {ChainEventEmitter} from "@chainsafe/lodestar/lib/chain/emitter"; +// eslint-disable-next-line no-restricted-imports +import {TransitionStore} from "@chainsafe/lodestar/lib/chain"; import {toHexString} from "@chainsafe/ssz"; import {IForkChoice} from "@chainsafe/lodestar-fork-choice"; import {ssz} from "@chainsafe/lodestar-types"; @@ -46,7 +48,7 @@ export function forkChoiceTest(fork: ForkName): void { let state = createCachedBeaconState(config, tbState); const emitter = new ChainEventEmitter(); - const transitionStore = null; + const transitionStore = new TransitionStore(null); const forkchoice = initializeForkChoice(config, transitionStore, emitter, currentSlot, state); const checkpointStateCache = new CheckpointStateCache({}); From ff04b574989b86683809254c2c4f244427f1b5e9 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Sat, 18 Sep 2021 16:41:25 +0200 Subject: [PATCH 2/4] Add Eth1MergeBlockTracker unit test --- .../src/eth1/eth1MergeBlockTracker.ts | 74 +++++--- .../unit/eth1/eth1MergeBlockTracker.test.ts | 175 ++++++++++++++++++ 2 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 packages/lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts diff --git a/packages/lodestar/src/eth1/eth1MergeBlockTracker.ts b/packages/lodestar/src/eth1/eth1MergeBlockTracker.ts index bc2442da7e9..35127e7bc1d 100644 --- a/packages/lodestar/src/eth1/eth1MergeBlockTracker.ts +++ b/packages/lodestar/src/eth1/eth1MergeBlockTracker.ts @@ -4,7 +4,7 @@ import {ITransitionStore} from "@chainsafe/lodestar-fork-choice"; import {Epoch} from "@chainsafe/lodestar-types"; import {IEth1Provider, EthJsonRpcBlockRaw} from "./interface"; import {hexToBigint, hexToDecimal, validateHexRoot} from "./provider/eth1Provider"; -import {ILogger} from "@chainsafe/lodestar-utils"; +import {ILogger, sleep} from "@chainsafe/lodestar-utils"; import {SLOTS_PER_EPOCH} from "@chainsafe/lodestar-params"; type RootHexPow = string; @@ -15,21 +15,28 @@ type PowMergeBlock = { totalDifficulty: bigint; }; -enum StatusCode { +export enum StatusCode { PRE_MERGE = "PRE_MERGE", SEARCHING = "SEARCHING", + FOUND = "FOUND", POST_MERGE = "POST_MERGE", } -/** - * Numbers of epochs in advance of merge fork condition to start looking for merge block - */ +/** Numbers of epochs in advance of merge fork condition to start looking for merge block */ const START_EPOCHS_IN_ADVANCE = 5; +/** + * Bounds `blocksByHashCache` cache, imposing a max distance between highest and lowest block numbers. + * In case of extreme forking the cache might grow unbounded. + */ const MAX_CACHE_POW_HEIGHT_DISTANCE = 1024; +/** Number of blocks to request at once in a getBlocksByNumber() request */ const MAX_BLOCKS_PER_PAST_REQUEST = 1000; +/** Prevent infinite loops on error by sleeping after each error */ +const SLEEP_ON_ERROR_MS = 3000; + export type Eth1MergeBlockTrackerModules = { transitionStore: ITransitionStore; config: IChainConfig; @@ -46,13 +53,14 @@ export class Eth1MergeBlockTracker { private readonly transitionStore: ITransitionStore; private readonly config: IChainConfig; private readonly logger: ILogger; + private readonly signal: AbortSignal; /** - * List of blocks that meet the merge block conditions and are safe for block inclusion. - * TODO: In the edge case there are multiple, what to do? + * First found mergeBlock. + * TODO: Accept multiple, but then handle long backwards searches properly after crossing TTD */ - private readonly mergeBlocks: PowMergeBlock[] = []; - private readonly blockCache = new Map(); + private mergeBlock: PowMergeBlock | null = null; + private readonly blocksByHashCache = new Map(); private status: StatusCode = StatusCode.PRE_MERGE; private readonly intervals: NodeJS.Timeout[] = []; @@ -64,6 +72,7 @@ export class Eth1MergeBlockTracker { this.transitionStore = transitionStore; this.config = config; this.logger = logger; + this.signal = signal; // If merge has already happened, disable if (isMergeComplete) { @@ -95,14 +104,12 @@ export class Eth1MergeBlockTracker { * Returns the most recent POW block that satisfies the merge block condition */ getMergeBlock(): PowMergeBlock | null { - const mergeBlock = this.mergeBlocks[this.mergeBlocks.length - 1] ?? null; - // For better debugging in case this module stops searching too early - if (mergeBlock === null && this.status === StatusCode.POST_MERGE) { + if (this.mergeBlock === null && this.status === StatusCode.POST_MERGE) { throw Error("Eth1MergeBlockFinder is on POST_MERGE status and found no mergeBlock"); } - return mergeBlock; + return this.mergeBlock; } /** @@ -118,14 +125,14 @@ export class Eth1MergeBlockTracker { */ async getPowBlock(powBlockHash: string): Promise { // Check cache first - const cachedBlock = this.blockCache.get(powBlockHash); + const cachedBlock = this.blocksByHashCache.get(powBlockHash); if (cachedBlock) return cachedBlock; // Fetch from node const blockRaw = await this.eth1Provider.getBlockByHash(powBlockHash); if (blockRaw) { const block = toPowBlock(blockRaw); - this.blockCache.set(block.blockhash, block); + this.blocksByHashCache.set(block.blockhash, block); return block; } @@ -136,6 +143,12 @@ export class Eth1MergeBlockTracker { this.intervals.forEach(clearInterval); } + private setMergeBlock(mergeBlock: PowMergeBlock): void { + this.mergeBlock = mergeBlock; + this.status = StatusCode.FOUND; + this.close(); + } + private startFinding(): void { if (this.status !== StatusCode.PRE_MERGE) return; this.status = StatusCode.SEARCHING; @@ -182,7 +195,7 @@ export class Eth1MergeBlockTracker { let minFetchedBlockNumber = latestBlock.number; // eslint-disable-next-line no-constant-condition while (true) { - const from = minFetchedBlockNumber - MAX_BLOCKS_PER_PAST_REQUEST; + const from = Math.max(0, minFetchedBlockNumber - MAX_BLOCKS_PER_PAST_REQUEST); // Re-fetch same block to have the full chain of parent-child nodes const to = minFetchedBlockNumber; @@ -205,14 +218,12 @@ export class Eth1MergeBlockTracker { // Is terminal total difficulty block if (childBlock.parentHash === parentBlock.blockhash) { // AND has verified block -> parent relationship - this.mergeBlocks.push(childBlock); + return this.setMergeBlock(childBlock); } else { // WARNING! Re-org while doing getBlocksByNumber() call. Ensure that this block is the merge block // and not some of its parents. - await this.fetchPotentialMergeBlock(childBlock); + return await this.fetchPotentialMergeBlock(childBlock); } - - return; } } @@ -225,6 +236,7 @@ export class Eth1MergeBlockTracker { } } catch (e) { this.logger.error("Error on fetchPreviousBlocks range", {from, to}, e as Error); + await sleep(SLEEP_ON_ERROR_MS, this.signal); } } } @@ -250,10 +262,12 @@ export class Eth1MergeBlockTracker { */ private async fetchPotentialMergeBlock(block: PowMergeBlock): Promise { // Persist block for future searches - this.blockCache.set(block.blockhash, block); + this.blocksByHashCache.set(block.blockhash, block); + + // Check if this block is already visited while (block.totalDifficulty >= this.transitionStore.terminalTotalDifficulty) { - const parent = await this.getPowBlock(block.blockhash); + const parent = await this.getPowBlock(block.parentHash); // Unknown parent if (!parent) { return; @@ -264,8 +278,12 @@ export class Eth1MergeBlockTracker { parent.totalDifficulty < this.transitionStore.terminalTotalDifficulty ) { // Is terminal total difficulty block AND has verified block -> parent relationship - this.mergeBlocks.push(block); - return; + return this.setMergeBlock(block); + } + + // Guard against infinite loops + if (parent.blockhash === block.blockhash) { + throw Error("Infinite loop: parent.blockhash === block.blockhash"); } // Fetch parent's parent @@ -282,7 +300,7 @@ export class Eth1MergeBlockTracker { private prune(): void { // Find the heightest block number in the cache let maxBlockNumber = 0; - for (const block of this.blockCache.values()) { + for (const block of this.blocksByHashCache.values()) { if (block.number > maxBlockNumber) { maxBlockNumber = block.number; } @@ -290,15 +308,15 @@ export class Eth1MergeBlockTracker { // Prune blocks below the max distance const minHeight = maxBlockNumber - MAX_CACHE_POW_HEIGHT_DISTANCE; - for (const [key, block] of this.blockCache.entries()) { + for (const [key, block] of this.blocksByHashCache.entries()) { if (block.number < minHeight) { - this.blockCache.delete(key); + this.blocksByHashCache.delete(key); } } } } -function toPowBlock(block: EthJsonRpcBlockRaw): PowMergeBlock { +export function toPowBlock(block: EthJsonRpcBlockRaw): PowMergeBlock { // Validate untrusted data from API validateHexRoot(block.hash); validateHexRoot(block.parentHash); diff --git a/packages/lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts b/packages/lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts new file mode 100644 index 00000000000..a98cfc96a37 --- /dev/null +++ b/packages/lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts @@ -0,0 +1,175 @@ +import {AbortController} from "@chainsafe/abort-controller"; +import {IChainConfig} from "@chainsafe/lodestar-config"; +import {sleep} from "@chainsafe/lodestar-utils"; +import {expect} from "chai"; +import {IEth1Provider} from "../../../src"; +import {Eth1MergeBlockTracker, StatusCode, toPowBlock} from "../../../src/eth1/eth1MergeBlockTracker"; +import {EthJsonRpcBlockRaw} from "../../../src/eth1/interface"; +import {testLogger} from "../../utils/logger"; + +/* eslint-disable @typescript-eslint/naming-convention */ + +describe("eth1 / Eth1MergeBlockTracker", () => { + const logger = testLogger(); + const notImplemented = async (): Promise => { + throw Error("Not implemented"); + }; + + // Set time units to 0 to make the test as fast as possible + const config = ({ + SECONDS_PER_ETH1_BLOCK: 0, + SECONDS_PER_SLOT: 0, + } as Partial) as IChainConfig; + + let controller: AbortController; + beforeEach(() => (controller = new AbortController())); + afterEach(() => controller.abort()); + + it("Should find merge block polling future 'latest' blocks", async () => { + const terminalTotalDifficulty = 1000; + // Set current network totalDifficulty to behind terminalTotalDifficulty by 5. + // Then on each call to getBlockByNumber("latest") increase totalDifficulty by 1. + const difficultyOffset = 5; + const totalDifficulty = terminalTotalDifficulty - difficultyOffset; + + let latestBlockPointer = 0; + const blocks: EthJsonRpcBlockRaw[] = []; + const blocksByHash = new Map(); + + function getLatestBlock(i: number): EthJsonRpcBlockRaw { + const block: EthJsonRpcBlockRaw = { + number: toHex(i), + hash: toRootHex(i + 1), + parentHash: toRootHex(i), + difficulty: "0x0", + // Latest block is under TTD, so past block search is stopped + totalDifficulty: toHex(totalDifficulty + i), + timestamp: "0x0", + }; + blocks.push(block); + blocksByHash.set(block.hash, block); + return block; + } + + const eth1Provider: IEth1Provider = { + deployBlock: 0, + getBlockNumber: async () => 0, + getBlockByNumber: async (blockNumber) => { + // On each call simulate that the eth1 chain advances 1 block with +1 totalDifficulty + if (blockNumber === "latest") return getLatestBlock(latestBlockPointer++); + return blocks[blockNumber]; + }, + getBlockByHash: async (blockHashHex) => blocksByHash.get(blockHashHex) ?? null, + getBlocksByNumber: notImplemented, + getDepositEvents: notImplemented, + validateContract: notImplemented, + }; + + const eth1MergeBlockTracker = new Eth1MergeBlockTracker( + { + transitionStore: {initialized: true, terminalTotalDifficulty: BigInt(terminalTotalDifficulty)}, + config, + logger, + signal: controller.signal, + clockEpoch: 0, + isMergeComplete: false, + }, + eth1Provider as IEth1Provider + ); + + // Wait for Eth1MergeBlockTracker to find at least one merge block + while (!controller.signal.aborted) { + if (eth1MergeBlockTracker.getMergeBlock()) break; + await sleep(10, controller.signal); + } + + // Status should acknowlege merge block is found + expect(eth1MergeBlockTracker["status"]).to.equal(StatusCode.FOUND, "Wrong StatusCode"); + + // Given the total difficulty offset the block that has TTD is the `difficultyOffset`nth block + expect(eth1MergeBlockTracker.getMergeBlock()).to.deep.equal( + toPowBlock(blocks[difficultyOffset]), + "Wrong found merge block" + ); + }); + + it("Should find merge block fetching past blocks", async () => { + const terminalTotalDifficulty = 1000; + // Set current network totalDifficulty to behind terminalTotalDifficulty by 5. + // Then on each call to getBlockByNumber("latest") increase totalDifficulty by 1. + const difficultyOffset = 5; + const totalDifficulty = terminalTotalDifficulty - difficultyOffset; + + const blocks: EthJsonRpcBlockRaw[] = []; + const blocksByHash = new Map(); + + for (let i = 0; i < difficultyOffset * 2; i++) { + const block: EthJsonRpcBlockRaw = { + number: toHex(i), + hash: toRootHex(i + 1), + parentHash: toRootHex(i), + difficulty: "0x0", + // Latest block is under TTD, so past block search is stopped + totalDifficulty: toHex(totalDifficulty + i), + timestamp: "0x0", + }; + blocks.push(block); + blocksByHash.set(block.hash, block); + } + + // Return a latest block that's over TTD but its parent doesn't exit to cancel future searches + const latestBlock: EthJsonRpcBlockRaw = { + ...blocks[blocks.length - 1], + parentHash: toRootHex(0xffffffff), + }; + + const eth1Provider: IEth1Provider = { + deployBlock: 0, + getBlockNumber: async () => 0, + getBlockByNumber: async (blockNumber) => { + // Always return the same block with totalDifficulty > TTD and unknown parent + if (blockNumber === "latest") return latestBlock; + return blocks[blockNumber]; + }, + getBlockByHash: async (blockHashHex) => blocksByHash.get(blockHashHex) ?? null, + getBlocksByNumber: async (from, to) => blocks.slice(from, to), + getDepositEvents: notImplemented, + validateContract: notImplemented, + }; + + const eth1MergeBlockTracker = new Eth1MergeBlockTracker( + { + transitionStore: {initialized: true, terminalTotalDifficulty: BigInt(terminalTotalDifficulty)}, + config, + logger, + signal: controller.signal, + clockEpoch: 0, + isMergeComplete: false, + }, + eth1Provider as IEth1Provider + ); + + // Wait for Eth1MergeBlockTracker to find at least one merge block + while (!controller.signal.aborted) { + if (eth1MergeBlockTracker.getMergeBlock()) break; + await sleep(10, controller.signal); + } + + // Status should acknowlege merge block is found + expect(eth1MergeBlockTracker["status"]).to.equal(StatusCode.FOUND, "Wrong StatusCode"); + + // Given the total difficulty offset the block that has TTD is the `difficultyOffset`nth block + expect(eth1MergeBlockTracker.getMergeBlock()).to.deep.equal( + toPowBlock(blocks[difficultyOffset]), + "Wrong found merge block" + ); + }); +}); + +function toHex(num: number | bigint): string { + return "0x" + num.toString(16); +} + +function toRootHex(num: number): string { + return "0x" + num.toString(16).padStart(64, "0"); +} From 348126459259b131f351e782c6f9aa611767d67c Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Sat, 18 Sep 2021 16:45:54 +0200 Subject: [PATCH 3/4] Update e2e test --- packages/lodestar/src/eth1/eth1DepositDataTracker.ts | 4 ++-- packages/lodestar/src/eth1/interface.ts | 5 ----- packages/lodestar/src/eth1/provider/eth1Provider.ts | 2 +- packages/lodestar/src/eth1/stream.ts | 4 ++-- packages/lodestar/test/e2e/eth1/eth1Provider.test.ts | 6 +++--- packages/lodestar/test/unit/chain/genesis/genesis.test.ts | 1 - .../lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts | 2 -- 7 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/lodestar/src/eth1/eth1DepositDataTracker.ts b/packages/lodestar/src/eth1/eth1DepositDataTracker.ts index c48880e1023..2aca24cd1d8 100644 --- a/packages/lodestar/src/eth1/eth1DepositDataTracker.ts +++ b/packages/lodestar/src/eth1/eth1DepositDataTracker.ts @@ -11,7 +11,7 @@ import {getDeposits} from "./utils/deposits"; import {Eth1DataAndDeposits, IEth1Provider} from "./interface"; import {Eth1Options} from "./options"; import {HttpRpcError} from "./provider/jsonRpcHttpClient"; -import {parseBlock} from "./provider/eth1Provider"; +import {parseEth1Block} from "./provider/eth1Provider"; const MAX_BLOCKS_PER_BLOCK_QUERY = 1000; const MAX_BLOCKS_PER_LOG_QUERY = 1000; @@ -190,7 +190,7 @@ export class Eth1DepositDataTracker { ); const blocksRaw = await this.eth1Provider.getBlocksByNumber(fromBlock, toBlock); - const blocks = blocksRaw.map(parseBlock); + const blocks = blocksRaw.map(parseEth1Block); this.logger.verbose("Fetched eth1 blocks", {blockCount: blocks.length, fromBlock, toBlock}); const eth1Datas = await this.depositsCache.getEth1DataForBlocks(blocks, lastProcessedDepositBlockNumber); diff --git a/packages/lodestar/src/eth1/interface.ts b/packages/lodestar/src/eth1/interface.ts index d25b7b911d3..4dcb34b9355 100644 --- a/packages/lodestar/src/eth1/interface.ts +++ b/packages/lodestar/src/eth1/interface.ts @@ -13,11 +13,6 @@ export type EthJsonRpcBlockRaw = { hash: string; /** 32 Bytes - hash of the parent block. `"0xe99e022112df268087ea7eafaf4790497fd21dbeeb6bd7a1721df161a6657a54"` */ parentHash: string; - /** - * integer of the difficulty for this block. `"0x4ea3f27bc"`. - * Current mainnet value is 0x1f42c087a0d61e, 53 bits so requires a bigint. - */ - difficulty: string; /** * integer of the total difficulty of the chain until this block. `"0x78ed983323d"`. * Current mainnet value is 0x684de10dc5c03f006b6, 75 bits so requires a bigint. diff --git a/packages/lodestar/src/eth1/provider/eth1Provider.ts b/packages/lodestar/src/eth1/provider/eth1Provider.ts index 16f4a5f4d6a..cbd8b7b4e00 100644 --- a/packages/lodestar/src/eth1/provider/eth1Provider.ts +++ b/packages/lodestar/src/eth1/provider/eth1Provider.ts @@ -182,7 +182,7 @@ function toHex(n: number): string { return "0x" + n.toString(16); } -export function parseBlock(blockRaw: EthJsonRpcBlockRaw): phase0.Eth1Block { +export function parseEth1Block(blockRaw: EthJsonRpcBlockRaw): phase0.Eth1Block { if (typeof blockRaw !== "object") throw Error("block is not an object"); validateHexRoot(blockRaw.hash); return { diff --git a/packages/lodestar/src/eth1/stream.ts b/packages/lodestar/src/eth1/stream.ts index 4a6f524750c..cc42adbbf09 100644 --- a/packages/lodestar/src/eth1/stream.ts +++ b/packages/lodestar/src/eth1/stream.ts @@ -8,7 +8,7 @@ import {groupDepositEventsByBlock} from "./utils/groupDepositEventsByBlock"; import {optimizeNextBlockDiffForGenesis} from "./utils/optimizeNextBlockDiffForGenesis"; import {sleep} from "@chainsafe/lodestar-utils"; import {phase0} from "@chainsafe/lodestar-types"; -import {parseBlock} from "./provider/eth1Provider"; +import {parseEth1Block} from "./provider/eth1Provider"; /** * Phase 1 of genesis building. @@ -60,7 +60,7 @@ export async function* getDepositsAndBlockStreamForGenesis( ]); if (!blockRaw) throw Error(`No block found for number ${toBlock}`); - const block = parseBlock(blockRaw); + const block = parseEth1Block(blockRaw); yield [logs, block]; diff --git a/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts b/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts index 8dd20641f64..23d77cc6ed8 100644 --- a/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts +++ b/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts @@ -6,7 +6,7 @@ import {getTestnetConfig, testnet} from "../../utils/testnet"; import {fromHexString} from "@chainsafe/ssz"; import {phase0} from "@chainsafe/lodestar-types"; import {goerliTestnetDepositEvents} from "../../utils/testnet"; -import {Eth1Provider} from "../../../src/eth1/provider/eth1Provider"; +import {Eth1Provider, parseEth1Block} from "../../../src/eth1/provider/eth1Provider"; describe("eth1 / Eth1Provider", function () { this.timeout("2 min"); @@ -76,13 +76,13 @@ describe("eth1 / Eth1Provider", function () { const fromBlock = firstGoerliBlocks[0].blockNumber; const toBlock = firstGoerliBlocks[firstGoerliBlocks.length - 1].blockNumber; const blocks = await getEth1Provider().getBlocksByNumber(fromBlock, toBlock); - expect(blocks).to.deep.equal(firstGoerliBlocks); + expect(blocks.map(parseEth1Block)).to.deep.equal(firstGoerliBlocks); }); it("getBlockByNumber: Should fetch a single block", async function () { const firstGoerliBlock = firstGoerliBlocks[0]; const block = await getEth1Provider().getBlockByNumber(firstGoerliBlock.blockNumber); - expect(block).to.deep.equal(firstGoerliBlock); + expect(block && parseEth1Block(block)).to.deep.equal(firstGoerliBlock); }); it("getBlockNumber: Should fetch latest block number", async function () { diff --git a/packages/lodestar/test/unit/chain/genesis/genesis.test.ts b/packages/lodestar/test/unit/chain/genesis/genesis.test.ts index 231efc48e5c..bfaa42ad5d6 100644 --- a/packages/lodestar/test/unit/chain/genesis/genesis.test.ts +++ b/packages/lodestar/test/unit/chain/genesis/genesis.test.ts @@ -51,7 +51,6 @@ describe("genesis builder", function () { timestamp: schlesiConfig.MIN_GENESIS_TIME + i.toString(16), // Extra un-used data for this test parentHash: "0x0", - difficulty: "0x0", totalDifficulty: "0x0", }); } diff --git a/packages/lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts b/packages/lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts index a98cfc96a37..8512f30161a 100644 --- a/packages/lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts +++ b/packages/lodestar/test/unit/eth1/eth1MergeBlockTracker.test.ts @@ -41,7 +41,6 @@ describe("eth1 / Eth1MergeBlockTracker", () => { number: toHex(i), hash: toRootHex(i + 1), parentHash: toRootHex(i), - difficulty: "0x0", // Latest block is under TTD, so past block search is stopped totalDifficulty: toHex(totalDifficulty + i), timestamp: "0x0", @@ -108,7 +107,6 @@ describe("eth1 / Eth1MergeBlockTracker", () => { number: toHex(i), hash: toRootHex(i + 1), parentHash: toRootHex(i), - difficulty: "0x0", // Latest block is under TTD, so past block search is stopped totalDifficulty: toHex(totalDifficulty + i), timestamp: "0x0", From 892837715106ee06307f9e17eb295f467f7bc17f Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Sun, 19 Sep 2021 12:24:08 +0200 Subject: [PATCH 4/4] Fix e2e test --- packages/lodestar/test/e2e/eth1/eth1Provider.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts b/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts index 23d77cc6ed8..2db71d90579 100644 --- a/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts +++ b/packages/lodestar/test/e2e/eth1/eth1Provider.test.ts @@ -42,7 +42,7 @@ describe("eth1 / Eth1Provider", function () { timestamp: 1548854791, }; const block = await getEth1Provider().getBlockByNumber(goerliGenesisBlock.blockNumber); - expect(block).to.deep.equal(goerliGenesisBlock); + expect(block && parseEth1Block(block)).to.deep.equal(goerliGenesisBlock); }); it("Should get deposits events for a block range", async function () {