UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

1,326 lines (1,210 loc) • 44.6 kB
import {ChainForkConfig} from "@lodestar/config"; import { ForkPostDeneb, ForkPostFulu, ForkPostGloas, ForkPreFulu, isForkPostFulu, isForkPostGloas, } from "@lodestar/params"; import { ColumnIndex, DataColumnSidecar, RootHex, SignedBeaconBlock, Slot, deneb, fulu, gloas, isGloasDataColumnSidecar, phase0, } from "@lodestar/types"; import {LodestarError, Logger, byteArrayEquals, fromHex, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import { BlockInputSource, DAType, IBlockInput, isBlockInputBlobs, isBlockInputColumns, } from "../../chain/blocks/blockInput/index.js"; import {PayloadEnvelopeInput} from "../../chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/types.js"; import {SeenBlockInput} from "../../chain/seenCache/seenGossipBlockInput.js"; import {SeenPayloadEnvelopeInput} from "../../chain/seenCache/seenPayloadEnvelopeInput.js"; import {validateBlockBlobSidecars} from "../../chain/validation/blobSidecar.js"; import { validateFuluBlockDataColumnSidecars, validateGloasBlockDataColumnSidecars, } from "../../chain/validation/dataColumnSidecar.js"; import {BeaconMetrics} from "../../metrics/metrics/beacon.js"; import {INetwork} from "../../network/index.js"; import {CustodyConfig, getBlobKzgCommitments} from "../../util/dataColumns.js"; import {PeerIdStr} from "../../util/peerId.js"; import {WarnResult} from "../../util/wrapError.js"; export type DownloadByRangeRequests = { blocksRequest?: phase0.BeaconBlocksByRangeRequest; blobsRequest?: deneb.BlobSidecarsByRangeRequest; columnsRequest?: fulu.DataColumnSidecarsByRangeRequest; envelopesRequest?: gloas.ExecutionPayloadEnvelopesByRangeRequest; /** * Post-Gloas only. Fetches the dangling-parent's payload envelope and/or its missing sampled * data columns by-root. Set by `Batch` for the first batch of a `SyncChain` after the first * block's parentRoot is known. */ parentPayloadRequest?: { blockRoot?: Uint8Array; columns?: ColumnIndex[]; envelopeBlockRoot?: Uint8Array; }; }; export type ParentPayloadCommitments = { blockRoot: Uint8Array; blockRootHex: RootHex; kzgCommitments: deneb.BlobKzgCommitments; }; export type DownloadByRangeResponses = { blocks?: SignedBeaconBlock[]; blobSidecars?: deneb.BlobSidecars; columnSidecars?: DataColumnSidecar[]; payloadEnvelopes?: gloas.SignedExecutionPayloadEnvelope[]; }; export type DownloadAndCacheByRangeProps = DownloadByRangeRequests & { config: ChainForkConfig; network: INetwork; logger: Logger; peerIdStr: string; batchBlocks?: IBlockInput[]; /** Required when `parentPayloadRequest` is set; supplies the data needed to validate the parent's columns. */ parentPayloadCommitments?: ParentPayloadCommitments; peerDasMetrics?: BeaconMetrics["peerDas"] | null; }; export type CacheByRangeResponsesProps = { cache: SeenBlockInput; seenPayloadEnvelopeInputCache: SeenPayloadEnvelopeInput; peerIdStr: string; responses: ValidatedResponses; batchBlocks: IBlockInput[]; /** Raw envelopes downloaded in this batch, keyed by slot (from downloadByRange return) */ downloadedPayloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null; /** Envelopes already wrapped from previous partial downloads on this batch */ existingPayloadEnvelopes: Map<Slot, PayloadEnvelopeInput> | null; /** Sampled/custody column indices for building PayloadEnvelopeInputs */ custodyConfig: Pick<CustodyConfig, "sampledColumns" | "custodyColumns">; seenTimestampSec: number; }; export type ValidatedBlock = { blockRoot: Uint8Array; block: SignedBeaconBlock; }; export type ValidatedBlobSidecars = { blockRoot: Uint8Array; blobSidecars: deneb.BlobSidecars; }; export type ValidatedColumnSidecars = { blockRoot: Uint8Array; columnSidecars: DataColumnSidecar[]; }; export type ValidatedResponses = { validatedBlocks?: ValidatedBlock[]; validatedBlobSidecars?: ValidatedBlobSidecars[]; validatedColumnSidecars?: ValidatedColumnSidecars[]; }; /** * Given existing cached batch block inputs and newly validated responses, update the cache with the new data */ export function cacheByRangeResponses({ cache, seenPayloadEnvelopeInputCache, peerIdStr, responses, batchBlocks, downloadedPayloadEnvelopes, existingPayloadEnvelopes, custodyConfig, seenTimestampSec, }: CacheByRangeResponsesProps): {blocks: IBlockInput[]; payloadEnvelopes: Map<Slot, PayloadEnvelopeInput> | null} { const source = BlockInputSource.byRange; const updatedBatchBlocks = new Map<Slot, IBlockInput>(batchBlocks.map((block) => [block.slot, block])); const blocks = responses.validatedBlocks ?? []; for (let i = 0; i < blocks.length; i++) { const {block, blockRoot} = blocks[i]; const blockRootHex = toRootHex(blockRoot); const existing = updatedBatchBlocks.get(block.message.slot); if (existing) { // In practice this code block shouldn't be reached because we shouldn't be refetching a block we already have, see Batch#getRequests. // Will throw if root hex does not match (meaning we are following the wrong chain) existing.addBlock( { block, blockRootHex, source, peerIdStr, seenTimestampSec, }, {throwOnDuplicateAdd: false} ); } else { const blockInput = cache.getByBlock({ block, blockRootHex, source, peerIdStr, seenTimestampSec, }); updatedBatchBlocks.set(blockInput.slot, blockInput); } } for (const {blockRoot, blobSidecars} of responses.validatedBlobSidecars ?? []) { const dataSlot = blobSidecars.at(0)?.signedBlockHeader.message.slot; if (dataSlot === undefined) { throw new Error( `Coding Error: empty blobSidecars returned for blockRoot=${toRootHex(blockRoot)} from validation functions` ); } const existing = updatedBatchBlocks.get(dataSlot); const blockRootHex = toRootHex(blockRoot); if (!existing) { throw new Error("Coding error: blockInput must exist when adding blobs"); } if (!isBlockInputBlobs(existing)) { throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.MISMATCH_BLOCK_INPUT_TYPE, slot: existing.slot, blockRoot: existing.blockRootHex, expected: DAType.Blobs, actual: existing.type, }); } for (const blobSidecar of blobSidecars) { // will throw if root hex does not match (meaning we are following the wrong chain) existing.addBlob( { blobSidecar, blockRootHex, seenTimestampSec, peerIdStr, source, }, {throwOnDuplicateAdd: false} ); } } // Seed seenPayloadEnvelopeInputCache for every gloas block in the batch, regardless of whether // the peer returned its envelope. Without this, a block returned without its envelope would be // imported with no cache entry, and later payload-by-root sync would throw // "Missing PayloadEnvelopeInput for known block" (see issue #9306). for (const blockInput of updatedBatchBlocks.values()) { if (!blockInput.hasBlock() || !isForkPostGloas(blockInput.forkName)) continue; seenPayloadEnvelopeInputCache.add({ blockRootHex: blockInput.blockRootHex, block: blockInput.getBlock() as SignedBeaconBlock<ForkPostGloas>, forkName: blockInput.forkName, sampledColumns: custodyConfig.sampledColumns, custodyColumns: custodyConfig.custodyColumns, timeCreatedSec: seenTimestampSec, }); } let payloadEnvelopes: Map<Slot, PayloadEnvelopeInput> | null = existingPayloadEnvelopes !== null ? new Map(existingPayloadEnvelopes) : null; if (downloadedPayloadEnvelopes !== null) { payloadEnvelopes ??= new Map(); for (const [slot, envelope] of downloadedPayloadEnvelopes) { const envelopeBlockRootHex = toRootHex(envelope.message.beaconBlockRoot); const payloadInput = seenPayloadEnvelopeInputCache.get(envelopeBlockRootHex); if (payloadInput === undefined) { // Unreachable given the loop above seeded an entry for every gloas block in the batch. // for the parent block, it's populated at BeaconChain init throw new Error(`Missing PayloadEnvelopeInput for block ${envelopeBlockRootHex}`); } if (!payloadInput.hasPayloadEnvelope()) { payloadInput.addPayloadEnvelope({ envelope, source: PayloadEnvelopeInputSource.byRange, seenTimestampSec, peerIdStr, }); } payloadEnvelopes.set(slot, payloadInput); } } for (const {blockRoot, columnSidecars} of responses.validatedColumnSidecars ?? []) { const firstColumn = columnSidecars[0]; if (!firstColumn) { throw new Error( `Coding Error: empty columnSidecars returned for blockRoot=${toRootHex(blockRoot)} from validation functions` ); } const blockRootHex = toRootHex(blockRoot); if (isGloasDataColumnSidecar(firstColumn)) { // Gloas columns are attached to the matching PayloadEnvelopeInput, NOT to IBlockInput. // Gloas DataColumnSidecar has `slot` directly (no signedBlockHeader). const dataSlot = firstColumn.slot; const payloadInput = payloadEnvelopes?.get(dataSlot); if (!payloadInput) { // Should not happen: we built payloadInputs for all gloas blocks above continue; } for (const columnSidecar of columnSidecars as gloas.DataColumnSidecar[]) { payloadInput.addColumn({ columnSidecar, seenTimestampSec, peerIdStr, source: PayloadEnvelopeInputSource.byRange, }); } continue; } const fuluColumns = columnSidecars as fulu.DataColumnSidecar[]; const dataSlot = fuluColumns[0].signedBlockHeader.message.slot; const existing = updatedBatchBlocks.get(dataSlot); if (!existing) { throw new Error("Coding error: blockInput must exist when adding columns"); } if (!isBlockInputColumns(existing)) { throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.MISMATCH_BLOCK_INPUT_TYPE, slot: existing.slot, blockRoot: existing.blockRootHex, expected: DAType.Columns, actual: existing.type, }); } for (const columnSidecar of fuluColumns) { // will throw if root hex does not match (meaning we are following the wrong chain) existing.addColumn( { columnSidecar, blockRootHex, seenTimestampSec, peerIdStr, source, }, {throwOnDuplicateAdd: false} ); } } return {blocks: Array.from(updatedBatchBlocks.values()), payloadEnvelopes}; } export async function downloadByRange({ config, network, peerIdStr, batchBlocks, blocksRequest, blobsRequest, columnsRequest, envelopesRequest, parentPayloadRequest, parentPayloadCommitments, peerDasMetrics, }: DownloadAndCacheByRangeProps): Promise< WarnResult< {responses: ValidatedResponses; payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null}, DownloadByRangeError > > { let response: DownloadByRangeResponses; try { response = await requestByRange({ network, peerIdStr, blocksRequest, blobsRequest, columnsRequest, envelopesRequest, parentPayloadRequest, }); } catch (err) { throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.REQ_RESP_ERROR, reason: (err as Error).message, ...requestsLogMeta({blocksRequest, blobsRequest, columnsRequest}), }); } return validateResponses({ config, batchBlocks, blocksRequest, blobsRequest, columnsRequest, envelopesRequest, parentPayloadRequest, parentPayloadCommitments, peerDasMetrics, ...response, }); } /** * Should not be called directly. Only exported for unit testing purposes */ export async function requestByRange({ network, peerIdStr, blocksRequest, blobsRequest, columnsRequest, envelopesRequest, parentPayloadRequest, }: DownloadByRangeRequests & { network: INetwork; peerIdStr: PeerIdStr; }): Promise<DownloadByRangeResponses> { let blocks: undefined | SignedBeaconBlock[]; let blobSidecars: undefined | deneb.BlobSidecars; const columnSidecars: DataColumnSidecar[] = []; const payloadEnvelopes: gloas.SignedExecutionPayloadEnvelope[] = []; const requests: Promise<unknown>[] = []; if (blocksRequest) { requests.push( network.sendBeaconBlocksByRange(peerIdStr, blocksRequest).then((blockResponse) => { blocks = blockResponse; }) ); } if (blobsRequest) { requests.push( network.sendBlobSidecarsByRange(peerIdStr, blobsRequest).then((blobResponse) => { blobSidecars = blobResponse; }) ); } if (columnsRequest) { requests.push( network.sendDataColumnSidecarsByRange(peerIdStr, columnsRequest).then((columnResponse) => { columnSidecars.push(...columnResponse); }) ); } if (envelopesRequest) { requests.push( network.sendExecutionPayloadEnvelopesByRange(peerIdStr, envelopesRequest).then((envelopeResponse) => { payloadEnvelopes.push(...envelopeResponse); }) ); } // Only happens on the 1st batch of a SyncChain — fetches whichever pieces of the // dangling-parent's payload are still missing from the seen-cache entry. if (parentPayloadRequest?.envelopeBlockRoot) { requests.push( network .sendExecutionPayloadEnvelopesByRoot(peerIdStr, [parentPayloadRequest.envelopeBlockRoot]) .then((envelopeResponse) => { payloadEnvelopes.push(...envelopeResponse); }) ); } if (parentPayloadRequest?.blockRoot && parentPayloadRequest.columns && parentPayloadRequest.columns.length > 0) { requests.push( network .sendDataColumnSidecarsByRoot(peerIdStr, [ {blockRoot: parentPayloadRequest.blockRoot, columns: parentPayloadRequest.columns}, ]) .then((columnResponse) => { columnSidecars.push(...columnResponse); }) ); } await Promise.all(requests); return { blocks, blobSidecars, columnSidecars, payloadEnvelopes, }; } /** * Should not be called directly. Only exported for unit testing purposes */ export async function validateResponses({ config, batchBlocks, blocksRequest, blobsRequest, columnsRequest, envelopesRequest, parentPayloadRequest, parentPayloadCommitments, blocks, blobSidecars, columnSidecars, payloadEnvelopes, peerDasMetrics, }: DownloadByRangeRequests & DownloadByRangeResponses & { config: ChainForkConfig; batchBlocks?: IBlockInput[]; parentPayloadCommitments?: ParentPayloadCommitments; peerDasMetrics?: BeaconMetrics["peerDas"] | null; }): Promise< WarnResult< {responses: ValidatedResponses; payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null}, DownloadByRangeError > > { // Blocks are always required for blob/column validation // If a blocksRequest is provided, blocks have just been downloaded // If no blocksRequest is provided, batchBlocks must have been provided from cache if ((blobsRequest || columnsRequest) && !(blocks || batchBlocks)) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.MISSING_BLOCKS_RESPONSE, ...requestsLogMeta({blobsRequest, columnsRequest}), }, "No blocks to validate data requests against" ); } // `parentPayloadRequest` and `parentPayloadCommitments` must be supplied together if ((parentPayloadRequest === undefined) !== (parentPayloadCommitments === undefined)) { throw new Error("Coding error: parentPayloadRequest and parentPayloadCommitments must be both set or both unset"); } const validatedResponses: ValidatedResponses = {}; let warnings: DownloadByRangeError[] | null = null; if (blocksRequest) { const result = validateBlockByRangeResponse(config, blocksRequest, blocks ?? []); if (result.warnings?.length) { warnings = result.warnings; } validatedResponses.validatedBlocks = result.result; } const needsEnvelopeValidation = !!envelopesRequest || parentPayloadCommitments !== undefined; const dataRequest = blobsRequest ?? columnsRequest; if (!dataRequest && !needsEnvelopeValidation) { return {result: {responses: validatedResponses, payloadEnvelopes: null}, warnings}; } if (!dataRequest) { // Only envelope and/or parent-by-root validation needed if (parentPayloadCommitments !== undefined) { const parentValidated = await validateParentPayloadColumns( parentPayloadCommitments, columnSidecars ?? [], peerDasMetrics ); if (parentValidated) { validatedResponses.validatedColumnSidecars = [parentValidated]; } } const validatedPayloadEnvelopes = validateEnvelopesByRangeResponse( validatedResponses.validatedBlocks ?? [], batchBlocks, payloadEnvelopes ?? [], parentPayloadCommitments ); return {result: {responses: validatedResponses, payloadEnvelopes: validatedPayloadEnvelopes}, warnings}; } const blocksForDataValidation = getBlocksForDataValidation( dataRequest, batchBlocks, validatedResponses.validatedBlocks?.length ? validatedResponses.validatedBlocks : undefined ); if (!blocksForDataValidation.length) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.MISSING_BLOCKS_RESPONSE, ...requestsLogMeta({blobsRequest, columnsRequest}), }, "No blocks in data request slot range to validate data response against" ); } if (blobsRequest) { if (!blobSidecars) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.MISSING_BLOBS_RESPONSE, ...requestsLogMeta({blobsRequest, columnsRequest}), }, "No blobSidecars to validate against blobsRequest" ); } validatedResponses.validatedBlobSidecars = await validateBlobsByRangeResponse( blocksForDataValidation, blobSidecars ); } if (columnsRequest) { if (!columnSidecars) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.MISSING_COLUMNS_RESPONSE, ...requestsLogMeta({blobsRequest, columnsRequest}), }, "No columnSidecars to check columnRequest against" ); } const validatedColumnSidecarsResult = await validateColumnsByRangeResponse( config, columnsRequest, blocksForDataValidation, columnSidecars, peerDasMetrics ); validatedResponses.validatedColumnSidecars = validatedColumnSidecarsResult.result; warnings = validatedColumnSidecarsResult.warnings; } // Parent columns (by-root): KZG-validate against parent's bid commitments and append. if (parentPayloadCommitments !== undefined) { const parentValidated = await validateParentPayloadColumns( parentPayloadCommitments, columnSidecars ?? [], peerDasMetrics ); if (parentValidated) { validatedResponses.validatedColumnSidecars = [ ...(validatedResponses.validatedColumnSidecars ?? []), parentValidated, ]; } } let validatedPayloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null = null; if (needsEnvelopeValidation) { validatedPayloadEnvelopes = validateEnvelopesByRangeResponse( validatedResponses.validatedBlocks ?? [], batchBlocks, payloadEnvelopes ?? [], parentPayloadCommitments ); } return {result: {responses: validatedResponses, payloadEnvelopes: validatedPayloadEnvelopes}, warnings}; } async function validateParentPayloadColumns( parentPayloadCommitments: ParentPayloadCommitments, columnSidecars: DataColumnSidecar[], peerDasMetrics?: BeaconMetrics["peerDas"] | null ): Promise<ValidatedColumnSidecars | null> { const parentColumns: gloas.DataColumnSidecar[] = []; for (const cs of columnSidecars) { if (isGloasDataColumnSidecar(cs) && byteArrayEquals(cs.beaconBlockRoot, parentPayloadCommitments.blockRoot)) { parentColumns.push(cs); } } if (parentColumns.length === 0) { return null; } parentColumns.sort((a, b) => a.index - b.index); const parentSlot = parentColumns[0].slot; await validateGloasBlockDataColumnSidecars( parentSlot, parentPayloadCommitments.blockRoot, parentPayloadCommitments.kzgCommitments, parentColumns, peerDasMetrics ); return {blockRoot: parentPayloadCommitments.blockRoot, columnSidecars: parentColumns}; } /** * Should not be called directly. Only exported for unit testing purposes * * - check all slots are within range of startSlot (inclusive) through startSlot + count (exclusive) * - don't have more than count number of blocks * - slots are in ascending order * - must allow for skip slots * - check is a chain of blocks where via parentRoot matches hashTreeRoot of block before */ export function validateBlockByRangeResponse( config: ChainForkConfig, blocksRequest: phase0.BeaconBlocksByRangeRequest, blocks: SignedBeaconBlock[] ): WarnResult<ValidatedBlock[], DownloadByRangeError> { const {startSlot, count} = blocksRequest; // An error was thrown here by @twoeths in #8150 but it breaks for epochs with 0 blocks during chain // liveness issues. See comment https://github.com/ChainSafe/lodestar/issues/8147#issuecomment-3246434697 // There are instances where clients return no blocks though. Need to monitor this via the warns to see // if what the correct behavior should be if (!blocks.length) { throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.MISSING_BLOCKS_RESPONSE, ...requestsLogMeta({blocksRequest}), }); // TODO: this was causing deadlock again. need to come back and fix this so that its possible to process through // an empty epoch for periods with poor liveness // return { // result: [], // warnings: [ // new DownloadByRangeError({ // code: DownloadByRangeErrorCode.MISSING_BLOCKS_RESPONSE, // ...requestsLogMeta({blocksRequest}), // }), // ], // }; } if (blocks.length > count) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.EXTRA_BLOCKS, expected: count, actual: blocks.length - count, }, "Extra blocks received in BeaconBlocksByRange response" ); } const lastValidSlot = startSlot + count - 1; for (let i = 0; i < blocks.length; i++) { const slot = blocks[i].message.slot; if (slot < startSlot || slot > lastValidSlot) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.OUT_OF_RANGE_BLOCKS, slot, }, "Blocks in response outside of requested slot range" ); } // do not check for out of order on first block, and for subsequent blocks make sure that // the current block in a later slot than the one prior if (i !== 0 && slot <= blocks[i - 1].message.slot) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.OUT_OF_ORDER_BLOCKS, }, "Blocks out of order in BeaconBlocksByRange response" ); } } // assumes all blocks are from the same fork. Batch only generated epoch-wise requests starting at slot // 0 of the epoch const type = config.getForkTypes(blocks[0].message.slot).BeaconBlock; const response: {block: SignedBeaconBlock; blockRoot: Uint8Array}[] = []; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const blockRoot = type.hashTreeRoot(block.message); response.push({block, blockRoot}); if (i < blocks.length - 1) { // compare the block root against the next block's parent root const parentRoot = blocks[i + 1].message.parentRoot; if (!byteArrayEquals(blockRoot, parentRoot)) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.PARENT_ROOT_MISMATCH, slot: blocks[i].message.slot, expected: toRootHex(blockRoot), actual: toRootHex(parentRoot), }, `Block parent root does not match the previous block's root in BeaconBlocksByRange response` ); } } } return { result: response, warnings: null, }; } /** * Should not be called directly. Only exported for unit testing purposes. * This is used only in Deneb and Electra */ export async function validateBlobsByRangeResponse( dataRequestBlocks: ValidatedBlock[], blobSidecars: deneb.BlobSidecars ): Promise<ValidatedBlobSidecars[]> { const expectedBlobCount = dataRequestBlocks.reduce( (acc, {block}) => (block as SignedBeaconBlock<ForkPostDeneb & ForkPreFulu>).message.body.blobKzgCommitments.length + acc, 0 ); if (blobSidecars.length > expectedBlobCount) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.EXTRA_BLOBS, expected: expectedBlobCount, actual: blobSidecars.length, }, "Extra blobs received in BlobSidecarsByRange response" ); } if (blobSidecars.length < expectedBlobCount) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.MISSING_BLOBS, expected: expectedBlobCount, actual: blobSidecars.length, }, "Missing blobs in BlobSidecarsByRange response" ); } const validateSidecarsPromises: Promise<ValidatedBlobSidecars>[] = []; for (let blockIndex = 0, blobSidecarIndex = 0; blockIndex < dataRequestBlocks.length; blockIndex++) { const {block, blockRoot} = dataRequestBlocks[blockIndex]; const blockKzgCommitments = (block as SignedBeaconBlock<ForkPostDeneb & ForkPreFulu>).message.body .blobKzgCommitments; if (blockKzgCommitments.length === 0) { continue; } const blockBlobSidecars = blobSidecars.slice(blobSidecarIndex, blobSidecarIndex + blockKzgCommitments.length); blobSidecarIndex += blockKzgCommitments.length; for (let i = 0; i < blockBlobSidecars.length; i++) { if (blockBlobSidecars[i].index !== i) { throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.OUT_OF_ORDER_BLOBS, slot: block.message.slot, }, "Blob sidecars not in order or do not match expected indexes in BlobSidecarsByRange response" ); } } validateSidecarsPromises.push( validateBlockBlobSidecars( null, // do not pass chain here so we do not validate header signature block.message.slot, blockRoot, blockKzgCommitments.length, blockBlobSidecars ).then(() => ({blockRoot, blobSidecars: blockBlobSidecars})) ); } // Await all sidecar validations in parallel return Promise.all(validateSidecarsPromises); } /** * Should not be called directly. Only exported for unit testing purposes * * Spec states: * 1) must be within range [start_slot, start_slot + count] * 2) should respond with all columns in the range or and 3:ResourceUnavailable (and potentially get down-scored) * 3) must response with at least the sidecars of the first blob-carrying block that exists in the range * 4) must include all sidecars from each block from which there are blobs * 5) where they exists, sidecars must be sent in (slot, index) order * 6) clients may limit the number of sidecars in a response * 7) clients may stop responding mid-response if their view of fork-choice changes * * We will interpret the spec as follows * - Errors when validating: 1, 3, 5 * - Warnings when validating: 2, 4, 6, 7 * * For "warning" cases, where we get a partial response but sidecars are validated and correct with respect to the * blocks, then they will be kept. This loosening of the spec is to help ensure sync goes smoothly and we can find * the data needed in difficult network situations. * * Assume for the following two examples we request indices 5, 10, 15 for a range of slots 32-63 * * For slots where we receive no sidecars, example slot 45, but blobs exist we will stop validating subsequent * slots, 45-63. The next round of requests will get structured to pull the from the slot that had columns * missing to the end of the range for all columns indices that were requested for the current partially failed * request (slots 45-63 and indices 5, 10, 15). * * For slots where only some of the requested sidecars are received we will proceed with validation. For simplicity sake * we will assume that if we only get some indices back for a (or several) slot(s) that the indices we get will be * consistent. IE if a peer returns only index 5, they will most likely return that same index for subsequent slot * (index 5 for slots 34, 35, 36, etc). They will not likely return 5 on slot 34, 10 on slot 35, 15 on slot 36, etc. * This assumption makes the code simpler. For both cases the request for the next round will be structured correctly * to pull any missing column indices for whatever range remains. The simplification just leads to re-verification * of the columns but the number of columns downloaded will be the same regardless of if they are validated twice. * * validateColumnsByRangeResponse makes some assumptions about the data being passed in * blocks are: * - slotwise in order * - form a chain * - non-sparse response (any missing block is a skipped slot not a bad response) * - last block is last slot received */ export async function validateColumnsByRangeResponse( config: ChainForkConfig, request: fulu.DataColumnSidecarsByRangeRequest, blocks: ValidatedBlock[], columnSidecars: DataColumnSidecar[], peerDasMetrics?: BeaconMetrics["peerDas"] | null ): Promise<WarnResult<ValidatedColumnSidecars[], DownloadByRangeError>> { const warnings: DownloadByRangeError[] = []; const seenColumns = new Map<Slot, Map<number, DataColumnSidecar>>(); let currentSlot = -1; let currentIndex = -1; // Check for duplicates and order for (const columnSidecar of columnSidecars) { const slot = isGloasDataColumnSidecar(columnSidecar) ? columnSidecar.slot : columnSidecar.signedBlockHeader.message.slot; let seenSlotColumns = seenColumns.get(slot); if (!seenSlotColumns) { seenSlotColumns = new Map(); seenColumns.set(slot, seenSlotColumns); } if (seenSlotColumns.has(columnSidecar.index)) { warnings.push( new DownloadByRangeError({ code: DownloadByRangeErrorCode.DUPLICATE_COLUMN, slot, index: columnSidecar.index, }) ); continue; } if (currentSlot > slot) { warnings.push( new DownloadByRangeError( { code: DownloadByRangeErrorCode.OUT_OF_ORDER_COLUMNS, slot, }, "ColumnSidecars received out of slot order" ) ); } if (currentSlot === slot && currentIndex > columnSidecar.index) { warnings.push( new DownloadByRangeError( { code: DownloadByRangeErrorCode.OUT_OF_ORDER_COLUMNS, slot, }, "Column indices out of order within a slot" ) ); } seenSlotColumns.set(columnSidecar.index, columnSidecar); if (currentSlot !== slot) { // a new slot has started, reset index currentIndex = -1; } else { currentIndex = columnSidecar.index; } currentSlot = slot; } const validationPromises: Promise<ValidatedColumnSidecars>[] = []; for (const {blockRoot, block} of blocks) { const slot = block.message.slot; const rootHex = toRootHex(blockRoot); const forkName = config.getForkName(slot); const columnSidecarsMap: Map<number, DataColumnSidecar> = seenColumns.get(slot) ?? new Map(); const columnSidecars = Array.from(columnSidecarsMap.values()).sort((a, b) => a.index - b.index); let blobCount: number; if (!isForkPostFulu(forkName)) { throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.MISMATCH_BLOCK_FORK, slot, blockFork: forkName, dataFork: "unknown", }); } const kzgCommitments = getBlobKzgCommitments(forkName, block as SignedBeaconBlock<ForkPostFulu>); blobCount = kzgCommitments.length; if (columnSidecars.length === 0) { if (!blobCount) { // no columns in the slot continue; } /** * If no columns are found for a block and there are commitments on the block then stop checking and just * return early. Even if there were columns returned for subsequent slots that doesn't matter because * we will be re-requesting them again anyway. Leftovers just get ignored */ warnings.push( new DownloadByRangeError({ code: DownloadByRangeErrorCode.MISSING_COLUMNS, slot, blockRoot: rootHex, missingIndices: prettyPrintIndices(request.columns), }) ); break; } const returnedColumns = Array.from(columnSidecarsMap.keys()).sort(); if (!blobCount) { // columns for a block that does not have blobs // TODO(fulu): should this be a hard error with no data retained from peer or just a warning throw new DownloadByRangeError( { code: DownloadByRangeErrorCode.NO_COLUMNS_FOR_BLOCK, slot, blockRoot: rootHex, invalidIndices: prettyPrintIndices(returnedColumns), }, "Block has no blob commitments but data column sidecars were provided" ); } const missingIndices = request.columns.filter((i) => !columnSidecarsMap.has(i)); if (missingIndices.length > 0) { warnings.push( new DownloadByRangeError( { code: DownloadByRangeErrorCode.MISSING_COLUMNS, slot, blockRoot: rootHex, missingIndices: prettyPrintIndices(missingIndices), }, "Missing data columns in DataColumnSidecarsByRange response" ) ); } const extraIndices = returnedColumns.filter((i) => !request.columns.includes(i)); if (extraIndices.length > 0) { warnings.push( new DownloadByRangeError( { code: DownloadByRangeErrorCode.EXTRA_COLUMNS, slot, blockRoot: rootHex, invalidIndices: prettyPrintIndices(extraIndices), }, "Data column in not in requested columns in DataColumnSidecarsByRange response" ) ); } const validatePromise = isForkPostGloas(forkName) ? validateGloasBlockDataColumnSidecars( slot, blockRoot, kzgCommitments, columnSidecars as gloas.DataColumnSidecar[], peerDasMetrics ) : validateFuluBlockDataColumnSidecars( null, // do not pass chain here so we do not validate header signature slot, blockRoot, blobCount, columnSidecars as fulu.DataColumnSidecar[], peerDasMetrics ); validationPromises.push( validatePromise.then(() => ({ blockRoot, columnSidecars, })) ); } const validatedColumns = await Promise.all(validationPromises); return { result: validatedColumns, warnings: warnings.length ? warnings : null, }; } /** * Given a data request, return only the blocks and roots that correspond to the data request (sorted). Assumes that * cached have slots that are all before the current batch of downloaded blocks */ export function getBlocksForDataValidation( dataRequest: {startSlot: Slot; count: number}, cached: IBlockInput[] | undefined, current: ValidatedBlock[] | undefined ): ValidatedBlock[] { const startSlot = dataRequest.startSlot; const endSlot = startSlot + dataRequest.count; // Organize cached blocks and current blocks, only including those in the requested slot range const dataRequestBlocks: ValidatedBlock[] = []; let lastSlot = startSlot - 1; if (cached) { for (let i = 0; i < cached.length; i++) { const blockInput = cached[i]; if (blockInput.slot >= startSlot && blockInput.slot < endSlot && blockInput.slot > lastSlot) { dataRequestBlocks.push({block: blockInput.getBlock(), blockRoot: fromHex(blockInput.blockRootHex)}); lastSlot = blockInput.slot; } } } if (current) { for (let i = 0; i < current.length; i++) { const block = current[i].block; if (block.message.slot >= startSlot && block.message.slot < endSlot && block.message.slot > lastSlot) { dataRequestBlocks.push(current[i]); lastSlot = block.message.slot; } } } return dataRequestBlocks; } function requestsLogMeta({blocksRequest, blobsRequest, columnsRequest}: DownloadByRangeRequests) { const logMeta: { blockStartSlot?: number; blockCount?: number; blobStartSlot?: number; blobCount?: number; columnStartSlot?: number; columnCount?: number; } = {}; if (blocksRequest) { logMeta.blockStartSlot = blocksRequest.startSlot; logMeta.blockCount = blocksRequest.count; } if (blobsRequest) { logMeta.blobStartSlot = blobsRequest.startSlot; logMeta.blobCount = blobsRequest.count; } if (columnsRequest) { logMeta.columnStartSlot = columnsRequest.startSlot; logMeta.columnCount = columnsRequest.count; } return logMeta; } export enum DownloadByRangeErrorCode { MISSING_BLOCKS_RESPONSE = "DOWNLOAD_BY_RANGE_ERROR_MISSING_BLOCK_RESPONSE", MISSING_BLOBS_RESPONSE = "DOWNLOAD_BY_RANGE_ERROR_MISSING_BLOBS_RESPONSE", MISSING_COLUMNS_RESPONSE = "DOWNLOAD_BY_RANGE_ERROR_MISSING_COLUMNS_RESPONSE", /** Error at the reqresp layer */ REQ_RESP_ERROR = "DOWNLOAD_BY_RANGE_ERROR_REQ_RESP_ERROR", // Errors validating a chain of blocks (not considering associated data) PARENT_ROOT_MISMATCH = "DOWNLOAD_BY_RANGE_ERROR_PARENT_ROOT_MISMATCH", EXTRA_BLOCKS = "DOWNLOAD_BY_RANGE_ERROR_EXTRA_BLOCKS", OUT_OF_RANGE_BLOCKS = "DOWNLOAD_BY_RANGE_OUT_OF_RANGE_BLOCKS", OUT_OF_ORDER_BLOCKS = "DOWNLOAD_BY_RANGE_OUT_OF_ORDER_BLOCKS", MISSING_BLOBS = "DOWNLOAD_BY_RANGE_ERROR_MISSING_BLOBS", OUT_OF_ORDER_BLOBS = "DOWNLOAD_BY_RANGE_ERROR_OUT_OF_ORDER_BLOBS", EXTRA_BLOBS = "DOWNLOAD_BY_RANGE_ERROR_EXTRA_BLOBS", MISSING_COLUMNS = "DOWNLOAD_BY_RANGE_ERROR_MISSING_COLUMNS", OVER_COLUMNS = "DOWNLOAD_BY_RANGE_ERROR_OVER_COLUMNS", EXTRA_COLUMNS = "DOWNLOAD_BY_RANGE_ERROR_EXTRA_COLUMNS", NO_COLUMNS_FOR_BLOCK = "DOWNLOAD_BY_RANGE_ERROR_NO_COLUMNS_FOR_BLOCK", DUPLICATE_COLUMN = "DOWNLOAD_BY_RANGE_ERROR_DUPLICATE_COLUMN", OUT_OF_ORDER_COLUMNS = "DOWNLOAD_BY_RANGE_OUT_OF_ORDER_COLUMNS", /** Cached block input type mismatches new data */ MISMATCH_BLOCK_FORK = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_FORK", MISMATCH_BLOCK_INPUT_TYPE = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_INPUT_TYPE", /** Envelope beaconBlockRoot does not match the block's root */ INVALID_ENVELOPE_BEACON_BLOCK_ROOT = "DOWNLOAD_BY_RANGE_ERROR_INVALID_ENVELOPE_BEACON_BLOCK_ROOT", /** Block segment + envelopes failed chain-segment linearity / FULL-chain checks */ INVALID_CHAIN_SEGMENT = "DOWNLOAD_BY_RANGE_ERROR_INVALID_CHAIN_SEGMENT", } export type DownloadByRangeErrorType = | { code: | DownloadByRangeErrorCode.MISSING_BLOCKS_RESPONSE | DownloadByRangeErrorCode.MISSING_BLOBS_RESPONSE | DownloadByRangeErrorCode.MISSING_COLUMNS_RESPONSE; blockStartSlot?: number; blockCount?: number; blobStartSlot?: number; blobCount?: number; columnStartSlot?: number; columnCount?: number; } | { code: DownloadByRangeErrorCode.OUT_OF_RANGE_BLOCKS; slot: number; } | { code: DownloadByRangeErrorCode.MISMATCH_BLOCK_FORK; slot: number; dataFork: string; blockFork: string; } | { code: DownloadByRangeErrorCode.OUT_OF_ORDER_BLOCKS; } | { code: DownloadByRangeErrorCode.REQ_RESP_ERROR; blockStartSlot?: number; blockCount?: number; blobStartSlot?: number; blobCount?: number; columnStartSlot?: number; columnCount?: number; reason: string; } | { code: DownloadByRangeErrorCode.PARENT_ROOT_MISMATCH; slot: number; expected: string; actual: string; } | { code: DownloadByRangeErrorCode.EXTRA_BLOCKS; expected: number; actual: number; } | { code: DownloadByRangeErrorCode.MISSING_BLOBS; expected: number; actual: number; } | { code: DownloadByRangeErrorCode.OUT_OF_ORDER_BLOBS | DownloadByRangeErrorCode.OUT_OF_ORDER_COLUMNS; slot: number; } | { code: DownloadByRangeErrorCode.EXTRA_BLOBS; expected: number; actual: number; } | { code: DownloadByRangeErrorCode.OVER_COLUMNS; max: number; actual: number; } | { code: DownloadByRangeErrorCode.MISSING_COLUMNS; slot: Slot; blockRoot: string; missingIndices: string; } | { code: DownloadByRangeErrorCode.DUPLICATE_COLUMN; slot: Slot; index: number; } | { code: DownloadByRangeErrorCode.EXTRA_COLUMNS | DownloadByRangeErrorCode.NO_COLUMNS_FOR_BLOCK; slot: Slot; blockRoot: string; invalidIndices: string; } | { code: DownloadByRangeErrorCode.MISMATCH_BLOCK_INPUT_TYPE; slot: number; blockRoot: string; expected: DAType; actual: DAType; } | { code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT; slot: Slot; expected: string; actual: string; } | { code: DownloadByRangeErrorCode.INVALID_CHAIN_SEGMENT; slot: Slot; reason: string; }; export class DownloadByRangeError extends LodestarError<DownloadByRangeErrorType> {} /** * Validates SignedExecutionPayloadEnvelopes received for a range request. * * Three categories of envelope slots: * - In-batch slot whose block we have: verify envelope.beaconBlockRoot matches. * - Dangling-parent envelope (only when `parentPayloadCommitments` is set, i.e. the first * batch of a `SyncChain`): keep if `envelope.beaconBlockRoot === parentPayloadCommitments.blockRoot`. * - Other "orphan" envelopes (e.g. unrelated slots): ignored. */ export function validateEnvelopesByRangeResponse( validatedBlocks: ValidatedBlock[], batchBlocks: IBlockInput[] | undefined, payloadEnvelopes: gloas.SignedExecutionPayloadEnvelope[], parentPayloadCommitments?: ParentPayloadCommitments ): Map<Slot, gloas.SignedExecutionPayloadEnvelope> { // Build a map of slot -> blockRoot for all blocks in the batch const batchBlockRoots = new Map<Slot, Uint8Array>(); if (batchBlocks) { for (const blockInput of batchBlocks) { batchBlockRoots.set(blockInput.slot, fromHex(blockInput.blockRootHex)); } } for (const {block, blockRoot} of validatedBlocks) { batchBlockRoots.set(block.message.slot, blockRoot); } const payloadEnvelopeMap = new Map<Slot, gloas.SignedExecutionPayloadEnvelope>(); for (const payloadEnvelope of payloadEnvelopes) { const slot = payloadEnvelope.message.payload.slotNumber; const batchBlockRoot = batchBlockRoots.get(slot); if (batchBlockRoot === undefined) { // Keep the requested dangling-parent envelope only when its beaconBlockRoot matches // exactly. All other unrelated envelopes are dropped. if ( parentPayloadCommitments !== undefined && byteArrayEquals(payloadEnvelope.message.beaconBlockRoot, parentPayloadCommitments.blockRoot) ) { payloadEnvelopeMap.set(slot, payloadEnvelope); } continue; } // Verify beaconBlockRoot matches the block's root if (!byteArrayEquals(payloadEnvelope.message.beaconBlockRoot, batchBlockRoot)) { throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT, slot, expected: toRootHex(batchBlockRoot), actual: toRootHex(payloadEnvelope.message.beaconBlockRoot), }); } payloadEnvelopeMap.set(slot, payloadEnvelope); } return payloadEnvelopeMap; }