Skip to content

Commit 29cf4d9

Browse files
authored
feat: Handle multiple blocks per slot in validators (#19240)
## Summary Implements Multiple Blocks Per Slot (MBPS) consensus model where validators attest to checkpoints (aggregations of blocks) instead of individual blocks. This is a foundational change for improving L2 throughput by allowing multiple blocks to be built within a single L1 slot. ## Key Changes ### New Consensus Types - **CheckpointAttestation**: Validators sign checkpoint payloads (header + archive) instead of individual blocks - **CheckpointProposal**: Proposers broadcast checkpoint proposals containing the last block and aggregated checkpoint header - **BlockAttestation deleted**: Fully replaced by CheckpointAttestation - no traces remain in active code paths ### Validator Behavior - Validators now call `attestToCheckpointProposal()` instead of `attestToProposal()` - Checkpoint number is derived from parent block's checkpoint info, not computed from block number - Block comparison during re-execution validates full struct (header + archive), not just archive root - Validators use `FullNodeCheckpointsBuilder` instead of block builder, enabling multi-block checkpoint re-execution and validation ### P2P Layer - New gossip topics: `checkpoint_proposal`, `checkpoint_attestation` - Removed `block_attestation` topic handling - Attestation pool interface simplified to only handle checkpoint attestations - Proposal validators consolidated into shared `proposal_validator/` directory ### Packages Modified - **stdlib**: New `CheckpointAttestation`, `CheckpointProposal` classes; deleted `BlockAttestation` - **p2p**: Updated pool, validators, libp2p service, encoding - **validator-client**: Updated attestation creation and block handling - **sequencer-client**: Fixed checkpoint number computation - **end-to-end**: Added MBPS-specific e2e tests ## New E2E Tests **These are pending review** - `epochs_multiple_blocks_per_slot.test.ts`: Single sequencer with mock gossip - `mbps_checkpoint_consensus.test.ts`: Multi-validator with real P2P networking ## Commits This PR is split into multiple commits for easier review. Note that intermediate commits may not build. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 10602b1 + e62895f commit 29cf4d9

File tree

135 files changed

+5347
-2425
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

135 files changed

+5347
-2425
lines changed

yarn-project/.claude/rules/typescript-style.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,30 @@ export class CheckpointProposal { ... }
174174
- Prefer `undefined` over `null`
175175
- Use `compactArray()` from foundation to filter undefined values
176176

177+
## Resource Management
178+
179+
Prefer `using`/`await using` over `try`/`finally` for cleanup of disposable resources:
180+
181+
```typescript
182+
// Good: using statement ensures cleanup even on exceptions
183+
using fork = await this.worldState.fork(blockNumber);
184+
const result = await processWithFork(fork);
185+
return result;
186+
187+
// Bad: try/finally is more verbose and error-prone
188+
const fork = await this.worldState.fork(blockNumber);
189+
try {
190+
const result = await processWithFork(fork);
191+
return result;
192+
} finally {
193+
await fork.close();
194+
}
195+
```
196+
197+
- Use `using` for `Disposable` resources (implements `[Symbol.dispose](): void`)
198+
- Use `await using` for `AsyncDisposable` resources (implements `[Symbol.asyncDispose](): Promise<void>`)
199+
- When the resource is obtained asynchronously but disposed synchronously, use `using x = await getResource()`
200+
177201
## General Style
178202

179203
- Prefer `const` over `let`

yarn-project/.claude/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"allow": [
44
"Bash(yarn:*)",
55
"Bash(forge:*)",
6-
"Bash(env LOG_LEVEL:* yarn:*)",
6+
"Bash(env LOG_LEVEL=* yarn *)",
77
"Bash(./bootstrap.sh:*)",
88
"Bash(git status:*)",
99
"Bash(git diff:*)",

yarn-project/CLAUDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,19 @@ Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
195195
- **Backport**: Fix in release branch -> forward-port to `next`
196196
- **Forward-port**: Fix in `next` -> backport if needed
197197

198+
### Determining the Base Branch
199+
200+
**Never assume the base branch is `master`**. Most branches are based on `next`, not `master`. When you need to compare commits or understand changes on a branch:
201+
202+
```bash
203+
# If there's an open PR, check its base branch
204+
gh pr view --json baseRefName -q '.baseRefName'
205+
206+
# Compare against the correct base
207+
git log origin/<base-branch>..HEAD # commits on this branch
208+
git diff origin/<base-branch>...HEAD # changes on this branch
209+
```
210+
198211
### Port Commits
199212

200213
When porting PRs between branches, include reference to original PR(s) in the PR body. Use the exact same commit message with the original PR number.

yarn-project/archiver/README.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,25 @@ The interfaces defining how the data can be consumed from the archiver are `L2Bl
1313

1414
The archiver sync process periodically checks its current state against the Rollup contract on L1 and updates its local state.
1515

16-
### Handling invalid blocks
16+
### Handling invalid checkpoints
1717

18-
After the implementation of [delayed attestation verification](https://github.com/AztecProtocol/engineering-designs/pull/69), the Rollup contract on L1 no longer validates committee attestations. Instead, these are posted in calldata, and L2 nodes are expected to verify them as they download blocks. The archiver handles this validation during its sync process.
18+
After the implementation of [delayed attestation verification](https://github.com/AztecProtocol/engineering-designs/pull/69), the Rollup contract on L1 no longer validates committee attestations. Instead, these are posted in calldata, and L2 nodes are expected to verify them as they download checkpoints. The archiver handles this validation during its sync process.
1919

20-
Whenever the archiver detects a block with invalid attestations, it skips it. These blocks are not meant to be part of the chain, so the archiver ignores them and continues processing the next blocks. It is expected that an honest proposer will eventually invalidate these blocks, removing them from the chain on L1, and then resume the sequence of valid blocks.
20+
Whenever the archiver detects a checkpoint with invalid attestations, it skips it. These checkpoints are not meant to be part of the chain, so the archiver ignores them and continues processing the next checkpoints. It is expected that an honest proposer will eventually invalidate these checkpoints, removing them from the chain on L1, and then resume the sequence of valid checkpoints.
2121

2222
> [!WARNING]
23-
> If the committee for the epoch is also malicious and attests to a descendant of an invalid block, nodes should also ignore these descendants, unless they become proven. This is currently not implemented. Nodes assume that the majority of the committee is honest.
23+
> If the committee for the epoch is also malicious and attests to a descendant of an invalid checkpoint, nodes should also ignore these descendants, unless they become proven. This is currently not implemented. Nodes assume that the majority of the committee is honest.
2424
25-
When the current node is elected as proposer, the `sequencer` needs to know whether there is an invalid block in L1 that needs to be purged before posting their own block. To support this, the archiver exposes a `pendingChainValidationStatus`, which is the state of the tip of the pending chain. This status can be valid in the happy path, or invalid if the tip of the pending chain has invalid attestations. If invalid, this status also contains all the data needed for purging the block from L1 via an `invalidate` call to the Rollup contract. Note that, if the head of the chain has more than one invalid consecutive block, this status will reference the earliest one that needs to be purged, since a call to purge an invalid block will automatically purge all descendants. Refer to the [InvalidateLib.sol](`l1-contracts/src/core/libraries/rollup/InvalidateLib.sol`) for more info.
25+
When the current node is elected as proposer, the `sequencer` needs to know whether there is an invalid checkpoint in L1 that needs to be purged before posting their own checkpoint. To support this, the archiver exposes a `pendingChainValidationStatus`, which is the state of the tip of the pending chain. This status can be valid in the happy path, or invalid if the tip of the pending chain has invalid attestations. If invalid, this status also contains all the data needed for purging the checkpoint from L1 via an `invalidate` call to the Rollup contract. Note that, if the head of the chain has more than one invalid consecutive checkpoint, this status will reference the earliest one that needs to be purged, since a call to purge an invalid checkpoint will automatically purge all descendants. Refer to the [InvalidateLib.sol](`l1-contracts/src/core/libraries/rollup/InvalidateLib.sol`) for more info.
2626

2727
> [!TIP]
28-
> The archiver can be configured to `skipValidateBlockAttestations`, which will make it skip this validation. This cannot be set via environment variables, only via a call to `nodeAdmin_setConfig`. This setting is only meant for testing purposes.
29-
30-
As an example, let's say the chain has been progressing normally up until block 10, and then:
31-
1. Block 11 is posted with invalid attestations. The archiver will report 10 as the latest block, but the `pendingChainValidationStatus` will point to block 11.
32-
2. Block 11 is purged, but another block 11 with invalid attestations is posted in its place. The archiver will still report 10 as latest, and the `pendingChainValidationStatus` will point to the new block 11 that needs to be purged.
33-
3. Block 12 with invalid attestations is posted. No changes in the archiver.
34-
4. Block 13 is posted with valid attestations, due to a malicious committee. The archiver will try to sync it and fail, since 13 does not follow 10. This scenario is not gracefully handled yet.
35-
5. Blocks 11 to 13 are purged. The archiver status will not yet be changed: 10 will still be the latest block, and the `pendingChainValidationStatus` will point to 11. This is because the archiver does **not** follow `BlockInvalidated` events.
36-
6. Block 11 with valid attestations is posted. The archiver pending chain now reports 11 as latest, and its status is valid.
28+
> The archiver can be configured to `skipValidateCheckpointAttestations`, which will make it skip this validation. This cannot be set via environment variables, only via a call to `nodeAdmin_setConfig`. This setting is only meant for testing purposes.
29+
30+
As an example, let's say the chain has been progressing normally up until checkpoint 10, and then:
31+
1. Checkpoint 11 is posted with invalid attestations. The archiver will report 10 as the latest checkpoint, but the `pendingChainValidationStatus` will point to checkpoint 11.
32+
2. Checkpoint 11 is purged, but another checkpoint 11 with invalid attestations is posted in its place. The archiver will still report 10 as latest, and the `pendingChainValidationStatus` will point to the new checkpoint 11 that needs to be purged.
33+
3. Checkpoint 12 with invalid attestations is posted. No changes in the archiver.
34+
4. Checkpoint 13 is posted with valid attestations, due to a malicious committee. The archiver will try to sync it and fail, since 13 does not follow 10. This scenario is not gracefully handled yet.
35+
5. Checkpoints 11 to 13 are purged. The archiver status will not yet be changed: 10 will still be the latest checkpoint, and the `pendingChainValidationStatus` will point to 11. This is because the archiver does **not** follow `CheckpointInvalidated` events.
36+
6. Checkpoint 11 with valid attestations is posted. The archiver pending chain now reports 11 as latest, and its status is valid.
3737

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

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
3030
import { InboxLeaf, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
3131
import {
3232
makeAndSignCommitteeAttestationsAndSigners,
33-
makeAttestationFromCheckpoint,
33+
makeCheckpointAttestationFromCheckpoint,
3434
makeStateReference,
3535
mockCheckpointAndMessages,
3636
} from '@aztec/stdlib/testing';
@@ -509,9 +509,9 @@ describe('Archiver', () => {
509509
const committee = signers.map(signer => signer.address);
510510
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee } as EpochCommitteeInfo);
511511

512-
// Setup spy to listen for InvalidBlockDetected events
513-
const invalidBlockDetectedSpy = jest.fn();
514-
archiver.on(L2BlockSourceEvents.InvalidAttestationsBlockDetected, invalidBlockDetectedSpy);
512+
// Setup spy to listen for InvalidCheckpointDetected events
513+
const invalidCheckpointDetectedSpy = jest.fn();
514+
archiver.on(L2BlockSourceEvents.InvalidAttestationsCheckpointDetected, invalidCheckpointDetectedSpy);
515515

516516
// Add messages for all good checkpoints
517517
messagesPerCheckpoint.map((messages, i) =>
@@ -584,15 +584,15 @@ describe('Archiver', () => {
584584
}),
585585
);
586586

587-
// Check that InvalidBlockDetected event was emitted for the bad block
588-
expect(invalidBlockDetectedSpy).toHaveBeenCalledWith(
587+
// Check that InvalidCheckpointDetected event was emitted for the bad checkpoint
588+
expect(invalidCheckpointDetectedSpy).toHaveBeenCalledWith(
589589
expect.objectContaining({
590-
type: L2BlockSourceEvents.InvalidAttestationsBlockDetected,
590+
type: L2BlockSourceEvents.InvalidAttestationsCheckpointDetected,
591591
validationResult: expect.objectContaining({
592592
valid: false,
593593
reason: 'invalid-attestation',
594594
invalidIndex: 0,
595-
block: expect.objectContaining({ blockNumber: 2 }),
595+
checkpoint: expect.objectContaining({ checkpointNumber: 2 }),
596596
}),
597597
}),
598598
);
@@ -615,8 +615,8 @@ describe('Archiver', () => {
615615
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1));
616616
let validationStatus = await archiver.getPendingChainValidationStatus();
617617
assert(!validationStatus.valid);
618-
expect(validationStatus.block.blockNumber).toEqual(2);
619-
expect(validationStatus.block.archive.toString()).toEqual(badCheckpoints[1].archive.root.toString());
618+
expect(validationStatus.checkpoint.checkpointNumber).toEqual(2);
619+
expect(validationStatus.checkpoint.archive.toString()).toEqual(badCheckpoints[1].archive.root.toString());
620620

621621
// Now another loop, where we propose a checkpoint 3 with bad attestations
622622
logger.warn(`Adding new checkpoint 3 with bad attestations`);
@@ -637,24 +637,24 @@ describe('Archiver', () => {
637637
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1));
638638
validationStatus = await archiver.getPendingChainValidationStatus();
639639
assert(!validationStatus.valid);
640-
expect(validationStatus.block.blockNumber).toEqual(2);
641-
expect(validationStatus.block.archive.toString()).toEqual(badCheckpoints[1].archive.root.toString());
640+
expect(validationStatus.checkpoint.checkpointNumber).toEqual(2);
641+
expect(validationStatus.checkpoint.archive.toString()).toEqual(badCheckpoints[1].archive.root.toString());
642642

643-
// Check that InvalidBlockDetected event was also emitted for bad block 3
644-
expect(invalidBlockDetectedSpy).toHaveBeenCalledWith(
643+
// Check that InvalidCheckpointDetected event was also emitted for bad checkpoint 3
644+
expect(invalidCheckpointDetectedSpy).toHaveBeenCalledWith(
645645
expect.objectContaining({
646-
type: L2BlockSourceEvents.InvalidAttestationsBlockDetected,
646+
type: L2BlockSourceEvents.InvalidAttestationsCheckpointDetected,
647647
validationResult: expect.objectContaining({
648648
valid: false,
649649
reason: 'invalid-attestation',
650650
invalidIndex: 0,
651-
block: expect.objectContaining({ blockNumber: 3 }),
651+
checkpoint: expect.objectContaining({ checkpointNumber: 3 }),
652652
}),
653653
}),
654654
);
655655

656656
// Should have been called three times total: bad checkpoint 2, bad checkpoint 2b, and bad checkpoint 3
657-
expect(invalidBlockDetectedSpy).toHaveBeenCalledTimes(3);
657+
expect(invalidCheckpointDetectedSpy).toHaveBeenCalledTimes(3);
658658

659659
// Now we go for another loop, where proper checkpoints 2 and 3 are proposed with correct attestations
660660
// IRL there would be an "Invalidated" event, but we are not currently relying on it
@@ -2441,7 +2441,7 @@ describe('Archiver', () => {
24412441
*/
24422442
const makeRollupTx = (checkpoint: Checkpoint, signers: Secp256k1Signer[] = []) => {
24432443
const attestations = signers
2444-
.map(signer => makeAttestationFromCheckpoint(checkpoint, signer))
2444+
.map(signer => makeCheckpointAttestationFromCheckpoint(checkpoint, signer))
24452445
.map(attestation => CommitteeAttestation.fromSignature(attestation.signature))
24462446
.map(committeeAttestation => committeeAttestation.toViem());
24472447
const header = checkpoint.header.toViem();

0 commit comments

Comments
 (0)