Skip to content

Commit e5284d7

Browse files
committed
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.
1 parent f1080bf commit e5284d7

6 files changed

Lines changed: 62 additions & 14 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ library Errors {
6060
error Rollup__InvalidCheckpointNumber(uint256 expected, uint256 actual); // 0xd1ba9bfa
6161
error Rollup__InvalidInHash(bytes32 expected, bytes32 actual); // 0xcd6f4233
6262
error Rollup__InvalidInboxRollingHash(bytes32 expected, bytes32 actual); // 0xed1f7bb5
63+
error Rollup__InboxBucketStillMutable(uint256 bucketSeq); // 0x7254b980
6364
error Rollup__UnconsumedInboxMessages(uint256 nextBucketSeq); // 0x2bd4bf10
6465
error Rollup__InboxConsumptionBehindParent(uint256 expected, uint256 actual); // 0x54e0c025
6566
error Rollup__TooManyInboxMessagesConsumed(uint256 consumed); // 0xf76d1426

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +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} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
8+
import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
99
import {TempCheckpointLog} from "@aztec/core/libraries/compressed-data/CheckpointLog.sol";
1010
import {FeeHeader} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol";
1111
import {ChainTipsLib, CompressedChainTips} from "@aztec/core/libraries/compressed-data/Tips.sol";
@@ -389,12 +389,14 @@ library ProposeLib {
389389
* bucket referenced by `_bucketHint`. The hint is a plain calldata lookup aid, not signed and not
390390
* part of the header: a wrong hint cannot change what gets accepted, it only reverts. A checkpoint
391391
* that consumes no messages references the same bucket as its parent.
392-
* 2. Consumption moves forward: the referenced bucket's cumulative total must be at least the 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
393395
* checkpoint's (equal consumes nothing; behind is a hard revert). This precedes the subtractions
394396
* below, which rely on `bucket.totalMsgCount >= _parentTotalMsgCount` to not underflow.
395-
* 3. Cap upper bound: a single checkpoint cannot consume more than MAX_L1_TO_L2_MSGS_PER_CHECKPOINT
397+
* 4. Cap upper bound: a single checkpoint cannot consume more than MAX_L1_TO_L2_MSGS_PER_CHECKPOINT
396398
* messages, the maximum the circuits can insert.
397-
* 4. Mandatory consumption (the censorship assert): the first unconsumed bucket (`_bucketHint + 1`)
399+
* 5. Mandatory consumption (the censorship assert): the first unconsumed bucket (`_bucketHint + 1`)
398400
* must either not exist, sit past the consumption cutoff, or be cap-escaped — consuming through it
399401
* would exceed MAX_L1_TO_L2_MSGS_PER_CHECKPOINT messages since the parent checkpoint's cumulative
400402
* total. The cutoff is the start of the checkpoint's build frame minus INBOX_LAG_SECONDS: a
@@ -427,6 +429,17 @@ library ProposeLib {
427429
Errors.Rollup__InvalidInboxRollingHash(bucket.rollingHash, _inboxRollingHash)
428430
);
429431

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+
430443
require(
431444
bucket.totalMsgCount >= _parentTotalMsgCount,
432445
Errors.Rollup__InboxConsumptionBehindParent(_parentTotalMsgCount, bucket.totalMsgCount)

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
}

l1-contracts/test/rollup/ProposeInboxConsumption.t.sol

Lines changed: 35 additions & 1 deletion
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 {
1010
ProposeLib,
1111
INBOX_LAG_SECONDS,
@@ -79,6 +79,8 @@ contract ProposeInboxConsumptionTest is Test {
7979
}
8080

8181
function testEmptyInbox() public {
82+
// The genesis bucket carries the Inbox's deployment timestamp and never absorbs messages, so it is settled
83+
// from the start: referencing it works even in the L1 block the Inbox was deployed in.
8284
uint256 consumed = rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0);
8385
assertEq(consumed, 0, "consumed nothing on empty inbox");
8486
}
@@ -218,6 +220,38 @@ contract ProposeInboxConsumptionTest is Test {
218220
rollup.validateInboxConsumption(ringInbox, bytes32(0), 1, SLOT, 0);
219221
}
220222

223+
function testCurrentBlockBucketRejected() public {
224+
// A proposer that bundles `sendL2Message` and `propose` into a single L1 transaction can reference a
225+
// bucket that is still accumulating and then mutate it with a trailing send. The snapshot the checkpoint
226+
// committed to would afterwards exist nowhere: not on L1, where it was overwritten in place, and not in
227+
// any node, which only ever observes a bucket's end-of-block state.
228+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
229+
_send(0);
230+
bytes32 liveHash = inbox.getBucket(1).rollingHash;
231+
232+
vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InboxBucketStillMutable.selector, 1));
233+
rollup.validateInboxConsumption(inbox, liveHash, 1, SLOT, 0);
234+
235+
// Nothing prevents the trailing send that erases the referenced snapshot, so the reference itself is what
236+
// has to be rejected.
237+
_send(1);
238+
assertTrue(inbox.getBucket(1).rollingHash != liveHash, "bucket mutated in place within one L1 block");
239+
}
240+
241+
function testFullBucketInCurrentBlockAccepted() public {
242+
// A bucket at the per-bucket cap cannot absorb another message: the next send in the same L1 block opens
243+
// a new bucket, so the snapshot is already final and referencing it within that block is safe.
244+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
245+
uint256 cap = MAX_MSGS_PER_BUCKET;
246+
_sendMany(cap);
247+
assertEq(inbox.getBucket(1).msgCount, cap, "bucket 1 is at the per-bucket cap");
248+
assertEq(inbox.getBucket(1).timestamp, block.timestamp, "bucket 1 was opened in this L1 block");
249+
250+
bytes32 endHash = inbox.getBucket(1).rollingHash;
251+
uint256 consumed = rollup.validateInboxConsumption(inbox, endHash, 1, SLOT, 0);
252+
assertEq(consumed, cap, "a full bucket is consumable in the block it was opened in");
253+
}
254+
221255
function testConsumeBackwardsReverts() public {
222256
// Buckets 1 and 2 exist with distinct cumulative totals. A proposal that references bucket 1 while its
223257
// parent already consumed through bucket 2 would move consumption backwards.

0 commit comments

Comments
 (0)