@aztec/validator-client 6.0.0-nightly.20260812 → 6.0.0-nightly.20260821

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,12 @@
1
1
  import type { Archiver } from '@aztec/archiver';
2
2
  import type { BlobClientInterface } from '@aztec/blob-client/client';
3
3
  import { type Blob, encodeCheckpointBlobDataFromBlocks, getBlobsPerL1Block } from '@aztec/blob-lib';
4
- import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants';
4
+ import {
5
+ INITIAL_L2_BLOCK_NUM,
6
+ MAX_BLOCKS_PER_CHECKPOINT,
7
+ MAX_L1_TO_L2_MSGS_PER_BLOCK,
8
+ MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
9
+ } from '@aztec/constants';
5
10
  import type { EpochCache } from '@aztec/epoch-cache';
6
11
  import { validateFeeAssetPriceModifier } from '@aztec/ethereum/contracts';
7
12
  import {
@@ -20,10 +25,13 @@ import { retryUntil } from '@aztec/foundation/retry';
20
25
  import { DateProvider, Timer } from '@aztec/foundation/timer';
21
26
  import { isErrorClass } from '@aztec/foundation/types';
22
27
  import type { P2P, PeerId } from '@aztec/p2p';
23
- import { BlockProposalValidator } from '@aztec/p2p/msg_validators';
24
28
  import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block';
25
29
  import type { CheckpointReexecutionTracker, ReexecutionOutcome } from '@aztec/stdlib/checkpoint';
26
- import { getPreviousCheckpointOutHashes, validateCheckpoint } from '@aztec/stdlib/checkpoint';
30
+ import {
31
+ getPreviousCheckpointInboxRollingHash,
32
+ getPreviousCheckpointOutHashes,
33
+ validateCheckpoint,
34
+ } from '@aztec/stdlib/checkpoint';
27
35
  import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers';
28
36
  import { Gas } from '@aztec/stdlib/gas';
29
37
  import type {
@@ -35,9 +43,16 @@ import type {
35
43
  import {
36
44
  type L1ToL2MessageSource,
37
45
  accumulateCheckpointOutHashes,
38
- computeInHashFromL1ToL2Messages,
46
+ getInboxCutoffTimestamp,
47
+ isInboxConsumptionSufficient,
39
48
  } from '@aztec/stdlib/messaging';
40
- import type { BlockProposal, CheckpointAttestation, CheckpointProposalCore } from '@aztec/stdlib/p2p';
49
+ import type {
50
+ BlockProposal,
51
+ CheckpointAttestation,
52
+ CheckpointProposalCore,
53
+ ValidatedBlockProposal,
54
+ ValidatedCheckpointProposalCore,
55
+ } from '@aztec/stdlib/p2p';
41
56
  import type { ConsensusTimetable } from '@aztec/stdlib/timetable';
42
57
  import { MerkleTreeId } from '@aztec/stdlib/trees';
43
58
  import type { CheckpointGlobalVariables, FailedTx, Tx, TxHash } from '@aztec/stdlib/tx';
@@ -53,13 +68,20 @@ import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/te
53
68
 
54
69
  import type { FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
55
70
  import type { ValidatorMetrics } from './metrics.js';
71
+ import {
72
+ type StreamingBlockCheckReason,
73
+ type StreamingBlockMetadataCheckResult,
74
+ checkStreamingBlockProposalMetadata,
75
+ getStreamingBlockBundle,
76
+ } from './streaming_inbox_checks.js';
56
77
 
57
78
  export type BlockProposalValidationFailureReason =
58
79
  | 'invalid_signature'
59
80
  | 'invalid_proposal'
60
81
  | 'parent_block_not_found'
61
82
  | 'parent_block_wrong_slot'
62
- | 'in_hash_mismatch'
83
+ // Streaming Inbox per-block acceptance failures.
84
+ | StreamingBlockCheckReason
63
85
  | 'global_variables_mismatch'
64
86
  | 'block_number_already_exists'
65
87
  | 'txs_not_available'
@@ -109,6 +131,8 @@ export type CheckpointProposalValidationFailureReason =
109
131
  | 'checkpoint_header_mismatch'
110
132
  | 'archive_mismatch'
111
133
  | 'out_hash_mismatch'
134
+ // Streaming Inbox last-block censorship failure.
135
+ | 'inbox_consumption_insufficient'
112
136
  | 'checkpoint_validation_failed';
113
137
 
114
138
  /**
@@ -134,6 +158,7 @@ const CHECKPOINT_VALIDATION_REASON_TO_OUTCOME: Record<
134
158
  checkpoint_header_mismatch: 'invalid',
135
159
  archive_mismatch: 'invalid',
136
160
  out_hash_mismatch: 'invalid',
161
+ inbox_consumption_insufficient: 'invalid',
137
162
  checkpoint_validation_failed: 'invalid',
138
163
  };
139
164
 
@@ -175,7 +200,6 @@ export const SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT: BlockProposalValidation
175
200
  'global_variables_mismatch',
176
201
  'invalid_proposal',
177
202
  'parent_block_wrong_slot',
178
- 'in_hash_mismatch',
179
203
  'duplicate_txs',
180
204
  'invalid_embedded_txs',
181
205
  ];
@@ -198,6 +222,9 @@ export const SLASHABLE_CHECKPOINT_PROPOSAL_VALIDATION_RESULT: Record<
198
222
  ['last_block_archive_mismatch']: true,
199
223
 
200
224
  // disabled
225
+ // Streaming Inbox last-block censorship: kept out of slashing while the streaming path is new; L1 `propose` is the
226
+ // authoritative reject (Rollup__UnconsumedInboxMessages).
227
+ ['inbox_consumption_insufficient']: false,
201
228
  ['invalid_signature']: false,
202
229
  ['last_block_not_found']: false,
203
230
  ['block_fetch_error']: false,
@@ -248,7 +275,6 @@ export class ProposalHandler {
248
275
  private blockSource: L2BlockSource & L2BlockSink,
249
276
  private l1ToL2MessageSource: L1ToL2MessageSource,
250
277
  private txProvider: ITxProvider,
251
- private blockProposalValidator: BlockProposalValidator,
252
278
  private epochCache: EpochCache,
253
279
  private timetable: ConsensusTimetable,
254
280
  private config: ValidatorClientFullConfig,
@@ -328,7 +354,7 @@ export class ProposalHandler {
328
354
 
329
355
  // Non-validator handler that processes or re-executes for monitoring but does not attest.
330
356
  // Returns boolean indicating whether the proposal was valid.
331
- const blockHandler = async (proposal: BlockProposal, proposalSender: PeerId): Promise<boolean> => {
357
+ const blockHandler = async (proposal: ValidatedBlockProposal, proposalSender: PeerId): Promise<boolean> => {
332
358
  try {
333
359
  const { slotNumber, blockNumber } = proposal;
334
360
  const result = await this.handleBlockProposal(proposal, proposalSender, shouldReexecute);
@@ -378,7 +404,7 @@ export class ProposalHandler {
378
404
  // Runs for all nodes (validators and non-validators). Validators get the cached result in the
379
405
  // validator-specific callback (attestToCheckpointProposal) which runs after this one.
380
406
  const checkpointHandler = async (
381
- proposal: CheckpointProposalCore,
407
+ proposal: ValidatedCheckpointProposalCore,
382
408
  _sender: PeerId,
383
409
  ): Promise<CheckpointAttestation[] | undefined> => {
384
410
  try {
@@ -446,8 +472,15 @@ export class ProposalHandler {
446
472
  return this;
447
473
  }
448
474
 
475
+ /**
476
+ * Processes a block proposal: collects its txs and, if requested, re-executes them to check the resulting
477
+ * block against the proposal. Expects the proposal to have already passed p2p ingress validation (signature
478
+ * context, signature, expected proposer, index within checkpoint, tx field checks, and the receive-window
479
+ * timeliness check) — none of those are re-applied here, and only deterministic properties of the payload
480
+ * are validated before processing.
481
+ */
449
482
  async handleBlockProposal(
450
- proposal: BlockProposal,
483
+ proposal: ValidatedBlockProposal,
451
484
  proposalSender: PeerId,
452
485
  shouldReexecute: boolean,
453
486
  ): Promise<BlockProposalValidationResult> {
@@ -472,13 +505,9 @@ export class ProposalHandler {
472
505
  txHashes: proposal.txHashes.map(t => t.toString()),
473
506
  });
474
507
 
475
- // Check that the proposal is from the current proposer, or the next proposer
476
- // This should have been handled by the p2p layer, but we double check here out of caution
477
- const validationResult = await this.blockProposalValidator.validate(proposal);
478
- if (validationResult.result !== 'accept') {
479
- this.log.warn(`Proposal is not valid, skipping processing`, proposalInfo);
480
- return { isValid: false, reason: 'invalid_proposal' };
481
- }
508
+ // The receive-window check from p2p ingress is deliberately not re-applied here: its outcome depends on
509
+ // the wall clock at evaluation time, so re-running it turned node-local processing latency into an
510
+ // invalid-proposal verdict against an honest proposer, which then fed the invalid-block slashing path.
482
511
 
483
512
  // A tx can only appear once in a block: the second copy would emit nullifiers already emitted by the
484
513
  // first. This is not a relaying-peer fault, so it passes gossip validation and is classified here as
@@ -545,9 +574,29 @@ export class ProposalHandler {
545
574
  return { isValid: false, blockNumber, reason: 'block_number_already_exists' };
546
575
  }
547
576
 
577
+ // Streaming Inbox: run the metadata checks before committing to any network work. They are point lookups
578
+ // against our own Inbox view, so a proposal carrying a bucket reference that does not resolve locally is
579
+ // rejected without a proposer being able to make us spend the validation window collecting its txs.
580
+ const streamingMetadata = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock);
581
+ if (!streamingMetadata.accepted) {
582
+ this.log.warn(`Streaming Inbox block acceptance check failed, skipping processing`, {
583
+ reason: streamingMetadata.reason,
584
+ bucketRef: proposal.bucketRef?.toInspect(),
585
+ ...proposalInfo,
586
+ });
587
+ return { isValid: false, blockNumber, reason: streamingMetadata.reason };
588
+ }
589
+
548
590
  // Collect txs from the proposal. We start doing this as early as possible,
549
591
  // and we do it even if we don't plan to re-execute the txs, so that we have them if another node needs them.
550
- const collected = await this.collectProposalTxs(proposal, blockNumber, proposalSender, proposalInfo);
592
+ // The block's message bundle is an independent read, so derive it concurrently with the collection.
593
+ const txsPromise = this.collectProposalTxs(proposal, blockNumber, proposalSender, proposalInfo);
594
+ const bundlePromise = getStreamingBlockBundle(this.l1ToL2MessageSource, streamingMetadata);
595
+ // Promise.all settles on the first rejection, so without a handler of its own the loser's later rejection
596
+ // would surface as an unhandled rejection. Awaiting below still observes whichever rejected first.
597
+ txsPromise.catch(() => {});
598
+ bundlePromise.catch(() => {});
599
+ const [collected, l1ToL2Messages] = await Promise.all([txsPromise, bundlePromise]);
551
600
  if (collected === 'invalid_embedded_txs') {
552
601
  return { isValid: false, blockNumber, reason: collected };
553
602
  }
@@ -573,19 +622,6 @@ export class ProposalHandler {
573
622
  const checkpointNumber = checkpointResult.checkpointNumber;
574
623
  proposalInfo.checkpointNumber = checkpointNumber;
575
624
 
576
- // Check that I have the same set of l1ToL2Messages as the proposal
577
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
578
- const computedInHash = computeInHashFromL1ToL2Messages(l1ToL2Messages);
579
- const proposalInHash = proposal.inHash;
580
- if (!computedInHash.equals(proposalInHash)) {
581
- this.log.warn(`L1 to L2 messages in hash mismatch, skipping processing`, {
582
- proposalInHash: proposalInHash.toString(),
583
- computedInHash: computedInHash.toString(),
584
- ...proposalInfo,
585
- });
586
- return { isValid: false, blockNumber, reason: 'in_hash_mismatch' };
587
- }
588
-
589
625
  // Check that all of the transactions in the proposal are available
590
626
  if (missingTxs.length > 0) {
591
627
  this.log.warn(`Missing ${missingTxs.length} txs to process proposal`, { ...proposalInfo, missingTxs });
@@ -605,6 +641,12 @@ export class ProposalHandler {
605
641
  log: this.log,
606
642
  });
607
643
 
644
+ const previousInboxRollingHash = await getPreviousCheckpointInboxRollingHash({
645
+ blockSource: this.blockSource,
646
+ checkpointNumber,
647
+ log: this.log,
648
+ });
649
+
608
650
  // Try re-executing the transactions in the proposal if needed
609
651
  let reexecutionResult;
610
652
  try {
@@ -616,6 +658,7 @@ export class ProposalHandler {
616
658
  txs,
617
659
  l1ToL2Messages,
618
660
  previousCheckpointOutHashes,
661
+ previousInboxRollingHash,
619
662
  );
620
663
  } catch (error) {
621
664
  this.log.error(`Error reexecuting txs while processing block proposal`, error, proposalInfo);
@@ -926,6 +969,121 @@ export class ProposalHandler {
926
969
  }
927
970
  }
928
971
 
972
+ /**
973
+ * Runs the streaming-Inbox per-block metadata checks for a block proposal, returning the bucket range its message
974
+ * bundle derives from or a rejection reason. The parent block's consumed total and the checkpoint's starting total
975
+ * are derived from L1-to-L2 tree leaf counts; a parent whose count does not sit on a bucket boundary is rejected
976
+ * inside {@link checkStreamingBlockProposalMetadata}.
977
+ */
978
+ private async checkStreamingBlockMetadata(
979
+ proposal: BlockProposal,
980
+ blockNumber: BlockNumber,
981
+ parentBlock: 'genesis' | BlockData,
982
+ ): Promise<StreamingBlockMetadataCheckResult> {
983
+ const parentTotalMsgCount = this.getConsumedMsgTotal(parentBlock);
984
+ const checkpointStartTotalMsgCount = await this.resolveCheckpointStartTotal(
985
+ blockNumber,
986
+ proposal.indexWithinCheckpoint,
987
+ parentTotalMsgCount,
988
+ );
989
+ if (checkpointStartTotalMsgCount === undefined) {
990
+ // The block before the checkpoint's first block has not synced locally, so the per-checkpoint cap origin is
991
+ // unavailable: treat as an unknown local view. There is no bounded wait for the missing block yet.
992
+ return { accepted: false, reason: 'bucket_unknown' };
993
+ }
994
+ const nowSeconds = BigInt(Math.floor(this.dateProvider.now() / 1000));
995
+ return checkStreamingBlockProposalMetadata({
996
+ messageSource: this.l1ToL2MessageSource,
997
+ bucketRef: proposal.bucketRef,
998
+ parentTotalMsgCount,
999
+ checkpointStartTotalMsgCount,
1000
+ nowSeconds,
1001
+ minBucketAgeSeconds: this.epochCache.getL1Constants().ethereumSlotDuration,
1002
+ perBlockCap: MAX_L1_TO_L2_MSGS_PER_BLOCK,
1003
+ perCheckpointCap: MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
1004
+ });
1005
+ }
1006
+
1007
+ /** A block's L1-to-L2 message tree leaf count: the cumulative Inbox message count it consumed through. */
1008
+ private blockLeafCount(block: BlockData | L2Block): bigint {
1009
+ return BigInt(block.header.state.l1ToL2MessageTree.nextAvailableLeafIndex);
1010
+ }
1011
+
1012
+ /** The cumulative Inbox message count consumed through a block: its L1-to-L2 tree leaf count (0 at genesis). */
1013
+ private getConsumedMsgTotal(block: 'genesis' | BlockData): bigint {
1014
+ return block === 'genesis' ? 0n : this.blockLeafCount(block);
1015
+ }
1016
+
1017
+ /**
1018
+ * The cumulative Inbox message count consumed as of the parent checkpoint (the per-checkpoint cap origin). For a
1019
+ * checkpoint's first block this is the parent block's total; for a later block it is the leaf count of the block
1020
+ * before the checkpoint's first block. Returns undefined when that block has not synced locally.
1021
+ */
1022
+ private resolveCheckpointStartTotal(
1023
+ blockNumber: BlockNumber,
1024
+ indexWithinCheckpoint: number,
1025
+ parentTotalMsgCount: bigint,
1026
+ ): Promise<bigint | undefined> {
1027
+ return indexWithinCheckpoint === 0
1028
+ ? Promise.resolve(parentTotalMsgCount)
1029
+ : this.getPreBlockConsumedTotal(blockNumber - indexWithinCheckpoint);
1030
+ }
1031
+
1032
+ /**
1033
+ * The cumulative Inbox message count consumed as of the block immediately before `firstBlockNumber` (its L1-to-L2
1034
+ * tree leaf count): 0 when that block is genesis, undefined when it has not synced locally.
1035
+ */
1036
+ private async getPreBlockConsumedTotal(firstBlockNumber: number): Promise<bigint | undefined> {
1037
+ const preBlockNumber = firstBlockNumber - 1;
1038
+ if (preBlockNumber < INITIAL_L2_BLOCK_NUM) {
1039
+ return 0n;
1040
+ }
1041
+ const preBlock = await this.blockSource.getBlockData({ number: BlockNumber(preBlockNumber) });
1042
+ return preBlock === undefined ? undefined : this.blockLeafCount(preBlock);
1043
+ }
1044
+
1045
+ /**
1046
+ * Enforces the streaming-Inbox last-block minimum-consumption (censorship) rule for a checkpoint, mirroring
1047
+ * `ProposeLib.validateInboxConsumption`: the first bucket the checkpoint left unconsumed must be absent, past the
1048
+ * cutoff, or a cap-escape. Returns true (sufficient) when the checkpoint's consumption cannot be resolved against
1049
+ * the local Inbox view, deferring to L1 `propose` as the authoritative reject.
1050
+ */
1051
+ private async isLastBlockConsumptionSufficient(slot: SlotNumber, blocks: L2Block[]): Promise<boolean> {
1052
+ const lastBlockTotal = this.blockLeafCount(blocks[blocks.length - 1]);
1053
+ const checkpointStartTotal = await this.getPreBlockConsumedTotal(blocks[0].number);
1054
+ const lastConsumedBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(lastBlockTotal);
1055
+ if (checkpointStartTotal === undefined || lastConsumedBucket === undefined) {
1056
+ return true;
1057
+ }
1058
+ const nextBucket = await this.l1ToL2MessageSource.getInboxBucket(lastConsumedBucket.seq + 1n);
1059
+ const cutoffTimestamp = getInboxCutoffTimestamp(slot, this.epochCache.getL1Constants());
1060
+ return isInboxConsumptionSufficient({
1061
+ nextBucket,
1062
+ cutoffTimestamp,
1063
+ checkpointStartTotalMsgCount: checkpointStartTotal,
1064
+ perCheckpointCap: MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
1065
+ });
1066
+ }
1067
+
1068
+ /**
1069
+ * Derives the ordered list of L1-to-L2 messages a checkpoint consumed across its blocks, from the Inbox buckets
1070
+ * between the parent checkpoint's consumed position and the checkpoint's last block. Empty when
1071
+ * the checkpoint consumed nothing or its consumption cannot be resolved against the local Inbox view.
1072
+ */
1073
+ private async deriveCheckpointConsumedMessages(blocks: L2Block[]): Promise<Fr[]> {
1074
+ const checkpointStartTotal = await this.getPreBlockConsumedTotal(blocks[0].number);
1075
+ const lastBlockTotal = this.blockLeafCount(blocks[blocks.length - 1]);
1076
+ if (checkpointStartTotal === undefined || lastBlockTotal <= checkpointStartTotal) {
1077
+ return [];
1078
+ }
1079
+ const startBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(checkpointStartTotal);
1080
+ const endBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(lastBlockTotal);
1081
+ if (startBucket === undefined || endBucket === undefined) {
1082
+ return [];
1083
+ }
1084
+ return this.l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets(startBucket.seq, endBucket.seq);
1085
+ }
1086
+
929
1087
  async reexecuteTransactions(
930
1088
  proposal: BlockProposal,
931
1089
  blockNumber: BlockNumber,
@@ -933,6 +1091,7 @@ export class ProposalHandler {
933
1091
  txs: Tx[],
934
1092
  l1ToL2Messages: Fr[],
935
1093
  previousCheckpointOutHashes: Fr[],
1094
+ previousInboxRollingHash: Fr,
936
1095
  ): Promise<ReexecuteTransactionsResult> {
937
1096
  const { blockHeader, txHashes } = proposal;
938
1097
 
@@ -974,13 +1133,15 @@ export class ProposalHandler {
974
1133
  gasFees: blockHeader.globalVariables.gasFees,
975
1134
  };
976
1135
 
977
- // Create checkpoint builder with prior blocks
1136
+ // Create checkpoint builder with prior blocks. The messages the prior blocks consumed are not needed: this path
1137
+ // only re-executes and compares the new block, never completing the checkpoint (whose rolling hash they seed).
978
1138
  const checkpointBuilder = await this.checkpointsBuilder.openCheckpoint(
979
1139
  checkpointNumber,
980
1140
  constants,
981
1141
  0n, // only takes effect in the following checkpoint.
982
- l1ToL2Messages,
1142
+ [],
983
1143
  previousCheckpointOutHashes,
1144
+ previousInboxRollingHash,
984
1145
  fork,
985
1146
  priorBlocks,
986
1147
  this.log.getBindings(),
@@ -999,6 +1160,7 @@ export class ProposalHandler {
999
1160
  expectedEndState: blockHeader.state,
1000
1161
  maxTransactions: this.config.validateMaxTxsPerBlock,
1001
1162
  maxBlockGas,
1163
+ l1ToL2Messages,
1002
1164
  });
1003
1165
 
1004
1166
  const { block, failedTxs } = result;
@@ -1054,9 +1216,11 @@ export class ProposalHandler {
1054
1216
  * Validates a checkpoint proposal, caches the result, and uploads blobs if configured.
1055
1217
  * Returns a cached result if the same proposal (archive + slot) was already validated.
1056
1218
  * Used by both the all-nodes callback (via register) and the validator client (via delegation).
1219
+ * Expects the proposal to have already passed p2p ingress validation (expected proposer and receive-window
1220
+ * timeliness); only deterministic properties of the signed payload are checked here.
1057
1221
  */
1058
1222
  async handleCheckpointProposal(
1059
- proposal: CheckpointProposalCore,
1223
+ proposal: ValidatedCheckpointProposalCore,
1060
1224
  proposalInfo: LogData,
1061
1225
  ): Promise<CheckpointProposalValidationResult> {
1062
1226
  const slot = proposal.slotNumber;
@@ -1184,9 +1348,15 @@ export class ProposalHandler {
1184
1348
  };
1185
1349
  }
1186
1350
 
1187
- // Note this condition should never trigger, since we dont process block proposals that exceed indexWithinCheckpoint
1188
- const maxBlocksPerCheckpoint = this.config.maxBlocksPerCheckpoint;
1189
- if (maxBlocksPerCheckpoint !== undefined && blocks.length > maxBlocksPerCheckpoint) {
1351
+ // The checkpoint root circuit caps how many blocks a checkpoint may contain, and L1 cannot check it when the
1352
+ // checkpoint is proposed (`ProposedHeader` carries no block count, only `blockHeadersHash`). An over-cap
1353
+ // checkpoint would therefore be accepted on L1 and then be unprovable, forcing an epoch reorg, so validators
1354
+ // reject it before attesting. An operator-configured limit may only tighten the protocol cap, never relax it.
1355
+ const maxBlocksPerCheckpoint = Math.min(
1356
+ this.config.maxBlocksPerCheckpoint ?? MAX_BLOCKS_PER_CHECKPOINT,
1357
+ MAX_BLOCKS_PER_CHECKPOINT,
1358
+ );
1359
+ if (blocks.length > maxBlocksPerCheckpoint) {
1190
1360
  this.log.warn(`Checkpoint proposal exceeds maxBlocksPerCheckpoint`, {
1191
1361
  ...proposalInfo,
1192
1362
  blocksInProposal: blocks.length,
@@ -1209,8 +1379,20 @@ export class ProposalHandler {
1209
1379
  const constants = this.extractCheckpointConstants(firstBlock);
1210
1380
  const checkpointNumber = firstBlock.checkpointNumber;
1211
1381
 
1212
- // Get L1-to-L2 messages for this checkpoint
1213
- const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber);
1382
+ // Streaming Inbox: on the last block of a checkpoint, enforce the minimum-consumption
1383
+ // (censorship) rule before attesting. Reject (no attestation) if a mandatory bucket was left unconsumed.
1384
+ if (!(await this.isLastBlockConsumptionSufficient(slot, blocks))) {
1385
+ this.log.warn(`Streaming Inbox last-block censorship check failed, refusing to attest`, {
1386
+ ...proposalInfo,
1387
+ checkpointNumber,
1388
+ });
1389
+ return { isValid: false, reason: 'inbox_consumption_insufficient', checkpointNumber };
1390
+ }
1391
+
1392
+ // Derive the checkpoint's consumed L1-to-L2 message list from the Inbox buckets between the parent checkpoint's
1393
+ // consumed position and the last block's (compact indexing). The messages are already in the db from per-block
1394
+ // validation; this list only drives the checkpoint's rolling-hash recomputation in completeCheckpoint.
1395
+ const l1ToL2Messages = await this.deriveCheckpointConsumedMessages(blocks);
1214
1396
 
1215
1397
  // Collect the out hashes of all the checkpoints before this one in the same epoch.
1216
1398
  // See note on the analogous block-proposal site: the helper handles pipelining lag.
@@ -1224,6 +1406,12 @@ export class ProposalHandler {
1224
1406
  log: this.log,
1225
1407
  });
1226
1408
 
1409
+ const previousInboxRollingHash = await getPreviousCheckpointInboxRollingHash({
1410
+ blockSource: this.blockSource,
1411
+ checkpointNumber,
1412
+ log: this.log,
1413
+ });
1414
+
1227
1415
  // Fork world state at the block before the first block. getFork syncs world state to the parent block
1228
1416
  // first (see its doc): the block source (archiver) can already hold the block while world state still
1229
1417
  // trails it by one, and forking a not-yet-applied block throws a raw tree error that would otherwise
@@ -1267,6 +1455,7 @@ export class ProposalHandler {
1267
1455
  proposal.feeAssetPriceModifier,
1268
1456
  l1ToL2Messages,
1269
1457
  previousCheckpointOutHashes,
1458
+ previousInboxRollingHash,
1270
1459
  fork,
1271
1460
  blocks,
1272
1461
  this.log.getBindings(),
@@ -0,0 +1,198 @@
1
+ import type { Fr } from '@aztec/foundation/curves/bn254';
2
+ import type { InboxBucket, InboxBucketRef, L1ToL2MessageSource } from '@aztec/stdlib/messaging';
3
+
4
+ /**
5
+ * Reason a streaming-Inbox block proposal fails the per-block acceptance checks. Follows the
6
+ * handler's existing `{ isValid, reason }` string style.
7
+ */
8
+ export type StreamingBlockCheckReason =
9
+ | 'bucket_unknown'
10
+ | 'bucket_hash_mismatch'
11
+ | 'parent_bucket_unresolved'
12
+ | 'bucket_moves_backwards'
13
+ | 'bucket_too_new'
14
+ | 'bundle_over_block_cap'
15
+ | 'checkpoint_over_msg_cap';
16
+
17
+ /** The subset of the archiver's Inbox-bucket queries the per-block streaming checks need. */
18
+ export type StreamingInboxBucketSource = Pick<
19
+ L1ToL2MessageSource,
20
+ 'getInboxBucket' | 'getInboxBucketByTotalMsgCount' | 'getL1ToL2MessagesBetweenBuckets'
21
+ >;
22
+
23
+ /** Inputs to the per-block streaming Inbox metadata checks. */
24
+ export type StreamingBlockMetadataCheckInput = {
25
+ /** Archiver Inbox-bucket queries (resolved against this node's own Inbox view). */
26
+ messageSource: Pick<StreamingInboxBucketSource, 'getInboxBucket' | 'getInboxBucketByTotalMsgCount'>;
27
+ /** The proposal's bucket reference: `bucketSeq` is the lookup hint, `inboxRollingHash` the expected commitment. */
28
+ bucketRef: InboxBucketRef | undefined;
29
+ /** Cumulative Inbox message count consumed through the parent block (its L1-to-L2 tree leaf count; 0 at genesis). */
30
+ parentTotalMsgCount: bigint;
31
+ /** Cumulative Inbox message count consumed as of the parent checkpoint; the per-checkpoint cap origin. */
32
+ checkpointStartTotalMsgCount: bigint;
33
+ /** Validation-time wall clock in seconds; the lag-eligibility anchor. */
34
+ nowSeconds: bigint;
35
+ /**
36
+ * Minimum bucket age in seconds for a bucket to be lag-eligible: one configured Ethereum slot, the same value the
37
+ * sequencer's selection uses.
38
+ *
39
+ * Age in seconds is a proxy for L1 reorg depth, and only an exact one when no L1 slot is missed: with missed slots a
40
+ * bucket can be a full Ethereum slot old and still sit in the latest L1 block. A block-depth rule would be
41
+ * stronger — require at least one later L1 block to have synced — and is implementable without new L1 state, since
42
+ * the archiver records each bucket's `l1BlockNumber`.
43
+ */
44
+ minBucketAgeSeconds: number;
45
+ /** Maximum number of messages this block may consume (`MAX_L1_TO_L2_MSGS_PER_BLOCK`). */
46
+ perBlockCap: number;
47
+ /** Maximum number of messages the checkpoint may consume in total (`MAX_L1_TO_L2_MSGS_PER_CHECKPOINT`). */
48
+ perCheckpointCap: number;
49
+ };
50
+
51
+ /** Inputs to the full per-block streaming Inbox acceptance checks, metadata plus bundle derivation. */
52
+ export type StreamingBlockCheckInput = Omit<StreamingBlockMetadataCheckInput, 'messageSource'> & {
53
+ messageSource: StreamingInboxBucketSource;
54
+ };
55
+
56
+ /** The buckets a passing block proposal consumes between; the input to bundle derivation. */
57
+ export type StreamingBlockBucketRange = {
58
+ /** The bucket the block consumes through, resolved from this node's own Inbox view. */
59
+ bucket: InboxBucket;
60
+ /** The bucket the parent block consumed through. Equal to `bucket` when the block consumes nothing. */
61
+ parentBucket: InboxBucket;
62
+ };
63
+
64
+ /** Result of the per-block streaming Inbox metadata checks. */
65
+ export type StreamingBlockMetadataCheckResult =
66
+ | ({
67
+ /** Every metadata check passed; the block's bundle can be derived from the returned bucket range. */
68
+ accepted: true;
69
+ } & StreamingBlockBucketRange)
70
+ | {
71
+ /** A check failed; `reason` mirrors the L1 acceptance condition that would have rejected the proposal. */
72
+ accepted: false;
73
+ reason: StreamingBlockCheckReason;
74
+ };
75
+
76
+ /** Result of the per-block streaming Inbox acceptance checks. */
77
+ export type StreamingBlockCheckResult =
78
+ | {
79
+ /** All checks passed; `bundle` is the message-leaf bundle this block consumes, for re-execution. */
80
+ accepted: true;
81
+ bundle: Fr[];
82
+ }
83
+ | {
84
+ /** A check failed; `reason` mirrors the L1 acceptance condition that would have rejected the proposal. */
85
+ accepted: false;
86
+ reason: StreamingBlockCheckReason;
87
+ };
88
+
89
+ /**
90
+ * Runs the metadata half of the per-block acceptance checks a validator applies to a streaming block proposal:
91
+ * every check that is a bounded number of point lookups against the local Inbox view, with no message data read.
92
+ * Mirrors the L1 acceptance conditions:
93
+ *
94
+ * 1. **Exists**: the referenced bucket resolves in this node's own Inbox view, and its consensus rolling hash matches
95
+ * the reference. An unknown bucket is an immediate reject here (there is no bounded wait yet); a hash
96
+ * mismatch means the wire reference disagrees with the local bucket. The reference is trusted only as a `bucketSeq`
97
+ * lookup hint — timestamp and message counts are read from the locally resolved bucket, never from the wire.
98
+ * 2. **Moves forward**: the bucket's cumulative total is at least the parent block's, so consumption never rewinds.
99
+ * Equal totals mean the block consumes nothing (empty bundle).
100
+ * 3. **Not too new**: the bucket is at least `minBucketAgeSeconds` old at validation time
101
+ * (`timestamp <= now - minBucketAgeSeconds`, inclusive — a bucket exactly that old is eligible, matching L1's
102
+ * strict `>` "too new" test).
103
+ * 4. **Caps**: the per-block message count and the running per-checkpoint total fit their respective caps.
104
+ * 5. **Parent boundary**: the parent block's cumulative total sits on a bucket boundary, so the consumed range is
105
+ * well defined.
106
+ *
107
+ * Because this phase is cheap and needs nothing off the network, a caller can run it before committing to any
108
+ * expensive work on a proposal — notably before collecting the proposal's transactions over P2P.
109
+ *
110
+ * The reject branch is a single function so a future bounded wait can wrap `bucket_unknown`.
111
+ */
112
+ export async function checkStreamingBlockProposalMetadata(
113
+ input: StreamingBlockMetadataCheckInput,
114
+ ): Promise<StreamingBlockMetadataCheckResult> {
115
+ const {
116
+ messageSource,
117
+ bucketRef,
118
+ parentTotalMsgCount,
119
+ checkpointStartTotalMsgCount,
120
+ nowSeconds,
121
+ minBucketAgeSeconds,
122
+ perBlockCap,
123
+ perCheckpointCap,
124
+ } = input;
125
+
126
+ // A streaming proposal must carry a bucket reference to derive its bundle from.
127
+ if (bucketRef === undefined) {
128
+ return { accepted: false, reason: 'bucket_unknown' };
129
+ }
130
+
131
+ // Check 1: exists in our own Inbox view, and the resolved rolling hash matches the reference.
132
+ const bucket = await messageSource.getInboxBucket(bucketRef.bucketSeq);
133
+ if (bucket === undefined) {
134
+ return { accepted: false, reason: 'bucket_unknown' };
135
+ }
136
+ if (!bucket.inboxRollingHash.equals(bucketRef.inboxRollingHash)) {
137
+ return { accepted: false, reason: 'bucket_hash_mismatch' };
138
+ }
139
+
140
+ // Check 2: consumption moves forward relative to the parent block.
141
+ if (bucket.totalMsgCount < parentTotalMsgCount) {
142
+ return { accepted: false, reason: 'bucket_moves_backwards' };
143
+ }
144
+
145
+ // Check 3: the bucket is at least `minBucketAgeSeconds` old at validation time.
146
+ if (bucket.timestamp > nowSeconds - BigInt(minBucketAgeSeconds)) {
147
+ return { accepted: false, reason: 'bucket_too_new' };
148
+ }
149
+
150
+ // Check 4a: the per-block message count fits the per-block cap.
151
+ const blockCount = bucket.totalMsgCount - parentTotalMsgCount;
152
+ if (blockCount > BigInt(perBlockCap)) {
153
+ return { accepted: false, reason: 'bundle_over_block_cap' };
154
+ }
155
+
156
+ // Check 4b: the running per-checkpoint total fits the per-checkpoint cap.
157
+ const checkpointCount = bucket.totalMsgCount - checkpointStartTotalMsgCount;
158
+ if (checkpointCount > BigInt(perCheckpointCap)) {
159
+ return { accepted: false, reason: 'checkpoint_over_msg_cap' };
160
+ }
161
+
162
+ // Check 5: the parent bucket is the one whose cumulative total equals the parent block's leaf count (messages are
163
+ // indexed compactly, with no padding); a parent whose count does not sit on a bucket boundary is unresolvable.
164
+ const parentBucket = await messageSource.getInboxBucketByTotalMsgCount(parentTotalMsgCount);
165
+ if (parentBucket === undefined) {
166
+ return { accepted: false, reason: 'parent_bucket_unresolved' };
167
+ }
168
+
169
+ return { accepted: true, bucket, parentBucket };
170
+ }
171
+
172
+ /**
173
+ * Derives the message-leaf bundle a streaming block proposal consumes (for re-execution): the leaves between the
174
+ * parent bucket and the proposed one, bounded by the per-block cap that
175
+ * {@link checkStreamingBlockProposalMetadata} already enforced.
176
+ */
177
+ export function getStreamingBlockBundle(
178
+ messageSource: Pick<StreamingInboxBucketSource, 'getL1ToL2MessagesBetweenBuckets'>,
179
+ range: StreamingBlockBucketRange,
180
+ ): Promise<Fr[]> {
181
+ const { bucket, parentBucket } = range;
182
+ return parentBucket.seq === bucket.seq
183
+ ? Promise.resolve([])
184
+ : messageSource.getL1ToL2MessagesBetweenBuckets(parentBucket.seq, bucket.seq);
185
+ }
186
+
187
+ /**
188
+ * Runs the per-block acceptance checks a validator applies to a streaming block proposal, and derives the
189
+ * message-leaf bundle the block consumes. Composes {@link checkStreamingBlockProposalMetadata} with
190
+ * {@link getStreamingBlockBundle} for callers that have no use for running the two phases separately.
191
+ */
192
+ export async function checkStreamingBlockProposal(input: StreamingBlockCheckInput): Promise<StreamingBlockCheckResult> {
193
+ const metadata = await checkStreamingBlockProposalMetadata(input);
194
+ if (!metadata.accepted) {
195
+ return metadata;
196
+ }
197
+ return { accepted: true, bundle: await getStreamingBlockBundle(input.messageSource, metadata) };
198
+ }