Skip to content

Commit 2d6dee2

Browse files
committed
feat(fast-inbox): sequencer streaming message selection, flag off (A-1382)
Implements A-1382 (FI-12): sequencer streaming Inbox message selection behind a `streamingInbox` flag, default off. Part of the Fast Inbox stack (AZIP-22). ## Flag semantics - One shared env var `STREAMING_INBOX` -> `streamingInbox: boolean` (default false), defined once in `stdlib`'s `sharedSequencerConfigMappings` so the sequencer reads it now and the validator (A-1383) maps the same env. Flag off is byte-identical: the new paths are gated at checkpoint start and per block. - With the flag on, checkpoints are expected to fail L1 submission (on-chain `propose` still enforces the legacy per-checkpoint consumption until the flip, A-1384). The streaming path is exercised with unit/mock tests only. ## Selection policy (mirrors `ProposeLib.validateInboxConsumption`) New pure selector `sequencer-client/src/sequencer/inbox_bucket_selector.ts`, run per block: - Pick the newest lag-eligible bucket (`getLatestInboxBucketAtOrBefore(now - INBOX_LAG_SECONDS)`); on the checkpoint's final block, also consider the cutoff bucket and take the newer, so consumption reaches the censorship floor. - Walk back from the candidate to the newest bucket that fits both the per-block cap (`bucket.totalMsgCount - parent.totalMsgCount`) and the per-checkpoint cap (`bucket.totalMsgCount - checkpointStartTotalMsgCount`). If even the first forward bucket overshoots the per-checkpoint cap, consume nothing — matching L1's cap-escape (`next.totalMsgCount - parentTotal > MAX_L1_TO_L2_MSGS_PER_CHECKPOINT`). - The inclusive `<=` comparisons make a bucket exactly `INBOX_LAG_SECONDS` old eligible and a bucket exactly at the cutoff mandatory, matching L1's strict `next.timestamp > cutoff`. Cutoff is computed as `getTimestampForSlot(slot - 1) - INBOX_LAG_SECONDS`, matching `ProposeLib` exactly (not the consensus timetable's `getBuildFrameStart`, which subtracts an extra ethereum-slot). Boundary vectors pinned against A-1371 resolution section 13. ## Cutoff floor On the checkpoint's final block (including the block that reaches the per-checkpoint block cap) the cutoff is a consumption floor, so the checkpoint's own header would pass the L1 censorship assert post-flip. ## Builder interface change - `ICheckpointBlockBuilder.buildBlock` gains an optional per-block `l1ToL2Messages` bundle; `ICheckpointsBuilder.startCheckpoint` gains `insertMessagesPerBlock`. Threaded through `FullNodeCheckpointsBuilder`/`CheckpointBuilder` and the lightweight builder (first-in-checkpoint bundle padded, non-first compact; the inHash/rolling hash recompute over the accumulated logical messages). - An optional signed bucket reference is threaded onto block proposals (`Validator.createBlockProposal` -> `ValidationService` -> `BlockProposal.createProposalFromSigner`). ## Constant `INBOX_LAG_SECONDS = 12` added to `constants.nr` and regenerated into the TS constants (`constants.gen.ts`). Not emitted to `ConstantsGen.sol` (Solidity whitelist); `ProposeLib` keeps its local copy pending consolidation (A-1434). ## Open item / to verify on CI - Non-genesis cross-checkpoint parent-bucket sourcing is not wired: there is no by-rolling-hash archiver lookup and legacy parents do not sit on a bucket boundary, so only the genesis base case is resolved and non-genesis throws (safely caught -> skipped proposal). This is a flip-time concern. - Local build/typecheck of `sequencer-client`, `validator-client`, `prover-client` is blocked by the known stale-artifact/`@aztec/bb-avm-sim` breakage, so CI is the typecheck of record for the job/builder/proposal edits. The pure selector unit tests and the stdlib config changes were verified locally. Replaces #24787.
1 parent 6857461 commit 2d6dee2

15 files changed

Lines changed: 670 additions & 18 deletions

File tree

noir-projects/fnd/noir-protocol-circuits/crates/types/src/constants.nr

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ pub global L1_TO_L2_MSG_SUBTREE_ROOT_SIBLING_PATH_LENGTH: u32 =
6969
pub global MAX_L1_TO_L2_MSGS_PER_BLOCK: u32 = 1024;
7070
// Cap on L1-to-L2 messages consumed by a single checkpoint. Semantically today's NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP.
7171
pub global MAX_L1_TO_L2_MSGS_PER_CHECKPOINT: u32 = 1024;
72+
// Minimum bucket age, in seconds, at the start of a checkpoint's build frame for its consumption to be mandatory under
73+
// the streaming Inbox censorship cutoff (AZIP-22 Fast Inbox). One L1 slot: validators cannot be required to act on
74+
// buckets they may not yet have seen. L2 consensus policy consumed by the sequencer and validator (and mirrored by
75+
// ProposeLib on L1); the protocol circuits do not read it.
76+
pub global INBOX_LAG_SECONDS: u32 = 12;
7277
// Maximum number of subtrees a L2ToL1Msg unbalanced tree can have. Used when calculating the out hash of a tx.
7378
pub global MAX_L2_TO_L1_MSG_SUBTREES_PER_TX: u32 = 3; // ceil(log2(MAX_L2_TO_L1_MSGS_PER_TX))
7479

yarn-project/foundation/src/config/env_var.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ export type EnvVar =
385385
| 'FISHERMAN_MODE'
386386
| 'MAX_ALLOWED_ETH_CLIENT_DRIFT_SECONDS'
387387
| 'MAX_BLOCKS_PER_CHECKPOINT'
388+
| 'STREAMING_INBOX'
388389
| 'LEGACY_BLS_CLI'
389390
| 'DEBUG_FORCE_TX_PROOF_VERIFICATION'
390391
| 'VALIDATOR_ALLOW_EPHEMERAL_SIGNING_PROTECTION'

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,14 @@ export class LightweightCheckpointBuilder {
7070
db: MerkleTreeWriteOperations,
7171
bindings?: LoggerBindings,
7272
feeAssetPriceModifier: bigint = 0n,
73+
// Streaming Inbox (AZIP-22 Fast Inbox): messages are inserted per block via `addBlock`, so `l1ToL2Messages` here
74+
// is empty and the up-front checkpoint-wide insertion is skipped.
75+
insertMessagesPerBlock: boolean = false,
7376
): Promise<LightweightCheckpointBuilder> {
74-
// Insert l1-to-l2 messages into the tree.
75-
await appendL1ToL2MessagesToTree(db, l1ToL2Messages);
77+
// Insert l1-to-l2 messages into the tree (legacy flow: the whole checkpoint's messages up front).
78+
if (!insertMessagesPerBlock) {
79+
await appendL1ToL2MessagesToTree(db, l1ToL2Messages);
80+
}
7681

7782
return new LightweightCheckpointBuilder(
7883
checkpointNumber,
@@ -172,7 +177,7 @@ export class LightweightCheckpointBuilder {
172177
public async addBlock(
173178
globalVariables: GlobalVariables,
174179
txs: ProcessedTx[],
175-
opts: { insertTxsEffects?: boolean; expectedEndState?: StateReference } = {},
180+
opts: { insertTxsEffects?: boolean; expectedEndState?: StateReference; l1ToL2Messages?: Fr[] } = {},
176181
): Promise<{ block: L2Block; timings: Record<string, number> }> {
177182
const timings: Record<string, number> = {};
178183
const isFirstBlock = this.blocks.length === 0;
@@ -203,6 +208,20 @@ export class LightweightCheckpointBuilder {
203208
timings.insertSideEffects = msInsertSideEffects;
204209
}
205210

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

@@ -238,6 +257,12 @@ export class LightweightCheckpointBuilder {
238257
const block = new L2Block(newArchive, header, body, this.checkpointNumber, indexWithinCheckpoint);
239258
this.blocks.push(block);
240259

260+
// Accumulate the streaming bundle now that the block is fully built, so a mid-build throw above leaves the
261+
// checkpoint's message list (and thus its inHash/rolling hash) consistent with the blocks actually built.
262+
if (opts.l1ToL2Messages !== undefined) {
263+
this.l1ToL2Messages.push(...opts.l1ToL2Messages);
264+
}
265+
241266
const [msSpongeAbsorb] = await elapsed(() => this.spongeBlob.absorb(blockBlobFields));
242267
timings.spongeAbsorb = msSpongeAbsorb;
243268
this.blobFields.push(...blockBlobFields);

yarn-project/sequencer-client/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const DefaultSequencerConfig = {
6969
skipPushProposedBlocksToArchiver: false,
7070
skipPublishingCheckpointsPercent: 0,
7171
maxBlocksPerCheckpoint: DEFAULT_MAX_BLOCKS_PER_CHECKPOINT,
72+
streamingInbox: false,
7273
} satisfies ResolvedSequencerConfig;
7374

7475
/**

yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
type ResolvedSequencerConfig,
4141
type WorldStateSynchronizer,
4242
} from '@aztec/stdlib/interfaces/server';
43-
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
43+
import type { InboxBucket, L1ToL2MessageSource } from '@aztec/stdlib/messaging';
4444
import { BlockProposal, CheckpointProposal, type CoordinationSignatureContext } from '@aztec/stdlib/p2p';
4545
import { CheckpointHeader } from '@aztec/stdlib/rollup';
4646
import type { ProposerTimetable, SubslotSelection } from '@aztec/stdlib/timetable';
@@ -1394,6 +1394,59 @@ describe('CheckpointProposalJob', () => {
13941394
});
13951395
});
13961396

1397+
describe('streaming inbox (flag on)', () => {
1398+
beforeEach(() => {
1399+
job.setTimetable(makeProposerTimetable({ l1Constants, blockDurationMs: 3000 }));
1400+
job.updateConfig({ streamingInbox: true });
1401+
});
1402+
1403+
it('streams per-block bucket bundles, advances the reference, and skips the bulk message fetch', async () => {
1404+
jest
1405+
.spyOn(job.getTimetable(), 'selectNextSubslot')
1406+
.mockReturnValueOnce(subslot(10, 0, false))
1407+
.mockReturnValueOnce(subslot(18, 1, true))
1408+
.mockReturnValue(noSubslot());
1409+
1410+
// The archiver returns the newest synced bucket (seq 2) for any lag/cutoff lookup, so the first block
1411+
// consumes through it and the second block (cursor already at seq 2) consumes nothing and reuses the ref.
1412+
const bucket: InboxBucket = {
1413+
seq: 2n,
1414+
inboxRollingHash: new Fr(99),
1415+
totalMsgCount: 5n,
1416+
timestamp: 0n,
1417+
msgCount: 2,
1418+
lastMessageIndex: 4n,
1419+
};
1420+
const bundle = Array.from({ length: 5 }, (_, i) => new Fr(i + 1));
1421+
l1ToL2MessageSource.getLatestInboxBucketAtOrBefore.mockResolvedValue(bucket);
1422+
l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets.mockResolvedValue(bundle);
1423+
1424+
const { lastBlock } = await setupMultipleBlocks(2, [2, 1]);
1425+
validatorClient.collectAttestations.mockResolvedValue(getAttestations(lastBlock));
1426+
1427+
const checkpoint = await job.executeAndAwait();
1428+
1429+
expect(checkpoint).toBeDefined();
1430+
1431+
// Streaming skips the bulk per-checkpoint fetch and tells the builder to insert messages per block.
1432+
expect(l1ToL2MessageSource.getL1ToL2Messages).not.toHaveBeenCalled();
1433+
expect(checkpointsBuilder.startCheckpointCalls).toHaveLength(1);
1434+
expect(checkpointsBuilder.startCheckpointCalls[0].l1ToL2Messages).toEqual([]);
1435+
expect(checkpointsBuilder.startCheckpointCalls[0].insertMessagesPerBlock).toBe(true);
1436+
1437+
// The first block consumes the selected bundle; the second consumes nothing.
1438+
expect(checkpointBuilder.buildBlockCalls).toHaveLength(2);
1439+
expect(checkpointBuilder.buildBlockCalls[0].opts.l1ToL2Messages).toEqual(bundle);
1440+
expect(checkpointBuilder.buildBlockCalls[1].opts.l1ToL2Messages).toEqual([]);
1441+
1442+
// Both block proposals carry the selected bucket reference (the second reuses the first's).
1443+
const bucketRefArgs = validatorClient.createBlockProposal.mock.calls.map(call => call[8]);
1444+
expect(bucketRefArgs).toHaveLength(2);
1445+
expect(bucketRefArgs[0]?.bucketSeq).toBe(2n);
1446+
expect(bucketRefArgs[1]?.bucketSeq).toBe(2n);
1447+
});
1448+
});
1449+
13971450
describe('build single block', () => {
13981451
it('does not build a block if not enough valid txs are collected', async () => {
13991452
// We have enough txs, but not enough valid ones

0 commit comments

Comments
 (0)