diff --git a/yarn-project/archiver/src/archiver-sync.test.ts b/yarn-project/archiver/src/archiver-sync.test.ts index f75422e67168..e6b9ba4c8ca5 100644 --- a/yarn-project/archiver/src/archiver-sync.test.ts +++ b/yarn-project/archiver/src/archiver-sync.test.ts @@ -920,6 +920,116 @@ describe('Archiver Sync', () => { expect(rejectedBad).toBeDefined(); expect(rejectedValid).toBeDefined(); }, 15_000); + + it('rejects a checkpoint with invalid attestations even when its blob data is malformed', async () => { + // The archiver fetched and decoded checkpoint blobs before validating committee attestations. + // A checkpoint with BOTH invalid attestations and malformed blob data threw + // BlobDeserializationError during decode before the invalid-attestation skip path ran, so it was + // never recorded as rejected and sync looped on it forever (taking the valid CP1 in the same batch + // down with it). Attestations must be validated from calldata first, so the malformed blob is never + // fetched/decoded. + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(0)); + + // Committee of 3 signers. + fake.setTargetCommitteeSize(3); + const signers = times(3, Secp256k1Signer.random); + const committee = signers.map(signer => signer.address); + epochCache.getCommitteeForEpoch.mockResolvedValue({ committee } as EpochCommitteeInfo); + + const invalidCheckpointDetectedSpy = jest.fn(); + archiver.events.on(L2BlockSourceEvents.InvalidAttestationsCheckpointDetected, invalidCheckpointDetectedSpy); + + // Valid CP1 with correct attestations and well-formed blobs. + await fake.addCheckpoint(CheckpointNumber(1), { + l1BlockNumber: 70n, + messagesL1BlockNumber: 50n, + numL1ToL2Messages: 3, + signers, + }); + + // CP2 with BAD attestations (random signers not in committee). + const badSigners = times(3, Secp256k1Signer.random); + const { checkpoint: badCp2 } = await fake.addCheckpoint(CheckpointNumber(2), { + l1BlockNumber: 80n, + numL1ToL2Messages: 0, + signers: badSigners, + }); + + // Make ONLY CP2's blob malformed; CP1 keeps its real blobs. The default mock maps a blob sidecar to a + // checkpoint by its L1 block hash (Buffer32 of the L1 block number). + const cp2BlockId = Buffer32.fromBigInt(80n).toString(); + const malformedBlob = await makeRandomBlob(3); + const defaultGetBlobSidecar = blobClient.getBlobSidecar.getMockImplementation()!; + blobClient.getBlobSidecar.mockImplementation((...args: Parameters) => + args[0] === cp2BlockId ? Promise.resolve([malformedBlob]) : defaultGetBlobSidecar(...args), + ); + + fake.setL1BlockNumber(82n); + + // Must not throw: attestations are checked from calldata before the malformed CP2 blob is fetched. + await expect(archiver.syncImmediate()).resolves.toBeUndefined(); + + // CP1 syncs; CP2 is rejected for invalid attestations (not a blob-decode failure). + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(invalidCheckpointDetectedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: L2BlockSourceEvents.InvalidAttestationsCheckpointDetected, + validationResult: expect.objectContaining({ + valid: false, + checkpoint: expect.objectContaining({ checkpointNumber: 2 }), + }), + }), + ); + const rejected = await archiverStore.blocks.getRejectedCheckpointByArchiveRoot(badCp2.archive.root); + expect(rejected).toBeDefined(); + + // Repeated polling over the same L1 state stays stable. Without the fix, the malformed CP2 blob + // throws on every sync and the batch never commits -- even the valid CP1 stays unsynced and the + // archiver is stuck re-querying the same L1 blocks forever (the sync point never advances past the + // throw). With the fix, CP2 is rejected from calldata, CP1 is synced, and re-polling is a no-op. + for (let i = 0; i < 3; i++) { + await expect(archiver.syncImmediate()).resolves.toBeUndefined(); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); + } + }, 20_000); + + it('throws on a malformed blob with valid attestations', async () => { + // A checkpoint with VALID attestations but an unfetchable/undecodable blob is fatal: validating + // attestations from calldata never reaches the blob, so a malformed blob throws during decode and + // propagates (rolling back the L1 sync point so the fetch is retried on the next iteration). + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(0)); + + fake.setTargetCommitteeSize(3); + const signers = times(3, Secp256k1Signer.random); + const committee = signers.map(signer => signer.address); + epochCache.getCommitteeForEpoch.mockResolvedValue({ committee } as EpochCommitteeInfo); + + // CP1 valid with well-formed blobs. + await fake.addCheckpoint(CheckpointNumber(1), { + l1BlockNumber: 70n, + messagesL1BlockNumber: 50n, + numL1ToL2Messages: 3, + signers, + }); + + // CP2 with VALID attestations (signed by the committee) but a malformed blob. + await fake.addCheckpoint(CheckpointNumber(2), { + l1BlockNumber: 80n, + numL1ToL2Messages: 0, + signers, + }); + + const cp2BlockId = Buffer32.fromBigInt(80n).toString(); + const malformedBlob = await makeRandomBlob(3); + const defaultGetBlobSidecar = blobClient.getBlobSidecar.getMockImplementation()!; + blobClient.getBlobSidecar.mockImplementation((...args: Parameters) => + args[0] === cp2BlockId ? Promise.resolve([malformedBlob]) : defaultGetBlobSidecar(...args), + ); + + fake.setL1BlockNumber(82n); + + await expect(archiver.syncImmediate()).rejects.toThrow(); + }, 20_000); }); describe('reorg handling', () => { @@ -1666,6 +1776,64 @@ describe('Archiver Sync', () => { expect(checkpointedBlocks[0].checkpointNumber).toEqual(2); }, 10_000); + it('promotes a matching local checkpoint even when its on-chain blob is malformed', async () => { + // A checkpoint with a withheld/malformed blob is immune to the blob-decode stall when a matching local + // proposed copy exists, because it is promoted from local blocks and the blob fetch is skipped + // entirely. This must hold regardless of the blob being unfetchable. + await fake.addCheckpoint(CheckpointNumber(1), { + l1BlockNumber: 70n, + messagesL1BlockNumber: 60n, + numL1ToL2Messages: 3, + }); + + fake.setL1BlockNumber(100n); + await archiver.syncImmediate(); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); + + // Checkpoint 2 on L1 at a far-future block, with a malformed blob that would throw if ever fetched. + const { checkpoint: cp2 } = await fake.addCheckpoint(CheckpointNumber(2), { + l1BlockNumber: 5000n, + messagesL1BlockNumber: 4990n, + numL1ToL2Messages: 3, + }); + const cp2BlockId = Buffer32.fromBigInt(5000n).toString(); + const malformedBlob = await makeRandomBlob(3); + const defaultGetBlobSidecar = blobClient.getBlobSidecar.getMockImplementation()!; + blobClient.getBlobSidecar.mockImplementation((...args: Parameters) => + args[0] === cp2BlockId ? Promise.resolve([malformedBlob]) : defaultGetBlobSidecar(...args), + ); + + // Register checkpoint 2's blocks and a matching proposed checkpoint directly on the store, so the + // archiver has a local copy to promote (the archive root is computed from the stored blocks). We go + // through the store rather than archiver.addBlock/addProposedCheckpoint to avoid those methods firing + // background sync runs that would race with the explicit sync below. + for (const block of cp2.blocks) { + await archiverStore.blocks.addProposedBlock(block); + } + await archiverStore.blocks.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(2), + header: cp2.header, + startBlock: cp2.blocks[0].number, + blockCount: cp2.blocks.length, + totalManaUsed: 0n, + feeAssetPriceModifier: cp2.feeAssetPriceModifier, + }); + + blobClient.getBlobSidecar.mockClear(); + + fake.setL1BlockNumber(5010n); + await expect(archiver.syncImmediate()).resolves.toBeUndefined(); + + // Checkpoint 2 is ingested via promotion; its malformed blob was never fetched. + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(2)); + expect(pruneSpy).not.toHaveBeenCalled(); + expect(blobClient.getBlobSidecar).not.toHaveBeenCalledWith(cp2BlockId, expect.anything(), expect.anything()); + + const tips = await archiver.getL2Tips(); + expect(tips.checkpointed.checkpoint.number).toEqual(CheckpointNumber(2)); + expect(tips.checkpointed.block.number).toEqual(cp2.blocks[cp2.blocks.length - 1].number); + }, 10_000); + it('rejects adding blocks that are already checkpointed', async () => { // First, sync checkpoint 1 from L1 to establish a baseline const { checkpoint: cp1 } = await fake.addCheckpoint(CheckpointNumber(1), { diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index fd61b33a963b..de484c3be5a6 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -17,7 +17,13 @@ import { count } from '@aztec/foundation/string'; import { DateProvider, Timer, elapsed } from '@aztec/foundation/timer'; import { isDefined, isErrorClass } from '@aztec/foundation/types'; import { type ArchiverEmitter, L2BlockSourceEvents, type ValidateCheckpointResult } from '@aztec/stdlib/block'; -import { Checkpoint, type CheckpointData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import { + Checkpoint, + type CheckpointData, + type CheckpointInfo, + type L1PublishedData, + PublishedCheckpoint, +} from '@aztec/stdlib/checkpoint'; import { type L1RollupConstants, getEpochAtSlot, getSlotAtNextL1Block } from '@aztec/stdlib/epoch-helpers'; import { computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p'; @@ -39,7 +45,7 @@ import { MessageStoreError } from '../store/message_store.js'; import type { InboxMessage } from '../structs/inbox_message.js'; import { ArchiverDataStoreUpdater } from './data_store_updater.js'; import type { ArchiverInstrumentation } from './instrumentation.js'; -import { validateCheckpointAttestations } from './validation.js'; +import { validateCheckpointAttestationsFromCalldata } from './validation.js'; type RollupStatus = { provenCheckpointNumber: CheckpointNumber; @@ -50,7 +56,7 @@ type RollupStatus = { /** Last valid checkpoint observed on L1 and synced on this iteration */ lastRetrievedCheckpoint?: PublishedCheckpoint; /** Last checkpoint observed on L1 across both valid and rejected entries on this iteration */ - lastSeenCheckpoint?: PublishedCheckpoint; + lastSeenCheckpoint?: { checkpointNumber: CheckpointNumber; l1: L1PublishedData }; }; /** @@ -790,7 +796,7 @@ export class ArchiverL1Synchronizer implements Traceable { let searchStartBlock: bigint = blocksSynchedTo; let searchEndBlock: bigint = blocksSynchedTo; let lastRetrievedCheckpoint: PublishedCheckpoint | undefined; - let lastSeenCheckpoint: PublishedCheckpoint | undefined; + let lastSeenCheckpoint: { checkpointNumber: CheckpointNumber; l1: L1PublishedData } | undefined; do { [searchStartBlock, searchEndBlock] = this.nextRange(searchEndBlock, currentL1BlockNumber); @@ -835,37 +841,21 @@ export class ArchiverL1Synchronizer implements Traceable { const evictProposedFrom = promoteResult && 'diverged' in promoteResult ? promoteResult.fromCheckpointNumber : undefined; - // Then fetch blobs in parallel and build the full published checkpoints - const toFetchBlobs = checkpointToPromote ? calldataCheckpoints.slice(0, -1) : calldataCheckpoints; - const blobFetched = await asyncPool(10, toFetchBlobs, async checkpoint => - retrievedToPublishedCheckpoint({ - ...checkpoint, - checkpointBlobData: await getCheckpointBlobDataFromBlobs( - this.blobClient, - checkpoint.l1.blockHash, - checkpoint.blobHashes, - checkpoint.checkpointNumber, - this.log, - !initialSyncComplete, - checkpoint.parentBeaconBlockRoot, - checkpoint.l1.timestamp, - ), - }), - ); + // Validate attestations from CALLDATA before fetching any blobs. A checkpoint with invalid + // attestations (or one descending from a rejected ancestor) is rejected here without fetching its + // blobs, so a malformed blob does not throw during decode before the rejection path runs and + // stall sync. The signed consensus payload (header, archive root, fee asset price + // modifier) is fully available from calldata. + const checkpointsToIngest: RetrievedCheckpointFromCalldata[] = []; - // And add the promoted checkpoint to the list of all checkpoints - const publishedCheckpoints = checkpointToPromote ? [...blobFetched, checkpointToPromote] : blobFetched; - const validCheckpoints: PublishedCheckpoint[] = []; - - // Now loop through all checkpoints and validate their attestations - for (const published of publishedCheckpoints) { - // Check the attestations uploaded by the publisher to L1 are correct + for (const calldataCheckpoint of calldataCheckpoints) { + // Check the attestations uploaded by the publisher to L1 are correct. // Rollup contract does not validate attestations to save on gas, so this // falls on the nodes to verify offchain and skip those checkpoints. const validationResult = this.config.skipValidateCheckpointAttestations ? { valid: true as const } - : await validateCheckpointAttestations( - published, + : await validateCheckpointAttestationsFromCalldata( + calldataCheckpoint, this.epochCache, this.l1Constants, this.getSignatureContext(), @@ -877,7 +867,7 @@ export class ArchiverL1Synchronizer implements Traceable { // ancestor was skipped earlier (e.g. due to invalid attestations), the catch handler // would roll back the L1 sync point, and the next iteration would re-fetch and re-throw. const rejectedAncestor = await this.stores.blocks.getRejectedCheckpointByArchiveRoot( - published.checkpoint.header.lastArchiveRoot, + calldataCheckpoint.header.lastArchiveRoot, ); // Update the validation result if it has changed, so we can keep track of the first invalid checkpoint @@ -899,9 +889,9 @@ export class ArchiverL1Synchronizer implements Traceable { } if (!validationResult.valid) { - this.log.warn(`Skipping checkpoint ${published.checkpoint.number} due to invalid attestations`, { - checkpointHash: published.checkpoint.hash(), - l1BlockNumber: published.l1.blockNumber, + this.log.warn(`Skipping checkpoint ${calldataCheckpoint.checkpointNumber} due to invalid attestations`, { + checkpointNumber: calldataCheckpoint.checkpointNumber, + l1BlockNumber: calldataCheckpoint.l1.blockNumber, ...pick(validationResult, 'reason'), }); @@ -915,11 +905,11 @@ export class ArchiverL1Synchronizer implements Traceable { // is detected and skipped (rather than tripping the addCheckpoints consecutive-number // check and causing the sync point to roll back in a loop). await this.stores.blocks.addRejectedCheckpoint({ - checkpointNumber: published.checkpoint.number, - archiveRoot: published.checkpoint.archive.root, - parentArchiveRoot: published.checkpoint.header.lastArchiveRoot, - slotNumber: published.checkpoint.header.slotNumber, - l1: published.l1, + checkpointNumber: calldataCheckpoint.checkpointNumber, + archiveRoot: calldataCheckpoint.archiveRoot, + parentArchiveRoot: calldataCheckpoint.header.lastArchiveRoot, + slotNumber: calldataCheckpoint.header.slotNumber, + l1: calldataCheckpoint.l1, reason: 'invalid-attestations' as const, }); @@ -927,15 +917,20 @@ export class ArchiverL1Synchronizer implements Traceable { } if (rejectedAncestor) { - const descendantInfo = published.checkpoint.toCheckpointInfo(); + const descendantInfo: CheckpointInfo = { + archive: calldataCheckpoint.archiveRoot, + lastArchive: calldataCheckpoint.header.lastArchiveRoot, + slotNumber: calldataCheckpoint.header.slotNumber, + checkpointNumber: calldataCheckpoint.checkpointNumber, + timestamp: calldataCheckpoint.header.timestamp, + }; this.log.warn( - `Skipping checkpoint ${published.checkpoint.number} as it is a descendant of ` + + `Skipping checkpoint ${calldataCheckpoint.checkpointNumber} as it is a descendant of ` + `rejected checkpoint ${rejectedAncestor.checkpointNumber} (${rejectedAncestor.reason})`, { - checkpointNumber: published.checkpoint.number, - checkpointHash: published.checkpoint.hash(), - l1BlockNumber: published.l1.blockNumber, - l1BlockHash: published.l1.blockHash, + checkpointNumber: calldataCheckpoint.checkpointNumber, + l1BlockNumber: calldataCheckpoint.l1.blockNumber, + l1BlockHash: calldataCheckpoint.l1.blockHash, ancestorCheckpointNumber: rejectedAncestor.checkpointNumber, ancestorArchiveRoot: rejectedAncestor.archiveRoot.toString(), ancestorReason: rejectedAncestor.reason, @@ -952,17 +947,54 @@ export class ArchiverL1Synchronizer implements Traceable { // Persist this chainpoint as rejected as well, so we can construct a chain of // skipped checkpoints starting from the first one with invalid attestations. await this.stores.blocks.addRejectedCheckpoint({ - checkpointNumber: published.checkpoint.number, - archiveRoot: published.checkpoint.archive.root, - parentArchiveRoot: published.checkpoint.header.lastArchiveRoot, - slotNumber: published.checkpoint.header.slotNumber, - l1: published.l1, + checkpointNumber: calldataCheckpoint.checkpointNumber, + archiveRoot: calldataCheckpoint.archiveRoot, + parentArchiveRoot: calldataCheckpoint.header.lastArchiveRoot, + slotNumber: calldataCheckpoint.header.slotNumber, + l1: calldataCheckpoint.l1, reason: 'descends-from-invalid-attestations' as const, }); continue; } + checkpointsToIngest.push(calldataCheckpoint); + } + + // Fetch blobs in parallel only for the surviving (attestation-valid, non-descendant) checkpoints, + // then build the full published checkpoints. The last calldata checkpoint may be promotable from a + // local proposed block (checkpointToPromote), in which case it carries no blob to fetch. A missing or + // undecodable blob throws and propagates, rolling back the L1 sync point so the fetch is retried. + const toFetchBlobs = checkpointToPromote + ? checkpointsToIngest.filter(c => c.checkpointNumber !== checkpointToPromote.checkpoint.number) + : checkpointsToIngest; + const blobFetched = await asyncPool(10, toFetchBlobs, async checkpoint => + retrievedToPublishedCheckpoint({ + ...checkpoint, + checkpointBlobData: await getCheckpointBlobDataFromBlobs( + this.blobClient, + checkpoint.l1.blockHash, + checkpoint.blobHashes, + checkpoint.checkpointNumber, + this.log, + !initialSyncComplete, + checkpoint.parentBeaconBlockRoot, + checkpoint.l1.timestamp, + ), + }), + ); + + // Index the built checkpoints by number so we can ingest them in calldata order, slotting in the + // promoted checkpoint (built from a local proposed block rather than blobs). + const publishedByNumber = new Map(blobFetched.map(published => [published.checkpoint.number, published])); + if (checkpointToPromote) { + publishedByNumber.set(checkpointToPromote.checkpoint.number, checkpointToPromote); + } + + const validCheckpoints: PublishedCheckpoint[] = []; + for (const calldataCheckpoint of checkpointsToIngest) { + const published = publishedByNumber.get(calldataCheckpoint.checkpointNumber)!; + // Check the inHash of the checkpoint against the l1->l2 messages. // The messages should've been synced up to the currentL1BlockNumber and must be available for the published // checkpoints we just retrieved. @@ -1095,7 +1127,9 @@ export class ArchiverL1Synchronizer implements Traceable { }); } lastRetrievedCheckpoint = validCheckpoints.at(-1) ?? lastRetrievedCheckpoint; - lastSeenCheckpoint = publishedCheckpoints.at(-1) ?? lastSeenCheckpoint; + // The last checkpoint seen on L1 this batch (valid or rejected), tracked from calldata since + // rejected checkpoints are no longer built into PublishedCheckpoints. + lastSeenCheckpoint = lastCalldataCheckpoint; } while (searchEndBlock < currentL1BlockNumber); // Important that we update AFTER inserting the blocks. @@ -1205,7 +1239,7 @@ export class ArchiverL1Synchronizer implements Traceable { // Compare the last checkpoint (valid or not) we have (either retrieved in this round or loaded from store) // with what the rollup contract told us was the latest one (pinned at the currentL1BlockNumber). const latestLocalCheckpointNumber = - lastSeenCheckpoint?.checkpoint.number ?? + lastSeenCheckpoint?.checkpointNumber ?? CheckpointNumber.max( await this.stores.blocks.getLatestCheckpointNumber(), await this.stores.blocks.getLatestRejectedCheckpointNumber(), @@ -1218,7 +1252,11 @@ export class ArchiverL1Synchronizer implements Traceable { // We suspect an L1 reorg that added checkpoints *behind* us. If that is the case, it must have happened between // the last checkpoint we saw and the current one, so we reset the last synched L1 block number. In the edge case // we don't have one, we go back 2 L1 epochs, which is the deepest possible reorg (assuming Casper is working). - const latestLocalCheckpoint: PublishedCheckpoint | CheckpointData | RejectedCheckpoint | undefined = + const latestLocalCheckpoint: + | { checkpointNumber: CheckpointNumber; l1: L1PublishedData } + | CheckpointData + | RejectedCheckpoint + | undefined = lastSeenCheckpoint ?? (await this.stores.blocks.getCheckpointData(latestLocalCheckpointNumber)) ?? (await this.stores.blocks.getRejectedCheckpointByNumber(latestLocalCheckpointNumber)); diff --git a/yarn-project/archiver/src/modules/validation.ts b/yarn-project/archiver/src/modules/validation.ts index ffb6c0f57c7f..529dceea8ebf 100644 --- a/yarn-project/archiver/src/modules/validation.ts +++ b/yarn-project/archiver/src/modules/validation.ts @@ -1,16 +1,19 @@ import type { EpochCache } from '@aztec/epoch-cache'; -import { EpochNumber } from '@aztec/foundation/branded-types'; +import { type CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types'; import { compactArray } from '@aztec/foundation/collection'; +import type { Fr } from '@aztec/foundation/curves/bn254'; import type { Logger } from '@aztec/foundation/log'; import { type AttestationInfo, + type CommitteeAttestation, type ValidateCheckpointNegativeResult, type ValidateCheckpointResult, getAttestationInfoFromPayload, } from '@aztec/stdlib/block'; -import type { PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import type { CheckpointInfo, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import { type L1RollupConstants, computeQuorum, getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { ConsensusPayload, type CoordinationSignatureContext } from '@aztec/stdlib/p2p'; +import type { CheckpointHeader } from '@aztec/stdlib/rollup'; export type { ValidateCheckpointResult }; @@ -27,27 +30,83 @@ export function getAttestationInfoFromPublishedCheckpoint( } /** - * Validates the attestations submitted for the given checkpoint. + * Validates the attestations of a checkpoint already retrieved (with its blocks) from blobs. * Returns true if the attestations are valid and sufficient, false otherwise. */ -export async function validateCheckpointAttestations( +export function validateCheckpointAttestations( publishedCheckpoint: PublishedCheckpoint, epochCache: EpochCache, constants: Pick, signatureContext: CoordinationSignatureContext, logger?: Logger, ): Promise { - const attestorInfos = getAttestationInfoFromPublishedCheckpoint(publishedCheckpoint, signatureContext); - const attestors = compactArray(attestorInfos.map(info => ('address' in info ? info.address : undefined))); const { checkpoint, attestations } = publishedCheckpoint; - const headerHash = checkpoint.header.hash(); - const archiveRoot = checkpoint.archive.root.toString(); - const slot = checkpoint.header.slotNumber; + const payload = ConsensusPayload.fromCheckpoint(checkpoint, signatureContext); + return validateAttestations(payload, attestations, checkpoint.toCheckpointInfo(), epochCache, constants, logger); +} + +/** The subset of a calldata-only checkpoint needed to validate its committee attestations. */ +export type CalldataCheckpointForAttestations = { + checkpointNumber: CheckpointNumber; + archiveRoot: Fr; + feeAssetPriceModifier: bigint; + header: CheckpointHeader; + attestations: CommitteeAttestation[]; +}; + +/** + * Validates the attestations of a checkpoint from L1 calldata only, without fetching or decoding its blobs. + * The signed consensus payload (header, archive root, fee asset price modifier) is fully available from + * calldata, so an invalid-attestation checkpoint can be rejected before any (possibly malformed) blob is + * fetched and decoded. + */ +export function validateCheckpointAttestationsFromCalldata( + checkpoint: CalldataCheckpointForAttestations, + epochCache: EpochCache, + constants: Pick, + signatureContext: CoordinationSignatureContext, + logger?: Logger, +): Promise { + const payload = new ConsensusPayload( + checkpoint.header, + checkpoint.archiveRoot, + checkpoint.feeAssetPriceModifier, + signatureContext, + ); + const checkpointInfo: CheckpointInfo = { + archive: checkpoint.archiveRoot, + lastArchive: checkpoint.header.lastArchiveRoot, + slotNumber: checkpoint.header.slotNumber, + checkpointNumber: checkpoint.checkpointNumber, + timestamp: checkpoint.header.timestamp, + }; + return validateAttestations(payload, checkpoint.attestations, checkpointInfo, epochCache, constants, logger); +} + +/** + * Core attestation validation over a consensus payload, its attestations, and checkpoint metadata -- + * independent of whether the checkpoint's blocks have been decoded from blobs. Returns true if the + * attestations are valid and sufficient, false otherwise. + */ +async function validateAttestations( + payload: ConsensusPayload, + attestations: CommitteeAttestation[], + checkpointInfo: CheckpointInfo, + epochCache: EpochCache, + constants: Pick, + logger?: Logger, +): Promise { + const attestorInfos = getAttestationInfoFromPayload(payload, attestations); + const attestors = compactArray(attestorInfos.map(info => ('address' in info ? info.address : undefined))); + const headerHash = payload.header.hash(); + const archiveRoot = payload.archive.toString(); + const slot = payload.header.slotNumber; + const checkpointNumber = checkpointInfo.checkpointNumber; const epoch: EpochNumber = getEpochAtSlot(slot, constants); const { committee, seed } = await epochCache.getCommitteeForEpoch(epoch); - const logData = { checkpointNumber: checkpoint.number, slot, epoch, headerHash, archiveRoot }; + const logData = { checkpointNumber, slot, epoch, headerHash, archiveRoot }; - logger?.debug(`Validating attestations for checkpoint ${checkpoint.number} at slot ${slot} in epoch ${epoch}`, { + logger?.debug(`Validating attestations for checkpoint ${checkpointNumber} at slot ${slot} in epoch ${epoch}`, { committee: (committee ?? []).map(member => member.toString()), recoveredAttestors: attestorInfos, postedAttestations: attestations.map(a => (a.address.isZero() ? a.signature : a.address).toString()), @@ -72,7 +131,7 @@ export async function validateCheckpointAttestations( const failedValidationResult = (reason: TReason) => ({ valid: false as const, reason, - checkpoint: checkpoint.toCheckpointInfo(), + checkpoint: checkpointInfo, committee, seed, epoch, @@ -123,7 +182,7 @@ export async function validateCheckpointAttestations( } logger?.debug( - `Checkpoint attestations validated successfully for checkpoint ${checkpoint.number} at slot ${slot}`, + `Checkpoint attestations validated successfully for checkpoint ${checkpointNumber} at slot ${slot}`, logData, ); return { valid: true }; diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts index 939ecc2fe1aa..4751d74224ec 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts @@ -877,4 +877,39 @@ describe('e2e_epochs/epochs_invalidate_block', () => { disableConfig: { shuffleAttestationOrdering: false }, }); }); + + // A checkpoint with invalid attestations must be rejected from L1 calldata, before its blob is fetched. + // The attack forces the bad checkpoint to be reachable only from L1 calldata — no blob, no local blocks + // anywhere — so the proposer that later invalidates it can only have rejected it from calldata. Had the + // archiver fetched the blob first, the missing blob would have stalled its sync and nothing would ever be + // invalidated. Recovering from a checkpoint whose blob is genuinely unavailable is out of scope (A-1260). + it('proposer invalidates a non-broadcast checkpoint whose blob is withheld', async () => { + // Drop every node's blob store so the bad checkpoint's blob never reaches the shared filestore. Reads + // (getBlobSidecar) are untouched, so previously-stored good checkpoints stay fetchable; only this + // checkpoint's blob is missing. jest.restoreAllMocks() in afterEach restores the original method. + const blobSpies = nodes.map(node => + jest.spyOn(node.getBlobClient()!, 'sendBlobsToFilestore').mockResolvedValue(false), + ); + + await runInvalidationTest({ + // skipCollectingAttestations makes the checkpoint invalid; skipBroadcastProposals withholds the p2p + // proposal so peers only see it on L1; skipPushProposedBlocksToArchiver denies even the proposer's own + // archiver a local copy — otherwise it could promote that copy, skip the blob fetch, detect the bad + // attestations and invalidate without ever exercising the calldata-first path, masking a regression. + attackConfig: { + skipCollectingAttestations: true, + skipBroadcastProposals: true, + skipPushProposedBlocksToArchiver: true, + }, + disableConfig: { + skipCollectingAttestations: false, + skipBroadcastProposals: false, + skipPushProposedBlocksToArchiver: false, + }, + }); + + // The bad checkpoint's blob upload was intercepted, so it never reached the shared store: the + // invalidation above proves a proposer rejected it from L1 calldata without ever fetching its blob. + expect(blobSpies.some(spy => spy.mock.calls.length > 0)).toBe(true); + }); });