diff --git a/l1-contracts/gas_benchmark.md b/l1-contracts/gas_benchmark.md index c7da56e4e562..944a34485c33 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,527 | 224,710 | 996 | 15,936 | -| submitEpochRootProof | 925,439 | 966,485 | 14,212 | 227,392 | +| propose | 198,659 | 224,842 | 996 | 15,936 | +| submitEpochRootProof | 925,461 | 966,507 | 14,212 | 227,392 | | setupEpoch | 32,020 | 113,815 | - | - | -**Avg Gas Cost per Second**: 3,574.5 gas/second +**Avg Gas Cost per Second**: 3,576.4 gas/second *Epoch duration*: 0h 38m 24s ## Validators | Function | Avg Gas | Max Gas | Calldata Size | Calldata Gas | |----------------------|-----------|-----------|---------------|--------------| -| 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 | - | - | +| propose | 327,074 | 354,847 | 4,516 | 72,256 | +| submitEpochRootProof | 1,505,353 | 1,605,843 | 16,708 | 267,328 | +| aggregate3 | 376,090 | 389,450 | - | - | | setupEpoch | 46,482 | 547,648 | - | - | -**Avg Gas Cost per Second**: 5,867.7 gas/second +**Avg Gas Cost per Second**: 5,869.6 gas/second *Epoch duration*: 0h 38m 24s diff --git a/l1-contracts/gas_benchmark_results.json b/l1-contracts/gas_benchmark_results.json index eae259bf540d..2875d492dc99 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": 184882, - "mean": 198527, - "median": 194272, - "max": 224710, + "min": 185014, + "mean": 198659, + "median": 194404, + "max": 224842, "calldata_size": 996, "calldata_gas": 15936 }, @@ -18,10 +18,10 @@ }, "submitEpochRootProof": { "calls": 4, - "min": 904555, - "mean": 925439, - "median": 915359, - "max": 966485, + "min": 904577, + "mean": 925461, + "median": 915381, + "max": 966507, "calldata_size": 14212, "calldata_gas": 227392 } @@ -29,10 +29,10 @@ "validators": { "propose": { "calls": 150, - "min": 304546, - "mean": 326942, - "median": 326387, - "max": 354715, + "min": 304678, + "mean": 327074, + "median": 326519, + "max": 354847, "calldata_size": 4516, "calldata_gas": 72256 }, @@ -45,19 +45,19 @@ }, "submitEpochRootProof": { "calls": 4, - "min": 1393333, - "mean": 1505331, - "median": 1511086, - "max": 1605821, + "min": 1393355, + "mean": 1505353, + "median": 1511108, + "max": 1605843, "calldata_size": 16708, "calldata_gas": 267328 }, "aggregate3": { "calls": 55, - "min": 364757, - "mean": 375936, - "median": 375678, - "max": 389296 + "min": 364911, + "mean": 376090, + "median": 375832, + "max": 389450 } } } \ No newline at end of file diff --git a/l1-contracts/gas_report.json b/l1-contracts/gas_report.json index fa3f1190ac30..1e61c0079609 100644 --- a/l1-contracts/gas_report.json +++ b/l1-contracts/gas_report.json @@ -3,15 +3,15 @@ "contract": "src/core/messagebridge/Inbox.sol:Inbox", "deployment": { "gas": 0, - "size": 6805 + "size": 7903 }, "functions": { "getBucket(uint256)": { "calls": 4689, - "min": 7436, - "mean": 7436, - "median": 7436, - "max": 7436 + "min": 7475, + "mean": 7475, + "median": 7475, + "max": 7475 }, "getCurrentBucketSeq()": { "calls": 4694, @@ -22,10 +22,10 @@ }, "getFeeAssetPortal()": { "calls": 2592, - "min": 212, - "mean": 212, - "median": 212, - "max": 212 + "min": 345, + "mean": 345, + "median": 345, + "max": 345 }, "getProvenConsumedBucketSeq()": { "calls": 6, @@ -36,10 +36,10 @@ }, "getRingHeadroom()": { "calls": 1, - "min": 2618, - "mean": 2618, - "median": 2618, - "max": 2618 + "min": 2596, + "mean": 2596, + "median": 2596, + "max": 2596 }, "sendL2Message((bytes32,uint256),bytes32,bytes32)": { "calls": 37489, @@ -90,7 +90,7 @@ }, "functions": { "getCanonicalRollup()": { - "calls": 1770, + "calls": 1686, "min": 1073, "mean": 4073, "median": 4073, @@ -120,7 +120,7 @@ }, "functions": { "availableTo(address)": { - "calls": 885, + "calls": 843, "min": 20573, "mean": 20573, "median": 20573, @@ -132,7 +132,7 @@ "contract": "test/RollupWithPreheating.sol:RollupWithPreheating", "deployment": { "gas": 0, - "size": 42838 + "size": 44546 }, "functions": { "archive()": { @@ -143,7 +143,7 @@ "max": 4641 }, "getCheckpoint(uint256)": { - "calls": 897, + "calls": 855, "min": 27343, "mean": 27343, "median": 27343, @@ -151,20 +151,20 @@ }, "getCheckpointReward()": { "calls": 2595, - "min": 1302, - "mean": 1307, - "median": 1302, - "max": 5802 + "min": 1150, + "mean": 1155, + "median": 1150, + "max": 5650 }, "getCollectiveProverRewardsForEpoch(uint256)": { "calls": 3, - "min": 5948, - "mean": 5948, - "median": 5948, - "max": 5948 + "min": 5970, + "mean": 5970, + "median": 5970, + "max": 5970 }, "getCurrentEpoch()": { - "calls": 886, + "calls": 844, "min": 915, "mean": 915, "median": 915, @@ -179,10 +179,10 @@ }, "getEpochDuration()": { "calls": 2, - "min": 2420, - "mean": 2420, - "median": 2420, - "max": 2420 + "min": 2442, + "mean": 2442, + "median": 2442, + "max": 2442 }, "getEpochProofPublicInputs(uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],bytes)": { "calls": 5, @@ -193,45 +193,45 @@ }, "getEthPerFeeAsset()": { "calls": 2, - "min": 11021, - "mean": 11021, - "median": 11021, - "max": 11021 + "min": 11043, + "mean": 11043, + "median": 11043, + "max": 11043 }, "getFeeAssetPortal()": { "calls": 4672, - "min": 879, - "mean": 879, - "median": 879, - "max": 879 + "min": 901, + "mean": 901, + "median": 901, + "max": 901 }, "getInbox()": { "calls": 9112, - "min": 856, - "mean": 856, - "median": 856, - "max": 856 + "min": 878, + "mean": 878, + "median": 878, + "max": 878 }, "getL1FeesAt(uint256)": { "calls": 2, - "min": 9064, - "mean": 9064, - "median": 9064, - "max": 9064 + "min": 9086, + "mean": 9086, + "median": 9086, + "max": 9086 }, "getManaMinFeeAt(uint256,bool)": { "calls": 2347, - "min": 27286, - "mean": 28944, - "median": 27286, - "max": 32304 + "min": 27308, + "mean": 28966, + "median": 27308, + "max": 32326 }, "getManaTarget()": { "calls": 1026, - "min": 2612, - "mean": 2612, - "median": 2612, - "max": 2612 + "min": 2459, + "mean": 2459, + "median": 2459, + "max": 2459 }, "getOutbox()": { "calls": 2, @@ -256,24 +256,24 @@ }, "getProvingCostPerManaInEth()": { "calls": 1, - "min": 5707, - "mean": 5707, - "median": 5707, - "max": 5707 + "min": 5729, + "mean": 5729, + "median": 5729, + "max": 5729 }, "getProvingCostPerManaInFeeAsset()": { "calls": 1, - "min": 14453, - "mean": 14453, - "median": 14453, - "max": 14453 + "min": 14475, + "mean": 14475, + "median": 14475, + "max": 14475 }, "getSequencerRewards(address)": { "calls": 3, - "min": 5959, - "mean": 5959, - "median": 5959, - "max": 5959 + "min": 5981, + "mean": 5981, + "median": 5981, + "max": 5981 }, "getTimestampForSlot(uint256)": { "calls": 2455, @@ -291,24 +291,24 @@ }, "owner()": { "calls": 5185, - "min": 511, - "mean": 511, - "median": 511, - "max": 2511 + "min": 533, + "mean": 533, + "median": 533, + "max": 2533 }, "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": 2350, - "min": 55404, - "mean": 266090, - "median": 283443, - "max": 324553 + "min": 55426, + "mean": 266221, + "median": 283575, + "max": 324685 }, "prune()": { "calls": 7, - "min": 26689, - "mean": 33279, - "median": 33682, - "max": 38274 + "min": 26711, + "mean": 33301, + "median": 33704, + "max": 38296 }, "setProvingCostPerMana(uint256)": { "calls": 1, @@ -318,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": 893, - "min": 60875, - "mean": 397634, - "median": 403830, - "max": 437846 + "calls": 851, + "min": 60897, + "mean": 397538, + "median": 403852, + "max": 437868 }, "updateManaTarget(uint256)": { "calls": 512, diff --git a/l1-contracts/partial_epoch_proof_gas_report.json b/l1-contracts/partial_epoch_proof_gas_report.json index 379eb28bcd9a..6c3bf56afebd 100644 --- a/l1-contracts/partial_epoch_proof_gas_report.json +++ b/l1-contracts/partial_epoch_proof_gas_report.json @@ -3,15 +3,15 @@ "contract": "test/RollupWithPreheating.sol:RollupWithPreheating", "deployment": { "gas": 0, - "size": 42838 + "size": 44546 }, "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": 1246286, - "mean": 1246286, - "median": 1246286, - "max": 1246286 + "min": 1246308, + "mean": 1246308, + "median": 1246308, + "max": 1246308 }, "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, diff --git a/l1-contracts/partial_epoch_proof_gas_report.md b/l1-contracts/partial_epoch_proof_gas_report.md index 9fbd94c59b38..aefbc031c146 100644 --- a/l1-contracts/partial_epoch_proof_gas_report.md +++ b/l1-contracts/partial_epoch_proof_gas_report.md @@ -5,7 +5,7 @@ | 1 Checkpoint | 654,930 | | 8 Checkpoints | 956,937 | | 8 More Checkpoints | 910,467 | -| 16 Checkpoints | 1,246,286 | +| 16 Checkpoints | 1,246,308 | | 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/scripts/constants-codegen/solidity.json b/l1-contracts/scripts/constants-codegen/solidity.json index 67650fce4f17..cbb23f9d703c 100644 --- a/l1-contracts/scripts/constants-codegen/solidity.json +++ b/l1-contracts/scripts/constants-codegen/solidity.json @@ -1,6 +1,7 @@ [ "MAX_FIELD_VALUE", "L1_TO_L2_MSG_SUBTREE_HEIGHT", + "MAX_L1_TO_L2_MSGS_PER_BLOCK", "MAX_L1_TO_L2_MSGS_PER_CHECKPOINT", "MAX_L2_TO_L1_MSGS_PER_TX", "INITIAL_CHECKPOINT_NUMBER", diff --git a/l1-contracts/src/core/Rollup.sol b/l1-contracts/src/core/Rollup.sol index 74bcd1f06c4f..b2b7c5fe1e21 100644 --- a/l1-contracts/src/core/Rollup.sol +++ b/l1-contracts/src/core/Rollup.sol @@ -12,6 +12,7 @@ import { ManaMinFeeComponents, EthPerFeeAssetE12, CheckpointHeaderValidationFlags, + CheckpointPreflightArgs, FeeHeader, RollupConfigInput, RollupStore @@ -103,6 +104,24 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore { ); } + /** + * @notice Validate a header and its final Inbox consumption against the parent `propose` would use at this time + * + * @dev Convenience function for the sequencer to simulate with the intended execution timestamp and state; + * derives the parent from storage, so the caller's `_expectedParentCheckpointNumber` is checked rather + * than trusted. See `RollupOperationsExtLib.validateCheckpointHeaderAndInbox`. + * + * @param _args - The header validation inputs plus the consumed Inbox total and the expected parent + * @return The Inbox bucket sequence to submit as `bucketHint` + */ + function validateCheckpointHeaderAndInbox(CheckpointPreflightArgs calldata _args) + external + override(IRollup) + returns (uint64) + { + return RollupOperationsExtLib.validateCheckpointHeaderAndInbox(_args, _getRollupConfig().inbox); + } + /** * @notice Get the validator set for the current epoch * @return The validator set for the current epoch diff --git a/l1-contracts/src/core/interfaces/IRollup.sol b/l1-contracts/src/core/interfaces/IRollup.sol index 41c25aa348a7..520917fad64d 100644 --- a/l1-contracts/src/core/interfaces/IRollup.sol +++ b/l1-contracts/src/core/interfaces/IRollup.sol @@ -58,6 +58,32 @@ struct CheckpointHeaderValidationFlags { bool ignoreDA; } +/** + * @notice Inputs of the proposer's integrated header and Inbox preflight + * @dev Bundles the `validateHeaderWithAttestations` argument set with the two Inbox inputs so the call stays off + * the stack limit and the Rollup's forwarder stays small. + * @param header - The proposed checkpoint header + * @param attestations - Committee attestations to validate, or empty to skip signature checks + * @param signers - Addresses of the signers in the attestations + * @param attestationsAndSignersSignature - The proposer's signature over the attestations and signers + * @param digest - The digest the attestations signed + * @param blobsHash - The blobs hash for this checkpoint + * @param flags - Which header checks to skip + * @param expectedTotal - The cumulative Inbox message count the checkpoint consumed up to + * @param expectedParentCheckpointNumber - The checkpoint number the header was built on + */ +struct CheckpointPreflightArgs { + ProposedHeader header; + CommitteeAttestations attestations; + address[] signers; + Signature attestationsAndSignersSignature; + bytes32 digest; + bytes32 blobsHash; + CheckpointHeaderValidationFlags flags; + uint64 expectedTotal; + uint256 expectedParentCheckpointNumber; +} + struct GenesisState { bytes32 vkTreeRoot; bytes32 protocolContractsHash; @@ -187,6 +213,8 @@ interface IRollup is IRollupCore, IHaveVersion { CheckpointHeaderValidationFlags memory _flags ) external; + function validateCheckpointHeaderAndInbox(CheckpointPreflightArgs calldata _args) external returns (uint64); + function canProposeAtTime(Timestamp _ts, bytes32 _archive, address _who) external returns (Slot, uint256); function getTips() external view returns (ChainTips memory); diff --git a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol index cbecaf0e5b37..8e8be55c242d 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol @@ -2,12 +2,14 @@ // Copyright 2024 Aztec Labs. pragma solidity >=0.8.27; +import {Constants} from "../../libraries/ConstantsGen.sol"; import {DataStructures} from "../../libraries/DataStructures.sol"; // Maximum number of messages a single bucket can hold before further messages in the same L1 block spill over -// into the next bucket. Matches the number of L1 to L2 messages a single L2 block can insert, so any one bucket -// is always consumable by one block. -uint256 constant MAX_MSGS_PER_BUCKET = 256; +// into the next bucket. Aliases the protocol's per-block message cap so any one bucket is always consumable by one +// L2 block, and so the node-side completion guard that reserves one bucket of checkpoint capacity uses the same +// generated value. +uint256 constant MAX_MSGS_PER_BUCKET = Constants.MAX_L1_TO_L2_MSGS_PER_BLOCK; /** * @title Inbox @@ -107,6 +109,18 @@ interface IInbox { */ function getBucket(uint256 _seq) external view returns (InboxBucket memory); + /** + * @notice Returns the live bucket with the greatest cumulative message total at or below `_upperBound` + * @dev Cumulative totals strictly increase with the bucket sequence (every opened bucket absorbs at least one + * message), so the result is the newest retained bucket whose end position does not exceed the bound. Probes the + * newest four live entries first, then binary-searches the remaining live interval. Only ring entries that have + * not been overwritten are candidates; the genesis bucket qualifies only while its ring entry is still live. + * Reverts with `Inbox__NoBucketAtOrBeforeTotal` when even the oldest retained bucket ends past the bound. + * @param _upperBound - The cumulative message total the returned bucket may not exceed + * @return The sequence number of the matching bucket and the bucket itself + */ + function getBucketAtOrBeforeTotal(uint64 _upperBound) external view returns (uint64, InboxBucket memory); + /** * @notice Returns the sequence number of the newest bucket consumed by the proven chain * @return The proven-consumed bucket sequence number diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index cce835cb2b2d..b59918b30573 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -29,6 +29,7 @@ library Errors { error Inbox__BucketOutOfWindow(uint256 seq, uint256 current); // 0xfee255b7 error Inbox__Unauthorized(); // 0xe5336a6b error Inbox__WouldOverwriteUnconsumedBucket(uint64 evictedBucketSeq); // 0x2eb49c6d + error Inbox__NoBucketAtOrBeforeTotal(uint256 upperBound, uint256 oldestLiveTotal); // Outbox error Outbox__Unauthorized(); // 0x2c9490c2 @@ -65,6 +66,8 @@ library Errors { error Rollup__UnconsumedInboxMessages(uint256 nextBucketSeq); // 0x2bd4bf10 error Rollup__InboxConsumptionBehindParent(uint256 expected, uint256 actual); // 0x54e0c025 error Rollup__TooManyInboxMessagesConsumed(uint256 consumed); // 0xf76d1426 + error Rollup__InboxTotalNotAtBucketBoundary(uint256 expected, uint256 actual); + error Rollup__UnexpectedParentCheckpoint(uint256 expected, uint256 actual); error Rollup__InvalidOutHash(bytes32 expected, bytes32 actual); // 0x8eb39062 error Rollup__InvalidPreviousArchive(bytes32 expected, bytes32 actual); // 0xb682a40e error Rollup__InvalidProof(); // 0xa5b2ba17 diff --git a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol index bd7aaa8d736b..ddb96e4a3fb8 100644 --- a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol @@ -485,6 +485,39 @@ library ProposeLib { return bucket.totalMsgCount; } + /** + * @notice Resolves a checkpoint's final consumed Inbox total to a live bucket and validates the consumption it + * implies, returning the bucket sequence to pass to `propose` as `bucketHint` + * + * @dev The proposer-side counterpart of the `propose` check. `propose` receives the bucket sequence as an unsigned + * hint; a proposer only knows the total it consumed up to, so this resolves that total to the bucket ending + * exactly there and runs that bucket through the same `validateInboxConsumption` predicate. A total that is + * not a bucket boundary has no snapshot to compare the header's rolling hash against and is rejected, as is a + * total below the oldest retained bucket (`Inbox__NoBucketAtOrBeforeTotal`). + * + * @param _inbox - The Inbox holding the rolling-hash buckets + * @param _inboxRollingHash - The checkpoint header's inbox rolling hash + * @param _expectedTotal - The cumulative Inbox message count the checkpoint consumed up to + * @param _slotNumber - The slot the checkpoint is proposed in + * @param _parentTotalMsgCount - Cumulative Inbox message count consumed as of the parent checkpoint + * @return The sequence number of the bucket ending at `_expectedTotal` + */ + function validateInboxConsumptionAtTotal( + IInbox _inbox, + bytes32 _inboxRollingHash, + uint64 _expectedTotal, + Slot _slotNumber, + uint256 _parentTotalMsgCount + ) internal view returns (uint64) { + (uint64 seq, IInbox.InboxBucket memory bucket) = _inbox.getBucketAtOrBeforeTotal(_expectedTotal); + require( + bucket.totalMsgCount == _expectedTotal, + Errors.Rollup__InboxTotalNotAtBucketBoundary(_expectedTotal, bucket.totalMsgCount) + ); + validateInboxConsumption(_inbox, _inboxRollingHash, seq, _slotNumber, _parentTotalMsgCount); + return seq; + } + /** * @notice Gets the mana min fee components * For more context, consult: diff --git a/l1-contracts/src/core/libraries/rollup/RollupOperationsExtLib.sol b/l1-contracts/src/core/libraries/rollup/RollupOperationsExtLib.sol index 20837d10f2dd..159717b0aa25 100644 --- a/l1-contracts/src/core/libraries/rollup/RollupOperationsExtLib.sol +++ b/l1-contracts/src/core/libraries/rollup/RollupOperationsExtLib.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.27; import {Errors} from "@aztec/core/libraries/Errors.sol"; -import {CheckpointHeaderValidationFlags} from "@aztec/core/interfaces/IRollup.sol"; +import {CheckpointHeaderValidationFlags, CheckpointPreflightArgs} from "@aztec/core/interfaces/IRollup.sol"; import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; import {STFLib} from "@aztec/core/libraries/rollup/STFLib.sol"; import {Timestamp, TimeLib, Slot, Epoch} from "@aztec/core/libraries/TimeLib.sol"; @@ -55,24 +55,56 @@ library RollupOperationsExtLib { bytes32 _blobsHash, CheckpointHeaderValidationFlags calldata _flags ) external { - ProposeLib.validateHeader( - ValidateHeaderArgs({ - header: _header, - digest: _digest, - manaMinFee: FeeLib.summedMinFee(ProposeLib.getManaMinFeeComponentsAt(Timestamp.wrap(block.timestamp), true)), - blobsHashesCommitment: _blobsHash, - flags: _flags - }) + checkHeaderWithAttestations( + _header, _attestations, _signers, _attestationsAndSignersSignature, _digest, _blobsHash, _flags ); - if (_attestations.isEmpty()) { - return; // No attestations to validate - } + } - Slot slot = _header.slotNumber; - Epoch epoch = slot.epochFromSlot(); - ValidatorSelectionLib.verifyAttestations(epoch, _attestations, _digest); - ValidatorSelectionLib.verifyProposer( - slot, epoch, _attestations, _signers, _digest, _attestationsAndSignersSignature, false + /** + * @notice Validates a checkpoint header together with its final Inbox consumption against the parent `propose` + * would build on at the simulated `block.timestamp`, returning the bucket sequence to submit as `bucketHint` + * + * @dev The parent is derived exactly as `propose` derives it: the effective pending checkpoint at + * `block.timestamp`, i.e. the proven tip if the pending chain is prunable at that time. The caller states + * which parent it built on and the call rejects any other, so a simulation whose state overrides do not + * survive the real prune rule fails here instead of at `propose`. The header's `lastArchiveRoot` is checked + * against that parent's archive by the shared header validation, and the parent's consumed total comes from + * its stored record, never from the caller. The Inbox check resolves `_expectedTotal` to a live bucket and runs + * the same settlement, monotonicity, cap and censorship predicate as `propose`. + * + * Meant to be simulated with the intended execution timestamp and state; the real transaction's + * `block.timestamp` remains authoritative. + * + * @param _args - The header validation inputs plus the consumed Inbox total and the expected parent + * @param _inbox - The Inbox to validate consumption against + * @return The sequence number of the bucket ending at `_args.expectedTotal` + */ + function validateCheckpointHeaderAndInbox(CheckpointPreflightArgs calldata _args, IInbox _inbox) + external + returns (uint64) + { + uint256 effectiveParent = STFLib.getEffectivePendingCheckpointNumber(Timestamp.wrap(block.timestamp)); + require( + effectiveParent == _args.expectedParentCheckpointNumber, + Errors.Rollup__UnexpectedParentCheckpoint(_args.expectedParentCheckpointNumber, effectiveParent) + ); + + checkHeaderWithAttestations( + _args.header, + _args.attestations, + _args.signers, + _args.attestationsAndSignersSignature, + _args.digest, + _args.blobsHash, + _args.flags + ); + + return ProposeLib.validateInboxConsumptionAtTotal( + _inbox, + _args.header.inboxRollingHash, + _args.expectedTotal, + _args.header.slotNumber, + STFLib.getInboxMsgTotal(effectiveParent) ); } @@ -111,4 +143,35 @@ library RollupOperationsExtLib { function getBlobBaseFee() external view returns (uint256) { return BlobLib.getBlobBaseFee(); } + + function checkHeaderWithAttestations( + ProposedHeader calldata _header, + CommitteeAttestations calldata _attestations, + address[] calldata _signers, + Signature calldata _attestationsAndSignersSignature, + bytes32 _digest, + bytes32 _blobsHash, + CheckpointHeaderValidationFlags calldata _flags + ) internal { + ProposeLib.validateHeader( + ValidateHeaderArgs({ + header: _header, + digest: _digest, + manaMinFee: FeeLib.summedMinFee(ProposeLib.getManaMinFeeComponentsAt(Timestamp.wrap(block.timestamp), true)), + blobsHashesCommitment: _blobsHash, + flags: _flags + }) + ); + + if (_attestations.isEmpty()) { + return; // No attestations to validate + } + + Slot slot = _header.slotNumber; + Epoch epoch = slot.epochFromSlot(); + ValidatorSelectionLib.verifyAttestations(epoch, _attestations, _digest); + ValidatorSelectionLib.verifyProposer( + slot, epoch, _attestations, _signers, _digest, _attestationsAndSignersSignature, false + ); + } } diff --git a/l1-contracts/src/core/messagebridge/Inbox.sol b/l1-contracts/src/core/messagebridge/Inbox.sol index 21cf892c42b5..097d70928db7 100644 --- a/l1-contracts/src/core/messagebridge/Inbox.sol +++ b/l1-contracts/src/core/messagebridge/Inbox.sol @@ -26,6 +26,11 @@ uint256 constant INBOX_BUCKET_RING_SIZE = 4096; // rounded up to the next power of two, kept at or below the production ring. uint256 constant MIN_BUCKET_RING_SIZE = 512; +// Number of newest live buckets `getBucketAtOrBeforeTotal` probes one by one before binary-searching the rest of +// the ring. Checkpoint endpoints normally sit within the last few buckets, so the short walk resolves the common +// case in a handful of reads while a target far behind a deposit backlog still costs O(probes + log ring size). +uint256 constant ENDPOINT_WALKBACK_PROBES = 4; + /** * @title Inbox * @author Aztec Labs @@ -158,6 +163,64 @@ contract Inbox is IInbox { return buckets[_seq % BUCKET_RING_SIZE]; } + /** + * @notice Returns the live bucket with the greatest cumulative message total at or below `_upperBound` + * + * @dev Totals strictly increase with the bucket sequence, so the newest live bucket whose total does not exceed + * the bound is the answer. The live interval is `[oldest, current]` with `oldest = current - ringSize + 1` once the + * ring has wrapped (computed subtraction-first so `current + 1` cannot overflow at the sequence maximum); entries + * below `oldest` share a ring slot with a newer bucket and are never dereferenced. Walks back from `current` for + * at most ENDPOINT_WALKBACK_PROBES live entries, checking for `oldest` before each step down, then binary-searches + * only the still unscanned interval below the walk. + * + * @param _upperBound - The cumulative message total the returned bucket may not exceed + * + * @return The sequence number of the matching bucket and the bucket itself + */ + function getBucketAtOrBeforeTotal(uint64 _upperBound) + external + view + override(IInbox) + returns (uint64, InboxBucket memory) + { + uint256 current = currentBucketSeq; + uint256 oldest = 0; + if (current >= BUCKET_RING_SIZE) { + oldest = current - BUCKET_RING_SIZE + 1; + } + + uint256 seq = current; + for (uint256 i = 0; i < ENDPOINT_WALKBACK_PROBES; i++) { + uint64 total = buckets[seq % BUCKET_RING_SIZE].totalMsgCount; + if (total <= _upperBound) { + return (SafeCast.toUint64(seq), buckets[seq % BUCKET_RING_SIZE]); + } + if (seq == oldest) { + revert Errors.Inbox__NoBucketAtOrBeforeTotal(_upperBound, total); + } + seq -= 1; + } + + // Every entry in (seq, current] exceeds the bound, so the answer, if any, lies in [oldest, seq]. + uint256 lo = oldest; + uint256 hi = seq; + uint64 oldestTotal = buckets[lo % BUCKET_RING_SIZE].totalMsgCount; + if (oldestTotal > _upperBound) { + revert Errors.Inbox__NoBucketAtOrBeforeTotal(_upperBound, oldestTotal); + } + // Invariant: the bucket at `lo` qualifies and every bucket above `hi` does not; `mid` rounds up so `lo` always + // moves on a hit and the loop converges on the greatest qualifying sequence. + while (lo < hi) { + uint256 mid = hi - (hi - lo) / 2; + if (buckets[mid % BUCKET_RING_SIZE].totalMsgCount <= _upperBound) { + lo = mid; + } else { + hi = mid - 1; + } + } + return (SafeCast.toUint64(lo), buckets[lo % BUCKET_RING_SIZE]); + } + function getProvenConsumedBucketSeq() external view override(IInbox) returns (uint64) { return provenConsumedBucketSeq; } diff --git a/l1-contracts/test/InboxBucketSearch.t.sol b/l1-contracts/test/InboxBucketSearch.t.sol new file mode 100644 index 000000000000..28295c253cb3 --- /dev/null +++ b/l1-contracts/test/InboxBucketSearch.t.sol @@ -0,0 +1,337 @@ +// 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 {ENDPOINT_WALKBACK_PROBES, 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 {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; + +/** + * `getBucketAtOrBeforeTotal`: the newest live bucket whose cumulative total is at or below a bound. Every test + * compares against a brute-force walk over `getBucket`, which is the reference definition of "live". + */ +contract InboxBucketSearchTest is Test { + uint256 internal constant RING_SIZE = MIN_BUCKET_RING_SIZE; + + InboxHarness internal inbox; + uint256 internal version = 0; + + function setUp() public { + IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this)); + inbox = new InboxHarness(address(this), feeAsset, version, RING_SIZE); + } + + function _send(uint256 _salt) internal { + inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}), + bytes32(uint256(0x2000 + _salt)), + bytes32(uint256(0x3000 + _salt)) + ); + } + + // Opens one bucket per L1 block, each holding `_sizes[i]` messages. + function _openBuckets(uint256[] memory _sizes) internal { + for (uint256 i = 0; i < _sizes.length; i++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + for (uint256 j = 0; j < _sizes[i]; j++) { + _send(inbox.getTotalMessagesInserted()); + } + } + } + + // Opens `_count` single-message buckets, one per L1 block, keeping proven consumption trailing so the ring may + // wrap past its oldest entries. + function _openSingleMessageBuckets(uint256 _count) internal { + for (uint256 i = 0; i < _count; i++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _send(inbox.getTotalMessagesInserted()); + inbox.markProvenConsumed(inbox.getCurrentBucketSeq() - 1); + } + } + + function _oldestLiveSeq() internal view returns (uint256) { + uint256 current = inbox.getCurrentBucketSeq(); + if (current >= RING_SIZE) { + return current - RING_SIZE + 1; + } + return 0; + } + + // Reference: walks every live bucket from newest to oldest and returns the first whose total fits the bound. + function _bruteForce(uint64 _bound) internal view returns (bool found, uint256 seq) { + uint256 oldest = _oldestLiveSeq(); + for (uint256 s = inbox.getCurrentBucketSeq(); s >= oldest; s--) { + if (inbox.getBucket(s).totalMsgCount <= _bound) { + return (true, s); + } + if (s == 0) { + break; + } + } + return (false, 0); + } + + 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); + } + + // Asserts the search agrees with the brute-force reference for `_bound`, including the not-found revert. + function _assertAgreesWithReference(uint64 _bound) internal { + (bool found, uint256 expectedSeq) = _bruteForce(_bound); + if (!found) { + uint64 oldestTotal = inbox.getBucket(_oldestLiveSeq()).totalMsgCount; + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__NoBucketAtOrBeforeTotal.selector, _bound, oldestTotal)); + inbox.getBucketAtOrBeforeTotal(_bound); + return; + } + (uint64 seq, IInbox.InboxBucket memory bucket) = inbox.getBucketAtOrBeforeTotal(_bound); + assertEq(seq, expectedSeq, "sequence"); + _assertBucketEq(bucket, inbox.getBucket(expectedSeq), "bucket data"); + } + + // Storage slot holding the packed (totalMsgCount, timestamp, msgCount) word of the ring entry for `_seq`. + function _totalsSlotOf(uint256 _seq) internal pure returns (bytes32) { + // `buckets` is the first storage variable; the struct's bytes32 occupies the base slot, the packed word the next. + return bytes32(uint256(keccak256(abi.encode(_seq % RING_SIZE, uint256(0)))) + 1); + } + + function _countReadsOf(bytes32[] memory _reads, bytes32 _slot) internal pure returns (uint256 count) { + for (uint256 i = 0; i < _reads.length; i++) { + if (_reads[i] == _slot) { + count++; + } + } + } + + function testGenesisOnEmptyInbox() public view { + (uint64 seq, IInbox.InboxBucket memory bucket) = inbox.getBucketAtOrBeforeTotal(0); + assertEq(seq, 0, "genesis seq for bound 0"); + assertEq(bucket.totalMsgCount, 0, "genesis total"); + + (seq,) = inbox.getBucketAtOrBeforeTotal(type(uint64).max); + assertEq(seq, 0, "genesis is the only bucket, whatever the bound"); + } + + // With fewer live entries than probes, the walk reaches genesis and stops there: the bound is never below the + // genesis total, so nothing is dereferenced below sequence zero. + function testFewerLiveEntriesThanProbes() public { + uint256[] memory sizes = new uint256[](2); + sizes[0] = 3; + sizes[1] = 2; + _openBuckets(sizes); + assertEq(inbox.getCurrentBucketSeq(), 2, "two buckets plus genesis"); + + _assertAgreesWithReference(0); // genesis + _assertAgreesWithReference(2); // still genesis: bucket 1 ends at 3 + _assertAgreesWithReference(3); // exact hit on bucket 1 + _assertAgreesWithReference(4); // interior of bucket 2 + _assertAgreesWithReference(5); // exact hit on the current bucket + _assertAgreesWithReference(type(uint64).max); + } + + // Exact bounds land on the bucket ending there; interior bounds fall back to the previous bucket. + function testExactAndInteriorBounds() public { + uint256[] memory sizes = new uint256[](4); + sizes[0] = 1; + sizes[1] = 3; + sizes[2] = 2; + sizes[3] = 4; + _openBuckets(sizes); + // Totals: 1, 4, 6, 10. + (uint64 seq,) = inbox.getBucketAtOrBeforeTotal(4); + assertEq(seq, 2, "exact bound on bucket 2"); + (seq,) = inbox.getBucketAtOrBeforeTotal(5); + assertEq(seq, 2, "interior of bucket 3 resolves to bucket 2"); + (seq,) = inbox.getBucketAtOrBeforeTotal(9); + assertEq(seq, 3, "interior of the current bucket resolves to bucket 3"); + for (uint64 bound = 0; bound <= 11; bound++) { + _assertAgreesWithReference(bound); + } + } + + // A full bucket and the spill-over bucket that follows it in the same L1 block are distinct endpoints. + function testFullAndSpilledBucketsInOneBlock() public { + uint256 cap = MAX_MSGS_PER_BUCKET; + assertEq(cap, Constants.MAX_L1_TO_L2_MSGS_PER_BLOCK, "bucket cap is the generated per-block cap"); + for (uint256 i = 0; i < cap + 1; i++) { + _send(i); + } + assertEq(inbox.getCurrentBucketSeq(), 2, "cap plus one spills into a second bucket"); + + (uint64 seq, IInbox.InboxBucket memory bucket) = inbox.getBucketAtOrBeforeTotal(uint64(cap)); + assertEq(seq, 1, "the full bucket"); + assertEq(bucket.msgCount, cap, "full"); + (seq, bucket) = inbox.getBucketAtOrBeforeTotal(uint64(cap + 1)); + assertEq(seq, 2, "the spilled bucket"); + assertEq(bucket.msgCount, 1, "one spilled message"); + (seq,) = inbox.getBucketAtOrBeforeTotal(uint64(cap - 1)); + assertEq(seq, 0, "nothing but genesis fits below the full bucket"); + } + + // Each of the walkback probes can be the one that hits, and the probe count bounds the storage reads. + function testHitOnEachWalkbackProbe() public { + uint256[] memory sizes = new uint256[](8); + for (uint256 i = 0; i < sizes.length; i++) { + sizes[i] = 2; + } + _openBuckets(sizes); + uint256 current = inbox.getCurrentBucketSeq(); + + for (uint256 probe = 0; probe < ENDPOINT_WALKBACK_PROBES; probe++) { + uint256 target = current - probe; + uint64 bound = inbox.getBucket(target).totalMsgCount; + + vm.record(); + (uint64 seq,) = inbox.getBucketAtOrBeforeTotal(bound); + (bytes32[] memory reads,) = vm.accesses(address(inbox)); + + assertEq(seq, target, "probe hit"); + // One read of the sequence word, one totals read per probed entry, and the two-slot copy of the result. + assertLe(reads.length, 1 + (probe + 1) + 2, "walk stays within the probed entries"); + for (uint256 s = target + 1; s <= current; s++) { + assertEq(_countReadsOf(reads, _totalsSlotOf(s)), 1, "each missed entry is probed exactly once"); + } + } + } + + // Bounds just beyond the walk fall through to the binary search, which agrees with the reference and never + // re-reads the entries the walk already excluded. + function testFallbackBeyondWalkback() public { + uint256[] memory sizes = new uint256[](12); + for (uint256 i = 0; i < sizes.length; i++) { + sizes[i] = 3; + } + _openBuckets(sizes); + uint256 current = inbox.getCurrentBucketSeq(); + uint256 firstUnscanned = current - ENDPOINT_WALKBACK_PROBES; + + // Exactly the first entry beyond the walk, then the interior just below it. + uint64 bound = inbox.getBucket(firstUnscanned).totalMsgCount; + vm.record(); + (uint64 seq,) = inbox.getBucketAtOrBeforeTotal(bound); + (bytes32[] memory reads,) = vm.accesses(address(inbox)); + assertEq(seq, firstUnscanned, "first unscanned entry"); + for (uint256 s = firstUnscanned + 1; s <= current; s++) { + assertEq(_countReadsOf(reads, _totalsSlotOf(s)), 1, "scanned suffix is not searched again"); + } + + (seq,) = inbox.getBucketAtOrBeforeTotal(bound - 1); + assertEq(seq, firstUnscanned - 1, "interior below the first unscanned entry"); + + for (uint64 b = 0; b <= uint64(current * 3 + 1); b++) { + _assertAgreesWithReference(b); + } + } + + // Once the ring has wrapped, the genesis bucket and every other overwritten entry drop out of the candidates; + // the oldest retained bucket is a hit at its own total and the search reverts one below it. + function testRingWrapExcludesOverwrittenEntries() public { + uint256 extra = 40; + _openSingleMessageBuckets(RING_SIZE + extra); + uint256 current = inbox.getCurrentBucketSeq(); + assertEq(current, RING_SIZE + extra, "one bucket per block"); + uint256 oldest = current - RING_SIZE + 1; + assertEq(oldest, extra + 1, "oldest live entry"); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, 0, current)); + inbox.getBucket(0); + + // The bound qualifies the overwritten genesis, but genesis is no longer live; the ring slot it shared now + // holds bucket RING_SIZE, whose total is far above the bound. + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__NoBucketAtOrBeforeTotal.selector, 0, oldest)); + inbox.getBucketAtOrBeforeTotal(0); + + // Totals equal the sequence here, so the oldest entry hits exactly at its own total and misses one below. + (uint64 seq,) = inbox.getBucketAtOrBeforeTotal(uint64(oldest)); + assertEq(seq, oldest, "oldest live entry is a hit at its total"); + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__NoBucketAtOrBeforeTotal.selector, oldest - 1, oldest)); + inbox.getBucketAtOrBeforeTotal(uint64(oldest - 1)); + + // The whole live window agrees with the reference, including bounds that only the binary search reaches. + for (uint256 s = oldest; s <= current; s += 7) { + _assertAgreesWithReference(uint64(s)); + } + _assertAgreesWithReference(uint64(current)); + _assertAgreesWithReference(type(uint64).max); + } + + // The binary search reads a logarithmic number of entries on top of the fixed walk. + function testFallbackReadsAreLogarithmic() public { + _openSingleMessageBuckets(RING_SIZE + 3); + uint256 oldest = _oldestLiveSeq(); + + vm.record(); + (uint64 seq,) = inbox.getBucketAtOrBeforeTotal(uint64(oldest)); + (bytes32[] memory reads,) = vm.accesses(address(inbox)); + assertEq(seq, oldest, "oldest entry via the fallback"); + + uint256 log2Ring = 0; + while ((1 << log2Ring) < RING_SIZE) { + log2Ring++; + } + // Sequence word, the probes, the oldest-entry check, at most log2(ring) bisection reads, and the result copy. + assertLe(reads.length, 1 + ENDPOINT_WALKBACK_PROBES + 1 + log2Ring + 2, "bounded search"); + } + + // At the sequence type's maximum, the live-interval arithmetic must not overflow and the returned sequence must + // survive the narrowing cast. The ring is populated by hand since no test can send that many messages. + function testMaximumSequenceArithmetic() public { + uint64 current = type(uint64).max; + uint64 oldest = current - uint64(RING_SIZE) + 1; + inbox.setCurrentBucketSeq(current); + for (uint64 s = oldest;; s++) { + uint64 total = 1000 + (s - oldest) * 2; + inbox.setBucket( + s, + IInbox.InboxBucket({rollingHash: bytes32(uint256(s)), totalMsgCount: total, timestamp: uint64(s), msgCount: 2}) + ); + if (s == current) { + break; + } + } + + (uint64 seq, IInbox.InboxBucket memory bucket) = inbox.getBucketAtOrBeforeTotal(type(uint64).max); + assertEq(seq, current, "current bucket at the sequence maximum"); + assertEq(bucket.rollingHash, bytes32(uint256(current)), "current bucket data"); + + (seq,) = inbox.getBucketAtOrBeforeTotal(1000); + assertEq(seq, oldest, "oldest live entry, found by the fallback"); + (seq,) = inbox.getBucketAtOrBeforeTotal(1001); + assertEq(seq, oldest, "interior above the oldest entry"); + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__NoBucketAtOrBeforeTotal.selector, 999, 1000)); + inbox.getBucketAtOrBeforeTotal(999); + + for (uint64 k = 0; k < 6; k++) { + uint64 target = current - k; + (seq,) = inbox.getBucketAtOrBeforeTotal(1000 + (target - oldest) * 2 + 1); + assertEq(seq, target, "walk and fallback near the maximum"); + } + } + + function testFuzzAgreesWithReference(uint256 _seed) public { + uint256[] memory sizes = new uint256[](16); + for (uint256 i = 0; i < sizes.length; i++) { + sizes[i] = 1 + (uint256(keccak256(abi.encode(_seed, i))) % 5); + } + _openBuckets(sizes); + + uint64 total = inbox.getTotalMessagesInserted(); + for (uint64 bound = 0; bound <= total + 1; bound++) { + _assertAgreesWithReference(bound); + } + } +} diff --git a/l1-contracts/test/InboxBuckets.t.sol b/l1-contracts/test/InboxBuckets.t.sol index bad3cd4e59c5..95eac4aead96 100644 --- a/l1-contracts/test/InboxBuckets.t.sol +++ b/l1-contracts/test/InboxBuckets.t.sol @@ -6,6 +6,7 @@ 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 {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; import {InboxHarness} from "./harnesses/InboxHarness.sol"; import {TestConstants} from "./harnesses/TestConstants.sol"; @@ -196,6 +197,21 @@ contract InboxBucketsTest is Test { assertEq(bucket2.msgCount, 1, "bucket 2 msg count"); } + // The bucket cap is the protocol's per-block message cap, sourced from the generated constants so the node and + // the contract cannot drift apart. A hand-edited alias fails here; the rollover below pins the behaviour to the + // generated value. + function testBucketCapIsTheGeneratedPerBlockCap() public { + assertEq(MAX_MSGS_PER_BUCKET, Constants.MAX_L1_TO_L2_MSGS_PER_BLOCK, "bucket cap alias drifted"); + + uint256 cap = Constants.MAX_L1_TO_L2_MSGS_PER_BLOCK; + for (uint256 i = 0; i < cap; i++) { + _send(inbox, i); + } + assertEq(inbox.getCurrentBucketSeq(), 1, "the per-block cap fits in one bucket"); + _send(inbox, cap); + assertEq(inbox.getCurrentBucketSeq(), 2, "one more message spills into the next bucket"); + } + function testRolloverIntoNextBucket() public { uint256 cap = MAX_MSGS_PER_BUCKET; for (uint256 i = 0; i < cap; i++) { diff --git a/l1-contracts/test/harnesses/InboxHarness.sol b/l1-contracts/test/harnesses/InboxHarness.sol index 983710628b48..6de2b5130709 100644 --- a/l1-contracts/test/harnesses/InboxHarness.sol +++ b/l1-contracts/test/harnesses/InboxHarness.sol @@ -14,4 +14,14 @@ contract InboxHarness is Inbox { // Compact cumulative index: the next message's index is the count inserted so far. return _totalMessagesInserted(); } + + // Ring surgery for tests that need the sequence counter far from where real sends could take it, such as the + // sequence type's maximum. Callers are responsible for writing every live ring slot afterwards. + function setCurrentBucketSeq(uint64 _seq) external { + currentBucketSeq = _seq; + } + + function setBucket(uint64 _seq, InboxBucket memory _bucket) external { + buckets[_seq % BUCKET_RING_SIZE] = _bucket; + } } diff --git a/l1-contracts/test/rollup/CheckpointPreflight.t.sol b/l1-contracts/test/rollup/CheckpointPreflight.t.sol new file mode 100644 index 000000000000..47697783e75e --- /dev/null +++ b/l1-contracts/test/rollup/CheckpointPreflight.t.sol @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {DecoderBase} from "../base/DecoderBase.sol"; +import {RollupBase, IInstance} from "../base/RollupBase.sol"; +import {RollupBuilder} from "../builder/RollupBuilder.sol"; +import {TestConstants} from "../harnesses/TestConstants.sol"; +import {AttestationLibHelper} from "@test/helper_libraries/AttestationLibHelper.sol"; + +import {Inbox} from "@aztec/core/messagebridge/Inbox.sol"; +import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import {CheckpointHeaderValidationFlags, CheckpointPreflightArgs, EthValue} from "@aztec/core/interfaces/IRollup.sol"; +import {Constants} from "@aztec/core/libraries/ConstantsGen.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {ProposeArgs, OracleInput} from "@aztec/core/libraries/rollup/ProposeLib.sol"; +import {ProposedHeader} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol"; +import {Timestamp, Slot, Epoch, TimeLib} from "@aztec/core/libraries/TimeLib.sol"; +import {SafeCast} from "@oz/utils/math/SafeCast.sol"; + +// solhint-disable comprehensive-interface + +/** + * `validateCheckpointHeaderAndInbox` against `propose`: with the same parent state and execution timestamp the + * preflight must accept exactly the headers `propose` accepts, resolving the consumed total to the bucket + * `propose` then validates from the returned hint. Blob and signature checks are skipped on both sides + * (`ignoreDA`, no attestations, committee size zero), so parity here covers the parent, header and Inbox rules. + */ +contract CheckpointPreflightTest is RollupBase { + using TimeLib for Timestamp; + using TimeLib for Slot; + using TimeLib for Epoch; + + // Chain tips live in the first word of the STF namespaced storage: pending in the upper 128 bits, proven below. + bytes32 internal constant STF_STORAGE_POSITION = keccak256("aztec.stf.storage"); + + uint256 internal SLOT_DURATION; + + constructor() { + TimeLib.initialize( + block.timestamp, + TestConstants.AZTEC_SLOT_DURATION, + TestConstants.AZTEC_EPOCH_DURATION, + TestConstants.AZTEC_PROOF_SUBMISSION_EPOCHS, + TestConstants.ETHEREUM_SLOT_DURATION + ); + SLOT_DURATION = TestConstants.AZTEC_SLOT_DURATION; + } + + modifier setUpFor(string memory _name) { + { + DecoderBase.Full memory full = load(_name); + uint256 slotNumber = Slot.unwrap(full.checkpoint.header.slotNumber); + uint256 initialTime = Timestamp.unwrap(full.checkpoint.header.timestamp) - slotNumber * SLOT_DURATION; + vm.warp(initialTime); + } + + RollupBuilder builder = + new RollupBuilder(address(this)).setTargetCommitteeSize(0).setProvingCostPerMana(EthValue.wrap(1000)); + builder.deploy(); + rollup = IInstance(address(builder.getConfig().rollup)); + inbox = Inbox(address(rollup.getInbox())); + _; + } + + // Builds the fixture's header the way `_proposeCheckpoint` does, at the given slot, without touching the Inbox + // or the clock. The Inbox rolling hash is left for the caller to pin. + function _buildHeader(string memory _name, uint256 _slotNumber) internal returns (DecoderBase.Full memory full) { + full = load(_name); + Slot slotNumber = Slot.wrap(_slotNumber); + Timestamp ts = rollup.getTimestampForSlot(slotNumber); + full.checkpoint.header.timestamp = ts; + full.checkpoint.header.slotNumber = slotNumber; + uint128 minFee = SafeCast.toUint128(rollup.getManaMinFeeAt(ts, true)); + full.checkpoint.header.gasFees.feePerL2Gas = minFee; + full.checkpoint.header.totalManaUsed = 0; + full.checkpoint.header.accumulatedFees = 0; + full.checkpoint.header.coinbase = address(bytes20("sequencer")); + } + + function _warpToSlot(uint256 _slotNumber) internal { + vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(Slot.wrap(_slotNumber)))); + } + + // Sends `_count` messages in a fresh L1 block `_secondsBack` seconds before the current time, then restores the + // clock. Returns the bucket they landed in. + function _seedMessagesAt(uint256 _timestamp, uint256 _count) internal returns (uint64 bucketSeq) { + uint256 now_ = block.timestamp; + vm.roll(block.number + 1); + vm.warp(_timestamp); + bytes32[] memory contents = new bytes32[](_count); + for (uint256 i = 0; i < _count; i++) { + contents[i] = bytes32(uint256(keccak256(abi.encode(_timestamp, i))) % Constants.MAX_FIELD_VALUE); + } + _populateInbox(address(this), bytes32(uint256(0x5678)), contents); + bucketSeq = inbox.getCurrentBucketSeq(); + vm.warp(now_); + } + + function _preflightArgs(ProposedHeader memory _header, uint64 _expectedTotal, uint256 _expectedParent) + internal + view + returns (CheckpointPreflightArgs memory) + { + return CheckpointPreflightArgs({ + header: _header, + attestations: AttestationLibHelper.packAttestations(attestations), + signers: signers, + attestationsAndSignersSignature: attestationsAndSignersSignature, + digest: bytes32(0), + blobsHash: _header.blobsHash, + flags: CheckpointHeaderValidationFlags({ignoreDA: true}), + expectedTotal: _expectedTotal, + expectedParentCheckpointNumber: _expectedParent + }); + } + + function _preflight(ProposedHeader memory _header, uint64 _expectedTotal, uint256 _expectedParent) + internal + returns (uint64) + { + return rollup.validateCheckpointHeaderAndInbox(_preflightArgs(_header, _expectedTotal, _expectedParent)); + } + + function _propose(DecoderBase.Full memory _full, uint256 _bucketHint) internal { + skipBlobCheck(address(rollup)); + proposedHeaders[_full.checkpoint.checkpointNumber] = _full.checkpoint.header; + rollup.propose( + ProposeArgs({ + header: _full.checkpoint.header, + archive: _full.checkpoint.archive, + oracleInput: OracleInput(0), + bucketHint: _bucketHint + }), + AttestationLibHelper.packAttestations(attestations), + signers, + attestationsAndSignersSignature, + _full.checkpoint.blobCommitments + ); + } + + function _proposeExpectingRevert(DecoderBase.Full memory _full, uint256 _bucketHint, bytes memory _revert) internal { + skipBlobCheck(address(rollup)); + vm.expectRevert(_revert); + rollup.propose( + ProposeArgs({ + header: _full.checkpoint.header, + archive: _full.checkpoint.archive, + oracleInput: OracleInput(0), + bucketHint: _bucketHint + }), + AttestationLibHelper.packAttestations(attestations), + signers, + attestationsAndSignersSignature, + _full.checkpoint.blobCommitments + ); + } + + function _setTips(uint256 _pending, uint256 _proven) internal { + vm.store(address(rollup), STF_STORAGE_POSITION, bytes32((_pending << 128) | _proven)); + } + + // Happy path on a fresh chain: the preflight resolves the consumed total to the bucket holding the messages and + // `propose` accepts that bucket as its hint. + function testPreflightThenProposeOnFreshChain() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + uint64 seeded = _seedMessagesAt(block.timestamp - 100, 16); + assertEq(seeded, 1, "messages opened bucket 1"); + + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + + uint64 hint = _preflight(full.checkpoint.header, 16, 0); + assertEq(hint, 1, "resolved the consumed total to bucket 1"); + + _propose(full, hint); + assertEq(rollup.getPendingCheckpointNumber(), 1, "proposed with the returned hint"); + } + + // Consuming nothing resolves to the genesis bucket, exactly as `propose` accepts hint zero. + function testPreflightConsumingNothingResolvesGenesis() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = bytes32(0); + + uint64 hint = _preflight(full.checkpoint.header, 0, 0); + assertEq(hint, 0, "genesis bucket"); + _propose(full, hint); + } + + // A consumed total inside a bucket has no snapshot to check against and is rejected before any hash comparison. + function testPreflightRejectsInteriorTotal() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + _seedMessagesAt(block.timestamp - 100, 16); + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InboxTotalNotAtBucketBoundary.selector, 10, 0)); + _preflight(full.checkpoint.header, 10, 0); + } + + // A total past the newest bucket resolves to that bucket and is then rejected as a non-boundary. + function testPreflightRejectsTotalPastNewestBucket() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + _seedMessagesAt(block.timestamp - 100, 16); + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InboxTotalNotAtBucketBoundary.selector, 17, 16)); + _preflight(full.checkpoint.header, 17, 0); + } + + // Wrong hash: preflight and `propose` reject the same header with the same error. + function testPreflightAndProposeRejectWrongHash() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + _seedMessagesAt(block.timestamp - 100, 16); + bytes32 bucketHash = inbox.getBucket(1).rollingHash; + bytes32 wrongHash = bytes32(uint256(bucketHash) ^ 1); + + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = wrongHash; + + bytes memory expected = + abi.encodeWithSelector(Errors.Rollup__InvalidInboxRollingHash.selector, bucketHash, wrongHash); + vm.expectRevert(expected); + _preflight(full.checkpoint.header, 16, 0); + _proposeExpectingRevert(full, 1, expected); + } + + // The caller's parent claim is checked against the parent `propose` would use, not trusted. + function testPreflightRejectsWrongParentIdentity() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = bytes32(0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnexpectedParentCheckpoint.selector, 1, 0)); + _preflight(full.checkpoint.header, 0, 1); + } + + // A bucket opened in the execution block is still mutable: at an equal timestamp both calls reject it, one L1 + // block later both accept it. + function testPreflightSettlementAtEqualAndLaterTimestamps() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + uint256 slotStart = block.timestamp; + // Seed in the execution block itself. + bytes32[] memory contents = new bytes32[](3); + for (uint256 i = 0; i < 3; i++) { + contents[i] = bytes32(uint256(0x100 + i)); + } + _populateInbox(address(this), bytes32(uint256(0x5678)), contents); + assertEq(inbox.getBucket(1).timestamp, slotStart, "bucket opened in the execution block"); + + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + + bytes memory expected = abi.encodeWithSelector(Errors.Rollup__InboxBucketStillMutable.selector, 1); + vm.expectRevert(expected); + _preflight(full.checkpoint.header, 3, 0); + _proposeExpectingRevert(full, 1, expected); + + vm.roll(block.number + 1); + vm.warp(slotStart + 1); + assertEq(_preflight(full.checkpoint.header, 3, 0), 1, "settled one block later"); + _propose(full, 1); + } + + // Censorship: a bucket at or before the cutoff must be consumed; both calls point at it. + function testPreflightAndProposeEnforceCensorship() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + uint256 slotStart = block.timestamp; + uint256 cutoff = slotStart - SLOT_DURATION - TestConstants.ETHEREUM_SLOT_DURATION; + _seedMessagesAt(cutoff - 24, 4); // bucket 1 + _seedMessagesAt(cutoff, 2); // bucket 2, exactly at the cutoff: mandatory + + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + + bytes memory expected = abi.encodeWithSelector(Errors.Rollup__UnconsumedInboxMessages.selector, 2); + vm.expectRevert(expected); + _preflight(full.checkpoint.header, 4, 0); + _proposeExpectingRevert(full, 1, expected); + + // Consuming through bucket 2 satisfies both. + full.checkpoint.header.inboxRollingHash = inbox.getBucket(2).rollingHash; + assertEq(_preflight(full.checkpoint.header, 6, 0), 2, "mandatory bucket consumed"); + _propose(full, 2); + } + + // A bucket past the cutoff is optional: stopping before it is accepted by both calls. + function testPreflightAllowsSkippingBucketPastCutoff() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + uint256 slotStart = block.timestamp; + uint256 cutoff = slotStart - SLOT_DURATION - TestConstants.ETHEREUM_SLOT_DURATION; + _seedMessagesAt(cutoff - 24, 4); + _seedMessagesAt(cutoff + 1, 2); + + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + assertEq(_preflight(full.checkpoint.header, 4, 0), 1, "optional bucket left for later"); + _propose(full, 1); + } + + // Cap: more than the per-checkpoint maximum before the cutoff. Consuming to the cap is accepted (the excess bucket + // escapes censorship); one bucket further is rejected by both calls. + function testPreflightAndProposeEnforceCap() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + uint256 slotStart = block.timestamp; + uint256 cutoff = slotStart - SLOT_DURATION - TestConstants.ETHEREUM_SLOT_DURATION; + _seedMessagesAt(cutoff - 100, Constants.MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1); + assertEq(inbox.getCurrentBucketSeq(), 5, "four full buckets plus the excess"); + + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + full.checkpoint.header.inboxRollingHash = inbox.getBucket(5).rollingHash; + uint64 overCap = uint64(Constants.MAX_L1_TO_L2_MSGS_PER_CHECKPOINT + 1); + bytes memory expected = abi.encodeWithSelector(Errors.Rollup__TooManyInboxMessagesConsumed.selector, overCap); + vm.expectRevert(expected); + _preflight(full.checkpoint.header, overCap, 0); + _proposeExpectingRevert(full, 5, expected); + + full.checkpoint.header.inboxRollingHash = inbox.getBucket(4).rollingHash; + uint64 cap = uint64(Constants.MAX_L1_TO_L2_MSGS_PER_CHECKPOINT); + assertEq(_preflight(full.checkpoint.header, cap, 0), 4, "cap-escaped endpoint"); + _propose(full, 4); + } + + // Building on a published parent: the parent's stored total, not the caller, sets the floor. + function testPreflightOnPublishedParent() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + _seedMessagesAt(block.timestamp - 100, 16); + DecoderBase.Full memory first = _buildHeader("mixed_checkpoint_1", 1); + first.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + _propose(first, _preflight(first.checkpoint.header, 16, 0)); + + _warpToSlot(2); + // Past the cutoff, so bucket 2 is optional for this slot. + _seedMessagesAt(block.timestamp - 50, 5); + DecoderBase.Full memory second = _buildHeader("mixed_checkpoint_2", 2); + + // Re-referencing the parent's bucket consumes nothing and is fine; going behind it is not. + second.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + assertEq(_preflight(second.checkpoint.header, 16, 1), 1, "equal reference consumes nothing"); + second.checkpoint.header.inboxRollingHash = bytes32(0); + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InboxConsumptionBehindParent.selector, 16, 0)); + _preflight(second.checkpoint.header, 0, 1); + + // Claiming the wrong parent fails even with an otherwise valid header. + second.checkpoint.header.inboxRollingHash = inbox.getBucket(2).rollingHash; + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnexpectedParentCheckpoint.selector, 0, 1)); + _preflight(second.checkpoint.header, 21, 0); + + uint64 hint = _preflight(second.checkpoint.header, 21, 1); + assertEq(hint, 2, "resolved against the published parent"); + _propose(second, hint); + } + + // Once the pending chain is prunable, the effective parent is the proven tip: a header built on the pruned + // checkpoint is rejected for the parent mismatch and one built on the proven tip passes, matching `propose`, + // which prunes before validating. + function testPreflightFollowsAutomaticPrune() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + _seedMessagesAt(block.timestamp - 100, 16); + DecoderBase.Full memory first = _buildHeader("mixed_checkpoint_1", 1); + first.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + _propose(first, 1); + assertEq(rollup.getPendingCheckpointNumber(), 1, "checkpoint 1 pending"); + + Slot prunableAt = rollup.getCheckpoint(1).slotNumber + Epoch.wrap(2).toSlots(); + _warpToSlot(Slot.unwrap(prunableAt)); + assertTrue(rollup.canPruneAtTime(Timestamp.wrap(block.timestamp)), "pending chain is prunable"); + + // Built on checkpoint 1, which the prune will remove. + DecoderBase.Full memory stale = _buildHeader("mixed_checkpoint_2", Slot.unwrap(prunableAt)); + stale.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnexpectedParentCheckpoint.selector, 1, 0)); + _preflight(stale.checkpoint.header, 16, 1); + + // Built on the proven tip: consumes bucket 1 again from a parent total of zero. + DecoderBase.Full memory replacement = _buildHeader("empty_checkpoint_1", Slot.unwrap(prunableAt)); + replacement.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + assertEq(_preflight(replacement.checkpoint.header, 16, 0), 1, "resolved against the proven tip"); + _propose(replacement, 1); + assertEq(rollup.getPendingCheckpointNumber(), 1, "replacement chain proposed after the prune"); + } + + // An earlier invalidation rewinds the pending tip; the preflight follows the tips in storage, as a simulation + // whose state override reflects a bundled invalidate transaction would see. + function testPreflightFollowsInvalidatedParent() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + _seedMessagesAt(block.timestamp - 100, 16); + DecoderBase.Full memory first = _buildHeader("mixed_checkpoint_1", 1); + first.checkpoint.header.inboxRollingHash = inbox.getBucket(1).rollingHash; + _propose(first, 1); + + _warpToSlot(2); + _seedMessagesAt(block.timestamp - 100, 5); + DecoderBase.Full memory second = _buildHeader("mixed_checkpoint_2", 2); + second.checkpoint.header.inboxRollingHash = inbox.getBucket(2).rollingHash; + _propose(second, 2); + assertEq(rollup.getPendingCheckpointNumber(), 2, "two checkpoints pending"); + + // Invalidate checkpoint 2 by rewinding the tips, as the invalidation path does. + _setTips(1, 0); + assertEq(rollup.getPendingCheckpointNumber(), 1, "checkpoint 2 invalidated"); + + _warpToSlot(3); + // A header built on the invalidated checkpoint 2 no longer has a parent. + DecoderBase.Full memory onInvalidated = _buildHeader("mixed_checkpoint_2", 3); + onInvalidated.checkpoint.header.lastArchiveRoot = second.checkpoint.archive; + onInvalidated.checkpoint.header.inboxRollingHash = inbox.getBucket(2).rollingHash; + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__UnexpectedParentCheckpoint.selector, 2, 1)); + _preflight(onInvalidated.checkpoint.header, 21, 2); + + // The replacement for checkpoint 2 builds on checkpoint 1's archive and total. + DecoderBase.Full memory replacement = _buildHeader("mixed_checkpoint_2", 3); + replacement.checkpoint.header.inboxRollingHash = inbox.getBucket(2).rollingHash; + assertEq(_preflight(replacement.checkpoint.header, 21, 1), 2, "resolved against checkpoint 1"); + _propose(replacement, 2); + assertEq(rollup.getPendingCheckpointNumber(), 2, "replacement proposed"); + } + + // The header checks are the shared ones: a wrong archive for the derived parent fails in the preflight as in + // `propose`, even when the parent identity claim is right. + function testPreflightSharesHeaderValidation() public setUpFor("mixed_checkpoint_1") { + _warpToSlot(1); + DecoderBase.Full memory full = _buildHeader("mixed_checkpoint_1", 1); + bytes32 genesisArchive = full.checkpoint.header.lastArchiveRoot; + full.checkpoint.header.lastArchiveRoot = bytes32(uint256(genesisArchive) ^ 1); + full.checkpoint.header.inboxRollingHash = bytes32(0); + + bytes memory expected = abi.encodeWithSelector( + Errors.Rollup__InvalidArchive.selector, genesisArchive, full.checkpoint.header.lastArchiveRoot + ); + vm.expectRevert(expected); + _preflight(full.checkpoint.header, 0, 0); + _proposeExpectingRevert(full, 0, expected); + } +} diff --git a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol index 66567a43ed49..5510626b9bf7 100644 --- a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -31,6 +31,18 @@ contract ProposeLibHarness { _inbox, _inboxRollingHash, _bucketHint, _slotNumber, _parentTotalMsgCount ); } + + function validateInboxConsumptionAtTotal( + IInbox _inbox, + bytes32 _inboxRollingHash, + uint64 _expectedTotal, + Slot _slotNumber, + uint256 _parentTotalMsgCount + ) external view returns (uint64) { + return ProposeLib.validateInboxConsumptionAtTotal( + _inbox, _inboxRollingHash, _expectedTotal, _slotNumber, _parentTotalMsgCount + ); + } } contract ProposeInboxConsumptionTest is Test { @@ -225,6 +237,49 @@ contract ProposeInboxConsumptionTest is Test { rollup.validateInboxConsumption(ringInbox, bytes32(0), 1, SLOT, 0); } + // Resolving by total agrees with the hinted check on every bucket, and rejects totals that are not boundaries. + function testResolveByTotalMatchesHintedValidation() public { + vm.warp(cutoff - 100); + _sendMany(3); + vm.roll(block.number + 1); + // Past the cutoff, so stopping at bucket 1 is not censorship. + vm.warp(cutoff + 10); + _sendMany(2); + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + + for (uint64 seq = 1; seq <= 2; seq++) { + IInbox.InboxBucket memory bucket = inbox.getBucket(seq); + uint64 hinted = uint64(rollup.validateInboxConsumption(inbox, bucket.rollingHash, seq, SLOT, 0)); + uint64 resolved = rollup.validateInboxConsumptionAtTotal(inbox, bucket.rollingHash, bucket.totalMsgCount, SLOT, 0); + assertEq(resolved, seq, "resolved sequence"); + assertEq(hinted, bucket.totalMsgCount, "hinted total"); + } + + bytes32 endHash = inbox.getBucket(2).rollingHash; + vm.expectRevert(abi.encodeWithSelector(Errors.Rollup__InboxTotalNotAtBucketBoundary.selector, 4, 3)); + rollup.validateInboxConsumptionAtTotal(inbox, endHash, 4, SLOT, 0); + } + + // A total below the oldest retained bucket cannot be resolved once the ring has wrapped past it. + function testResolveByTotalBelowRetainedWindowReverts() public { + InboxHarness ringInbox = _deployInbox(MIN_BUCKET_RING_SIZE); + for (uint256 i = 1; i <= MIN_BUCKET_RING_SIZE + 1; i++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + 1); + ringInbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + i)), version: version}), + bytes32(uint256(0x2000 + i)), + bytes32(uint256(0x3000 + i)) + ); + vm.prank(address(rollup)); + ringInbox.markProvenConsumed(uint64(i - 1)); + } + + // Genesis and bucket 1 are overwritten; the oldest retained bucket ends at total 2. + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__NoBucketAtOrBeforeTotal.selector, 1, 2)); + rollup.validateInboxConsumptionAtTotal(ringInbox, bytes32(0), 1, SLOT, 0); + } + function testCurrentBlockBucketRejected() public { // A proposer that bundles `sendL2Message` and `propose` into a single L1 transaction can reference a // bucket that is still accumulating and then mutate it with a trailing send. The snapshot the checkpoint diff --git a/labs-patches/0013-feat-ethereum-wrap-inbox-endpoint-resolution-and-che.patch b/labs-patches/0013-feat-ethereum-wrap-inbox-endpoint-resolution-and-che.patch new file mode 100644 index 000000000000..f90bed882465 --- /dev/null +++ b/labs-patches/0013-feat-ethereum-wrap-inbox-endpoint-resolution-and-che.patch @@ -0,0 +1,640 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Santiago Palladino +Date: Thu, 10 Sep 2026 12:50:44 -0300 +Subject: [PATCH] feat(ethereum): wrap inbox endpoint resolution and checkpoint + preflight + +`InboxContract.getBucketAtOrBeforeTotal` resolves a locally known cumulative +message count to the newest live bucket boundary at or below it, returning +`undefined` on the not-found revert so a caller whose target has already been +evicted from the ring sees an absent endpoint rather than an error. + +`RollupContract.validateCheckpointHeaderAndInbox` simulates the Rollup's +integrated header and Inbox preflight over `eth_simulateV1` with a block-time +override and the caller's state overrides, and returns the bucket sequence to +submit to `propose` as `bucketHint`. + +`getRevertedErrorName` decodes the custom error a viem contract call reverted +with, which is how the Inbox wrapper tells the not-found revert apart from +every other failure. + +diff --git a/yarn-project/ethereum/src/contracts/inbox.test.ts b/yarn-project/ethereum/src/contracts/inbox.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..9736cb997e90d039777e11d69d56185939f11134 +--- /dev/null ++++ b/yarn-project/ethereum/src/contracts/inbox.test.ts +@@ -0,0 +1,179 @@ ++import { InboxAbi } from '@aztec-foundation/l1-artifacts/InboxAbi'; ++ ++import { Fr } from '@aztec-labs/foundation/curves/bn254'; ++import { createLogger } from '@aztec-labs/foundation/log'; ++import { DateProvider } from '@aztec-labs/foundation/timer'; ++import { mock } from 'jest-mock-extended'; ++import { ++ ContractFunctionExecutionError, ++ ContractFunctionRevertedError, ++ type Hex, ++ encodeAbiParameters, ++ encodeErrorResult, ++ getContract, ++ keccak256, ++} from 'viem'; ++import { foundry } from 'viem/chains'; ++ ++import { L1RpcError } from '../client.js'; ++import { DefaultL1ContractsConfig } from '../config.js'; ++import { type DeployAztecL1ContractsReturnType, deployAztecL1Contracts } from '../deploy_aztec_l1_contracts.js'; ++import { EthCheatCodes } from '../test/eth_cheat_codes.js'; ++import type { Anvil } from '../test/start_anvil.js'; ++import { startAnvil } from '../test/start_anvil.js'; ++import type { ViemClient } from '../types.js'; ++import { InboxContract } from './inbox.js'; ++import { RollupContract } from './rollup.js'; ++ ++describe('InboxContract', () => { ++ let anvil: Anvil; ++ let rpcUrl: string; ++ let cheatCodes: EthCheatCodes; ++ let deployed: DeployAztecL1ContractsReturnType; ++ let inbox: InboxContract; ++ let version: bigint; ++ ++ beforeAll(async () => { ++ const privateKeyRaw = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; ++ ({ anvil, rpcUrl } = await startAnvil()); ++ cheatCodes = new EthCheatCodes([rpcUrl], new DateProvider()); ++ ++ deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { ++ ...DefaultL1ContractsConfig, ++ vkTreeRoot: Fr.random(), ++ protocolContractsHash: Fr.random(), ++ genesisArchiveRoot: Fr.random(), ++ realVerifier: false, ++ }); ++ inbox = InboxContract.getFromL1ContractsValues(deployed); ++ version = await new RollupContract(deployed.l1Client, deployed.l1ContractAddresses.rollupAddress).getVersion(); ++ }); ++ ++ afterAll(async () => { ++ await anvil?.stop().catch(err => createLogger('cleanup').error(err)); ++ }); ++ ++ /** ++ * Sends `count` messages in one fresh L1 block, 12 seconds after the previous one so they open a new bucket, and ++ * returns that bucket's sequence. ++ */ ++ async function sendMessages(count: number): Promise { ++ const contract = getContract({ ++ address: deployed.l1ContractAddresses.inboxAddress.toString(), ++ abi: InboxAbi, ++ client: deployed.l1Client, ++ }); ++ const { timestamp } = await deployed.l1Client.getBlock(); ++ await cheatCodes.warp(timestamp + 12n, { silent: true }); ++ await cheatCodes.setAutomine(false); ++ const hashes: Hex[] = []; ++ for (let i = 0; i < count; i++) { ++ hashes.push( ++ await contract.write.sendL2Message( ++ [{ actor: Fr.random().toString(), version }, Fr.random().toString(), Fr.random().toString()], ++ { gas: 1_000_000n }, ++ ), ++ ); ++ } ++ await cheatCodes.mine(1); ++ await cheatCodes.setAutomine(true); ++ for (const hash of hashes) { ++ const receipt = await deployed.l1Client.waitForTransactionReceipt({ hash }); ++ expect(receipt.status).toBe('success'); ++ } ++ return inbox.getCurrentBucketSeq(); ++ } ++ ++ describe('getBucketAtOrBeforeTotal', () => { ++ it('resolves to the genesis bucket on an empty inbox', async () => { ++ const found = await inbox.getBucketAtOrBeforeTotal(0n); ++ expect(found?.seq).toBe(0n); ++ expect(found?.bucket).toEqual(await inbox.getBucket(0n)); ++ ++ const loose = await inbox.getBucketAtOrBeforeTotal(100n); ++ expect(loose?.seq).toBe(0n); ++ }); ++ ++ it('resolves exact and interior bounds to the newest bucket at or below them', async () => { ++ const first = await sendMessages(3); ++ const second = await sendMessages(2); ++ expect([first, second]).toEqual([1n, 2n]); ++ ++ const exact = await inbox.getBucketAtOrBeforeTotal(3n); ++ expect(exact?.seq).toBe(1n); ++ expect(exact?.bucket).toEqual(await inbox.getBucket(1n)); ++ expect(exact?.bucket.totalMsgCount).toBe(3n); ++ ++ const interior = await inbox.getBucketAtOrBeforeTotal(4n); ++ expect(interior?.seq).toBe(1n); ++ ++ const newest = await inbox.getBucketAtOrBeforeTotal(5n); ++ expect(newest?.seq).toBe(2n); ++ expect(newest?.bucket.totalMsgCount).toBe(5n); ++ ++ const belowFirst = await inbox.getBucketAtOrBeforeTotal(2n); ++ expect(belowFirst?.seq).toBe(0n); ++ }); ++ ++ // The ring has to wrap for a bound to fall below every live bucket, which no test can reach by sending messages. ++ // Rewrite the Inbox's storage instead: a wrapped sequence counter with the newest probed entries and the oldest ++ // live entry all ending past the bound, exactly the state the contract reverts on. ++ it('resolves to undefined when every live bucket ends past the bound', async () => { ++ const inboxAddress = deployed.l1ContractAddresses.inboxAddress; ++ const current = await inbox.getContract().read.BUCKET_RING_SIZE(); ++ const oldest = 1n; ++ // Slot 1 packs (currentBucketSeq, provenConsumedBucketSeq); a bucket's packed word sits one slot after its ++ // `buckets` mapping entry and holds (totalMsgCount, timestamp, msgCount). ++ const seqSlot = 1n; ++ const totalsSlotOf = (seq: bigint) => ++ BigInt(keccak256(encodeAbiParameters([{ type: 'uint256' }, { type: 'uint256' }], [seq % current, 0n]))) + 1n; ++ const touched = [seqSlot, ...[current, current - 1n, current - 2n, current - 3n, oldest].map(totalsSlotOf)]; ++ const saved = await Promise.all(touched.map(slot => cheatCodes.load(inboxAddress, slot))); ++ ++ try { ++ await cheatCodes.store(inboxAddress, seqSlot, current); ++ for (const slot of touched.slice(1)) { ++ await cheatCodes.store(inboxAddress, slot, 10n); ++ } ++ ++ await expect(inbox.getBucketAtOrBeforeTotal(9n)).resolves.toBeUndefined(); ++ const hit = await inbox.getBucketAtOrBeforeTotal(10n); ++ expect(hit?.seq).toBe(current); ++ expect(hit?.bucket.totalMsgCount).toBe(10n); ++ } finally { ++ for (const [i, slot] of touched.entries()) { ++ await cheatCodes.store(inboxAddress, slot, saved[i]); ++ } ++ } ++ expect((await inbox.getBucketAtOrBeforeTotal(0n))?.seq).toBe(0n); ++ }); ++ ++ it('propagates reverts other than not-found and transport failures', async () => { ++ const address = deployed.l1ContractAddresses.inboxAddress.toString(); ++ const revertWith = (errorName: 'Inbox__NoBucketAtOrBeforeTotal' | 'Inbox__BucketOutOfWindow') => ++ new ContractFunctionExecutionError( ++ new ContractFunctionRevertedError({ ++ abi: InboxAbi, ++ data: encodeErrorResult({ abi: InboxAbi, errorName, args: [3n, 7n] }), ++ functionName: 'getBucketAtOrBeforeTotal', ++ }), ++ { abi: InboxAbi, functionName: 'getBucketAtOrBeforeTotal', args: [3n], contractAddress: address }, ++ ); ++ const failing = (err: Error) => { ++ const client = mock(); ++ client.readContract.mockRejectedValue(err); ++ return new InboxContract(client, address); ++ }; ++ ++ await expect(failing(revertWith('Inbox__NoBucketAtOrBeforeTotal')).getBucketAtOrBeforeTotal(3n)).resolves.toBe( ++ undefined, ++ ); ++ await expect(failing(revertWith('Inbox__BucketOutOfWindow')).getBucketAtOrBeforeTotal(3n)).rejects.toThrow( ++ /Inbox__BucketOutOfWindow/, ++ ); ++ await expect(failing(new L1RpcError('L1 RPC request failed')).getBucketAtOrBeforeTotal(3n)).rejects.toThrow( ++ /L1 RPC request failed/, ++ ); ++ }); ++ }); ++}); +diff --git a/yarn-project/ethereum/src/contracts/inbox.ts b/yarn-project/ethereum/src/contracts/inbox.ts +index 0cd7471ceca7db19152f8ddd3c07dac57ec20f79..83515b23a951327c99c0b6838c231b00ad05591f 100644 +--- a/yarn-project/ethereum/src/contracts/inbox.ts ++++ b/yarn-project/ethereum/src/contracts/inbox.ts +@@ -11,8 +11,9 @@ import { getPublicClient } from '../client.js'; + import type { DeployAztecL1ContractsReturnType } from '../deploy_aztec_l1_contracts.js'; + import type { L1ReaderConfig } from '../l1_reader.js'; + import type { ViemClient } from '../types.js'; ++import { formatViemError } from '../utils.js'; + import type { L1EventLog } from './log.js'; +-import { checkBlockTag } from './utils.js'; ++import { checkBlockTag, getRevertedErrorName } from './utils.js'; + + /** The full L1-to-L2 message emitted by the Inbox, decoded from the event. Hashing it yields `leaf`. */ + export type MessageSentMessage = { +@@ -105,12 +106,29 @@ export class InboxContract { + ): Promise { + await checkBlockTag(opts.blockNumber, this.client); + const bucket = await this.inbox.read.getBucket([seq], opts); +- return { +- rollingHash: Fr.fromString(bucket.rollingHash), +- totalMsgCount: bucket.totalMsgCount, +- timestamp: bucket.timestamp, +- msgCount: bucket.msgCount, +- }; ++ return toInboxContractBucket(bucket); ++ } ++ ++ /** ++ * Returns the live Inbox bucket with the greatest cumulative message total at or below `upperBound`, together with ++ * its sequence number. Resolves to `undefined` when even the oldest retained bucket ends past the bound, i.e. the ++ * ring has already evicted every bucket that could have matched. Totals strictly increase per bucket, so the result ++ * is the newest bucket boundary reachable from a local message count. ++ */ ++ public async getBucketAtOrBeforeTotal( ++ upperBound: bigint, ++ opts: { blockTag?: BlockTag; blockNumber?: bigint } = {}, ++ ): Promise<{ seq: bigint; bucket: InboxContractBucket } | undefined> { ++ await checkBlockTag(opts.blockNumber, this.client); ++ try { ++ const [seq, bucket] = await this.inbox.read.getBucketAtOrBeforeTotal([upperBound], opts); ++ return { seq, bucket: toInboxContractBucket(bucket) }; ++ } catch (err) { ++ if (getRevertedErrorName(err) === 'Inbox__NoBucketAtOrBeforeTotal') { ++ return undefined; ++ } ++ throw formatViemError(err); ++ } + } + + /** Fetches MessageSent events within the given block range. */ +@@ -210,6 +228,20 @@ export type InboxContractState = { + currentBucketSeq: bigint; + }; + ++function toInboxContractBucket(bucket: { ++ rollingHash: Hex; ++ totalMsgCount: bigint; ++ timestamp: bigint; ++ msgCount: number; ++}): InboxContractBucket { ++ return { ++ rollingHash: Fr.fromString(bucket.rollingHash), ++ totalMsgCount: bucket.totalMsgCount, ++ timestamp: bucket.timestamp, ++ msgCount: bucket.msgCount, ++ }; ++} ++ + /** A snapshot of an on-chain Inbox rolling-hash bucket. */ + export type InboxContractBucket = { + /** Consensus rolling hash (truncated sha256 chain) after the last message absorbed into this bucket. */ +diff --git a/yarn-project/ethereum/src/contracts/rollup.test.ts b/yarn-project/ethereum/src/contracts/rollup.test.ts +index 7c770fabeac2c4a296c348524d106fc0dc047997..cf222eccafe55ff9b3615f3ad1c28d8ab4631e20 100644 +--- a/yarn-project/ethereum/src/contracts/rollup.test.ts ++++ b/yarn-project/ethereum/src/contracts/rollup.test.ts +@@ -1,3 +1,4 @@ ++import { InboxAbi } from '@aztec-foundation/l1-artifacts/InboxAbi'; + import { RollupAbi } from '@aztec-foundation/l1-artifacts/RollupAbi'; + + import { L1RpcError, getPublicClient } from '@aztec-labs/ethereum/client'; +@@ -8,16 +9,19 @@ import { EthAddress } from '@aztec-labs/foundation/eth-address'; + import { createLogger } from '@aztec-labs/foundation/log'; + import { DateProvider } from '@aztec-labs/foundation/timer'; + import { jest } from '@jest/globals'; +-import { type Abi, RpcRequestError, encodeErrorResult } from 'viem'; ++import { type Abi, RpcRequestError, encodeErrorResult, getContract } from 'viem'; + import { foundry } from 'viem/chains'; + + import { DefaultL1ContractsConfig } from '../config.js'; +-import { deployAztecL1Contracts } from '../deploy_aztec_l1_contracts.js'; ++import { type DeployAztecL1ContractsReturnType, deployAztecL1Contracts } from '../deploy_aztec_l1_contracts.js'; ++import { ReadOnlyL1TxUtils } from '../l1_tx_utils/index.js'; + import { EthCheatCodes } from '../test/eth_cheat_codes.js'; + import type { Anvil } from '../test/start_anvil.js'; + import { startAnvil } from '../test/start_anvil.js'; + import type { ViemClient } from '../types.js'; +-import { type FeeHeader, RollupContract, TempCheckpointLogField } from './rollup.js'; ++import { buildSimulationOverridesStateOverride } from './chain_state_override.js'; ++import { InboxContract } from './inbox.js'; ++import { type CheckpointPreflightArgs, type FeeHeader, RollupContract, TempCheckpointLogField } from './rollup.js'; + + describe('compressFeeHeader', () => { + /** Creates a zero fee header with the given overrides. */ +@@ -237,25 +241,28 @@ describe('Rollup', () => { + + let vkTreeRoot: Fr; + let protocolContractsHash: Fr; ++ let genesisArchiveRoot: Fr; + let rollupAddress: `0x${string}`; + let rollup: RollupContract; ++ let deployed: DeployAztecL1ContractsReturnType; + + beforeAll(async () => { + // this is the 6th address that gets funded by the junk mnemonic + const privateKeyRaw = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; + vkTreeRoot = Fr.random(); + protocolContractsHash = Fr.random(); ++ genesisArchiveRoot = Fr.random(); + + ({ anvil, rpcUrl } = await startAnvil()); + + publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: 31337 }); + cheatCodes = new EthCheatCodes([rpcUrl], new DateProvider()); + +- const deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { ++ deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { + ...DefaultL1ContractsConfig, + vkTreeRoot, + protocolContractsHash, +- genesisArchiveRoot: Fr.random(), ++ genesisArchiveRoot, + realVerifier: false, + }); + +@@ -361,6 +368,185 @@ describe('Rollup', () => { + }); + }); + ++ describe('validateCheckpointHeaderAndInbox', () => { ++ let l1TxUtils: ReadOnlyL1TxUtils; ++ let inbox: InboxContract; ++ let slotDuration: bigint; ++ let l1GenesisTime: bigint; ++ let liveTips: bigint; ++ ++ beforeAll(async () => { ++ l1TxUtils = new ReadOnlyL1TxUtils(publicClient, undefined, new DateProvider()); ++ inbox = InboxContract.getFromL1ContractsValues(deployed); ++ slotDuration = BigInt(await rollup.getSlotDuration()); ++ l1GenesisTime = await rollup.getL1GenesisTime(); ++ // Earlier tests rewrote the chain tips; run against the fresh chain (effective parent 0) and put them back after. ++ liveTips = await cheatCodes.load(EthAddress.fromString(rollupAddress), BigInt(RollupContract.stfStorageSlot)); ++ await cheatCodes.store(EthAddress.fromString(rollupAddress), BigInt(RollupContract.stfStorageSlot), 0n); ++ }); ++ ++ afterAll(async () => { ++ await cheatCodes.store(EthAddress.fromString(rollupAddress), BigInt(RollupContract.stfStorageSlot), liveTips); ++ }); ++ ++ /** Builds an otherwise valid header for the second L2 slot after the current L1 time, and the time to simulate at. */ ++ async function buildHeader(inboxRollingHash: Fr): Promise<{ args: CheckpointPreflightArgs; time: bigint }> { ++ const now = (await publicClient.getBlock()).timestamp; ++ const slot = SlotNumber(Number((now - l1GenesisTime) / slotDuration) + 2); ++ const time = await rollup.getTimestampForSlot(slot); ++ const minFee = await rollup.getManaMinFeeAt(time, true); ++ const zero = Fr.ZERO.toString(); ++ const args: CheckpointPreflightArgs = { ++ header: { ++ lastArchiveRoot: genesisArchiveRoot.toString(), ++ blockHeadersHash: zero, ++ blobsHash: zero, ++ inboxRollingHash: inboxRollingHash.toString(), ++ outHash: zero, ++ slotNumber: BigInt(slot), ++ timestamp: time, ++ coinbase: EthAddress.random().toString(), ++ feeRecipient: zero, ++ gasFees: { feePerDaGas: 0n, feePerL2Gas: minFee }, ++ totalManaUsed: 0n, ++ accumulatedFees: 0n, ++ }, ++ attestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' }, ++ signers: [], ++ attestationsAndSignersSignature: { r: zero, s: zero, v: 0 }, ++ digest: zero, ++ blobsHash: zero, ++ flags: { ignoreDA: true }, ++ expectedTotal: 0n, ++ expectedParentCheckpointNumber: 0n, ++ }; ++ return { args, time }; ++ } ++ ++ it('returns the genesis bucket for a checkpoint consuming nothing on a fresh chain', async () => { ++ const { args, time } = await buildHeader(Fr.ZERO); ++ await expect(rollup.validateCheckpointHeaderAndInbox(l1TxUtils, args, { time })).resolves.toBe(0n); ++ }); ++ ++ it('rejects a parent claim that differs from the parent propose would use', async () => { ++ const { args, time } = await buildHeader(Fr.ZERO); ++ await expect( ++ rollup.validateCheckpointHeaderAndInbox(l1TxUtils, { ...args, expectedParentCheckpointNumber: 1n }, { time }), ++ ).rejects.toThrow(/Rollup__UnexpectedParentCheckpoint/); ++ }); ++ ++ it('rejects a consumed total that is not a bucket boundary', async () => { ++ const { args, time } = await buildHeader(Fr.ZERO); ++ await expect( ++ rollup.validateCheckpointHeaderAndInbox(l1TxUtils, { ...args, expectedTotal: 1n }, { time }), ++ ).rejects.toThrow(/Rollup__InboxTotalNotAtBucketBoundary/); ++ }); ++ ++ it('runs the shared header checks against the derived parent', async () => { ++ const { args, time } = await buildHeader(Fr.ZERO); ++ const header = { ...args.header, lastArchiveRoot: Fr.random().toString() }; ++ await expect(rollup.validateCheckpointHeaderAndInbox(l1TxUtils, { ...args, header }, { time })).rejects.toThrow( ++ /Rollup__InvalidArchive/, ++ ); ++ }); ++ ++ /** Sends one message in a fresh L1 block 12 seconds after the previous one, so it opens a new bucket. */ ++ async function sendMessageInNewBucket(): Promise { ++ const inboxContract = getContract({ ++ address: deployed.l1ContractAddresses.inboxAddress.toString(), ++ abi: InboxAbi, ++ client: deployed.l1Client, ++ }); ++ const version = await rollup.getVersion(); ++ const { timestamp } = await publicClient.getBlock(); ++ await cheatCodes.warp(timestamp + 12n, { silent: true }); ++ const hash = await inboxContract.write.sendL2Message( ++ [{ actor: Fr.random().toString(), version }, Fr.random().toString(), Fr.random().toString()], ++ { gas: 1_000_000n }, ++ ); ++ await deployed.l1Client.waitForTransactionReceipt({ hash }); ++ } ++ ++ it('resolves a consumed bucket to its sequence and rejects a stale hash for it', async () => { ++ await sendMessageInNewBucket(); ++ const bucket = await inbox.getBucket(1n); ++ expect(bucket.totalMsgCount).toBe(1n); ++ ++ const { args, time } = await buildHeader(bucket.rollingHash); ++ await expect( ++ rollup.validateCheckpointHeaderAndInbox(l1TxUtils, { ...args, expectedTotal: 1n }, { time }), ++ ).resolves.toBe(1n); ++ ++ const stale = { ...args.header, inboxRollingHash: Fr.random().toString() }; ++ await expect( ++ rollup.validateCheckpointHeaderAndInbox(l1TxUtils, { ...args, header: stale, expectedTotal: 1n }, { time }), ++ ).rejects.toThrow(/Rollup__InvalidInboxRollingHash/); ++ }); ++ ++ // A pipelined checkpoint is validated against a parent that has not landed: the state overrides describe that ++ // parent (tips, archive, slot, consumed total) and the call reads the parent's total from them, so the same call ++ // without the overrides has no such parent and a parent total ahead of the child rejects the child. ++ it('validates against an unpublished parent supplied through state overrides', async () => { ++ // Two fresh buckets: the parent consumed through the first, the child consumes the second. ++ await sendMessageInNewBucket(); ++ const parentSeq = await inbox.getCurrentBucketSeq(); ++ await sendMessageInNewBucket(); ++ const childSeq = await inbox.getCurrentBucketSeq(); ++ expect(childSeq).toBe(parentSeq + 1n); ++ const parentBucket = await inbox.getBucket(parentSeq); ++ const childBucket = await inbox.getBucket(childSeq); ++ expect(childBucket.totalMsgCount).toBe(parentBucket.totalMsgCount + 1n); ++ ++ const { args, time } = await buildHeader(childBucket.rollingHash); ++ const parentArchive = Fr.random(); ++ const parentSlot = SlotNumber(Number(args.header.slotNumber) - 1); ++ // The mana fee derivation reads the parent's fee header; the genesis one stands in for a real parent's. ++ const parentFeeHeader = await rollup.getFeeHeader(0n); ++ const overridesFor = (parentInboxMsgTotal: bigint) => ++ buildSimulationOverridesStateOverride(rollup, { ++ chainTipsOverride: { pending: CheckpointNumber(1), proven: CheckpointNumber(0) }, ++ pendingCheckpointState: { ++ archive: parentArchive, ++ slotNumber: parentSlot, ++ inboxMsgTotal: parentInboxMsgTotal, ++ inboxConsumedBucket: parentSeq, ++ feeHeader: parentFeeHeader, ++ }, ++ }); ++ ++ const stateOverrides = await overridesFor(parentBucket.totalMsgCount); ++ const minFee = await rollup.getManaMinFeeAt(time, true, stateOverrides); ++ const header = { ++ ...args.header, ++ lastArchiveRoot: parentArchive.toString(), ++ gasFees: { ...args.header.gasFees, feePerL2Gas: minFee }, ++ }; ++ const childArgs = { ++ ...args, ++ header, ++ expectedTotal: childBucket.totalMsgCount, ++ expectedParentCheckpointNumber: 1n, ++ }; ++ ++ await expect( ++ rollup.validateCheckpointHeaderAndInbox(l1TxUtils, childArgs, { time, stateOverrides }), ++ ).resolves.toBe(childSeq); ++ ++ // Without the parent's state the effective parent is still checkpoint 0. ++ await expect(rollup.validateCheckpointHeaderAndInbox(l1TxUtils, childArgs, { time })).rejects.toThrow( ++ /Rollup__UnexpectedParentCheckpoint/, ++ ); ++ ++ // The parent's stored total, not the caller, is the consumption floor. ++ await expect( ++ rollup.validateCheckpointHeaderAndInbox(l1TxUtils, childArgs, { ++ time, ++ stateOverrides: await overridesFor(childBucket.totalMsgCount + 3n), ++ }), ++ ).rejects.toThrow(/Rollup__InboxConsumptionBehindParent/); ++ }); ++ }); ++ + describe('getVkTreeRoot and getProtocolContractsHash', () => { + it('reads vkTreeRoot', async () => { + const result = await rollup.getVkTreeRoot(); +diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts +index b72cc405e81aee557a54df55a165666884e939b9..e8c5da3881bb37da8f712d67a0a22a6cf564d3be 100644 +--- a/yarn-project/ethereum/src/contracts/rollup.ts ++++ b/yarn-project/ethereum/src/contracts/rollup.ts +@@ -1,3 +1,4 @@ ++import { ErrorsAbi } from '@aztec-foundation/l1-artifacts/ErrorsAbi'; + import { EscapeHatchAbi } from '@aztec-foundation/l1-artifacts/EscapeHatchAbi'; + import { RollupAbi } from '@aztec-foundation/l1-artifacts/RollupAbi'; + import { RollupStorage } from '@aztec-foundation/l1-artifacts/RollupStorage'; +@@ -22,6 +23,7 @@ import { + type StateOverride, + type WatchContractEventReturnType, + decodeErrorResult, ++ decodeFunctionResult, + encodeAbiParameters, + encodeFunctionData, + getContract, +@@ -33,9 +35,9 @@ import { getPublicClient } from '../client.js'; + import type { DeployAztecL1ContractsReturnType } from '../deploy_aztec_l1_contracts.js'; + import type { L1ContractAddresses } from '../l1_contract_addresses.js'; + import type { L1ReaderConfig } from '../l1_reader.js'; +-import type { L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js'; ++import type { L1TxRequest, L1TxUtils, ReadOnlyL1TxUtils } from '../l1_tx_utils/index.js'; + import type { ViemClient } from '../types.js'; +-import { formatViemError } from '../utils.js'; ++import { formatViemError, mergeAbis } from '../utils.js'; + import { GSEContract } from './gse.js'; + import type { L1EventLog } from './log.js'; + import { SlasherContract } from './slasher_contract.js'; +@@ -96,6 +98,21 @@ export type ViemGasFees = { + feePerL2Gas: bigint; + }; + ++/** Inputs of the Rollup's integrated header and Inbox preflight, mirroring the contract's `CheckpointPreflightArgs`. */ ++export type CheckpointPreflightArgs = { ++ header: ViemHeader; ++ attestations: ViemCommitteeAttestations; ++ signers: `0x${string}`[]; ++ attestationsAndSignersSignature: ViemSignature; ++ digest: `0x${string}`; ++ blobsHash: `0x${string}`; ++ flags: { ignoreDA: boolean }; ++ /** Cumulative Inbox message count the checkpoint consumed up to; must be a live bucket boundary. */ ++ expectedTotal: bigint; ++ /** Checkpoint number the header was built on; the call derives the real parent and rejects any other. */ ++ expectedParentCheckpointNumber: bigint; ++}; ++ + /** + * Status of a validator/attester in the staking system. + * Matches the Status enum in StakingLib.sol +@@ -890,6 +907,39 @@ export class RollupContract { + } + } + ++ /** ++ * Simulates `validateCheckpointHeaderAndInbox` at the intended execution time and state, and returns the Inbox ++ * bucket sequence to submit to `propose` as `bucketHint`. ++ * ++ * The call derives the parent checkpoint from the simulated Rollup storage the way `propose` does (the proven tip if ++ * the pending chain is prunable at `time`), so `stateOverrides` must describe the state the real transaction will ++ * see: a pipelined parent, or the tips after a bundled invalidation. It runs over `eth_simulateV1` with a block time ++ * override, the same transport as the header-only preflight, and throws a formatted error naming the contract ++ * revert (`Rollup__UnexpectedParentCheckpoint`, `Rollup__InboxTotalNotAtBucketBoundary`, ++ * `Inbox__NoBucketAtOrBeforeTotal`, or any header/Inbox consumption error `propose` raises) when the checkpoint is ++ * not publishable in that context. ++ * @param l1TxUtils - The simulation transport ++ * @param args - The header validation inputs plus the consumed Inbox total and the expected parent ++ * @param opts - The block timestamp to simulate at, the state overrides to apply, and the simulated sender ++ */ ++ public async validateCheckpointHeaderAndInbox( ++ l1TxUtils: Pick, ++ args: CheckpointPreflightArgs, ++ opts: { time: bigint; stateOverrides?: StateOverride; from?: `0x${string}` }, ++ ): Promise { ++ const { result } = await l1TxUtils.simulate( ++ { ++ to: this.address, ++ data: encodeFunctionData({ abi: RollupAbi, functionName: 'validateCheckpointHeaderAndInbox', args: [args] }), ++ from: opts.from, ++ }, ++ { time: opts.time }, ++ opts.stateOverrides ?? [], ++ mergeAbis([RollupAbi, ErrorsAbi]), ++ ); ++ return decodeFunctionResult({ abi: RollupAbi, functionName: 'validateCheckpointHeaderAndInbox', data: result }); ++ } ++ + /** + * @notice Calls `canProposeAtTime` with the time of the next Ethereum block and the sender address + * +diff --git a/yarn-project/ethereum/src/contracts/utils.ts b/yarn-project/ethereum/src/contracts/utils.ts +index 5877ba44392da9e77b9198a69c0c40951ae6bde4..b3a061a615e653939ac6c5432659485a408b9b19 100644 +--- a/yarn-project/ethereum/src/contracts/utils.ts ++++ b/yarn-project/ethereum/src/contracts/utils.ts +@@ -1,3 +1,5 @@ ++import { BaseError, ContractFunctionRevertedError } from 'viem'; ++ + import type { ViemClient } from '../types.js'; + import { BlockTagTooOldError } from './errors.js'; + +@@ -12,3 +14,12 @@ export async function checkBlockTag(block: bigint | undefined, publicClient: Vie + throw new BlockTagTooOldError(block, latestBlock); + } + } ++ ++/** Returns the name of the custom error a viem contract call reverted with, if the revert data could be decoded. */ ++export function getRevertedErrorName(err: unknown): string | undefined { ++ if (!(err instanceof BaseError)) { ++ return undefined; ++ } ++ const revertError = err.walk(e => e instanceof ContractFunctionRevertedError); ++ return revertError instanceof ContractFunctionRevertedError ? revertError.data?.errorName : undefined; ++} diff --git a/labs-patches/0014-fix-ethereum-drop-the-dead-ignoreSignatures-header-v.patch b/labs-patches/0014-fix-ethereum-drop-the-dead-ignoreSignatures-header-v.patch new file mode 100644 index 000000000000..f51a72cc4f45 --- /dev/null +++ b/labs-patches/0014-fix-ethereum-drop-the-dead-ignoreSignatures-header-v.patch @@ -0,0 +1,52 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Santiago Palladino +Date: Thu, 10 Sep 2026 18:38:20 -0300 +Subject: [PATCH] fix(ethereum): drop the dead ignoreSignatures header + validation flag + +`CheckpointHeaderValidationFlags` on the Rollup carries a single field, +`ignoreDA`. The `ignoreSignatures` field the TypeScript declared alongside it +is a leftover from `BlockHeaderValidationFlags`, which lost that flag when +signature verification moved out of the header check and became a function of +the attestations passed in. + +Because the flags argument reaches viem as a typed variable rather than an +inline literal, viem encoded it against the ABI and discarded the unknown key +with no type error and no runtime error. The flag has therefore had no effect +for as long as it has been declared, and removing it changes no encoded +calldata. + +Nor does it change intent: signature checks are skipped by passing empty +attestations, which the one caller that set `ignoreSignatures: true` +(`SequencerPublisher.validateCheckpointHeader`) already does, so the header +preflight it simulates keeps skipping them. + +diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts +index e8c5da3881bb37da8f712d67a0a22a6cf564d3be..0b04e06b0845647877173fcd4fe723b509c40707 100644 +--- a/yarn-project/ethereum/src/contracts/rollup.ts ++++ b/yarn-project/ethereum/src/contracts/rollup.ts +@@ -887,10 +887,7 @@ export class RollupContract { + ViemSignature, + `0x${string}`, + `0x${string}`, +- { +- ignoreDA: boolean; +- ignoreSignatures: boolean; +- }, ++ { ignoreDA: boolean }, + ], + account: `0x${string}` | Account, + ): Promise { +diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +index 948f14d655618eb2c9516689074a18dd9efdc2e1..f20fbb70727827cbfc3f6eab2dd9ccd5b1571723 100644 +--- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts ++++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +@@ -983,7 +983,7 @@ export class SequencerPublisher { + header: CheckpointHeader, + simulationOverridesPlan?: SimulationOverridesPlan, + ): Promise { +- const flags = { ignoreDA: true, ignoreSignatures: true }; ++ const flags = { ignoreDA: true }; + + const args = [ + header.toViem(),