diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index 4aec6f3c9e69..86659a42b3ed 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -120,7 +120,11 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra }, private readonly blobClient: BlobClientInterface, instrumentation: ArchiverInstrumentation, - protected override readonly l1Constants: L1RollupConstants & { l1StartBlockHash: Buffer32; genesisArchiveRoot: Fr }, + protected override readonly l1Constants: L1RollupConstants & { + l1StartBlockHash: Buffer32; + genesisArchiveRoot: Fr; + rollupManaLimit?: number; + }, synchronizer: ArchiverL1Synchronizer, events: ArchiverEmitter, l2TipsCache?: L2TipsCache, @@ -133,7 +137,9 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra this.synchronizer = synchronizer; this.events = events; this.l2TipsCache = l2TipsCache ?? new L2TipsCache(this.dataStore.blockStore); - this.updater = new ArchiverDataStoreUpdater(this.dataStore, this.l2TipsCache); + this.updater = new ArchiverDataStoreUpdater(this.dataStore, this.l2TipsCache, { + rollupManaLimit: l1Constants.rollupManaLimit, + }); // Running promise starts with a small interval inbetween runs, so all iterations needed for the initial sync // are done as fast as possible. This then gets updated once the initial sync completes. diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index ca4d60f8a780..f7f2d46b44db 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -85,6 +85,7 @@ export async function createArchiver( genesisArchiveRoot, slashingProposerAddress, targetCommitteeSize, + rollupManaLimit, ] = await Promise.all([ rollup.getL1StartBlock(), rollup.getL1GenesisTime(), @@ -92,6 +93,7 @@ export async function createArchiver( rollup.getGenesisArchiveTreeRoot(), rollup.getSlashingProposerAddress(), rollup.getTargetCommitteeSize(), + rollup.getManaLimit(), ] as const); const l1StartBlockHash = await publicClient @@ -110,6 +112,7 @@ export async function createArchiver( proofSubmissionEpochs: Number(proofSubmissionEpochs), targetCommitteeSize, genesisArchiveRoot: Fr.fromString(genesisArchiveRoot.toString()), + rollupManaLimit: Number(rollupManaLimit), }; const archiverConfig = merge( diff --git a/yarn-project/archiver/src/modules/data_store_updater.ts b/yarn-project/archiver/src/modules/data_store_updater.ts index dd2e6becd57a..83864240f01d 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.ts @@ -11,7 +11,7 @@ import { ContractInstanceUpdatedEvent, } from '@aztec/protocol-contracts/instance-registry'; import type { L2Block, ValidateCheckpointResult } from '@aztec/stdlib/block'; -import type { PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import { type PublishedCheckpoint, validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { type ExecutablePrivateFunctionWithMembershipProof, type UtilityFunctionWithMembershipProof, @@ -48,6 +48,7 @@ export class ArchiverDataStoreUpdater { constructor( private store: KVArchiverDataStore, private l2TipsCache?: L2TipsCache, + private opts: { rollupManaLimit?: number } = {}, ) {} /** @@ -97,6 +98,10 @@ export class ArchiverDataStoreUpdater { checkpoints: PublishedCheckpoint[], pendingChainValidationStatus?: ValidateCheckpointResult, ): Promise { + for (const checkpoint of checkpoints) { + validateCheckpoint(checkpoint.checkpoint, { rollupManaLimit: this.opts?.rollupManaLimit }); + } + const result = await this.store.transactionAsync(async () => { // Before adding checkpoints, check for conflicts with local blocks if any const { prunedBlocks, lastAlreadyInsertedBlockNumber } = await this.pruneMismatchingLocalBlocks(checkpoints); diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 640a10234127..221d50336fb7 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -69,13 +69,19 @@ export class ArchiverL1Synchronizer implements Traceable { private readonly epochCache: EpochCache, private readonly dateProvider: DateProvider, private readonly instrumentation: ArchiverInstrumentation, - private readonly l1Constants: L1RollupConstants & { l1StartBlockHash: Buffer32; genesisArchiveRoot: Fr }, + private readonly l1Constants: L1RollupConstants & { + l1StartBlockHash: Buffer32; + genesisArchiveRoot: Fr; + rollupManaLimit?: number; + }, private readonly events: ArchiverEmitter, tracer: Tracer, l2TipsCache?: L2TipsCache, private readonly log: Logger = createLogger('archiver:l1-sync'), ) { - this.updater = new ArchiverDataStoreUpdater(this.store, l2TipsCache); + this.updater = new ArchiverDataStoreUpdater(this.store, l2TipsCache, { + rollupManaLimit: l1Constants.rollupManaLimit, + }); this.tracer = tracer; } diff --git a/yarn-project/stdlib/src/checkpoint/validate.test.ts b/yarn-project/stdlib/src/checkpoint/validate.test.ts new file mode 100644 index 000000000000..6dfa314dd0c3 --- /dev/null +++ b/yarn-project/stdlib/src/checkpoint/validate.test.ts @@ -0,0 +1,233 @@ +import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; +import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; + +import { jest } from '@jest/globals'; + +import { AztecAddress } from '../aztec-address/index.js'; +import { GasFees } from '../gas/index.js'; +import { AppendOnlyTreeSnapshot } from '../trees/append_only_tree_snapshot.js'; +import { BlockHeader } from '../tx/block_header.js'; +import { Checkpoint } from './checkpoint.js'; +import { CheckpointValidationError, validateCheckpoint, validateCheckpointStructure } from './validate.js'; + +describe('validateCheckpointStructure', () => { + const checkpointNumber = CheckpointNumber(1); + + const fixedSlot = SlotNumber(42); + const fixedCoinbase = EthAddress.random(); + const fixedFeeRecipient = AztecAddress.fromField(Fr.random()); + const fixedGasFees = GasFees.random(); + const fixedTimestamp = BigInt(Math.floor(Date.now() / 1000)); + + /** Builds a valid random checkpoint with the given number of blocks. All blocks share the same slot, + * coinbase, feeRecipient, gasFees, and timestamp, and the checkpoint header's lastArchiveRoot is + * aligned with the first block. */ + async function makeValidCheckpoint(numBlocks = 2): Promise { + const checkpoint = await Checkpoint.random(checkpointNumber, { + numBlocks, + startBlockNumber: 1, + slotNumber: fixedSlot, + coinbase: fixedCoinbase, + feeRecipient: fixedFeeRecipient, + gasFees: fixedGasFees, + timestamp: fixedTimestamp, + }); + // Align checkpoint header's lastArchiveRoot with the first block. + checkpoint.header.lastArchiveRoot = checkpoint.blocks[0].header.lastArchive.root; + return checkpoint; + } + + it('passes on a valid single-block checkpoint', async () => { + const checkpoint = await makeValidCheckpoint(1); + expect(() => validateCheckpointStructure(checkpoint)).not.toThrow(); + }); + + it('passes on a valid multi-block checkpoint', async () => { + const checkpoint = await makeValidCheckpoint(3); + expect(() => validateCheckpointStructure(checkpoint)).not.toThrow(); + }); + + it('throws when checkpoint slot does not match first block slot', async () => { + const checkpoint = await makeValidCheckpoint(1); + checkpoint.header.slotNumber = SlotNumber(checkpoint.blocks[0].slot + 1); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/all blocks must share the same slot/); + }); + + it('throws when checkpoint lastArchiveRoot does not match first block lastArchive root', async () => { + const checkpoint = await makeValidCheckpoint(1); + checkpoint.header.lastArchiveRoot = AppendOnlyTreeSnapshot.random().root; + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/lastArchiveRoot does not match first block/); + }); + + it('throws on empty block list', async () => { + const checkpoint = await makeValidCheckpoint(1); + checkpoint.blocks = []; + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow('Checkpoint has no blocks'); + }); + + it('throws when block count exceeds MAX_BLOCKS_PER_CHECKPOINT', async () => { + // Build 73 blocks (MAX_BLOCKS_PER_CHECKPOINT = 72) + const checkpoint = await makeValidCheckpoint(1); + // Reuse the single block to fill up 73 slots (structure checks happen before archive chaining in loop) + const block = checkpoint.blocks[0]; + checkpoint.blocks = Array.from({ length: 73 }, (_, i) => { + const cloned = Object.create(Object.getPrototypeOf(block), Object.getOwnPropertyDescriptors(block)); + cloned.indexWithinCheckpoint = IndexWithinCheckpoint(i); + return cloned; + }); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/exceeding limit of 72/); + }); + + it('throws when indexWithinCheckpoint is wrong', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Swap the indices + const block0 = checkpoint.blocks[0]; + block0.indexWithinCheckpoint = IndexWithinCheckpoint(1); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/indexWithinCheckpoint/); + }); + + it('throws when block numbers are not sequential', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Manually set block[1] to a non-sequential number (block[0].number + 2) + const block1 = checkpoint.blocks[1]; + // Override block number via header globalVariables + const gv = block1.header.globalVariables; + gv.blockNumber = BlockNumber(gv.blockNumber + 2); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/not sequential/); + }); + + it('throws when archive roots are not chained', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Break chaining: replace block[1]'s header with a new one that has a random lastArchive + const block1 = checkpoint.blocks[1]; + block1.header = BlockHeader.from({ ...block1.header, lastArchive: AppendOnlyTreeSnapshot.random() }); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/lastArchive root does not match/); + }); + + it('throws when blocks have different slot numbers', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Change block[1]'s slot to something different + const block1 = checkpoint.blocks[1]; + block1.header.globalVariables.slotNumber = SlotNumber(block1.slot + 1); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/all blocks must share the same slot/); + }); + + it('throws when a block global variables do not match checkpoint header', async () => { + const checkpoint = await makeValidCheckpoint(2); + // Mutate coinbase on block[1] to something different from the checkpoint header + checkpoint.blocks[1].header.globalVariables.coinbase = EthAddress.random(); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(CheckpointValidationError); + expect(() => validateCheckpointStructure(checkpoint)).toThrow(/global variables.*do not match checkpoint header/); + }); +}); + +describe('validateCheckpoint — limits', () => { + const checkpointNumber = CheckpointNumber(1); + const fixedSlot = SlotNumber(42); + const fixedCoinbase = EthAddress.random(); + const fixedFeeRecipient = AztecAddress.fromField(Fr.random()); + const fixedGasFees = GasFees.random(); + const fixedTimestamp = BigInt(Math.floor(Date.now() / 1000)); + + /** A known mana value injected into every block, making assertions deterministic. */ + const specificMana = 1_000_000; + + /** Opts that leave all limits wide open so structural validity is tested in isolation. */ + const validOpts = { + rollupManaLimit: Number.MAX_SAFE_INTEGER, + maxL2BlockGas: undefined as number | undefined, + maxDABlockGas: undefined as number | undefined, + }; + + /** Builds a structurally valid single-block checkpoint with a known mana value. */ + async function makeCheckpoint(): Promise { + const checkpoint = await Checkpoint.random(checkpointNumber, { + numBlocks: 1, + startBlockNumber: 1, + slotNumber: fixedSlot, + coinbase: fixedCoinbase, + feeRecipient: fixedFeeRecipient, + gasFees: fixedGasFees, + timestamp: fixedTimestamp, + totalManaUsed: new Fr(specificMana), + }); + checkpoint.header.lastArchiveRoot = checkpoint.blocks[0].header.lastArchive.root; + return checkpoint; + } + + it('passes when all limits are within bounds', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, validOpts)).not.toThrow(); + }); + + it('throws when checkpoint mana exceeds rollupManaLimit', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, rollupManaLimit: specificMana - 1 })).toThrow( + CheckpointValidationError, + ); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, rollupManaLimit: specificMana - 1 })).toThrow( + /mana cost.*exceeds rollup limit/, + ); + }); + + it('passes when checkpoint mana equals rollupManaLimit', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, rollupManaLimit: specificMana })).not.toThrow(); + }); + + it('throws when checkpoint DA gas exceeds MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT', async () => { + const checkpoint = await makeCheckpoint(); + jest.spyOn(checkpoint.blocks[0], 'computeDAGasUsed').mockReturnValue(MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT + 1); + expect(() => validateCheckpoint(checkpoint, validOpts)).toThrow(CheckpointValidationError); + expect(() => validateCheckpoint(checkpoint, validOpts)).toThrow(/DA gas cost.*exceeds limit/); + }); + + it('throws when checkpoint blob field count exceeds limit', async () => { + const checkpoint = await makeCheckpoint(); + const maxBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB; + jest.spyOn(checkpoint, 'toBlobFields').mockReturnValue(new Array(maxBlobFields + 1).fill(Fr.ZERO)); + expect(() => validateCheckpoint(checkpoint, validOpts)).toThrow(CheckpointValidationError); + expect(() => validateCheckpoint(checkpoint, validOpts)).toThrow(/blob field count.*exceeds limit/); + }); + + it('throws when a block L2 gas exceeds maxL2BlockGas', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxL2BlockGas: specificMana - 1 })).toThrow( + CheckpointValidationError, + ); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxL2BlockGas: specificMana - 1 })).toThrow( + /L2 gas used.*exceeding limit/, + ); + }); + + it('skips per-block L2 gas check when maxL2BlockGas is undefined', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxL2BlockGas: undefined })).not.toThrow(); + }); + + it('throws when a block DA gas exceeds maxDABlockGas', async () => { + const checkpoint = await makeCheckpoint(); + jest.spyOn(checkpoint.blocks[0], 'computeDAGasUsed').mockReturnValue(1000); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxDABlockGas: 999 })).toThrow( + CheckpointValidationError, + ); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxDABlockGas: 999 })).toThrow( + /DA gas used.*exceeding limit/, + ); + }); + + it('skips per-block DA gas check when maxDABlockGas is undefined', async () => { + const checkpoint = await makeCheckpoint(); + expect(() => validateCheckpoint(checkpoint, { ...validOpts, maxDABlockGas: undefined })).not.toThrow(); + }); +}); diff --git a/yarn-project/stdlib/src/checkpoint/validate.ts b/yarn-project/stdlib/src/checkpoint/validate.ts index a89d9409f189..bd69180f21a2 100644 --- a/yarn-project/stdlib/src/checkpoint/validate.ts +++ b/yarn-project/stdlib/src/checkpoint/validate.ts @@ -2,6 +2,7 @@ import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB, MAX_PROCESSABLE_DA_GAS_PER_CHECK import type { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { sum } from '@aztec/foundation/collection'; +import { MAX_BLOCKS_PER_CHECKPOINT } from '../deserialization/index.js'; import type { Checkpoint } from './checkpoint.js'; export class CheckpointValidationError extends Error { @@ -17,6 +18,7 @@ export class CheckpointValidationError extends Error { /** * Validates a checkpoint. Throws a CheckpointValidationError if any validation fails. + * - Validates structural integrity (non-empty, block count, sequential numbers, archive chaining, slot consistency) * - Validates checkpoint blob field count against maxBlobFields limit * - Validates total L2 gas used by checkpoint blocks against the Rollup contract mana limit * - Validates total DA gas used by checkpoint blocks against MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT @@ -25,21 +27,107 @@ export class CheckpointValidationError extends Error { export function validateCheckpoint( checkpoint: Checkpoint, opts: { - rollupManaLimit: number; - maxL2BlockGas: number | undefined; - maxDABlockGas: number | undefined; + rollupManaLimit?: number; + maxL2BlockGas?: number; + maxDABlockGas?: number; }, ): void { + validateCheckpointStructure(checkpoint); validateCheckpointLimits(checkpoint, opts); validateCheckpointBlocksGasLimits(checkpoint, opts); } +/** + * Validates structural integrity of a checkpoint. + * - Non-empty block list + * - Block count within MAX_BLOCKS_PER_CHECKPOINT + * - Checkpoint slot matches the first block's slot + * - Checkpoint lastArchiveRoot matches the first block's lastArchive root + * - Sequential block numbers without gaps + * - Sequential indexWithinCheckpoint starting at 0 + * - Archive root chaining between consecutive blocks + * - Consistent slot number across all blocks + * - Global variables (slot, timestamp, coinbase, feeRecipient, gasFees) match checkpoint header for each block + */ +export function validateCheckpointStructure(checkpoint: Checkpoint): void { + const { blocks, number, slot } = checkpoint; + + if (blocks.length === 0) { + throw new CheckpointValidationError('Checkpoint has no blocks', number, slot); + } + + if (blocks.length > MAX_BLOCKS_PER_CHECKPOINT) { + throw new CheckpointValidationError( + `Checkpoint has ${blocks.length} blocks, exceeding limit of ${MAX_BLOCKS_PER_CHECKPOINT}`, + number, + slot, + ); + } + + const firstBlock = blocks[0]; + + if (!checkpoint.header.lastArchiveRoot.equals(firstBlock.header.lastArchive.root)) { + throw new CheckpointValidationError( + `Checkpoint lastArchiveRoot does not match first block's lastArchive root`, + number, + slot, + ); + } + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + + if (block.indexWithinCheckpoint !== i) { + throw new CheckpointValidationError( + `Block at index ${i} has indexWithinCheckpoint ${block.indexWithinCheckpoint}, expected ${i}`, + number, + slot, + ); + } + + if (block.slot !== slot) { + throw new CheckpointValidationError( + `Block ${block.number} has slot ${block.slot}, expected ${slot} (all blocks must share the same slot)`, + number, + slot, + ); + } + + if (!checkpoint.header.matchesGlobalVariables(block.header.globalVariables)) { + throw new CheckpointValidationError( + `Block ${block.number} global variables (slot, timestamp, coinbase, feeRecipient, gasFees) do not match checkpoint header`, + number, + slot, + ); + } + + if (i > 0) { + const prev = blocks[i - 1]; + if (block.number !== prev.number + 1) { + throw new CheckpointValidationError( + `Block numbers are not sequential: block at index ${i - 1} has number ${prev.number}, block at index ${i} has number ${block.number}`, + number, + slot, + ); + } + + if (!block.header.lastArchive.root.equals(prev.archive.root)) { + throw new CheckpointValidationError( + `Block ${block.number} lastArchive root does not match archive root of block ${prev.number}`, + number, + slot, + ); + } + } + } +} + /** Validates checkpoint blocks gas limits */ function validateCheckpointBlocksGasLimits( checkpoint: Checkpoint, opts: { - maxL2BlockGas: number | undefined; - maxDABlockGas: number | undefined; + maxL2BlockGas?: number; + maxDABlockGas?: number; }, ): void { const { maxL2BlockGas, maxDABlockGas } = opts; @@ -75,7 +163,7 @@ function validateCheckpointBlocksGasLimits( function validateCheckpointLimits( checkpoint: Checkpoint, opts: { - rollupManaLimit: number; + rollupManaLimit?: number; }, ): void { const { rollupManaLimit } = opts; @@ -83,13 +171,15 @@ function validateCheckpointLimits( const maxBlobFields = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB; const maxDAGas = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; - const checkpointMana = sum(checkpoint.blocks.map(block => block.header.totalManaUsed.toNumber())); - if (checkpointMana > rollupManaLimit) { - throw new CheckpointValidationError( - `Checkpoint mana cost ${checkpointMana} exceeds rollup limit of ${rollupManaLimit}`, - checkpoint.number, - checkpoint.slot, - ); + if (rollupManaLimit !== undefined) { + const checkpointMana = sum(checkpoint.blocks.map(block => block.header.totalManaUsed.toNumber())); + if (checkpointMana > rollupManaLimit) { + throw new CheckpointValidationError( + `Checkpoint mana cost ${checkpointMana} exceeds rollup limit of ${rollupManaLimit}`, + checkpoint.number, + checkpoint.slot, + ); + } } const checkpointDAGas = sum(checkpoint.blocks.map(block => block.computeDAGasUsed())); @@ -101,14 +191,12 @@ function validateCheckpointLimits( ); } - if (maxBlobFields !== undefined) { - const checkpointBlobFields = checkpoint.toBlobFields().length; - if (checkpointBlobFields > maxBlobFields) { - throw new CheckpointValidationError( - `Checkpoint blob field count ${checkpointBlobFields} exceeds limit of ${maxBlobFields}`, - checkpoint.number, - checkpoint.slot, - ); - } + const checkpointBlobFields = checkpoint.toBlobFields().length; + if (checkpointBlobFields > maxBlobFields) { + throw new CheckpointValidationError( + `Checkpoint blob field count ${checkpointBlobFields} exceeds limit of ${maxBlobFields}`, + checkpoint.number, + checkpoint.slot, + ); } }