Skip to content

Commit 5a10de1

Browse files
committed
feat(fast-inbox): validator streaming acceptance conditions behind STREAMING_INBOX (A-1383)
Behind the shared `streamingInbox` flag (default off), the validator applies the AZIP-22 Fast Inbox acceptance conditions in place of the legacy per-checkpoint inHash comparison. Flag off, behavior is byte-identical. Block proposals (`proposal_handler.handleBlockProposal`), via a new pure `streaming_inbox_checks` module: - exists: the referenced bucket resolves in the node's own Inbox view and its rolling hash matches the reference (unknown bucket is an immediate reject; the bounded wait is A-1393); - moves forward: the bucket's cumulative total is at least the parent block's; - not too new: the bucket is at least INBOX_LAG_SECONDS old at validation time (dateProvider.now, inclusive boundary); - caps: the per-block count and the running per-checkpoint total fit their caps. The derived bundle is fed into per-block re-execution (insertMessagesPerBlock threaded through openCheckpoint/reexecuteTransactions), whose state-ref comparison stays the final arbiter. Checkpoint proposals (`validateCheckpointProposal`): the last-block minimum-consumption (censorship) rule runs before attesting, via the shared `isInboxConsumptionSufficient` predicate and the corrected cutoff anchor `getTimestampForSlot(slot - 1) - INBOX_LAG_SECONDS`; a mandatory unconsumed bucket rejects the checkpoint (no attestation). Parent/last-consumed buckets are resolved from L1-to-L2 tree leaf counts via a new `getInboxBucketByTotalMsgCount` archiver lookup (compact post-flip indexing); a leaf count that does not land on a bucket boundary (a pre-flip padded parent) is rejected/deferred, the flip (A-1384) closes that gap. The flag is picked up on the validator config via pickConfigMappings of the shared `streamingInbox` key. New reasons are non-slashable while the path lands. Full tsc -b cannot run locally (pre-existing noir-protocol-circuits-types breakage); CI is the typecheck of record for validator-client. Unit tests cover each check, the caps and lag boundaries, running-total accumulation, the censorship predicate, and the handler wiring.
1 parent 625c2f7 commit 5a10de1

15 files changed

Lines changed: 894 additions & 21 deletions

yarn-project/archiver/src/modules/data_source_base.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,10 @@ export abstract class ArchiverDataSourceBase
332332
return this.stores.messages.getInboxBucket(seq);
333333
}
334334

335+
public getInboxBucketByTotalMsgCount(totalMsgCount: bigint): Promise<InboxBucket | undefined> {
336+
return this.stores.messages.getInboxBucketByTotalMsgCount(totalMsgCount);
337+
}
338+
335339
public getL1ToL2MessagesBetweenBuckets(fromExclusive: bigint, toInclusive: bigint): Promise<Fr[]> {
336340
return this.stores.messages.getL1ToL2MessagesBetweenBuckets(fromExclusive, toInclusive);
337341
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,20 @@ describe('MessageStore', () => {
389389
expect(await messageStore.getInboxBucket(4n)).toBeUndefined();
390390
});
391391

392+
it('resolves a bucket by its cumulative message total', async () => {
393+
const msgs = makeBucketedMessages(threeBucketSpec);
394+
await messageStore.addL1ToL2Messages(msgs);
395+
396+
// Each bucket boundary (cumulative totals 3, 5, 6) resolves to its bucket.
397+
expect((await messageStore.getInboxBucketByTotalMsgCount(3n))?.seq).toEqual(1n);
398+
expect((await messageStore.getInboxBucketByTotalMsgCount(5n))?.seq).toEqual(2n);
399+
expect((await messageStore.getInboxBucketByTotalMsgCount(6n))?.seq).toEqual(3n);
400+
// A total inside a bucket (not on a boundary) does not resolve.
401+
expect(await messageStore.getInboxBucketByTotalMsgCount(4n)).toBeUndefined();
402+
// A total past the last synced bucket does not resolve.
403+
expect(await messageStore.getInboxBucketByTotalMsgCount(7n)).toBeUndefined();
404+
});
405+
392406
it('continues a bucket that spans two insertion batches', async () => {
393407
const msgs = makeBucketedMessages(threeBucketSpec);
394408
await messageStore.addL1ToL2Messages(msgs.slice(0, 2));

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,25 @@ export class MessageStore {
410410
return snapshot && this.toInboxBucket(seq, snapshot);
411411
}
412412

413+
/**
414+
* Returns the Inbox bucket whose cumulative message total equals `totalMsgCount`, or undefined if no synced bucket
415+
* sits on that boundary (AZIP-22 Fast Inbox). Sequence 0 (total 0) is the genesis sentinel base case; otherwise the
416+
* message at global index `totalMsgCount - 1` is the last message of the bucket with that cumulative total, so its
417+
* `bucketSeq` resolves the bucket. A total that does not land on a bucket boundary (e.g. a pre-flip padded leaf
418+
* count) returns undefined.
419+
*/
420+
public async getInboxBucketByTotalMsgCount(totalMsgCount: bigint): Promise<InboxBucket | undefined> {
421+
if (totalMsgCount === 0n) {
422+
return this.getInboxBucket(0n);
423+
}
424+
const buffer = await this.#l1ToL2Messages.getAsync(this.indexToKey(totalMsgCount - 1n));
425+
if (buffer === undefined) {
426+
return undefined;
427+
}
428+
const bucket = await this.getInboxBucket(deserializeInboxMessage(buffer).bucketSeq);
429+
return bucket !== undefined && bucket.totalMsgCount === totalMsgCount ? bucket : undefined;
430+
}
431+
413432
/**
414433
* Returns the latest Inbox bucket opened at or before the given L1 timestamp, or undefined if every synced bucket
415434
* was opened strictly after it (AZIP-22 Fast Inbox).

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export class MockArchiver extends MockL2BlockSource implements L2BlockSource, L1
3737
return this.messageSource.getInboxBucket(seq);
3838
}
3939

40+
getInboxBucketByTotalMsgCount(totalMsgCount: bigint): Promise<InboxBucket | undefined> {
41+
return this.messageSource.getInboxBucketByTotalMsgCount(totalMsgCount);
42+
}
43+
4044
getL1ToL2MessagesBetweenBuckets(fromExclusive: bigint, toInclusive: bigint): Promise<Fr[]> {
4145
return this.messageSource.getL1ToL2MessagesBetweenBuckets(fromExclusive, toInclusive);
4246
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export class MockL1ToL2MessageSource implements L1ToL2MessageSource {
3838
return Promise.resolve(this.buckets.get(seq));
3939
}
4040

41+
getInboxBucketByTotalMsgCount(totalMsgCount: bigint): Promise<InboxBucket | undefined> {
42+
if (totalMsgCount === 0n) {
43+
return Promise.resolve(this.buckets.get(0n));
44+
}
45+
return Promise.resolve([...this.buckets.values()].find(bucket => bucket.totalMsgCount === totalMsgCount));
46+
}
47+
4148
getLatestInboxBucketAtOrBefore(timestamp: bigint): Promise<InboxBucket | undefined> {
4249
const atOrBefore = [...this.buckets.values()]
4350
.filter(bucket => bucket.timestamp <= timestamp)

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ describe('ArchiverApiSchema', () => {
224224
expect(result).toMatchObject({ seq: 2n, msgCount: 3, isOpen: true });
225225
});
226226

227+
it('getInboxBucketByTotalMsgCount', async () => {
228+
const result = await context.client.getInboxBucketByTotalMsgCount(3n);
229+
expect(result).toMatchObject({ seq: 2n, totalMsgCount: 3n, msgCount: 3 });
230+
});
231+
227232
it('getL1ToL2MessagesBetweenBuckets', async () => {
228233
const result = await context.client.getL1ToL2MessagesBetweenBuckets(0n, 3n);
229234
expect(result).toEqual([expect.any(Fr)]);
@@ -591,6 +596,18 @@ class MockArchiver implements ArchiverApi {
591596
isOpen: true,
592597
});
593598
}
599+
getInboxBucketByTotalMsgCount(totalMsgCount: bigint): Promise<InboxBucket | undefined> {
600+
expect(typeof totalMsgCount).toEqual('bigint');
601+
return Promise.resolve({
602+
seq: 2n,
603+
inboxRollingHash: Fr.random(),
604+
totalMsgCount,
605+
timestamp: 100n,
606+
msgCount: 3,
607+
lastMessageIndex: 2n,
608+
isOpen: true,
609+
});
610+
}
594611
getL1ToL2MessagesBetweenBuckets(fromExclusive: bigint, toInclusive: bigint): Promise<Fr[]> {
595612
expect(typeof fromExclusive).toEqual('bigint');
596613
expect(typeof toInclusive).toEqual('bigint');

yarn-project/stdlib/src/interfaces/archiver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ export const ArchiverApiSchema: ApiSchemaFor<ArchiverApi> = {
135135
output: InboxBucketSchema.optional(),
136136
}),
137137
getInboxBucket: z.function({ input: z.tuple([schemas.BigInt]), output: InboxBucketSchema.optional() }),
138+
getInboxBucketByTotalMsgCount: z.function({
139+
input: z.tuple([schemas.BigInt]),
140+
output: InboxBucketSchema.optional(),
141+
}),
138142
getL1ToL2MessagesBetweenBuckets: z.function({
139143
input: z.tuple([schemas.BigInt, schemas.BigInt]),
140144
output: z.array(schemas.Fr),

yarn-project/stdlib/src/interfaces/validator.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ export type ValidatorClientConfig = ValidatorHASignerConfig &
8383
};
8484

8585
export type ValidatorClientFullConfig = ValidatorClientConfig &
86-
Pick<SequencerConfig, 'txPublicSetupAllowListExtend' | 'broadcastInvalidBlockProposal' | 'maxBlocksPerCheckpoint'> &
86+
Pick<
87+
SequencerConfig,
88+
'txPublicSetupAllowListExtend' | 'broadcastInvalidBlockProposal' | 'maxBlocksPerCheckpoint' | 'streamingInbox'
89+
> &
8790
// `blockDurationMs` is optional on the loose `SequencerConfig` but is always populated via the shared
8891
// `numberConfigHelper(3000)` mapping, so it is required on the fully-resolved validator config.
8992
Required<Pick<SequencerConfig, 'blockDurationMs'>> &
@@ -134,6 +137,7 @@ export const ValidatorClientFullConfigSchema = zodFor<Omit<ValidatorClientFullCo
134137
broadcastInvalidBlockProposal: z.boolean().optional(),
135138
blockDurationMs: z.number().positive(),
136139
maxBlocksPerCheckpoint: z.number().positive().optional(),
140+
streamingInbox: z.boolean().optional(),
137141
slashBroadcastedInvalidBlockPenalty: schemas.BigInt,
138142
slashBroadcastedInvalidCheckpointProposalPenalty: schemas.BigInt,
139143
slashDuplicateProposalPenalty: schemas.BigInt,

yarn-project/stdlib/src/messaging/l1_to_l2_message_source.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ export interface L1ToL2MessageSource {
3939
*/
4040
getInboxBucket(seq: bigint): Promise<InboxBucket | undefined>;
4141

42+
/**
43+
* Returns the Inbox bucket whose cumulative message total equals `totalMsgCount`, or undefined if no synced bucket
44+
* sits on that boundary (AZIP-22 Fast Inbox). A block's or checkpoint's L1-to-L2 tree leaf count equals the
45+
* cumulative total of the last bucket it consumed (post-flip compact indexing), so validators use this to resolve the
46+
* bucket a block consumed through from the block's leaf count, without trusting a wire hint. `totalMsgCount === 0`
47+
* resolves the genesis sentinel bucket (sequence 0); a total that does not land on a bucket boundary (e.g. a pre-flip
48+
* padded leaf count) returns undefined.
49+
* @param totalMsgCount - The cumulative Inbox message count (leaf count) to resolve to a bucket boundary.
50+
*/
51+
getInboxBucketByTotalMsgCount(totalMsgCount: bigint): Promise<InboxBucket | undefined>;
52+
4253
/**
4354
* Returns the message leaves absorbed into buckets in the range `(fromExclusive, toInclusive]`, in insertion
4455
* order, for streaming message-bundle derivation (AZIP-22 Fast Inbox). Returns an empty array if the upper

yarn-project/validator-client/src/checkpoint_builder.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder {
375375
fork: MerkleTreeWriteOperations,
376376
existingBlocks: L2Block[] = [],
377377
bindings?: LoggerBindings,
378+
// Streaming Inbox (AZIP-22 Fast Inbox): when true the fresh-checkpoint path inserts messages per block (via
379+
// `buildBlock`'s `l1ToL2Messages`) instead of the whole checkpoint up front; `l1ToL2Messages` here must be empty.
380+
// The resume path never inserts messages up front, so this only affects `startCheckpoint`.
381+
insertMessagesPerBlock: boolean = false,
378382
): Promise<CheckpointBuilder> {
379383
const stateReference = await fork.getStateReference();
380384
const archiveTree = await fork.getTreeInfo(MerkleTreeId.ARCHIVE);
@@ -389,6 +393,7 @@ export class FullNodeCheckpointsBuilder implements ICheckpointsBuilder {
389393
previousInboxRollingHash,
390394
fork,
391395
bindings,
396+
insertMessagesPerBlock,
392397
);
393398
}
394399

0 commit comments

Comments
 (0)