Skip to content

Commit 896df6e

Browse files
committed
feat(fast-inbox): streaming inbox propose validation, unwired (A-1378)
Stacked on #24771 (A-1377). AZIP-22 Fast Inbox, FI-08. ## What Implements the AZIP's censorship assert as `ProposeLib.validateInboxConsumption` — a new library function that is **not yet called**: today's `inbox.consume()` / `inHash` check in `propose` is untouched, so this is dead code until the flip wires it in. Given the checkpoint header's `inboxRollingHash`, an unsigned calldata bucket hint, the proposed slot, and the parent checkpoint's cumulative consumed total (which the flip will source from the temp checkpoint log), the function enforces: 1. **End anchoring**: the header's rolling hash must equal the snapshot in `inbox.getBucket(hint)` (`Rollup__InvalidInboxRollingHash`). The hint is a lookup aid only — a wrong hint reverts, it cannot change what gets accepted — and `getBucket` itself rejects hints beyond the current bucket or already overwritten in the ring. A checkpoint consuming nothing references the same bucket as its parent (the genesis bucket for the first checkpoint), so there is no base case. 2. **Mandatory consumption**: the first unconsumed bucket (`hint + 1`) must be absent, **past the cutoff**, or **cap-escaped** (`Rollup__UnconsumedInboxMessages`). The cutoff is the build-frame start minus `INBOX_LAG_SECONDS`: a checkpoint proposed in slot S is built during slot S-1 (proposer pipelining), and validators are not required to act on buckets younger than one L1 slot at build start, so `cutoff = toTimestamp(S-1) - 12`. Cap escape allows stopping when consuming through the next bucket would exceed `MAX_L1_TO_L2_MSGS_PER_CHECKPOINT` (1024) messages since the parent's total. Both comparisons are exact-boundary tested. The function performs **no Inbox write**. It is a pure `view` that **returns** the consumed cumulative total (`bucket.totalMsgCount`). The flip (FI-14) stores that consumed position as part of the per-checkpoint temp-log record, extended to `{inboxRollingHash, inboxMsgTotal, inboxConsumedBucket}` and written by `propose`; that record is the authoritative consumed position and is prune-consistent, since temp logs rewind with the pending chain. Two new checks guard the returned value: consumption must **move forward** (`Rollup__InboxConsumptionBehindParent`, equal allowed — a proposal cannot consume behind its parent, and the check precedes the delta subtractions so a backwards proposal reverts descriptively rather than underflowing), and the consumed delta cannot exceed `MAX_L1_TO_L2_MSGS_PER_CHECKPOINT` in one checkpoint (`Rollup__TooManyInboxMessagesConsumed`). The Inbox-side proven-consumed cache that ring overwrite protection needs moved to **FI-20**, anchored to the proven tip rather than the pending chain (decided 2026-07-17): a pointer advanced with the pending chain is not prune-safe, since after a prune it would sit ahead of what the replacement chain consumed. `INBOX_LAG_SECONDS = 12` and `MAX_L1_TO_L2_MSGS_PER_CHECKPOINT = 1024` are defined at file level in `ProposeLib` for now; they mirror the protocol constants and should move into the generated `Constants` library once the Solidity emitter allow-list includes them. ## Testing New `ProposeInboxConsumption.t.sol` (15 tests) drives the library function through a harness that owns the Inbox and TimeLib storage, covering the roadmap done-when plus boundaries: - **empty Inbox** (hint 0 / hash 0 passes and returns 0; non-zero hash rejected), - **exact-cutoff bucket** (timestamp == cutoff must be consumed; cutoff + 1 need not), - **cap escape** (1025 messages spill into buckets of 256+1: stopping at bucket 4 = exactly 1024 escapes and returns the full cap; stopping at bucket 3 = 1024-through-next does not; parent total shifts the arithmetic and kills the escape), - **cap upper bound** (consuming more than 1024 in one checkpoint from a fresh parent reverts `Rollup__TooManyInboxMessagesConsumed`), - **moves forward** (a proposal whose referenced bucket total sits behind the parent reverts `Rollup__InboxConsumptionBehindParent`; an equal reference consumes nothing and returns the unchanged total), - **stale hash** (previous bucket's hash against the current bucket) and **unknown hash** rejection, plus out-of-window hints (future bucket, ring-overwritten bucket), - **return value**: the successful paths assert the consumed cumulative total the flip will record (0, 3, cap). The three former consumed-pointer tests are removed along with the pointer. Full `forge test` at the current rebased tip: 890 passed, 0 failed, 3 skipped (the previously noted `testGetEpochProofPublicInputsVerifiesHeaders` failure inherited from the base has since been fixed). Replaces #24773.
1 parent 1c18728 commit 896df6e

8 files changed

Lines changed: 413 additions & 21 deletions

File tree

.test_patterns.yml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -378,16 +378,6 @@ tests:
378378
owners:
379379
- *alex
380380

381-
# multi_proof runs three prover nodes; with fake proofs the orchestrator still does circuit witness
382-
# generation, and the per-block-bundle change from this point makes constrained parity (in_hash) hashing
383-
# heavier. Under CI's 2-CPU limit that extra witgen starves the sequencer's L1 publish past its tx timeout,
384-
# so the test is transitionally slow here. It passes on adequate hardware, and a later stack change that
385-
# turns the in_hash parity into an unconstrained hint restores the margin — this skip is removed there.
386-
- regex: "yarn-project/end-to-end/scripts/run_test.sh simple src/single-node/proving/multi_proof.test.ts"
387-
skip: true
388-
owners:
389-
- *palla
390-
391381
- regex: "yarn-project/end-to-end/scripts/run_test.sh compose src/composed/docs_examples.test.ts"
392382
error_regex: "maxFeesPerGas.feePerL2Gas must be greater than or equal to gasFees.feePerL2Gas,"
393383
owners:

l1-contracts/src/core/interfaces/messagebridge/IInbox.sol

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ pragma solidity >=0.8.27;
44

55
import {DataStructures} from "../../libraries/DataStructures.sol";
66

7+
// Maximum number of messages a single bucket can hold before further messages in the same L1 block spill over
8+
// into the next bucket. Matches the number of L1 to L2 messages a single L2 block can insert once the streaming
9+
// inbox is live, so any one bucket is always consumable by one block.
10+
uint256 constant MAX_MSGS_PER_BUCKET = 256;
11+
712
/**
813
* @title Inbox
914
* @author Aztec Labs
@@ -28,7 +33,7 @@ interface IInbox {
2833
* fixed-size ring indexed by a dense bucket sequence number (`seq % ringSize`). A bucket only accumulates
2934
* messages sent within a single L1 block, so its final state is the chain position as of the end of that
3035
* block; the censorship check at `propose` compares the checkpoint header's rolling hash against these
31-
* snapshots (AZIP-22 Fast Inbox).
36+
* snapshots.
3237
*/
3338
struct InboxBucket {
3439
// Rolling hash after the last message absorbed into this bucket. Each link is

l1-contracts/src/core/libraries/Errors.sol

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ library Errors {
5959
error Rollup__InvalidCheckpointHeaderCount(uint256 expected, uint256 actual);
6060
error Rollup__InvalidCheckpointNumber(uint256 expected, uint256 actual); // 0xd1ba9bfa
6161
error Rollup__InvalidInHash(bytes32 expected, bytes32 actual); // 0xcd6f4233
62+
error Rollup__InvalidInboxRollingHash(bytes32 expected, bytes32 actual); // 0xed1f7bb5
63+
error Rollup__InboxBucketStillMutable(uint256 bucketSeq); // 0x7254b980
64+
error Rollup__UnconsumedInboxMessages(uint256 nextBucketSeq); // 0x2bd4bf10
65+
error Rollup__InboxConsumptionBehindParent(uint256 expected, uint256 actual); // 0x54e0c025
66+
error Rollup__TooManyInboxMessagesConsumed(uint256 consumed); // 0xf76d1426
6267
error Rollup__InvalidOutHash(bytes32 expected, bytes32 actual); // 0x8eb39062
6368
error Rollup__InvalidPreviousArchive(bytes32 expected, bytes32 actual); // 0xb682a40e
6469
error Rollup__InvalidProof(); // 0xa5b2ba17

l1-contracts/src/core/libraries/crypto/Hash.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ library Hash {
5353
/**
5454
* @notice Advances the Inbox consensus rolling hash by one message leaf
5555
* @dev Truncated at every link so the value is always a field element; the rollup circuits recompute the
56-
* identical chain over the message leaves they insert (AZIP-22 Fast Inbox). The genesis value is zero.
56+
* identical chain over the message leaves they insert. The genesis value is zero.
5757
* @param _rollingHash - The current rolling hash
5858
* @param _leaf - The message leaf to absorb
5959
* @return The updated rolling hash

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pragma solidity >=0.8.27;
55
import {BlobLib} from "@aztec-blob-lib/BlobLib.sol";
66
import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol";
77
import {RollupStore, IRollupCore, CheckpointHeaderValidationFlags} from "@aztec/core/interfaces/IRollup.sol";
8+
import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
89
import {TempCheckpointLog} from "@aztec/core/libraries/compressed-data/CheckpointLog.sol";
910
import {FeeHeader} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol";
1011
import {ChainTipsLib, CompressedChainTips} from "@aztec/core/libraries/compressed-data/Tips.sol";
@@ -20,6 +21,14 @@ import {Signature} from "@aztec/shared/libraries/SignatureLib.sol";
2021
import {ProposedHeader, ProposedHeaderLib} from "./ProposedHeaderLib.sol";
2122
import {STFLib} from "./STFLib.sol";
2223

24+
// Streaming-inbox protocol constants (AZIP-22 Fast Inbox). These mirror the protocol circuit constants and
25+
// should move into the generated Constants library once the Solidity emitter includes them.
26+
// Minimum bucket age, in seconds, at the start of a checkpoint's build frame for its consumption to be
27+
// mandatory. One L1 slot: validators cannot be required to act on buckets they may not have seen.
28+
uint256 constant INBOX_LAG_SECONDS = 12;
29+
// Maximum number of L1 to L2 messages a single checkpoint can insert.
30+
uint256 constant MAX_L1_TO_L2_MSGS_PER_CHECKPOINT = 1024;
31+
2332
struct ProposeArgs {
2433
bytes32 archive;
2534
OracleInput oracleInput;
@@ -370,6 +379,91 @@ library ProposeLib {
370379
);
371380
}
372381

382+
/**
383+
* @notice Validates a checkpoint's Inbox consumption against the streaming inbox buckets and returns how
384+
* far consumption has reached. Not yet called from propose(): the legacy `consume()`/`inHash` flow
385+
* remains the enforced path until the streaming inbox (AZIP-22 Fast Inbox) flips on.
386+
*
387+
* @dev Read-only; performs no Inbox write. Checks, in order:
388+
* 1. The checkpoint header's `inboxRollingHash` must equal the rolling hash snapshotted in the Inbox
389+
* bucket referenced by `_bucketHint`. The hint is a plain calldata lookup aid, not signed and not
390+
* part of the header: a wrong hint cannot change what gets accepted, it only reverts. A checkpoint
391+
* that consumes no messages references the same bucket as its parent.
392+
* 2. The referenced bucket must be settled: a bucket that can still absorb another message is not a
393+
* snapshot of anything.
394+
* 3. Consumption moves forward: the referenced bucket's cumulative total must be at least the parent
395+
* checkpoint's (equal consumes nothing; behind is a hard revert). This precedes the subtractions
396+
* below, which rely on `bucket.totalMsgCount >= _parentTotalMsgCount` to not underflow.
397+
* 4. Cap upper bound: a single checkpoint cannot consume more than MAX_L1_TO_L2_MSGS_PER_CHECKPOINT
398+
* messages, the maximum the circuits can insert.
399+
* 5. Mandatory consumption (the censorship assert): the first unconsumed bucket (`_bucketHint + 1`)
400+
* must either not exist, sit past the consumption cutoff, or be cap-escaped — consuming through it
401+
* would exceed MAX_L1_TO_L2_MSGS_PER_CHECKPOINT messages since the parent checkpoint's cumulative
402+
* total. The cutoff is the start of the checkpoint's build frame minus INBOX_LAG_SECONDS: a
403+
* checkpoint proposed in slot S is built during slot S-1, and validators are not required to have
404+
* seen buckets younger than one L1 slot at build start.
405+
*
406+
* No consumed-bucket pointer is written here. The caller (FI-14) stores the returned consumed
407+
* position in the checkpoint's temp-log record, which is the authoritative consumed total: temp logs
408+
* rewind with the pending chain on a prune, so the record stays prune-consistent — unlike an
409+
* Inbox-side pointer advanced with the pending chain, which would sit ahead of the replacement chain.
410+
*
411+
* @param _inbox - The Inbox holding the rolling-hash buckets
412+
* @param _inboxRollingHash - The checkpoint header's inbox rolling hash
413+
* @param _bucketHint - Sequence number of the bucket the header's rolling hash corresponds to
414+
* @param _slotNumber - The slot the checkpoint is proposed in
415+
* @param _parentTotalMsgCount - Cumulative Inbox message count consumed as of the parent checkpoint
416+
* @return The cumulative Inbox message count consumed as of this checkpoint (`bucket.totalMsgCount`), for
417+
* the caller to store in the checkpoint's temp-log record
418+
*/
419+
function validateInboxConsumption(
420+
IInbox _inbox,
421+
bytes32 _inboxRollingHash,
422+
uint256 _bucketHint,
423+
Slot _slotNumber,
424+
uint256 _parentTotalMsgCount
425+
) internal view returns (uint256) {
426+
IInbox.InboxBucket memory bucket = _inbox.getBucket(_bucketHint);
427+
require(
428+
bucket.rollingHash == _inboxRollingHash,
429+
Errors.Rollup__InvalidInboxRollingHash(bucket.rollingHash, _inboxRollingHash)
430+
);
431+
432+
// A bucket that can still absorb a message mutates in place: a proposer bundling a send after its own propose
433+
// in one L1 transaction would leave the checkpoint committed to a rolling hash that exists neither on L1 nor
434+
// in any node, which only ever observes a bucket's end-of-block state, and no honest node could then resolve
435+
// the consumed position. Settled is the negation of the Inbox's rollover condition: the genesis bucket never
436+
// absorbs, a bucket whose L1 block has passed cannot be reopened, and a full bucket spills the next message
437+
// into a new one.
438+
require(
439+
_bucketHint == 0 || bucket.timestamp < block.timestamp || bucket.msgCount == MAX_MSGS_PER_BUCKET,
440+
Errors.Rollup__InboxBucketStillMutable(_bucketHint)
441+
);
442+
443+
require(
444+
bucket.totalMsgCount >= _parentTotalMsgCount,
445+
Errors.Rollup__InboxConsumptionBehindParent(_parentTotalMsgCount, bucket.totalMsgCount)
446+
);
447+
448+
require(
449+
bucket.totalMsgCount - _parentTotalMsgCount <= MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
450+
Errors.Rollup__TooManyInboxMessagesConsumed(bucket.totalMsgCount - _parentTotalMsgCount)
451+
);
452+
453+
if (_bucketHint < _inbox.getCurrentBucketSeq()) {
454+
IInbox.InboxBucket memory next = _inbox.getBucket(_bucketHint + 1);
455+
Timestamp buildFrameStart = TimeLib.toTimestamp(_slotNumber - Slot.wrap(1));
456+
Timestamp cutoff = buildFrameStart - Timestamp.wrap(INBOX_LAG_SECONDS);
457+
require(
458+
next.timestamp > Timestamp.unwrap(cutoff)
459+
|| next.totalMsgCount - _parentTotalMsgCount > MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
460+
Errors.Rollup__UnconsumedInboxMessages(_bucketHint + 1)
461+
);
462+
}
463+
464+
return bucket.totalMsgCount;
465+
}
466+
373467
/**
374468
* @notice Gets the mana min fee components
375469
* For more context, consult:

l1-contracts/src/core/messagebridge/Inbox.sol

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
pragma solidity >=0.8.27;
44

55
import {IRollup} from "@aztec/core/interfaces/IRollup.sol";
6-
import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
6+
import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
77
import {Constants} from "@aztec/core/libraries/ConstantsGen.sol";
88
import {FrontierLib} from "@aztec/core/libraries/crypto/FrontierLib.sol";
99
import {Hash} from "@aztec/core/libraries/crypto/Hash.sol";
@@ -34,11 +34,6 @@ contract Inbox is IInbox {
3434
using FrontierLib for FrontierLib.Forest;
3535
using FrontierLib for FrontierLib.Tree;
3636

37-
// Maximum number of messages a single bucket can hold before further messages in the same L1 block
38-
// spill over into the next bucket. Matches the number of L1 to L2 messages a single L2 block can
39-
// insert once the streaming inbox is live, so any one bucket is always consumable by one block.
40-
uint256 public constant MAX_MSGS_PER_BUCKET = 256;
41-
4237
address public immutable ROLLUP;
4338
uint256 public immutable VERSION;
4439
address public immutable FEE_ASSET_PORTAL;

l1-contracts/test/InboxBuckets.t.sol

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ pragma solidity >=0.8.27;
55
import {Test} from "forge-std/Test.sol";
66
import {TestERC20} from "src/mock/TestERC20.sol";
77
import {IERC20} from "@oz/token/ERC20/IERC20.sol";
8-
import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
8+
import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
99
import {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol";
1010
import {InboxHarness} from "./harnesses/InboxHarness.sol";
1111
import {TestConstants} from "./harnesses/TestConstants.sol";
@@ -172,7 +172,7 @@ contract InboxBucketsTest is Test {
172172
}
173173

174174
function testRolloverIntoNextBucket() public {
175-
uint256 cap = inbox.MAX_MSGS_PER_BUCKET();
175+
uint256 cap = MAX_MSGS_PER_BUCKET;
176176
for (uint256 i = 0; i < cap; i++) {
177177
_send(inbox, i);
178178
}
@@ -266,7 +266,7 @@ contract InboxBucketsTest is Test {
266266
// Gas cost of a rollover opening mid-block: once a bucket reaches MAX_MSGS_PER_BUCKET, the next
267267
// message in the same L1 block opens a new bucket even though the timestamp is unchanged.
268268
function testGasSendRolloverMidBlock() public {
269-
uint256 cap = inbox.MAX_MSGS_PER_BUCKET();
269+
uint256 cap = MAX_MSGS_PER_BUCKET;
270270
for (uint256 i = 0; i < cap; i++) {
271271
_send(inbox, i);
272272
}

0 commit comments

Comments
 (0)