From dccaafe9e4ef32e5e5633e7fabf487147778d756 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 5 May 2026 14:59:07 -0400 Subject: [PATCH 1/4] feat: add beacon_blocks_by_head reqresp --- packages/beacon-node/src/network/interface.ts | 2 + packages/beacon-node/src/network/network.ts | 10 + .../src/network/reqresp/ReqRespBeaconNode.ts | 1 + .../reqresp/handlers/beaconBlocksByHead.ts | 86 ++++++++ .../src/network/reqresp/handlers/index.ts | 6 + .../src/network/reqresp/interface.ts | 2 +- .../src/network/reqresp/protocols.ts | 6 + .../src/network/reqresp/rateLimit.ts | 4 + .../beacon-node/src/network/reqresp/score.ts | 1 + .../beacon-node/src/network/reqresp/types.ts | 7 + packages/beacon-node/src/util/types.ts | 9 + .../handlers/beaconBlocksByHead.test.ts | 200 ++++++++++++++++++ packages/reqresp/src/interface.ts | 2 +- packages/types/src/fulu/sszTypes.ts | 8 + packages/types/src/fulu/types.ts | 1 + 15 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts create mode 100644 packages/beacon-node/test/unit/network/reqresp/handlers/beaconBlocksByHead.test.ts diff --git a/packages/beacon-node/src/network/interface.ts b/packages/beacon-node/src/network/interface.ts index 59fd922fc38e..a3c7c9c2c30f 100644 --- a/packages/beacon-node/src/network/interface.ts +++ b/packages/beacon-node/src/network/interface.ts @@ -39,6 +39,7 @@ import {BlockInputSource} from "../chain/blocks/blockInput/types.js"; import {CustodyConfig} from "../util/dataColumns.js"; import {PeerIdStr} from "../util/peerId.js"; import { + BeaconBlocksByHeadRequest, BeaconBlocksByRootRequest, BlobSidecarsByRootRequest, DataColumnSidecarsByRootRequest, @@ -78,6 +79,7 @@ export interface INetwork extends INetworkCorePublic { // ReqResp sendBeaconBlocksByRange(peerId: PeerIdStr, request: phase0.BeaconBlocksByRangeRequest): Promise; sendBeaconBlocksByRoot(peerId: PeerIdStr, request: BeaconBlocksByRootRequest): Promise; + sendBeaconBlocksByHead(peerId: PeerIdStr, request: BeaconBlocksByHeadRequest): Promise; sendBlobSidecarsByRange(peerId: PeerIdStr, request: deneb.BlobSidecarsByRangeRequest): Promise; sendBlobSidecarsByRoot(peerId: PeerIdStr, request: BlobSidecarsByRootRequest): Promise; sendDataColumnSidecarsByRange( diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 24e2fd632539..062b8d45fdd5 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -40,6 +40,7 @@ import {CustodyConfig} from "../util/dataColumns.js"; import {PeerIdStr, peerIdToString} from "../util/peerId.js"; import {promiseAllMaybeAsync} from "../util/promises.js"; import { + BeaconBlocksByHeadRequest, BeaconBlocksByRootRequest, BlobSidecarsByRootRequest, DataColumnSidecarsByRootRequest, @@ -578,6 +579,15 @@ export class Network implements INetwork { ); } + async sendBeaconBlocksByHead(peerId: PeerIdStr, request: BeaconBlocksByHeadRequest): Promise { + return collectMaxResponseTypedWithBytes( + this.sendReqRespRequest(peerId, ReqRespMethod.BeaconBlocksByHead, [Version.V1], request), + Math.min(request.count, this.config.MAX_REQUEST_BLOCKS_DENEB), + responseSszTypeByMethod[ReqRespMethod.BeaconBlocksByHead], + this.chain.serializedCache + ); + } + async sendLightClientBootstrap(peerId: PeerIdStr, request: Root): Promise { return collectExactOneTyped( this.sendReqRespRequest(peerId, ReqRespMethod.LightClientBootstrap, [Version.V1], request), diff --git a/packages/beacon-node/src/network/reqresp/ReqRespBeaconNode.ts b/packages/beacon-node/src/network/reqresp/ReqRespBeaconNode.ts index 5aba7277cbac..62e75b74842d 100644 --- a/packages/beacon-node/src/network/reqresp/ReqRespBeaconNode.ts +++ b/packages/beacon-node/src/network/reqresp/ReqRespBeaconNode.ts @@ -286,6 +286,7 @@ export class ReqRespBeaconNode extends ReqResp { // instead of protocol version. This is not easily fixable with our current architecture. // See https://github.com/ChainSafe/lodestar/pull/8168 for more details. [protocols.StatusV2(fork, this.config), this.onStatus.bind(this)], + [protocols.BeaconBlocksByHead(fork, this.config), this.getHandler(ReqRespMethod.BeaconBlocksByHead)], [ protocols.DataColumnSidecarsByRoot(fork, this.config), this.getHandler(ReqRespMethod.DataColumnSidecarsByRoot), diff --git a/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts b/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts new file mode 100644 index 000000000000..8906df8fbec9 --- /dev/null +++ b/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts @@ -0,0 +1,86 @@ +import {PeerId} from "@libp2p/interface"; +import {BeaconConfig} from "@lodestar/config"; +import {GENESIS_EPOCH, GENESIS_SLOT} from "@lodestar/params"; +import {RespStatus, ResponseError, ResponseOutgoing} from "@lodestar/reqresp"; +import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {toRootHex} from "@lodestar/utils"; +import {IBeaconChain} from "../../../chain/index.js"; +import {getParentRootFromSignedBeaconBlockSerialized} from "../../../util/sszBytes.js"; +import {BeaconBlocksByHeadRequest} from "../../../util/types.js"; +import {prettyPrintPeerId} from "../../util.js"; + +export async function* onBeaconBlocksByHead( + request: BeaconBlocksByHeadRequest, + chain: IBeaconChain, + peerId: PeerId, + peerClient: string +): AsyncIterable { + const {beaconRoot, count} = validateBeaconBlocksByHeadRequest(chain.config, request); + + const requestedRootHex = toRootHex(beaconRoot); + let blockRootHex = requestedRootHex; + const minimumRequestEpoch = Math.max( + GENESIS_EPOCH, + chain.clock.currentEpoch - chain.config.MIN_EPOCHS_FOR_BLOCK_REQUESTS + ); + const minimumRequestSlot = computeStartSlotAtEpoch(minimumRequestEpoch); + + for (let blocksSent = 0; blocksSent < count; blocksSent++) { + const blockBytes = await chain.getSerializedBlockByRoot(blockRootHex); + if (!blockBytes) { + if (blocksSent === 0) { + throw new ResponseError(RespStatus.RESOURCE_UNAVAILABLE, `Unknown block root ${requestedRootHex}`); + } + return; + } + + if (blockBytes.slot < minimumRequestSlot) { + if (blocksSent === 0) { + chain.logger.verbose("Peer requested unavailable block for BeaconBlocksByHead", { + peer: prettyPrintPeerId(peerId), + client: peerClient, + requestedRoot: requestedRootHex, + slot: blockBytes.slot, + minimumRequestSlot, + }); + } + return; + } + + yield { + data: blockBytes.block, + boundary: chain.config.getForkBoundaryAtEpoch(computeEpochAtSlot(blockBytes.slot)), + }; + + if (blockBytes.slot === GENESIS_SLOT) { + return; + } + + const parentRootHex = getParentRootFromSignedBeaconBlockSerialized(blockBytes.block); + if (parentRootHex === null) { + throw new ResponseError( + RespStatus.SERVER_ERROR, + `Invalid block bytes for root ${blockRootHex} slot ${blockBytes.slot}` + ); + } + blockRootHex = parentRootHex; + } +} + +export function validateBeaconBlocksByHeadRequest( + config: BeaconConfig, + request: BeaconBlocksByHeadRequest +): BeaconBlocksByHeadRequest { + const {beaconRoot} = request; + let {count} = request; + + if (count < 0) { + throw new ResponseError(RespStatus.INVALID_REQUEST, "count < 0"); + } + + if (count > config.MAX_REQUEST_BLOCKS_DENEB) { + count = config.MAX_REQUEST_BLOCKS_DENEB; + } + + return {beaconRoot, count}; +} diff --git a/packages/beacon-node/src/network/reqresp/handlers/index.ts b/packages/beacon-node/src/network/reqresp/handlers/index.ts index c5c6109741ea..5ef9aec23cfb 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/index.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/index.ts @@ -3,12 +3,14 @@ import {ssz} from "@lodestar/types"; import {IBeaconChain} from "../../../chain/index.js"; import {IBeaconDb} from "../../../db/index.js"; import { + BeaconBlocksByHeadRequestType, BeaconBlocksByRootRequestType, BlobSidecarsByRootRequestType, DataColumnSidecarsByRootRequestType, ExecutionPayloadEnvelopesByRootRequestType, } from "../../../util/types.js"; import {GetReqRespHandlerFn, ReqRespMethod} from "../types.js"; +import {onBeaconBlocksByHead} from "./beaconBlocksByHead.js"; import {onBeaconBlocksByRange} from "./beaconBlocksByRange.js"; import {onBeaconBlocksByRoot} from "./beaconBlocksByRoot.js"; import {onBlobSidecarsByRange} from "./blobSidecarsByRange.js"; @@ -47,6 +49,10 @@ export function getReqRespHandlers({db, chain}: {db: IBeaconDb; chain: IBeaconCh const body = BeaconBlocksByRootRequestType(fork, chain.config).deserialize(req.data); return onBeaconBlocksByRoot(body, chain); }, + [ReqRespMethod.BeaconBlocksByHead]: (req, peerId, peerClient) => { + const body = BeaconBlocksByHeadRequestType.deserialize(req.data); + return onBeaconBlocksByHead(body, chain, peerId, peerClient); + }, [ReqRespMethod.BlobSidecarsByRoot]: (req) => { const fork = chain.config.getForkName(chain.clock.currentSlot); const body = BlobSidecarsByRootRequestType(fork, chain.config).deserialize(req.data); diff --git a/packages/beacon-node/src/network/reqresp/interface.ts b/packages/beacon-node/src/network/reqresp/interface.ts index 0a9d2f59b3ad..33ce4a7b795e 100644 --- a/packages/beacon-node/src/network/reqresp/interface.ts +++ b/packages/beacon-node/src/network/reqresp/interface.ts @@ -33,7 +33,7 @@ export enum RespStatus { */ SERVER_ERROR = 2, /** - * The responder does not have requested resource. The response payload adheres to the ErrorMessage schema (described below). Note: This response code is only valid as a response to BlocksByRange + * The responder does not have requested resource. The response payload adheres to the ErrorMessage schema. */ RESOURCE_UNAVAILABLE = 3, /** diff --git a/packages/beacon-node/src/network/reqresp/protocols.ts b/packages/beacon-node/src/network/reqresp/protocols.ts index 17dfc84b4910..063339dd58f0 100644 --- a/packages/beacon-node/src/network/reqresp/protocols.ts +++ b/packages/beacon-node/src/network/reqresp/protocols.ts @@ -70,6 +70,12 @@ export const BeaconBlocksByRootV2 = toProtocol({ contextBytesType: ContextBytesType.ForkDigest, }); +export const BeaconBlocksByHead = toProtocol({ + method: ReqRespMethod.BeaconBlocksByHead, + version: Version.V1, + contextBytesType: ContextBytesType.ForkDigest, +}); + export const BlobSidecarsByRange = toProtocol({ method: ReqRespMethod.BlobSidecarsByRange, version: Version.V1, diff --git a/packages/beacon-node/src/network/reqresp/rateLimit.ts b/packages/beacon-node/src/network/reqresp/rateLimit.ts index 924ece0d6902..fc5d4e626625 100644 --- a/packages/beacon-node/src/network/reqresp/rateLimit.ts +++ b/packages/beacon-node/src/network/reqresp/rateLimit.ts @@ -40,6 +40,10 @@ export const rateLimitQuotas: (fork: ForkName, config: BeaconConfig) => Record req.length), }, + [ReqRespMethod.BeaconBlocksByHead]: { + byPeer: {quota: config.MAX_REQUEST_BLOCKS_DENEB, quotaTimeMs: 10_000}, + getRequestCount: getRequestCountFn(fork, config, ReqRespMethod.BeaconBlocksByHead, (req) => req.count), + }, [ReqRespMethod.BlobSidecarsByRange]: { // Rationale: MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK byPeer: { diff --git a/packages/beacon-node/src/network/reqresp/score.ts b/packages/beacon-node/src/network/reqresp/score.ts index 7ed5e7dd1bec..f60bd5b01f20 100644 --- a/packages/beacon-node/src/network/reqresp/score.ts +++ b/packages/beacon-node/src/network/reqresp/score.ts @@ -46,6 +46,7 @@ export function onOutgoingReqRespError(e: RequestError, method: ReqRespMethod): return PeerAction.LowToleranceError; case ReqRespMethod.BeaconBlocksByRange: case ReqRespMethod.BeaconBlocksByRoot: + case ReqRespMethod.BeaconBlocksByHead: case ReqRespMethod.ExecutionPayloadEnvelopesByRoot: case ReqRespMethod.ExecutionPayloadEnvelopesByRange: return PeerAction.MidToleranceError; diff --git a/packages/beacon-node/src/network/reqresp/types.ts b/packages/beacon-node/src/network/reqresp/types.ts index b31cf1401de5..061e88d84fe3 100644 --- a/packages/beacon-node/src/network/reqresp/types.ts +++ b/packages/beacon-node/src/network/reqresp/types.ts @@ -21,6 +21,8 @@ import { sszTypesFor, } from "@lodestar/types"; import { + BeaconBlocksByHeadRequest, + BeaconBlocksByHeadRequestType, BeaconBlocksByRootRequest, BeaconBlocksByRootRequestType, BlobSidecarsByRootRequest, @@ -42,6 +44,7 @@ export enum ReqRespMethod { Metadata = "metadata", BeaconBlocksByRange = "beacon_blocks_by_range", BeaconBlocksByRoot = "beacon_blocks_by_root", + BeaconBlocksByHead = "beacon_blocks_by_head", BlobSidecarsByRange = "blob_sidecars_by_range", BlobSidecarsByRoot = "blob_sidecars_by_root", DataColumnSidecarsByRange = "data_column_sidecars_by_range", @@ -62,6 +65,7 @@ export type RequestBodyByMethod = { [ReqRespMethod.Metadata]: null; [ReqRespMethod.BeaconBlocksByRange]: phase0.BeaconBlocksByRangeRequest; [ReqRespMethod.BeaconBlocksByRoot]: BeaconBlocksByRootRequest; + [ReqRespMethod.BeaconBlocksByHead]: BeaconBlocksByHeadRequest; [ReqRespMethod.BlobSidecarsByRange]: deneb.BlobSidecarsByRangeRequest; [ReqRespMethod.BlobSidecarsByRoot]: BlobSidecarsByRootRequest; [ReqRespMethod.DataColumnSidecarsByRange]: fulu.DataColumnSidecarsByRangeRequest; @@ -82,6 +86,7 @@ type ResponseBodyByMethod = { // Do not matter [ReqRespMethod.BeaconBlocksByRange]: SignedBeaconBlock; [ReqRespMethod.BeaconBlocksByRoot]: SignedBeaconBlock; + [ReqRespMethod.BeaconBlocksByHead]: SignedBeaconBlock; [ReqRespMethod.BlobSidecarsByRange]: deneb.BlobSidecar; [ReqRespMethod.BlobSidecarsByRoot]: deneb.BlobSidecar; [ReqRespMethod.DataColumnSidecarsByRange]: DataColumnSidecar; @@ -111,6 +116,7 @@ export const requestSszTypeByMethod: ( [ReqRespMethod.BeaconBlocksByRange]: ssz.phase0.BeaconBlocksByRangeRequest, [ReqRespMethod.BeaconBlocksByRoot]: BeaconBlocksByRootRequestType(fork, config), + [ReqRespMethod.BeaconBlocksByHead]: BeaconBlocksByHeadRequestType, [ReqRespMethod.BlobSidecarsByRange]: ssz.deneb.BlobSidecarsByRangeRequest, [ReqRespMethod.BlobSidecarsByRoot]: BlobSidecarsByRootRequestType(fork, config), [ReqRespMethod.DataColumnSidecarsByRange]: ssz.fulu.DataColumnSidecarsByRangeRequest, @@ -142,6 +148,7 @@ export const responseSszTypeByMethod: {[K in ReqRespMethod]: ResponseTypeGetter< version === Version.V1 ? ssz.phase0.Metadata : version === Version.V2 ? ssz.altair.Metadata : ssz.fulu.Metadata, [ReqRespMethod.BeaconBlocksByRange]: blocksResponseType, [ReqRespMethod.BeaconBlocksByRoot]: blocksResponseType, + [ReqRespMethod.BeaconBlocksByHead]: (fork) => ssz[fork].SignedBeaconBlock, [ReqRespMethod.BlobSidecarsByRange]: () => ssz.deneb.BlobSidecar, [ReqRespMethod.BlobSidecarsByRoot]: () => ssz.deneb.BlobSidecar, [ReqRespMethod.LightClientBootstrap]: (fork) => sszTypesFor(onlyPostAltairFork(fork)).LightClientBootstrap, diff --git a/packages/beacon-node/src/util/types.ts b/packages/beacon-node/src/util/types.ts index 10a26de4838f..cfc54a9ae77b 100644 --- a/packages/beacon-node/src/util/types.ts +++ b/packages/beacon-node/src/util/types.ts @@ -19,6 +19,15 @@ export const BeaconBlocksByRootRequestType = (fork: ForkName, config: BeaconConf new ListCompositeType(ssz.Root, isForkPostDeneb(fork) ? config.MAX_REQUEST_BLOCKS_DENEB : config.MAX_REQUEST_BLOCKS); export type BeaconBlocksByRootRequest = ValueOf>; +export const BeaconBlocksByHeadRequestType = new ContainerType( + { + beaconRoot: ssz.Root, + count: ssz.UintNum64, + }, + {typeName: "BeaconBlocksByHeadRequest", jsonCase: "eth2"} +); +export type BeaconBlocksByHeadRequest = ValueOf; + export const BlobSidecarsByRootRequestType = (fork: ForkName, config: BeaconConfig) => new ListCompositeType( ssz.deneb.BlobIdentifier, diff --git a/packages/beacon-node/test/unit/network/reqresp/handlers/beaconBlocksByHead.test.ts b/packages/beacon-node/test/unit/network/reqresp/handlers/beaconBlocksByHead.test.ts new file mode 100644 index 000000000000..0421aa5c0ca4 --- /dev/null +++ b/packages/beacon-node/test/unit/network/reqresp/handlers/beaconBlocksByHead.test.ts @@ -0,0 +1,200 @@ +import {PeerId} from "@libp2p/interface"; +import {describe, expect, it, vi} from "vitest"; +import {createBeaconConfig} from "@lodestar/config"; +import {chainConfig} from "@lodestar/config/default"; +import {ForkName} from "@lodestar/params"; +import {RespStatus} from "@lodestar/reqresp"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {SignedBeaconBlock, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {IBeaconChain} from "../../../../../src/chain/index.js"; +import {ZERO_HASH} from "../../../../../src/constants/index.js"; +import {onBeaconBlocksByHead} from "../../../../../src/network/reqresp/handlers/beaconBlocksByHead.js"; +import {ReqRespMethod, Version, responseSszTypeByMethod} from "../../../../../src/network/reqresp/types.js"; +import {getSlotFromSignedBeaconBlockSerialized} from "../../../../../src/util/sszBytes.js"; + +const config = createBeaconConfig( + { + ...chainConfig, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + MIN_EPOCHS_FOR_BLOCK_REQUESTS: 1, + }, + ZERO_HASH +); +const crossForkConfig = createBeaconConfig( + { + ...chainConfig, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 2, + MIN_EPOCHS_FOR_BLOCK_REQUESTS: 1, + }, + ZERO_HASH +); +const peerId = {toString: () => "test-peer"} as PeerId; + +describe("onBeaconBlocksByHead", () => { + it("returns blocks from head to ancestors in descending slot order", async () => { + const blocks = createBlocks([10, 11, 12]); + const chain = createChain(blocks); + + const responses = await Array.fromAsync( + onBeaconBlocksByHead({beaconRoot: blocks[2].root, count: 2}, chain, peerId, "test-client") + ); + + expect(responses.map((response) => getSlotFromSignedBeaconBlockSerialized(response.data))).toEqual([12, 11]); + expect(responses.map((response) => response.boundary.fork)).toEqual([ForkName.fulu, ForkName.fulu]); + expect(responses.map((response) => response.data)).toEqual([blocks[2].bytes, blocks[1].bytes]); + }); + + it("stops before ancestors older than the minimum request epoch", async () => { + const currentEpoch = 5; + const minimumRequestSlot = computeStartSlotAtEpoch(currentEpoch - config.MIN_EPOCHS_FOR_BLOCK_REQUESTS); + const blocks = createBlocks([minimumRequestSlot - 1, minimumRequestSlot, minimumRequestSlot + 1]); + const chain = createChain(blocks, currentEpoch); + + const responses = await Array.fromAsync( + onBeaconBlocksByHead({beaconRoot: blocks[2].root, count: 3}, chain, peerId, "test-client") + ); + + expect(responses.map((response) => getSlotFromSignedBeaconBlockSerialized(response.data))).toEqual([ + minimumRequestSlot + 1, + minimumRequestSlot, + ]); + }); + + it("continues across the Fulu fork boundary", async () => { + const fuluStartSlot = computeStartSlotAtEpoch(crossForkConfig.FULU_FORK_EPOCH); + const blocks = createBlocks([fuluStartSlot - 1, fuluStartSlot], crossForkConfig); + const chain = createChain(blocks, crossForkConfig.FULU_FORK_EPOCH, crossForkConfig); + + const responses = await Array.fromAsync( + onBeaconBlocksByHead({beaconRoot: blocks[1].root, count: 2}, chain, peerId, "test-client") + ); + + expect(responses.map((response) => getSlotFromSignedBeaconBlockSerialized(response.data))).toEqual([ + fuluStartSlot, + fuluStartSlot - 1, + ]); + expect(responses.map((response) => response.boundary.fork)).toEqual([ForkName.fulu, ForkName.electra]); + }); + + it("caps responses to MAX_REQUEST_BLOCKS_DENEB", async () => { + const blocks = createBlocks(Array.from({length: config.MAX_REQUEST_BLOCKS_DENEB + 2}, (_, i) => i + 1)); + const chain = createChain(blocks); + const head = blocks.at(-1); + if (!head) { + throw Error("Expected at least one block"); + } + + const responses = await Array.fromAsync( + onBeaconBlocksByHead( + {beaconRoot: head.root, count: config.MAX_REQUEST_BLOCKS_DENEB + 10}, + chain, + peerId, + "test-client" + ) + ); + + expect(responses).toHaveLength(config.MAX_REQUEST_BLOCKS_DENEB); + }); + + it("returns no blocks when the requested root is older than the minimum request epoch", async () => { + const currentEpoch = 5; + const minimumRequestSlot = computeStartSlotAtEpoch(currentEpoch - config.MIN_EPOCHS_FOR_BLOCK_REQUESTS); + const blocks = createBlocks([minimumRequestSlot - 1, minimumRequestSlot, minimumRequestSlot + 1]); + const chain = createChain(blocks, currentEpoch); + + const responses = await Array.fromAsync( + onBeaconBlocksByHead({beaconRoot: blocks[0].root, count: 1}, chain, peerId, "test-client") + ); + + expect(responses).toEqual([]); + expect(chain.logger.verbose).toHaveBeenCalledOnce(); + }); + + it("allows zero-count requests", async () => { + const blocks = createBlocks([10]); + const chain = createChain(blocks); + + const responses = await Array.fromAsync( + onBeaconBlocksByHead({beaconRoot: blocks[0].root, count: 0}, chain, peerId, "test-client") + ); + + expect(responses).toEqual([]); + expect(chain.getSerializedBlockByRoot).not.toHaveBeenCalled(); + }); + + it("returns ResourceUnavailable when the requested root is unknown", async () => { + const blocks = createBlocks([10]); + const chain = createChain(blocks); + const unknownRoot = new Uint8Array(32).fill(0xab); + + await expect( + Array.fromAsync(onBeaconBlocksByHead({beaconRoot: unknownRoot, count: 1}, chain, peerId, "test-client")) + ).rejects.toMatchObject({status: RespStatus.RESOURCE_UNAVAILABLE}); + }); + + it("decodes BlocksByHead responses with the block fork type", () => { + expect(responseSszTypeByMethod[ReqRespMethod.BeaconBlocksByHead](ForkName.electra, Version.V1)).toBe( + ssz.electra.SignedBeaconBlock + ); + expect(responseSszTypeByMethod[ReqRespMethod.BeaconBlocksByHead](ForkName.fulu, Version.V1)).toBe( + ssz.fulu.SignedBeaconBlock + ); + }); +}); + +type TestBlock = { + block: SignedBeaconBlock; + bytes: Uint8Array; + root: Uint8Array; + rootHex: string; + slot: number; +}; + +function createBlocks(slots: number[], testConfig = config): TestBlock[] { + let parentRoot: Uint8Array = ZERO_HASH; + const blocks: TestBlock[] = []; + + for (const slot of slots) { + const forkTypes = testConfig.getForkTypes(slot); + const block = forkTypes.SignedBeaconBlock.defaultValue(); + block.message.slot = slot; + block.message.parentRoot = parentRoot; + + const root = forkTypes.BeaconBlock.hashTreeRoot(block.message); + blocks.push({ + block, + bytes: forkTypes.SignedBeaconBlock.serialize(block), + root, + rootHex: toRootHex(root), + slot, + }); + parentRoot = root; + } + + return blocks; +} + +function createChain(blocks: TestBlock[], currentEpoch = 0, testConfig = config): IBeaconChain { + const blocksByRoot = new Map(blocks.map((block) => [block.rootHex, block])); + + return { + config: testConfig, + clock: {currentEpoch}, + getSerializedBlockByRoot: vi.fn(async (rootHex: string) => { + const block = blocksByRoot.get(rootHex); + return block ? {block: block.bytes, executionOptimistic: false, finalized: false, slot: block.slot} : null; + }), + logger: {verbose: vi.fn()}, + } as unknown as IBeaconChain; +} diff --git a/packages/reqresp/src/interface.ts b/packages/reqresp/src/interface.ts index 1664b84d916c..beda796b2b23 100644 --- a/packages/reqresp/src/interface.ts +++ b/packages/reqresp/src/interface.ts @@ -14,7 +14,7 @@ export enum RespStatus { */ SERVER_ERROR = 2, /** - * The responder does not have requested resource. The response payload adheres to the ErrorMessage schema (described below). Note: This response code is only valid as a response to BlocksByRange + * The responder does not have requested resource. The response payload adheres to the ErrorMessage schema. */ RESOURCE_UNAVAILABLE = 3, /** diff --git a/packages/types/src/fulu/sszTypes.ts b/packages/types/src/fulu/sszTypes.ts index 282d848f9f60..4f5556cf5875 100644 --- a/packages/types/src/fulu/sszTypes.ts +++ b/packages/types/src/fulu/sszTypes.ts @@ -97,6 +97,14 @@ export const DataColumnSidecarsByRangeRequest = new ContainerType( {typeName: "DataColumnSidecarsByRangeRequest", jsonCase: "eth2"} ); +export const BeaconBlocksByHeadRequest = new ContainerType( + { + beaconRoot: Root, + count: UintNum64, + }, + {typeName: "BeaconBlocksByHeadRequest", jsonCase: "eth2"} +); + // Explicit aliases for a few common types export const BeaconBlock = electraSsz.BeaconBlock; export const SignedBeaconBlock = electraSsz.SignedBeaconBlock; diff --git a/packages/types/src/fulu/types.ts b/packages/types/src/fulu/types.ts index 92e82db293c3..cd2c01c3bb97 100644 --- a/packages/types/src/fulu/types.ts +++ b/packages/types/src/fulu/types.ts @@ -19,6 +19,7 @@ export type ProposerLookahead = ValueOf; export type DataColumnsByRootIdentifier = ValueOf; export type DataColumnSidecarsByRangeRequest = ValueOf; +export type BeaconBlocksByHeadRequest = ValueOf; export type BeaconBlock = ValueOf; export type SignedBeaconBlock = ValueOf; export type BeaconState = ValueOf; From 47dbc7811f10036ce77cf5b3715d3a62139a6da3 Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 7 May 2026 16:31:26 -0400 Subject: [PATCH 2/4] chore: remove duplicate ssz type --- packages/beacon-node/src/network/interface.ts | 3 +-- packages/beacon-node/src/network/network.ts | 6 ++++-- .../src/network/reqresp/handlers/beaconBlocksByHead.ts | 8 ++++---- .../beacon-node/src/network/reqresp/handlers/index.ts | 3 +-- packages/beacon-node/src/network/reqresp/types.ts | 6 ++---- packages/beacon-node/src/util/types.ts | 9 --------- 6 files changed, 12 insertions(+), 23 deletions(-) diff --git a/packages/beacon-node/src/network/interface.ts b/packages/beacon-node/src/network/interface.ts index a3c7c9c2c30f..4c3528bff638 100644 --- a/packages/beacon-node/src/network/interface.ts +++ b/packages/beacon-node/src/network/interface.ts @@ -39,7 +39,6 @@ import {BlockInputSource} from "../chain/blocks/blockInput/types.js"; import {CustodyConfig} from "../util/dataColumns.js"; import {PeerIdStr} from "../util/peerId.js"; import { - BeaconBlocksByHeadRequest, BeaconBlocksByRootRequest, BlobSidecarsByRootRequest, DataColumnSidecarsByRootRequest, @@ -79,7 +78,7 @@ export interface INetwork extends INetworkCorePublic { // ReqResp sendBeaconBlocksByRange(peerId: PeerIdStr, request: phase0.BeaconBlocksByRangeRequest): Promise; sendBeaconBlocksByRoot(peerId: PeerIdStr, request: BeaconBlocksByRootRequest): Promise; - sendBeaconBlocksByHead(peerId: PeerIdStr, request: BeaconBlocksByHeadRequest): Promise; + sendBeaconBlocksByHead(peerId: PeerIdStr, request: fulu.BeaconBlocksByHeadRequest): Promise; sendBlobSidecarsByRange(peerId: PeerIdStr, request: deneb.BlobSidecarsByRangeRequest): Promise; sendBlobSidecarsByRoot(peerId: PeerIdStr, request: BlobSidecarsByRootRequest): Promise; sendDataColumnSidecarsByRange( diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 062b8d45fdd5..3fad5fb25dac 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -40,7 +40,6 @@ import {CustodyConfig} from "../util/dataColumns.js"; import {PeerIdStr, peerIdToString} from "../util/peerId.js"; import {promiseAllMaybeAsync} from "../util/promises.js"; import { - BeaconBlocksByHeadRequest, BeaconBlocksByRootRequest, BlobSidecarsByRootRequest, DataColumnSidecarsByRootRequest, @@ -579,7 +578,10 @@ export class Network implements INetwork { ); } - async sendBeaconBlocksByHead(peerId: PeerIdStr, request: BeaconBlocksByHeadRequest): Promise { + async sendBeaconBlocksByHead( + peerId: PeerIdStr, + request: fulu.BeaconBlocksByHeadRequest + ): Promise { return collectMaxResponseTypedWithBytes( this.sendReqRespRequest(peerId, ReqRespMethod.BeaconBlocksByHead, [Version.V1], request), Math.min(request.count, this.config.MAX_REQUEST_BLOCKS_DENEB), diff --git a/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts b/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts index 8906df8fbec9..034336bf7e37 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts @@ -3,14 +3,14 @@ import {BeaconConfig} from "@lodestar/config"; import {GENESIS_EPOCH, GENESIS_SLOT} from "@lodestar/params"; import {RespStatus, ResponseError, ResponseOutgoing} from "@lodestar/reqresp"; import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {fulu} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {IBeaconChain} from "../../../chain/index.js"; import {getParentRootFromSignedBeaconBlockSerialized} from "../../../util/sszBytes.js"; -import {BeaconBlocksByHeadRequest} from "../../../util/types.js"; import {prettyPrintPeerId} from "../../util.js"; export async function* onBeaconBlocksByHead( - request: BeaconBlocksByHeadRequest, + request: fulu.BeaconBlocksByHeadRequest, chain: IBeaconChain, peerId: PeerId, peerClient: string @@ -69,8 +69,8 @@ export async function* onBeaconBlocksByHead( export function validateBeaconBlocksByHeadRequest( config: BeaconConfig, - request: BeaconBlocksByHeadRequest -): BeaconBlocksByHeadRequest { + request: fulu.BeaconBlocksByHeadRequest +): fulu.BeaconBlocksByHeadRequest { const {beaconRoot} = request; let {count} = request; diff --git a/packages/beacon-node/src/network/reqresp/handlers/index.ts b/packages/beacon-node/src/network/reqresp/handlers/index.ts index 5ef9aec23cfb..e5537e3fa272 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/index.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/index.ts @@ -3,7 +3,6 @@ import {ssz} from "@lodestar/types"; import {IBeaconChain} from "../../../chain/index.js"; import {IBeaconDb} from "../../../db/index.js"; import { - BeaconBlocksByHeadRequestType, BeaconBlocksByRootRequestType, BlobSidecarsByRootRequestType, DataColumnSidecarsByRootRequestType, @@ -50,7 +49,7 @@ export function getReqRespHandlers({db, chain}: {db: IBeaconDb; chain: IBeaconCh return onBeaconBlocksByRoot(body, chain); }, [ReqRespMethod.BeaconBlocksByHead]: (req, peerId, peerClient) => { - const body = BeaconBlocksByHeadRequestType.deserialize(req.data); + const body = ssz.fulu.BeaconBlocksByHeadRequest.deserialize(req.data); return onBeaconBlocksByHead(body, chain, peerId, peerClient); }, [ReqRespMethod.BlobSidecarsByRoot]: (req) => { diff --git a/packages/beacon-node/src/network/reqresp/types.ts b/packages/beacon-node/src/network/reqresp/types.ts index 061e88d84fe3..3435042e02db 100644 --- a/packages/beacon-node/src/network/reqresp/types.ts +++ b/packages/beacon-node/src/network/reqresp/types.ts @@ -21,8 +21,6 @@ import { sszTypesFor, } from "@lodestar/types"; import { - BeaconBlocksByHeadRequest, - BeaconBlocksByHeadRequestType, BeaconBlocksByRootRequest, BeaconBlocksByRootRequestType, BlobSidecarsByRootRequest, @@ -65,7 +63,7 @@ export type RequestBodyByMethod = { [ReqRespMethod.Metadata]: null; [ReqRespMethod.BeaconBlocksByRange]: phase0.BeaconBlocksByRangeRequest; [ReqRespMethod.BeaconBlocksByRoot]: BeaconBlocksByRootRequest; - [ReqRespMethod.BeaconBlocksByHead]: BeaconBlocksByHeadRequest; + [ReqRespMethod.BeaconBlocksByHead]: fulu.BeaconBlocksByHeadRequest; [ReqRespMethod.BlobSidecarsByRange]: deneb.BlobSidecarsByRangeRequest; [ReqRespMethod.BlobSidecarsByRoot]: BlobSidecarsByRootRequest; [ReqRespMethod.DataColumnSidecarsByRange]: fulu.DataColumnSidecarsByRangeRequest; @@ -116,7 +114,7 @@ export const requestSszTypeByMethod: ( [ReqRespMethod.BeaconBlocksByRange]: ssz.phase0.BeaconBlocksByRangeRequest, [ReqRespMethod.BeaconBlocksByRoot]: BeaconBlocksByRootRequestType(fork, config), - [ReqRespMethod.BeaconBlocksByHead]: BeaconBlocksByHeadRequestType, + [ReqRespMethod.BeaconBlocksByHead]: ssz.fulu.BeaconBlocksByHeadRequest, [ReqRespMethod.BlobSidecarsByRange]: ssz.deneb.BlobSidecarsByRangeRequest, [ReqRespMethod.BlobSidecarsByRoot]: BlobSidecarsByRootRequestType(fork, config), [ReqRespMethod.DataColumnSidecarsByRange]: ssz.fulu.DataColumnSidecarsByRangeRequest, diff --git a/packages/beacon-node/src/util/types.ts b/packages/beacon-node/src/util/types.ts index cfc54a9ae77b..10a26de4838f 100644 --- a/packages/beacon-node/src/util/types.ts +++ b/packages/beacon-node/src/util/types.ts @@ -19,15 +19,6 @@ export const BeaconBlocksByRootRequestType = (fork: ForkName, config: BeaconConf new ListCompositeType(ssz.Root, isForkPostDeneb(fork) ? config.MAX_REQUEST_BLOCKS_DENEB : config.MAX_REQUEST_BLOCKS); export type BeaconBlocksByRootRequest = ValueOf>; -export const BeaconBlocksByHeadRequestType = new ContainerType( - { - beaconRoot: ssz.Root, - count: ssz.UintNum64, - }, - {typeName: "BeaconBlocksByHeadRequest", jsonCase: "eth2"} -); -export type BeaconBlocksByHeadRequest = ValueOf; - export const BlobSidecarsByRootRequestType = (fork: ForkName, config: BeaconConfig) => new ListCompositeType( ssz.deneb.BlobIdentifier, From 4774e87ec21e74ec82f7ac57a218c86015dfc885 Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 7 May 2026 16:44:47 -0400 Subject: [PATCH 3/4] chore: more pr review --- .../reqresp/handlers/beaconBlocksByHead.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts b/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts index 034336bf7e37..5d22e8fa2d20 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/beaconBlocksByHead.ts @@ -1,6 +1,6 @@ import {PeerId} from "@libp2p/interface"; import {BeaconConfig} from "@lodestar/config"; -import {GENESIS_EPOCH, GENESIS_SLOT} from "@lodestar/params"; +import {ForkName, GENESIS_EPOCH, GENESIS_SLOT, isForkPostDeneb} from "@lodestar/params"; import {RespStatus, ResponseError, ResponseOutgoing} from "@lodestar/reqresp"; import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {fulu} from "@lodestar/types"; @@ -9,13 +9,15 @@ import {IBeaconChain} from "../../../chain/index.js"; import {getParentRootFromSignedBeaconBlockSerialized} from "../../../util/sszBytes.js"; import {prettyPrintPeerId} from "../../util.js"; +// See https://github.com/ethereum/consensus-specs/pull/5181 export async function* onBeaconBlocksByHead( request: fulu.BeaconBlocksByHeadRequest, chain: IBeaconChain, peerId: PeerId, peerClient: string ): AsyncIterable { - const {beaconRoot, count} = validateBeaconBlocksByHeadRequest(chain.config, request); + const currentFork = chain.config.getForkName(chain.clock.currentSlot); + const {beaconRoot, count} = validateBeaconBlocksByHeadRequest(currentFork, chain.config, request); const requestedRootHex = toRootHex(beaconRoot); let blockRootHex = requestedRootHex; @@ -68,18 +70,21 @@ export async function* onBeaconBlocksByHead( } export function validateBeaconBlocksByHeadRequest( + fork: ForkName, config: BeaconConfig, request: fulu.BeaconBlocksByHeadRequest ): fulu.BeaconBlocksByHeadRequest { const {beaconRoot} = request; let {count} = request; - if (count < 0) { - throw new ResponseError(RespStatus.INVALID_REQUEST, "count < 0"); + if (count < 1) { + throw new ResponseError(RespStatus.INVALID_REQUEST, "count < 1"); } - if (count > config.MAX_REQUEST_BLOCKS_DENEB) { - count = config.MAX_REQUEST_BLOCKS_DENEB; + const maxRequestBlocks = isForkPostDeneb(fork) ? config.MAX_REQUEST_BLOCKS_DENEB : config.MAX_REQUEST_BLOCKS; + + if (count > maxRequestBlocks) { + count = maxRequestBlocks; } return {beaconRoot, count}; From 5ad6bb40dfb111d41980575bf57cacc59a2ca083 Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 11 May 2026 16:30:21 -0400 Subject: [PATCH 4/4] chore: fix unit test --- .../reqresp/handlers/beaconBlocksByHead.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/test/unit/network/reqresp/handlers/beaconBlocksByHead.test.ts b/packages/beacon-node/test/unit/network/reqresp/handlers/beaconBlocksByHead.test.ts index 0421aa5c0ca4..7498290f42c9 100644 --- a/packages/beacon-node/test/unit/network/reqresp/handlers/beaconBlocksByHead.test.ts +++ b/packages/beacon-node/test/unit/network/reqresp/handlers/beaconBlocksByHead.test.ts @@ -121,15 +121,13 @@ describe("onBeaconBlocksByHead", () => { expect(chain.logger.verbose).toHaveBeenCalledOnce(); }); - it("allows zero-count requests", async () => { + it("rejects zero-count requests", async () => { const blocks = createBlocks([10]); const chain = createChain(blocks); - const responses = await Array.fromAsync( - onBeaconBlocksByHead({beaconRoot: blocks[0].root, count: 0}, chain, peerId, "test-client") - ); - - expect(responses).toEqual([]); + await expect( + Array.fromAsync(onBeaconBlocksByHead({beaconRoot: blocks[0].root, count: 0}, chain, peerId, "test-client")) + ).rejects.toMatchObject({status: RespStatus.INVALID_REQUEST}); expect(chain.getSerializedBlockByRoot).not.toHaveBeenCalled(); }); @@ -190,7 +188,7 @@ function createChain(blocks: TestBlock[], currentEpoch = 0, testConfig = config) return { config: testConfig, - clock: {currentEpoch}, + clock: {currentEpoch, currentSlot: computeStartSlotAtEpoch(currentEpoch)}, getSerializedBlockByRoot: vi.fn(async (rootHex: string) => { const block = blocksByRoot.get(rootHex); return block ? {block: block.bytes, executionOptimistic: false, finalized: false, slot: block.slot} : null;