Skip to content

Commit 57ce718

Browse files
committed
feat: streaming inbox propose validation, unwired
1 parent 68714ab commit 57ce718

3 files changed

Lines changed: 352 additions & 0 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ library Errors {
5959
error Rollup__InvalidCheckpointHeaderCount(uint256 expected, uint256 actual);
6060
error Rollup__InvalidCheckpointNumber(uint256 expected, uint256 actual); // 0xd1ba9bfa
6161
error Rollup__InvalidInHash(bytes32 expected, bytes32 actual); // 0xcd6f4233
62+
error Rollup__InvalidInboxRollingHash(bytes32 expected, bytes32 actual); // 0xed1f7bb5
63+
error Rollup__UnconsumedInboxMessages(uint256 nextBucketSeq); // 0x2bd4bf10
64+
error Rollup__InboxConsumptionBehindParent(uint256 expected, uint256 actual); // 0x54e0c025
65+
error Rollup__TooManyInboxMessagesConsumed(uint256 consumed); // 0xf76d1426
6266
error Rollup__InvalidOutHash(bytes32 expected, bytes32 actual); // 0x8eb39062
6367
error Rollup__InvalidPreviousArchive(bytes32 expected, bytes32 actual); // 0xb682a40e
6468
error Rollup__InvalidProof(); // 0xa5b2ba17

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pragma solidity >=0.8.27;
44

55
import {BlobLib} from "@aztec-blob-lib/BlobLib.sol";
66
import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol";
7+
import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
78
import {RollupStore, IRollupCore, CheckpointHeaderValidationFlags} from "@aztec/core/interfaces/IRollup.sol";
89
import {TempCheckpointLog} from "@aztec/core/libraries/compressed-data/CheckpointLog.sol";
910
import {FeeHeader} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol";
@@ -20,6 +21,14 @@ import {Signature} from "@aztec/shared/libraries/SignatureLib.sol";
2021
import {ProposedHeader, ProposedHeaderLib} from "./ProposedHeaderLib.sol";
2122
import {STFLib} from "./STFLib.sol";
2223

24+
// Streaming-inbox protocol constants (AZIP-22 Fast Inbox). These mirror the protocol circuit constants and
25+
// should move into the generated Constants library once the Solidity emitter includes them.
26+
// Minimum bucket age, in seconds, at the start of a checkpoint's build frame for its consumption to be
27+
// mandatory. One L1 slot: validators cannot be required to act on buckets they may not have seen.
28+
uint256 constant INBOX_LAG_SECONDS = 12;
29+
// Maximum number of L1 to L2 messages a single checkpoint can insert.
30+
uint256 constant MAX_L1_TO_L2_MSGS_PER_CHECKPOINT = 1024;
31+
2332
struct ProposeArgs {
2433
bytes32 archive;
2534
OracleInput oracleInput;
@@ -370,6 +379,78 @@ library ProposeLib {
370379
);
371380
}
372381

382+
/**
383+
* @notice Validates a checkpoint's Inbox consumption against the streaming inbox buckets and returns how
384+
* far consumption has reached. Not yet called from propose(): the legacy `consume()`/`inHash` flow
385+
* remains the enforced path until the streaming inbox (AZIP-22 Fast Inbox) flips on.
386+
*
387+
* @dev Read-only; performs no Inbox write. Checks, in order:
388+
* 1. The checkpoint header's `inboxRollingHash` must equal the rolling hash snapshotted in the Inbox
389+
* bucket referenced by `_bucketHint`. The hint is a plain calldata lookup aid, not signed and not
390+
* part of the header: a wrong hint cannot change what gets accepted, it only reverts. A checkpoint
391+
* that consumes no messages references the same bucket as its parent.
392+
* 2. Consumption moves forward: the referenced bucket's cumulative total must be at least the parent
393+
* checkpoint's (equal consumes nothing; behind is a hard revert). This precedes the subtractions
394+
* below, which rely on `bucket.totalMsgCount >= _parentTotalMsgCount` to not underflow.
395+
* 3. Cap upper bound: a single checkpoint cannot consume more than MAX_L1_TO_L2_MSGS_PER_CHECKPOINT
396+
* messages, the maximum the circuits can insert.
397+
* 4. Mandatory consumption (the censorship assert): the first unconsumed bucket (`_bucketHint + 1`)
398+
* must either not exist, sit past the consumption cutoff, or be cap-escaped — consuming through it
399+
* would exceed MAX_L1_TO_L2_MSGS_PER_CHECKPOINT messages since the parent checkpoint's cumulative
400+
* total. The cutoff is the start of the checkpoint's build frame minus INBOX_LAG_SECONDS: a
401+
* checkpoint proposed in slot S is built during slot S-1, and validators are not required to have
402+
* seen buckets younger than one L1 slot at build start.
403+
*
404+
* No consumed-bucket pointer is written here. The caller (FI-14) stores the returned consumed
405+
* position in the checkpoint's temp-log record, which is the authoritative consumed total: temp logs
406+
* rewind with the pending chain on a prune, so the record stays prune-consistent — unlike an
407+
* Inbox-side pointer advanced with the pending chain, which would sit ahead of the replacement chain.
408+
*
409+
* @param _inbox - The Inbox holding the rolling-hash buckets
410+
* @param _inboxRollingHash - The checkpoint header's inbox rolling hash
411+
* @param _bucketHint - Sequence number of the bucket the header's rolling hash corresponds to
412+
* @param _slotNumber - The slot the checkpoint is proposed in
413+
* @param _parentTotalMsgCount - Cumulative Inbox message count consumed as of the parent checkpoint
414+
* @return The cumulative Inbox message count consumed as of this checkpoint (`bucket.totalMsgCount`), for
415+
* the caller to store in the checkpoint's temp-log record
416+
*/
417+
function validateInboxConsumption(
418+
IInbox _inbox,
419+
bytes32 _inboxRollingHash,
420+
uint256 _bucketHint,
421+
Slot _slotNumber,
422+
uint256 _parentTotalMsgCount
423+
) internal view returns (uint256) {
424+
IInbox.InboxBucket memory bucket = _inbox.getBucket(_bucketHint);
425+
require(
426+
bucket.rollingHash == _inboxRollingHash,
427+
Errors.Rollup__InvalidInboxRollingHash(bucket.rollingHash, _inboxRollingHash)
428+
);
429+
430+
require(
431+
bucket.totalMsgCount >= _parentTotalMsgCount,
432+
Errors.Rollup__InboxConsumptionBehindParent(_parentTotalMsgCount, bucket.totalMsgCount)
433+
);
434+
435+
require(
436+
bucket.totalMsgCount - _parentTotalMsgCount <= MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
437+
Errors.Rollup__TooManyInboxMessagesConsumed(bucket.totalMsgCount - _parentTotalMsgCount)
438+
);
439+
440+
if (_bucketHint < _inbox.getCurrentBucketSeq()) {
441+
IInbox.InboxBucket memory next = _inbox.getBucket(_bucketHint + 1);
442+
Timestamp buildFrameStart = TimeLib.toTimestamp(_slotNumber - Slot.wrap(1));
443+
Timestamp cutoff = buildFrameStart - Timestamp.wrap(INBOX_LAG_SECONDS);
444+
require(
445+
next.timestamp > Timestamp.unwrap(cutoff)
446+
|| next.totalMsgCount - _parentTotalMsgCount > MAX_L1_TO_L2_MSGS_PER_CHECKPOINT,
447+
Errors.Rollup__UnconsumedInboxMessages(_bucketHint + 1)
448+
);
449+
}
450+
451+
return bucket.totalMsgCount;
452+
}
453+
373454
/**
374455
* @notice Gets the mana min fee components
375456
* For more context, consult:
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2024 Aztec Labs.
3+
pragma solidity >=0.8.27;
4+
5+
import {Test} from "forge-std/Test.sol";
6+
import {TestERC20} from "src/mock/TestERC20.sol";
7+
import {IERC20} from "@oz/token/ERC20/IERC20.sol";
8+
import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
9+
import {
10+
ProposeLib,
11+
INBOX_LAG_SECONDS,
12+
MAX_L1_TO_L2_MSGS_PER_CHECKPOINT
13+
} from "@aztec/core/libraries/rollup/ProposeLib.sol";
14+
import {TimeLib, Slot, Timestamp} from "@aztec/core/libraries/TimeLib.sol";
15+
import {Errors} from "@aztec/core/libraries/Errors.sol";
16+
import {DataStructures} from "@aztec/core/libraries/DataStructures.sol";
17+
import {InboxHarness} from "../harnesses/InboxHarness.sol";
18+
import {TestConstants} from "../harnesses/TestConstants.sol";
19+
20+
contract ProposeLibHarness {
21+
constructor(uint256 _genesisTime, uint256 _slotDuration, uint256 _epochDuration) {
22+
TimeLib.initialize(_genesisTime, _slotDuration, _epochDuration, 1);
23+
}
24+
25+
function validateInboxConsumption(
26+
IInbox _inbox,
27+
bytes32 _inboxRollingHash,
28+
uint256 _bucketHint,
29+
Slot _slotNumber,
30+
uint256 _parentTotalMsgCount
31+
) external view returns (uint256) {
32+
return ProposeLib.validateInboxConsumption(
33+
_inbox, _inboxRollingHash, _bucketHint, _slotNumber, _parentTotalMsgCount
34+
);
35+
}
36+
}
37+
38+
contract ProposeInboxConsumptionTest is Test {
39+
uint256 internal constant GENESIS_TIME = 100_000;
40+
uint256 internal constant SLOT_DURATION = 36;
41+
uint256 internal constant EPOCH_DURATION = 32;
42+
uint256 internal constant HEIGHT = 10;
43+
uint256 internal constant SMALL_RING_SIZE = 4;
44+
45+
Slot internal constant SLOT = Slot.wrap(10);
46+
47+
ProposeLibHarness internal rollup;
48+
InboxHarness internal inbox;
49+
uint256 internal version = 0;
50+
51+
// Start of the build frame for a checkpoint proposed in SLOT: it is built during the previous slot.
52+
uint256 internal buildFrameStart = GENESIS_TIME + (Slot.unwrap(SLOT) - 1) * SLOT_DURATION;
53+
// Buckets at or before the cutoff must be consumed by the checkpoint.
54+
uint256 internal cutoff = buildFrameStart - INBOX_LAG_SECONDS;
55+
56+
function setUp() public {
57+
vm.warp(GENESIS_TIME);
58+
rollup = new ProposeLibHarness(GENESIS_TIME, SLOT_DURATION, EPOCH_DURATION);
59+
inbox = _deployInbox(TestConstants.AZTEC_INBOX_BUCKET_RING_SIZE);
60+
}
61+
62+
function _deployInbox(uint256 _ringSize) internal returns (InboxHarness) {
63+
IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this));
64+
return new InboxHarness(address(rollup), feeAsset, version, HEIGHT, TestConstants.AZTEC_INBOX_LAG, _ringSize);
65+
}
66+
67+
function _send(uint256 _salt) internal {
68+
inbox.sendL2Message(
69+
DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}),
70+
bytes32(uint256(0x2000 + _salt)),
71+
bytes32(uint256(0x3000 + _salt))
72+
);
73+
}
74+
75+
function _sendMany(uint256 _count) internal {
76+
for (uint256 i = 0; i < _count; i++) {
77+
_send(i);
78+
}
79+
}
80+
81+
function testEmptyInbox() public {
82+
uint256 consumed = rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0);
83+
assertEq(consumed, 0, "consumed nothing on empty inbox");
84+
}
85+
86+
function testEmptyInboxRejectsNonZeroHash() public {
87+
vm.expectRevert(
88+
abi.encodeWithSelector(Errors.Rollup__InvalidInboxRollingHash.selector, bytes32(0), bytes32(uint256(1)))
89+
);
90+
rollup.validateInboxConsumption(inbox, bytes32(uint256(1)), 0, SLOT, 0);
91+
}
92+
93+
function testConsumeUpToLatestBucket() public {
94+
vm.warp(cutoff);
95+
_sendMany(3);
96+
bytes32 endHash = inbox.getBucket(1).rollingHash;
97+
98+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
99+
uint256 consumed = rollup.validateInboxConsumption(inbox, endHash, 1, SLOT, 0);
100+
assertEq(consumed, 3, "consumed all three messages");
101+
}
102+
103+
function testExactCutoffBucketMustBeConsumed() public {
104+
// A bucket created exactly at the cutoff is the "latest bucket at/before build-frame start minus lag":
105+
// consuming nothing is no longer allowed.
106+
vm.warp(cutoff);
107+
_send(0);
108+
109+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
110+
vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnconsumedInboxMessages.selector, 1));
111+
rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0);
112+
}
113+
114+
function testBucketPastCutoffNeedNotBeConsumed() public {
115+
vm.warp(cutoff + 1);
116+
_send(0);
117+
118+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
119+
uint256 consumed = rollup.validateInboxConsumption(inbox, bytes32(0), 0, SLOT, 0);
120+
assertEq(consumed, 0, "nothing consumed");
121+
}
122+
123+
function testCapEscape() public {
124+
// One more message than the checkpoint cap, all before the cutoff. They spill over into buckets
125+
// 1..4 of 256 (the per-bucket cap) plus bucket 5 with the single excess message.
126+
vm.warp(cutoff - 100);
127+
_sendMany(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1);
128+
assertEq(inbox.getCurrentBucketSeq(), 5, "expected five buckets");
129+
130+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
131+
132+
// Consuming through bucket 4 exhausts the cap exactly, so bucket 5 escapes the censorship assert
133+
// even though it is old.
134+
bytes32 endHash = inbox.getBucket(4).rollingHash;
135+
uint256 consumed = rollup.validateInboxConsumption(inbox, endHash, 4, SLOT, 0);
136+
assertEq(consumed, MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, "consumed the full cap");
137+
}
138+
139+
function testNoCapEscapeAtExactCap() public {
140+
vm.warp(cutoff - 100);
141+
_sendMany(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1);
142+
143+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
144+
145+
// Stopping at bucket 3 leaves bucket 4 unconsumed; consuming through it would total exactly the cap,
146+
// which fits in one checkpoint, so there is no escape and the old bucket must be consumed.
147+
bytes32 endHash = inbox.getBucket(3).rollingHash;
148+
vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnconsumedInboxMessages.selector, 4));
149+
rollup.validateInboxConsumption(inbox, endHash, 3, SLOT, 0);
150+
}
151+
152+
function testCapEscapeAccountsForParentTotal() public {
153+
// Same layout as testCapEscape, but the parent checkpoint had already consumed one message:
154+
// buckets 2..5 then hold cap messages total, which fit in one checkpoint, so no escape from bucket 1.
155+
vm.warp(cutoff - 100);
156+
_sendMany(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1);
157+
158+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
159+
160+
bytes32 endHash = inbox.getBucket(4).rollingHash;
161+
vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnconsumedInboxMessages.selector, 5));
162+
rollup.validateInboxConsumption(inbox, endHash, 4, SLOT, 1);
163+
}
164+
165+
function testStaleHashRejection() public {
166+
vm.warp(cutoff);
167+
_send(0);
168+
vm.roll(block.number + 1);
169+
vm.warp(cutoff + 10);
170+
_send(1);
171+
172+
bytes32 staleHash = inbox.getBucket(1).rollingHash;
173+
bytes32 currentHash = inbox.getBucket(2).rollingHash;
174+
175+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
176+
vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InvalidInboxRollingHash.selector, currentHash, staleHash));
177+
rollup.validateInboxConsumption(inbox, staleHash, 2, SLOT, 0);
178+
}
179+
180+
function testUnknownHashRejection() public {
181+
vm.warp(cutoff);
182+
_send(0);
183+
184+
bytes32 unknownHash = bytes32(uint256(0xdead));
185+
bytes32 bucketHash = inbox.getBucket(1).rollingHash;
186+
187+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
188+
vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InvalidInboxRollingHash.selector, bucketHash, unknownHash));
189+
rollup.validateInboxConsumption(inbox, unknownHash, 1, SLOT, 0);
190+
}
191+
192+
function testHintBeyondCurrentBucketRejected() public {
193+
vm.warp(cutoff);
194+
_send(0);
195+
196+
vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 2, 1));
197+
rollup.validateInboxConsumption(inbox, bytes32(0), 2, SLOT, 0);
198+
}
199+
200+
function testHintOnOverwrittenBucketRejected() public {
201+
InboxHarness smallRingInbox = _deployInbox(SMALL_RING_SIZE);
202+
203+
uint256 timestamp = cutoff - 100;
204+
for (uint256 i = 1; i <= SMALL_RING_SIZE + 1; i++) {
205+
vm.roll(block.number + 1);
206+
vm.warp(timestamp + i);
207+
smallRingInbox.sendL2Message(
208+
DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + i)), version: version}),
209+
bytes32(uint256(0x2000 + i)),
210+
bytes32(uint256(0x3000 + i))
211+
);
212+
}
213+
214+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
215+
vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 1, SMALL_RING_SIZE + 1));
216+
rollup.validateInboxConsumption(smallRingInbox, bytes32(0), 1, SLOT, 0);
217+
}
218+
219+
function testConsumeBackwardsReverts() public {
220+
// Buckets 1 and 2 exist with distinct cumulative totals. A proposal that references bucket 1 while its
221+
// parent already consumed through bucket 2 would move consumption backwards.
222+
vm.warp(cutoff - 100);
223+
_send(0);
224+
vm.roll(block.number + 1);
225+
vm.warp(cutoff - 50);
226+
_send(1);
227+
228+
uint256 parentTotal = inbox.getBucket(2).totalMsgCount;
229+
uint256 bucketTotal = inbox.getBucket(1).totalMsgCount;
230+
bytes32 bucketHash = inbox.getBucket(1).rollingHash;
231+
232+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
233+
vm.expectRevert(
234+
abi.encodeWithSelector(Errors.Rollup__InboxConsumptionBehindParent.selector, parentTotal, bucketTotal)
235+
);
236+
rollup.validateInboxConsumption(inbox, bucketHash, 1, SLOT, parentTotal);
237+
}
238+
239+
function testEqualReferenceConsumesNothing() public {
240+
// The parent already consumed the current bucket; re-referencing it with an equal parent total consumes
241+
// nothing and returns the unchanged cumulative total. No newer pre-cutoff bucket exists.
242+
vm.warp(cutoff);
243+
_sendMany(2);
244+
uint256 total = inbox.getBucket(1).totalMsgCount;
245+
bytes32 endHash = inbox.getBucket(1).rollingHash;
246+
247+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
248+
uint256 consumed = rollup.validateInboxConsumption(inbox, endHash, 1, SLOT, total);
249+
assertEq(consumed, total, "equal reference returns the unchanged total");
250+
}
251+
252+
function testCapUpperBoundReverts() public {
253+
// One more message than the checkpoint cap, all before the cutoff, referenced in a single proposal from
254+
// a fresh parent: the consumed delta exceeds what the circuits can insert.
255+
vm.warp(cutoff - 100);
256+
_sendMany(MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1);
257+
assertEq(inbox.getCurrentBucketSeq(), 5, "expected five buckets");
258+
259+
bytes32 endHash = inbox.getBucket(5).rollingHash;
260+
261+
vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION);
262+
vm.expectRevert(
263+
abi.encodeWithSelector(Errors.Rollup__TooManyInboxMessagesConsumed.selector, MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1)
264+
);
265+
rollup.validateInboxConsumption(inbox, endHash, 5, SLOT, 0);
266+
}
267+
}

0 commit comments

Comments
 (0)