Skip to content

Commit 36ac36d

Browse files
committed
fix(fast-inbox): thread per-block message sponges and wire the msgs-only block root in the prover (A-1384)
Three flip gaps surfaced by the phase-2 review: - The prover supplied the checkpoint-wide message sponge as every non-first block root's start sponge. The block merge and checkpoint root circuits assert per-block continuity (right.start_msg_sponge == left.end_msg_sponge, first block empty, merged end == InboxParity sponge), so any checkpoint whose non-first block carried messages was unprovable. The sponge is now threaded per block: each block starts from the previous block's end sponge and absorbs its own slice. - A zero-tx non-first block (the message-only block shape the sequencer can now produce) was rejected outright by BlockProvingState and by the lightweight builder, and the orchestrator never selected the msgs-only block root circuit. Both now accept a zero-tx non-first block carrying a non-empty bundle, and the orchestrator routes it through BlockRootMsgsOnlyRollupPrivateInputs (dispatch, prover method, VK allowlists, and job plumbing already existed). - SequencerPublisher.enqueueProposeCheckpoint gained a required bucketHint parameter but the publisher unit/integration tests and the e2e synching test still passed three arguments and could not typecheck. The call sites now pass a hint (the integration suite's consumption model is reworked for streaming semantics separately, on the node-cleanup branch). Also updates comments the flip left stale: the EpochProofLib note claiming the epoch rolling hashes are unvalidated, the flag-gated wording in the validator slashing table and proposal-handler tests, and the lightweight builder's padded first-block bundle note (both branches append compactly post-flip, so the branch is gone too).
1 parent 4ea6ed5 commit 36ac36d

10 files changed

Lines changed: 120 additions & 57 deletions

File tree

l1-contracts/src/core/libraries/rollup/EpochProofLib.sol

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,9 @@ library EpochProofLib {
232232

233233
publicInputs[2] = _args.outHash;
234234

235-
// Inbox rolling-hash chain segment consumed across the epoch (AZIP-22 Fast Inbox). Deliberately UNVALIDATED
236-
// until the Fast Inbox flip, when they get checked against per-checkpoint records written at propose; for now
237-
// they are only passed through to the proof's public inputs.
235+
// Inbox rolling-hash chain segment consumed across the epoch (AZIP-22 Fast Inbox). The start is validated above
236+
// against the record written at propose for checkpoint _start - 1; the end is pinned transitively through the
237+
// stored checkpoint header hashes (see the anchoring block in assertAcceptable).
238238
publicInputs[3] = _args.previousInboxRollingHash;
239239
publicInputs[4] = _args.endInboxRollingHash;
240240
}

yarn-project/end-to-end/src/single-node/sync/synching.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@ describe('single-node/sync/synching', () => {
502502
rollupAddress: deployL1ContractsValues.l1ContractAddresses.rollupAddress,
503503
}),
504504
Signature.empty(),
505+
0n,
505506
);
506507

507508
await cheatCodes.rollup.markAsProven(CheckpointNumber(provenThrough));

yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,10 @@ export class LightweightCheckpointBuilder {
181181
const timings: Record<string, number> = {};
182182
const isFirstBlock = this.blocks.length === 0;
183183

184-
// Empty blocks are only allowed as the first block in a checkpoint
185-
if (!isFirstBlock && txs.length === 0) {
186-
throw new Error('Cannot add empty block that is not the first block in the checkpoint.');
184+
// A non-first block with no txs is only allowed when it inserts a non-empty L1-to-L2 message bundle: the
185+
// message-only block shape (AZIP-22 Fast Inbox), proven by the msgs-only block root.
186+
if (!isFirstBlock && txs.length === 0 && (opts.l1ToL2Messages?.length ?? 0) === 0) {
187+
throw new Error('Cannot add an empty non-first block that carries no L1-to-L2 messages.');
187188
}
188189

189190
if (isFirstBlock) {
@@ -208,17 +209,12 @@ export class LightweightCheckpointBuilder {
208209
}
209210

210211
// Streaming Inbox (AZIP-22 Fast Inbox): insert this block's L1-to-L2 message bundle before reading the end state,
211-
// so the block header's L1-to-L2 tree snapshot reflects it. First-in-checkpoint bundles are padded to
212-
// NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP (matching the legacy per-checkpoint insertion and the world-state
213-
// synchronizer); non-first bundles are appended compactly. The logical (unpadded) messages are accumulated only
214-
// once the block is fully built (below), so a mid-build failure does not pollute the checkpoint's inHash/rolling
215-
// hash; the rolling hash and inHash are recomputed over them at checkpoint completion.
212+
// so the block header's L1-to-L2 tree snapshot reflects it. Bundles are appended compactly (unpadded, at the
213+
// tree's current next-available index). The logical messages are accumulated only once the block is fully built
214+
// (below), so a mid-build failure does not pollute the checkpoint's rolling hash; the rolling hash is recomputed
215+
// over them at checkpoint completion.
216216
if (opts.l1ToL2Messages !== undefined) {
217-
if (isFirstBlock) {
218-
await appendL1ToL2MessagesToTree(this.db, opts.l1ToL2Messages);
219-
} else {
220-
await this.db.appendLeaves(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, opts.l1ToL2Messages);
221-
}
217+
await appendL1ToL2MessagesToTree(this.db, opts.l1ToL2Messages);
222218
}
223219

224220
const [msGetEndState, endState] = await elapsed(() => this.db.getStateReference());

yarn-project/prover-client/src/orchestrator/block-proving-state.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import { type BlockBlobData, type BlockEndBlobData, type SpongeBlob, encodeBlockEndBlobData } from '@aztec/blob-lib';
2-
import {
3-
type ARCHIVE_HEIGHT,
4-
type L1_TO_L2_MSG_TREE_HEIGHT,
5-
type NESTED_RECURSIVE_ROLLUP_HONK_PROOF_LENGTH,
2+
import type {
3+
ARCHIVE_HEIGHT,
4+
L1_TO_L2_MSG_TREE_HEIGHT,
5+
NESTED_RECURSIVE_ROLLUP_HONK_PROOF_LENGTH,
66
} from '@aztec/constants';
77
import { BlockNumber } from '@aztec/foundation/branded-types';
88
import { Fr } from '@aztec/foundation/curves/bn254';
99
import type { Tuple } from '@aztec/foundation/serialize';
1010
import { type TreeNodeLocation, UnbalancedTreeStore } from '@aztec/foundation/trees';
1111
import type { PublicInputsAndRecursiveProof } from '@aztec/stdlib/interfaces/server';
12-
import { L1ToL2MessageBundle, makeL1ToL2MessageBundle } from '@aztec/stdlib/messaging';
12+
import { L1ToL2MessageBundle, type L1ToL2MessageSponge, makeL1ToL2MessageBundle } from '@aztec/stdlib/messaging';
1313
import type { RollupHonkProofData } from '@aztec/stdlib/proofs';
1414
import {
1515
BlockRollupPublicInputs,
1616
BlockRootEmptyTxFirstRollupPrivateInputs,
1717
BlockRootFirstRollupPrivateInputs,
18+
BlockRootMsgsOnlyRollupPrivateInputs,
1819
BlockRootRollupPrivateInputs,
1920
BlockRootSingleTxFirstRollupPrivateInputs,
2021
BlockRootSingleTxRollupPrivateInputs,
@@ -63,6 +64,9 @@ export class BlockProvingState {
6364
private readonly timestamp: UInt64,
6465
public readonly lastArchiveTreeSnapshot: AppendOnlyTreeSnapshot,
6566
private readonly lastArchiveSiblingPath: Tuple<Fr, typeof ARCHIVE_HEIGHT>,
67+
// The full state reference of the previous block, before this block's bundle is appended. Feeds the msgs-only
68+
// block root, whose zero-tx block has no tx constants to pin the previous state.
69+
private readonly previousState: StateReference,
6670
// This block's L1-to-L2 message tree snapshot before and after its own bundle (AZIP-22 Fast Inbox). The start is
6771
// the previous block's end (block-merge continuity); the end is this block's own post-bundle snapshot.
6872
private readonly startL1ToL2MessageTreeSnapshot: AppendOnlyTreeSnapshot,
@@ -71,18 +75,31 @@ export class BlockProvingState {
7175
private readonly l1ToL2MessageFrontierHint: Tuple<Fr, typeof L1_TO_L2_MSG_TREE_HEIGHT>,
7276
// This block's own real L1-to-L2 message leaves (unpadded slice).
7377
private readonly l1ToL2Messages: Fr[],
78+
// Message sponge threaded across the checkpoint's blocks (AZIP-22 Fast Inbox): the start is the previous block's
79+
// end sponge (empty for the first block), the end absorbs this block's own slice. Block merges assert the
80+
// continuity, so the end is exposed for the next block to inherit.
81+
private readonly startMsgSponge: L1ToL2MessageSponge,
82+
private readonly endMsgSponge: L1ToL2MessageSponge,
7483
private readonly headerOfLastBlockInPreviousCheckpoint: BlockHeader,
7584
private readonly startSpongeBlob: SpongeBlob,
7685
public parentCheckpoint: CheckpointProvingState,
7786
) {
7887
this.isFirstBlock = index === 0;
79-
if (!totalNumTxs && !this.isFirstBlock) {
80-
throw new Error(`Cannot create a block with 0 txs, unless it's the first block.`);
88+
if (!totalNumTxs && !this.isFirstBlock && l1ToL2Messages.length === 0) {
89+
throw new Error(
90+
`Cannot create a non-first block with 0 txs and no L1-to-L2 messages (block ${blockNumber}). ` +
91+
`A zero-tx non-first block must carry a message bundle (the msgs-only block root).`,
92+
);
8193
}
8294

8395
this.baseOrMergeProofs = new UnbalancedTreeStore(totalNumTxs);
8496
}
8597

98+
/** The message sponge after absorbing this block's slice; inherited by the next block as its start sponge. */
99+
public getEndMsgSponge(): L1ToL2MessageSponge {
100+
return this.endMsgSponge;
101+
}
102+
86103
public get epochNumber(): number {
87104
return this.parentCheckpoint.epochNumber;
88105
}
@@ -294,7 +311,25 @@ export class BlockProvingState {
294311

295312
const messageBundle = this.#getMessageBundle();
296313
const frontierHint = this.#getFrontierHint();
297-
const startMsgSponge = this.parentCheckpoint.getCheckpointMsgSponge();
314+
const startMsgSponge = this.startMsgSponge;
315+
316+
// A non-first block with no txs exists solely to insert its message bundle (the constructor rejects an empty one).
317+
if (this.totalNumTxs === 0) {
318+
return {
319+
rollupType: 'rollup-block-root-msgs-only' satisfies CircuitName,
320+
inputs: new BlockRootMsgsOnlyRollupPrivateInputs(
321+
this.lastArchiveTreeSnapshot,
322+
this.previousState,
323+
this.constants,
324+
this.timestamp,
325+
this.startSpongeBlob,
326+
startMsgSponge,
327+
messageBundle,
328+
frontierHint,
329+
this.lastArchiveSiblingPath,
330+
),
331+
};
332+
}
298333

299334
const [leftRollup, rightRollup] = previousRollups;
300335
if (!rightRollup) {

yarn-project/prover-client/src/orchestrator/checkpoint-proving-state.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { L1ToL2MessageSponge } from '@aztec/stdlib/messaging';
1414
import { InboxParityPrivateInputs, type ParityPublicInputs } from '@aztec/stdlib/parity';
1515
import { BlockMergeRollupPrivateInputs, BlockRollupPublicInputs, CheckpointConstantData } from '@aztec/stdlib/rollup';
1616
import type { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees';
17-
import type { BlockHeader } from '@aztec/stdlib/tx';
17+
import type { BlockHeader, StateReference } from '@aztec/stdlib/tx';
1818
import type { UInt64 } from '@aztec/stdlib/types';
1919

2020
import { toProofData } from './block-building-helpers.js';
@@ -42,10 +42,6 @@ export class CheckpointProvingState {
4242
// Inbox rolling hash before this checkpoint's messages (the previous checkpoint's end value; genesis is zero).
4343
// Threaded into the InboxParity circuit so the resulting checkpoint header rolling hash matches the proposer's.
4444
private readonly startInboxRollingHash: Fr,
45-
// Message-bundle sponge over the checkpoint's real messages (real-count absorb). Equals the InboxParity proof's
46-
// end sponge and the sponge the block roots accumulate, so it is threaded into non-first block roots as their
47-
// inherited `startMsgSponge`.
48-
private readonly checkpointMsgSponge: L1ToL2MessageSponge,
4945
public readonly epochNumber: number,
5046
/** Owner's liveness check. `verifyState()` returns false once this returns false. */
5147
private readonly isAlive: () => boolean,
@@ -61,24 +57,22 @@ export class CheckpointProvingState {
6157
return this.l1ToL2Messages;
6258
}
6359

64-
/** The message-bundle sponge over the checkpoint's real messages (real-count absorb) — inherited by non-first block roots. */
65-
public getCheckpointMsgSponge(): L1ToL2MessageSponge {
66-
return this.checkpointMsgSponge;
67-
}
68-
69-
public startNewBlock(
60+
public async startNewBlock(
7061
blockNumber: BlockNumber,
7162
timestamp: UInt64,
7263
totalNumTxs: number,
7364
lastArchiveTreeSnapshot: AppendOnlyTreeSnapshot,
7465
lastArchiveSiblingPath: Tuple<Fr, typeof ARCHIVE_HEIGHT>,
66+
// The full state reference of the previous block (before this block's message bundle is appended). Feeds the
67+
// msgs-only block root, whose zero-tx block carries no tx constants to pin the previous state.
68+
previousState: StateReference,
7569
// Per-block L1-to-L2 message state (AZIP-22 Fast Inbox): the block's start snapshot (its parent's end), its own
7670
// post-bundle end snapshot, the full-height frontier at the start index, and its own real message slice.
7771
startL1ToL2MessageTreeSnapshot: AppendOnlyTreeSnapshot,
7872
endL1ToL2MessageTreeSnapshot: AppendOnlyTreeSnapshot,
7973
l1ToL2MessageFrontierHint: Tuple<Fr, typeof L1_TO_L2_MSG_TREE_HEIGHT>,
8074
l1ToL2Messages: Fr[],
81-
): BlockProvingState {
75+
): Promise<BlockProvingState> {
8276
const index = Number(blockNumber) - Number(this.firstBlockNumber);
8377
if (index >= this.totalNumBlocks) {
8478
throw new Error(`Unable to start a new block at index ${index}. Expected at most ${this.totalNumBlocks} blocks.`);
@@ -91,6 +85,19 @@ export class CheckpointProvingState {
9185
);
9286
}
9387

88+
// Thread the message sponge across the checkpoint's blocks (AZIP-22 Fast Inbox): each block starts from the
89+
// previous block's end sponge (empty for the first block) and absorbs its own real slice. The block merge and
90+
// checkpoint root circuits assert exactly this continuity (`right.start_msg_sponge == left.end_msg_sponge`, first
91+
// block starts empty, merged end equals the InboxParity sponge), so the end sponge is computed eagerly here for
92+
// the next block to inherit. Blocks must therefore be started in order, which the sequential per-block message
93+
// appends already require.
94+
const startMsgSponge = index === 0 ? L1ToL2MessageSponge.empty() : this.blocks[index - 1]?.getEndMsgSponge();
95+
if (!startMsgSponge) {
96+
throw new Error('Cannot start a new block before the previous block in the checkpoint has been started.');
97+
}
98+
const endMsgSponge = startMsgSponge.clone();
99+
await endMsgSponge.absorb(l1ToL2Messages);
100+
94101
const block = new BlockProvingState(
95102
index,
96103
blockNumber,
@@ -99,10 +106,13 @@ export class CheckpointProvingState {
99106
timestamp,
100107
lastArchiveTreeSnapshot,
101108
lastArchiveSiblingPath,
109+
previousState,
102110
startL1ToL2MessageTreeSnapshot,
103111
endL1ToL2MessageTreeSnapshot,
104112
l1ToL2MessageFrontierHint,
105113
l1ToL2Messages,
114+
startMsgSponge,
115+
endMsgSponge,
106116
this.headerOfLastBlockInPreviousCheckpoint,
107117
startSpongeBlob,
108118
this,

yarn-project/prover-client/src/orchestrator/checkpoint-sub-tree-orchestrator.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ import type {
2020
ReadonlyWorldStateAccess,
2121
ServerCircuitProver,
2222
} from '@aztec/stdlib/interfaces/server';
23-
import { L1ToL2MessageSponge, appendL1ToL2MessagesToTree } from '@aztec/stdlib/messaging';
23+
import { appendL1ToL2MessagesToTree } from '@aztec/stdlib/messaging';
2424
import type { ParityPublicInputs } from '@aztec/stdlib/parity';
2525
import {
2626
type BaseRollupHints,
2727
type BlockRollupPublicInputs,
2828
BlockRootEmptyTxFirstRollupPrivateInputs,
2929
BlockRootFirstRollupPrivateInputs,
30+
BlockRootMsgsOnlyRollupPrivateInputs,
3031
BlockRootSingleTxFirstRollupPrivateInputs,
3132
BlockRootSingleTxRollupPrivateInputs,
3233
CheckpointConstantData,
@@ -287,6 +288,10 @@ export class CheckpointSubTreeOrchestrator extends ProvingScheduler {
287288
const lastArchiveTreeSnapshot = await getTreeSnapshot(MerkleTreeId.ARCHIVE, db);
288289
const lastArchiveSiblingPath = await getRootTreeSiblingPath(MerkleTreeId.ARCHIVE, db);
289290

291+
// The previous block's full state reference, captured before this block's message bundle is appended. Feeds the
292+
// msgs-only block root, whose zero-tx block carries no tx constants to pin the previous state.
293+
const previousState = await db.getStateReference();
294+
290295
// Streaming Inbox (AZIP-22 Fast Inbox): insert this block's own real message slice at compact indices, capturing
291296
// the start snapshot + full-height frontier before the append and the block's own post-bundle end snapshot after.
292297
const startL1ToL2Snapshot = await getTreeSnapshot(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, db);
@@ -297,12 +302,13 @@ export class CheckpointSubTreeOrchestrator extends ProvingScheduler {
297302
await appendL1ToL2MessagesToTree(db, l1ToL2Messages);
298303
const endL1ToL2Snapshot = await getTreeSnapshot(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, db);
299304

300-
const blockProvingState = this.provingState.startNewBlock(
305+
const blockProvingState = await this.provingState.startNewBlock(
301306
blockNumber,
302307
timestamp,
303308
totalNumTxs,
304309
lastArchiveTreeSnapshot,
305310
lastArchiveSiblingPath,
311+
previousState,
306312
startL1ToL2Snapshot,
307313
endL1ToL2Snapshot,
308314
l1ToL2FrontierHint,
@@ -322,8 +328,8 @@ export class CheckpointSubTreeOrchestrator extends ProvingScheduler {
322328

323329
// A block with no txs has no base or merge proof whose completion would enqueue its block root,
324330
// and parity now gates the checkpoint root rather than the first block root, so no other callback
325-
// fires it. Enqueue it here. Only a first block may be empty (the block proving state rejects
326-
// any other), so this always drives the empty-tx first block root.
331+
// fires it. Enqueue it here. This drives the empty-tx first block root, or the msgs-only block root
332+
// for a non-first zero-tx block carrying a message bundle.
327333
this.checkAndEnqueueBlockRootRollup(blockProvingState);
328334
}
329335
}
@@ -532,11 +538,8 @@ export class CheckpointSubTreeOrchestrator extends ProvingScheduler {
532538
const lastArchiveSiblingPath = await getLastSiblingPath(MerkleTreeId.ARCHIVE, db);
533539

534540
// Streaming Inbox (AZIP-22 Fast Inbox): messages are inserted per block in `startNewBlock`, not the whole
535-
// checkpoint up front. The message sponge still absorbs the checkpoint's real messages (real-count), matching the
536-
// checkpoint's single InboxParity proof; non-first block roots inherit this sponge.
537-
const checkpointMsgSponge = L1ToL2MessageSponge.empty();
538-
await checkpointMsgSponge.absorb(l1ToL2Messages);
539-
541+
// checkpoint up front. The message sponge is likewise threaded per block (each block's start sponge is the
542+
// previous block's end), so the last block's end sponge matches the checkpoint's single InboxParity proof.
540543
this.provingState = new CheckpointProvingState(
541544
/* index */ 0,
542545
constants,
@@ -545,7 +548,6 @@ export class CheckpointSubTreeOrchestrator extends ProvingScheduler {
545548
lastArchiveSiblingPath,
546549
l1ToL2Messages,
547550
startInboxRollingHash,
548-
checkpointMsgSponge,
549551
Number(this.epochNumber),
550552
/* isAlive */ () => !this.cancelled,
551553
/* onReject */ reason => this.subTreeResult.reject(new Error(reason)),
@@ -742,6 +744,8 @@ export class CheckpointSubTreeOrchestrator extends ProvingScheduler {
742744
return this.prover.getBlockRootSingleTxFirstRollupProof(inputs, signal, provingState.epochNumber);
743745
} else if (inputs instanceof BlockRootEmptyTxFirstRollupPrivateInputs) {
744746
return this.prover.getBlockRootEmptyTxFirstRollupProof(inputs, signal, provingState.epochNumber);
747+
} else if (inputs instanceof BlockRootMsgsOnlyRollupPrivateInputs) {
748+
return this.prover.getBlockRootMsgsOnlyRollupProof(inputs, signal, provingState.epochNumber);
745749
} else if (inputs instanceof BlockRootSingleTxRollupPrivateInputs) {
746750
return this.prover.getBlockRootSingleTxRollupProof(inputs, signal, provingState.epochNumber);
747751
} else {

0 commit comments

Comments
 (0)