From 455099a3928facedd6d3ee0b4d0b55bec943c034 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 10 Sep 2026 12:24:30 -0300 Subject: [PATCH 1/3] feat(l1): gate inbox bucket eviction on proven consumption Opening an Inbox bucket reuses the ring slot of the bucket `INBOX_BUCKET_RING_SIZE` positions back. Until now that overwrite was unconditional, so a burst of traffic could destroy messages that were still in flight, and worse, could slide the retained window off the only buckets a chain whose consumption had stalled could still propose against -- a gap that never closes, because every bucket left in the window is too far ahead of the stalled parent total to be consumed within one checkpoint's cap. Eviction is now gated on the proven chain having consumed the evicted entry. The Rollup pushes `markProvenConsumed` on every proven-tip advance that moved the rolling hash, reading the bucket sequence the checkpoint recorded at propose time; the Inbox refuses to open a bucket whose ring slot is not covered by that record, so exhausting the ring halts sends rather than overwriting. Anchoring on proven consumption is prune-immune -- the proven tip never rewinds -- and fail-closed, since the record can only lag the truth. `getRingHeadroom()` exposes how many bucket openings are left, and the ring grows from 1024 to 4096 so it covers the worst-case proving lag with enough margin that adversarial bucket creation cannot cheaply exhaust it. Carrying the consumed bucket needs one more `uint64` on the checkpoint temp log, which shares the slot-number storage word with the message total, so recording it costs no extra slot. Propose grows about 310 gas and an epoch proof about 40. --- l1-contracts/gas_benchmark.md | 14 +- l1-contracts/gas_benchmark_results.json | 40 +- l1-contracts/gas_report.json | 112 +++--- .../partial_epoch_proof_gas_report.json | 42 +- .../partial_epoch_proof_gas_report.md | 10 +- .../core/interfaces/messagebridge/IInbox.sol | 26 ++ l1-contracts/src/core/libraries/Errors.sol | 2 + .../compressed-data/CheckpointLog.sol | 12 +- .../core/libraries/rollup/EpochProofLib.sol | 9 + .../src/core/libraries/rollup/ProposeLib.sol | 3 +- .../src/core/libraries/rollup/STFLib.sol | 14 +- l1-contracts/src/core/messagebridge/Inbox.sol | 63 ++- l1-contracts/test/InboxBuckets.t.sol | 2 + .../test/InboxOverwriteProtection.t.sol | 362 ++++++++++++++++++ l1-contracts/test/Rollup.t.sol | 89 ++++- l1-contracts/test/base/RollupBase.sol | 22 +- l1-contracts/test/fees/MinimalFeeModel.sol | 3 +- l1-contracts/test/harnesses/TestConstants.sol | 2 +- .../test/rollup/InboxRingDeadlock.t.sol | 137 +++++++ .../test/rollup/ProposeInboxConsumption.t.sol | 3 + .../libraries/rewardlib/RewardLibWrapper.sol | 3 +- ...rry-the-consumed-inbox-bucket-in-che.patch | 129 +++++++ 22 files changed, 981 insertions(+), 118 deletions(-) create mode 100644 l1-contracts/test/InboxOverwriteProtection.t.sol create mode 100644 l1-contracts/test/rollup/InboxRingDeadlock.t.sol create mode 100644 labs-patches/0012-feat-ethereum-carry-the-consumed-inbox-bucket-in-che.patch diff --git a/l1-contracts/gas_benchmark.md b/l1-contracts/gas_benchmark.md index 6d185bac2243..c7da56e4e562 100644 --- a/l1-contracts/gas_benchmark.md +++ b/l1-contracts/gas_benchmark.md @@ -14,22 +14,22 @@ | Function | Avg Gas | Max Gas | Calldata Size | Calldata Gas | |----------------------|---------|---------|---------------|--------------| -| propose | 198,220 | 224,403 | 996 | 15,936 | -| submitEpochRootProof | 925,398 | 966,444 | 14,212 | 227,392 | +| propose | 198,527 | 224,710 | 996 | 15,936 | +| submitEpochRootProof | 925,439 | 966,485 | 14,212 | 227,392 | | setupEpoch | 32,020 | 113,815 | - | - | -**Avg Gas Cost per Second**: 3,570.2 gas/second +**Avg Gas Cost per Second**: 3,574.5 gas/second *Epoch duration*: 0h 38m 24s ## Validators | Function | Avg Gas | Max Gas | Calldata Size | Calldata Gas | |----------------------|-----------|-----------|---------------|--------------| -| propose | 326,630 | 354,402 | 4,516 | 72,256 | -| submitEpochRootProof | 1,505,290 | 1,605,780 | 16,708 | 267,328 | -| aggregate3 | 375,623 | 388,983 | - | - | +| propose | 326,942 | 354,715 | 4,516 | 72,256 | +| submitEpochRootProof | 1,505,331 | 1,605,821 | 16,708 | 267,328 | +| aggregate3 | 375,936 | 389,296 | - | - | | setupEpoch | 46,482 | 547,648 | - | - | -**Avg Gas Cost per Second**: 5,863.4 gas/second +**Avg Gas Cost per Second**: 5,867.7 gas/second *Epoch duration*: 0h 38m 24s diff --git a/l1-contracts/gas_benchmark_results.json b/l1-contracts/gas_benchmark_results.json index 4c4f736c4468..eae259bf540d 100644 --- a/l1-contracts/gas_benchmark_results.json +++ b/l1-contracts/gas_benchmark_results.json @@ -2,10 +2,10 @@ "no_validators": { "propose": { "calls": 150, - "min": 184575, - "mean": 198220, - "median": 193964, - "max": 224403, + "min": 184882, + "mean": 198527, + "median": 194272, + "max": 224710, "calldata_size": 996, "calldata_gas": 15936 }, @@ -18,10 +18,10 @@ }, "submitEpochRootProof": { "calls": 4, - "min": 904514, - "mean": 925398, - "median": 915318, - "max": 966444, + "min": 904555, + "mean": 925439, + "median": 915359, + "max": 966485, "calldata_size": 14212, "calldata_gas": 227392 } @@ -29,10 +29,10 @@ "validators": { "propose": { "calls": 150, - "min": 304233, - "mean": 326630, - "median": 326074, - "max": 354402, + "min": 304546, + "mean": 326942, + "median": 326387, + "max": 354715, "calldata_size": 4516, "calldata_gas": 72256 }, @@ -45,19 +45,19 @@ }, "submitEpochRootProof": { "calls": 4, - "min": 1393292, - "mean": 1505290, - "median": 1511045, - "max": 1605780, + "min": 1393333, + "mean": 1505331, + "median": 1511086, + "max": 1605821, "calldata_size": 16708, "calldata_gas": 267328 }, "aggregate3": { "calls": 55, - "min": 364444, - "mean": 375623, - "median": 375366, - "max": 388983 + "min": 364757, + "mean": 375936, + "median": 375678, + "max": 389296 } } } \ No newline at end of file diff --git a/l1-contracts/gas_report.json b/l1-contracts/gas_report.json index 4ce98dc13ef7..fa3f1190ac30 100644 --- a/l1-contracts/gas_report.json +++ b/l1-contracts/gas_report.json @@ -3,36 +3,50 @@ "contract": "src/core/messagebridge/Inbox.sol:Inbox", "deployment": { "gas": 0, - "size": 6140 + "size": 6805 }, "functions": { "getBucket(uint256)": { - "calls": 4677, - "min": 7414, - "mean": 7414, - "median": 7414, - "max": 7414 + "calls": 4689, + "min": 7436, + "mean": 7436, + "median": 7436, + "max": 7436 }, "getCurrentBucketSeq()": { - "calls": 4677, - "min": 408, - "mean": 1407, - "median": 408, - "max": 2408 + "calls": 4694, + "min": 441, + "mean": 1441, + "median": 2441, + "max": 2441 }, "getFeeAssetPortal()": { - "calls": 2588, + "calls": 2592, "min": 212, "mean": 212, "median": 212, "max": 212 }, + "getProvenConsumedBucketSeq()": { + "calls": 6, + "min": 2403, + "mean": 2403, + "median": 2403, + "max": 2403 + }, + "getRingHeadroom()": { + "calls": 1, + "min": 2618, + "mean": 2618, + "median": 2618, + "max": 2618 + }, "sendL2Message((bytes32,uint256),bytes32,bytes32)": { - "calls": 37408, + "calls": 37489, "min": 43269, - "mean": 46584, + "mean": 46587, "median": 43269, - "max": 102022 + "max": 102063 } } }, @@ -60,7 +74,7 @@ }, "functions": { "owner()": { - "calls": 5176, + "calls": 5184, "min": 397, "mean": 397, "median": 397, @@ -76,21 +90,21 @@ }, "functions": { "getCanonicalRollup()": { - "calls": 1762, + "calls": 1770, "min": 1073, "mean": 4073, "median": 4073, "max": 7073 }, "getRewardDistributor()": { - "calls": 2588, + "calls": 2592, "min": 420, "mean": 420, "median": 420, "max": 420 }, "owner()": { - "calls": 7764, + "calls": 7776, "min": 353, "mean": 353, "median": 353, @@ -106,7 +120,7 @@ }, "functions": { "availableTo(address)": { - "calls": 881, + "calls": 885, "min": 20573, "mean": 20573, "median": 20573, @@ -118,25 +132,25 @@ "contract": "test/RollupWithPreheating.sol:RollupWithPreheating", "deployment": { "gas": 0, - "size": 41936 + "size": 42838 }, "functions": { "archive()": { - "calls": 2338, + "calls": 2344, "min": 4641, "mean": 4641, "median": 4641, "max": 4641 }, "getCheckpoint(uint256)": { - "calls": 891, - "min": 27185, - "mean": 27185, - "median": 27185, - "max": 27185 + "calls": 897, + "min": 27343, + "mean": 27343, + "median": 27343, + "max": 27343 }, "getCheckpointReward()": { - "calls": 2591, + "calls": 2595, "min": 1302, "mean": 1307, "median": 1302, @@ -150,7 +164,7 @@ "max": 5948 }, "getCurrentEpoch()": { - "calls": 882, + "calls": 886, "min": 915, "mean": 915, "median": 915, @@ -185,14 +199,14 @@ "max": 11021 }, "getFeeAssetPortal()": { - "calls": 4664, + "calls": 4672, "min": 879, "mean": 879, "median": 879, "max": 879 }, "getInbox()": { - "calls": 9090, + "calls": 9112, "min": 856, "mean": 856, "median": 856, @@ -206,9 +220,9 @@ "max": 9064 }, "getManaMinFeeAt(uint256,bool)": { - "calls": 2341, + "calls": 2347, "min": 27286, - "mean": 28946, + "mean": 28944, "median": 27286, "max": 32304 }, @@ -227,14 +241,14 @@ "max": 856 }, "getPendingCheckpointNumber()": { - "calls": 1679, + "calls": 1681, "min": 2440, "mean": 2440, "median": 2440, "max": 2440 }, "getProvenCheckpointNumber()": { - "calls": 1685, + "calls": 1687, "min": 2585, "mean": 2585, "median": 2585, @@ -262,37 +276,37 @@ "max": 5959 }, "getTimestampForSlot(uint256)": { - "calls": 2447, + "calls": 2455, "min": 2808, "mean": 2808, "median": 2808, "max": 2808 }, "getVersion()": { - "calls": 4926, + "calls": 4936, "min": 852, "mean": 852, "median": 852, "max": 852 }, "owner()": { - "calls": 5177, + "calls": 5185, "min": 511, "mean": 511, "median": 511, "max": 2511 }, "propose((bytes32,(int256),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256),uint256),(bytes,bytes),address[],(uint8,bytes32,bytes32),bytes)": { - "calls": 2344, + "calls": 2350, "min": 55404, - "mean": 265738, - "median": 283135, - "max": 324245 + "mean": 266090, + "median": 283443, + "max": 324553 }, "prune()": { - "calls": 6, + "calls": 7, "min": 26689, - "mean": 33247, + "mean": 33279, "median": 33682, "max": 38274 }, @@ -304,11 +318,11 @@ "max": 55821 }, "submitEpochRootProof((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { - "calls": 889, + "calls": 893, "min": 60875, - "mean": 387946, - "median": 393723, - "max": 427739 + "mean": 397634, + "median": 403830, + "max": 437846 }, "updateManaTarget(uint256)": { "calls": 512, @@ -327,7 +341,7 @@ }, "functions": { "isAllBeneficiariesAllowed()": { - "calls": 2588, + "calls": 2592, "min": 404, "mean": 404, "median": 404, diff --git a/l1-contracts/partial_epoch_proof_gas_report.json b/l1-contracts/partial_epoch_proof_gas_report.json index bb226e4768ce..86e98639dbed 100644 --- a/l1-contracts/partial_epoch_proof_gas_report.json +++ b/l1-contracts/partial_epoch_proof_gas_report.json @@ -3,43 +3,43 @@ "contract": "test/RollupWithPreheating.sol:RollupWithPreheating", "deployment": { "gas": 0, - "size": 41936 + "size": 42838 }, "functions": { "gasReportSubmit16Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1246224, - "mean": 1246224, - "median": 1246224, - "max": 1246224 + "min": 1246265, + "mean": 1246265, + "median": 1246265, + "max": 1246265 }, "gasReportSubmit1Checkpoint((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 654868, - "mean": 654868, - "median": 654868, - "max": 654868 + "min": 654909, + "mean": 654909, + "median": 654909, + "max": 654909 }, "gasReportSubmit32Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1732651, - "mean": 1732651, - "median": 1732651, - "max": 1732651 + "min": 1732692, + "mean": 1732692, + "median": 1732692, + "max": 1732692 }, "gasReportSubmit8Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 956875, - "mean": 956875, - "median": 956875, - "max": 956875 + "min": 956916, + "mean": 956916, + "median": 956916, + "max": 956916 }, "gasReportSubmit8MoreCheckpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 910405, - "mean": 910405, - "median": 910405, - "max": 910405 + "min": 910446, + "mean": 910446, + "median": 910446, + "max": 910446 } } } diff --git a/l1-contracts/partial_epoch_proof_gas_report.md b/l1-contracts/partial_epoch_proof_gas_report.md index 9cb2f0b0f9ab..d0eb0adaaa01 100644 --- a/l1-contracts/partial_epoch_proof_gas_report.md +++ b/l1-contracts/partial_epoch_proof_gas_report.md @@ -2,10 +2,10 @@ | Proof submission | Gas | |---|---:| -| 1 Checkpoint | 654,868 | -| 8 Checkpoints | 956,875 | -| 8 More Checkpoints | 910,405 | -| 16 Checkpoints | 1,246,224 | -| 32 Checkpoints | 1,732,651 | +| 1 Checkpoint | 654,909 | +| 8 Checkpoints | 956,916 | +| 8 More Checkpoints | 910,446 | +| 16 Checkpoints | 1,246,265 | +| 32 Checkpoints | 1,732,692 | _Uses the mock epoch proof verifier; real ZK verification and top-level transaction calldata gas are not included._ diff --git a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol index c3d4bef8df51..cbecaf0e5b37 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol @@ -77,6 +77,16 @@ interface IInbox { returns (bytes32, uint256); // docs:end:send_l1_to_l2_message + /** + * @notice Records that the proven chain has consumed all messages up to and including bucket `_bucketSeq`, + * unlocking eviction of buckets at or below it when the ring wraps + * @dev Only callable by the rollup. Monotonic: a value at or below the current record is a no-op. Reverts with + * `Inbox__Unauthorized` if the caller is not the rollup, and with `Inbox__BucketOutOfWindow` if `_bucketSeq` is + * ahead of the current bucket. + * @param _bucketSeq - The sequence number of the newest bucket the proven chain has consumed + */ + function markProvenConsumed(uint64 _bucketSeq) external; + function getFeeAssetPortal() external view returns (address); function getState() external view returns (InboxState memory); @@ -96,4 +106,20 @@ interface IInbox { * @return The bucket */ function getBucket(uint256 _seq) external view returns (InboxBucket memory); + + /** + * @notice Returns the sequence number of the newest bucket consumed by the proven chain + * @return The proven-consumed bucket sequence number + */ + function getProvenConsumedBucketSeq() external view returns (uint64); + + /** + * @notice Returns the number of buckets that can still be opened before `sendL2Message` reverts to protect an + * unconsumed bucket from being overwritten + * @dev Counts bucket openings, not messages: at zero, messages can still be absorbed into the current bucket + * until it fills or its L1 block passes. Equals the ring size at genesis and recovers as proofs advance the + * proven-consumed record. + * @return The number of buckets that can still be opened + */ + function getRingHeadroom() external view returns (uint256); } diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index a5152d81beab..cce835cb2b2d 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -27,6 +27,8 @@ library Errors { error Inbox__ContentTooLarge(bytes32 content); // 0x47452014 error Inbox__SecretHashTooLarge(bytes32 secretHash); // 0xecde7e2c error Inbox__BucketOutOfWindow(uint256 seq, uint256 current); // 0xfee255b7 + error Inbox__Unauthorized(); // 0xe5336a6b + error Inbox__WouldOverwriteUnconsumedBucket(uint64 evictedBucketSeq); // 0x2eb49c6d // Outbox error Outbox__Unauthorized(); // 0x2c9490c2 diff --git a/l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol b/l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol index 65038cbc842d..06d70d2de8ca 100644 --- a/l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol +++ b/l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol @@ -34,10 +34,13 @@ struct TempCheckpointLog { bytes32 attestationsHash; bytes32 payloadDigest; Slot slotNumber; - // Streaming Inbox consumption count: the cumulative Inbox message count consumed as of this checkpoint (the - // child's parent-total origin). Declared next to the slot number so the two share one storage slot (4 + 8 of 32 - // bytes): propose writes and reads that slot for the slot-progression check anyway. + // Streaming Inbox consumption counts. `inboxMsgTotal` is the cumulative Inbox message count + // consumed as of this checkpoint (the child's parent-total origin), read back by the child's propose; + // `inboxConsumedBucket` is the bucket sequence number the header's rolling hash corresponds to, read back on a + // proven-tip advance to release the Inbox ring up to it. Declared next to the slot number so the three share one + // storage slot (4 + 8 + 8 of 32 bytes): propose writes and reads that slot for the slot-progression check anyway. uint64 inboxMsgTotal; + uint64 inboxConsumedBucket; FeeHeader feeHeader; // The consensus Inbox rolling hash the checkpoint header committed to, in a slot of its own: epoch proofs anchor // both ends of their consumed chain segment against it. @@ -52,6 +55,7 @@ struct CompressedTempCheckpointLog { bytes32 payloadDigest; CompressedSlot slotNumber; uint64 inboxMsgTotal; + uint64 inboxConsumedBucket; CompressedFeeHeader feeHeader; bytes32 inboxRollingHash; } @@ -71,6 +75,7 @@ library CompressedTempCheckpointLogLib { payloadDigest: _checkpoint.payloadDigest, slotNumber: _checkpoint.slotNumber.compress(), inboxMsgTotal: _checkpoint.inboxMsgTotal, + inboxConsumedBucket: _checkpoint.inboxConsumedBucket, feeHeader: _checkpoint.feeHeader.compress(), inboxRollingHash: _checkpoint.inboxRollingHash }); @@ -89,6 +94,7 @@ library CompressedTempCheckpointLogLib { payloadDigest: _compressedCheckpoint.payloadDigest, slotNumber: _compressedCheckpoint.slotNumber.decompress(), inboxMsgTotal: _compressedCheckpoint.inboxMsgTotal, + inboxConsumedBucket: _compressedCheckpoint.inboxConsumedBucket, feeHeader: _compressedCheckpoint.feeHeader.decompress(), inboxRollingHash: _compressedCheckpoint.inboxRollingHash }); diff --git a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol index c921a384f822..dfba6892c950 100644 --- a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol +++ b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol @@ -156,6 +156,15 @@ library EpochProofLib { // between walk forward to it; a later proof of an already proven range cannot reach here and overwrite it. STFLib.recordFirstProvenBy(_args.end, _args.args.proverId); + // Unlock Inbox ring eviction up to the bucket the newly proven tip consumed; its temp-log record was + // validated against the Inbox at propose time. Equal start and end rolling hashes mean the epoch consumed + // no messages, so the bucket is the one already recorded and the cross-contract write is skipped: both + // values are trusted here (the start was checked against storage, the end is bound by the proof), and a + // rolling hash identifies exactly one bucket since every bucket absorbs at least one message. + if (_args.args.previousInboxRollingHash != _args.args.endInboxRollingHash) { + _config.inbox.markProvenConsumed(STFLib.getInboxConsumedBucket(_args.end)); + } + // Handle L2->L1 message processing. // The circuit outputs an empty out hash tree root if the epoch contains no messages. // Since the out hash tree is append-only, with the first checkpoint at index 0, the second at index 1, and so on, diff --git a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol index 83a0004a14e5..bd7aaa8d736b 100644 --- a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol @@ -321,7 +321,8 @@ library ProposeLib { slotNumber: v.header.slotNumber, feeHeader: v.feeHeader, inboxRollingHash: v.header.inboxRollingHash, - inboxMsgTotal: v.consumedInboxMsgTotal.toUint64() + inboxMsgTotal: v.consumedInboxMsgTotal.toUint64(), + inboxConsumedBucket: _args.bucketHint.toUint64() }) ); diff --git a/l1-contracts/src/core/libraries/rollup/STFLib.sol b/l1-contracts/src/core/libraries/rollup/STFLib.sol index a92199b4e470..508981315552 100644 --- a/l1-contracts/src/core/libraries/rollup/STFLib.sol +++ b/l1-contracts/src/core/libraries/rollup/STFLib.sol @@ -132,7 +132,8 @@ library STFLib { // Genesis Inbox consumption base case, matching the Inbox's genesis bucket-0 sentinel {0, 0, 0}, so // checkpoint 1 validates its consumption against it. inboxRollingHash: bytes32(0), - inboxMsgTotal: 0 + inboxMsgTotal: 0, + inboxConsumedBucket: 0 }).compress(); } @@ -361,6 +362,17 @@ library STFLib { return getStorageTempCheckpointLog(_checkpointNumber).inboxRollingHash; } + /** + * @notice Retrieves the sequence number of the newest Inbox bucket a checkpoint consumed + * @dev Gas-efficient accessor reading only the streaming-inbox bucket sequence. Reverts if the checkpoint is + * stale. + * @param _checkpointNumber The checkpoint number to get the consumed bucket for + * @return The sequence number of the newest Inbox bucket consumed as of the checkpoint + */ + function getInboxConsumedBucket(uint256 _checkpointNumber) internal view returns (uint64) { + return getStorageTempCheckpointLog(_checkpointNumber).inboxConsumedBucket; + } + /** * @notice Gets the effective pending checkpoint number based on pruning eligibility * @dev Returns either the pending checkpoint number or proven checkpoint number depending on diff --git a/l1-contracts/src/core/messagebridge/Inbox.sol b/l1-contracts/src/core/messagebridge/Inbox.sol index 4b85a06ddde2..21cf892c42b5 100644 --- a/l1-contracts/src/core/messagebridge/Inbox.sol +++ b/l1-contracts/src/core/messagebridge/Inbox.sol @@ -12,10 +12,13 @@ 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 Aztec slot); 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; +// Number of buckets in the rolling-hash ring. Eviction is gated on proven consumption, so the ring has to +// cover the worst-case proving lag (~2 epochs of one bucket per L1 block, which is what MIN_BUCKET_RING_SIZE +// below is derived from) with enough margin that adversarial bucket creation cannot cheaply exhaust it: a +// forced rollover costs ~2.2M gas, so burning this much headroom takes ~250 continuously-owned L1 blocks +// (~50 min, ~9B gas). Ring size bounds retention only — 2 storage slots per live bucket, no per-send gas — and +// exhausting it halts sends rather than overwriting unconsumed buckets. +uint256 constant INBOX_BUCKET_RING_SIZE = 4096; // 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 @@ -43,6 +46,12 @@ contract Inbox is IInbox { uint64 internal currentBucketSeq; + // Sequence number of the newest bucket consumed by the proven chain, pushed by the Rollup on every proven-tip + // advance. Anchoring eviction to proven consumption is prune-immune (the proven tip never rewinds) and + // fail-closed (the cache can only lag the truth). Shares a slot with currentBucketSeq so the overwrite check + // reads it warm. + uint64 internal provenConsumedBucketSeq; + constructor(address _rollup, IERC20 _feeAsset, uint256 _version, uint256 _bucketRingSize) { ROLLUP = _rollup; VERSION = _version; @@ -108,6 +117,22 @@ contract Inbox is IInbox { return (leaf, index); } + /** + * @notice Records that the proven chain has consumed all messages up to and including bucket `_bucketSeq` + * + * @dev Callable only by the ROLLUP. Monotonic: a value at or below the current record is a no-op. Reverts if + * `_bucketSeq` is ahead of the current bucket. + * + * @param _bucketSeq - The sequence number of the newest bucket the proven chain has consumed + */ + function markProvenConsumed(uint64 _bucketSeq) external override(IInbox) { + require(msg.sender == ROLLUP, Errors.Inbox__Unauthorized()); + require(_bucketSeq <= currentBucketSeq, Errors.Inbox__BucketOutOfWindow(_bucketSeq, currentBucketSeq)); + if (_bucketSeq > provenConsumedBucketSeq) { + provenConsumedBucketSeq = _bucketSeq; + } + } + function getFeeAssetPortal() external view override(IInbox) returns (address) { return FEE_ASSET_PORTAL; } @@ -133,14 +158,34 @@ contract Inbox is IInbox { return buckets[_seq % BUCKET_RING_SIZE]; } + function getProvenConsumedBucketSeq() external view override(IInbox) returns (uint64) { + return provenConsumedBucketSeq; + } + + /** + * @notice Returns the number of buckets that can still be opened before sends revert to protect an unconsumed + * bucket from being overwritten + * + * @dev Counts bucket openings, not messages: at zero, messages can still be absorbed into the current bucket + * until it fills or its L1 block passes. Neither subtraction can underflow: `markProvenConsumed` keeps the + * record at or below `currentBucketSeq`, and opening bucket n requires `provenConsumedBucketSeq >= n - + * BUCKET_RING_SIZE`, so the unconsumed span never exceeds the ring. + * + * @return The number of buckets that can still be opened + */ + function getRingHeadroom() external view override(IInbox) returns (uint256) { + return BUCKET_RING_SIZE - (currentBucketSeq - provenConsumedBucketSeq); + } + /** * @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. + * case and never absorbs. Opening a bucket overwrites the ring entry from BUCKET_RING_SIZE buckets ago; the + * open reverts unless the proven chain has consumed that entry, so in-flight messages are never destroyed — + * sends halt instead until proving catches up. * * @param _leaf - The message leaf to absorb * @@ -157,6 +202,12 @@ contract Inbox is IInbox { // 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; + if (bucketSeq >= BUCKET_RING_SIZE) { + uint64 evictedBucketSeq = SafeCast.toUint64(bucketSeq - BUCKET_RING_SIZE); + require( + provenConsumedBucketSeq >= evictedBucketSeq, Errors.Inbox__WouldOverwriteUnconsumedBucket(evictedBucketSeq) + ); + } currentBucketSeq = bucketSeq; bucket = InboxBucket({ rollingHash: bucket.rollingHash, diff --git a/l1-contracts/test/InboxBuckets.t.sol b/l1-contracts/test/InboxBuckets.t.sol index d1e2da6d16fd..bad3cd4e59c5 100644 --- a/l1-contracts/test/InboxBuckets.t.sol +++ b/l1-contracts/test/InboxBuckets.t.sol @@ -227,6 +227,8 @@ contract InboxBucketsTest is Test { vm.roll(block.number + 1); vm.warp(block.timestamp + 12); _send(ringInbox, i); + // Evicting a ring slot requires the proven chain to have consumed it, so keep consumption trailing the sends. + ringInbox.markProvenConsumed(uint64(i - 1)); } uint256 current = ringInbox.getCurrentBucketSeq(); diff --git a/l1-contracts/test/InboxOverwriteProtection.t.sol b/l1-contracts/test/InboxOverwriteProtection.t.sol new file mode 100644 index 000000000000..1e262bb308fd --- /dev/null +++ b/l1-contracts/test/InboxOverwriteProtection.t.sol @@ -0,0 +1,362 @@ +// 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 {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; +import {InboxHarness} from "./harnesses/InboxHarness.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"; + +// Sends a batch of messages in one L1 transaction and lets a revert from any of them bubble out, taking the +// whole batch with it. Models a portal or bridge that fans several messages out per call. +contract RevertingBatchSender { + function sendMany(IInbox _inbox, uint256 _version, uint256 _count) external { + for (uint256 i = 0; i < _count; i++) { + _inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x5000 + i)), version: _version}), + bytes32(uint256(0x6000 + i)), + bytes32(uint256(0x7000 + i)) + ); + } + } +} + +// Same batch, but each send is wrapped in try/catch, so a revert on one message does not undo the others. +contract CatchingBatchSender { + function sendMany(IInbox _inbox, uint256 _version, uint256 _count) external returns (uint256 succeeded) { + for (uint256 i = 0; i < _count; i++) { + try _inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x5000 + i)), version: _version}), + bytes32(uint256(0x6000 + i)), + bytes32(uint256(0x7000 + i)) + ) { + succeeded += 1; + } catch {} + } + } +} + +/** + * Overwrite protection on the bucket ring: opening a bucket reuses the ring slot of the bucket + * BUCKET_RING_SIZE positions back, and the open is refused unless the proven chain has consumed that slot. + * The Inbox in these tests is owned by the test contract, which stands in for the rollup and pushes the + * proven-consumed record directly. + */ +contract InboxOverwriteProtectionTest is Test { + uint256 internal constant RING_SIZE = MIN_BUCKET_RING_SIZE; + + InboxHarness internal inbox; + uint256 internal version = 0; + + function setUp() public { + inbox = _deployInbox(address(this)); + } + + function _deployInbox(address _rollup) internal returns (InboxHarness) { + IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this)); + return new InboxHarness(_rollup, feeAsset, version, RING_SIZE); + } + + function _send(InboxHarness _inbox, uint256 _salt) internal returns (bytes32 leaf, uint256 index) { + (leaf, index) = _inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}), + bytes32(uint256(0x2000 + _salt)), + bytes32(uint256(0x3000 + _salt)) + ); + } + + // Sends without touching the return values: an intercepted revert leaves no returndata to decode, so a send + // fronted by `vm.expectRevert` must not be the one that assigns them. + function _sendExpectingOverwriteRevert(InboxHarness _inbox, uint256 _salt, uint64 _evicted) internal { + 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)); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__WouldOverwriteUnconsumedBucket.selector, _evicted)); + _inbox.sendL2Message(recipient, content, secretHash); + } + + // Opens `_count` buckets, one per L1 block: a strictly larger block timestamp forces the message into a + // freshly opened bucket. The last bucket is left open in the current L1 block. + function _openBuckets(InboxHarness _inbox, uint256 _count) internal { + uint256 startSeq = _inbox.getCurrentBucketSeq(); + for (uint256 i = 0; i < _count; i++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _send(_inbox, startSeq + i); + } + assertEq(_inbox.getCurrentBucketSeq(), startSeq + _count, "buckets opened"); + } + + // Fills the ring exactly once. Opening bucket RING_SIZE evicts the genesis bucket, which is consumed from the + // start, so it is allowed; the next bucket opening is the first one that can be refused. + function _reachRingWall(InboxHarness _inbox) internal { + _openBuckets(_inbox, RING_SIZE); + assertEq(_inbox.getRingHeadroom(), 0, "ring wall reached"); + } + + function _assertBucketEq(IInbox.InboxBucket memory _actual, IInbox.InboxBucket memory _expected, string memory _err) + internal + pure + { + assertEq(_actual.rollingHash, _expected.rollingHash, _err); + assertEq(_actual.totalMsgCount, _expected.totalMsgCount, _err); + assertEq(_actual.timestamp, _expected.timestamp, _err); + assertEq(_actual.msgCount, _expected.msgCount, _err); + } + + // With nothing proven-consumed, the ring fills to one wrap and then refuses the bucket that would overwrite + // bucket 1: its messages are still in flight, so the send is what has to fail, and it must fail without + // leaving a trace. + function testWrapIntoUnconsumedReverts() public { + _reachRingWall(inbox); + + IInbox.InboxBucket memory head = inbox.getBucket(RING_SIZE); + IInbox.InboxBucket memory oldest = inbox.getBucket(1); + uint64 totalBefore = inbox.getTotalMessagesInserted(); + assertEq(oldest.totalMsgCount, 1, "bucket 1 holds the first message"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 999, 1); + + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE, "current bucket unchanged"); + assertEq(inbox.getTotalMessagesInserted(), totalBefore, "no message inserted"); + _assertBucketEq(inbox.getBucket(RING_SIZE), head, "head bucket untouched"); + _assertBucketEq(inbox.getBucket(1), oldest, "oldest unconsumed bucket untouched"); + assertEq(inbox.getRingHeadroom(), 0, "still no headroom"); + } + + // The check compares the proven-consumed record against the exact bucket being evicted: releasing bucket 1 + // unlocks exactly one more opening, and the one after it stops on bucket 2. + function testExactBoundaryUnlocksOneBucket() public { + _reachRingWall(inbox); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 1000, 1); + + inbox.markProvenConsumed(1); + _send(inbox, 1000); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE + 1, "releasing the evicted bucket allowed the opening"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 1001, 2); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE + 1, "one release unlocks one opening only"); + } + + // A halted send is resumable: once the proven chain releases the bucket, the same message goes in, extends the + // rolling-hash chain from the ring head, and takes the index the failed attempt left unused. + function testResumeAfterProvingPreservesChain() public { + _reachRingWall(inbox); + + bytes32 headHash = inbox.getBucket(RING_SIZE).rollingHash; + uint64 totalBefore = inbox.getTotalMessagesInserted(); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 1234, 1); + + inbox.markProvenConsumed(1); + (bytes32 leaf, uint256 index) = _send(inbox, 1234); + + IInbox.InboxBucket memory opened = inbox.getBucket(RING_SIZE + 1); + assertEq(opened.rollingHash, Hash.accumulateInboxRollingHash(headHash, leaf), "chain continues from the ring head"); + assertEq(index, totalBefore, "the failed send consumed no index"); + assertEq(opened.totalMsgCount, totalBefore + 1, "cumulative total advanced by one"); + } + + // Only proven consumption releases a bucket. L1 blocks going by does not, so a chain that stops proving keeps + // the Inbox halted for as long as it stalls rather than recovering on its own. + function testTimeAloneDoesNotUnlock() public { + _reachRingWall(inbox); + + for (uint256 i = 0; i < 5; i++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 2000 + i, 1); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE, "still at the ring wall"); + assertEq(inbox.getRingHeadroom(), 0, "still no headroom"); + } + + inbox.markProvenConsumed(1); + _send(inbox, 2100); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE + 1, "proven consumption is the only unlock"); + } + + // The proven-consumed record decides which ring slots may be overwritten, so only the rollup may move it. + function testMarkProvenConsumedOnlyRollup() public { + InboxHarness rollupOwned = _deployInbox(address(0xbeef)); + _send(rollupOwned, 0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__Unauthorized.selector)); + rollupOwned.markProvenConsumed(1); + assertEq(rollupOwned.getProvenConsumedBucketSeq(), 0, "record unchanged"); + + vm.prank(address(0xbeef)); + rollupOwned.markProvenConsumed(1); + assertEq(rollupOwned.getProvenConsumedBucketSeq(), 1, "the rollup moved the record"); + } + + // The record only moves forward: the rollup pushes it on every proven-tip advance, and a shorter epoch proof + // or a re-submission must not walk it back and re-lock slots the ring may already have reused. + function testMarkProvenConsumedMonotonic() public { + _openBuckets(inbox, 6); + + inbox.markProvenConsumed(5); + assertEq(inbox.getProvenConsumedBucketSeq(), 5, "record set"); + + inbox.markProvenConsumed(3); + assertEq(inbox.getProvenConsumedBucketSeq(), 5, "a lower value is a no-op"); + + inbox.markProvenConsumed(5); + assertEq(inbox.getProvenConsumedBucketSeq(), 5, "an equal value is a no-op"); + + inbox.markProvenConsumed(6); + assertEq(inbox.getProvenConsumedBucketSeq(), 6, "a higher value advances"); + } + + // A record ahead of the newest bucket would release ring slots that hold nothing yet, so it is rejected + // rather than clamped: it can only come from the rollup and the Inbox disagreeing about the window. + function testMarkProvenConsumedAheadOfCurrentReverts() public { + _openBuckets(inbox, 3); + uint64 current = inbox.getCurrentBucketSeq(); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, current + 1, current)); + inbox.markProvenConsumed(current + 1); + assertEq(inbox.getProvenConsumedBucketSeq(), 0, "record unchanged"); + } + + // Headroom counts bucket openings left, not messages: it drops by one per bucket opened, is untouched by a + // message absorbed into the open bucket, and rises by exactly what the proven chain releases. + function testRingHeadroomSemantics() public { + assertEq(inbox.getRingHeadroom(), RING_SIZE, "genesis: the whole ring is available"); + + _send(inbox, 0); + assertEq(inbox.getRingHeadroom(), RING_SIZE - 1, "the first message opened one bucket"); + + _send(inbox, 1); + assertEq(inbox.getRingHeadroom(), RING_SIZE - 1, "absorbing into the open bucket opens nothing"); + + _openBuckets(inbox, 3); + assertEq(inbox.getRingHeadroom(), RING_SIZE - 4, "one opening each"); + + inbox.markProvenConsumed(2); + assertEq(inbox.getRingHeadroom(), RING_SIZE - 2, "released two buckets"); + + inbox.markProvenConsumed(4); + assertEq(inbox.getRingHeadroom(), RING_SIZE, "released the remaining two"); + + // At zero headroom the wall applies to bucket openings only: a bucket still open in the current L1 block and + // below the per-bucket cap keeps absorbing, so messages are not blocked until a rollover is needed. + InboxHarness wallInbox = _deployInbox(address(this)); + _reachRingWall(wallInbox); + + uint64 totalBefore = wallInbox.getTotalMessagesInserted(); + _send(wallInbox, 4242); + assertEq(wallInbox.getTotalMessagesInserted(), totalBefore + 1, "absorbed at zero headroom"); + assertEq(wallInbox.getCurrentBucketSeq(), RING_SIZE, "no bucket opened"); + assertEq(wallInbox.getRingHeadroom(), 0, "headroom still zero"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(wallInbox, 4243, 1); + } + + // A batching caller that lets a revert bubble loses the whole batch at the ring wall: the head bucket is full, + // so the batch's first send has to roll over into a slot that is not released yet. + function testRevertingBatchIsAtomic() public { + _reachRingWall(inbox); + for (uint256 i = 1; i < MAX_MSGS_PER_BUCKET; i++) { + _send(inbox, 3000 + i); + } + assertEq(inbox.getBucket(RING_SIZE).msgCount, MAX_MSGS_PER_BUCKET, "head bucket full"); + + uint64 totalBefore = inbox.getTotalMessagesInserted(); + RevertingBatchSender sender = new RevertingBatchSender(); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__WouldOverwriteUnconsumedBucket.selector, uint64(1))); + sender.sendMany(inbox, version, 5); + + assertEq(inbox.getTotalMessagesInserted(), totalBefore, "no message from the batch landed"); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE, "no bucket opened"); + } + + // A batching caller that swallows the revert keeps the sends that fit before the wall: the head bucket is one + // short of the per-bucket cap, so the first send absorbs and only the rollovers after it are refused. + function testCatchingBatchKeepsPreWallSends() public { + _reachRingWall(inbox); + for (uint256 i = 1; i < MAX_MSGS_PER_BUCKET - 1; i++) { + _send(inbox, 4000 + i); + } + assertEq(inbox.getBucket(RING_SIZE).msgCount, MAX_MSGS_PER_BUCKET - 1, "head bucket one short of full"); + + uint64 totalBefore = inbox.getTotalMessagesInserted(); + CatchingBatchSender sender = new CatchingBatchSender(); + + uint256 succeeded = sender.sendMany(inbox, version, 5); + + assertEq(succeeded, 1, "only the send that fit before the wall"); + assertEq(inbox.getTotalMessagesInserted(), totalBefore + 1, "exactly one message landed"); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE, "no bucket opened"); + } + + /// forge-config: default.fuzz.runs = 32 + // Random interleavings of bucket openings and proven-consumption releases, starting at the ring wall so every + // run exercises both sides of the check, against a model of the ring: no sequence of the two overwrites a + // bucket the proven chain has not released, and every refused send names the exact bucket it would have + // destroyed. + function testFuzzNoUnconsumedOverwrite(uint256 _seed) public { + _reachRingWall(inbox); + uint256 modelCurrent = RING_SIZE; + + uint256 modelProven = 0; + uint256 modelTotal = modelCurrent; + + for (uint256 i = 0; i < 16; i++) { + uint256 entropy = uint256(keccak256(abi.encodePacked(_seed, i))); + + if (entropy % 3 == 0) { + uint256 target = modelProven + ((entropy >> 8) % 8); + if (target > modelCurrent) { + target = modelCurrent; + } + inbox.markProvenConsumed(uint64(target)); + modelProven = target; + continue; + } + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + + uint256 opening = modelCurrent + 1; + if (opening < RING_SIZE || modelProven >= opening - RING_SIZE) { + _send(inbox, 5000 + i); + modelCurrent = opening; + modelTotal += 1; + } else { + _sendExpectingOverwriteRevert(inbox, 5000 + i, uint64(opening - RING_SIZE)); + } + } + + assertEq(inbox.getCurrentBucketSeq(), modelCurrent, "current bucket tracks the model"); + assertEq(inbox.getProvenConsumedBucketSeq(), modelProven, "proven-consumed record tracks the model"); + assertEq(inbox.getRingHeadroom(), modelProven + RING_SIZE - modelCurrent, "headroom tracks the model"); + assertEq(inbox.getTotalMessagesInserted(), modelTotal, "every allowed send landed and no refused one did"); + + if (modelProven < modelCurrent) { + // Every bucket in this run holds exactly one message, so the oldest unreleased bucket's cumulative total + // is its own sequence number: a wrapped-over slot would report someone else's. + IInbox.InboxBucket memory oldest = inbox.getBucket(modelProven + 1); + assertEq(oldest.msgCount, 1, "oldest unreleased bucket holds its own message"); + assertEq(oldest.totalMsgCount, modelProven + 1, "oldest unreleased bucket was never overwritten"); + } + } +} diff --git a/l1-contracts/test/Rollup.t.sol b/l1-contracts/test/Rollup.t.sol index bb4619c90c22..0b518207bba3 100644 --- a/l1-contracts/test/Rollup.t.sol +++ b/l1-contracts/test/Rollup.t.sol @@ -11,7 +11,7 @@ import {Math} from "@oz/utils/math/Math.sol"; import {SafeCast} from "@oz/utils/math/SafeCast.sol"; import {Registry} from "@aztec/governance/Registry.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 {Errors} from "@aztec/core/libraries/Errors.sol"; import {ProposedHeader, ProposedHeaderLib} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol"; @@ -1070,6 +1070,93 @@ contract RollupTest is RollupBase { assertNotEq(publicInputs[4], storedEnd, "wrong end must not be replaced by the stored value"); } + // Ring eviction is gated on proven consumption, not pending consumption: proposing records the consumed bucket + // in the checkpoint's temp log only, and the Inbox's record moves once the epoch proof makes that checkpoint the + // proven tip. + function testProvenConsumedBucketAdvancesOnEpochProof() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + + uint64 consumedBucket = inbox.getCurrentBucketSeq(); + assertGt(consumedBucket, 0, "checkpoint consumed L1 to L2 messages"); + assertEq(inbox.getProvenConsumedBucketSeq(), 0, "pending consumption does not unlock eviction"); + + _proveCheckpoints("mixed_checkpoint_", 1, 1, address(this)); + + assertEq(inbox.getProvenConsumedBucketSeq(), consumedBucket, "proven consumption caught up with the proposal"); + assertEq(inbox.getRingHeadroom(), INBOX_BUCKET_RING_SIZE, "the whole ring is available again"); + } + + // An epoch that consumed no messages proves with equal start and end rolling hashes, and the proven-tip advance + // skips the Inbox write since the record could not move. + function testEmptyInboxEpochProofSkipsInboxWrite() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + _proveCheckpoints("mixed_checkpoint_", 1, 1, address(this)); + uint64 provenConsumedBucket = inbox.getProvenConsumedBucketSeq(); + assertGt(provenConsumedBucket, 0, "the first epoch consumed L1 to L2 messages"); + + // The next epoch's checkpoint references the same bucket, adding no messages. + _proposeCheckpointWithoutInboxMessages("mixed_checkpoint_2", EPOCH_DURATION); + assertEq(inbox.getCurrentBucketSeq(), provenConsumedBucket, "no new bucket was opened"); + + vm.expectCall(address(inbox), abi.encodeWithSelector(inbox.markProvenConsumed.selector), 0); + _proveCheckpoints("mixed_checkpoint_", 2, 2, address(this)); + + assertEq(rollup.getProvenCheckpointNumber(), 2, "second epoch proven"); + assertEq(inbox.getProvenConsumedBucketSeq(), provenConsumedBucket, "record unchanged"); + } + + // A prune rewinds the pending chain along with the temp-log records of what it consumed, but the buckets that + // chain referenced are exactly the ones the replacement chain has to re-consume, so eviction stays locked. + function testPruneDoesNotAdvanceProvenConsumed() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + assertGt(inbox.getCurrentBucketSeq(), 0, "checkpoint consumed L1 to L2 messages"); + + CheckpointLog memory checkpoint = rollup.getCheckpoint(1); + Slot prunableAt = checkpoint.slotNumber + Epoch.wrap(2).toSlots(); + vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(prunableAt))); + + rollup.prune(); + + assertEq(rollup.getPendingCheckpointNumber(), 0, "pending chain pruned"); + assertEq(inbox.getProvenConsumedBucketSeq(), 0, "a pruned chain's consumption never unlocks eviction"); + } + + // Re-proposing a checkpoint number after a prune overwrites its temp log wholesale, so proving the replacement + // records the replacement's consumption — the pruned proposal's stale record cannot leak into the Inbox. + function testProvenConsumedTracksReplacementChainAfterPrune() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + uint64 staleConsumedBucket = inbox.getCurrentBucketSeq(); + assertGt(staleConsumedBucket, 0, "the pruned proposal consumed L1 to L2 messages"); + + CheckpointLog memory checkpoint = rollup.getCheckpoint(1); + Slot prunableAt = checkpoint.slotNumber + Epoch.wrap(2).toSlots(); + uint256 replacementTimestamp = Timestamp.unwrap(rollup.getTimestampForSlot(prunableAt)); + + // An extra message in an L1 block before the replacement's slot opens one more bucket, so the replacement + // proposal consumes one bucket further than the pruned one did and the two temp-log records differ. + vm.warp(replacementTimestamp - 12); + vm.roll(block.number + 1); + bytes32[] memory contents = new bytes32[](1); + contents[0] = bytes32(uint256(0x1234)); + _populateInbox(address(this), bytes32(uint256(0x5678)), contents); + uint64 replacementConsumedBucket = inbox.getCurrentBucketSeq(); + assertEq(replacementConsumedBucket, staleConsumedBucket + 1, "the extra message opened one more bucket"); + + // The propose call prunes the stale chain and installs checkpoint 1 from the empty fixture, referencing the + // newest bucket. + _proposeCheckpoint("empty_checkpoint_1", Slot.unwrap(prunableAt)); + assertEq(rollup.getPendingCheckpointNumber(), 1, "replacement chain proposed"); + + _proveCheckpoints("empty_checkpoint_", 1, 1, address(this)); + + assertEq(rollup.getProvenCheckpointNumber(), 1, "replacement checkpoint proven"); + assertEq( + inbox.getProvenConsumedBucketSeq(), + replacementConsumedBucket, + "the replacement's consumption is recorded, not the pruned proposal's" + ); + } + function _submitEpochProof( uint256 _start, uint256 _end, diff --git a/l1-contracts/test/base/RollupBase.sol b/l1-contracts/test/base/RollupBase.sol index 13c1f6bbf76a..1fa6c5c47cb2 100644 --- a/l1-contracts/test/base/RollupBase.sol +++ b/l1-contracts/test/base/RollupBase.sol @@ -138,12 +138,30 @@ contract RollupBase is DecoderBase { _proposeCheckpoint(_name, _slotNumber, _manaUsed, _extraBlobHashes, ""); } + // Proposes without seeding the fixture's L1 to L2 messages, so the checkpoint references the bucket its parent + // already consumed and adds no messages. + function _proposeCheckpointWithoutInboxMessages(string memory _name, uint256 _slotNumber) internal { + bytes32[] memory extraBlobHashes = new bytes32[](0); + _proposeCheckpoint(_name, _slotNumber, 0, extraBlobHashes, "", false); + } + function _proposeCheckpoint( string memory _name, uint256 _slotNumber, uint256 _manaUsed, bytes32[] memory _extraBlobHashes, bytes memory _revertMsg + ) private { + _proposeCheckpoint(_name, _slotNumber, _manaUsed, _extraBlobHashes, _revertMsg, true); + } + + function _proposeCheckpoint( + string memory _name, + uint256 _slotNumber, + uint256 _manaUsed, + bytes32[] memory _extraBlobHashes, + bytes memory _revertMsg, + bool _seedInbox ) private { DecoderBase.Full memory full = load(_name); bytes memory blobCommitments = full.checkpoint.blobCommitments; @@ -170,7 +188,9 @@ contract RollupBase is DecoderBase { // Seed the Inbox before jumping to the checkpoint's L1 block: propose rejects a bucket that is still // accumulating, and a bucket keeps accumulating for the whole L1 block that opened it. - _populateInbox(full.populate.sender, full.populate.recipient, full.populate.l1ToL2Content); + if (_seedInbox) { + _populateInbox(full.populate.sender, full.populate.recipient, full.populate.l1ToL2Content); + } // We jump to the time of the block, always past the L1 block the messages above landed in. vm.warp(max(block.timestamp + 1, Timestamp.unwrap(full.checkpoint.header.timestamp))); diff --git a/l1-contracts/test/fees/MinimalFeeModel.sol b/l1-contracts/test/fees/MinimalFeeModel.sol index dfc494de15ad..194ededc3ac0 100644 --- a/l1-contracts/test/fees/MinimalFeeModel.sol +++ b/l1-contracts/test/fees/MinimalFeeModel.sol @@ -130,7 +130,8 @@ contract MinimalFeeModel { slotNumber: Slot.wrap(0), feeHeader: FeeLib.computeFeeHeader(checkpointNumber, _oracleInput.feeAssetPriceModifier, _manaUsed, 0, 0), inboxRollingHash: bytes32(0), - inboxMsgTotal: 0 + inboxMsgTotal: 0, + inboxConsumedBucket: 0 }) ); // FeeLib.writeFeeHeader(++populatedThrough, _oracleInput.feeAssetPriceModifier, _manaUsed, 0, 0); diff --git a/l1-contracts/test/harnesses/TestConstants.sol b/l1-contracts/test/harnesses/TestConstants.sol index fdd1bf7e2ba1..881dfa54743f 100644 --- a/l1-contracts/test/harnesses/TestConstants.sol +++ b/l1-contracts/test/harnesses/TestConstants.sol @@ -25,7 +25,7 @@ library TestConstants { uint256 internal constant AZTEC_TARGET_COMMITTEE_SIZE = 48; 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_BUCKET_RING_SIZE = 1024; + uint256 internal constant AZTEC_INBOX_BUCKET_RING_SIZE = 4096; 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/rollup/InboxRingDeadlock.t.sol b/l1-contracts/test/rollup/InboxRingDeadlock.t.sol new file mode 100644 index 000000000000..1b3e30b89919 --- /dev/null +++ b/l1-contracts/test/rollup/InboxRingDeadlock.t.sol @@ -0,0 +1,137 @@ +// 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 {Constants} from "@aztec/core/libraries/ConstantsGen.sol"; +import {Slot} 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 {ProposeLibHarness} from "./ProposeInboxConsumption.t.sol"; + +/** + * Why unconsumed buckets must never be evicted, from the consumption side. A proposal can only reference a + * retained bucket, and it can only consume MAX_L1_TO_L2_MSGS_PER_CHECKPOINT messages beyond its parent's + * cumulative total. Those two limits close against each other: heavy traffic pushes the buckets whose delta from + * a stalled parent total still fits the cap out of the retained window, and every bucket left in the window is + * too far ahead of that parent to be consumed in one checkpoint. A chain whose consumption stalled at genesis + * then has no proposable cursor at all, and no amount of waiting produces one — the gap is permanent. + * + * With overwrite protection the gap cannot open: sends halt at the ring wall instead of evicting, so the oldest + * unconsumed bucket stays retained and stays proposable. + */ +contract InboxRingDeadlockTest is Test { + uint256 internal constant GENESIS_TIME = 100_000; + uint256 internal constant SLOT_DURATION = 72; + uint256 internal constant EPOCH_DURATION = 32; + uint256 internal constant ETHEREUM_SLOT_DURATION = 12; + + // Far enough into the chain that the traffic below, which spans more L1 blocks than the ring holds buckets, + // lands entirely before the proposal's timestamp. + Slot internal constant SLOT = Slot.wrap(100); + + ProposeLibHarness internal rollup; + InboxHarness internal inbox; + uint256 internal version = 0; + + function setUp() public { + vm.warp(GENESIS_TIME); + rollup = new ProposeLibHarness(GENESIS_TIME, SLOT_DURATION, EPOCH_DURATION, ETHEREUM_SLOT_DURATION); + + IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this)); + inbox = new InboxHarness(address(rollup), feeAsset, version, MIN_BUCKET_RING_SIZE); + } + + // Sends one message, discarding the return values so a send fronted by `vm.expectRevert` has no returndata to + // decode. + function _send(uint256 _salt) internal { + inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}), + bytes32(uint256(0x2000 + _salt)), + bytes32(uint256(0x3000 + _salt)) + ); + } + + // Drives the Inbox to `_targetBucketSeq` with the traffic shape that opens the cap-versus-window gap: four L1 + // blocks of MAX_MSGS_PER_BUCKET messages each, taking the cumulative total to exactly the per-checkpoint cap, a + // fifth block with the one message past it, then one message per L1 block. + function _driveTraffic(uint256 _targetBucketSeq, bool _forceProvenConsumed) internal { + for (uint256 l1Block = 1; inbox.getCurrentBucketSeq() < _targetBucketSeq; l1Block++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + ETHEREUM_SLOT_DURATION); + + uint256 count = l1Block <= 4 ? MAX_MSGS_PER_BUCKET : 1; + for (uint256 i = 0; i < count; i++) { + _send(inbox.getTotalMessagesInserted()); + } + + if (_forceProvenConsumed) { + uint64 newest = inbox.getCurrentBucketSeq(); + vm.prank(address(rollup)); + inbox.markProvenConsumed(newest); + } + } + } + + // Standing in for a contract without overwrite protection: the proven-consumed record is pushed to the newest + // bucket every L1 block, ahead of any real consumption, so eviction is never refused and the retained window + // slides off the only buckets a chain stalled at genesis could have proposed. The same sequence doubles as the + // damage model for an unfaithful rollup: a consumption claim not backed by a proven checkpoint reproduces the + // unprotected behavior exactly. + function testDeadlockWithoutProtection() public { + _driveTraffic(MIN_BUCKET_RING_SIZE + 4, true); + + uint256 current = inbox.getCurrentBucketSeq(); + assertEq(current, MIN_BUCKET_RING_SIZE + 4, "traffic drove the ring past one wrap"); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + + // The buckets whose delta from a parent total of zero fits the cap are buckets 0 through 4, and every one of + // them has been overwritten. + for (uint256 hint = 0; hint <= 4; hint++) { + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, hint, current)); + rollup.validateInboxConsumption(inbox, bytes32(0), hint, SLOT, 0); + } + + // Every bucket still retained is too far ahead of that parent total to be consumed in one checkpoint, so no + // reference at all is proposable and the pending chain can never cross the gap. + for (uint256 hint = 5; hint <= current; hint++) { + IInbox.InboxBucket memory bucket = inbox.getBucket(hint); + assertGt(bucket.totalMsgCount, Constants.MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, "bucket past the cap"); + + vm.expectRevert( + abi.encodeWithSelector(Errors.Rollup__TooManyInboxMessagesConsumed.selector, bucket.totalMsgCount) + ); + rollup.validateInboxConsumption(inbox, bucket.rollingHash, hint, SLOT, 0); + } + } + + // The same traffic against the real contract: nothing is proven-consumed, so sends halt at the ring wall with + // the whole window intact. + function testProtectionPreventsDeadlock() public { + _driveTraffic(MIN_BUCKET_RING_SIZE, false); + assertEq(inbox.getCurrentBucketSeq(), MIN_BUCKET_RING_SIZE, "ring filled to one wrap"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + ETHEREUM_SLOT_DURATION); + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__WouldOverwriteUnconsumedBucket.selector, uint64(1))); + _send(0xdead); + + assertEq(inbox.getCurrentBucketSeq(), MIN_BUCKET_RING_SIZE, "sends halted rather than evicting bucket 1"); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + + // One bucket's delta is at most MAX_MSGS_PER_BUCKET and so always fits the per-checkpoint cap, so the oldest + // unconsumed bucket - which the protection never lets be evicted - always yields a proposable cursor. Here + // that cursor is bucket 4, whose delta is exactly the cap; the next bucket exceeds it, so mandatory + // consumption passes via the cap escape. + IInbox.InboxBucket memory cursor = inbox.getBucket(4); + uint256 consumed = rollup.validateInboxConsumption(inbox, cursor.rollingHash, 4, SLOT, 0); + assertEq(consumed, Constants.MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, "a proposable cursor survives"); + } +} diff --git a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol index 1b8ebb6e591c..66567a43ed49 100644 --- a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -214,6 +214,9 @@ contract ProposeInboxConsumptionTest is Test { bytes32(uint256(0x2000 + i)), bytes32(uint256(0x3000 + i)) ); + // Evicting a ring slot requires the proven chain to have consumed it, so keep consumption trailing the sends. + vm.prank(address(rollup)); + ringInbox.markProvenConsumed(uint64(i - 1)); } // No proposal-time warp: the loop above has already moved past SLOT's proposal time, and the revert fires diff --git a/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol b/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol index efbc6a2b2a73..c49060329e7d 100644 --- a/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol +++ b/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol @@ -120,7 +120,8 @@ contract RewardLibWrapper { slotNumber: Slot.wrap(0), feeHeader: _feeHeader, inboxRollingHash: bytes32(0), - inboxMsgTotal: 0 + inboxMsgTotal: 0, + inboxConsumedBucket: 0 }) ); } diff --git a/labs-patches/0012-feat-ethereum-carry-the-consumed-inbox-bucket-in-che.patch b/labs-patches/0012-feat-ethereum-carry-the-consumed-inbox-bucket-in-che.patch new file mode 100644 index 000000000000..1d1ce792aa6b --- /dev/null +++ b/labs-patches/0012-feat-ethereum-carry-the-consumed-inbox-bucket-in-che.patch @@ -0,0 +1,129 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Santiago Palladino +Date: Thu, 10 Sep 2026 12:23:21 -0300 +Subject: [PATCH] feat(ethereum): carry the consumed inbox bucket in checkpoint + log overrides + +The L1 `TempCheckpointLog` gains an `inboxConsumedBucket` member, packed into +the same storage word as the slot number and the cumulative inbox message +total. The override builders have to write it, otherwise supplying either of +the other two zeroes it out and a simulation reads a checkpoint that consumed +bucket 0. + +The packed-word diff now fires when any of the three fields is supplied and +writes all three, with the new field width-checked at 64 bits like the +message total. + +diff --git a/yarn-project/ethereum/src/contracts/chain_state_override.ts b/yarn-project/ethereum/src/contracts/chain_state_override.ts +index b080b8056fafbe4cf45d47c482488beb56eb7631..eb93049c680f49e5896c590a8e9195634a33f91e 100644 +--- a/yarn-project/ethereum/src/contracts/chain_state_override.ts ++++ b/yarn-project/ethereum/src/contracts/chain_state_override.ts +@@ -21,6 +21,7 @@ export type PendingCheckpointOverrideState = { + payloadDigest?: Buffer32; + slotNumber?: SlotNumber; + inboxMsgTotal?: bigint; ++ inboxConsumedBucket?: bigint; + }; + + export type ChainTipsOverride = { +@@ -95,8 +96,8 @@ export class SimulationOverridesBuilder { + * Overrides one or more `tempCheckpointLogs` cell fields for the configured pending checkpoint. + * Any subset can be provided. The translator (`makeTempCheckpointLogOverride`) emits a stateDiff + * entry per storage word touched, so fields in untouched words stay at their on-chain values; +- * `slotNumber` and `inboxMsgTotal` share a word, so setting either zeroes the other unless it is +- * supplied too. ++ * `slotNumber`, `inboxMsgTotal` and `inboxConsumedBucket` share a word, so setting any of them ++ * zeroes the others unless they are supplied too. + * + * `slotNumber` is required for `STFLib.canPruneAtTime`: when the simulation overrides `pending` + * to a checkpoint that has no on-chain `tempCheckpointLogs` entry yet, the missing slotNumber falls +@@ -109,6 +110,7 @@ export class SimulationOverridesBuilder { + payloadDigest?: Buffer32; + slotNumber?: SlotNumber; + inboxMsgTotal?: bigint; ++ inboxConsumedBucket?: bigint; + }): this { + this.assertPendingCheckpointNumber(); + this.pendingCheckpointState = { ...(this.pendingCheckpointState ?? {}), ...fields }; +@@ -177,6 +179,7 @@ export async function buildSimulationOverridesStateOverride( + payloadDigest: plan.pendingCheckpointState.payloadDigest, + slotNumber: plan.pendingCheckpointState.slotNumber, + inboxMsgTotal: plan.pendingCheckpointState.inboxMsgTotal, ++ inboxConsumedBucket: plan.pendingCheckpointState.inboxConsumedBucket, + feeHeader: plan.pendingCheckpointState.feeHeader, + }), + ), +diff --git a/yarn-project/ethereum/src/contracts/rollup.test.ts b/yarn-project/ethereum/src/contracts/rollup.test.ts +index 54744f5474fa00832e9eb0abc510bea8a90bbaec..7c770fabeac2c4a296c348524d106fc0dc047997 100644 +--- a/yarn-project/ethereum/src/contracts/rollup.test.ts ++++ b/yarn-project/ethereum/src/contracts/rollup.test.ts +@@ -487,16 +487,17 @@ describe('Rollup', () => { + ); + }); + +- it('packs the inbox consumption count into the slot-number word', async () => { ++ it('packs the inbox consumption counts into the slot-number word', async () => { + const checkpointNumber = CheckpointNumber(13); + const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, { + slotNumber: SlotNumber(7), + inboxMsgTotal: 300n, ++ inboxConsumedBucket: 5n, + }); + const { map, slotFor } = getDiffMap(checkpointNumber, override); + expect(override[0].stateDiff).toHaveLength(1); + expect(map.get(await slotFor(TempCheckpointLogField.SlotNumber))).toBe( +- `0x${(7n | (300n << 32n)).toString(16).padStart(64, '0')}`.toLowerCase(), ++ `0x${(7n | (300n << 32n) | (5n << 96n)).toString(16).padStart(64, '0')}`.toLowerCase(), + ); + }); + +diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts +index 6ac1639751d082d633487d7dcf04fa58714f82d1..b72cc405e81aee557a54df55a165666884e939b9 100644 +--- a/yarn-project/ethereum/src/contracts/rollup.ts ++++ b/yarn-project/ethereum/src/contracts/rollup.ts +@@ -160,8 +160,8 @@ export enum TempCheckpointLogField { + * arbitrary `bytes32` value rather than a BN254 scalar. `slotNumber` carries the uint32 portion + * of the on-chain `CompressedSlot`. + * +- * `slotNumber` and `inboxMsgTotal` share a single storage word, so supplying either rewrites both; +- * the one left out lands as zero. ++ * `slotNumber`, `inboxMsgTotal` and `inboxConsumedBucket` share a single storage word, so supplying ++ * any one of them rewrites all three; the ones left out land as zero. + */ + export type TempCheckpointLogOverrideFields = { + headerHash?: Fr; +@@ -170,6 +170,8 @@ export type TempCheckpointLogOverrideFields = { + slotNumber?: SlotNumber; + /** Cumulative Inbox message count consumed as of this checkpoint. */ + inboxMsgTotal?: bigint; ++ /** Inbox bucket sequence number this checkpoint's rolling hash corresponds to. */ ++ inboxConsumedBucket?: bigint; + feeHeader?: FeeHeader; + }; + +@@ -998,16 +1000,21 @@ export class RollupContract { + value: fields.payloadDigest.toString() as `0x${string}`, + }); + } +- if (fields.slotNumber !== undefined || fields.inboxMsgTotal !== undefined) { +- // The L1 struct packs the slot number and the inbox consumption count into one word, so this +- // diff always writes both. Widths are enforced here because the L1 writers cast through ++ if ( ++ fields.slotNumber !== undefined || ++ fields.inboxMsgTotal !== undefined || ++ fields.inboxConsumedBucket !== undefined ++ ) { ++ // The L1 struct packs the slot number and the two inbox consumption counts into one word, so this ++ // diff always writes all three. Widths are enforced here because the L1 writers cast through + // SafeCast and revert on overflow; a malformed override must surface rather than silently truncate + // into a neighbouring field. + const slotNumber = requireUintFits(BigInt(fields.slotNumber ?? 0), 32, 'slotNumber'); + const inboxMsgTotal = requireUintFits(fields.inboxMsgTotal ?? 0n, 64, 'inboxMsgTotal'); ++ const inboxConsumedBucket = requireUintFits(fields.inboxConsumedBucket ?? 0n, 64, 'inboxConsumedBucket'); + stateDiff.push({ + slot: slotAt(TempCheckpointLogField.SlotNumber), +- value: word(slotNumber | (inboxMsgTotal << 32n)), ++ value: word(slotNumber | (inboxMsgTotal << 32n) | (inboxConsumedBucket << 96n)), + }); + } + if (fields.feeHeader) { From 5d33ba9d3f4255139f0b746ac44be1b18d015cbb Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 10 Sep 2026 18:23:57 -0300 Subject: [PATCH 2/3] test(l1): cover the epoch proof against a non-empty Inbox Every Solidity epoch-proof fixture proved an epoch whose Inbox never received a message, so the proven-consumption callback that unlocks bucket eviction was never reached and the interaction between compact header submission and the eviction gating had no coverage at all. Seed the Inbox ahead of the fixture epoch and assert the resulting eviction boundary: for a first non-overlapping proof, for extensions that do and do not consume a further bucket, on an Inbox that stayed empty, and for submissions whose header layout or already-proven prefix could otherwise steer the decision. The last group covers a fully compacted prefix, where the old proven tip has no header left to index into, a partially compacted one, a tampered rolling hash on the already-proven tip's unverified header, a submission included after another proof moved the tip, and a repeated shorter proof. Reaching any of it needs two corrections to the reporter harness, whose runtime is etched onto the live Rollup and so carries its own INBOX immutable. Proposals must be generated before the etch, because the propose path reads that immutable directly, and _getRollupConfig must point config.inbox back at the Inbox the live Rollup created, because the submission path reads the config. Without the first, propose rejects the seeded bucket reference; without the second, markProvenConsumed reverts Inbox__Unauthorized. That extra assignment costs 21 gas inside the measured entrypoints, so every row of the committed partial epoch proof gas report moves up by 21. Production gas is untouched: Rollup's own _getRollupConfig is unchanged. --- .../partial_epoch_proof_gas_report.json | 40 ++-- .../partial_epoch_proof_gas_report.md | 10 +- .../benchmark/InboxCallbackScenarios.t.sol | 195 ++++++++++++++++++ .../PartialEpochProofGasReporter.sol | 10 +- l1-contracts/test/benchmark/happy.t.sol | 40 +++- 5 files changed, 267 insertions(+), 28 deletions(-) create mode 100644 l1-contracts/test/benchmark/InboxCallbackScenarios.t.sol diff --git a/l1-contracts/partial_epoch_proof_gas_report.json b/l1-contracts/partial_epoch_proof_gas_report.json index 86e98639dbed..379eb28bcd9a 100644 --- a/l1-contracts/partial_epoch_proof_gas_report.json +++ b/l1-contracts/partial_epoch_proof_gas_report.json @@ -8,38 +8,38 @@ "functions": { "gasReportSubmit16Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1246265, - "mean": 1246265, - "median": 1246265, - "max": 1246265 + "min": 1246286, + "mean": 1246286, + "median": 1246286, + "max": 1246286 }, "gasReportSubmit1Checkpoint((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 654909, - "mean": 654909, - "median": 654909, - "max": 654909 + "min": 654930, + "mean": 654930, + "median": 654930, + "max": 654930 }, "gasReportSubmit32Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1732692, - "mean": 1732692, - "median": 1732692, - "max": 1732692 + "min": 1732713, + "mean": 1732713, + "median": 1732713, + "max": 1732713 }, "gasReportSubmit8Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 956916, - "mean": 956916, - "median": 956916, - "max": 956916 + "min": 956937, + "mean": 956937, + "median": 956937, + "max": 956937 }, "gasReportSubmit8MoreCheckpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(address,uint256)[],(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 910446, - "mean": 910446, - "median": 910446, - "max": 910446 + "min": 910467, + "mean": 910467, + "median": 910467, + "max": 910467 } } } diff --git a/l1-contracts/partial_epoch_proof_gas_report.md b/l1-contracts/partial_epoch_proof_gas_report.md index d0eb0adaaa01..9fbd94c59b38 100644 --- a/l1-contracts/partial_epoch_proof_gas_report.md +++ b/l1-contracts/partial_epoch_proof_gas_report.md @@ -2,10 +2,10 @@ | Proof submission | Gas | |---|---:| -| 1 Checkpoint | 654,909 | -| 8 Checkpoints | 956,916 | -| 8 More Checkpoints | 910,446 | -| 16 Checkpoints | 1,246,265 | -| 32 Checkpoints | 1,732,692 | +| 1 Checkpoint | 654,930 | +| 8 Checkpoints | 956,937 | +| 8 More Checkpoints | 910,467 | +| 16 Checkpoints | 1,246,286 | +| 32 Checkpoints | 1,732,713 | _Uses the mock epoch proof verifier; real ZK verification and top-level transaction calldata gas are not included._ diff --git a/l1-contracts/test/benchmark/InboxCallbackScenarios.t.sol b/l1-contracts/test/benchmark/InboxCallbackScenarios.t.sol new file mode 100644 index 000000000000..2e557b5e847a --- /dev/null +++ b/l1-contracts/test/benchmark/InboxCallbackScenarios.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aztec Labs. +pragma solidity >=0.8.27; + +import {PartialEpochProofGasReportBase} from "./happy.t.sol"; +import {ExpectedEpochPublicInputsVerifier} from "./CompactEpochProof.t.sol"; +import {SubmitEpochRootProofArgs} from "@aztec/core/interfaces/IRollup.sol"; + +// solhint-disable comprehensive-interface + +/** + * @notice Epoch proofs against a non-empty Inbox, covering the proven-consumption callback. + * + * Every other epoch-proof fixture proves an epoch whose Inbox never received a message, so the callback that + * unlocks bucket eviction is never reached. These scenarios seed the Inbox ahead of the fixture epoch and assert + * the eviction boundary that results from a first proof, from extensions that do and do not consume a further + * bucket, and from submissions whose header layout or already-proven prefix could otherwise steer the decision. + */ +abstract contract InboxCallbackScenarioBase is PartialEpochProofGasReportBase { + /// @dev The `[1...16]` submission with its first `_prefixLength` already-proven headers compacted away. + function _extensionSubmission(uint256 _prefixLength) internal view returns (SubmitEpochRootProofArgs memory) { + return _compactSubmission(_getGasReportSubmission(16), _prefixLength); + } + + function _bindPublicInputs(SubmitEpochRootProofArgs memory _args) internal { + bytes32[] memory inputs = + rollup.getEpochProofPublicInputs(_args.start, _args.end, _args.args, _args.headers, _args.blobInputs); + ExpectedEpochPublicInputsVerifier verifier = new ExpectedEpochPublicInputsVerifier(inputs); + vm.etch(address(rollup.getEpochProofVerifier()), address(verifier).code); + } +} + +/// @notice A first, non-overlapping proof that consumes messages. The callback must fire. +contract InboxCallbackFirstProofTest is InboxCallbackScenarioBase { + function _seedInitialInboxBucket() internal pure override returns (bool) { + return true; + } + + function testFirstProofConsumingInbox() public { + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 0); + rollup.submitEpochRootProof(_getGasReportSubmission(8)); + assertEq(rollup.getProvenCheckpointNumber(), 8); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 1); + } +} + +/// @notice An extension over an already-proven prefix that consumes nothing new leaves the boundary where it was. +contract InboxCallbackNoNewConsumptionTest is InboxCallbackScenarioBase { + function _seedInitialInboxBucket() internal pure override returns (bool) { + return true; + } + + function setUp() public override { + super.setUp(); + rollup.submitEpochRootProof(_getGasReportSubmission(8)); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 1); + } + + function testCompactedExtension() public { + rollup.submitEpochRootProof(_extensionSubmission(8)); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 1); + } + + function testUncompactedExtension() public { + rollup.submitEpochRootProof(_extensionSubmission(0)); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 1); + } +} + +/// @notice An extension whose new checkpoints consume a further bucket must move the boundary to it. +contract InboxCallbackNewConsumptionTest is InboxCallbackScenarioBase { + function _seedInitialInboxBucket() internal pure override returns (bool) { + return true; + } + + function _seedSecondInboxBucket() internal pure override returns (bool) { + return true; + } + + function setUp() public override { + super.setUp(); + rollup.submitEpochRootProof(_getGasReportSubmission(8)); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 1); + } + + function testCompactedExtension() public { + rollup.submitEpochRootProof(_extensionSubmission(8)); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + } + + function testUncompactedExtension() public { + rollup.submitEpochRootProof(_extensionSubmission(0)); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + } +} + +/// @notice An extension on an Inbox that never received a message. The whole-epoch guard skips the callback. +contract InboxCallbackEmptyInboxTest is InboxCallbackScenarioBase { + function setUp() public override { + super.setUp(); + rollup.submitEpochRootProof(_getGasReportSubmission(8)); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 0); + } + + function testCompactedExtension() public { + rollup.submitEpochRootProof(_extensionSubmission(8)); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 0); + } + + function testUncompactedExtension() public { + rollup.submitEpochRootProof(_extensionSubmission(0)); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 0); + } +} + +/// @notice The callback must follow canonical storage, not the submission's header layout. +contract InboxCallbackRegressionTest is InboxCallbackScenarioBase { + function _seedInitialInboxBucket() internal pure override returns (bool) { + return true; + } + + function _seedSecondInboxBucket() internal pure override returns (bool) { + return true; + } + + function setUp() public override { + super.setUp(); + rollup.submitEpochRootProof(_getGasReportSubmission(8)); + assertEq(rollup.getProvenCheckpointNumber(), 8); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 1); + } + + /// @dev The old proven tip has no full header left in the array; nothing may index into it. + function testSingleCheckpointExtensionWithFullyCompactedPrefix() public { + rollup.submitEpochRootProof(_compactSubmission(_getGasReportSubmission(9), 8)); + assertEq(rollup.getProvenCheckpointNumber(), 9); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + } + + /// @dev Same submission uncompacted: the outcome must not depend on the compact-prefix length. + function testSingleCheckpointExtensionWithoutCompaction() public { + rollup.submitEpochRootProof(_getGasReportSubmission(9)); + assertEq(rollup.getProvenCheckpointNumber(), 9); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + } + + function testPartiallyCompactedExtensionAdvancesConsumption() public { + rollup.submitEpochRootProof(_compactSubmission(_getGasReportSubmission(16), 4)); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + } + + /** + * @dev An already-proven header may be supplied in full but is not rehashed against storage, so its + * `inboxRollingHash` is unauthenticated. Tampering with it must not steer the callback. The proof is checked + * against the public inputs the Rollup assembles from the untampered submission, so a condition that read the + * tampered field would have to reach its decision on inputs the verifier never saw. + */ + function testTamperedProvenHeaderRollingHashDoesNotSuppressCallback() public { + SubmitEpochRootProofArgs memory args = _getGasReportSubmission(16); + _bindPublicInputs(args); + // Checkpoints 9-16 consume bucket 2, so the canonical hash at the old tip differs from the end hash and the + // callback is owed. Claim on checkpoint 8's unverified header that the epoch ended where it began. + args.headers[7].inboxRollingHash = args.args.endInboxRollingHash; + rollup.submitEpochRootProof(args); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + } + + /// @dev A submission built at tip 8 but included after the tip already moved compares against the real tip. + function testSubmissionIncludedAfterAnotherProofAdvancedTheTip() public { + SubmitEpochRootProofArgs memory prepared = _getGasReportSubmission(16); + rollup.submitEpochRootProof(_getGasReportSubmission(12)); + assertEq(rollup.getProvenCheckpointNumber(), 12); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + rollup.submitEpochRootProof(prepared); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + } + + /// @dev A proof that does not advance the tip must not touch the Inbox record. + function testRepeatedProofDoesNotAdvanceConsumption() public { + rollup.submitEpochRootProof(_getGasReportSubmission(16)); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + rollup.submitEpochRootProof(_getGasReportSubmission(12)); + assertEq(rollup.getProvenCheckpointNumber(), 16); + assertEq(rollup.getInbox().getProvenConsumedBucketSeq(), 2); + } +} diff --git a/l1-contracts/test/benchmark/PartialEpochProofGasReporter.sol b/l1-contracts/test/benchmark/PartialEpochProofGasReporter.sol index 731103ac03d7..f8949b3e89f3 100644 --- a/l1-contracts/test/benchmark/PartialEpochProofGasReporter.sol +++ b/l1-contracts/test/benchmark/PartialEpochProofGasReporter.sol @@ -8,6 +8,7 @@ import {GenesisState, RollupConfigInput} from "@aztec/core/Rollup.sol"; import { IERC20, IFeeJuicePortal, + IInbox, IOutbox, RollupConfig, SubmitEpochRootProofArgs @@ -19,6 +20,7 @@ import {GSE} from "@aztec/governance/GSE.sol"; contract PartialEpochProofGasReporter is RollupWithPreheating { IOutbox private immutable ORIGINAL_OUTBOX; IFeeJuicePortal private immutable ORIGINAL_FEE_ASSET_PORTAL; + IInbox private immutable ORIGINAL_INBOX; constructor( IERC20 _feeAsset, @@ -29,16 +31,22 @@ contract PartialEpochProofGasReporter is RollupWithPreheating { GenesisState memory _genesisState, RollupConfigInput memory _config, IOutbox _originalOutbox, - IFeeJuicePortal _originalFeeAssetPortal + IFeeJuicePortal _originalFeeAssetPortal, + IInbox _originalInbox ) RollupWithPreheating(_feeAsset, _stakingAsset, _gse, _epochProofVerifier, _governance, _genesisState, _config) { ORIGINAL_OUTBOX = _originalOutbox; ORIGINAL_FEE_ASSET_PORTAL = _originalFeeAssetPortal; + ORIGINAL_INBOX = _originalInbox; } function _getRollupConfig() internal view override returns (RollupConfig memory config) { config = super._getRollupConfig(); config.outbox = ORIGINAL_OUTBOX; config.feeAssetPortal = ORIGINAL_FEE_ASSET_PORTAL; + // The etched runtime carries the reporter's own Inbox immutable, whose ROLLUP is the reporter's deployment + // address; the proven-consumption callback would be rejected by it. Point the config at the Inbox the live + // Rollup created, which the fixture's proposals were validated against. + config.inbox = ORIGINAL_INBOX; } /** diff --git a/l1-contracts/test/benchmark/happy.t.sol b/l1-contracts/test/benchmark/happy.t.sol index d4495e3ab751..8f2f11f103ca 100644 --- a/l1-contracts/test/benchmark/happy.t.sol +++ b/l1-contracts/test/benchmark/happy.t.sol @@ -225,7 +225,8 @@ abstract contract BenchmarkRollupBase is FeeModelTestPoints, DecoderBase { config.genesisState, config.rollupConfigInput, rollup.getOutbox(), - rollup.getFeeAssetPortal() + rollup.getFeeAssetPortal(), + rollup.getInbox() ); // Keep the initialized rollup storage while exposing named gas-report entrypoints. vm.etch(address(rollup), address(reporter).code); @@ -581,22 +582,47 @@ abstract contract PartialEpochProofGasReportBase is BenchmarkRollupBase { mapping(uint256 checkpointNumber => bytes32 outHash) internal gasReportOutHashes; mapping(uint256 checkpointNumber => bytes32 inboxRollingHash) internal gasReportInboxRollingHashes; + /// @dev Seed one Inbox message a slot before the fixture epoch, so every checkpoint consumes bucket 1. + function _seedInitialInboxBucket() internal view virtual returns (bool) { + return false; + } + + /// @dev Seed a second Inbox message just before checkpoint 9, so checkpoints 9 and up consume bucket 2. + function _seedSecondInboxBucket() internal view virtual returns (bool) { + return false; + } + function setUp() public virtual override { super.setUp(); RollupBuilder builder = _prepare(48, false, TestSlash.NONE); - _installPartialEpochProofGasReporter(builder); + // Propose against the deployed Rollup before etching. `propose` reads the `INBOX` immutable out of the running + // code, so proposals made through the reporter would validate against the reporter's own Inbox, whose `ROLLUP` + // is the reporter's deployment address rather than this one. _prepareGasReportEpoch(); + _installPartialEpochProofGasReporter(builder); + } + + function _sendInboxMessage(uint256 _salt) internal { + vm.prank(address(this)); + Inbox(address(rollup.getInbox())).sendL2Message( + DataStructures.L2Actor({actor: bytes32(_salt), version: rollup.getVersion()}), bytes32(_salt), bytes32(0) + ); } function _prepareGasReportEpoch() internal { Slot firstSlot = Slot.wrap(EPOCH_DURATION * GAS_REPORT_EPOCH); Slot endSlot = firstSlot + Slot.wrap(EPOCH_DURATION); + bool inboxSeeded; for (uint256 i = 0; i < l1Metadata.length; i++) { _loadL1Metadata(i); Slot currentSlot = rollup.getCurrentSlot(); if (currentSlot < firstSlot) { + if (_seedInitialInboxBucket() && !inboxSeeded && currentSlot + Slot.wrap(1) == firstSlot) { + _sendInboxMessage(1); + inboxSeeded = true; + } continue; } if (currentSlot >= endSlot) { @@ -605,6 +631,15 @@ abstract contract PartialEpochProofGasReportBase is BenchmarkRollupBase { rollup.setupEpoch(); + // A bucket only settles once its L1 block has passed, so open bucket 2 slightly before checkpoint 9's + // proposal timestamp rather than at it. + if (_seedSecondInboxBucket() && rollup.getPendingCheckpointNumber() == 8) { + uint256 timestamp = block.timestamp; + vm.warp(timestamp - 12); + _sendInboxMessage(2); + vm.warp(timestamp); + } + Checkpoint memory checkpoint = getCheckpoint(); address proposer = rollup.getCurrentProposer(); @@ -633,6 +668,7 @@ abstract contract PartialEpochProofGasReportBase is BenchmarkRollupBase { } assertEq(rollup.getPendingCheckpointNumber(), EPOCH_DURATION); + assertEq(inboxSeeded, _seedInitialInboxBucket()); assertEq(rollup.getEpochCommittee(Epoch.wrap(GAS_REPORT_EPOCH)).length, 48); } From f8abc9700575e65cf2b96ee0865614254f429272 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 10 Sep 2026 18:55:28 -0300 Subject: [PATCH 3/3] chore(l1): satisfy forge fmt in the benchmark Inbox message helper --- l1-contracts/test/benchmark/happy.t.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/l1-contracts/test/benchmark/happy.t.sol b/l1-contracts/test/benchmark/happy.t.sol index 8f2f11f103ca..a69dae9aaf08 100644 --- a/l1-contracts/test/benchmark/happy.t.sol +++ b/l1-contracts/test/benchmark/happy.t.sol @@ -604,9 +604,10 @@ abstract contract PartialEpochProofGasReportBase is BenchmarkRollupBase { function _sendInboxMessage(uint256 _salt) internal { vm.prank(address(this)); - Inbox(address(rollup.getInbox())).sendL2Message( - DataStructures.L2Actor({actor: bytes32(_salt), version: rollup.getVersion()}), bytes32(_salt), bytes32(0) - ); + Inbox(address(rollup.getInbox())) + .sendL2Message( + DataStructures.L2Actor({actor: bytes32(_salt), version: rollup.getVersion()}), bytes32(_salt), bytes32(0) + ); } function _prepareGasReportEpoch() internal {