diff --git a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol index be17910ef368..9fbb33709cec 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol @@ -100,12 +100,27 @@ interface IInbox { function getInProgress() external view returns (uint64); + /** + * @notice Records that checkpoint consumption has reached the given bucket + * @dev Only callable by the rollup contract. The pointer only ever advances: it is a cache of the + * per-checkpoint consumption records kept by the rollup (used for overwrite protection on the ring), + * not the source of truth, so it does not rewind when checkpoints are pruned. + * @param _seq - The bucket sequence number consumption has reached + */ + function markBucketConsumed(uint256 _seq) external; + /** * @notice Returns the sequence number of the bucket currently accumulating messages * @return The current bucket sequence number */ function getCurrentBucketSeq() external view returns (uint64); + /** + * @notice Returns the highest bucket sequence number that checkpoint consumption has reached + * @return The consumed bucket sequence number + */ + function getConsumedBucketSeq() external view returns (uint64); + /** * @notice Returns the bucket with the given sequence number * @dev Reverts if the bucket is ahead of the current one or has already been overwritten in the ring diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 46dccf4a7788..8b6717f89598 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -59,6 +59,8 @@ 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__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..de3fb122d8d9 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,59 @@ library ProposeLib { ); } + /** + * @notice Validates a checkpoint's Inbox consumption against the streaming inbox buckets and records 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 Two checks: + * 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. 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. + * + * On success, advances the Inbox's consumed-bucket pointer, which the Inbox needs to protect + * unconsumed buckets from ring overwrites. + * + * @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 + */ + function validateInboxConsumption( + IInbox _inbox, + bytes32 _inboxRollingHash, + uint256 _bucketHint, + Slot _slotNumber, + uint256 _parentTotalMsgCount + ) internal { + IInbox.InboxBucket memory bucket = _inbox.getBucket(_bucketHint); + require( + bucket.rollingHash == _inboxRollingHash, + Errors.Rollup__InvalidInboxRollingHash(bucket.rollingHash, _inboxRollingHash) + ); + + 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) + ); + } + + _inbox.markBucketConsumed(_bucketHint); + } + /** * @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 2dde615f5507..9cccba1e0bf1 100644 --- a/l1-contracts/src/core/messagebridge/Inbox.sol +++ b/l1-contracts/src/core/messagebridge/Inbox.sol @@ -57,6 +57,7 @@ contract Inbox is IInbox { mapping(uint256 ringIndex => InboxBucket bucket) internal buckets; uint64 internal currentBucketSeq; + uint64 internal consumedBucketSeq; constructor( address _rollup, @@ -249,10 +250,25 @@ contract Inbox is IInbox { return state.inProgress; } + function markBucketConsumed(uint256 _seq) external override(IInbox) { + require(msg.sender == ROLLUP, Errors.Inbox__Unauthorized()); + + uint64 current = currentBucketSeq; + require(_seq <= current, Errors.Inbox__BucketOutOfWindow(_seq, current)); + + if (_seq > consumedBucketSeq) { + consumedBucketSeq = SafeCast.toUint64(_seq); + } + } + function getCurrentBucketSeq() external view override(IInbox) returns (uint64) { return currentBucketSeq; } + function getConsumedBucketSeq() external view override(IInbox) returns (uint64) { + return consumedBucketSeq; + } + function getBucket(uint256 _seq) external view override(IInbox) returns (InboxBucket memory) { uint256 current = currentBucketSeq; require(_seq <= current && current - _seq < BUCKET_RING_SIZE, Errors.Inbox__BucketOutOfWindow(_seq, current)); diff --git a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol new file mode 100644 index 000000000000..6100553afa4b --- /dev/null +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -0,0 +1,248 @@ +// 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 { + 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 { + rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0); + assertEq(inbox.getConsumedBucketSeq(), 0, "consumed pointer at genesis bucket"); + } + + 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); + rollup.validateInboxConsumption(inbox, endHash, 1, SLOT, 0); + assertEq(inbox.getConsumedBucketSeq(), 1, "consumed pointer advanced"); + } + + 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); + rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0); + assertEq(inbox.getConsumedBucketSeq(), 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; + rollup.validateInboxConsumption(inbox, endHash, 4, SLOT, 0); + assertEq(inbox.getConsumedBucketSeq(), 4, "consumed pointer advanced"); + } + + 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 testConsumedPointerIsMonotonic() public { + vm.warp(cutoff); + _send(0); + vm.roll(block.number + 1); + vm.warp(cutoff + 10); + _send(1); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + rollup.validateInboxConsumption(inbox, inbox.getBucket(2).rollingHash, 2, SLOT, 0); + assertEq(inbox.getConsumedBucketSeq(), 2, "consumed pointer at bucket 2"); + + // Marking an earlier position (e.g. after a prune rewound the pending chain) does not rewind the + // pointer: it is a cache of the rollup's per-checkpoint records, not the source of truth. + vm.prank(address(rollup)); + inbox.markBucketConsumed(1); + assertEq(inbox.getConsumedBucketSeq(), 2, "consumed pointer did not rewind"); + } + + function testMarkBucketConsumedOnlyRollup() public { + vm.warp(cutoff); + _send(0); + + vm.expectRevert(Errors.Inbox__Unauthorized.selector); + inbox.markBucketConsumed(1); + } + + function testMarkBucketConsumedRejectsFutureBucket() public { + vm.prank(address(rollup)); + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 1, 0)); + inbox.markBucketConsumed(1); + } +}