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..379eb28bcd9a 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": 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": 654868, - "mean": 654868, - "median": 654868, - "max": 654868 + "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": 1732651, - "mean": 1732651, - "median": 1732651, - "max": 1732651 + "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": 956875, - "mean": 956875, - "median": 956875, - "max": 956875 + "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": 910405, - "mean": 910405, - "median": 910405, - "max": 910405 + "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 9cb2f0b0f9ab..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,868 | -| 8 Checkpoints | 956,875 | -| 8 More Checkpoints | 910,405 | -| 16 Checkpoints | 1,246,224 | -| 32 Checkpoints | 1,732,651 | +| 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/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/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..a69dae9aaf08 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,48 @@ 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 +632,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 +669,7 @@ abstract contract PartialEpochProofGasReportBase is BenchmarkRollupBase { } assertEq(rollup.getPendingCheckpointNumber(), EPOCH_DURATION); + assertEq(inboxSeeded, _seedInitialInboxBucket()); assertEq(rollup.getEpochCommittee(Epoch.wrap(GAS_REPORT_EPOCH)).length, 48); } 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) {