diff --git a/docs/examples/solidity/aave_bridge/AavePortal.sol b/docs/examples/solidity/aave_bridge/AavePortal.sol index 8a97256b4547..805b0479e2c1 100644 --- a/docs/examples/solidity/aave_bridge/AavePortal.sol +++ b/docs/examples/solidity/aave_bridge/AavePortal.sol @@ -34,13 +34,9 @@ contract AavePortal { bool private _initialized; - function initialize( - address _registry, - address _underlying, - address _aToken, - address _aavePool, - bytes32 _l2Bridge - ) external { + function initialize(address _registry, address _underlying, address _aToken, address _aavePool, bytes32 _l2Bridge) + external + { require(!_initialized, "Already initialized"); _initialized = true; @@ -55,6 +51,7 @@ contract AavePortal { inbox = rollup.getInbox(); rollupVersion = rollup.getVersion(); } + // docs:end:portal_setup // docs:start:portal_deposit_to_aave @@ -65,6 +62,7 @@ contract AavePortal { uint256 _amount, bool _withCaller, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external { @@ -74,31 +72,28 @@ contract AavePortal { recipient: DataStructures.L1Actor(address(this), block.chainid), content: Hash.sha256ToField( abi.encodeWithSignature( - "withdraw(address,uint256,address)", - _recipient, - _amount, - _withCaller ? msg.sender : address(0) + "withdraw(address,uint256,address)", _recipient, _amount, _withCaller ? msg.sender : address(0) ) ) }); // Consume the message from the outbox (verifies merkle proof) - outbox.consume(message, _epoch, _leafIndex, _path); + outbox.consume(message, _epoch, _numCheckpointsInEpoch, _leafIndex, _path); // Deposit into Aave instead of sending tokens to the recipient. // The portal must already hold the underlying tokens (pre-funded or bridged separately). underlying.approve(address(aavePool), _amount); aavePool.supply(address(underlying), _amount, address(this), 0); } + // docs:end:portal_deposit_to_aave // docs:start:portal_claim_public /// @notice Withdraw from Aave and send an L1->L2 message to mint tokens publicly on L2 - function claimFromAavePublic( - uint256 _aTokenAmount, - bytes32 _to, - bytes32 _secretHash - ) external returns (bytes32, uint256) { + function claimFromAavePublic(uint256 _aTokenAmount, bytes32 _to, bytes32 _secretHash) + external + returns (bytes32, uint256) + { // Withdraw from Aave (returns underlying + yield) aToken.approve(address(aavePool), _aTokenAmount); uint256 withdrawn = aavePool.withdraw(address(underlying), _aTokenAmount, address(this)); @@ -111,22 +106,19 @@ contract AavePortal { (bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, _secretHash); return (key, index); } + // docs:end:portal_claim_public // docs:start:portal_claim_private /// @notice Withdraw from Aave and send an L1->L2 message to mint tokens privately on L2 - function claimFromAavePrivate( - uint256 _aTokenAmount, - bytes32 _secretHash - ) external returns (bytes32, uint256) { + function claimFromAavePrivate(uint256 _aTokenAmount, bytes32 _secretHash) external returns (bytes32, uint256) { // Withdraw from Aave (returns underlying + yield) aToken.approve(address(aavePool), _aTokenAmount); uint256 withdrawn = aavePool.withdraw(address(underlying), _aTokenAmount, address(this)); // Send L1->L2 message for private minting DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion); - bytes32 contentHash = - Hash.sha256ToField(abi.encodeWithSignature("mint_to_private(uint256)", withdrawn)); + bytes32 contentHash = Hash.sha256ToField(abi.encodeWithSignature("mint_to_private(uint256)", withdrawn)); (bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, _secretHash); return (key, index); diff --git a/docs/examples/solidity/example_swap/ExampleTokenPortal.sol b/docs/examples/solidity/example_swap/ExampleTokenPortal.sol index 5e728b795604..c6729f81e180 100644 --- a/docs/examples/solidity/example_swap/ExampleTokenPortal.sol +++ b/docs/examples/solidity/example_swap/ExampleTokenPortal.sol @@ -27,11 +27,7 @@ contract ExampleTokenPortal { uint256 public rollupVersion; /// @dev No access control for simplicity. A production contract should restrict this to the deployer/owner. - function initialize( - address _registry, - address _underlying, - bytes32 _l2Bridge - ) external { + function initialize(address _registry, address _underlying, bytes32 _l2Bridge) external { registry = IRegistry(_registry); underlying = IERC20(_underlying); l2Bridge = _l2Bridge; @@ -41,37 +37,32 @@ contract ExampleTokenPortal { inbox = rollup.getInbox(); rollupVersion = rollup.getVersion(); } + // docs:end:example_token_portal // docs:start:deposit_to_aztec_public /// @notice Deposit tokens and send L1->L2 message for public minting on Aztec - function depositToAztecPublic( - bytes32 _to, - uint256 _amount, - bytes32 _secretHash - ) external returns (bytes32, uint256) { + function depositToAztecPublic(bytes32 _to, uint256 _amount, bytes32 _secretHash) + external + returns (bytes32, uint256) + { DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion); - bytes32 contentHash = Hash.sha256ToField( - abi.encodeWithSignature("mint_to_public(bytes32,uint256)", _to, _amount) - ); + bytes32 contentHash = + Hash.sha256ToField(abi.encodeWithSignature("mint_to_public(bytes32,uint256)", _to, _amount)); underlying.safeTransferFrom(msg.sender, address(this), _amount); return inbox.sendL2Message(actor, contentHash, _secretHash); } + // docs:end:deposit_to_aztec_public /// @notice Deposit tokens and send L1->L2 message for private minting on Aztec - function depositToAztecPrivate( - uint256 _amount, - bytes32 _secretHash - ) external returns (bytes32, uint256) { + function depositToAztecPrivate(uint256 _amount, bytes32 _secretHash) external returns (bytes32, uint256) { DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion); - bytes32 contentHash = Hash.sha256ToField( - abi.encodeWithSignature("mint_to_private(uint256)", _amount) - ); + bytes32 contentHash = Hash.sha256ToField(abi.encodeWithSignature("mint_to_private(uint256)", _amount)); underlying.safeTransferFrom(msg.sender, address(this), _amount); @@ -80,10 +71,12 @@ contract ExampleTokenPortal { // docs:start:withdraw /// @notice Withdraw tokens after consuming an L2->L1 message. + /// @param _numCheckpointsInEpoch The partial-proof depth (1-indexed) the witness was built against. function withdraw( address _recipient, uint256 _amount, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external { @@ -91,16 +84,11 @@ contract ExampleTokenPortal { sender: DataStructures.L2Actor(l2Bridge, rollupVersion), recipient: DataStructures.L1Actor(address(this), block.chainid), content: Hash.sha256ToField( - abi.encodeWithSignature( - "withdraw(address,uint256,address)", - _recipient, - _amount, - msg.sender - ) + abi.encodeWithSignature("withdraw(address,uint256,address)", _recipient, _amount, msg.sender) ) }); - outbox.consume(message, _epoch, _leafIndex, _path); + outbox.consume(message, _epoch, _numCheckpointsInEpoch, _leafIndex, _path); underlying.safeTransfer(_recipient, _amount); } diff --git a/docs/examples/solidity/example_swap/ExampleUniswapPortal.sol b/docs/examples/solidity/example_swap/ExampleUniswapPortal.sol index 0b62ece196a4..fb62d76f8ff5 100644 --- a/docs/examples/solidity/example_swap/ExampleUniswapPortal.sol +++ b/docs/examples/solidity/example_swap/ExampleUniswapPortal.sol @@ -33,6 +33,7 @@ contract ExampleUniswapPortal { outbox = rollup.getOutbox(); rollupVersion = rollup.getVersion(); } + // docs:end:example_uniswap_portal // docs:start:swap_public @@ -49,19 +50,15 @@ contract ExampleUniswapPortal { bytes32 _secretHashForL1ToL2Message, // Outbox message metadata for the two L2->L1 messages Epoch[2] calldata _epochs, + uint256[2] calldata _numCheckpointsInEpochs, uint256[2] calldata _leafIndices, bytes32[][2] calldata _paths ) external returns (bytes32, uint256) { IERC20 outputAsset = ExampleTokenPortal(_outputTokenPortal).underlying(); // Message 1: Consume the token bridge exit message (withdraw input tokens) - ExampleTokenPortal(_inputTokenPortal).withdraw( - address(this), - _inAmount, - _epochs[0], - _leafIndices[0], - _paths[0] - ); + ExampleTokenPortal(_inputTokenPortal) + .withdraw(address(this), _inAmount, _epochs[0], _numCheckpointsInEpochs[0], _leafIndices[0], _paths[0]); // Message 2: Consume the uniswap swap intent message bytes32 contentHash = Hash.sha256ToField( @@ -84,6 +81,7 @@ contract ExampleUniswapPortal { content: contentHash }), _epochs[1], + _numCheckpointsInEpochs[1], _leafIndices[1], _paths[1] ); @@ -94,12 +92,10 @@ contract ExampleUniswapPortal { // Approve output token portal and deposit back to Aztec outputAsset.approve(_outputTokenPortal, amountOut); - return ExampleTokenPortal(_outputTokenPortal).depositToAztecPublic( - _aztecRecipient, - amountOut, - _secretHashForL1ToL2Message - ); + return ExampleTokenPortal(_outputTokenPortal) + .depositToAztecPublic(_aztecRecipient, amountOut, _secretHashForL1ToL2Message); } + // docs:end:swap_public // docs:start:swap_private @@ -113,19 +109,15 @@ contract ExampleUniswapPortal { bytes32 _secretHashForL1ToL2Message, // Outbox message metadata for the two L2->L1 messages Epoch[2] calldata _epochs, + uint256[2] calldata _numCheckpointsInEpochs, uint256[2] calldata _leafIndices, bytes32[][2] calldata _paths ) external returns (bytes32, uint256) { IERC20 outputAsset = ExampleTokenPortal(_outputTokenPortal).underlying(); // Message 1: Consume the token bridge exit message (withdraw input tokens) - ExampleTokenPortal(_inputTokenPortal).withdraw( - address(this), - _inAmount, - _epochs[0], - _leafIndices[0], - _paths[0] - ); + ExampleTokenPortal(_inputTokenPortal) + .withdraw(address(this), _inAmount, _epochs[0], _numCheckpointsInEpochs[0], _leafIndices[0], _paths[0]); // Message 2: Consume the uniswap swap intent message bytes32 contentHash = Hash.sha256ToField( @@ -147,6 +139,7 @@ contract ExampleUniswapPortal { content: contentHash }), _epochs[1], + _numCheckpointsInEpochs[1], _leafIndices[1], _paths[1] ); @@ -157,10 +150,7 @@ contract ExampleUniswapPortal { // Approve output token portal and deposit back to Aztec privately outputAsset.approve(_outputTokenPortal, amountOut); - return ExampleTokenPortal(_outputTokenPortal).depositToAztecPrivate( - amountOut, - _secretHashForL1ToL2Message - ); + return ExampleTokenPortal(_outputTokenPortal).depositToAztecPrivate(amountOut, _secretHashForL1ToL2Message); } // docs:end:swap_private } diff --git a/docs/examples/solidity/nft_bridge/NFTPortal.sol b/docs/examples/solidity/nft_bridge/NFTPortal.sol index 9ba1a1e2a901..8da7aa5742bc 100644 --- a/docs/examples/solidity/nft_bridge/NFTPortal.sol +++ b/docs/examples/solidity/nft_bridge/NFTPortal.sol @@ -31,6 +31,7 @@ contract NFTPortal { inbox = rollup.getInbox(); rollupVersion = rollup.getVersion(); } + // docs:end:portal_setup // docs:start:portal_deposit_and_withdraw @@ -52,6 +53,7 @@ contract NFTPortal { function withdraw( uint256 tokenId, Epoch epoch, + uint256 numCheckpointsInEpoch, uint256 leafIndex, bytes32[] calldata path ) external { @@ -62,7 +64,7 @@ contract NFTPortal { content: Hash.sha256ToField(abi.encodePacked(tokenId, msg.sender)) }); - outbox.consume(message, epoch, leafIndex, path); + outbox.consume(message, epoch, numCheckpointsInEpoch, leafIndex, path); // Unlock NFT nftContract.transferFrom(address(this), msg.sender, tokenId); diff --git a/docs/examples/ts/aave_bridge/index.ts b/docs/examples/ts/aave_bridge/index.ts index 01f618b05d67..030722a0baa7 100644 --- a/docs/examples/ts/aave_bridge/index.ts +++ b/docs/examples/ts/aave_bridge/index.ts @@ -4,6 +4,7 @@ import { SetPublicAuthwitContractInteraction } from "@aztec/aztec.js/authorizati import { Fr } from "@aztec/aztec.js/fields"; import { createAztecNodeClient, waitForNode } from "@aztec/aztec.js/node"; import { createExtendedL1Client } from "@aztec/ethereum/client"; +import { OutboxContract } from "@aztec/ethereum/contracts"; import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; import { sha256ToField } from "@aztec/foundation/crypto/sha256"; import { @@ -278,13 +279,20 @@ while (provenBlockNumber < exitBlockNumber) { } console.log("Block proven!\n"); -// Compute the membership witness using the message hash and the L2 tx hash +// Compute the membership witness using the message hash and the L2 tx hash. +// The Outbox is queried to pick the smallest partial-proof root that covers the tx's checkpoint. +const outbox = new OutboxContract( + l1Client, + nodeInfo.l1ContractAddresses.outboxAddress, +); const witness = await computeL2ToL1MembershipWitness( node, + outbox, msgLeaf, exitReceipt.txHash, ); const epoch = witness!.epochNumber; +const numCheckpointsInEpoch = witness!.numCheckpointsInEpoch; const siblingPathHex = witness!.siblingPath .toBufferArray() @@ -304,6 +312,7 @@ const depositToAaveHash = await l1Client.writeContract({ amountToDeposit, false, // withCaller = false (matches caller_on_l1 = address(0)) BigInt(epoch), + BigInt(numCheckpointsInEpoch), BigInt(witness!.leafIndex), siblingPathHex, ], diff --git a/docs/examples/ts/example_swap/index.ts b/docs/examples/ts/example_swap/index.ts index 883c120c5bad..0b819054d3cb 100644 --- a/docs/examples/ts/example_swap/index.ts +++ b/docs/examples/ts/example_swap/index.ts @@ -5,6 +5,7 @@ import { SetPublicAuthwitContractInteraction } from "@aztec/aztec.js/authorizati import { Fr } from "@aztec/aztec.js/fields"; import { createAztecNodeClient, waitForNode } from "@aztec/aztec.js/node"; import { createExtendedL1Client } from "@aztec/ethereum/client"; +import { OutboxContract } from "@aztec/ethereum/contracts"; import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; import { sha256ToField } from "@aztec/foundation/crypto/sha256"; import { TokenContract } from "@aztec/noir-contracts.js/Token"; @@ -445,8 +446,14 @@ const exitMsgLeaf = computeL2ToL1MessageHash({ // docs:end:consume_l1_messages_setup // docs:start:consume_l1_messages_witnesses +// The Outbox is queried to pick the smallest partial-proof root that covers each tx's checkpoint. +const outbox = new OutboxContract( + l1Client, + nodeInfo.l1ContractAddresses.outboxAddress, +); const exitWitness = await computeL2ToL1MembershipWitness( node, + outbox, exitMsgLeaf, swapReceipt.txHash, ); @@ -501,6 +508,7 @@ const swapMsgLeaf = computeL2ToL1MessageHash({ const swapWitness = await computeL2ToL1MembershipWitness( node, + outbox, swapMsgLeaf, swapReceipt.txHash, ); @@ -528,6 +536,10 @@ const l1SwapHash = await l1Client.writeContract({ size: 32, }), [BigInt(exitWitness!.epochNumber), BigInt(swapWitness!.epochNumber)], + [ + BigInt(exitWitness!.numCheckpointsInEpoch), + BigInt(swapWitness!.numCheckpointsInEpoch), + ], [BigInt(exitWitness!.leafIndex), BigInt(swapWitness!.leafIndex)], [exitSiblingPath, swapSiblingPath], ], diff --git a/docs/examples/ts/token_bridge/index.ts b/docs/examples/ts/token_bridge/index.ts index ac0a1d5b6a29..425b5056f26b 100644 --- a/docs/examples/ts/token_bridge/index.ts +++ b/docs/examples/ts/token_bridge/index.ts @@ -4,6 +4,7 @@ import { AztecAddress, EthAddress } from "@aztec/aztec.js/addresses"; import { Fr } from "@aztec/aztec.js/fields"; import { createAztecNodeClient } from "@aztec/aztec.js/node"; import { createExtendedL1Client } from "@aztec/ethereum/client"; +import { OutboxContract } from "@aztec/ethereum/contracts"; import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; import { sha256ToField } from "@aztec/foundation/crypto/sha256"; import { @@ -303,13 +304,20 @@ while (provenBlockNumber < exitReceipt.blockNumber!) { console.log("Block proven!\n"); -// Compute the membership witness using the message hash and the L2 tx hash +// Compute the membership witness using the message hash and the L2 tx hash. +// The Outbox is queried to pick the smallest partial-proof root that covers the tx's checkpoint. +const outbox = new OutboxContract( + l1Client, + nodeInfo.l1ContractAddresses.outboxAddress, +); const witness = await computeL2ToL1MembershipWitness( node, + outbox, msgLeaf, exitReceipt.txHash, ); const epoch = witness!.epochNumber; +const numCheckpointsInEpoch = witness!.numCheckpointsInEpoch; console.log(` Epoch for block ${exitReceipt.blockNumber}: ${epoch}`); const siblingPathHex = witness!.siblingPath @@ -324,7 +332,13 @@ const withdrawHash = await l1Client.writeContract({ address: portalAddress.toString() as `0x${string}`, abi: NFTPortal.abi, functionName: "withdraw", - args: [tokenId, BigInt(epoch), BigInt(witness!.leafIndex), siblingPathHex], + args: [ + tokenId, + BigInt(epoch), + BigInt(numCheckpointsInEpoch), + BigInt(witness!.leafIndex), + siblingPathHex, + ], }); await l1Client.waitForTransactionReceipt({ hash: withdrawHash }); console.log("NFT withdrawn to L1\n"); diff --git a/l1-contracts/src/core/Rollup.sol b/l1-contracts/src/core/Rollup.sol index 281e4badd070..b6e4e46cfbf0 100644 --- a/l1-contracts/src/core/Rollup.sol +++ b/l1-contracts/src/core/Rollup.sol @@ -210,6 +210,16 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore { return StakingLib.getStorage().slasher; } + function getPendingSlasher() external view override(IStaking) returns (address slasher, Timestamp readyAt) { + slasher = StakingLib.getStorage().pendingSlasher; + readyAt = StakingLib.getStorage().pendingSlasherReadyAt.decompress(); + } + + function getLegacySlasher() external view override(IStaking) returns (address slasher, Timestamp authorizedUntil) { + slasher = StakingLib.getStorage().legacySlasher; + authorizedUntil = StakingLib.getStorage().legacySlasherAuthorizedUntil.decompress(); + } + function getLocalEjectionThreshold() external view override(IStaking) returns (uint256) { return StakingLib.getStorage().localEjectionThreshold; } @@ -594,6 +604,14 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore { return ValidatorOperationsExtLib.getEntryQueueAt(_index); } + function getSlasherExecutionDelay() external pure override(IStaking) returns (uint256) { + return StakingLib.SLASHER_EXECUTION_DELAY; + } + + function getLegacySlasherDrainWindow() external pure override(IStaking) returns (uint256) { + return StakingLib.LEGACY_SLASHER_DRAIN_WINDOW; + } + function getBurnAddress() external pure override(IRollup) returns (address) { return address(bytes20("CUAUHXICALLI")); } diff --git a/l1-contracts/src/core/RollupCore.sol b/l1-contracts/src/core/RollupCore.sol index acfb79c0ffd3..73b3da4c4b41 100644 --- a/l1-contracts/src/core/RollupCore.sol +++ b/l1-contracts/src/core/RollupCore.sol @@ -34,7 +34,7 @@ import {GSE} from "@aztec/governance/GSE.sol"; import {Ownable} from "@oz/access/Ownable.sol"; import {IERC20} from "@oz/token/ERC20/IERC20.sol"; import {EIP712} from "@oz/utils/cryptography/EIP712.sol"; -import {RewardExtLib, RewardConfig} from "@aztec/core/libraries/rollup/RewardExtLib.sol"; +import {RewardExtLib, RewardConfig, MutableRewardConfig} from "@aztec/core/libraries/rollup/RewardExtLib.sol"; import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; import {FeeConfigLib, CompressedFeeConfig} from "@aztec/core/libraries/compressed-data/fees/FeeConfig.sol"; import {G1Point, G2Point} from "@aztec/shared/libraries/BN254Lib.sol"; @@ -222,11 +222,16 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali GenesisState memory _genesisState, RollupConfigInput memory _config ) Ownable(_governance) { - // We do not allow the `normalFlushSizeMin` to be 0 when deployed as it would lock deposits (which is never desired - // from the onset). It might be updated later to 0 by governance in order to close the validator set for this - // instance. For details see `StakingLib.getEntryQueueFlushSize` function. - require(_config.stakingQueueConfig.normalFlushSizeMin > 0, Errors.Staking__InvalidStakingQueueConfig()); - require(_config.stakingQueueConfig.normalFlushSizeQuotient > 0, Errors.Staking__InvalidNormalFlushSizeQuotient()); + StakingLib.assertValidQueueConfig(_config.stakingQueueConfig); + + // queueSetSlasher schedules the replacement slasher to land at `block.timestamp + + // SLASHER_EXECUTION_DELAY`. If a validator's exit delay is longer, an objecting validator + // cannot finish withdrawal before the new slasher takes over, so the rollup cannot honor + // its end of the replacement opt-out window for the configured exit delay. + require( + _config.exitDelaySeconds <= StakingLib.SLASHER_EXECUTION_DELAY, + Errors.Staking__ExitDelayAboveSlasherDelay(_config.exitDelaySeconds, StakingLib.SLASHER_EXECUTION_DELAY) + ); TimeLib.initialize( block.timestamp, _config.aztecSlotDuration, _config.aztecEpochDuration, _config.aztecProofSubmissionEpochs @@ -256,12 +261,15 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali } /** - * @notice Updates the reward configuration for sequencers and provers - * @dev Only callable by the contract owner. Updates how rewards are calculated and distributed. - * @param _config The new reward configuration including rates and booster settings + * @notice Updates the mutable reward configuration (sequencer/prover split and checkpoint reward). + * @dev Only callable by the contract owner. The `rewardDistributor` and `booster` addresses are + * deliberately NOT exposed by this setter -- they are written exactly once at construction + * by {_initializeRewards} and immutable thereafter. Rotating either address requires + * redeploying the rollup via `Registry.addRollup`. + * @param _config The new mutable reward configuration */ - function setRewardConfig(RewardConfig memory _config) external override(IRollupCore) onlyOwner { - RewardExtLib.setConfig(_config); + function setRewardConfig(MutableRewardConfig memory _config) external override(IRollupCore) onlyOwner { + RewardExtLib.updateConfig(_config); emit RewardConfigUpdated(_config); } @@ -281,22 +289,27 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali } /** - * @notice Updates the slasher contract address - * @dev Only callable by owner. The slasher handles punishment for validator misbehavior. + * @notice Queues a slasher replacement. Takes effect only after a 60-day delay via + * {finalizeSetSlasher}. Validators who object to the change have time to exit + * before it lands. Overwrites any pending change, resetting the timer. + * @dev The zero address is permissible. Setting to this would imply that the rollup has + * at least temporarily relinquished their ability to slash. * @param _slasher The address of the new slasher contract */ - function setSlasher(address _slasher) external override(IStakingCore) onlyOwner { - ValidatorOperationsExtLib.setSlasher(_slasher); + function queueSetSlasher(address _slasher) external override(IStakingCore) onlyOwner { + ValidatorOperationsExtLib.queueSetSlasher(_slasher); } - /** - * @notice Updates the local ejection threshold - * @dev Only callable by owner. The local ejection threshold is the minimum amount of stake that a validator can have - * after being slashed. - * @param _localEjectionThreshold The new local ejection threshold - */ - function setLocalEjectionThreshold(uint256 _localEjectionThreshold) external override(IStakingCore) onlyOwner { - ValidatorOperationsExtLib.setLocalEjectionThreshold(_localEjectionThreshold); + /// @notice Cancels the pending slasher replacement. Reverts if nothing is pending. + function cancelSetSlasher() external override(IStakingCore) onlyOwner { + ValidatorOperationsExtLib.cancelSetSlasher(); + } + + /// @notice Applies the pending slasher replacement once the 60-day delay has elapsed. + /// @dev Permissionless: the queued payload and the elapsed delay are the authorization. + /// Anyone may poke the transaction through. + function finalizeSetSlasher() external override(IStakingCore) { + ValidatorOperationsExtLib.finalizeSetSlasher(); } /** @@ -318,14 +331,16 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali } /** - * @notice Sets the escape hatch contract address - * @dev Only callable by owner. Set to address(0) to disable escape hatch functionality. - * The escape hatch provides an alternative block production path when the committee is unavailable. - * @param _escapeHatch The address of the EscapeHatch contract, or address(0) to disable + * @notice Sets the escape hatch contract address. One-shot: callable at most once per rollup. + * @dev Only callable by owner. The escape hatch provides an alternative block production path + * when the committee is unavailable. Once set, the address is immutable for the life of + * the rollup -- there is no replacement path. To launch without an escape hatch, simply + * never call this function. + * @param _escapeHatch The address of the EscapeHatch contract (must be non-zero) */ - function updateEscapeHatch(address _escapeHatch) external override(IValidatorSelectionCore) onlyOwner { - ValidatorOperationsExtLib.updateEscapeHatch(_escapeHatch); - emit IValidatorSelectionCore.EscapeHatchUpdated(_escapeHatch); + function setEscapeHatch(address _escapeHatch) external override(IValidatorSelectionCore) onlyOwner { + ValidatorOperationsExtLib.setEscapeHatch(_escapeHatch); + emit IValidatorSelectionCore.EscapeHatchSet(_escapeHatch); } /** @@ -587,7 +602,8 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali rewardConfig.booster = RewardExtLib.deployRewardBooster(_config.rewardBoostConfig); } - RewardExtLib.setConfig(rewardConfig); + // Constructor-only writer; post-deployment updates go through {setRewardConfig}. + RewardExtLib.initializeConfig(rewardConfig); } function _initializeStore( diff --git a/l1-contracts/src/core/interfaces/IRollup.sol b/l1-contracts/src/core/interfaces/IRollup.sol index f3e67ab100a5..36bcbf96fb65 100644 --- a/l1-contracts/src/core/interfaces/IRollup.sol +++ b/l1-contracts/src/core/interfaces/IRollup.sol @@ -15,7 +15,7 @@ import {CommitteeAttestations} from "@aztec/core/libraries/rollup/AttestationLib import {ManaMinFeeComponents} from "@aztec/core/libraries/rollup/FeeLib.sol"; import {ProposedHeader} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol"; import {ProposeArgs} from "@aztec/core/libraries/rollup/ProposeLib.sol"; -import {RewardConfig} from "@aztec/core/libraries/rollup/RewardLib.sol"; +import {RewardConfig, MutableRewardConfig} from "@aztec/core/libraries/rollup/RewardLib.sol"; import {RewardBoostConfig} from "@aztec/core/reward-boost/RewardBooster.sol"; import {IHaveVersion} from "@aztec/governance/interfaces/IRegistry.sol"; import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; @@ -111,7 +111,7 @@ interface IRollupCore { ); event L2ProofVerified(uint256 indexed checkpointNumber, address indexed proverId); event CheckpointInvalidated(uint256 indexed checkpointNumber); - event RewardConfigUpdated(RewardConfig rewardConfig); + event RewardConfigUpdated(MutableRewardConfig rewardConfig); event ManaTargetUpdated(uint256 indexed manaTarget); event PrunedPending(uint256 provenCheckpointNumber, uint256 pendingCheckpointNumber); @@ -146,7 +146,7 @@ interface IRollupCore { address[] memory _committee ) external; - function setRewardConfig(RewardConfig memory _config) external; + function setRewardConfig(MutableRewardConfig memory _config) external; function updateManaTarget(uint256 _manaTarget) external; // solhint-disable-next-line func-name-mixedcase diff --git a/l1-contracts/src/core/interfaces/IStaking.sol b/l1-contracts/src/core/interfaces/IStaking.sol index ee12d0b87400..588df1e07540 100644 --- a/l1-contracts/src/core/interfaces/IStaking.sol +++ b/l1-contracts/src/core/interfaces/IStaking.sol @@ -12,9 +12,9 @@ import {IERC20} from "@oz/token/ERC20/IERC20.sol"; interface IStakingCore { event SlasherUpdated(address indexed oldSlasher, address indexed newSlasher); - event LocalEjectionThresholdUpdated( - uint256 indexed oldLocalEjectionThreshold, uint256 indexed newLocalEjectionThreshold - ); + event PendingSlasherQueued(address indexed slasher, uint256 readyAt); + event PendingSlasherCancelled(address indexed slasher); + event LegacySlasherAuthorized(address indexed legacySlasher, uint256 authorizedUntil); event ValidatorQueued(address indexed attester, address indexed withdrawer); event Deposit( address indexed attester, @@ -36,8 +36,9 @@ interface IStakingCore { event Slashed(address indexed attester, uint256 amount); event StakingQueueConfigUpdated(StakingQueueConfig config); - function setSlasher(address _slasher) external; - function setLocalEjectionThreshold(uint256 _localEjectionThreshold) external; + function queueSetSlasher(address _slasher) external; + function cancelSetSlasher() external; + function finalizeSetSlasher() external; function deposit( address _attester, address _withdrawer, @@ -63,7 +64,11 @@ interface IStaking is IStakingCore { function getExit(address _attester) external view returns (Exit memory); function getAttesterAtIndex(uint256 _index) external view returns (address); function getSlasher() external view returns (address); + function getPendingSlasher() external view returns (address slasher, Timestamp readyAt); + function getLegacySlasher() external view returns (address slasher, Timestamp authorizedUntil); function getLocalEjectionThreshold() external view returns (uint256); + function getSlasherExecutionDelay() external view returns (uint256); + function getLegacySlasherDrainWindow() external view returns (uint256); function getStakingAsset() external view returns (IERC20); function getActivationThreshold() external view returns (uint256); function getEjectionThreshold() external view returns (uint256); diff --git a/l1-contracts/src/core/interfaces/IValidatorSelection.sol b/l1-contracts/src/core/interfaces/IValidatorSelection.sol index 0236869901fe..8607c43b0267 100644 --- a/l1-contracts/src/core/interfaces/IValidatorSelection.sol +++ b/l1-contracts/src/core/interfaces/IValidatorSelection.sol @@ -21,11 +21,11 @@ struct ValidatorSelectionStorage { } interface IValidatorSelectionCore { - event EscapeHatchUpdated(address escapeHatch); + event EscapeHatchSet(address escapeHatch); function setupEpoch() external; function checkpointRandao() external; - function updateEscapeHatch(address _escapeHatch) external; + function setEscapeHatch(address _escapeHatch) external; } interface IValidatorSelection is IValidatorSelectionCore, IEmperor { diff --git a/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol index d0005f39bde1..968c7c242311 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol @@ -5,6 +5,10 @@ pragma solidity >=0.8.27; import {DataStructures} from "../../libraries/DataStructures.sol"; import {Epoch} from "../../libraries/TimeLib.sol"; +// File-level integer literal so it can be used as a fixed-size array length. MUST equal +// `Constants.MAX_CHECKPOINTS_PER_EPOCH`; the Outbox constructor enforces this at deploy time. +uint256 constant MAX_CHECKPOINTS_PER_EPOCH = 32; + /** * @title IOutbox * @author Aztec Labs @@ -12,18 +16,30 @@ import {Epoch} from "../../libraries/TimeLib.sol"; * and will be consumed by the portal contracts. */ interface IOutbox { - event RootAdded(Epoch indexed epoch, bytes32 indexed root); - event MessageConsumed(Epoch indexed epoch, bytes32 indexed root, bytes32 indexed messageHash, uint256 leafId); + event RootAdded(Epoch indexed epoch, uint256 indexed numCheckpointsInEpoch, bytes32 root); + event MessageConsumed( + Epoch indexed epoch, + bytes32 indexed root, + bytes32 indexed messageHash, + uint256 leafId, + uint256 numCheckpointsInEpoch + ); // docs:start:outbox_insert /** - * @notice Inserts the root of a merkle tree containing all of the L2 to L1 messages in an epoch specified by _epoch. + * @notice Inserts the root of a merkle tree containing all of the L2 to L1 messages in an epoch + * after a proof covering the first `_numCheckpointsInEpoch` checkpoints of that epoch lands. * @dev Only callable by the rollup contract * @dev Emits `RootAdded` upon inserting the root successfully + * @dev Successive inserts for the same epoch with larger `_numCheckpointsInEpoch` values do not + * disturb earlier entries, so users with witnesses built against an earlier partial proof can still + * consume them. * @param _epoch - The epoch in which the L2 to L1 messages reside + * @param _numCheckpointsInEpoch - The number of checkpoints the inserting proof covered in this + * epoch. Must be in [1, MAX_CHECKPOINTS_PER_EPOCH]. * @param _root - The merkle root of the tree where all the L2 to L1 messages are leaves */ - function insert(Epoch _epoch, bytes32 _root) external; + function insert(Epoch _epoch, uint256 _numCheckpointsInEpoch, bytes32 _root) external; // docs:end:outbox_insert // docs:start:outbox_consume @@ -33,6 +49,9 @@ interface IOutbox { * @dev Emits `MessageConsumed` when consuming messages * @param _message - The L2 to L1 message * @param _epoch - The epoch that contains the message we want to consume + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root this + * consume verifies against. The caller's witness path must have been built against the epoch tree + * padded to that number of real checkpoints. * @param _leafIndex - The index at the level in the epoch message tree where the message is located * @param _path - The sibling path used to prove inclusion of the message, the _path length depends * on the location of the L2 to L1 message in the epoch message tree. @@ -40,6 +59,7 @@ interface IOutbox { function consume( DataStructures.L2ToL1Msg calldata _message, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external; @@ -56,12 +76,24 @@ interface IOutbox { // docs:end:outbox_has_message_been_consumed_at_epoch_and_index /** - * @notice Fetch the root data for a given epoch - * Returns (0, 0) if the epoch is not proven + * @notice Fetch the root data for a given epoch and partial-proof depth. + * Returns 0 if no proof has been inserted at that depth. * * @param _epoch - The epoch to fetch the root data for + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root to fetch * * @return bytes32 - The root of the merkle tree containing the L2 to L1 messages */ - function getRootData(Epoch _epoch) external view returns (bytes32); + function getRootData(Epoch _epoch, uint256 _numCheckpointsInEpoch) external view returns (bytes32); + + /** + * @notice Fetch every root stored for a given epoch. The returned array has + * MAX_CHECKPOINTS_PER_EPOCH entries; slot `i` holds the root for + * `numCheckpointsInEpoch = i + 1`, or zero if no proof of that depth has been inserted. + * + * @param _epoch - The epoch to fetch the roots for + * + * @return bytes32[] - The roots stored for this epoch. + */ + function getRoots(Epoch _epoch) external view returns (bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory); } diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 7787b5558f43..e5c6a1dcadf3 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -48,6 +48,7 @@ library Errors { error Outbox__NothingToConsumeAtEpoch(Epoch epoch); // 0x5e3d32ce error Outbox__PathTooLong(); error Outbox__LeafIndexOutOfBounds(uint256 leafIndex, uint256 pathLength); + error Outbox__InvalidNumCheckpointsInEpoch(uint256 numCheckpointsInEpoch); // Rollup error Rollup__InsufficientBondAmount(uint256 minimum, uint256 provided); // 0xa165f276 @@ -131,6 +132,9 @@ library Errors { error ValidatorSelection__ProposerIndexTooLarge(uint256 index); error ValidatorSelection__EpochNotStable(uint256 queriedEpoch, uint32 currentTimestamp); error ValidatorSelection__InvalidLagInEpochs(uint256 lagInEpochsForValidatorSet, uint256 lagInEpochsForRandao); + error ValidatorSelection__EscapeHatchAlreadySet(); + error ValidatorSelection__EscapeHatchCannotBeZero(); + error ValidatorSelection__EscapeHatchRollupMismatch(address expected, address actual); // Staking error Staking__AlreadyQueued(address _attester); @@ -167,6 +171,13 @@ library Errors { error Staking__InsufficientBootstrapValidators(uint256 queueSize, uint256 bootstrapFlushSize); error Staking__InvalidStakingQueueConfig(); error Staking__InvalidNormalFlushSizeQuotient(); + error Staking__InvalidMaxQueueFlushSize(); + error Staking__InvalidBootstrapFlushSize(); + error Staking__BootstrapFlushSizeAboveMax(uint256 bootstrapFlushSize, uint256 maxQueueFlushSize); + error Staking__ExitDelayAboveSlasherDelay(uint256 exitDelaySeconds, uint256 slasherExecutionDelay); + error Staking__SlasherProposerNotInitialized(address slasher); + error Staking__NoPendingSlasher(); + error Staking__SlasherNotReady(Timestamp readyAt); // Fee Juice Portal error FeeJuicePortal__AlreadyInitialized(); // 0xc7a172fe @@ -184,6 +195,10 @@ library Errors { error FeeLib__InvalidManaTarget(uint256 minimum, uint256 provided); error FeeLib__InvalidManaLimit(uint256 maximum, uint256 provided); error FeeLib__InvalidInitialEthPerFeeAsset(uint256 provided, uint256 minimum, uint256 maximum); + error FeeLib__ProvingCostBelowFloor(uint256 provided, uint256 minimum); + error FeeLib__ProvingCostAboveCeiling(uint256 provided, uint256 maximum); + error FeeLib__ProvingCostCooldown(uint256 nextAllowed); + error FeeLib__ProvingCostStepExceeded(uint256 current, uint256 requested); // SignatureLib (duplicated) error SignatureLib__InvalidSignature(address, address); // 0xd9cbae6c @@ -197,8 +212,10 @@ library Errors { // RewardBooster error RewardBooster__OnlyRollup(address caller); + error RewardBooster__InvalidConfig(); error RewardLib__InvalidSequencerBps(); + error RewardLib__ZeroShares(address prover); // SlashingProposer error SlashingProposer__InvalidSignature(); diff --git a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol index ca52d60f5b01..5e84462ab748 100644 --- a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol +++ b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol @@ -127,8 +127,11 @@ library EpochProofLib { // a partial epoch cannot produce a non-empty out hash and later revert to an empty one as more checkpoints are // included. Therefore, it is safe to skip insertion when the out hash is empty. if (_args.args.outHash != bytes32(Constants.EMPTY_EPOCH_OUT_HASH)) { - // Insert L2->L1 messages root into outbox for consumption. - rollupStore.config.outbox.insert(endEpoch, _args.args.outHash); + // Insert L2->L1 messages root into outbox for consumption. The Outbox keys each root by + // 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); } } diff --git a/l1-contracts/src/core/libraries/rollup/FeeLib.sol b/l1-contracts/src/core/libraries/rollup/FeeLib.sol index 8133a5d8d3e4..72d42ca7e6c3 100644 --- a/l1-contracts/src/core/libraries/rollup/FeeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/FeeLib.sol @@ -71,6 +71,29 @@ uint256 constant MAGIC_CONGESTION_VALUE_MULTIPLIER = 854_700_854; uint256 constant BLOB_GAS_PER_BLOB = 2 ** 17; uint256 constant BLOBS_PER_CHECKPOINT = 3; +/* + * Proving-cost rate limit + * + * `setProvingCostPerMana` can move the rollup's fee model materially, so the value is + * constrained to a bounded multiplicative step per cooldown instead of unconstrained writes. + * + * - PROVING_COST_UPDATE_INTERVAL: minimum time between updates (acts as the anti-multicall guard). + * - PROVING_COST_STEP_NUM / _DEN: multiplicative step cap applied against the live value. + * - MIN_PROVING_COST_PER_MANA: floor that keeps the ratio algebra useful (0 and 1 freeze). + * + * With 3/2 per 30 days, the value requires ~170 days to move 10x and ~340 days to move 100x. + * `provingCostLastUpdate == 0` after `initialize`, so the first post-init update is not gated + * by the cooldown; the 30-day cadence engages after that. + */ +uint256 constant PROVING_COST_UPDATE_INTERVAL = 30 days; +uint256 constant PROVING_COST_STEP_NUM = 3; +uint256 constant PROVING_COST_STEP_DEN = 2; +uint256 constant MIN_PROVING_COST_PER_MANA = 2; +// Initial-only ceiling. Prevents a mistaken deployment from setting a value that will take a long +// time to correct from. At the time of this writing, deployed value is 2.5e7, and it is expected +// that proving costs will go down. +uint256 constant MAX_INITIAL_PROVING_COST_PER_MANA = 2e8; + struct OracleInput { int256 feeAssetPriceModifier; } @@ -85,6 +108,7 @@ struct ManaMinFeeComponents { struct FeeStore { CompressedFeeConfig config; L1GasOracleValues l1GasOracleValues; + uint64 provingCostLastUpdate; } library FeeLib { @@ -119,6 +143,21 @@ library FeeLib { // Computes and ensures that limit is within sane bounds computeManaLimit(_manaTarget); + // The rate-limit algebra in updateProvingCostPerMana assumes `current >= 2`; initializing + // below the floor would permanently freeze the proving-cost update path. + uint256 provingCost = EthValue.unwrap(_provingCostPerMana); + require( + provingCost >= MIN_PROVING_COST_PER_MANA, + Errors.FeeLib__ProvingCostBelowFloor(provingCost, MIN_PROVING_COST_PER_MANA) + ); + // The uint64 cap inside FeeConfigLib.compress is a storage-shape bound, not an economic + // bound. Enforce a separate initial ceiling so a deploy cannot strand the rollup at a value + // that takes years to drift back to normal operating ranges via the rate-limited updater. + require( + provingCost <= MAX_INITIAL_PROVING_COST_PER_MANA, + Errors.FeeLib__ProvingCostAboveCeiling(provingCost, MAX_INITIAL_PROVING_COST_PER_MANA) + ); + // Validate initial ETH per fee asset is within bounds uint256 initialPrice = EthPerFeeAssetE12.unwrap(_initialEthPerFeeAsset); require( @@ -158,8 +197,27 @@ library FeeLib { function updateProvingCostPerMana(EthValue _provingCostPerMana) internal { FeeStore storage feeStore = getStorage(); FeeConfig memory config = feeStore.config.decompress(); + + uint256 current = EthValue.unwrap(config.provingCostPerMana); + uint256 newV = EthValue.unwrap(_provingCostPerMana); + + require(newV >= MIN_PROVING_COST_PER_MANA, Errors.FeeLib__ProvingCostBelowFloor(newV, MIN_PROVING_COST_PER_MANA)); + + uint256 nextAllowed = uint256(feeStore.provingCostLastUpdate) + PROVING_COST_UPDATE_INTERVAL; + require( + feeStore.provingCostLastUpdate == 0 || block.timestamp >= nextAllowed, + Errors.FeeLib__ProvingCostCooldown(nextAllowed) + ); + + require( + newV * PROVING_COST_STEP_DEN <= current * PROVING_COST_STEP_NUM + && newV * PROVING_COST_STEP_NUM >= current * PROVING_COST_STEP_DEN, + Errors.FeeLib__ProvingCostStepExceeded(current, newV) + ); + config.provingCostPerMana = _provingCostPerMana; feeStore.config = config.compress(); + feeStore.provingCostLastUpdate = uint64(block.timestamp); } function updateL1GasFeeOracle() internal { diff --git a/l1-contracts/src/core/libraries/rollup/RewardExtLib.sol b/l1-contracts/src/core/libraries/rollup/RewardExtLib.sol index 0ce5aaa34da2..8f62815cde84 100644 --- a/l1-contracts/src/core/libraries/rollup/RewardExtLib.sol +++ b/l1-contracts/src/core/libraries/rollup/RewardExtLib.sol @@ -10,7 +10,7 @@ import { EthValue } from "@aztec/core/libraries/rollup/FeeLib.sol"; import {ProposeLib} from "@aztec/core/libraries/rollup/ProposeLib.sol"; -import {RewardLib, RewardConfig} from "@aztec/core/libraries/rollup/RewardLib.sol"; +import {RewardLib, RewardConfig, MutableRewardConfig} from "@aztec/core/libraries/rollup/RewardLib.sol"; import {STFLib} from "@aztec/core/libraries/rollup/STFLib.sol"; import {Epoch, Timestamp} from "@aztec/core/libraries/TimeLib.sol"; import { @@ -22,8 +22,12 @@ import { import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; library RewardExtLib { - function setConfig(RewardConfig memory _config) external { - RewardLib.setConfig(_config); + function initializeConfig(RewardConfig memory _config) external { + RewardLib.initializeConfig(_config); + } + + function updateConfig(MutableRewardConfig memory _config) external { + RewardLib.updateConfig(_config); } function claimSequencerRewards(address _sequencer) external returns (uint256) { diff --git a/l1-contracts/src/core/libraries/rollup/RewardLib.sol b/l1-contracts/src/core/libraries/rollup/RewardLib.sol index b3ca745dc5ff..07ae1405a6a0 100644 --- a/l1-contracts/src/core/libraries/rollup/RewardLib.sol +++ b/l1-contracts/src/core/libraries/rollup/RewardLib.sol @@ -41,6 +41,14 @@ struct RewardConfig { uint96 checkpointReward; } +/// @notice The post-deployment-mutable subset of {RewardConfig}. +/// @dev `rewardDistributor` and `booster` are deliberately *not* in this struct: they are +/// set once at construction and immutable thereafter. +struct MutableRewardConfig { + Bps sequencerBps; + uint96 checkpointReward; +} + struct RewardStorage { mapping(address => uint256) sequencerRewards; mapping(Epoch => EpochRewards) epochRewards; @@ -76,12 +84,27 @@ library RewardLib { // such as sacrificial hearts, during rituals performed within temples. address public constant BURN_ADDRESS = address(bytes20("CUAUHXICALLI")); - function setConfig(RewardConfig memory _config) internal { + /// @notice One-shot writer used during rollup construction. Writes every field of + /// {RewardConfig}, including the immutable `rewardDistributor` and `booster`. + /// @dev Must only be reachable from the constructor path. Post-deployment updates go through + /// {updateConfig}, which preserves the immutable fields. + function initializeConfig(RewardConfig memory _config) internal { require(Bps.unwrap(_config.sequencerBps) <= 10_000, Errors.RewardLib__InvalidSequencerBps()); RewardStorage storage rewardStorage = getStorage(); rewardStorage.config = _config; } + /// @notice Owner-gated post-deployment writer. Only updates the mutable subset + /// (`sequencerBps`, `checkpointReward`). The `rewardDistributor` and `booster` + /// addresses MUST NOT be reachable from this path -- they remain whatever was + /// written by {initializeConfig}. + function updateConfig(MutableRewardConfig memory _config) internal { + require(Bps.unwrap(_config.sequencerBps) <= 10_000, Errors.RewardLib__InvalidSequencerBps()); + RewardStorage storage rewardStorage = getStorage(); + rewardStorage.config.sequencerBps = _config.sequencerBps; + rewardStorage.config.checkpointReward = _config.checkpointReward; + } + function claimSequencerRewards(address _sequencer) internal returns (uint256) { RewardStorage storage rewardStorage = getStorage(); RollupStore storage rollupStore = STFLib.getStorage(); @@ -132,8 +155,6 @@ library RewardLib { RollupStore storage rollupStore = STFLib.getStorage(); RewardStorage storage rewardStorage = getStorage(); - // Determine if this rollup is canonical according to its RewardDistributor. - uint256 length = _args.end - _args.start + 1; EpochRewards storage $er = rewardStorage.epochRewards[_endEpoch]; @@ -147,6 +168,13 @@ library RewardLib { // efficient way to do it, so this is fine. uint256 shares = rewardStorage.config.booster.updateAndGetShares(prover); + // The duplicate-submission guard above uses `shares == 0` as the sentinel for "not yet + // submitted". A booster that ever returns zero would let the same prover submit again + // for the same epoch length, breaking that guard. RewardBooster's constructor rejects + // configs that can return zero, but the booster slot is an external pointer; bounce + // back if a misbehaving booster ever crosses this layer. + require(shares > 0, Errors.RewardLib__ZeroShares(prover)); + $sr.shares[prover] = shares; $sr.summedShares += shares; } @@ -160,19 +188,15 @@ library RewardLib { uint256 checkpointRewardsDesired = added * getCheckpointReward(); uint256 checkpointRewardsAvailable = 0; - // Only if we require checkpoint rewards and are canonical will we claim. if (checkpointRewardsDesired > 0) { // Cache the reward distributor contract IRewardDistributor distributor = rewardStorage.config.rewardDistributor; - if (address(this) == distributor.canonicalRollup()) { - uint256 amountToClaim = - Math.min(checkpointRewardsDesired, rollupStore.config.feeAsset.balanceOf(address(distributor))); + uint256 amountToClaim = Math.min(checkpointRewardsDesired, distributor.availableTo(address(this))); - if (amountToClaim > 0) { - distributor.claim(address(this), amountToClaim); - checkpointRewardsAvailable = amountToClaim; - } + if (amountToClaim > 0) { + distributor.claim(address(this), amountToClaim); + checkpointRewardsAvailable = amountToClaim; } } diff --git a/l1-contracts/src/core/libraries/rollup/StakingLib.sol b/l1-contracts/src/core/libraries/rollup/StakingLib.sol index 96905f365e31..17f1d8326dc4 100644 --- a/l1-contracts/src/core/libraries/rollup/StakingLib.sol +++ b/l1-contracts/src/core/libraries/rollup/StakingLib.sol @@ -11,6 +11,7 @@ import { import {Errors} from "@aztec/core/libraries/Errors.sol"; import {StakingQueueLib, StakingQueue, DepositArgs} from "@aztec/core/libraries/StakingQueue.sol"; import {TimeLib, Timestamp, Epoch} from "@aztec/core/libraries/TimeLib.sol"; +import {Slasher} from "@aztec/core/slashing/Slasher.sol"; import {Governance} from "@aztec/governance/Governance.sol"; import {GSE, AttesterConfig, IGSECore} from "@aztec/governance/GSE.sol"; import {Proposal} from "@aztec/governance/interfaces/IGovernance.sol"; @@ -79,6 +80,8 @@ struct StakingStorage { IERC20 stakingAsset; address slasher; uint96 localEjectionThreshold; + address pendingSlasher; + CompressedTimestamp pendingSlasherReadyAt; GSE gse; CompressedTimestamp exitDelay; mapping(address attester => Exit) exits; @@ -87,6 +90,12 @@ struct StakingStorage { CompressedEpoch nextFlushableEpoch; uint32 availableValidatorFlushes; bool isBootstrapped; + // Outgoing slasher that finalizeSetSlasher has rotated off the active slot. Retains + // authority to call {slash} until `legacySlasherAuthorizedUntil` so that slashing rounds + // which already reached quorum before the rotation can still execute after the new + // slasher takes over. + address legacySlasher; + CompressedTimestamp legacySlasherAuthorizedUntil; } library StakingLib { @@ -103,6 +112,17 @@ library StakingLib { bytes32 private constant STAKING_SLOT = keccak256("aztec.core.staking.storage"); + /// @notice Delay between queuing a slasher replacement and being able to finalize it. + uint256 internal constant SLASHER_EXECUTION_DELAY = 60 days; + /// @notice After {finalizeSetSlasher} swaps the active slasher, the outgoing slasher retains + /// the right to call {slash} for this long. The window is sized to comfortably cover + /// any reasonable SlashingProposer lifetime: at the default config a round's full + /// vote -> execution lifetime fits inside a few hours, so 30 days is generous. + /// Rollups configured with multi-week round lifetimes should raise this -- the value + /// is the only knob bounding how long an in-flight slash can drain through the old + /// proposer after rotation. + uint256 internal constant LEGACY_SLASHER_DRAIN_WINDOW = 30 days; + function initialize( IERC20 _stakingAsset, GSE _gse, @@ -121,22 +141,63 @@ library StakingLib { store.localEjectionThreshold = _localEjectionThreshold.toUint96(); } - function setSlasher(address _slasher) internal { + function queueSetSlasher(address _slasher) internal { StakingStorage storage store = getStorage(); - address oldSlasher = store.slasher; - store.slasher = _slasher; + // `Slasher.initializeProposer` is permissionless while `PROPOSER` is unset. Queuing a + // not-yet-initialized Slasher would let anyone claim the proposer role during the 60-day + // delay and gain arbitrary slash-payload authority once `finalizeSetSlasher` lands. Require + // the proposer to already be wired so the queued replacement is not capturable. + require(Slasher(_slasher).PROPOSER() != address(0), Errors.Staking__SlasherProposerNotInitialized(_slasher)); - emit IStakingCore.SlasherUpdated(oldSlasher, _slasher); + Timestamp readyAt = Timestamp.wrap(block.timestamp + SLASHER_EXECUTION_DELAY); + store.pendingSlasher = _slasher; + store.pendingSlasherReadyAt = readyAt.compress(); + + emit IStakingCore.PendingSlasherQueued(_slasher, Timestamp.unwrap(readyAt)); } - function setLocalEjectionThreshold(uint256 _localEjectionThreshold) internal { + function cancelSetSlasher() internal { StakingStorage storage store = getStorage(); - uint256 oldLocalEjectionThreshold = store.localEjectionThreshold; - store.localEjectionThreshold = _localEjectionThreshold.toUint96(); + require(CompressedTimestamp.unwrap(store.pendingSlasherReadyAt) != 0, Errors.Staking__NoPendingSlasher()); + + address cancelled = store.pendingSlasher; + store.pendingSlasher = address(0); + store.pendingSlasherReadyAt = CompressedTimestamp.wrap(0); + + emit IStakingCore.PendingSlasherCancelled(cancelled); + } + + function finalizeSetSlasher() internal { + StakingStorage storage store = getStorage(); + + require(CompressedTimestamp.unwrap(store.pendingSlasherReadyAt) != 0, Errors.Staking__NoPendingSlasher()); + Timestamp readyAt = store.pendingSlasherReadyAt.decompress(); + require(Timestamp.wrap(block.timestamp) >= readyAt, Errors.Staking__SlasherNotReady(readyAt)); + + address newSlasher = store.pendingSlasher; + // Defense in depth against state queued before the queueSetSlasher guard existed, and + // against a replacement whose proposer somehow regressed to zero after queueing. + require(Slasher(newSlasher).PROPOSER() != address(0), Errors.Staking__SlasherProposerNotInitialized(newSlasher)); - emit IStakingCore.LocalEjectionThresholdUpdated(oldLocalEjectionThreshold, _localEjectionThreshold); + address oldSlasher = store.slasher; + // Park the outgoing slasher in the legacy slot with a drain window so quorum-backed rounds + // already accumulated on the old SlashingProposer can still execute against the rollup + // through the old slasher. Without this, a committee just before a queued slasher takes over + // gets a "free round" to do what they like. Any prior legacy auth window is overwritten -- only + // one rotation can be in flight at a time, and the most recent rotation defines the + // currently-relevant outgoing slasher. + Timestamp drainUntil = Timestamp.wrap(block.timestamp + LEGACY_SLASHER_DRAIN_WINDOW); + store.legacySlasher = oldSlasher; + store.legacySlasherAuthorizedUntil = drainUntil.compress(); + + store.slasher = newSlasher; + store.pendingSlasher = address(0); + store.pendingSlasherReadyAt = CompressedTimestamp.wrap(0); + + emit IStakingCore.SlasherUpdated(oldSlasher, newSlasher); + emit IStakingCore.LegacySlasherAuthorized(oldSlasher, Timestamp.unwrap(drainUntil)); } /** @@ -220,7 +281,7 @@ library StakingLib { */ function slash(address _attester, uint256 _amount) internal { StakingStorage storage store = getStorage(); - require(msg.sender == store.slasher, Errors.Staking__NotSlasher(store.slasher, msg.sender)); + require(_isAuthorizedSlasher(store, msg.sender), Errors.Staking__NotSlasher(store.slasher, msg.sender)); Exit storage exit = store.exits[_attester]; @@ -465,6 +526,7 @@ library StakingLib { } function updateStakingQueueConfig(StakingQueueConfig memory _config) internal { + assertValidQueueConfig(_config); getStorage().queueConfig = _config.compress(); emit IStakingCore.StakingQueueConfigUpdated(_config); } @@ -556,7 +618,9 @@ library StakingLib { * 3. Normal phase: After the initial bootstrap and growth phases, returns a number proportional to the current * set size for conservative steady-state growth, unless constrained by configuration (`normalFlushSizeMin`). * - * All phases are subject to a hard cap of `maxQueueFlushSize`. + * The normal-phase result is clamped to `maxQueueFlushSize` at runtime; the + * bootstrap-phase value is bounded by the same cap at config-acceptance time inside + * {assertValidQueueConfig}, so every phase respects the cap. * * The motivation for floodgates is that the whole system starts producing checkpoints with what is considered * a sufficiently decentralized set of validators. @@ -603,6 +667,33 @@ library StakingLib { return getStorage().availableValidatorFlushes; } + /// @notice Enforces invariants on a {StakingQueueConfig}. + /// - `normalFlushSizeMin > 0`: a zero floor can close the queue on a running rollup. + /// - `normalFlushSizeQuotient > 0`: {getEntryQueueFlushSize} divides by this field. + /// - `maxQueueFlushSize > 0`: a zero cap leaves the normal-phase queue impossible to + /// drain (the `Math.min(..., 0)` clamp pins every flush at zero), trapping queued + /// validator stake. + /// - `bootstrapFlushSize > 0` whenever `bootstrapValidatorSetSize > 0`: a zero + /// bootstrap flush size traps queued validators during bootstrap growth because the + /// bootstrap branch returns `bootstrapFlushSize` directly. + /// - `bootstrapFlushSize <= maxQueueFlushSize`: keeps {getEntryQueueFlushSize}'s + /// bootstrap-phase return inside the same cap that bounds the normal phase, so the + /// cap holds across every phase as documented. + /// @param _config The queue config to validate; reverts when any of the above is violated. + function assertValidQueueConfig(StakingQueueConfig memory _config) internal pure { + require(_config.normalFlushSizeMin > 0, Errors.Staking__InvalidStakingQueueConfig()); + require(_config.normalFlushSizeQuotient > 0, Errors.Staking__InvalidNormalFlushSizeQuotient()); + require(_config.maxQueueFlushSize > 0, Errors.Staking__InvalidMaxQueueFlushSize()); + require( + _config.bootstrapValidatorSetSize == 0 || _config.bootstrapFlushSize > 0, + Errors.Staking__InvalidBootstrapFlushSize() + ); + require( + _config.bootstrapFlushSize <= _config.maxQueueFlushSize, + Errors.Staking__BootstrapFlushSizeAboveMax(_config.bootstrapFlushSize, _config.maxQueueFlushSize) + ); + } + function getStorage() internal pure returns (StakingStorage storage storageStruct) { bytes32 position = STAKING_SLOT; assembly { @@ -610,6 +701,21 @@ library StakingLib { } } + /// @notice Whether `_caller` can call {slash}. + /// @dev The active slasher always qualifies. The legacy slasher qualifies only while its + /// drain window is still open, so quorum-backed rounds queued before a rotation can + /// still execute against the rollup even though the active slasher has moved on. + function _isAuthorizedSlasher(StakingStorage storage _store, address _caller) private view returns (bool) { + if (_caller == _store.slasher) { + return true; + } + address legacy = _store.legacySlasher; + if (legacy == address(0) || _caller != legacy) { + return false; + } + return Timestamp.wrap(block.timestamp) <= _store.legacySlasherAuthorizedUntil.decompress(); + } + function _calculateAvailableFlushes() private view diff --git a/l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol b/l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol index a10ef29e3c80..946b6f6d2d86 100644 --- a/l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol @@ -32,12 +32,16 @@ import {G1Point, G2Point} from "@aztec/shared/libraries/BN254Lib.sol"; library ValidatorOperationsExtLib { using TimeLib for Timestamp; - function setSlasher(address _slasher) external { - StakingLib.setSlasher(_slasher); + function queueSetSlasher(address _slasher) external { + StakingLib.queueSetSlasher(_slasher); } - function setLocalEjectionThreshold(uint256 _localEjectionThreshold) external { - StakingLib.setLocalEjectionThreshold(_localEjectionThreshold); + function cancelSetSlasher() external { + StakingLib.cancelSetSlasher(); + } + + function finalizeSetSlasher() external { + StakingLib.finalizeSetSlasher(); } function vote(uint256 _proposalId) external { @@ -91,8 +95,8 @@ library ValidatorOperationsExtLib { StakingLib.updateStakingQueueConfig(_config); } - function updateEscapeHatch(address _escapeHatch) external { - ValidatorSelectionLib.updateEscapeHatch(_escapeHatch); + function setEscapeHatch(address _escapeHatch) external { + ValidatorSelectionLib.setEscapeHatch(_escapeHatch); } function invalidateBadAttestation( diff --git a/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol b/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol index 5d1d8b0fad38..c9197de49fe2 100644 --- a/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol @@ -148,18 +148,34 @@ library ValidatorSelectionLib { } /** - * @notice Sets the escape hatch contract address - * @dev Only callable through RollupCore.setEscapeHatch (governance-controlled). - * Set to address(0) to disable escape hatch functionality. - * @param _escapeHatch The address of the EscapeHatch contract, or address(0) to disable + * @notice Sets the escape hatch contract address. One-shot: can only be called once per rollup. + * @dev Only callable through RollupCore.setEscapeHatch (owner-gated). Once set, the rollup's + * escape hatch is immutable for the life of the rollup -- there is no replacement path. + * Callers who want no escape hatch should simply never call this function. + * @param _escapeHatch The address of the EscapeHatch contract (must be non-zero) */ - function updateEscapeHatch(address _escapeHatch) internal { - // Key the checkpoint to the START of the next epoch so the change never affects - // the current epoch. This prevents a same-block governance action from retroactively - // altering the escape hatch for an epoch where proposals may have already been made. + function setEscapeHatch(address _escapeHatch) internal { + require(_escapeHatch != address(0), Errors.ValidatorSelection__EscapeHatchCannotBeZero()); + + // The registration is one-shot and ungoverned after setup, and an open escape hatch can act + // as an alternate proposal route (ProposeLib.propose authorizes the registered escape + // hatch's designated proposer during escape-hatch epochs). Pointing at a stranger contract + // -- including a hatch wired to a different rollup -- would create a permanent foreign + // proposal authority that cannot be replaced. Require the hatch to point back here. + address hatchRollup = IEscapeHatch(_escapeHatch).getRollup(); + require( + hatchRollup == address(this), Errors.ValidatorSelection__EscapeHatchRollupMismatch(address(this), hatchRollup) + ); + + ValidatorSelectionStorage storage store = getStorage(); + require(store.escapeHatchCheckpoints.length() == 0, Errors.ValidatorSelection__EscapeHatchAlreadySet()); + + // Key the checkpoint to the START of the next epoch so the registration never affects + // the current epoch. This prevents a same-block action from retroactively classifying + // an in-flight epoch as an escape-hatch epoch. Epoch nextEpoch = Timestamp.wrap(block.timestamp).epochFromTimestamp() + Epoch.wrap(1); uint96 nextEpochTs = uint96(Timestamp.unwrap(nextEpoch.toTimestamp())); - getStorage().escapeHatchCheckpoints.push(nextEpochTs, uint160(_escapeHatch)); + store.escapeHatchCheckpoints.push(nextEpochTs, uint160(_escapeHatch)); } /** diff --git a/l1-contracts/src/core/messagebridge/Outbox.sol b/l1-contracts/src/core/messagebridge/Outbox.sol index 00fea24cf935..8559903ff74b 100644 --- a/l1-contracts/src/core/messagebridge/Outbox.sol +++ b/l1-contracts/src/core/messagebridge/Outbox.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.27; import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; import {IOutbox} from "@aztec/core/interfaces/messagebridge/IOutbox.sol"; +import {Constants} from "@aztec/core/libraries/ConstantsGen.sol"; import {Hash} from "@aztec/core/libraries/crypto/Hash.sol"; import {MerkleLib} from "@aztec/core/libraries/crypto/MerkleLib.sol"; import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; @@ -11,15 +12,39 @@ import {Errors} from "@aztec/core/libraries/Errors.sol"; import {Epoch} from "@aztec/core/libraries/TimeLib.sol"; import {BitMaps} from "@oz/utils/structs/BitMaps.sol"; +// File-level integer literal so it can be used as a fixed-size array length (Solidity rejects +// dotted library-member access in array-length positions). MUST equal +// `Constants.MAX_CHECKPOINTS_PER_EPOCH`; the Outbox constructor enforces this at deploy time. +uint256 constant MAX_CHECKPOINTS_PER_EPOCH = 32; + /** * @title Outbox * @author Aztec Labs * @notice Lives on L1 and is used to consume L2 -> L1 messages. Messages are inserted by the Rollup * and will be consumed by the portal contracts. * - * @dev Messages are tracked using unique leaf IDs computed from their position in the epoch's tree structure. - * This design ensures that when longer epoch proofs are submitted (proving more blocks), messages from - * earlier blocks retain their consumed status because their leaf IDs remain stable. + * @dev Each epoch may accumulate multiple message roots when `insert` is called more than once + * (e.g. when a partial epoch proof is followed by an extending proof). Roots are keyed by the + * number of checkpoints proven in that epoch (`numCheckpointsInEpoch`, in [1, MAX_CHECKPOINTS_PER_EPOCH]), + * so an off-chain consumer can map their L2 transaction's position-within-epoch directly to the + * smallest proof that covers it without needing to recover that count from chain history. + * + * The nullifier bitmap is shared across every root of the same epoch, so a message consumed against + * one root cannot be replayed against another root of the same epoch. + * + * Messages are tracked using unique leaf IDs computed from their position in the epoch's tree structure. + * This design ensures that when longer epoch proofs are submitted (proving more checkpoints), messages + * from earlier checkpoints retain their consumed status because their leaf IDs remain stable. + * + * @dev The Outbox does not (and cannot) verify on chain that a given message has the same leaf id + * across two different roots of the same epoch. Leaf-id stability across extending partial-epoch + * proofs is a property the rollup's proving system is expected to uphold (each checkpoint's subtree + * is built only from its own messages, and the epoch tree is padded to a fixed size, so positions + * of already-included messages are preserved when more checkpoints are added). A buggy or malicious + * rollup that submitted two proofs for the same epoch where the same message lived at different + * positions would, on the Outbox side, produce two different leaf ids on the shared bitmap and + * therefore allow that message to be consumed twice. This is the same trust boundary the Outbox + * has always had with the rollup; AZIP-14 does not extend it. * * For detailed information about the tree structure and leaf ID computation, see: * yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts @@ -28,11 +53,15 @@ contract Outbox is IOutbox { using Hash for DataStructures.L2ToL1Msg; using BitMaps for BitMaps.BitMap; - struct RootData { - // This is the outHash in the root rollup's public inputs. - // It represents the root of the epoch tree containing all L2->L1 messages. - bytes32 root; - // Bitmap tracking which messages (by leaf ID) have been consumed. + struct EpochData { + // Slot `i` holds the epoch-tree out-hash root for `numCheckpointsInEpoch = i + 1` (i.e. the + // proof that covered the first `i + 1` checkpoints of this epoch). Unset slots read as zero. + // The array is sized at MAX_CHECKPOINTS_PER_EPOCH because that is the maximum number of + // checkpoints the rollup ever proves in a single epoch. + bytes32[MAX_CHECKPOINTS_PER_EPOCH] roots; + // Bitmap tracking which messages (by leaf ID) have been consumed within this epoch. + // The bitmap is shared across every root of the epoch: a message consumed against one + // root cannot be replayed against another root for the same epoch. // Leaf IDs are stable across different epoch proof lengths, ensuring consumed // messages remain marked as consumed when longer proofs are submitted. BitMaps.BitMap nullified; @@ -40,9 +69,16 @@ contract Outbox is IOutbox { IRollup public immutable ROLLUP; uint256 public immutable VERSION; - mapping(Epoch => RootData root) internal roots; + mapping(Epoch epoch => EpochData epochData) internal epochs; constructor(address _rollup, uint256 _version) { + // Keep the file-level literal in lockstep with the generated constant. If this ever fires, + // update MAX_CHECKPOINTS_PER_EPOCH at the top of this file (and IOutbox.sol). + require( + MAX_CHECKPOINTS_PER_EPOCH == Constants.MAX_CHECKPOINTS_PER_EPOCH, + Errors.Outbox__InvalidNumCheckpointsInEpoch(MAX_CHECKPOINTS_PER_EPOCH) + ); + ROLLUP = IRollup(_rollup); VERSION = _version; } @@ -53,15 +89,27 @@ contract Outbox is IOutbox { * @dev Only callable by the rollup contract * @dev Emits `RootAdded` upon inserting the root successfully * + * @dev `_numCheckpointsInEpoch` identifies which partial-proof depth this root corresponds to: + * the rollup proved the first `_numCheckpointsInEpoch` checkpoints of `_epoch`. A subsequent + * insert for the same epoch with a larger `_numCheckpointsInEpoch` adds a new entry without + * disturbing earlier ones, so users with witnesses built against an earlier partial proof can + * still consume them. + * * @param _epoch - The epoch in which the L2 to L1 messages reside + * @param _numCheckpointsInEpoch - The number of checkpoints the inserting proof covered in this + * epoch. Must be in [1, MAX_CHECKPOINTS_PER_EPOCH]. Values outside that range will revert. * @param _root - The merkle root of the tree where all the L2 to L1 messages are leaves */ - function insert(Epoch _epoch, bytes32 _root) external override(IOutbox) { + function insert(Epoch _epoch, uint256 _numCheckpointsInEpoch, bytes32 _root) external override(IOutbox) { require(msg.sender == address(ROLLUP), Errors.Outbox__Unauthorized()); + require( + _numCheckpointsInEpoch >= 1 && _numCheckpointsInEpoch <= MAX_CHECKPOINTS_PER_EPOCH, + Errors.Outbox__InvalidNumCheckpointsInEpoch(_numCheckpointsInEpoch) + ); - roots[_epoch].root = _root; + epochs[_epoch].roots[_numCheckpointsInEpoch - 1] = _root; - emit RootAdded(_epoch, _root); + emit RootAdded(_epoch, _numCheckpointsInEpoch, _root); } /** @@ -72,6 +120,9 @@ contract Outbox is IOutbox { * * @param _message - The L2 to L1 message * @param _epoch - The epoch that contains the message we want to consume + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root this + * consume verifies against. The caller's witness `_path` must have been built against the epoch + * tree padded to that number of real checkpoints. * @param _leafIndex - The index at the level in the wonky tree where the message is located * @param _path - The sibling path used to prove inclusion of the message, the _path length depends * on the location of the L2 to L1 message in the wonky tree. @@ -79,6 +130,7 @@ contract Outbox is IOutbox { function consume( DataStructures.L2ToL1Msg calldata _message, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external override(IOutbox) { @@ -92,24 +144,29 @@ contract Outbox is IOutbox { require(block.chainid == _message.recipient.chainId, Errors.Outbox__InvalidChainId()); - RootData storage rootData = roots[_epoch]; + require( + _numCheckpointsInEpoch >= 1 && _numCheckpointsInEpoch <= MAX_CHECKPOINTS_PER_EPOCH, + Errors.Outbox__NothingToConsumeAtEpoch(_epoch) + ); - bytes32 root = rootData.root; + EpochData storage epochData = epochs[_epoch]; + bytes32 root = epochData.roots[_numCheckpointsInEpoch - 1]; + // A zero root means no proof was ever inserted for this `_numCheckpointsInEpoch`. require(root != bytes32(0), Errors.Outbox__NothingToConsumeAtEpoch(_epoch)); // Compute the unique leaf ID for this message. uint256 leafId = (1 << _path.length) + _leafIndex; - require(!rootData.nullified.get(leafId), Errors.Outbox__AlreadyNullified(_epoch, leafId)); + require(!epochData.nullified.get(leafId), Errors.Outbox__AlreadyNullified(_epoch, leafId)); bytes32 messageHash = _message.sha256ToField(); MerkleLib.verifyMembership(_path, messageHash, _leafIndex, root); - rootData.nullified.set(leafId); + epochData.nullified.set(leafId); - emit MessageConsumed(_epoch, root, messageHash, leafId); + emit MessageConsumed(_epoch, root, messageHash, leafId, _numCheckpointsInEpoch); } /** @@ -123,19 +180,35 @@ contract Outbox is IOutbox { * @return bool - True if the message has been consumed, false otherwise */ function hasMessageBeenConsumedAtEpoch(Epoch _epoch, uint256 _leafId) external view override(IOutbox) returns (bool) { - return roots[_epoch].nullified.get(_leafId); + return epochs[_epoch].nullified.get(_leafId); } /** - * @notice Fetch the root data for a given epoch - * Returns (0, 0) if the epoch is not proven + * @notice Fetch the root data for a given epoch and partial-proof depth + * Returns 0 if no proof has been inserted at that depth (or if the depth is out of range) * * @param _epoch - The epoch to fetch the root data for + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root to fetch * * @return bytes32 - The root of the merkle tree containing the L2 to L1 messages */ - function getRootData(Epoch _epoch) external view override(IOutbox) returns (bytes32) { - RootData storage rootData = roots[_epoch]; - return rootData.root; + function getRootData(Epoch _epoch, uint256 _numCheckpointsInEpoch) external view override(IOutbox) returns (bytes32) { + if (_numCheckpointsInEpoch == 0 || _numCheckpointsInEpoch > MAX_CHECKPOINTS_PER_EPOCH) { + return bytes32(0); + } + return epochs[_epoch].roots[_numCheckpointsInEpoch - 1]; + } + + /** + * @notice Fetch every root stored for a given epoch. The returned array has + * MAX_CHECKPOINTS_PER_EPOCH entries; slot `i` holds the root for + * `numCheckpointsInEpoch = i + 1`, or zero if no proof of that depth has been inserted. + * + * @param _epoch - The epoch to fetch the roots for + * + * @return bytes32[] - The roots stored for this epoch. + */ + function getRoots(Epoch _epoch) external view override(IOutbox) returns (bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory) { + return epochs[_epoch].roots; } } diff --git a/l1-contracts/src/core/reward-boost/RewardBooster.sol b/l1-contracts/src/core/reward-boost/RewardBooster.sol index e20ce3e14d52..37ff81701e01 100644 --- a/l1-contracts/src/core/reward-boost/RewardBooster.sol +++ b/l1-contracts/src/core/reward-boost/RewardBooster.sol @@ -62,6 +62,17 @@ contract RewardBooster is IBooster { } constructor(IValidatorSelection _rollup, RewardBoostConfig memory _config) { + // `_toShares` returns either `CONFIG_K` (top of the curve) or `CONFIG_MINIMUM` (anywhere + // else). If either is zero, an accepted prover submission can record zero shares, which + // RewardLib.handleRewardsAndFees uses as the duplicate-submission sentinel -- the same + // prover could then submit again and reward accounting would be silently dropped. + // `maxScore == 0` short-circuits every call to the K branch; require it positive so the + // curve has a real range. `minimum <= k` keeps the curve monotonic. + require(_config.k > 0, Errors.RewardBooster__InvalidConfig()); + require(_config.minimum > 0, Errors.RewardBooster__InvalidConfig()); + require(_config.maxScore > 0, Errors.RewardBooster__InvalidConfig()); + require(_config.minimum <= _config.k, Errors.RewardBooster__InvalidConfig()); + ROLLUP = _rollup; CONFIG_INCREMENT = _config.increment; diff --git a/l1-contracts/src/governance/RewardDistributor.sol b/l1-contracts/src/governance/RewardDistributor.sol index 632b49b53581..b3ba10c168c3 100644 --- a/l1-contracts/src/governance/RewardDistributor.sol +++ b/l1-contracts/src/governance/RewardDistributor.sol @@ -11,31 +11,162 @@ import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; /** * @title RewardDistributor - * @notice This contract is responsible for distributing rewards. + * @notice Holds ASSET and makes it claimable by the canonical rollup, with optional per-address earmarking. + * + * Any address may be specifically funded via `subsidizeAddress`. ASSETs transferred to this contract + * directly (not via `subsidizeAddress`) form an implicit pool that is claimable only by the + * presently canonical rollup, in addition to whatever has been earmarked to it. + * + * Rollups are not privileged at the bookkeeping layer: the only place the concept of "rollup" + * enters is that the canonical rollup is the sole address with access to the implicit pool. + * Earmarked balances are tracked per arbitrary address. + * + * Governance may recover all funds, earmarked or otherwise. + * + * NOTE: This is intended to be used with the $AZTEC token (0xa27ec0006e59f245217ff08cd52a7e8b169e62d2) + * or at least a standard, ERC-20 token that does not have any fee-on-transfer or rebasing. */ contract RewardDistributor is IRewardDistributor { using SafeERC20 for IERC20; + /// @notice The ERC-20 token distributed by this contract. IERC20 public immutable ASSET; + /// @notice The registry consulted to resolve the canonical rollup and the governance owner. IRegistry public immutable REGISTRY; + /// @notice Earmarked ASSET balance per recipient. + /// @dev ASSET sent directly to this contract (not via `subsidizeAddress`) is *not* recorded + /// here; it forms the implicit pool available to the canonical rollup. + mapping(address recipient => uint256 amount) public specificRecipientBalance; + + /// @notice The sum of `specificRecipientBalance` across all recipients. + uint256 public totalEarmarkedBalance; + + /** + * @notice Bind this distributor to a specific ASSET and registry. + * @param _asset The ERC-20 token this contract will hold and distribute. + * @param _registry The registry used to look up the canonical rollup and governance owner. + */ constructor(IERC20 _asset, IRegistry _registry) { ASSET = _asset; REGISTRY = _registry; } + /** + * @notice Transfer funds from msg.sender and earmark them for `_recipient`. + * @dev No validation is made that `_recipient` is a rollup or registered with the registry. + * This allows rollups that have not been registered yet to receive funds, or even a + * contract that is not a rollup at all to be funded through this contract. Rollups are + * not privileged here; the only place "rollup" matters is that the canonical rollup + * additionally has access to the implicit (un-earmarked) pool via `claim`. + * @param _recipient The address to earmark claimable funds to. Must be non-zero. + * @param _amount The amount of ASSET to pull from msg.sender. + */ + function subsidizeAddress(address _recipient, uint256 _amount) external override(IRewardDistributor) { + require(_recipient != address(0), Errors.RewardDistributor__ZeroRollup()); + specificRecipientBalance[_recipient] += _amount; + totalEarmarkedBalance += _amount; + ASSET.safeTransferFrom(msg.sender, address(this), _amount); + emit Subsidized(msg.sender, _recipient, _amount); + } + + /** + * @notice Claim funds available to the caller. + * @dev When the caller is the canonical rollup it can draw from both the implicit pool + * (un-earmarked ASSET held by this contract) and any balance earmarked to it. For any + * other caller only its earmarked balance is available. + * @param _to The address that receives the transferred ASSET. + * @param _amount The amount of ASSET to transfer. + */ function claim(address _to, uint256 _amount) external override(IRewardDistributor) { - require(msg.sender == canonicalRollup(), Errors.RewardDistributor__InvalidCaller(msg.sender, canonicalRollup())); - ASSET.safeTransfer(_to, _amount); + _transfer(msg.sender, _to, _amount); + } + + /** + * @notice Governance-only recovery of ASSET held by this contract. + * @dev Same accounting rules as `claim`: when `_from` is the canonical rollup the implicit + * pool is drawn from first, otherwise only `_from`'s earmarked balance is available. + * The function selector differs from the pre-existing `recover(address,address,uint256)` + * only in parameter naming, so this signature is intentionally renamed to avoid silently + * hijacking call sites that targeted the older shape. + * @param _from The address whose accounting bucket the funds are drawn from. + * @param _to The recipient of the recovered ASSET. + * @param _amount The amount of ASSET to transfer. + */ + function recoverFrom(address _from, address _to, uint256 _amount) external override(IRewardDistributor) { + address owner = Ownable(address(REGISTRY)).owner(); + require(msg.sender == owner, Errors.RewardDistributor__InvalidCaller(msg.sender, owner)); + _transfer(_from, _to, _amount); } - function recover(address _asset, address _to, uint256 _amount) external override(IRewardDistributor) { + /** + * @notice Governance-only recovery of tokens other than ASSET that ended up in this contract. + * @dev Refuses ASSET so the ASSET accounting (implicit pool + earmarked balances) cannot be + * bypassed. Use `recoverFrom` for ASSET. + * @param _asset The ERC-20 token to transfer; must not equal `ASSET`. + * @param _to The recipient of the transferred tokens. + * @param _amount The amount to transfer. + */ + function recoverWrongAsset(address _asset, address _to, uint256 _amount) external override(IRewardDistributor) { address owner = Ownable(address(REGISTRY)).owner(); require(msg.sender == owner, Errors.RewardDistributor__InvalidCaller(msg.sender, owner)); + require(_asset != address(ASSET), Errors.RewardDistributor__WrongRecoverMechanism()); IERC20(_asset).safeTransfer(_to, _amount); } + /** + * @notice Returns the ASSET amount that `_recipient` can currently `claim`. + * @dev The canonical rollup sees the implicit pool plus its own earmarked balance; any other + * address sees only its earmarked balance. + * @param _recipient The address to query the available balance for. + * @return The amount of ASSET `_recipient` can claim right now. + */ + function availableTo(address _recipient) public view override(IRewardDistributor) returns (uint256) { + address canonical = canonicalRollup(); + uint256 claimableAsCanonical = _recipient == canonical ? ASSET.balanceOf(address(this)) - totalEarmarkedBalance : 0; + return claimableAsCanonical + specificRecipientBalance[_recipient]; + } + + /** + * @notice Returns the address currently registered as the canonical rollup in the registry. + * @return The canonical rollup address; this is the only address with access to the implicit pool. + */ function canonicalRollup() public view override(IRewardDistributor) returns (address) { return address(REGISTRY.getCanonicalRollup()); } + + /** + * @notice Shared accounting path for `claim` and `recoverFrom`. + * @dev When `_from` is the canonical rollup, the implicit (un-earmarked) pool is consumed + * first; any shortfall is drawn from `_from`'s earmarked balance. For non-canonical + * `_from`, only its earmarked balance is available. + * @param _from The accounting bucket to draw funds from. + * @param _to The recipient of the transferred ASSET. + * @param _amount The amount of ASSET to transfer. + */ + function _transfer(address _from, address _to, uint256 _amount) internal { + address canonical = canonicalRollup(); + uint256 claimableAsCanonical = _from == canonical ? ASSET.balanceOf(address(this)) - totalEarmarkedBalance : 0; + + // This is the standard case, so avoid SLOAD if we can + if (_amount <= claimableAsCanonical) { + ASSET.safeTransfer(_to, _amount); + emit Distributed(_from, _to, _amount, _amount, 0); + return; + } + + // Canonical balance couldn't cover the requested amount, + // see if we can get there with funds earmarked for this address. + uint256 earmarked = specificRecipientBalance[_from]; + uint256 totalAvailable = claimableAsCanonical + earmarked; + require(totalAvailable >= _amount, Errors.RewardDistributor__InsufficientAvailable(_amount, totalAvailable)); + + // Reduce this address's earmarked funds and totalEarmarkedBalance since we know we drew from it. + // Effectively, we draw from the canonical/implicit pool first. + uint256 earmarkedFundsUsed = _amount - claimableAsCanonical; + specificRecipientBalance[_from] -= earmarkedFundsUsed; + totalEarmarkedBalance -= earmarkedFundsUsed; + ASSET.safeTransfer(_to, _amount); + emit Distributed(_from, _to, _amount, claimableAsCanonical, earmarkedFundsUsed); + } } diff --git a/l1-contracts/src/governance/interfaces/IRewardDistributor.sol b/l1-contracts/src/governance/interfaces/IRewardDistributor.sol index 60616169d0a2..c4031892895a 100644 --- a/l1-contracts/src/governance/interfaces/IRewardDistributor.sol +++ b/l1-contracts/src/governance/interfaces/IRewardDistributor.sol @@ -2,7 +2,22 @@ pragma solidity >=0.8.27; interface IRewardDistributor { + /// @notice Emitted when a funder earmarks ASSET for a specific recipient via `subsidizeAddress`. + event Subsidized(address indexed funder, address indexed recipient, uint256 amount); + + /// @notice Emitted whenever `claim` or `recoverFrom` debits the distributor. + /// @dev `implicitAmountUsed` is the share drawn from the canonical-rollup implicit pool, + /// `earmarkedAmountUsed` is the share drawn from `from`'s earmarked balance, and the two + /// always sum to `amount`. Lets a log-only indexer reconstruct bucket-by-bucket history + /// without polling storage at every block. + event Distributed( + address indexed from, address indexed to, uint256 amount, uint256 implicitAmountUsed, uint256 earmarkedAmountUsed + ); + function claim(address _to, uint256 _amount) external; - function recover(address _asset, address _to, uint256 _amount) external; + function recoverFrom(address _from, address _to, uint256 _amount) external; + function recoverWrongAsset(address _asset, address _to, uint256 _amount) external; + function subsidizeAddress(address _recipient, uint256 _amount) external; function canonicalRollup() external view returns (address); + function availableTo(address _recipient) external view returns (uint256); } diff --git a/l1-contracts/src/governance/libraries/Errors.sol b/l1-contracts/src/governance/libraries/Errors.sol index ae25f43e4421..34d5587e46ba 100644 --- a/l1-contracts/src/governance/libraries/Errors.sol +++ b/l1-contracts/src/governance/libraries/Errors.sol @@ -65,6 +65,9 @@ library Errors { error Registry__NoRollupsRegistered(); error RewardDistributor__InvalidCaller(address caller, address canonical); // 0xb95e39f6 + error RewardDistributor__InsufficientAvailable(uint256 requested, uint256 available); + error RewardDistributor__ZeroRollup(); + error RewardDistributor__WrongRecoverMechanism(); error GSE__NotRollup(address); error GSE__GovernanceAlreadySet(); diff --git a/l1-contracts/test/Outbox.t.sol b/l1-contracts/test/Outbox.t.sol index 6f97c5cc9adc..820400c246f4 100644 --- a/l1-contracts/test/Outbox.t.sol +++ b/l1-contracts/test/Outbox.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.27; import {Test} from "forge-std/Test.sol"; -import {Outbox} from "@aztec/core/messagebridge/Outbox.sol"; +import {Outbox, MAX_CHECKPOINTS_PER_EPOCH} from "@aztec/core/messagebridge/Outbox.sol"; import {IOutbox} from "@aztec/core/interfaces/messagebridge/IOutbox.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {Epoch} from "@aztec/core/libraries/TimeLib.sol"; @@ -26,6 +26,9 @@ contract OutboxTest is Test { uint256 internal constant AZTEC_VERSION = 1; Epoch internal constant DEFAULT_EPOCH = Epoch.wrap(1); + // Most tests insert a single root for an epoch. Use K = 1 to identify it. + uint256 internal constant K1 = 1; + address internal ROLLUP_CONTRACT; Outbox internal outbox; NaiveMerkle internal epochTree; @@ -51,6 +54,7 @@ contract OutboxTest is Test { function _consumeMessageAtEpoch( Epoch epoch, + uint256 numCheckpointsInEpoch, NaiveMerkle tree, uint256 leafIndex, bytes32 leaf, @@ -65,19 +69,20 @@ contract OutboxTest is Test { assertEq(abi.encode(0), abi.encode(statusBeforeConsumption)); vm.expectEmit(true, true, true, true, address(outbox)); - emit IOutbox.MessageConsumed(epoch, root, leaf, leafId); - outbox.consume(message, epoch, leafIndex, path); + emit IOutbox.MessageConsumed(epoch, root, leaf, leafId, numCheckpointsInEpoch); + outbox.consume(message, epoch, numCheckpointsInEpoch, leafIndex, path); bool statusAfterConsumption = outbox.hasMessageBeenConsumedAtEpoch(epoch, leafId); assertEq(abi.encode(1), abi.encode(statusAfterConsumption)); } function _consumeMessage(uint256 leafIndex, bytes32 leaf, DataStructures.L2ToL1Msg memory message) internal { - _consumeMessageAtEpoch(DEFAULT_EPOCH, epochTree, leafIndex, leaf, message); + _consumeMessageAtEpoch(DEFAULT_EPOCH, K1, epochTree, leafIndex, leaf, message); } function _consumeNullifiedMessageAtEpoch( Epoch epoch, + uint256 numCheckpointsInEpoch, NaiveMerkle tree, uint256 leafIndex, DataStructures.L2ToL1Msg memory message @@ -86,11 +91,11 @@ contract OutboxTest is Test { (bytes32[] memory path,) = tree.computeSiblingPath(leafIndex); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, epoch, leafId)); - outbox.consume(message, epoch, leafIndex, path); + outbox.consume(message, epoch, numCheckpointsInEpoch, leafIndex, path); } function _consumeNullifiedMessage(uint256 leafIndex, DataStructures.L2ToL1Msg memory message) internal { - _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, epochTree, leafIndex, message); + _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, K1, epochTree, leafIndex, message); } function testRevertIfInsertingFromNonRollup(address _caller) public { @@ -99,14 +104,29 @@ contract OutboxTest is Test { vm.prank(_caller); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__Unauthorized.selector)); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); + } + + function testRevertIfInsertingNumCheckpointsZero() public { + bytes32 root = epochTree.computeRoot(); + vm.prank(ROLLUP_CONTRACT); + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__InvalidNumCheckpointsInEpoch.selector, 0)); + outbox.insert(DEFAULT_EPOCH, 0, root); + } + + function testRevertIfInsertingNumCheckpointsAboveMax(uint256 _n) public { + uint256 n = bound(_n, MAX_CHECKPOINTS_PER_EPOCH + 1, type(uint256).max); + bytes32 root = epochTree.computeRoot(); + vm.prank(ROLLUP_CONTRACT); + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__InvalidNumCheckpointsInEpoch.selector, n)); + outbox.insert(DEFAULT_EPOCH, n, root); } function testRevertIfPathTooLong() public { DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); bytes32[] memory path = new bytes32[](256); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__PathTooLong.selector)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 0, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 0, path); } function testRevertIfLeafIndexOutOfBounds(uint256 _leafIndex) public { @@ -114,10 +134,9 @@ contract OutboxTest is Test { bytes32[] memory path = new bytes32[](4); uint256 leafIndex = bound(_leafIndex, 1 << path.length, type(uint256).max); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__LeafIndexOutOfBounds.selector, leafIndex, path.length)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, leafIndex, path); } - // This function tests the insertion of random arrays of L2 to L1 messages // We make a naive tree with a computed height, insert the leafs into it, and compute a root. We then add the root as // the root of the L2 to L1 message tree, expect for the correct event to be emitted, and then query for the root in // the contract, making sure the roots match. @@ -133,12 +152,16 @@ contract OutboxTest is Test { bytes32 root = tree.computeRoot(); vm.expectEmit(true, true, true, true, address(outbox)); - emit IOutbox.RootAdded(DEFAULT_EPOCH, root); + emit IOutbox.RootAdded(DEFAULT_EPOCH, K1, root); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); - bytes32 actualRoot = outbox.getRootData(DEFAULT_EPOCH); - assertEq(root, actualRoot); + assertEq(root, outbox.getRootData(DEFAULT_EPOCH, K1)); + bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory roots = outbox.getRoots(DEFAULT_EPOCH); + assertEq(roots[0], root); + for (uint256 i = 1; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + assertEq(roots[i], bytes32(0)); + } } function testRevertIfConsumingMessageBelongingToOther() public { @@ -148,7 +171,7 @@ contract OutboxTest is Test { vm.prank(NOT_RECIPIENT); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__InvalidRecipient.selector, address(this), NOT_RECIPIENT)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 1, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 1, path); } function testRevertIfConsumingMessageWithInvalidChainId() public { @@ -159,7 +182,7 @@ contract OutboxTest is Test { fakeMessage.recipient.chainId = block.chainid + 1; vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__InvalidChainId.selector)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 1, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 1, path); } function testRevertIfVersionMismatch() public { @@ -170,7 +193,7 @@ contract OutboxTest is Test { vm.expectRevert( abi.encodeWithSelector(Errors.Outbox__VersionMismatch.selector, message.sender.version, AZTEC_VERSION) ); - outbox.consume(message, DEFAULT_EPOCH, 1, path); + outbox.consume(message, DEFAULT_EPOCH, K1, 1, path); } function testRevertIfNothingInsertedAtEpoch() public { @@ -179,7 +202,54 @@ contract OutboxTest is Test { (bytes32[] memory path,) = epochTree.computeSiblingPath(0); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__NothingToConsumeAtEpoch.selector, DEFAULT_EPOCH)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 1, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 1, path); + } + + function testRevertIfConsumingAtNumCheckpointsWithoutRoot() public { + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + // Insert at K=1, but try to consume at K=2 (no root there). + vm.prank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, K1, root); + + (bytes32[] memory path,) = epochTree.computeSiblingPath(0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__NothingToConsumeAtEpoch.selector, DEFAULT_EPOCH)); + outbox.consume(fakeMessage, DEFAULT_EPOCH, 2, 0, path); + } + + function testRevertIfConsumingAtNumCheckpointsZero() public { + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + vm.prank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, K1, root); + + (bytes32[] memory path,) = epochTree.computeSiblingPath(0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__NothingToConsumeAtEpoch.selector, DEFAULT_EPOCH)); + outbox.consume(fakeMessage, DEFAULT_EPOCH, 0, 0, path); + } + + function testRevertIfConsumingAtNumCheckpointsAboveMax(uint256 _n) public { + uint256 n = bound(_n, MAX_CHECKPOINTS_PER_EPOCH + 1, type(uint256).max); + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + vm.prank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, K1, root); + + (bytes32[] memory path,) = epochTree.computeSiblingPath(0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__NothingToConsumeAtEpoch.selector, DEFAULT_EPOCH)); + outbox.consume(fakeMessage, DEFAULT_EPOCH, n, 0, path); } function testValidInsertAndConsume() public { @@ -190,7 +260,7 @@ contract OutboxTest is Test { bytes32 root = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); uint256 leafIndex = 0; _consumeMessage(leafIndex, leaf, fakeMessage); @@ -204,7 +274,7 @@ contract OutboxTest is Test { bytes32 root = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); uint256 leafIndex = 0; _consumeMessage(leafIndex, leaf, fakeMessage); @@ -220,7 +290,7 @@ contract OutboxTest is Test { bytes32 root = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); NaiveMerkle smallerTree = new NaiveMerkle(DEFAULT_TREE_HEIGHT - 1); smallerTree.insertLeaf(leaf); @@ -228,7 +298,7 @@ contract OutboxTest is Test { (bytes32[] memory path,) = smallerTree.computeSiblingPath(0); vm.expectRevert(abi.encodeWithSelector(Errors.MerkleLib__InvalidRoot.selector, root, smallerTreeRoot, leaf, 0)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 0, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 0, path); } function testRevertIfTryingToConsumeMessageNotInTree() public { @@ -245,12 +315,12 @@ contract OutboxTest is Test { bytes32 modifiedRoot = modifiedTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); (bytes32[] memory path,) = modifiedTree.computeSiblingPath(0); vm.expectRevert(abi.encodeWithSelector(Errors.MerkleLib__InvalidRoot.selector, root, modifiedRoot, modifiedLeaf, 0)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 0, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 0, path); } // This test takes awhile so to keep it somewhat reasonable we've set a limit on the amount of fuzz runs @@ -278,18 +348,18 @@ contract OutboxTest is Test { bytes32 root = tree.computeRoot(); vm.expectEmit(true, true, true, true, address(outbox)); - emit IOutbox.RootAdded(epoch, root); + emit IOutbox.RootAdded(epoch, K1, root); vm.prank(ROLLUP_CONTRACT); - outbox.insert(epoch, root); + outbox.insert(epoch, K1, root); for (uint256 i = 0; i < numberOfMessages; i++) { (bytes32[] memory path, bytes32 leaf) = tree.computeSiblingPath(i); uint256 leafId = 2 ** treeHeight + i; vm.expectEmit(true, true, true, true, address(outbox)); - emit IOutbox.MessageConsumed(epoch, root, leaf, leafId); + emit IOutbox.MessageConsumed(epoch, root, leaf, leafId, K1); vm.prank(_recipients[i]); - outbox.consume(messages[i], epoch, i, path); + outbox.consume(messages[i], epoch, K1, i, path); } } @@ -302,18 +372,276 @@ contract OutboxTest is Test { bytes32 root = epochTree.computeRoot(); vm.startPrank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, 1, root); + outbox.insert(DEFAULT_EPOCH, 2, root); + vm.stopPrank(); + + assertEq(root, outbox.getRootData(DEFAULT_EPOCH, 1)); + assertEq(root, outbox.getRootData(DEFAULT_EPOCH, 2)); + + // numCheckpointsInEpoch=0 and >MAX both return zero, no revert. + assertEq(bytes32(0), outbox.getRootData(DEFAULT_EPOCH, 0)); + assertEq(bytes32(0), outbox.getRootData(DEFAULT_EPOCH, 3)); + assertEq(bytes32(0), outbox.getRootData(DEFAULT_EPOCH, MAX_CHECKPOINTS_PER_EPOCH + 1)); + + // Unrelated epoch returns zero for any K. + Epoch otherEpoch = DEFAULT_EPOCH + Epoch.wrap(1); + assertEq(bytes32(0), outbox.getRootData(otherEpoch, 1)); + bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory otherRoots = outbox.getRoots(otherEpoch); + for (uint256 i = 0; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + assertEq(otherRoots[i], bytes32(0)); + } + } + + function testGetRootsReturnsAllSlots() public { + bytes32 r1 = bytes32(uint256(0xa)); + bytes32 r3 = bytes32(uint256(0xb)); + bytes32 rMax = bytes32(uint256(0xc)); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, r1); + outbox.insert(DEFAULT_EPOCH, 3, r3); + outbox.insert(DEFAULT_EPOCH, MAX_CHECKPOINTS_PER_EPOCH, rMax); + vm.stopPrank(); + + bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory roots = outbox.getRoots(DEFAULT_EPOCH); + assertEq(roots[0], r1); + assertEq(roots[1], bytes32(0)); + assertEq(roots[2], r3); + for (uint256 i = 3; i < MAX_CHECKPOINTS_PER_EPOCH - 1; i++) { + assertEq(roots[i], bytes32(0)); + } + assertEq(roots[MAX_CHECKPOINTS_PER_EPOCH - 1], rMax); + } + + function testMessageConsumedEventCarriesNumCheckpoints() public { + // Insert two distinct roots at K=1 and K=2 for the same epoch. Consume one message against + // each root and verify the emitted MessageConsumed carries the exact numCheckpointsInEpoch + // the caller proved against, so a log-only indexer can recover the AZIP-14 root slot without + // decoding calldata or replaying RootAdded state. The two messages live at different + // positions so their leaf ids are distinct (the bitmap is shared across roots in the epoch). + DataStructures.L2ToL1Msg memory m0 = _fakeMessage(address(this), 700); + DataStructures.L2ToL1Msg memory m1 = _fakeMessage(address(this), 701); + + bytes32 leaf0 = m0.sha256ToField(); + bytes32 leaf1 = m1.sha256ToField(); + + NaiveMerkle tree0 = new NaiveMerkle(1); + tree0.insertLeaf(leaf0); + tree0.insertLeaf(bytes32(uint256(0))); + bytes32 root0 = tree0.computeRoot(); + + NaiveMerkle tree1 = new NaiveMerkle(1); + tree1.insertLeaf(bytes32(uint256(0))); + tree1.insertLeaf(leaf1); + bytes32 root1 = tree1.computeRoot(); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, root0); + outbox.insert(DEFAULT_EPOCH, 2, root1); + vm.stopPrank(); + + uint256 leafId0 = (1 << 1) + 0; + uint256 leafId1 = (1 << 1) + 1; + (bytes32[] memory path0,) = tree0.computeSiblingPath(0); + (bytes32[] memory path1,) = tree1.computeSiblingPath(1); + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, root0, leaf0, leafId0, 1); + outbox.consume(m0, DEFAULT_EPOCH, 1, 0, path0); + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, root1, leaf1, leafId1, 2); + outbox.consume(m1, DEFAULT_EPOCH, 2, 1, path1); + } + + function testRootAddedEventCarriesNumCheckpoints() public { + bytes32 r1 = bytes32(uint256(0xa)); + bytes32 r2 = bytes32(uint256(0xb)); + bytes32 r3 = bytes32(uint256(0xc)); + + vm.startPrank(ROLLUP_CONTRACT); + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.RootAdded(DEFAULT_EPOCH, 1, r1); + outbox.insert(DEFAULT_EPOCH, 1, r1); + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.RootAdded(DEFAULT_EPOCH, 2, r2); + outbox.insert(DEFAULT_EPOCH, 2, r2); + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.RootAdded(DEFAULT_EPOCH, 3, r3); + outbox.insert(DEFAULT_EPOCH, 3, r3); + vm.stopPrank(); + + assertEq(outbox.getRootData(DEFAULT_EPOCH, 1), r1); + assertEq(outbox.getRootData(DEFAULT_EPOCH, 2), r2); + assertEq(outbox.getRootData(DEFAULT_EPOCH, 3), r3); + } + + function testConsumeAgainstFirstRootOfMultiple() public { + // Single message included in the first (smaller) root, then a second root is inserted on top. + // Consuming against the first root must still succeed. + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 firstRoot = epochTree.computeRoot(); + + // Insert a second (different) root for the same epoch at K=2. + bytes32 secondRoot = bytes32(uint256(uint256(firstRoot) ^ 0x1)); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, firstRoot); + outbox.insert(DEFAULT_EPOCH, 2, secondRoot); vm.stopPrank(); + uint256 leafIndex = 0; + uint256 leafId = 2 ** DEFAULT_TREE_HEIGHT + leafIndex; + (bytes32[] memory path,) = epochTree.computeSiblingPath(leafIndex); + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, firstRoot, leaf, leafId, 1); + outbox.consume(fakeMessage, DEFAULT_EPOCH, 1, leafIndex, path); + + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafId)); + } + + function testReplayAcrossRootsRejected() public { + // Build a wonky tree so the leaf id of an earlier-checkpoint message is preserved when the tree grows. + // After consuming against the first root, attempting to replay against a second root with the same leaf id + // must revert with Outbox__AlreadyNullified. + DataStructures.L2ToL1Msg[] memory msgs = new DataStructures.L2ToL1Msg[](3); + bytes32[] memory leaves = new bytes32[](3); + for (uint256 i = 0; i < 3; i++) { + msgs[i] = _fakeMessage(address(this), i + 123); + leaves[i] = msgs[i].sha256ToField(); + } + + // First root (K=1): wonky tree with the first 2 messages. + // firstRoot + // / \ + // m0 m1 + bytes32 firstRoot; { - bytes32 actualRoot = outbox.getRootData(DEFAULT_EPOCH); - assertEq(root, actualRoot); + NaiveMerkle firstTree = new NaiveMerkle(1); + firstTree.insertLeaf(leaves[0]); + firstTree.insertLeaf(leaves[1]); + firstRoot = firstTree.computeRoot(); } + // Second root (K=2): an extended wonky tree that still has m0 at the top-left position. + // secondRoot + // / \ + // m0 subtree(m1,m2) + bytes32 secondRoot; + bytes32 subtreeRoot; { - bytes32 actualRoot = outbox.getRootData(DEFAULT_EPOCH + Epoch.wrap(1)); - assertEq(bytes32(0), actualRoot); + NaiveMerkle subtree = new NaiveMerkle(1); + subtree.insertLeaf(leaves[1]); + subtree.insertLeaf(leaves[2]); + subtreeRoot = subtree.computeRoot(); + NaiveMerkle secondTree = new NaiveMerkle(1); + secondTree.insertLeaf(leaves[0]); + secondTree.insertLeaf(subtreeRoot); + secondRoot = secondTree.computeRoot(); + } + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, firstRoot); + outbox.insert(DEFAULT_EPOCH, 2, secondRoot); + vm.stopPrank(); + + // leaves[0]'s leaf id is the same against either root because its position in the wonky tree is preserved. + uint256 leafId = (1 << 1); + + // Consume against the first root: sibling is leaves[1]. + { + bytes32[] memory path = new bytes32[](1); + path[0] = leaves[1]; + outbox.consume(msgs[0], DEFAULT_EPOCH, 1, 0, path); + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafId)); + } + + // Attempt to replay against the second root: must revert because the bitmap is shared. + // Against the second root, m0's sibling is `subtreeRoot`. + { + bytes32[] memory path = new bytes32[](1); + path[0] = subtreeRoot; + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); + outbox.consume(msgs[0], DEFAULT_EPOCH, 2, 0, path); + } + + // A message that only exists in the second root can still be consumed against K=2. + // leaves[2] sits at leafIndex=3 (depth 2): subtree(m1,m2) right child. + { + uint256 m2LeafIndex = 3; + uint256 m2LeafId = (1 << 2) + m2LeafIndex; + bytes32[] memory m2Path = new bytes32[](2); + m2Path[0] = leaves[1]; + m2Path[1] = leaves[0]; + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, secondRoot, leaves[2], m2LeafId, 2); + outbox.consume(msgs[2], DEFAULT_EPOCH, 2, m2LeafIndex, m2Path); + } + } + + // Companion to testReplayAcrossRootsRejected. Consumes msgs[0] against the K=2 root first + // (forcing MerkleLib to actually accept the path against the second root, proving it's a valid + // proof), and then attempts to replay against K=1. The replay must revert. + function testReplayAcrossRootsRejectedReverseOrder() public { + DataStructures.L2ToL1Msg[] memory msgs = new DataStructures.L2ToL1Msg[](3); + bytes32[] memory leaves = new bytes32[](3); + for (uint256 i = 0; i < 3; i++) { + msgs[i] = _fakeMessage(address(this), i + 200); + leaves[i] = msgs[i].sha256ToField(); + } + + bytes32 firstRoot; + { + NaiveMerkle firstTree = new NaiveMerkle(1); + firstTree.insertLeaf(leaves[0]); + firstTree.insertLeaf(leaves[1]); + firstRoot = firstTree.computeRoot(); + } + + bytes32 secondRoot; + bytes32 subtreeRoot; + { + NaiveMerkle subtree = new NaiveMerkle(1); + subtree.insertLeaf(leaves[1]); + subtree.insertLeaf(leaves[2]); + subtreeRoot = subtree.computeRoot(); + NaiveMerkle secondTree = new NaiveMerkle(1); + secondTree.insertLeaf(leaves[0]); + secondTree.insertLeaf(subtreeRoot); + secondRoot = secondTree.computeRoot(); + } + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, firstRoot); + outbox.insert(DEFAULT_EPOCH, 2, secondRoot); + vm.stopPrank(); + + uint256 leafId = (1 << 1); + + // Consume msgs[0] against the SECOND root first. This goes through MerkleLib verification + // and only succeeds if the (path, leafIndex) genuinely proves m0 against secondRoot. + { + bytes32[] memory path = new bytes32[](1); + path[0] = subtreeRoot; + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, secondRoot, leaves[0], leafId, 2); + outbox.consume(msgs[0], DEFAULT_EPOCH, 2, 0, path); + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafId)); + } + + // Now replay against K=1 with a valid first-root path. Must revert at the shared bitmap check. + { + bytes32[] memory path = new bytes32[](1); + path[0] = leaves[1]; + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); + outbox.consume(msgs[0], DEFAULT_EPOCH, 1, 0, path); } } @@ -325,7 +653,7 @@ contract OutboxTest is Test { bytes32 root = leaf; vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, leaf); + outbox.insert(DEFAULT_EPOCH, K1, leaf); uint256 leafIndex = 0; uint256 leafId = 1; @@ -334,9 +662,9 @@ contract OutboxTest is Test { assertEq(abi.encode(0), abi.encode(statusBeforeConsumption)); vm.expectEmit(true, true, true, true, address(outbox)); - emit IOutbox.MessageConsumed(DEFAULT_EPOCH, root, leaf, leafId); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, root, leaf, leafId, K1); bytes32[] memory path = new bytes32[](0); - outbox.consume(fakeMessage, DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, leafIndex, path); bool statusAfterConsumption = outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafId); assertEq(abi.encode(1), abi.encode(statusAfterConsumption)); @@ -357,26 +685,18 @@ contract OutboxTest is Test { // / \ // tx0 tx1 - // First, build the left subtree with 2 leaves. - // subtreeRoot - // / \ - // tx0 tx1 NaiveMerkle subtree = new NaiveMerkle(1); subtree.insertLeaf(leaves[0]); subtree.insertLeaf(leaves[1]); bytes32 subtreeRoot = subtree.computeRoot(); - // Then, build the top tree with the subtree root and the last leaf. - // outHash - // / \ - // subtreeRoot tx2 NaiveMerkle topTree = new NaiveMerkle(1); topTree.insertLeaf(subtreeRoot); topTree.insertLeaf(leaves[2]); bytes32 root = topTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); // Consume the message of tx0. { @@ -390,10 +710,10 @@ contract OutboxTest is Test { path[0] = subtreePath[0]; path[1] = topTreePath[0]; } - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume the message of tx1. @@ -408,10 +728,10 @@ contract OutboxTest is Test { path[0] = subtreePath[0]; path[1] = topTreePath[0]; } - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume the message of tx2. @@ -420,10 +740,10 @@ contract OutboxTest is Test { uint256 leafIndex = 1; uint256 leafId = 2 ** 1 + 1; (bytes32[] memory path,) = topTree.computeSiblingPath(1); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } } @@ -442,10 +762,8 @@ contract OutboxTest is Test { bytes32[] memory txOutHashes = new bytes32[](3); - // tx0 has 1 message, the message leaf is the root. txOutHashes[0] = leaves[0]; - // Build the subtree of tx1 with 3 message. bytes32 tx1SubtreeRoot; { NaiveMerkle subtree = new NaiveMerkle(1); @@ -458,7 +776,6 @@ contract OutboxTest is Test { txOutHashes[1] = topTree.computeRoot(); } - // Build the subtree of tx2 with 3 messages. bytes32 tx2SubtreeRoot; { NaiveMerkle tx2Subtree = new NaiveMerkle(1); @@ -471,17 +788,6 @@ contract OutboxTest is Test { txOutHashes[2] = tx2TopTree.computeRoot(); } - // Build a wonky tree of 3 txs. - // outHash - // / \ - // . tx2 - // / \ - // tx0 tx1 - - // First, build the left subtree with 2 txOutHashes. - // subtreeRoot - // / \ - // tx0 tx1 bytes32 subtreeRoot; { NaiveMerkle subtree = new NaiveMerkle(1); @@ -490,10 +796,6 @@ contract OutboxTest is Test { subtreeRoot = subtree.computeRoot(); } - // Then, build the top tree with the subtree root and the last txOutHash. - // outHash - // / \ - // subtreeRoot tx2 { NaiveMerkle topTree = new NaiveMerkle(1); topTree.insertLeaf(subtreeRoot); @@ -501,98 +803,72 @@ contract OutboxTest is Test { bytes32 root = topTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); } // Consume messages[0] in tx0. { - // outHash - // / \ - // . tx2 - // / \ - // m0 tx1 uint256 msgIndex = 0; uint256 leafIndex = 0; uint256 leafId = 2 ** 2; bytes32[] memory path = new bytes32[](2); path[0] = txOutHashes[1]; path[1] = txOutHashes[2]; - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume messages[2] in tx1. { - // outHash - // / \ - // . tx2 - // / \ - // tx0 tx1 - // / \ - // . m3 - // / \ - // m1 m2 uint256 msgIndex = 2; - uint256 leafIndex = 5; // Leaf at index 5 in a balanced tree of height 4. + uint256 leafIndex = 5; uint256 leafId = 2 ** 4 + leafIndex; bytes32[] memory path = new bytes32[](4); path[0] = leaves[1]; path[1] = leaves[3]; path[2] = txOutHashes[0]; path[3] = txOutHashes[2]; - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume messages[4] in tx2. { - // outHash - // / \ - // . tx2 - // / \ - // . m6 - // / \ - // m4 m5 uint256 msgIndex = 4; - uint256 leafIndex = 4; // Leaf at index 4 in a balanced tree of height 3. + uint256 leafIndex = 4; uint256 leafId = 2 ** 3 + leafIndex; bytes32[] memory path = new bytes32[](3); path[0] = leaves[5]; path[1] = leaves[6]; path[2] = subtreeRoot; - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume messages[6] in tx2. { - // outHash - // / \ - // . tx2 - // / \ - // . m6 uint256 msgIndex = 6; - uint256 leafIndex = 3; // Leaf at index 3 in a balanced tree of height 2. + uint256 leafIndex = 3; uint256 leafId = 2 ** 2 + leafIndex; bytes32[] memory path = new bytes32[](2); path[0] = tx2SubtreeRoot; path[1] = subtreeRoot; - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } } - // This test checks that the status of existing messages is preserved when the root for an epoch is overwritten. + // This test checks that the status of existing messages is preserved when a new (extending) root is inserted + // for the same epoch. function testConsumeAgainFailAfterChainProgressed() public { - // Create 3 messages to be inserted into the epoch tree. DataStructures.L2ToL1Msg[] memory fakeMessages = new DataStructures.L2ToL1Msg[](3); bytes32[] memory leaves = new bytes32[](3); for (uint256 i = 0; i < 3; i++) { @@ -600,51 +876,53 @@ contract OutboxTest is Test { leaves[i] = fakeMessages[i].sha256ToField(); } - // First, insert the root of a short epoch containing 2 checkpoints, each has 1 message. + // First, insert the root of a short partial proof covering 2 checkpoints (K=2). epochTree.insertLeaf(leaves[0]); epochTree.insertLeaf(leaves[1]); - bytes32 rootForShortEpoch = epochTree.computeRoot(); + bytes32 rootForShortProof = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, rootForShortEpoch); + outbox.insert(DEFAULT_EPOCH, 2, rootForShortProof); - // Consume leaves[1] + // Consume leaves[1] against K=2. { uint256 leafIndex = 1; - _consumeMessage(leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(DEFAULT_EPOCH, 2, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } - // Then, insert the root of a long epoch containing 3 checkpoints, including the existing 2 checkpoints, plus a new - // checkpoint with 1 tx/message. + // Then, insert the root of an extending proof covering 3 checkpoints (K=3). epochTree.insertLeaf(leaves[2]); - bytes32 rootForLongEpoch = epochTree.computeRoot(); + bytes32 rootForLongProof = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, rootForLongEpoch); + outbox.insert(DEFAULT_EPOCH, 3, rootForLongProof); + + assertEq(outbox.getRootData(DEFAULT_EPOCH, 2), rootForShortProof); + assertEq(outbox.getRootData(DEFAULT_EPOCH, 3), rootForLongProof); - // Cannot to consume leaves[1] again. + // Cannot consume leaves[1] again against either root: the bitmap is shared. { uint256 leafIndex = 1; - _consumeNullifiedMessage(leafIndex, fakeMessages[leafIndex]); + _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, 2, epochTree, leafIndex, fakeMessages[leafIndex]); + _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, 3, epochTree, leafIndex, fakeMessages[leafIndex]); } - // leaves[0] can still be consumed. + // leaves[0] can still be consumed against either root. { uint256 leafIndex = 0; - _consumeMessage(leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(DEFAULT_EPOCH, 3, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } - // New leaf leaves[2] can be consumed. + // New leaf leaves[2] can be consumed against K=3. { uint256 leafIndex = 2; - _consumeMessage(leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(DEFAULT_EPOCH, 3, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } } // This test checks that the status of existing messages is preserved when the root for a new epoch is inserted. function testConsumeMessagesInTwoEpochs() public { - // Insert 2 checkpoints to the epoch tree, each has 1 message. DataStructures.L2ToL1Msg[] memory fakeMessages = new DataStructures.L2ToL1Msg[](2); bytes32[] memory leaves = new bytes32[](2); for (uint256 i = 0; i < 2; i++) { @@ -655,38 +933,148 @@ contract OutboxTest is Test { epochTree.insertLeaf(leaves[1]); bytes32 root = epochTree.computeRoot(); - // First, insert the root for the first epoch. Epoch epoch1 = DEFAULT_EPOCH; vm.prank(ROLLUP_CONTRACT); - outbox.insert(epoch1, root); + outbox.insert(epoch1, K1, root); - // Consume leaves[1] in the first epoch { uint256 leafIndex = 1; - _consumeMessageAtEpoch(epoch1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(epoch1, K1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } - // Then, insert the root of the same epoch tree for the second epoch. Epoch epoch2 = epoch1 + Epoch.wrap(1); vm.prank(ROLLUP_CONTRACT); - outbox.insert(epoch2, root); + outbox.insert(epoch2, K1, root); // Cannot consume leaves[1] again in the first epoch. { uint256 leafIndex = 1; - _consumeNullifiedMessageAtEpoch(epoch1, epochTree, leafIndex, fakeMessages[leafIndex]); + _consumeNullifiedMessageAtEpoch(epoch1, K1, epochTree, leafIndex, fakeMessages[leafIndex]); } // The same leaf leaves[1] in the second epoch can be consumed. { uint256 leafIndex = 1; - _consumeMessageAtEpoch(epoch2, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(epoch2, K1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } // leaves[0] in the first epoch can still be consumed. { uint256 leafIndex = 0; - _consumeMessageAtEpoch(epoch1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(epoch1, K1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + } + } + + // Inserting the same root value at two distinct K values produces two addressable entries. + // Consuming against either marks the shared bitmap, blocking a second consume against the other + // for the same leaf id. + function testDuplicateRootInsertedAtDistinctIndices() public { + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, root); + outbox.insert(DEFAULT_EPOCH, 2, root); + vm.stopPrank(); + + assertEq(outbox.getRootData(DEFAULT_EPOCH, 1), root); + assertEq(outbox.getRootData(DEFAULT_EPOCH, 2), root); + + _consumeMessageAtEpoch(DEFAULT_EPOCH, 1, epochTree, 0, leaf, fakeMessage); + _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, 2, epochTree, 0, fakeMessage); + } + + // Different leaf ids within the same epoch but across different roots can each be consumed + // independently — the bitmap only blocks a given leaf id, not arbitrary leaves on the same root. + function testDistinctLeafIdsAcrossRootsConsumeIndependently() public { + DataStructures.L2ToL1Msg memory msgA = _fakeMessage(address(this), 1); + DataStructures.L2ToL1Msg memory msgB = _fakeMessage(address(this), 2); + bytes32 leafA = msgA.sha256ToField(); + bytes32 leafB = msgB.sha256ToField(); + + NaiveMerkle treeA = new NaiveMerkle(DEFAULT_TREE_HEIGHT); + treeA.insertLeaf(leafA); + bytes32 rootA = treeA.computeRoot(); + + NaiveMerkle treeB = new NaiveMerkle(1); + treeB.insertLeaf(leafB); + bytes32 rootB = treeB.computeRoot(); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, rootA); + outbox.insert(DEFAULT_EPOCH, 2, rootB); + vm.stopPrank(); + + uint256 leafIdA = (1 << DEFAULT_TREE_HEIGHT) + 0; + uint256 leafIdB = (1 << 1) + 0; + assertTrue(leafIdA != leafIdB); + + _consumeMessageAtEpoch(DEFAULT_EPOCH, 1, treeA, 0, leafA, msgA); + _consumeMessageAtEpoch(DEFAULT_EPOCH, 2, treeB, 0, leafB, msgB); + + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafIdA)); + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafIdB)); + } + + // Bitmap state and roots must be isolated between epochs even when both have multiple inserts. + function testMultiRootEpochsAreIsolated() public { + Epoch epoch1 = DEFAULT_EPOCH; + Epoch epoch2 = DEFAULT_EPOCH + Epoch.wrap(1); + + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 7); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + bytes32 sentinel = bytes32(uint256(0xdead)); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(epoch1, 1, root); + outbox.insert(epoch1, 2, sentinel); + outbox.insert(epoch2, 1, root); + outbox.insert(epoch2, 2, sentinel); + vm.stopPrank(); + + uint256 leafIndex = 0; + uint256 leafId = (1 << DEFAULT_TREE_HEIGHT) + leafIndex; + + _consumeMessageAtEpoch(epoch1, 1, epochTree, leafIndex, leaf, fakeMessage); + + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(epoch1, leafId)); + assertFalse(outbox.hasMessageBeenConsumedAtEpoch(epoch2, leafId)); + + _consumeMessageAtEpoch(epoch2, 1, epochTree, leafIndex, leaf, fakeMessage); + + _consumeNullifiedMessageAtEpoch(epoch2, 1, epochTree, leafIndex, fakeMessage); + } + + // Fuzz: inserting N non-zero roots at arbitrary distinct K values in [1, MAX] keeps each + // (K, root) pair retrievable via getRootData/getRoots and emits matching RootAdded events. + function testFuzzInsertManyRootsIndexingAndEvents(bytes32[] calldata _roots) public { + uint256 n = _roots.length; + vm.assume(n > 0 && n <= MAX_CHECKPOINTS_PER_EPOCH); + + vm.startPrank(ROLLUP_CONTRACT); + for (uint256 i = 0; i < n; i++) { + vm.assume(_roots[i] != bytes32(0)); + uint256 k = i + 1; + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.RootAdded(DEFAULT_EPOCH, k, _roots[i]); + outbox.insert(DEFAULT_EPOCH, k, _roots[i]); + } + vm.stopPrank(); + + for (uint256 i = 0; i < n; i++) { + assertEq(outbox.getRootData(DEFAULT_EPOCH, i + 1), _roots[i]); + } + bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory roots = outbox.getRoots(DEFAULT_EPOCH); + for (uint256 i = 0; i < n; i++) { + assertEq(roots[i], _roots[i]); + } + for (uint256 i = n; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + assertEq(roots[i], bytes32(0)); } } } diff --git a/l1-contracts/test/Rollup.t.sol b/l1-contracts/test/Rollup.t.sol index ea2e74d38c86..0a73e9be1083 100644 --- a/l1-contracts/test/Rollup.t.sol +++ b/l1-contracts/test/Rollup.t.sol @@ -84,7 +84,8 @@ contract RollupTest is RollupBase { vm.warp(initialTime); } - RollupBuilder builder = new RollupBuilder(address(this)).setTargetCommitteeSize(0); + RollupBuilder builder = + new RollupBuilder(address(this)).setTargetCommitteeSize(0).setProvingCostPerMana(EthValue.wrap(1000)); builder.deploy(); testERC20 = builder.getConfig().testERC20; @@ -366,20 +367,20 @@ contract RollupTest is RollupBase { // We need to mint some fee asset to the portal to cover the 2M mana spent. deal(address(testERC20), address(feeJuicePortal), 2e6 * 1e18); - vm.prank(Ownable(address(rollup)).owner()); - rollup.setProvingCostPerMana(EthValue.wrap(1000)); + // Checkpoint 1 uses the initial provingCostPerMana = 1000. _proposeCheckpoint("mixed_checkpoint_1", 1, 1e6); + // First post-init update bypasses the cooldown; 1500 is exactly 3/2 * 1000. vm.prank(Ownable(address(rollup)).owner()); - rollup.setProvingCostPerMana(EthValue.wrap(2000)); + rollup.setProvingCostPerMana(EthValue.wrap(1500)); _proposeCheckpoint("mixed_checkpoint_2", 2, 1e6); // At this point in time, we have had different proving costs for the two checkpoints. When we prove them // in the same epoch, we want to see that the correct fee is taken for each checkpoint. _proveCheckpoints("mixed_checkpoint_", 1, 2, address(this)); - // 1e6 mana at 1000 and 2000 cost per manage multiplied by 10 for the price conversion to fee asset. - uint256 proverFees = 1e6 * (1000 + 2000); + // 1e6 mana at 1000 and 1500 cost per manage multiplied by 10 for the price conversion to fee asset. + uint256 proverFees = 1e6 * (1000 + 1500); // Then we also need the component that is for covering the gas proverFees += (Math.mulDiv( Math.mulDiv( @@ -838,9 +839,10 @@ contract RollupTest is RollupBase { // Submit proof for checkpoints 1-2 with outHash2 _submitEpochProof(1, 2, checkpoint.archive, checkpoint2Data.archive, checkpoint2Data.batchedBlobInputs, outHash2); - // Verify the state after the first proof + // Verify the state after the first proof (covered 2 checkpoints → K=2). assertEq(rollup.getProvenCheckpointNumber(), 2, "Proven checkpoint number should be 2"); - assertEq(outbox.getRootData(Epoch.wrap(0)), outHash2, "OutHash should be outHash2"); + assertEq(outbox.getRootData(Epoch.wrap(0), 2), outHash2, "Root at K=2 should be outHash2"); + assertEq(outbox.getRootData(Epoch.wrap(0), 1), bytes32(0), "Root at K=1 should be unset"); // Attempt to submit proof for checkpoints 1-1 with outHash1 (shorter proof) // This should not revert, but should not update anything @@ -849,8 +851,9 @@ contract RollupTest is RollupBase { // Verify that the proven checkpoint number did NOT regress assertEq(rollup.getProvenCheckpointNumber(), 2, "Proven checkpoint number should still be 2"); - // Verify that the outHash did NOT change - assertEq(outbox.getRootData(Epoch.wrap(0)), outHash2, "OutHash should still be outHash2"); + // Verify that no new root was inserted (the shorter proof gate in EpochProofLib skips insert). + assertEq(outbox.getRootData(Epoch.wrap(0), 2), outHash2, "Root at K=2 should still be outHash2"); + assertEq(outbox.getRootData(Epoch.wrap(0), 1), bytes32(0), "Root at K=1 should remain unset"); } function testLongerEpochProofCanUpdateAfterShorterProof() public setUpFor("mixed_checkpoint_1") { @@ -870,19 +873,20 @@ contract RollupTest is RollupBase { // Submit proof for checkpoints 1-1 with outHash1 (shorter proof first) _submitEpochProof(1, 1, checkpoint.archive, checkpoint1Data.archive, checkpoint1Data.batchedBlobInputs, outHash1); - // Verify the state after the first proof + // Verify the state after the first proof (covered 1 checkpoint → K=1). assertEq(rollup.getProvenCheckpointNumber(), 1, "Proven checkpoint number should be 1"); - assertEq(outbox.getRootData(Epoch.wrap(0)), outHash1, "OutHash should be outHash1"); + assertEq(outbox.getRootData(Epoch.wrap(0), 1), outHash1, "Root at K=1 should be outHash1"); // Submit proof for checkpoints 1-2 with outHash2 (longer proof) - // This SHOULD update both the proven checkpoint number and the outHash + // This SHOULD update the proven checkpoint number and insert a new root at K=2. _submitEpochProof(1, 2, checkpoint.archive, checkpoint2Data.archive, checkpoint2Data.batchedBlobInputs, outHash2); // Verify that the proven checkpoint number progressed to 2 assertEq(rollup.getProvenCheckpointNumber(), 2, "Proven checkpoint number should be 2"); - // Verify that the outHash was updated to outHash2 - assertEq(outbox.getRootData(Epoch.wrap(0)), outHash2, "OutHash should be outHash2"); + // The earlier root at K=1 remains addressable, and the new root sits at K=2. + assertEq(outbox.getRootData(Epoch.wrap(0), 1), outHash1, "Root at K=1 should remain outHash1"); + assertEq(outbox.getRootData(Epoch.wrap(0), 2), outHash2, "Root at K=2 should be outHash2"); } function _submitEpochProof( diff --git a/l1-contracts/test/RollupGetters.t.sol b/l1-contracts/test/RollupGetters.t.sol index dff58c1374e6..03dd7829db63 100644 --- a/l1-contracts/test/RollupGetters.t.sol +++ b/l1-contracts/test/RollupGetters.t.sol @@ -10,7 +10,7 @@ import {IStakingCore} from "@aztec/core/interfaces/IStaking.sol"; import {IVerifier} from "@aztec/core/interfaces/IVerifier.sol"; import {TestConstants} from "./harnesses/TestConstants.sol"; import {Timestamp, Slot, Epoch} from "@aztec/shared/libraries/TimeMath.sol"; -import {RewardConfig, Bps} from "@aztec/core/libraries/rollup/RewardLib.sol"; +import {RewardConfig, MutableRewardConfig, Bps} from "@aztec/core/libraries/rollup/RewardLib.sol"; import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; import {ValidatorSelectionTestBase} from "./validator-selection/ValidatorSelectionBase.sol"; import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; @@ -309,21 +309,18 @@ contract RollupShouldBeGetters is ValidatorSelectionTestBase { } function test_getRewardConfig() external setup(1, 1) { - // By default, we will be replacing the reward distributor and booster addresses + // AZIP-2: setRewardConfig MUST NOT mutate `rewardDistributor` or `booster`. They are + // set exactly once in the constructor and immutable thereafter. RewardConfig memory defaultConfig = TestConstants.getRewardConfig(); - RewardConfig memory config = rollup.getRewardConfig(); + RewardConfig memory before = rollup.getRewardConfig(); - RewardConfig memory updated = RewardConfig({ - sequencerBps: Bps.wrap(1), - rewardDistributor: IRewardDistributor(address(2)), - booster: IBoosterCore(address(3)), - checkpointReward: 100e18 - }); + address initialDistributor = address(before.rewardDistributor); + address initialBooster = address(before.booster); - assertNotEq(address(config.rewardDistributor), address(updated.rewardDistributor), "invalid reward distributor"); - assertNotEq(address(config.booster), address(updated.booster), "invalid booster"); - assertEq(Bps.unwrap(config.sequencerBps), Bps.unwrap(defaultConfig.sequencerBps), "invalid sequencerBps"); - assertEq(config.checkpointReward, defaultConfig.checkpointReward, "invalid initial checkpointReward"); + assertEq(Bps.unwrap(before.sequencerBps), Bps.unwrap(defaultConfig.sequencerBps), "invalid sequencerBps"); + assertEq(before.checkpointReward, defaultConfig.checkpointReward, "invalid initial checkpointReward"); + + MutableRewardConfig memory updated = MutableRewardConfig({sequencerBps: Bps.wrap(1), checkpointReward: 100e18}); address owner = rollup.owner(); @@ -331,11 +328,15 @@ contract RollupShouldBeGetters is ValidatorSelectionTestBase { emit IRollupCore.RewardConfigUpdated(updated); vm.prank(owner); rollup.setRewardConfig(updated); - config = rollup.getRewardConfig(); - assertEq(Bps.unwrap(config.sequencerBps), Bps.unwrap(updated.sequencerBps), "invalid sequencerBps"); - assertEq(address(config.rewardDistributor), address(updated.rewardDistributor), "invalid reward distributor"); - assertEq(address(config.booster), address(updated.booster), "invalid booster"); - assertEq(config.checkpointReward, updated.checkpointReward, "invalid checkpointReward"); + RewardConfig memory afterUpdate = rollup.getRewardConfig(); + + // The mutable subset reflects the new values. + assertEq(Bps.unwrap(afterUpdate.sequencerBps), Bps.unwrap(updated.sequencerBps), "sequencerBps not updated"); + assertEq(afterUpdate.checkpointReward, updated.checkpointReward, "checkpointReward not updated"); + + // The immutable fields are unchanged regardless of what the owner tried to do. + assertEq(address(afterUpdate.rewardDistributor), initialDistributor, "rewardDistributor must be immutable"); + assertEq(address(afterUpdate.booster), initialBooster, "booster must be immutable"); } } diff --git a/l1-contracts/test/benchmark/happy.t.sol b/l1-contracts/test/benchmark/happy.t.sol index 80375cc6434f..2a02c0546c8f 100644 --- a/l1-contracts/test/benchmark/happy.t.sol +++ b/l1-contracts/test/benchmark/happy.t.sol @@ -93,7 +93,14 @@ contract FakeCanonical is IRewardDistributor { function updateRegistry(IRegistry _registry) external {} - function recover(address _asset, address _to, uint256 _amount) external {} + function recoverFrom(address _from, address _to, uint256 _amount) external {} + function recoverWrongAsset(address _asset, address _to, uint256 _amount) external {} + + function subsidizeAddress(address, uint256) external {} + + function availableTo(address) external pure returns (uint256) { + return type(uint256).max; + } } contract BenchmarkRollupTest is FeeModelTestPoints, DecoderBase { diff --git a/l1-contracts/test/boosted_rewards/RewardBoosterConstructor.t.sol b/l1-contracts/test/boosted_rewards/RewardBoosterConstructor.t.sol new file mode 100644 index 000000000000..12e227448f80 --- /dev/null +++ b/l1-contracts/test/boosted_rewards/RewardBoosterConstructor.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.27; +// solhint-disable func-name-mixedcase +// solhint-disable comprehensive-interface + +import {Test} from "forge-std/Test.sol"; +import {RewardBooster, RewardBoostConfig} from "@aztec/core/reward-boost/RewardBooster.sol"; +import {IValidatorSelection} from "@aztec/core/interfaces/IValidatorSelection.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; + +/// @notice Verifies the RewardBooster constructor rejects configs that can make `_toShares` +/// return zero, since RewardLib treats zero shares as the duplicate-submission +/// sentinel. +contract RewardBoosterConstructorTest is Test { + IValidatorSelection internal constant ROLLUP = IValidatorSelection(address(0xC0FFEE)); + + function _validConfig() internal pure returns (RewardBoostConfig memory) { + return RewardBoostConfig({increment: 200_000, maxScore: 5_000_000, a: 5000, minimum: 100_000, k: 1_000_000}); + } + + function test_acceptsValidConfig() external { + RewardBoostConfig memory config = _validConfig(); + RewardBooster booster = new RewardBooster(ROLLUP, config); + assertTrue(address(booster) != address(0), "valid config must deploy"); + } + + function test_revertsWhenKIsZero() external { + RewardBoostConfig memory config = _validConfig(); + config.k = 0; + + vm.expectRevert(abi.encodeWithSelector(Errors.RewardBooster__InvalidConfig.selector)); + new RewardBooster(ROLLUP, config); + } + + function test_revertsWhenMinimumIsZero() external { + RewardBoostConfig memory config = _validConfig(); + config.minimum = 0; + + vm.expectRevert(abi.encodeWithSelector(Errors.RewardBooster__InvalidConfig.selector)); + new RewardBooster(ROLLUP, config); + } + + function test_revertsWhenMaxScoreIsZero() external { + RewardBoostConfig memory config = _validConfig(); + config.maxScore = 0; + + vm.expectRevert(abi.encodeWithSelector(Errors.RewardBooster__InvalidConfig.selector)); + new RewardBooster(ROLLUP, config); + } + + function test_revertsWhenMinimumExceedsK() external { + RewardBoostConfig memory config = _validConfig(); + config.minimum = config.k + 1; + + vm.expectRevert(abi.encodeWithSelector(Errors.RewardBooster__InvalidConfig.selector)); + new RewardBooster(ROLLUP, config); + } + + function test_acceptsMinimumEqualToK() external { + RewardBoostConfig memory config = _validConfig(); + config.minimum = config.k; + + RewardBooster booster = new RewardBooster(ROLLUP, config); + assertTrue(address(booster) != address(0), "minimum == k must deploy"); + } +} diff --git a/l1-contracts/test/builder/RollupBuilder.sol b/l1-contracts/test/builder/RollupBuilder.sol index 398679f76d25..6d01ae12d3bc 100644 --- a/l1-contracts/test/builder/RollupBuilder.sol +++ b/l1-contracts/test/builder/RollupBuilder.sol @@ -234,6 +234,11 @@ contract RollupBuilder is Test { return this; } + function setLocalEjectionThreshold(uint256 _localEjectionThreshold) public returns (RollupBuilder) { + config.rollupConfigInput.localEjectionThreshold = _localEjectionThreshold; + return this; + } + function setStakingQueueConfig(StakingQueueConfig memory _stakingQueueConfig) public returns (RollupBuilder) { config.rollupConfigInput.stakingQueueConfig = _stakingQueueConfig; return this; diff --git a/l1-contracts/test/compression/PreHeating.t.sol b/l1-contracts/test/compression/PreHeating.t.sol index 5c9ff87495d5..9ba40b85e51e 100644 --- a/l1-contracts/test/compression/PreHeating.t.sol +++ b/l1-contracts/test/compression/PreHeating.t.sol @@ -90,7 +90,14 @@ contract FakeCanonical is IRewardDistributor { function updateRegistry(IRegistry _registry) external {} - function recover(address _asset, address _to, uint256 _amount) external {} + function recoverFrom(address _from, address _to, uint256 _amount) external {} + function recoverWrongAsset(address _asset, address _to, uint256 _amount) external {} + + function availableTo(address) external pure returns (uint256) { + return type(uint256).max; + } + + function subsidizeAddress(address, uint256) external {} } /** diff --git a/l1-contracts/test/escape-hatch/base.sol b/l1-contracts/test/escape-hatch/base.sol index 55879f0fd4a6..1abf81ee0eb5 100644 --- a/l1-contracts/test/escape-hatch/base.sol +++ b/l1-contracts/test/escape-hatch/base.sol @@ -101,7 +101,7 @@ contract EscapeHatchBase is TestBase { // Register escape hatch with the rollup so selectCandidates deactivation guard passes vm.prank(Ownable(address(rollup)).owner()); - rollup.updateEscapeHatch(address(escapeHatch)); + rollup.setEscapeHatch(address(escapeHatch)); vm.label(address(rollup), "Rollup"); vm.label(address(bondToken), "BondToken"); @@ -256,12 +256,15 @@ contract EscapeHatchBase is TestBase { vm.label(address(escapeHatch), "FuzzedEscapeHatch"); - // Register the new escape hatch so selectCandidates deactivation guard passes + // Register the new escape hatch so selectCandidates deactivation guard passes. + // Production rollup.setEscapeHatch is one-shot, so we reset the checkpoint trace via + // vm.store before re-registering. This is a test-only bypass of the one-shot guard. if (useFakeRollup) { fakeRollup.setEscapeHatch(address(escapeHatch)); } else { + _resetRollupEscapeHatchRegistration(); vm.prank(Ownable(address(rollup)).owner()); - rollup.updateEscapeHatch(address(escapeHatch)); + rollup.setEscapeHatch(address(escapeHatch)); } // Warp to safe epoch to avoid HatchTooEarly errors @@ -269,6 +272,28 @@ contract EscapeHatchBase is TestBase { _; } + /// @notice Test-only helper that clears the rollup's escape-hatch checkpoint trace length. + /// @dev Allows re-registering an escape hatch despite the one-shot production guard. + /// Matches the storage layout of ValidatorSelectionStorage.escapeHatchCheckpoints + /// (a Trace160 whose inner array's length slot sits at offset 3 of the library's + /// storage struct -- see ValidatorSelectionLib.VALIDATOR_SELECTION_STORAGE_POSITION). + function _resetRollupEscapeHatchRegistration() internal { + bytes32 base = keccak256("aztec.validator_selection.storage"); + // Layout: [0]=committeeCommitments mapping ptr, [1]=Trace224 randaos, [2]=packed uint32s, + // [3]=Trace160 escapeHatchCheckpoints (whose sole field is the Checkpoint160[] + // dynamic array; its length lives directly at this slot). + bytes32 traceLengthSlot = bytes32(uint256(base) + 3); + vm.store(address(rollup), traceLengthSlot, bytes32(0)); + // Layout-drift canary: if ValidatorSelectionStorage gains/loses/reorders a field, the slot + // above no longer holds the trace length and the public getter will still see the prior + // checkpoint. Surface that explicitly instead of silently clobbering an unrelated slot. + assertEq( + address(IValidatorSelection(address(rollup)).getEscapeHatch()), + address(0), + "escapeHatchCheckpoints trace length not at expected slot - ValidatorSelectionStorage layout drift?" + ); + } + /// @notice Helper to join candidate set using current config's bond size function _joinCandidateSetWithConfig(address _candidate) internal { _mintAndApprove(_candidate, config.bondSize); diff --git a/l1-contracts/test/escape-hatch/e2e/escapeHatchReplacement.t.sol b/l1-contracts/test/escape-hatch/e2e/escapeHatchReplacement.t.sol deleted file mode 100644 index 2005c24fa70c..000000000000 --- a/l1-contracts/test/escape-hatch/e2e/escapeHatchReplacement.t.sol +++ /dev/null @@ -1,504 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2025 Aztec Labs. -pragma solidity >=0.8.27; - -import {EscapeHatchIntegrationBase} from "../integration/EscapeHatchIntegrationBase.sol"; -import {EscapeHatch} from "@aztec/core/EscapeHatch.sol"; -import {IEscapeHatchCore, IEscapeHatch, Status, CandidateInfo, Hatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; -import {Errors} from "@aztec/core/libraries/Errors.sol"; -import {Constants} from "@aztec/core/libraries/ConstantsGen.sol"; -import {Epoch, Timestamp} from "@aztec/shared/libraries/TimeMath.sol"; -import {ProposeArgs, OracleInput, ProposeLib, ProposePayload} from "@aztec/core/libraries/rollup/ProposeLib.sol"; -import {ProposedHeader, ProposedHeaderLib, GasFees} from "@aztec/core/libraries/rollup/ProposedHeaderLib.sol"; -import { - CommitteeAttestations, - CommitteeAttestation, - Signature, - AttestationLib -} from "@aztec/core/libraries/rollup/AttestationLib.sol"; -import {SubmitEpochRootProofArgs, PublicInputArgs} from "@aztec/core/interfaces/IRollup.sol"; -import {Ownable} from "@oz/access/Ownable.sol"; -import {SafeCast} from "@oz/utils/math/SafeCast.sol"; -import {AttestationLibHelper} from "@test/helper_libraries/AttestationLibHelper.sol"; -import {stdStorage, StdStorage} from "forge-std/Test.sol"; - -/** - * @title EscapeHatchReplacementTest - * @notice E2E tests for escape hatch governance replacement scenarios. - * - * @dev These tests assert the DESIRED behavior when governance replaces the escape hatch. - * They currently FAIL (before #20363) because the escape hatch lookup is not epoch-stable: - * - * - ProposeLib queries the CURRENT escape hatch for the proposal path decision, - * so a mid-epoch replacement retroactively blocks an already-selected proposer. - * - * - EscapeHatch.selectCandidates has no deactivation guard, so candidates are - * selected on a contract the rollup no longer uses. - * - * - EscapeHatch.validateProofSubmission punishes candidates who had no way to - * propose because the rollup stopped recognizing the old escape hatch. - * - * - EpochProofLib skips attestation verification for retroactively classified - * escape hatch epochs, allowing proofs with bad attestations through. - * - * - InvalidateLib blocks invalidation for retroactively classified escape hatch - * epochs, protecting checkpoints with bad attestations from removal. - * - * After epoch-stable snapshotting is implemented, these tests should PASS. - */ -contract EscapeHatchReplacementTest is EscapeHatchIntegrationBase { - using stdStorage for StdStorage; - - // ============================================================================ - // Helpers for retroactive escape hatch deployment - // ============================================================================ - - struct BadAttestationData { - CommitteeAttestations packedAttestations; - CommitteeAttestation[] attestations; - address[] committee; - uint256 invalidSignatureIndex; - Epoch proposalEpoch; - } - - /** - * @notice Propose a checkpoint during a normal epoch with one bad attestation signature - * @dev Adapted from invalidate.t.sol - creates a valid proposal except one committee - * member's signature is replaced with a signature from an unrelated key. - */ - function _proposeWithBadAttestation() internal returns (BadAttestationData memory data) { - ProposedHeader memory header = full.checkpoint.header; - - vm.warp(max(block.timestamp, Timestamp.unwrap(full.checkpoint.header.timestamp))); - - rollup.setupEpoch(); - data.proposalEpoch = rollup.getCurrentEpoch(); - - address proposer = rollup.getCurrentProposer(); - data.committee = rollup.getEpochCommittee(data.proposalEpoch); - - { - uint128 manaMinFee = SafeCast.toUint128(rollup.getManaMinFeeAt(Timestamp.wrap(block.timestamp), true)); - header.gasFees.feePerL2Gas = manaMinFee; - } - - ProposeArgs memory proposeArgs = - ProposeArgs({header: header, archive: full.checkpoint.archive, oracleInput: OracleInput(0)}); - - skipBlobCheck(address(rollup)); - - ProposePayload memory proposePayload = ProposePayload({ - archive: proposeArgs.archive, oracleInput: proposeArgs.oracleInput, headerHash: ProposedHeaderLib.hash(header) - }); - - // Create attestations - all valid except one - uint256 committeeSize = data.committee.length; - data.attestations = new CommitteeAttestation[](committeeSize); - address[] memory signers = new address[](committeeSize); - bytes32 digest = ProposeLib.digest(proposePayload, address(rollup)); - - for (uint256 i = 0; i < committeeSize; i++) { - data.attestations[i] = _createAttestation(data.committee[i], digest); - signers[i] = data.committee[i]; - } - - // Make one attestation invalid (not the proposer's) - for (uint256 i = 0; i < committeeSize; i++) { - if (data.committee[i] != proposer) { - uint256 invalidKey = uint256(keccak256(abi.encode("invalid", block.timestamp))); - address invalidSigner = vm.addr(invalidKey); - attesterPrivateKeys[invalidSigner] = invalidKey; - data.attestations[i] = _createAttestation(invalidSigner, digest); - data.invalidSignatureIndex = i; - break; - } - } - - data.packedAttestations = AttestationLibHelper.packAttestations(data.attestations); - - // Proposer signs over attestations and signers - Signature memory attestationsAndSignersSignature = - _createAttestation( - proposer, AttestationLib.getAttestationsAndSignersDigest(data.packedAttestations, signers, address(rollup)) - ).signature; - - vm.prank(proposer); - rollup.propose( - proposeArgs, data.packedAttestations, signers, attestationsAndSignersSignature, full.checkpoint.blobCommitments - ); - - assertEq(rollup.getPendingCheckpointNumber(), 1, "Checkpoint should be proposed"); - } - - /** - * @notice Deploy an escape hatch that retroactively classifies a given epoch as an escape hatch epoch - * @dev Deploys a new EscapeHatch with frequency/activeDuration chosen so the target epoch - * falls in the active window, then uses stdstore to set a designated proposer. - */ - function _deployRetroactiveEscapeHatch(Epoch _epoch) internal { - uint256 epochNum = Epoch.unwrap(_epoch); - - // Choose parameters so epoch % frequency < activeDuration - // With frequency = epochNum + 2, activeDuration = epochNum + 1: - // epochNum % (epochNum + 2) = epochNum < epochNum + 1 ✓ - uint256 proofSubmissionEpochs = rollup.getProofSubmissionEpochs(); - uint256 newActiveDuration = epochNum + 1; - if (newActiveDuration < proofSubmissionEpochs + 1) { - newActiveDuration = proofSubmissionEpochs + 1; - } - uint256 newFrequency = newActiveDuration + 1; - // Ensure frequency > LAG_IN_EPOCHS_FOR_SET_SIZE (2) - if (newFrequency <= 2) { - newFrequency = 3; - newActiveDuration = 2; - } - - EscapeHatch retroactiveEscapeHatch = new EscapeHatch( - address(rollup), - address(testERC20), - DEFAULT_BOND_SIZE, - DEFAULT_WITHDRAWAL_TAX, - DEFAULT_FAILED_HATCH_PUNISHMENT, - newFrequency, - newActiveDuration, - DEFAULT_LAG_IN_HATCHES, - DEFAULT_PROPOSING_EXIT_DELAY - ); - vm.label(address(retroactiveEscapeHatch), "RetroactiveEscapeHatch"); - - // Set designated proposer so isHatchOpen returns true - uint256 hatchNumber = epochNum / newFrequency; - stdstore.target(address(retroactiveEscapeHatch)).sig("getDesignatedProposer(uint256)").with_key(hatchNumber) - .checked_write(address(0xBEEF)); - - // Update rollup to use the new escape hatch - address rollupOwner = Ownable(address(rollup)).owner(); - vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(retroactiveEscapeHatch)); - - // Verify the epoch is now classified as escape hatch - (bool isOpen,) = retroactiveEscapeHatch.isHatchOpen(_epoch); - assertTrue(isOpen, "Epoch should be retroactively classified as escape hatch"); - } - - /** - * @notice Variant of _proposeWithHatch that expects the rollup.propose() call to revert. - */ - function _proposeWithHatchExpectRevert(address _proposer) internal { - (ProposeArgs memory args, bytes memory blobs) = _buildProposeArgs(_proposer); - skipBlobCheck(address(rollup)); - - vm.expectRevert(); - vm.prank(_proposer); - rollup.propose( - args, - CommitteeAttestations({signatureIndices: "", signaturesOrAddresses: ""}), - new address[](0), - Signature({v: 0, r: 0, s: 0}), - blobs - ); - } - - /** - * @notice Proposer should still be able to propose after mid-epoch replacement - * - * A proposer selected for an escape hatch epoch should still be able to propose - * even if governance replaces the escape hatch mid-epoch. - * - * @dev DESIRED: Proposal succeeds because epoch-stable snapshotting preserves the - * escape hatch that was active when the epoch started. - */ - function test_proposerCanStillProposeAfterMidEpochReplacement() public setup(48, 48) progressEpochsToInclusion { - full = load("empty_checkpoint_1"); - _deployEscapeHatch(); - - // Setup: candidate joins, is selected, warp to hatch window - _joinCandidateSet(CANDIDATE1); - targetHatch = _selectCandidateForHatch(); - assertEq(escapeHatch.getDesignatedProposer(targetHatch), CANDIDATE1, "CANDIDATE1 should be proposer"); - - _warpToHatch(targetHatch); - - // Governance replaces escape hatch mid-epoch - address rollupOwner = Ownable(address(rollup)).owner(); - vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(0)); - - // DESIRED: Proposal should still succeed (epoch-stable snapshot preserves escape hatch) - // CURRENT: This REVERTS because ProposeLib uses the current escape hatch (address(0)) - _proposeWithHatch(CANDIDATE1); - assertEq(rollup.getPendingCheckpointNumber(), 1, "Proposal should succeed with epoch-stable escape hatch"); - } - - /** - * @notice Already-selected candidate should NOT be punished after deactivation - * - * A candidate selected before escape hatch deactivation should not be - * punished for failing to propose - they had no way to fulfill their duty. - * - * @dev DESIRED: validateProofSubmission recognizes the escape hatch was deactivated - * and does NOT apply punishment. Bond stays at DEFAULT_BOND_SIZE. - */ - function test_alreadySelectedCandidateNotPunishedAfterDeactivation() public setup(48, 48) progressEpochsToInclusion { - full = load("empty_checkpoint_1"); - _deployEscapeHatch(); - - // Step 1: Candidate joins and is selected for a hatch - _joinCandidateSet(CANDIDATE1); - targetHatch = _selectCandidateForHatch(); - - assertEq(escapeHatch.getDesignatedProposer(targetHatch), CANDIDATE1, "Should be designated proposer"); - CandidateInfo memory info = escapeHatch.getCandidateInfo(CANDIDATE1); - assertEq(uint8(info.status), uint8(Status.PROPOSING), "Should be in PROPOSING state"); - assertEq(info.amount, DEFAULT_BOND_SIZE, "Should have full bond"); - - // Step 2: Governance removes escape hatch BEFORE the hatch window - address rollupOwner = Ownable(address(rollup)).owner(); - vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(0)); - - // Step 3: The hatch window arrives - demonstrate the candidate CANNOT propose. - // The rollup no longer recognizes any escape hatch, so ProposeLib falls - // through to the committee attestation path, which fails for escape hatch - // proposals (they carry no committee attestations). - _warpToHatch(targetHatch); - assertEq(address(rollup.getEscapeHatch()), address(0), "Rollup should have no escape hatch"); - - _proposeWithHatchExpectRevert(CANDIDATE1); - - // Step 4: The candidate is stuck in PROPOSING on the dead escape hatch contract. - // They can't propose (step 3) and can't exit until exitable at. - // This is true in both current and fixed implementations since the - // governance update moved us to a future epoch from the update. - info = escapeHatch.getCandidateInfo(CANDIDATE1); - assertEq(uint8(info.status), uint8(Status.PROPOSING), "Still stuck in PROPOSING on dead contract"); - - // Step 5: Warp to exitable at and validate proof submission - _warpToExitableAt(CANDIDATE1); - escapeHatch.validateProofSubmission(targetHatch); - - // DESIRED: Candidate should NOT be punished - they had no way to propose - // CURRENT: info.amount == DEFAULT_BOND_SIZE - DEFAULT_FAILED_HATCH_PUNISHMENT - info = escapeHatch.getCandidateInfo(CANDIDATE1); - assertTrue(info.status == Status.EXITING, "Should be EXITING after validation"); - assertEq(info.amount, DEFAULT_BOND_SIZE, "Candidate should NOT be punished when escape hatch was deactivated"); - } - - /** - * @notice selectCandidates should be no-op on deactivated escape hatch - * - * selectCandidates() should not select new candidates on a deactivated - * escape hatch contract. Candidates selected on a dead contract can never - * propose and inevitably get punished. - * - * @dev DESIRED: selectCandidates() is a no-op when the contract is no longer the - * active escape hatch. Candidate remains in ACTIVE state. - */ - function test_selectCandidatesNoOpOnDeactivatedEscapeHatch() public setup(48, 48) progressEpochsToInclusion { - full = load("empty_checkpoint_1"); - _deployEscapeHatch(); - - // Step 1: Candidate joins escape hatch - _joinCandidateSet(CANDIDATE1); - - CandidateInfo memory info = escapeHatch.getCandidateInfo(CANDIDATE1); - assertEq(uint8(info.status), uint8(Status.ACTIVE), "Should be ACTIVE after joining"); - - // Step 2: Governance deactivates the escape hatch BEFORE selection - address rollupOwner = Ownable(address(rollup)).owner(); - vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(0)); - - assertEq(address(rollup.getEscapeHatch()), address(0), "Rollup should have no escape hatch"); - - // Step 3: Call selectCandidates on the deactivated escape hatch - _setRandomPrevrandao(); - _warpForwardEpochs(DEFAULT_FREQUENCY); - escapeHatch.selectCandidates(); - - // DESIRED: Candidate should remain in ACTIVE state (selectCandidates is no-op) - // CURRENT: Candidate transitions to PROPOSING on a dead contract - info = escapeHatch.getCandidateInfo(CANDIDATE1); - assertTrue(info.status == Status.ACTIVE, "Candidate should remain ACTIVE on deactivated escape hatch"); - } - - /** - * @notice Proof submission should verify attestations for normal epochs - * - * Proof submission should still verify attestation signatures for checkpoints - * proposed during a normal epoch, even if the escape hatch is retroactively - * configured to classify that epoch as an escape hatch epoch. - * - * @dev DESIRED: Proof fails because attestation verification catches the bad signature. - * The epoch was normal at propose time, so attestation verification should run. - */ - function test_proofSubmissionVerifiesAttestationsForNormalEpoch() public setup(4, 4) progressEpochsToInclusion { - full = load("mixed_checkpoint_1"); - - // Step 1: Propose during a normal epoch (no escape hatch configured) - // One attestation has an invalid signature - BadAttestationData memory data = _proposeWithBadAttestation(); - - // Step 2: Deploy an escape hatch that retroactively covers the proposal epoch - _deployRetroactiveEscapeHatch(data.proposalEpoch); - - // Step 3: Submit proof with the stored attestations - // DESIRED: Proof fails (attestation verification catches bad signature) - // CURRENT: Proof succeeds (attestation verification SKIPPED due to retroactive escape hatch) - bytes32 previousArchive = rollup.archiveAt(0); - bytes32 endArchive = rollup.archiveAt(1); - - bytes32[] memory fees = new bytes32[](64); - fees[0] = bytes32(uint256(uint160(bytes20(("sequencer"))))); - fees[1] = bytes32(0); - - vm.expectRevert(); - rollup.submitEpochRootProof( - SubmitEpochRootProofArgs({ - start: 1, - end: 1, - args: PublicInputArgs({ - previousArchive: previousArchive, - endArchive: endArchive, - outHash: full.checkpoint.header.outHash, - proverId: address(this) - }), - fees: fees, - attestations: data.packedAttestations, - blobInputs: full.checkpoint.batchedBlobInputs, - proof: "" - }) - ); - } - - /** - * @notice Invalidation should work for normal epoch checkpoints - * - * A checkpoint proposed during a normal epoch with a bad attestation should - * be invalidatable, even if the escape hatch is retroactively configured to - * classify that epoch as an escape hatch epoch. - * - * @dev DESIRED: Invalidation succeeds because the epoch was normal at propose time. - * The bad attestation is detected and the checkpoint is removed. - */ - function test_invalidationWorksForNormalEpochCheckpoint() public setup(4, 4) progressEpochsToInclusion { - full = load("mixed_checkpoint_1"); - - // Step 1: Propose during a normal epoch (no escape hatch configured) - // One attestation has an invalid signature - BadAttestationData memory data = _proposeWithBadAttestation(); - - // Step 2: Deploy an escape hatch that retroactively covers the proposal epoch - _deployRetroactiveEscapeHatch(data.proposalEpoch); - - // Step 3: Invalidate the checkpoint using the bad attestation - // DESIRED: Invalidation succeeds (epoch was normal, bad attestation detected) - // CURRENT: Reverts with CannotInvalidateEscapeHatch (retroactive classification blocks invalidation) - rollup.invalidateBadAttestation(1, data.packedAttestations, data.committee, data.invalidSignatureIndex); - - assertEq(rollup.getPendingCheckpointNumber(), 0, "Checkpoint should be invalidated"); - } - - /** - * @notice Mid-window deactivation - candidate who proposed is still punished - * - * When the escape hatch is deactivated mid-window and the candidate DID propose - * during the first epoch, they are still held to normal validation. If their proof - * was not submitted, they get punished. - * - * @dev Scenario: - * 1. Candidate selected, proposes during first epoch of active window - * 2. Governance deactivates escape hatch during the first epoch. With next-epoch - * activation, the second epoch no longer has the escape hatch. - * 3. Proof for the proposed checkpoint is never submitted - * 4. At validation: candidate proposed something and is "living up to that" - - * normal validation applies, punishment for unproven checkpoint. - * - * This test PASSES in both current and fixed implementations because the - * candidate took on responsibility by proposing and must be held accountable. - */ - function test_midWindowDeactivation_proposedThenDeactivated_stillPunished() - public - setup(48, 48) - progressEpochsToInclusion - { - full = load("empty_checkpoint_1"); - _deployEscapeHatch(); - - // Step 1: Candidate joins and is selected for a hatch - _joinCandidateSet(CANDIDATE1); - targetHatch = _selectCandidateForHatch(); - assertEq(escapeHatch.getDesignatedProposer(targetHatch), CANDIDATE1, "Should be designated proposer"); - - // Step 2: Warp to first epoch of hatch window and propose successfully - _warpToHatch(targetHatch); - _proposeWithHatch(CANDIDATE1); - assertEq(rollup.getPendingCheckpointNumber(), 1, "Checkpoint should be proposed"); - - // Step 3: Governance deactivates escape hatch during the first epoch of the window. - // With next-epoch activation, the second epoch no longer has the escape hatch, - // so the candidate loses coverage for the latter half of their window. - address rollupOwner = Ownable(address(rollup)).owner(); - vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(0)); - - // Step 4: Candidate proposed but proof was never submitted - should be punished - _warpToExitableAt(CANDIDATE1); - escapeHatch.validateProofSubmission(targetHatch); - - CandidateInfo memory info = escapeHatch.getCandidateInfo(CANDIDATE1); - assertEq(uint8(info.status), uint8(Status.EXITING), "Should be EXITING after validation"); - assertEq( - info.amount, - DEFAULT_BOND_SIZE - DEFAULT_FAILED_HATCH_PUNISHMENT, - "Should be punished - proposed but proof not submitted" - ); - } - - /** - * @notice Mid-window deactivation - candidate who did NOT propose is free - * - * When the escape hatch is deactivated mid-window and the candidate did NOT - * propose, they should NOT be punished - the disruption from governance's - * mid-window change excuses them even though they could have proposed during - * the first epoch of the window. - * - * @dev Scenario: - * 1. Candidate selected for a hatch but does NOT propose during first epoch - * 2. Governance deactivates escape hatch during the first epoch. With next-epoch - * activation, the second epoch no longer has the escape hatch. - * 3. At validation: candidate did nothing AND escape hatch was disrupted - - * no punishment despite the candidate potentially stalling for one epoch. - * - * DESIRED: No punishment (candidate gets benefit of the doubt under disruption). - */ - function test_midWindowDeactivation_didNotPropose_notPunished() public setup(48, 48) progressEpochsToInclusion { - full = load("empty_checkpoint_1"); - _deployEscapeHatch(); - - // Step 1: Candidate joins and is selected for a hatch - _joinCandidateSet(CANDIDATE1); - targetHatch = _selectCandidateForHatch(); - assertEq(escapeHatch.getDesignatedProposer(targetHatch), CANDIDATE1, "Should be designated proposer"); - - // Step 2: Warp to first epoch of hatch window but do NOT propose - _warpToHatch(targetHatch); - - // Step 3: Governance deactivates escape hatch during the first epoch of the window. - // With next-epoch activation, the second epoch no longer has the escape hatch, - // so the candidate's window is disrupted partway through. - address rollupOwner = Ownable(address(rollup)).owner(); - vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(0)); - - // Step 4: Candidate didn't propose and escape hatch was deactivated mid-window. - // Even though they could have stalled for one epoch, the benefit of the - // doubt is given since governance disrupted the active window. - _warpToExitableAt(CANDIDATE1); - escapeHatch.validateProofSubmission(targetHatch); - - CandidateInfo memory info = escapeHatch.getCandidateInfo(CANDIDATE1); - assertEq(uint8(info.status), uint8(Status.EXITING), "Should be EXITING after validation"); - assertEq(info.amount, DEFAULT_BOND_SIZE, "Should NOT be punished - did not propose and escape hatch was disrupted"); - } -} diff --git a/l1-contracts/test/escape-hatch/integration/EscapeHatchIntegrationBase.sol b/l1-contracts/test/escape-hatch/integration/EscapeHatchIntegrationBase.sol index f578aa23dc9b..216f63ae4d56 100644 --- a/l1-contracts/test/escape-hatch/integration/EscapeHatchIntegrationBase.sol +++ b/l1-contracts/test/escape-hatch/integration/EscapeHatchIntegrationBase.sol @@ -83,9 +83,9 @@ abstract contract EscapeHatchIntegrationBase is ValidatorSelectionTestBase { address rollupOwner = Ownable(address(rollup)).owner(); vm.expectEmit(true, true, true, true, address(rollup)); - emit IValidatorSelectionCore.EscapeHatchUpdated(address(escapeHatch)); + emit IValidatorSelectionCore.EscapeHatchSet(address(escapeHatch)); vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(escapeHatch)); + rollup.setEscapeHatch(address(escapeHatch)); } /** diff --git a/l1-contracts/test/escape-hatch/integration/setEscapeHatchOneShot.t.sol b/l1-contracts/test/escape-hatch/integration/setEscapeHatchOneShot.t.sol new file mode 100644 index 000000000000..b6fbcc49f3c9 --- /dev/null +++ b/l1-contracts/test/escape-hatch/integration/setEscapeHatchOneShot.t.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aztec Labs. +pragma solidity >=0.8.27; + +import {EscapeHatchIntegrationBase} from "./EscapeHatchIntegrationBase.sol"; +import {EscapeHatch} from "@aztec/core/EscapeHatch.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {IValidatorSelectionCore} from "@aztec/core/interfaces/IValidatorSelection.sol"; +import {Ownable} from "@oz/access/Ownable.sol"; + +/** + * @notice Verifies setEscapeHatch is one-shot. + * + * - setEscapeHatch(0) reverts with `EscapeHatchCannotBeZero`. + * - First setEscapeHatch(nonZero) succeeds and emits `EscapeHatchSet`. + * - Any subsequent setEscapeHatch reverts with `EscapeHatchAlreadySet`, regardless of the + * address (including the same one) and regardless of whether the caller is the owner. + */ +contract SetEscapeHatchOneShotTest is EscapeHatchIntegrationBase { + function _newEscapeHatch() internal returns (EscapeHatch) { + return new EscapeHatch( + address(rollup), + address(testERC20), + DEFAULT_BOND_SIZE, + DEFAULT_WITHDRAWAL_TAX, + DEFAULT_FAILED_HATCH_PUNISHMENT, + DEFAULT_FREQUENCY, + DEFAULT_ACTIVE_DURATION, + DEFAULT_LAG_IN_HATCHES, + DEFAULT_PROPOSING_EXIT_DELAY + ); + } + + function test_revertsOnZeroAddress() external setup(2, 2) { + address owner = Ownable(address(rollup)).owner(); + vm.expectRevert(abi.encodeWithSelector(Errors.ValidatorSelection__EscapeHatchCannotBeZero.selector)); + vm.prank(owner); + rollup.setEscapeHatch(address(0)); + } + + function test_revertsWhenHatchPointsAtDifferentRollup() external setup(2, 2) { + // An EscapeHatch bound to a foreign rollup must not be acceptable. Once installed it would + // become a permanent alternate proposal route the current rollup cannot reach or replace. + // Mock the IInstance read the EscapeHatch constructor performs so we can build a hatch + // pointing at a foreign address without deploying a second rollup. + address fakeRollup = address(0xC0FFEE); + vm.mockCall( + fakeRollup, abi.encodeWithSelector(bytes4(keccak256("getProofSubmissionEpochs()"))), abi.encode(uint256(1)) + ); + + EscapeHatch foreignHatch = new EscapeHatch( + fakeRollup, + address(testERC20), + DEFAULT_BOND_SIZE, + DEFAULT_WITHDRAWAL_TAX, + DEFAULT_FAILED_HATCH_PUNISHMENT, + DEFAULT_FREQUENCY, + DEFAULT_ACTIVE_DURATION, + DEFAULT_LAG_IN_HATCHES, + DEFAULT_PROPOSING_EXIT_DELAY + ); + + address owner = Ownable(address(rollup)).owner(); + vm.expectRevert( + abi.encodeWithSelector(Errors.ValidatorSelection__EscapeHatchRollupMismatch.selector, address(rollup), fakeRollup) + ); + vm.prank(owner); + rollup.setEscapeHatch(address(foreignHatch)); + } + + function test_succeedsOnFirstNonZeroCallAndEmitsEvent() external setup(2, 2) { + EscapeHatch first = _newEscapeHatch(); + address owner = Ownable(address(rollup)).owner(); + + vm.expectEmit(true, true, true, true, address(rollup)); + emit IValidatorSelectionCore.EscapeHatchSet(address(first)); + vm.prank(owner); + rollup.setEscapeHatch(address(first)); + } + + function test_revertsOnSecondCallWithDifferentAddress() external setup(2, 2) { + EscapeHatch first = _newEscapeHatch(); + EscapeHatch second = _newEscapeHatch(); + address owner = Ownable(address(rollup)).owner(); + + vm.prank(owner); + rollup.setEscapeHatch(address(first)); + + vm.expectRevert(abi.encodeWithSelector(Errors.ValidatorSelection__EscapeHatchAlreadySet.selector)); + vm.prank(owner); + rollup.setEscapeHatch(address(second)); + } + + function test_revertsOnSecondCallWithSameAddress() external setup(2, 2) { + EscapeHatch first = _newEscapeHatch(); + address owner = Ownable(address(rollup)).owner(); + + vm.prank(owner); + rollup.setEscapeHatch(address(first)); + + vm.expectRevert(abi.encodeWithSelector(Errors.ValidatorSelection__EscapeHatchAlreadySet.selector)); + vm.prank(owner); + rollup.setEscapeHatch(address(first)); + } + + function test_revertsOnSecondCallEvenAfterZeroAttempt() external setup(2, 2) { + EscapeHatch first = _newEscapeHatch(); + address owner = Ownable(address(rollup)).owner(); + + // Zero attempt reverts but does NOT mutate state, so a subsequent non-zero call still + // succeeds (proving zero is rejected before the one-shot check). + vm.expectRevert(abi.encodeWithSelector(Errors.ValidatorSelection__EscapeHatchCannotBeZero.selector)); + vm.prank(owner); + rollup.setEscapeHatch(address(0)); + + vm.prank(owner); + rollup.setEscapeHatch(address(first)); + + vm.expectRevert(abi.encodeWithSelector(Errors.ValidatorSelection__EscapeHatchAlreadySet.selector)); + vm.prank(owner); + rollup.setEscapeHatch(address(first)); + } + + function test_revertsForNonOwner(address _caller) external setup(2, 2) { + address owner = Ownable(address(rollup)).owner(); + vm.assume(_caller != owner); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller)); + vm.prank(_caller); + rollup.setEscapeHatch(address(0xdeadbeef)); + } +} diff --git a/l1-contracts/test/fees/FeeHeaderOverflow.t.sol b/l1-contracts/test/fees/FeeHeaderOverflow.t.sol index 2970f01151ef..ed8a7e208798 100644 --- a/l1-contracts/test/fees/FeeHeaderOverflow.t.sol +++ b/l1-contracts/test/fees/FeeHeaderOverflow.t.sol @@ -137,17 +137,29 @@ contract FeeHeaderOverflowTest is DecoderBase { * because the final proving prover cost also includes the L1 component. */ function test_propose_compressOverflow_provingCost() public { - // 2^63 fits in uint64 (FeeConfig) but exceeds uint63 (FeeHeader) - EthValue provingCostPerMana = EthValue.wrap((1 << 63)); - + // Deploy with a normal initial provingCostPerMana, then forcibly inject the oversized value + // by overwriting the compressed FeeStore slot directly. The deploy-time ceiling + // (MAX_INITIAL_PROVING_COST_PER_MANA) blocks (1 << 63) at construction; this test models the + // post-deploy reality that governance updates have no absolute ceiling and the value can + // drift past 2^63 over time, so the compress() guard still has to cope. + // In practice, this will not happen because the deploy-time ceiling should make approaching + // 2^63 infeasible. RollupConfigInput memory config = TestConstants.getRollupConfigInput(); - config.provingCostPerMana = provingCostPerMana; // 1:1 ETH/AZTEC parity so proverCost (fee asset) = proverCostPerMana (wei) config.initialEthPerFeeAsset = EthPerFeeAssetE12.wrap(1e12); config.targetCommitteeSize = 0; Rollup rollup = _deployRollup(config); + // Overwrite the low 64 bits of the FeeStore's CompressedFeeConfig (slot 0 of the FeeLib + // namespaced storage) with (1 << 63). The high 192 bits (manaTarget, + // congestionUpdateFraction) stay intact. + bytes32 feeStoreSlot = keccak256("aztec.fee.storage"); + uint256 existing = uint256(vm.load(address(rollup), feeStoreSlot)); + uint256 masked = existing & ~((uint256(1) << 64) - 1); + uint256 newConfig = masked | (uint256(1) << 63); + vm.store(address(rollup), feeStoreSlot, bytes32(newConfig)); + // Warp to slot 1 vm.warp(block.timestamp + SLOT_DURATION); diff --git a/l1-contracts/test/fees/MinimalFeeModel.sol b/l1-contracts/test/fees/MinimalFeeModel.sol index d4295de5ab59..f37d6dd4fd7f 100644 --- a/l1-contracts/test/fees/MinimalFeeModel.sol +++ b/l1-contracts/test/fees/MinimalFeeModel.sol @@ -67,10 +67,11 @@ contract MinimalFeeModel { uint256 _slotDuration, uint256 _epochDuration, uint256 _proofSubmissionEpochs, - EthPerFeeAssetE12 _initialEthPerFeeAsset + EthPerFeeAssetE12 _initialEthPerFeeAsset, + EthValue _provingCost ) { TimeLib.initialize(block.timestamp, _slotDuration, _epochDuration, _proofSubmissionEpochs); - FeeLib.initialize(MANA_TARGET, EthValue.wrap(100), _initialEthPerFeeAsset); + FeeLib.initialize(MANA_TARGET, _provingCost, _initialEthPerFeeAsset); STFLib.initialize( GenesisState({vkTreeRoot: bytes32(0), protocolContractsHash: bytes32(0), genesisArchiveRoot: bytes32(0)}) ); @@ -131,10 +132,6 @@ contract MinimalFeeModel { // FeeLib.writeFeeHeader(++populatedThrough, _oracleInput.feeAssetPriceModifier, _manaUsed, 0, 0); } - function setProvingCost(EthValue _provingCost) public { - FeeLib.updateProvingCostPerMana(_provingCost); - } - /** * @notice Take a snapshot of the l1 fees * @dev Can only be called AFTER the scheduled change has passed. diff --git a/l1-contracts/test/fees/MinimalFeeModel.t.sol b/l1-contracts/test/fees/MinimalFeeModel.t.sol index ba23b7407c71..47010ed6fce9 100644 --- a/l1-contracts/test/fees/MinimalFeeModel.t.sol +++ b/l1-contracts/test/fees/MinimalFeeModel.t.sol @@ -47,9 +47,8 @@ contract MinimalFeeModelTest is FeeModelTestPoints { vm.blobBaseFee(l1Metadata[0].blob_fee); model = new MinimalFeeModel( - SLOT_DURATION, EPOCH_DURATION, PROOF_SUBMISSION_EPOCHS, TestConstants.AZTEC_INITIAL_ETH_PER_FEE_ASSET + SLOT_DURATION, EPOCH_DURATION, PROOF_SUBMISSION_EPOCHS, TestConstants.AZTEC_INITIAL_ETH_PER_FEE_ASSET, provingCost ); - model.setProvingCost(provingCost); } function test_computeEthPerFeeAsset() public { diff --git a/l1-contracts/test/fees/ProvingCostRateLimit.t.sol b/l1-contracts/test/fees/ProvingCostRateLimit.t.sol new file mode 100644 index 000000000000..018bf0c473ae --- /dev/null +++ b/l1-contracts/test/fees/ProvingCostRateLimit.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aztec Labs. +pragma solidity >=0.8.27; + +import {RollupBuilder} from "../builder/RollupBuilder.sol"; +import {Rollup} from "@aztec/core/Rollup.sol"; +import {IRollup, EthValue} from "@aztec/core/interfaces/IRollup.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import { + MIN_PROVING_COST_PER_MANA, + PROVING_COST_STEP_DEN, + PROVING_COST_STEP_NUM, + PROVING_COST_UPDATE_INTERVAL +} from "@aztec/core/libraries/rollup/FeeLib.sol"; +import {Test} from "forge-std/Test.sol"; + +/** + * @title ProvingCostRateLimitTest + * @notice Exercises the rate limiter on setProvingCostPerMana: + * + * - hard floor (MIN_PROVING_COST_PER_MANA = 2) + * - multiplicative step cap (3/2) against the live value + * - cooldown (30 days) between updates, with the first post-init update exempt + * + * Tests go through the real Rollup surface so the whole path is validated. + */ +contract ProvingCostRateLimitTest is Test { + uint256 internal constant INITIAL = 1000; + + Rollup internal rollup; + + function setUp() public { + RollupBuilder builder = new RollupBuilder(address(this)).setMakeGovernance(false).setTargetCommitteeSize(0) + .setProvingCostPerMana(EthValue.wrap(INITIAL)); + builder.deploy(); + rollup = builder.getConfig().rollup; + } + + // --------------------------------------------------------------------- + // Floor + // --------------------------------------------------------------------- + + function test_revertsWhen_belowFloor() public { + vm.expectRevert(abi.encodeWithSelector(Errors.FeeLib__ProvingCostBelowFloor.selector, 1, MIN_PROVING_COST_PER_MANA)); + rollup.setProvingCostPerMana(EthValue.wrap(1)); + + vm.expectRevert(abi.encodeWithSelector(Errors.FeeLib__ProvingCostBelowFloor.selector, 0, MIN_PROVING_COST_PER_MANA)); + rollup.setProvingCostPerMana(EthValue.wrap(0)); + } + + // --------------------------------------------------------------------- + // Step cap + // --------------------------------------------------------------------- + + function test_firstUpdate_bypassesCooldown_atStepCap() public { + // newV <= current * PROVING_COST_STEP_NUM / PROVING_COST_STEP_DEN + uint256 maxUp = INITIAL * PROVING_COST_STEP_NUM / PROVING_COST_STEP_DEN; + rollup.setProvingCostPerMana(EthValue.wrap(maxUp)); + assertEq(EthValue.unwrap(rollup.getProvingCostPerManaInEth()), maxUp); + } + + function test_revertsWhen_aboveStepCap() public { + uint256 above = (INITIAL * PROVING_COST_STEP_NUM / PROVING_COST_STEP_DEN) + 1; + vm.expectRevert(abi.encodeWithSelector(Errors.FeeLib__ProvingCostStepExceeded.selector, INITIAL, above)); + rollup.setProvingCostPerMana(EthValue.wrap(above)); + } + + function test_downStep_atBoundary() public { + // newV >= current * PROVING_COST_STEP_DEN / PROVING_COST_STEP_NUM + uint256 maxDown = (INITIAL * PROVING_COST_STEP_DEN + PROVING_COST_STEP_NUM - 1) / PROVING_COST_STEP_NUM; + rollup.setProvingCostPerMana(EthValue.wrap(maxDown)); + assertEq(EthValue.unwrap(rollup.getProvingCostPerManaInEth()), maxDown); + } + + function test_revertsWhen_belowStepCap() public { + uint256 below = (INITIAL * PROVING_COST_STEP_DEN + PROVING_COST_STEP_NUM - 1) / PROVING_COST_STEP_NUM - 1; + vm.expectRevert(abi.encodeWithSelector(Errors.FeeLib__ProvingCostStepExceeded.selector, INITIAL, below)); + rollup.setProvingCostPerMana(EthValue.wrap(below)); + } + + // --------------------------------------------------------------------- + // Cooldown + // --------------------------------------------------------------------- + + function test_revertsWhen_withinCooldown() public { + // First update: consumes the "lastUpdate == 0" bypass. + rollup.setProvingCostPerMana(EthValue.wrap(1500)); + + // Any follow-up before the interval reverts, regardless of value. + uint256 nextAllowed = block.timestamp + PROVING_COST_UPDATE_INTERVAL; + vm.expectRevert(abi.encodeWithSelector(Errors.FeeLib__ProvingCostCooldown.selector, nextAllowed)); + rollup.setProvingCostPerMana(EthValue.wrap(1500)); + } + + function test_succeedsAt_cooldownBoundary() public { + rollup.setProvingCostPerMana(EthValue.wrap(1500)); + + vm.warp(block.timestamp + PROVING_COST_UPDATE_INTERVAL); + // 1500 * 3/2 = 2250, at the boundary. + rollup.setProvingCostPerMana(EthValue.wrap(2250)); + assertEq(EthValue.unwrap(rollup.getProvingCostPerManaInEth()), 2250); + } + + function test_revertsWhen_oneSecondShortOfCooldown() public { + rollup.setProvingCostPerMana(EthValue.wrap(1500)); + + uint256 nextAllowed = block.timestamp + PROVING_COST_UPDATE_INTERVAL; + vm.warp(nextAllowed - 1); + vm.expectRevert(abi.encodeWithSelector(Errors.FeeLib__ProvingCostCooldown.selector, nextAllowed)); + rollup.setProvingCostPerMana(EthValue.wrap(1500)); + } + + // --------------------------------------------------------------------- + // Rate-of-growth guarantee + // --------------------------------------------------------------------- + + /// @notice Ten cooperating 3/2 steps (the theoretical max growth rate) should not exceed + /// (3/2)^10 ≈ 57.67x. Guards against accidental amplification bugs. + function test_tenStepsCapGrowth() public { + uint256 value = INITIAL; + // First step is free of cooldown. + uint256 next = value * PROVING_COST_STEP_NUM / PROVING_COST_STEP_DEN; + rollup.setProvingCostPerMana(EthValue.wrap(next)); + value = next; + + for (uint256 i = 0; i < 9; i++) { + vm.warp(block.timestamp + PROVING_COST_UPDATE_INTERVAL); + next = value * PROVING_COST_STEP_NUM / PROVING_COST_STEP_DEN; + rollup.setProvingCostPerMana(EthValue.wrap(next)); + value = next; + } + + // (3/2)^10 * 1000 = 57_665.039..., integer flooring makes this 57_629 (bounded tightly below 58k). + assertLt(value, 58_000, "value should not exceed (3/2)^10 * INITIAL"); + assertGt(value, 57_000, "value should reach close to (3/2)^10 * INITIAL"); + } +} diff --git a/l1-contracts/test/governance/reward-distributor/Base.t.sol b/l1-contracts/test/governance/reward-distributor/Base.t.sol index c4db9de098cf..63c73e90f676 100644 --- a/l1-contracts/test/governance/reward-distributor/Base.t.sol +++ b/l1-contracts/test/governance/reward-distributor/Base.t.sol @@ -22,7 +22,7 @@ contract RewardDistributorBase is Test { Registry internal registry; RewardDistributor internal rewardDistributor; - function setUp() public { + function setUp() public virtual { token = IMintableERC20(address(new TestERC20("test", "TEST", address(this)))); registry = new Registry(address(this), token); diff --git a/l1-contracts/test/governance/reward-distributor/claim.t.sol b/l1-contracts/test/governance/reward-distributor/claim.t.sol index 0f51b4e2377f..a6d992f1b0a3 100644 --- a/l1-contracts/test/governance/reward-distributor/claim.t.sol +++ b/l1-contracts/test/governance/reward-distributor/claim.t.sol @@ -1,47 +1,379 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.27; -import {RewardDistributorBase} from "./Base.t.sol"; +import {RewardDistributorBase, FakeRollup} from "./Base.t.sol"; import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; +import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; +// Authorization for `claim` is `_amount <= availableTo(msg.sender)`. +// availableTo == specificRecipientBalance for non-canonical callers, and +// (balance - totalEarmarked) + specificRecipientBalance for the canonical caller. contract ClaimTest is RewardDistributorBase { - address internal caller; + address internal canonical; - function test_WhenCallerIsNotCanonical(address _caller) external { - // it reverts - address canonical = address(registry.getCanonicalRollup()); + function setUp() public override { + super.setUp(); + canonical = address(registry.getCanonicalRollup()); + } + + // --------------------------------------------------------------- + // Authorization + // --------------------------------------------------------------- + + function test_revertsWhen_callerIsNotCanonicalAndHasNoSubsidy(address _caller, uint256 _amount) external { vm.assume(_caller != canonical); + uint256 amount = bound(_amount, 1, type(uint256).max); - vm.expectRevert(abi.encodeWithSelector(Errors.RewardDistributor__InvalidCaller.selector, _caller, canonical)); + vm.expectRevert(abi.encodeWithSelector(Errors.RewardDistributor__InsufficientAvailable.selector, amount, 0)); vm.prank(_caller); - rewardDistributor.claim(_caller, 1e18); + rewardDistributor.claim(_caller, amount); } - modifier whenCallerIsCanonical() { - caller = address(registry.getCanonicalRollup()); - _; + function test_oldRollupCannotClaimMoreThanItsSubsidy(address _old) external { + vm.assume(_old != canonical && _old != address(0)); + uint256 subsidy = 100e18; + _subsidize(_old, subsidy); + + vm.expectRevert( + abi.encodeWithSelector(Errors.RewardDistributor__InsufficientAvailable.selector, subsidy + 1, subsidy) + ); + vm.prank(_old); + rewardDistributor.claim(_old, subsidy + 1); } - function test_GivenBalanceIs0() external whenCallerIsCanonical { - // it reverts with insufficient balance - vm.prank(caller); - vm.expectRevert(); - rewardDistributor.claim(caller, 1e18); + function test_canonicalCannotDrawFromOtherRollupEarmarked(address _old) external { + vm.assume(_old != canonical && _old != address(0)); + uint256 subsidy = 100e18; + _subsidize(_old, subsidy); + + assertEq(rewardDistributor.availableTo(canonical), 0, "canonical should not see other rollup's subsidy"); + + vm.expectRevert(abi.encodeWithSelector(Errors.RewardDistributor__InsufficientAvailable.selector, 1, 0)); + vm.prank(canonical); + rewardDistributor.claim(canonical, 1); } - function test_GivenBalanceGt0(uint256 _balance, uint256 _amount) external whenCallerIsCanonical { - // it transfers the requested amount + // --------------------------------------------------------------- + // Canonical: drains unearmarked first, then dips into its own earmarked + // --------------------------------------------------------------- - uint256 balance = bound(_balance, 1, type(uint256).max); + function test_canonicalClaimsFromUnearmarkedPool(uint256 _balance, uint256 _amount) external { + uint256 balance = bound(_balance, 1, type(uint128).max); uint256 amount = bound(_amount, 1, balance); token.mint(address(rewardDistributor), balance); - uint256 callerBalance = token.balanceOf(caller); - vm.prank(caller); - rewardDistributor.claim(caller, amount); + assertEq(rewardDistributor.availableTo(canonical), balance); - assertEq(token.balanceOf(caller), callerBalance + amount); + uint256 callerBalance = token.balanceOf(canonical); + vm.prank(canonical); + rewardDistributor.claim(canonical, amount); + + assertEq(token.balanceOf(canonical), callerBalance + amount); assertEq(token.balanceOf(address(rewardDistributor)), balance - amount); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0, "no earmarked accounting touched"); + } + + function test_canonicalClaim_atUnearmarkedBoundary_doesNotTouchEarmarked() external { + uint256 unearmarked = 40e18; + token.mint(address(rewardDistributor), unearmarked); + + uint256 earmarked = 10e18; + _subsidize(canonical, earmarked); + + vm.prank(canonical); + rewardDistributor.claim(canonical, unearmarked); + + assertEq(rewardDistributor.specificRecipientBalance(canonical), earmarked, "canonical earmarked untouched"); + assertEq(rewardDistributor.totalEarmarkedBalance(), earmarked, "totalEarmarked untouched"); + assertEq(token.balanceOf(address(rewardDistributor)), earmarked, "balance == remaining earmarked"); + } + + function test_canonicalClaim_oneAboveUnearmarked_pullsOneFromEarmarked() external { + uint256 unearmarked = 40e18; + token.mint(address(rewardDistributor), unearmarked); + + uint256 earmarked = 10e18; + _subsidize(canonical, earmarked); + + vm.prank(canonical); + rewardDistributor.claim(canonical, unearmarked + 1); + + assertEq(rewardDistributor.specificRecipientBalance(canonical), earmarked - 1); + assertEq(rewardDistributor.totalEarmarkedBalance(), earmarked - 1); + } + + function test_canonicalClaim_drainsBothPools() external { + uint256 unearmarked = 40e18; + uint256 earmarked = 10e18; + token.mint(address(rewardDistributor), unearmarked); + _subsidize(canonical, earmarked); + + vm.prank(canonical); + rewardDistributor.claim(canonical, unearmarked + earmarked); + + assertEq(rewardDistributor.specificRecipientBalance(canonical), 0); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0); + assertEq(token.balanceOf(address(rewardDistributor)), 0); + } + + // --------------------------------------------------------------- + // Non-canonical can drain its own earmarked + // --------------------------------------------------------------- + + function test_oldRollupCanClaimUpToItsSubsidy(address _old, uint256 _subsidy, uint256 _claimAmt) external { + vm.assume(_old != canonical && _old != address(0)); + uint256 subsidy = bound(_subsidy, 1, type(uint128).max); + uint256 claimAmt = bound(_claimAmt, 1, subsidy); + + _subsidize(_old, subsidy); + + assertEq(rewardDistributor.specificRecipientBalance(_old), subsidy); + assertEq(rewardDistributor.totalEarmarkedBalance(), subsidy); + + address recipient = makeAddr("oldRecipient"); + vm.prank(_old); + rewardDistributor.claim(recipient, claimAmt); + + assertEq(token.balanceOf(recipient), claimAmt); + assertEq(rewardDistributor.specificRecipientBalance(_old), subsidy - claimAmt); + assertEq(rewardDistributor.totalEarmarkedBalance(), subsidy - claimAmt); + } + + // --------------------------------------------------------------- + // Zero-amount claims are no-ops in either path + // --------------------------------------------------------------- + + function test_zeroAmountClaim_canonical_isNoop() external { + token.mint(address(rewardDistributor), 50e18); + uint256 balBefore = token.balanceOf(address(rewardDistributor)); + + vm.prank(canonical); + rewardDistributor.claim(address(0xbeef), 0); + + assertEq(token.balanceOf(address(rewardDistributor)), balBefore); + assertEq(token.balanceOf(address(0xbeef)), 0); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0); + } + + function test_zeroAmountClaim_nonCanonical_isNoop(address _caller) external { + vm.assume(_caller != canonical && _caller != address(0)); + vm.prank(_caller); + rewardDistributor.claim(address(0xbeef), 0); + assertEq(rewardDistributor.specificRecipientBalance(_caller), 0); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0); + } + + // --------------------------------------------------------------- + // Multi-rollup isolation + // --------------------------------------------------------------- + + function test_subsidizingDoesNotReduceCanonicalAvailability(address _old, uint256 _balance, uint256 _subsidy) + external + { + vm.assume(_old != canonical && _old != address(0)); + uint256 balance = bound(_balance, 1, type(uint128).max); + uint256 subsidy = bound(_subsidy, 1, type(uint128).max); + + token.mint(address(rewardDistributor), balance); + uint256 canonicalAvailable = rewardDistributor.availableTo(canonical); + assertEq(canonicalAvailable, balance, "canonical baseline incorrect"); + + _subsidize(_old, subsidy); + + // balance (now balance + subsidy) - totalEarmarked (subsidy) == balance. + assertEq(rewardDistributor.availableTo(canonical), canonicalAvailable, "canonical availability changed"); + assertEq(rewardDistributor.availableTo(_old), subsidy); + } + + function test_perRollupSubsidiesAreIsolatedAcrossClaim(address _a, address _b, address _c) external { + vm.assume( + _a != canonical && _b != canonical && _c != canonical && _a != _b && _b != _c && _a != _c && _a != address(0) + && _b != address(0) && _c != address(0) + ); + uint256 sa = 10e18; + uint256 sb = 20e18; + uint256 sc = 30e18; + _subsidize(_a, sa); + _subsidize(_b, sb); + _subsidize(_c, sc); + + assertEq(rewardDistributor.availableTo(_a), sa); + assertEq(rewardDistributor.availableTo(_b), sb); + assertEq(rewardDistributor.availableTo(_c), sc); + // Canonical sees nothing — entire balance is earmarked. + assertEq(rewardDistributor.availableTo(canonical), 0); + + vm.prank(_b); + rewardDistributor.claim(_b, sb); + assertEq(rewardDistributor.specificRecipientBalance(_a), sa); + assertEq(rewardDistributor.specificRecipientBalance(_c), sc); + assertEq(rewardDistributor.specificRecipientBalance(_b), 0); + assertEq(rewardDistributor.totalEarmarkedBalance(), sa + sc); + } + + // --------------------------------------------------------------- + // Canonical rotation + // --------------------------------------------------------------- + + function test_availableToCanonicalIncludesItsOwnEarmarked() external { + address r1 = address(new FakeRollup()); + uint256 subsidy = 100e18; + uint256 unearmarked = 50e18; + + _subsidize(r1, subsidy); + token.mint(address(rewardDistributor), unearmarked); + + // Non-canonical r1 sees only its own specificRecipientBalance. + assertEq(rewardDistributor.availableTo(r1), subsidy); + + // Promote r1 to canonical; it now sees unearmarked + its own earmarked. + registry.addRollup(IRollup(r1)); + assertEq(rewardDistributor.availableTo(r1), unearmarked + subsidy); + } + + function test_canonicalDrainsThenSurvivesRotation() external { + address r1 = address(new FakeRollup()); + uint256 subsidy = 100e18; + uint256 unearmarked = 50e18; + uint256 takenWhileCanonical = unearmarked + (subsidy / 2); + uint256 expectedRemaining = subsidy - (subsidy / 2); + + _subsidize(r1, subsidy); + token.mint(address(rewardDistributor), unearmarked); + + registry.addRollup(IRollup(r1)); + assertEq(rewardDistributor.availableTo(r1), unearmarked + subsidy); + + // Drain unearmarked + half of r1's earmarked in a single claim. + address recipient = makeAddr("recipient"); + vm.prank(r1); + rewardDistributor.claim(recipient, takenWhileCanonical); + + assertEq(token.balanceOf(recipient), takenWhileCanonical); + assertEq(rewardDistributor.specificRecipientBalance(r1), expectedRemaining); + assertEq(rewardDistributor.totalEarmarkedBalance(), expectedRemaining); + assertEq(token.balanceOf(address(rewardDistributor)), expectedRemaining); + assertEq(rewardDistributor.availableTo(r1), expectedRemaining); + + // Rotate canonical away from r1 — r1's remaining earmarked must survive. + address r2 = address(new FakeRollup()); + registry.addRollup(IRollup(r2)); + assertEq(address(registry.getCanonicalRollup()), r2); + assertEq(rewardDistributor.specificRecipientBalance(r1), expectedRemaining); + + // r1 is non-canonical again and recovers its remaining earmarked. + assertEq(rewardDistributor.availableTo(r1), expectedRemaining); + vm.prank(r1); + rewardDistributor.claim(recipient, expectedRemaining); + + assertEq(token.balanceOf(recipient), takenWhileCanonical + expectedRemaining); + assertEq(rewardDistributor.specificRecipientBalance(r1), 0); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0); + assertEq(token.balanceOf(address(rewardDistributor)), 0); + } + + function test_rotationPreservesEarmarkedAcrossManyRollups() external { + address r1 = address(new FakeRollup()); + address r2 = address(new FakeRollup()); + address r3 = address(new FakeRollup()); + + uint256 s1 = 11e18; + uint256 s2 = 22e18; + uint256 s3 = 33e18; + _subsidize(r1, s1); + _subsidize(r2, s2); + _subsidize(r3, s3); + + registry.addRollup(IRollup(r1)); + assertEq(rewardDistributor.availableTo(r1), s1); + + registry.addRollup(IRollup(r2)); + assertEq(rewardDistributor.specificRecipientBalance(r1), s1); + + registry.addRollup(IRollup(r3)); + assertEq(rewardDistributor.specificRecipientBalance(r1), s1); + assertEq(rewardDistributor.specificRecipientBalance(r2), s2); + assertEq(rewardDistributor.availableTo(r3), s3); + + vm.prank(r1); + rewardDistributor.claim(r1, s1); + vm.prank(r2); + rewardDistributor.claim(r2, s2); + vm.prank(r3); + rewardDistributor.claim(r3, s3); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0); + assertEq(token.balanceOf(address(rewardDistributor)), 0); + } + + function test_newCanonicalDoesNotInheritOldCanonicalEarmarked() external { + address r1 = address(new FakeRollup()); + address r2 = address(new FakeRollup()); + + uint256 r1Subsidy = 50e18; + _subsidize(r1, r1Subsidy); + + registry.addRollup(IRollup(r1)); + registry.addRollup(IRollup(r2)); + + // r2 must NOT see r1's earmarked. balance - totalEarmarked = 50 - 50 = 0. + assertEq(rewardDistributor.availableTo(r2), 0); + assertEq(rewardDistributor.availableTo(r1), r1Subsidy, "r1 retains earmarked after losing canonical"); + } + + // --------------------------------------------------------------- + // Distributed event splits implicit-pool draw from earmarked-pool draw + // --------------------------------------------------------------- + + function test_canonicalUnearmarkedClaim_emitsDistributedWithZeroEarmarked() external { + uint256 balance = 50e18; + uint256 amount = 30e18; + token.mint(address(rewardDistributor), balance); + + address recipient = makeAddr("eventRecipient"); + vm.expectEmit(true, true, true, true, address(rewardDistributor)); + emit IRewardDistributor.Distributed(canonical, recipient, amount, amount, 0); + vm.prank(canonical); + rewardDistributor.claim(recipient, amount); + } + + function test_canonicalMixedClaim_emitsDistributedSplit() external { + uint256 unearmarked = 40e18; + uint256 earmarked = 10e18; + uint256 total = unearmarked + earmarked; + token.mint(address(rewardDistributor), unearmarked); + _subsidize(canonical, earmarked); + + address recipient = makeAddr("mixedRecipient"); + vm.expectEmit(true, true, true, true, address(rewardDistributor)); + emit IRewardDistributor.Distributed(canonical, recipient, total, unearmarked, earmarked); + vm.prank(canonical); + rewardDistributor.claim(recipient, total); + } + + function test_nonCanonicalClaim_emitsDistributedWithOnlyEarmarked(address _old) external { + vm.assume(_old != canonical && _old != address(0)); + uint256 subsidy = 7e18; + _subsidize(_old, subsidy); + + address recipient = makeAddr("oldEventRecipient"); + vm.expectEmit(true, true, true, true, address(rewardDistributor)); + emit IRewardDistributor.Distributed(_old, recipient, subsidy, 0, subsidy); + vm.prank(_old); + rewardDistributor.claim(recipient, subsidy); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + function _subsidize(address _rollup, uint256 _amount) internal { + address funder = makeAddr("funder"); + token.mint(funder, _amount); + vm.prank(funder); + token.approve(address(rewardDistributor), _amount); + vm.prank(funder); + rewardDistributor.subsidizeAddress(_rollup, _amount); } } diff --git a/l1-contracts/test/governance/reward-distributor/claim.tree b/l1-contracts/test/governance/reward-distributor/claim.tree deleted file mode 100644 index 3d87d0ba1391..000000000000 --- a/l1-contracts/test/governance/reward-distributor/claim.tree +++ /dev/null @@ -1,9 +0,0 @@ -ClaimTest -├── when caller is not canonical -│ └── it reverts -└── when caller is canonical - ├── given balance is 0 - │ └── it return 0 - └── given balance gt 0 - ├── it transfer min(balance, CHECKPOINT_REWARD) - └── it return min(balance, CHECKPOINT_REWARD) diff --git a/l1-contracts/test/governance/reward-distributor/invariant.t.sol b/l1-contracts/test/governance/reward-distributor/invariant.t.sol new file mode 100644 index 000000000000..59b68b503d24 --- /dev/null +++ b/l1-contracts/test/governance/reward-distributor/invariant.t.sol @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; + +import {RewardDistributorBase, FakeRollup} from "./Base.t.sol"; + +import {Ownable} from "@oz/access/Ownable.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {RewardDistributor} from "@aztec/governance/RewardDistributor.sol"; +import {IMintableERC20} from "@aztec/shared/interfaces/IMintableERC20.sol"; +import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; + +// Cross-cutting accounting identities for RewardDistributor: +// +// ASSET.balanceOf(distributor) >= totalEarmarkedBalance // never under-collateralized +// sum_over_rollups(specificRecipientBalance) == totalEarmarkedBalance +// availableTo(canonical) == (balance - totalEarmarked) + specificRecipientBalance[canonical] +// +// Defended both by a deterministic mixed-op test and a stateful fuzz handler. + +contract IdentityTest is RewardDistributorBase { + address internal owner; + address internal canonical; + + function setUp() public override { + super.setUp(); + owner = Ownable(address(registry)).owner(); + canonical = address(registry.getCanonicalRollup()); + } + + function test_balanceIdentity_holdsAfterMixedOps(address _r1) external { + vm.assume(_r1 != canonical && _r1 != address(0)); + address funder = makeAddr("funder"); + + uint256 s1 = 19e18; + uint256 sc = 23e18; + token.mint(funder, s1 + sc); + vm.prank(funder); + token.approve(address(rewardDistributor), s1 + sc); + vm.prank(funder); + rewardDistributor.subsidizeAddress(_r1, s1); + vm.prank(funder); + rewardDistributor.subsidizeAddress(canonical, sc); + + uint256 unearmarked = 7e18; + token.mint(address(rewardDistributor), unearmarked); + _assertIdentity(_r1); + + vm.prank(_r1); + rewardDistributor.claim(_r1, 5e18); + _assertIdentity(_r1); + + // Canonical claim that dips into earmarked. + vm.prank(canonical); + rewardDistributor.claim(canonical, unearmarked + 3e18); + _assertIdentity(_r1); + + vm.prank(owner); + rewardDistributor.recoverFrom(canonical, owner, 4e18); + _assertIdentity(_r1); + + vm.prank(owner); + rewardDistributor.recoverFrom(_r1, owner, 2e18); + _assertIdentity(_r1); + } + + function _assertIdentity(address _r1) internal view { + uint256 balance = token.balanceOf(address(rewardDistributor)); + uint256 total = rewardDistributor.totalEarmarkedBalance(); + assertGe(balance, total, "balance must cover totalEarmarked"); + uint256 sumSpecific = + rewardDistributor.specificRecipientBalance(_r1) + rewardDistributor.specificRecipientBalance(canonical); + assertEq(sumSpecific, total, "sum of tracked specifics == totalEarmarked"); + uint256 canonicalAvail = rewardDistributor.availableTo(canonical); + assertEq( + canonicalAvail, balance - total + rewardDistributor.specificRecipientBalance(canonical), "canonical available" + ); + } +} + +// Stateful fuzz Handler. Pre-allocates a small set of rollup addresses so random call +// sequences reach overlapping state (subsidize/claim/recover all targeting the same rollup). +contract RewardDistributorHandler is Test { + RewardDistributor public distributor; + IMintableERC20 public token; + Registry public registry; + address public owner; + + address[] public rollups; + address[] public knownRollups; + mapping(address => bool) internal seen; + + uint256 internal constant MAX_AMOUNT = 1_000_000e18; + + constructor( + RewardDistributor _distributor, + IMintableERC20 _token, + Registry _registry, + address _owner, + address _initialCanonical + ) { + distributor = _distributor; + token = _token; + registry = _registry; + owner = _owner; + rollups.push(_initialCanonical); + _track(_initialCanonical); + } + + function _track(address _r) internal { + if (_r == address(0)) return; + if (seen[_r]) return; + seen[_r] = true; + knownRollups.push(_r); + } + + function _pickRollup(uint256 _seed) internal view returns (address) { + if (rollups.length == 0) return address(0); + return rollups[_seed % rollups.length]; + } + + function subsidize(uint256 _seed, uint256 _amount) external { + address r = _pickRollup(_seed); + if (r == address(0)) return; + uint256 amount = bound(_amount, 0, MAX_AMOUNT); + if (amount == 0) { + distributor.subsidizeAddress(r, 0); + _track(r); + return; + } + token.mint(address(this), amount); + token.approve(address(distributor), amount); + distributor.subsidizeAddress(r, amount); + _track(r); + } + + function claim(uint256 _seed, uint256 _amount) external { + address r = _pickRollup(_seed); + if (r == address(0)) return; + uint256 avail = distributor.availableTo(r); + if (avail == 0) return; + uint256 amount = bound(_amount, 0, avail); + vm.prank(r); + distributor.claim(address(0xbeef), amount); + } + + function recover(uint256 _seed, uint256 _amount) external { + address r = _pickRollup(_seed); + if (r == address(0)) return; + uint256 avail = distributor.availableTo(r); + if (avail == 0) return; + uint256 amount = bound(_amount, 0, avail); + vm.prank(owner); + distributor.recoverFrom(r, address(0xdead), amount); + } + + // Drop ASSET into the contract via plain mint (the `direct transfer` case). + function donate(uint256 _amount) external { + uint256 amount = bound(_amount, 0, MAX_AMOUNT); + token.mint(address(distributor), amount); + } + + function rotate() external { + FakeRollup nr = new FakeRollup(); + address newRollup = address(nr); + vm.prank(owner); + registry.addRollup(IRollup(newRollup)); + rollups.push(newRollup); + _track(newRollup); + } + + function knownRollupsLength() external view returns (uint256) { + return knownRollups.length; + } +} + +contract RewardDistributorInvariantTest is RewardDistributorBase { + RewardDistributorHandler internal handler; + + function setUp() public override { + super.setUp(); + address owner = Ownable(address(registry)).owner(); + address canonical = address(registry.getCanonicalRollup()); + + handler = new RewardDistributorHandler(rewardDistributor, token, registry, owner, canonical); + + // The handler needs to mint TestERC20 so that subsidize/donate exercise real balance changes. + token.addMinter(address(handler)); + + targetContract(address(handler)); + + bytes4[] memory sels = new bytes4[](5); + sels[0] = handler.subsidize.selector; + sels[1] = handler.claim.selector; + sels[2] = handler.recover.selector; + sels[3] = handler.donate.selector; + sels[4] = handler.rotate.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: sels})); + } + + // A violation here means a `claim`/`recover` underflowed or some path exfiltrated + // more ASSET than the accounting accepted. + function invariant_balanceCoversEarmarked() external view { + assertGe(token.balanceOf(address(rewardDistributor)), rewardDistributor.totalEarmarkedBalance()); + } + + // Catches double-debiting or missed-debiting bugs in `_transfer`. + function invariant_sumSpecificEqualsTotal() external view { + uint256 sum = 0; + uint256 n = handler.knownRollupsLength(); + for (uint256 i = 0; i < n; i++) { + sum += rewardDistributor.specificRecipientBalance(handler.knownRollups(i)); + } + assertEq(sum, rewardDistributor.totalEarmarkedBalance()); + } + + // The headline behavioural promise of the canonical inheritance design. + function invariant_canonicalAvailableMatchesIdentity() external view { + address canonical = rewardDistributor.canonicalRollup(); + uint256 expected = + token.balanceOf(address(rewardDistributor)) - rewardDistributor.totalEarmarkedBalance() + + rewardDistributor.specificRecipientBalance(canonical); + assertEq(rewardDistributor.availableTo(canonical), expected); + } +} diff --git a/l1-contracts/test/governance/reward-distributor/recover.t.sol b/l1-contracts/test/governance/reward-distributor/recover.t.sol index c6cc34e4768b..0bd012400c74 100644 --- a/l1-contracts/test/governance/reward-distributor/recover.t.sol +++ b/l1-contracts/test/governance/reward-distributor/recover.t.sol @@ -1,50 +1,267 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.27; -import {RewardDistributorBase} from "./Base.t.sol"; +import {RewardDistributorBase, FakeRollup} from "./Base.t.sol"; import {Errors} from "@aztec/governance/libraries/Errors.sol"; import {Ownable} from "@oz/access/Ownable.sol"; import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; +// `recoverFrom(_from, _to, _amount)` is owner-only and shares the accounting path with `claim`: +// - canonical: draws from unearmarked first, then specificRecipientBalance[canonical]. +// - non-canonical: draws only from specificRecipientBalance[_from]. +// +// `recoverWrongAsset(_asset, _to, _amount)` is owner-only and refuses ASSET so accounting cannot +// be bypassed. contract RecoverTest is RewardDistributorBase { - address internal caller; + address internal owner; + address internal canonical; - function test_WhenCallerIsNotOwner(address _caller) external { - // it reverts - address owner = Ownable(address(registry)).owner(); + function setUp() public override { + super.setUp(); + owner = Ownable(address(registry)).owner(); + canonical = address(registry.getCanonicalRollup()); + } + + // --------------------------------------------------------------- + // Authorization + // --------------------------------------------------------------- + + function test_recover_revertsWhen_callerIsNotOwner(address _caller) external { vm.assume(_caller != owner); + vm.expectRevert(abi.encodeWithSelector(Errors.RewardDistributor__InvalidCaller.selector, _caller, owner)); + vm.prank(_caller); + rewardDistributor.recoverFrom(canonical, _caller, 1e18); + } + function test_recoverWrongAsset_revertsWhen_callerIsNotOwner(address _caller) external { + vm.assume(_caller != owner); + TestERC20 other = new TestERC20("Other", "OTH", address(this)); vm.expectRevert(abi.encodeWithSelector(Errors.RewardDistributor__InvalidCaller.selector, _caller, owner)); vm.prank(_caller); - rewardDistributor.recover(address(token), _caller, 1e18); + rewardDistributor.recoverWrongAsset(address(other), _caller, 1e18); } - modifier whenCallerIsOwner() { - caller = Ownable(address(registry)).owner(); - _; + // Authority follows the registry's current owner, not whoever was owner at deploy. + function test_recoverAuthority_followsRegistryOwner() external { + address newOwner = makeAddr("newOwner"); + Ownable(address(registry)).transferOwnership(newOwner); + + token.mint(address(rewardDistributor), 1e18); + + vm.expectRevert(abi.encodeWithSelector(Errors.RewardDistributor__InvalidCaller.selector, owner, newOwner)); + vm.prank(owner); + rewardDistributor.recoverFrom(canonical, owner, 1); + + vm.prank(newOwner); + rewardDistributor.recoverFrom(canonical, newOwner, 1); + assertEq(token.balanceOf(newOwner), 1); } - function test_GivenBalanceGt0(uint256 _balance, uint256 _amount) external whenCallerIsOwner { - // it transfers the requested amount + // --------------------------------------------------------------- + // recover — canonical path mirrors `claim` from canonical + // --------------------------------------------------------------- - uint256 balance = bound(_balance, 1, type(uint256).max); + function test_recover_canonical_drawsFromUnearmarked(uint256 _balance, uint256 _amount) external { + uint256 balance = bound(_balance, 1, type(uint128).max); uint256 amount = bound(_amount, 1, balance); token.mint(address(rewardDistributor), balance); - uint256 callerBalance = token.balanceOf(caller); - vm.prank(caller); - rewardDistributor.recover(address(token), caller, amount); + vm.prank(owner); + rewardDistributor.recoverFrom(canonical, owner, amount); - assertEq(token.balanceOf(caller), callerBalance + amount); + assertEq(token.balanceOf(owner), amount); assertEq(token.balanceOf(address(rewardDistributor)), balance - amount); + assertEq(rewardDistributor.specificRecipientBalance(canonical), 0, "canonical earmarked unchanged"); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0, "totalEarmarked unchanged"); + } + + function test_recover_canonical_dipsIntoOwnEarmarked() external { + uint256 earmarked = 100e18; + _subsidize(canonical, earmarked); + uint256 unearmarked = 30e18; + token.mint(address(rewardDistributor), unearmarked); + + uint256 dip = 20e18; + uint256 amount = unearmarked + dip; + vm.prank(owner); + rewardDistributor.recoverFrom(canonical, owner, amount); + + assertEq(token.balanceOf(owner), amount); + assertEq(rewardDistributor.specificRecipientBalance(canonical), earmarked - dip); + assertEq(rewardDistributor.totalEarmarkedBalance(), earmarked - dip); + assertEq(token.balanceOf(address(rewardDistributor)), earmarked - dip); + } + + function test_recover_canonical_revertsAboveAvailable() external { + uint256 unearmarked = 10e18; + token.mint(address(rewardDistributor), unearmarked); + uint256 earmarked = 5e18; + _subsidize(canonical, earmarked); + + uint256 totalAvailable = unearmarked + earmarked; + vm.expectRevert( + abi.encodeWithSelector( + Errors.RewardDistributor__InsufficientAvailable.selector, totalAvailable + 1, totalAvailable + ) + ); + vm.prank(owner); + rewardDistributor.recoverFrom(canonical, owner, totalAvailable + 1); + } + + // --------------------------------------------------------------- + // recover — non-canonical path: only that rollup's earmarked + // --------------------------------------------------------------- + + function test_recover_nonCanonical_drawsFromEarmarked(address _old, uint256 _subsidy, uint256 _amount) external { + vm.assume(_old != canonical && _old != address(0)); + uint256 subsidy = bound(_subsidy, 1, type(uint128).max); + uint256 amount = bound(_amount, 1, subsidy); + + _subsidize(_old, subsidy); + + vm.prank(owner); + rewardDistributor.recoverFrom(_old, owner, amount); + + assertEq(token.balanceOf(owner), amount); + assertEq(rewardDistributor.specificRecipientBalance(_old), subsidy - amount); + assertEq(rewardDistributor.totalEarmarkedBalance(), subsidy - amount); + assertEq(token.balanceOf(address(rewardDistributor)), subsidy - amount); + } + + // Adding unearmarked balance must NOT make it accessible via recover(_old, ...). + function test_recover_nonCanonical_revertsAboveEarmarked(address _old) external { + vm.assume(_old != canonical && _old != address(0)); + uint256 subsidy = 7e18; + _subsidize(_old, subsidy); + + token.mint(address(rewardDistributor), 1000e18); + + vm.expectRevert( + abi.encodeWithSelector(Errors.RewardDistributor__InsufficientAvailable.selector, subsidy + 1, subsidy) + ); + vm.prank(owner); + rewardDistributor.recoverFrom(_old, owner, subsidy + 1); + } + + function test_recover_doesNotTouchOtherRollupsEarmarked(address _a, address _b) external { + vm.assume(_a != canonical && _b != canonical && _a != _b && _a != address(0) && _b != address(0)); + uint256 subsidyA = 30e18; + uint256 subsidyB = 70e18; + + _subsidize(_a, subsidyA); + _subsidize(_b, subsidyB); + + vm.prank(owner); + rewardDistributor.recoverFrom(_a, owner, subsidyA); + + assertEq(rewardDistributor.specificRecipientBalance(_a), 0, "A drained"); + assertEq(rewardDistributor.specificRecipientBalance(_b), subsidyB, "B untouched"); + assertEq(rewardDistributor.totalEarmarkedBalance(), subsidyB); + assertEq(token.balanceOf(address(rewardDistributor)), subsidyB); + } + + function test_governanceCanFullyDrainAllRollupsAndUnearmarked(address _a) external { + vm.assume(_a != canonical && _a != address(0)); + + uint256 subsidyA = 13e18; + uint256 subsidyCanonical = 21e18; + uint256 unearmarked = 5e18; + + _subsidize(_a, subsidyA); + _subsidize(canonical, subsidyCanonical); + token.mint(address(rewardDistributor), unearmarked); + + // Drain canonical pool first (unearmarked + canonical earmarked), then A's earmarked. + vm.prank(owner); + rewardDistributor.recoverFrom(canonical, owner, unearmarked + subsidyCanonical); + vm.prank(owner); + rewardDistributor.recoverFrom(_a, owner, subsidyA); + + assertEq(token.balanceOf(owner), unearmarked + subsidyCanonical + subsidyA); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0); + assertEq(rewardDistributor.specificRecipientBalance(_a), 0); + assertEq(rewardDistributor.specificRecipientBalance(canonical), 0); + assertEq(token.balanceOf(address(rewardDistributor)), 0); + } + + function test_recover_followsCanonicalRotation() external { + address r1 = address(new FakeRollup()); + uint256 subsidy = 80e18; + _subsidize(r1, subsidy); + registry.addRollup(IRollup(r1)); + assertEq(address(registry.getCanonicalRollup()), r1); + + uint256 unearmarked = 10e18; + token.mint(address(rewardDistributor), unearmarked); + + // recover(canonical=r1) reaches unearmarked + r1's earmarked. + vm.prank(owner); + rewardDistributor.recoverFrom(r1, owner, unearmarked + subsidy); + assertEq(rewardDistributor.specificRecipientBalance(r1), 0); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0); + assertEq(token.balanceOf(address(rewardDistributor)), 0); + } + + // --------------------------------------------------------------- + // recoverWrongAsset + // --------------------------------------------------------------- + + function test_recoverWrongAsset_transfersAnyOtherToken(uint256 _balance, uint256 _amount) external { + uint256 balance = bound(_balance, 1, type(uint128).max); + uint256 amount = bound(_amount, 1, balance); + + TestERC20 other = new TestERC20("Other", "OTH", address(this)); + other.mint(address(rewardDistributor), balance); + + vm.prank(owner); + rewardDistributor.recoverWrongAsset(address(other), owner, amount); + + assertEq(other.balanceOf(owner), amount); + assertEq(other.balanceOf(address(rewardDistributor)), balance - amount); + } + + function test_recoverWrongAsset_revertsForAsset() external { + token.mint(address(rewardDistributor), 100e18); + vm.expectRevert(abi.encodeWithSelector(Errors.RewardDistributor__WrongRecoverMechanism.selector)); + vm.prank(owner); + rewardDistributor.recoverWrongAsset(address(token), owner, 1e18); + } + + function test_recoverWrongAsset_doesNotAffectAssetBookkeeping() external { + uint256 subsidy = 50e18; + _subsidize(canonical, subsidy); + uint256 unearmarked = 25e18; + token.mint(address(rewardDistributor), unearmarked); + + TestERC20 other = new TestERC20("Other", "OTH", address(this)); + uint256 stray = 7e18; + other.mint(address(rewardDistributor), stray); + + uint256 balanceBefore = token.balanceOf(address(rewardDistributor)); + uint256 specificBefore = rewardDistributor.specificRecipientBalance(canonical); + uint256 totalBefore = rewardDistributor.totalEarmarkedBalance(); + + vm.prank(owner); + rewardDistributor.recoverWrongAsset(address(other), owner, stray); + + assertEq(other.balanceOf(owner), stray); + assertEq(token.balanceOf(address(rewardDistributor)), balanceBefore, "ASSET balance unchanged"); + assertEq(rewardDistributor.specificRecipientBalance(canonical), specificBefore); + assertEq(rewardDistributor.totalEarmarkedBalance(), totalBefore); + } - TestERC20 token2 = new TestERC20("Token2", "T2", address(this)); - token2.mint(address(rewardDistributor), balance); + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- - vm.prank(caller); - rewardDistributor.recover(address(token2), caller, amount); - assertEq(token2.balanceOf(caller), amount); - assertEq(token2.balanceOf(address(rewardDistributor)), balance - amount); + function _subsidize(address _rollup, uint256 _amount) internal { + address funder = makeAddr("funder"); + token.mint(funder, _amount); + vm.prank(funder); + token.approve(address(rewardDistributor), _amount); + vm.prank(funder); + rewardDistributor.subsidizeAddress(_rollup, _amount); } } diff --git a/l1-contracts/test/governance/reward-distributor/subsidizeAddress.t.sol b/l1-contracts/test/governance/reward-distributor/subsidizeAddress.t.sol new file mode 100644 index 000000000000..4b7e5e8a8cfe --- /dev/null +++ b/l1-contracts/test/governance/reward-distributor/subsidizeAddress.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {RewardDistributorBase} from "./Base.t.sol"; + +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; + +contract SubsidizeRollupTest is RewardDistributorBase { + function test_revertsWhen_rollupIsZero(uint256 _amount) external { + address funder = makeAddr("funder"); + token.mint(funder, _amount); + vm.prank(funder); + token.approve(address(rewardDistributor), _amount); + + vm.expectRevert(abi.encodeWithSelector(Errors.RewardDistributor__ZeroRollup.selector)); + vm.prank(funder); + rewardDistributor.subsidizeAddress(address(0), _amount); + } + + // subsidizeAddress is permissionless: any non-zero caller with allowance can fund any non-zero rollup. + function test_creditsSpecificAndTotal(address _funder, address _rollup, uint256 _amount) external { + vm.assume(_rollup != address(0)); + vm.assume(_funder != address(0) && _funder != address(rewardDistributor)); + uint256 amount = bound(_amount, 1, type(uint128).max); + + token.mint(_funder, amount); + vm.prank(_funder); + token.approve(address(rewardDistributor), amount); + + uint256 prevSpecific = rewardDistributor.specificRecipientBalance(_rollup); + uint256 prevDebt = rewardDistributor.totalEarmarkedBalance(); + uint256 prevBalance = token.balanceOf(address(rewardDistributor)); + + vm.prank(_funder); + rewardDistributor.subsidizeAddress(_rollup, amount); + + assertEq(rewardDistributor.specificRecipientBalance(_rollup), prevSpecific + amount, "specificRecipientBalance"); + assertEq(rewardDistributor.totalEarmarkedBalance(), prevDebt + amount, "totalEarmarkedBalance"); + assertEq(token.balanceOf(address(rewardDistributor)), prevBalance + amount, "balance"); + assertEq(rewardDistributor.availableTo(_rollup), prevSpecific + amount, "availableTo"); + } + + function test_accumulatesAcrossCalls(address _rollup) external { + vm.assume(_rollup != address(0)); + uint256 first = 10e18; + uint256 second = 25e18; + + address funder = makeAddr("funder"); + token.mint(funder, first + second); + vm.prank(funder); + token.approve(address(rewardDistributor), first + second); + + vm.prank(funder); + rewardDistributor.subsidizeAddress(_rollup, first); + vm.prank(funder); + rewardDistributor.subsidizeAddress(_rollup, second); + + assertEq(rewardDistributor.specificRecipientBalance(_rollup), first + second); + assertEq(rewardDistributor.totalEarmarkedBalance(), first + second); + } + + function test_emitsSubsidizedEvent(address _rollup, uint256 _amount) external { + // Every credit recorded against `specificRecipientBalance` must emit Subsidized so an + // off-chain indexer can rebuild bucket-by-bucket history from logs alone. + vm.assume(_rollup != address(0)); + uint256 amount = bound(_amount, 0, type(uint128).max); + + address funder = makeAddr("eventFunder"); + token.mint(funder, amount); + vm.prank(funder); + token.approve(address(rewardDistributor), amount); + + vm.expectEmit(true, true, true, true, address(rewardDistributor)); + emit IRewardDistributor.Subsidized(funder, _rollup, amount); + vm.prank(funder); + rewardDistributor.subsidizeAddress(_rollup, amount); + } + + function test_zeroAmountIsNoop(address _rollup) external { + vm.assume(_rollup != address(0)); + address funder = makeAddr("funder"); + vm.prank(funder); + token.approve(address(rewardDistributor), 0); + + uint256 prevSpecific = rewardDistributor.specificRecipientBalance(_rollup); + uint256 prevDebt = rewardDistributor.totalEarmarkedBalance(); + + vm.prank(funder); + rewardDistributor.subsidizeAddress(_rollup, 0); + + assertEq(rewardDistributor.specificRecipientBalance(_rollup), prevSpecific); + assertEq(rewardDistributor.totalEarmarkedBalance(), prevDebt); + } +} diff --git a/l1-contracts/test/outbox/tmnt205.t.sol b/l1-contracts/test/outbox/tmnt205.t.sol index 45cf31c63353..84336243dc61 100644 --- a/l1-contracts/test/outbox/tmnt205.t.sol +++ b/l1-contracts/test/outbox/tmnt205.t.sol @@ -34,7 +34,7 @@ contract Tmnt205Test is Test { $root = _buildWonkyTree(); vm.prank(rollup); - outbox.insert(DEFAULT_EPOCH, $root); + outbox.insert(DEFAULT_EPOCH, 1, $root); } function test_replays_exact() public { @@ -61,12 +61,12 @@ contract Tmnt205Test is Test { uint256 leafId = leafIndex + (1 << path.length); vm.expectEmit(true, true, true, true, address(outbox)); - emit IOutbox.MessageConsumed(DEFAULT_EPOCH, $root, message.sha256ToField(), leafId); - outbox.consume(message, DEFAULT_EPOCH, leafIndex, path); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, $root, message.sha256ToField(), leafId, 1); + outbox.consume(message, DEFAULT_EPOCH, 1, leafIndex, path); // It should always revert, here, either incorrect values or already used. vm.expectRevert(); - outbox.consume(message, DEFAULT_EPOCH, leafIndex2, path); + outbox.consume(message, DEFAULT_EPOCH, 1, leafIndex2, path); } function test_overrides() public { @@ -93,7 +93,7 @@ contract Tmnt205Test is Test { // The outbox should revert earlier to that due to the index beyond boundary vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__LeafIndexOutOfBounds.selector, a_leafIndex, a_path.length)); - outbox.consume(a_message, DEFAULT_EPOCH, a_leafIndex, a_path); + outbox.consume(a_message, DEFAULT_EPOCH, 1, a_leafIndex, a_path); // Real message DataStructures.L2ToL1Msg memory r_message = $msgs[4]; @@ -105,7 +105,7 @@ contract Tmnt205Test is Test { leftSubTree.insertLeaf($txOutHashes[0]); leftSubTree.insertLeaf($txOutHashes[1]); r_path[2] = leftSubTree.computeRoot(); - outbox.consume(r_message, DEFAULT_EPOCH, r_leafIndex, r_path); + outbox.consume(r_message, DEFAULT_EPOCH, 1, r_leafIndex, r_path); } function _fakeMessage(address _recipient, uint256 _content) internal view returns (DataStructures.L2ToL1Msg memory) { diff --git a/l1-contracts/test/portals/DataStructures.sol b/l1-contracts/test/portals/DataStructures.sol index 611a15c32d7a..afbcb865f538 100644 --- a/l1-contracts/test/portals/DataStructures.sol +++ b/l1-contracts/test/portals/DataStructures.sol @@ -7,6 +7,7 @@ import {Epoch} from "@aztec/core/libraries/TimeLib.sol"; library DataStructures { struct OutboxMessageMetadata { Epoch _epoch; + uint256 _numCheckpointsInEpoch; uint256 _leafIndex; bytes32[] _path; } diff --git a/l1-contracts/test/portals/TokenPortal.sol b/l1-contracts/test/portals/TokenPortal.sol index 170a0da3e386..fa4a84303511 100644 --- a/l1-contracts/test/portals/TokenPortal.sol +++ b/l1-contracts/test/portals/TokenPortal.sol @@ -117,8 +117,10 @@ contract TokenPortal { * @param _amount - The amount to withdraw * @param _withCaller - Flag to use `msg.sender` as caller, otherwise address(0) * @param _epoch - The epoch the message is in - * @param _leafIndex - The amount to withdraw - * @param _path - Flag to use `msg.sender` as caller, otherwise address(0) + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root this + * consume verifies against + * @param _leafIndex - The index of the leaf in the epoch message tree + * @param _path - The sibling path proving inclusion of the message in the epoch's root * Must match the caller of the message (specified from L2) to consume it. */ function withdraw( @@ -126,6 +128,7 @@ contract TokenPortal { uint256 _amount, bool _withCaller, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external { @@ -141,7 +144,7 @@ contract TokenPortal { ) }); - outbox.consume(message, _epoch, _leafIndex, _path); + outbox.consume(message, _epoch, _numCheckpointsInEpoch, _leafIndex, _path); underlying.safeTransfer(_recipient, _amount); } diff --git a/l1-contracts/test/portals/TokenPortal.t.sol b/l1-contracts/test/portals/TokenPortal.t.sol index 4a6977ab4a6d..47e341d38f68 100644 --- a/l1-contracts/test/portals/TokenPortal.t.sol +++ b/l1-contracts/test/portals/TokenPortal.t.sol @@ -206,7 +206,7 @@ contract TokenPortalTest is Test { bytes32 treeRoot = tree.computeRoot(); // Insert messages into the outbox (impersonating the rollup contract) vm.prank(address(rollup)); - outbox.insert(_epoch, treeRoot); + outbox.insert(_epoch, 1, treeRoot); return (l2ToL1Message, siblingPath, treeRoot); } @@ -224,15 +224,15 @@ contract TokenPortalTest is Test { vm.startPrank(_caller); vm.expectEmit(true, true, true, true); - emit IOutbox.MessageConsumed(DEFAULT_EPOCH, treeRoot, l2ToL1Message, leafId); - tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, leafIndex, siblingPath); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, treeRoot, l2ToL1Message, leafId, 1); + tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, 1, leafIndex, siblingPath); // Should have received 654 RNA tokens assertEq(testERC20.balanceOf(recipient), withdrawAmount); // Should not be able to withdraw again vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, leafIndex, siblingPath); + tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, 1, leafIndex, siblingPath); vm.stopPrank(); } @@ -246,13 +246,13 @@ contract TokenPortalTest is Test { vm.expectRevert( abi.encodeWithSelector(Errors.MerkleLib__InvalidRoot.selector, treeRoot, consumedRoot, l2ToL1MessageHash, 0) ); - tokenPortal.withdraw(recipient, withdrawAmount, true, DEFAULT_EPOCH, 0, siblingPath); + tokenPortal.withdraw(recipient, withdrawAmount, true, DEFAULT_EPOCH, 1, 0, siblingPath); (l2ToL1MessageHash, consumedRoot) = _createWithdrawMessageForOutbox(address(0)); vm.expectRevert( abi.encodeWithSelector(Errors.MerkleLib__InvalidRoot.selector, treeRoot, consumedRoot, l2ToL1MessageHash, 0) ); - tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, 0, siblingPath); + tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, 1, 0, siblingPath); vm.stopPrank(); } @@ -265,8 +265,8 @@ contract TokenPortalTest is Test { uint256 leafId = 2 ** siblingPath.length + leafIndex; vm.expectEmit(true, true, true, true); - emit IOutbox.MessageConsumed(DEFAULT_EPOCH, treeRoot, l2ToL1Message, leafId); - tokenPortal.withdraw(recipient, withdrawAmount, true, DEFAULT_EPOCH, leafIndex, siblingPath); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, treeRoot, l2ToL1Message, leafId, 1); + tokenPortal.withdraw(recipient, withdrawAmount, true, DEFAULT_EPOCH, 1, leafIndex, siblingPath); // Should have received 654 RNA tokens assertEq(testERC20.balanceOf(recipient), withdrawAmount); diff --git a/l1-contracts/test/portals/UniswapPortal.sol b/l1-contracts/test/portals/UniswapPortal.sol index 91d676a90e09..317ba3fe27d5 100644 --- a/l1-contracts/test/portals/UniswapPortal.sol +++ b/l1-contracts/test/portals/UniswapPortal.sol @@ -86,6 +86,7 @@ contract UniswapPortal { _inAmount, true, _outboxMessageMetadata[0]._epoch, + _outboxMessageMetadata[0]._numCheckpointsInEpoch, _outboxMessageMetadata[0]._leafIndex, _outboxMessageMetadata[0]._path ); @@ -119,6 +120,7 @@ contract UniswapPortal { content: vars.contentHash }), _outboxMessageMetadata[1]._epoch, + _outboxMessageMetadata[1]._numCheckpointsInEpoch, _outboxMessageMetadata[1]._leafIndex, _outboxMessageMetadata[1]._path ); @@ -188,6 +190,7 @@ contract UniswapPortal { _inAmount, true, _outboxMessageMetadata[0]._epoch, + _outboxMessageMetadata[0]._numCheckpointsInEpoch, _outboxMessageMetadata[0]._leafIndex, _outboxMessageMetadata[0]._path ); @@ -220,6 +223,7 @@ contract UniswapPortal { content: vars.contentHash }), _outboxMessageMetadata[1]._epoch, + _outboxMessageMetadata[1]._numCheckpointsInEpoch, _outboxMessageMetadata[1]._leafIndex, _outboxMessageMetadata[1]._path ); diff --git a/l1-contracts/test/portals/UniswapPortal.t.sol b/l1-contracts/test/portals/UniswapPortal.t.sol index 815d5890d9d0..1da7f9fe3c52 100644 --- a/l1-contracts/test/portals/UniswapPortal.t.sol +++ b/l1-contracts/test/portals/UniswapPortal.t.sol @@ -179,7 +179,7 @@ contract UniswapPortalTest is Test { (bytes32[] memory swapSiblingPath,) = tree.computeSiblingPath(1); vm.prank(address(rollup)); - outbox.insert(_epoch, treeRoot); + outbox.insert(_epoch, 1, treeRoot); return (treeRoot, withdrawSiblingPath, swapSiblingPath); } @@ -210,8 +210,12 @@ contract UniswapPortalTest is Test { ); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; uniswapPortal.swapPublic( @@ -257,8 +261,12 @@ contract UniswapPortalTest is Test { ); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; uniswapPortal.swapPublic( @@ -306,8 +314,12 @@ contract UniswapPortalTest is Test { ); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; uniswapPortal.swapPublic( @@ -331,8 +343,12 @@ contract UniswapPortalTest is Test { _addMessagesToOutbox(daiWithdrawMessageHash, swapMessageHash, DEFAULT_EPOCH); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; uniswapPortal.swapPublic( @@ -367,8 +383,12 @@ contract UniswapPortalTest is Test { _addMessagesToOutbox(daiWithdrawMessageHash, swapMessageHash, DEFAULT_EPOCH); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; vm.prank(_caller); @@ -403,8 +423,12 @@ contract UniswapPortalTest is Test { _addMessagesToOutbox(daiWithdrawMessageHash, swapMessageHash, DEFAULT_EPOCH); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; vm.startPrank(_caller); @@ -486,8 +510,12 @@ contract UniswapPortalTest is Test { _addMessagesToOutbox(daiWithdrawMessageHash, swapMessageHash, DEFAULT_EPOCH); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; bytes32 messageHashPortalChecksAgainst = _createUniswapSwapMessagePrivate(address(this)); diff --git a/l1-contracts/test/rollup/constructorExitDelay.t.sol b/l1-contracts/test/rollup/constructorExitDelay.t.sol new file mode 100644 index 000000000000..c0dd7e64985d --- /dev/null +++ b/l1-contracts/test/rollup/constructorExitDelay.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.27; +// solhint-disable func-name-mixedcase +// solhint-disable comprehensive-interface + +import {Test} from "forge-std/Test.sol"; +import {Rollup} from "@aztec/core/Rollup.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {StakingLib} from "@aztec/core/libraries/rollup/StakingLib.sol"; +import {RollupConfigInput} from "@aztec/core/interfaces/IRollup.sol"; +import {RollupBuilder, Config as BuilderConfig} from "@test/builder/RollupBuilder.sol"; +import {MockVerifier} from "@aztec/mock/MockVerifier.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {GSE} from "@aztec/governance/GSE.sol"; +import {GenesisState} from "@aztec/core/libraries/rollup/STFLib.sol"; +import {IVerifier} from "@aztec/core/interfaces/IVerifier.sol"; + +/// @notice Verifies the Rollup constructor enforces exitDelaySeconds <= SLASHER_EXECUTION_DELAY. +/// queueSetSlasher schedules the replacement slasher to land after +/// SLASHER_EXECUTION_DELAY. A longer exit delay would leave an objecting validator +/// unable to finish withdrawal before the new slasher takes over, breaking the opt-out +/// window the rotation queue is meant to provide. +contract ConstructorExitDelayTest is Test { + TestERC20 internal token; + GSE internal gse; + GenesisState internal genesisState; + IVerifier internal verifier; + + function setUp() public { + RollupBuilder builder = new RollupBuilder(address(this)); + builder.deploy(); + // Cache the deps locally so each test's expectRevert isn't consumed by intermediate + // getConfig() calls when constructing the Rollup under test. + BuilderConfig memory cfg = builder.getConfig(); + token = cfg.testERC20; + gse = cfg.gse; + genesisState = cfg.genesisState; + verifier = new MockVerifier(); + } + + function _buildDefaultConfig(RollupBuilder _builder) internal view returns (RollupConfigInput memory) { + return _builder.getConfig().rollupConfigInput; + } + + function test_revertsWhenExitDelayAboveSlasherDelay(uint256 _excess) external { + RollupBuilder builder = new RollupBuilder(address(this)); + builder.deploy(); + + uint256 excess = bound(_excess, 1, 365 days); + uint256 badDelay = StakingLib.SLASHER_EXECUTION_DELAY + excess; + + RollupConfigInput memory config = builder.getConfig().rollupConfigInput; + config.exitDelaySeconds = badDelay; + + // Constructor is the call right after expectRevert; no intermediate getConfig() reads. + vm.expectRevert( + abi.encodeWithSelector( + Errors.Staking__ExitDelayAboveSlasherDelay.selector, badDelay, StakingLib.SLASHER_EXECUTION_DELAY + ) + ); + new Rollup(token, token, gse, verifier, address(this), genesisState, config); + } + + function test_succeedsAtSlasherDelayBoundary() external { + RollupBuilder builder = new RollupBuilder(address(this)); + builder.deploy(); + + RollupConfigInput memory config = builder.getConfig().rollupConfigInput; + config.exitDelaySeconds = StakingLib.SLASHER_EXECUTION_DELAY; + + Rollup rollup = new Rollup(token, token, gse, verifier, address(this), genesisState, config); + assertTrue(address(rollup) != address(0), "constructor must accept exitDelay == slasherDelay"); + } +} diff --git a/l1-contracts/test/rollup/libraries/feelib/FeeLibWrapper.sol b/l1-contracts/test/rollup/libraries/feelib/FeeLibWrapper.sol index 2246fe1b3d8f..646fb43c154a 100644 --- a/l1-contracts/test/rollup/libraries/feelib/FeeLibWrapper.sol +++ b/l1-contracts/test/rollup/libraries/feelib/FeeLibWrapper.sol @@ -15,6 +15,12 @@ contract FeeLibWrapper { FeeLib.initialize(_manaTarget, EthValue.wrap(100), _initialEthPerFeeAsset); } + function initialize(uint256 _manaTarget, EthValue _provingCostPerMana, EthPerFeeAssetE12 _initialEthPerFeeAsset) + external + { + FeeLib.initialize(_manaTarget, _provingCostPerMana, _initialEthPerFeeAsset); + } + function updateManaTarget(uint256 _manaTarget) external { FeeLib.updateManaTarget(_manaTarget); } diff --git a/l1-contracts/test/rollup/libraries/feelib/initialize.t.sol b/l1-contracts/test/rollup/libraries/feelib/initialize.t.sol index f52344440a87..6e98b69a1a19 100644 --- a/l1-contracts/test/rollup/libraries/feelib/initialize.t.sol +++ b/l1-contracts/test/rollup/libraries/feelib/initialize.t.sol @@ -5,6 +5,7 @@ pragma solidity >=0.8.27; import { MAGIC_CONGESTION_VALUE_MULTIPLIER, MAGIC_CONGESTION_VALUE_DIVISOR, + MAX_INITIAL_PROVING_COST_PER_MANA, EthValue, EthPerFeeAssetE12 } from "@aztec/core/libraries/rollup/FeeLib.sol"; @@ -31,6 +32,45 @@ contract InitializeTest is TestBase { feeLibWrapper.initialize(0, TestConstants.AZTEC_INITIAL_ETH_PER_FEE_ASSET); } + function test_WhenProvingCostBelowFloor(uint256 _provingCost) external { + // it reverts with {FeeLib__ProvingCostBelowFloor} + // Without enforcement here, a deploy with provingCost < 2 would permanently freeze the + // rate limiter (the step-cap algebra in updateProvingCostPerMana requires current >= 2). + uint256 provingCost = bound(_provingCost, 0, 1); + + vm.expectRevert(abi.encodeWithSelector(Errors.FeeLib__ProvingCostBelowFloor.selector, provingCost, 2)); + feeLibWrapper.initialize(1, EthValue.wrap(provingCost), TestConstants.AZTEC_INITIAL_ETH_PER_FEE_ASSET); + } + + function test_WhenProvingCostAtFloor() external { + // it initializes successfully at the floor + feeLibWrapper.initialize(1, EthValue.wrap(2), TestConstants.AZTEC_INITIAL_ETH_PER_FEE_ASSET); + assertEq(EthValue.unwrap(feeLibWrapper.getConfig().provingCostPerMana), 2); + } + + function test_WhenProvingCostAboveCeiling(uint256 _provingCost) external { + // it reverts with {FeeLib__ProvingCostAboveCeiling} + // The uint64 storage cap inside compress() is not a safe economic bound: a deploy near + // uint64.max would need many years of (3/2)-per-cooldown corrections before the value + // returned to a normal operating range. + uint256 provingCost = bound(_provingCost, MAX_INITIAL_PROVING_COST_PER_MANA + 1, type(uint256).max); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.FeeLib__ProvingCostAboveCeiling.selector, provingCost, MAX_INITIAL_PROVING_COST_PER_MANA + ) + ); + feeLibWrapper.initialize(1, EthValue.wrap(provingCost), TestConstants.AZTEC_INITIAL_ETH_PER_FEE_ASSET); + } + + function test_WhenProvingCostAtCeiling() external { + // it initializes successfully at the ceiling + feeLibWrapper.initialize( + 1, EthValue.wrap(MAX_INITIAL_PROVING_COST_PER_MANA), TestConstants.AZTEC_INITIAL_ETH_PER_FEE_ASSET + ); + assertEq(EthValue.unwrap(feeLibWrapper.getConfig().provingCostPerMana), MAX_INITIAL_PROVING_COST_PER_MANA); + } + function test_WhenManaLimitGTUint32(uint256 _manaTarget) external { // it reverts with {FeeLib__InvalidManaLimit} diff --git a/l1-contracts/test/rollup/libraries/feelib/initialize.tree b/l1-contracts/test/rollup/libraries/feelib/initialize.tree index aedfa362d40f..c3df0e49bbd2 100644 --- a/l1-contracts/test/rollup/libraries/feelib/initialize.tree +++ b/l1-contracts/test/rollup/libraries/feelib/initialize.tree @@ -3,6 +3,10 @@ InitializeTest │ └── it reverts with {FeeLib__InvalidManaTarget} ├── when mana limit GT uint32 │ └── it reverts with {FeeLib__InvalidManaLimit} +├── when proving cost below floor +│ └── it reverts with {FeeLib__ProvingCostBelowFloor} +├── when proving cost at floor +│ └── it initializes successfully └── when mana limit LE uint32 ├── it store the config └── it store the l1 gas oracle values diff --git a/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol b/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol index 090547c65027..2e478b0aa2d8 100644 --- a/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol +++ b/l1-contracts/test/rollup/libraries/rewardlib/RewardLibWrapper.sol @@ -33,20 +33,28 @@ contract FakeFeePortal { } contract FakeRewardDistributor { - address public canonicalRollup; + address internal _canonicalRollup; IERC20 public feeAsset; constructor(IERC20 _feeAsset) { - canonicalRollup = msg.sender; + _canonicalRollup = msg.sender; feeAsset = _feeAsset; } + function canonicalRollup() external view returns (address) { + return _canonicalRollup; + } + + function availableTo(address _rollup) external view returns (uint256) { + return _rollup == _canonicalRollup ? feeAsset.balanceOf(address(this)) : 0; + } + function claim(address _to, uint256 _amount) external { feeAsset.transfer(_to, _amount); } function nuke() external { - canonicalRollup = address(0); + _canonicalRollup = address(0); } } @@ -73,7 +81,7 @@ contract RewardLibWrapper { checkpointReward: _checkpointReward }); - RewardLib.setConfig(config); + RewardLib.initializeConfig(config); RollupStore storage rollupStore = STFLib.getStorage(); rollupStore.config.feeAsset = _feeAsset; diff --git a/l1-contracts/test/slashing/SlashingProposerEscapeHatch.t.sol b/l1-contracts/test/slashing/SlashingProposerEscapeHatch.t.sol index 8152272347b8..4768a2ad777c 100644 --- a/l1-contracts/test/slashing/SlashingProposerEscapeHatch.t.sol +++ b/l1-contracts/test/slashing/SlashingProposerEscapeHatch.t.sol @@ -122,7 +122,7 @@ contract SlashingProposerEscapeHatchTest is TestBase { // Point rollup/validator selection to the escape hatch address rollupOwner = rollup.owner(); vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(escapeHatch)); + rollup.setEscapeHatch(address(escapeHatch)); } function test_tallyEscapeHatch_open() public { diff --git a/l1-contracts/test/slashing/SlashingProposerRetroactive.t.sol b/l1-contracts/test/slashing/SlashingProposerRetroactive.t.sol deleted file mode 100644 index 67904620700c..000000000000 --- a/l1-contracts/test/slashing/SlashingProposerRetroactive.t.sol +++ /dev/null @@ -1,102 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2025 Aztec Labs. -pragma solidity >=0.8.27; - -import {SlashingProposerEscapeHatchTest} from "./SlashingProposerEscapeHatch.t.sol"; -import {SlashingProposer} from "@aztec/core/slashing/SlashingProposer.sol"; -import {SlashRound} from "@aztec/core/libraries/SlashRoundLib.sol"; -import {Epoch} from "@aztec/core/libraries/TimeLib.sol"; -import {stdStorage, StdStorage} from "forge-std/Test.sol"; - -/** - * @title SlashingProposerRetroactiveTest - * @notice Tests that retroactively configuring an escape hatch should NOT grant slashing - * immunity for epochs that were normal at the time. - * - * @dev Inherits from SlashingProposerEscapeHatchTest to reuse all setup and helpers. - * setUp deploys the escape hatch and registers it. The test immediately removes it, - * then re-registers it later to simulate retroactive configuration. - * - * CURRENTLY FAILS because _getEscapeHatchEpochFlags queries the CURRENT escape hatch - * (which retroactively reports historical epochs as open), granting unearned immunity. - * - * After epoch-stable snapshotting, getEscapeHatchForEpoch returns address(0) for - * historical epochs where no escape hatch was configured, so no immunity is granted. - */ -contract SlashingProposerRetroactiveTest is SlashingProposerEscapeHatchTest { - using stdStorage for StdStorage; - - /** - * @notice Validators should remain slashable when escape hatch is configured after the fact - * - * @dev Scenario: - * 1. Escape hatch is removed (simulating "no escape hatch during target epochs") - * 2. Votes are cast to slash all validators - * 3. Governance re-configures the escape hatch that covers the target epochs - * 4. Tally is computed - * - * DESIRED: All 8 validators slashable (2 epochs x 4 committee members). - * No immunity because no escape hatch was active during those epochs. - */ - function test_retroactiveEscapeHatchDoesNotGrantSlashingImmunity() public { - // Remove escape hatch so the rollup has none during target epochs. - // The escapeHatch contract itself is preserved for re-use below. - address rollupOwner = rollup.owner(); - vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(0)); - - // Step 1: Find a round where at least one target epoch falls in the escape hatch - // active window (epoch % ESCAPE_FREQUENCY < ESCAPE_ACTIVE_DURATION) - // so that retroactive deployment would grant immunity - uint256 targetRound = SLASH_OFFSET_IN_ROUNDS; - bool foundProtectedEpoch = false; - - while (!foundProtectedEpoch) { - for (uint256 i; i < ROUND_SIZE_IN_EPOCHS; i++) { - Epoch epoch = slashingProposer.getSlashTargetEpoch(SlashRound.wrap(targetRound), i); - if (Epoch.unwrap(epoch) % ESCAPE_FREQUENCY < ESCAPE_ACTIVE_DURATION) { - foundProtectedEpoch = true; - break; - } - } - if (!foundProtectedEpoch) { - ++targetRound; - } - } - - _jumpToSlashRound(targetRound); - SlashRound currentRound = slashingProposer.getCurrentRound(); - - // Step 2: Cast votes to slash all validators (no escape hatch active) - uint8 slashIndex = 3; - bytes memory voteData = _createUniformVoteData(slashIndex); - _castVotes(QUORUM, voteData); - - // Step 3: Re-configure the escape hatch AFTER the target epochs - vm.prank(rollupOwner); - rollup.updateEscapeHatch(address(escapeHatch)); - - // Set designated proposers for hatches covering target epochs - for (uint256 i; i < ROUND_SIZE_IN_EPOCHS; i++) { - Epoch epoch = slashingProposer.getSlashTargetEpoch(currentRound, i); - if (Epoch.unwrap(epoch) % ESCAPE_FREQUENCY < ESCAPE_ACTIVE_DURATION) { - uint256 hatchNumber = Epoch.unwrap(epoch) / ESCAPE_FREQUENCY; - stdstore.target(address(escapeHatch)).sig("getDesignatedProposer(uint256)").with_key(hatchNumber) - .checked_write(address(0xBEEF)); - } - } - - // Step 4: Get tally results - address[][] memory committees = slashingProposer.getSlashTargetCommittees(currentRound); - SlashingProposer.SlashAction[] memory actions = slashingProposer.getTally(currentRound, committees); - - // DESIRED: All validators should be slashable (no escape hatch was active during target epochs) - // CURRENT: Some validators have retroactive immunity - actions.length < expected - uint256 totalValidators = ROUND_SIZE_IN_EPOCHS * COMMITTEE_SIZE; - assertEq( - actions.length, - totalValidators, - "All validators should be slashable - no escape hatch was active during target epochs" - ); - } -} diff --git a/l1-contracts/test/staking/flushEntryQueue.t.sol b/l1-contracts/test/staking/flushEntryQueue.t.sol index 359503d6e912..48ec795a18f1 100644 --- a/l1-contracts/test/staking/flushEntryQueue.t.sol +++ b/l1-contracts/test/staking/flushEntryQueue.t.sol @@ -45,7 +45,7 @@ contract FlushEntryQueueTest is StakingBase { StakingQueueConfig memory stakingQueueConfig = StakingQueueConfig({ bootstrapValidatorSetSize: bound(_bootstrapValidatorSetSize, 0, type(uint32).max), bootstrapFlushSize: bound(_bootstrapFlushSize, 0, type(uint32).max), - normalFlushSizeMin: bound(_normalFlushSizeMin, 0, type(uint32).max), + normalFlushSizeMin: bound(_normalFlushSizeMin, 1, type(uint32).max), normalFlushSizeQuotient: bound(_normalFlushSizeQuotient, 1, type(uint32).max), maxQueueFlushSize: MAX_QUEUE_FLUSH_SIZE }); @@ -104,7 +104,8 @@ contract FlushEntryQueueTest is StakingBase { _bootstrapValidatorSetSize = bound(_bootstrapValidatorSetSize, 1, 500); _numValidators = bound(_numValidators, _bootstrapValidatorSetSize, _bootstrapValidatorSetSize * 2); - _bootstrapFlushSize = bound(_bootstrapFlushSize, 1, _bootstrapValidatorSetSize * 2); + // bootstrapFlushSize must stay <= maxQueueFlushSize so assertValidQueueConfig accepts it. + _bootstrapFlushSize = bound(_bootstrapFlushSize, 1, MAX_QUEUE_FLUSH_SIZE); uint256 effectiveFlushSize = _bootstrapFlushSize; _setupQueueConfig(_bootstrapValidatorSetSize, _bootstrapFlushSize, _normalFlushSizeMin, _normalFlushSizeQuotient); @@ -126,7 +127,8 @@ contract FlushEntryQueueTest is StakingBase { // it refunds the withdrawer if the deposit fails _bootstrapValidatorSetSize = bound(_bootstrapValidatorSetSize, 3, 1000); - _bootstrapFlushSize = bound(_bootstrapFlushSize, 1, _bootstrapValidatorSetSize / 3); + // bootstrapFlushSize must stay <= maxQueueFlushSize so assertValidQueueConfig accepts it. + _bootstrapFlushSize = bound(_bootstrapFlushSize, 1, Math.min(_bootstrapValidatorSetSize / 3, MAX_QUEUE_FLUSH_SIZE)); uint256 effectiveFlushSize = _bootstrapFlushSize; _setupQueueConfig(_bootstrapValidatorSetSize, _bootstrapFlushSize, _normalFlushSizeMin, _normalFlushSizeQuotient); diff --git a/l1-contracts/test/staking/setLocalEjectionThresholdRemoval.t.sol b/l1-contracts/test/staking/setLocalEjectionThresholdRemoval.t.sol new file mode 100644 index 000000000000..65d27c22cff9 --- /dev/null +++ b/l1-contracts/test/staking/setLocalEjectionThresholdRemoval.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {StakingBase} from "./base.t.sol"; + +/** + * @notice The `setLocalEjectionThreshold(uint256)` selector is no longer reachable on the + * deployed rollup. The threshold is still readable via {getLocalEjectionThreshold} but + * can only be set in the rollup constructor -- never mutated after deployment. + */ +contract SetLocalEjectionThresholdRemovalTest is StakingBase { + function test_setLocalEjectionThresholdSelectorIsUnreachable() external { + // The selector was removed from IStakingCore. A low-level call with the old selector hits + // the missing-fallback path and reverts. We prank as the owner because that was the only + // caller that could have exercised it (the function was `onlyOwner`). + bytes4 removedSelector = bytes4(keccak256("setLocalEjectionThreshold(uint256)")); + vm.prank(address(this)); + (bool ok,) = address(staking).call(abi.encodeWithSelector(removedSelector, uint256(1))); + assertFalse(ok, "setLocalEjectionThreshold selector must not be reachable on the rollup"); + } + + function test_localEjectionThresholdRemainsReadable() external view { + staking.getLocalEjectionThreshold(); + } +} diff --git a/l1-contracts/test/staking/setSlasher.t.sol b/l1-contracts/test/staking/setSlasher.t.sol index df31cd65768e..a6dcb9f5d73e 100644 --- a/l1-contracts/test/staking/setSlasher.t.sol +++ b/l1-contracts/test/staking/setSlasher.t.sol @@ -3,28 +3,187 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; -import {IStakingCore, Status, AttesterView, Exit, Timestamp} from "@aztec/core/interfaces/IStaking.sol"; +import {IStakingCore, Timestamp} from "@aztec/core/interfaces/IStaking.sol"; import {Ownable} from "@oz/access/Ownable.sol"; -contract SetslasherTest is StakingBase { - function test_setSlasher_whenNotOwner(address _caller) external { - address owner = Ownable(address(staking)).owner(); +contract SetSlasherTest is StakingBase { + function _owner() internal view returns (address) { + return Ownable(address(staking)).owner(); + } + + function _delay() internal view returns (uint256) { + return staking.getSlasherExecutionDelay(); + } + + /// @dev Make `_slasher` look like a real Slasher whose proposer is already initialized so + /// queueSetSlasher accepts it. Tests that want to exercise the uninitialized-slasher + /// guard call queueSetSlasher directly without mocking. + function _mockInitializedSlasher(address _slasher) internal { + vm.mockCall(_slasher, abi.encodeWithSignature("PROPOSER()"), abi.encode(address(0xBEEF))); + } - vm.assume(_caller != owner); + function test_queueSetSlasher_whenNotOwner(address _caller) external { + vm.assume(_caller != _owner()); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller)); vm.prank(_caller); - staking.setSlasher(address(1)); + staking.queueSetSlasher(address(1)); + } + + function test_cancelSetSlasher_whenNotOwner(address _caller) external { + vm.assume(_caller != _owner()); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller)); + vm.prank(_caller); + staking.cancelSetSlasher(); + } + + function test_finalizeSetSlasher_callableByAnyone(address _caller, address _newSlasher) external { + address oldSlasher = staking.getSlasher(); + + _mockInitializedSlasher(_newSlasher); + vm.prank(_owner()); + staking.queueSetSlasher(_newSlasher); + + vm.warp(block.timestamp + _delay()); + + vm.expectEmit(true, true, true, true); + emit IStakingCore.SlasherUpdated(oldSlasher, _newSlasher); + vm.prank(_caller); + staking.finalizeSetSlasher(); + + assertEq(staking.getSlasher(), _newSlasher, "finalize must be permissionless"); + } + + function test_queueSetSlasher_emitsEventAndRecordsPending(address _newSlasher) external { + uint256 readyAt = block.timestamp + _delay(); + + _mockInitializedSlasher(_newSlasher); + vm.expectEmit(true, true, true, true); + emit IStakingCore.PendingSlasherQueued(_newSlasher, readyAt); + + vm.prank(_owner()); + staking.queueSetSlasher(_newSlasher); + + (address pending, Timestamp pendingReadyAt) = staking.getPendingSlasher(); + assertEq(pending, _newSlasher, "pending slasher mismatch"); + assertEq(Timestamp.unwrap(pendingReadyAt), readyAt, "ready at mismatch"); + assertEq(staking.getSlasher(), SLASHER, "active slasher must not change before finalize"); + } + + function test_queueSetSlasher_overwritesExistingPending(address _first, address _second) external { + vm.assume(_first != _second); + + _mockInitializedSlasher(_first); + _mockInitializedSlasher(_second); + vm.prank(_owner()); + staking.queueSetSlasher(_first); + + vm.warp(block.timestamp + 1 days); + uint256 expectedReadyAt = block.timestamp + _delay(); + + vm.expectEmit(true, true, true, true); + emit IStakingCore.PendingSlasherQueued(_second, expectedReadyAt); + vm.prank(_owner()); + staking.queueSetSlasher(_second); + + (address pending, Timestamp pendingReadyAt) = staking.getPendingSlasher(); + assertEq(pending, _second); + assertEq(Timestamp.unwrap(pendingReadyAt), expectedReadyAt); } - function test_setSlasher(address _newSlasher) external { - address owner = Ownable(address(staking)).owner(); + function test_cancelSetSlasher_clearsPending(address _newSlasher) external { + _mockInitializedSlasher(_newSlasher); + vm.prank(_owner()); + staking.queueSetSlasher(_newSlasher); vm.expectEmit(true, true, true, true); - emit IStakingCore.SlasherUpdated(SLASHER, _newSlasher); + emit IStakingCore.PendingSlasherCancelled(_newSlasher); + vm.prank(_owner()); + staking.cancelSetSlasher(); + + (address pending, Timestamp readyAt) = staking.getPendingSlasher(); + assertEq(pending, address(0)); + assertEq(Timestamp.unwrap(readyAt), 0); + } + + function test_cancelSetSlasher_revertsIfNothingPending() external { + address owner = _owner(); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__NoPendingSlasher.selector)); + vm.prank(owner); + staking.cancelSetSlasher(); + } + + function test_finalizeSetSlasher_revertsIfNothingPending() external { + address owner = _owner(); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__NoPendingSlasher.selector)); + vm.prank(owner); + staking.finalizeSetSlasher(); + } + + function test_finalizeSetSlasher_revertsBeforeReady(address _newSlasher, uint256 _earlyOffset) external { + uint256 delay = _delay(); + uint256 earlyOffset = bound(_earlyOffset, 0, delay - 1); + address owner = _owner(); + + _mockInitializedSlasher(_newSlasher); + vm.prank(owner); + staking.queueSetSlasher(_newSlasher); + uint256 readyAt = block.timestamp + delay; + + vm.warp(block.timestamp + earlyOffset); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__SlasherNotReady.selector, Timestamp.wrap(readyAt))); + vm.prank(owner); + staking.finalizeSetSlasher(); + } + + function test_queueSetSlasher_revertsWhenProposerUnset(address _newSlasher) external { + // PROPOSER returns address(0) -- the uninitialized state. Queueing must reject because + // Slasher.initializeProposer is permissionless and an attacker could claim the proposer + // role during the 60-day delay. + address owner = _owner(); // cache before expectRevert to avoid consuming it + vm.mockCall(_newSlasher, abi.encodeWithSignature("PROPOSER()"), abi.encode(address(0))); vm.prank(owner); - staking.setSlasher(_newSlasher); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__SlasherProposerNotInitialized.selector, _newSlasher)); + staking.queueSetSlasher(_newSlasher); + } + + function test_finalizeSetSlasher_revertsIfProposerWentToZero(address _newSlasher) external { + // Queue with a wired proposer, then drop the proposer to zero just before finalize. The + // defense-in-depth guard in finalizeSetSlasher must catch this and refuse to install the + // replacement slasher. + address owner = _owner(); + uint256 delay = _delay(); + + _mockInitializedSlasher(_newSlasher); + vm.prank(owner); + staking.queueSetSlasher(_newSlasher); + + vm.warp(block.timestamp + delay); + + vm.mockCall(_newSlasher, abi.encodeWithSignature("PROPOSER()"), abi.encode(address(0))); + + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__SlasherProposerNotInitialized.selector, _newSlasher)); + staking.finalizeSetSlasher(); + } + + function test_finalizeSetSlasher_appliesAfterDelay(address _newSlasher) external { + address oldSlasher = staking.getSlasher(); + + _mockInitializedSlasher(_newSlasher); + vm.prank(_owner()); + staking.queueSetSlasher(_newSlasher); + + vm.warp(block.timestamp + _delay()); + + vm.expectEmit(true, true, true, true); + emit IStakingCore.SlasherUpdated(oldSlasher, _newSlasher); + vm.prank(_owner()); + staking.finalizeSetSlasher(); + + assertEq(staking.getSlasher(), _newSlasher, "slasher not applied"); - assertEq(staking.getSlasher(), _newSlasher); + (address pending, Timestamp readyAt) = staking.getPendingSlasher(); + assertEq(pending, address(0), "pending not cleared"); + assertEq(Timestamp.unwrap(readyAt), 0, "readyAt not cleared"); } } diff --git a/l1-contracts/test/staking/slash.t.sol b/l1-contracts/test/staking/slash.t.sol index 0ed01eeaa3f4..826b3a8090cb 100644 --- a/l1-contracts/test/staking/slash.t.sol +++ b/l1-contracts/test/staking/slash.t.sol @@ -2,8 +2,9 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; +import {RollupBuilder} from "../builder/RollupBuilder.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; -import {IStakingCore, Status, AttesterView, Exit, Timestamp} from "@aztec/core/interfaces/IStaking.sol"; +import {IStaking, IStakingCore, Status, AttesterView, Exit, Timestamp} from "@aztec/core/interfaces/IStaking.sol"; import {BN254Lib, G1Point, G2Point} from "@aztec/shared/libraries/BN254Lib.sol"; import {Ownable} from "@oz/access/Ownable.sol"; @@ -153,43 +154,6 @@ contract SlashTest is StakingBase { } } - function test_WhenAttesterIsValidatingAndStakeIsBelowLocalEjectionThreshold(uint256 _localEjectionThreshold) - external - whenCallerIsTheSlasher - whenAttesterIsRegistered - { - // The test picks a value for the local ejection that is LARGER than the global ejection threshold - // This way, a slash that moves us below the local but above the global will show that the local works as expected. - uint256 localEjectionThreshold = bound(_localEjectionThreshold, EJECTION_THRESHOLD + 1, ACTIVATION_THRESHOLD); - - vm.prank(Ownable(address(staking)).owner()); - staking.setLocalEjectionThreshold(localEjectionThreshold); - - AttesterView memory attesterView = staking.getAttesterView(ATTESTER); - uint256 targetBalance = localEjectionThreshold - 1; - - // As we are below the global ejection, it won't kick us. - assertGe(targetBalance, EJECTION_THRESHOLD); - - slashingAmount = attesterView.effectiveBalance - targetBalance; - - assertTrue(attesterView.status == Status.VALIDATING); - uint256 activeAttesterCount = staking.getActiveAttesterCount(); - uint256 balance = attesterView.effectiveBalance; - - vm.expectEmit(true, true, true, true, address(staking)); - emit IStakingCore.Slashed(ATTESTER, slashingAmount); - vm.prank(SLASHER); - staking.slash(ATTESTER, slashingAmount); - - attesterView = staking.getAttesterView(ATTESTER); - assertEq(attesterView.effectiveBalance, 0); - assertEq(attesterView.exit.amount, balance - slashingAmount); - assertTrue(attesterView.status == Status.ZOMBIE); - - assertEq(staking.getActiveAttesterCount(), activeAttesterCount - 1); - } - modifier whenAttesterIsValidatingAndStakeIsBelowEjectionThreshold() { AttesterView memory attesterView = staking.getAttesterView(ATTESTER); uint256 targetBalance = EJECTION_THRESHOLD - 1; @@ -288,3 +252,74 @@ contract SlashTest is StakingBase { assertTrue(attesterView.status == Status.NONE, "Status should be NONE"); } } + +/** + * @notice Exercises the local-ejection-threshold path. The threshold is baked in at rollup + * construction (there is no live setter), so this contract deploys its own rollup + * with a non-zero threshold rather than extending {SlashTest}'s default-zero setup. + */ +contract SlashLocalEjectionTest is StakingBase { + // Pick a threshold strictly between the global ejection threshold (50e18) and + // the activation threshold (100e18) so a slash that lands between them ejects + // locally but would not eject globally. + uint256 internal constant LOCAL_EJECTION_THRESHOLD = 75e18; + + function setUp() public override { + RollupBuilder builder = new RollupBuilder(address(this)).setSlashingQuorum(1).setSlashingRoundSize(1) + .setLocalEjectionThreshold(LOCAL_EJECTION_THRESHOLD); + builder.deploy(); + + registry = builder.getConfig().registry; + EPOCH_DURATION_SECONDS = builder.getConfig().rollupConfigInput.aztecEpochDuration + * builder.getConfig().rollupConfigInput.aztecSlotDuration; + + staking = IStaking(address(builder.getConfig().rollup)); + stakingAsset = builder.getConfig().testERC20; + + ACTIVATION_THRESHOLD = staking.getActivationThreshold(); + EJECTION_THRESHOLD = staking.getEjectionThreshold(); + SLASHER = staking.getSlasher(); + } + + function test_localEjectionThresholdIsApplied() external { + assertEq(staking.getLocalEjectionThreshold(), LOCAL_EJECTION_THRESHOLD); + assertGt(LOCAL_EJECTION_THRESHOLD, EJECTION_THRESHOLD, "threshold must exceed global ejection"); + assertLe(LOCAL_EJECTION_THRESHOLD, ACTIVATION_THRESHOLD, "threshold must fit in activation"); + } + + function test_WhenAttesterIsValidatingAndStakeIsBelowLocalEjectionThreshold() external { + mint(address(this), ACTIVATION_THRESHOLD); + stakingAsset.approve(address(staking), ACTIVATION_THRESHOLD); + staking.deposit({ + _attester: ATTESTER, + _withdrawer: WITHDRAWER, + _publicKeyInG1: BN254Lib.g1Zero(), + _publicKeyInG2: BN254Lib.g2Zero(), + _proofOfPossession: BN254Lib.g1Zero(), + _moveWithLatestRollup: true + }); + staking.flushEntryQueue(); + + AttesterView memory attesterView = staking.getAttesterView(ATTESTER); + uint256 targetBalance = LOCAL_EJECTION_THRESHOLD - 1; + assertGe(targetBalance, EJECTION_THRESHOLD, "target above global ejection"); + + uint256 slashingAmount = attesterView.effectiveBalance - targetBalance; + uint256 balance = attesterView.effectiveBalance; + uint256 activeAttesterCount = staking.getActiveAttesterCount(); + + assertTrue(attesterView.status == Status.VALIDATING); + + vm.expectEmit(true, true, true, true, address(staking)); + emit IStakingCore.Slashed(ATTESTER, slashingAmount); + vm.prank(SLASHER); + staking.slash(ATTESTER, slashingAmount); + + attesterView = staking.getAttesterView(ATTESTER); + assertEq(attesterView.effectiveBalance, 0); + assertEq(attesterView.exit.amount, balance - slashingAmount); + assertTrue(attesterView.status == Status.ZOMBIE); + + assertEq(staking.getActiveAttesterCount(), activeAttesterCount - 1); + } +} diff --git a/l1-contracts/test/staking/slashLegacy.t.sol b/l1-contracts/test/staking/slashLegacy.t.sol new file mode 100644 index 000000000000..f252d929a9cb --- /dev/null +++ b/l1-contracts/test/staking/slashLegacy.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable func-name-mixedcase +// solhint-disable comprehensive-interface +pragma solidity >=0.8.27; + +import {StakingBase} from "./base.t.sol"; +import {RollupBuilder} from "../builder/RollupBuilder.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {IStakingCore, IStaking, Timestamp} from "@aztec/core/interfaces/IStaking.sol"; +import {RollupConfigInput} from "@aztec/core/interfaces/IRollup.sol"; +import {BN254Lib} from "@aztec/shared/libraries/BN254Lib.sol"; +import {Ownable} from "@oz/access/Ownable.sol"; + +/// @notice Covers the legacy-slasher drain window introduced to keep already-quorumed slashing +/// rounds executable across a slasher rotation. The window must: +/// - install on finalize and emit `LegacySlasherAuthorized`, +/// - accept slash calls from the outgoing slasher while open, +/// - reject them once the window has elapsed. +contract SlashLegacyTest is StakingBase { + /// @dev StakingBase deploys with slasher disabled (default config). Override to enable + /// slashing so the rollup actually has a non-zero active slasher to rotate off. + function setUp() public override { + // SlashingProposer requires `slashingRoundSize % epochDuration == 0` and quorum > roundSize/2. + // Default epoch duration is 32, so round 32 and quorum 17 satisfy both constraints. + RollupBuilder builder = + new RollupBuilder(address(this)).setSlashingQuorum(17).setSlashingRoundSize(32).setSlasherEnabled(true); + builder.deploy(); + + registry = builder.getConfig().registry; + RollupConfigInput memory rollupConfig = builder.getConfig().rollupConfigInput; + EPOCH_DURATION_SECONDS = rollupConfig.aztecEpochDuration * rollupConfig.aztecSlotDuration; + + staking = IStaking(address(builder.getConfig().rollup)); + stakingAsset = builder.getConfig().testERC20; + + ACTIVATION_THRESHOLD = staking.getActivationThreshold(); + EJECTION_THRESHOLD = staking.getEjectionThreshold(); + SLASHER = staking.getSlasher(); + require(SLASHER != address(0), "slasher must be enabled for these tests"); + } + address internal constant NEW_SLASHER = address(uint160(uint256(keccak256("new-slasher")))); + + function _owner() internal view returns (address) { + return Ownable(address(staking)).owner(); + } + + function _depositActive() internal { + mint(address(this), ACTIVATION_THRESHOLD); + stakingAsset.approve(address(staking), ACTIVATION_THRESHOLD); + staking.deposit({ + _attester: ATTESTER, + _withdrawer: WITHDRAWER, + _publicKeyInG1: BN254Lib.g1Zero(), + _publicKeyInG2: BN254Lib.g2Zero(), + _proofOfPossession: BN254Lib.g1Zero(), + _moveWithLatestRollup: true + }); + staking.flushEntryQueue(); + } + + function _finalizeNewSlasher() internal { + address owner = _owner(); + uint256 delay = staking.getSlasherExecutionDelay(); + + // Mock NEW_SLASHER so it satisfies both queueSetSlasher's and finalize's PROPOSER check. + vm.mockCall(NEW_SLASHER, abi.encodeWithSignature("PROPOSER()"), abi.encode(address(0xBEEF))); + + vm.prank(owner); + staking.queueSetSlasher(NEW_SLASHER); + vm.warp(block.timestamp + delay); + staking.finalizeSetSlasher(); + } + + function test_finalize_emitsLegacySlasherAuthorizedAndPopulatesGetter() external { + address oldSlasher = staking.getSlasher(); + address owner = _owner(); + uint256 delay = staking.getSlasherExecutionDelay(); + uint256 window = staking.getLegacySlasherDrainWindow(); + + vm.mockCall(NEW_SLASHER, abi.encodeWithSignature("PROPOSER()"), abi.encode(address(0xBEEF))); + vm.prank(owner); + staking.queueSetSlasher(NEW_SLASHER); + vm.warp(block.timestamp + delay); + + uint256 expectedUntil = block.timestamp + window; + vm.expectEmit(true, true, true, true, address(staking)); + emit IStakingCore.LegacySlasherAuthorized(oldSlasher, expectedUntil); + staking.finalizeSetSlasher(); + + assertEq(staking.getSlasher(), NEW_SLASHER, "active slasher rotated"); + (address legacy, Timestamp authorizedUntil) = staking.getLegacySlasher(); + assertEq(legacy, oldSlasher, "legacy slot must hold the outgoing slasher"); + assertEq(Timestamp.unwrap(authorizedUntil), expectedUntil, "legacy authorizedUntil mismatch"); + } + + function test_legacySlasher_canStillSlashInsideWindow() external { + address oldSlasher = staking.getSlasher(); + _depositActive(); + + _finalizeNewSlasher(); + + // Still inside the drain window: the old slasher must continue to be authorized so an + // already-quorumed slashing round can settle even though the active slasher moved. + vm.expectEmit(true, true, true, true, address(staking)); + emit IStakingCore.Slashed(ATTESTER, 1); + vm.prank(oldSlasher); + staking.slash(ATTESTER, 1); + } + + function test_legacySlasher_revertsAfterWindow() external { + address oldSlasher = staking.getSlasher(); + _depositActive(); + + _finalizeNewSlasher(); + + (, Timestamp authorizedUntil) = staking.getLegacySlasher(); + vm.warp(Timestamp.unwrap(authorizedUntil) + 1); + + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__NotSlasher.selector, NEW_SLASHER, oldSlasher)); + vm.prank(oldSlasher); + staking.slash(ATTESTER, 1); + } + + function test_legacySlasher_atWindowBoundaryStillAuthorized() external { + address oldSlasher = staking.getSlasher(); + _depositActive(); + + _finalizeNewSlasher(); + + (, Timestamp authorizedUntil) = staking.getLegacySlasher(); + vm.warp(Timestamp.unwrap(authorizedUntil)); + + // Inclusive boundary: equality must still authorize. + vm.prank(oldSlasher); + staking.slash(ATTESTER, 1); + } + + function test_activeSlasher_canSlashEvenWhileLegacyOpen() external { + _depositActive(); + _finalizeNewSlasher(); + + // The new (active) slasher's path stays unaffected by the legacy slot. + vm.expectEmit(true, true, true, true, address(staking)); + emit IStakingCore.Slashed(ATTESTER, 1); + vm.prank(NEW_SLASHER); + staking.slash(ATTESTER, 1); + } +} diff --git a/l1-contracts/test/staking/tmnt333.t.sol b/l1-contracts/test/staking/tmnt333.t.sol index 724e80c7e7c4..76ffed4fc816 100644 --- a/l1-contracts/test/staking/tmnt333.t.sol +++ b/l1-contracts/test/staking/tmnt333.t.sol @@ -43,7 +43,7 @@ contract Tmnt333Test is StakingBase { bootstrapFlushSize: 125, normalFlushSizeMin: 1, normalFlushSizeQuotient: 2048, - maxQueueFlushSize: 8 + maxQueueFlushSize: 125 }); Rollup rollup = Rollup(address(registry.getCanonicalRollup())); vm.prank(rollup.owner()); diff --git a/l1-contracts/test/staking/updateStakingQueueConfig.t.sol b/l1-contracts/test/staking/updateStakingQueueConfig.t.sol index d3ba64204ee7..612f203ccb56 100644 --- a/l1-contracts/test/staking/updateStakingQueueConfig.t.sol +++ b/l1-contracts/test/staking/updateStakingQueueConfig.t.sol @@ -8,6 +8,7 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; import {Rollup} from "@aztec/core/Rollup.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; import {IStakingCore} from "@aztec/core/interfaces/IStaking.sol"; import {Ownable} from "@oz/access/Ownable.sol"; @@ -31,12 +32,15 @@ contract UpdateStakingQueueConfigTest is StakingBase { // it updates the staking queue config // it emits a {StakingQueueConfigUpdated} event - // Update the config to have sane values that can be compressed + // Update the config to have sane values that can be compressed. All flush-size invariants + // checked by assertValidQueueConfig must hold here. + _config.maxQueueFlushSize = bound(_config.maxQueueFlushSize, 1, type(uint32).max); _config.bootstrapValidatorSetSize = bound(_config.bootstrapValidatorSetSize, 0, type(uint32).max); - _config.bootstrapFlushSize = bound(_config.bootstrapFlushSize, 0, type(uint32).max); - _config.normalFlushSizeMin = bound(_config.normalFlushSizeMin, 0, type(uint32).max); - _config.normalFlushSizeQuotient = bound(_config.normalFlushSizeQuotient, 0, type(uint32).max); - _config.maxQueueFlushSize = bound(_config.maxQueueFlushSize, 0, type(uint32).max); + // bootstrapFlushSize must be > 0 when bootstrap is active, and never exceed maxQueueFlushSize. + uint256 lower = _config.bootstrapValidatorSetSize == 0 ? 0 : 1; + _config.bootstrapFlushSize = bound(_config.bootstrapFlushSize, lower, _config.maxQueueFlushSize); + _config.normalFlushSizeMin = bound(_config.normalFlushSizeMin, 1, type(uint32).max); + _config.normalFlushSizeQuotient = bound(_config.normalFlushSizeQuotient, 1, type(uint32).max); Rollup rollup = Rollup(address(registry.getCanonicalRollup())); vm.prank(rollup.owner()); @@ -44,4 +48,93 @@ contract UpdateStakingQueueConfigTest is StakingBase { emit IStakingCore.StakingQueueConfigUpdated(_config); staking.updateStakingQueueConfig(_config); } + + function test_RevertsWhenFlushSizeMinIsZero(StakingQueueConfig memory _config) external givenCallerIsTheRollupOwner { + _config.normalFlushSizeMin = 0; + _config.normalFlushSizeQuotient = bound(_config.normalFlushSizeQuotient, 1, type(uint32).max); + + Rollup rollup = Rollup(address(registry.getCanonicalRollup())); + address owner = rollup.owner(); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__InvalidStakingQueueConfig.selector)); + vm.prank(owner); + staking.updateStakingQueueConfig(_config); + } + + function test_RevertsWhenFlushSizeQuotientIsZero(StakingQueueConfig memory _config) + external + givenCallerIsTheRollupOwner + { + _config.normalFlushSizeMin = bound(_config.normalFlushSizeMin, 1, type(uint32).max); + _config.normalFlushSizeQuotient = 0; + + Rollup rollup = Rollup(address(registry.getCanonicalRollup())); + address owner = rollup.owner(); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__InvalidNormalFlushSizeQuotient.selector)); + vm.prank(owner); + staking.updateStakingQueueConfig(_config); + } + + function test_RevertsWhenMaxQueueFlushSizeIsZero(StakingQueueConfig memory _config) + external + givenCallerIsTheRollupOwner + { + // A zero maxQueueFlushSize would trap queued validator stake in the normal phase: the + // Math.min(..., 0) clamp inside getEntryQueueFlushSize would pin every flush at zero. + _config.normalFlushSizeMin = bound(_config.normalFlushSizeMin, 1, type(uint32).max); + _config.normalFlushSizeQuotient = bound(_config.normalFlushSizeQuotient, 1, type(uint32).max); + _config.maxQueueFlushSize = 0; + _config.bootstrapValidatorSetSize = 0; + _config.bootstrapFlushSize = 0; + + Rollup rollup = Rollup(address(registry.getCanonicalRollup())); + address owner = rollup.owner(); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__InvalidMaxQueueFlushSize.selector)); + vm.prank(owner); + staking.updateStakingQueueConfig(_config); + } + + function test_RevertsWhenBootstrapFlushSizeIsZeroWithBootstrapMode(StakingQueueConfig memory _config) + external + givenCallerIsTheRollupOwner + { + // A zero bootstrap flush size traps queued validators during bootstrap growth: the + // bootstrap branch in getEntryQueueFlushSize returns bootstrapFlushSize directly. + _config.normalFlushSizeMin = bound(_config.normalFlushSizeMin, 1, type(uint32).max); + _config.normalFlushSizeQuotient = bound(_config.normalFlushSizeQuotient, 1, type(uint32).max); + _config.maxQueueFlushSize = bound(_config.maxQueueFlushSize, 1, type(uint32).max); + _config.bootstrapValidatorSetSize = bound(_config.bootstrapValidatorSetSize, 1, type(uint32).max); + _config.bootstrapFlushSize = 0; + + Rollup rollup = Rollup(address(registry.getCanonicalRollup())); + address owner = rollup.owner(); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__InvalidBootstrapFlushSize.selector)); + vm.prank(owner); + staking.updateStakingQueueConfig(_config); + } + + function test_RevertsWhenBootstrapFlushSizeExceedsMaxQueueFlushSize(uint256 _max, uint256 _bootstrap) + external + givenCallerIsTheRollupOwner + { + // Without this guard the bootstrap branch in getEntryQueueFlushSize would return a value + // above the cap that the docs claim binds every phase. + uint256 maxQueueFlushSize = bound(_max, 1, type(uint32).max - 1); + uint256 bootstrapFlushSize = bound(_bootstrap, maxQueueFlushSize + 1, type(uint32).max); + + StakingQueueConfig memory config = StakingQueueConfig({ + bootstrapValidatorSetSize: 1, + bootstrapFlushSize: bootstrapFlushSize, + normalFlushSizeMin: 1, + normalFlushSizeQuotient: 1, + maxQueueFlushSize: maxQueueFlushSize + }); + + Rollup rollup = Rollup(address(registry.getCanonicalRollup())); + address owner = rollup.owner(); + vm.expectRevert( + abi.encodeWithSelector(Errors.Staking__BootstrapFlushSizeAboveMax.selector, bootstrapFlushSize, maxQueueFlushSize) + ); + vm.prank(owner); + staking.updateStakingQueueConfig(config); + } } diff --git a/yarn-project/aztec.js/src/ethereum/portal_manager.ts b/yarn-project/aztec.js/src/ethereum/portal_manager.ts index 3b8975b35e5a..5b913a6d2b4e 100644 --- a/yarn-project/aztec.js/src/ethereum/portal_manager.ts +++ b/yarn-project/aztec.js/src/ethereum/portal_manager.ts @@ -412,6 +412,7 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { * @param amount - Amount to withdraw. * @param recipient - Who will receive the funds. * @param epochNumber - Epoch number of the message. + * @param numCheckpointsInEpoch - The partial-proof depth (1-indexed) the witness was built against. * @param messageIndex - Index of the message. * @param siblingPath - Sibling path of the message. */ @@ -419,11 +420,12 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { amount: bigint, recipient: EthAddress, epochNumber: EpochNumber, + numCheckpointsInEpoch: number, messageIndex: bigint, siblingPath: SiblingPath, ) { this.logger.info( - `Sending L1 tx to consume message at epoch ${epochNumber} index ${messageIndex} to withdraw ${amount}`, + `Sending L1 tx to consume message at epoch ${epochNumber} numCheckpointsInEpoch ${numCheckpointsInEpoch} index ${messageIndex} to withdraw ${amount}`, ); const messageLeafId = getL2ToL1MessageLeafId({ leafIndex: messageIndex, siblingPath }); @@ -440,6 +442,7 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { amount, false, BigInt(epochNumber), + BigInt(numCheckpointsInEpoch), messageIndex, siblingPath.toBufferArray().map((buf: Buffer): Hex => `0x${buf.toString('hex')}`), ]); diff --git a/yarn-project/aztec/src/testing/epoch_test_settler.ts b/yarn-project/aztec/src/testing/epoch_test_settler.ts index 37697b74ed29..50b52a1ade32 100644 --- a/yarn-project/aztec/src/testing/epoch_test_settler.ts +++ b/yarn-project/aztec/src/testing/epoch_test_settler.ts @@ -52,7 +52,7 @@ export class EpochTestSettler { const outHash = computeEpochOutHash(messagesInEpoch); if (!outHash.isZero()) { - await this.rollupCheatCodes.insertOutbox(epoch, outHash.toBigInt()); + await this.rollupCheatCodes.insertOutbox(epoch, messagesInEpoch.length, outHash.toBigInt()); } else { this.log.info(`No L2 to L1 messages in epoch ${epoch}`); } diff --git a/yarn-project/end-to-end/src/composed/e2e_token_bridge_tutorial_test.test.ts b/yarn-project/end-to-end/src/composed/e2e_token_bridge_tutorial_test.test.ts index b20a3d70274e..079374151cf6 100644 --- a/yarn-project/end-to-end/src/composed/e2e_token_bridge_tutorial_test.test.ts +++ b/yarn-project/end-to-end/src/composed/e2e_token_bridge_tutorial_test.test.ts @@ -6,6 +6,7 @@ import { Fr } from '@aztec/aztec.js/fields'; import { createLogger } from '@aztec/aztec.js/log'; import { createAztecNodeClient, waitForNode } from '@aztec/aztec.js/node'; import { createExtendedL1Client } from '@aztec/ethereum/client'; +import { OutboxContract } from '@aztec/ethereum/contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import { FeeAssetHandlerAbi, @@ -196,7 +197,8 @@ describe('e2e_cross_chain_messaging token_bridge_tutorial_test', () => { .simulate({ from: ownerAztecAddress }); logger.info(`New L2 balance of ${ownerAztecAddress} is ${newL2Balance}`); - const result = await computeL2ToL1MembershipWitness(node, l2ToL1Message, l2TxReceipt.txHash); + const outboxContract = new OutboxContract(l1Client, l1ContractAddresses.outboxAddress); + const result = await computeL2ToL1MembershipWitness(node, outboxContract, l2ToL1Message, l2TxReceipt); if (!result) { throw new Error('L2 to L1 message not found'); } @@ -205,6 +207,7 @@ describe('e2e_cross_chain_messaging token_bridge_tutorial_test', () => { withdrawAmount, EthAddress.fromString(ownerEthAddress), result.epochNumber, + result.numCheckpointsInEpoch, result.leafIndex, result.siblingPath, ); diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts index 34b29d165450..f39d728e33f7 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts @@ -290,13 +290,14 @@ describe('e2e_cross_chain_messaging l2_to_l1', () => { async function expectConsumeMessageToSucceed(msg: ReturnType, l2TxHash: TxHash) { const msgLeaf = computeMessageLeaf(msg); - const result = (await computeL2ToL1MembershipWitness(aztecNode, msgLeaf, l2TxHash))!; - const { epochNumber: epoch, ...witness } = result; + const result = (await computeL2ToL1MembershipWitness(aztecNode, outbox, msgLeaf, l2TxHash))!; + const { epochNumber: epoch, numCheckpointsInEpoch, ...witness } = result; const leafId = getL2ToL1MessageLeafId(witness); const txHash = await outbox.consume( msg, epoch, + numCheckpointsInEpoch, witness.leafIndex, witness.siblingPath.toFields().map(f => f.toString()), ); @@ -324,6 +325,7 @@ describe('e2e_cross_chain_messaging l2_to_l1', () => { root: `0x${string}`; messageHash: `0x${string}`; leafId: bigint; + numCheckpointsInEpoch: bigint; }; }; expect(topics.args.epoch).toBe(BigInt(epoch)); @@ -343,6 +345,7 @@ describe('e2e_cross_chain_messaging l2_to_l1', () => { outbox.consume( msg, witness.epochNumber, + witness.numCheckpointsInEpoch, witness.leafIndex, witness.siblingPath.toFields().map(f => f.toString()), ), diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts index 70cf8c1b53eb..3e9425a7be9f 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts @@ -82,13 +82,19 @@ describe('e2e_cross_chain_messaging token_bridge_private', () => { // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. await t.advanceToEpochProven(l2TxReceipt); - const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness(aztecNode, l2ToL1Message, l2TxReceipt.txHash))!; + const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness( + aztecNode, + crossChainTestHarness.outboxContract, + l2ToL1Message, + l2TxReceipt, + ))!; // Check balance before and after exit. expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); await crossChainTestHarness.withdrawFundsFromBridgeOnL1( withdrawAmount, l2ToL1MessageResult.epochNumber, + l2ToL1MessageResult.numCheckpointsInEpoch, l2ToL1MessageResult.leafIndex, l2ToL1MessageResult.siblingPath, ); diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_public.test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_public.test.ts index 23894e168172..a8461cff5463 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_public.test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_public.test.ts @@ -88,13 +88,19 @@ describe('e2e_cross_chain_messaging token_bridge_public', () => { // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. await t.advanceToEpochProven(l2TxReceipt); - const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness(aztecNode, l2ToL1Message, l2TxReceipt.txHash))!; + const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness( + aztecNode, + crossChainTestHarness.outboxContract, + l2ToL1Message, + l2TxReceipt, + ))!; // Check balance before and after exit. expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); await crossChainTestHarness.withdrawFundsFromBridgeOnL1( withdrawAmount, l2ToL1MessageResult.epochNumber, + l2ToL1MessageResult.numCheckpointsInEpoch, l2ToL1MessageResult.leafIndex, l2ToL1MessageResult.siblingPath, ); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_partial_proof_multi_root.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_partial_proof_multi_root.test.ts new file mode 100644 index 000000000000..a1751d51da2c --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_partial_proof_multi_root.test.ts @@ -0,0 +1,339 @@ +import { EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import { EpochTestSettler } from '@aztec/aztec/testing'; +import { MAX_CHECKPOINTS_PER_EPOCH } from '@aztec/constants'; +import { OutboxContract, type ViemL2ToL1Msg } from '@aztec/ethereum/contracts'; +import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; +import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; +import { EpochNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; +import { OutboxAbi } from '@aztec/l1-artifacts'; +import { TestContract } from '@aztec/noir-test-contracts.js/Test'; +import { computeL2ToL1MessageHash } from '@aztec/stdlib/hash'; +import { computeEpochOutHash, computeL2ToL1MembershipWitness, getL2ToL1MessageLeafId } from '@aztec/stdlib/messaging'; +import { type TxReceipt, TxStatus } from '@aztec/stdlib/tx'; + +import { jest } from '@jest/globals'; +import { type Hex, decodeEventLog } from 'viem'; + +import { EpochsTestContext } from './epochs_test.js'; + +jest.setTimeout(1000 * 60 * 10); + +// Since AZIP-14 the Outbox can hold up to MAX_CHECKPOINTS_PER_EPOCH partial-proof roots per epoch, one +// per `numCheckpointsInEpoch` (1-indexed). This test stages three progressively-deeper roots for +// the same epoch by driving the EpochTestSettler after each checkpointed tx, then tests: +// (a) consuming a message uses the smallest covering root the client helper picks, +// (b) the user can consume the same message against any covering root (both K=1 and K=2 cover +// a message in checkpoint 0), and the shared bitmap prevents double-consume across K, and +// (c) a message whose checkpoint is not yet covered by any root yields no witness. +describe('e2e_epochs/epochs_partial_proof_multi_root', () => { + let test: EpochsTestContext; + let logger: Logger; + let node: AztecNode; + let l1Client: ExtendedViemWalletClient; + let l1Contracts: L1ContractAddresses; + let outbox: OutboxContract; + let settler: EpochTestSettler; + + // The L1 EOA that submits the consume() tx. The L2-to-L1 message recipient must equal this + // address. The Outbox enforces `msg.sender == _message.recipient.actor` on consume. + let recipient: EthAddress; + + beforeEach(async () => { + test = await EpochsTestContext.setup({ + numberOfAccounts: 1, + minTxsPerBlock: 1, + // Long epoch so 4 well-spaced checkpoints comfortably fit before the boundary. + aztecEpochDuration: 1000, + // Don't let the real prover land a partial proof under us. We drive Outbox state via the + // settler. `aztecProofSubmissionEpochs` >> test duration makes it impossible to enter the + // submission window. + aztecProofSubmissionEpochs: 1024, + startProverNode: false, + enableProposerPipelining: true, + disableAnvilTestWatcher: true, + }); + ({ logger } = test); + node = test.context.aztecNode; + l1Client = test.context.deployL1ContractsValues.l1Client; + l1Contracts = test.context.deployL1ContractsValues.l1ContractAddresses; + outbox = new OutboxContract(l1Client, l1Contracts.outboxAddress); + recipient = EthAddress.fromString(l1Client.account.address); + + // Construct a standalone EpochTestSettler that we drive by hand: we never call `.start()`, + // just `handleEpochReadyToProve(epoch)` synchronously after each tx is checkpointed. That + // keeps the prover-style polling loop out of the way and lets the test choose exactly when + // each progressive K root lands in the Outbox. + settler = new EpochTestSettler( + test.context.cheatCodes.eth, + l1Contracts.rollupAddress, + test.context.aztecNodeService.getBlockSource(), + logger.createChild('epoch-settler'), + { pollingIntervalMs: 200 }, + ); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await test.teardown(); + }); + + it('stages 3 partial-proof roots and lets messages consume against any covering root', async () => { + const { wallet } = test.context; + const [from] = test.context.accounts; + const version = BigInt(test.context.aztecNodeConfig.rollupVersion); + const chainId = BigInt(l1Client.chain.id); + + // Deploy TestContract. It provides `create_l2_to_l1_message_arbitrary_recipient_private`, + // the minimal way to emit a single L2-to-L1 message per tx. + logger.warn(`Deploying TestContract`); + const { contract } = await TestContract.deploy(wallet).send({ from }); + logger.warn(`Deployed TestContract at ${contract.address}`); + + // Warp past the current epoch so the deploy blocks (and any setup blocks) sit alone in the + // previous epoch, leaving the new epoch with only the 4 L2-to-L1 message txs we are about to + // send. Without this, the deploys would share an epoch with the messages and the per-checkpoint + // assertions below would include the empty deploy checkpoints. + await test.context.cheatCodes.rollup.advanceToNextEpoch(); + + // Make each L2-to-L1 message ABI-shaped for the Outbox consume() call. + const makeMsg = (content: Fr): ViemL2ToL1Msg => ({ + sender: { actor: contract.address.toString() as Hex, version }, + recipient: { actor: recipient.toString() as Hex, chainId }, + content: content.toString() as Hex, + }); + const computeLeaf = (msg: ViemL2ToL1Msg) => + computeL2ToL1MessageHash({ + l2Sender: contract.address, + l1Recipient: EthAddress.fromString(msg.recipient.actor), + content: Fr.fromString(msg.content), + rollupVersion: new Fr(msg.sender.version), + chainId: new Fr(msg.recipient.chainId), + }); + + // Send 4 messages, each landing in a separate checkpoint of the same epoch. We send a tx, + // wait for it to be CHECKPOINTED, then drive the settler to insert a partial-proof root that + // covers the checkpoints seen so far (K=1, then K=2, then K=3). Between sends we advance an + // L2 slot so the next tx lands in a fresh slot, and therefore the next checkpoint (one slot = + // one checkpoint in current Aztec). The 4th tx is sent but intentionally NOT settled, to + // exercise the "no covering root yet" negative case below. + const sends: { msg: ViemL2ToL1Msg; leaf: Fr; receipt: TxReceipt }[] = []; + let epoch: EpochNumber | undefined; + for (let i = 0; i < 4; i++) { + const content = Fr.random(); + const msg = makeMsg(content); + const leaf = computeLeaf(msg); + logger.warn(`Sending L2-to-L1 message ${i} (content=${content.toString()})`); + const { receipt } = await contract.methods + .create_l2_to_l1_message_arbitrary_recipient_private(content, recipient) + .send({ from, wait: { waitForStatus: TxStatus.CHECKPOINTED } }); + logger.warn(`Tx ${i} checkpointed`, { + txHash: receipt.txHash.toString(), + blockNumber: receipt.blockNumber, + epochNumber: receipt.epochNumber, + }); + sends.push({ msg, leaf, receipt }); + + if (epoch === undefined) { + epoch = receipt.epochNumber; + if (epoch === undefined) { + throw new Error('First tx is missing an epochNumber on its receipt'); + } + } + expect(receipt.epochNumber).toBe(epoch); + + // Settle the epoch for the first 3 txs. The settler reads only checkpointed blocks from the + // node, groups them per checkpoint, and inserts the resulting out-hash at + // `numCheckpointsInEpoch = i + 1`. The 4th tx is left unsettled. + if (i < 3) { + logger.warn(`Settling epoch ${epoch} after tx ${i} (expecting K=${i + 1})`); + await settler.handleEpochReadyToProve(epoch); + } + + // Push the next tx into a fresh slot. Skipped after the last one. + if (i < 3) { + await test.context.cheatCodes.rollup.advanceToNextSlot(); + } + } + + if (epoch === undefined) { + throw new Error('No txs were sent'); + } + + // Confirm all 4 txs landed in the same epoch and 4 distinct checkpoints. + const checkpointNumbers: number[] = []; + for (const [i, { receipt }] of sends.entries()) { + expect(receipt.epochNumber).toBe(epoch); + const block = (await node.getBlock(receipt.blockNumber!))!; + checkpointNumbers.push(Number(block.checkpointNumber)); + logger.warn(`Tx ${i} block ${receipt.blockNumber} is in checkpoint ${block.checkpointNumber}`); + } + expect(new Set(checkpointNumbers).size).toBe(4); + // Checkpoints should be monotonically increasing (we advanced slots one-by-one). + for (let i = 1; i < checkpointNumbers.length; i++) { + expect(checkpointNumbers[i]).toBeGreaterThan(checkpointNumbers[i - 1]); + } + + // Pull all messages in the epoch from the node, organized as checkpoint -> block -> tx -> + // message. With 4 distinct checkpoints, each holding a single block with a single tx with a + // single message, this should have shape [[[[m0]]], [[[m1]]], [[[m2]]], [[[m3]]]]. + const messagesPerCheckpoint = await node.getL2ToL1Messages(epoch); + expect(messagesPerCheckpoint.length).toBe(4); + for (let i = 0; i < 4; i++) { + expect(messagesPerCheckpoint[i]).toHaveLength(1); + expect(messagesPerCheckpoint[i][0]).toHaveLength(1); + expect(messagesPerCheckpoint[i][0][0]).toHaveLength(1); + expect(messagesPerCheckpoint[i][0][0][0].toString()).toBe(sends[i].leaf.toString()); + } + + // Recompute the 3 partial-proof roots locally for K in {1, 2, 3} to cross-check the settler. + // This mirrors what the rollup proves on chain: slice the outer (checkpoint) array to the + // first K entries and feed it to `computeEpochOutHash`, which internally zero-pads up to + // OUT_HASH_TREE_LEAF_COUNT. + const root1 = computeEpochOutHash(messagesPerCheckpoint.slice(0, 1)); + const root2 = computeEpochOutHash(messagesPerCheckpoint.slice(0, 2)); + const root3 = computeEpochOutHash(messagesPerCheckpoint.slice(0, 3)); + expect(root1.equals(root2)).toBe(false); + expect(root2.equals(root3)).toBe(false); + + // Sanity-check the Outbox holds exactly the 3 roots the settler inserted, padded with zeros + // beyond. + const onChainRoots = await outbox.getRoots(epoch); + expect(onChainRoots).toHaveLength(MAX_CHECKPOINTS_PER_EPOCH); + expect(onChainRoots[0].toString()).toBe(root1.toString()); + expect(onChainRoots[1].toString()).toBe(root2.toString()); + expect(onChainRoots[2].toString()).toBe(root3.toString()); + for (let i = 3; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + expect(onChainRoots[i].isZero()).toBe(true); + } + + // Consume msg2 against the smallest covering root the helper picks (K=2). + { + const witness = (await computeL2ToL1MembershipWitness(node, outbox, sends[1].leaf, sends[1].receipt))!; + expect(witness).toBeDefined(); + expect(witness.epochNumber).toBe(epoch); + expect(witness.numCheckpointsInEpoch).toBe(2); + expect(witness.root.toString()).toBe(root2.toString()); + + await expectConsumeSucceeds(sends[1].msg, sends[1].leaf, witness, epoch); + } + + // Negative: msg4 lives in checkpoint at index 3, and the largest covering K we inserted is + // K=3 (covers indices 0..2). The helper must return undefined: no covering root yet. + { + const witness = await computeL2ToL1MembershipWitness(node, outbox, sends[3].leaf, sends[3].receipt); + expect(witness).toBeUndefined(); + } + + // "User can pick any covering root" property. We consume msg1 (in checkpoint at index 0) + // against K=1, the default the helper picks. Then we manually build a witness against K=2 + // (by hiding slot 0 of the on-chain roots from the helper so it walks past it) and verify + // the second consume reverts due to the shared bitmap. + { + // K=1 path (default helper choice). + const witnessK1 = (await computeL2ToL1MembershipWitness(node, outbox, sends[0].leaf, sends[0].receipt))!; + expect(witnessK1.numCheckpointsInEpoch).toBe(1); + expect(witnessK1.root.toString()).toBe(root1.toString()); + await expectConsumeSucceeds(sends[0].msg, sends[0].leaf, witnessK1, epoch); + + // K=2 path: feed the helper a roots array with slot 0 zeroed so it picks the next covering + // root (K=2). Both K=1 and K=2 cover checkpoint 0, so this is a legitimate witness, but + // the shared bitmap (indexed by stable leafId) must prevent a second consume. + const rootsWithoutK1: Fr[] = [Fr.ZERO, ...onChainRoots.slice(1)]; + const witnessK2 = (await computeL2ToL1MembershipWitness(node, rootsWithoutK1, sends[0].leaf, sends[0].receipt))!; + expect(witnessK2.numCheckpointsInEpoch).toBe(2); + expect(witnessK2.root.toString()).toBe(root2.toString()); + // The K=2 witness must produce the same leafId as the K=1 witness (leafId is stable). + expect(getL2ToL1MessageLeafId(witnessK2)).toBe(getL2ToL1MessageLeafId(witnessK1)); + + // Trying to consume msg1 again, under either K, reverts. + await expect( + outbox.consume( + sends[0].msg, + witnessK2.epochNumber, + witnessK2.numCheckpointsInEpoch, + witnessK2.leafIndex, + witnessK2.siblingPath.toFields().map(f => f.toString()), + ), + ).rejects.toThrow(); + } + + // Replay protection: consuming msg2 again (now under any covering root) reverts. + { + const witness = (await computeL2ToL1MembershipWitness(node, outbox, sends[1].leaf, sends[1].receipt))!; + await expect( + outbox.consume( + sends[1].msg, + witness.epochNumber, + witness.numCheckpointsInEpoch, + witness.leafIndex, + witness.siblingPath.toFields().map(f => f.toString()), + ), + ).rejects.toThrow(); + } + + // After all the above, slot 3..31 of the Outbox stayed zero (we never staged K=4..32). + const rootsAfter = await outbox.getRoots(epoch); + for (let i = 3; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + expect(rootsAfter[i].isZero()).toBe(true); + } + + // Once we drive the settler one more time, K=4 lands and the previously-unwitnessable msg4 + // becomes consumable. + { + await settler.handleEpochReadyToProve(epoch); + const root4 = computeEpochOutHash(messagesPerCheckpoint.slice(0, 4)); + await retryUntil(async () => !(await outbox.getRoots(epoch))[3].isZero(), 'K=4 root visible', 10, 0.1); + + const witness = (await computeL2ToL1MembershipWitness(node, outbox, sends[3].leaf, sends[3].receipt))!; + expect(witness.numCheckpointsInEpoch).toBe(4); + expect(witness.root.toString()).toBe(root4.toString()); + await expectConsumeSucceeds(sends[3].msg, sends[3].leaf, witness, epoch); + } + }); + + /** + * Submits an Outbox.consume tx and asserts: the L1 tx succeeds, exactly one MessageConsumed log + * is emitted, and the log's epoch/root/messageHash/leafId match the supplied witness. + */ + async function expectConsumeSucceeds( + msg: ViemL2ToL1Msg, + leaf: Fr, + witness: NonNullable>>, + epoch: EpochNumber, + ) { + const txHash = await outbox.consume( + msg, + witness.epochNumber, + witness.numCheckpointsInEpoch, + witness.leafIndex, + witness.siblingPath.toFields().map(f => f.toString()), + ); + const l1Receipt = await l1Client.waitForTransactionReceipt({ hash: txHash }); + expect(l1Receipt.status).toBe('success'); + expect(l1Receipt.logs.length).toBe(1); + + const decoded = decodeEventLog({ + abi: OutboxAbi, + data: l1Receipt.logs[0].data, + topics: l1Receipt.logs[0].topics, + }) as { + eventName: 'MessageConsumed'; + args: { + epoch: bigint; + root: `0x${string}`; + messageHash: `0x${string}`; + leafId: bigint; + numCheckpointsInEpoch: bigint; + }; + }; + expect(decoded.eventName).toBe('MessageConsumed'); + expect(decoded.args.epoch).toBe(BigInt(epoch)); + expect(decoded.args.root).toBe(witness.root.toString()); + expect(decoded.args.messageHash).toBe(leaf.toString()); + expect(decoded.args.leafId).toBe(getL2ToL1MessageLeafId(witness)); + } +}); diff --git a/yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts b/yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts index 1ec24f5563dc..c9c95763d8bf 100644 --- a/yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts +++ b/yarn-project/end-to-end/src/e2e_fees/fee_settings.test.ts @@ -218,6 +218,10 @@ describe('e2e_fees fee settings', () => { }); it('reproduces the stale fee snapshot race deterministically', async () => { + // The previous test bumped the proving cost, setting FeeLib's provingCostLastUpdate. + // Clear the 30-day cooldown so bumpL2Fees below can land. + await cheatCodes.rollup.clearProvingCostCooldown(); + const lowerMinFees = await getCurrentMinFeesAfterCheckpoint(testContractDeployBlock); // `higherMinFees` is the synthetic "stale" snapshot the wallet supposedly took before the // real L2 fee bumped — it only needs to stay above the realized `bumpedMinFees` so that diff --git a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts index 5c4c468fbcdf..17953e856dc6 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts @@ -7,7 +7,7 @@ import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; import { Fr } from '@aztec/aztec.js/fields'; import { waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import { RollupCheatCodes } from '@aztec/aztec/testing'; -import { FeeAssetHandlerContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; +import { FeeAssetHandlerContract, OutboxContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import { deployRollupForUpgrade } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; @@ -364,15 +364,20 @@ describe('e2e_p2p_add_rollup', () => { chainId: new Fr(l1Client.chain.id), }); - const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness(node, leaf, l2OutgoingReceipt.txHash))!; - const { epochNumber: epoch, ...l2ToL1MessageWitness } = l2ToL1MessageResult; - const leafId = getL2ToL1MessageLeafId(l2ToL1MessageWitness); - // We need to advance to the next epoch so that the out hash will be set to outbox when the epoch is proven. const cheatcodes = RollupCheatCodes.create(l1RpcUrls, l1ContractAddresses, t.ctx.dateProvider); - await cheatcodes.advanceToEpoch(EpochNumber(epoch + 1)); + const minedReceipt = await node.getTxReceipt(l2OutgoingReceipt.txHash); + if (minedReceipt.epochNumber === undefined) { + throw new Error('Outgoing tx is not yet in an epoch'); + } + await cheatcodes.advanceToEpoch(EpochNumber(minedReceipt.epochNumber + 1)); await waitForProven(node, l2OutgoingReceipt, { provenTimeout: 300 }); + const outboxContract = new OutboxContract(l1Client, l1ContractAddresses.outboxAddress); + const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness(node, outboxContract, leaf, minedReceipt))!; + const { epochNumber: epoch, numCheckpointsInEpoch, ...l2ToL1MessageWitness } = l2ToL1MessageResult; + const leafId = getL2ToL1MessageLeafId(l2ToL1MessageWitness); + // Then we want to go and comsume it! const outbox = getContract({ address: l1ContractAddresses.outboxAddress.toString(), @@ -388,6 +393,7 @@ describe('e2e_p2p_add_rollup', () => { args: [ l2ToL1Message, BigInt(epoch), + BigInt(numCheckpointsInEpoch), BigInt(l2ToL1MessageWitness.leafIndex), l2ToL1MessageWitness.siblingPath .toBufferArray() @@ -412,6 +418,7 @@ describe('e2e_p2p_add_rollup', () => { root: `0x${string}`; messageHash: `0x${string}`; leafId: bigint; + numCheckpointsInEpoch: bigint; }; }; diff --git a/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts b/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts index c7fa39824224..c2e8639610c3 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts @@ -3,16 +3,12 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { type Logger, createLogger } from '@aztec/aztec.js/log'; import { createExtendedL1Client } from '@aztec/ethereum/client'; import { RollupContract, SlashingProposerContract } from '@aztec/ethereum/contracts'; -import { L1Deployer } from '@aztec/ethereum/deploy-l1-contract'; -import { SlasherArtifact, SlashingProposerArtifact } from '@aztec/ethereum/l1-artifacts'; import { L1TxUtils, createL1TxUtils } from '@aztec/ethereum/l1-tx-utils'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; -import { tryJsonStringify } from '@aztec/foundation/json-rpc'; import { promiseWithResolvers } from '@aztec/foundation/promise'; import { retryUntil } from '@aztec/foundation/retry'; import { bufferToHex } from '@aztec/foundation/string'; import { GSEAbi } from '@aztec/l1-artifacts/GSEAbi'; -import { RollupAbi } from '@aztec/l1-artifacts/RollupAbi'; import { SlasherAbi } from '@aztec/l1-artifacts/SlasherAbi'; import assert from 'assert'; @@ -48,9 +44,16 @@ const LIFETIME_IN_ROUNDS = 2; const EXECUTION_DELAY_IN_ROUNDS = 1; // unit of slashing const SLASHING_UNIT = BigInt(20e18); +// how long slashing stays disabled after the vetoer disables it (1 hour) +const SLASHING_DISABLE_DURATION_SECONDS = 3600; + +// Vetoer address is derived deterministically from the test mnemonic so the slasher +// can be deployed with the correct vetoer from the start -- no mid-test setSlasher swap needed. +const VETOER_ADDRESS = EthAddress.fromString( + privateKeyToAccount(bufferToHex(getPrivateKeyFromIndex(VETOER_PRIVATE_KEY_INDEX)!)).address, +); // offset for slashing rounds const SLASH_OFFSET_IN_ROUNDS = 2; -const COMMITEE_SIZE = NUM_VALIDATORS; const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'slash-veto-demo-')); describe('veto slash', () => { @@ -87,6 +90,10 @@ describe('veto slash', () => { slashAmountLarge: SLASHING_UNIT * 3n, slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / EPOCH_DURATION, slashingQuorum: SLASHING_QUORUM, + slashingLifetimeInRounds: LIFETIME_IN_ROUNDS, + slashingExecutionDelayInRounds: EXECUTION_DELAY_IN_ROUNDS, + slashingDisableDuration: SLASHING_DISABLE_DURATION_SECONDS, + slashingVetoer: VETOER_ADDRESS, slashInactivityTargetPercentage: SLASH_INACTIVITY_TARGET_PERCENTAGE, }, }); @@ -146,53 +153,6 @@ describe('veto slash', () => { } }); - /** - * Deploys a new slasher contract on L1. - * - * @param deployerClient - The client to use to deploy the slasher contract. Also serves as the VETOER. - * @returns The address of the deployed slasher contract. - */ - async function deployNewSlasher(deployerClient: ExtendedViemWalletClient) { - const deployer = new L1Deployer(deployerClient, 42, undefined, false, undefined, undefined); - - const vetoer = deployerClient.account.address; - const governance = EthAddress.random().toString(); // We don't need a real governance address for this test - debugLogger.info(`\n\ndeploying slasher with vetoer: ${vetoer}\n\n`); - const slasher = (await deployer.deploy(SlasherArtifact, [vetoer, governance, 3600n])).address; - await deployer.waitForDeployments(); - - const proposerArgs = [ - rollup.address, // instance - slasher.toString(), // slasher - BigInt(SLASHING_QUORUM), - BigInt(SLASHING_ROUND_SIZE), - BigInt(LIFETIME_IN_ROUNDS), - BigInt(EXECUTION_DELAY_IN_ROUNDS), - [SLASHING_UNIT, SLASHING_UNIT * 2n, SLASHING_UNIT * 3n], - BigInt(COMMITEE_SIZE), - BigInt(EPOCH_DURATION), - BigInt(SLASH_OFFSET_IN_ROUNDS), - ] as const; - debugLogger.info(`\n\ndeploying tally slasher proposer with args: ${tryJsonStringify(proposerArgs)}\n\n`); - const proposer = (await deployer.deploy(SlashingProposerArtifact, proposerArgs)).address; - - debugLogger.info(`\n\ninitializing slasher with proposer: ${proposer}\n\n`); - const txUtils = createL1TxUtils(deployerClient, { - logger: t.logger, - dateProvider: t.ctx.dateProvider, - }); - await txUtils.sendAndMonitorTransaction({ - to: slasher.toString(), - data: encodeFunctionData({ - abi: SlasherAbi, - functionName: 'initializeProposer', - args: [proposer.toString()], - }), - }); - - return slasher; - } - /** Waits for a round to be executable */ async function waitForSubmittableRound( proposer: SlashingProposerContract, @@ -211,46 +171,23 @@ describe('veto slash', () => { } it.each([[true]] as const)( - 'vetoes %s and sets the new tally slasher', + 'vetoes %s a slashing payload', async (shouldVeto: boolean) => { - //################################// - // // - // Create new Slasher with Vetoer // - // // - //################################// - - const newSlasherAddress = await deployNewSlasher(vetoerL1Client); - debugLogger.info(`\n\nnewSlasherAddress: ${newSlasherAddress}\n\n`); - - // Need to impersonate governance to set the new slasher - await t.ctx.cheatCodes.eth.startImpersonating( - t.ctx.deployL1ContractsValues.l1ContractAddresses.governanceAddress, - ); - - const setSlasherTx = await t.ctx.deployL1ContractsValues.l1Client.writeContract({ - address: rollup.address, - abi: RollupAbi, - functionName: 'setSlasher', - args: [newSlasherAddress.toString()], - account: t.ctx.deployL1ContractsValues.l1ContractAddresses.governanceAddress.toString(), - }); - const receipt = await t.ctx.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ - hash: setSlasherTx, - }); - expect(receipt.status).toEqual('success'); - - await t.ctx.cheatCodes.eth.stopImpersonating(t.ctx.deployL1ContractsValues.l1ContractAddresses.governanceAddress); + //#####################################// + // // + // Verify the initial slasher's vetoer // + // // + //#####################################// const slasherAddress = await rollup.getSlasherAddress(); - expect(slasherAddress.toString().toLowerCase()).toEqual(newSlasherAddress.toString().toLowerCase()); - debugLogger.info(`\n\nnew slasher address: ${slasherAddress}\n\n`); + debugLogger.info(`\n\nslasher address: ${slasherAddress}\n\n`); const slasher = getContract({ address: slasherAddress.toString() as `0x${string}`, abi: SlasherAbi, client: t.ctx.deployL1ContractsValues.l1Client, }); const slasherVetoer = await slasher.read.VETOER(); - debugLogger.info(`\n\nnew slasher vetoer: ${slasherVetoer}\n\n`); + debugLogger.info(`\n\nslasher vetoer: ${slasherVetoer}\n\n`); expect(slasherVetoer).toEqual(vetoerL1Client.account.address); const slashingProposer = await rollup.getSlashingProposer(); diff --git a/yarn-project/end-to-end/src/e2e_sequencer/escape_hatch_vote_only.test.ts b/yarn-project/end-to-end/src/e2e_sequencer/escape_hatch_vote_only.test.ts index 216b0abef73e..2c2a346dc6f2 100644 --- a/yarn-project/end-to-end/src/e2e_sequencer/escape_hatch_vote_only.test.ts +++ b/yarn-project/end-to-end/src/e2e_sequencer/escape_hatch_vote_only.test.ts @@ -140,7 +140,7 @@ describe('e2e_escape_hatch_vote_only', () => { // Wire escape hatch into the rollup (owner-only). await cheatCodes.rollup.asOwner(async (owner, rollupAsOwner) => { - const hash = await rollupAsOwner.write.updateEscapeHatch([escapeHatchAddress.toString()], { account: owner }); + const hash = await rollupAsOwner.write.setEscapeHatch([escapeHatchAddress.toString()], { account: owner }); await l1Client.waitForTransactionReceipt({ hash }); }); }); diff --git a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts index cdfd91e4af3a..e9d2cbfb648a 100644 --- a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts +++ b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts @@ -13,6 +13,7 @@ import type { AztecNode } from '@aztec/aztec.js/node'; import type { SiblingPath } from '@aztec/aztec.js/trees'; import type { TxReceipt } from '@aztec/aztec.js/tx'; import type { Wallet } from '@aztec/aztec.js/wallet'; +import { OutboxContract } from '@aztec/ethereum/contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; @@ -166,6 +167,7 @@ export class CrossChainTestHarness { private readonly l1TokenManager: L1TokenManager; private readonly l1TokenPortalManager: L1TokenPortalManager; + public readonly outboxContract: OutboxContract; constructor( /** Aztec node instance. */ @@ -206,6 +208,7 @@ export class CrossChainTestHarness { this.logger, ); this.l1TokenManager = this.l1TokenPortalManager.getTokenManager(); + this.outboxContract = new OutboxContract(this.l1Client, this.l1ContractAddresses.outboxAddress); } async mintTokensOnL1(amount: bigint) { @@ -319,10 +322,18 @@ export class CrossChainTestHarness { withdrawFundsFromBridgeOnL1( amount: bigint, epochNumber: EpochNumber, + numCheckpointsInEpoch: number, messageIndex: bigint, siblingPath: SiblingPath, ) { - return this.l1TokenPortalManager.withdrawFunds(amount, this.ethAccount, epochNumber, messageIndex, siblingPath); + return this.l1TokenPortalManager.withdrawFunds( + amount, + this.ethAccount, + epochNumber, + numCheckpointsInEpoch, + messageIndex, + siblingPath, + ); } async transferToPrivateOnL2(shieldAmount: bigint) { diff --git a/yarn-project/end-to-end/src/shared/uniswap_l1_l2.ts b/yarn-project/end-to-end/src/shared/uniswap_l1_l2.ts index 8a52e28d826d..8bf2428f21c0 100644 --- a/yarn-project/end-to-end/src/shared/uniswap_l1_l2.ts +++ b/yarn-project/end-to-end/src/shared/uniswap_l1_l2.ts @@ -250,14 +250,13 @@ export const uniswapL1L2TestSuite = ( // ensure that uniswap contract didn't eat the funds. await wethCrossChainHarness.expectPublicBalanceOnL2(uniswapL2Contract.address, 0n); - // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch. - const swapResult = (await computeL2ToL1MembershipWitness( - aztecNode, - swapPrivateLeaf, - l2UniswapInteractionReceipt.txHash, - ))!; - const { epochNumber: epoch } = swapResult; - await cheatCodes.rollup.advanceToEpoch(EpochNumber(epoch + 1)); + // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch + // before we can ask the witness helper for a covering root. + const minedReceipt = await aztecNode.getTxReceipt(l2UniswapInteractionReceipt.txHash); + if (minedReceipt.epochNumber === undefined) { + throw new Error('L2 Uniswap interaction tx is not yet in an epoch'); + } + await cheatCodes.rollup.advanceToEpoch(EpochNumber(minedReceipt.epochNumber + 1)); await waitForProven(aztecNode, l2UniswapInteractionReceipt, { provenTimeout: 300 }); // 5. Consume L2 to L1 message by calling uniswapPortal.swap_private() @@ -265,11 +264,11 @@ export const uniswapL1L2TestSuite = ( const daiL1BalanceOfPortalBeforeSwap = await daiCrossChainHarness.getL1BalanceOf( daiCrossChainHarness.tokenPortalAddress, ); - const withdrawResult = (await computeL2ToL1MembershipWitness( - aztecNode, - withdrawLeaf, - l2UniswapInteractionReceipt.txHash, - ))!; + // Fetch the outbox roots once and share them between the two witnesses in this epoch. + const epochRoots = await wethCrossChainHarness.outboxContract.getRoots(minedReceipt.epochNumber); + const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, swapPrivateLeaf, minedReceipt))!; + const { epochNumber: epoch, numCheckpointsInEpoch } = swapResult; + const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, withdrawLeaf, minedReceipt))!; const swapPrivateL2MessageIndex = swapResult.leafIndex; const swapPrivateSiblingPath = swapResult.siblingPath; @@ -279,6 +278,7 @@ export const uniswapL1L2TestSuite = ( const withdrawMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(withdrawResult.numCheckpointsInEpoch), _leafIndex: BigInt(withdrawL2MessageIndex), _path: withdrawSiblingPath .toBufferArray() @@ -287,6 +287,7 @@ export const uniswapL1L2TestSuite = ( const swapPrivateMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(numCheckpointsInEpoch), _leafIndex: BigInt(swapPrivateL2MessageIndex), _path: swapPrivateSiblingPath .toBufferArray() @@ -843,9 +844,23 @@ export const uniswapL1L2TestSuite = ( chainId: new Fr(l1Client.chain.id), }); - const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, swapPrivateLeaf, withdrawReceipt.txHash))!; - const { epochNumber: epoch } = swapResult; - const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, withdrawLeaf, withdrawReceipt.txHash))!; + // ensure that user's funds were burnt + await wethCrossChainHarness.expectPrivateBalanceOnL2(ownerAddress, wethL2BalanceBeforeSwap - wethAmountToBridge); + + // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch + // before we can ask the witness helper for a covering root. + const minedReceipt = await aztecNode.getTxReceipt(withdrawReceipt.txHash); + if (minedReceipt.epochNumber === undefined) { + throw new Error('Withdraw tx is not yet in an epoch'); + } + await cheatCodes.rollup.advanceToEpoch(EpochNumber(minedReceipt.epochNumber + 1)); + await waitForProven(aztecNode, withdrawReceipt, { provenTimeout: 300 }); + + // Fetch the outbox roots once and share them between the two witnesses in this epoch. + const epochRoots = await wethCrossChainHarness.outboxContract.getRoots(minedReceipt.epochNumber); + const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, swapPrivateLeaf, minedReceipt))!; + const { epochNumber: epoch, numCheckpointsInEpoch } = swapResult; + const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, withdrawLeaf, minedReceipt))!; const swapPrivateL2MessageIndex = swapResult.leafIndex; const swapPrivateSiblingPath = swapResult.siblingPath; @@ -855,6 +870,7 @@ export const uniswapL1L2TestSuite = ( const withdrawMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(withdrawResult.numCheckpointsInEpoch), _leafIndex: BigInt(withdrawL2MessageIndex), _path: withdrawSiblingPath .toBufferArray() @@ -863,19 +879,13 @@ export const uniswapL1L2TestSuite = ( const swapPrivateMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(numCheckpointsInEpoch), _leafIndex: BigInt(swapPrivateL2MessageIndex), _path: swapPrivateSiblingPath .toBufferArray() .map((buf: Buffer) => `0x${buf.toString('hex')}`) as readonly `0x${string}`[], }; - // ensure that user's funds were burnt - await wethCrossChainHarness.expectPrivateBalanceOnL2(ownerAddress, wethL2BalanceBeforeSwap - wethAmountToBridge); - - // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch. - await cheatCodes.rollup.advanceToEpoch(EpochNumber(epoch + 1)); - await waitForProven(aztecNode, withdrawReceipt, { provenTimeout: 300 }); - // On L1 call swap_public! logger.info('call swap_public on L1'); const swapArgs = [ @@ -975,9 +985,23 @@ export const uniswapL1L2TestSuite = ( chainId: new Fr(l1Client.chain.id), }); - const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, swapPublicLeaf, withdrawReceipt.txHash))!; - const { epochNumber: epoch } = swapResult; - const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, withdrawLeaf, withdrawReceipt.txHash))!; + // check weth balance of owner on L2 (we first bridged `wethAmountToBridge` into L2 and now withdrew it!) + await wethCrossChainHarness.expectPublicBalanceOnL2(ownerAddress, 0n); + + // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch + // before we can ask the witness helper for a covering root. + const minedReceipt = await aztecNode.getTxReceipt(withdrawReceipt.txHash); + if (minedReceipt.epochNumber === undefined) { + throw new Error('Withdraw tx is not yet in an epoch'); + } + await cheatCodes.rollup.advanceToEpoch(EpochNumber(minedReceipt.epochNumber + 1)); + await waitForProven(aztecNode, withdrawReceipt, { provenTimeout: 300 }); + + // Fetch the outbox roots once and share them between the two witnesses in this epoch. + const epochRoots = await wethCrossChainHarness.outboxContract.getRoots(minedReceipt.epochNumber); + const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, swapPublicLeaf, minedReceipt))!; + const { epochNumber: epoch, numCheckpointsInEpoch } = swapResult; + const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, withdrawLeaf, minedReceipt))!; const swapPublicL2MessageIndex = swapResult.leafIndex; const swapPublicSiblingPath = swapResult.siblingPath; @@ -987,6 +1011,7 @@ export const uniswapL1L2TestSuite = ( const withdrawMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(withdrawResult.numCheckpointsInEpoch), _leafIndex: BigInt(withdrawL2MessageIndex), _path: withdrawSiblingPath .toBufferArray() @@ -995,19 +1020,13 @@ export const uniswapL1L2TestSuite = ( const swapPublicMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(numCheckpointsInEpoch), _leafIndex: BigInt(swapPublicL2MessageIndex), _path: swapPublicSiblingPath .toBufferArray() .map((buf: Buffer) => `0x${buf.toString('hex')}`) as readonly `0x${string}`[], }; - // check weth balance of owner on L2 (we first bridged `wethAmountToBridge` into L2 and now withdrew it!) - await wethCrossChainHarness.expectPublicBalanceOnL2(ownerAddress, 0n); - - // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch. - await cheatCodes.rollup.advanceToEpoch(EpochNumber(epoch + 1)); - await waitForProven(aztecNode, withdrawReceipt, { provenTimeout: 300 }); - // Call swap_private on L1 logger.info('Execute withdraw and swap on the uniswapPortal!'); diff --git a/yarn-project/ethereum/src/contracts/outbox.ts b/yarn-project/ethereum/src/contracts/outbox.ts index a331fb616b42..a6adb4194e60 100644 --- a/yarn-project/ethereum/src/contracts/outbox.ts +++ b/yarn-project/ethereum/src/contracts/outbox.ts @@ -1,4 +1,5 @@ import type { EpochNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { OutboxAbi } from '@aztec/l1-artifacts/OutboxAbi'; @@ -42,8 +43,20 @@ export class OutboxContract { return new OutboxContract(client, address); } - static getEpochRootStorageSlot(epoch: EpochNumber) { - return hexToBigInt(keccak256(encodeAbiParameters([{ type: 'uint256' }, { type: 'uint256' }], [BigInt(epoch), 0n]))); + /** + * Storage slot of `epochs[epoch].roots[numCheckpointsInEpoch - 1]` in the Outbox contract. + * + * The Outbox lays out storage as `mapping(Epoch => EpochData) internal epochs` at base slot 0 + * (ROLLUP and VERSION are immutable and do not occupy slots). For a mapping at slot `b`, the + * value for key `k` begins at `keccak256(abi.encode(k, b))`. `EpochData` starts with a fixed- + * size `bytes32[MAX_CHECKPOINTS_PER_EPOCH] roots` array, so `roots[i]` sits at the EpochData's + * base slot plus `i`. `numCheckpointsInEpoch` is 1-indexed (matches `Outbox.insert`/`consume`). + */ + static getEpochRootStorageSlot(epoch: EpochNumber, numCheckpointsInEpoch: number) { + const epochDataSlot = hexToBigInt( + keccak256(encodeAbiParameters([{ type: 'uint256' }, { type: 'uint256' }], [BigInt(epoch), 0n])), + ); + return epochDataSlot + BigInt(numCheckpointsInEpoch - 1); } constructor( @@ -68,24 +81,41 @@ export class OutboxContract { return this.outbox.read.hasMessageBeenConsumedAtEpoch([BigInt(epoch), leafId]); } - public getRootData(epoch: EpochNumber) { - return this.outbox.read.getRootData([BigInt(epoch)]); + public getRootData(epoch: EpochNumber, numCheckpointsInEpoch: number) { + return this.outbox.read.getRootData([BigInt(epoch), BigInt(numCheckpointsInEpoch)]); } - public consume(message: ViemL2ToL1Msg, epoch: EpochNumber, leafIndex: bigint, path: Hex[]) { + /** + * Returns every root stored for `epoch`. Slot `i` of the returned array holds the root inserted + * for `numCheckpointsInEpoch = i + 1`, or `Fr.ZERO` if no proof of that depth has been inserted + * yet. The array length is always `MAX_CHECKPOINTS_PER_EPOCH`. + */ + public async getRoots(epoch: EpochNumber): Promise { + const raw = await this.outbox.read.getRoots([BigInt(epoch)]); + return raw.map(hex => Fr.fromString(hex)); + } + + public consume( + message: ViemL2ToL1Msg, + epoch: EpochNumber, + numCheckpointsInEpoch: number, + leafIndex: bigint, + path: Hex[], + ) { const wallet = this.assertWallet(); - return wallet.write.consume([message, BigInt(epoch), leafIndex, path]); + return wallet.write.consume([message, BigInt(epoch), BigInt(numCheckpointsInEpoch), leafIndex, path]); } public async getMessageConsumedEvents( l1BlockHash: Hex, - ): Promise<{ epoch: bigint; root: Hex; messageHash: Hex; leafId: bigint }[]> { + ): Promise<{ epoch: bigint; root: Hex; messageHash: Hex; leafId: bigint; numCheckpointsInEpoch: bigint }[]> { const events = await this.outbox.getEvents.MessageConsumed({}, { blockHash: l1BlockHash, strict: true }); return events.map(event => ({ epoch: event.args.epoch!, root: event.args.root!, messageHash: event.args.messageHash!, leafId: event.args.leafId!, + numCheckpointsInEpoch: event.args.numCheckpointsInEpoch!, })); } diff --git a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts index 2c7343e56518..f43777515767 100644 --- a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts @@ -15,6 +15,7 @@ import { getContract, hexToBigInt, http, + keccak256, } from 'viem'; import { EthCheatCodes } from './eth_cheat_codes.js'; @@ -284,12 +285,14 @@ export class RollupCheatCodes { }); } - public insertOutbox(epoch: EpochNumber, outHash: bigint) { + public insertOutbox(epoch: EpochNumber, numCheckpointsInEpoch: number, outHash: bigint) { return this.ethCheatCodes.execWithPausedAnvil(async () => { const outboxAddress = await this.rollup.read.getOutbox(); - const epochRootSlot = OutboxContract.getEpochRootStorageSlot(epoch); + const epochRootSlot = OutboxContract.getEpochRootStorageSlot(epoch, numCheckpointsInEpoch); await this.ethCheatCodes.store(EthAddress.fromString(outboxAddress), epochRootSlot, outHash); - this.logger.warn(`Advanced outbox to epoch ${epoch} with out hash ${outHash}`); + this.logger.warn( + `Advanced outbox to epoch ${epoch} numCheckpointsInEpoch ${numCheckpointsInEpoch} with out hash ${outHash}`, + ); }); } @@ -338,7 +341,8 @@ export class RollupCheatCodes { } /** - * Directly updates proving cost per mana. + * Directly updates proving cost per mana. Throws if the on-chain tx reverts + * (e.g. rate-limit cooldown, step cap, or floor) instead of silently succeeding. * @param ethValue - The new proving cost per mana in ETH */ public async setProvingCostPerMana(ethValue: bigint) { @@ -348,8 +352,37 @@ export class RollupCheatCodes { chain: this.client.chain, gasLimit: 1000000n, }); - await this.client.waitForTransactionReceipt({ hash }); + const receipt = await this.client.waitForTransactionReceipt({ hash }); + if (receipt.status !== 'success') { + throw new Error( + `setProvingCostPerMana(${ethValue}) reverted on L1 (tx ${hash}). ` + + `Likely FeeLib rate-limit (30-day cooldown or 1.5x step cap); ` + + `use clearProvingCostCooldown() between successive updates.`, + ); + } this.logger.warn(`Updated proving cost per mana to ${ethValue}`); }); } + + /** + * Resets the 30-day proving-cost update cooldown enforced by FeeLib.updateProvingCostPerMana + * by zeroing `FeeStore.provingCostLastUpdate` directly in contract storage. Use between + * successive setProvingCostPerMana / bumpProvingCostPerMana calls so the later update can + * land instead of reverting. Does not touch L1 time, so PXE/tx-expiration state stays intact. + * + * @note This is tightly coupled to the `FeeStore` layout in + * l1-contracts/src/core/libraries/rollup/FeeLib.sol: + * slot + 0: CompressedFeeConfig config (uint256) + * slot + 1: L1GasOracleValues l1GasOracleValues (14+14+4 bytes, packed) + * slot + 2: uint64 provingCostLastUpdate (only member — zeroing the slot is safe) + * If the struct layout changes, update the offset below. + */ + public async clearProvingCostCooldown() { + const feeStoreBaseSlot = hexToBigInt(keccak256(Buffer.from('aztec.fee.storage', 'utf-8'))); + const provingCostLastUpdateSlot = feeStoreBaseSlot + 2n; + await this.ethCheatCodes.store(EthAddress.fromString(this.rollup.address), provingCostLastUpdateSlot, 0n, { + silent: true, + }); + this.logger.warn(`Cleared proving-cost update cooldown`); + } } diff --git a/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts b/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts index a34ca559a253..09584783cd2b 100644 --- a/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts +++ b/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts @@ -1,10 +1,25 @@ -import { OUT_HASH_TREE_LEAF_COUNT } from '@aztec/constants'; +import { MAX_CHECKPOINTS_PER_EPOCH, OUT_HASH_TREE_LEAF_COUNT } from '@aztec/constants'; import type { EpochNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { SiblingPath, UnbalancedMerkleTreeCalculator, computeUnbalancedShaRoot } from '@aztec/foundation/trees'; import type { AztecNode } from '../interfaces/aztec-node.js'; import { TxHash } from '../tx/tx_hash.js'; +import { TxReceipt } from '../tx/tx_receipt.js'; + +/** + * Provides access to the L1 Outbox's per-epoch roots so the witness helper can pick the smallest + * partial-proof root that covers a tx's checkpoint. Implemented by `OutboxContract` in the + * ethereum package. + */ +export interface OutboxRootsReader { + /** + * Returns the array of roots stored for `epoch`. Slot `i` holds the root inserted for + * `numCheckpointsInEpoch = i + 1`, or `Fr.ZERO` if no proof of that depth has landed yet. The + * returned array length is `MAX_CHECKPOINTS_PER_EPOCH`. + */ + getRoots(epoch: EpochNumber): Promise; +} /** * # L2-to-L1 Message Tree Structure and Leaf IDs @@ -96,29 +111,47 @@ export function getL2ToL1MessageLeafId( } export type L2ToL1MembershipWitness = { + epochNumber: EpochNumber; + /** + * The number of checkpoints covered by the partial-proof root this witness was built against + * (1-indexed; equal to `roots-array-index + 1` on the Outbox). Pass this through to + * `Outbox.consume` so the contract reads the matching root slot. + */ + numCheckpointsInEpoch: number; root: Fr; leafIndex: bigint; siblingPath: SiblingPath; - epochNumber: EpochNumber; }; /** * Computes the L2 to L1 membership witness for a given message in a transaction. * + * Queries the L1 Outbox to find the smallest partial-proof root that covers the tx's checkpoint, + * then builds the witness against that root by including only the first `numCheckpointsInEpoch` + * checkpoints of the epoch in the tree (the remaining slots are zero-padded, matching the shape + * the rollup proved). Returns `undefined` if the tx is not yet in a block/epoch or if the Outbox + * holds no root yet that covers the tx's checkpoint. + * * @param node - The Aztec node to query for block/tx/epoch data. + * @param outboxOrRoots - Either an `OutboxRootsReader` (the helper will fetch the per-epoch roots), + * or an already-resolved roots array of length `MAX_CHECKPOINTS_PER_EPOCH`. Pass the array when + * you already read the outbox (e.g. inside a tight loop that resolves witnesses for many + * messages in the same epoch) to avoid redundant L1 reads. * @param message - The L2 to L1 message hash to prove membership of. - * @param txHash - The hash of the transaction that emitted the message. + * @param txHashOrReceipt - Either the tx hash, or the already-fetched `TxReceipt`. Passing the + * receipt skips an internal `getTxReceipt` call. * @param messageIndexInTx - Optional index of the message within the transaction's L2-to-L1 messages. * If not provided, the message is found by scanning the tx's messages (throws if duplicates exist). - * @returns The membership witness and epoch number, or undefined if the tx is not yet in a block/epoch. */ export async function computeL2ToL1MembershipWitness( node: Pick, + outboxOrRoots: OutboxRootsReader | Fr[], message: Fr, - txHash: TxHash, + txHashOrReceipt: TxHash | Pick, messageIndexInTx?: number, ): Promise { - const { epochNumber, blockNumber } = await node.getTxReceipt(txHash); + const receipt = 'txHash' in txHashOrReceipt ? txHashOrReceipt : await node.getTxReceipt(txHashOrReceipt); + const { txHash, epochNumber, blockNumber } = receipt; if (epochNumber === undefined || blockNumber === undefined) { return undefined; } @@ -142,15 +175,57 @@ export async function computeL2ToL1MembershipWitness( const blockIndex = block.indexWithinCheckpoint; const txIndex = txEffect.txIndexInBlock; + // Pick the smallest partial-proof root on the Outbox that covers checkpointIndex. The Outbox + // stores roots keyed by `numCheckpointsInEpoch - 1`, so to cover a tx in checkpoint at index + // `checkpointIndex` we need a non-zero entry at array index >= checkpointIndex. + const roots = Array.isArray(outboxOrRoots) + ? (outboxOrRoots as Fr[]) + : await (outboxOrRoots as OutboxRootsReader).getRoots(epochNumber); + const numCheckpointsInEpoch = findSmallestCoveringRootCount(roots, checkpointIndex); + if (numCheckpointsInEpoch === undefined) { + return undefined; + } + + // Build the witness against the first `numCheckpointsInEpoch` checkpoints. The inner builder + // pads to OUT_HASH_TREE_LEAF_COUNT internally, so slicing the outer array narrows the real-leaf + // prefix and grows the zero suffix — exactly the shape the rollup proved against. + const messagesInPartialEpoch = messagesInEpoch.slice(0, numCheckpointsInEpoch); + const { root, leafIndex, siblingPath } = computeL2ToL1MembershipWitnessFromMessagesInEpoch( - messagesInEpoch, + messagesInPartialEpoch, message, checkpointIndex, blockIndex, txIndex, messageIndexInTx, ); - return { epochNumber, root, leafIndex, siblingPath }; + + // Cross-check: the recomputed root must equal the root the Outbox is holding for this depth. + // A mismatch means the node and L1 disagree about the epoch's contents; fail loud rather than + // return a witness that will revert on chain. + const expected = roots[numCheckpointsInEpoch - 1]; + if (!root.equals(expected)) { + throw new Error( + `Local epoch out-hash does not match Outbox at epoch ${epochNumber} numCheckpointsInEpoch ` + + `${numCheckpointsInEpoch}: local=${root.toString()} outbox=${expected.toString()}`, + ); + } + + return { epochNumber, numCheckpointsInEpoch, root, leafIndex, siblingPath }; +} + +/** + * Returns the smallest `numCheckpointsInEpoch` (1-indexed) for which the Outbox holds a root that + * covers the message at `checkpointIndex` (0-indexed). Returns `undefined` if no covering root has + * been inserted yet. + */ +function findSmallestCoveringRootCount(roots: Fr[], checkpointIndex: number): number | undefined { + for (let i = checkpointIndex; i < Math.min(roots.length, MAX_CHECKPOINTS_PER_EPOCH); i++) { + if (!roots[i].isZero()) { + return i + 1; + } + } + return undefined; } /**