Skip to content

Commit 983038e

Browse files
authored
fix(archiver): skip descendants of invalid-attestations checkpoints (#23502)
## Motivation `archiver/src/modules/l1_synchronizer.ts` skipped checkpoints with insufficient/invalid attestations under the assumption that the next proposer would invalidate them before publishing. When that assumption was violated — i.e., proposer P2 published a valid-attestations checkpoint that extended P1's invalid one — the archiver hit `InitialCheckpointNumberNotSequentialError` in `block_store.addCheckpoints`, the catch handler rolled back the L1 sync point, and the next poll re-fetched the same range and re-threw. The archiver looped indefinitely. The protocol already defines `OffenseType.PROPOSED_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS` for exactly this case but the slasher couldn't see valid-attestations descendants because the archiver threw before emitting any event. ### Human Note This is particularly relevant under pipelining. Attestors now attest to a checkpoint _before_ the previous one is pushed to L1, so they can be inadvertently attesting to a checkpoint built on top of one that became invalid as it was published to the rollup the contract with wrong attestations. So an honest attestor could get slashed if the proposer was malicious. ## Approach In the synchronizer, persist rejected ancestors in the block store keyed by archive root. On each new checkpoint, before attestation validation, compare its `header.lastArchiveRoot` against the persisted set — if it matches, skip the checkpoint as a descendant of an invalid ancestor and emit a new `L2BlockSourceEvents.CheckpointBuiltOnInvalidAncestorDetected` event with enough metadata to resolve the proposer. The slasher's `AttestationsBlockWatcher` is fixed to slash the proposer (not the attestors) under the new event. Fixes A-1072
1 parent a51f60a commit 983038e

14 files changed

Lines changed: 875 additions & 172 deletions

File tree

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

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,13 @@ describe('Archiver Sync', () => {
689689
const invalidCheckpointDetectedSpy = jest.fn();
690690
archiver.events.on(L2BlockSourceEvents.InvalidAttestationsCheckpointDetected, invalidCheckpointDetectedSpy);
691691

692+
// And another spy for DescendentOfInvalidAttestationsCheckpointDetected, which fires only for a
693+
// checkpoint with VALID attestations that builds on a rejected ancestor. CP3 here has invalid
694+
// attestations of its own, so it is caught by the attestation check first and should never
695+
// reach the descendant path — this spy must not fire in this test.
696+
const descendantOfInvalidSpy = jest.fn();
697+
archiver.events.on(L2BlockSourceEvents.DescendentOfInvalidAttestationsCheckpointDetected, descendantOfInvalidSpy);
698+
692699
// Add valid checkpoint 1 with correct attestations
693700
const { checkpoint: cp1 } = await fake.addCheckpoint(CheckpointNumber(1), {
694701
l1BlockNumber: 70n,
@@ -780,21 +787,24 @@ describe('Archiver Sync', () => {
780787
expect(validationStatus.checkpoint.checkpointNumber).toEqual(2);
781788
expect(validationStatus.checkpoint.archive.toString()).toEqual(badCp2b.archive.root.toString());
782789

783-
// Check that event was also emitted for bad CP3
790+
// CP3 has invalid attestations of its own, so it is caught by the attestation check (which
791+
// runs before the descendant-of-invalid check) and surfaced as an
792+
// InvalidAttestationsCheckpointDetected event — NOT a descendant event — even though it also
793+
// builds on the rejected bad CP2b.
794+
expect(descendantOfInvalidSpy).not.toHaveBeenCalled();
795+
796+
// Should have been called 3 times for invalid attestations: bad CP2, bad CP2b, bad CP3
797+
expect(invalidCheckpointDetectedSpy).toHaveBeenCalledTimes(3);
784798
expect(invalidCheckpointDetectedSpy).toHaveBeenCalledWith(
785799
expect.objectContaining({
786800
type: L2BlockSourceEvents.InvalidAttestationsCheckpointDetected,
787801
validationResult: expect.objectContaining({
788802
valid: false,
789-
reason: 'invalid-attestation',
790803
checkpoint: expect.objectContaining({ checkpointNumber: 3 }),
791804
}),
792805
}),
793806
);
794807

795-
// Should have been called 3 times: bad CP2, bad CP2b, bad CP3
796-
expect(invalidCheckpointDetectedSpy).toHaveBeenCalledTimes(3);
797-
798808
// Now recover: remove bad checkpoints and add good CP2 and CP3 with valid attestations
799809
// Good checkpoints have messages that the archiver will validate
800810
logger.warn('Fourth sync: adding good CP2 and CP3 with correct attestations');
@@ -831,6 +841,76 @@ describe('Archiver Sync', () => {
831841
// With a valid pending chain validation status
832842
expect(await archiver.getPendingChainValidationStatus()).toEqual(expect.objectContaining({ valid: true }));
833843
}, 15_000);
844+
845+
it('skips a valid-attestations checkpoint that builds on a rejected ancestor', async () => {
846+
// Regression for the archiver "non-consecutive checkpoint" retry loop: when a checkpoint
847+
// with insufficient/invalid attestations is followed by a valid-attestations descendant,
848+
// addCheckpoints used to throw InitialCheckpointNumberNotSequentialError and loop on the
849+
// catch handler's L1-sync-point rollback. Now the descendant is detected, skipped, and
850+
// surfaced via DescendentOfInvalidAttestationsCheckpointDetected.
851+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(0));
852+
853+
fake.setTargetCommitteeSize(3);
854+
const signers = times(3, Secp256k1Signer.random);
855+
const committee = signers.map(s => s.address);
856+
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee, seed: 0n } as EpochCommitteeInfo);
857+
858+
const descendantOfInvalidSpy = jest.fn();
859+
archiver.events.on(L2BlockSourceEvents.DescendentOfInvalidAttestationsCheckpointDetected, descendantOfInvalidSpy);
860+
861+
// Valid CP1
862+
const { checkpoint: cp1 } = await fake.addCheckpoint(CheckpointNumber(1), {
863+
l1BlockNumber: 70n,
864+
messagesL1BlockNumber: 50n,
865+
numL1ToL2Messages: 3,
866+
signers,
867+
});
868+
const cp1Archive = cp1.blocks.at(-1)!.archive;
869+
870+
// Bad CP2 (insufficient attestations — random signers not in committee)
871+
const badSigners = times(3, Secp256k1Signer.random);
872+
const { checkpoint: badCp2 } = await fake.addCheckpoint(CheckpointNumber(2), {
873+
l1BlockNumber: 80n,
874+
numL1ToL2Messages: 0,
875+
signers: badSigners,
876+
previousArchive: cp1Archive,
877+
});
878+
879+
// Valid-attestations CP3 chained from bad CP2: this is the case that used to wedge the
880+
// synchronizer.
881+
const { checkpoint: validCp3 } = await fake.addCheckpoint(CheckpointNumber(3), {
882+
l1BlockNumber: 82n,
883+
numL1ToL2Messages: 0,
884+
signers,
885+
previousArchive: badCp2.blocks.at(-1)!.archive,
886+
});
887+
888+
fake.setL1BlockNumber(85n);
889+
await archiver.syncImmediate();
890+
891+
// Archiver should have stayed at CP1 (skipped both CP2 and CP3) without throwing.
892+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1));
893+
894+
// The descendant event should have fired for CP3 with the bad CP2 ancestor.
895+
expect(descendantOfInvalidSpy).toHaveBeenCalledWith(
896+
expect.objectContaining({
897+
type: L2BlockSourceEvents.DescendentOfInvalidAttestationsCheckpointDetected,
898+
checkpoint: expect.objectContaining({
899+
checkpointNumber: 3,
900+
archive: validCp3.archive.root,
901+
}),
902+
ancestorArchiveRoot: badCp2.archive.root,
903+
ancestorCheckpointNumber: 2,
904+
}),
905+
);
906+
expect(descendantOfInvalidSpy).toHaveBeenCalledTimes(1);
907+
908+
// The rejected entries should persist in the store, keyed by their own archive roots.
909+
const rejectedBad = await archiverStore.blocks.getRejectedCheckpointByArchiveRoot(badCp2.archive.root);
910+
const rejectedValid = await archiverStore.blocks.getRejectedCheckpointByArchiveRoot(validCp3.archive.root);
911+
expect(rejectedBad).toBeDefined();
912+
expect(rejectedValid).toBeDefined();
913+
}, 15_000);
834914
});
835915

836916
describe('reorg handling', () => {

yarn-project/archiver/src/errors.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ export class InitialCheckpointNumberNotSequentialError extends Error {
3131

3232
export class CheckpointNumberNotSequentialError extends Error {
3333
constructor(
34-
newCheckpointNumber: CheckpointNumber,
35-
previous: CheckpointNumber | undefined,
34+
public readonly newCheckpointNumber: CheckpointNumber,
35+
public readonly previousCheckpointNumber: CheckpointNumber | undefined,
3636
source?: 'proposed' | 'confirmed',
3737
) {
3838
const qualifier = source ? `${source} ` : '';
3939
super(
40-
`Cannot insert new checkpoint ${newCheckpointNumber} given previous ${qualifier}checkpoint number is ${previous ?? 'undefined'}`,
40+
`Cannot insert new checkpoint ${newCheckpointNumber} given previous ${qualifier}checkpoint number is ${previousCheckpointNumber ?? 'undefined'}`,
4141
);
4242
this.name = 'CheckpointNumberNotSequentialError';
4343
}

0 commit comments

Comments
 (0)