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: diff --git a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol index be17910ef368..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 @@ -28,7 +33,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/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 46dccf4a7788..db0ea9ed37bd 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -59,6 +59,11 @@ 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__InboxBucketStillMutable(uint256 bucketSeq); // 0x7254b980 + 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/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 diff --git a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol index e4807e5bcc71..8b4022175636 100644 --- a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol @@ -5,6 +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, 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"; @@ -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,91 @@ 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. 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. + * 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. + * 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 + * 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) + ); + + // 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) + ); + + 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/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 new file mode 100644 index 000000000000..7d1e8a00c032 --- /dev/null +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -0,0 +1,303 @@ +// 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, MAX_MSGS_PER_BUCKET} 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 {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.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; + + 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 { + // 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"); + } + + 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 ringInbox = _deployInbox(MIN_BUCKET_RING_SIZE); + + // 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(block.timestamp + 1); + ringInbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + i)), version: version}), + bytes32(uint256(0x2000 + i)), + bytes32(uint256(0x3000 + i)) + ); + } + + // 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); + } + + 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. + 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); + } +}