From 3a2047d80f2f3d13130be4ed45e65d6d0837d36b Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 17 Jul 2026 15:22:05 -0300 Subject: [PATCH 1/7] feat: streaming inbox propose validation, unwired --- l1-contracts/src/core/libraries/Errors.sol | 4 + .../src/core/libraries/rollup/ProposeLib.sol | 81 ++++++ .../test/rollup/ProposeInboxConsumption.t.sol | 267 ++++++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 l1-contracts/test/rollup/ProposeInboxConsumption.t.sol diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 46dccf4a7788..98ca02144041 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -59,6 +59,10 @@ library Errors { error Rollup__InvalidCheckpointHeaderCount(uint256 expected, uint256 actual); error Rollup__InvalidCheckpointNumber(uint256 expected, uint256 actual); // 0xd1ba9bfa error Rollup__InvalidInHash(bytes32 expected, bytes32 actual); // 0xcd6f4233 + error Rollup__InvalidInboxRollingHash(bytes32 expected, bytes32 actual); // 0xed1f7bb5 + error Rollup__UnconsumedInboxMessages(uint256 nextBucketSeq); // 0x2bd4bf10 + error Rollup__InboxConsumptionBehindParent(uint256 expected, uint256 actual); // 0x54e0c025 + error Rollup__TooManyInboxMessagesConsumed(uint256 consumed); // 0xf76d1426 error Rollup__InvalidOutHash(bytes32 expected, bytes32 actual); // 0x8eb39062 error Rollup__InvalidPreviousArchive(bytes32 expected, bytes32 actual); // 0xb682a40e error Rollup__InvalidProof(); // 0xa5b2ba17 diff --git a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol index e4807e5bcc71..6d78992f02e6 100644 --- a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.27; import {BlobLib} from "@aztec-blob-lib/BlobLib.sol"; import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; +import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; import {RollupStore, IRollupCore, CheckpointHeaderValidationFlags} from "@aztec/core/interfaces/IRollup.sol"; import {TempCheckpointLog} from "@aztec/core/libraries/compressed-data/CheckpointLog.sol"; import {FeeHeader} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol"; @@ -20,6 +21,14 @@ import {Signature} from "@aztec/shared/libraries/SignatureLib.sol"; import {ProposedHeader, ProposedHeaderLib} from "./ProposedHeaderLib.sol"; import {STFLib} from "./STFLib.sol"; +// Streaming-inbox protocol constants (AZIP-22 Fast Inbox). These mirror the protocol circuit constants and +// should move into the generated Constants library once the Solidity emitter includes them. +// Minimum bucket age, in seconds, at the start of a checkpoint's build frame for its consumption to be +// mandatory. One L1 slot: validators cannot be required to act on buckets they may not have seen. +uint256 constant INBOX_LAG_SECONDS = 12; +// Maximum number of L1 to L2 messages a single checkpoint can insert. +uint256 constant MAX_L1_TO_L2_MSGS_PER_CHECKPOINT = 1024; + struct ProposeArgs { bytes32 archive; OracleInput oracleInput; @@ -370,6 +379,78 @@ library ProposeLib { ); } + /** + * @notice Validates a checkpoint's Inbox consumption against the streaming inbox buckets and returns how + * far consumption has reached. Not yet called from propose(): the legacy `consume()`/`inHash` flow + * remains the enforced path until the streaming inbox (AZIP-22 Fast Inbox) flips on. + * + * @dev Read-only; performs no Inbox write. Checks, in order: + * 1. The checkpoint header's `inboxRollingHash` must equal the rolling hash snapshotted in the Inbox + * bucket referenced by `_bucketHint`. The hint is a plain calldata lookup aid, not signed and not + * part of the header: a wrong hint cannot change what gets accepted, it only reverts. A checkpoint + * that consumes no messages references the same bucket as its parent. + * 2. Consumption moves forward: the referenced bucket's cumulative total must be at least the parent + * checkpoint's (equal consumes nothing; behind is a hard revert). This precedes the subtractions + * below, which rely on `bucket.totalMsgCount >= _parentTotalMsgCount` to not underflow. + * 3. Cap upper bound: a single checkpoint cannot consume more than MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + * messages, the maximum the circuits can insert. + * 4. Mandatory consumption (the censorship assert): the first unconsumed bucket (`_bucketHint + 1`) + * must either not exist, sit past the consumption cutoff, or be cap-escaped — consuming through it + * would exceed MAX_L1_TO_L2_MSGS_PER_CHECKPOINT messages since the parent checkpoint's cumulative + * total. The cutoff is the start of the checkpoint's build frame minus INBOX_LAG_SECONDS: a + * checkpoint proposed in slot S is built during slot S-1, and validators are not required to have + * seen buckets younger than one L1 slot at build start. + * + * No consumed-bucket pointer is written here. The caller (FI-14) stores the returned consumed + * position in the checkpoint's temp-log record, which is the authoritative consumed total: temp logs + * rewind with the pending chain on a prune, so the record stays prune-consistent — unlike an + * Inbox-side pointer advanced with the pending chain, which would sit ahead of the replacement chain. + * + * @param _inbox - The Inbox holding the rolling-hash buckets + * @param _inboxRollingHash - The checkpoint header's inbox rolling hash + * @param _bucketHint - Sequence number of the bucket the header's rolling hash corresponds to + * @param _slotNumber - The slot the checkpoint is proposed in + * @param _parentTotalMsgCount - Cumulative Inbox message count consumed as of the parent checkpoint + * @return The cumulative Inbox message count consumed as of this checkpoint (`bucket.totalMsgCount`), for + * the caller to store in the checkpoint's temp-log record + */ + function validateInboxConsumption( + IInbox _inbox, + bytes32 _inboxRollingHash, + uint256 _bucketHint, + Slot _slotNumber, + uint256 _parentTotalMsgCount + ) internal view returns (uint256) { + IInbox.InboxBucket memory bucket = _inbox.getBucket(_bucketHint); + require( + bucket.rollingHash == _inboxRollingHash, + Errors.Rollup__InvalidInboxRollingHash(bucket.rollingHash, _inboxRollingHash) + ); + + require( + bucket.totalMsgCount >= _parentTotalMsgCount, + Errors.Rollup__InboxConsumptionBehindParent(_parentTotalMsgCount, bucket.totalMsgCount) + ); + + require( + bucket.totalMsgCount - _parentTotalMsgCount <= MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, + Errors.Rollup__TooManyInboxMessagesConsumed(bucket.totalMsgCount - _parentTotalMsgCount) + ); + + if (_bucketHint < _inbox.getCurrentBucketSeq()) { + IInbox.InboxBucket memory next = _inbox.getBucket(_bucketHint + 1); + Timestamp buildFrameStart = TimeLib.toTimestamp(_slotNumber - Slot.wrap(1)); + Timestamp cutoff = buildFrameStart - Timestamp.wrap(INBOX_LAG_SECONDS); + require( + next.timestamp > Timestamp.unwrap(cutoff) + || next.totalMsgCount - _parentTotalMsgCount > MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, + Errors.Rollup__UnconsumedInboxMessages(_bucketHint + 1) + ); + } + + return bucket.totalMsgCount; + } + /** * @notice Gets the mana min fee components * For more context, consult: diff --git a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol new file mode 100644 index 000000000000..ccf9a61d337e --- /dev/null +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {TestERC20} from "src/mock/TestERC20.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import { + ProposeLib, + INBOX_LAG_SECONDS, + MAX_L1_TO_L2_MSGS_PER_CHECKPOINT +} from "@aztec/core/libraries/rollup/ProposeLib.sol"; +import {TimeLib, Slot, Timestamp} from "@aztec/core/libraries/TimeLib.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; +import {InboxHarness} from "../harnesses/InboxHarness.sol"; +import {TestConstants} from "../harnesses/TestConstants.sol"; + +contract ProposeLibHarness { + constructor(uint256 _genesisTime, uint256 _slotDuration, uint256 _epochDuration) { + TimeLib.initialize(_genesisTime, _slotDuration, _epochDuration, 1); + } + + function validateInboxConsumption( + IInbox _inbox, + bytes32 _inboxRollingHash, + uint256 _bucketHint, + Slot _slotNumber, + uint256 _parentTotalMsgCount + ) external view returns (uint256) { + return ProposeLib.validateInboxConsumption( + _inbox, _inboxRollingHash, _bucketHint, _slotNumber, _parentTotalMsgCount + ); + } +} + +contract ProposeInboxConsumptionTest is Test { + uint256 internal constant GENESIS_TIME = 100_000; + uint256 internal constant SLOT_DURATION = 36; + uint256 internal constant EPOCH_DURATION = 32; + uint256 internal constant HEIGHT = 10; + uint256 internal constant SMALL_RING_SIZE = 4; + + Slot internal constant SLOT = Slot.wrap(10); + + ProposeLibHarness internal rollup; + InboxHarness internal inbox; + uint256 internal version = 0; + + // Start of the build frame for a checkpoint proposed in SLOT: it is built during the previous slot. + uint256 internal buildFrameStart = GENESIS_TIME + (Slot.unwrap(SLOT) - 1) * SLOT_DURATION; + // Buckets at or before the cutoff must be consumed by the checkpoint. + uint256 internal cutoff = buildFrameStart - INBOX_LAG_SECONDS; + + function setUp() public { + vm.warp(GENESIS_TIME); + rollup = new ProposeLibHarness(GENESIS_TIME, SLOT_DURATION, EPOCH_DURATION); + inbox = _deployInbox(TestConstants.AZTEC_INBOX_BUCKET_RING_SIZE); + } + + function _deployInbox(uint256 _ringSize) internal returns (InboxHarness) { + IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this)); + return new InboxHarness(address(rollup), feeAsset, version, HEIGHT, TestConstants.AZTEC_INBOX_LAG, _ringSize); + } + + function _send(uint256 _salt) internal { + inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}), + bytes32(uint256(0x2000 + _salt)), + bytes32(uint256(0x3000 + _salt)) + ); + } + + function _sendMany(uint256 _count) internal { + for (uint256 i = 0; i < _count; i++) { + _send(i); + } + } + + function testEmptyInbox() public { + uint256 consumed = rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0); + assertEq(consumed, 0, "consumed nothing on empty inbox"); + } + + function testEmptyInboxRejectsNonZeroHash() public { + vm.expectRevert( + abi.encodeWithSelector(Errors.Rollup__InvalidInboxRollingHash.selector, bytes32(0), bytes32(uint256(1))) + ); + rollup.validateInboxConsumption(inbox, bytes32(uint256(1)), 0, SLOT, 0); + } + + function testConsumeUpToLatestBucket() public { + vm.warp(cutoff); + _sendMany(3); + bytes32 endHash = inbox.getBucket(1).rollingHash; + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + uint256 consumed = rollup.validateInboxConsumption(inbox, endHash, 1, SLOT, 0); + assertEq(consumed, 3, "consumed all three messages"); + } + + function testExactCutoffBucketMustBeConsumed() public { + // A bucket created exactly at the cutoff is the "latest bucket at/before build-frame start minus lag": + // consuming nothing is no longer allowed. + vm.warp(cutoff); + _send(0); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnconsumedInboxMessages.selector, 1)); + rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0); + } + + function testBucketPastCutoffNeedNotBeConsumed() public { + vm.warp(cutoff + 1); + _send(0); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + uint256 consumed = rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0); + assertEq(consumed, 0, "nothing consumed"); + } + + function testCapEscape() public { + // One more message than the checkpoint cap, all before the cutoff. They spill over into buckets + // 1..4 of 256 (the per-bucket cap) plus bucket 5 with the single excess message. + vm.warp(cutoff - 100); + _sendMany(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1); + assertEq(inbox.getCurrentBucketSeq(), 5, "expected five buckets"); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + + // Consuming through bucket 4 exhausts the cap exactly, so bucket 5 escapes the censorship assert + // even though it is old. + bytes32 endHash = inbox.getBucket(4).rollingHash; + uint256 consumed = rollup.validateInboxConsumption(inbox, endHash, 4, SLOT, 0); + assertEq(consumed, MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, "consumed the full cap"); + } + + function testNoCapEscapeAtExactCap() public { + vm.warp(cutoff - 100); + _sendMany(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + + // Stopping at bucket 3 leaves bucket 4 unconsumed; consuming through it would total exactly the cap, + // which fits in one checkpoint, so there is no escape and the old bucket must be consumed. + bytes32 endHash = inbox.getBucket(3).rollingHash; + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnconsumedInboxMessages.selector, 4)); + rollup.validateInboxConsumption(inbox, endHash, 3, SLOT, 0); + } + + function testCapEscapeAccountsForParentTotal() public { + // Same layout as testCapEscape, but the parent checkpoint had already consumed one message: + // buckets 2..5 then hold cap messages total, which fit in one checkpoint, so no escape from bucket 1. + vm.warp(cutoff - 100); + _sendMany(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + + bytes32 endHash = inbox.getBucket(4).rollingHash; + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnconsumedInboxMessages.selector, 5)); + rollup.validateInboxConsumption(inbox, endHash, 4, SLOT, 1); + } + + function testStaleHashRejection() public { + vm.warp(cutoff); + _send(0); + vm.roll(block.number + 1); + vm.warp(cutoff + 10); + _send(1); + + bytes32 staleHash = inbox.getBucket(1).rollingHash; + bytes32 currentHash = inbox.getBucket(2).rollingHash; + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InvalidInboxRollingHash.selector, currentHash, staleHash)); + rollup.validateInboxConsumption(inbox, staleHash, 2, SLOT, 0); + } + + function testUnknownHashRejection() public { + vm.warp(cutoff); + _send(0); + + bytes32 unknownHash = bytes32(uint256(0xdead)); + bytes32 bucketHash = inbox.getBucket(1).rollingHash; + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InvalidInboxRollingHash.selector, bucketHash, unknownHash)); + rollup.validateInboxConsumption(inbox, unknownHash, 1, SLOT, 0); + } + + function testHintBeyondCurrentBucketRejected() public { + vm.warp(cutoff); + _send(0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 2, 1)); + rollup.validateInboxConsumption(inbox, bytes32(0), 2, SLOT, 0); + } + + function testHintOnOverwrittenBucketRejected() public { + InboxHarness smallRingInbox = _deployInbox(SMALL_RING_SIZE); + + uint256 timestamp = cutoff - 100; + for (uint256 i = 1; i <= SMALL_RING_SIZE + 1; i++) { + vm.roll(block.number + 1); + vm.warp(timestamp + i); + smallRingInbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + i)), version: version}), + bytes32(uint256(0x2000 + i)), + bytes32(uint256(0x3000 + i)) + ); + } + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 1, SMALL_RING_SIZE + 1)); + rollup.validateInboxConsumption(smallRingInbox, bytes32(0), 1, SLOT, 0); + } + + function testConsumeBackwardsReverts() public { + // Buckets 1 and 2 exist with distinct cumulative totals. A proposal that references bucket 1 while its + // parent already consumed through bucket 2 would move consumption backwards. + vm.warp(cutoff - 100); + _send(0); + vm.roll(block.number + 1); + vm.warp(cutoff - 50); + _send(1); + + uint256 parentTotal = inbox.getBucket(2).totalMsgCount; + uint256 bucketTotal = inbox.getBucket(1).totalMsgCount; + bytes32 bucketHash = inbox.getBucket(1).rollingHash; + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + vm.expectRevert( + abi.encodeWithSelector(Errors.Rollup__InboxConsumptionBehindParent.selector, parentTotal, bucketTotal) + ); + rollup.validateInboxConsumption(inbox, bucketHash, 1, SLOT, parentTotal); + } + + function testEqualReferenceConsumesNothing() public { + // The parent already consumed the current bucket; re-referencing it with an equal parent total consumes + // nothing and returns the unchanged cumulative total. No newer pre-cutoff bucket exists. + vm.warp(cutoff); + _sendMany(2); + uint256 total = inbox.getBucket(1).totalMsgCount; + bytes32 endHash = inbox.getBucket(1).rollingHash; + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + uint256 consumed = rollup.validateInboxConsumption(inbox, endHash, 1, SLOT, total); + assertEq(consumed, total, "equal reference returns the unchanged total"); + } + + function testCapUpperBoundReverts() public { + // One more message than the checkpoint cap, all before the cutoff, referenced in a single proposal from + // a fresh parent: the consumed delta exceeds what the circuits can insert. + vm.warp(cutoff - 100); + _sendMany(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1); + assertEq(inbox.getCurrentBucketSeq(), 5, "expected five buckets"); + + bytes32 endHash = inbox.getBucket(5).rollingHash; + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + vm.expectRevert( + abi.encodeWithSelector(Errors.Rollup__TooManyInboxMessagesConsumed.selector, MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1) + ); + rollup.validateInboxConsumption(inbox, endHash, 5, SLOT, 0); + } +} From decfb91797f94f7d6bd4792021cd587e5f837a90 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sat, 18 Jul 2026 01:51:49 -0300 Subject: [PATCH 2/7] test(fast-inbox): use the real 512 ring floor in the overwritten-hint test (A-1378) --- .../test/rollup/ProposeInboxConsumption.t.sol | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol index ccf9a61d337e..056f68912aea 100644 --- a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -14,6 +14,7 @@ import { import {TimeLib, Slot, Timestamp} from "@aztec/core/libraries/TimeLib.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; +import {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; import {InboxHarness} from "../harnesses/InboxHarness.sol"; import {TestConstants} from "../harnesses/TestConstants.sol"; @@ -40,7 +41,6 @@ contract ProposeInboxConsumptionTest is Test { uint256 internal constant SLOT_DURATION = 36; uint256 internal constant EPOCH_DURATION = 32; uint256 internal constant HEIGHT = 10; - uint256 internal constant SMALL_RING_SIZE = 4; Slot internal constant SLOT = Slot.wrap(10); @@ -198,13 +198,14 @@ contract ProposeInboxConsumptionTest is Test { } function testHintOnOverwrittenBucketRejected() public { - InboxHarness smallRingInbox = _deployInbox(SMALL_RING_SIZE); + InboxHarness ringInbox = _deployInbox(MIN_BUCKET_RING_SIZE); - uint256 timestamp = cutoff - 100; - for (uint256 i = 1; i <= SMALL_RING_SIZE + 1; i++) { + // One bucket per L1 block; after MIN_BUCKET_RING_SIZE + 1 buckets the ring has wrapped past bucket 1, + // so a hint pointing at it must be rejected before any cutoff logic runs. + for (uint256 i = 1; i <= MIN_BUCKET_RING_SIZE + 1; i++) { vm.roll(block.number + 1); - vm.warp(timestamp + i); - smallRingInbox.sendL2Message( + vm.warp(block.timestamp + 1); + ringInbox.sendL2Message( DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + i)), version: version}), bytes32(uint256(0x2000 + i)), bytes32(uint256(0x3000 + i)) @@ -212,8 +213,8 @@ contract ProposeInboxConsumptionTest is Test { } vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); - vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 1, SMALL_RING_SIZE + 1)); - rollup.validateInboxConsumption(smallRingInbox, bytes32(0), 1, SLOT, 0); + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 1, MIN_BUCKET_RING_SIZE + 1)); + rollup.validateInboxConsumption(ringInbox, bytes32(0), 1, SLOT, 0); } function testConsumeBackwardsReverts() public { From a6444aac0ade65b300d40f1f85f6124fad23fe08 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sat, 18 Jul 2026 02:23:12 -0300 Subject: [PATCH 3/7] test(fast-inbox): drop the backwards proposal-time warp in the overwritten-hint test (A-1378) --- l1-contracts/test/rollup/ProposeInboxConsumption.t.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol index 056f68912aea..fe175d66933b 100644 --- a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -212,7 +212,8 @@ contract ProposeInboxConsumptionTest is Test { ); } - vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + // No proposal-time warp: the loop above has already moved past SLOT's proposal time, and the revert fires + // in getBucket before any cutoff logic reads the clock. vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 1, MIN_BUCKET_RING_SIZE + 1)); rollup.validateInboxConsumption(ringInbox, bytes32(0), 1, SLOT, 0); } From 83a77c28a3416c1d0c390be5c7079b279ccba623 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Sun, 19 Jul 2026 13:12:27 -0300 Subject: [PATCH 4/7] chore(fast-inbox): fix solhint import order in ProposeLib (A-1378) Place the IInbox import after IRollup so solhint's imports-order rule passes. --- l1-contracts/src/core/libraries/rollup/ProposeLib.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol index 6d78992f02e6..7fe3f7d05b95 100644 --- a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol @@ -4,8 +4,8 @@ pragma solidity >=0.8.27; import {BlobLib} from "@aztec-blob-lib/BlobLib.sol"; import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; -import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; import {RollupStore, IRollupCore, CheckpointHeaderValidationFlags} from "@aztec/core/interfaces/IRollup.sol"; +import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; import {TempCheckpointLog} from "@aztec/core/libraries/compressed-data/CheckpointLog.sol"; import {FeeHeader} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol"; import {ChainTipsLib, CompressedChainTips} from "@aztec/core/libraries/compressed-data/Tips.sol"; From 91e0559873c67d3fa4744153f6f78640ef202571 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 20 Jul 2026 12:56:28 -0300 Subject: [PATCH 5/7] chore(fast-inbox): re-enable multi_proof from this point in the stack (A-1378) Remove the transitional skip added at the per-block-bundle branch. multi_proof was observed passing in CI at this content (2-CPU, 65s on 2026-07-19) and passes on next/merge-train, so the constrained-CPU slowdown introduced earlier in the stack is resolved by here. The skip stays protective over the intervening branches where we have no passing observation; from this branch upward the test runs normally. --- .test_patterns.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.test_patterns.yml b/.test_patterns.yml index 82d638ed4642..fdbff0409b31 100644 --- a/.test_patterns.yml +++ b/.test_patterns.yml @@ -378,16 +378,6 @@ tests: owners: - *alex - # multi_proof runs three prover nodes; with fake proofs the orchestrator still does circuit witness - # generation, and the per-block-bundle change from this point makes constrained parity (in_hash) hashing - # heavier. Under CI's 2-CPU limit that extra witgen starves the sequencer's L1 publish past its tx timeout, - # so the test is transitionally slow here. It passes on adequate hardware, and a later stack change that - # turns the in_hash parity into an unconstrained hint restores the margin — this skip is removed there. - - regex: "yarn-project/end-to-end/scripts/run_test.sh simple src/single-node/proving/multi_proof.test.ts" - skip: true - owners: - - *palla - - regex: "yarn-project/end-to-end/scripts/run_test.sh compose src/composed/docs_examples.test.ts" error_regex: "maxFeesPerGas.feePerL2Gas must be greater than or equal to gasFees.feePerL2Gas," owners: From f1080bf92f50f27cbe1ecb7fc1a1e5b32435142b Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 27 Jul 2026 16:42:38 -0300 Subject: [PATCH 6/7] docs(fast-inbox): describe the inbox rolling-hash mechanism without a spec reference (A-1378) --- l1-contracts/src/core/interfaces/messagebridge/IInbox.sol | 2 +- l1-contracts/src/core/libraries/crypto/Hash.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol index be17910ef368..883280382ade 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol @@ -28,7 +28,7 @@ interface IInbox { * fixed-size ring indexed by a dense bucket sequence number (`seq % ringSize`). A bucket only accumulates * messages sent within a single L1 block, so its final state is the chain position as of the end of that * block; the censorship check at `propose` compares the checkpoint header's rolling hash against these - * snapshots (AZIP-22 Fast Inbox). + * snapshots. */ struct InboxBucket { // Rolling hash after the last message absorbed into this bucket. Each link is diff --git a/l1-contracts/src/core/libraries/crypto/Hash.sol b/l1-contracts/src/core/libraries/crypto/Hash.sol index 15789aeae21e..18fc73e180b3 100644 --- a/l1-contracts/src/core/libraries/crypto/Hash.sol +++ b/l1-contracts/src/core/libraries/crypto/Hash.sol @@ -53,7 +53,7 @@ library Hash { /** * @notice Advances the Inbox consensus rolling hash by one message leaf * @dev Truncated at every link so the value is always a field element; the rollup circuits recompute the - * identical chain over the message leaves they insert (AZIP-22 Fast Inbox). The genesis value is zero. + * identical chain over the message leaves they insert. The genesis value is zero. * @param _rollingHash - The current rolling hash * @param _leaf - The message leaf to absorb * @return The updated rolling hash From e5284d7527ae5981db20714dd8420bdb8b525f27 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 27 Jul 2026 19:33:46 -0300 Subject: [PATCH 7/7] fix(fast-inbox): reject propose against a still-mutable inbox bucket (A-1378) A bucket keeps absorbing messages for the whole L1 block that opened it, so a reference to the current bucket is not a reference to any lasting snapshot. A lone designated proposer could bundle send(A) -> propose(hash-after-A) -> send(B) into one L1 transaction: L1 accepts the checkpoint, then the trailing send overwrites the bucket in place, leaving the checkpoint committed to a rolling hash that exists neither on L1 nor in any node, since nodes only ever observe a bucket's end-of-block state. No honest node can resolve the consumed position, so the pending chain stalls until the checkpoint is pruned. validateInboxConsumption now requires the referenced bucket to be settled, which is the negation of the Inbox's own rollover condition: the genesis bucket (which never absorbs), a bucket whose L1 block has passed, or a full bucket whose successor takes the next message. The per-bucket cap moves to IInbox.sol so the Inbox and the propose path share one definition without the rollup libraries depending on the Inbox implementation. Honest proposers never reference a bucket younger than INBOX_LAG_SECONDS, so nothing legitimate is excluded. --- .../core/interfaces/messagebridge/IInbox.sol | 5 +++ l1-contracts/src/core/libraries/Errors.sol | 1 + .../src/core/libraries/rollup/ProposeLib.sol | 21 ++++++++--- l1-contracts/src/core/messagebridge/Inbox.sol | 7 +--- l1-contracts/test/InboxBuckets.t.sol | 6 ++-- .../test/rollup/ProposeInboxConsumption.t.sol | 36 ++++++++++++++++++- 6 files changed, 62 insertions(+), 14 deletions(-) diff --git a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol index 883280382ade..8a9366b1b0c5 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol @@ -4,6 +4,11 @@ pragma solidity >=0.8.27; import {DataStructures} from "../../libraries/DataStructures.sol"; +// Maximum number of messages a single bucket can hold before further messages in the same L1 block spill over +// into the next bucket. Matches the number of L1 to L2 messages a single L2 block can insert once the streaming +// inbox is live, so any one bucket is always consumable by one block. +uint256 constant MAX_MSGS_PER_BUCKET = 256; + /** * @title Inbox * @author Aztec Labs diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 98ca02144041..db0ea9ed37bd 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -60,6 +60,7 @@ library Errors { error Rollup__InvalidCheckpointNumber(uint256 expected, uint256 actual); // 0xd1ba9bfa error Rollup__InvalidInHash(bytes32 expected, bytes32 actual); // 0xcd6f4233 error Rollup__InvalidInboxRollingHash(bytes32 expected, bytes32 actual); // 0xed1f7bb5 + error Rollup__InboxBucketStillMutable(uint256 bucketSeq); // 0x7254b980 error Rollup__UnconsumedInboxMessages(uint256 nextBucketSeq); // 0x2bd4bf10 error Rollup__InboxConsumptionBehindParent(uint256 expected, uint256 actual); // 0x54e0c025 error Rollup__TooManyInboxMessagesConsumed(uint256 consumed); // 0xf76d1426 diff --git a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol index 7fe3f7d05b95..8b4022175636 100644 --- a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol @@ -5,7 +5,7 @@ pragma solidity >=0.8.27; import {BlobLib} from "@aztec-blob-lib/BlobLib.sol"; import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; import {RollupStore, IRollupCore, CheckpointHeaderValidationFlags} from "@aztec/core/interfaces/IRollup.sol"; -import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; import {TempCheckpointLog} from "@aztec/core/libraries/compressed-data/CheckpointLog.sol"; import {FeeHeader} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol"; import {ChainTipsLib, CompressedChainTips} from "@aztec/core/libraries/compressed-data/Tips.sol"; @@ -389,12 +389,14 @@ library ProposeLib { * bucket referenced by `_bucketHint`. The hint is a plain calldata lookup aid, not signed and not * part of the header: a wrong hint cannot change what gets accepted, it only reverts. A checkpoint * that consumes no messages references the same bucket as its parent. - * 2. Consumption moves forward: the referenced bucket's cumulative total must be at least the parent + * 2. The referenced bucket must be settled: a bucket that can still absorb another message is not a + * snapshot of anything. + * 3. Consumption moves forward: the referenced bucket's cumulative total must be at least the parent * checkpoint's (equal consumes nothing; behind is a hard revert). This precedes the subtractions * below, which rely on `bucket.totalMsgCount >= _parentTotalMsgCount` to not underflow. - * 3. Cap upper bound: a single checkpoint cannot consume more than MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + * 4. Cap upper bound: a single checkpoint cannot consume more than MAX_L1_TO_L2_MSGS_PER_CHECKPOINT * messages, the maximum the circuits can insert. - * 4. Mandatory consumption (the censorship assert): the first unconsumed bucket (`_bucketHint + 1`) + * 5. Mandatory consumption (the censorship assert): the first unconsumed bucket (`_bucketHint + 1`) * must either not exist, sit past the consumption cutoff, or be cap-escaped — consuming through it * would exceed MAX_L1_TO_L2_MSGS_PER_CHECKPOINT messages since the parent checkpoint's cumulative * total. The cutoff is the start of the checkpoint's build frame minus INBOX_LAG_SECONDS: a @@ -427,6 +429,17 @@ library ProposeLib { Errors.Rollup__InvalidInboxRollingHash(bucket.rollingHash, _inboxRollingHash) ); + // A bucket that can still absorb a message mutates in place: a proposer bundling a send after its own propose + // in one L1 transaction would leave the checkpoint committed to a rolling hash that exists neither on L1 nor + // in any node, which only ever observes a bucket's end-of-block state, and no honest node could then resolve + // the consumed position. Settled is the negation of the Inbox's rollover condition: the genesis bucket never + // absorbs, a bucket whose L1 block has passed cannot be reopened, and a full bucket spills the next message + // into a new one. + require( + _bucketHint == 0 || bucket.timestamp < block.timestamp || bucket.msgCount == MAX_MSGS_PER_BUCKET, + Errors.Rollup__InboxBucketStillMutable(_bucketHint) + ); + require( bucket.totalMsgCount >= _parentTotalMsgCount, Errors.Rollup__InboxConsumptionBehindParent(_parentTotalMsgCount, bucket.totalMsgCount) diff --git a/l1-contracts/src/core/messagebridge/Inbox.sol b/l1-contracts/src/core/messagebridge/Inbox.sol index 45ee4453498f..c0782bc87909 100644 --- a/l1-contracts/src/core/messagebridge/Inbox.sol +++ b/l1-contracts/src/core/messagebridge/Inbox.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.27; import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; -import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; import {Constants} from "@aztec/core/libraries/ConstantsGen.sol"; import {FrontierLib} from "@aztec/core/libraries/crypto/FrontierLib.sol"; import {Hash} from "@aztec/core/libraries/crypto/Hash.sol"; @@ -34,11 +34,6 @@ contract Inbox is IInbox { using FrontierLib for FrontierLib.Forest; using FrontierLib for FrontierLib.Tree; - // Maximum number of messages a single bucket can hold before further messages in the same L1 block - // spill over into the next bucket. Matches the number of L1 to L2 messages a single L2 block can - // insert once the streaming inbox is live, so any one bucket is always consumable by one block. - uint256 public constant MAX_MSGS_PER_BUCKET = 256; - address public immutable ROLLUP; uint256 public immutable VERSION; address public immutable FEE_ASSET_PORTAL; diff --git a/l1-contracts/test/InboxBuckets.t.sol b/l1-contracts/test/InboxBuckets.t.sol index c0d89c309d7e..574621bcad1b 100644 --- a/l1-contracts/test/InboxBuckets.t.sol +++ b/l1-contracts/test/InboxBuckets.t.sol @@ -5,7 +5,7 @@ pragma solidity >=0.8.27; import {Test} from "forge-std/Test.sol"; import {TestERC20} from "src/mock/TestERC20.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; -import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; import {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; import {InboxHarness} from "./harnesses/InboxHarness.sol"; import {TestConstants} from "./harnesses/TestConstants.sol"; @@ -172,7 +172,7 @@ contract InboxBucketsTest is Test { } function testRolloverIntoNextBucket() public { - uint256 cap = inbox.MAX_MSGS_PER_BUCKET(); + uint256 cap = MAX_MSGS_PER_BUCKET; for (uint256 i = 0; i < cap; i++) { _send(inbox, i); } @@ -266,7 +266,7 @@ contract InboxBucketsTest is Test { // Gas cost of a rollover opening mid-block: once a bucket reaches MAX_MSGS_PER_BUCKET, the next // message in the same L1 block opens a new bucket even though the timestamp is unchanged. function testGasSendRolloverMidBlock() public { - uint256 cap = inbox.MAX_MSGS_PER_BUCKET(); + uint256 cap = MAX_MSGS_PER_BUCKET; for (uint256 i = 0; i < cap; i++) { _send(inbox, i); } diff --git a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol index fe175d66933b..7d1e8a00c032 100644 --- a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -5,7 +5,7 @@ pragma solidity >=0.8.27; import {Test} from "forge-std/Test.sol"; import {TestERC20} from "src/mock/TestERC20.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; -import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; import { ProposeLib, INBOX_LAG_SECONDS, @@ -79,6 +79,8 @@ contract ProposeInboxConsumptionTest is Test { } function testEmptyInbox() public { + // The genesis bucket carries the Inbox's deployment timestamp and never absorbs messages, so it is settled + // from the start: referencing it works even in the L1 block the Inbox was deployed in. uint256 consumed = rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0); assertEq(consumed, 0, "consumed nothing on empty inbox"); } @@ -218,6 +220,38 @@ contract ProposeInboxConsumptionTest is Test { rollup.validateInboxConsumption(ringInbox, bytes32(0), 1, SLOT, 0); } + function testCurrentBlockBucketRejected() public { + // A proposer that bundles `sendL2Message` and `propose` into a single L1 transaction can reference a + // bucket that is still accumulating and then mutate it with a trailing send. The snapshot the checkpoint + // committed to would afterwards exist nowhere: not on L1, where it was overwritten in place, and not in + // any node, which only ever observes a bucket's end-of-block state. + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + _send(0); + bytes32 liveHash = inbox.getBucket(1).rollingHash; + + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InboxBucketStillMutable.selector, 1)); + rollup.validateInboxConsumption(inbox, liveHash, 1, SLOT, 0); + + // Nothing prevents the trailing send that erases the referenced snapshot, so the reference itself is what + // has to be rejected. + _send(1); + assertTrue(inbox.getBucket(1).rollingHash != liveHash, "bucket mutated in place within one L1 block"); + } + + function testFullBucketInCurrentBlockAccepted() public { + // A bucket at the per-bucket cap cannot absorb another message: the next send in the same L1 block opens + // a new bucket, so the snapshot is already final and referencing it within that block is safe. + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + uint256 cap = MAX_MSGS_PER_BUCKET; + _sendMany(cap); + assertEq(inbox.getBucket(1).msgCount, cap, "bucket 1 is at the per-bucket cap"); + assertEq(inbox.getBucket(1).timestamp, block.timestamp, "bucket 1 was opened in this L1 block"); + + bytes32 endHash = inbox.getBucket(1).rollingHash; + uint256 consumed = rollup.validateInboxConsumption(inbox, endHash, 1, SLOT, 0); + assertEq(consumed, cap, "a full bucket is consumable in the block it was opened in"); + } + function testConsumeBackwardsReverts() public { // Buckets 1 and 2 exist with distinct cumulative totals. A proposal that references bucket 1 while its // parent already consumed through bucket 2 would move consumption backwards.