From 608dd954b4104df85ae144133bac58dfea5c7130 Mon Sep 17 00:00:00 2001 From: twoeths Date: Tue, 15 Sep 2026 15:24:02 +0700 Subject: [PATCH] fix: unknown sync to verify downloaded block before retain --- packages/beacon-node/src/sync/unknownBlock.ts | 43 +++- .../src/sync/utils/downloadByRoot.ts | 186 +++++++++++++++++- .../test/unit/sync/unknownBlock.test.ts | 59 ++++-- .../unit/sync/utils/downloadByRoot.test.ts | 3 + 4 files changed, 275 insertions(+), 16 deletions(-) diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 28b677a68128..db6a30674f6f 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -43,7 +43,7 @@ import { isPendingPayloadEnvelope, isPendingPayloadInput, } from "./types.js"; -import {DownloadByRootError, downloadByRoot} from "./utils/downloadByRoot.js"; +import {DownloadByRootError, downloadByRoot, getTerminalDownloadByRootError} from "./utils/downloadByRoot.js"; import {getAllDescendantBlocks, getUnknownAndAncestorBlocks} from "./utils/pendingBlocksTree.js"; import {getRateLimitedUntilMs} from "./utils/rateLimit.js"; @@ -829,6 +829,24 @@ export class BlockInputSync { }; this.logger.verbose("Downloaded unknown block", logCtx2); + // NOT_LATER_THAN_PARENT propagated forward: a pending child that claimed this block as parent but + // does not have a later slot is provably invalid, remove it and its subtree. + for (const child of this.pendingBlocks.values()) { + if ( + isPendingBlockInput(child) && + child.blockInput.parentRootHex === pending.blockInput.blockRootHex && + child.blockInput.slot <= blockSlot + ) { + this.logger.debug("Pending block is not later than its downloaded parent", { + slot: child.blockInput.slot, + root: child.blockInput.blockRootHex, + parentSlot: blockSlot, + parentRoot: pending.blockInput.blockRootHex, + }); + this.removeAndDownScoreAllDescendants(child, DroppedItemReason.invalidBlock); + } + } + if (parentInForkChoice) { // If the direct parent is already in fork choice, let the block state machine decide if // the next step is block import, parent payload download, or branch removal. @@ -865,6 +883,20 @@ export class BlockInputSync { this.onUnknownBlockRoot({rootHex: pending.blockInput.parentRootHex, source: BlockInputSource.byRoot}); } } else { + const terminalError = getTerminalDownloadByRootError(res.err); + if (terminalError !== null) { + // penalize the serving peer, forwarding peers will be penalized inside removeAndDownScoreAllDescendants + this.network.reportPeer(terminalError.peerIdStr, terminalError.peerAction, terminalError.code); + this.logger.debug( + "Downloaded block by root is invalid", + {...logCtx, peer: prettyPrintPeerIdStr(terminalError.peerIdStr), peerAction: terminalError.peerAction}, + res.err + ); + this.metrics?.blockInputSync.downloadedBlocksError.inc(); + this.removeAndDownScoreAllDescendants(block, DroppedItemReason.invalidBlock); + return; + } + if (res.err instanceof UnknownBlockRateLimitedError) { const pendingBlock = this.pendingBlocks.get(rootHex); if (pendingBlock) { @@ -928,6 +960,9 @@ export class BlockInputSync { // there could be finalized/head sync at the same time so we need to ignore if finalized // see https://github.com/ChainSafe/lodestar/issues/5650 ignoreIfFinalized: true, + // proposer signature already checked in fetchAndValidateBlock() for downloaded blocks and in + // validateBeaconBlock() for gossip-retained blocks + validProposerSignature: true, blsVerifyOnMainThread: true, }) ); @@ -1532,6 +1567,10 @@ export class BlockInputSync { if (e instanceof DownloadByRootError) { const errorCode = e.type.code; downloadByRootMetrics?.error.inc({code: errorCode, client: peerClient}); + if (getTerminalDownloadByRootError(e) !== null) { + // block was downloaded with terminal error, we should not go with another loop + throw e; + } excludedPeers.add(peerId); } else if (e instanceof RequestError) { // should look into req_resp metrics in this case @@ -1629,7 +1668,7 @@ export class BlockInputSync { for (const block of badPendingBlocks) { // - // TODO(fulu): why is this commented out here? + // TODO(fulu): why is this commented out here? see https://github.com/ChainSafe/lodestar/issues/10091 // // this.knownBadBlocks.add(block.blockRootHex); // for (const peerIdStr of block.peerIdStrings) { diff --git a/packages/beacon-node/src/sync/utils/downloadByRoot.ts b/packages/beacon-node/src/sync/utils/downloadByRoot.ts index d78c71992ac4..34e21f347cde 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRoot.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRoot.ts @@ -9,16 +9,20 @@ import { isForkPostFulu, isForkPostGloas, } from "@lodestar/params"; +import {signedBlockToSignedHeader} from "@lodestar/state-transition"; import {BlobIndex, ColumnIndex, SignedBeaconBlock, Slot, deneb, fulu} from "@lodestar/types"; import {LodestarError, byteArrayEquals, fromHex, prettyPrintIndices, toHex, toRootHex} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {BlockInputSource, IBlockInput} from "../../chain/blocks/blockInput/types.js"; import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js"; import {ChainEventEmitter} from "../../chain/emitter.js"; +import {BlockGossipError} from "../../chain/errors/index.js"; import {IBeaconChain} from "../../chain/interface.js"; import {validateBlockBlobSidecars} from "../../chain/validation/blobSidecar.js"; +import {verifyBlockProposerSignature} from "../../chain/validation/block.js"; import {validateFuluBlockDataColumnSidecars} from "../../chain/validation/dataColumnSidecar.js"; import {INetwork} from "../../network/interface.js"; +import {PeerAction} from "../../network/peers/index.js"; import {PeerSyncMeta} from "../../network/peers/peersData.js"; import {prettyPrintPeerIdStr} from "../../network/util.js"; import {getBlobKzgCommitments} from "../../util/dataColumns.js"; @@ -243,6 +247,7 @@ export async function fetchByRoot({ } else { block = await fetchAndValidateBlock({ config, + chain, network, peerIdStr, blockRoot, @@ -279,6 +284,7 @@ export async function fetchByRoot({ } else { block = await fetchAndValidateBlock({ config, + chain, network, peerIdStr, blockRoot, @@ -326,10 +332,11 @@ export async function fetchByRoot({ export async function fetchAndValidateBlock({ config, + chain, network, peerIdStr, blockRoot, -}: Omit): Promise { +}: FetchByRootAndValidateBlockProps): Promise { const response = await network.sendBeaconBlocksByRoot(peerIdStr, [blockRoot]); const block = response.at(0); if (!block) { @@ -351,6 +358,96 @@ export async function fetchAndValidateBlock({ "block does not match requested root" ); } + + // Chain is not null as passed by fetchBlockInput() + if (chain !== null) { + const blockSlot = block.message.slot; + const {proposerIndex} = block.message; + const rootHex = toRootHex(blockRoot); + const peer = prettyPrintPeerIdStr(peerIdStr); + + const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(toRootHex(block.message.parentRoot)); + + // [terminal] the block must be later than its parent, provable already when the parent is in fork choice + if (parentBlock !== null && parentBlock.slot >= blockSlot) { + throw new DownloadByRootError({ + code: DownloadByRootErrorCode.NOT_LATER_THAN_PARENT, + peer, + peerIdStr, + slot: blockSlot, + blockRoot: rootHex, + parentSlot: parentBlock.slot, + }); + } + + // the common ancestor of the downloading chain and canonical chain should be at least the finalized slot and + // we should found it through forkchoice. If not, we should penalize all peers sending us this block chain + // 0 - 1 - ... - n - finalizedSlot + // \ + // parent 1 - parent 2 - ... - unknownParent block + const finalizedSlot = chain.forkChoice.getFinalizedBlock().slot; + if (blockSlot <= finalizedSlot && parentBlock === null) { + throw new DownloadByRootError({ + code: DownloadByRootErrorCode.WOULD_REVERT_FINALIZED_SLOT, + peer, + peerIdStr, + slot: blockSlot, + blockRoot: rootHex, + finalizedSlot, + }); + } + const currentSlotWithDisparity = chain.clock.currentSlotWithGossipDisparity; + if (blockSlot > currentSlotWithDisparity) { + throw new DownloadByRootError({ + code: DownloadByRootErrorCode.FUTURE_SLOT, + peer, + peerIdStr, + slot: blockSlot, + blockRoot: rootHex, + currentSlot: chain.clock.currentSlot, + }); + } + + if (proposerIndex >= chain.pubkeyCache.size) { + throw new DownloadByRootError({ + code: DownloadByRootErrorCode.UNKNOWN_PROPOSER, + peer, + peerIdStr, + slot: blockSlot, + blockRoot: rootHex, + proposerIndex, + }); + } + + try { + await verifyBlockProposerSignature(chain, block, rootHex); + } catch (e) { + if (!(e instanceof BlockGossipError)) { + // BLS backend failure, not proof of an invalid block + throw e; + } + throw new DownloadByRootError({ + code: DownloadByRootErrorCode.PROPOSAL_SIGNATURE_INVALID, + peer, + peerIdStr, + slot: blockSlot, + blockRoot: rootHex, + }); + } + + if (blockSlot > finalizedSlot) { + chain.seenBlockProposers.observeBlockRoot( + blockSlot, + proposerIndex, + rootHex, + signedBlockToSignedHeader(config, block) + ); + if (!chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { + chain.seenBlockProposers.add(blockSlot, proposerIndex, rootHex); + } + } + } + return block; } @@ -496,8 +593,56 @@ export enum DownloadByRootErrorCode { MISSING_BLOCK_RESPONSE = "DOWNLOAD_BY_ROOT_ERROR_MISSING_BLOCK_RESPONSE", MISSING_BLOB_RESPONSE = "DOWNLOAD_BY_ROOT_ERROR_MISSING_BLOB_RESPONSE", MISSING_COLUMN_RESPONSE = "DOWNLOAD_BY_ROOT_ERROR_MISSING_COLUMN_RESPONSE", + WOULD_REVERT_FINALIZED_SLOT = "DOWNLOAD_BY_ROOT_ERROR_WOULD_REVERT_FINALIZED_SLOT", + FUTURE_SLOT = "DOWNLOAD_BY_ROOT_ERROR_FUTURE_SLOT", + NOT_LATER_THAN_PARENT = "DOWNLOAD_BY_ROOT_ERROR_NOT_LATER_THAN_PARENT", + UNKNOWN_PROPOSER = "DOWNLOAD_BY_ROOT_ERROR_UNKNOWN_PROPOSER", + PROPOSAL_SIGNATURE_INVALID = "DOWNLOAD_BY_ROOT_ERROR_PROPOSAL_SIGNATURE_INVALID", Z = "DOWNLOAD_BY_ROOT_ERROR_Z", } + +type TerminalDownloadByRootErrorCode = + | DownloadByRootErrorCode.WOULD_REVERT_FINALIZED_SLOT + | DownloadByRootErrorCode.FUTURE_SLOT + | DownloadByRootErrorCode.NOT_LATER_THAN_PARENT + | DownloadByRootErrorCode.UNKNOWN_PROPOSER + | DownloadByRootErrorCode.PROPOSAL_SIGNATURE_INVALID; + +/** + * PeerAction to report the serving peer with, per terminal error code. Terminal means we don't want to download + * the same block from another peer. + * + */ +const terminalErrorPeerAction: Record = { + [DownloadByRootErrorCode.WOULD_REVERT_FINALIZED_SLOT]: PeerAction.LowToleranceError, + [DownloadByRootErrorCode.FUTURE_SLOT]: PeerAction.LowToleranceError, + [DownloadByRootErrorCode.NOT_LATER_THAN_PARENT]: PeerAction.LowToleranceError, + [DownloadByRootErrorCode.UNKNOWN_PROPOSER]: PeerAction.Fatal, + [DownloadByRootErrorCode.PROPOSAL_SIGNATURE_INVALID]: PeerAction.Fatal, +}; + +export type TerminalDownloadByRootError = { + code: TerminalDownloadByRootErrorCode; + peerAction: PeerAction; + peerIdStr: PeerIdStr; +}; + +/** Returns null when the error is retryable with another peer */ +export function getTerminalDownloadByRootError(e: unknown): TerminalDownloadByRootError | null { + if (!(e instanceof DownloadByRootError)) { + return null; + } + switch (e.type.code) { + case DownloadByRootErrorCode.WOULD_REVERT_FINALIZED_SLOT: + case DownloadByRootErrorCode.FUTURE_SLOT: + case DownloadByRootErrorCode.NOT_LATER_THAN_PARENT: + case DownloadByRootErrorCode.UNKNOWN_PROPOSER: + case DownloadByRootErrorCode.PROPOSAL_SIGNATURE_INVALID: + return {code: e.type.code, peerAction: terminalErrorPeerAction[e.type.code], peerIdStr: e.type.peerIdStr}; + default: + return null; + } +} export type DownloadByRootErrorType = | { code: DownloadByRootErrorCode.MISMATCH_BLOCK_ROOT; @@ -550,6 +695,45 @@ export type DownloadByRootErrorType = code: DownloadByRootErrorCode.MISSING_COLUMN_RESPONSE; peer: string; blockRoot: string; + } + | { + code: DownloadByRootErrorCode.WOULD_REVERT_FINALIZED_SLOT; + peer: string; + peerIdStr: PeerIdStr; + slot: Slot; + blockRoot: string; + finalizedSlot: Slot; + } + | { + code: DownloadByRootErrorCode.FUTURE_SLOT; + peer: string; + peerIdStr: PeerIdStr; + slot: Slot; + blockRoot: string; + currentSlot: Slot; + } + | { + code: DownloadByRootErrorCode.NOT_LATER_THAN_PARENT; + peer: string; + peerIdStr: PeerIdStr; + slot: Slot; + blockRoot: string; + parentSlot: Slot; + } + | { + code: DownloadByRootErrorCode.UNKNOWN_PROPOSER; + peer: string; + peerIdStr: PeerIdStr; + slot: Slot; + blockRoot: string; + proposerIndex: number; + } + | { + code: DownloadByRootErrorCode.PROPOSAL_SIGNATURE_INVALID; + peer: string; + peerIdStr: PeerIdStr; + slot: Slot; + blockRoot: string; }; export class DownloadByRootError extends LodestarError {} diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index ca79ce3cfd21..44caf4a9ac9c 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -285,8 +285,8 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { id: "downloaded parent is before finalized slot", event: ChainEvent.blockUnknownParent, finalizedSlot: 2, - // Peer reporting is currently disabled in source (commented out in removeAndDownScoreAllDescendants) - // Test verifies blocks are cleaned up from pendingBlocks instead + // The below-finalized download is terminal: the serving peer is reported and the referencing + // blocks are cleaned up from pendingBlocks reportPeer: true, }, { @@ -370,6 +370,7 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { }); let sendBeaconBlocksByRootCallCount = 0; + const reportPeerFn = vi.fn(); const networkEvents = new NetworkEventBus(); const network: Partial = { events: networkEvents, @@ -396,6 +397,7 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { .filter(notNullish); return wrongBlockRoot ? [ssz.gloas.SignedBeaconBlock.defaultValue()] : correctBlocks; }, + reportPeer: reportPeerFn, }; const forkChoiceKnownRoots = new Set([blockRootHex0]); @@ -420,9 +422,11 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { slot: finalizedSlot, }) as ProtoBlock, }; - const seenBlockProposers: Pick = { + const seenBlockProposers: Pick = { // only return seenBlock for blockC isKnown: (blockSlot) => (blockSlot === blockC.message.slot ? seenBlock : false), + observeBlockRoot: () => {}, + add: () => {}, }; const blockAResolver: () => void = () => {}; @@ -434,9 +438,11 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { const emitter = new ChainEventEmitter(); const chain: Partial = { emitter, - clock: new ClockStopped(0), + // stopped at blockC's slot so downloaded blocks pass the future-slot check + clock: new ClockStopped(blockC.message.slot), forkChoice: forkChoice as IForkChoice, genesisTime: 0, + pubkeyCache: {size: 1_000_000} as unknown as IBeaconChain["pubkeyCache"], custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, processBlock: async (blockInput, opts) => { const block = blockInput.getBlock(); @@ -486,6 +492,8 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { source, }), prune: () => {}, + // treat every downloaded block's proposer signature as already verified + isVerifiedProposerSignature: () => true, } as unknown as SeenBlockInput, seenPayloadEnvelopeInputCache: { add: vi.fn(), @@ -545,9 +553,10 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { // Wait for the network request to happen, then allow async processing to complete await sendBeaconBlocksByRootPromise; await sleep(200); - // Downloaded block is before finalized slot, so blocks should be cleaned up - // (peer reporting is currently disabled in removeAndDownScoreAllDescendants) + // Downloaded block is before finalized slot (terminal), so blocks are cleaned up and the + // serving peer is reported expect(processBlockSpy).not.toHaveBeenCalled(); + expect(reportPeerFn).toHaveBeenCalled(); } else if (maxPendingBlocks !== undefined) { // With maxPendingBlocks=1 and unknownParent event, the scheduler can re-queue one pruned // parent root at a time, so it partially recovers the chain. It still cannot retain enough @@ -698,7 +707,7 @@ describe("UnknownBlockSync", () => { const processBlock = vi.fn().mockRejectedValue(error(block)); const chainForTest = { emitter: new ChainEventEmitter(), - clock: new ClockStopped(0), + clock: new ClockStopped(block.message.slot), forkChoice: { hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), getBlockHexDefaultStatus: vi @@ -708,6 +717,7 @@ describe("UnknownBlockSync", () => { getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), } as unknown as IForkChoice, genesisTime: 0, + pubkeyCache: {size: 1_000_000} as unknown as IBeaconChain["pubkeyCache"], custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, processBlock, seenBlockInputCache: { @@ -734,8 +744,13 @@ describe("UnknownBlockSync", () => { peerIdStr, }), prune: vi.fn(), + isVerifiedProposerSignature: vi.fn().mockReturnValue(true), } as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + seenBlockProposers: { + isKnown: vi.fn().mockReturnValue(false), + observeBlockRoot: vi.fn(), + add: vi.fn(), + } as unknown as SeenBlockProposers, seenPayloadEnvelopeInputCache: { add: vi.fn(), get: vi.fn().mockReturnValue(undefined), @@ -815,11 +830,13 @@ describe("UnknownBlockSync", () => { const chain = { emitter, - clock: new ClockStopped(0), + // payload sync fixtures use slot 1 blocks, keep the clock ahead of them for the future-slot check + clock: new ClockStopped(1), config: gloasConfig, custodyConfig, genesisTime: 0, metrics: null, + pubkeyCache: {size: 1_000_000} as unknown as IBeaconChain["pubkeyCache"], serializedCache: {delete: vi.fn()} as unknown as IBeaconChain["serializedCache"], getBlockByRoot: vi.fn().mockResolvedValue(null), processExecutionPayload: vi.fn().mockResolvedValue(undefined), @@ -829,8 +846,15 @@ describe("UnknownBlockSync", () => { getOrReload: vi.fn().mockResolvedValue(undefined), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + seenBlockInputCache: { + prune: vi.fn(), + isVerifiedProposerSignature: vi.fn().mockReturnValue(true), + } as unknown as SeenBlockInput, + seenBlockProposers: { + isKnown: vi.fn().mockReturnValue(false), + observeBlockRoot: vi.fn(), + add: vi.fn(), + } as unknown as SeenBlockProposers, forkChoice: { hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), hasBlockHex: vi.fn().mockReturnValue(false), @@ -1135,6 +1159,7 @@ describe("UnknownBlockSync", () => { source, }), prune: vi.fn(), + isVerifiedProposerSignature: vi.fn().mockReturnValue(true), } as unknown as SeenBlockInput, forkChoice: { hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), @@ -1242,6 +1267,7 @@ describe("UnknownBlockSync", () => { source, }), prune: vi.fn(), + isVerifiedProposerSignature: vi.fn().mockReturnValue(true), } as unknown as SeenBlockInput, forkChoice: { hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), @@ -1350,6 +1376,7 @@ describe("UnknownBlockSync", () => { source, }), prune: vi.fn(), + isVerifiedProposerSignature: vi.fn().mockReturnValue(true), } as unknown as SeenBlockInput, forkChoice: { hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), @@ -2118,10 +2145,11 @@ describe("UnknownBlockSync", () => { const chainForTest: Partial = { emitter: new ChainEventEmitter(), config: gloasConfig, - clock: new ClockStopped(0), + clock: new ClockStopped(block.message.slot), custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, genesisTime: 0, metrics: null, + pubkeyCache: {size: 1_000_000} as unknown as IBeaconChain["pubkeyCache"], processBlock, forkChoice: { getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), @@ -2142,9 +2170,14 @@ describe("UnknownBlockSync", () => { getOrReload: vi.fn().mockResolvedValue(undefined), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockInputCache: { + prune: vi.fn(), + isVerifiedProposerSignature: vi.fn().mockReturnValue(true), + } as unknown as SeenBlockInput, seenBlockProposers: { isKnown: vi.fn().mockReturnValue(false), + observeBlockRoot: vi.fn(), + add: vi.fn(), } as unknown as SeenBlockProposers, }; diff --git a/packages/beacon-node/test/unit/sync/utils/downloadByRoot.test.ts b/packages/beacon-node/test/unit/sync/utils/downloadByRoot.test.ts index 209756e312a9..1011060b6654 100644 --- a/packages/beacon-node/test/unit/sync/utils/downloadByRoot.test.ts +++ b/packages/beacon-node/test/unit/sync/utils/downloadByRoot.test.ts @@ -52,6 +52,7 @@ describe("downloadByRoot.ts", () => { const response = await fetchAndValidateBlock({ config, + chain: null, network, peerIdStr, blockRoot: capellaBlock.blockRoot, @@ -68,6 +69,7 @@ describe("downloadByRoot.ts", () => { await expect( fetchAndValidateBlock({ config, + chain: null, network, peerIdStr, blockRoot: capellaBlock.blockRoot, @@ -85,6 +87,7 @@ describe("downloadByRoot.ts", () => { await expect( fetchAndValidateBlock({ config, + chain: null, network, peerIdStr, blockRoot: invalidRoot,