From 57a2f091a167818fc5d80e5a9ea0f513ee06be90 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 23 Jun 2026 13:13:29 -0300 Subject: [PATCH 1/2] fix(archiver): validate checkpoint attestations from calldata before fetching blobs (A-1252) --- .../archiver/src/archiver-sync.test.ts | 168 +++++++++++++++ .../archiver/src/modules/l1_synchronizer.ts | 144 ++++++++----- .../archiver/src/modules/validation.ts | 85 ++++++-- .../epochs_invalidate_block.parallel.test.ts | 203 ++++++++++++++++++ 4 files changed, 534 insertions(+), 66 deletions(-) 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..c738eb82f130 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 @@ -25,6 +25,8 @@ import { L2BlockSourceEvents } from '@aztec/stdlib/block'; import { computeQuorum, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { jest } from '@jest/globals'; +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; import type { Log } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; @@ -878,3 +880,204 @@ describe('e2e_epochs/epochs_invalidate_block', () => { }); }); }); + +// A checkpoint with invalid attestations must be rejected from L1 calldata, before its blob is +// fetched. We withhold the bad checkpoint's blob from the shared store to prove the rejection happens +// from calldata alone: a node that tried to fetch the blob first would throw on the missing blob and +// stall its sync. Recovering from a checkpoint whose blob is genuinely unavailable (regardless of +// attestation validity) is out of scope here and tracked in A-1260. +describe('e2e_epochs/epochs_reject_invalid_attestations_from_calldata', () => { + let context: EndToEndContext; + let logger: Logger; + let l1Client: ExtendedViemWalletClient; + let rollupContract: RollupContract; + let portOffset = 100; + + let test: EpochsTestContext; + let validators: (Operator & { privateKey: `0x${string}` })[]; + let nodes: AztecNodeService[]; + let testContract: TestContract; + let from: AztecAddress; + let nullifierSeed = 0; + + beforeEach(async () => { + validators = times(VALIDATOR_COUNT, i => { + const privateKey = bufferToHex(getPrivateKeyFromIndex(i + 3)!); + const attester = EthAddress.fromString(privateKeyToAccount(privateKey).address); + return { attester, withdrawer: attester, privateKey, bn254SecretKey: new SecretValue(Fr.random().toBigInt()) }; + }); + + test = await EpochsTestContext.setup({ + ethereumSlotDuration: 8, + aztecSlotDuration: 32, + aztecEpochDuration: 6, + blockDurationMs: 6000, + numberOfAccounts: 0, + initialValidators: validators, + mockGossipSubNetwork: true, + // Short proof window + no prover, so a checkpoint's epoch becomes prunable shortly after it ends. + aztecProofSubmissionEpochs: 1, + startProverNode: false, + aztecTargetCommitteeSize: VALIDATOR_COUNT, + // Disable all invalidation by default so a bad checkpoint stays canonical while we test it; the + // invalid-attestations test re-enables proposer invalidation only for its recovery phase. + secondsBeforeInvalidatingBlockAsCommitteeMember: Number.MAX_SAFE_INTEGER, + secondsBeforeInvalidatingBlockAsNonCommitteeMember: Number.MAX_SAFE_INTEGER, + skipInvalidateBlockAsProposer: true, + archiverPollingIntervalMS: 200, + anvilAccounts: 20, + anvilPort: BASE_ANVIL_PORT + ++portOffset, + // Require a tx to build a checkpoint and never build empty ones, so checkpoint production is driven + // deterministically by sending txs — we control exactly which checkpoint is "bad" and the chain stays + // frozen on it once we stop. This avoids racing sequencer-config changes against streaming empties. + minTxsPerBlock: 1, + maxTxsPerBlock: 1, + buildCheckpointIfEmpty: false, + skipInitialSequencer: true, + }); + + ({ context, logger, l1Client } = test); + rollupContract = new RollupContract(l1Client, test.rollup.address); + from = context.accounts[0]; // auto-created by setup + nullifierSeed = 0; + + const validatorNodes = validators.slice(0, NODE_COUNT); + nodes = await asyncMap(validatorNodes, ({ privateKey }) => + test.createValidatorNode([privateKey], { dontStartSequencer: true, minTxsPerBlock: 1, maxTxsPerBlock: 1 }), + ); + testContract = await test.registerTestContract(context.wallet); + logger.warn(`Started ${NODE_COUNT} validator nodes.`); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await test.teardown(); + }); + + // Feed one tx and wait for exactly one new checkpoint to land. With empty checkpoints disabled and + // maxTxsPerBlock 1, a single tx produces a single checkpoint, then production halts (chain frozen). + const produceCheckpoint = async () => { + const start = (await nodes[0].getChainTips()).checkpointed.checkpoint.number; + void testContract.methods + .emit_nullifier(BigInt(++nullifierSeed)) + .send({ from, wait: NO_WAIT }) + .catch(() => {}); + await test.waitUntilCheckpointNumber(CheckpointNumber(start + 1), test.L2_SLOT_DURATION_IN_S * 10); + }; + + // Keep feeding txs until nodes[0] reaches `target` (used to resume production and rebuild after a prune). + const driveToCheckpoint = (target: CheckpointNumber, timeoutSlots = 16) => + retryUntil( + async () => { + const tip = (await nodes[0].getChainTips()).checkpointed.checkpoint.number; + if (tip >= target) { + return true; + } + void testContract.methods + .emit_nullifier(BigInt(++nullifierSeed)) + .send({ from, wait: NO_WAIT }) + .catch(() => {}); + return false; + }, + `drive chain to checkpoint ${target}`, + test.L2_SLOT_DURATION_IN_S * timeoutSlots, + 2, + ); + + it('rejects a canonical invalid-attestations checkpoint from calldata without its blob', async () => { + const sequencers = nodes.map(node => node.getSequencer()!); + + // Make every proposer skip collecting attestations BEFORE they start, so the first checkpoint they + // produce deterministically lands with insufficient attestations (setting it just before a specific + // checkpoint is racy under pipelining — the proposer may have already locked the prior config). We + // then feed exactly one tx to produce that one bad checkpoint and stop, so it stays the canonical tip + // (invalidation is disabled fixture-wide). Keeping it canonical is the whole point — it forces the + // observer down the attestation-from-calldata path rather than the archive-mismatch filter, which only + // drops checkpoints that are no longer canonical (e.g. already invalidated/replaced). + // Disable every invalidation path on the sequencers too (the fixture-level settings do not all + // propagate to the running sequencer), otherwise proposers thrash — invalidating and re-proposing the + // bad checkpoint every slot — instead of leaving it canonical. + sequencers.forEach(s => + s.updateConfig({ + skipCollectingAttestations: true, + skipInvalidateBlockAsProposer: true, + secondsBeforeInvalidatingBlockAsCommitteeMember: Number.MAX_SAFE_INTEGER, + secondsBeforeInvalidatingBlockAsNonCommitteeMember: Number.MAX_SAFE_INTEGER, + }), + ); + // Create a point with invalid attestations + await Promise.all(sequencers.map(s => s.start())); + await produceCheckpoint(); + + const proposedEvents = await rollupContract.getCheckpointProposedEvents(1n, await l1Client.getBlockNumber()); + const badEvent = proposedEvents.reduce((a, b) => (b.args.checkpointNumber > a.args.checkpointNumber ? b : a)); + const badCheckpointNumber = badEvent.args.checkpointNumber; + const badL1Timestamp = (await l1Client.getBlock({ blockNumber: badEvent.l1BlockNumber })).timestamp; + logger.warn(`Froze chain on invalid-attestations checkpoint ${badCheckpointNumber}`); + + // Withhold its blob from the shared store. + const sharedRoot = join(test.context.config.dataDirectory!, 'shared-blobs'); + const namespaceDir = (await readdir(sharedRoot)).find(e => e.startsWith('aztec-')); + expect(namespaceDir).toBeDefined(); + const blobsDir = join(sharedRoot, namespaceDir!, 'blobs'); + const targetNames = badEvent.args.versionedBlobHashes.map(h => `0x${h.toString('hex')}.data`); + const before = await readdir(blobsDir); + for (const name of targetNames) { + expect(before).toContain(name); + await rm(join(blobsDir, name), { force: true }); + } + logger.warn(`Withheld ${targetNames.length} blob(s) for checkpoint ${badCheckpointNumber}`); + + // Late observer (started after the bad checkpoint was gossiped, so it has no proposed copy to promote) + // that also never promotes, forcing it to rely on L1. It must rely on the attestation check, not the + // archive-mismatch filter: that filter only drops checkpoints that are no longer canonical, and we have + // kept this one canonical (no invalidation). Its sync clock advancing past the checkpoint — while its + // blob is withheld — proves it rejected from calldata without fetching the blob; without the + // calldata-first ordering it would throw on the missing blob and its clock would stay frozen. + const observer = await test.createNonValidatorNode({ + skipArchiverInitialSync: true, + skipPromoteProposedCheckpointDuringL1Sync: true, + }); + await retryUntil( + async () => { + const ts = await observer.getSyncedL1Timestamp(); + return ts !== undefined && ts > badL1Timestamp; + }, + 'observer sync clock advances past the canonical invalid-attestations checkpoint without its blob', + test.L2_SLOT_DURATION_IN_S * 8, + 0.5, + ); + + // While the bad checkpoint is still canonical (we have not invalidated it yet), the observer must NOT + // have ingested it: it was rejected from calldata, so it is absent from the observer's store rather than + // built from the withheld blob. + const observerBadCheckpoint = await observer.getCheckpoint(badCheckpointNumber); + expect(observerBadCheckpoint).toBeUndefined(); + + // Resume honest production AND re-enable proposer invalidation: a proposer invalidates the bad + // checkpoint and the chain rebuilds; every node — validators and the observer — progresses past it. + logger.warn(`Resuming honest production to let the chain invalidate and rebuild`); + sequencers.forEach(s => + s.updateConfig({ skipCollectingAttestations: false, skipInvalidateBlockAsProposer: false }), + ); + await driveToCheckpoint(CheckpointNumber(badCheckpointNumber + 1), 20); + const allNodes = [...nodes, observer]; + await retryUntil( + async () => { + const tips = await Promise.all(allNodes.map(n => n.getChainTips().then(t => t.checkpointed.checkpoint.number))); + logger.info(`Node checkpoint tips: ${tips.join(', ')} (target > ${badCheckpointNumber})`); + return tips.every(n => n > badCheckpointNumber); + }, + 'chain invalidates the bad checkpoint and every node (incl. the observer) progresses past it', + test.L2_SLOT_DURATION_IN_S * 12, + 0.5, + ); + + // The bad checkpoint was actually invalidated and replaced, not merely buried: the archive now committed + // on-chain at its number differs from the bad checkpoint's archive. + const archiveAtBadNumber = await rollupContract.archiveAt(badCheckpointNumber); + expect(archiveAtBadNumber.equals(badEvent.args.archive)).toBe(false); + + logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); + }); +}); From d4027eb319a91ca394404580dab06e975f8280bc Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 23 Jun 2026 15:38:25 -0300 Subject: [PATCH 2/2] test(e2e): withhold blob via blob-client spy instead of deleting files (A-1252) Replace the separate epochs_reject_invalid_attestations_from_calldata suite (which deleted blob files on disk and spun up a special late observer) with a single test reusing runInvalidationTest. The bad checkpoint is made reachable only from L1 calldata: skipCollectingAttestations makes it invalid, skipBroadcastProposals withholds the p2p proposal, skipPushProposedBlocksToArchiver denies the proposer's own archiver a local copy, and a jest spy drops every node's blob store. A proposer can then only invalidate it by rejecting from calldata before fetching the (withheld) blob. --- .../epochs_invalidate_block.parallel.test.ts | 222 +++--------------- 1 file changed, 27 insertions(+), 195 deletions(-) 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 c738eb82f130..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 @@ -25,8 +25,6 @@ import { L2BlockSourceEvents } from '@aztec/stdlib/block'; import { computeQuorum, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { jest } from '@jest/globals'; -import { readdir, rm } from 'node:fs/promises'; -import { join } from 'node:path'; import type { Log } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; @@ -879,205 +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. We withhold the bad checkpoint's blob from the shared store to prove the rejection happens -// from calldata alone: a node that tried to fetch the blob first would throw on the missing blob and -// stall its sync. Recovering from a checkpoint whose blob is genuinely unavailable (regardless of -// attestation validity) is out of scope here and tracked in A-1260. -describe('e2e_epochs/epochs_reject_invalid_attestations_from_calldata', () => { - let context: EndToEndContext; - let logger: Logger; - let l1Client: ExtendedViemWalletClient; - let rollupContract: RollupContract; - let portOffset = 100; - - let test: EpochsTestContext; - let validators: (Operator & { privateKey: `0x${string}` })[]; - let nodes: AztecNodeService[]; - let testContract: TestContract; - let from: AztecAddress; - let nullifierSeed = 0; - beforeEach(async () => { - validators = times(VALIDATOR_COUNT, i => { - const privateKey = bufferToHex(getPrivateKeyFromIndex(i + 3)!); - const attester = EthAddress.fromString(privateKeyToAccount(privateKey).address); - return { attester, withdrawer: attester, privateKey, bn254SecretKey: new SecretValue(Fr.random().toBigInt()) }; - }); - - test = await EpochsTestContext.setup({ - ethereumSlotDuration: 8, - aztecSlotDuration: 32, - aztecEpochDuration: 6, - blockDurationMs: 6000, - numberOfAccounts: 0, - initialValidators: validators, - mockGossipSubNetwork: true, - // Short proof window + no prover, so a checkpoint's epoch becomes prunable shortly after it ends. - aztecProofSubmissionEpochs: 1, - startProverNode: false, - aztecTargetCommitteeSize: VALIDATOR_COUNT, - // Disable all invalidation by default so a bad checkpoint stays canonical while we test it; the - // invalid-attestations test re-enables proposer invalidation only for its recovery phase. - secondsBeforeInvalidatingBlockAsCommitteeMember: Number.MAX_SAFE_INTEGER, - secondsBeforeInvalidatingBlockAsNonCommitteeMember: Number.MAX_SAFE_INTEGER, - skipInvalidateBlockAsProposer: true, - archiverPollingIntervalMS: 200, - anvilAccounts: 20, - anvilPort: BASE_ANVIL_PORT + ++portOffset, - // Require a tx to build a checkpoint and never build empty ones, so checkpoint production is driven - // deterministically by sending txs — we control exactly which checkpoint is "bad" and the chain stays - // frozen on it once we stop. This avoids racing sequencer-config changes against streaming empties. - minTxsPerBlock: 1, - maxTxsPerBlock: 1, - buildCheckpointIfEmpty: false, - skipInitialSequencer: true, - }); - - ({ context, logger, l1Client } = test); - rollupContract = new RollupContract(l1Client, test.rollup.address); - from = context.accounts[0]; // auto-created by setup - nullifierSeed = 0; - - const validatorNodes = validators.slice(0, NODE_COUNT); - nodes = await asyncMap(validatorNodes, ({ privateKey }) => - test.createValidatorNode([privateKey], { dontStartSequencer: true, minTxsPerBlock: 1, maxTxsPerBlock: 1 }), + // 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), ); - testContract = await test.registerTestContract(context.wallet); - logger.warn(`Started ${NODE_COUNT} validator nodes.`); - }); - afterEach(async () => { - jest.restoreAllMocks(); - await test.teardown(); - }); - - // Feed one tx and wait for exactly one new checkpoint to land. With empty checkpoints disabled and - // maxTxsPerBlock 1, a single tx produces a single checkpoint, then production halts (chain frozen). - const produceCheckpoint = async () => { - const start = (await nodes[0].getChainTips()).checkpointed.checkpoint.number; - void testContract.methods - .emit_nullifier(BigInt(++nullifierSeed)) - .send({ from, wait: NO_WAIT }) - .catch(() => {}); - await test.waitUntilCheckpointNumber(CheckpointNumber(start + 1), test.L2_SLOT_DURATION_IN_S * 10); - }; - - // Keep feeding txs until nodes[0] reaches `target` (used to resume production and rebuild after a prune). - const driveToCheckpoint = (target: CheckpointNumber, timeoutSlots = 16) => - retryUntil( - async () => { - const tip = (await nodes[0].getChainTips()).checkpointed.checkpoint.number; - if (tip >= target) { - return true; - } - void testContract.methods - .emit_nullifier(BigInt(++nullifierSeed)) - .send({ from, wait: NO_WAIT }) - .catch(() => {}); - return false; - }, - `drive chain to checkpoint ${target}`, - test.L2_SLOT_DURATION_IN_S * timeoutSlots, - 2, - ); - - it('rejects a canonical invalid-attestations checkpoint from calldata without its blob', async () => { - const sequencers = nodes.map(node => node.getSequencer()!); - - // Make every proposer skip collecting attestations BEFORE they start, so the first checkpoint they - // produce deterministically lands with insufficient attestations (setting it just before a specific - // checkpoint is racy under pipelining — the proposer may have already locked the prior config). We - // then feed exactly one tx to produce that one bad checkpoint and stop, so it stays the canonical tip - // (invalidation is disabled fixture-wide). Keeping it canonical is the whole point — it forces the - // observer down the attestation-from-calldata path rather than the archive-mismatch filter, which only - // drops checkpoints that are no longer canonical (e.g. already invalidated/replaced). - // Disable every invalidation path on the sequencers too (the fixture-level settings do not all - // propagate to the running sequencer), otherwise proposers thrash — invalidating and re-proposing the - // bad checkpoint every slot — instead of leaving it canonical. - sequencers.forEach(s => - s.updateConfig({ + 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, - skipInvalidateBlockAsProposer: true, - secondsBeforeInvalidatingBlockAsCommitteeMember: Number.MAX_SAFE_INTEGER, - secondsBeforeInvalidatingBlockAsNonCommitteeMember: Number.MAX_SAFE_INTEGER, - }), - ); - // Create a point with invalid attestations - await Promise.all(sequencers.map(s => s.start())); - await produceCheckpoint(); - - const proposedEvents = await rollupContract.getCheckpointProposedEvents(1n, await l1Client.getBlockNumber()); - const badEvent = proposedEvents.reduce((a, b) => (b.args.checkpointNumber > a.args.checkpointNumber ? b : a)); - const badCheckpointNumber = badEvent.args.checkpointNumber; - const badL1Timestamp = (await l1Client.getBlock({ blockNumber: badEvent.l1BlockNumber })).timestamp; - logger.warn(`Froze chain on invalid-attestations checkpoint ${badCheckpointNumber}`); - - // Withhold its blob from the shared store. - const sharedRoot = join(test.context.config.dataDirectory!, 'shared-blobs'); - const namespaceDir = (await readdir(sharedRoot)).find(e => e.startsWith('aztec-')); - expect(namespaceDir).toBeDefined(); - const blobsDir = join(sharedRoot, namespaceDir!, 'blobs'); - const targetNames = badEvent.args.versionedBlobHashes.map(h => `0x${h.toString('hex')}.data`); - const before = await readdir(blobsDir); - for (const name of targetNames) { - expect(before).toContain(name); - await rm(join(blobsDir, name), { force: true }); - } - logger.warn(`Withheld ${targetNames.length} blob(s) for checkpoint ${badCheckpointNumber}`); - - // Late observer (started after the bad checkpoint was gossiped, so it has no proposed copy to promote) - // that also never promotes, forcing it to rely on L1. It must rely on the attestation check, not the - // archive-mismatch filter: that filter only drops checkpoints that are no longer canonical, and we have - // kept this one canonical (no invalidation). Its sync clock advancing past the checkpoint — while its - // blob is withheld — proves it rejected from calldata without fetching the blob; without the - // calldata-first ordering it would throw on the missing blob and its clock would stay frozen. - const observer = await test.createNonValidatorNode({ - skipArchiverInitialSync: true, - skipPromoteProposedCheckpointDuringL1Sync: true, - }); - await retryUntil( - async () => { - const ts = await observer.getSyncedL1Timestamp(); - return ts !== undefined && ts > badL1Timestamp; + skipBroadcastProposals: true, + skipPushProposedBlocksToArchiver: true, }, - 'observer sync clock advances past the canonical invalid-attestations checkpoint without its blob', - test.L2_SLOT_DURATION_IN_S * 8, - 0.5, - ); - - // While the bad checkpoint is still canonical (we have not invalidated it yet), the observer must NOT - // have ingested it: it was rejected from calldata, so it is absent from the observer's store rather than - // built from the withheld blob. - const observerBadCheckpoint = await observer.getCheckpoint(badCheckpointNumber); - expect(observerBadCheckpoint).toBeUndefined(); - - // Resume honest production AND re-enable proposer invalidation: a proposer invalidates the bad - // checkpoint and the chain rebuilds; every node — validators and the observer — progresses past it. - logger.warn(`Resuming honest production to let the chain invalidate and rebuild`); - sequencers.forEach(s => - s.updateConfig({ skipCollectingAttestations: false, skipInvalidateBlockAsProposer: false }), - ); - await driveToCheckpoint(CheckpointNumber(badCheckpointNumber + 1), 20); - const allNodes = [...nodes, observer]; - await retryUntil( - async () => { - const tips = await Promise.all(allNodes.map(n => n.getChainTips().then(t => t.checkpointed.checkpoint.number))); - logger.info(`Node checkpoint tips: ${tips.join(', ')} (target > ${badCheckpointNumber})`); - return tips.every(n => n > badCheckpointNumber); + disableConfig: { + skipCollectingAttestations: false, + skipBroadcastProposals: false, + skipPushProposedBlocksToArchiver: false, }, - 'chain invalidates the bad checkpoint and every node (incl. the observer) progresses past it', - test.L2_SLOT_DURATION_IN_S * 12, - 0.5, - ); - - // The bad checkpoint was actually invalidated and replaced, not merely buried: the archive now committed - // on-chain at its number differs from the bad checkpoint's archive. - const archiveAtBadNumber = await rollupContract.archiveAt(badCheckpointNumber); - expect(archiveAtBadNumber.equals(badEvent.args.archive)).toBe(false); + }); - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); + // 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); }); });