From fa5976bba7c76a865f9c86ea06d4a0313851b9ff Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 10 Sep 2026 12:51:36 -0300 Subject: [PATCH 1/2] feat(l1): resolve inbox endpoints by message total and preflight checkpoints Two L1 primitives are missing for a node that lets ordinary L2 blocks end at arbitrary message prefixes and only resolves a live Inbox bucket when a checkpoint is completed: a one-call way to find the newest live bucket boundary at or below a locally known message count, and a publication preflight that checks a checkpoint's header *and* its final Inbox consumption against the parent `propose` will actually use at execution time, rather than against a caller-supplied parent total. Both go in as additive contract APIs; nothing on the existing propose path changes. `IInbox.getBucketAtOrBeforeTotal(uint64)` returns the live bucket with the greatest cumulative total at or below the bound. Totals strictly increase with the bucket sequence, so that is the newest retained bucket whose end position fits. 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. The search walks back the newest `ENDPOINT_WALKBACK_PROBES` live entries, checking for `oldest` before each step down, then binary-searches only the interval the walk did not scan. Overwritten ring entries are never dereferenced, and genesis is a candidate only while its ring entry is still live. `IRollup.validateCheckpointHeaderAndInbox` derives the effective pending checkpoint at `block.timestamp` exactly as `propose` does, rejects any parent other than the one the caller claims, runs the shared header and attestation checks, then resolves the consumed total to a live bucket and runs it through the same settlement, monotonicity, cap and censorship predicate `propose` enforces, returning the sequence to submit as `bucketHint`. The arguments arrive as one `CheckpointPreflightArgs` calldata struct: the flat form hits "stack too deep", and a single struct keeps the Rollup's forwarder small enough to stay under the EIP-170 limit. `MAX_MSGS_PER_BUCKET` now aliases `Constants.MAX_L1_TO_L2_MSGS_PER_BLOCK` instead of hardcoding 256, so the Inbox rollover and the node-side completion guard read one generated value; `MAX_L1_TO_L2_MSGS_PER_BLOCK` is exported to Solidity for it. Propose grows about 130 gas from the larger Inbox and Rollup dispatch tables. --- l1-contracts/gas_benchmark.md | 14 +- l1-contracts/gas_benchmark_results.json | 40 +- l1-contracts/gas_report.json | 166 ++--- .../partial_epoch_proof_gas_report.json | 10 +- .../partial_epoch_proof_gas_report.md | 2 +- .../scripts/constants-codegen/solidity.json | 1 + l1-contracts/src/core/Rollup.sol | 19 + l1-contracts/src/core/interfaces/IRollup.sol | 28 + .../core/interfaces/messagebridge/IInbox.sol | 20 +- l1-contracts/src/core/libraries/Errors.sol | 3 + .../src/core/libraries/rollup/ProposeLib.sol | 33 + .../rollup/RollupOperationsExtLib.sol | 97 ++- l1-contracts/src/core/messagebridge/Inbox.sol | 63 ++ l1-contracts/test/InboxBucketSearch.t.sol | 337 +++++++++ l1-contracts/test/InboxBuckets.t.sol | 16 + l1-contracts/test/harnesses/InboxHarness.sol | 10 + .../test/rollup/CheckpointPreflight.t.sol | 436 ++++++++++++ .../test/rollup/ProposeInboxConsumption.t.sol | 55 ++ ...ap-inbox-endpoint-resolution-and-che.patch | 640 ++++++++++++++++++ 19 files changed, 1854 insertions(+), 136 deletions(-) create mode 100644 l1-contracts/test/InboxBucketSearch.t.sol create mode 100644 l1-contracts/test/rollup/CheckpointPreflight.t.sol create mode 100644 labs-patches/0013-feat-ethereum-wrap-inbox-endpoint-resolution-and-che.patch 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; ++} From 72e763e1f74593090d7cb64c9fd0f0a168f42317 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 10 Sep 2026 18:39:00 -0300 Subject: [PATCH 2/2] fix(ethereum): drop the dead ignoreSignatures header validation flag `CheckpointHeaderValidationFlags` carries only `ignoreDA`; the second field the labs TypeScript declared for it was silently discarded by viem's ABI encoding, so it never skipped a signature check. Queues the removal as a labs patch. --- ...p-the-dead-ignoreSignatures-header-v.patch | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 labs-patches/0014-fix-ethereum-drop-the-dead-ignoreSignatures-header-v.patch 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(),