Skip to content
2 changes: 2 additions & 0 deletions docs/examples/ts/aave_bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ const INBOX_ABI = [
{ name: "index", type: "uint256", indexed: false },
{ name: "hash", type: "bytes32", indexed: true },
{ name: "rollingHash", type: "bytes16", indexed: false },
{ name: "inboxRollingHash", type: "bytes32", indexed: false },
{ name: "bucketSeq", type: "uint256", indexed: false },
],
},
] as const;
Expand Down
2 changes: 2 additions & 0 deletions docs/examples/ts/example_swap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ const INBOX_ABI = [
{ name: "index", type: "uint256", indexed: false },
{ name: "hash", type: "bytes32", indexed: true },
{ name: "rollingHash", type: "bytes16", indexed: false },
{ name: "inboxRollingHash", type: "bytes32", indexed: false },
{ name: "bucketSeq", type: "uint256", indexed: false },
],
},
] as const;
Expand Down
2 changes: 2 additions & 0 deletions docs/examples/ts/token_bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ const INBOX_ABI = [
{ name: "index", type: "uint256", indexed: false },
{ name: "hash", type: "bytes32", indexed: true },
{ name: "rollingHash", type: "bytes16", indexed: false },
{ name: "inboxRollingHash", type: "bytes32", indexed: false },
{ name: "bucketSeq", type: "uint256", indexed: false },
],
},
] as const;
Expand Down
11 changes: 9 additions & 2 deletions l1-contracts/src/core/RollupCore.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
)
)
);

Expand Down
47 changes: 46 additions & 1 deletion l1-contracts/src/core/interfaces/messagebridge/IInbox.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
/**
Expand Down Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions l1-contracts/src/core/libraries/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions l1-contracts/src/core/libraries/crypto/Hash.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
96 changes: 94 additions & 2 deletions l1-contracts/src/core/messagebridge/Inbox.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;

Expand All @@ -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);

Expand Down Expand Up @@ -122,7 +161,9 @@ 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);
}
Expand Down Expand Up @@ -177,4 +218,55 @@ 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];
}

/**
* @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);
}
}
9 changes: 7 additions & 2 deletions l1-contracts/test/Inbox.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading