Skip to content

Commit 8447782

Browse files
authored
fix(sequencer): override full parent checkpoint cell in pipelined simulation (#23073)
## Motivation When proposer pipelining is enabled, the sequencer simulates `propose()` for checkpoint K one slot ahead while K-1 has not yet landed on L1. The previous override only patched `tips.pending`, `archives[K-1]`, and (sometimes) the fee header, leaving the rest of `tempCheckpointLogs[K-1]` at storage zero. With `slotNumber` zeroed, `canPruneAtTime` falsely declared the proof window expired, the contract returned `proven` from `getEffectivePendingCheckpointNumber`, and the precheck reverted with `Rollup__InvalidArchive` — surfacing as a `proposer-rollup-check-failed` storm whenever a checkpoint took an extra L1 block to land. Additionally, fixes a bug in L2-to-L1 messages related to how the `outHash` is computed by the proposer (see "include parent checkpointOutHash when pipelining same-epoch builds"). Also adds sanity checks to `checkSync` to guard against race conditions when querying archiver data. ## Approach The simulated `tempCheckpointLogs[K-1]` cell is now byte-faithful with what L1 will see once K-1 actually lands: header hash, out hash, payload digest, slot number, and fee header. `blobCommitmentsHash` and `attestationsHash` are intentionally left out — the propose path never asserts on them. The override is built through a single per-cell helper that throws on `slotNumber > uint32`, mirroring the on-chain `SafeCast.toUint32`. ## Changes - **stdlib (`checkpoint/digest.ts`)**: new shared `computeCheckpointPayloadDigest` helper. Archiver migrated to it. - **ethereum (`rollup.ts` / `chain_state_override.ts`)**: replaces `makeFeeHeaderOverride` with `makeTempCheckpointLogOverride` (all-required) and `makeTempCheckpointLogPartialOverride` (subset). Extends `PendingCheckpointOverrideState` and `SimulationOverridesBuilder` with `withPendingHeaderHash/OutHash/PayloadDigest/SlotNumber`. Plan translation now goes through the partial helper so a missing fee header no longer suppresses the rest. - **sequencer-client**: `buildPipelinedParentSimulationOverridesPlan` takes a `signatureContext`, populates the new fields when `proposedCheckpointData` matches the parent, and guards against stale entries. The inline override in `Sequencer` is consolidated through the helper, with a defensive archive fallback when `proposedCheckpointData` is absent. `CheckpointProposalJob` threads the signature context through. - **end-to-end (`epochs_mbps.parallel.test`)**: switches the test to the pipelined-MBPS timing (12s L1 / 72s L2 / 5500ms blocks, `enableProposerPipelining: true`, `perBlockAllocationMultiplier: 8`) and asserts there are no `proposer-rollup-check-failed` events under normal operation. - **.test_patterns.yml**: marks the L2-to-L1-messages variant of the test as `skip: true` for an unrelated `Tx dropped by P2P node` flake under the new pipelined timing — tracked as a follow-up. - **tests**: new unit tests for `makeTempCheckpointLogOverride` (storage-slot round-trip via `getCheckpoint`, slot-overflow throw, partial-emission), `withPending*` builders, and the populated/empty/stale-checkpoint paths in `buildPipelinedParentSimulationOverridesPlan`.
1 parent 7ece286 commit 8447782

13 files changed

Lines changed: 618 additions & 74 deletions

File tree

yarn-project/archiver/src/l1/calldata_retriever.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { EthAddress } from '@aztec/foundation/eth-address';
77
import type { Logger } from '@aztec/foundation/log';
88
import { RollupAbi } from '@aztec/l1-artifacts';
99
import { CommitteeAttestation } from '@aztec/stdlib/block';
10-
import { ConsensusPayload, getHashedSignaturePayloadTypedData } from '@aztec/stdlib/p2p';
10+
import { computeCheckpointPayloadDigest } from '@aztec/stdlib/checkpoint';
1111
import { CheckpointHeader } from '@aztec/stdlib/rollup';
1212

1313
import {
@@ -473,13 +473,12 @@ export class CalldataRetriever {
473473

474474
/** Computes the keccak256 payload digest from the checkpoint header, archive root, and fee asset price modifier. */
475475
private computePayloadDigest(header: CheckpointHeader, archiveRoot: Fr, feeAssetPriceModifier: bigint): Hex {
476-
const consensusPayload = new ConsensusPayload(
476+
return computeCheckpointPayloadDigest({
477477
header,
478478
archiveRoot,
479479
feeAssetPriceModifier,
480-
this.getSignatureContext(),
481-
);
482-
return getHashedSignaturePayloadTypedData(consensusPayload).toString();
480+
signatureContext: this.getSignatureContext(),
481+
}).toString();
483482
}
484483

485484
/**

yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { sendL1ToL2Message } from '../fixtures/l1_to_l2_messaging.js';
3030
import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js';
3131
import { TestWallet } from '../test-wallet/test_wallet.js';
3232
import { proveInteraction } from '../test-wallet/utils.js';
33-
import { EpochsTestContext } from './epochs_test.js';
33+
import { EpochsTestContext, type TrackedSequencerEvent } from './epochs_test.js';
3434

3535
jest.setTimeout(1000 * 60 * 20);
3636

@@ -61,6 +61,7 @@ describe('e2e_epochs/epochs_mbps', () => {
6161
let crossChainContract: TestContract | undefined;
6262
let wallet: TestWallet;
6363
let from: AztecAddress;
64+
let failEvents: TrackedSequencerEvent[];
6465

6566
/**
6667
* Creates validators and sets up the test context with MBPS configuration.
@@ -81,30 +82,28 @@ describe('e2e_epochs/epochs_mbps', () => {
8182
});
8283

8384
// Setup context with the given set of validators and MBPS configuration.
84-
// Timing calculation for 3 blocks per checkpoint with 8s sub-slots:
85-
// - initializationOffset ≈ 0.5s (test mode with ethereumSlotDuration < 8)
86-
// - 3 blocks × 8s = 24s
87-
// - checkpointFinalization = 0.5s (assemble) + 0 (p2p in test) + 2s (L1 publish) = 2.5s
88-
// - finalBlockDuration = 8s
89-
// - Total: 0.5 + 24 + 8 + 2.5 = 35s → use 36s for margin
85+
// Pipelining is enabled, so we adopt the wider timing used by the dedicated
86+
// epochs_mbps.pipeline.parallel test (72s L2 slots, 12s L1 slots, 5500ms blocks).
87+
// The tighter 36s/4s timing produces CheckpointNumberNotSequentialError on non-proposer
88+
// nodes when the pipelined proposer races ahead of L1 confirmation (see A-914).
9089
test = await EpochsTestContext.setup({
9190
numberOfAccounts: 0,
9291
initialValidators: validators,
92+
enableProposerPipelining: true,
9393
mockGossipSubNetwork: true,
9494
disableAnvilTestWatcher: true,
9595
startProverNode: true,
96+
// Mirrors the pipeline-MBPS sibling: more blocks per slot needs a larger per-block gas
97+
// allocation multiplier so each block can fit non-trivial txs.
98+
perBlockAllocationMultiplier: 8,
9699
aztecEpochDuration: 4,
97100
enforceTimeTable: true,
98-
// L1 slot duration - using < 8 to enable test mode optimizations
99-
ethereumSlotDuration: 4,
100-
// L2 slot duration - should fit 3 blocks (8s each) + overhead
101-
aztecSlotDuration: 36,
102-
// Block duration of 8s as specified
103-
blockDurationMs: 8000,
104-
// L1 publishing time
105-
l1PublishingTime: 2,
106-
// Reduce attestation propagation time for tests
107-
attestationPropagationTime: 0.5,
101+
// L1 slot duration - mirrors the pipeline-MBPS test for headroom on the parent's L1 tx
102+
ethereumSlotDuration: 12,
103+
// L2 slot duration - should fit several blocks (5.5s each) with pipelining overhead
104+
aztecSlotDuration: 72,
105+
// Block duration of 5.5s, matches the pipeline sibling
106+
blockDurationMs: 5500,
108107
// Committee size of 3
109108
aztecTargetCommitteeSize: 3,
110109
// Additional options (minTxsPerBlock, maxTxsPerBlock, etc.)
@@ -125,6 +124,10 @@ describe('e2e_epochs/epochs_mbps', () => {
125124
test.createValidatorNode([privateKey], { dontStartSequencer: true }),
126125
);
127126
logger.warn(`Started ${NODE_COUNT} validator nodes.`, { validators: validators.map(v => v.attester.toString()) });
127+
({ failEvents } = test.watchSequencerEvents(
128+
nodes.map(n => n.getSequencer()!),
129+
i => ({ validator: validators[i].attester }),
130+
));
128131

129132
// Point the wallet at a validator node. The initial node-0 has all validator keys in its config,
130133
// so it rejects block proposals from validators thinking they come from itself. By redirecting
@@ -185,6 +188,11 @@ describe('e2e_epochs/epochs_mbps', () => {
185188

186189
/** Waits until a specific multi-block checkpoint is proven, verifying that proving succeeds with MBPS blocks. */
187190
async function waitForProvenCheckpoint(targetCheckpoint: CheckpointNumber) {
191+
test.assertNoFailuresFromSequencers(failEvents);
192+
193+
logger.warn(`Stopping validator sequencers before waiting for checkpoint ${targetCheckpoint} to be proven`);
194+
await Promise.all(nodes.map(n => n.getSequencer()?.stop()));
195+
188196
const provenTimeout = test.L2_SLOT_DURATION_IN_S * test.epochDuration * 4;
189197
logger.warn(`Waiting for checkpoint ${targetCheckpoint} to be proven (timeout=${provenTimeout}s)`);
190198
await test.waitUntilProvenCheckpointNumber(targetCheckpoint, provenTimeout);

yarn-project/ethereum/src/contracts/chain_state_override.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { CheckpointNumber } from '@aztec/foundation/branded-types';
1+
import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
2+
import { Buffer32 } from '@aztec/foundation/buffer';
23
import { Fr } from '@aztec/foundation/curves/bn254';
34

45
import { SimulationOverridesBuilder } from './chain_state_override.js';
@@ -64,4 +65,28 @@ describe('SimulationOverridesBuilder', () => {
6465
const plan = builder.build();
6566
expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(7), proven: CheckpointNumber(3) });
6667
});
68+
69+
it('attaches temp checkpoint log fields under the configured pending checkpoint', () => {
70+
const headerHash = Fr.random();
71+
const outHash = Fr.random();
72+
const payloadDigest = Buffer32.random();
73+
const slotNumber = SlotNumber(42);
74+
const plan = new SimulationOverridesBuilder()
75+
.withChainTips({ pending: CheckpointNumber(7) })
76+
.withPendingTempCheckpointLogFields({ headerHash, outHash, payloadDigest, slotNumber })
77+
.build();
78+
79+
expect(plan?.pendingCheckpointState).toEqual({ headerHash, outHash, payloadDigest, slotNumber });
80+
});
81+
82+
it('throws when withPendingTempCheckpointLogFields is called without a pending chain-tip override', () => {
83+
expect(() =>
84+
new SimulationOverridesBuilder().withPendingTempCheckpointLogFields({
85+
headerHash: Fr.random(),
86+
outHash: Fr.random(),
87+
payloadDigest: Buffer32.random(),
88+
slotNumber: SlotNumber(42),
89+
}),
90+
).toThrow(/withChainTips\(\{ pending \}\) must be called/);
91+
});
6792
});

yarn-project/ethereum/src/contracts/chain_state_override.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer';
2-
import type { CheckpointNumber } from '@aztec/foundation/branded-types';
2+
import type { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
3+
import type { Buffer32 } from '@aztec/foundation/buffer';
34
import type { Fr } from '@aztec/foundation/curves/bn254';
45

56
import type { StateOverride } from 'viem';
67

78
import { type FeeHeader, RollupContract } from './rollup.js';
89

10+
/**
11+
* Override values for the pending checkpoint that the simulation should treat as already applied.
12+
* Every field is optional at plan-building time so callers can populate them incrementally; whatever
13+
* is present at translation time is forwarded to the partial `tempCheckpointLogs` helper so the
14+
* load-bearing `slotNumber` can land even if other fields could not be derived locally.
15+
*/
916
export type PendingCheckpointOverrideState = {
1017
archive?: Fr;
1118
feeHeader?: FeeHeader;
19+
headerHash?: Fr;
20+
outHash?: Fr;
21+
payloadDigest?: Buffer32;
22+
slotNumber?: SlotNumber;
1223
};
1324

1425
export type ChainTipsOverride = {
@@ -75,6 +86,22 @@ export class SimulationOverridesBuilder {
7586
return this;
7687
}
7788

89+
/**
90+
* Overrides the locally-derivable `tempCheckpointLogs` cell fields for the configured pending
91+
* checkpoint. Callers populate these together because they all come from the same proposed
92+
* checkpoint payload — there is no use case for setting them independently.
93+
*/
94+
public withPendingTempCheckpointLogFields(fields: {
95+
headerHash: Fr;
96+
outHash: Fr;
97+
payloadDigest: Buffer32;
98+
slotNumber: SlotNumber;
99+
}): this {
100+
this.assertPendingCheckpointNumber();
101+
this.pendingCheckpointState = { ...(this.pendingCheckpointState ?? {}), ...fields };
102+
return this;
103+
}
104+
78105
/** Disables blob checking for simulations that cannot provide DA inputs. */
79106
public withoutBlobCheck(): this {
80107
this.disableBlobCheck = true;
@@ -128,10 +155,16 @@ export async function buildSimulationOverridesStateOverride(
128155
);
129156
}
130157

131-
if (plan.pendingCheckpointState?.feeHeader) {
158+
if (plan.pendingCheckpointState) {
132159
rollupStateDiff.push(
133160
...extractRollupStateDiff(
134-
await rollup.makeFeeHeaderOverride(plan.chainTipsOverride!.pending!, plan.pendingCheckpointState.feeHeader),
161+
await rollup.makeTempCheckpointLogOverride(plan.chainTipsOverride!.pending!, {
162+
headerHash: plan.pendingCheckpointState.headerHash,
163+
outHash: plan.pendingCheckpointState.outHash,
164+
payloadDigest: plan.pendingCheckpointState.payloadDigest,
165+
slotNumber: plan.pendingCheckpointState.slotNumber,
166+
feeHeader: plan.pendingCheckpointState.feeHeader,
167+
}),
135168
),
136169
);
137170
}

yarn-project/ethereum/src/contracts/rollup.test.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getPublicClient } from '@aztec/ethereum/client';
2-
import { CheckpointNumber } from '@aztec/foundation/branded-types';
2+
import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
3+
import { Buffer32 } from '@aztec/foundation/buffer';
34
import { Fr } from '@aztec/foundation/curves/bn254';
45
import { EthAddress } from '@aztec/foundation/eth-address';
56
import { createLogger } from '@aztec/foundation/log';
@@ -392,6 +393,108 @@ describe('Rollup', () => {
392393
});
393394
});
394395

396+
describe('makeTempCheckpointLogOverride', () => {
397+
const fields = {
398+
headerHash: Fr.random(),
399+
outHash: Fr.random(),
400+
payloadDigest: Buffer32.random(),
401+
slotNumber: SlotNumber(42),
402+
feeHeader: {
403+
manaUsed: 12345n,
404+
excessMana: 67890n,
405+
ethPerFeeAsset: 1_000_000_000_000n,
406+
congestionCost: 99999n,
407+
proverCost: 55555n,
408+
} as FeeHeader,
409+
};
410+
411+
function getDiffMap(
412+
checkpointNumber: CheckpointNumber,
413+
override: Awaited<ReturnType<RollupContract['makeTempCheckpointLogOverride']>>,
414+
) {
415+
const map = new Map<string, string>();
416+
for (const entry of override) {
417+
for (const diff of entry.stateDiff ?? []) {
418+
map.set(diff.slot.toLowerCase(), diff.value.toLowerCase());
419+
}
420+
}
421+
const slotFor = async (field: TempCheckpointLogField) =>
422+
`0x${(await rollup.getTempCheckpointLogStorageSlot(checkpointNumber, field)).toString(16).padStart(64, '0')}`.toLowerCase();
423+
return { map, slotFor };
424+
}
425+
426+
it('emits one diff entry per required field at the expected storage slot', async () => {
427+
const checkpointNumber = CheckpointNumber(7);
428+
const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, fields);
429+
const { map, slotFor } = getDiffMap(checkpointNumber, override);
430+
431+
expect(override).toHaveLength(1);
432+
expect(override[0].stateDiff).toHaveLength(5);
433+
expect(map.get(await slotFor(TempCheckpointLogField.HeaderHash))).toBe(
434+
fields.headerHash.toString().toLowerCase(),
435+
);
436+
expect(map.get(await slotFor(TempCheckpointLogField.OutHash))).toBe(fields.outHash.toString().toLowerCase());
437+
expect(map.get(await slotFor(TempCheckpointLogField.PayloadDigest))).toBe(
438+
fields.payloadDigest.toString().toLowerCase(),
439+
);
440+
expect(map.get(await slotFor(TempCheckpointLogField.SlotNumber))).toBe(
441+
`0x${BigInt(fields.slotNumber).toString(16).padStart(64, '0')}`.toLowerCase(),
442+
);
443+
expect(map.get(await slotFor(TempCheckpointLogField.FeeHeader))).toBe(
444+
`0x${RollupContract.compressFeeHeader(fields.feeHeader).toString(16).padStart(64, '0')}`.toLowerCase(),
445+
);
446+
});
447+
448+
it('throws when slotNumber overflows uint32 (matches L1 SafeCast.toUint32 semantics)', async () => {
449+
const checkpointNumber = CheckpointNumber(3);
450+
const slotNumber = SlotNumber(0xdeadbeef + 0x1_0000_0000);
451+
await expect(rollup.makeTempCheckpointLogOverride(checkpointNumber, { ...fields, slotNumber })).rejects.toThrow(
452+
/does not fit in uint32/,
453+
);
454+
});
455+
456+
it('partial override emits only the supplied fields', async () => {
457+
const checkpointNumber = CheckpointNumber(13);
458+
const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, {
459+
slotNumber: SlotNumber(7),
460+
});
461+
const { map, slotFor } = getDiffMap(checkpointNumber, override);
462+
expect(override[0].stateDiff).toHaveLength(1);
463+
expect(map.get(await slotFor(TempCheckpointLogField.SlotNumber))).toBe(
464+
`0x${7n.toString(16).padStart(64, '0')}`.toLowerCase(),
465+
);
466+
});
467+
468+
it('partial override returns an empty array when no fields are supplied', async () => {
469+
const override = await rollup.makeTempCheckpointLogOverride(CheckpointNumber(13), {});
470+
expect(override).toEqual([]);
471+
});
472+
473+
it('round-trips slot, header hash, and fee header through getCheckpoint', async () => {
474+
// Reset tips so checkpoint 0 is in range, then build an override and read it back through the contract.
475+
await cheatCodes.store(
476+
EthAddress.fromString(rollupAddress),
477+
RollupContract.chainTipsStorageSlot,
478+
RollupContract.packChainTips(0n, 0n),
479+
);
480+
481+
const checkpointNumber = CheckpointNumber(0);
482+
const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, fields);
483+
484+
const { result } = await publicClient.simulateContract({
485+
address: rollupAddress,
486+
abi: RollupAbi as Abi,
487+
functionName: 'getCheckpoint',
488+
args: [BigInt(checkpointNumber)],
489+
stateOverride: override,
490+
});
491+
const checkpoint = result as { headerHash: `0x${string}`; outHash: `0x${string}`; slotNumber: bigint };
492+
expect(checkpoint.headerHash.toLowerCase()).toBe(fields.headerHash.toString().toLowerCase());
493+
expect(checkpoint.outHash.toLowerCase()).toBe(fields.outHash.toString().toLowerCase());
494+
expect(checkpoint.slotNumber).toBe(BigInt(fields.slotNumber));
495+
});
496+
});
497+
395498
describe('getSlashingProposer', () => {
396499
it('returns a slashing proposer', async () => {
397500
const slashingProposer = await rollup.getSlashingProposer();

0 commit comments

Comments
 (0)