diff --git a/l1-contracts/src/core/RollupCore.sol b/l1-contracts/src/core/RollupCore.sol index 73b3da4c4b41..cc66c96895e2 100644 --- a/l1-contracts/src/core/RollupCore.sol +++ b/l1-contracts/src/core/RollupCore.sol @@ -27,7 +27,7 @@ import {ProposeArgs} from "@aztec/core/libraries/rollup/ProposeLib.sol"; import {STFLib, GenesisState} from "@aztec/core/libraries/rollup/STFLib.sol"; import {StakingLib} from "@aztec/core/libraries/rollup/StakingLib.sol"; import {Timestamp, Slot, Epoch, TimeLib} from "@aztec/core/libraries/TimeLib.sol"; -import {Inbox} from "@aztec/core/messagebridge/Inbox.sol"; +import {Inbox, INBOX_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; import {Outbox} from "@aztec/core/messagebridge/Outbox.sol"; import {ISlasher} from "@aztec/core/slashing/Slasher.sol"; import {GSE} from "@aztec/governance/GSE.sol"; @@ -621,7 +621,14 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali IInbox inbox = IInbox( address( - new Inbox(address(this), _feeAsset, _config.version, Constants.L1_TO_L2_MSG_SUBTREE_HEIGHT, _config.inboxLag) + new Inbox( + address(this), + _feeAsset, + _config.version, + Constants.L1_TO_L2_MSG_SUBTREE_HEIGHT, + _config.inboxLag, + INBOX_BUCKET_RING_SIZE + ) ) ); diff --git a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol index a001403791da..be17910ef368 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol @@ -13,6 +13,8 @@ interface IInbox { struct InboxState { // Rolling hash of all messages inserted into the inbox. // Used by clients to check for consistency. + // TODO: remove once the streaming inbox (AZIP-22 Fast Inbox) flips on and clients rely on the + // consensus rolling hash tracked in the buckets instead. bytes16 rollingHash; // This value is not used much by the contract, but it is useful for synching the node faster // as it can more easily figure out if it can just skip looking for events for a time period. @@ -21,14 +23,43 @@ interface IInbox { uint64 inProgress; } + /** + * @notice Snapshot of the consensus rolling hash over the messages inserted into the Inbox, stored in a + * 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). + */ + struct InboxBucket { + // Rolling hash after the last message absorbed into this bucket. Each link is + // `sha256ToField(previousRollingHash || leaf)`; the genesis value is zero. + bytes32 rollingHash; + // Cumulative number of messages inserted into the Inbox up to and including this bucket. + uint64 totalMsgCount; + // L1 block timestamp at which this bucket was opened. Recency comparisons (message lag, + // censorship cutoff) are done in seconds against this value. + uint64 timestamp; + // Number of messages absorbed into this bucket, capped at the per-bucket maximum. + uint32 msgCount; + } + /** * @notice Emitted when a message is sent * @param checkpointNumber - The checkpoint number in which the message is included * @param index - The index of the message in the L1 to L2 messages tree * @param hash - The hash of the message * @param rollingHash - The rolling hash of all messages inserted into the inbox + * @param inboxRollingHash - The consensus rolling hash (truncated sha256 chain) after this message + * @param bucketSeq - The sequence number of the bucket this message was absorbed into */ - event MessageSent(uint256 indexed checkpointNumber, uint256 index, bytes32 indexed hash, bytes16 rollingHash); + event MessageSent( + uint256 indexed checkpointNumber, + uint256 index, + bytes32 indexed hash, + bytes16 rollingHash, + bytes32 inboxRollingHash, + uint256 bucketSeq + ); // docs:start:send_l1_to_l2_message /** @@ -68,4 +99,18 @@ interface IInbox { function getTotalMessagesInserted() external view returns (uint64); function getInProgress() external view returns (uint64); + + /** + * @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 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 + * @param _seq - The bucket sequence number + * @return The bucket + */ + function getBucket(uint256 _seq) external view returns (InboxBucket memory); } diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 01152320a831..46dccf4a7788 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -28,6 +28,7 @@ library Errors { error Inbox__ContentTooLarge(bytes32 content); // 0x47452014 error Inbox__SecretHashTooLarge(bytes32 secretHash); // 0xecde7e2c error Inbox__MustBuildBeforeConsume(); // 0xc4901999 + error Inbox__BucketOutOfWindow(uint256 seq, uint256 current); // 0xfee255b7 // Outbox error Outbox__Unauthorized(); // 0x2c9490c2 diff --git a/l1-contracts/src/core/libraries/crypto/Hash.sol b/l1-contracts/src/core/libraries/crypto/Hash.sol index b2d4df0aac56..15789aeae21e 100644 --- a/l1-contracts/src/core/libraries/crypto/Hash.sol +++ b/l1-contracts/src/core/libraries/crypto/Hash.sol @@ -49,4 +49,16 @@ library Hash { function sha256ToField(bytes memory _data) internal pure returns (bytes32) { return bytes32(bytes.concat(new bytes(1), bytes31(sha256(_data)))); } + + /** + * @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. + * @param _rollingHash - The current rolling hash + * @param _leaf - The message leaf to absorb + * @return The updated rolling hash + */ + function accumulateInboxRollingHash(bytes32 _rollingHash, bytes32 _leaf) internal pure returns (bytes32) { + return sha256ToField(abi.encodePacked(_rollingHash, _leaf)); + } } diff --git a/l1-contracts/src/core/messagebridge/Inbox.sol b/l1-contracts/src/core/messagebridge/Inbox.sol index 8355421b6178..cb7d0fadaa5c 100644 --- a/l1-contracts/src/core/messagebridge/Inbox.sol +++ b/l1-contracts/src/core/messagebridge/Inbox.sol @@ -13,6 +13,17 @@ import {FeeJuicePortal} from "@aztec/core/messagebridge/FeeJuicePortal.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; import {SafeCast} from "@oz/utils/math/SafeCast.sol"; +// Number of buckets in the rolling-hash ring. Sized far beyond normal consumption lag (the censorship +// cutoff bounds it to roughly one build frame); outages longer than the ring are handled by overwrite +// protection on unconsumed buckets, not by growing the ring. +uint256 constant INBOX_BUCKET_RING_SIZE = 1024; + +// Constructor floor for the bucket ring. The ring must cover the longest stall the chain recovers from on +// its own: the prune-and-repropose window of 64 checkpoints (2 epochs = 384 L1 blocks) at the natural cadence +// of one bucket per L1 block, so the buckets it must re-consume after a prune have not been overwritten. 384 +// rounded up to the next power of two, kept at or below the production ring. +uint256 constant MIN_BUCKET_RING_SIZE = 512; + /** * @title Inbox * @author Aztec Labs @@ -23,12 +34,19 @@ 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; uint256 public immutable LAG; + uint256 public immutable BUCKET_RING_SIZE; + uint256 internal immutable HEIGHT; uint256 internal immutable SIZE; bytes32 internal immutable EMPTY_ROOT; // The root of an empty frontier tree @@ -40,7 +58,20 @@ contract Inbox is IInbox { InboxState internal state; - constructor(address _rollup, IERC20 _feeAsset, uint256 _version, uint256 _height, uint256 _lag) { + // Ring of rolling-hash buckets, keyed by `bucketSeq % BUCKET_RING_SIZE`. Inert for the legacy + // frontier-tree flow; consumed by the streaming inbox checks at `propose` (AZIP-22 Fast Inbox). + mapping(uint256 ringIndex => InboxBucket bucket) internal buckets; + + uint64 internal currentBucketSeq; + + constructor( + address _rollup, + IERC20 _feeAsset, + uint256 _version, + uint256 _height, + uint256 _lag, + uint256 _bucketRingSize + ) { ROLLUP = _rollup; VERSION = _version; @@ -50,10 +81,18 @@ contract Inbox is IInbox { require(_lag > 0, "LAG TOO SMALL"); LAG = _lag; + require(_bucketRingSize >= MIN_BUCKET_RING_SIZE, "BUCKET RING TOO SMALL"); + BUCKET_RING_SIZE = _bucketRingSize; + state = InboxState({ rollingHash: 0, totalMessagesInserted: 0, inProgress: SafeCast.toUint64(Constants.INITIAL_CHECKPOINT_NUMBER + LAG) }); + // Genesis bucket: a checkpoint consuming no messages references the same bucket as its parent, so the + // first checkpoint against an empty Inbox references this one and no base case leaks into `propose`. + buckets[0] = + InboxBucket({rollingHash: 0, totalMsgCount: 0, timestamp: SafeCast.toUint64(block.timestamp), msgCount: 0}); + forest.initialize(_height); EMPTY_ROOT = trees[type(uint256).max].root(forest, HEIGHT, SIZE); @@ -122,11 +161,54 @@ contract Inbox is IInbox { rollingHash: updatedRollingHash, totalMessagesInserted: totalMessagesInserted + 1, inProgress: inProgress }); - emit MessageSent(inProgress, index, leaf, updatedRollingHash); + (uint64 bucketSeq, bytes32 inboxRollingHash) = _absorbIntoBucket(leaf); + + emit MessageSent(inProgress, index, leaf, updatedRollingHash, inboxRollingHash, bucketSeq); return (leaf, index); } + /** + * @notice Absorbs a message leaf into the consensus rolling hash and snapshots it into the bucket ring + * + * @dev A bucket only holds messages from a single L1 block, up to MAX_MSGS_PER_BUCKET; the first message + * of a new L1 block — or the message after a full bucket, spilling over within the same block — opens the + * next bucket, inheriting the rolling hash and cumulative count. Bucket 0 is the pristine genesis base + * case and never absorbs. Opening a bucket overwrites the ring entry from BUCKET_RING_SIZE buckets ago; + * protection against overwriting unconsumed buckets is not enforced yet. + * + * @param _leaf - The message leaf to absorb + * + * @return The sequence number of the bucket the leaf was absorbed into and the updated rolling hash + */ + function _absorbIntoBucket(bytes32 _leaf) internal returns (uint64, bytes32) { + uint64 bucketSeq = currentBucketSeq; + InboxBucket memory bucket = buckets[bucketSeq % BUCKET_RING_SIZE]; + + // Buckets are keyed by L1 block timestamp: a strictly larger timestamp opens a new bucket (a full bucket + // also rolls over within the same block). Post-merge Ethereum increases block.timestamp strictly per block, + // so messages from different L1 blocks always land in different buckets. Under anvil with manual mining two + // blocks can share a timestamp and therefore a bucket; this is harmless because the consumption cutoff is + // computed over timestamps, so co-timestamped blocks are indistinguishable to it. + if (bucketSeq == 0 || bucket.timestamp < block.timestamp || bucket.msgCount == MAX_MSGS_PER_BUCKET) { + bucketSeq += 1; + currentBucketSeq = bucketSeq; + bucket = InboxBucket({ + rollingHash: bucket.rollingHash, + totalMsgCount: bucket.totalMsgCount, + timestamp: SafeCast.toUint64(block.timestamp), + msgCount: 0 + }); + } + + bucket.rollingHash = Hash.accumulateInboxRollingHash(bucket.rollingHash, _leaf); + bucket.totalMsgCount += 1; + bucket.msgCount += 1; + buckets[bucketSeq % BUCKET_RING_SIZE] = bucket; + + return (bucketSeq, bucket.rollingHash); + } + /** * @notice Consumes the current tree, and starts a new one if needed * @@ -177,4 +259,14 @@ contract Inbox is IInbox { function getInProgress() external view override(IInbox) returns (uint64) { return state.inProgress; } + + function getCurrentBucketSeq() external view override(IInbox) returns (uint64) { + return currentBucketSeq; + } + + 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)); + return buckets[_seq % BUCKET_RING_SIZE]; + } } diff --git a/l1-contracts/test/Inbox.t.sol b/l1-contracts/test/Inbox.t.sol index daac928af500..68cccd760645 100644 --- a/l1-contracts/test/Inbox.t.sol +++ b/l1-contracts/test/Inbox.t.sol @@ -30,7 +30,9 @@ contract InboxTest is Test { function setUp() public { address rollup = address(this); IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this)); - inbox = new InboxHarness(rollup, feeAsset, version, HEIGHT, TestConstants.AZTEC_INBOX_LAG); + inbox = new InboxHarness( + rollup, feeAsset, version, HEIGHT, TestConstants.AZTEC_INBOX_LAG, TestConstants.AZTEC_INBOX_BUCKET_RING_SIZE + ); emptyTreeRoot = inbox.getEmptyRoot(); } @@ -96,9 +98,12 @@ contract InboxTest is Test { bytes32 leaf = message.sha256ToField(); bytes16 expectedRollingHash = bytes16(keccak256(abi.encodePacked(stateBefore.rollingHash, leaf))); + bytes32 expectedInboxRollingHash = Hash.accumulateInboxRollingHash(bytes32(0), leaf); vm.expectEmit(true, true, true, true); // event we expect - emit IInbox.MessageSent(FIRST_REAL_TREE_NUM, globalLeafIndex, leaf, expectedRollingHash); + emit IInbox.MessageSent( + FIRST_REAL_TREE_NUM, globalLeafIndex, leaf, expectedRollingHash, expectedInboxRollingHash, 1 + ); // event we will get (bytes32 insertedLeaf, uint256 insertedIndex) = inbox.sendL2Message(message.recipient, message.content, message.secretHash); diff --git a/l1-contracts/test/InboxBuckets.t.sol b/l1-contracts/test/InboxBuckets.t.sol new file mode 100644 index 000000000000..c0d89c309d7e --- /dev/null +++ b/l1-contracts/test/InboxBuckets.t.sol @@ -0,0 +1,292 @@ +// 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 {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; +import {InboxHarness} from "./harnesses/InboxHarness.sol"; +import {TestConstants} from "./harnesses/TestConstants.sol"; +import {Constants} from "@aztec/core/libraries/ConstantsGen.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Hash} from "@aztec/core/libraries/crypto/Hash.sol"; +import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; + +contract InboxBucketsTest is Test { + uint256 internal constant FIRST_REAL_TREE_NUM = Constants.INITIAL_CHECKPOINT_NUMBER + TestConstants.AZTEC_INBOX_LAG; + uint256 internal constant HEIGHT = 10; + + InboxHarness internal inbox; + uint256 internal version = 0; + bytes32 internal expectedRollingHash; + + function setUp() public { + 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(this), feeAsset, version, HEIGHT, TestConstants.AZTEC_INBOX_LAG, _ringSize); + } + + function _send(InboxHarness _inbox, uint256 _salt) internal returns (bytes32) { + (bytes32 leaf,) = _inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}), + bytes32(uint256(0x2000 + _salt)), + bytes32(uint256(0x3000 + _salt)) + ); + expectedRollingHash = Hash.accumulateInboxRollingHash(expectedRollingHash, leaf); + return leaf; + } + + // Sends a message and returns the gas consumed by the external `sendL2Message` call. The + // recipient/content/secretHash are built before the measurement window so only the call is timed. The + // figure is warm execution gas including the CALL overhead; it excludes the 21k intrinsic tx cost, calldata + // gas, and the cold-access surcharge a standalone EOA transaction pays on its first touch of each slot. + function _measureSend(InboxHarness _inbox, uint256 _salt) internal returns (uint256 gasUsed) { + DataStructures.L2Actor memory recipient = + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}); + bytes32 content = bytes32(uint256(0x2000 + _salt)); + bytes32 secretHash = bytes32(uint256(0x3000 + _salt)); + + uint256 gasBefore = gasleft(); + _inbox.sendL2Message(recipient, content, secretHash); + gasUsed = gasBefore - gasleft(); + } + + // Shared test vectors for the rolling-hash chain, pinned across the noir circuits, the TS mirror, + // and this L1 implementation. Generated from an independent sha256 implementation. + function testRollingHashTestVectors() public pure { + bytes32 h = Hash.accumulateInboxRollingHash(bytes32(0), bytes32(uint256(11))); + assertEq(h, 0x00815fb1e9d2076ae5761439b6144ad11da69eb6c41ab2aca39e770407ad8d12, "chain(0, [11])"); + + h = Hash.accumulateInboxRollingHash(h, bytes32(uint256(22))); + h = Hash.accumulateInboxRollingHash(h, bytes32(uint256(33))); + assertEq(h, 0x0014cae968461979aab6d33266a2310ed234d3f6cf4472737c57551db07bd0da, "chain(0, [11, 22, 33])"); + + h = bytes32(0); + for (uint256 i = 1; i <= 256; i++) { + h = Hash.accumulateInboxRollingHash(h, bytes32(i)); + } + assertEq(h, 0x00ea95b96f17b75be03525b35a2a1918b42f03ad8c00a437cf641751825f3992, "chain(0, [1..=256])"); + + h = Hash.accumulateInboxRollingHash(bytes32(uint256(0x2a)), bytes32(uint256(7))); + assertEq(h, 0x0032a934005556d1b9d22708666ee8b05f91fafad624dd64a6ea878e048e5438, "chain(0x2a, [7])"); + + h = Hash.accumulateInboxRollingHash(h, bytes32(uint256(8))); + assertEq(h, 0x0054d96b8a074a5030a5838972d0a3c04ba47cf5956348c853e02e9566233f65, "chain(0x2a, [7, 8])"); + } + + function testGenesisBucket() public { + assertEq(inbox.getCurrentBucketSeq(), 0, "genesis seq"); + + IInbox.InboxBucket memory bucket = inbox.getBucket(0); + assertEq(bucket.rollingHash, bytes32(0), "genesis rolling hash"); + assertEq(bucket.totalMsgCount, 0, "genesis total"); + assertEq(bucket.timestamp, uint64(block.timestamp), "genesis timestamp"); + assertEq(bucket.msgCount, 0, "genesis msg count"); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 1, 0)); + inbox.getBucket(1); + } + + function testFirstMessageOpensBucketOne() public { + // Even in the deployment block, the first message must not absorb into the genesis bucket: a + // checkpoint consuming no messages needs a bucket whose rolling hash matches its parent's chain + // position, which for the first checkpoint is the zero genesis bucket. + _send(inbox, 0); + + assertEq(inbox.getCurrentBucketSeq(), 1, "current seq"); + assertEq(inbox.getBucket(0).rollingHash, bytes32(0), "genesis untouched"); + assertEq(inbox.getBucket(0).msgCount, 0, "genesis still empty"); + assertEq(inbox.getBucket(1).msgCount, 1, "bucket 1 has the message"); + } + + function testAccumulationWithinSingleBlock() public { + bytes32 leaf1 = _send(inbox, 1); + bytes32 chain1 = Hash.accumulateInboxRollingHash(bytes32(0), leaf1); + bytes32 leaf2 = _send(inbox, 2); + bytes32 chain2 = Hash.accumulateInboxRollingHash(chain1, leaf2); + bytes32 leaf3 = _send(inbox, 3); + bytes32 chain3 = Hash.accumulateInboxRollingHash(chain2, leaf3); + + assertEq(inbox.getCurrentBucketSeq(), 1, "all messages share one bucket"); + + IInbox.InboxBucket memory bucket = inbox.getBucket(1); + assertEq(bucket.rollingHash, chain3, "bucket rolling hash"); + assertEq(bucket.totalMsgCount, 3, "bucket cumulative total"); + assertEq(bucket.timestamp, uint64(block.timestamp), "bucket timestamp"); + assertEq(bucket.msgCount, 3, "bucket msg count"); + } + + function testMessageSentEventCarriesBucketData() public { + DataStructures.L2Actor memory recipient = + DataStructures.L2Actor({actor: bytes32(uint256(0x1000)), version: version}); + bytes32 content = bytes32(uint256(0x2000)); + bytes32 secretHash = bytes32(uint256(0x3000)); + + DataStructures.L1ToL2Msg memory message = DataStructures.L1ToL2Msg({ + sender: DataStructures.L1Actor(address(this), block.chainid), + recipient: recipient, + content: content, + secretHash: secretHash, + index: (FIRST_REAL_TREE_NUM - 1) * (2 ** HEIGHT) + }); + bytes32 leaf = Hash.sha256ToField(message); + bytes16 legacyHash = bytes16(keccak256(abi.encodePacked(inbox.getState().rollingHash, leaf))); + bytes32 inboxRollingHash = Hash.accumulateInboxRollingHash(bytes32(0), leaf); + + vm.expectEmit(true, true, true, true, address(inbox)); + emit IInbox.MessageSent(FIRST_REAL_TREE_NUM, message.index, leaf, legacyHash, inboxRollingHash, 1); + inbox.sendL2Message(recipient, content, secretHash); + } + + function testSnapshotBoundariesAcrossBlocks() public { + _send(inbox, 1); + _send(inbox, 2); + IInbox.InboxBucket memory bucket1 = inbox.getBucket(1); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + + bytes32 leaf3 = _send(inbox, 3); + + assertEq(inbox.getCurrentBucketSeq(), 2, "new block opens a new bucket"); + + // The previous bucket's snapshot is frozen at its end-of-block state. + IInbox.InboxBucket memory bucket1After = inbox.getBucket(1); + assertEq(bucket1After.rollingHash, bucket1.rollingHash, "bucket 1 rolling hash frozen"); + assertEq(bucket1After.totalMsgCount, 2, "bucket 1 total frozen"); + assertEq(bucket1After.msgCount, 2, "bucket 1 msg count frozen"); + assertEq(bucket1After.timestamp, bucket1.timestamp, "bucket 1 timestamp frozen"); + + // The new bucket continues the chain from the previous bucket. + IInbox.InboxBucket memory bucket2 = inbox.getBucket(2); + assertEq(bucket2.rollingHash, Hash.accumulateInboxRollingHash(bucket1.rollingHash, leaf3), "chain continuity"); + assertEq(bucket2.rollingHash, expectedRollingHash, "chain matches reference"); + assertEq(bucket2.totalMsgCount, 3, "cumulative total spans buckets"); + assertEq(bucket2.timestamp, uint64(block.timestamp), "bucket 2 timestamp"); + assertEq(bucket2.msgCount, 1, "bucket 2 msg count"); + } + + function testRolloverIntoNextBucket() public { + uint256 cap = inbox.MAX_MSGS_PER_BUCKET(); + for (uint256 i = 0; i < cap; i++) { + _send(inbox, i); + } + assertEq(inbox.getCurrentBucketSeq(), 1, "cap messages fit in one bucket"); + IInbox.InboxBucket memory bucket1 = inbox.getBucket(1); + assertEq(bucket1.msgCount, cap, "bucket 1 full"); + + // The next message in the same L1 block spills over into a new bucket with the same timestamp. + bytes32 leaf = _send(inbox, cap); + assertEq(inbox.getCurrentBucketSeq(), 2, "rollover opened next bucket"); + + IInbox.InboxBucket memory bucket2 = inbox.getBucket(2); + assertEq(bucket2.rollingHash, Hash.accumulateInboxRollingHash(bucket1.rollingHash, leaf), "chain continuity"); + assertEq(bucket2.totalMsgCount, cap + 1, "cumulative total"); + assertEq(bucket2.timestamp, bucket1.timestamp, "same block, same timestamp"); + assertEq(bucket2.msgCount, 1, "spilled message only"); + + assertEq(inbox.getBucket(1).msgCount, cap, "bucket 1 untouched by rollover"); + } + + function testRingWraparound() public { + InboxHarness ringInbox = _deployInbox(MIN_BUCKET_RING_SIZE); + expectedRollingHash = 0; + + // One bucket per L1 block; after MIN_BUCKET_RING_SIZE + 1 buckets the ring has wrapped past bucket 1. + for (uint256 i = 1; i <= MIN_BUCKET_RING_SIZE + 1; i++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _send(ringInbox, i); + } + + uint256 current = ringInbox.getCurrentBucketSeq(); + assertEq(current, MIN_BUCKET_RING_SIZE + 1, "one bucket per block"); + + // Buckets 0 and 1 have been overwritten: their ring slots were reused by buckets + // MIN_BUCKET_RING_SIZE and MIN_BUCKET_RING_SIZE + 1. + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 0, current)); + ringInbox.getBucket(0); + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 1, current)); + ringInbox.getBucket(1); + + // The live window is intact, with per-bucket data at the right ring slots. + uint64 previousTimestamp = 0; + for (uint256 seq = current - MIN_BUCKET_RING_SIZE + 1; seq <= current; seq++) { + IInbox.InboxBucket memory bucket = ringInbox.getBucket(seq); + assertEq(bucket.totalMsgCount, seq, "cumulative total"); + assertEq(bucket.msgCount, 1, "one message per bucket"); + assertGt(bucket.timestamp, previousTimestamp, "timestamps increase per bucket"); + previousTimestamp = bucket.timestamp; + } + assertEq(ringInbox.getBucket(current).rollingHash, expectedRollingHash, "chain matches reference"); + + // Buckets ahead of the current one do not exist yet. + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, current + 1, current)); + ringInbox.getBucket(current + 1); + } + + function testConstructorRevertsBelowRingFloor() public { + IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this)); + vm.expectRevert("BUCKET RING TOO SMALL"); + new InboxHarness(address(this), feeAsset, version, HEIGHT, TestConstants.AZTEC_INBOX_LAG, MIN_BUCKET_RING_SIZE - 1); + } + + // Gas cost of a message absorbed into an already-open bucket (the common per-message case): the + // second message of an L1 block updates the live bucket in place without opening a new ring slot. + function testGasSendIntoExistingBucket() public { + _send(inbox, 0); + assertEq(inbox.getCurrentBucketSeq(), 1, "warmup opened bucket 1"); + + uint256 gasUsed = _measureSend(inbox, 1); + emit log_named_uint("gas: absorb into existing bucket", gasUsed); + + assertEq(inbox.getCurrentBucketSeq(), 1, "absorbed without opening a new bucket"); + } + + // Gas cost of the first message of a new L1 block: a larger timestamp opens the next bucket, + // writing a fresh ring slot on top of the per-message frontier-tree insert. + function testGasSendFirstMessageOfNewBlock() public { + _send(inbox, 0); + assertEq(inbox.getCurrentBucketSeq(), 1, "warmup opened bucket 1"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + + uint256 gasUsed = _measureSend(inbox, 1); + emit log_named_uint("gas: first message of a new L1 block", gasUsed); + + assertEq(inbox.getCurrentBucketSeq(), 2, "new block opened bucket 2"); + } + + // 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(); + for (uint256 i = 0; i < cap; i++) { + _send(inbox, i); + } + assertEq(inbox.getCurrentBucketSeq(), 1, "cap messages fit in bucket 1"); + + uint256 gasUsed = _measureSend(inbox, cap); + emit log_named_uint("gas: rollover open mid-block", gasUsed); + + assertEq(inbox.getCurrentBucketSeq(), 2, "rollover opened bucket 2"); + } + + // Gas cost of the first-ever message against a freshly deployed Inbox: the state struct, bucket 1, and the + // first frontier-tree slots are all written cold. This is the cold-storage case, not the global worst-case + // insert — later frontier indices with more levels to hash can cost more. + function testGasSendFirstEverMessage() public { + assertEq(inbox.getCurrentBucketSeq(), 0, "no message sent yet"); + + uint256 gasUsed = _measureSend(inbox, 0); + emit log_named_uint("gas: first-ever message", gasUsed); + + assertEq(inbox.getCurrentBucketSeq(), 1, "first message opened bucket 1"); + } +} diff --git a/l1-contracts/test/fee_portal/depositToAztecPublic.t.sol b/l1-contracts/test/fee_portal/depositToAztecPublic.t.sol index 0a4b0c99374d..61a5ff73a90f 100644 --- a/l1-contracts/test/fee_portal/depositToAztecPublic.t.sol +++ b/l1-contracts/test/fee_portal/depositToAztecPublic.t.sol @@ -111,8 +111,9 @@ contract DepositToAztecPublic is Test { bytes16 expectedHash = bytes16(keccak256(abi.encodePacked(inbox.getState().rollingHash, expectedKey))); uint256 expectedInProgress = Constants.INITIAL_CHECKPOINT_NUMBER + TestConstants.AZTEC_INBOX_LAG; + bytes32 expectedInboxRollingHash = Hash.accumulateInboxRollingHash(bytes32(0), expectedKey); vm.expectEmit(true, true, true, true, address(inbox)); - emit IInbox.MessageSent(expectedInProgress, expectedIndex, expectedKey, expectedHash); + emit IInbox.MessageSent(expectedInProgress, expectedIndex, expectedKey, expectedHash, expectedInboxRollingHash, 1); vm.expectEmit(true, true, true, true, address(feeJuicePortal)); emit IFeeJuicePortal.DepositToAztecPublic(to, amount, secretHash, expectedKey, expectedIndex); diff --git a/l1-contracts/test/harnesses/InboxHarness.sol b/l1-contracts/test/harnesses/InboxHarness.sol index 5257aa8a196b..1ba2b3b446ca 100644 --- a/l1-contracts/test/harnesses/InboxHarness.sol +++ b/l1-contracts/test/harnesses/InboxHarness.sol @@ -10,8 +10,8 @@ import {FrontierLib} from "@aztec/core/libraries/crypto/FrontierLib.sol"; contract InboxHarness is Inbox { using FrontierLib for FrontierLib.Tree; - constructor(address _rollup, IERC20 _feeAsset, uint256 _version, uint256 _height, uint256 _lag) - Inbox(_rollup, _feeAsset, _version, _height, _lag) + constructor(address _rollup, IERC20 _feeAsset, uint256 _version, uint256 _height, uint256 _lag, uint256 _ringSize) + Inbox(_rollup, _feeAsset, _version, _height, _lag, _ringSize) {} function getSize() external view returns (uint256) { diff --git a/l1-contracts/test/harnesses/TestConstants.sol b/l1-contracts/test/harnesses/TestConstants.sol index c0d7a6738981..44aee456e1d4 100644 --- a/l1-contracts/test/harnesses/TestConstants.sol +++ b/l1-contracts/test/harnesses/TestConstants.sol @@ -26,6 +26,7 @@ library TestConstants { uint256 internal constant AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET = 3; uint256 internal constant AZTEC_LAG_IN_EPOCHS_FOR_RANDAO = 2; uint256 internal constant AZTEC_INBOX_LAG = 2; + uint256 internal constant AZTEC_INBOX_BUCKET_RING_SIZE = 1024; uint256 internal constant AZTEC_PROOF_SUBMISSION_EPOCHS = 1; uint256 internal constant AZTEC_SLASHING_QUORUM = 17; // Must be > ROUND_SIZE / 2 (ROUND_SIZE derived from // EPOCH_DURATION) diff --git a/l1-contracts/test/portals/TokenPortal.t.sol b/l1-contracts/test/portals/TokenPortal.t.sol index 47e341d38f68..cb6bf77e3bd6 100644 --- a/l1-contracts/test/portals/TokenPortal.t.sol +++ b/l1-contracts/test/portals/TokenPortal.t.sol @@ -124,10 +124,11 @@ contract TokenPortalTest is Test { bytes32 expectedLeaf = expectedMessage.sha256ToField(); bytes16 expectedHash = bytes16(keccak256(abi.encodePacked(inbox.getState().rollingHash, expectedLeaf))); + bytes32 expectedInboxRollingHash = Hash.accumulateInboxRollingHash(bytes32(0), expectedLeaf); // Check the event was emitted vm.expectEmit(true, true, true, true); // event we expect - emit IInbox.MessageSent(FIRST_REAL_TREE_NUM, expectedIndex, expectedLeaf, expectedHash); + emit IInbox.MessageSent(FIRST_REAL_TREE_NUM, expectedIndex, expectedLeaf, expectedHash, expectedInboxRollingHash, 1); // event we will get // Perform op @@ -150,11 +151,12 @@ contract TokenPortalTest is Test { DataStructures.L1ToL2Msg memory expectedMessage = _createExpectedMintPublicL1ToL2Message(expectedIndex); bytes32 expectedLeaf = expectedMessage.sha256ToField(); bytes16 expectedHash = bytes16(keccak256(abi.encodePacked(inbox.getState().rollingHash, expectedLeaf))); + bytes32 expectedInboxRollingHash = Hash.accumulateInboxRollingHash(bytes32(0), expectedLeaf); // Check the event was emitted vm.expectEmit(true, true, true, true); // event we expect - emit IInbox.MessageSent(FIRST_REAL_TREE_NUM, expectedIndex, expectedLeaf, expectedHash); + emit IInbox.MessageSent(FIRST_REAL_TREE_NUM, expectedIndex, expectedLeaf, expectedHash, expectedInboxRollingHash, 1); // Perform op (bytes32 leaf, uint256 index) = tokenPortal.depositToAztecPublic(to, amount, secretHashForL2MessageConsumption); diff --git a/yarn-project/archiver/src/test/fake_l1_state.ts b/yarn-project/archiver/src/test/fake_l1_state.ts index 0b9cbcb177f2..d5e5bf6e2197 100644 --- a/yarn-project/archiver/src/test/fake_l1_state.ts +++ b/yarn-project/archiver/src/test/fake_l1_state.ts @@ -628,6 +628,8 @@ export class FakeL1State { index: msg.index, leaf: msg.leaf, rollingHash: msg.rollingHash, + inboxRollingHash: Fr.ZERO, + bucketSeq: 0n, }, })); } @@ -651,6 +653,8 @@ export class FakeL1State { index: msg.index, leaf: msg.leaf, rollingHash: msg.rollingHash, + inboxRollingHash: Fr.ZERO, + bucketSeq: 0n, }, }; } diff --git a/yarn-project/ethereum/src/contracts/inbox.ts b/yarn-project/ethereum/src/contracts/inbox.ts index 77bcfd1a1f15..23d7f6ce3df4 100644 --- a/yarn-project/ethereum/src/contracts/inbox.ts +++ b/yarn-project/ethereum/src/contracts/inbox.ts @@ -20,6 +20,10 @@ export type MessageSentArgs = { leaf: Fr; checkpointNumber: CheckpointNumber; rollingHash: Buffer16; + /** Consensus rolling hash (truncated sha256 chain) after this message (AZIP-22 Fast Inbox). Not yet consumed by the node. */ + inboxRollingHash: Fr; + /** Sequence number of the Inbox bucket this message was absorbed into (AZIP-22 Fast Inbox). Not yet consumed by the node. */ + bucketSeq: bigint; }; /** Log type for MessageSent events. */ @@ -100,7 +104,14 @@ export class InboxContract { blockNumber: bigint | null; blockHash: `0x${string}` | null; transactionHash: `0x${string}` | null; - args: { index?: bigint; hash?: `0x${string}`; checkpointNumber?: bigint; rollingHash?: `0x${string}` }; + args: { + index?: bigint; + hash?: `0x${string}`; + checkpointNumber?: bigint; + rollingHash?: `0x${string}`; + inboxRollingHash?: `0x${string}`; + bucketSeq?: bigint; + }; }): MessageSentLog { return { l1BlockNumber: log.blockNumber!, @@ -111,6 +122,8 @@ export class InboxContract { leaf: Fr.fromString(log.args.hash!), checkpointNumber: CheckpointNumber.fromBigInt(log.args.checkpointNumber!), rollingHash: Buffer16.fromString(log.args.rollingHash!), + inboxRollingHash: Fr.fromString(log.args.inboxRollingHash!), + bucketSeq: log.args.bucketSeq!, }, }; }