Skip to content

Commit a612452

Browse files
authored
fix(archiver): prune blocks without proposed checkpoint by end of build slot (#23606)
When the previous proposer sent some block proposals but failed to send the corresponding checkpoint proposal, the current proposer would assume there was no proposed checkpoint to build on top of, but would still use the proposed blocks as chain tip. This meant a failed `canPropose` check against the Rollup contract as soon as it started its slot, since the proposed blocks from the previous proposer meant the proposer had a wrong chain tip. To fix, the sequencer is now aware that there may be proposed blocks without the corresponding checkpoints, and it can't start building until that's resolved. Also, the archiver now prunes proposed blocks without a checkpoint when the corresponding _build_ slot is over. --- ## Motivation Under proposer pipelining a node can receive and reexecute the block-only proposals for a checkpoint before (or without ever) receiving the enclosing proposed checkpoint. This leaves the local tip one checkpoint ahead of the checkpointed tip with no proposed checkpoint backing it. A sequencer that then builds the next checkpoint on top of that orphan tip forks the chain off a parent no other node can follow, which was the root cause behind the sentinel CI flake. ## Approach Two complementary defenses. The sequencer's `checkSync` refuses to proceed when the synced block's checkpoint is ahead of the checkpointed tip and no matching proposed checkpoint exists, holding the line during the window before cleanup. The archiver adds a wall-clock orphan prune that, shortly after a block's build slot ends, removes a block-only tip whose checkpoint was never proposed, restoring liveness even while L1 is quiet. ## Changes - **sequencer-client**: `checkSync` rejects syncing onto a proposed block with no matching proposed-checkpoint tip/data, logging a descriptive warning. - **archiver**: new `pruneOrphanProposedBlocks` on the L1 synchronizer, run from `Archiver.sync()` after the inbound queue drains and before L1 sync; prunes after `start(blockSlot) + grace` using the epoch-cache pipelining offset and emits `L2PruneUncheckpointed`. The existing L1-sync prune is preserved (shared prune/emit helper). - **archiver/stdlib/foundation config**: new `orphanProposedBlockPruneGraceSeconds` in `ArchiverSpecificConfig`, archiver config mappings (`ARCHIVER_ORPHAN_PROPOSED_BLOCK_PRUNE_GRACE_SECONDS`), `mapArchiverConfig`, the synchronizer/archiver config types, and a new `EnvVar`. - **aztec-node**: defaults the grace window from `blockDurationMs / 1000` when unset, falling back to `MIN_EXECUTION_TIME`; the archiver factory also defaults to `MIN_EXECUTION_TIME`. - **sequencer-client (tests)**: orphan tip returns `undefined` and warns; matching proposed checkpoint proceeds. - **archiver (tests)**: no prune before grace; prune + event after grace; no prune when a matching proposed checkpoint exists; queued proposed checkpoint is processed before the prune.
1 parent 5fbc956 commit a612452

19 files changed

Lines changed: 838 additions & 15 deletions

File tree

yarn-project/archiver/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ The archiver runs a periodic sync loop with two phases:
2626

2727
```
2828
sync()
29-
├── processQueuedBlocks() # Handle blocks pushed via addBlock()
29+
├── processQueuedBlocks() # Handle blocks pushed via addBlock()
30+
├── pruneOrphanProposedBlocks() # Wall-clock prune of orphan block-only tips
3031
└── syncFromL1()
31-
├── handleL1ToL2Messages() # Sync messages from Inbox contract
32-
├── handleCheckpoints() # Sync checkpoints from Rollup contract
32+
├── handleL1ToL2Messages() # Sync messages from Inbox contract
33+
├── handleCheckpoints() # Sync checkpoints from Rollup contract
3334
├── pruneUncheckpointedBlocks() # Prune provisional blocks from expired slots
34-
├── handleEpochPrune() # Proactive unwind before proof window expires
35+
├── handleEpochPrune() # Proactive unwind before proof window expires
3536
└── checkForNewCheckpointsBeforeL1SyncPoint() # Handle L1 reorg edge case
3637
```
3738

@@ -100,8 +101,9 @@ Queued blocks are processed at the start of each sync iteration. This allows the
100101
Blocks added via `addBlock()` are considered "provisional" until they appear in an L1 checkpoint. These provisional blocks may need to be reconciled when:
101102
- **Checkpoint mismatch**: A checkpoint lands on L1 with different blocks than stored locally (e.g., a different proposer won the slot)
102103
- **Slot expiration**: An L2 slot ends without any checkpoint being mined on L1
104+
- **Orphan proposed block**: Under proposer pipelining, a proposer can broadcast a block-only proposal but never the matching `CheckpointProposal` (e.g. it crashes before assembling the checkpoint). The provisional block then has no proposed checkpoint backing it.
103105

104-
When `handleCheckpoints()` processes incoming checkpoints, it compares archive roots of local blocks against the checkpoint's blocks. If they differ, local blocks are pruned and replaced with the checkpoint's blocks. After checkpoint sync, `pruneUncheckpointedBlocks()` removes any remaining provisional blocks from slots that have ended. Both cases emit `L2PruneUncheckpointed`.
106+
When `handleCheckpoints()` processes incoming checkpoints, it compares archive roots of local blocks against the checkpoint's blocks. If they differ, local blocks are pruned and replaced with the checkpoint's blocks. After checkpoint sync, `pruneUncheckpointedBlocks()` removes any remaining provisional blocks from slots that have ended. Independently, `pruneOrphanProposedBlocks()` runs on wall-clock time (so it fires during quiet L1 periods) and removes a block-only tip once its build slot ended without a matching proposed checkpoint, plus a grace window configured via `orphanProposedBlockPruneGraceSeconds`. All three cases emit `L2PruneUncheckpointed`.
105107

106108
### Querying Block Data
107109

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/f
88
import { Buffer32 } from '@aztec/foundation/buffer';
99
import { Fr } from '@aztec/foundation/curves/bn254';
1010
import { EthAddress } from '@aztec/foundation/eth-address';
11+
import { DateProvider } from '@aztec/foundation/timer';
1112
import { openTmpStore } from '@aztec/kv-store/lmdb-v2';
1213
import type { L2Tips } from '@aztec/stdlib/block';
1314
import type { CheckpointData } from '@aztec/stdlib/checkpoint';
@@ -57,6 +58,7 @@ describe('Archiver misc', () => {
5758
const rollupContract = mock<RollupContract>();
5859
const epochCache = mock<EpochCache>();
5960
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee: [] as EthAddress[] } as EpochCommitteeInfo);
61+
epochCache.pipeliningOffset.mockReturnValue(0);
6062

6163
const tracer = getTelemetryClient().getTracer('');
6264
const instrumentation = mock<ArchiverInstrumentation>({ isEnabled: () => true, tracer });
@@ -78,7 +80,12 @@ describe('Archiver misc', () => {
7880
slashingProposerAddress: EthAddress.random(),
7981
},
8082
archiverStore,
81-
{ pollingIntervalMs: 1000, batchSize: 1000, maxAllowedEthClientDriftSeconds: 300 },
83+
{
84+
pollingIntervalMs: 1000,
85+
batchSize: 1000,
86+
maxAllowedEthClientDriftSeconds: 300,
87+
orphanProposedBlockPruneGraceSeconds: 2,
88+
},
8289
blobClient,
8390
instrumentation,
8491
l1Constants,
@@ -87,6 +94,8 @@ describe('Archiver misc', () => {
8794
initialHeader,
8895
initialBlockHash,
8996
l2TipsCache,
97+
epochCache,
98+
new DateProvider(),
9099
);
91100
});
92101

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { Buffer32 } from '@aztec/foundation/buffer';
1313
import { Fr } from '@aztec/foundation/curves/bn254';
1414
import { EthAddress } from '@aztec/foundation/eth-address';
15+
import { DateProvider } from '@aztec/foundation/timer';
1516
import { openTmpStore } from '@aztec/kv-store/lmdb-v2';
1617
import { L2Block } from '@aztec/stdlib/block';
1718
import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
@@ -63,6 +64,7 @@ describe('Archiver Store', () => {
6364
blobClient = mock<BlobClientInterface>();
6465
epochCache = mock<EpochCache>();
6566
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee: [] as EthAddress[] } as EpochCommitteeInfo);
67+
epochCache.pipeliningOffset.mockReturnValue(0);
6668

6769
const rollupContract = mock<RollupContract>();
6870
Object.defineProperty(rollupContract, 'address', { value: rollupAddress.toString(), writable: true });
@@ -98,6 +100,7 @@ describe('Archiver Store', () => {
98100
batchSize: 1000,
99101
maxAllowedEthClientDriftSeconds: 300,
100102
ethereumAllowNoDebugHosts: true,
103+
orphanProposedBlockPruneGraceSeconds: 2,
101104
};
102105

103106
const events = new EventEmitter() as ArchiverEmitter;
@@ -120,6 +123,8 @@ describe('Archiver Store', () => {
120123
initialHeader,
121124
initialBlockHash,
122125
l2TipsCache,
126+
epochCache,
127+
new DateProvider(),
123128
);
124129
});
125130

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

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { ProposedCheckpointInput } from '@aztec/stdlib/checkpoint';
2020
import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
2121
import { computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
2222
import { CheckpointHeader } from '@aztec/stdlib/rollup';
23+
import { mockCheckpointAndMessages } from '@aztec/stdlib/testing';
2324
import { BlockHeader } from '@aztec/stdlib/tx';
2425
import { getTelemetryClient } from '@aztec/telemetry-client';
2526

@@ -92,6 +93,9 @@ describe('Archiver Sync', () => {
9293
// Create epoch cache mock (separate from fake)
9394
epochCache = mock<EpochCache>();
9495
epochCache.getCommitteeForEpoch.mockResolvedValue({ committee: [] as EthAddress[] } as EpochCommitteeInfo);
96+
// Default to no pipelining offset; the orphan-prune tests below override this. Keeps the prune
97+
// deadline well ahead of wall-clock time for the other tests so it never fires spuriously.
98+
epochCache.pipeliningOffset.mockReturnValue(0);
9599

96100
// Create instrumentation mock
97101
const tracer = getTelemetryClient().getTracer('');
@@ -118,6 +122,7 @@ describe('Archiver Sync', () => {
118122
maxAllowedEthClientDriftSeconds: 300,
119123
ethereumAllowNoDebugHosts: true,
120124
skipHistoricalLogsCheck: true,
125+
orphanProposedBlockPruneGraceSeconds: 2,
121126
};
122127

123128
// Create event emitter shared by archiver and synchronizer
@@ -162,6 +167,8 @@ describe('Archiver Sync', () => {
162167
initialHeader,
163168
initialBlockHash,
164169
l2TipsCache,
170+
epochCache,
171+
dateProvider,
165172
);
166173
});
167174

@@ -2143,4 +2150,157 @@ describe('Archiver Sync', () => {
21432150
expect(tips.proposedCheckpoint.block.number).toEqual(tips.checkpointed.block.number);
21442151
}, 15_000);
21452152
});
2153+
2154+
describe('pruning orphan proposed blocks', () => {
2155+
let pruneSpy: jest.Mock;
2156+
2157+
// Slot the orphan block targets. With slotDuration=24, slot S starts at l1GenesisTime + S*24.
2158+
const orphanSlot = SlotNumber(1);
2159+
// Grace period configured for these tests (see the `config` object above).
2160+
const graceSeconds = 2;
2161+
2162+
beforeEach(() => {
2163+
pruneSpy = jest.fn();
2164+
archiver.events.on(L2BlockSourceEvents.L2PruneUncheckpointed, pruneSpy);
2165+
// Normal proposer pipelining: a block targeting slot S is built during slot S-1, so its proposed
2166+
// checkpoint is expected by the start of slot S.
2167+
epochCache.pipeliningOffset.mockReturnValue(1);
2168+
});
2169+
2170+
afterEach(() => {
2171+
archiver.events.off(L2BlockSourceEvents.L2PruneUncheckpointed, pruneSpy);
2172+
});
2173+
2174+
// Wall-clock time (seconds) at which the orphan tip becomes prunable: start(orphanSlot) + grace.
2175+
const pruneDeadline = () => now + Number(orphanSlot) * l1Constants.slotDuration + graceSeconds;
2176+
const pruneDeadlineForSlot = (slot: SlotNumber) => now + Number(slot) * l1Constants.slotDuration + graceSeconds;
2177+
2178+
// Syncs checkpoint 1 (slot 0), then writes uncheckpointed blocks for slot 1 (checkpoint 2) straight
2179+
// into the store as a block-only tip with no matching proposed checkpoint. L1 is held at slot 1 so
2180+
// the L1-sync prune (which only fires once the build slot has ended on L1) stays out of the way.
2181+
const setupOrphanTip = async () => {
2182+
const { checkpoint: cp1 } = await fake.addCheckpoint(CheckpointNumber(1), {
2183+
l1BlockNumber: 1n,
2184+
messagesL1BlockNumber: 1n,
2185+
numL1ToL2Messages: 3,
2186+
slotNumber: SlotNumber(0),
2187+
});
2188+
const cp1Archive = cp1.blocks.at(-1)!.archive;
2189+
fake.setL1BlockNumber(1n);
2190+
await archiver.syncImmediate();
2191+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1));
2192+
2193+
const lastBlockInCp1 = cp1.blocks.at(-1)!.number;
2194+
const provisionalBlocks = await fake.makeBlocks(CheckpointNumber(2), {
2195+
l1BlockNumber: 2n,
2196+
previousArchive: cp1Archive,
2197+
slotNumber: orphanSlot,
2198+
});
2199+
for (const block of provisionalBlocks) {
2200+
await archiver.addBlock(block);
2201+
}
2202+
2203+
// Hold L1 at slot 1 so the slot has not ended from L1's perspective.
2204+
fake.setL1BlockNumber(2n);
2205+
return { lastBlockInCp1, lastProvisional: provisionalBlocks.at(-1)!.number, provisionalBlocks };
2206+
};
2207+
2208+
const makeProposedCheckpoint = (lastBlockInCp1: BlockNumber, blockCount: number): ProposedCheckpointInput => ({
2209+
checkpointNumber: CheckpointNumber(2),
2210+
header: CheckpointHeader.empty({ slotNumber: orphanSlot }),
2211+
startBlock: BlockNumber(lastBlockInCp1 + 1),
2212+
blockCount,
2213+
totalManaUsed: 0n,
2214+
feeAssetPriceModifier: 0n,
2215+
});
2216+
2217+
it('does not prune before the grace window elapses', async () => {
2218+
const { lastProvisional } = await setupOrphanTip();
2219+
2220+
dateProvider.setTime((pruneDeadline() - 1) * 1000);
2221+
await archiver.syncImmediate();
2222+
2223+
expect(pruneSpy).not.toHaveBeenCalled();
2224+
expect(await archiver.getBlockNumber()).toEqual(lastProvisional);
2225+
}, 15_000);
2226+
2227+
it('prunes the orphan tip once the grace window elapses', async () => {
2228+
const { lastBlockInCp1, provisionalBlocks } = await setupOrphanTip();
2229+
2230+
dateProvider.setTime((pruneDeadline() + 1) * 1000);
2231+
await archiver.syncImmediate();
2232+
2233+
expect(pruneSpy).toHaveBeenCalledWith(
2234+
expect.objectContaining({
2235+
type: L2BlockSourceEvents.L2PruneUncheckpointed,
2236+
slotNumber: orphanSlot,
2237+
blocks: expect.arrayContaining(provisionalBlocks.map(b => expect.objectContaining({ number: b.number }))),
2238+
}),
2239+
);
2240+
expect(await archiver.getBlockNumber()).toEqual(lastBlockInCp1);
2241+
expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1));
2242+
}, 15_000);
2243+
2244+
it('does not prune when a matching proposed checkpoint exists', async () => {
2245+
const { lastBlockInCp1, lastProvisional, provisionalBlocks } = await setupOrphanTip();
2246+
2247+
await archiver.addProposedCheckpoint(makeProposedCheckpoint(lastBlockInCp1, provisionalBlocks.length));
2248+
2249+
dateProvider.setTime((pruneDeadline() + 100) * 1000);
2250+
await archiver.syncImmediate();
2251+
2252+
expect(pruneSpy).not.toHaveBeenCalled();
2253+
expect(await archiver.getBlockNumber()).toEqual(lastProvisional);
2254+
expect(await archiverStore.blocks.getLastProposedCheckpoint()).toBeDefined();
2255+
}, 15_000);
2256+
2257+
it('processes a queued proposed checkpoint before pruning, sparing the tip', async () => {
2258+
const { lastBlockInCp1, lastProvisional, provisionalBlocks } = await setupOrphanTip();
2259+
2260+
// Past the grace window: without the matching checkpoint the next sync would prune the tip.
2261+
dateProvider.setTime((pruneDeadline() + 100) * 1000);
2262+
2263+
// Queue the proposed checkpoint. The triggered sync drains the inbound queue (storing the
2264+
// checkpoint) before running the orphan prune, so the prune sees it and stands down. If the
2265+
// order were reversed, this sync would prune the tip before storing the checkpoint.
2266+
await archiver.addProposedCheckpoint(makeProposedCheckpoint(lastBlockInCp1, provisionalBlocks.length));
2267+
await archiver.syncImmediate();
2268+
2269+
expect(pruneSpy).not.toHaveBeenCalled();
2270+
expect(await archiver.getBlockNumber()).toEqual(lastProvisional);
2271+
expect(await archiverStore.blocks.getLastProposedCheckpoint()).toBeDefined();
2272+
}, 15_000);
2273+
2274+
it('prunes only the orphan suffix after a covered pending checkpoint', async () => {
2275+
const { lastBlockInCp1, provisionalBlocks: checkpointTwoBlocks } = await setupOrphanTip();
2276+
2277+
await archiver.addProposedCheckpoint(makeProposedCheckpoint(lastBlockInCp1, checkpointTwoBlocks.length));
2278+
2279+
const orphanSuffixSlot = SlotNumber(orphanSlot + 1);
2280+
const { checkpoint: orphanSuffixCheckpoint } = await mockCheckpointAndMessages(CheckpointNumber(3), {
2281+
startBlockNumber: BlockNumber(checkpointTwoBlocks.at(-1)!.number + 1),
2282+
numBlocks: 1,
2283+
previousArchive: checkpointTwoBlocks.at(-1)!.archive,
2284+
slotNumber: orphanSuffixSlot,
2285+
});
2286+
const orphanSuffixBlocks = orphanSuffixCheckpoint.blocks;
2287+
for (const block of orphanSuffixBlocks) {
2288+
await archiver.addBlock(block);
2289+
}
2290+
2291+
dateProvider.setTime((pruneDeadlineForSlot(orphanSuffixSlot) + 1) * 1000);
2292+
await archiver.syncImmediate();
2293+
2294+
expect(pruneSpy).toHaveBeenCalledWith(
2295+
expect.objectContaining({
2296+
type: L2BlockSourceEvents.L2PruneUncheckpointed,
2297+
slotNumber: orphanSuffixSlot,
2298+
blocks: expect.arrayContaining(orphanSuffixBlocks.map(b => expect.objectContaining({ number: b.number }))),
2299+
}),
2300+
);
2301+
expect(await archiver.getBlockNumber()).toEqual(checkpointTwoBlocks.at(-1)!.number);
2302+
expect(await archiverStore.blocks.getProposedCheckpointByNumber(CheckpointNumber(2))).toBeDefined();
2303+
expect(await archiverStore.blocks.getProposedCheckpointByNumber(CheckpointNumber(3))).toBeUndefined();
2304+
}, 15_000);
2305+
});
21462306
});

0 commit comments

Comments
 (0)