Skip to content

Commit 13f6840

Browse files
AztecBotPhilWindle
andauthored
fix(archiver): handle duplicate checkpoint from L1 reorg (#22252)
## Summary Fixes a mainnet issue where an L1 reorg moved a checkpoint to a different L1 block, causing the archiver to re-discover it and crash with `InitialCheckpointNumberNotSequentialError` in an infinite loop. When `addCheckpoints` receives a checkpoint that's already stored, it now: - **Accepts it** if the archive root matches (same content, just different L1 block) - **Updates the L1 metadata** (block number, timestamp, hash) and attestations - **Throws** if the archive root doesn't match (content mismatch — genuine conflict) ## Changes - **`block_store.ts`**: Added `skipOrUpdateAlreadyStoredCheckpoints` method that handles duplicate checkpoints at the start of a batch. Verifies archive roots match and updates L1 info. - **`fake_l1_state.ts`**: Added `moveCheckpointToL1Block` helper for simulating L1 reorgs that move checkpoints. - **`archiver-sync.test.ts`**: Added e2e test `handles L1 reorg that moves a checkpoint to a later L1 block` in the reorg handling suite. - **`kv_archiver_store.test.ts`**: Added unit tests for accepting matching duplicates with updated L1 info, accepting fully-duplicate batches, and rejecting mismatching duplicates. ## Test plan - [x] Unit tests: 207 passed in `kv_archiver_store.test.ts` - [x] Sync tests: 37 passed in `archiver-sync.test.ts` - [x] Build, format, lint all pass ClaudeBox log: https://claudebox.work/s/e5247344b8df94ca?run=2 Co-authored-by: PhilWindle <60546371+PhilWindle@users.noreply.github.com>
1 parent 04f1d9a commit 13f6840

4 files changed

Lines changed: 177 additions & 9 deletions

File tree

yarn-project/archiver/src/archiver-sync.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,6 +1245,56 @@ describe('Archiver Sync', () => {
12451245

12461246
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(2));
12471247
}, 15_000);
1248+
1249+
it('handles L1 reorg that moves a checkpoint to a later L1 block', async () => {
1250+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(0));
1251+
1252+
// Sync checkpoints 1 and 2
1253+
await fake.addCheckpoint(CheckpointNumber(1), {
1254+
l1BlockNumber: 70n,
1255+
messagesL1BlockNumber: 50n,
1256+
numL1ToL2Messages: 3,
1257+
});
1258+
const { checkpoint: cp2 } = await fake.addCheckpoint(CheckpointNumber(2), {
1259+
l1BlockNumber: 80n,
1260+
messagesL1BlockNumber: 60n,
1261+
numL1ToL2Messages: 3,
1262+
});
1263+
1264+
fake.setL1BlockNumber(90n);
1265+
await archiver.syncImmediate();
1266+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(2));
1267+
1268+
// Verify checkpoint 2's blocks are stored
1269+
const lastBlockNumber = cp2.blocks.at(-1)!.number;
1270+
const tips = await archiver.getL2Tips();
1271+
expect(tips.checkpointed.checkpoint.number).toEqual(CheckpointNumber(2));
1272+
expect(tips.checkpointed.block.number).toEqual(lastBlockNumber);
1273+
1274+
// Simulate L1 reorg: checkpoint 2 moves from L1 block 80 to L1 block 85.
1275+
// The checkpoint content (blocks, archive) stays the same — only the L1 block changes.
1276+
// This causes the archiver to re-discover checkpoint 2 when scanning from block 81 onward.
1277+
fake.moveCheckpointToL1Block(CheckpointNumber(2), 85n);
1278+
1279+
// Advance L1 and sync. The archiver's sync point is at L1 block 80 (from checkpoint 2's
1280+
// original insertion). The scan starts from 81, finds checkpoint 2 at block 85, and must
1281+
// accept it as a duplicate with updated L1 info rather than throwing.
1282+
fake.setL1BlockNumber(95n);
1283+
await archiver.syncImmediate();
1284+
1285+
// The archiver should still be at checkpoint 2 and healthy
1286+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(2));
1287+
1288+
// Add checkpoint 3 to verify the archiver can continue syncing after the duplicate
1289+
await fake.addCheckpoint(CheckpointNumber(3), {
1290+
l1BlockNumber: 100n,
1291+
messagesL1BlockNumber: 90n,
1292+
numL1ToL2Messages: 3,
1293+
});
1294+
fake.setL1BlockNumber(110n);
1295+
await archiver.syncImmediate();
1296+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(3));
1297+
}, 15_000);
12481298
});
12491299

12501300
describe('finalized checkpoint', () => {

yarn-project/archiver/src/store/block_store.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,28 @@ export class BlockStore {
262262
}
263263

264264
return await this.db.transactionAsync(async () => {
265-
// Check that the checkpoint immediately before the first block to be added is present in the store.
266265
const firstCheckpointNumber = checkpoints[0].checkpoint.number;
267266
const previousCheckpointNumber = await this.getLatestCheckpointNumber();
268267

269-
if (previousCheckpointNumber !== firstCheckpointNumber - 1 && !opts.force) {
268+
// Handle already-stored checkpoints at the start of the batch.
269+
// This can happen after an L1 reorg re-includes a checkpoint in a different L1 block.
270+
// We accept them if archives match (same content) and update their L1 metadata.
271+
if (!opts.force && firstCheckpointNumber <= previousCheckpointNumber) {
272+
checkpoints = await this.skipOrUpdateAlreadyStoredCheckpoints(checkpoints, previousCheckpointNumber);
273+
if (checkpoints.length === 0) {
274+
return true;
275+
}
276+
// Re-check sequentiality after skipping
277+
const newFirstNumber = checkpoints[0].checkpoint.number;
278+
if (previousCheckpointNumber !== newFirstNumber - 1) {
279+
throw new InitialCheckpointNumberNotSequentialError(newFirstNumber, previousCheckpointNumber);
280+
}
281+
} else if (previousCheckpointNumber !== firstCheckpointNumber - 1 && !opts.force) {
270282
throw new InitialCheckpointNumberNotSequentialError(firstCheckpointNumber, previousCheckpointNumber);
271283
}
272284

273285
// Get the last block of the previous checkpoint for archive chaining
274-
let previousBlock = await this.getPreviousCheckpointBlock(firstCheckpointNumber);
286+
let previousBlock = await this.getPreviousCheckpointBlock(checkpoints[0].checkpoint.number);
275287

276288
// Iterate over checkpoints array and insert them, checking that the block numbers are sequential.
277289
let previousCheckpoint: PublishedCheckpoint | undefined = undefined;
@@ -322,6 +334,50 @@ export class BlockStore {
322334
});
323335
}
324336

337+
/**
338+
* Handles checkpoints at the start of a batch that are already stored (e.g. due to L1 reorg).
339+
* Verifies the archive root matches, updates L1 metadata, and returns only the new checkpoints.
340+
*/
341+
private async skipOrUpdateAlreadyStoredCheckpoints(
342+
checkpoints: PublishedCheckpoint[],
343+
latestStored: CheckpointNumber,
344+
): Promise<PublishedCheckpoint[]> {
345+
let i = 0;
346+
for (; i < checkpoints.length && checkpoints[i].checkpoint.number <= latestStored; i++) {
347+
const incoming = checkpoints[i];
348+
const stored = await this.getCheckpointData(incoming.checkpoint.number);
349+
if (!stored) {
350+
// Should not happen if latestStored is correct, but be safe
351+
break;
352+
}
353+
// Verify the checkpoint content matches (archive root)
354+
if (!stored.archive.root.equals(incoming.checkpoint.archive.root)) {
355+
throw new Error(
356+
`Checkpoint ${incoming.checkpoint.number} already exists in store but with a different archive root. ` +
357+
`Stored: ${stored.archive.root}, incoming: ${incoming.checkpoint.archive.root}`,
358+
);
359+
}
360+
// Update L1 metadata and attestations for the already-stored checkpoint
361+
this.#log.warn(
362+
`Checkpoint ${incoming.checkpoint.number} already stored, updating L1 info ` +
363+
`(L1 block ${stored.l1.blockNumber} -> ${incoming.l1.blockNumber})`,
364+
);
365+
await this.#checkpoints.set(incoming.checkpoint.number, {
366+
header: incoming.checkpoint.header.toBuffer(),
367+
archive: incoming.checkpoint.archive.toBuffer(),
368+
checkpointOutHash: incoming.checkpoint.getCheckpointOutHash().toBuffer(),
369+
l1: incoming.l1.toBuffer(),
370+
attestations: incoming.attestations.map(a => a.toBuffer()),
371+
checkpointNumber: incoming.checkpoint.number,
372+
startBlock: incoming.checkpoint.blocks[0].number,
373+
blockCount: incoming.checkpoint.blocks.length,
374+
});
375+
// Update the sync point to reflect the new L1 block
376+
await this.#lastSynchedL1Block.set(incoming.l1.blockNumber);
377+
}
378+
return checkpoints.slice(i);
379+
}
380+
325381
/**
326382
* Gets the last block of the checkpoint before the given one.
327383
* Returns undefined if there is no previous checkpoint (i.e. genesis).

yarn-project/archiver/src/store/kv_archiver_store.test.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
makeInboxMessage,
6262
makeInboxMessages,
6363
makeInboxMessagesWithFullBlocks,
64+
makeL1PublishedData,
6465
makePrivateLog,
6566
makePrivateLogTag,
6667
makePublicLog,
@@ -138,10 +139,56 @@ describe('KVArchiverDataStore', () => {
138139
await expect(store.addCheckpoints(publishedCheckpoints)).resolves.toBe(true);
139140
});
140141

141-
it('throws on duplicate checkpoints', async () => {
142-
await store.addCheckpoints(publishedCheckpoints);
143-
await expect(store.addCheckpoints(publishedCheckpoints)).rejects.toThrow(
144-
InitialCheckpointNumberNotSequentialError,
142+
it('accepts duplicate checkpoints with matching archives and updates L1 info', async () => {
143+
// Add first 3 checkpoints
144+
const first3 = publishedCheckpoints.slice(0, 3);
145+
await store.addCheckpoints(first3);
146+
147+
// Verify initial L1 block number for checkpoint 3
148+
const beforeData = await store.getCheckpointData(CheckpointNumber(3));
149+
expect(beforeData).toBeDefined();
150+
const originalL1Block = beforeData!.l1.blockNumber;
151+
152+
// Re-add checkpoint 3 with the same content but different L1 published data
153+
// This simulates an L1 reorg that moved the checkpoint to a different L1 block
154+
const cp3WithNewL1 = new PublishedCheckpoint(
155+
first3[2].checkpoint,
156+
makeL1PublishedData(999),
157+
first3[2].attestations,
158+
);
159+
// Also add checkpoint 4 (the next one) in the same batch
160+
await store.addCheckpoints([cp3WithNewL1, publishedCheckpoints[3]]);
161+
162+
// Checkpoint 3's L1 info should be updated
163+
const afterData = await store.getCheckpointData(CheckpointNumber(3));
164+
expect(afterData).toBeDefined();
165+
expect(afterData!.l1.blockNumber).toEqual(999n);
166+
expect(afterData!.l1.blockNumber).not.toEqual(originalL1Block);
167+
168+
// Checkpoint 4 should be stored
169+
expect(await store.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(4));
170+
});
171+
172+
it('accepts a batch that is entirely already-stored checkpoints', async () => {
173+
const first3 = publishedCheckpoints.slice(0, 3);
174+
await store.addCheckpoints(first3);
175+
176+
// Re-add the same 3 checkpoints — should succeed without error
177+
await expect(store.addCheckpoints(first3)).resolves.toBe(true);
178+
});
179+
180+
it('throws on duplicate checkpoints with mismatching archives', async () => {
181+
const first3 = publishedCheckpoints.slice(0, 3);
182+
await store.addCheckpoints(first3);
183+
184+
// Create a fake checkpoint 3 with a different archive root (content mismatch)
185+
const differentCheckpoint3 = await Checkpoint.random(CheckpointNumber(3), {
186+
numBlocks: 1,
187+
startBlockNumber: 3,
188+
});
189+
const mismatchedCp3 = makePublishedCheckpoint(differentCheckpoint3, 999);
190+
await expect(store.addCheckpoints([mismatchedCp3])).rejects.toThrow(
191+
'already exists in store but with a different archive',
145192
);
146193
});
147194

@@ -278,7 +325,7 @@ describe('KVArchiverDataStore', () => {
278325
await expect(store.addCheckpoints([publishedCheckpoint])).resolves.toBe(true);
279326
});
280327

281-
it('throws on duplicate initial checkpoint', async () => {
328+
it('throws on duplicate checkpoint with different content', async () => {
282329
const block1 = await L2Block.random(BlockNumber(1), {
283330
checkpointNumber: CheckpointNumber(1),
284331
indexWithinCheckpoint: IndexWithinCheckpoint(0),
@@ -307,7 +354,7 @@ describe('KVArchiverDataStore', () => {
307354

308355
await expect(store.addCheckpoints([publishedCheckpoint])).resolves.toBe(true);
309356
await expect(store.addCheckpoints([publishedCheckpoint2])).rejects.toThrow(
310-
InitialCheckpointNumberNotSequentialError,
357+
'already exists in store but with a different archive',
311358
);
312359
});
313360

yarn-project/archiver/src/test/fake_l1_state.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,21 @@ export class FakeL1State {
332332
this.updatePendingCheckpointNumber();
333333
}
334334

335+
/**
336+
* Moves a checkpoint to a different L1 block number (simulates L1 reorg that
337+
* re-includes the same checkpoint transaction in a different block).
338+
* The checkpoint content stays the same — only the L1 metadata changes.
339+
* Auto-updates pending status.
340+
*/
341+
moveCheckpointToL1Block(checkpointNumber: CheckpointNumber, newL1BlockNumber: bigint): void {
342+
for (const cpData of this.checkpoints) {
343+
if (cpData.checkpointNumber === checkpointNumber) {
344+
cpData.l1BlockNumber = newL1BlockNumber;
345+
}
346+
}
347+
this.updatePendingCheckpointNumber();
348+
}
349+
335350
/**
336351
* Removes messages after a given total index (simulates L1 reorg).
337352
* Auto-updates rolling hash.

0 commit comments

Comments
 (0)