diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index ace502b72512..11431bf703b0 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -5,10 +5,11 @@ import {JobItemQueue} from "../../util/queue/index.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; import {IBeaconChain} from "../interface.js"; -import {verifyBlock, VerifyBlockModules} from "./verifyBlock.js"; +import {VerifyBlockModules, verifyBlockStateTransition} from "./verifyBlock.js"; import {importBlock, ImportBlockModules} from "./importBlock.js"; import {assertLinearChainSegment} from "./utils/chainSegment.js"; -import {ImportBlockOpts} from "./types.js"; +import {FullyVerifiedBlock, ImportBlockOpts} from "./types.js"; +import {verifyBlocksSanityChecks} from "./verifyBlocksSanityChecks.js"; export {ImportBlockOpts} from "./types.js"; const QUEUE_MAX_LENGHT = 256; @@ -62,8 +63,27 @@ export async function processBlocks( } try { - for (const block of blocks) { - const fullyVerifiedBlock = await verifyBlock(chain, block, opts); + const {relevantBlocks, parentSlots} = verifyBlocksSanityChecks(chain, blocks, opts); + + // No relevant blocks, skip verifyBlocksInEpoch() + if (relevantBlocks.length === 0) { + return; + } + + for (const [i, block] of relevantBlocks.entries()) { + // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate + // states in the state cache through regen. + const {postState, executionStatus, proposerBalanceDiff} = await verifyBlockStateTransition(chain, block, opts); + + const fullyVerifiedBlock: FullyVerifiedBlock = { + block, + postState, + parentBlockSlot: parentSlots[i], + executionStatus, + proposerBalanceDiff, + // TODO: Make this param mandatory and capture in gossip + seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000), + }; // No need to sleep(0) here since `importBlock` includes a disk write // TODO: Consider batching importBlock too if it takes significant time diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index df4d65498902..7136d411641f 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -1,6 +1,5 @@ import { CachedBeaconStateAllForks, - computeStartSlotAtEpoch, isBellatrixStateType, isBellatrixBlockBodyType, isMergeTransitionBlock as isMergeTransitionBlockFn, @@ -10,7 +9,7 @@ import { } from "@lodestar/state-transition"; import {allForks, bellatrix} from "@lodestar/types"; import {toHexString} from "@chainsafe/ssz"; -import {IForkChoice, ProtoBlock, ExecutionStatus, assertValidTerminalPowBlock} from "@lodestar/fork-choice"; +import {IForkChoice, ExecutionStatus, assertValidTerminalPowBlock} from "@lodestar/fork-choice"; import {IChainForkConfig} from "@lodestar/config"; import {ILogger} from "@lodestar/utils"; import {IMetrics} from "../../metrics/index.js"; @@ -23,7 +22,7 @@ import {IBlsVerifier} from "../bls/index.js"; import {ExecutePayloadStatus} from "../../execution/engine/interface.js"; import {byteArrayEquals} from "../../util/bytes.js"; import {IEth1ForBlockProduction} from "../../eth1/index.js"; -import {FullyVerifiedBlock, ImportBlockOpts} from "./types.js"; +import {ImportBlockOpts} from "./types.js"; import {POS_PANDA_MERGE_TRANSITION_BANNER} from "./utils/pandaMergeTransitionBanner.js"; export type VerifyBlockModules = { @@ -38,81 +37,6 @@ export type VerifyBlockModules = { metrics: IMetrics | null; }; -/** - * Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate - * states in the state cache through regen. - */ -export async function verifyBlock( - chain: VerifyBlockModules, - block: allForks.SignedBeaconBlock, - opts: ImportBlockOpts & BlockProcessOpts -): Promise { - const parentBlock = verifyBlockSanityChecks(chain, block); - - const {postState, executionStatus, proposerBalanceDiff} = await verifyBlockStateTransition(chain, block, opts); - - return { - block, - postState, - parentBlockSlot: parentBlock.slot, - executionStatus, - proposerBalanceDiff, - // TODO: Make this param mandatory and capture in gossip - seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000), - }; -} - -/** - * Verifies som early cheap sanity checks on the block before running the full state transition. - * - * - Parent is known to the fork-choice - * - Check skipped slots limit - * - check_block_relevancy() - * - Block not in the future - * - Not genesis block - * - Block's slot is < Infinity - * - Not finalized slot - * - Not already known - */ -export function verifyBlockSanityChecks(chain: VerifyBlockModules, block: allForks.SignedBeaconBlock): ProtoBlock { - const blockSlot = block.message.slot; - - // Not genesis block - if (blockSlot === 0) { - throw new BlockError(block, {code: BlockErrorCode.GENESIS_BLOCK}); - } - - // Not finalized slot - const finalizedSlot = computeStartSlotAtEpoch(chain.forkChoice.getFinalizedCheckpoint().epoch); - if (blockSlot <= finalizedSlot) { - throw new BlockError(block, {code: BlockErrorCode.WOULD_REVERT_FINALIZED_SLOT, blockSlot, finalizedSlot}); - } - - // Parent is known to the fork-choice - const parentRoot = toHexString(block.message.parentRoot); - const parentBlock = chain.forkChoice.getBlockHex(parentRoot); - if (!parentBlock) { - throw new BlockError(block, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot}); - } - - // Check skipped slots limit - // TODO - - // Block not in the future, also checks for infinity - const currentSlot = chain.clock.currentSlot; - if (blockSlot > currentSlot) { - throw new BlockError(block, {code: BlockErrorCode.FUTURE_SLOT, blockSlot, currentSlot}); - } - - // Not already known - const blockHash = toHexString(chain.config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message)); - if (chain.forkChoice.hasBlockHex(blockHash)) { - throw new BlockError(block, {code: BlockErrorCode.ALREADY_KNOWN, root: blockHash}); - } - - return parentBlock; -} - /** * Verifies a block is fully valid running the full state transition. To relieve the main thread signatures are * verified separately in workers with chain.bls worker pool. diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts new file mode 100644 index 000000000000..141c17cb084e --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts @@ -0,0 +1,100 @@ +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {IChainForkConfig} from "@lodestar/config"; +import {IForkChoice} from "@lodestar/fork-choice"; +import {allForks, Slot} from "@lodestar/types"; +import {toHexString} from "@lodestar/utils"; +import {IBeaconClock} from "../clock/interface.js"; +import {BlockError, BlockErrorCode} from "../errors/index.js"; +import {ImportBlockOpts} from "./types.js"; + +/** + * Verifies some early cheap sanity checks on the block before running the full state transition. + * + * - Parent is known to the fork-choice + * - Check skipped slots limit + * - check_block_relevancy() + * - Block not in the future + * - Not genesis block + * - Block's slot is < Infinity + * - Not finalized slot + * - Not already known + */ +export function verifyBlocksSanityChecks( + chain: {forkChoice: IForkChoice; clock: IBeaconClock; config: IChainForkConfig}, + blocks: allForks.SignedBeaconBlock[], + opts: ImportBlockOpts +): {relevantBlocks: allForks.SignedBeaconBlock[]; parentSlots: Slot[]} { + if (blocks.length === 0) { + throw Error("Empty partiallyVerifiedBlocks"); + } + + const relevantBlocks: allForks.SignedBeaconBlock[] = []; + const parentSlots: Slot[] = []; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + const blockSlot = block.message.slot; + + // Not genesis block + // IGNORE if `partiallyVerifiedBlock.ignoreIfKnown` + if (blockSlot === 0) { + if (opts.ignoreIfKnown) { + continue; + } else { + throw new BlockError(block, {code: BlockErrorCode.GENESIS_BLOCK}); + } + } + + // Not finalized slot + // IGNORE if `partiallyVerifiedBlock.ignoreIfFinalized` + const finalizedSlot = computeStartSlotAtEpoch(chain.forkChoice.getFinalizedCheckpoint().epoch); + if (blockSlot <= finalizedSlot) { + if (opts.ignoreIfFinalized) { + continue; + } else { + throw new BlockError(block, {code: BlockErrorCode.WOULD_REVERT_FINALIZED_SLOT, blockSlot, finalizedSlot}); + } + } + + let parentBlockSlot: Slot; + + // When importing a block segment, only the first NON-IGNORED block must be known to the fork-choice. + if (relevantBlocks.length > 0) { + parentBlockSlot = relevantBlocks[relevantBlocks.length - 1].message.slot; + } else { + // Parent is known to the fork-choice + const parentRoot = toHexString(block.message.parentRoot); + const parentBlock = chain.forkChoice.getBlockHex(parentRoot); + if (!parentBlock) { + throw new BlockError(block, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot}); + } else { + parentBlockSlot = parentBlock.slot; + } + } + + // Block not in the future, also checks for infinity + const currentSlot = chain.clock.currentSlot; + if (blockSlot > currentSlot) { + throw new BlockError(block, {code: BlockErrorCode.FUTURE_SLOT, blockSlot, currentSlot}); + } + + // Not already known + // IGNORE if `partiallyVerifiedBlock.ignoreIfKnown` + const blockHash = toHexString( + chain.config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message) + ); + if (chain.forkChoice.hasBlockHex(blockHash)) { + if (opts.ignoreIfKnown) { + continue; + } else { + throw new BlockError(block, {code: BlockErrorCode.ALREADY_KNOWN, root: blockHash}); + } + } + + // Block is relevant + relevantBlocks.push(block); + parentSlots.push(parentBlockSlot); + } + + return {relevantBlocks, parentSlots}; +} diff --git a/packages/beacon-node/test/unit/chain/blocks/verifyBlock.test.ts b/packages/beacon-node/test/unit/chain/blocks/verifyBlock.test.ts deleted file mode 100644 index 85155329a1c0..000000000000 --- a/packages/beacon-node/test/unit/chain/blocks/verifyBlock.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import sinon, {SinonStubbedInstance} from "sinon"; - -import {config} from "@lodestar/config/default"; -import {ForkChoice, ProtoBlock} from "@lodestar/fork-choice"; - -import {allForks, ssz} from "@lodestar/types"; -import {verifyBlockSanityChecks, VerifyBlockModules} from "../../../../src/chain/blocks/verifyBlock.js"; -import {LocalClock} from "../../../../src/chain/clock/index.js"; -import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; -import {expectThrowsLodestarError} from "../../../utils/errors.js"; - -describe("chain / blocks / verifyBlock", function () { - let forkChoice: SinonStubbedInstance; - let clock: LocalClock; - let modules: VerifyBlockModules; - let block: allForks.SignedBeaconBlock; - const currentSlot = 1; - - beforeEach(function () { - block = ssz.phase0.SignedBeaconBlock.defaultValue(); - block.message.slot = currentSlot; - - forkChoice = sinon.createStubInstance(ForkChoice); - forkChoice.getFinalizedCheckpoint.returns({epoch: 0, root: Buffer.alloc(32), rootHex: ""}); - clock = {currentSlot} as LocalClock; - modules = ({config, forkChoice, clock} as Partial) as VerifyBlockModules; - // On first call, parentRoot is known - forkChoice.getBlockHex.returns({} as ProtoBlock); - }); - - it("PARENT_UNKNOWN", function () { - forkChoice.getBlockHex.returns(null); - expectThrowsLodestarError(() => verifyBlockSanityChecks(modules, block), BlockErrorCode.PARENT_UNKNOWN); - }); - - it("GENESIS_BLOCK", function () { - block.message.slot = 0; - expectThrowsLodestarError(() => verifyBlockSanityChecks(modules, block), BlockErrorCode.GENESIS_BLOCK); - }); - - it("ALREADY_KNOWN", function () { - forkChoice.hasBlockHex.returns(true); - expectThrowsLodestarError(() => verifyBlockSanityChecks(modules, block), BlockErrorCode.ALREADY_KNOWN); - }); - - it("WOULD_REVERT_FINALIZED_SLOT", function () { - forkChoice.getFinalizedCheckpoint.returns({epoch: 5, root: Buffer.alloc(32), rootHex: ""}); - expectThrowsLodestarError( - () => verifyBlockSanityChecks(modules, block), - BlockErrorCode.WOULD_REVERT_FINALIZED_SLOT - ); - }); - - it("FUTURE_SLOT", function () { - block.message.slot = currentSlot + 1; - expectThrowsLodestarError(() => verifyBlockSanityChecks(modules, block), BlockErrorCode.FUTURE_SLOT); - }); -}); diff --git a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts new file mode 100644 index 000000000000..c25edca2d995 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts @@ -0,0 +1,182 @@ +import sinon, {SinonStubbedInstance} from "sinon"; +import {expect} from "chai"; + +import {config} from "@lodestar/config/default"; +import {ForkChoice, IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {toHex} from "@lodestar/utils"; +import {IChainForkConfig} from "@lodestar/config"; +import {allForks, Slot, ssz} from "@lodestar/types"; +import {verifyBlocksSanityChecks} from "../../../../src/chain/blocks/verifyBlocksSanityChecks.js"; +import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; +import {expectThrowsLodestarError} from "../../../utils/errors.js"; +import {IBeaconClock} from "../../../../src/chain/index.js"; +import {ClockStopped} from "../../../utils/mocks/clock.js"; + +describe("chain / blocks / verifyBlocksSanityChecks", function () { + let forkChoice: SinonStubbedInstance; + let clock: ClockStopped; + let modules: {forkChoice: IForkChoice; clock: IBeaconClock; config: IChainForkConfig}; + let block: allForks.SignedBeaconBlock; + const currentSlot = 1; + + beforeEach(() => { + block = ssz.phase0.SignedBeaconBlock.defaultValue(); + block.message.slot = currentSlot; + + forkChoice = sinon.createStubInstance(ForkChoice); + forkChoice.getFinalizedCheckpoint.returns({epoch: 0, root: Buffer.alloc(32), rootHex: ""}); + clock = new ClockStopped(currentSlot); + modules = {config, forkChoice, clock} as {forkChoice: IForkChoice; clock: IBeaconClock; config: IChainForkConfig}; + // On first call, parentRoot is known + forkChoice.getBlockHex.returns({} as ProtoBlock); + }); + + it("PARENT_UNKNOWN", () => { + forkChoice.getBlockHex.returns(null); + expectThrowsLodestarError(() => verifyBlocksSanityChecks(modules, [block], {}), BlockErrorCode.PARENT_UNKNOWN); + }); + + it("GENESIS_BLOCK", () => { + block.message.slot = 0; + expectThrowsLodestarError(() => verifyBlocksSanityChecks(modules, [block], {}), BlockErrorCode.GENESIS_BLOCK); + }); + + it("ALREADY_KNOWN", () => { + forkChoice.hasBlockHex.returns(true); + expectThrowsLodestarError(() => verifyBlocksSanityChecks(modules, [block], {}), BlockErrorCode.ALREADY_KNOWN); + }); + + it("WOULD_REVERT_FINALIZED_SLOT", () => { + forkChoice.getFinalizedCheckpoint.returns({epoch: 5, root: Buffer.alloc(32), rootHex: ""}); + expectThrowsLodestarError( + () => verifyBlocksSanityChecks(modules, [block], {}), + BlockErrorCode.WOULD_REVERT_FINALIZED_SLOT + ); + }); + + it("FUTURE_SLOT", () => { + block.message.slot = currentSlot + 1; + expectThrowsLodestarError(() => verifyBlocksSanityChecks(modules, [block], {}), BlockErrorCode.FUTURE_SLOT); + }); + + it("[OK, OK]", () => { + const blocks = getValidChain(3); + const blocksToProcess = [blocks[1], blocks[2]]; + + // allBlocks[0] = Genesis, not submitted + // allBlocks[1] = OK + // allBlocks[2] = OK + modules.forkChoice = getForkChoice([blocks[0]]); + clock.setSlot(3); + + const {relevantBlocks, parentSlots} = verifyBlocksSanityChecks(modules, blocksToProcess, {ignoreIfKnown: true}); + + expect(relevantBlocks).to.deep.equal([blocks[1], blocks[2]], "Wrong relevantBlocks"); + // Also check parentSlots + expect(parentSlots).to.deep.equal(slots([blocks[0], blocks[1]]), "Wrong parentSlots"); + }); + + it("[ALREADY_KNOWN, OK, OK]", () => { + const blocks = getValidChain(4); + const blocksToProcess = [blocks[1], blocks[2], blocks[3]]; + + // allBlocks[0] = Genesis, not submitted + // allBlocks[1] = ALREADY_KNOWN + // allBlocks[2] = OK + // allBlocks[3] = OK + modules.forkChoice = getForkChoice([blocks[0], blocks[1]]); + clock.setSlot(4); + + const {relevantBlocks} = verifyBlocksSanityChecks(modules, blocksToProcess, { + ignoreIfKnown: true, + }); + + expectBlocks(relevantBlocks, [blocks[2], blocks[3]], blocks, "Wrong relevantBlocks"); + }); + + it("[WOULD_REVERT_FINALIZED_SLOT, OK, OK]", () => { + const finalizedEpoch = 5; + const finalizedSlot = computeStartSlotAtEpoch(finalizedEpoch); + const blocks = getValidChain(4, finalizedSlot - 1); + const blocksToProcess = [blocks[1], blocks[2], blocks[3]]; + + // allBlocks[0] = Genesis, not submitted + // allBlocks[1] = WOULD_REVERT_FINALIZED_SLOT + ALREADY_KNOWN + // allBlocks[2] = OK + // allBlocks[3] = OK + modules.forkChoice = getForkChoice([blocks[0], blocks[1]], finalizedEpoch); + clock.setSlot(finalizedSlot + 4); + + const {relevantBlocks} = verifyBlocksSanityChecks(modules, blocksToProcess, { + ignoreIfFinalized: true, + }); + + expectBlocks(relevantBlocks, [blocks[2], blocks[3]], blocks, "Wrong relevantBlocks"); + }); +}); + +function getValidChain(count: number, initialSlot = 0): allForks.SignedBeaconBlock[] { + const blocks: allForks.SignedBeaconBlock[] = []; + + for (let i = 0; i < count; i++) { + const block = ssz.phase0.SignedBeaconBlock.defaultValue(); + if (i === 0) { + block.message.slot = initialSlot; + block.message.parentRoot = ssz.Root.defaultValue(); + } else { + block.message.slot = blocks[i - 1].message.slot + 1; + block.message.parentRoot = ssz.phase0.BeaconBlock.hashTreeRoot(blocks[i - 1].message); + } + blocks.push(block); + } + + return blocks; +} + +function getForkChoice(knownBlocks: allForks.SignedBeaconBlock[], finalizedEpoch = 0): IForkChoice { + const blocks = new Map(); + for (const block of knownBlocks) { + const protoBlock = toProtoBlock(block); + blocks.set(protoBlock.blockRoot, protoBlock); + } + + return ({ + getBlockHex(blockRoot) { + return blocks.get(blockRoot) ?? null; + }, + hasBlockHex(blockRoot) { + return blocks.has(blockRoot); + }, + getFinalizedCheckpoint() { + return {epoch: finalizedEpoch, root: Buffer.alloc(32), rootHex: ""}; + }, + } as Partial) as IForkChoice; +} + +function toProtoBlock(block: allForks.SignedBeaconBlock): ProtoBlock { + return ({ + slot: block.message.slot, + blockRoot: toHex(ssz.phase0.BeaconBlock.hashTreeRoot((block as allForks.SignedBeaconBlock).message)), + parentRoot: toHex(block.message.parentRoot), + stateRoot: toHex(block.message.stateRoot), + } as Partial) as ProtoBlock; +} + +function slots(blocks: allForks.SignedBeaconBlock[]): Slot[] { + return blocks.map((block) => block.message.slot); +} + +/** Since blocks have no meaning compare the indexes against `allBlocks` */ +function expectBlocks( + expectedBlocks: allForks.SignedBeaconBlock[], + actualBlocks: allForks.SignedBeaconBlock[], + allBlocks: allForks.SignedBeaconBlock[], + message: string +): void { + function indexOfBlocks(blocks: allForks.SignedBeaconBlock[]): number[] { + return blocks.map((block) => allBlocks.indexOf(block)); + } + + expect(indexOfBlocks(actualBlocks)).to.deep.equal(indexOfBlocks(expectedBlocks), `${message} - of block indexes`); +}