diff --git a/docs/docs-developers/docs/tutorials/js_tutorials/aave_bridge.md b/docs/docs-developers/docs/tutorials/js_tutorials/aave_bridge.md index aafcc915af38..029ec76fcbe3 100644 --- a/docs/docs-developers/docs/tutorials/js_tutorials/aave_bridge.md +++ b/docs/docs-developers/docs/tutorials/js_tutorials/aave_bridge.md @@ -441,7 +441,7 @@ Extract the message leaf index: #include_code get_claim_leaf_index /docs/examples/ts/aave_bridge/index.ts typescript -On the local network, L2 blocks are only produced when transactions are submitted. An L1-to-L2 message can only be consumed once an L2 block includes it, and the network waits until the message is at least 12 seconds old before including it. This utility deploys two dummy contracts (with random salts for unique addresses) to force block production. On devnet or testnet, blocks are produced continuously and this step is unnecessary: +On the local network, L2 blocks are only produced when transactions are submitted. An L1-to-L2 message can only be consumed once an L2 block includes it, and the local sandbox includes it as soon as it sees it. This utility deploys two dummy contracts (with random salts for unique addresses) to force block production. On devnet or testnet, blocks are produced continuously and this step is unnecessary, but there the sequencer first waits for the L1 block carrying the message to gain a child, which takes one more L1 block (around 12 to 14 seconds): #include_code mine_blocks /docs/examples/ts/aave_bridge/index.ts typescript diff --git a/docs/docs-developers/docs/tutorials/js_tutorials/token_bridge.md b/docs/docs-developers/docs/tutorials/js_tutorials/token_bridge.md index 9dacbf993adb..230cc542274b 100644 --- a/docs/docs-developers/docs/tutorials/js_tutorials/token_bridge.md +++ b/docs/docs-developers/docs/tutorials/js_tutorials/token_bridge.md @@ -443,7 +443,7 @@ Use viem to extract this information: #include_code get_message_leaf_index /docs/examples/ts/token_bridge/index.ts typescript -This extracts the logs from the deposit and retrieves the leaf index. You can now claim it on L2. However, a message can only be claimed once an L2 block includes it, and the network waits until the message is at least 12 seconds old before including it, so expect 12 to 30 seconds of latency. If you called `claim` on the L2 contract immediately, it would return "no message available". +This extracts the logs from the deposit and retrieves the leaf index. You can now claim it on L2. However, a message can only be claimed once an L2 block includes it. On a live network the sequencer first waits for the L1 block carrying the message to gain a child, which takes one more L1 block (around 12 to 14 seconds), so expect 15 to 30 seconds of latency; the local sandbox consumes the message as soon as it sees it. If you called `claim` on the L2 contract immediately, it would return "no message available". On a local network blocks are only produced when transactions are submitted, so add a utility function that forces a couple of blocks (it deploys a contract with a random salt): diff --git a/docs/docs-developers/docs/tutorials/js_tutorials/uniswap_swap.md b/docs/docs-developers/docs/tutorials/js_tutorials/uniswap_swap.md index 537605723c49..c921285d639a 100644 --- a/docs/docs-developers/docs/tutorials/js_tutorials/uniswap_swap.md +++ b/docs/docs-developers/docs/tutorials/js_tutorials/uniswap_swap.md @@ -309,7 +309,7 @@ Bridge WETH from L1 to L2: When depositing from L1 to L2, we use a secret/secret-hash pattern: generate a random secret on the client, send only the hash to L1 (in the deposit transaction), then later reveal the secret on L2 to claim the tokens. This prevents **front-running attacks**: a malicious sequencer (the node that orders and processes L2 transactions) cannot observe the L1 deposit and claim the tokens themselves because they don't know the secret. Only someone who knows the preimage can claim. ::: -Before claiming, we need to mine a couple of L2 blocks. An L1-to-L2 message is not available the moment it is sent -- the rollup only includes it in an L2 block once it is at least 12 seconds old, and it becomes consumable as soon as that block lands. We use a helper that deploys throwaway contracts to force these blocks: +Before claiming, we need to mine a couple of L2 blocks. An L1-to-L2 message is not available the moment it is sent -- it becomes consumable only once an L2 block includes it. The local sandbox includes it as soon as it sees it; on a live network the sequencer first waits for the L1 block carrying it to gain a child, which takes one more L1 block (around 12 to 14 seconds). We use a helper that deploys throwaway contracts to force these blocks: #include_code mine_blocks /docs/examples/ts/example_swap/index.ts typescript diff --git a/l1-contracts/gas_benchmark.md b/l1-contracts/gas_benchmark.md index 127649a78e40..d5fd08e6372e 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 | 199,366 | 225,550 | 996 | 15,936 | -| submitEpochRootProof | 991,032 | 1,029,525 | 14,148 | 226,368 | +| propose | 197,740 | 223,924 | 996 | 15,936 | +| submitEpochRootProof | 980,266 | 1,018,730 | 14,148 | 226,368 | | setupEpoch | 32,042 | 113,837 | - | - | -**Avg Gas Cost per Second**: 3,643.2 gas/second +**Avg Gas Cost per Second**: 3,611.2 gas/second *Epoch duration*: 0h 38m 24s ## Validators | Function | Avg Gas | Max Gas | Calldata Size | Calldata Gas | |----------------------|-----------|-----------|---------------|--------------| -| propose | 327,774 | 355,591 | 4,516 | 72,256 | -| submitEpochRootProof | 1,572,081 | 1,669,921 | 16,644 | 266,304 | -| aggregate3 | 376,665 | 390,039 | - | - | +| propose | 326,159 | 353,977 | 4,516 | 72,256 | +| submitEpochRootProof | 1,561,332 | 1,659,142 | 16,644 | 266,304 | +| aggregate3 | 375,051 | 388,425 | - | - | | setupEpoch | 46,504 | 547,670 | - | - | -**Avg Gas Cost per Second**: 5,937.3 gas/second +**Avg Gas Cost per Second**: 5,905.5 gas/second *Epoch duration*: 0h 38m 24s diff --git a/l1-contracts/gas_benchmark_results.json b/l1-contracts/gas_benchmark_results.json index a742ea9be3a1..4b3f584ddcdf 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": 185722, - "mean": 199366, - "median": 195111, - "max": 225550, + "min": 184096, + "mean": 197740, + "median": 193486, + "max": 223924, "calldata_size": 996, "calldata_gas": 15936 }, @@ -18,10 +18,10 @@ }, "submitEpochRootProof": { "calls": 4, - "min": 972329, - "mean": 991032, - "median": 981138, - "max": 1029525, + "min": 961647, + "mean": 980266, + "median": 970345, + "max": 1018730, "calldata_size": 14148, "calldata_gas": 226368 } @@ -29,10 +29,10 @@ "validators": { "propose": { "calls": 150, - "min": 305434, - "mean": 327774, - "median": 327227, - "max": 355591, + "min": 303820, + "mean": 326159, + "median": 325613, + "max": 353977, "calldata_size": 4516, "calldata_gas": 72256 }, @@ -45,19 +45,19 @@ }, "submitEpochRootProof": { "calls": 4, - "min": 1460323, - "mean": 1572081, - "median": 1579041, - "max": 1669921, + "min": 1449546, + "mean": 1561332, + "median": 1568320, + "max": 1659142, "calldata_size": 16644, "calldata_gas": 266304 }, "aggregate3": { "calls": 55, - "min": 365547, - "mean": 376665, - "median": 376350, - "max": 390039 + "min": 363933, + "mean": 375051, + "median": 374735, + "max": 388425 } } } \ No newline at end of file diff --git a/l1-contracts/gas_report.json b/l1-contracts/gas_report.json index 6d7c967d5aef..7876ade81881 100644 --- a/l1-contracts/gas_report.json +++ b/l1-contracts/gas_report.json @@ -3,36 +3,50 @@ "contract": "src/core/messagebridge/Inbox.sol:Inbox", "deployment": { "gas": 0, - "size": 6140 + "size": 6805 }, "functions": { "getBucket(uint256)": { - "calls": 4667, - "min": 7414, - "mean": 7414, - "median": 7414, - "max": 7414 + "calls": 4679, + "min": 7436, + "mean": 7436, + "median": 7436, + "max": 7436 }, "getCurrentBucketSeq()": { - "calls": 4667, - "min": 408, - "mean": 1407, - "median": 408, - "max": 2408 + "calls": 4684, + "min": 441, + "mean": 1441, + "median": 2441, + "max": 2441 }, "getFeeAssetPortal()": { - "calls": 2586, + "calls": 2590, "min": 212, "mean": 212, "median": 212, "max": 212 }, + "getProvenConsumedBucketSeq()": { + "calls": 6, + "min": 2403, + "mean": 2403, + "median": 2403, + "max": 2403 + }, + "getRingHeadroom()": { + "calls": 1, + "min": 2618, + "mean": 2618, + "median": 2618, + "max": 2618 + }, "sendL2Message((bytes32,uint256),bytes32,bytes32)": { - "calls": 37328, + "calls": 37409, "min": 43269, - "mean": 46585, + "mean": 46588, "median": 43269, - "max": 102022 + "max": 102063 } } }, @@ -60,7 +74,7 @@ }, "functions": { "owner()": { - "calls": 5172, + "calls": 5180, "min": 397, "mean": 397, "median": 397, @@ -76,21 +90,21 @@ }, "functions": { "getCanonicalRollup()": { - "calls": 1780, + "calls": 1728, "min": 1073, "mean": 4073, "median": 4073, "max": 7073 }, "getRewardDistributor()": { - "calls": 2586, + "calls": 2590, "min": 420, "mean": 420, "median": 420, "max": 420 }, "owner()": { - "calls": 7758, + "calls": 7770, "min": 353, "mean": 353, "median": 353, @@ -106,7 +120,7 @@ }, "functions": { "availableTo(address)": { - "calls": 890, + "calls": 864, "min": 20573, "mean": 20573, "median": 20573, @@ -118,11 +132,11 @@ "contract": "test/RollupWithPreheating.sol:RollupWithPreheating", "deployment": { "gas": 0, - "size": 42950 + "size": 43818 }, "functions": { "archive()": { - "calls": 2333, + "calls": 2339, "min": 4641, "mean": 4641, "median": 4641, @@ -136,28 +150,28 @@ "max": 2509 }, "getCheckpoint(uint256)": { - "calls": 900, - "min": 27185, - "mean": 27185, - "median": 27185, - "max": 27185 + "calls": 876, + "min": 27343, + "mean": 27343, + "median": 27343, + "max": 27343 }, "getCheckpointReward()": { - "calls": 2589, - "min": 1128, - "mean": 1133, - "median": 1128, - "max": 5628 + "calls": 2593, + "min": 1040, + "mean": 1045, + "median": 1040, + "max": 5540 }, "getCollectiveProverRewardsForEpoch(uint256)": { "calls": 3, - "min": 5837, - "mean": 5837, - "median": 5837, - "max": 5837 + "min": 5859, + "mean": 5859, + "median": 5859, + "max": 5859 }, "getCurrentEpoch()": { - "calls": 891, + "calls": 865, "min": 915, "mean": 915, "median": 915, @@ -179,10 +193,10 @@ }, "getEpochProofPublicInputs(uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],bytes)": { "calls": 4, - "min": 16751, - "mean": 45593, - "median": 47624, - "max": 70374 + "min": 18578, + "mean": 44270, + "median": 46301, + "max": 65899 }, "getEthPerFeeAsset()": { "calls": 2, @@ -192,56 +206,56 @@ "max": 11043 }, "getFeeAssetPortal()": { - "calls": 4660, - "min": 566, - "mean": 1456, - "median": 566, - "max": 2566 + "calls": 4668, + "min": 901, + "mean": 901, + "median": 901, + "max": 901 }, "getInbox()": { - "calls": 9073, - "min": 2543, - "mean": 2543, - "median": 2543, - "max": 2543 + "calls": 9095, + "min": 878, + "mean": 878, + "median": 878, + "max": 878 }, "getL1FeesAt(uint256)": { "calls": 2, - "min": 9086, - "mean": 9086, - "median": 9086, - "max": 9086 + "min": 9130, + "mean": 9130, + "median": 9130, + "max": 9130 }, "getManaMinFeeAt(uint256,bool)": { - "calls": 2336, + "calls": 2342, "min": 27032, - "mean": 28689, + "mean": 28687, "median": 27032, "max": 32050 }, "getManaTarget()": { "calls": 1026, - "min": 5526, - "mean": 5526, - "median": 5526, - "max": 5526 + "min": 5548, + "mean": 5548, + "median": 5548, + "max": 5548 }, "getOutbox()": { "calls": 2, - "min": 2521, - "mean": 2521, - "median": 2521, - "max": 2521 + "min": 856, + "mean": 856, + "median": 856, + "max": 856 }, "getPendingCheckpointNumber()": { - "calls": 1679, + "calls": 1681, "min": 2462, "mean": 2462, "median": 2462, "max": 2462 }, "getProvenCheckpointNumber()": { - "calls": 1684, + "calls": 1686, "min": 2585, "mean": 2585, "median": 2585, @@ -263,45 +277,45 @@ }, "getSequencerRewards(address)": { "calls": 2, - "min": 5981, - "mean": 5981, - "median": 5981, - "max": 5981 + "min": 6025, + "mean": 6025, + "median": 6025, + "max": 6025 }, "getTimestampForSlot(uint256)": { - "calls": 2442, + "calls": 2450, "min": 2808, "mean": 2808, "median": 2808, "max": 2808 }, "getVersion()": { - "calls": 4919, - "min": 499, - "mean": 1447, - "median": 499, - "max": 2499 + "calls": 4929, + "min": 852, + "mean": 852, + "median": 852, + "max": 852 }, "owner()": { - "calls": 5173, + "calls": 5181, "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": 2339, + "calls": 2345, "min": 0, - "mean": 266764, - "median": 284282, - "max": 325392 + "mean": 265187, + "median": 282657, + "max": 323767 }, "prune()": { - "calls": 6, - "min": 26689, - "mean": 33247, - "median": 33682, - "max": 38274 + "calls": 7, + "min": 26711, + "mean": 33301, + "median": 33704, + "max": 38296 }, "setProvingCostPerMana(uint256)": { "calls": 1, @@ -311,11 +325,11 @@ "max": 52474 }, "submitEpochRootProof((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { - "calls": 897, - "min": 58286, - "mean": 374005, - "median": 379619, - "max": 418311 + "calls": 871, + "min": 60147, + "mean": 376201, + "median": 381032, + "max": 417630 }, "updateManaTarget(uint256)": { "calls": 512, @@ -334,7 +348,7 @@ }, "functions": { "isAllBeneficiariesAllowed()": { - "calls": 2586, + "calls": 2590, "min": 404, "mean": 404, "median": 404, diff --git a/l1-contracts/src/core/Rollup.sol b/l1-contracts/src/core/Rollup.sol index 3abd82078875..4321db2222f4 100644 --- a/l1-contracts/src/core/Rollup.sol +++ b/l1-contracts/src/core/Rollup.sol @@ -13,7 +13,8 @@ import { EthPerFeeAssetE12, CheckpointHeaderValidationFlags, FeeHeader, - RollupConfigInput + RollupConfigInput, + RollupStore } from "@aztec/core/interfaces/IRollup.sol"; import {IStaking, AttesterConfig, Exit, AttesterView, Status} from "@aztec/core/interfaces/IStaking.sol"; import {IValidatorSelection, IEmperor} from "@aztec/core/interfaces/IValidatorSelection.sol"; @@ -28,7 +29,6 @@ import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributo import {CompressedSlot, CompressedTimestamp, CompressedTimeMath} from "@aztec/shared/libraries/CompressedTimeMath.sol"; import {Signature} from "@aztec/shared/libraries/SignatureLib.sol"; import {ChainTipsLib, CompressedChainTips} from "./libraries/compressed-data/Tips.sol"; -import {ValidateHeaderArgs} from "./libraries/rollup/ProposeLib.sol"; import {RewardExtLib, RewardConfig} from "./libraries/rollup/RewardExtLib.sol"; import {DepositArgs} from "./libraries/StakingQueue.sol"; import { @@ -46,7 +46,6 @@ import { ValidatorOperationsExtLib, EthValue, STFLib, - RollupStore, IInbox, IOutbox } from "./RollupCore.sol"; @@ -90,24 +89,15 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore { */ function validateHeaderWithAttestations( ProposedHeader calldata _header, - CommitteeAttestations memory _attestations, + CommitteeAttestations calldata _attestations, address[] calldata _signers, - Signature memory _attestationsAndSignersSignature, + Signature calldata _attestationsAndSignersSignature, bytes32 _digest, bytes32 _blobsHash, - CheckpointHeaderValidationFlags memory _flags + CheckpointHeaderValidationFlags calldata _flags ) external override(IRollup) { RollupOperationsExtLib.validateHeaderWithAttestations( - ValidateHeaderArgs({ - header: _header, - digest: _digest, - manaMinFee: getManaMinFeeAt(Timestamp.wrap(block.timestamp), true), - blobsHashesCommitment: _blobsHash, - flags: _flags - }), - _attestations, - _signers, - _attestationsAndSignersSignature + _header, _attestations, _signers, _attestationsAndSignersSignature, _digest, _blobsHash, _flags ); } @@ -301,7 +291,9 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore { ProposedHeader[] calldata _headers, bytes calldata _blobPublicInputs ) external view override(IRollup) returns (bytes32[] memory) { - return EpochProofExtLib.getEpochProofPublicInputs(_start, _end, _args, _headers, _blobPublicInputs); + return EpochProofExtLib.getEpochProofPublicInputs( + _start, _end, _args, _headers, _blobPublicInputs, _getRollupConfig() + ); } /** @@ -540,36 +532,39 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore { return RewardExtLib.getProvingCostPerMana().toFeeAsset(getEthPerFeeAsset()); } + // The config getters below go through {_getRollupConfig} rather than reading their immutable + // directly. Each direct read inlines a 32-byte push into this contract's runtime code, and Rollup + // sits close to the EIP-170 limit; sharing one assembly across all of them is ~95 bytes cheaper. function getVersion() external view override(IHaveVersion) returns (uint256) { - return STFLib.getStorage().config.version; + return _getRollupConfig().version; } function getInbox() external view override(IRollup) returns (IInbox) { - return STFLib.getStorage().config.inbox; + return _getRollupConfig().inbox; } function getOutbox() external view override(IRollup) returns (IOutbox) { - return STFLib.getStorage().config.outbox; + return _getRollupConfig().outbox; } function getFeeAsset() external view override(IRollup) returns (IERC20) { - return STFLib.getStorage().config.feeAsset; + return _getRollupConfig().feeAsset; } function getFeeAssetPortal() external view override(IRollup) returns (IFeeJuicePortal) { - return STFLib.getStorage().config.feeAssetPortal; + return _getRollupConfig().feeAssetPortal; } function getVkTreeRoot() external view override(IRollup) returns (bytes32) { - return STFLib.getStorage().config.vkTreeRoot; + return _getRollupConfig().vkTreeRoot; } function getProtocolContractsHash() external view override(IRollup) returns (bytes32) { - return STFLib.getStorage().config.protocolContractsHash; + return _getRollupConfig().protocolContractsHash; } function getEpochProofVerifier() external view override(IRollup) returns (IVerifier) { - return STFLib.getStorage().config.epochProofVerifier; + return _getRollupConfig().epochProofVerifier; } function getRewardDistributor() external view override(IRollup) returns (IRewardDistributor) { diff --git a/l1-contracts/src/core/RollupCore.sol b/l1-contracts/src/core/RollupCore.sol index 019b721bb4f9..37ecb0e7310e 100644 --- a/l1-contracts/src/core/RollupCore.sol +++ b/l1-contracts/src/core/RollupCore.sol @@ -6,7 +6,7 @@ pragma solidity >=0.8.27; import {IFeeJuicePortal} from "@aztec/core/interfaces/IFeeJuicePortal.sol"; import { IRollupCore, - RollupStore, + RollupConfig, SubmitEpochRootProofArgs, RollupConfigInput } from "@aztec/core/interfaces/IRollup.sol"; @@ -189,6 +189,18 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali */ uint256 public immutable L1_BLOCK_AT_GENESIS; + // The deployment-time rollup configuration. Every value is fixed at construction, so it is held in + // immutables rather than storage; {_getRollupConfig} assembles it for the libraries, which cannot read + // a contract's immutables themselves. + bytes32 internal immutable VK_TREE_ROOT; + bytes32 internal immutable PROTOCOL_CONTRACTS_HASH; + uint32 internal immutable VERSION; + IERC20 internal immutable FEE_ASSET; + IFeeJuicePortal internal immutable FEE_ASSET_PORTAL; + IVerifier internal immutable EPOCH_PROOF_VERIFIER; + IInbox internal immutable INBOX; + IOutbox internal immutable OUTBOX; + /** * @dev Storage gap to ensure checkBlob is in its own storage slot */ @@ -259,7 +271,20 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali L1_BLOCK_AT_GENESIS = block.number; - _initializeStore(_feeAsset, _epochProofVerifier, _genesisState, _config); + // Immutables must be assigned directly in the constructor body, so the store setup cannot be + // factored out into a helper the way the slasher and reward setup are. + VK_TREE_ROOT = _genesisState.vkTreeRoot; + PROTOCOL_CONTRACTS_HASH = _genesisState.protocolContractsHash; + VERSION = _config.version; + FEE_ASSET = _feeAsset; + EPOCH_PROOF_VERIFIER = _epochProofVerifier; + + IInbox inbox = IInbox(address(new Inbox(address(this), _feeAsset, _config.version, INBOX_BUCKET_RING_SIZE))); + INBOX = inbox; + OUTBOX = IOutbox(address(new Outbox(address(this), _config.version))); + FEE_ASSET_PORTAL = IFeeJuicePortal(inbox.getFeeAssetPortal()); + + STFLib.initialize(_genesisState); FeeLib.initialize(_config.manaTarget, _config.provingCostPerMana, _config.initialEthPerFeeAsset); } @@ -354,7 +379,7 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali * @return The amount of rewards claimed */ function claimSequencerRewards(address _coinbase) external override(IRollupCore) returns (uint256) { - return RewardExtLib.claimSequencerRewards(_coinbase); + return RewardExtLib.claimSequencerRewards(_coinbase, FEE_ASSET); } /** @@ -370,7 +395,7 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali override(IRollupCore) returns (uint256) { - return RewardExtLib.claimProverRewards(_coinbase, _epochs); + return RewardExtLib.claimProverRewards(_coinbase, _epochs, FEE_ASSET); } /** @@ -470,7 +495,7 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali * @param _args Contains the epoch range, public inputs, fees, attestations, and the ZK proof */ function submitEpochRootProof(SubmitEpochRootProofArgs calldata _args) external override(IRollupCore) { - EpochProofExtLib.submitEpochRootProof(_args); + EpochProofExtLib.submitEpochRootProof(_args, _getRollupConfig()); } /** @@ -493,7 +518,7 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali bytes calldata _blobInput ) external override(IRollupCore) { RollupOperationsExtLib.propose( - _args, _attestations, _signers, _attestationsAndSignersSignature, _blobInput, checkBlob + _args, _attestations, _signers, _attestationsAndSignersSignature, _blobInput, checkBlob, INBOX ); } @@ -610,23 +635,16 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali RewardExtLib.initializeConfig(rewardConfig); } - function _initializeStore( - IERC20 _feeAsset, - IVerifier _epochProofVerifier, - GenesisState memory _genesisState, - RollupConfigInput memory _config - ) internal { - STFLib.initialize(_genesisState); - RollupStore storage rollupStore = STFLib.getStorage(); - - rollupStore.config.feeAsset = _feeAsset; - rollupStore.config.epochProofVerifier = _epochProofVerifier; - rollupStore.config.version = _config.version; - - IInbox inbox = IInbox(address(new Inbox(address(this), _feeAsset, _config.version, INBOX_BUCKET_RING_SIZE))); - - rollupStore.config.inbox = inbox; - rollupStore.config.outbox = IOutbox(address(new Outbox(address(this), _config.version))); - rollupStore.config.feeAssetPortal = IFeeJuicePortal(inbox.getFeeAssetPortal()); + function _getRollupConfig() internal view returns (RollupConfig memory) { + return RollupConfig({ + vkTreeRoot: VK_TREE_ROOT, + protocolContractsHash: PROTOCOL_CONTRACTS_HASH, + version: VERSION, + feeAsset: FEE_ASSET, + feeAssetPortal: FEE_ASSET_PORTAL, + epochProofVerifier: EPOCH_PROOF_VERIFIER, + inbox: INBOX, + outbox: OUTBOX + }); } } diff --git a/l1-contracts/src/core/interfaces/IRollup.sol b/l1-contracts/src/core/interfaces/IRollup.sol index a15827dca52e..19a7293241f9 100644 --- a/l1-contracts/src/core/interfaces/IRollup.sol +++ b/l1-contracts/src/core/interfaces/IRollup.sol @@ -86,6 +86,12 @@ struct RollupConfigInput { uint256 ethereumSlotDuration; } +/** + * @notice The rollup's deployment-time configuration. + * @dev Every field is fixed at construction, so the values live in the Rollup's immutables rather than + * in storage. This struct is assembled in memory and threaded down into the libraries, which cannot + * read the contract's immutables themselves. + */ struct RollupConfig { bytes32 vkTreeRoot; bytes32 protocolContractsHash; @@ -102,7 +108,6 @@ struct RollupStore { mapping(uint256 checkpointNumber => bytes32 archive) archives; // The following represents a circular buffer. Key is `checkpointNumber % size`. mapping(uint256 circularIndex => CompressedTempCheckpointLog temp) tempCheckpointLogs; - RollupConfig config; } interface IRollupCore { diff --git a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol index c3d4bef8df51..cbecaf0e5b37 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol @@ -77,6 +77,16 @@ interface IInbox { returns (bytes32, uint256); // docs:end:send_l1_to_l2_message + /** + * @notice Records that the proven chain has consumed all messages up to and including bucket `_bucketSeq`, + * unlocking eviction of buckets at or below it when the ring wraps + * @dev Only callable by the rollup. Monotonic: a value at or below the current record is a no-op. Reverts with + * `Inbox__Unauthorized` if the caller is not the rollup, and with `Inbox__BucketOutOfWindow` if `_bucketSeq` is + * ahead of the current bucket. + * @param _bucketSeq - The sequence number of the newest bucket the proven chain has consumed + */ + function markProvenConsumed(uint64 _bucketSeq) external; + function getFeeAssetPortal() external view returns (address); function getState() external view returns (InboxState memory); @@ -96,4 +106,20 @@ interface IInbox { * @return The bucket */ function getBucket(uint256 _seq) external view returns (InboxBucket memory); + + /** + * @notice Returns the sequence number of the newest bucket consumed by the proven chain + * @return The proven-consumed bucket sequence number + */ + function getProvenConsumedBucketSeq() external view returns (uint64); + + /** + * @notice Returns the number of buckets that can still be opened before `sendL2Message` reverts to protect an + * unconsumed bucket from being overwritten + * @dev Counts bucket openings, not messages: at zero, messages can still be absorbed into the current bucket + * until it fills or its L1 block passes. Equals the ring size at genesis and recovers as proofs advance the + * proven-consumed record. + * @return The number of buckets that can still be opened + */ + function getRingHeadroom() external view returns (uint256); } diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 2b620a344329..4978740f477a 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -27,6 +27,8 @@ library Errors { error Inbox__ContentTooLarge(bytes32 content); // 0x47452014 error Inbox__SecretHashTooLarge(bytes32 secretHash); // 0xecde7e2c error Inbox__BucketOutOfWindow(uint256 seq, uint256 current); // 0xfee255b7 + error Inbox__Unauthorized(); // 0xe5336a6b + error Inbox__WouldOverwriteUnconsumedBucket(uint64 evictedBucketSeq); // 0x2eb49c6d // Outbox error Outbox__Unauthorized(); // 0x2c9490c2 diff --git a/l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol b/l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol index 65038cbc842d..06d70d2de8ca 100644 --- a/l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol +++ b/l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol @@ -34,10 +34,13 @@ struct TempCheckpointLog { bytes32 attestationsHash; bytes32 payloadDigest; Slot slotNumber; - // Streaming Inbox consumption count: the cumulative Inbox message count consumed as of this checkpoint (the - // child's parent-total origin). Declared next to the slot number so the two share one storage slot (4 + 8 of 32 - // bytes): propose writes and reads that slot for the slot-progression check anyway. + // Streaming Inbox consumption counts. `inboxMsgTotal` is the cumulative Inbox message count + // consumed as of this checkpoint (the child's parent-total origin), read back by the child's propose; + // `inboxConsumedBucket` is the bucket sequence number the header's rolling hash corresponds to, read back on a + // proven-tip advance to release the Inbox ring up to it. Declared next to the slot number so the three share one + // storage slot (4 + 8 + 8 of 32 bytes): propose writes and reads that slot for the slot-progression check anyway. uint64 inboxMsgTotal; + uint64 inboxConsumedBucket; FeeHeader feeHeader; // The consensus Inbox rolling hash the checkpoint header committed to, in a slot of its own: epoch proofs anchor // both ends of their consumed chain segment against it. @@ -52,6 +55,7 @@ struct CompressedTempCheckpointLog { bytes32 payloadDigest; CompressedSlot slotNumber; uint64 inboxMsgTotal; + uint64 inboxConsumedBucket; CompressedFeeHeader feeHeader; bytes32 inboxRollingHash; } @@ -71,6 +75,7 @@ library CompressedTempCheckpointLogLib { payloadDigest: _checkpoint.payloadDigest, slotNumber: _checkpoint.slotNumber.compress(), inboxMsgTotal: _checkpoint.inboxMsgTotal, + inboxConsumedBucket: _checkpoint.inboxConsumedBucket, feeHeader: _checkpoint.feeHeader.compress(), inboxRollingHash: _checkpoint.inboxRollingHash }); @@ -89,6 +94,7 @@ library CompressedTempCheckpointLogLib { payloadDigest: _compressedCheckpoint.payloadDigest, slotNumber: _compressedCheckpoint.slotNumber.decompress(), inboxMsgTotal: _compressedCheckpoint.inboxMsgTotal, + inboxConsumedBucket: _compressedCheckpoint.inboxConsumedBucket, feeHeader: _compressedCheckpoint.feeHeader.decompress(), inboxRollingHash: _compressedCheckpoint.inboxRollingHash }); diff --git a/l1-contracts/src/core/libraries/rollup/EpochProofExtLib.sol b/l1-contracts/src/core/libraries/rollup/EpochProofExtLib.sol index a71251c19a7b..bda2a0bf61d8 100644 --- a/l1-contracts/src/core/libraries/rollup/EpochProofExtLib.sol +++ b/l1-contracts/src/core/libraries/rollup/EpochProofExtLib.sol @@ -2,7 +2,7 @@ // Copyright 2024 Aztec Labs. pragma solidity >=0.8.27; -import {SubmitEpochRootProofArgs, PublicInputArgs} from "@aztec/core/interfaces/IRollup.sol"; +import {SubmitEpochRootProofArgs, PublicInputArgs, RollupConfig} from "@aztec/core/interfaces/IRollup.sol"; import {ProposedHeader} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol"; import {EpochProofLib} from "./EpochProofLib.sol"; @@ -20,8 +20,8 @@ import {EpochProofLib} from "./EpochProofLib.sol"; * - Epoch proof public input computation */ library EpochProofExtLib { - function submitEpochRootProof(SubmitEpochRootProofArgs calldata _args) external { - EpochProofLib.submitEpochRootProof(_args); + function submitEpochRootProof(SubmitEpochRootProofArgs calldata _args, RollupConfig memory _config) external { + EpochProofLib.submitEpochRootProof(_args, _config); } function getEpochProofPublicInputs( @@ -29,8 +29,9 @@ library EpochProofExtLib { uint256 _end, PublicInputArgs calldata _args, ProposedHeader[] calldata _headers, - bytes calldata _blobPublicInputs + bytes calldata _blobPublicInputs, + RollupConfig memory _config ) external view returns (bytes32[] memory) { - return EpochProofLib.getEpochProofPublicInputs(_start, _end, _args, _headers, _blobPublicInputs); + return EpochProofLib.getEpochProofPublicInputs(_start, _end, _args, _headers, _blobPublicInputs, _config); } } diff --git a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol index dbbf21d55042..dc44ccd1fca4 100644 --- a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol +++ b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol @@ -4,7 +4,13 @@ pragma solidity >=0.8.27; import {BlobLib} from "@aztec-blob-lib/BlobLib.sol"; import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; -import {SubmitEpochRootProofArgs, PublicInputArgs, IRollupCore, RollupStore} from "@aztec/core/interfaces/IRollup.sol"; +import { + SubmitEpochRootProofArgs, + PublicInputArgs, + IRollupCore, + RollupStore, + RollupConfig +} from "@aztec/core/interfaces/IRollup.sol"; import {CompressedTempCheckpointLog} from "@aztec/core/libraries/compressed-data/CheckpointLog.sol"; import {CompressedFeeHeader, FeeHeaderLib} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol"; import {ChainTipsLib, CompressedChainTips} from "@aztec/core/libraries/compressed-data/Tips.sol"; @@ -101,8 +107,9 @@ library EpochProofLib { * - attestations: Committee attestations for the last checkpoint in the epoch * - blobInputs: Batched blob data for EIP-4844 point evaluation precompile * - proof: The validity proof bytes for the root rollup circuit + * @param _config The rollup's deployment-time configuration */ - function submitEpochRootProof(SubmitEpochRootProofArgs calldata _args) internal { + function submitEpochRootProof(SubmitEpochRootProofArgs calldata _args, RollupConfig memory _config) internal { if (STFLib.canPruneAtTime(Timestamp.wrap(block.timestamp))) { STFLib.prune(); } @@ -118,7 +125,7 @@ library EpochProofLib { // ensuring committee agreement on the epoch's validity alongside the cryptographic proof verification below. verifyLastCheckpointAttestationsAndOutHash(_args.end, _args.attestations, _args.args.outHash); - require(verifyEpochRootProof(_args), Errors.Rollup__InvalidProof()); + require(verifyEpochRootProof(_args, _config), Errors.Rollup__InvalidProof()); RollupStore storage rollupStore = STFLib.getStorage(); @@ -126,6 +133,15 @@ library EpochProofLib { if (_args.end > rollupStore.tips.getProven()) { rollupStore.tips = rollupStore.tips.updateProven(_args.end); + // Unlock Inbox ring eviction up to the bucket the newly proven tip consumed; its temp-log record was + // validated against the Inbox at propose time. Equal start and end rolling hashes mean the epoch consumed + // no messages, so the bucket is the one already recorded and the cross-contract write is skipped: both + // values are trusted here (the start was checked against storage, the end is bound by the proof), and a + // rolling hash identifies exactly one bucket since every bucket absorbs at least one message. + if (_args.args.previousInboxRollingHash != _args.args.endInboxRollingHash) { + _config.inbox.markProvenConsumed(STFLib.getInboxConsumedBucket(_args.end)); + } + // Handle L2->L1 message processing. // The circuit outputs an empty out hash tree root if the epoch contains no messages. // Since the out hash tree is append-only, with the first checkpoint at index 0, the second at index 1, and so on, @@ -136,11 +152,11 @@ library EpochProofLib { // the number of checkpoints proven in this epoch so off-chain consumers can map a tx's // position-within-epoch directly to the smallest proof that covers it. uint256 numCheckpointsInEpoch = _args.end - _args.start + 1; - rollupStore.config.outbox.insert(endEpoch, numCheckpointsInEpoch, _args.args.outHash); + _config.outbox.insert(endEpoch, numCheckpointsInEpoch, _args.args.outHash); } } - RewardLib.handleRewardsAndFees(_args, endEpoch); + RewardLib.handleRewardsAndFees(_args, endEpoch, _config); emit IRollupCore.L2ProofVerified(_args.end, _args.args.proverId); } @@ -162,16 +178,18 @@ library EpochProofLib { * @param _args - Array of public inputs to the proof (previousArchive, endArchive, endTimestamp, outHash, proverId) * @param _headers - The proposed checkpoint headers supplying the fee recipient and value for each checkpoint * @param _blobPublicInputs- The blob public inputs for the proof + * @param _config - The rollup's deployment-time configuration */ function getEpochProofPublicInputs( uint256 _start, uint256 _end, PublicInputArgs calldata _args, ProposedHeader[] calldata _headers, - bytes calldata _blobPublicInputs + bytes calldata _blobPublicInputs, + RollupConfig memory _config ) internal view returns (bytes32[] memory) { verifyHeaders(_start, _end, _headers); - return computeEpochProofPublicInputs(_start, _end, _args, _headers, _blobPublicInputs); + return computeEpochProofPublicInputs(_start, _end, _args, _headers, _blobPublicInputs, _config); } /** @@ -240,13 +258,15 @@ library EpochProofLib { * @param _args - Array of public inputs to the proof (previousArchive, endArchive, endTimestamp, outHash, proverId) * @param _headers - The proposed checkpoint headers supplying the fee recipient and value for each checkpoint * @param _blobPublicInputs- The blob public inputs for the proof + * @param _config - The rollup's deployment-time configuration */ function computeEpochProofPublicInputs( uint256 _start, uint256 _end, PublicInputArgs calldata _args, ProposedHeader[] calldata _headers, - bytes calldata _blobPublicInputs + bytes calldata _blobPublicInputs, + RollupConfig memory _config ) private view returns (bytes32[] memory) { RollupStore storage rollupStore = STFLib.getStorage(); @@ -339,15 +359,15 @@ library EpochProofLib { publicInputs[offset] = bytes32(block.chainid); offset += 1; - publicInputs[offset] = bytes32(uint256(rollupStore.config.version)); + publicInputs[offset] = bytes32(uint256(_config.version)); offset += 1; // vk_tree_root - publicInputs[offset] = rollupStore.config.vkTreeRoot; + publicInputs[offset] = _config.vkTreeRoot; offset += 1; // protocol_contracts_hash - publicInputs[offset] = rollupStore.config.protocolContractsHash; + publicInputs[offset] = _config.protocolContractsHash; offset += 1; // prover_id: id of current epoch's prover @@ -486,17 +506,20 @@ library EpochProofLib { * - Rollup__InvalidArchive: End archive root mismatch in public inputs * * @param _args The epoch proof submission arguments containing proof data and public inputs + * @param _config The rollup's deployment-time configuration * @return True if both blob proof and validity proof verification succeed */ - function verifyEpochRootProof(SubmitEpochRootProofArgs calldata _args) private view returns (bool) { - RollupStore storage rollupStore = STFLib.getStorage(); - + function verifyEpochRootProof(SubmitEpochRootProofArgs calldata _args, RollupConfig memory _config) + private + view + returns (bool) + { BlobLib.validateBatchedBlob(_args.blobInputs); bytes32[] memory publicInputs = - computeEpochProofPublicInputs(_args.start, _args.end, _args.args, _args.headers, _args.blobInputs); + computeEpochProofPublicInputs(_args.start, _args.end, _args.args, _args.headers, _args.blobInputs, _config); - require(rollupStore.config.epochProofVerifier.verify(_args.proof, publicInputs), Errors.Rollup__InvalidProof()); + require(_config.epochProofVerifier.verify(_args.proof, publicInputs), Errors.Rollup__InvalidProof()); return true; } diff --git a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol index 6df3377d8259..1c5b02b41a06 100644 --- a/l1-contracts/src/core/libraries/rollup/ProposeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ProposeLib.sol @@ -39,6 +39,17 @@ struct ProposePayload { bytes32 headerHash; } +/** + * @notice The caller-supplied context for a proposal, bundled to keep `propose` off the stack limit. + * @param inbox - The Inbox the header's streaming message consumption is validated against + * @param checkBlob - Whether to run blob related checks. Hardcoded to true in RollupCore, exists only to be + * overridden in tests + */ +struct ProposeConfig { + IInbox inbox; + bool checkBlob; +} + struct InterimProposeValues { ProposedHeader header; bytes32[] blobHashes; @@ -168,8 +179,7 @@ library ProposeLib { * @param _blobsInput - The bytes to verify our input blob commitments match real blobs: * - input[:1] - num blobs in checkpoint * - input[1:] - blob commitments (48 bytes * num blobs in checkpoint) - * @param _checkBlob - Whether to skip blob related checks. Hardcoded to true in RollupCore, exists only to be - * overridden in tests + * @param _config - The Inbox to validate message consumption against, and the blob check flag */ function propose( ProposeArgs calldata _args, @@ -177,7 +187,7 @@ library ProposeLib { address[] memory _signers, Signature calldata _attestationsAndSignersSignature, bytes calldata _blobsInput, - bool _checkBlob + ProposeConfig memory _config ) internal { // Prune unproven checkpoints if the proof submission window has passed if (STFLib.canPruneAtTime(Timestamp.wrap(block.timestamp))) { @@ -192,7 +202,7 @@ library ProposeLib { // Validate blob commitments against actual blob data and extract hashes // TODO(#13430): The below blobsHashesCommitment known as blobsHash elsewhere in the code. The name is confusingly // similar to blobCommitmentsHash, see comment in BlobLib.sol -> validateBlobs(). - (v.blobHashes, v.blobsHashesCommitment, v.blobCommitments) = BlobLib.validateBlobs(_blobsInput, _checkBlob); + (v.blobHashes, v.blobsHashesCommitment, v.blobCommitments) = BlobLib.validateBlobs(_blobsInput, _config.checkBlob); v.header = _args.header; @@ -269,7 +279,7 @@ library ProposeLib { // child validates against it and, since temp-log records rewind with the pending chain on a prune, the record // stays prune-consistent. v.consumedInboxMsgTotal = validateInboxConsumption( - rollupStore.config.inbox, + _config.inbox, v.header.inboxRollingHash, _args.bucketHint, v.header.slotNumber, @@ -311,7 +321,8 @@ library ProposeLib { slotNumber: v.header.slotNumber, feeHeader: v.feeHeader, inboxRollingHash: v.header.inboxRollingHash, - inboxMsgTotal: v.consumedInboxMsgTotal.toUint64() + inboxMsgTotal: v.consumedInboxMsgTotal.toUint64(), + inboxConsumedBucket: _args.bucketHint.toUint64() }) ); diff --git a/l1-contracts/src/core/libraries/rollup/RewardExtLib.sol b/l1-contracts/src/core/libraries/rollup/RewardExtLib.sol index 8f62815cde84..2935dc485636 100644 --- a/l1-contracts/src/core/libraries/rollup/RewardExtLib.sol +++ b/l1-contracts/src/core/libraries/rollup/RewardExtLib.sol @@ -20,6 +20,7 @@ import { IValidatorSelection } from "@aztec/core/reward-boost/RewardBooster.sol"; import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; library RewardExtLib { function initializeConfig(RewardConfig memory _config) external { @@ -30,12 +31,12 @@ library RewardExtLib { RewardLib.updateConfig(_config); } - function claimSequencerRewards(address _sequencer) external returns (uint256) { - return RewardLib.claimSequencerRewards(_sequencer); + function claimSequencerRewards(address _sequencer, IERC20 _feeAsset) external returns (uint256) { + return RewardLib.claimSequencerRewards(_sequencer, _feeAsset); } - function claimProverRewards(address _prover, Epoch[] memory _epochs) external returns (uint256) { - return RewardLib.claimProverRewards(_prover, _epochs); + function claimProverRewards(address _prover, Epoch[] memory _epochs, IERC20 _feeAsset) external returns (uint256) { + return RewardLib.claimProverRewards(_prover, _epochs, _feeAsset); } function deployRewardBooster(RewardBoostConfig memory _config) external returns (IBoosterCore) { diff --git a/l1-contracts/src/core/libraries/rollup/RewardLib.sol b/l1-contracts/src/core/libraries/rollup/RewardLib.sol index 9858551510aa..648a3e8111e5 100644 --- a/l1-contracts/src/core/libraries/rollup/RewardLib.sol +++ b/l1-contracts/src/core/libraries/rollup/RewardLib.sol @@ -2,7 +2,7 @@ // Copyright 2024 Aztec Labs. pragma solidity >=0.8.27; -import {RollupStore, SubmitEpochRootProofArgs} from "@aztec/core/interfaces/IRollup.sol"; +import {RollupConfig, SubmitEpochRootProofArgs} from "@aztec/core/interfaces/IRollup.sol"; import {CompressedFeeHeader, FeeHeaderLib} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {STFLib} from "@aztec/core/libraries/rollup/STFLib.sol"; @@ -105,22 +105,20 @@ library RewardLib { rewardStorage.config.checkpointReward = _config.checkpointReward; } - function claimSequencerRewards(address _sequencer) internal returns (uint256) { + function claimSequencerRewards(address _sequencer, IERC20 _feeAsset) internal returns (uint256) { RewardStorage storage rewardStorage = getStorage(); - RollupStore storage rollupStore = STFLib.getStorage(); uint256 amount = rewardStorage.sequencerRewards[_sequencer]; if (amount > 0) { rewardStorage.sequencerRewards[_sequencer] = 0; - rollupStore.config.feeAsset.safeTransfer(_sequencer, amount); + _feeAsset.safeTransfer(_sequencer, amount); } return amount; } - function claimProverRewards(address _prover, Epoch[] memory _epochs) internal returns (uint256) { + function claimProverRewards(address _prover, Epoch[] memory _epochs, IERC20 _feeAsset) internal returns (uint256) { Epoch currentEpoch = Timestamp.wrap(block.timestamp).epochFromTimestamp(); - RollupStore storage rollupStore = STFLib.getStorage(); RewardStorage storage rewardStorage = getStorage(); @@ -145,14 +143,15 @@ library RewardLib { } if (accumulatedRewards > 0) { - rollupStore.config.feeAsset.safeTransfer(_prover, accumulatedRewards); + _feeAsset.safeTransfer(_prover, accumulatedRewards); } return accumulatedRewards; } - function handleRewardsAndFees(SubmitEpochRootProofArgs calldata _args, Epoch _endEpoch) internal { - RollupStore storage rollupStore = STFLib.getStorage(); + function handleRewardsAndFees(SubmitEpochRootProofArgs calldata _args, Epoch _endEpoch, RollupConfig memory _config) + internal + { RewardStorage storage rewardStorage = getStorage(); uint256 length = _args.end - _args.start + 1; @@ -241,11 +240,11 @@ library RewardLib { $er.longestProvenLength = length.toUint128(); if (t.feesToClaim > 0) { - rollupStore.config.feeAssetPortal.distributeFees(address(this), t.feesToClaim); + _config.feeAssetPortal.distributeFees(address(this), t.feesToClaim); } if (t.totalBurn > 0) { - rollupStore.config.feeAsset.safeTransfer(BURN_ADDRESS, t.totalBurn); + _config.feeAsset.safeTransfer(BURN_ADDRESS, t.totalBurn); } } } diff --git a/l1-contracts/src/core/libraries/rollup/RollupOperationsExtLib.sol b/l1-contracts/src/core/libraries/rollup/RollupOperationsExtLib.sol index 8d3a5a5e93b2..925f4153dad5 100644 --- a/l1-contracts/src/core/libraries/rollup/RollupOperationsExtLib.sol +++ b/l1-contracts/src/core/libraries/rollup/RollupOperationsExtLib.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.27; import {Errors} from "@aztec/core/libraries/Errors.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"; import {BlobLib} from "@aztec-blob-lib/BlobLib.sol"; @@ -11,10 +12,14 @@ import {AttestationLib} from "@aztec/core/libraries/rollup/AttestationLib.sol"; import { ProposeLib, ProposeArgs, + ProposeConfig, CommitteeAttestations, ValidateHeaderArgs, ValidatorSelectionLib } from "./ProposeLib.sol"; +import {CheckpointHeaderValidationFlags} from "@aztec/core/interfaces/IRollup.sol"; +import {FeeLib} from "@aztec/core/libraries/rollup/FeeLib.sol"; +import {ProposedHeader} from "./ProposedHeaderLib.sol"; import {Signature} from "@aztec/shared/libraries/SignatureLib.sol"; /** @@ -36,22 +41,39 @@ library RollupOperationsExtLib { using TimeLib for Slot; using AttestationLib for CommitteeAttestations; + /** + * @dev Assembles `ValidateHeaderArgs` here rather than in the Rollup: building that struct + * (which embeds a full `ProposedHeader`) in the Rollup's own code costs several hundred + * bytes of runtime bytecode it cannot spare. + */ function validateHeaderWithAttestations( - ValidateHeaderArgs calldata _args, + ProposedHeader calldata _header, CommitteeAttestations calldata _attestations, address[] calldata _signers, - Signature calldata _attestationsAndSignersSignature + Signature calldata _attestationsAndSignersSignature, + bytes32 _digest, + bytes32 _blobsHash, + CheckpointHeaderValidationFlags calldata _flags ) external { - ProposeLib.validateHeader(_args); + 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 = _args.header.slotNumber; + Slot slot = _header.slotNumber; Epoch epoch = slot.epochFromSlot(); - ValidatorSelectionLib.verifyAttestations(epoch, _attestations, _args.digest); + ValidatorSelectionLib.verifyAttestations(epoch, _attestations, _digest); ValidatorSelectionLib.verifyProposer( - slot, epoch, _attestations, _signers, _args.digest, _attestationsAndSignersSignature, false + slot, epoch, _attestations, _signers, _digest, _attestationsAndSignersSignature, false ); } @@ -61,9 +83,17 @@ library RollupOperationsExtLib { address[] calldata _signers, Signature calldata _attestationsAndSignersSignature, bytes calldata _blobInput, - bool _checkBlob + bool _checkBlob, + IInbox _inbox ) external { - ProposeLib.propose(_args, _attestations, _signers, _attestationsAndSignersSignature, _blobInput, _checkBlob); + ProposeLib.propose( + _args, + _attestations, + _signers, + _attestationsAndSignersSignature, + _blobInput, + ProposeConfig({inbox: _inbox, checkBlob: _checkBlob}) + ); } function prune() external { diff --git a/l1-contracts/src/core/libraries/rollup/STFLib.sol b/l1-contracts/src/core/libraries/rollup/STFLib.sol index 44f23b2ae05d..5552a041500a 100644 --- a/l1-contracts/src/core/libraries/rollup/STFLib.sol +++ b/l1-contracts/src/core/libraries/rollup/STFLib.sol @@ -93,21 +93,15 @@ library STFLib { bytes32 private constant STF_STORAGE_POSITION = keccak256("aztec.stf.storage"); /** - * @notice Initializes the rollup state with genesis configuration - * @dev Sets up the initial state of the rollup including verification keys and the genesis archive root. - * This function should only be called once during rollup deployment. + * @notice Writes the genesis archive root at checkpoint 0 + * @dev Should only be called once during rollup deployment. The remaining genesis fields + * (vkTreeRoot, protocolContractsHash) are held in the Rollup's immutables. * - * @param _genesisState The initial state configuration containing: - * - vkTreeRoot: Root of the verification key tree for circuit verification - * - protocolContractsHash: Root containing protocol contract addresses and configurations - * - genesisArchiveRoot: Initial archive root representing the genesis state + * @param _genesisState The initial state configuration; only `genesisArchiveRoot` is read here */ function initialize(GenesisState memory _genesisState) internal { RollupStore storage rollupStore = STFLib.getStorage(); - rollupStore.config.vkTreeRoot = _genesisState.vkTreeRoot; - rollupStore.config.protocolContractsHash = _genesisState.protocolContractsHash; - // The genesis archive root is decoded as an Fr off chain and propagates into the first header's lastArchiveRoot, // so it must be a valid field element. FieldLib.requireValidFieldElement(_genesisState.genesisArchiveRoot); @@ -136,7 +130,8 @@ library STFLib { // Genesis Inbox consumption base case, matching the Inbox's genesis bucket-0 sentinel {0, 0, 0}, so // checkpoint 1 validates its consumption against it. inboxRollingHash: bytes32(0), - inboxMsgTotal: 0 + inboxMsgTotal: 0, + inboxConsumedBucket: 0 }).compress(); } @@ -329,6 +324,17 @@ library STFLib { return getStorageTempCheckpointLog(_checkpointNumber).inboxRollingHash; } + /** + * @notice Retrieves the sequence number of the newest Inbox bucket a checkpoint consumed + * @dev Gas-efficient accessor reading only the streaming-inbox bucket sequence. Reverts if the checkpoint is + * stale. + * @param _checkpointNumber The checkpoint number to get the consumed bucket for + * @return The sequence number of the newest Inbox bucket consumed as of the checkpoint + */ + function getInboxConsumedBucket(uint256 _checkpointNumber) internal view returns (uint64) { + return getStorageTempCheckpointLog(_checkpointNumber).inboxConsumedBucket; + } + /** * @notice Gets the effective pending checkpoint number based on pruning eligibility * @dev Returns either the pending checkpoint number or proven checkpoint number depending on diff --git a/l1-contracts/src/core/messagebridge/Inbox.sol b/l1-contracts/src/core/messagebridge/Inbox.sol index 4b85a06ddde2..21cf892c42b5 100644 --- a/l1-contracts/src/core/messagebridge/Inbox.sol +++ b/l1-contracts/src/core/messagebridge/Inbox.sol @@ -12,10 +12,13 @@ import {FeeJuicePortal} from "@aztec/core/messagebridge/FeeJuicePortal.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; import {SafeCast} from "@oz/utils/math/SafeCast.sol"; -// Number of buckets in the rolling-hash ring. Sized far beyond normal consumption lag (the censorship -// cutoff bounds it to roughly one Aztec slot); outages longer than the ring are handled by overwrite -// protection on unconsumed buckets, not by growing the ring. -uint256 constant INBOX_BUCKET_RING_SIZE = 1024; +// Number of buckets in the rolling-hash ring. Eviction is gated on proven consumption, so the ring has to +// cover the worst-case proving lag (~2 epochs of one bucket per L1 block, which is what MIN_BUCKET_RING_SIZE +// below is derived from) with enough margin that adversarial bucket creation cannot cheaply exhaust it: a +// forced rollover costs ~2.2M gas, so burning this much headroom takes ~250 continuously-owned L1 blocks +// (~50 min, ~9B gas). Ring size bounds retention only — 2 storage slots per live bucket, no per-send gas — and +// exhausting it halts sends rather than overwriting unconsumed buckets. +uint256 constant INBOX_BUCKET_RING_SIZE = 4096; // Constructor floor for the bucket ring. The ring must cover the longest stall the chain recovers from on // its own: the prune-and-repropose window of 64 checkpoints (2 epochs = 384 L1 blocks) at the natural cadence @@ -43,6 +46,12 @@ contract Inbox is IInbox { uint64 internal currentBucketSeq; + // Sequence number of the newest bucket consumed by the proven chain, pushed by the Rollup on every proven-tip + // advance. Anchoring eviction to proven consumption is prune-immune (the proven tip never rewinds) and + // fail-closed (the cache can only lag the truth). Shares a slot with currentBucketSeq so the overwrite check + // reads it warm. + uint64 internal provenConsumedBucketSeq; + constructor(address _rollup, IERC20 _feeAsset, uint256 _version, uint256 _bucketRingSize) { ROLLUP = _rollup; VERSION = _version; @@ -108,6 +117,22 @@ contract Inbox is IInbox { return (leaf, index); } + /** + * @notice Records that the proven chain has consumed all messages up to and including bucket `_bucketSeq` + * + * @dev Callable only by the ROLLUP. Monotonic: a value at or below the current record is a no-op. Reverts if + * `_bucketSeq` is ahead of the current bucket. + * + * @param _bucketSeq - The sequence number of the newest bucket the proven chain has consumed + */ + function markProvenConsumed(uint64 _bucketSeq) external override(IInbox) { + require(msg.sender == ROLLUP, Errors.Inbox__Unauthorized()); + require(_bucketSeq <= currentBucketSeq, Errors.Inbox__BucketOutOfWindow(_bucketSeq, currentBucketSeq)); + if (_bucketSeq > provenConsumedBucketSeq) { + provenConsumedBucketSeq = _bucketSeq; + } + } + function getFeeAssetPortal() external view override(IInbox) returns (address) { return FEE_ASSET_PORTAL; } @@ -133,14 +158,34 @@ contract Inbox is IInbox { return buckets[_seq % BUCKET_RING_SIZE]; } + function getProvenConsumedBucketSeq() external view override(IInbox) returns (uint64) { + return provenConsumedBucketSeq; + } + + /** + * @notice Returns the number of buckets that can still be opened before sends revert to protect an unconsumed + * bucket from being overwritten + * + * @dev Counts bucket openings, not messages: at zero, messages can still be absorbed into the current bucket + * until it fills or its L1 block passes. Neither subtraction can underflow: `markProvenConsumed` keeps the + * record at or below `currentBucketSeq`, and opening bucket n requires `provenConsumedBucketSeq >= n - + * BUCKET_RING_SIZE`, so the unconsumed span never exceeds the ring. + * + * @return The number of buckets that can still be opened + */ + function getRingHeadroom() external view override(IInbox) returns (uint256) { + return BUCKET_RING_SIZE - (currentBucketSeq - provenConsumedBucketSeq); + } + /** * @notice Absorbs a message leaf into the consensus rolling hash and snapshots it into the bucket ring * * @dev A bucket only holds messages from a single L1 block, up to MAX_MSGS_PER_BUCKET; the first message * of a new L1 block — or the message after a full bucket, spilling over within the same block — opens the * next bucket, inheriting the rolling hash and cumulative count. Bucket 0 is the pristine genesis base - * case and never absorbs. Opening a bucket overwrites the ring entry from BUCKET_RING_SIZE buckets ago; - * protection against overwriting unconsumed buckets is not enforced yet. + * case and never absorbs. Opening a bucket overwrites the ring entry from BUCKET_RING_SIZE buckets ago; the + * open reverts unless the proven chain has consumed that entry, so in-flight messages are never destroyed — + * sends halt instead until proving catches up. * * @param _leaf - The message leaf to absorb * @@ -157,6 +202,12 @@ contract Inbox is IInbox { // computed over timestamps, so co-timestamped blocks are indistinguishable to it. if (bucketSeq == 0 || bucket.timestamp < block.timestamp || bucket.msgCount == MAX_MSGS_PER_BUCKET) { bucketSeq += 1; + if (bucketSeq >= BUCKET_RING_SIZE) { + uint64 evictedBucketSeq = SafeCast.toUint64(bucketSeq - BUCKET_RING_SIZE); + require( + provenConsumedBucketSeq >= evictedBucketSeq, Errors.Inbox__WouldOverwriteUnconsumedBucket(evictedBucketSeq) + ); + } currentBucketSeq = bucketSeq; bucket = InboxBucket({ rollingHash: bucket.rollingHash, diff --git a/l1-contracts/test/InboxBuckets.t.sol b/l1-contracts/test/InboxBuckets.t.sol index d1e2da6d16fd..bad3cd4e59c5 100644 --- a/l1-contracts/test/InboxBuckets.t.sol +++ b/l1-contracts/test/InboxBuckets.t.sol @@ -227,6 +227,8 @@ contract InboxBucketsTest is Test { vm.roll(block.number + 1); vm.warp(block.timestamp + 12); _send(ringInbox, i); + // Evicting a ring slot requires the proven chain to have consumed it, so keep consumption trailing the sends. + ringInbox.markProvenConsumed(uint64(i - 1)); } uint256 current = ringInbox.getCurrentBucketSeq(); diff --git a/l1-contracts/test/InboxOverwriteProtection.t.sol b/l1-contracts/test/InboxOverwriteProtection.t.sol new file mode 100644 index 000000000000..1e262bb308fd --- /dev/null +++ b/l1-contracts/test/InboxOverwriteProtection.t.sol @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {TestERC20} from "src/mock/TestERC20.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; +import {InboxHarness} from "./harnesses/InboxHarness.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Hash} from "@aztec/core/libraries/crypto/Hash.sol"; +import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; + +// Sends a batch of messages in one L1 transaction and lets a revert from any of them bubble out, taking the +// whole batch with it. Models a portal or bridge that fans several messages out per call. +contract RevertingBatchSender { + function sendMany(IInbox _inbox, uint256 _version, uint256 _count) external { + for (uint256 i = 0; i < _count; i++) { + _inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x5000 + i)), version: _version}), + bytes32(uint256(0x6000 + i)), + bytes32(uint256(0x7000 + i)) + ); + } + } +} + +// Same batch, but each send is wrapped in try/catch, so a revert on one message does not undo the others. +contract CatchingBatchSender { + function sendMany(IInbox _inbox, uint256 _version, uint256 _count) external returns (uint256 succeeded) { + for (uint256 i = 0; i < _count; i++) { + try _inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x5000 + i)), version: _version}), + bytes32(uint256(0x6000 + i)), + bytes32(uint256(0x7000 + i)) + ) { + succeeded += 1; + } catch {} + } + } +} + +/** + * Overwrite protection on the bucket ring: opening a bucket reuses the ring slot of the bucket + * BUCKET_RING_SIZE positions back, and the open is refused unless the proven chain has consumed that slot. + * The Inbox in these tests is owned by the test contract, which stands in for the rollup and pushes the + * proven-consumed record directly. + */ +contract InboxOverwriteProtectionTest is Test { + uint256 internal constant RING_SIZE = MIN_BUCKET_RING_SIZE; + + InboxHarness internal inbox; + uint256 internal version = 0; + + function setUp() public { + inbox = _deployInbox(address(this)); + } + + function _deployInbox(address _rollup) internal returns (InboxHarness) { + IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this)); + return new InboxHarness(_rollup, feeAsset, version, RING_SIZE); + } + + function _send(InboxHarness _inbox, uint256 _salt) internal returns (bytes32 leaf, uint256 index) { + (leaf, index) = _inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}), + bytes32(uint256(0x2000 + _salt)), + bytes32(uint256(0x3000 + _salt)) + ); + } + + // Sends without touching the return values: an intercepted revert leaves no returndata to decode, so a send + // fronted by `vm.expectRevert` must not be the one that assigns them. + function _sendExpectingOverwriteRevert(InboxHarness _inbox, uint256 _salt, uint64 _evicted) internal { + DataStructures.L2Actor memory recipient = + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}); + bytes32 content = bytes32(uint256(0x2000 + _salt)); + bytes32 secretHash = bytes32(uint256(0x3000 + _salt)); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__WouldOverwriteUnconsumedBucket.selector, _evicted)); + _inbox.sendL2Message(recipient, content, secretHash); + } + + // Opens `_count` buckets, one per L1 block: a strictly larger block timestamp forces the message into a + // freshly opened bucket. The last bucket is left open in the current L1 block. + function _openBuckets(InboxHarness _inbox, uint256 _count) internal { + uint256 startSeq = _inbox.getCurrentBucketSeq(); + for (uint256 i = 0; i < _count; i++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _send(_inbox, startSeq + i); + } + assertEq(_inbox.getCurrentBucketSeq(), startSeq + _count, "buckets opened"); + } + + // Fills the ring exactly once. Opening bucket RING_SIZE evicts the genesis bucket, which is consumed from the + // start, so it is allowed; the next bucket opening is the first one that can be refused. + function _reachRingWall(InboxHarness _inbox) internal { + _openBuckets(_inbox, RING_SIZE); + assertEq(_inbox.getRingHeadroom(), 0, "ring wall reached"); + } + + function _assertBucketEq(IInbox.InboxBucket memory _actual, IInbox.InboxBucket memory _expected, string memory _err) + internal + pure + { + assertEq(_actual.rollingHash, _expected.rollingHash, _err); + assertEq(_actual.totalMsgCount, _expected.totalMsgCount, _err); + assertEq(_actual.timestamp, _expected.timestamp, _err); + assertEq(_actual.msgCount, _expected.msgCount, _err); + } + + // With nothing proven-consumed, the ring fills to one wrap and then refuses the bucket that would overwrite + // bucket 1: its messages are still in flight, so the send is what has to fail, and it must fail without + // leaving a trace. + function testWrapIntoUnconsumedReverts() public { + _reachRingWall(inbox); + + IInbox.InboxBucket memory head = inbox.getBucket(RING_SIZE); + IInbox.InboxBucket memory oldest = inbox.getBucket(1); + uint64 totalBefore = inbox.getTotalMessagesInserted(); + assertEq(oldest.totalMsgCount, 1, "bucket 1 holds the first message"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 999, 1); + + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE, "current bucket unchanged"); + assertEq(inbox.getTotalMessagesInserted(), totalBefore, "no message inserted"); + _assertBucketEq(inbox.getBucket(RING_SIZE), head, "head bucket untouched"); + _assertBucketEq(inbox.getBucket(1), oldest, "oldest unconsumed bucket untouched"); + assertEq(inbox.getRingHeadroom(), 0, "still no headroom"); + } + + // The check compares the proven-consumed record against the exact bucket being evicted: releasing bucket 1 + // unlocks exactly one more opening, and the one after it stops on bucket 2. + function testExactBoundaryUnlocksOneBucket() public { + _reachRingWall(inbox); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 1000, 1); + + inbox.markProvenConsumed(1); + _send(inbox, 1000); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE + 1, "releasing the evicted bucket allowed the opening"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 1001, 2); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE + 1, "one release unlocks one opening only"); + } + + // A halted send is resumable: once the proven chain releases the bucket, the same message goes in, extends the + // rolling-hash chain from the ring head, and takes the index the failed attempt left unused. + function testResumeAfterProvingPreservesChain() public { + _reachRingWall(inbox); + + bytes32 headHash = inbox.getBucket(RING_SIZE).rollingHash; + uint64 totalBefore = inbox.getTotalMessagesInserted(); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 1234, 1); + + inbox.markProvenConsumed(1); + (bytes32 leaf, uint256 index) = _send(inbox, 1234); + + IInbox.InboxBucket memory opened = inbox.getBucket(RING_SIZE + 1); + assertEq(opened.rollingHash, Hash.accumulateInboxRollingHash(headHash, leaf), "chain continues from the ring head"); + assertEq(index, totalBefore, "the failed send consumed no index"); + assertEq(opened.totalMsgCount, totalBefore + 1, "cumulative total advanced by one"); + } + + // Only proven consumption releases a bucket. L1 blocks going by does not, so a chain that stops proving keeps + // the Inbox halted for as long as it stalls rather than recovering on its own. + function testTimeAloneDoesNotUnlock() public { + _reachRingWall(inbox); + + for (uint256 i = 0; i < 5; i++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(inbox, 2000 + i, 1); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE, "still at the ring wall"); + assertEq(inbox.getRingHeadroom(), 0, "still no headroom"); + } + + inbox.markProvenConsumed(1); + _send(inbox, 2100); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE + 1, "proven consumption is the only unlock"); + } + + // The proven-consumed record decides which ring slots may be overwritten, so only the rollup may move it. + function testMarkProvenConsumedOnlyRollup() public { + InboxHarness rollupOwned = _deployInbox(address(0xbeef)); + _send(rollupOwned, 0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__Unauthorized.selector)); + rollupOwned.markProvenConsumed(1); + assertEq(rollupOwned.getProvenConsumedBucketSeq(), 0, "record unchanged"); + + vm.prank(address(0xbeef)); + rollupOwned.markProvenConsumed(1); + assertEq(rollupOwned.getProvenConsumedBucketSeq(), 1, "the rollup moved the record"); + } + + // The record only moves forward: the rollup pushes it on every proven-tip advance, and a shorter epoch proof + // or a re-submission must not walk it back and re-lock slots the ring may already have reused. + function testMarkProvenConsumedMonotonic() public { + _openBuckets(inbox, 6); + + inbox.markProvenConsumed(5); + assertEq(inbox.getProvenConsumedBucketSeq(), 5, "record set"); + + inbox.markProvenConsumed(3); + assertEq(inbox.getProvenConsumedBucketSeq(), 5, "a lower value is a no-op"); + + inbox.markProvenConsumed(5); + assertEq(inbox.getProvenConsumedBucketSeq(), 5, "an equal value is a no-op"); + + inbox.markProvenConsumed(6); + assertEq(inbox.getProvenConsumedBucketSeq(), 6, "a higher value advances"); + } + + // A record ahead of the newest bucket would release ring slots that hold nothing yet, so it is rejected + // rather than clamped: it can only come from the rollup and the Inbox disagreeing about the window. + function testMarkProvenConsumedAheadOfCurrentReverts() public { + _openBuckets(inbox, 3); + uint64 current = inbox.getCurrentBucketSeq(); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, current + 1, current)); + inbox.markProvenConsumed(current + 1); + assertEq(inbox.getProvenConsumedBucketSeq(), 0, "record unchanged"); + } + + // Headroom counts bucket openings left, not messages: it drops by one per bucket opened, is untouched by a + // message absorbed into the open bucket, and rises by exactly what the proven chain releases. + function testRingHeadroomSemantics() public { + assertEq(inbox.getRingHeadroom(), RING_SIZE, "genesis: the whole ring is available"); + + _send(inbox, 0); + assertEq(inbox.getRingHeadroom(), RING_SIZE - 1, "the first message opened one bucket"); + + _send(inbox, 1); + assertEq(inbox.getRingHeadroom(), RING_SIZE - 1, "absorbing into the open bucket opens nothing"); + + _openBuckets(inbox, 3); + assertEq(inbox.getRingHeadroom(), RING_SIZE - 4, "one opening each"); + + inbox.markProvenConsumed(2); + assertEq(inbox.getRingHeadroom(), RING_SIZE - 2, "released two buckets"); + + inbox.markProvenConsumed(4); + assertEq(inbox.getRingHeadroom(), RING_SIZE, "released the remaining two"); + + // At zero headroom the wall applies to bucket openings only: a bucket still open in the current L1 block and + // below the per-bucket cap keeps absorbing, so messages are not blocked until a rollover is needed. + InboxHarness wallInbox = _deployInbox(address(this)); + _reachRingWall(wallInbox); + + uint64 totalBefore = wallInbox.getTotalMessagesInserted(); + _send(wallInbox, 4242); + assertEq(wallInbox.getTotalMessagesInserted(), totalBefore + 1, "absorbed at zero headroom"); + assertEq(wallInbox.getCurrentBucketSeq(), RING_SIZE, "no bucket opened"); + assertEq(wallInbox.getRingHeadroom(), 0, "headroom still zero"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + _sendExpectingOverwriteRevert(wallInbox, 4243, 1); + } + + // A batching caller that lets a revert bubble loses the whole batch at the ring wall: the head bucket is full, + // so the batch's first send has to roll over into a slot that is not released yet. + function testRevertingBatchIsAtomic() public { + _reachRingWall(inbox); + for (uint256 i = 1; i < MAX_MSGS_PER_BUCKET; i++) { + _send(inbox, 3000 + i); + } + assertEq(inbox.getBucket(RING_SIZE).msgCount, MAX_MSGS_PER_BUCKET, "head bucket full"); + + uint64 totalBefore = inbox.getTotalMessagesInserted(); + RevertingBatchSender sender = new RevertingBatchSender(); + + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__WouldOverwriteUnconsumedBucket.selector, uint64(1))); + sender.sendMany(inbox, version, 5); + + assertEq(inbox.getTotalMessagesInserted(), totalBefore, "no message from the batch landed"); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE, "no bucket opened"); + } + + // A batching caller that swallows the revert keeps the sends that fit before the wall: the head bucket is one + // short of the per-bucket cap, so the first send absorbs and only the rollovers after it are refused. + function testCatchingBatchKeepsPreWallSends() public { + _reachRingWall(inbox); + for (uint256 i = 1; i < MAX_MSGS_PER_BUCKET - 1; i++) { + _send(inbox, 4000 + i); + } + assertEq(inbox.getBucket(RING_SIZE).msgCount, MAX_MSGS_PER_BUCKET - 1, "head bucket one short of full"); + + uint64 totalBefore = inbox.getTotalMessagesInserted(); + CatchingBatchSender sender = new CatchingBatchSender(); + + uint256 succeeded = sender.sendMany(inbox, version, 5); + + assertEq(succeeded, 1, "only the send that fit before the wall"); + assertEq(inbox.getTotalMessagesInserted(), totalBefore + 1, "exactly one message landed"); + assertEq(inbox.getCurrentBucketSeq(), RING_SIZE, "no bucket opened"); + } + + /// forge-config: default.fuzz.runs = 32 + // Random interleavings of bucket openings and proven-consumption releases, starting at the ring wall so every + // run exercises both sides of the check, against a model of the ring: no sequence of the two overwrites a + // bucket the proven chain has not released, and every refused send names the exact bucket it would have + // destroyed. + function testFuzzNoUnconsumedOverwrite(uint256 _seed) public { + _reachRingWall(inbox); + uint256 modelCurrent = RING_SIZE; + + uint256 modelProven = 0; + uint256 modelTotal = modelCurrent; + + for (uint256 i = 0; i < 16; i++) { + uint256 entropy = uint256(keccak256(abi.encodePacked(_seed, i))); + + if (entropy % 3 == 0) { + uint256 target = modelProven + ((entropy >> 8) % 8); + if (target > modelCurrent) { + target = modelCurrent; + } + inbox.markProvenConsumed(uint64(target)); + modelProven = target; + continue; + } + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + + uint256 opening = modelCurrent + 1; + if (opening < RING_SIZE || modelProven >= opening - RING_SIZE) { + _send(inbox, 5000 + i); + modelCurrent = opening; + modelTotal += 1; + } else { + _sendExpectingOverwriteRevert(inbox, 5000 + i, uint64(opening - RING_SIZE)); + } + } + + assertEq(inbox.getCurrentBucketSeq(), modelCurrent, "current bucket tracks the model"); + assertEq(inbox.getProvenConsumedBucketSeq(), modelProven, "proven-consumed record tracks the model"); + assertEq(inbox.getRingHeadroom(), modelProven + RING_SIZE - modelCurrent, "headroom tracks the model"); + assertEq(inbox.getTotalMessagesInserted(), modelTotal, "every allowed send landed and no refused one did"); + + if (modelProven < modelCurrent) { + // Every bucket in this run holds exactly one message, so the oldest unreleased bucket's cumulative total + // is its own sequence number: a wrapped-over slot would report someone else's. + IInbox.InboxBucket memory oldest = inbox.getBucket(modelProven + 1); + assertEq(oldest.msgCount, 1, "oldest unreleased bucket holds its own message"); + assertEq(oldest.totalMsgCount, modelProven + 1, "oldest unreleased bucket was never overwritten"); + } + } +} diff --git a/l1-contracts/test/Rollup.t.sol b/l1-contracts/test/Rollup.t.sol index b5667b07dadb..16df19e27131 100644 --- a/l1-contracts/test/Rollup.t.sol +++ b/l1-contracts/test/Rollup.t.sol @@ -9,7 +9,7 @@ import {Math} from "@oz/utils/math/Math.sol"; import {SafeCast} from "@oz/utils/math/SafeCast.sol"; import {Registry} from "@aztec/governance/Registry.sol"; -import {Inbox} from "@aztec/core/messagebridge/Inbox.sol"; +import {Inbox, INBOX_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; import {Outbox} from "@aztec/core/messagebridge/Outbox.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {ProposedHeader, ProposedHeaderLib} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol"; @@ -969,6 +969,93 @@ contract RollupTest is RollupBase { assertNotEq(publicInputs[4], storedEnd, "wrong end must not be replaced by the stored value"); } + // Ring eviction is gated on proven consumption, not pending consumption: proposing records the consumed bucket + // in the checkpoint's temp log only, and the Inbox's record moves once the epoch proof makes that checkpoint the + // proven tip. + function testProvenConsumedBucketAdvancesOnEpochProof() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + + uint64 consumedBucket = inbox.getCurrentBucketSeq(); + assertGt(consumedBucket, 0, "checkpoint consumed L1 to L2 messages"); + assertEq(inbox.getProvenConsumedBucketSeq(), 0, "pending consumption does not unlock eviction"); + + _proveCheckpoints("mixed_checkpoint_", 1, 1, address(this)); + + assertEq(inbox.getProvenConsumedBucketSeq(), consumedBucket, "proven consumption caught up with the proposal"); + assertEq(inbox.getRingHeadroom(), INBOX_BUCKET_RING_SIZE, "the whole ring is available again"); + } + + // An epoch that consumed no messages proves with equal start and end rolling hashes, and the proven-tip advance + // skips the Inbox write since the record could not move. + function testEmptyInboxEpochProofSkipsInboxWrite() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + _proveCheckpoints("mixed_checkpoint_", 1, 1, address(this)); + uint64 provenConsumedBucket = inbox.getProvenConsumedBucketSeq(); + assertGt(provenConsumedBucket, 0, "the first epoch consumed L1 to L2 messages"); + + // The next epoch's checkpoint references the same bucket, adding no messages. + _proposeCheckpointWithoutInboxMessages("mixed_checkpoint_2", EPOCH_DURATION); + assertEq(inbox.getCurrentBucketSeq(), provenConsumedBucket, "no new bucket was opened"); + + vm.expectCall(address(inbox), abi.encodeWithSelector(inbox.markProvenConsumed.selector), 0); + _proveCheckpoints("mixed_checkpoint_", 2, 2, address(this)); + + assertEq(rollup.getProvenCheckpointNumber(), 2, "second epoch proven"); + assertEq(inbox.getProvenConsumedBucketSeq(), provenConsumedBucket, "record unchanged"); + } + + // A prune rewinds the pending chain along with the temp-log records of what it consumed, but the buckets that + // chain referenced are exactly the ones the replacement chain has to re-consume, so eviction stays locked. + function testPruneDoesNotAdvanceProvenConsumed() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + assertGt(inbox.getCurrentBucketSeq(), 0, "checkpoint consumed L1 to L2 messages"); + + CheckpointLog memory checkpoint = rollup.getCheckpoint(1); + Slot prunableAt = checkpoint.slotNumber + Epoch.wrap(2).toSlots(); + vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(prunableAt))); + + rollup.prune(); + + assertEq(rollup.getPendingCheckpointNumber(), 0, "pending chain pruned"); + assertEq(inbox.getProvenConsumedBucketSeq(), 0, "a pruned chain's consumption never unlocks eviction"); + } + + // Re-proposing a checkpoint number after a prune overwrites its temp log wholesale, so proving the replacement + // records the replacement's consumption — the pruned proposal's stale record cannot leak into the Inbox. + function testProvenConsumedTracksReplacementChainAfterPrune() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + uint64 staleConsumedBucket = inbox.getCurrentBucketSeq(); + assertGt(staleConsumedBucket, 0, "the pruned proposal consumed L1 to L2 messages"); + + CheckpointLog memory checkpoint = rollup.getCheckpoint(1); + Slot prunableAt = checkpoint.slotNumber + Epoch.wrap(2).toSlots(); + uint256 replacementTimestamp = Timestamp.unwrap(rollup.getTimestampForSlot(prunableAt)); + + // An extra message in an L1 block before the replacement's slot opens one more bucket, so the replacement + // proposal consumes one bucket further than the pruned one did and the two temp-log records differ. + vm.warp(replacementTimestamp - 12); + vm.roll(block.number + 1); + bytes32[] memory contents = new bytes32[](1); + contents[0] = bytes32(uint256(0x1234)); + _populateInbox(address(this), bytes32(uint256(0x5678)), contents); + uint64 replacementConsumedBucket = inbox.getCurrentBucketSeq(); + assertEq(replacementConsumedBucket, staleConsumedBucket + 1, "the extra message opened one more bucket"); + + // The propose call prunes the stale chain and installs checkpoint 1 from the empty fixture, referencing the + // newest bucket. + _proposeCheckpoint("empty_checkpoint_1", Slot.unwrap(prunableAt)); + assertEq(rollup.getPendingCheckpointNumber(), 1, "replacement chain proposed"); + + _proveCheckpoints("empty_checkpoint_", 1, 1, address(this)); + + assertEq(rollup.getProvenCheckpointNumber(), 1, "replacement checkpoint proven"); + assertEq( + inbox.getProvenConsumedBucketSeq(), + replacementConsumedBucket, + "the replacement's consumption is recorded, not the pruned proposal's" + ); + } + function _submitEpochProof( uint256 _start, uint256 _end, diff --git a/l1-contracts/test/RollupWithPreheating.sol b/l1-contracts/test/RollupWithPreheating.sol index 1636a6ea0a5d..a41e269b5d33 100644 --- a/l1-contracts/test/RollupWithPreheating.sol +++ b/l1-contracts/test/RollupWithPreheating.sol @@ -7,7 +7,8 @@ import {IERC20} from "@aztec/core/interfaces/IRollup.sol"; import {IRollupCore} from "@aztec/core/interfaces/IRollup.sol"; import {GSE} from "@aztec/governance/GSE.sol"; import {IVerifier} from "@aztec/core/interfaces/IVerifier.sol"; -import {STFLib, RollupStore, RollupCore} from "@aztec/core/RollupCore.sol"; +import {STFLib, RollupCore} from "@aztec/core/RollupCore.sol"; +import {RollupStore} from "@aztec/core/interfaces/IRollup.sol"; import {CompressedFeeHeader, FeeHeaderLib} from "@aztec/core/libraries/compressed-data/fees/FeeStructs.sol"; import { CompressedTempCheckpointLogLib, diff --git a/l1-contracts/test/base/RollupBase.sol b/l1-contracts/test/base/RollupBase.sol index cdf6ee4014a2..a054fb7ffdcf 100644 --- a/l1-contracts/test/base/RollupBase.sol +++ b/l1-contracts/test/base/RollupBase.sol @@ -135,12 +135,30 @@ contract RollupBase is DecoderBase { _proposeCheckpoint(_name, _slotNumber, _manaUsed, _extraBlobHashes, ""); } + // Proposes without seeding the fixture's L1 to L2 messages, so the checkpoint references the bucket its parent + // already consumed and adds no messages. + function _proposeCheckpointWithoutInboxMessages(string memory _name, uint256 _slotNumber) internal { + bytes32[] memory extraBlobHashes = new bytes32[](0); + _proposeCheckpoint(_name, _slotNumber, 0, extraBlobHashes, "", false); + } + function _proposeCheckpoint( string memory _name, uint256 _slotNumber, uint256 _manaUsed, bytes32[] memory _extraBlobHashes, bytes memory _revertMsg + ) private { + _proposeCheckpoint(_name, _slotNumber, _manaUsed, _extraBlobHashes, _revertMsg, true); + } + + function _proposeCheckpoint( + string memory _name, + uint256 _slotNumber, + uint256 _manaUsed, + bytes32[] memory _extraBlobHashes, + bytes memory _revertMsg, + bool _seedInbox ) private { DecoderBase.Full memory full = load(_name); bytes memory blobCommitments = full.checkpoint.blobCommitments; @@ -167,7 +185,9 @@ contract RollupBase is DecoderBase { // Seed the Inbox before jumping to the checkpoint's L1 block: propose rejects a bucket that is still // accumulating, and a bucket keeps accumulating for the whole L1 block that opened it. - _populateInbox(full.populate.sender, full.populate.recipient, full.populate.l1ToL2Content); + if (_seedInbox) { + _populateInbox(full.populate.sender, full.populate.recipient, full.populate.l1ToL2Content); + } // We jump to the time of the block, always past the L1 block the messages above landed in. vm.warp(max(block.timestamp + 1, Timestamp.unwrap(full.checkpoint.header.timestamp))); diff --git a/l1-contracts/test/fees/MinimalFeeModel.sol b/l1-contracts/test/fees/MinimalFeeModel.sol index 3232385e9de1..8fcc28c5d372 100644 --- a/l1-contracts/test/fees/MinimalFeeModel.sol +++ b/l1-contracts/test/fees/MinimalFeeModel.sol @@ -130,7 +130,8 @@ contract MinimalFeeModel { slotNumber: Slot.wrap(0), feeHeader: FeeLib.computeFeeHeader(checkpointNumber, _oracleInput.feeAssetPriceModifier, _manaUsed, 0, 0), inboxRollingHash: bytes32(0), - inboxMsgTotal: 0 + inboxMsgTotal: 0, + inboxConsumedBucket: 0 }) ); // FeeLib.writeFeeHeader(++populatedThrough, _oracleInput.feeAssetPriceModifier, _manaUsed, 0, 0); diff --git a/l1-contracts/test/harnesses/TestConstants.sol b/l1-contracts/test/harnesses/TestConstants.sol index fdd1bf7e2ba1..881dfa54743f 100644 --- a/l1-contracts/test/harnesses/TestConstants.sol +++ b/l1-contracts/test/harnesses/TestConstants.sol @@ -25,7 +25,7 @@ library TestConstants { uint256 internal constant AZTEC_TARGET_COMMITTEE_SIZE = 48; uint256 internal constant AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET = 3; uint256 internal constant AZTEC_LAG_IN_EPOCHS_FOR_RANDAO = 2; - uint256 internal constant AZTEC_INBOX_BUCKET_RING_SIZE = 1024; + uint256 internal constant AZTEC_INBOX_BUCKET_RING_SIZE = 4096; uint256 internal constant AZTEC_PROOF_SUBMISSION_EPOCHS = 1; uint256 internal constant AZTEC_SLASHING_QUORUM = 17; // Must be > ROUND_SIZE / 2 (ROUND_SIZE derived from // EPOCH_DURATION) diff --git a/l1-contracts/test/rollup/InboxRingDeadlock.t.sol b/l1-contracts/test/rollup/InboxRingDeadlock.t.sol new file mode 100644 index 000000000000..1b3e30b89919 --- /dev/null +++ b/l1-contracts/test/rollup/InboxRingDeadlock.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {TestERC20} from "src/mock/TestERC20.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {IInbox, MAX_MSGS_PER_BUCKET} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import {Constants} from "@aztec/core/libraries/ConstantsGen.sol"; +import {Slot} from "@aztec/core/libraries/TimeLib.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; +import {MIN_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; +import {InboxHarness} from "../harnesses/InboxHarness.sol"; +import {ProposeLibHarness} from "./ProposeInboxConsumption.t.sol"; + +/** + * Why unconsumed buckets must never be evicted, from the consumption side. A proposal can only reference a + * retained bucket, and it can only consume MAX_L1_TO_L2_MSGS_PER_CHECKPOINT messages beyond its parent's + * cumulative total. Those two limits close against each other: heavy traffic pushes the buckets whose delta from + * a stalled parent total still fits the cap out of the retained window, and every bucket left in the window is + * too far ahead of that parent to be consumed in one checkpoint. A chain whose consumption stalled at genesis + * then has no proposable cursor at all, and no amount of waiting produces one — the gap is permanent. + * + * With overwrite protection the gap cannot open: sends halt at the ring wall instead of evicting, so the oldest + * unconsumed bucket stays retained and stays proposable. + */ +contract InboxRingDeadlockTest is Test { + uint256 internal constant GENESIS_TIME = 100_000; + uint256 internal constant SLOT_DURATION = 72; + uint256 internal constant EPOCH_DURATION = 32; + uint256 internal constant ETHEREUM_SLOT_DURATION = 12; + + // Far enough into the chain that the traffic below, which spans more L1 blocks than the ring holds buckets, + // lands entirely before the proposal's timestamp. + Slot internal constant SLOT = Slot.wrap(100); + + ProposeLibHarness internal rollup; + InboxHarness internal inbox; + uint256 internal version = 0; + + function setUp() public { + vm.warp(GENESIS_TIME); + rollup = new ProposeLibHarness(GENESIS_TIME, SLOT_DURATION, EPOCH_DURATION, ETHEREUM_SLOT_DURATION); + + IERC20 feeAsset = new TestERC20("Fee Asset", "FA", address(this)); + inbox = new InboxHarness(address(rollup), feeAsset, version, MIN_BUCKET_RING_SIZE); + } + + // Sends one message, discarding the return values so a send fronted by `vm.expectRevert` has no returndata to + // decode. + function _send(uint256 _salt) internal { + inbox.sendL2Message( + DataStructures.L2Actor({actor: bytes32(uint256(0x1000 + _salt)), version: version}), + bytes32(uint256(0x2000 + _salt)), + bytes32(uint256(0x3000 + _salt)) + ); + } + + // Drives the Inbox to `_targetBucketSeq` with the traffic shape that opens the cap-versus-window gap: four L1 + // blocks of MAX_MSGS_PER_BUCKET messages each, taking the cumulative total to exactly the per-checkpoint cap, a + // fifth block with the one message past it, then one message per L1 block. + function _driveTraffic(uint256 _targetBucketSeq, bool _forceProvenConsumed) internal { + for (uint256 l1Block = 1; inbox.getCurrentBucketSeq() < _targetBucketSeq; l1Block++) { + vm.roll(block.number + 1); + vm.warp(block.timestamp + ETHEREUM_SLOT_DURATION); + + uint256 count = l1Block <= 4 ? MAX_MSGS_PER_BUCKET : 1; + for (uint256 i = 0; i < count; i++) { + _send(inbox.getTotalMessagesInserted()); + } + + if (_forceProvenConsumed) { + uint64 newest = inbox.getCurrentBucketSeq(); + vm.prank(address(rollup)); + inbox.markProvenConsumed(newest); + } + } + } + + // Standing in for a contract without overwrite protection: the proven-consumed record is pushed to the newest + // bucket every L1 block, ahead of any real consumption, so eviction is never refused and the retained window + // slides off the only buckets a chain stalled at genesis could have proposed. The same sequence doubles as the + // damage model for an unfaithful rollup: a consumption claim not backed by a proven checkpoint reproduces the + // unprotected behavior exactly. + function testDeadlockWithoutProtection() public { + _driveTraffic(MIN_BUCKET_RING_SIZE + 4, true); + + uint256 current = inbox.getCurrentBucketSeq(); + assertEq(current, MIN_BUCKET_RING_SIZE + 4, "traffic drove the ring past one wrap"); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + + // The buckets whose delta from a parent total of zero fits the cap are buckets 0 through 4, and every one of + // them has been overwritten. + for (uint256 hint = 0; hint <= 4; hint++) { + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__BucketOutOfWindow.selector, hint, current)); + rollup.validateInboxConsumption(inbox, bytes32(0), hint, SLOT, 0); + } + + // Every bucket still retained is too far ahead of that parent total to be consumed in one checkpoint, so no + // reference at all is proposable and the pending chain can never cross the gap. + for (uint256 hint = 5; hint <= current; hint++) { + IInbox.InboxBucket memory bucket = inbox.getBucket(hint); + assertGt(bucket.totalMsgCount, Constants.MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, "bucket past the cap"); + + vm.expectRevert( + abi.encodeWithSelector(Errors.Rollup__TooManyInboxMessagesConsumed.selector, bucket.totalMsgCount) + ); + rollup.validateInboxConsumption(inbox, bucket.rollingHash, hint, SLOT, 0); + } + } + + // The same traffic against the real contract: nothing is proven-consumed, so sends halt at the ring wall with + // the whole window intact. + function testProtectionPreventsDeadlock() public { + _driveTraffic(MIN_BUCKET_RING_SIZE, false); + assertEq(inbox.getCurrentBucketSeq(), MIN_BUCKET_RING_SIZE, "ring filled to one wrap"); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + ETHEREUM_SLOT_DURATION); + vm.expectRevert(abi.encodeWithSelector(Errors.Inbox__WouldOverwriteUnconsumedBucket.selector, uint64(1))); + _send(0xdead); + + assertEq(inbox.getCurrentBucketSeq(), MIN_BUCKET_RING_SIZE, "sends halted rather than evicting bucket 1"); + + vm.warp(GENESIS_TIME + Slot.unwrap(SLOT) * SLOT_DURATION); + + // One bucket's delta is at most MAX_MSGS_PER_BUCKET and so always fits the per-checkpoint cap, so the oldest + // unconsumed bucket - which the protection never lets be evicted - always yields a proposable cursor. Here + // that cursor is bucket 4, whose delta is exactly the cap; the next bucket exceeds it, so mandatory + // consumption passes via the cap escape. + IInbox.InboxBucket memory cursor = inbox.getBucket(4); + uint256 consumed = rollup.validateInboxConsumption(inbox, cursor.rollingHash, 4, SLOT, 0); + assertEq(consumed, Constants.MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, "a proposable cursor survives"); + } +} diff --git a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol index 1b8ebb6e591c..66567a43ed49 100644 --- a/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol +++ b/l1-contracts/test/rollup/ProposeInboxConsumption.t.sol @@ -214,6 +214,9 @@ contract ProposeInboxConsumptionTest is Test { bytes32(uint256(0x2000 + i)), bytes32(uint256(0x3000 + i)) ); + // Evicting a ring slot requires the proven chain to have consumed it, so keep consumption trailing the sends. + vm.prank(address(rollup)); + ringInbox.markProvenConsumed(uint64(i - 1)); } // No proposal-time warp: the loop above has already moved past SLOT's proposal time, and the revert fires diff --git a/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol b/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol index 6f54d87b95c1..8d52e93399da 100644 --- a/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol +++ b/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol @@ -7,7 +7,7 @@ import {Timestamp, Slot, Epoch} from "@aztec/core/libraries/TimeLib.sol"; import {RewardBooster, IBoosterCore, RewardBoostConfig} from "@aztec/core/reward-boost/RewardBooster.sol"; import {IValidatorSelection} from "@aztec/core/interfaces/IValidatorSelection.sol"; import {Bps} from "@aztec/core/libraries/rollup/RewardLib.sol"; -import {SubmitEpochRootProofArgs} from "@aztec/core/interfaces/IRollup.sol"; +import {SubmitEpochRootProofArgs, RollupConfig} from "@aztec/core/interfaces/IRollup.sol"; import {STFLib, RollupStore} from "@aztec/core/libraries/rollup/STFLib.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; @@ -63,6 +63,8 @@ contract RewardLibWrapper { RewardBooster internal booster; Epoch internal currentEpoch; + IERC20 internal immutable FEE_ASSET; + IFeeJuicePortal internal immutable FEE_ASSET_PORTAL; FakeRewardDistributor public rewardDistributor; FakeFeePortal public feePortal; @@ -83,9 +85,8 @@ contract RewardLibWrapper { RewardLib.initializeConfig(config); - RollupStore storage rollupStore = STFLib.getStorage(); - rollupStore.config.feeAsset = _feeAsset; - rollupStore.config.feeAssetPortal = IFeeJuicePortal(address(feePortal)); + FEE_ASSET = _feeAsset; + FEE_ASSET_PORTAL = IFeeJuicePortal(address(feePortal)); TimeLib.initialize( block.timestamp, @@ -119,7 +120,8 @@ contract RewardLibWrapper { slotNumber: Slot.wrap(0), feeHeader: _feeHeader, inboxRollingHash: bytes32(0), - inboxMsgTotal: 0 + inboxMsgTotal: 0, + inboxConsumedBucket: 0 }) ); } @@ -129,13 +131,19 @@ contract RewardLibWrapper { } function handleRewardsAndFees(SubmitEpochRootProofArgs calldata _args, Epoch _endEpoch) external { - RewardLib.handleRewardsAndFees(_args, _endEpoch); + RewardLib.handleRewardsAndFees(_args, _endEpoch, _rollupConfig()); } function getSequencerRewards(address _sequencer) external view returns (uint256) { return RewardLib.getSequencerRewards(_sequencer); } + // Only the fields handleRewardsAndFees reads are populated; the rest stay zero. + function _rollupConfig() internal view returns (RollupConfig memory config) { + config.feeAsset = FEE_ASSET; + config.feeAssetPortal = FEE_ASSET_PORTAL; + } + function getCollectiveProverRewardsForEpoch(Epoch _epoch) external view returns (uint256) { return RewardLib.getCollectiveProverRewardsForEpoch(_epoch); } diff --git a/yarn-project/archiver/package.json b/yarn-project/archiver/package.json index 0fc676235f15..4f833e0af94e 100644 --- a/yarn-project/archiver/package.json +++ b/yarn-project/archiver/package.json @@ -81,7 +81,7 @@ "lodash.omit": "^4.5.0", "tslib": "^2.5.0", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/archiver/src/archiver-sync.test.ts b/yarn-project/archiver/src/archiver-sync.test.ts index 8d34be5e9f61..cb6ae0e9efa2 100644 --- a/yarn-project/archiver/src/archiver-sync.test.ts +++ b/yarn-project/archiver/src/archiver-sync.test.ts @@ -272,12 +272,16 @@ describe('Archiver Sync', () => { msgCount: 3, totalMsgCount: 3n, timestamp: t1, + l1BlockNumber: 98n, + l1BlockHash: Buffer32.fromBigInt(98n), }); expect(await archiver.getInboxBucket(3n)).toMatchObject({ seq: 3n, msgCount: 3, totalMsgCount: 9n, timestamp: t3, + l1BlockNumber: 2511n, + l1BlockHash: Buffer32.fromBigInt(2511n), }); // At-or-before lookups resolve the latest bucket not opened after the given timestamp. diff --git a/yarn-project/archiver/src/errors.ts b/yarn-project/archiver/src/errors.ts index 60661d9ea296..d5e989b705da 100644 --- a/yarn-project/archiver/src/errors.ts +++ b/yarn-project/archiver/src/errors.ts @@ -114,13 +114,18 @@ export class InboxBucketNotSyncedError extends Error { } /** - * Thrown when a cumulative Inbox message count does not resolve to a bucket boundary this archiver has synced, either - * because the count sits inside a bucket or because the bucket is not synced yet. + * Thrown when a cumulative Inbox message-count range is not fully backed by the messages this archiver has synced, + * either because it reaches past the synced tip or because the store is missing a message the range needs. + * Distinguishes "not available locally, retry once L1 sync catches up" from a genuinely empty range. */ -export class InboxBucketBoundaryNotSyncedError extends Error { - constructor(public readonly totalMsgCount: bigint) { - super(`No synced Inbox bucket ends at cumulative message count ${totalMsgCount}`); - this.name = 'InboxBucketBoundaryNotSyncedError'; +export class InboxMessageRangeNotSyncedError extends Error { + constructor( + public readonly startLeafCount: bigint, + public readonly endLeafCount: bigint, + detail: string, + ) { + super(`Inbox message range [${startLeafCount}, ${endLeafCount}) is not fully synced: ${detail}`); + this.name = 'InboxMessageRangeNotSyncedError'; } } diff --git a/yarn-project/archiver/src/modules/data_source_base.ts b/yarn-project/archiver/src/modules/data_source_base.ts index 9e407d9e6765..c37ff585e341 100644 --- a/yarn-project/archiver/src/modules/data_source_base.ts +++ b/yarn-project/archiver/src/modules/data_source_base.ts @@ -40,7 +40,13 @@ import { } from '@aztec/stdlib/epoch-helpers'; import type { L2LogsSource } from '@aztec/stdlib/interfaces/server'; import type { LogResult, PrivateLogsQuery, PublicLogsQuery } from '@aztec/stdlib/logs'; -import type { InboxBucket, L1ToL2MessageSource, L2ToL1MembershipWitness } from '@aztec/stdlib/messaging'; +import type { + InboxBucket, + InboxMessagePosition, + InboxMessageRange, + L1ToL2MessageSource, + L2ToL1MembershipWitness, +} from '@aztec/stdlib/messaging'; import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import type { BlockHeader, IndexedTxEffect, TxHash } from '@aztec/stdlib/tx'; import type { UInt64 } from '@aztec/stdlib/types'; @@ -340,6 +346,18 @@ export abstract class ArchiverDataSourceBase return this.stores.messages.getL1ToL2MessagesBetweenLeafCounts(startLeafCount, endLeafCount); } + public getMessagePosition(totalMessageCount: bigint): Promise { + return this.stores.messages.getMessagePosition(totalMessageCount); + } + + public getSyncedMessagePosition(): Promise { + return this.stores.messages.getSyncedMessagePosition(); + } + + public getL1ToL2MessageRange(startLeafCount: bigint, endLeafCount: bigint): Promise { + return this.stores.messages.getL1ToL2MessageRange(startLeafCount, endLeafCount); + } + private async getPublishedCheckpointFromCheckpointData(checkpoint: CheckpointData): Promise { const blocksForCheckpoint = await this.stores.blocks.getBlocksForCheckpoint(checkpoint.checkpointNumber); if (!blocksForCheckpoint) { diff --git a/yarn-project/archiver/src/store/message_store.test.ts b/yarn-project/archiver/src/store/message_store.test.ts index b60745673c09..677e3ad688a2 100644 --- a/yarn-project/archiver/src/store/message_store.test.ts +++ b/yarn-project/archiver/src/store/message_store.test.ts @@ -2,17 +2,20 @@ import { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { toArray } from '@aztec/foundation/iterable'; +import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { Checkpoint, type PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import { updateInboxRollingHash } from '@aztec/stdlib/messaging'; import '@aztec/stdlib/testing/jest'; -import { InboxBucketBoundaryNotSyncedError, InboxBucketNotSyncedError } from '../errors.js'; +import { InboxBucketNotSyncedError, InboxMessageRangeNotSyncedError } from '../errors.js'; import type { InboxMessage } from '../structs/inbox_message.js'; import { makeInboxMessage, makeInboxMessages, makeInboxMessagesWithFullBlocks, + makeL1BlockHash, + makeL1BlockNumberForBucket, makePublishedCheckpoint, makeStateForBlock, } from '../test/mock_structs.js'; @@ -21,6 +24,7 @@ import { type ArchiverL1SynchPoint, getArchiverSynchPoint } from './data_stores. import { MessageStore, MessageStoreError } from './message_store.js'; describe('MessageStore', () => { + let db: AztecAsyncKVStore; let blockStore: BlockStore; let messageStore: MessageStore; let publishedCheckpoints: PublishedCheckpoint[]; @@ -38,7 +42,7 @@ describe('MessageStore', () => { }); beforeEach(async () => { - const db = await openTmpStore('message_store_test'); + db = await openTmpStore('message_store_test'); blockStore = new BlockStore(db); messageStore = new MessageStore(db); // Create checkpoints sequentially to ensure archive roots are chained properly. @@ -222,6 +226,147 @@ describe('MessageStore', () => { }); }); + describe('iterateL1ToL2Messages', () => { + it('honours zero range bounds', async () => { + const msgs = makeInboxMessages(3); + await messageStore.addL1ToL2MessageBuckets(msgs); + + // Zero is a valid compact index, so an exclusive end of zero selects nothing rather than everything. + expect(await toArray(messageStore.iterateL1ToL2Messages({ end: 0n }))).toEqual([]); + expect(await toArray(messageStore.iterateL1ToL2Messages({ start: 0n, end: 2n }))).toEqual(msgs.slice(0, 2)); + expect(await toArray(messageStore.iterateL1ToL2Messages({ start: 0n }))).toEqual(msgs); + }); + }); + + describe('message positions', () => { + const zeroPosition = { totalMessageCount: 0n, rollingHash: Fr.ZERO }; + const positionAfter = (msg: InboxMessage) => ({ + totalMessageCount: msg.index + 1n, + rollingHash: msg.inboxRollingHash, + }); + + it('resolves the position at a synced message count', async () => { + const msgs = makeInboxMessages(6); + await messageStore.addL1ToL2MessageBuckets(msgs); + + // Position zero always resolves; every other count resolves to the hash stored with the message before it. + expect(await messageStore.getMessagePosition(0n)).toEqual(zeroPosition); + expect(await messageStore.getMessagePosition(1n)).toEqual(positionAfter(msgs[0])); + expect(await messageStore.getMessagePosition(4n)).toEqual(positionAfter(msgs[3])); + expect(await messageStore.getMessagePosition(6n)).toEqual(positionAfter(msgs[5])); + // Past the synced tip there is no position yet. + expect(await messageStore.getMessagePosition(7n)).toBeUndefined(); + await expect(messageStore.getMessagePosition(-1n)).rejects.toThrow(/Invalid Inbox message count/); + }); + + it('resolves position zero on an empty store', async () => { + expect(await messageStore.getMessagePosition(0n)).toEqual(zeroPosition); + expect(await messageStore.getMessagePosition(1n)).toBeUndefined(); + expect(await messageStore.getSyncedMessagePosition()).toEqual(zeroPosition); + }); + + it('hands out positions a caller can mutate without affecting later reads', async () => { + const position = (await messageStore.getMessagePosition(0n))!; + position.rollingHash = new Fr(99); + position.totalMessageCount = 99n; + + expect(await messageStore.getMessagePosition(0n)).toEqual(zeroPosition); + expect((await messageStore.getL1ToL2MessageRange(0n, 0n)).start).toEqual(zeroPosition); + }); + + it('tracks the synced position through appends and removals', async () => { + const msgs = makeInboxMessages(6); + await messageStore.addL1ToL2MessageBuckets(msgs.slice(0, 4)); + expect(await messageStore.getSyncedMessagePosition()).toEqual(positionAfter(msgs[3])); + + await messageStore.addL1ToL2MessageBuckets(msgs.slice(4)); + expect(await messageStore.getSyncedMessagePosition()).toEqual(positionAfter(msgs[5])); + + await messageStore.removeL1ToL2Messages(2n); + expect(await messageStore.getSyncedMessagePosition()).toEqual(positionAfter(msgs[1])); + // The removed suffix no longer has positions. + expect(await messageStore.getMessagePosition(3n)).toBeUndefined(); + }); + + it('reads a message range together with the positions at both bounds', async () => { + const msgs = makeInboxMessages(6); + await messageStore.addL1ToL2MessageBuckets(msgs); + const leaves = msgs.map(m => m.leaf); + + expect(await messageStore.getL1ToL2MessageRange(0n, 6n)).toEqual({ + messages: leaves, + start: zeroPosition, + end: positionAfter(msgs[5]), + }); + expect(await messageStore.getL1ToL2MessageRange(1n, 4n)).toEqual({ + messages: leaves.slice(1, 4), + start: positionAfter(msgs[0]), + end: positionAfter(msgs[3]), + }); + // An empty range is valid at any synced count and returns equal positions. + expect(await messageStore.getL1ToL2MessageRange(3n, 3n)).toEqual({ + messages: [], + start: positionAfter(msgs[2]), + end: positionAfter(msgs[2]), + }); + expect(await messageStore.getL1ToL2MessageRange(6n, 6n)).toEqual({ + messages: [], + start: positionAfter(msgs[5]), + end: positionAfter(msgs[5]), + }); + }); + + it('reads the messages and the ending position from one snapshot under a concurrent removal', async () => { + const msgs = makeInboxMessages(6); + await messageStore.addL1ToL2MessageBuckets(msgs); + + // Both operations are queued without awaiting: the read runs as one store transaction, so it sees either the + // full sequence or the truncated one, never the leaves of one with the ending hash of the other. + const rangePromise = messageStore.getL1ToL2MessageRange(0n, 6n); + const removalPromise = messageStore.removeL1ToL2Messages(3n); + const [range] = await Promise.all([rangePromise, removalPromise]); + + expect(range.messages).toEqual(msgs.map(m => m.leaf)); + expect(range.end).toEqual(positionAfter(msgs[5])); + expect(await messageStore.getSyncedMessagePosition()).toEqual(positionAfter(msgs[2])); + await expect(messageStore.getL1ToL2MessageRange(0n, 6n)).rejects.toThrow(InboxMessageRangeNotSyncedError); + }); + + it('reads the empty range at position zero on an empty store', async () => { + expect(await messageStore.getL1ToL2MessageRange(0n, 0n)).toEqual({ + messages: [], + start: zeroPosition, + end: zeroPosition, + }); + }); + + it('throws on an invalid or unsynced message range', async () => { + const msgs = makeInboxMessages(6); + await messageStore.addL1ToL2MessageBuckets(msgs); + + await expect(messageStore.getL1ToL2MessageRange(3n, 9n)).rejects.toThrow(InboxMessageRangeNotSyncedError); + await expect(messageStore.getL1ToL2MessageRange(7n, 7n)).rejects.toThrow(InboxMessageRangeNotSyncedError); + await expect(messageStore.getL1ToL2MessageRange(5n, 3n)).rejects.toThrow(/Invalid Inbox leaf count range/); + await expect(messageStore.getL1ToL2MessageRange(-1n, 3n)).rejects.toThrow(/Invalid Inbox leaf count range/); + }); + + it('throws when the range or its starting position has a hole', async () => { + const msgs = makeInboxMessages(6); + await messageStore.addL1ToL2MessageBuckets(msgs); + await db.openMap('archiver_l1_to_l2_messages').delete(2); + + // Index 2 is missing: ranges over it are short, and a range starting at count 3 has no starting position. + await expect(messageStore.getL1ToL2MessageRange(0n, 6n)).rejects.toThrow(InboxMessageRangeNotSyncedError); + await expect(messageStore.getL1ToL2MessageRange(3n, 6n)).rejects.toThrow(/missing the message at index 2/); + // Ranges that need neither the hole nor a position at it are unaffected. + expect(await messageStore.getL1ToL2MessageRange(4n, 6n)).toEqual({ + messages: msgs.slice(4).map(m => m.leaf), + start: positionAfter(msgs[3]), + end: positionAfter(msgs[5]), + }); + }); + }); + describe('Inbox buckets', () => { // Builds `count` consecutive valid messages, then reassigns their bucket sequence and timestamp per the given // per-message spec so we can exercise multi-message and rollover buckets. @@ -232,7 +377,10 @@ describe('MessageStore', () => { msgs.forEach((msg, i) => { msg.bucketSeq = spec[i].seq; msg.bucketTimestamp = spec[i].timestamp; - msg.l1BlockNumber = spec[i].l1BlockNumber ?? msg.l1BlockNumber; + // Buckets opened at the same L1 timestamp are rollover siblings within one L1 block, so derive the block + // from the timestamp rather than from the bucket sequence. + msg.l1BlockNumber = spec[i].l1BlockNumber ?? makeL1BlockNumberForBucket(spec[i].timestamp); + msg.l1BlockHash = makeL1BlockHash(msg.l1BlockNumber); }); return msgs; }; @@ -271,6 +419,8 @@ describe('MessageStore', () => { timestamp: 100n, msgCount: 3, lastMessageIndex: msgs[2].index, + l1BlockNumber: msgs[0].l1BlockNumber, + l1BlockHash: msgs[0].l1BlockHash, }); expect(await messageStore.getInboxBucket(2n)).toEqual({ seq: 2n, @@ -279,6 +429,8 @@ describe('MessageStore', () => { timestamp: 200n, msgCount: 2, lastMessageIndex: msgs[4].index, + l1BlockNumber: msgs[3].l1BlockNumber, + l1BlockHash: msgs[3].l1BlockHash, }); expect(await messageStore.getInboxBucket(3n)).toEqual({ seq: 3n, @@ -287,6 +439,8 @@ describe('MessageStore', () => { timestamp: 300n, msgCount: 1, lastMessageIndex: msgs[5].index, + l1BlockNumber: msgs[5].l1BlockNumber, + l1BlockHash: msgs[5].l1BlockHash, }); expect(await messageStore.getInboxBucket(4n)).toBeUndefined(); }); @@ -336,10 +490,64 @@ describe('MessageStore', () => { timestamp: 100n, msgCount: 3, lastMessageIndex: replacement.index, + l1BlockNumber: msgs[0].l1BlockNumber, + l1BlockHash: msgs[0].l1BlockHash, }); expect(await messageStore.getTotalL1ToL2MessageCount()).toEqual(3n); }); + it('records the L1 block a bucket is re-delivered in after a rollback', async () => { + const msgs = makeBucketedMessages(threeBucketSpec).slice(0, 3); + await messageStore.addL1ToL2MessageBuckets(msgs); + expect(await messageStore.getInboxBucket(1n)).toMatchObject({ + l1BlockNumber: msgs[0].l1BlockNumber, + l1BlockHash: msgs[0].l1BlockHash, + }); + + // The L1 block holding bucket 1 was reorged out and re-mined with the same messages under a different hash. + await messageStore.rollbackL1ToL2MessagesAfterL1Block(0n); + const replayed = msgs.map(msg => ({ ...msg, l1BlockHash: makeL1BlockHash(99n) })); + await messageStore.addL1ToL2MessageBuckets(replayed); + + expect(await messageStore.getInboxBucket(1n)).toMatchObject({ + msgCount: 3, + l1BlockNumber: msgs[0].l1BlockNumber, + l1BlockHash: makeL1BlockHash(99n), + }); + }); + + it('records the L1 block a bucket is re-delivered in without a rollback', async () => { + const msgs = makeBucketedMessages(threeBucketSpec).slice(0, 3); + await messageStore.addL1ToL2MessageBuckets(msgs); + + // The same messages are seen again under a different L1 block hash, and are reinserted in place. + const replayed = msgs.map(msg => ({ ...msg, l1BlockHash: makeL1BlockHash(99n) })); + await messageStore.addL1ToL2MessageBuckets(replayed); + + expect(await messageStore.getInboxBucket(1n)).toMatchObject({ + msgCount: 3, + totalMsgCount: 3n, + l1BlockNumber: msgs[0].l1BlockNumber, + l1BlockHash: makeL1BlockHash(99n), + }); + expect(await messageStore.getTotalL1ToL2MessageCount()).toEqual(3n); + }); + + it('records the opening L1 block of a bucket spanning co-timestamped L1 blocks', async () => { + // Chains that allow consecutive blocks to share a timestamp (anvil with manual mining) can spread one + // bucket over several L1 blocks; the snapshot keeps the block the bucket was opened in. + const msgs = makeBucketedMessages(threeBucketSpec); + msgs[2].l1BlockNumber = msgs[0].l1BlockNumber + 1n; + msgs[2].l1BlockHash = makeL1BlockHash(msgs[2].l1BlockNumber); + await messageStore.addL1ToL2MessageBuckets(msgs); + + expect(await messageStore.getInboxBucket(1n)).toMatchObject({ + msgCount: 3, + l1BlockNumber: msgs[0].l1BlockNumber, + l1BlockHash: msgs[0].l1BlockHash, + }); + }); + it('rejects a message opening a bucket older than the newest stored one', async () => { const msgs = makeBucketedMessages([ { seq: 1n, timestamp: 100n }, @@ -419,23 +627,61 @@ describe('MessageStore', () => { expect(await messageStore.getL1ToL2MessagesBetweenLeafCounts(0n, 0n)).toEqual([]); }); - it('throws when a leaf count does not land on a synced bucket boundary', async () => { + it('returns messages between leaf counts interior to the bucket partition', async () => { const msgs = makeBucketedMessages(threeBucketSpec); await messageStore.addL1ToL2MessageBuckets(msgs); + const leaves = msgs.map(m => m.leaf); - // Counts inside a bucket and past the last synced bucket both fail rather than returning a partial range. - await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(0n, 4n)).rejects.toThrow( - InboxBucketBoundaryNotSyncedError, - ); - await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(4n, 6n)).rejects.toThrow( - InboxBucketBoundaryNotSyncedError, - ); + // Counts 1, 2 and 4 sit inside a bucket. A published block commits to a leaf count, and a reorg can merge away + // the bucket that ended there, so the range is addressed by message index alone. + expect(await messageStore.getL1ToL2MessagesBetweenLeafCounts(1n, 6n)).toEqual(leaves.slice(1)); + expect(await messageStore.getL1ToL2MessagesBetweenLeafCounts(0n, 4n)).toEqual(leaves.slice(0, 4)); + expect(await messageStore.getL1ToL2MessagesBetweenLeafCounts(1n, 4n)).toEqual(leaves.slice(1, 4)); + expect(await messageStore.getL1ToL2MessagesBetweenLeafCounts(4n, 6n)).toEqual(leaves.slice(4)); + // An empty range at an interior count consumes nothing rather than failing. + expect(await messageStore.getL1ToL2MessagesBetweenLeafCounts(4n, 4n)).toEqual([]); + }); + + it('throws on an invalid or unsynced leaf count range', async () => { + const msgs = makeBucketedMessages(threeBucketSpec); + await messageStore.addL1ToL2MessageBuckets(msgs); + + // Ranges reaching past the synced tip fail rather than returning a partial range, empty ones included. await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(3n, 9n)).rejects.toThrow( - InboxBucketBoundaryNotSyncedError, + InboxMessageRangeNotSyncedError, + ); + await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(7n, 7n)).rejects.toThrow( + InboxMessageRangeNotSyncedError, ); + // Reversed and negative bounds are caller errors, reported like every other failure of this API: as a + // rejection, so remote callers behind the archiver RPC see them the same way. await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(5n, 3n)).rejects.toThrow( /Invalid Inbox leaf count range/, ); + await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(-1n, 3n)).rejects.toThrow( + /Invalid Inbox leaf count range/, + ); + await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(0n, -1n)).rejects.toThrow( + /Invalid Inbox leaf count range/, + ); + }); + + it('throws when the leaf count range has a hole', async () => { + const msgs = makeBucketedMessages(threeBucketSpec); + await messageStore.addL1ToL2MessageBuckets(msgs); + + // Defense in depth: insertion is contiguity-checked and removal only ever drops a suffix, so no caller can put + // a hole in the middle of the log. Punch one straight into the map to prove a short read is never returned. + await db.openMap('archiver_l1_to_l2_messages').delete(2); + + await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(0n, 6n)).rejects.toThrow( + InboxMessageRangeNotSyncedError, + ); + await expect(messageStore.getL1ToL2MessagesBetweenLeafCounts(2n, 3n)).rejects.toThrow( + InboxMessageRangeNotSyncedError, + ); + // Ranges that do not cover the hole are unaffected. + expect(await messageStore.getL1ToL2MessagesBetweenLeafCounts(3n, 6n)).toEqual(msgs.slice(3).map(m => m.leaf)); }); it('rewinds buckets when messages are removed', async () => { @@ -453,6 +699,8 @@ describe('MessageStore', () => { timestamp: 200n, msgCount: 1, lastMessageIndex: msgs[3].index, + l1BlockNumber: msgs[3].l1BlockNumber, + l1BlockHash: msgs[3].l1BlockHash, }); expect(await messageStore.getInboxBucket(1n)).toMatchObject({ msgCount: 3, totalMsgCount: 3n }); diff --git a/yarn-project/archiver/src/store/message_store.ts b/yarn-project/archiver/src/store/message_store.ts index 0590a060f4f2..9503ccc6616e 100644 --- a/yarn-project/archiver/src/store/message_store.ts +++ b/yarn-project/archiver/src/store/message_store.ts @@ -12,21 +12,30 @@ import { type CustomRange, mapRange, } from '@aztec/kv-store'; -import { type InboxBucket, updateInboxRollingHash } from '@aztec/stdlib/messaging'; +import { + type InboxBucket, + type InboxMessagePosition, + type InboxMessageRange, + updateInboxRollingHash, +} from '@aztec/stdlib/messaging'; -import { InboxBucketBoundaryNotSyncedError, InboxBucketNotSyncedError } from '../errors.js'; +import { InboxBucketNotSyncedError, InboxMessageRangeNotSyncedError } from '../errors.js'; import { type InboxMessage, deserializeInboxMessage, serializeInboxMessage } from '../structs/inbox_message.js'; /** * Persisted snapshot of an Inbox rolling-hash bucket. Mirrors the fields the on-chain Inbox tracks per bucket, plus - * the L1 block the bucket was opened in and the index span of its messages, so rollbacks and range queries can work - * off bucket records alone without scanning messages. + * the number and hash of the L1 block the bucket was opened in and the index span of its messages, so rollbacks, + * range queries and canonicality checks can work off bucket records alone without scanning messages. + * + * The Inbox keys buckets by `block.timestamp`, so on a chain that allows consecutive blocks to share a timestamp + * (anvil with manual mining, for instance) a bucket may span several L1 blocks. Only the opening one is recorded. */ type BucketSnapshot = { inboxRollingHash: Fr; totalMsgCount: bigint; timestamp: bigint; l1BlockNumber: bigint; + l1BlockHash: Buffer32; msgCount: number; firstMessageIndex: bigint; lastMessageIndex: bigint; @@ -38,6 +47,7 @@ function serializeBucketSnapshot(snapshot: BucketSnapshot): Buffer { bigintToUInt64BE(snapshot.totalMsgCount), bigintToUInt64BE(snapshot.timestamp), bigintToUInt64BE(snapshot.l1BlockNumber), + snapshot.l1BlockHash, numToUInt32BE(snapshot.msgCount), bigintToUInt64BE(snapshot.firstMessageIndex), bigintToUInt64BE(snapshot.lastMessageIndex), @@ -50,10 +60,20 @@ function deserializeBucketSnapshot(buffer: Buffer): BucketSnapshot { const totalMsgCount = reader.readUInt64(); const timestamp = reader.readUInt64(); const l1BlockNumber = reader.readUInt64(); + const l1BlockHash = Buffer32.fromBuffer(reader.readBytes(Buffer32.SIZE)); const msgCount = reader.readNumber(); const firstMessageIndex = reader.readUInt64(); const lastMessageIndex = reader.readUInt64(); - return { inboxRollingHash, totalMsgCount, timestamp, l1BlockNumber, msgCount, firstMessageIndex, lastMessageIndex }; + return { + inboxRollingHash, + totalMsgCount, + timestamp, + l1BlockNumber, + l1BlockHash, + msgCount, + firstMessageIndex, + lastMessageIndex, + }; } /** The messages of a single Inbox bucket within an incoming batch, in insertion order. */ @@ -82,7 +102,8 @@ function groupMessagesByBucket(messages: InboxMessage[]): IncomingBucket[] { // The genesis sentinel bucket: sequence 0 with a zero rolling hash and no messages, mirroring the // on-chain Inbox's base case. The archiver never ingests a snapshot for it (no message is absorbed into sequence 0), so // it is synthesized on read. Consumers use its sequence number and zero total; its deploy-time timestamp is not tracked -// here and is unused. +// here and is unused. Its timestamp, L1 block number and L1 block hash are sentinels, not values read from L1: +// consumers must short-circuit on sequence 0 rather than looking any of them up on chain. const GENESIS_INBOX_BUCKET: InboxBucket = { seq: 0n, inboxRollingHash: Fr.ZERO, @@ -90,8 +111,25 @@ const GENESIS_INBOX_BUCKET: InboxBucket = { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }; +/** + * The position before any message: zero count and zero rolling hash, mirroring the on-chain Inbox base case. Built + * fresh on every call because positions are plain mutable objects handed out to callers. + */ +function zeroMessagePosition(): InboxMessagePosition { + return { totalMessageCount: 0n, rollingHash: Fr.ZERO }; +} + +/** Rejects reversed or negative compact leaf count bounds, which are caller errors rather than sync state. */ +function assertValidLeafCountRange(startLeafCount: bigint, endLeafCount: bigint): void { + if (startLeafCount < 0n || endLeafCount < 0n || startLeafCount > endLeafCount) { + throw new Error(`Invalid Inbox leaf count range [${startLeafCount}, ${endLeafCount})`); + } +} + export class MessageStoreError extends Error { constructor( message: string, @@ -297,20 +335,32 @@ export class MessageStore { /** * Writes one snapshot per bucket in the batch, each derived from the bucket's complete message set. Cumulative * totals thread forward from the bucket preceding the batch, so a bucket re-delivered with extra messages shifts - * the totals of the buckets after it within the same batch. + * the totals of the buckets after it within the same batch. A bucket always arrives complete from its first message + * (checked when the batch is validated), so the opening L1 block is read off that first message; a bucket whose + * later messages come from a co-timestamped L1 block still records the block it was opened in. */ private async writeIncomingBucketSnapshots(incomingBuckets: IncomingBucket[]): Promise { let cumulativeTotal = await this.getTotalMsgCountBeforeBucket(incomingBuckets[0].seq); for (const { seq, messages } of incomingBuckets) { + const openingMessage = messages[0]; const lastInBucket = messages.at(-1)!; + if (lastInBucket.l1BlockNumber !== openingMessage.l1BlockNumber) { + this.#log.warn(`Inbox bucket ${seq} spans more than one L1 block`, { + bucketSeq: seq, + bucketTimestamp: lastInBucket.bucketTimestamp, + firstL1BlockNumber: openingMessage.l1BlockNumber, + lastL1BlockNumber: lastInBucket.l1BlockNumber, + }); + } cumulativeTotal += BigInt(messages.length); await this.writeBucketSnapshot(seq, { inboxRollingHash: lastInBucket.inboxRollingHash, totalMsgCount: cumulativeTotal, timestamp: lastInBucket.bucketTimestamp, - l1BlockNumber: lastInBucket.l1BlockNumber, + l1BlockNumber: openingMessage.l1BlockNumber, + l1BlockHash: openingMessage.l1BlockHash, msgCount: messages.length, - firstMessageIndex: messages[0].index, + firstMessageIndex: openingMessage.index, lastMessageIndex: lastInBucket.index, }); } @@ -444,27 +494,116 @@ export class MessageStore { /** * Returns the message leaves in the cumulative Inbox message-count range `[startLeafCount, endLeafCount)`, in - * insertion order. The bounds are compact L1-to-L2 tree leaf counts, which every block header - * carries, so consumers can ask for the messages a block or checkpoint consumed without resolving buckets - * themselves. Both bounds must land on a bucket boundary this archiver has synced; it throws otherwise, since a - * caller asking for a range always expects the messages in it. + * insertion order. The bounds are compact L1-to-L2 tree leaf counts, which every block header carries, so consumers + * can ask for the messages a block or checkpoint consumed without resolving buckets themselves. + * + * The bounds address canonical compact message indices and need not land on a bucket boundary of the partition this + * archiver currently holds: a published block commits to a leaf count, while an L1 reorg can merge away the bucket + * that once ended there. An invalid range, one reaching past the synced tip, or one the store cannot serve whole + * throws, since a caller asking for a range always expects every message in it. */ public async getL1ToL2MessagesBetweenLeafCounts(startLeafCount: bigint, endLeafCount: bigint): Promise { - if (startLeafCount > endLeafCount) { - throw new Error(`Invalid Inbox leaf count range [${startLeafCount}, ${endLeafCount})`); + assertValidLeafCountRange(startLeafCount, endLeafCount); + // The synced total and the leaves are read together so a concurrent suffix removal cannot land between them and + // turn a range this store holds whole into a spurious incomplete one. + return await this.db.transactionAsync(async () => { + await this.assertLeafCountRangeSynced(startLeafCount, endLeafCount); + const messages = await this.getMessagesInLeafCountRange(startLeafCount, endLeafCount); + return messages.map(message => message.leaf); + }); + } + + /** + * Returns the position of the Inbox message sequence after `totalMessageCount` messages: that count and the rolling + * hash over them, which is the rolling hash stored with the message at compact index `totalMessageCount - 1`. + * Position zero always resolves with a zero hash; a count past the synced tip returns undefined. + */ + public async getMessagePosition(totalMessageCount: bigint): Promise { + if (totalMessageCount < 0n) { + throw new Error(`Invalid Inbox message count ${totalMessageCount}`); + } + if (totalMessageCount === 0n) { + return zeroMessagePosition(); } - const startBucket = await this.getBucketAtBoundary(startLeafCount); - const endBucket = await this.getBucketAtBoundary(endLeafCount); - return this.getL1ToL2MessagesBetweenBuckets(startBucket.seq, endBucket.seq); + const buffer = await this.#l1ToL2Messages.getAsync(this.indexToKey(totalMessageCount - 1n)); + return buffer === undefined + ? undefined + : { totalMessageCount, rollingHash: deserializeInboxMessage(buffer).inboxRollingHash }; } - /** Resolves the bucket ending at the given cumulative message count, failing loudly if there is none. */ - private async getBucketAtBoundary(totalMsgCount: bigint): Promise { - const bucket = await this.getInboxBucketByTotalMsgCount(totalMsgCount); - if (bucket === undefined) { - throw new InboxBucketBoundaryNotSyncedError(totalMsgCount); + /** Returns the position at the synced tip: the total message count and the rolling hash over every stored message. */ + public getSyncedMessagePosition(): Promise { + return this.db.transactionAsync(async () => { + const syncedTotal = await this.getTotalL1ToL2MessageCount(); + const position = await this.getMessagePosition(syncedTotal); + if (position === undefined) { + throw new Error(`Inbox message store holds ${syncedTotal} messages but is missing index ${syncedTotal - 1n}`); + } + return position; + }); + } + + /** + * Returns the messages in the cumulative Inbox message-count range `[startLeafCount, endLeafCount)` together with + * the positions at both bounds. Everything is read in one store transaction, so the ending hash authenticates + * exactly the returned messages appended after the starting position, and a concurrent suffix replacement cannot + * pair the leaves of one version of the sequence with the hash of another. Follows the range contract of + * `getL1ToL2MessagesBetweenLeafCounts`, with the starting position also required to be available; an empty range + * returns equal positions. + */ + public async getL1ToL2MessageRange(startLeafCount: bigint, endLeafCount: bigint): Promise { + assertValidLeafCountRange(startLeafCount, endLeafCount); + return await this.db.transactionAsync(async () => { + await this.assertLeafCountRangeSynced(startLeafCount, endLeafCount); + const start = await this.getMessagePosition(startLeafCount); + if (start === undefined) { + throw new InboxMessageRangeNotSyncedError( + startLeafCount, + endLeafCount, + `the store is missing the message at index ${startLeafCount - 1n}`, + ); + } + const messages = await this.getMessagesInLeafCountRange(startLeafCount, endLeafCount); + const lastMessage = messages.at(-1); + const end = + lastMessage === undefined + ? start + : { totalMessageCount: endLeafCount, rollingHash: lastMessage.inboxRollingHash }; + return { messages: messages.map(message => message.leaf), start, end }; + }); + } + + /** Throws unless every message in `[startLeafCount, endLeafCount)` is within the synced total. Empty ranges included. */ + private async assertLeafCountRangeSynced(startLeafCount: bigint, endLeafCount: bigint): Promise { + const syncedTotal = await this.getTotalL1ToL2MessageCount(); + if (endLeafCount > syncedTotal) { + const available = syncedTotal > startLeafCount ? syncedTotal - startLeafCount : 0n; + throw new InboxMessageRangeNotSyncedError( + startLeafCount, + endLeafCount, + `only ${available} of ${endLeafCount - startLeafCount} messages are synced`, + ); + } + } + + /** + * Reads the messages in the compact index range `[startLeafCount, endLeafCount)`, which the caller has established + * lies within the synced total. The map holds at most one entry per index, so a short read is the only way a hole + * inside the range can show up, and the count catches every one of them. + */ + private async getMessagesInLeafCountRange(startLeafCount: bigint, endLeafCount: bigint): Promise { + if (startLeafCount === endLeafCount) { + return []; + } + const messages = await toArray(this.iterateL1ToL2Messages({ start: startLeafCount, end: endLeafCount })); + if (BigInt(messages.length) !== endLeafCount - startLeafCount) { + throw new InboxMessageRangeNotSyncedError( + startLeafCount, + endLeafCount, + `the store holds ${messages.length} of ${endLeafCount - startLeafCount} messages`, + ); } - return bucket; + return messages; } /** @@ -568,6 +707,8 @@ export class MessageStore { timestamp: snapshot.timestamp, msgCount: snapshot.msgCount, lastMessageIndex: snapshot.lastMessageIndex, + l1BlockNumber: snapshot.l1BlockNumber, + l1BlockHash: snapshot.l1BlockHash, }; } @@ -583,8 +724,10 @@ export class MessageStore { * Removes every L1 to L2 message inserted after the given L1 block, so the message store matches the L1 Inbox state * as of that block. Used when rolling the archiver back to an earlier checkpoint, whose L1 block is passed here. * - * A bucket lives entirely within one L1 block, so the cut always falls on a bucket boundary and can be found from - * the bucket snapshots alone, without reading the messages being removed. + * The cut is found from the bucket snapshots alone, without reading the messages being removed: a bucket is kept + * whole when the block it was opened in survives. On a chain with strictly increasing block timestamps a bucket + * lives entirely within one L1 block, so that is exact; where consecutive blocks may share a timestamp, a bucket + * opened at or before the cut keeps the messages it absorbed in the co-timestamped blocks after it. */ public async rollbackL1ToL2MessagesAfterL1Block(l1BlockNumber: bigint): Promise { this.#log.debug(`Deleting L1 to L2 messages inserted after L1 block ${l1BlockNumber}`); diff --git a/yarn-project/archiver/src/test/mock_archiver.ts b/yarn-project/archiver/src/test/mock_archiver.ts index 357c09d673e0..de301e878be5 100644 --- a/yarn-project/archiver/src/test/mock_archiver.ts +++ b/yarn-project/archiver/src/test/mock_archiver.ts @@ -1,10 +1,17 @@ +import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { L2BlockSource } from '@aztec/stdlib/block'; import type { Checkpoint } from '@aztec/stdlib/checkpoint'; -import type { InboxBucket, L1ToL2MessageSource } from '@aztec/stdlib/messaging'; +import type { + InboxBucket, + InboxMessagePosition, + InboxMessageRange, + L1ToL2MessageSource, +} from '@aztec/stdlib/messaging'; import { MockL1ToL2MessageSource } from './mock_l1_to_l2_message_source.js'; import { MockL2BlockSource } from './mock_l2_block_source.js'; +import { makeL1BlockHash, makeL1BlockNumberForBucket } from './mock_structs.js'; /** * A mocked implementation of the archiver that implements L2BlockSource and L1ToL2MessageSource. @@ -16,6 +23,14 @@ export class MockArchiver extends MockL2BlockSource implements L2BlockSource, L1 this.messageSource.setInboxBucket(bucket, msgs); } + public replaceInboxBuckets(buckets: { bucket: InboxBucket; msgs: Fr[] }[]) { + this.messageSource.replaceInboxBuckets(buckets); + } + + public appendL1ToL2Messages(msgs: Fr[]) { + this.messageSource.appendL1ToL2Messages(msgs); + } + getL1ToL2MessageIndex(_l1ToL2Message: Fr): Promise { return this.messageSource.getL1ToL2MessageIndex(_l1ToL2Message); } @@ -39,6 +54,18 @@ export class MockArchiver extends MockL2BlockSource implements L2BlockSource, L1 getL1ToL2MessagesBetweenLeafCounts(startLeafCount: bigint, endLeafCount: bigint): Promise { return this.messageSource.getL1ToL2MessagesBetweenLeafCounts(startLeafCount, endLeafCount); } + + getMessagePosition(totalMessageCount: bigint): Promise { + return this.messageSource.getMessagePosition(totalMessageCount); + } + + getSyncedMessagePosition(): Promise { + return this.messageSource.getSyncedMessagePosition(); + } + + getL1ToL2MessageRange(startLeafCount: bigint, endLeafCount: bigint): Promise { + return this.messageSource.getL1ToL2MessageRange(startLeafCount, endLeafCount); + } } /** @@ -65,12 +92,11 @@ export class MockPrefilledArchiver extends MockArchiver { this.prefilledMessages[checkpoint.number - 1] = messages; } - // Register the Inbox buckets the streaming world-state synchronizer reconstructs each block's consumed - // message bundle from: a genesis sentinel (totalMsgCount 0) so a leaf count of 0 - // resolves to a bucket, plus one bucket per message-carrying checkpoint whose cumulative totalMsgCount - // matches the block's post-insertion L1-to-L2 leaf count. Rebuilt from the full prefilled chain (not just - // this call's checkpoints) so a reorg re-prefill that replaces a suffix keeps the cumulative aligned. - // Without these the synchronizer derives an empty bundle and the reconstructed block state diverges. + // Index every message-carrying checkpoint's leaves at the compact positions the archiver would give them, which is + // what published-block replay reads by count. The leaves are registered through buckets (a genesis sentinel plus + // one bucket per message-carrying checkpoint) so tests can still model the live partition and repartition it as an + // L1 reorg would. Rebuilt from the full prefilled chain (not just this call's checkpoints) so a reorg re-prefill + // that replaces a suffix keeps the cumulative counts aligned. this.setInboxBucket( { seq: 0n, @@ -79,6 +105,8 @@ export class MockPrefilledArchiver extends MockArchiver { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }, [], ); @@ -91,6 +119,7 @@ export class MockPrefilledArchiver extends MockArchiver { } bucketSeq += 1n; totalMsgCount += BigInt(messages.length); + const l1BlockNumber = makeL1BlockNumberForBucket(bucketSeq); this.setInboxBucket( { seq: bucketSeq, @@ -99,6 +128,8 @@ export class MockPrefilledArchiver extends MockArchiver { timestamp: bucketSeq, msgCount: messages.length, lastMessageIndex: totalMsgCount - 1n, + l1BlockNumber, + l1BlockHash: makeL1BlockHash(l1BlockNumber), }, messages, ); diff --git a/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.test.ts b/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.test.ts new file mode 100644 index 000000000000..8c9a74fb8d73 --- /dev/null +++ b/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.test.ts @@ -0,0 +1,62 @@ +import { Buffer32 } from '@aztec/foundation/buffer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { type InboxBucket, updateInboxRollingHash } from '@aztec/stdlib/messaging'; + +import { InboxMessageRangeNotSyncedError } from '../errors.js'; +import { MockL1ToL2MessageSource } from './mock_l1_to_l2_message_source.js'; + +/** A single-message bucket at the start of the Inbox; only its total matters to the leaf index. */ +const singleMessageBucket: InboxBucket = { + seq: 1n, + inboxRollingHash: Fr.ZERO, + totalMsgCount: 1n, + timestamp: 1n, + msgCount: 1, + lastMessageIndex: 0n, + l1BlockNumber: 1n, + l1BlockHash: Buffer32.ZERO, +}; + +describe('MockL1ToL2MessageSource', () => { + let source: MockL1ToL2MessageSource; + + beforeEach(() => { + source = new MockL1ToL2MessageSource(0); + }); + + it('derives positions from the indexed leaves', async () => { + const leaves = [new Fr(11), new Fr(12), new Fr(13)]; + source.appendL1ToL2Messages(leaves); + const hashAfterTwo = updateInboxRollingHash(updateInboxRollingHash(Fr.ZERO, leaves[0]), leaves[1]); + + expect(await source.getMessagePosition(0n)).toEqual({ totalMessageCount: 0n, rollingHash: Fr.ZERO }); + expect(await source.getMessagePosition(2n)).toEqual({ totalMessageCount: 2n, rollingHash: hashAfterTwo }); + expect(await source.getMessagePosition(4n)).toBeUndefined(); + expect((await source.getSyncedMessagePosition()).totalMessageCount).toEqual(3n); + expect(await source.getL1ToL2MessageRange(1n, 2n)).toEqual({ + messages: [leaves[1]], + start: { totalMessageCount: 1n, rollingHash: updateInboxRollingHash(Fr.ZERO, leaves[0]) }, + end: { totalMessageCount: 2n, rollingHash: hashAfterTwo }, + }); + }); + + it('rejects ranges past the mocked tip with the archiver error, empty ones included', async () => { + await expect(source.getL1ToL2MessagesBetweenLeafCounts(7n, 7n)).rejects.toThrow(InboxMessageRangeNotSyncedError); + await expect(source.getL1ToL2MessageRange(7n, 7n)).rejects.toThrow(InboxMessageRangeNotSyncedError); + await expect(source.getL1ToL2MessageRange(0n, 1n)).rejects.toThrow(InboxMessageRangeNotSyncedError); + await expect(source.getL1ToL2MessageRange(2n, 1n)).rejects.toThrow(/Invalid Inbox leaf count range/); + expect(await source.getL1ToL2MessagesBetweenLeafCounts(0n, 0n)).toEqual([]); + }); + + it('reads the leaves and the ending hash of a range from the same version of the log', async () => { + source.setInboxBucket(singleMessageBucket, [new Fr(11)]); + + // Replace the leaf while the read is pending: the result must describe one version, not a mix of both. + const pending = source.getL1ToL2MessageRange(0n, 1n); + source.setInboxBucket(singleMessageBucket, [new Fr(22)]); + + const range = await pending; + expect(range.messages).toEqual([new Fr(11)]); + expect(range.end.rollingHash).toEqual(updateInboxRollingHash(Fr.ZERO, new Fr(11))); + }); +}); diff --git a/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.ts b/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.ts index 53b652f8d279..40caceaf39b8 100644 --- a/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.ts +++ b/yarn-project/archiver/src/test/mock_l1_to_l2_message_source.ts @@ -1,7 +1,15 @@ import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { CheckpointId, L2BlockId, L2TipId, L2Tips } from '@aztec/stdlib/block'; -import type { InboxBucket, L1ToL2MessageSource } from '@aztec/stdlib/messaging'; +import { + type InboxBucket, + type InboxMessagePosition, + type InboxMessageRange, + type L1ToL2MessageSource, + updateInboxRollingHash, +} from '@aztec/stdlib/messaging'; + +import { InboxMessageRangeNotSyncedError } from '../errors.js'; /** * A mocked implementation of L1ToL2MessageSource to be used in tests. @@ -9,18 +17,65 @@ import type { InboxBucket, L1ToL2MessageSource } from '@aztec/stdlib/messaging'; export class MockL1ToL2MessageSource implements L1ToL2MessageSource { private buckets = new Map(); private messagesPerBucket = new Map(); + /** + * The canonical message log, keyed by compact global index. This is the primary fixture: message positions and + * count ranges derive from it alone, and it is kept apart from the bucket partition so a test can repartition the + * buckets (as an L1 reorg does) while the indexed leaves stay exactly as they were. + */ + private leavesByIndex = new Map(); constructor(private blockNumber: number) {} public setInboxBucket(bucket: InboxBucket, msgs: Fr[] = []) { this.buckets.set(bucket.seq, bucket); this.messagesPerBucket.set(bucket.seq, msgs); + // Index from the bucket's own cumulative total rather than call order, so re-registering a bucket or setting them + // out of order keeps every leaf at the index the archiver would give it. + const firstIndex = bucket.totalMsgCount - BigInt(msgs.length); + msgs.forEach((msg, i) => this.leavesByIndex.set(firstIndex + BigInt(i), msg)); + } + + /** + * Replaces the current bucket partition without touching the indexed leaf log, modelling an L1 reorg that re-mines + * the same messages under different bucket boundaries. + */ + public replaceInboxBuckets(buckets: { bucket: InboxBucket; msgs: Fr[] }[]) { + this.buckets = new Map(); + this.messagesPerBucket = new Map(); + for (const { bucket, msgs } of buckets) { + this.buckets.set(bucket.seq, bucket); + this.messagesPerBucket.set(bucket.seq, msgs); + } + } + + /** Appends leaves to the indexed message log at its synced tip, without registering any bucket for them. */ + public appendL1ToL2Messages(msgs: Fr[]) { + const firstIndex = this.getSyncedMessageCount(); + msgs.forEach((msg, i) => this.leavesByIndex.set(firstIndex + BigInt(i), msg)); } public setBlockNumber(blockNumber: number) { this.blockNumber = blockNumber; } + /** The number of leaves indexed contiguously from zero: the mocked synced tip. */ + private getSyncedMessageCount(): bigint { + let count = 0n; + while (this.leavesByIndex.has(count)) { + count++; + } + return count; + } + + /** Recomputes the rolling hash over the first `totalMessageCount` indexed leaves, as the archiver stores per message. */ + private computeRollingHash(totalMessageCount: bigint): Fr { + let hash = Fr.ZERO; + for (let index = 0n; index < totalMessageCount; index++) { + hash = updateInboxRollingHash(hash, this.leavesByIndex.get(index)!); + } + return hash; + } + getL1ToL2MessageIndex(_l1ToL2Message: Fr): Promise { throw new Error('Method not implemented.'); } @@ -50,13 +105,70 @@ export class MockL1ToL2MessageSource implements L1ToL2MessageSource { return Promise.resolve(seqs.flatMap(seq => this.messagesPerBucket.get(seq) ?? [])); } - async getL1ToL2MessagesBetweenLeafCounts(startLeafCount: bigint, endLeafCount: bigint): Promise { - const startBucket = await this.getInboxBucketByTotalMsgCount(startLeafCount); - const endBucket = await this.getInboxBucketByTotalMsgCount(endLeafCount); - if (startBucket === undefined || endBucket === undefined) { - throw new Error(`No mocked Inbox bucket boundary at ${startLeafCount} or ${endLeafCount}`); + /** + * Slices the indexed leaf log, enforcing the same range contract as the archiver's message store: invalid bounds and + * ranges past the synced tip are rejected, the latter with the archiver's typed availability error. Every failure is + * a rejection rather than a synchronous throw, so callers see this stand-in behave like the async source it mocks. + */ + getL1ToL2MessagesBetweenLeafCounts(startLeafCount: bigint, endLeafCount: bigint): Promise { + try { + return Promise.resolve(this.readLeafCountRange(startLeafCount, endLeafCount)); + } catch (err) { + return Promise.reject(err); + } + } + + getMessagePosition(totalMessageCount: bigint): Promise { + if (totalMessageCount < 0n) { + return Promise.reject(new Error(`Invalid Inbox message count ${totalMessageCount}`)); + } + if (totalMessageCount > this.getSyncedMessageCount()) { + return Promise.resolve(undefined); + } + return Promise.resolve({ totalMessageCount, rollingHash: this.computeRollingHash(totalMessageCount) }); + } + + getSyncedMessagePosition(): Promise { + const totalMessageCount = this.getSyncedMessageCount(); + return Promise.resolve({ totalMessageCount, rollingHash: this.computeRollingHash(totalMessageCount) }); + } + + /** + * Reads the range and both positions synchronously from the current leaf log, so a test that mutates the log while + * a range read is pending cannot pair leaves of one version with hashes of another, as the archiver's single-transaction + * read cannot either. + */ + getL1ToL2MessageRange(startLeafCount: bigint, endLeafCount: bigint): Promise { + try { + const messages = this.readLeafCountRange(startLeafCount, endLeafCount); + return Promise.resolve({ + messages, + start: { totalMessageCount: startLeafCount, rollingHash: this.computeRollingHash(startLeafCount) }, + end: { totalMessageCount: endLeafCount, rollingHash: this.computeRollingHash(endLeafCount) }, + }); + } catch (err) { + return Promise.reject(err); + } + } + + private readLeafCountRange(startLeafCount: bigint, endLeafCount: bigint): Fr[] { + if (startLeafCount < 0n || endLeafCount < 0n || startLeafCount > endLeafCount) { + throw new Error(`Invalid Inbox leaf count range [${startLeafCount}, ${endLeafCount})`); + } + const syncedCount = this.getSyncedMessageCount(); + if (endLeafCount > syncedCount) { + const available = syncedCount > startLeafCount ? syncedCount - startLeafCount : 0n; + throw new InboxMessageRangeNotSyncedError( + startLeafCount, + endLeafCount, + `only ${available} of ${endLeafCount - startLeafCount} messages are mocked`, + ); + } + const leaves: Fr[] = []; + for (let index = startLeafCount; index < endLeafCount; index++) { + leaves.push(this.leavesByIndex.get(index)!); } - return this.getL1ToL2MessagesBetweenBuckets(startBucket.seq, endBucket.seq); + return leaves; } getBlockNumber() { diff --git a/yarn-project/archiver/src/test/mock_structs.ts b/yarn-project/archiver/src/test/mock_structs.ts index 5e6539ce0c59..b1de6c46af8e 100644 --- a/yarn-project/archiver/src/test/mock_structs.ts +++ b/yarn-project/archiver/src/test/mock_structs.ts @@ -3,7 +3,6 @@ import { makeTuple } from '@aztec/foundation/array'; import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { times, timesParallel } from '@aztec/foundation/collection'; -import { randomBigInt } from '@aztec/foundation/crypto/random'; import type { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -20,12 +19,30 @@ import { PartialStateReference, StateReference, TxEffect } from '@aztec/stdlib/t import type { InboxMessage } from '../structs/inbox_message.js'; +/** + * Deterministic, distinct L1 block hash for a block number, so tests can predict the hash a bucket records. The + * leading marker keeps it clearly distinguishable from a bare number-derived hash. + */ +export function makeL1BlockHash(l1BlockNumber: bigint): Buffer32 { + const buffer = Buffer.alloc(Buffer32.SIZE); + buffer.writeUInt16BE(0xb10c, 0); + buffer.writeBigUInt64BE(l1BlockNumber, Buffer32.SIZE - 8); + return Buffer32.fromBuffer(buffer); +} + +/** + * Deterministic L1 block number for a bucket opened at the given L1 block timestamp. Buckets sharing a timestamp (a + * full bucket rolling over within one L1 block) land in the same L1 block, and the mapping is deliberately unrelated + * to the bucket sequence so that code confusing the two fails its tests. + */ +export function makeL1BlockNumberForBucket(bucketTimestamp: bigint): bigint { + return 1000n + 2n * bucketTimestamp; +} + export function makeInboxMessage( previousInboxRollingHash = Fr.ZERO, overrides: Partial = {}, ): InboxMessage { - const { l1BlockNumber = randomBigInt(100n) + 1n } = overrides; - const { l1BlockHash = Buffer32.random() } = overrides; const { leaf = Fr.random() } = overrides; // Compact global insertion index: defaults to the first slot. const { index = 0n } = overrides; @@ -33,6 +50,9 @@ export function makeInboxMessage( // Default each message to its own bucket, keyed monotonically off its global index. const { bucketSeq = index + 1n } = overrides; const { bucketTimestamp = index + 1n } = overrides; + // A bucket is opened by the first message of its L1 block timestamp, so derive the block from that timestamp. + const { l1BlockNumber = makeL1BlockNumberForBucket(bucketTimestamp) } = overrides; + const { l1BlockHash = makeL1BlockHash(l1BlockNumber) } = overrides; return { index, @@ -85,7 +105,14 @@ export function makeInboxMessagesWithFullBlocks(blockCount: number): InboxMessag return makeInboxMessages(MAX_L1_TO_L2_MSGS_PER_BLOCK * blockCount, { overrideFn: (msg, i) => { const bucketSeq = BigInt(Math.floor(i / MAX_L1_TO_L2_MSGS_PER_BLOCK)) + 1n; - return { ...msg, bucketSeq, bucketTimestamp: bucketSeq }; + const l1BlockNumber = makeL1BlockNumberForBucket(bucketSeq); + return { + ...msg, + bucketSeq, + bucketTimestamp: bucketSeq, + l1BlockNumber, + l1BlockHash: makeL1BlockHash(l1BlockNumber), + }; }, }); } diff --git a/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts b/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts index 3fc1008c01ee..7cbccb09975c 100644 --- a/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts @@ -7,9 +7,11 @@ import { IndexWithinCheckpoint, SlotNumber, } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { unfreeze } from '@aztec/foundation/types'; +import type { L1BlockReader } from '@aztec/sequencer-client'; import { type AvmSimulator, PublicProcessor, PublicProcessorFactory } from '@aztec/simulator/server'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { @@ -116,16 +118,18 @@ describe('NodePublicCallsSimulator', () => { /** * Mocks the Inbox so the next-block prediction selects a two-message bundle: the fork's message total (0) - * resolves to bucket 0, and bucket 1 is lag-eligible and holds both messages. + * resolves to bucket 0, and bucket 1 holds both messages. */ - const mockInboxSelection = () => { + const mockInboxSelection = (timestamp = 0n) => { const makeBucket = (seq: bigint, totalMsgCount: bigint): InboxBucket => ({ seq, inboxRollingHash: Fr.ZERO, totalMsgCount, - timestamp: 0n, + timestamp, msgCount: Number(totalMsgCount), lastMessageIndex: totalMsgCount === 0n ? 0n : totalMsgCount - 1n, + l1BlockNumber: seq, + l1BlockHash: Buffer32.fromBigInt(seq), }); const bundle = [new Fr(0x1234), new Fr(0x5678)]; l1ToL2MessageSource.getInboxBucketByTotalMsgCount.mockResolvedValue(makeBucket(0n, 0n)); @@ -279,6 +283,102 @@ describe('NodePublicCallsSimulator', () => { expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled(); }); + describe('with an L1 client, mirroring the proposer eligibility rule', () => { + const ETHEREUM_SLOT_DURATION = 12; + // The simulator reads wall-clock time, so bucket timestamps are placed relative to it. + const nowSeconds = () => BigInt(Math.floor(Date.now() / 1000)); + + /** An L1 view in which the bucket's opening block already has a canonical child. */ + const makeL1Client = (): L1BlockReader & { reads: bigint[] } => { + const reads: bigint[] = []; + return { + reads, + getBlock({ blockNumber }) { + reads.push(blockNumber); + return Promise.resolve({ + hash: Buffer32.fromBigInt(blockNumber).toString(), + parentHash: Buffer32.fromBigInt(blockNumber - 1n).toString(), + }); + }, + }; + }; + + const makeSimulator = (opts: { l1Client?: L1BlockReader; useAutomineSequencer?: boolean }) => + new NodePublicCallsSimulator({ + blockSource, + worldStateSynchronizer, + l1ToL2MessageSource, + contractDataSource, + globalVariableBuilder, + rollupContract, + epochCache, + avmSimulator, + signatureContext: { chainId: CHAIN_ID.toNumber(), rollupAddress: ROLLUP_ADDRESS }, + l1Client: opts.l1Client, + config: { + rpcSimulatePublicMaxGasLimit: 1e11, + rpcSimulatePublicMaxDebugLogMemoryReads: 100, + useAutomineSequencer: opts.useAutomineSequencer, + }, + }); + + beforeEach(() => { + epochCache.getL1Constants.mockReturnValue({ + ...EmptyL1RollupConstants, + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + }); + setupMidCheckpoint(); + blockSource.getBlockData.mockImplementation((query: BlockQuery) => + Promise.resolve('number' in query ? makeBlockData(query.number, SlotNumber(42)) : undefined), + ); + mockNextL1Slot(SlotNumber(100)); + }); + + it('predicts nothing from a bucket the proposer would still be waiting on', async () => { + const tx = await lowGasTx(); + // Opened this very second: no L1 block can have built on it yet, so no proposer will consume it. + mockInboxSelection(nowSeconds()); + const l1Client = makeL1Client(); + + await expect(makeSimulator({ l1Client }).simulate(tx)).resolves.toBeDefined(); + + expect(merkleTreeFork.appendLeaves).not.toHaveBeenCalled(); + expect(l1Client.reads).toEqual([]); + }); + + it('predicts the bundle once the bucket opening block has a canonical child', async () => { + const tx = await lowGasTx(); + const bundle = mockInboxSelection(nowSeconds() - BigInt(ETHEREUM_SLOT_DURATION)); + const l1Client = makeL1Client(); + + await makeSimulator({ l1Client }).simulate(tx); + + expect(merkleTreeFork.appendLeaves).toHaveBeenCalledWith(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, bundle); + // Bucket 1 was opened by L1 block 1, so its child is block 2. + expect(l1Client.reads).toEqual([2n]); + }); + + it('never waits for a confirmation under automine, which mines on demand', async () => { + const tx = await lowGasTx(); + const bundle = mockInboxSelection(nowSeconds()); + const l1Client = makeL1Client(); + + await makeSimulator({ l1Client, useAutomineSequencer: true }).simulate(tx); + + expect(merkleTreeFork.appendLeaves).toHaveBeenCalledWith(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, bundle); + expect(l1Client.reads).toEqual([]); + }); + + it('predicts against every synced bucket when the node has no L1 client', async () => { + const tx = await lowGasTx(); + const bundle = mockInboxSelection(nowSeconds()); + + await makeSimulator({}).simulate(tx); + + expect(merkleTreeFork.appendLeaves).toHaveBeenCalledWith(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, bundle); + }); + }); + it('fails with a retryable error when the latest proposed header is missing, without double-inserting messages', async () => { const tx = await lowGasTx(); setupMidCheckpoint(); diff --git a/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts b/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts index 8eca30114e00..5414b6e73fa0 100644 --- a/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts +++ b/yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.ts @@ -12,7 +12,14 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import { BadRequestError } from '@aztec/foundation/json-rpc'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; -import { type InboxBucketSource, selectInboxBucketForBlock } from '@aztec/sequencer-client'; +import { + InboxBucketConfirmationTracker, + type InboxBucketEligibility, + type InboxBucketSource, + type L1BlockReader, + immediateEligibility, + selectInboxBucketForBlock, +} from '@aztec/sequencer-client'; import { type AvmSimulator, PublicContractsDB, PublicProcessorFactory } from '@aztec/simulator/server'; import { CollectionLimitsConfig, PublicSimulatorConfig } from '@aztec/stdlib/avm'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; @@ -43,6 +50,11 @@ export interface NodePublicCallsSimulatorConfig { rpcSimulatePublicMaxGasLimit: number; /** Maximum number of debug-log memory reads collected during simulation. */ rpcSimulatePublicMaxDebugLogMemoryReads: number; + /** + * Whether this node runs the automine sequencer. Automine consumes Inbox buckets the moment it sees them, so the + * next-block prediction must not wait for an L1 confirmation the local chain will never produce on its own. + */ + useAutomineSequencer?: boolean; } /** Dependencies required to build a {@link NodePublicCallsSimulator}. */ @@ -62,6 +74,11 @@ export interface NodePublicCallsSimulatorDeps { rollupContract?: RollupContract; epochCache: EpochCacheInterface; signatureContext: CoordinationSignatureContext; + /** + * L1 client used to tell which Inbox buckets a proposer would consider confirmed. Optional: without one the + * prediction assumes every synced bucket is consumable, which is what automine and TXE nodes do anyway. + */ + l1Client?: L1BlockReader; config: NodePublicCallsSimulatorConfig; /** * AVM execution backend the public processor drives to run public calls. Optional because unit/TXE nodes @@ -101,7 +118,13 @@ export class NodePublicCallsSimulator { private readonly rollupContract: RollupContract | undefined; private readonly epochCache: EpochCacheInterface; private readonly signatureContext: CoordinationSignatureContext; + private readonly l1Client: L1BlockReader | undefined; private readonly config: NodePublicCallsSimulatorConfig; + /** + * Shared by every simulation on this node. Its confirmations are permanent facts about L1, so a node-lifetime + * cache is correct and keeps the RPC cost of the prediction near zero; its rejections expire every second. + */ + private inboxBucketConfirmations: InboxBucketConfirmationTracker | undefined; private readonly avmSimulator?: AvmSimulator; private readonly telemetry: TelemetryClient; private readonly log: Logger; @@ -116,6 +139,7 @@ export class NodePublicCallsSimulator { this.rollupContract = deps.rollupContract; this.epochCache = deps.epochCache; this.signatureContext = deps.signatureContext; + this.l1Client = deps.l1Client; this.config = deps.config; this.avmSimulator = deps.avmSimulator; this.telemetry = deps.telemetry ?? getTelemetryClient(); @@ -241,9 +265,9 @@ export class NodePublicCallsSimulator { /** * Appends the L1-to-L2 message bundle the next block would consume to the simulation fork, so a transaction * consuming a message that has reached the Inbox but no block yet simulates against the state it will run in. - * Runs the same bucket selection the sequencer runs (lag eligibility plus the per-block and per-checkpoint caps), - * treating the next block as non-final: the censorship cutoff only widens consumption on a checkpoint's last - * block, and the node cannot know whether the next block is it. + * Runs the same bucket selection the sequencer runs (the per-block and per-checkpoint caps), treating the next + * block as non-final: the censorship cutoff only widens consumption on a checkpoint's last block, and the node + * cannot know whether the next block is it. * * Best-effort. Any failure — Inbox buckets not synced yet, a torn archiver snapshot — leaves the fork at the tip * state, which is what the transaction sees if the next block consumes nothing. @@ -282,7 +306,8 @@ export class NodePublicCallsSimulator { const selection = await selectInboxBucketForBlock({ messageSource: this.l1ToL2MessageSource, now: BigInt(Math.floor(this.dateProvider.now() / 1000)), - minBucketAgeSeconds: BigInt(l1Constants.ethereumSlotDuration), + isEligible: this.getInboxBucketEligibility(l1Constants.ethereumSlotDuration), + ethereumSlotDuration: l1Constants.ethereumSlotDuration, parent: { seq: parentBucket.seq, totalMsgCount: parentBucket.totalMsgCount }, checkpointStartTotalMsgCount, perBlockCap: MAX_L1_TO_L2_MSGS_PER_BLOCK, @@ -304,6 +329,24 @@ export class NodePublicCallsSimulator { } } + /** + * The eligibility rule the next proposer is expected to apply. It has to match the sequencer's: a transaction + * simulated against a bundle no proposer will consume yet enters the pool and then fails when the block that + * includes it consumes less. Automine, and any node without an L1 client, never waits, so those predict against + * every synced bucket instead. + */ + private getInboxBucketEligibility(ethereumSlotDuration: number): InboxBucketEligibility { + if (this.config.useAutomineSequencer || this.l1Client === undefined) { + return immediateEligibility; + } + this.inboxBucketConfirmations ??= new InboxBucketConfirmationTracker({ + l1Client: this.l1Client, + ethereumSlotDuration, + log: this.log.createChild('inbox-bucket-confirmation'), + }); + return this.inboxBucketConfirmations.isEligible; + } + /** Cumulative Inbox message total consumed as of `blockNumber`, i.e. its L1-to-L2 message tree leaf count. */ private async getConsumedMessageTotal(blockNumber: BlockNumber): Promise { if (blockNumber === BlockNumber.ZERO) { diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 6988c98440d2..16ee6cdea106 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -29,6 +29,7 @@ import { type P2P, createTxValidatorForAcceptingTxsOverRPC, getDefaultAllowedSet import { ProtocolContractAddress } from '@aztec/protocol-contracts'; import type { ProverNode } from '@aztec/prover-node'; import { SequencerClient } from '@aztec/sequencer-client'; +import type { L1BlockReader } from '@aztec/sequencer-client'; import { AutomineSequencer } from '@aztec/sequencer-client/automine'; import type { AvmSimulator } from '@aztec/simulator/server'; import type { SlasherClientInterface } from '@aztec/slasher'; @@ -151,6 +152,11 @@ export interface AztecNodeServiceDeps { keyStoreManager?: KeystoreManager; debugLogStore?: DebugLogStore; automineSequencer?: AutomineSequencer; + /** + * L1 client the public-calls simulator reads blocks from, to predict which Inbox buckets the next proposer will + * consider confirmed. Absent in unit/TXE nodes, which fall back to predicting against every synced bucket. + */ + l1Client?: L1BlockReader; // AVM execution backend for public simulation. Wired in production (factory.ts); absent in unit/TXE nodes // that don't drive public execution, hence optional and asserted at the simulation call site. Owned by the // node (disposed on stop), so it must be disposable — a spawned process pool + CDB IPC server. @@ -248,6 +254,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb rollupContract: this.rollupContract, epochCache: this.epochCache, signatureContext: { chainId: this.l1ChainId, rollupAddress: this.config.rollupAddress }, + l1Client: deps.l1Client, config: this.config, avmSimulator: this.avmSimulator, telemetry: this.telemetry, diff --git a/yarn-project/aztec-node/src/factory.ts b/yarn-project/aztec-node/src/factory.ts index 682f5f3d8930..9e5c0cce7db0 100644 --- a/yarn-project/aztec-node/src/factory.ts +++ b/yarn-project/aztec-node/src/factory.ts @@ -664,6 +664,7 @@ export async function createAztecNodeService( debugLogStore, automineSequencer, avmSimulator, + l1Client: publicClient, }); return node; diff --git a/yarn-project/aztec.js/package.json b/yarn-project/aztec.js/package.json index 961bdc1f2703..8c9cbea7c46e 100644 --- a/yarn-project/aztec.js/package.json +++ b/yarn-project/aztec.js/package.json @@ -105,7 +105,7 @@ "axios": "^1.15.1", "tslib": "^2.4.0", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@aztec/builder": "workspace:^", diff --git a/yarn-project/blob-client/package.json b/yarn-project/blob-client/package.json index 9f3480ca9b07..99df99eabad0 100644 --- a/yarn-project/blob-client/package.json +++ b/yarn-project/blob-client/package.json @@ -67,7 +67,7 @@ "source-map-support": "^0.5.21", "tslib": "^2.4.0", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/bot/package.json b/yarn-project/bot/package.json index ec1c8e5896b3..e395b375e55c 100644 --- a/yarn-project/bot/package.json +++ b/yarn-project/bot/package.json @@ -71,7 +71,7 @@ "source-map-support": "^0.5.21", "tslib": "^2.4.0", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/end-to-end/package.json b/yarn-project/end-to-end/package.json index 4e3826be22b0..d088378a740e 100644 --- a/yarn-project/end-to-end/package.json +++ b/yarn-project/end-to-end/package.json @@ -105,7 +105,7 @@ "typescript": "^5.3.3", "util": "^0.12.5", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "0x": "^5.7.0", diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/streaming_inbox.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/streaming_inbox.test.ts index 5f544237dfe9..d8b93fd0ca33 100644 --- a/yarn-project/end-to-end/src/single-node/cross-chain/streaming_inbox.test.ts +++ b/yarn-project/end-to-end/src/single-node/cross-chain/streaming_inbox.test.ts @@ -22,8 +22,8 @@ jest.setTimeout(600_000); // entered at the first block of the *next* checkpoint, mid-checkpoint inclusion, message-only blocks, and // per-block streaming latency had no observable surface. Runs the production // pipelining sequencer via CrossChainMessagingTest with a widened slot (36s / 6s blocks -> up to ~4 blocks -// per checkpoint) so a message can become lag-eligible partway through a checkpoint and land in a non-first -// block. minTxsPerBlock=0 lets a checkpoint carry a zero-tx block whose only content is a streaming bundle. +// per checkpoint) so a message's bucket can become eligible partway through a checkpoint and land in a +// non-first block. minTxsPerBlock=0 lets a checkpoint carry a zero-tx block whose only content is a streaming bundle. // // Grounded on l1_to_l2.test.ts (send/wait helpers, TestContract arbitrary-sender consume) and // cross_chain_public_message.test.ts (same-block public consume). All cases share one node stood up once. @@ -46,9 +46,9 @@ describe('single-node/cross-chain/streaming_inbox', () => { t = new CrossChainMessagingTest( 'streaming_inbox', // A 36s slot with 6s blocks yields up to ~4 blocks per checkpoint (the pipelining timing model gives - // maxBlocks = floor((36 - 0.5 - (0.5 + D)) / D) = 4 for D=6), which is what lets a message aged past - // the minimum bucket age land in a non-first block of the same checkpoint. minTxsPerBlock=0 permits a - // zero-tx message-only block (the FI-05 relaxation). + // maxBlocks = floor((36 - 0.5 - (0.5 + D)) / D) = 4 for D=6), which is what lets a message whose bucket + // is confirmed mid-checkpoint land in a non-first block of the same checkpoint. minTxsPerBlock=0 permits + // a zero-tx message-only block (the FI-05 relaxation). { ...PIPELINING_SETUP_OPTS, aztecSlotDuration: 36, blockDurationMs: 6000, minTxsPerBlock: 0 }, { aztecProofSubmissionEpochs: 2, aztecEpochDuration: 4 }, { syncChainTip: 'checkpointed' }, @@ -133,7 +133,7 @@ describe('single-node/cross-chain/streaming_inbox', () => { // Test 1 (mid-checkpoint inclusion): a message sent mid-checkpoint becomes available in a *later* block of // the same checkpoint (indexWithinCheckpoint > 0), which the legacy first-block-of-next-checkpoint flow // could never produce. Feeds a steady tx stream so checkpoints fill to multiple blocks, times the send so - // the message ages past the minimum bucket age partway through a checkpoint's build, then locates the inserting + // the message's bucket becomes eligible partway through a checkpoint's build, then locates the inserting // block. Retries with fresh messages so a message that happens to age exactly at a checkpoint boundary (and // lands at index 0) does not fail the run. it('includes a message in a non-first block of a checkpoint (mid-checkpoint streaming)', async () => { @@ -203,10 +203,9 @@ describe('single-node/cross-chain/streaming_inbox', () => { // Test 2 (latency bound): the delay between a message's L1 inclusion and the L2 block that makes it // available stays within the streaming bound. Asserted in slot-denominated terms (L1/L2 timestamps, not // wall-clock): the including block's timestamp minus the message's L1 timestamp must be at most - // ethereumSlotDuration + 2 * slotDuration (the minimum bucket age + a full slot straddle + one slot of CI - // slack). No lower bound - // is asserted (eligibility is already enforced by L1 and the validator). The wall-clock latency is logged - // for information only. + // ethereumSlotDuration + 2 * slotDuration (the descendant-confirmation wait + a full slot straddle + one slot + // of CI slack). No lower bound is asserted: when a bucket becomes consumable is the proposer's own policy. The + // wall-clock latency is logged for information only. it('makes a message available within the streaming latency bound', async () => { const { slotDuration } = t.constants; const maxDelaySeconds = BigInt(t.constants.ethereumSlotDuration) + 2n * BigInt(slotDuration); @@ -326,4 +325,70 @@ describe('single-node/cross-chain/streaming_inbox', () => { .send({ from: user1Address, wait: { dontThrowOnRevert: true } }); expect(failedReceipt.executionResult).toBe(TxExecutionResult.REVERTED); }); + + // Test 5 (forced same-block consumption): a public tx consuming a message that the *same* block inserts must + // succeed. The block builder appends the block's messages to its fork before executing txs, matching the prover + // and the block-root circuit (which pins each tx's L1-to-L2 tree snapshot to the post-append root); if it did + // not, this tx would revert at proposal time and succeed at proving time, making the epoch unprovable. The node + // simulates public calls against the messages predicted for the next block, so a consume tx sent as soon as the + // message's bucket becomes eligible passes simulation and lands in the pool before the inserting block is + // built. The send is timed to that instant and retried with fresh messages when a block slips in between. + it('consumes a message in the same block that inserts it', async () => { + const l1Account = t.ethAccount; + const maxAttempts = 5; + let sameBlockReceipt: { blockNumber: BlockNumber; msgHash: Fr; globalLeafIndex: bigint } | undefined; + + for (let attempt = 0; attempt < maxAttempts && sameBlockReceipt === undefined; attempt++) { + const [secret, secretHash] = await generateClaimSecret(); + const message = { recipient: testContract.address, content: Fr.random(), secretHash }; + const { msgHash, globalLeafIndex, txReceipt: l1Receipt } = await sendMessageToL2(message); + const messageL1Ts = await getMessageL1Timestamp(l1Receipt.blockNumber!); + const messageL1Block = l1Receipt.blockNumber!; + // The proposer consumes a bucket once its opening L1 block has a canonical descendant, falling back to + // "two Ethereum slots have elapsed and the block is still canonical" when the next L1 slot is missed. + const fallbackAt = messageL1Ts + 2n * BigInt(t.constants.ethereumSlotDuration); + log.warn(`Attempt ${attempt}: sent message ${msgHash.toString()} in L1 block ${messageL1Block}`); + + // Do not drive L2 blocks while waiting: an extra block here only shifts the timing of the inserting block. + await retryUntil( + async () => + (await t.harnessL1Client.getBlockNumber()) > messageL1Block || + BigInt(await t.cheatCodes.eth.lastBlockTimestamp()) >= fallbackAt, + `message bucket gains an L1 descendant (or reaches ${fallbackAt})`, + Number(t.constants.slotDuration) * 3, + 0.2, + ); + const blockAtSend = await aztecNode.getBlockNumber(); + + const { receipt } = await testContract.methods + .consume_message_from_arbitrary_sender_public(message.content, secret, l1Account, globalLeafIndex.toBigInt()) + .send({ from: user1Address, wait: { dontThrowOnRevert: true } }); + const inserting = await findInsertingBlock(msgHash, BlockNumber(blockAtSend + 1)); + const consumeBlock = BlockNumber(Number(receipt.blockNumber)); + log.warn(`Consume tx for ${msgHash.toString()} landed in block ${consumeBlock}`, { + insertingBlock: inserting.blockNumber, + consumeBlock, + executionResult: receipt.executionResult, + }); + + if (consumeBlock !== inserting.blockNumber) { + // A block was built between eligibility and the tx's arrival; that is timing, not a bug. + log.warn(`Consume did not land in the inserting block; retrying with a fresh message`); + continue; + } + + // Same block reached: the consume must have succeeded against the block's own messages. + expect(receipt.executionResult).toBe(TxExecutionResult.SUCCESS); + sameBlockReceipt = { blockNumber: consumeBlock, msgHash, globalLeafIndex: globalLeafIndex.toBigInt() }; + } + + if (sameBlockReceipt === undefined) { + throw new Error(`Could not produce a same-block consume in ${maxAttempts} attempts`); + } + const [resolvedIndex] = (await aztecNode.getL1ToL2MessageMembershipWitness( + sameBlockReceipt.blockNumber, + sameBlockReceipt.msgHash, + ))!; + expect(resolvedIndex).toBe(sameBlockReceipt.globalLeafIndex); + }); }); diff --git a/yarn-project/entrypoints/package.json b/yarn-project/entrypoints/package.json index 6b271d602ccc..c55ade0c3225 100644 --- a/yarn-project/entrypoints/package.json +++ b/yarn-project/entrypoints/package.json @@ -73,7 +73,7 @@ "@aztec/standard-contracts": "workspace:^", "@aztec/stdlib": "workspace:^", "tslib": "^2.4.0", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/epoch-cache/package.json b/yarn-project/epoch-cache/package.json index 5560af9582f4..185707fac24f 100644 --- a/yarn-project/epoch-cache/package.json +++ b/yarn-project/epoch-cache/package.json @@ -35,7 +35,7 @@ "jest-mock-extended": "^4.0.0", "tslib": "^2.4.0", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/ethereum/package.json b/yarn-project/ethereum/package.json index e58c3ddaefd8..061df0f6e4ec 100644 --- a/yarn-project/ethereum/package.json +++ b/yarn-project/ethereum/package.json @@ -58,7 +58,7 @@ "lodash.pickby": "^4.5.0", "tslib": "^2.4.0", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/ethereum/src/contracts/chain_state_override.ts b/yarn-project/ethereum/src/contracts/chain_state_override.ts index baf4164dd6f0..b17d841b0683 100644 --- a/yarn-project/ethereum/src/contracts/chain_state_override.ts +++ b/yarn-project/ethereum/src/contracts/chain_state_override.ts @@ -22,6 +22,7 @@ export type PendingCheckpointOverrideState = { payloadDigest?: Buffer32; slotNumber?: SlotNumber; inboxMsgTotal?: bigint; + inboxConsumedBucket?: bigint; }; export type ChainTipsOverride = { @@ -96,8 +97,8 @@ export class SimulationOverridesBuilder { * Overrides one or more `tempCheckpointLogs` cell fields for the configured pending checkpoint. * Any subset can be provided. The translator (`makeTempCheckpointLogOverride`) emits a stateDiff * entry per storage word touched, so fields in untouched words stay at their on-chain values; - * `slotNumber` and `inboxMsgTotal` share a word, so setting either zeroes the other unless it is - * supplied too. + * `slotNumber`, `inboxMsgTotal` and `inboxConsumedBucket` share a word, so setting any of them + * zeroes the others unless they are supplied too. * * `slotNumber` is required for `STFLib.canPruneAtTime`: when the simulation overrides `pending` * to a checkpoint that has no on-chain `tempCheckpointLogs` entry yet, the missing slotNumber falls @@ -110,6 +111,7 @@ export class SimulationOverridesBuilder { payloadDigest?: Buffer32; slotNumber?: SlotNumber; inboxMsgTotal?: bigint; + inboxConsumedBucket?: bigint; }): this { this.assertPendingCheckpointNumber(); this.pendingCheckpointState = { ...(this.pendingCheckpointState ?? {}), ...fields }; @@ -178,6 +180,7 @@ export async function buildSimulationOverridesStateOverride( payloadDigest: plan.pendingCheckpointState.payloadDigest, slotNumber: plan.pendingCheckpointState.slotNumber, inboxMsgTotal: plan.pendingCheckpointState.inboxMsgTotal, + inboxConsumedBucket: plan.pendingCheckpointState.inboxConsumedBucket, feeHeader: plan.pendingCheckpointState.feeHeader, }), ), diff --git a/yarn-project/ethereum/src/contracts/rollup.test.ts b/yarn-project/ethereum/src/contracts/rollup.test.ts index 52069c7496d4..c80853744ed5 100644 --- a/yarn-project/ethereum/src/contracts/rollup.test.ts +++ b/yarn-project/ethereum/src/contracts/rollup.test.ts @@ -362,12 +362,12 @@ describe('Rollup', () => { }); describe('getVkTreeRoot and getProtocolContractsHash', () => { - it('reads vkTreeRoot from storage', async () => { + it('reads vkTreeRoot', async () => { const result = await rollup.getVkTreeRoot(); expect(result).toEqual(vkTreeRoot); }); - it('reads protocolContractsHash from storage', async () => { + it('reads protocolContractsHash', async () => { const result = await rollup.getProtocolContractsHash(); expect(result).toEqual(protocolContractsHash); }); @@ -487,16 +487,17 @@ describe('Rollup', () => { ); }); - it('packs the inbox consumption count into the slot-number word', async () => { + it('packs the inbox consumption counts into the slot-number word', async () => { const checkpointNumber = CheckpointNumber(13); const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, { slotNumber: SlotNumber(7), inboxMsgTotal: 300n, + inboxConsumedBucket: 5n, }); const { map, slotFor } = getDiffMap(checkpointNumber, override); expect(override[0].stateDiff).toHaveLength(1); expect(map.get(await slotFor(TempCheckpointLogField.SlotNumber))).toBe( - `0x${(7n | (300n << 32n)).toString(16).padStart(64, '0')}`.toLowerCase(), + `0x${(7n | (300n << 32n) | (5n << 96n)).toString(16).padStart(64, '0')}`.toLowerCase(), ); }); diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index af17f8d37c38..3e35ba67cb67 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -160,8 +160,8 @@ export enum TempCheckpointLogField { * arbitrary `bytes32` value rather than a BN254 scalar. `slotNumber` carries the uint32 portion * of the on-chain `CompressedSlot`. * - * `slotNumber` and `inboxMsgTotal` share a single storage word, so supplying either rewrites both; - * the one left out lands as zero. + * `slotNumber`, `inboxMsgTotal` and `inboxConsumedBucket` share a single storage word, so supplying + * any one of them rewrites all three; the ones left out land as zero. */ export type TempCheckpointLogOverrideFields = { headerHash?: Fr; @@ -170,6 +170,8 @@ export type TempCheckpointLogOverrideFields = { slotNumber?: SlotNumber; /** Cumulative Inbox message count consumed as of this checkpoint. */ inboxMsgTotal?: bigint; + /** Inbox bucket sequence number this checkpoint's rolling hash corresponds to. */ + inboxConsumedBucket?: bigint; feeHeader?: FeeHeader; }; @@ -449,16 +451,12 @@ export class RollupContract { @memoize async getVkTreeRoot(): Promise { - const slot = BigInt(RollupContract.stfStorageSlot) + 3n; - const value = await this.client.getStorageAt({ address: this.address, slot: `0x${slot.toString(16)}` }); - return Fr.fromString(value ?? '0x0'); + return Fr.fromString(await this.rollup.read.getVkTreeRoot()); } @memoize async getProtocolContractsHash(): Promise { - const slot = BigInt(RollupContract.stfStorageSlot) + 4n; - const value = await this.client.getStorageAt({ address: this.address, slot: `0x${slot.toString(16)}` }); - return Fr.fromString(value ?? '0x0'); + return Fr.fromString(await this.rollup.read.getProtocolContractsHash()); } /** @@ -992,16 +990,21 @@ export class RollupContract { value: fields.payloadDigest.toString() as `0x${string}`, }); } - if (fields.slotNumber !== undefined || fields.inboxMsgTotal !== undefined) { - // The L1 struct packs the slot number and the inbox consumption count into one word, so this - // diff always writes both. Widths are enforced here because the L1 writers cast through + if ( + fields.slotNumber !== undefined || + fields.inboxMsgTotal !== undefined || + fields.inboxConsumedBucket !== undefined + ) { + // The L1 struct packs the slot number and the two inbox consumption counts into one word, so this + // diff always writes all three. Widths are enforced here because the L1 writers cast through // SafeCast and revert on overflow; a malformed override must surface rather than silently truncate // into a neighbouring field. const slotNumber = requireUintFits(BigInt(fields.slotNumber ?? 0), 32, 'slotNumber'); const inboxMsgTotal = requireUintFits(fields.inboxMsgTotal ?? 0n, 64, 'inboxMsgTotal'); + const inboxConsumedBucket = requireUintFits(fields.inboxConsumedBucket ?? 0n, 64, 'inboxConsumedBucket'); stateDiff.push({ slot: slotAt(TempCheckpointLogField.SlotNumber), - value: word(slotNumber | (inboxMsgTotal << 32n)), + value: word(slotNumber | (inboxMsgTotal << 32n) | (inboxConsumedBucket << 96n)), }); } if (fields.feeHeader) { diff --git a/yarn-project/foundation/package.json b/yarn-project/foundation/package.json index f0c25bfa22b3..d84c16cfa6b5 100644 --- a/yarn-project/foundation/package.json +++ b/yarn-project/foundation/package.json @@ -167,7 +167,7 @@ "pino-pretty": "^13.0.0", "sha3": "^2.1.4", "undici": "^5.28.5", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/kv-store/src/interfaces/common.ts b/yarn-project/kv-store/src/interfaces/common.ts index 1b6c142d491f..45584336b0ad 100644 --- a/yarn-project/kv-store/src/interfaces/common.ts +++ b/yarn-project/kv-store/src/interfaces/common.ts @@ -18,8 +18,8 @@ export type CustomRange = { /** Maps a custom range into a range of valid key types to iterate over. */ export function mapRange(range: CustomRange, mapFn: (key: CK) => K): Range { return { - start: range.start ? mapFn(range.start) : undefined, - end: range.end ? mapFn(range.end) : undefined, + start: range.start !== undefined ? mapFn(range.start) : undefined, + end: range.end !== undefined ? mapFn(range.end) : undefined, reverse: range.reverse, limit: range.limit, }; diff --git a/yarn-project/node-keystore/package.json b/yarn-project/node-keystore/package.json index 786d62dbab2f..5755976b80c0 100644 --- a/yarn-project/node-keystore/package.json +++ b/yarn-project/node-keystore/package.json @@ -70,7 +70,7 @@ "@ethersproject/wallet": "^5.7.0", "tslib": "^2.4.0", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/prover-client/package.json b/yarn-project/prover-client/package.json index 597f3074aa6f..a7ca4e5c5fc2 100644 --- a/yarn-project/prover-client/package.json +++ b/yarn-project/prover-client/package.json @@ -87,7 +87,7 @@ "lodash.chunk": "^4.2.0", "source-map-support": "^0.5.21", "tslib": "^2.4.0", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@aztec/noir-contracts.js": "workspace:^", diff --git a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.bench.test.ts b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.bench.test.ts index 670fb97b4d22..7204a47e5460 100644 --- a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.bench.test.ts +++ b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.bench.test.ts @@ -157,7 +157,7 @@ describe('LightweightCheckpointBuilder benchmarks', () => { const globalVariables = makeGlobalVariables(blockNumber, slotNumber); const txs = await timesAsync(numTxs, i => makeTx(globalVariables, 5000 + i)); - const { timings } = await builder.addBlock(globalVariables, txs, [], { insertTxsEffects: true }); + const { timings } = await builder.applyEffectsAndSealBlock(globalVariables, txs, []); const prefix = `addBlock/${label}/${numTxs} txs`; for (const [step, ms] of Object.entries(timings)) { diff --git a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.test.ts b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.test.ts index 7361d00f3fc8..82f408f3d2bc 100644 --- a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.test.ts +++ b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.test.ts @@ -9,9 +9,9 @@ import { PublicDataWrite } from '@aztec/stdlib/avm'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { EthAddress } from '@aztec/stdlib/block'; import { GasFees } from '@aztec/stdlib/gas'; -import { accumulateCheckpointOutHashes } from '@aztec/stdlib/messaging'; +import { accumulateCheckpointOutHashes, appendL1ToL2MessagesToTree } from '@aztec/stdlib/messaging'; import { mockProcessedTx } from '@aztec/stdlib/testing'; -import { PublicDataTreeLeaf } from '@aztec/stdlib/trees'; +import { MerkleTreeId, PublicDataTreeLeaf } from '@aztec/stdlib/trees'; import type { CheckpointGlobalVariables, ProcessedTx } from '@aztec/stdlib/tx'; import { GlobalVariables } from '@aztec/stdlib/tx'; import type { GenesisData } from '@aztec/stdlib/world-state'; @@ -112,7 +112,7 @@ describe('LightweightCheckpointBuilder', () => { // Build empty block const globalVariables = makeGlobalVariables(blockNumber, slotNumber); - const { block } = await checkpointBuilder.addBlock(globalVariables, [], [], { insertTxsEffects: true }); + const { block } = await checkpointBuilder.applyEffectsAndSealBlock(globalVariables, [], []); expect(block.header.globalVariables.blockNumber).toEqual(blockNumber); @@ -156,10 +156,8 @@ describe('LightweightCheckpointBuilder', () => { const msgs = [Fr.random(), Fr.random()]; tx.txEffect.l2ToL1Msgs.push(...msgs); - // Build block with tx - insertTxsEffects will handle inserting side effects - const { block } = await checkpointBuilder.addBlock(globalVariables, [tx], [], { - insertTxsEffects: true, - }); + // Build block with tx + const { block } = await checkpointBuilder.applyEffectsAndSealBlock(globalVariables, [tx], []); expect(block.header.globalVariables.blockNumber).toEqual(blockNumber); expect(block.body.txEffects.length).toBe(1); @@ -202,10 +200,8 @@ describe('LightweightCheckpointBuilder', () => { const globalVariables = makeGlobalVariables(blockNumber, slotNumber); const txs = await timesAsync(3, i => makeProcessedTx(globalVariables, 1000 + i)); - // Build block with txs - insertTxsEffects will handle inserting side effects - const { block } = await checkpointBuilder.addBlock(globalVariables, txs, [], { - insertTxsEffects: true, - }); + // Build block with txs + const { block } = await checkpointBuilder.applyEffectsAndSealBlock(globalVariables, txs, []); expect(block.header.globalVariables.blockNumber).toEqual(blockNumber); expect(block.body.txEffects.length).toBe(3); @@ -247,10 +243,8 @@ describe('LightweightCheckpointBuilder', () => { // Create txs for this block const txs = await timesAsync(txsPerBlock, j => makeProcessedTx(globalVariables, 2000 + i * 10 + j)); - // Build block - insertTxsEffects will handle inserting side effects - const { block } = await checkpointBuilder.addBlock(globalVariables, txs, [], { - insertTxsEffects: true, - }); + // Build block + const { block } = await checkpointBuilder.applyEffectsAndSealBlock(globalVariables, txs, []); expect(block.header.globalVariables.blockNumber).toEqual(blockNumber); expect(block.body.txEffects.length).toBe(txsPerBlock); @@ -288,11 +282,11 @@ describe('LightweightCheckpointBuilder', () => { const globalVariables1 = makeGlobalVariables(BlockNumber(1), slotNumber); const txs1 = await timesAsync(2, i => makeProcessedTx(globalVariables1, 3000 + i)); - await checkpointBuilder.addBlock(globalVariables1, txs1, [], { insertTxsEffects: true }); + await checkpointBuilder.applyEffectsAndSealBlock(globalVariables1, txs1, []); const messages = [new Fr(0xb00), new Fr(0xb01)]; const globalVariables2 = makeGlobalVariables(BlockNumber(2), slotNumber); - const { block } = await checkpointBuilder.addBlock(globalVariables2, [], messages, { insertTxsEffects: true }); + const { block } = await checkpointBuilder.applyEffectsAndSealBlock(globalVariables2, [], messages); expect(block.body.txEffects.length).toBe(0); expect(block.header.state.l1ToL2MessageTree.nextAvailableLeafIndex).toBe(messages.length); @@ -303,6 +297,39 @@ describe('LightweightCheckpointBuilder', () => { await fork.close(); }); + + it('sealBlock reuses leaves already in the fork and produces the same block as applyEffectsAndSealBlock', async () => { + const checkpointNumber = CheckpointNumber(1); + const slotNumber = SlotNumber(15); + const constants = makeCheckpointConstants(slotNumber); + const messages = [new Fr(0xb00), new Fr(0xb01), new Fr(0xb02)]; + const globalVariables = makeGlobalVariables(BlockNumber(1), slotNumber); + + // applyEffectsAndSealBlock appends the messages itself. + const fork1 = await worldState.fork(); + const builder1 = LightweightCheckpointBuilder.startNewCheckpoint(checkpointNumber, constants, [], Fr.ZERO, fork1); + const { block: block1 } = await builder1.applyEffectsAndSealBlock(globalVariables, [], messages); + + // sealBlock expects the caller to have appended the messages already. + const fork2 = await worldState.fork(); + const builder2 = LightweightCheckpointBuilder.startNewCheckpoint(checkpointNumber, constants, [], Fr.ZERO, fork2); + await appendL1ToL2MessagesToTree(fork2, messages); + const { block: block2 } = await builder2.sealBlock(globalVariables, [], messages); + + expect(block2.header.equals(block1.header)).toBe(true); + expect(block1.header.state.l1ToL2MessageTree.nextAvailableLeafIndex).toBe(messages.length); + expect((await fork1.getTreeInfo(MerkleTreeId.L1_TO_L2_MESSAGE_TREE)).size).toBe(BigInt(messages.length)); + expect((await fork2.getTreeInfo(MerkleTreeId.L1_TO_L2_MESSAGE_TREE)).size).toBe(BigInt(messages.length)); + + // The messages are still accumulated into the checkpoint's message list when the append is skipped. + const checkpoint1 = await builder1.completeCheckpoint(); + const checkpoint2 = await builder2.completeCheckpoint(); + expect(checkpoint2.header.inboxRollingHash).toEqual(checkpoint1.header.inboxRollingHash); + expect(checkpoint1.header.inboxRollingHash).not.toEqual(Fr.ZERO); + + await fork1.close(); + await fork2.close(); + }); }); describe('error handling', () => { @@ -351,7 +378,7 @@ describe('LightweightCheckpointBuilder', () => { const wrongBlockNumber = BlockNumber(5); const globalVariables = makeGlobalVariables(wrongBlockNumber, slotNumber); - await expect(checkpointBuilder.addBlock(globalVariables, [], [], { insertTxsEffects: true })).rejects.toThrow( + await expect(checkpointBuilder.applyEffectsAndSealBlock(globalVariables, [], [])).rejects.toThrow( /Archive tree next leaf index mismatch/, ); diff --git a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts index b7992afaff75..5a19733441cf 100644 --- a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts +++ b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts @@ -165,15 +165,41 @@ export class LightweightCheckpointBuilder { } /** - * Adds a new block to the checkpoint. The tx effects must have already been inserted into the db if - * this is called after tx processing, if that's not the case, then set `insertTxsEffects` to true. + * Seals a block whose state updates are already in the db: the caller has inserted the tx effects and appended the + * block's L1-to-L2 messages to the tree (so the AVM read the same post-append tree the prover and the block-root + * circuit use). Reads the end state, builds the header and body, and records the block in the checkpoint. * @param l1ToL2Messages - The message leaves this block consumes from the Inbox, in insertion order. + * @param opts.expectedEndState - If set, the db's end state must match it or the block is rejected. */ - public async addBlock( + public sealBlock( globalVariables: GlobalVariables, txs: ProcessedTx[], l1ToL2Messages: Fr[], - opts: { insertTxsEffects?: boolean; expectedEndState?: StateReference } = {}, + opts: { expectedEndState?: StateReference } = {}, + ): Promise<{ block: L2Block; timings: Record }> { + return this.addBlock(globalVariables, txs, l1ToL2Messages, { ...opts, applyStateUpdates: false }); + } + + /** + * Inserts the txs' side effects into the db, appends the block's L1-to-L2 messages to the tree, and then seals the + * block as `sealBlock` does. + * @param l1ToL2Messages - The message leaves this block consumes from the Inbox, in insertion order. + * @param opts.expectedEndState - If set, the db's end state must match it or the block is rejected. + */ + public applyEffectsAndSealBlock( + globalVariables: GlobalVariables, + txs: ProcessedTx[], + l1ToL2Messages: Fr[], + opts: { expectedEndState?: StateReference } = {}, + ): Promise<{ block: L2Block; timings: Record }> { + return this.addBlock(globalVariables, txs, l1ToL2Messages, { ...opts, applyStateUpdates: true }); + } + + private async addBlock( + globalVariables: GlobalVariables, + txs: ProcessedTx[], + l1ToL2Messages: Fr[], + opts: { applyStateUpdates: boolean; expectedEndState?: StateReference }, ): Promise<{ block: L2Block; timings: Record }> { const timings: Record = {}; const isFirstBlock = this.blocks.length === 0; @@ -186,7 +212,7 @@ export class LightweightCheckpointBuilder { const lastArchive = this.lastArchives.at(-1)!; - if (opts.insertTxsEffects) { + if (opts.applyStateUpdates) { this.logger.debug( `Inserting side effects for ${txs.length} txs for block ${globalVariables.blockNumber} into db`, { txs: txs.map(tx => tx.hash.toString()) }, @@ -199,12 +225,14 @@ export class LightweightCheckpointBuilder { timings.insertSideEffects = msInsertSideEffects; } - // Streaming Inbox: insert this block's L1-to-L2 message bundle before reading the end state, - // so the block header's L1-to-L2 tree snapshot reflects it. Bundles are appended compactly (unpadded, at the - // tree's current next-available index). The logical messages are accumulated only once the block is fully built - // (below), so a mid-build failure does not pollute the checkpoint's rolling hash; the rolling hash is recomputed - // over them at checkpoint completion. - await appendL1ToL2MessagesToTree(this.db, l1ToL2Messages); + // Streaming Inbox: the block's L1-to-L2 messages must be in the tree before reading the end state, so the block + // header's L1-to-L2 tree snapshot reflects them. Messages are appended compactly (unpadded, at the tree's current + // next-available index). The logical messages are accumulated only once the block is fully built (below), so a + // mid-build failure does not pollute the checkpoint's rolling hash; the rolling hash is recomputed over them at + // checkpoint completion. + if (opts.applyStateUpdates) { + await appendL1ToL2MessagesToTree(this.db, l1ToL2Messages); + } const [msGetEndState, endState] = await elapsed(() => this.db.getStateReference()); timings.getEndState = msGetEndState; diff --git a/yarn-project/prover-client/src/mocks/test_context.ts b/yarn-project/prover-client/src/mocks/test_context.ts index 08bf74daabcf..cad4fd21bcd1 100644 --- a/yarn-project/prover-client/src/mocks/test_context.ts +++ b/yarn-project/prover-client/src/mocks/test_context.ts @@ -260,10 +260,12 @@ export class TestContext { const txs = blockTxs[i]; const state = blockEndStates[i]; - const { block } = await builder.addBlock(blockGlobalVariables[i], txs, i === 0 ? l1ToL2Messages : [], { - expectedEndState: state, - insertTxsEffects: true, - }); + const { block } = await builder.applyEffectsAndSealBlock( + blockGlobalVariables[i], + txs, + i === 0 ? l1ToL2Messages : [], + { expectedEndState: state }, + ); const header = block.header; this.headers.set(block.number, header); @@ -386,10 +388,12 @@ export class TestContext { const txs = blockTxs[i]; const state = blockEndStates[i]; - const { block } = await builder.addBlock(blockGlobalVariables[i], txs, l1ToL2MessagesPerBlock[i], { - expectedEndState: state, - insertTxsEffects: true, - }); + const { block } = await builder.applyEffectsAndSealBlock( + blockGlobalVariables[i], + txs, + l1ToL2MessagesPerBlock[i], + { expectedEndState: state }, + ); const header = block.header; this.headers.set(block.number, header); diff --git a/yarn-project/sequencer-client/package.json b/yarn-project/sequencer-client/package.json index bc9af4db9e25..4926b18a0a52 100644 --- a/yarn-project/sequencer-client/package.json +++ b/yarn-project/sequencer-client/package.json @@ -52,7 +52,7 @@ "lodash.chunk": "^4.2.0", "tslib": "^2.4.0", "viem": "npm:@aztec/viem@2.38.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@aztec/archiver": "workspace:^", diff --git a/yarn-project/sequencer-client/src/index.ts b/yarn-project/sequencer-client/src/index.ts index c88e2a5f5d74..2b68a635811e 100644 --- a/yarn-project/sequencer-client/src/index.ts +++ b/yarn-project/sequencer-client/src/index.ts @@ -6,6 +6,12 @@ export { Sequencer, SequencerState, type SequencerEvents } from './sequencer/ind // Used by the node to simulate public parts of transactions. Should these be moved to a shared library? // ISSUE(#9832) export * from './global_variable_builder/index.js'; +export { + InboxBucketConfirmationTracker, + type InboxBucketEligibility, + type L1BlockReader, + immediateEligibility, +} from './sequencer/inbox_bucket_eligibility.js'; export { type ConsumedBucketCursor, type InboxBucketSelection, diff --git a/yarn-project/sequencer-client/src/publisher/l1_publisher.integration.test.ts b/yarn-project/sequencer-client/src/publisher/l1_publisher.integration.test.ts index 7c3e8aca92c2..ae94abf9ba6c 100644 --- a/yarn-project/sequencer-client/src/publisher/l1_publisher.integration.test.ts +++ b/yarn-project/sequencer-client/src/publisher/l1_publisher.integration.test.ts @@ -24,7 +24,13 @@ import { EpochCache } from '@aztec/epoch-cache'; import { createEthereumChain } from '@aztec/ethereum/chain'; import { createExtendedL1Client } from '@aztec/ethereum/client'; import { type L1ContractsConfig, getL1ContractsConfigEnvVars } from '@aztec/ethereum/config'; -import { GovernanceProposerContract, RollupContract, SimulationOverridesBuilder } from '@aztec/ethereum/contracts'; +import { + GovernanceProposerContract, + InboxContract, + type MessageSentLog, + RollupContract, + SimulationOverridesBuilder, +} from '@aztec/ethereum/contracts'; import { type DeployAztecL1ContractsArgs, deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { TxUtilsState, createL1TxUtils } from '@aztec/ethereum/l1-tx-utils'; @@ -49,7 +55,7 @@ import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; import { hexToBuffer } from '@aztec/foundation/string'; import { TestDateProvider } from '@aztec/foundation/timer'; -import { InboxAbi, RollupAbi } from '@aztec/l1-artifacts'; +import { RollupAbi } from '@aztec/l1-artifacts'; import { getVKTreeRoot } from '@aztec/noir-protocol-circuits-types/vk-tree'; import { ProtocolContractsList, protocolContractsHash } from '@aztec/protocol-contracts'; import { LightweightCheckpointBuilder } from '@aztec/prover-client/light'; @@ -88,11 +94,12 @@ import { NativeWorldStateService, ServerWorldStateSynchronizer, type WorldStateC import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; -import { type Address, encodeFunctionData, getAbiItem, getAddress, getContract, multicall3Abi } from 'viem'; +import { type Address, encodeFunctionData, getAbiItem, getAddress, multicall3Abi } from 'viem'; import { type PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts'; import { foundry } from 'viem/chains'; import { type SequencerClientConfig, getConfigEnvVars } from '../config.js'; +import { immediateEligibility } from '../sequencer/inbox_bucket_eligibility.js'; import { selectInboxBucketForBlock } from '../sequencer/inbox_bucket_selector.js'; import { sendL1ToL2Message } from './l1_to_l2_messaging.js'; import { SequencerPublisherMetrics } from './sequencer-publisher-metrics.js'; @@ -142,9 +149,9 @@ describe('L1Publisher integration', () => { let builderDb: NativeWorldStateService; - // Backs the blockSource mock's streaming L1->L2 message queries. The world-state synchronizer reconstructs each - // block's consumed message bundle from Inbox buckets when it syncs a block back, so the test - // registers one bucket per published block here (see buildAndPublishBlock). + // Backs the blockSource mock's streaming L1->L2 message queries. The world-state synchronizer reads each block's + // consumed message bundle by leaf count when it syncs a block back, so the test mirrors every Inbox bucket and its + // leaves here before publishing the block that consumes them (see buildAndPublishBlock). let messageSource: MockL1ToL2MessageSource; // The header of the last block @@ -294,6 +301,8 @@ describe('L1Publisher integration', () => { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }, [], ); @@ -372,13 +381,10 @@ describe('L1Publisher integration', () => { getBlockNumber(): Promise { return Promise.resolve(BlockNumber(blocks.at(-1)?.number ?? BlockNumber.ZERO)); }, - // Streaming L1->L2 message reconstruction: the world-state synchronizer resolves each - // block's consumed message bundle from the Inbox buckets registered per published block in buildAndPublishBlock. - getInboxBucketByTotalMsgCount(totalMsgCount: bigint) { - return messageSource.getInboxBucketByTotalMsgCount(totalMsgCount); - }, - getL1ToL2MessagesBetweenBuckets(fromExclusive: bigint, toInclusive: bigint) { - return messageSource.getL1ToL2MessagesBetweenBuckets(fromExclusive, toInclusive); + // Streaming L1->L2 message reconstruction: the world-state synchronizer reads each block's consumed message + // bundle by leaf count from the leaves mirrored per published block in buildAndPublishBlock. + getL1ToL2MessagesBetweenLeafCounts(startLeafCount: bigint, endLeafCount: bigint) { + return messageSource.getL1ToL2MessagesBetweenLeafCounts(startLeafCount, endLeafCount); }, }); @@ -509,7 +515,7 @@ describe('L1Publisher integration', () => { tempFork, ); - await builder.addBlock(globalVariables, txs, l1ToL2Messages, { insertTxsEffects: true }); + await builder.applyEffectsAndSealBlock(globalVariables, txs, l1ToL2Messages); const checkpoint = await builder.completeCheckpoint(); await tempFork.close(); @@ -572,11 +578,7 @@ describe('L1Publisher integration', () => { // then reuses the production `selectInboxBucketForBlock` (which mirrors `ProposeLib.validateInboxConsumption`) to // pick exactly the buckets it must consume, deriving the consumed bundle, the propose bucket hint, and the header // rolling hash from that one selection so header, world state, and L1 agree by construction. - const inbox = getContract({ - address: getAddress(l1ContractAddresses.inboxAddress.toString()), - abi: InboxAbi, - client: l1Client, - }); + const inbox = new InboxContract(l1Client, l1ContractAddresses.inboxAddress); // Every message sent to the Inbox, in insertion order, so each bucket's leaves can be mirrored into messageSource. const allSentMessages: Fr[] = []; let mirroredThroughSeq = 0n; @@ -594,6 +596,9 @@ describe('L1Publisher integration', () => { // and causes a chain prune const l1ToL2Content = range(Math.min(16, MAX_L1_TO_L2_MSGS_PER_CHECKPOINT), 128 * i + 1 + 0x400).map(fr); + // Uncached: viem caches getBlockNumber for its polling interval, and a stale head here would leave the + // MessageSent query below short of the blocks the sends land in. + const l1BlockBeforeSending = await l1Client.getBlockNumber({ cacheTime: 0 }); const sentThisCheckpoint: Fr[] = []; for (let j = 0; j < l1ToL2Content.length; j++) { sentThisCheckpoint.push(await sendToL2(l1ToL2Content[j], recipientAddress)); @@ -602,18 +607,33 @@ describe('L1Publisher integration', () => { // Mirror the Inbox's new buckets (seq, timestamp, rolling hash, totals) and their leaves into messageSource, // so the selector, the world-state synchronizer, and L1 all read the same bucket state. - const currentBucketSeq = await inbox.read.getCurrentBucketSeq(); + const currentBucketSeq = await inbox.getCurrentBucketSeq(); + // The MessageSent log of a bucket's first message names the L1 block the bucket was opened in, which is + // what the archiver records for it. + const openingLogs = new Map(); + const l1BlockAfterSending = await l1Client.getBlockNumber({ cacheTime: 0 }); + for (const log of await inbox.getMessageSentEvents(l1BlockBeforeSending, l1BlockAfterSending)) { + if (!openingLogs.has(log.args.bucketSeq)) { + openingLogs.set(log.args.bucketSeq, log); + } + } for (let seq = mirroredThroughSeq + 1n; seq <= currentBucketSeq; seq++) { - const bucket = await inbox.read.getBucket([seq]); + const bucket = await inbox.getBucket(seq); + const openingLog = openingLogs.get(seq); + if (openingLog === undefined) { + throw new Error(`No MessageSent log found for inbox bucket ${seq}`); + } const bucketMessages = allSentMessages.slice(Number(mirroredThroughTotal), Number(bucket.totalMsgCount)); messageSource.setInboxBucket( { seq, - inboxRollingHash: Fr.fromString(bucket.rollingHash), + inboxRollingHash: bucket.rollingHash, totalMsgCount: bucket.totalMsgCount, timestamp: bucket.timestamp, msgCount: Number(bucket.msgCount), lastMessageIndex: bucket.totalMsgCount - 1n, + l1BlockNumber: openingLog.l1BlockNumber, + l1BlockHash: openingLog.l1BlockHash, }, bucketMessages, ); @@ -649,8 +669,12 @@ describe('L1Publisher integration', () => { const cutoffTimestamp = previousSlotStart - BigInt(config.ethereumSlotDuration); const selection = await selectInboxBucketForBlock({ messageSource, - now: previousSlotStart, - minBucketAgeSeconds: BigInt(config.ethereumSlotDuration), + // Anvil mines on demand, so a bucket's opening L1 block gains a descendant only when the next transaction + // is sent; the harness consumes every bucket it has, anchored at the cutoff so each block's expected + // message set stays pinned to the slot it belongs to. + now: cutoffTimestamp, + isEligible: immediateEligibility, + ethereumSlotDuration: config.ethereumSlotDuration, parent, checkpointStartTotalMsgCount: parent.totalMsgCount, perBlockCap: MAX_L1_TO_L2_MSGS_PER_BLOCK, diff --git a/yarn-project/sequencer-client/src/sequencer/automine/automine_sequencer.ts b/yarn-project/sequencer-client/src/sequencer/automine/automine_sequencer.ts index 92f37f7fc1cd..3acd2ffc5c63 100644 --- a/yarn-project/sequencer-client/src/sequencer/automine/automine_sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/automine/automine_sequencer.ts @@ -38,6 +38,7 @@ import type { GlobalVariableBuilder } from '../../global_variable_builder/global import type { SequencerPublisherFactory } from '../../publisher/sequencer-publisher-factory.js'; import type { SequencerPublisher } from '../../publisher/sequencer-publisher.js'; import type { SequencerConfig } from '../config.js'; +import { immediateEligibility } from '../inbox_bucket_eligibility.js'; import { selectInboxBucketForBlock } from '../inbox_bucket_selector.js'; /** @@ -471,8 +472,10 @@ export class AutomineSequencer { await using fork = await this.deps.worldState.fork(syncedToBlockNumber, { closeDelayMs: 0 }); // Streaming Inbox: automine builds a single-block checkpoint, so its one block is the - // checkpoint's final block; select its bundle from the newest lag-eligible bucket with the last-block censorship - // floor. The parent total is the fork's L1-to-L2 leaf count (compact indexing), which resolves the parent bucket. + // checkpoint's final block; select its bundle from the newest synced bucket with the last-block censorship floor. + // Automine never waits for L1 confirmations: anvil mines on demand, so a bucket's opening block gains a + // descendant only when the next transaction is sent, which may be long after the block that consumes it. + // The parent total is the fork's L1-to-L2 leaf count (compact indexing), which resolves the parent bucket. const parentInfo = await fork.getTreeInfo(MerkleTreeId.L1_TO_L2_MESSAGE_TREE); const parentTotalMsgCount = parentInfo.size; const parentBucket = await this.deps.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(parentTotalMsgCount); @@ -483,7 +486,8 @@ export class AutomineSequencer { const selection = await selectInboxBucketForBlock({ messageSource: this.deps.l1ToL2MessageSource, now: BigInt(Math.floor(this.deps.dateProvider.now() / 1000)), - minBucketAgeSeconds: BigInt(this.deps.l1Constants.ethereumSlotDuration), + isEligible: immediateEligibility, + ethereumSlotDuration: this.deps.l1Constants.ethereumSlotDuration, parent: { seq: parentBucket.seq, totalMsgCount: parentBucket.totalMsgCount }, checkpointStartTotalMsgCount: parentTotalMsgCount, perBlockCap: MAX_L1_TO_L2_MSGS_PER_BLOCK, diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index 1f6bf353480e..f41f118479ee 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -6,6 +6,7 @@ import { IndexWithinCheckpoint, SlotNumber, } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { timesAsync } from '@aztec/foundation/collection'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -75,9 +76,22 @@ import { import { CheckpointProposalJob } from './checkpoint_proposal_job.js'; import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal_job_metrics.js'; import type { SequencerEvents } from './events.js'; +import type { L1BlockReader } from './inbox_bucket_eligibility.js'; import type { SequencerMetrics } from './metrics.js'; import { RequestsTracker } from './requests_tracker.js'; +/** + * L1 view in which every bucket's opening block already has a canonical child, so the job's confirmation tracker + * admits every bucket the archiver mock returns. + */ +const confirmingL1Client: L1BlockReader = { + getBlock: ({ blockNumber }) => + Promise.resolve({ + hash: Buffer32.fromBigInt(blockNumber).toString(), + parentHash: Buffer32.fromBigInt(blockNumber - 1n).toString(), + }), +}; + describe('CheckpointProposalJob', () => { let publisher: MockProxy; let epochCache: MockProxy; @@ -258,6 +272,8 @@ describe('CheckpointProposalJob', () => { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }); l2BlockSource = mock(); @@ -815,6 +831,7 @@ describe('CheckpointProposalJob', () => { p2p, worldState, l1ToL2MessageSource, + confirmingL1Client, l2BlockSource, checkpointsBuilder as unknown as FullNodeCheckpointsBuilder, blockSink, @@ -1199,6 +1216,8 @@ describe('CheckpointProposalJob', () => { timestamp: 0n, msgCount: 2, lastMessageIndex, + l1BlockNumber: seq, + l1BlockHash: Buffer32.fromBigInt(seq), }); l1ToL2MessageSource.getLatestInboxBucketAtOrBefore .mockResolvedValueOnce(makeBucket(2n, 2n, 1n)) @@ -1486,6 +1505,8 @@ describe('CheckpointProposalJob', () => { timestamp: 0n, msgCount: 2, lastMessageIndex: 4n, + l1BlockNumber: 2n, + l1BlockHash: Buffer32.fromBigInt(2n), }; const bundle = Array.from({ length: 5 }, (_, i) => new Fr(i + 1)); l1ToL2MessageSource.getLatestInboxBucketAtOrBefore.mockResolvedValue(bucket); @@ -1526,6 +1547,8 @@ describe('CheckpointProposalJob', () => { timestamp: 0n, msgCount: 2, lastMessageIndex: 4n, + l1BlockNumber: 2n, + l1BlockHash: Buffer32.fromBigInt(2n), }; const bundle = Array.from({ length: 5 }, (_, i) => new Fr(i + 1)); l1ToL2MessageSource.getLatestInboxBucketAtOrBefore.mockResolvedValue(bucket); @@ -1564,6 +1587,8 @@ describe('CheckpointProposalJob', () => { timestamp: 0n, msgCount: 2, lastMessageIndex: 4n, + l1BlockNumber: 2n, + l1BlockHash: Buffer32.fromBigInt(2n), }; const bundle = Array.from({ length: 5 }, (_, i) => new Fr(i + 1)); l1ToL2MessageSource.getLatestInboxBucketAtOrBefore.mockResolvedValue(bucket); @@ -1607,6 +1632,8 @@ describe('CheckpointProposalJob', () => { timestamp: 0n, msgCount: Number(totalMsgCount - (i === 0 ? 0n : totals[i - 1])), lastMessageIndex: totalMsgCount - 1n, + l1BlockNumber: BigInt(i + 1), + l1BlockHash: Buffer32.fromBigInt(BigInt(i + 1)), })); l1ToL2MessageSource.getLatestInboxBucketAtOrBefore.mockResolvedValue(buckets[3]); l1ToL2MessageSource.getInboxBucket.mockImplementation(seq => Promise.resolve(buckets[Number(seq) - 1])); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts index a11d3f131727..8dc6116ed11e 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts @@ -1,5 +1,6 @@ import { EpochCache, PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -49,6 +50,7 @@ import { import { CheckpointProposalJob } from './checkpoint_proposal_job.js'; import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal_job_metrics.js'; import type { SequencerEvents } from './events.js'; +import type { L1BlockReader } from './inbox_bucket_eligibility.js'; import type { SequencerMetrics } from './metrics.js'; import { RequestsTracker } from './requests_tracker.js'; import { SequencerState } from './utils.js'; @@ -189,6 +191,18 @@ class TimingTestCheckpointProposalJob extends CheckpointProposalJob { } } +/** + * L1 view in which every bucket's opening block already has a canonical child, so the job's confirmation tracker + * admits every bucket the archiver mock returns. + */ +const confirmingL1Client: L1BlockReader = { + getBlock: ({ blockNumber }) => + Promise.resolve({ + hash: Buffer32.fromBigInt(blockNumber).toString(), + parentHash: Buffer32.fromBigInt(blockNumber - 1n).toString(), + }), +}; + describe('CheckpointProposalJob Timing Tests', () => { // Realistic production-like timing configuration const ETHEREUM_SLOT_DURATION = 12; // seconds @@ -331,6 +345,7 @@ describe('CheckpointProposalJob Timing Tests', () => { p2p, worldState, l1ToL2MessageSource, + confirmingL1Client, l2BlockSource, checkpointsBuilder as unknown as FullNodeCheckpointsBuilder, blockSink, @@ -462,6 +477,8 @@ describe('CheckpointProposalJob Timing Tests', () => { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }); l2BlockSource = mock(); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 65c4801fc0ee..7a48f9a12d91 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -84,6 +84,7 @@ import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal import { CheckpointVoter } from './checkpoint_voter.js'; import { SequencerInterruptedError } from './errors.js'; import type { SequencerEvents } from './events.js'; +import { InboxBucketConfirmationTracker, type L1BlockReader } from './inbox_bucket_eligibility.js'; import { type ConsumedBucketCursor, type InboxBucketSelection, @@ -147,6 +148,12 @@ export class CheckpointProposalJob implements Traceable { private readonly interruptibleSleep = new InterruptibleSleep(); private interrupted = false; + /** + * Tracks which Inbox buckets have gained a canonical L1 descendant. One per job, so its confirmation cache lives + * exactly as long as the slot whose blocks consult it. + */ + private readonly inboxBucketConfirmations: InboxBucketConfirmationTracker; + /** * Chain state overrides built once per slot in proposeCheckpoint after the checkpoint is * complete. Carries the pending parent override (archive + slot + fee header) for pipelining, @@ -175,6 +182,7 @@ export class CheckpointProposalJob implements Traceable { private readonly p2pClient: P2P, private readonly worldState: WorldStateSynchronizer, private readonly l1ToL2MessageSource: L1ToL2MessageSource, + private readonly l1Client: L1BlockReader, private readonly l2BlockSource: L2BlockSource, private readonly checkpointsBuilder: FullNodeCheckpointsBuilder, private readonly blockSink: L2BlockSink & ProposedCheckpointSink, @@ -204,6 +212,11 @@ export class CheckpointProposalJob implements Traceable { ...bindings, instanceId: `slot-${this.getBuildSlot()}`, }); + this.inboxBucketConfirmations = new InboxBucketConfirmationTracker({ + l1Client: this.l1Client, + ethereumSlotDuration: this.l1Constants.ethereumSlotDuration, + log: this.log, + }); } /** @@ -1207,7 +1220,8 @@ export class CheckpointProposalJob implements Traceable { return selectInboxBucketForBlock({ messageSource: this.l1ToL2MessageSource, now: BigInt(Math.floor(nowSeconds)), - minBucketAgeSeconds: BigInt(this.l1Constants.ethereumSlotDuration), + isEligible: this.inboxBucketConfirmations.isEligible, + ethereumSlotDuration: this.l1Constants.ethereumSlotDuration, parent: state.parent, checkpointStartTotalMsgCount: state.checkpointStartTotalMsgCount, perBlockCap: MAX_L1_TO_L2_MSGS_PER_BLOCK, diff --git a/yarn-project/sequencer-client/src/sequencer/inbox_bucket_eligibility.test.ts b/yarn-project/sequencer-client/src/sequencer/inbox_bucket_eligibility.test.ts new file mode 100644 index 000000000000..e093f8239378 --- /dev/null +++ b/yarn-project/sequencer-client/src/sequencer/inbox_bucket_eligibility.test.ts @@ -0,0 +1,190 @@ +import { Buffer32 } from '@aztec/foundation/buffer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { InboxBucket } from '@aztec/stdlib/messaging'; + +import { describe, expect, it } from '@jest/globals'; +import { BlockNotFoundError } from 'viem'; + +import { + InboxBucketConfirmationTracker, + type L1BlockReader, + type L1BlockRef, + immediateEligibility, +} from './inbox_bucket_eligibility.js'; + +const ETHEREUM_SLOT_DURATION = 12; +const OPENED_AT = 1_000n; +const BLOCK_NUMBER = 500n; + +/** An L1 block as far as the tracker is concerned: only its hash and parent hash are read. */ +type FakeL1Block = L1BlockRef & { number: bigint }; + +const hashOf = (blockNumber: bigint) => Buffer32.fromBigInt(blockNumber).toString(); + +function makeBucket(overrides: Partial = {}): InboxBucket { + return { + seq: 3n, + inboxRollingHash: new Fr(7), + totalMsgCount: 5n, + timestamp: OPENED_AT, + msgCount: 2, + lastMessageIndex: 4n, + l1BlockNumber: BLOCK_NUMBER, + l1BlockHash: Buffer32.fromString(hashOf(BLOCK_NUMBER)), + ...overrides, + }; +} + +/** An L1 client serving a fixed set of blocks by number, counting the reads the tracker makes. */ +function makeL1Client(blocks: FakeL1Block[]): L1BlockReader & { calls: bigint[] } { + const byNumber = new Map(blocks.map(block => [block.number, block])); + const calls: bigint[] = []; + return { + calls, + getBlock({ blockNumber }) { + calls.push(blockNumber); + const block = byNumber.get(blockNumber); + return block === undefined ? Promise.reject(new BlockNotFoundError({ blockNumber })) : Promise.resolve(block); + }, + }; +} + +/** A canonical child of the bucket's opening block. */ +const canonicalChild: FakeL1Block = { + number: BLOCK_NUMBER + 1n, + hash: hashOf(BLOCK_NUMBER + 1n), + parentHash: hashOf(BLOCK_NUMBER), +}; + +/** The opening block itself, still canonical. */ +const canonicalSelf: FakeL1Block = { + number: BLOCK_NUMBER, + hash: hashOf(BLOCK_NUMBER), + parentHash: hashOf(BLOCK_NUMBER - 1n), +}; + +function makeTracker(blocks: FakeL1Block[], clockToleranceSeconds = 0) { + const l1Client = makeL1Client(blocks); + const tracker = new InboxBucketConfirmationTracker({ + l1Client, + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + clockToleranceSeconds, + }); + return { tracker, l1Client }; +} + +describe('immediateEligibility', () => { + it('accepts any bucket at any time', async () => { + await expect(immediateEligibility(makeBucket(), 0n)).resolves.toBe(true); + }); +}); + +describe('InboxBucketConfirmationTracker', () => { + it('does not read L1 before the next Ethereum slot has started', async () => { + const { tracker, l1Client } = makeTracker([canonicalChild]); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 11n)).resolves.toBe(false); + expect(l1Client.calls).toEqual([]); + }); + + it('confirms a bucket whose opening block has a canonical child', async () => { + const { tracker, l1Client } = makeTracker([canonicalChild]); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 12n)).resolves.toBe(true); + expect(l1Client.calls).toEqual([BLOCK_NUMBER + 1n]); + }); + + it('caches a confirmation for the tracker lifetime', async () => { + const { tracker, l1Client } = makeTracker([canonicalChild]); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 12n)).resolves.toBe(true); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 15n)).resolves.toBe(true); + expect(l1Client.calls).toEqual([BLOCK_NUMBER + 1n]); + }); + + it('rejects a bucket whose opening block was replaced, seen through the child parent hash', async () => { + const orphaningChild: FakeL1Block = { + number: BLOCK_NUMBER + 1n, + hash: hashOf(BLOCK_NUMBER + 1n), + // Built on the replacement of the bucket's block, not on the bucket's block itself. + parentHash: hashOf(999n), + }; + const { tracker, l1Client } = makeTracker([orphaningChild]); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 14n)).resolves.toBe(false); + expect(l1Client.calls).toEqual([BLOCK_NUMBER + 1n]); + }); + + it('reads L1 once per second, reusing the rejection within the same one', async () => { + const { tracker, l1Client } = makeTracker([]); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 14n)).resolves.toBe(false); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 14n)).resolves.toBe(false); + expect(l1Client.calls).toEqual([BLOCK_NUMBER + 1n]); + + // A later second re-checks, still before the missed-slot fallback opens. + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 17n)).resolves.toBe(false); + expect(l1Client.calls).toEqual([BLOCK_NUMBER + 1n, BLOCK_NUMBER + 1n]); + }); + + it('falls back to the opening block itself once the next Ethereum slot has fully elapsed', async () => { + const { tracker, l1Client } = makeTracker([canonicalSelf]); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 24n)).resolves.toBe(true); + expect(l1Client.calls).toEqual([BLOCK_NUMBER + 1n, BLOCK_NUMBER]); + }); + + it('rejects when the fallback finds a different block at the same height', async () => { + const replacement: FakeL1Block = { + number: BLOCK_NUMBER, + hash: hashOf(999n), + parentHash: hashOf(BLOCK_NUMBER - 1n), + }; + const { tracker, l1Client } = makeTracker([replacement]); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 24n)).resolves.toBe(false); + expect(l1Client.calls).toEqual([BLOCK_NUMBER + 1n, BLOCK_NUMBER]); + }); + + it('applies the clock tolerance in the permissive direction', async () => { + const { tracker, l1Client } = makeTracker([canonicalSelf], 2); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 22n)).resolves.toBe(true); + expect(l1Client.calls).toEqual([BLOCK_NUMBER + 1n, BLOCK_NUMBER]); + }); + + it('treats the genesis sentinel bucket as eligible without reading L1', async () => { + const { tracker, l1Client } = makeTracker([]); + const genesis = makeBucket({ + seq: 0n, + totalMsgCount: 0n, + msgCount: 0, + timestamp: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, + }); + await expect(tracker.isEligible(genesis, 0n)).resolves.toBe(true); + expect(l1Client.calls).toEqual([]); + }); + + it('leaves a bucket ineligible when the L1 read fails outright, without retrying in the same second', async () => { + const calls: bigint[] = []; + const l1Client: L1BlockReader = { + getBlock({ blockNumber }) { + calls.push(blockNumber); + return Promise.reject(new Error('connection reset')); + }, + }; + const tracker = new InboxBucketConfirmationTracker({ l1Client, ethereumSlotDuration: ETHEREUM_SLOT_DURATION }); + + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 14n)).resolves.toBe(false); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 14n)).resolves.toBe(false); + expect(calls).toEqual([BLOCK_NUMBER + 1n]); + + // The failure is not sticky: the next second tries again. + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 15n)).resolves.toBe(false); + expect(calls).toEqual([BLOCK_NUMBER + 1n, BLOCK_NUMBER + 1n]); + }); + + it('leaves a bucket ineligible when the L1 read does not answer in time', async () => { + const l1Client: L1BlockReader = { getBlock: () => new Promise(() => {}) }; + const tracker = new InboxBucketConfirmationTracker({ + l1Client, + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + l1ReadTimeoutMs: 20, + }); + await expect(tracker.isEligible(makeBucket(), OPENED_AT + 14n)).resolves.toBe(false); + }); +}); diff --git a/yarn-project/sequencer-client/src/sequencer/inbox_bucket_eligibility.ts b/yarn-project/sequencer-client/src/sequencer/inbox_bucket_eligibility.ts new file mode 100644 index 000000000000..0671b4550208 --- /dev/null +++ b/yarn-project/sequencer-client/src/sequencer/inbox_bucket_eligibility.ts @@ -0,0 +1,206 @@ +import { type Logger, createLogger } from '@aztec/foundation/log'; +import { executeTimeout } from '@aztec/foundation/timer'; +import type { InboxBucket } from '@aztec/stdlib/messaging'; + +import { BlockNotFoundError } from 'viem'; + +/** + * Whether a proposer may consume an Inbox bucket in the sub-slot anchored at `nowSeconds`. Eligibility is proposer + * policy, not a consensus rule: L1 accepts any bucket at or below the censorship cutoff, and validators only check + * what L1 checks. A proposer that consumes an Inbox bucket whose L1 block is later reorged out builds a checkpoint + * its own archiver will disown, so it delays consumption until the opening block looks settled. + */ +export type InboxBucketEligibility = (bucket: InboxBucket, nowSeconds: bigint) => Promise; + +/** Consumes every bucket the archiver has, with no L1 confirmation wait. Used by automine, which never waits. */ +export const immediateEligibility: InboxBucketEligibility = () => Promise.resolve(true); + +/** What the confirmation tracker reads off an L1 block: its own identity and its parent link. */ +export type L1BlockRef = { hash: string | null; parentHash: string }; + +/** + * The single L1 read the confirmation tracker performs: a block by number. Narrower than viem's `getBlock` on + * purpose, so tests and any other caller can supply a plain object instead of a whole public client; a viem public + * client satisfies it as-is. + */ +export interface L1BlockReader { + /** Fetches an L1 block header by number; rejects with viem's `BlockNotFoundError` when the block does not exist. */ + getBlock(args: { blockNumber: bigint; includeTransactions?: false }): Promise; +} + +/** Dependencies of an {@link InboxBucketConfirmationTracker}. */ +export type InboxBucketConfirmationTrackerDeps = { + /** L1 client used to look up the bucket's opening block and its child. */ + l1Client: L1BlockReader; + /** Configured Ethereum slot duration in seconds; the unit the confirmation deadlines are expressed in. */ + ethereumSlotDuration: number; + /** Wall-clock tolerance in seconds, applied in the permissive direction only. Defaults to 0. */ + clockToleranceSeconds?: number; + /** + * How long a single L1 block read may take before the bucket is treated as unconfirmed for now. + * Defaults to {@link DEFAULT_L1_READ_TIMEOUT_MS}. These reads sit on the block-building path, where the viem + * default (10s plus retries) would eat the sub-slot. + */ + l1ReadTimeoutMs?: number; + log?: Logger; +}; + +/** Default cap on a single confirmation read, well under a sub-slot. */ +const DEFAULT_L1_READ_TIMEOUT_MS = 2_000; + +/** + * Decides when an Inbox bucket's opening L1 block is safe from the common one-block reorg, using only EL block reads. + * A bucket becomes eligible once a child block is observed on top of the block carrying it, or the following L1 slot + * was missed and that block is still canonical after it. + * + * Concretely, a bucket opened in L1 block `N` (number `h`, hash `H`, timestamp `T`) is eligible once either + * + * - block `h + 1` is visible and its `parentHash` is `H` — `N` then survives even if that child is itself reorged, + * since the replacement builds on the same parent; or + * - slot `S + 1` has fully elapsed (`now >= T + 2E`) and block `h` still hashes to `H`, which covers a missed slot + * `S + 1`. Once `S + 1` has passed with no child, the attestations slot `S`'s committee already cast for `N` keep + * it canonical under honest fork choice, and proposer boost only ever weights the current slot's proposal, so it + * cannot lift a competitor from a past slot above it. + * + * A child with a different parent, or a block `h` with a different hash, means `N` is already orphaned: the bucket is + * permanently ineligible under this tracker, and the archiver will roll it back shortly. + * + * Age in seconds is deliberately not the rule. A replacement block lands at roughly `T + 13..15`, which is *after* + * the `T + 12` tick an age-of-one-Ethereum-slot rule would have released the bucket at, so that rule buys no safety + * at all; and with a missed slot a bucket can be a full Ethereum slot old while still sitting in the latest L1 block. + * Waiting for evidence of a descendant is cheaper too (usually ~`T + 14`). + * + * One tracker is meant to live for one proposal job (one slot). It is frugal with RPCs: it performs no call before a + * child could exist, caches confirmations for its lifetime, caches rejections for the second they were computed in, + * answers every bucket a given L1 block opened from one read, and decides each branch from a single response — + * behind a load-balanced RPC two calls may see different heads, so no branch ever compares two responses. Every read + * is time-boxed: an L1 endpoint that hangs must not cost the proposer its sub-slot. + */ +export class InboxBucketConfirmationTracker { + private readonly l1Client: L1BlockReader; + private readonly ethereumSlotDuration: bigint; + private readonly clockToleranceSeconds: bigint; + private readonly l1ReadTimeoutMs: number; + private readonly log: Logger; + + /** + * Opening L1 blocks known to have a canonical descendant. Confirmed never becomes unconfirmed. Keyed by block + * identity rather than by bucket, so the many buckets a busy L1 block opens all resolve from one read. + */ + private readonly confirmed = new Set(); + + /** + * Opening L1 blocks known to be unconfirmed, keyed to the `nowSeconds` the answer was computed at: a repeat call + * within the same second reuses it, a later one re-reads L1. + */ + private readonly rejectedAt = new Map(); + + constructor(deps: InboxBucketConfirmationTrackerDeps) { + this.l1Client = deps.l1Client; + this.ethereumSlotDuration = BigInt(deps.ethereumSlotDuration); + this.clockToleranceSeconds = BigInt(deps.clockToleranceSeconds ?? 0); + this.l1ReadTimeoutMs = deps.l1ReadTimeoutMs ?? DEFAULT_L1_READ_TIMEOUT_MS; + this.log = deps.log ?? createLogger('sequencer:inbox-bucket-confirmation'); + } + + /** + * Eligibility function for the Inbox bucket selector, bound to this tracker's caches. An L1 read that fails leaves + * the bucket ineligible for now rather than aborting the block: a bucket at or below the censorship cutoff is + * consumed by the checkpoint's last block regardless of eligibility, so a flaky L1 endpoint costs latency, not + * liveness. + */ + public readonly isEligible: InboxBucketEligibility = async (bucket, nowSeconds) => { + // The genesis sentinel holds no messages and was never opened by an L1 block, so there is nothing to confirm. + if (bucket.seq === 0n) { + return true; + } + + const key = `${bucket.l1BlockNumber}:${bucket.l1BlockHash.toString()}`; + try { + return await this.check(bucket, key, nowSeconds); + } catch (err) { + // A read that times out or fails leaves the block neither confirmed nor orphaned; it is pending, and the + // rejection is cached so a flaky endpoint costs one read per second rather than one per bucket. + this.log.debug(`Could not read L1 to confirm Inbox bucket ${bucket.seq}, treating it as pending: ${err}`, { + bucketSeq: bucket.seq, + l1BlockNumber: bucket.l1BlockNumber, + }); + return this.reject(key, nowSeconds); + } + }; + + private async check(bucket: InboxBucket, key: string, nowSeconds: bigint): Promise { + if (this.confirmed.has(key)) { + return true; + } + if (this.rejectedAt.get(key) === nowSeconds) { + return false; + } + + const openedAt = bucket.timestamp; + const permissiveNow = nowSeconds + this.clockToleranceSeconds; + + // Slot S+1 has not started, so no child can exist yet and block h cannot have settled either. + if (permissiveNow < openedAt + this.ethereumSlotDuration) { + return this.reject(key, nowSeconds); + } + + const child = await this.getBlockByNumber(bucket.l1BlockNumber + 1n); + if (child !== undefined) { + if (this.hashMatches(child.parentHash, bucket)) { + this.confirmed.add(key); + return true; + } + this.log.debug(`Inbox bucket ${bucket.seq} sits on an orphaned L1 block`, { + reason: 'bucket_l1_block_orphaned', + bucketSeq: bucket.seq, + l1BlockNumber: bucket.l1BlockNumber, + expectedParent: bucket.l1BlockHash.toString(), + actualParent: child.parentHash, + }); + return this.reject(key, nowSeconds); + } + + // No child: slot S+1 was missed. Once it has fully elapsed, block h being canonical is enough. + if (permissiveNow >= openedAt + 2n * this.ethereumSlotDuration) { + const current = await this.getBlockByNumber(bucket.l1BlockNumber); + if (current !== undefined && this.hashMatches(current.hash, bucket)) { + this.confirmed.add(key); + return true; + } + this.log.debug(`Inbox bucket ${bucket.seq} is no longer on the canonical L1 chain`, { + reason: 'bucket_l1_block_orphaned', + bucketSeq: bucket.seq, + l1BlockNumber: bucket.l1BlockNumber, + expectedHash: bucket.l1BlockHash.toString(), + actualHash: current?.hash, + }); + } + + return this.reject(key, nowSeconds); + } + + private reject(key: string, nowSeconds: bigint): false { + this.rejectedAt.set(key, nowSeconds); + return false; + } + + private hashMatches(hash: string | null, bucket: InboxBucket): boolean { + return hash !== null && hash.toLowerCase() === bucket.l1BlockHash.toString().toLowerCase(); + } + + private async getBlockByNumber(blockNumber: bigint) { + try { + return await executeTimeout( + () => this.l1Client.getBlock({ blockNumber, includeTransactions: false }), + this.l1ReadTimeoutMs, + `L1 getBlock(${blockNumber})`, + ); + } catch (err) { + if (err instanceof BlockNotFoundError) { + return undefined; + } + throw err; + } + } +} diff --git a/yarn-project/sequencer-client/src/sequencer/inbox_bucket_selector.test.ts b/yarn-project/sequencer-client/src/sequencer/inbox_bucket_selector.test.ts index 857b178bcf41..7240fe4ebdf0 100644 --- a/yarn-project/sequencer-client/src/sequencer/inbox_bucket_selector.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/inbox_bucket_selector.test.ts @@ -1,10 +1,23 @@ +import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type InboxBucket, MIN_BLOCKS_FOR_INBOX_CATCHUP, isInboxConsumptionSufficient } from '@aztec/stdlib/messaging'; +import { BlockNotFoundError } from 'viem'; + +import { + InboxBucketConfirmationTracker, + type InboxBucketEligibility, + type L1BlockReader, + immediateEligibility, +} from './inbox_bucket_eligibility.js'; import { type InboxBucketSource, selectInboxBucketForBlock } from './inbox_bucket_selector.js'; -/** A test bucket: its cumulative totals and leaves are derived from a running message count. */ -type TestBucketSpec = { seq: bigint; timestamp: bigint; msgCount: number }; +/** + * A test bucket: its cumulative totals and leaves are derived from a running message count. `l1BlockNumber` defaults + * to the sequence number (one bucket per L1 block); set it explicitly to model an L1 block that rolled the Inbox over + * more than once. + */ +type TestBucketSpec = { seq: bigint; timestamp: bigint; msgCount: number; l1BlockNumber?: bigint }; /** * Builds an in-memory {@link InboxBucketSource} from a list of bucket specs, mirroring the archiver's dense, @@ -30,6 +43,8 @@ function makeSource(specs: TestBucketSpec[]): { timestamp: spec.timestamp, msgCount: spec.msgCount, lastMessageIndex: total - 1n, + l1BlockNumber: spec.l1BlockNumber ?? spec.seq, + l1BlockHash: Buffer32.fromBigInt(spec.l1BlockNumber ?? spec.seq), }); } @@ -63,15 +78,33 @@ function makeSource(specs: TestBucketSpec[]): { const GENESIS_PARENT = { seq: 0n, totalMsgCount: 0n }; // Pinned cross-layer values shared with the L1 Foundry harness: genesisTime=100000, slotDuration=36, -// ethereumSlotDuration=12 (which is also the minimum bucket age). +// ethereumSlotDuration=12. const GENESIS_TIME = 100000n; const SLOT_DURATION = 36n; -const MIN_AGE = 12n; -const cutoffForSlot = (slot: bigint) => GENESIS_TIME + (slot - 1n) * SLOT_DURATION - MIN_AGE; +const ETHEREUM_SLOT_DURATION = 12n; +const cutoffForSlot = (slot: bigint) => GENESIS_TIME + (slot - 1n) * SLOT_DURATION - ETHEREUM_SLOT_DURATION; + +/** + * Stands in for the descendant-confirmed rule without an L1 client: a bucket is eligible once one Ethereum slot has + * passed since it was opened, which is when its child block would be visible. + */ +const eligibleAfterOneEthereumSlot: InboxBucketEligibility = (bucket, now) => + Promise.resolve(bucket.timestamp + ETHEREUM_SLOT_DURATION <= now); + +/** Records which buckets the eligibility rule was asked about, for the walk-bound assertions. */ +function trackingEligibility(inner: InboxBucketEligibility): InboxBucketEligibility & { asked: bigint[] } { + const asked: bigint[] = []; + const fn = async (bucket: InboxBucket, now: bigint) => { + asked.push(bucket.seq); + return await inner(bucket, now); + }; + return Object.assign(fn, { asked }); +} describe('selectInboxBucketForBlock', () => { const baseInput = { - minBucketAgeSeconds: MIN_AGE, + isEligible: eligibleAfterOneEthereumSlot, + ethereumSlotDuration: Number(ETHEREUM_SLOT_DURATION), checkpointStartTotalMsgCount: 0n, perBlockCap: 1024, perCheckpointCap: 1024, @@ -90,17 +123,17 @@ describe('selectInboxBucketForBlock', () => { expect(result.consume).toBe(false); }); - it('picks the newest lag-eligible bucket and derives its bundle from genesis', async () => { + it('picks the newest eligible bucket and derives its bundle from genesis', async () => { const { source } = makeSource([ { seq: 1n, timestamp: 100n, msgCount: 3 }, { seq: 2n, timestamp: 200n, msgCount: 2 }, { seq: 3n, timestamp: 300n, msgCount: 4 }, ]); - // now - lag = 250 -> newest bucket at-or-before 250 is seq 2. + // At now=262 buckets 1 and 2 are confirmed but bucket 3 (opened at 300) is still in the future. const result = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: 250n + MIN_AGE, + now: 250n + ETHEREUM_SLOT_DURATION, parent: GENESIS_PARENT, }); expect(result).toMatchObject({ consume: true }); @@ -110,25 +143,143 @@ describe('selectInboxBucketForBlock', () => { } }); - it('treats a bucket exactly one Ethereum slot old as eligible (inclusive lag boundary)', async () => { - const { source } = makeSource([{ seq: 1n, timestamp: 500n, msgCount: 1 }]); - // Bucket age exactly == the minimum: timestamp == now - minBucketAgeSeconds. + it('skips an unconfirmed newest bucket and consumes through the newest confirmed one', async () => { + const { source } = makeSource([ + { seq: 1n, timestamp: 100n, msgCount: 3 }, + { seq: 2n, timestamp: 200n, msgCount: 2 }, + { seq: 3n, timestamp: 300n, msgCount: 4 }, + ]); + const isEligible = trackingEligibility(bucket => Promise.resolve(bucket.seq <= 2n)); const result = await selectInboxBucketForBlock({ ...baseInput, + isEligible, messageSource: source, - now: 500n + MIN_AGE, + now: 1_000n, parent: GENESIS_PARENT, }); - expect(result.consume).toBe(true); + expect(result).toMatchObject({ consume: true }); + if (result.consume) { + expect(result.bucket.seq).toBe(2n); + expect(result.bundle).toHaveLength(5); + } + // The walk starts at the archiver's head bucket and stops at the first eligible one. + expect(isEligible.asked).toEqual([3n, 2n]); + }); + + it('consumes nothing when no bucket past the parent is eligible', async () => { + const { source } = makeSource([ + { seq: 1n, timestamp: 100n, msgCount: 3 }, + { seq: 2n, timestamp: 200n, msgCount: 2 }, + ]); + const result = await selectInboxBucketForBlock({ + ...baseInput, + isEligible: () => Promise.resolve(false), + messageSource: source, + now: 1_000n, + parent: GENESIS_PARENT, + }); + expect(result.consume).toBe(false); + }); + + it('falls back to the newest settled bucket once the walk runs out of L1 blocks', async () => { + // Buckets 5..12 were all opened within the last Ethereum slot and none of them is confirmed; buckets 1..4 are + // old enough that eligibility no longer depends on a descendant showing up. + const { source } = makeSource([ + ...Array.from({ length: 4 }, (_, i) => ({ seq: BigInt(i + 1), timestamp: BigInt((i + 1) * 100), msgCount: 1 })), + ...Array.from({ length: 8 }, (_, i) => ({ seq: BigInt(i + 5), timestamp: 977n + BigInt(i), msgCount: 1 })), + ]); + const isEligible = trackingEligibility(bucket => Promise.resolve(bucket.seq <= 4n)); + const result = await selectInboxBucketForBlock({ + ...baseInput, + isEligible, + messageSource: source, + now: 1_000n, + parent: GENESIS_PARENT, + }); + expect(result).toMatchObject({ consume: true }); + if (result.consume) { + expect(result.bucket.seq).toBe(4n); + } + // Eight distinct L1 blocks are walked, then the newest bucket at or before now - 2 * ethereumSlotDuration (976). + expect(isEligible.asked).toEqual([12n, 11n, 10n, 9n, 8n, 7n, 6n, 5n, 4n]); + }); + + it('consumes nothing when the settled fallback bucket is ineligible too', async () => { + const { source } = makeSource([ + ...Array.from({ length: 4 }, (_, i) => ({ seq: BigInt(i + 1), timestamp: BigInt((i + 1) * 100), msgCount: 1 })), + ...Array.from({ length: 8 }, (_, i) => ({ seq: BigInt(i + 5), timestamp: 977n + BigInt(i), msgCount: 1 })), + ]); + const isEligible = trackingEligibility(() => Promise.resolve(false)); + const result = await selectInboxBucketForBlock({ + ...baseInput, + isEligible, + messageSource: source, + now: 1_000n, + parent: GENESIS_PARENT, + }); + expect(result.consume).toBe(false); + expect(isEligible.asked).toEqual([12n, 11n, 10n, 9n, 8n, 7n, 6n, 5n, 4n]); + }); + + it('spends one L1 read on all the buckets a single L1 block opened', async () => { + // One busy L1 block rolled the Inbox over ten times. Those ten buckets share an opening block, so they share one + // eligibility answer and must not crowd out the confirmed bucket behind them. + const { source } = makeSource([ + { seq: 1n, timestamp: 100n, msgCount: 1, l1BlockNumber: 99n }, + ...Array.from({ length: 10 }, (_, i) => ({ + seq: BigInt(i + 2), + timestamp: 200n, + msgCount: 1, + l1BlockNumber: 100n, + })), + ]); + // Block 100 has no child yet; block 99 does, and it is the bucket's own opening block. + const reads: bigint[] = []; + const l1Client: L1BlockReader = { + getBlock({ blockNumber }) { + reads.push(blockNumber); + return blockNumber === 100n + ? Promise.resolve({ + hash: Buffer32.fromBigInt(100n).toString(), + parentHash: Buffer32.fromBigInt(99n).toString(), + }) + : Promise.reject(new BlockNotFoundError({ blockNumber })); + }, + }; + const tracker = new InboxBucketConfirmationTracker({ l1Client, ethereumSlotDuration: 12 }); - // One second younger than the minimum-age boundary: not yet eligible. - const tooYoung = await selectInboxBucketForBlock({ + const result = await selectInboxBucketForBlock({ ...baseInput, + isEligible: tracker.isEligible, messageSource: source, - now: 500n + MIN_AGE - 1n, + now: 214n, // block 100 could have a child by now, but none is visible; block 99's child is block 100 parent: GENESIS_PARENT, }); - expect(tooYoung.consume).toBe(false); + + expect(result).toMatchObject({ consume: true }); + if (result.consume) { + expect(result.bucket.seq).toBe(1n); + } + // One read per distinct opening L1 block, not per bucket: the ten siblings cost a single lookup. + expect(reads).toEqual([101n, 100n]); + }); + + it('consumes every bucket the archiver has under immediate eligibility', async () => { + const { source } = makeSource([ + { seq: 1n, timestamp: 100n, msgCount: 3 }, + { seq: 2n, timestamp: 999n, msgCount: 2 }, + ]); + const result = await selectInboxBucketForBlock({ + ...baseInput, + isEligible: immediateEligibility, + messageSource: source, + now: 1_000n, + parent: GENESIS_PARENT, + }); + expect(result).toMatchObject({ consume: true }); + if (result.consume) { + expect(result.bucket.seq).toBe(2n); + } }); it('walks back to the newest bucket that fits the per-block cap', async () => { @@ -141,7 +292,7 @@ describe('selectInboxBucketForBlock', () => { const result = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: 300n + MIN_AGE, + now: 300n + ETHEREUM_SLOT_DURATION, parent: GENESIS_PARENT, perBlockCap: 400, }); @@ -162,7 +313,7 @@ describe('selectInboxBucketForBlock', () => { const result = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: 200n + MIN_AGE, + now: 200n + ETHEREUM_SLOT_DURATION, parent: { seq: 1n, totalMsgCount: buckets.get(1n)!.totalMsgCount }, checkpointStartTotalMsgCount: 0n, perCheckpointCap: 1000, @@ -175,11 +326,11 @@ describe('selectInboxBucketForBlock', () => { { seq: 1n, timestamp: 100n, msgCount: 2 }, { seq: 2n, timestamp: 260n, msgCount: 3 }, ]); - // Block 1 at now-lag=150: only bucket 1 eligible. + // Block 1 at now=162: only bucket 1 is confirmed. const first = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: 150n + MIN_AGE, + now: 150n + ETHEREUM_SLOT_DURATION, parent: GENESIS_PARENT, }); expect(first).toMatchObject({ consume: true }); @@ -188,11 +339,11 @@ describe('selectInboxBucketForBlock', () => { } expect(first.bucket.seq).toBe(1n); - // Block 2, later sub-slot (now-lag=300): bucket 2 has now aged in; parent is block 1's bucket. + // Block 2, later sub-slot (now=312): bucket 2 is now confirmed too; parent is block 1's bucket. const second = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: 300n + MIN_AGE, + now: 300n + ETHEREUM_SLOT_DURATION, parent: { seq: first.bucket.seq, totalMsgCount: first.bucket.totalMsgCount }, checkpointStartTotalMsgCount: 0n, }); @@ -205,14 +356,14 @@ describe('selectInboxBucketForBlock', () => { }); it('applies the cutoff as a consumption floor on the last block', async () => { - // Bucket sits at the cutoff for slot 10 but is younger than now-lag, so a non-last block skips it. + // Bucket sits at the cutoff for slot 10 but is not yet confirmed, so a non-last block skips it. const cutoff = cutoffForSlot(10n); const { source } = makeSource([{ seq: 1n, timestamp: cutoff, msgCount: 5 }]); const nonLast = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: cutoff + MIN_AGE - 1n, // bucket is one second too young for lag eligibility + now: cutoff + ETHEREUM_SLOT_DURATION - 1n, // bucket's opening block has no descendant yet parent: GENESIS_PARENT, isLastBlock: false, cutoffTimestamp: cutoff, @@ -222,7 +373,7 @@ describe('selectInboxBucketForBlock', () => { const last = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: cutoff + MIN_AGE - 1n, + now: cutoff + ETHEREUM_SLOT_DURATION - 1n, parent: GENESIS_PARENT, isLastBlock: true, cutoffTimestamp: cutoff, @@ -242,7 +393,7 @@ describe('selectInboxBucketForBlock', () => { const mustConsume = await selectInboxBucketForBlock({ ...baseInput, messageSource: atCutoff.source, - now: cutoff, // before lag would make it eligible, forcing reliance on the cutoff floor + now: cutoff, // before eligibility would admit it, forcing reliance on the cutoff floor parent: GENESIS_PARENT, isLastBlock: true, cutoffTimestamp: cutoff, @@ -273,7 +424,7 @@ describe('selectInboxBucketForBlock', () => { const result = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: cutoff + MIN_AGE, + now: cutoff + ETHEREUM_SLOT_DURATION, parent: GENESIS_PARENT, perBlockCap: 256, perCheckpointCap: 1024, @@ -299,7 +450,7 @@ describe('selectInboxBucketForBlock', () => { const result = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: cutoff + MIN_AGE, + now: cutoff + ETHEREUM_SLOT_DURATION, parent: GENESIS_PARENT, perBlockCap: 256, perCheckpointCap: 1024, @@ -335,7 +486,7 @@ describe('selectInboxBucketForBlock', () => { ...baseInput, ...caps, messageSource: source, - now: cutoff + MIN_AGE, + now: cutoff + ETHEREUM_SLOT_DURATION, parent, isLastBlock, cutoffTimestamp: cutoff, @@ -356,12 +507,52 @@ describe('selectInboxBucketForBlock', () => { expect(await sufficiencyAt({ seq: 6n, totalMsgCount: 771n })).toBe(false); }); + it('makes a bucket opened at the cutoff confirmable within the build frame', async () => { + // The censorship floor is only satisfiable if a bucket opened at the very last moment L1 makes mandatory still + // becomes eligible while the checkpoint is being built. Slot S's cutoff is one Ethereum slot before the build + // frame opens, so the bucket's child block lands ~2s into the frame, before the first block is proposed. + const cutoff = cutoffForSlot(10n); + const buildFrameStart = GENESIS_TIME + 9n * SLOT_DURATION; + expect(cutoff).toBe(buildFrameStart - ETHEREUM_SLOT_DURATION); + + const { source } = makeSource([{ seq: 1n, timestamp: cutoff, msgCount: 2 }]); + const childVisibleAt = cutoff + 14n; + let now = buildFrameStart; + const l1Client: L1BlockReader = { + getBlock: ({ blockNumber }) => + blockNumber === 2n && now >= childVisibleAt + ? Promise.resolve({ + hash: Buffer32.fromBigInt(2n).toString(), + parentHash: Buffer32.fromBigInt(1n).toString(), + }) + : Promise.reject(new BlockNotFoundError({ blockNumber })), + }; + const tracker = new InboxBucketConfirmationTracker({ l1Client, ethereumSlotDuration: 12 }); + + // First sub-slot of the build frame: the child is already visible, so the mandatory bucket is consumable + // without falling back on the cutoff override. + now = buildFrameStart + 3n; + const result = await selectInboxBucketForBlock({ + ...baseInput, + isEligible: tracker.isEligible, + messageSource: source, + now, + parent: GENESIS_PARENT, + isLastBlock: false, + cutoffTimestamp: cutoff, + }); + expect(result).toMatchObject({ consume: true }); + if (result.consume) { + expect(result.bucket.seq).toBe(1n); + } + }); + it('consumes nothing when even the first bucket past the parent exceeds the per-checkpoint cap (cap-escape)', async () => { const { source } = makeSource([{ seq: 1n, timestamp: 100n, msgCount: 2000 }]); const result = await selectInboxBucketForBlock({ ...baseInput, messageSource: source, - now: 100n + MIN_AGE, + now: 100n + ETHEREUM_SLOT_DURATION, parent: GENESIS_PARENT, perBlockCap: 4096, perCheckpointCap: 1024, diff --git a/yarn-project/sequencer-client/src/sequencer/inbox_bucket_selector.ts b/yarn-project/sequencer-client/src/sequencer/inbox_bucket_selector.ts index a96eb03a1a1c..e05834860cba 100644 --- a/yarn-project/sequencer-client/src/sequencer/inbox_bucket_selector.ts +++ b/yarn-project/sequencer-client/src/sequencer/inbox_bucket_selector.ts @@ -1,6 +1,19 @@ import type { Fr } from '@aztec/foundation/curves/bn254'; +import { type Logger, createLogger } from '@aztec/foundation/log'; import { type InboxBucket, type L1ToL2MessageSource, isInboxConsumptionSufficient } from '@aztec/stdlib/messaging'; +import type { InboxBucketEligibility } from './inbox_bucket_eligibility.js'; + +/** + * How many distinct L1 blocks back from the archiver's head the eligibility walk looks before falling back. The bound + * counts L1 blocks rather than buckets because eligibility is a property of the opening block: a busy L1 block that + * rolls the Inbox over several times yields many buckets that all resolve from a single L1 read, and bounding by + * buckets would let one such block hide every older, already-confirmed bucket behind it. + */ +const MAX_ELIGIBILITY_WALK_L1_BLOCKS = 8; + +const log: Logger = createLogger('sequencer:inbox-bucket-selector'); + /** The subset of the archiver's Inbox-bucket queries the selector needs. */ export type InboxBucketSource = Pick< L1ToL2MessageSource, @@ -19,14 +32,21 @@ export type ConsumedBucketCursor = Pick; export type SelectInboxBucketInput = { /** Archiver Inbox-bucket queries. */ messageSource: InboxBucketSource; - /** Wall-clock time of this sub-slot, in seconds; the lag-eligibility anchor. */ + /** Wall-clock time of this sub-slot, in seconds; the anchor eligibility is evaluated at. */ now: bigint; /** - * Minimum bucket age in seconds for a bucket to be lag-eligible this sub-slot: one configured Ethereum slot, the - * same value the validator's acceptance check applies (which documents why age in seconds is only a proxy for the - * L1 reorg depth this really guards against). + * Whether a bucket may be consumed in this sub-slot. The live sequencer passes an + * `InboxBucketConfirmationTracker`, which waits for the bucket's opening L1 block to gain a canonical descendant; + * automine passes `immediateEligibility`. Eligibility is proposer policy: L1 and validators accept whatever the + * censorship cutoff and the caps allow, whenever it is proposed. */ - minBucketAgeSeconds: bigint; + isEligible: InboxBucketEligibility; + /** + * Ethereum slot duration in seconds. Only used to pick the fallback bucket when the eligibility walk hits its + * bound: a bucket opened at or before `now - 2 * ethereumSlotDuration` can be decided outright, without waiting + * for a descendant to appear. + */ + ethereumSlotDuration: number; /** The last bucket consumed by this checkpoint so far (parent checkpoint's at the first block). */ parent: ConsumedBucketCursor; /** Cumulative Inbox message count consumed as of the parent checkpoint; the per-checkpoint cap origin. */ @@ -74,9 +94,13 @@ export type InboxBucketSelection = InboxBucketConsumption & { * Selects the newest Inbox bucket a block streams from, mirroring the L1 consumption predicate in * `ProposeLib.validateInboxConsumption`. The policy, per block: * - * 1. Pick the newest lag-eligible bucket: the newest bucket opened at or before `now - minBucketAgeSeconds`. On the - * checkpoint's last block, also consider the cutoff bucket (newest opened at or before `cutoffTimestamp`) and take - * whichever is newer, so the checkpoint reaches the censorship floor even if the sub-slot lag preferred less. + * 1. Pick the newest eligible bucket per the caller's eligibility rule (descendant-confirmed for the live sequencer, + * immediate for automine), walking back from the archiver's head bucket and stopping at the first eligible one. + * The walk spans a bounded number of distinct opening L1 blocks, past which it jumps straight to the newest bucket + * old enough to decide without waiting for a descendant. On + * the checkpoint's last block, also consider the cutoff bucket (newest opened at or before `cutoffTimestamp`) and + * take whichever is newer, so the checkpoint reaches the censorship floor even when eligibility preferred less: a + * mandatory bucket is consumed whether or not it is confirmed. * 2. If nothing is newer than the parent bucket, consume nothing. * 3. Otherwise walk back from the candidate to the newest bucket whose consumption fits both the per-block cap * (`bucket.totalMsgCount - parent.totalMsgCount`) and the per-checkpoint cap @@ -88,8 +112,8 @@ export type InboxBucketSelection = InboxBucketConsumption & { * onto a prefix that still leaves a mandatory bucket behind; that is reported as * `insufficientFinalBlockCapacity` rather than passed off as a usable selection. * - * The `<=` comparisons make a bucket exactly `minBucketAgeSeconds` old lag-eligible and a bucket exactly at the cutoff - * mandatory, matching the strict `>` "past cutoff" test on L1 (`next.timestamp > cutoff` leaves it optional). + * The `<=` comparison on the cutoff makes a bucket exactly at the cutoff mandatory, matching the strict `>` "past + * cutoff" test on L1 (`next.timestamp > cutoff` leaves it optional). * * A single bucket never exceeds the per-block cap by construction (the Inbox bucket size is at most the per-block cap), * so per-block walk-back always lands on at least one bucket; only the per-checkpoint cap can force consuming nothing. @@ -116,8 +140,6 @@ export async function selectInboxBucketForBlock(input: SelectInboxBucketInput): async function selectConsumption(input: SelectInboxBucketInput): Promise { const { messageSource, - now, - minBucketAgeSeconds, parent, checkpointStartTotalMsgCount, perBlockCap, @@ -126,7 +148,7 @@ async function selectConsumption(input: SelectInboxBucketInput): Promise `${bucket.l1BlockNumber}:${bucket.l1BlockHash.toString()}`; + +/** + * Step 1 of {@link selectInboxBucketForBlock}: the newest bucket the caller's eligibility rule admits, found by + * walking back from the archiver's head bucket. The walk is bounded by {@link MAX_ELIGIBILITY_WALK_L1_BLOCKS} distinct + * opening L1 blocks; hitting the bound means the whole visible tail is unconfirmed, which on a healthy chain does not + * happen, and the walk then falls back to the newest bucket old enough to be decided outright + * ({@link selectSettledBucket}) rather than consuming nothing. + */ +async function selectEligibleBucket(input: SelectInboxBucketInput): Promise { + const { messageSource, now, isEligible, parent } = input; + + const walkedL1Blocks = new Set(); + let candidate = await messageSource.getLatestInboxBucketAtOrBefore(now); + while (candidate !== undefined && candidate.seq > parent.seq) { + walkedL1Blocks.add(openingL1Block(candidate)); + if (walkedL1Blocks.size > MAX_ELIGIBILITY_WALK_L1_BLOCKS) { + const settled = await selectSettledBucket(input); + log.warn( + `No eligible Inbox bucket within ${MAX_ELIGIBILITY_WALK_L1_BLOCKS} L1 blocks of the head bucket; ` + + (settled === undefined ? 'consuming nothing' : `falling back to bucket ${settled.seq}`), + { headBucketSeq: candidate.seq, parentBucketSeq: parent.seq, fallbackBucketSeq: settled?.seq, now }, + ); + return settled; + } + if (await isEligible(candidate, now)) { + return candidate; + } + candidate = await messageSource.getInboxBucket(candidate.seq - 1n); + } + return candidate; +} + +/** + * The newest bucket whose opening L1 block is old enough that eligibility no longer depends on a descendant showing + * up: past `now - 2 * ethereumSlotDuration` the descendant-confirmed rule decides from the opening block alone, so + * one read settles it. Used only when the eligibility walk gives up, so a long tail of unconfirmed buckets still + * lets a checkpoint consume the messages behind it. + */ +async function selectSettledBucket(input: SelectInboxBucketInput): Promise { + const { messageSource, now, isEligible, parent, ethereumSlotDuration } = input; + + const settledBy = now - 2n * BigInt(ethereumSlotDuration); + const settled = await messageSource.getLatestInboxBucketAtOrBefore(settledBy); + if (settled === undefined || settled.seq <= parent.seq) { + return undefined; + } + return (await isEligible(settled, now)) ? settled : undefined; +} diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index 4eff6b347658..035bc9097da9 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -7,6 +7,7 @@ import { IndexWithinCheckpoint, SlotNumber, } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { omit, times, timesParallel } from '@aztec/foundation/collection'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -372,6 +373,8 @@ describe('sequencer', () => { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }); validatorClient = mock(); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index a7f343506dfa..b8234d288ac4 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -780,6 +780,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { it('getInboxBucket', async () => { const result = await context.client.getInboxBucket(2n); - expect(result).toMatchObject({ seq: 2n, msgCount: 3 }); + expect(result).toMatchObject({ + seq: 2n, + msgCount: 3, + l1BlockNumber: 20n, + l1BlockHash: Buffer32.fromBigInt(20n), + }); }); it('getInboxBucketByTotalMsgCount', async () => { @@ -236,6 +243,25 @@ describe('ArchiverApiSchema', () => { expect(result).toEqual([expect.any(Fr)]); }); + it('getMessagePosition', async () => { + const result = await context.client.getMessagePosition(3n); + expect(result).toEqual({ totalMessageCount: 3n, rollingHash: expect.any(Fr) }); + }); + + it('getSyncedMessagePosition', async () => { + const result = await context.client.getSyncedMessagePosition(); + expect(result).toEqual({ totalMessageCount: 3n, rollingHash: expect.any(Fr) }); + }); + + it('getL1ToL2MessageRange', async () => { + const result = await context.client.getL1ToL2MessageRange(2n, 3n); + expect(result).toEqual({ + messages: [expect.any(Fr)], + start: { totalMessageCount: 2n, rollingHash: expect.any(Fr) }, + end: { totalMessageCount: 3n, rollingHash: expect.any(Fr) }, + }); + }); + it('registerContractFunctionSignatures', async () => { await context.client.registerContractFunctionSignatures(['test()']); }); @@ -604,6 +630,8 @@ class MockArchiver implements ArchiverApi { timestamp: 100n, msgCount: 3, lastMessageIndex: 2n, + l1BlockNumber: 20n, + l1BlockHash: Buffer32.fromBigInt(20n), }); } getInboxBucket(seq: bigint): Promise { @@ -615,6 +643,8 @@ class MockArchiver implements ArchiverApi { timestamp: 100n, msgCount: 3, lastMessageIndex: 2n, + l1BlockNumber: 20n, + l1BlockHash: Buffer32.fromBigInt(20n), }); } getInboxBucketByTotalMsgCount(totalMsgCount: bigint): Promise { @@ -626,6 +656,8 @@ class MockArchiver implements ArchiverApi { timestamp: 100n, msgCount: 3, lastMessageIndex: 2n, + l1BlockNumber: 20n, + l1BlockHash: Buffer32.fromBigInt(20n), }); } getL1ToL2MessagesBetweenBuckets(fromExclusive: bigint, toInclusive: bigint): Promise { @@ -638,6 +670,22 @@ class MockArchiver implements ArchiverApi { expect(typeof endLeafCount).toEqual('bigint'); return Promise.resolve([Fr.random()]); } + getMessagePosition(totalMessageCount: bigint): Promise { + expect(typeof totalMessageCount).toEqual('bigint'); + return Promise.resolve({ totalMessageCount, rollingHash: Fr.random() }); + } + getSyncedMessagePosition(): Promise { + return Promise.resolve({ totalMessageCount: 3n, rollingHash: Fr.random() }); + } + getL1ToL2MessageRange(startLeafCount: bigint, endLeafCount: bigint): Promise { + expect(typeof startLeafCount).toEqual('bigint'); + expect(typeof endLeafCount).toEqual('bigint'); + return Promise.resolve({ + messages: [Fr.random()], + start: { totalMessageCount: startLeafCount, rollingHash: Fr.random() }, + end: { totalMessageCount: endLeafCount, rollingHash: Fr.random() }, + }); + } getL1Constants(): Promise { return Promise.resolve(EmptyL1RollupConstants); } diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index 605f34e03233..1614b2d7ae56 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -26,7 +26,11 @@ import { L1RollupConstantsSchema } from '../epoch-helpers/index.js'; import { LogResultSchema } from '../logs/log_result.js'; import { PrivateLogsQuerySchema, PublicLogsQuerySchema } from '../logs/logs_query.js'; import { InboxBucketSchema } from '../messaging/inbox_bucket.js'; -import type { L1ToL2MessageSource } from '../messaging/l1_to_l2_message_source.js'; +import { + InboxMessagePositionSchema, + InboxMessageRangeSchema, + type L1ToL2MessageSource, +} from '../messaging/l1_to_l2_message_source.js'; import { L2ToL1MembershipWitnessSchema } from '../messaging/l2_to_l1_membership.js'; import { optional, schemas } from '../schemas/schemas.js'; import { indexedTxSchema } from '../tx/indexed_tx_effect.js'; @@ -154,6 +158,12 @@ export const ArchiverApiSchema: ApiSchemaFor = { input: z.tuple([schemas.BigInt, schemas.BigInt]), output: z.array(schemas.Fr), }), + getMessagePosition: z.function({ input: z.tuple([schemas.BigInt]), output: InboxMessagePositionSchema.optional() }), + getSyncedMessagePosition: z.function({ input: z.tuple([]), output: InboxMessagePositionSchema }), + getL1ToL2MessageRange: z.function({ + input: z.tuple([schemas.BigInt, schemas.BigInt]), + output: InboxMessageRangeSchema, + }), getDebugFunctionName: z.function({ input: z.tuple([schemas.AztecAddress, schemas.FunctionSelector]), output: optional(z.string()), diff --git a/yarn-project/stdlib/src/messaging/inbox_bucket.test.ts b/yarn-project/stdlib/src/messaging/inbox_bucket.test.ts index 4211aebbf9b7..c85ddb9cb776 100644 --- a/yarn-project/stdlib/src/messaging/inbox_bucket.test.ts +++ b/yarn-project/stdlib/src/messaging/inbox_bucket.test.ts @@ -1,8 +1,28 @@ +import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { jsonParseWithSchema, jsonStringify } from '@aztec/foundation/json-rpc'; import type { InboxBucket } from './inbox_bucket.js'; -import { InboxBucketRef } from './inbox_bucket.js'; +import { InboxBucketRef, InboxBucketSchema } from './inbox_bucket.js'; + +const makeBucket = (overrides: Partial = {}): InboxBucket => ({ + seq: 12n, + inboxRollingHash: new Fr(0xabcn), + totalMsgCount: 30n, + timestamp: 1_650_000_000n, + msgCount: 3, + lastMessageIndex: 29n, + l1BlockNumber: 4_200n, + l1BlockHash: Buffer32.fromBigInt(4_200n), + ...overrides, +}); + +describe('InboxBucket', () => { + it('round-trips through its zod schema', () => { + const bucket = makeBucket(); + expect(jsonParseWithSchema(jsonStringify(bucket), InboxBucketSchema)).toEqual(bucket); + }); +}); describe('InboxBucketRef', () => { it('serializes and deserializes round-trip', () => { @@ -27,14 +47,7 @@ describe('InboxBucketRef', () => { }); it('derives from a bucket snapshot', () => { - const bucket: InboxBucket = { - seq: 12n, - inboxRollingHash: new Fr(0xabcn), - totalMsgCount: 30n, - timestamp: 1_650_000_000n, - msgCount: 3, - lastMessageIndex: 29n, - }; + const bucket = makeBucket(); const ref = InboxBucketRef.fromBucket(bucket); expect(ref.bucketSeq).toBe(bucket.seq); expect(ref.bucketTimestamp).toBe(bucket.timestamp); diff --git a/yarn-project/stdlib/src/messaging/inbox_bucket.ts b/yarn-project/stdlib/src/messaging/inbox_bucket.ts index e7700882c587..87f48eef1886 100644 --- a/yarn-project/stdlib/src/messaging/inbox_bucket.ts +++ b/yarn-project/stdlib/src/messaging/inbox_bucket.ts @@ -1,3 +1,4 @@ +import type { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type ZodFor, schemas } from '@aztec/foundation/schemas'; import { BufferReader, bigintToUInt64BE, serializeToBuffer } from '@aztec/foundation/serialize'; @@ -27,6 +28,19 @@ export type InboxBucket = { msgCount: number; /** Global leaf index of the last message absorbed into this bucket. */ lastMessageIndex: bigint; + /** + * L1 block in which this bucket was opened. Buckets are keyed by L1 block timestamp, so on a chain that allows + * consecutive blocks to share a timestamp (anvil with manual mining, for instance) a bucket may span several L1 + * blocks and only the opening one is recorded. Production Ethereum timestamps are strictly increasing, so there a + * bucket never spans more than one block. + */ + l1BlockNumber: bigint; + /** + * Hash of that L1 block as seen when the bucket was synced; lets callers test whether the bucket is still on the + * canonical chain. Since only the opening block is recorded, a reorg that touches only a later co-timestamped block + * of the same bucket is not detectable from this hash. + */ + l1BlockHash: Buffer32; }; export const InboxBucketSchema = z.object({ @@ -36,6 +50,8 @@ export const InboxBucketSchema = z.object({ timestamp: schemas.BigInt, msgCount: schemas.Integer, lastMessageIndex: schemas.BigInt, + l1BlockNumber: schemas.BigInt, + l1BlockHash: schemas.Buffer32, }) satisfies z.ZodType; /** diff --git a/yarn-project/stdlib/src/messaging/l1_to_l2_message_source.ts b/yarn-project/stdlib/src/messaging/l1_to_l2_message_source.ts index 4040483f144b..20c1ecdd23f8 100644 --- a/yarn-project/stdlib/src/messaging/l1_to_l2_message_source.ts +++ b/yarn-project/stdlib/src/messaging/l1_to_l2_message_source.ts @@ -1,8 +1,48 @@ -import type { Fr } from '@aztec/foundation/curves/bn254'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { schemas } from '@aztec/foundation/schemas'; + +import { z } from 'zod'; import type { L2Tips } from '../block/l2_block_source.js'; import type { InboxBucket } from './inbox_bucket.js'; +/** + * A position in the ordered Inbox message sequence: the number of messages up to it, which is also the compact index + * of the next message, and the consensus rolling hash over exactly those messages. Position zero has a zero hash. + * Block headers commit to the count (the L1-to-L2 tree leaf count) and checkpoint headers to the hash, so a position + * identifies a message prefix independently of how L1 partitioned the messages into buckets. + */ +export type InboxMessagePosition = { + /** Number of messages in the sequence up to this position. */ + totalMessageCount: bigint; + /** Consensus rolling hash (truncated sha256 chain) over the messages up to this position; zero at position zero. */ + rollingHash: Fr; +}; + +export const InboxMessagePositionSchema = z.object({ + totalMessageCount: schemas.BigInt, + rollingHash: Fr.schema, +}) satisfies z.ZodType; + +/** + * The messages in a compact count range together with the positions the range starts and ends at, all read from one + * snapshot of the source, so `end.rollingHash` authenticates exactly `messages` appended after `start`. + */ +export type InboxMessageRange = { + /** The message leaves in the range, in insertion order. */ + messages: Fr[]; + /** The position the range starts at, inclusive. */ + start: InboxMessagePosition; + /** The position the range ends at, exclusive; equal to `start` for an empty range. */ + end: InboxMessagePosition; +}; + +export const InboxMessageRangeSchema = z.object({ + messages: z.array(schemas.Fr), + start: InboxMessagePositionSchema, + end: InboxMessagePositionSchema, +}) satisfies z.ZodType; + /** * Interface of classes allowing for the retrieval of L1 to L2 messages. */ @@ -53,14 +93,41 @@ export interface L1ToL2MessageSource { /** * Returns the message leaves in the cumulative Inbox message-count range `[startLeafCount, endLeafCount)`, in - * insertion order. The bounds are compact L1-to-L2 tree leaf counts, which every block header - * carries, so a consumer can ask for the messages a block or checkpoint consumed without resolving Inbox buckets - * itself. Both bounds must land on a bucket boundary the source has synced; it throws otherwise. + * insertion order. The bounds are compact L1-to-L2 tree leaf counts, which every block header carries, so a + * consumer can ask for the messages a block or checkpoint consumed without resolving Inbox buckets itself. + * + * The bounds address canonical compact message indices and need not land on a boundary of the bucket partition the + * source currently holds, so a published block's committed leaf counts stay resolvable after an L1 reorg has merged + * the bucket that ended at one of them. An invalid range, one past the synced tip, or one the source cannot serve + * whole throws, so an empty result always means the range holds no messages. * @param startLeafCount - The cumulative Inbox message count the range starts at, inclusive. * @param endLeafCount - The cumulative Inbox message count the range ends at, exclusive. */ getL1ToL2MessagesBetweenLeafCounts(startLeafCount: bigint, endLeafCount: bigint): Promise; + /** + * Returns the position of the Inbox message sequence after `totalMessageCount` messages: that count and the rolling + * hash over them. Position zero always resolves, with a zero hash; a count past the synced tip returns undefined, + * and a negative one throws. + * @param totalMessageCount - The cumulative Inbox message count (leaf count) whose position to resolve. + */ + getMessagePosition(totalMessageCount: bigint): Promise; + + /** Returns the position at the source's synced tip: how many messages it holds and the rolling hash over them. */ + getSyncedMessagePosition(): Promise; + + /** + * Returns the messages in the cumulative Inbox message-count range `[startLeafCount, endLeafCount)` together with + * the positions at both bounds, all read from one snapshot of the source, so the ending hash authenticates exactly + * the returned messages and cannot describe a different version of the sequence than they do. An empty range is + * valid and returns equal positions. The range contract is that of `getL1ToL2MessagesBetweenLeafCounts`: an + * invalid range, or one the source cannot serve whole (including its starting position), throws rather than + * returning a partial or empty result. + * @param startLeafCount - The cumulative Inbox message count the range starts at, inclusive. + * @param endLeafCount - The cumulative Inbox message count the range ends at, exclusive. + */ + getL1ToL2MessageRange(startLeafCount: bigint, endLeafCount: bigint): Promise; + /** * Returns the tips of the L2 chain. */ diff --git a/yarn-project/stdlib/src/tx/public_simulation_output.test.ts b/yarn-project/stdlib/src/tx/public_simulation_output.test.ts index 84151682f58f..a40dcb907652 100644 --- a/yarn-project/stdlib/src/tx/public_simulation_output.test.ts +++ b/yarn-project/stdlib/src/tx/public_simulation_output.test.ts @@ -1,6 +1,6 @@ import { jsonStringify } from '@aztec/foundation/json-rpc'; -import { PublicSimulationOutput } from './public_simulation_output.js'; +import { NestedProcessReturnValues, PublicSimulationOutput } from './public_simulation_output.js'; describe('PublicSimulationOutput', () => { it('serializes to JSON', async () => { @@ -9,3 +9,11 @@ describe('PublicSimulationOutput', () => { expect(PublicSimulationOutput.schema.parse(JSON.parse(json))).toEqual(output); }); }); + +describe('NestedProcessReturnValues', () => { + it('serializes nested values to JSON', () => { + const values = NestedProcessReturnValues.random(3); + const json = jsonStringify(values); + expect(NestedProcessReturnValues.schema.parse(JSON.parse(json))).toEqual(values); + }); +}); diff --git a/yarn-project/stdlib/src/tx/public_simulation_output.ts b/yarn-project/stdlib/src/tx/public_simulation_output.ts index 984a747fcf3d..3b7ad85655e2 100644 --- a/yarn-project/stdlib/src/tx/public_simulation_output.ts +++ b/yarn-project/stdlib/src/tx/public_simulation_output.ts @@ -35,12 +35,7 @@ export class NestedProcessReturnValues { } static get schema(): ZodFor { - return z - .object({ - values: NullishToUndefined(z.array(schemas.Fr)), - nested: z.array(z.lazy(() => NestedProcessReturnValues.schema)), - }) - .transform(({ values, nested }) => new NestedProcessReturnValues(values, nested)); + return nestedProcessReturnValuesSchema; } static fromPlainObject(obj: any): NestedProcessReturnValues { @@ -62,6 +57,15 @@ export class NestedProcessReturnValues { } } +// Built once at module load so that the self-reference below resolves to a single schema instance. Rebuilding it per +// access (as a getter body would) makes the recursion unbounded. +const nestedProcessReturnValuesSchema: ZodFor = z + .object({ + values: NullishToUndefined(z.array(schemas.Fr)), + nested: z.array(z.lazy(() => nestedProcessReturnValuesSchema)), + }) + .transform(({ values, nested }) => new NestedProcessReturnValues(values, nested)); + /** * Outputs of processing the public component of a transaction. */ diff --git a/yarn-project/txe/package.json b/yarn-project/txe/package.json index dcd9deb7f1c2..c269e658c53b 100644 --- a/yarn-project/txe/package.json +++ b/yarn-project/txe/package.json @@ -86,7 +86,7 @@ "@aztec/stdlib": "workspace:^", "@aztec/world-state": "workspace:^", "msgpackr": "^1.11.2", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@jest/globals": "^30.0.0", diff --git a/yarn-project/validator-client/src/checkpoint_builder.test.ts b/yarn-project/validator-client/src/checkpoint_builder.test.ts index 1d140f683bc3..df07c251a53b 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.test.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.test.ts @@ -11,7 +11,7 @@ import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/bra import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { TestDateProvider } from '@aztec/foundation/timer'; -import type { LightweightCheckpointBuilder } from '@aztec/prover-client/light'; +import { LightweightCheckpointBuilder } from '@aztec/prover-client/light'; import type { AvmSimulator, PublicContractsDB, PublicProcessor } from '@aztec/simulator/server'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash, L2Block } from '@aztec/stdlib/block'; @@ -26,6 +26,7 @@ import { type PublicProcessorValidator, type WorldStateSynchronizer, } from '@aztec/stdlib/interfaces/server'; +import { MerkleTreeId } from '@aztec/stdlib/trees'; import { type CheckpointGlobalVariables, type GlobalVariables, @@ -34,8 +35,9 @@ import { TxHash, } from '@aztec/stdlib/tx'; import type { TelemetryClient } from '@aztec/telemetry-client'; +import { NativeWorldStateService } from '@aztec/world-state/native'; -import { describe, expect, it, jest } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; import { CheckpointBuilder, FullNodeCheckpointsBuilder } from './checkpoint_builder.js'; @@ -171,7 +173,7 @@ describe('CheckpointBuilder', () => { async function mockSuccessfulBlock() { const block = await L2Block.random(blockNumber); - lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block, timings: {} }); + lightweightCheckpointBuilder.sealBlock.mockResolvedValue({ block, timings: {} }); processor.process.mockResolvedValue([[{ hash: TxHash.random() } as ProcessedTx], [], [], [], []]); return block; } @@ -206,7 +208,7 @@ describe('CheckpointBuilder', () => { lightweightCheckpointBuilder.getBlockCount.mockReturnValue(0); const expectedBlock = await L2Block.random(blockNumber); - lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); + lightweightCheckpointBuilder.sealBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); processor.process.mockResolvedValue([ [{ hash: Fr.random() } as unknown as ProcessedTx], @@ -221,12 +223,12 @@ describe('CheckpointBuilder', () => { expect(result.block).toBe(expectedBlock); expect(result.numTxs).toBe(1); expect(result.failedTxs).toEqual([]); - expect(lightweightCheckpointBuilder.addBlock).toHaveBeenCalled(); + expect(lightweightCheckpointBuilder.sealBlock).toHaveBeenCalled(); }); it('allows building an empty block when minValidTxs is 0', async () => { const expectedBlock = await L2Block.random(blockNumber, { txsPerBlock: 0 }); - lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); + lightweightCheckpointBuilder.sealBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); // No transactions processed processor.process.mockResolvedValue([ @@ -241,7 +243,7 @@ describe('CheckpointBuilder', () => { expect(result.block).toBe(expectedBlock); expect(result.numTxs).toBe(0); - expect(lightweightCheckpointBuilder.addBlock).toHaveBeenCalled(); + expect(lightweightCheckpointBuilder.sealBlock).toHaveBeenCalled(); }); it('throws InsufficientValidTxsError when fewer txs than minValidTxs', async () => { @@ -258,7 +260,7 @@ describe('CheckpointBuilder', () => { checkpointBuilder.buildBlock([], blockNumber, 1000n, validatorOpts({ minValidTxs: 1 })), ).rejects.toThrow(InsufficientValidTxsError); - expect(lightweightCheckpointBuilder.addBlock).not.toHaveBeenCalled(); + expect(lightweightCheckpointBuilder.sealBlock).not.toHaveBeenCalled(); }); it('does not update state when some txs succeed but below minValidTxs', async () => { @@ -280,19 +282,19 @@ describe('CheckpointBuilder', () => { expect(err).toBeInstanceOf(InsufficientValidTxsError); expect((err as InsufficientValidTxsError).processedCount).toBe(1); expect((err as InsufficientValidTxsError).minRequired).toBe(2); - expect(lightweightCheckpointBuilder.addBlock).not.toHaveBeenCalled(); + expect(lightweightCheckpointBuilder.sealBlock).not.toHaveBeenCalled(); }); it('defaults to minValidTxs=0 when not specified, allowing empty blocks', async () => { const expectedBlock = await L2Block.random(blockNumber, { txsPerBlock: 0 }); - lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); + lightweightCheckpointBuilder.sealBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); processor.process.mockResolvedValue([[], [], [], [], []]); const result = await checkpointBuilder.buildBlock([], blockNumber, 1000n, validatorOpts()); expect(result.numTxs).toBe(0); - expect(lightweightCheckpointBuilder.addBlock).toHaveBeenCalled(); + expect(lightweightCheckpointBuilder.sealBlock).toHaveBeenCalled(); }); }); @@ -578,7 +580,7 @@ describe('CheckpointBuilder', () => { ]); const expectedBlock = await L2Block.random(blockNumber); - lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); + lightweightCheckpointBuilder.sealBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); processor.process.mockResolvedValue([[{ hash: Fr.random() } as unknown as ProcessedTx], [], [], [], []]); // Build block 2 @@ -596,7 +598,7 @@ describe('CheckpointBuilder', () => { setupBuilder({ rollupManaLimit }); const expectedBlock = await L2Block.random(blockNumber); - lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); + lightweightCheckpointBuilder.sealBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); processor.process.mockResolvedValue([[{ hash: Fr.random() } as unknown as ProcessedTx], [], [], [], []]); const capturedL2GasLimits: number[] = []; @@ -629,7 +631,7 @@ describe('CheckpointBuilder', () => { setupBuilder({ rollupManaLimit }); const expectedBlock = await L2Block.random(blockNumber); - lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); + lightweightCheckpointBuilder.sealBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); processor.process.mockResolvedValue([[{ hash: Fr.random() } as unknown as ProcessedTx], [], [], [], []]); const capturedL2GasLimits: number[] = []; @@ -667,7 +669,7 @@ describe('CheckpointBuilder', () => { setupBuilder({ rollupManaLimit }); const expectedBlock = await L2Block.random(blockNumber); - lightweightCheckpointBuilder.addBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); + lightweightCheckpointBuilder.sealBlock.mockResolvedValue({ block: expectedBlock, timings: {} }); processor.process.mockResolvedValue([[{ hash: Fr.random() } as unknown as ProcessedTx], [], [], [], []]); // Explicit per-block limit (100k) is TIGHTER than redistribution. @@ -789,6 +791,102 @@ describe('CheckpointBuilder', () => { expect(capped.maxBlobFields!).toBeLessThan(largestDeployBlobFields); }); }); + + // These cases use a real world state fork and a real LightweightCheckpointBuilder, since the position of the + // block's L1-to-L2 messages relative to tx execution is invisible with a mocked fork. + describe('buildBlock with streaming L1-to-L2 messages (real world state)', () => { + let worldState: NativeWorldStateService; + let realFork: MerkleTreeWriteOperations; + let lightweight: LightweightCheckpointBuilder; + let builder: TestCheckpointBuilder; + + const messages = [new Fr(0xb00), new Fr(0xb01), new Fr(0xb02)]; + const firstBlockNumber = BlockNumber(1); + + const getL1ToL2TreeSize = () => realFork.getTreeInfo(MerkleTreeId.L1_TO_L2_MESSAGE_TREE).then(info => info.size); + + beforeEach(async () => { + worldState = await NativeWorldStateService.tmp(); + realFork = await worldState.fork(); + lightweight = LightweightCheckpointBuilder.startNewCheckpoint(checkpointNumber, constants, [], Fr.ZERO, realFork); + builder = new TestCheckpointBuilder( + lightweight, + realFork, + config, + contractDataSource, + dateProvider, + telemetryClient, + mock(), + ); + }); + + afterEach(async () => { + await realFork.close(); + await worldState.close(); + }); + + it("the block's messages are in the fork when the public processor runs", async () => { + let treeSizeDuringExecution: bigint | undefined; + let leafIndicesDuringExecution: (bigint | undefined)[] | undefined; + processor.process.mockImplementation(async () => { + treeSizeDuringExecution = await getL1ToL2TreeSize(); + leafIndicesDuringExecution = await realFork.findLeafIndices(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, messages); + return [[], [], [], [], []]; + }); + + const { block } = await builder.buildBlock([], firstBlockNumber, 1000n, { + ...validatorOpts(), + l1ToL2Messages: messages, + }); + + // The AVM must read the same post-append tree the prover and the block-root circuit use. + expect(treeSizeDuringExecution).toBe(3n); + expect(leafIndicesDuringExecution).toEqual([0n, 1n, 2n]); + expect(block.header.state.l1ToL2MessageTree.nextAvailableLeafIndex).toBe(3); + // The messages are appended exactly once. + expect(await getL1ToL2TreeSize()).toBe(3n); + }); + + it('a failed block rolls its messages back', async () => { + processor.process.mockRejectedValue(new Error('processor failure')); + + await expect( + builder.buildBlock([], firstBlockNumber, 1000n, { ...validatorOpts(), l1ToL2Messages: messages }), + ).rejects.toThrow('processor failure'); + + expect(await getL1ToL2TreeSize()).toBe(0n); + expect(lightweight.getBlocks()).toEqual([]); + }); + + it('a block below minValidTxs rolls its messages back', async () => { + processor.process.mockResolvedValue([[], [], [], [], []]); + + await expect( + builder.buildBlock([], firstBlockNumber, 1000n, { + ...validatorOpts({ minValidTxs: 1 }), + l1ToL2Messages: messages, + }), + ).rejects.toThrow(InsufficientValidTxsError); + + expect(await getL1ToL2TreeSize()).toBe(0n); + expect(lightweight.getBlocks()).toEqual([]); + }); + + it('an empty or absent message list leaves the tree untouched', async () => { + processor.process.mockResolvedValue([[], [], [], [], []]); + + const { block: block1 } = await builder.buildBlock([], firstBlockNumber, 1000n, { + ...validatorOpts(), + l1ToL2Messages: [], + }); + expect(block1.header.state.l1ToL2MessageTree.nextAvailableLeafIndex).toBe(0); + expect(await getL1ToL2TreeSize()).toBe(0n); + + const { block: block2 } = await builder.buildBlock([], BlockNumber(firstBlockNumber + 1), 1000n, validatorOpts()); + expect(block2.header.state.l1ToL2MessageTree.nextAvailableLeafIndex).toBe(0); + expect(await getL1ToL2TreeSize()).toBe(0n); + }); + }); }); describe('FullNodeCheckpointsBuilder', () => { diff --git a/yarn-project/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index 7f7c10dddbaa..f55a850bc57d 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -33,6 +33,7 @@ import { type WorldStateSynchronizer, } from '@aztec/stdlib/interfaces/server'; import { type DebugLogStore, NullDebugLogStore } from '@aztec/stdlib/logs'; +import { appendL1ToL2MessagesToTree } from '@aztec/stdlib/messaging'; import { MerkleTreeId } from '@aztec/stdlib/trees'; import { type CheckpointGlobalVariables, GlobalVariables, StateReference, Tx } from '@aztec/stdlib/tx'; import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client'; @@ -118,6 +119,14 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { const forkCheckpoint = await ForkCheckpoint.new(this.fork); try { + // Insert this block's streaming L1-to-L2 messages before executing its txs. The prover node appends them to its + // fork before re-executing, and the block-root circuit pins each tx's L1-to-L2 tree snapshot to the post-append + // root, so the AVM here must read the same tree or a tx consuming a message this block inserts would revert at + // proposal time and succeed at proving time. Appending inside the fork checkpoint means a failed block rolls the + // leaves back together with the tx effects. + const l1ToL2Messages = opts.l1ToL2Messages ?? []; + await appendL1ToL2MessagesToTree(this.fork, l1ToL2Messages); + const [publicProcessorDuration, [processedTxs, failedTxs, usedTxs]] = await elapsed(() => processor.process(pendingTxs, cappedOpts, validator), ); @@ -131,15 +140,9 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { // Commit the fork checkpoint await forkCheckpoint.commit(); - // Add block to checkpoint, inserting this block's streaming L1-to-L2 message bundle (if any) into the fork. - const { block } = await this.checkpointBuilder.addBlock( - globalVariables, - processedTxs, - opts.l1ToL2Messages ?? [], - { - expectedEndState: opts.expectedEndState, - }, - ); + const { block } = await this.checkpointBuilder.sealBlock(globalVariables, processedTxs, l1ToL2Messages, { + expectedEndState: opts.expectedEndState, + }); this.contractsDB.commitCheckpoint(); diff --git a/yarn-project/validator-client/src/proposal_handler.test.ts b/yarn-project/validator-client/src/proposal_handler.test.ts index 265e1eceb053..72ff62e9163c 100644 --- a/yarn-project/validator-client/src/proposal_handler.test.ts +++ b/yarn-project/validator-client/src/proposal_handler.test.ts @@ -4,6 +4,7 @@ import { INITIAL_L2_BLOCK_NUM, MAX_BLOCKS_PER_CHECKPOINT } from '@aztec/constant import type { EpochCache } from '@aztec/epoch-cache'; import { MAX_FEE_ASSET_PRICE_MODIFIER_BPS } from '@aztec/ethereum/contracts'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { TestDateProvider } from '@aztec/foundation/timer'; @@ -561,6 +562,127 @@ describe('ProposalHandler checkpoint validation', () => { return makeHeader({ epochOutHash, ...overrides }); } + /** A checkpoint whose first block is `firstBlockNumber`, with the parent block reporting `parentLeafCount`. */ + function setupCheckpointWithConsumption(opts: { + firstBlockNumber: number; + parentLeafCount: number | undefined; + lastLeafCount: number; + }) { + const { firstBlockNumber, parentLeafCount, lastLeafCount } = opts; + const block = { + archive: new AppendOnlyTreeSnapshot(archiveRoot, 1), + number: firstBlockNumber, + checkpointNumber: CheckpointNumber(1), + header: { + globalVariables: GlobalVariables.empty({ slotNumber: SlotNumber(1) }), + state: { l1ToL2MessageTree: { nextAvailableLeafIndex: lastLeafCount } }, + }, + } as unknown as L2Block; + blockSource.getBlocksForSlot.mockResolvedValue([block]); + blockSource.getBlockData.mockImplementation(query => + Promise.resolve( + 'number' in query && query.number === firstBlockNumber - 1 + ? parentLeafCount === undefined + ? undefined + : ({ + header: { state: { l1ToL2MessageTree: { nextAvailableLeafIndex: parentLeafCount } } }, + } as unknown as BlockData) + : ({ header: makeBlockHeader() } as BlockData), + ), + ); + return block; + } + + // An L1 reorg can merge the buckets a checkpointed block and the proposal's last block consumed through into + // others. The archiver never prunes a checkpointed block, so the parent position stays interior to the current + // partition forever, and the proposal's own final position may sit interior to it as well while the messages + // themselves are unchanged. The consumed bundle is derived from the committed counts alone, so neither position + // needs to resolve to a bucket; whether the final position is a live bucket end is L1's publication rule. + it('derives the consumed bundle by count when neither checkpoint bound is a current bucket boundary', async () => { + const header = makeHeader(); + const consumedMessages = [new Fr(1000), new Fr(1001), new Fr(1002), new Fr(1003)]; + setupDeepValidationMocks({ header }); + const block = setupCheckpointWithConsumption({ firstBlockNumber: 5, parentLeafCount: 3, lastLeafCount: 7 }); + + l1ToL2MessageSource.getInboxBucketByTotalMsgCount.mockResolvedValue(undefined); + l1ToL2MessageSource.getL1ToL2MessagesBetweenLeafCounts.mockResolvedValue(consumedMessages); + + await handler.handleCheckpointProposal( + await makeProposal({ archiveRoot, checkpointHeader: header }), + proposalInfo, + ); + + expect(l1ToL2MessageSource.getL1ToL2MessagesBetweenLeafCounts).toHaveBeenCalledWith(3n, 7n); + expect(l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets).not.toHaveBeenCalled(); + expect(checkpointsBuilder.openCheckpoint).toHaveBeenCalledWith( + CheckpointNumber(1), + expect.anything(), + expect.anything(), + consumedMessages, + expect.anything(), + expect.anything(), + expect.anything(), + [block], + expect.anything(), + ); + }); + + it('derives an empty bundle without any message query when the checkpoint consumed nothing', async () => { + const header = makeHeader(); + setupDeepValidationMocks({ header }); + const block = setupCheckpointWithConsumption({ firstBlockNumber: 5, parentLeafCount: 3, lastLeafCount: 3 }); + + await handler.handleCheckpointProposal( + await makeProposal({ archiveRoot, checkpointHeader: header }), + proposalInfo, + ); + + expect(l1ToL2MessageSource.getL1ToL2MessagesBetweenLeafCounts).not.toHaveBeenCalled(); + expect(checkpointsBuilder.openCheckpoint).toHaveBeenCalledWith( + CheckpointNumber(1), + expect.anything(), + expect.anything(), + [], + expect.anything(), + expect.anything(), + expect.anything(), + [block], + expect.anything(), + ); + }); + + // A missing parent is a local chain-availability failure. Deriving an empty bundle instead would make the + // rolling-hash recomputation fail and classify the proposer's valid checkpoint as a slashable header mismatch. + it('reports a fetch error instead of deriving an empty bundle when the parent block is unavailable', async () => { + const header = makeHeader(); + setupDeepValidationMocks({ header }); + setupCheckpointWithConsumption({ firstBlockNumber: 5, parentLeafCount: undefined, lastLeafCount: 7 }); + + const result = await handler.handleCheckpointProposal( + await makeProposal({ archiveRoot, checkpointHeader: header }), + proposalInfo, + ); + + expect(result).toEqual({ isValid: false, reason: 'block_fetch_error', checkpointNumber: CheckpointNumber(1) }); + expect(l1ToL2MessageSource.getL1ToL2MessagesBetweenLeafCounts).not.toHaveBeenCalled(); + expect(checkpointsBuilder.openCheckpoint).not.toHaveBeenCalled(); + }); + + it('surfaces an unavailable consumed range instead of deriving an empty bundle', async () => { + const header = makeHeader(); + setupDeepValidationMocks({ header }); + setupCheckpointWithConsumption({ firstBlockNumber: 5, parentLeafCount: 3, lastLeafCount: 7 }); + l1ToL2MessageSource.getL1ToL2MessagesBetweenLeafCounts.mockRejectedValue( + new Error('Inbox message range [3, 7) is not fully synced'), + ); + + await expect( + handler.handleCheckpointProposal(await makeProposal({ archiveRoot, checkpointHeader: header }), proposalInfo), + ).rejects.toThrow(/not fully synced/); + + expect(checkpointsBuilder.openCheckpoint).not.toHaveBeenCalled(); + }); + it('returns checkpoint_header_mismatch when headers differ', async () => { const proposalHeader = makeHeader(); const computedHeader = makeHeader({ totalManaUsed: new Fr(999) }); @@ -740,6 +862,8 @@ describe('ProposalHandler checkpoint validation', () => { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }; /** @@ -876,8 +1000,7 @@ describe('ProposalHandler checkpoint validation', () => { it('processes a rebuilt proposal once the stale fork at this number is pruned', async () => { const { proposal, blockHandler } = await setupGenesisProposal(Fr.random()); - // Past the minimum bucket age but well before the slot-1 attestation deadline (40s), so the prune wait has - // budget to retry. + // Well before the slot-1 attestation deadline (40s), so the prune wait has budget to retry. dateProvider.setTime(5_000); // Stale block (different archive) on the first read, then pruned (undefined) on the retry. blockSource.getBlockData.mockResolvedValueOnce(blockAt(Fr.random())).mockResolvedValue(undefined); @@ -966,11 +1089,13 @@ describe('ProposalHandler checkpoint validation', () => { timestamp: 100n, msgCount: 2, lastMessageIndex: 1n, + l1BlockNumber: 10n, + l1BlockHash: Buffer32.fromBigInt(10n), ...overrides, }); /** Genesis-parent streaming block proposal at slot 1, with the handler wired to reach the streaming checks. */ - async function setupStreamingProposal(bucketRef: InboxBucketRef | undefined) { + async function setupStreamingProposal(bucketRef: InboxBucketRef | undefined, options: { nowMs?: number } = {}) { const proposal = ValidatedBlockProposal( await makeBlockProposal({ blockHeader: makeBlockHeader(1, { slotNumber: SlotNumber(1) }), @@ -987,8 +1112,7 @@ describe('ProposalHandler checkpoint validation', () => { const txProvider = mock(); txProvider.getTxsForBlockProposal.mockResolvedValue({ txs: [], missingTxs: [] } as any); - // Well past the minimum bucket age (one 12s Ethereum slot) for a bucket opened at t=100. - dateProvider.setTime(1_000_000); + dateProvider.setTime(options.nowMs ?? 1_000_000); const blockHandler = new ProposalHandler( checkpointsBuilder, @@ -1086,6 +1210,138 @@ describe('ProposalHandler checkpoint validation', () => { expect.anything(), ); }); + + // A bucket the proposer already consumed is on L1 by construction, so a bucket this node cannot resolve is + // (usually) local archiver lag, not a divergence: the handler forces a sync and re-checks until the + // attestation deadline instead of dropping the attestation on the spot. + describe('bucket sync wait', () => { + // attestation_deadline(slot=1) = 1*24 + 24 - 8 = 40s. Waits run on a real timer against the remaining + // budget read off the fake clock, so holding it 2s short of the deadline keeps the tests short. + const DEADLINE_MS = 40_000; + const WAIT_BUDGET_MS = 2_000; + const BEFORE_DEADLINE_MS = DEADLINE_MS - WAIT_BUDGET_MS; + const PAST_DEADLINE_MS = DEADLINE_MS + 1_000; + const WAIT_INTERVAL_MS = 500; + + /** The bucket a proposal under test consumes through. */ + const eligibleBucket = (overrides: Partial = {}) => bucket({ timestamp: 10n, ...overrides }); + + /** Wires the parent-bucket lookup and the bundle read for a proposal that consumes `eligibleBucket()`. */ + function mockAcceptedSurroundings() { + l1ToL2MessageSource.getInboxBucketByTotalMsgCount.mockResolvedValue( + eligibleBucket({ seq: 0n, totalMsgCount: 0n, msgCount: 0 }), + ); + l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets.mockResolvedValue([new Fr(1000), new Fr(1001)]); + } + + it('attests once the referenced bucket shows up on a later archiver sync', async () => { + const ref = new InboxBucketRef(1n, 10n, new Fr(0xabc)); + const { proposal, blockHandler } = await setupStreamingProposal(ref, { nowMs: BEFORE_DEADLINE_MS }); + mockAcceptedSurroundings(); + // Unknown on arrival, synced by the time the wait re-checks. + l1ToL2MessageSource.getInboxBucket.mockResolvedValueOnce(undefined).mockResolvedValue(eligibleBucket()); + jest.spyOn(blockHandler, 'reexecuteTransactions').mockResolvedValue({ block: undefined } as any); + + const result = await blockHandler.handleBlockProposal(proposal, {} as any, true); + + expect(result.isValid).toBe(true); + expect(result.blockNumber).toEqual(BlockNumber(INITIAL_L2_BLOCK_NUM)); + }); + + it('rejects with bucket_unknown when the bucket never syncs, no earlier than the deadline', async () => { + const ref = new InboxBucketRef(1n, 10n, new Fr(0xabc)); + const { proposal, blockHandler, txProvider } = await setupStreamingProposal(ref, { + nowMs: BEFORE_DEADLINE_MS, + }); + mockAcceptedSurroundings(); + l1ToL2MessageSource.getInboxBucket.mockResolvedValue(undefined); + + const startMs = Date.now(); + const result = await blockHandler.handleBlockProposal(proposal, {} as any, true); + const elapsedMs = Date.now() - startMs; + + expect(result).toEqual({ + isValid: false, + blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM), + reason: 'bucket_unknown', + }); + // The wait runs out the remaining budget and gives up within one retry interval of the deadline. + expect(elapsedMs).toBeGreaterThanOrEqual(WAIT_BUDGET_MS - 100); + expect(elapsedMs).toBeLessThan(WAIT_BUDGET_MS + 2 * WAIT_INTERVAL_MS); + // Waiting never buys the proposer any network work: the rejection still happens before tx collection. + expect(txProvider.getTxsForBlockProposal).not.toHaveBeenCalled(); + }); + + it('rejects immediately without syncing when the attestation deadline has already passed', async () => { + const ref = new InboxBucketRef(1n, 10n, new Fr(0xabc)); + const { proposal, blockHandler } = await setupStreamingProposal(ref, { nowMs: PAST_DEADLINE_MS }); + mockAcceptedSurroundings(); + l1ToL2MessageSource.getInboxBucket.mockResolvedValue(undefined); + + const result = await blockHandler.handleBlockProposal(proposal, {} as any, true); + + expect(result).toEqual({ + isValid: false, + blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM), + reason: 'bucket_unknown', + }); + // With no budget left there is nothing to wait for, so the archiver is not poked at all. + expect(blockSource.syncImmediate).not.toHaveBeenCalled(); + }); + + it('rejects immediately without syncing when the proposal carries no bucket reference', async () => { + const { proposal, blockHandler } = await setupStreamingProposal(undefined, { + nowMs: BEFORE_DEADLINE_MS, + }); + + const result = await blockHandler.handleBlockProposal(proposal, {} as any, true); + + expect(result).toEqual({ + isValid: false, + blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM), + reason: 'bucket_unknown', + }); + expect(blockSource.syncImmediate).not.toHaveBeenCalled(); + }); + + it('rejects a hash mismatch that survives one forced sync, without looping', async () => { + const ref = new InboxBucketRef(1n, 10n, new Fr(0xdead)); + const { proposal, blockHandler } = await setupStreamingProposal(ref, { nowMs: BEFORE_DEADLINE_MS }); + mockAcceptedSurroundings(); + l1ToL2MessageSource.getInboxBucket.mockResolvedValue(eligibleBucket({ inboxRollingHash: new Fr(0xabc) })); + + const startMs = Date.now(); + const result = await blockHandler.handleBlockProposal(proposal, {} as any, true); + const elapsedMs = Date.now() - startMs; + + expect(result).toEqual({ + isValid: false, + blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM), + reason: 'bucket_hash_mismatch', + }); + // A persistent mismatch is a divergence from L1, not local lag: one sync, one re-check, no retry loop. + expect(blockSource.syncImmediate).toHaveBeenCalledTimes(1); + expect(elapsedMs).toBeLessThan(WAIT_INTERVAL_MS); + }); + + it('attests when the forced sync replaces our stale bucket with the proposed one', async () => { + // This validator held the orphaned side of an L1 reorg; the forced sync rolls it back and re-syncs. + const ref = new InboxBucketRef(1n, 10n, new Fr(0xabc)); + const { proposal, blockHandler } = await setupStreamingProposal(ref, { nowMs: BEFORE_DEADLINE_MS }); + mockAcceptedSurroundings(); + l1ToL2MessageSource.getInboxBucket.mockResolvedValue(eligibleBucket({ inboxRollingHash: new Fr(0xbad) })); + blockSource.syncImmediate.mockImplementation(() => { + l1ToL2MessageSource.getInboxBucket.mockResolvedValue(eligibleBucket()); + return Promise.resolve(); + }); + jest.spyOn(blockHandler, 'reexecuteTransactions').mockResolvedValue({ block: undefined } as any); + + const result = await blockHandler.handleBlockProposal(proposal, {} as any, true); + + expect(result.isValid).toBe(true); + expect(result.blockNumber).toEqual(BlockNumber(INITIAL_L2_BLOCK_NUM)); + }); + }); }); // Streaming Inbox: the checkpoint handler enforces the last-block minimum-consumption (censorship) rule before diff --git a/yarn-project/validator-client/src/proposal_handler.ts b/yarn-project/validator-client/src/proposal_handler.ts index ec348375861b..ded3b5c6e32f 100644 --- a/yarn-project/validator-client/src/proposal_handler.ts +++ b/yarn-project/validator-client/src/proposal_handler.ts @@ -577,7 +577,7 @@ export class ProposalHandler { // Streaming Inbox: run the metadata checks before committing to any network work. They are point lookups // against our own Inbox view, so a proposal carrying a bucket reference that does not resolve locally is // rejected without a proposer being able to make us spend the validation window collecting its txs. - const streamingMetadata = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock); + const streamingMetadata = await this.awaitStreamingBlockMetadata(proposal, blockNumber, parentBlock, proposalInfo); if (!streamingMetadata.accepted) { this.log.warn(`Streaming Inbox block acceptance check failed, skipping processing`, { reason: streamingMetadata.reason, @@ -729,6 +729,40 @@ export class ProposalHandler { } } + /** + * Re-runs `resolve` against this node's local view, forcing an archiver L1 sync before every attempt, until it + * yields a value or the slot's attestation deadline passes. Returns `undefined` when the deadline had already + * passed on entry (nothing is forced in that case) or when it passes while waiting; anything other than the + * timeout propagates. Callers own their own logging and whatever they fall back to on `undefined`, and check + * the deadline themselves when they need to tell "no budget on entry" apart from "timed out while waiting". + */ + private async awaitLocalSync( + slotNumber: SlotNumber, + what: string, + resolve: () => Promise, + ): Promise { + const deadline = this.getReexecutionDeadline(slotNumber); + if (deadline.getTime() - this.dateProvider.now() <= 0) { + return undefined; + } + try { + return await retryUntil( + async () => { + await this.blockSource.syncImmediate(); + return await resolve(); + }, + what, + { deadline, dateProvider: this.dateProvider }, + 0.5, + ); + } catch (err) { + if (err instanceof TimeoutError) { + return undefined; + } + throw err; + } + } + private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockData | undefined> { const parentArchive = proposal.blockHeader.lastArchive.root; const { genesisArchiveRoot } = await this.blockSource.getGenesisValues(); @@ -737,28 +771,23 @@ export class ProposalHandler { return 'genesis'; } - const deadline = this.getReexecutionDeadline(proposal.slotNumber); - const timeoutDurationMs = deadline.getTime() - this.dateProvider.now(); - try { - return ( - (await this.blockSource.getBlockData({ archive: parentArchive })) ?? - (timeoutDurationMs <= 0 - ? undefined - : await retryUntil( - () => - this.blockSource.syncImmediate().then(() => this.blockSource.getBlockData({ archive: parentArchive })), - 'force archiver sync', - { deadline, dateProvider: this.dateProvider }, - 0.5, - )) + const parentBlock = await this.blockSource.getBlockData({ archive: parentArchive }); + if (parentBlock !== undefined) { + return parentBlock; + } + if (this.getReexecutionDeadline(proposal.slotNumber).getTime() - this.dateProvider.now() <= 0) { + return undefined; + } + const synced = await this.awaitLocalSync(proposal.slotNumber, 'force archiver sync', () => + this.blockSource.getBlockData({ archive: parentArchive }), ); - } catch (err) { - if (err instanceof TimeoutError) { + if (synced === undefined) { this.log.debug(`Timed out getting parent block by archive root`, { parentArchive }); - } else { - this.log.error('Error getting parent block by archive root', err, { parentArchive }); } + return synced; + } catch (err) { + this.log.error('Error getting parent block by archive root', err, { parentArchive }); return undefined; } } @@ -784,8 +813,7 @@ export class ProposalHandler { // A different block already occupies this number: it may be a stale fork being pruned during a reorg, not a // genuine duplicate. Wait for the local prune rather than permanently rejecting the proposal. - const deadline = this.getReexecutionDeadline(slotNumber); - if (deadline.getTime() - this.dateProvider.now() <= 0) { + if (this.getReexecutionDeadline(slotNumber).getTime() - this.dateProvider.now() <= 0) { return existingBlock; } @@ -795,29 +823,19 @@ export class ProposalHandler { proposalArchive: proposalArchive.toString(), }); - try { - const { block } = await retryUntil( - async () => { - await this.blockSource.syncImmediate(); - const block = await this.blockSource.getBlockData({ number: blockNumber }); - // Resolve once the existing block is gone (pruned) or has been replaced by one matching the - // proposal — the same condition as the early return above. A matching block is returned so the - // caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal - // be processed. Wrap in an object so the `undefined` case is still a truthy retry result. - return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined; - }, - `prune of stale block ${blockNumber}`, - { deadline, dateProvider: this.dateProvider }, - 0.5, - ); - return block; - } catch (err) { - if (err instanceof TimeoutError) { - this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber }); - return existingBlock; - } - throw err; + const pruned = await this.awaitLocalSync(slotNumber, `prune of stale block ${blockNumber}`, async () => { + const block = await this.blockSource.getBlockData({ number: blockNumber }); + // Resolve once the existing block is gone (pruned) or has been replaced by one matching the + // proposal — the same condition as the early return above. A matching block is returned so the + // caller still treats it as a genuine duplicate; an `undefined` (pruned) block lets the proposal + // be processed. Wrap in an object so the `undefined` case is still a truthy retry result. + return block === undefined || block.archive.root.equals(proposalArchive) ? { block } : undefined; + }); + if (pruned === undefined) { + this.log.warn(`Timed out waiting for stale block ${blockNumber} to be pruned`, { blockNumber }); + return existingBlock; } + return pruned.block; } private computeCheckpointNumber( @@ -969,6 +987,80 @@ export class ProposalHandler { } } + /** + * Runs the streaming-Inbox metadata checks, waiting out a local sync lag. A bucket the proposer consumed is at + * least one Ethereum slot old, so it is on L1 by the time the proposal arrives: a bucket this node cannot + * resolve is almost always its own archiver trailing L1, not a divergence. That case (and the equivalent one + * where the block before the checkpoint's first block has not synced) forces an archiver sync and re-checks + * every half second until it resolves or the attestation deadline passes, instead of dropping the attestation + * on the spot. A hash mismatch on a bucket we do know gets exactly one forced sync and one re-check, because + * this node may be the stale side of an L1 reorg and that sync performs the rollback; a mismatch that survives + * it will not resolve by waiting. Every other reason is a structural rejection and returns immediately. + * + * The wait is bounded by the same consensus deadline as the other sync waits here, so a proposer referencing a + * bucket that never appears can at most make validators poll their own archiver for the remainder of its own + * slot — which it could waste anyway by not proposing. + */ + private async awaitStreamingBlockMetadata( + proposal: BlockProposal, + blockNumber: BlockNumber, + parentBlock: 'genesis' | BlockData, + proposalInfo: LogData, + ): Promise { + const first = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock); + const bucketRef = proposal.bucketRef; + if (first.accepted || bucketRef === undefined) { + return first; + } + + const slotNumber = proposal.slotNumber; + const bucketSeq = bucketRef.bucketSeq; + const outOfBudget = this.getReexecutionDeadline(slotNumber).getTime() - this.dateProvider.now() <= 0; + + if (first.reason === 'bucket_hash_mismatch') { + if (outOfBudget) { + return first; + } + await this.blockSource.syncImmediate(); + const rechecked = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock); + if (!rechecked.accepted && rechecked.reason === 'bucket_hash_mismatch') { + this.log.warn(`Inbox bucket ${bucketSeq} still disagrees with the proposal after forcing an archiver sync`, { + reason: 'bucket_hash_mismatch_after_sync', + bucketSeq, + expected: bucketRef.inboxRollingHash.toString(), + actual: (await this.l1ToL2MessageSource.getInboxBucket(bucketSeq))?.inboxRollingHash.toString(), + ...proposalInfo, + }); + } + return rechecked; + } + + if (first.reason !== 'bucket_unknown') { + return first; + } + + this.log.info(`Referenced Inbox bucket ${bucketSeq} not synced locally, awaiting archiver sync`, { + bucketSeq, + ...proposalInfo, + }); + const timer = new Timer(); + const resolved = await this.awaitLocalSync(slotNumber, `inbox bucket ${bucketSeq}`, async () => { + const result = await this.checkStreamingBlockMetadata(proposal, blockNumber, parentBlock); + return !result.accepted && result.reason === 'bucket_unknown' ? undefined : result; + }); + if (resolved === undefined) { + this.log.warn(`Timed out waiting for Inbox bucket ${bucketSeq} to sync, rejecting proposal`, { + reason: 'bucket_sync_timeout', + slot: slotNumber, + bucketSeq, + waitedMs: timer.ms(), + ...proposalInfo, + }); + return first; + } + return resolved; + } + /** * Runs the streaming-Inbox per-block metadata checks for a block proposal, returning the bucket range its message * bundle derives from or a rejection reason. The parent block's consumed total and the checkpoint's starting total @@ -988,17 +1080,15 @@ export class ProposalHandler { ); if (checkpointStartTotalMsgCount === undefined) { // The block before the checkpoint's first block has not synced locally, so the per-checkpoint cap origin is - // unavailable: treat as an unknown local view. There is no bounded wait for the missing block yet. + // unavailable: treat as an unknown local view. Like an unknown bucket this is local lag rather than a + // divergence, and `awaitStreamingBlockMetadata` waits it out by re-running the whole check after a sync. return { accepted: false, reason: 'bucket_unknown' }; } - const nowSeconds = BigInt(Math.floor(this.dateProvider.now() / 1000)); return checkStreamingBlockProposalMetadata({ messageSource: this.l1ToL2MessageSource, bucketRef: proposal.bucketRef, parentTotalMsgCount, checkpointStartTotalMsgCount, - nowSeconds, - minBucketAgeSeconds: this.epochCache.getL1Constants().ethereumSlotDuration, perBlockCap: MAX_L1_TO_L2_MSGS_PER_BLOCK, perCheckpointCap: MAX_L1_TO_L2_MSGS_PER_CHECKPOINT, }); @@ -1045,14 +1135,17 @@ export class ProposalHandler { /** * Enforces the streaming-Inbox last-block minimum-consumption (censorship) rule for a checkpoint, mirroring * `ProposeLib.validateInboxConsumption`: the first bucket the checkpoint left unconsumed must be absent, past the - * cutoff, or a cap-escape. Returns true (sufficient) when the checkpoint's consumption cannot be resolved against - * the local Inbox view, deferring to L1 `propose` as the authoritative reject. + * cutoff, or a cap-escape. Returns true (sufficient) when the checkpoint's final consumption position cannot be + * resolved against the local Inbox view, deferring to L1 `propose` as the authoritative reject. */ - private async isLastBlockConsumptionSufficient(slot: SlotNumber, blocks: L2Block[]): Promise { + private async isLastBlockConsumptionSufficient( + slot: SlotNumber, + checkpointStartTotal: bigint, + blocks: L2Block[], + ): Promise { const lastBlockTotal = this.blockLeafCount(blocks[blocks.length - 1]); - const checkpointStartTotal = await this.getPreBlockConsumedTotal(blocks[0].number); const lastConsumedBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(lastBlockTotal); - if (checkpointStartTotal === undefined || lastConsumedBucket === undefined) { + if (lastConsumedBucket === undefined) { return true; } const nextBucket = await this.l1ToL2MessageSource.getInboxBucket(lastConsumedBucket.seq + 1n); @@ -1066,22 +1159,24 @@ export class ProposalHandler { } /** - * Derives the ordered list of L1-to-L2 messages a checkpoint consumed across its blocks, from the Inbox buckets - * between the parent checkpoint's consumed position and the checkpoint's last block. Empty when - * the checkpoint consumed nothing or its consumption cannot be resolved against the local Inbox view. + * Derives the ordered list of L1-to-L2 messages a checkpoint consumed across its blocks: the compact message-count + * range between the parent checkpoint's consumed position and the checkpoint's last block, read by count from the + * local message log. Empty when the checkpoint consumed nothing. + * + * Neither bound is resolved as an Inbox bucket. Both are counts committed by block headers, and an L1 reorg that + * merges buckets can leave either one interior to the current partition without changing the messages the blocks + * consumed; whether the final position is a live bucket end is a publication rule L1 `propose` enforces. The + * minimum-consumption guard that runs before this only checks censorship against the buckets it can resolve + * locally and defers an unresolved endpoint to L1. Throws when part of the range is not available locally: a local + * availability + * gap must surface as such rather than as an empty bundle that would make a valid proposal fail its rolling-hash + * recomputation. */ - private async deriveCheckpointConsumedMessages(blocks: L2Block[]): Promise { - const checkpointStartTotal = await this.getPreBlockConsumedTotal(blocks[0].number); + private deriveCheckpointConsumedMessages(checkpointStartTotal: bigint, blocks: L2Block[]): Promise { const lastBlockTotal = this.blockLeafCount(blocks[blocks.length - 1]); - if (checkpointStartTotal === undefined || lastBlockTotal <= checkpointStartTotal) { - return []; - } - const startBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(checkpointStartTotal); - const endBucket = await this.l1ToL2MessageSource.getInboxBucketByTotalMsgCount(lastBlockTotal); - if (startBucket === undefined || endBucket === undefined) { - return []; - } - return this.l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets(startBucket.seq, endBucket.seq); + return lastBlockTotal <= checkpointStartTotal + ? Promise.resolve([]) + : this.l1ToL2MessageSource.getL1ToL2MessagesBetweenLeafCounts(checkpointStartTotal, lastBlockTotal); } async reexecuteTransactions( @@ -1379,9 +1474,21 @@ export class ProposalHandler { const constants = this.extractCheckpointConstants(firstBlock); const checkpointNumber = firstBlock.checkpointNumber; + // The checkpoint's Inbox consumption starts at the leaf count of the block before its first block. Without that + // block the consumed bundle cannot be derived; an empty bundle would make a valid proposal fail its rolling-hash + // recomputation and be classified as a proposer offense, so a missing parent is a local fetch failure instead. + const checkpointStartTotal = await this.getPreBlockConsumedTotal(firstBlock.number); + if (checkpointStartTotal === undefined) { + this.log.warn(`Block before checkpoint proposal's first block ${firstBlock.number} is unavailable locally`, { + ...proposalInfo, + checkpointNumber, + }); + return { isValid: false, reason: 'block_fetch_error', checkpointNumber }; + } + // Streaming Inbox: on the last block of a checkpoint, enforce the minimum-consumption // (censorship) rule before attesting. Reject (no attestation) if a mandatory bucket was left unconsumed. - if (!(await this.isLastBlockConsumptionSufficient(slot, blocks))) { + if (!(await this.isLastBlockConsumptionSufficient(slot, checkpointStartTotal, blocks))) { this.log.warn(`Streaming Inbox last-block censorship check failed, refusing to attest`, { ...proposalInfo, checkpointNumber, @@ -1389,10 +1496,10 @@ export class ProposalHandler { return { isValid: false, reason: 'inbox_consumption_insufficient', checkpointNumber }; } - // Derive the checkpoint's consumed L1-to-L2 message list from the Inbox buckets between the parent checkpoint's - // consumed position and the last block's (compact indexing). The messages are already in the db from per-block + // Derive the checkpoint's consumed L1-to-L2 message list from the message-count range between the parent + // checkpoint's consumed position and the last block's. The messages are already in the db from per-block // validation; this list only drives the checkpoint's rolling-hash recomputation in completeCheckpoint. - const l1ToL2Messages = await this.deriveCheckpointConsumedMessages(blocks); + const l1ToL2Messages = await this.deriveCheckpointConsumedMessages(checkpointStartTotal, blocks); // Collect the out hashes of all the checkpoints before this one in the same epoch. // See note on the analogous block-proposal site: the helper handles pipelining lag. diff --git a/yarn-project/validator-client/src/streaming_inbox_checks.test.ts b/yarn-project/validator-client/src/streaming_inbox_checks.test.ts index bee9091b7b3f..78b823e1ace0 100644 --- a/yarn-project/validator-client/src/streaming_inbox_checks.test.ts +++ b/yarn-project/validator-client/src/streaming_inbox_checks.test.ts @@ -1,3 +1,4 @@ +import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { InboxBucket } from '@aztec/stdlib/messaging'; import { InboxBucketRef } from '@aztec/stdlib/messaging'; @@ -10,7 +11,6 @@ import { checkStreamingBlockProposal, } from './streaming_inbox_checks.js'; -const MIN_BUCKET_AGE_SECONDS = 12; const PER_BLOCK_CAP = 1024; const PER_CHECKPOINT_CAP = 1024; const NOW = 10_000n; @@ -33,6 +33,8 @@ class FakeInboxView implements StreamingInboxBucketSource { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }); } @@ -50,6 +52,8 @@ class FakeInboxView implements StreamingInboxBucketSource { timestamp: BigInt(timestamp), msgCount, lastMessageIndex: totalMsgCount - 1n, + l1BlockNumber: BigInt(seq), + l1BlockHash: Buffer32.fromBigInt(BigInt(seq)), }; this.buckets.set(BigInt(seq), bucket); return bucket; @@ -93,8 +97,6 @@ function baseInput(overrides: Partial): StreamingBlock bucketRef: undefined, parentTotalMsgCount: 0n, checkpointStartTotalMsgCount: 0n, - nowSeconds: NOW, - minBucketAgeSeconds: MIN_BUCKET_AGE_SECONDS, perBlockCap: PER_BLOCK_CAP, perCheckpointCap: PER_CHECKPOINT_CAP, ...overrides, @@ -149,23 +151,17 @@ describe('checkStreamingBlockProposal', () => { }); }); - describe('check 3: bucket is at least one Ethereum slot old', () => { - it('accepts a bucket exactly at the minimum age (inclusive boundary)', async () => { + describe('bucket age is not a check', () => { + it('accepts a bucket opened one second ago', async () => { + // How long a proposer waits before consuming a bucket is its own policy; L1 has no age rule, so neither do we. const view = new FakeInboxView(); - const bucket = view.addBucket(1, 2, Number(NOW) - MIN_BUCKET_AGE_SECONDS); // timestamp == now - minBucketAgeSeconds + const bucket = view.addBucket(1, 2, Number(NOW) - 1); const result = await checkStreamingBlockProposal(baseInput({ messageSource: view, bucketRef: refFor(bucket) })); - expect(result.accepted).toBe(true); - }); - - it('rejects a bucket one second too new', async () => { - const view = new FakeInboxView(); - const bucket = view.addBucket(1, 2, Number(NOW) - MIN_BUCKET_AGE_SECONDS + 1); - const result = await checkStreamingBlockProposal(baseInput({ messageSource: view, bucketRef: refFor(bucket) })); - expect(result).toEqual({ accepted: false, reason: 'bucket_too_new' }); + expect(result).toEqual({ accepted: true, bundle: [new Fr(1000), new Fr(1001)] }); }); }); - describe('check 4: caps', () => { + describe('check 3: caps', () => { it('accepts a block consuming exactly the per-block cap', async () => { const view = new FakeInboxView(); const bucket = view.addBucket(1, PER_BLOCK_CAP, 100); diff --git a/yarn-project/validator-client/src/streaming_inbox_checks.ts b/yarn-project/validator-client/src/streaming_inbox_checks.ts index 2d2f1effa0dd..2e07fb3b841a 100644 --- a/yarn-project/validator-client/src/streaming_inbox_checks.ts +++ b/yarn-project/validator-client/src/streaming_inbox_checks.ts @@ -10,7 +10,6 @@ export type StreamingBlockCheckReason = | 'bucket_hash_mismatch' | 'parent_bucket_unresolved' | 'bucket_moves_backwards' - | 'bucket_too_new' | 'bundle_over_block_cap' | 'checkpoint_over_msg_cap'; @@ -30,18 +29,6 @@ export type StreamingBlockMetadataCheckInput = { parentTotalMsgCount: bigint; /** Cumulative Inbox message count consumed as of the parent checkpoint; the per-checkpoint cap origin. */ checkpointStartTotalMsgCount: bigint; - /** Validation-time wall clock in seconds; the lag-eligibility anchor. */ - nowSeconds: bigint; - /** - * Minimum bucket age in seconds for a bucket to be lag-eligible: one configured Ethereum slot, the same value the - * sequencer's selection uses. - * - * Age in seconds is a proxy for L1 reorg depth, and only an exact one when no L1 slot is missed: with missed slots a - * bucket can be a full Ethereum slot old and still sit in the latest L1 block. A block-depth rule would be - * stronger — require at least one later L1 block to have synced — and is implementable without new L1 state, since - * the archiver records each bucket's `l1BlockNumber`. - */ - minBucketAgeSeconds: number; /** Maximum number of messages this block may consume (`MAX_L1_TO_L2_MSGS_PER_BLOCK`). */ perBlockCap: number; /** Maximum number of messages the checkpoint may consume in total (`MAX_L1_TO_L2_MSGS_PER_CHECKPOINT`). */ @@ -92,36 +79,35 @@ export type StreamingBlockCheckResult = * Mirrors the L1 acceptance conditions: * * 1. **Exists**: the referenced bucket resolves in this node's own Inbox view, and its consensus rolling hash matches - * the reference. An unknown bucket is an immediate reject here (there is no bounded wait yet); a hash - * mismatch means the wire reference disagrees with the local bucket. The reference is trusted only as a `bucketSeq` - * lookup hint — timestamp and message counts are read from the locally resolved bucket, never from the wire. + * the reference. An unknown bucket is an immediate reject here; the caller decides whether it is worth waiting for + * a local sync and re-running the checks (the validator's proposal handler does, bounded by the attestation + * deadline). A hash mismatch means the wire reference disagrees with the local bucket. The reference is trusted + * only as a `bucketSeq` lookup hint — timestamp and message counts are read from the locally resolved bucket, + * never from the wire. * 2. **Moves forward**: the bucket's cumulative total is at least the parent block's, so consumption never rewinds. * Equal totals mean the block consumes nothing (empty bundle). - * 3. **Not too new**: the bucket is at least `minBucketAgeSeconds` old at validation time - * (`timestamp <= now - minBucketAgeSeconds`, inclusive — a bucket exactly that old is eligible, matching L1's - * strict `>` "too new" test). - * 4. **Caps**: the per-block message count and the running per-checkpoint total fit their respective caps. - * 5. **Parent boundary**: the parent block's cumulative total sits on a bucket boundary, so the consumed range is + * 3. **Caps**: the per-block message count and the running per-checkpoint total fit their respective caps. + * 4. **Parent boundary**: the parent block's cumulative total sits on a bucket boundary, so the consumed range is * well defined. * + * There is deliberately no check on how recently the bucket was opened. *When* a bucket becomes consumable is the + * proposer's own policy — it waits for the bucket's opening L1 block to gain a canonical descendant so it does not + * build on a block its archiver will disown — and L1 has no matching rule: `propose` accepts any bucket the + * censorship cutoff and the caps allow, whenever it is proposed. A validator that rejected a young bucket would + * therefore refuse to attest to a checkpoint L1 accepts, so validators check only what L1 checks plus consistency + * with their own Inbox view. + * * Because this phase is cheap and needs nothing off the network, a caller can run it before committing to any * expensive work on a proposal — notably before collecting the proposal's transactions over P2P. * - * The reject branch is a single function so a future bounded wait can wrap `bucket_unknown`. + * The reject branch is a single function so a caller can re-run the whole check after forcing a local sync, which + * is how the validator's proposal handler turns a `bucket_unknown` into a bounded wait. */ export async function checkStreamingBlockProposalMetadata( input: StreamingBlockMetadataCheckInput, ): Promise { - const { - messageSource, - bucketRef, - parentTotalMsgCount, - checkpointStartTotalMsgCount, - nowSeconds, - minBucketAgeSeconds, - perBlockCap, - perCheckpointCap, - } = input; + const { messageSource, bucketRef, parentTotalMsgCount, checkpointStartTotalMsgCount, perBlockCap, perCheckpointCap } = + input; // A streaming proposal must carry a bucket reference to derive its bundle from. if (bucketRef === undefined) { @@ -142,24 +128,19 @@ export async function checkStreamingBlockProposalMetadata( return { accepted: false, reason: 'bucket_moves_backwards' }; } - // Check 3: the bucket is at least `minBucketAgeSeconds` old at validation time. - if (bucket.timestamp > nowSeconds - BigInt(minBucketAgeSeconds)) { - return { accepted: false, reason: 'bucket_too_new' }; - } - - // Check 4a: the per-block message count fits the per-block cap. + // Check 3a: the per-block message count fits the per-block cap. const blockCount = bucket.totalMsgCount - parentTotalMsgCount; if (blockCount > BigInt(perBlockCap)) { return { accepted: false, reason: 'bundle_over_block_cap' }; } - // Check 4b: the running per-checkpoint total fits the per-checkpoint cap. + // Check 3b: the running per-checkpoint total fits the per-checkpoint cap. const checkpointCount = bucket.totalMsgCount - checkpointStartTotalMsgCount; if (checkpointCount > BigInt(perCheckpointCap)) { return { accepted: false, reason: 'checkpoint_over_msg_cap' }; } - // Check 5: the parent bucket is the one whose cumulative total equals the parent block's leaf count (messages are + // Check 4: the parent bucket is the one whose cumulative total equals the parent block's leaf count (messages are // indexed compactly, with no padding); a parent whose count does not sit on a bucket boundary is unresolvable. const parentBucket = await messageSource.getInboxBucketByTotalMsgCount(parentTotalMsgCount); if (parentBucket === undefined) { diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index 75a54f84468a..3d3922dad677 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -53,8 +53,6 @@ jest.setTimeout(60_000); describe('ValidatorClient Integration', () => { // Constants for L1 const l1Constants: L1RollupConstants = { - // Non-zero genesis time so the slot-1 validation clock is well past the minimum bucket age; otherwise the - // streaming Inbox acceptance check rejects even a genesis-timestamp (0) bucket as `bucket_too_new`. l1GenesisTime: 1_700_000_000n, slotDuration: 24, epochDuration: 16, diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index d7c0c4ae9f04..fa60588a7a19 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -406,6 +406,16 @@ describe('ValidatorClient', () => { } as unknown as L2Block; const disposeFork = jest.fn(); blockSource.getBlocksForSlot.mockResolvedValue([checkpointBlock]); + // The checkpoint's consumed message bundle derives from the leaf count of the block before its first block, so + // that parent must resolve by number; other number queries stay unresolved as in the surrounding setup. + blockSource.getBlockData.mockImplementation(query => + Promise.resolve('number' in query && query.number !== blockNumber - 1 ? undefined : parentBlockData), + ); + // With the parent resolvable, the censorship check runs too: leave no bucket after genesis so nothing is left + // unconsumed and validation reaches the header comparison. + l1ToL2MessageSource.getInboxBucket.mockImplementation(seq => + Promise.resolve(seq === 0n ? genesisInboxBucket : undefined), + ); checkpointsBuilder.getFork.mockResolvedValue({ [Symbol.asyncDispose]: disposeFork, // Match the proposal's expected starting archive so the fork archive check passes and validation @@ -465,6 +475,8 @@ describe('ValidatorClient', () => { timestamp: 0n, msgCount: 0, lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, }; const genesisBucketRef = InboxBucketRef.fromBucket(genesisInboxBucket); @@ -549,6 +561,7 @@ describe('ValidatorClient', () => { l1ToL2MessageSource.getInboxBucket.mockResolvedValue(genesisInboxBucket); l1ToL2MessageSource.getInboxBucketByTotalMsgCount.mockResolvedValue(genesisInboxBucket); l1ToL2MessageSource.getL1ToL2MessagesBetweenBuckets.mockResolvedValue([]); + l1ToL2MessageSource.getL1ToL2MessagesBetweenLeafCounts.mockResolvedValue([]); const clonedBlockHeader = blockHeader.clone(); blockBuildResult = { diff --git a/yarn-project/validator-ha-signer/package.json b/yarn-project/validator-ha-signer/package.json index c8266d35483b..d08560f78355 100644 --- a/yarn-project/validator-ha-signer/package.json +++ b/yarn-project/validator-ha-signer/package.json @@ -83,7 +83,7 @@ "node-pg-migrate": "^8.0.4", "pg": "^8.11.3", "tslib": "^2.4.0", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@electric-sql/pglite": "^0.3.14", diff --git a/yarn-project/world-state/package.json b/yarn-project/world-state/package.json index ab1522e4c780..4448dc7659f6 100644 --- a/yarn-project/world-state/package.json +++ b/yarn-project/world-state/package.json @@ -74,7 +74,7 @@ "@aztec/wsdb": "0.1.0", "msgpackr": "^1.11.2", "tslib": "^2.4.0", - "zod": "^4" + "zod": "~4.4.3" }, "devDependencies": { "@aztec/archiver": "workspace:^", diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts index 1edcaf2bdb64..d381c992c026 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.test.ts @@ -51,6 +51,21 @@ describe('ServerWorldStateSynchronizer', () => { beforeEach(() => { blockAndMessagesSource = mock(); blockAndMessagesSource.getBlockNumber.mockResolvedValue(BlockNumber(LATEST_BLOCK_NUMBER)); + // Published-block replay reads the parent block's leaf count and then the messages in the range it opens, so the + // source answers both like an archiver holding the whole mock chain. + blockAndMessagesSource.getBlockData.mockImplementation(async query => { + const block = allBlocks().find(b => 'number' in query && b.number === query.number); + return block === undefined + ? undefined + : { + header: block.header, + archive: block.archive, + blockHash: await block.hash(), + checkpointNumber: block.checkpointNumber, + indexWithinCheckpoint: block.indexWithinCheckpoint, + }; + }); + blockAndMessagesSource.getL1ToL2MessagesBetweenLeafCounts.mockResolvedValue([]); merkleTreeRead = mock(); merkleTreeRead.getInitialHeader.mockReturnValue({ @@ -88,8 +103,10 @@ describe('ServerWorldStateSynchronizer', () => { await server.stop(); }); + const allBlocks = () => checkpoints.flatMap(c => c.checkpoint.blocks); + const pushBlocks = async (from: number, to: number) => { - const blocks = checkpoints.flatMap(c => c.checkpoint.blocks).filter(b => b.number >= from && b.number <= to); + const blocks = allBlocks().filter(b => b.number >= from && b.number <= to); await server.handleBlockStreamEvent({ type: 'blocks-added', blocks, @@ -248,6 +265,96 @@ describe('ServerWorldStateSynchronizer', () => { await expect(pushBlocks(1, 5)).rejects.toThrow(/Test error/i); }); + describe('L1 to L2 message replay', () => { + // Blocks with a strictly growing leaf count, so each one opens a non-empty range over the one before it. + const blockLeafCounts = [3, 3, 7]; + + let messagesByRange: Map; + let originalLeafCounts: number[]; + + afterEach(() => { + const blocks = allBlocks(); + originalLeafCounts.forEach( + (count, i) => (blocks[i].header.state.l1ToL2MessageTree.nextAvailableLeafIndex = count), + ); + }); + + beforeEach(() => { + const blocks = allBlocks(); + originalLeafCounts = blockLeafCounts.map( + (_, i) => blocks[i].header.state.l1ToL2MessageTree.nextAvailableLeafIndex, + ); + blockLeafCounts.forEach((count, i) => (blocks[i].header.state.l1ToL2MessageTree.nextAvailableLeafIndex = count)); + + // Historical replay addresses canonical message indices: resolving a count as a bucket of the current + // partition is exactly what a reorg can make impossible, so it must not happen here. + blockAndMessagesSource.getInboxBucketByTotalMsgCount.mockImplementation(() => { + throw new Error('Published-block replay must not resolve leaf counts as buckets'); + }); + blockAndMessagesSource.getL1ToL2MessagesBetweenBuckets.mockImplementation(() => { + throw new Error('Published-block replay must not read messages by bucket'); + }); + + messagesByRange = new Map([ + ['0-3', [new Fr(10n), new Fr(11n), new Fr(12n)]], + ['3-7', [new Fr(13n), new Fr(14n), new Fr(15n), new Fr(16n)]], + ]); + blockAndMessagesSource.getL1ToL2MessagesBetweenLeafCounts.mockImplementation((start, end) => { + const messages = messagesByRange.get(`${start}-${end}`); + return messages === undefined + ? Promise.reject(new Error(`Unexpected leaf count range [${start}, ${end})`)) + : Promise.resolve(messages); + }); + }); + + it('replays each block from the leaf count range it committed to', async () => { + void server.start(); + await pushBlocks(1, 3); + + const blocks = allBlocks(); + expect(merkleTreeDb.handleL2BlockAndMessages.mock.calls).toEqual([ + [blocks[0], messagesByRange.get('0-3')], + // The second block consumed nothing, so its range is empty and needs no query at all. + [blocks[1], []], + [blocks[2], messagesByRange.get('3-7')], + ]); + expect(blockAndMessagesSource.getL1ToL2MessagesBetweenLeafCounts.mock.calls).toEqual([ + [0n, 3n], + [3n, 7n], + ]); + }); + + it('applies the blocks before an unavailable range and stops there', async () => { + void server.start(); + messagesByRange.delete('3-7'); + + await expect(pushBlocks(1, 3)).rejects.toThrow(/Unexpected leaf count range \[3, 7\)/); + + // The unavailable range fails the sync instead of silently applying the block with no messages; the blocks before + // it were fetched and applied one at a time, so the failure does not discard them. + expect(merkleTreeDb.handleL2BlockAndMessages.mock.calls).toEqual([ + [allBlocks()[0], messagesByRange.get('0-3')], + [allBlocks()[1], []], + ]); + + // Once the archiver serves the range, replay resumes from the failed block, reading its parent's leaf count. + messagesByRange.set('3-7', [new Fr(13n), new Fr(14n), new Fr(15n), new Fr(16n)]); + await pushBlocks(3, 3); + expect(merkleTreeDb.handleL2BlockAndMessages.mock.calls.at(-1)).toEqual([ + allBlocks()[2], + messagesByRange.get('3-7'), + ]); + }); + + it('fails instead of replaying a whole message history when the parent block is missing', async () => { + void server.start(); + blockAndMessagesSource.getBlockData.mockResolvedValue(undefined); + + await expect(pushBlocks(2, 3)).rejects.toThrow(/block 1 is unavailable/); + expect(merkleTreeDb.handleL2BlockAndMessages).not.toHaveBeenCalled(); + }); + }); + describe('getVerifiedSnapshot', () => { let snapshot: MockProxy; diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts index eefe75f2a976..4f9ee689be53 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts @@ -369,31 +369,22 @@ export class ServerWorldStateSynchronizer private async handleL2Blocks(l2Blocks: L2Block[]) { this.log.debug(`Handling L2 blocks ${l2Blocks[0].number} to ${l2Blocks.at(-1)!.number}`); - // Derive each block's real L1-to-L2 message bundle from the compact leaf-index range it inserted: - // the messages between the parent block's L1-to-L2 tree leaf count and this block's, resolved via the - // Inbox buckets. Blocks in a batch are consecutive, so we track the running leaf count. - const messagesForBlocks = new Map(); + // Each block's real L1-to-L2 message bundle is the compact leaf-index range it inserted: the messages between the + // parent block's L1-to-L2 tree leaf count and this block's. The range addresses canonical message indices, so it + // is served whole even after an L1 reorg merged away the bucket that once ended at the parent's count. Blocks in + // a batch are consecutive, so we track the running leaf count; each block is fetched and applied before the next + // one is read, so a range the archiver cannot serve yet does not discard the blocks already applied. let prevLeafCount = await this.getL1ToL2LeafCountBefore(l2Blocks[0].number); + let updateStatus: WorldStateStatusFull | undefined = undefined; for (const block of l2Blocks) { const blockLeafCount = BigInt(block.header.state.l1ToL2MessageTree.nextAvailableLeafIndex); - if (blockLeafCount > prevLeafCount) { - const startBucket = await this.l2BlockSource.getInboxBucketByTotalMsgCount(prevLeafCount); - const endBucket = await this.l2BlockSource.getInboxBucketByTotalMsgCount(blockLeafCount); - if (startBucket !== undefined && endBucket !== undefined) { - messagesForBlocks.set( - block.number, - await this.l2BlockSource.getL1ToL2MessagesBetweenBuckets(startBucket.seq, endBucket.seq), - ); - } - } + const messages = + blockLeafCount > prevLeafCount + ? await this.l2BlockSource.getL1ToL2MessagesBetweenLeafCounts(prevLeafCount, blockLeafCount) + : []; prevLeafCount = blockLeafCount; - } - let updateStatus: WorldStateStatusFull | undefined = undefined; - for (const block of l2Blocks) { - const [duration, result] = await elapsed(() => - this.handleL2Block(block, messagesForBlocks.get(block.number) ?? []), - ); + const [duration, result] = await elapsed(() => this.handleL2Block(block, messages)); this.log.info(`World state updated with L2 block ${block.number}`, { eventName: 'l2-block-handled', duration, @@ -410,14 +401,21 @@ export class ServerWorldStateSynchronizer this.instrumentation.updateWorldStateMetrics(updateStatus); } - /** The L1-to-L2 message tree leaf count as of the block before `blockNumber` (0 if that block is genesis). */ + /** + * The L1-to-L2 message tree leaf count as of the block before `blockNumber` (0 if that block is genesis). Throws + * when a non-genesis parent is unavailable: treating it as zero would ask for every message ever received as the + * next block's bundle. + */ private async getL1ToL2LeafCountBefore(blockNumber: BlockNumber): Promise { const parentNumber = blockNumber - 1; if (parentNumber < INITIAL_L2_BLOCK_NUM) { return 0n; } const parentBlock = await this.l2BlockSource.getBlockData({ number: BlockNumber(parentNumber) }); - return parentBlock === undefined ? 0n : BigInt(parentBlock.header.state.l1ToL2MessageTree.nextAvailableLeafIndex); + if (parentBlock === undefined) { + throw new Error(`Cannot derive L1 to L2 messages for block ${blockNumber}: block ${parentNumber} is unavailable`); + } + return BigInt(parentBlock.header.state.l1ToL2MessageTree.nextAvailableLeafIndex); } /** diff --git a/yarn-project/world-state/src/test/integration.test.ts b/yarn-project/world-state/src/test/integration.test.ts index b5c29b7b9f31..e87c33bd722e 100644 --- a/yarn-project/world-state/src/test/integration.test.ts +++ b/yarn-project/world-state/src/test/integration.test.ts @@ -1,6 +1,7 @@ import { MockPrefilledArchiver } from '@aztec/archiver/test'; import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants'; import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { timesAsync } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -164,6 +165,71 @@ describe('world-state integration', () => { }); }); + describe('Inbox bucket repartitioning', () => { + // Rebuilds the current bucket partition as a single bucket holding every message the given blocks consumed, as an + // L1 reorg that re-mines the same messages under merged boundaries leaves it. The indexed leaves are unchanged, + // but the boundary each published block consumed through is gone from the current partition. + const mergeInboxBuckets = (blockCount: number) => { + const messages = checkpoints.slice(0, blockCount).flatMap(c => c.messages); + archiver.replaceInboxBuckets([ + { + bucket: { + seq: 0n, + inboxRollingHash: Fr.ZERO, + totalMsgCount: 0n, + timestamp: 0n, + msgCount: 0, + lastMessageIndex: 0n, + l1BlockNumber: 0n, + l1BlockHash: Buffer32.ZERO, + }, + msgs: [], + }, + { + bucket: { + seq: 1n, + inboxRollingHash: Fr.ZERO, + totalMsgCount: BigInt(messages.length), + timestamp: 1n, + msgCount: messages.length, + lastMessageIndex: BigInt(messages.length) - 1n, + l1BlockNumber: 1n, + l1BlockHash: Buffer32.ZERO, + }, + msgs: messages, + }, + ]); + }; + + it('replays published blocks whose consumed boundary the current partition no longer has', async () => { + const blockCount = 3; + await archiver.createBlocks(blockCount); + mergeInboxBuckets(blockCount); + + // Driven directly rather than through the block stream, which swallows and retries the archive-root divergence + // a wrong message bundle causes. + const blocks = checkpoints.slice(0, blockCount).flatMap(c => c.checkpoint.blocks); + await synchronizer.handleBlockStreamEvent({ type: 'blocks-added', blocks }); + + for (let blockNumber = 1; blockNumber <= blockCount; blockNumber++) { + await expectSynchedBlockHashMatches(blockNumber); + } + const lastBlockState = blocks.at(-1)!.header.state; + const messageTree = await db.getCommitted().getTreeInfo(MerkleTreeId.L1_TO_L2_MESSAGE_TREE); + expect(messageTree.root).toEqual(lastBlockState.l1ToL2MessageTree.root.toBuffer()); + expect(messageTree.size).toEqual(BigInt(lastBlockState.l1ToL2MessageTree.nextAvailableLeafIndex)); + }); + + it('syncs the whole chain through the block stream under a merged partition', async () => { + const blockCount = 3; + await archiver.createBlocks(blockCount); + mergeInboxBuckets(blockCount); + + await synchronizer.start(); + await expectSynchedToBlock(blockCount); + }); + }); + describe('reorgs', () => { it('prunes blocks upon a reorg and resyncs', async () => { await archiver.createBlocks(5); diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock index b685d5c5028c..95272051287d 100644 --- a/yarn-project/yarn.lock +++ b/yarn-project/yarn.lock @@ -744,7 +744,7 @@ __metadata: tslib: "npm:^2.5.0" typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -825,7 +825,7 @@ __metadata: typescript: "npm:^5.3.3" util: "npm:^0.12.5" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -1041,7 +1041,7 @@ __metadata: tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" bin: blob-client: ./dest/client/bin/index.js languageName: unknown @@ -1096,7 +1096,7 @@ __metadata: tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -1347,7 +1347,7 @@ __metadata: typescript: "npm:^5.3.3" util: "npm:^0.12.5" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -1367,7 +1367,7 @@ __metadata: ts-node: "npm:^10.9.1" tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -1391,7 +1391,7 @@ __metadata: tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -1420,7 +1420,7 @@ __metadata: tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -1480,7 +1480,7 @@ __metadata: typescript-eslint: "npm:^8.32.1" undici: "npm:^5.28.5" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -1627,7 +1627,7 @@ __metadata: tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -1906,7 +1906,7 @@ __metadata: ts-node: "npm:^10.9.1" tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -2062,7 +2062,7 @@ __metadata: tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -2131,7 +2131,7 @@ __metadata: tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -2209,7 +2209,7 @@ __metadata: typescript: "npm:^5.3.3" viem: "npm:@aztec/viem@2.38.2" vitest: "npm:^4.0.0" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -2275,7 +2275,7 @@ __metadata: msgpackr: "npm:^1.11.2" ts-node: "npm:^10.9.1" typescript: "npm:^5.3.3" - zod: "npm:^4" + zod: "npm:~4.4.3" bin: txe: ./dest/bin/index.js languageName: unknown @@ -2344,7 +2344,7 @@ __metadata: ts-node: "npm:^10.9.1" tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -2419,7 +2419,7 @@ __metadata: ts-node: "npm:^10.9.1" tslib: "npm:^2.4.0" typescript: "npm:^5.3.3" - zod: "npm:^4" + zod: "npm:~4.4.3" languageName: unknown linkType: soft @@ -22340,7 +22340,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^4": +"zod@npm:~4.4.3": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36