diff --git a/l1-contracts/src/core/Rollup.sol b/l1-contracts/src/core/Rollup.sol index 281e4badd070..cd68158ce5b3 100644 --- a/l1-contracts/src/core/Rollup.sol +++ b/l1-contracts/src/core/Rollup.sol @@ -210,6 +210,11 @@ 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 getLocalEjectionThreshold() external view override(IStaking) returns (uint256) { return StakingLib.getStorage().localEjectionThreshold; } @@ -594,6 +599,10 @@ 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 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..64c3a762ef9d 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,7 @@ 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); TimeLib.initialize( block.timestamp, _config.aztecSlotDuration, _config.aztecEpochDuration, _config.aztecProofSubmissionEpochs @@ -256,12 +252,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 +280,25 @@ 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. * @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 +320,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 +591,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..f09ba08adf41 100644 --- a/l1-contracts/src/core/interfaces/IStaking.sol +++ b/l1-contracts/src/core/interfaces/IStaking.sol @@ -12,9 +12,8 @@ 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 ValidatorQueued(address indexed attester, address indexed withdrawer); event Deposit( address indexed attester, @@ -36,8 +35,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 +63,9 @@ 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 getLocalEjectionThreshold() external view returns (uint256); + function getSlasherExecutionDelay() 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/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 7787b5558f43..fd0adb66cae1 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -131,6 +131,8 @@ 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(); // Staking error Staking__AlreadyQueued(address _attester); @@ -167,6 +169,8 @@ library Errors { error Staking__InsufficientBootstrapValidators(uint256 queueSize, uint256 bootstrapFlushSize); error Staking__InvalidStakingQueueConfig(); error Staking__InvalidNormalFlushSizeQuotient(); + error Staking__NoPendingSlasher(); + error Staking__SlasherNotReady(Timestamp readyAt); // Fee Juice Portal error FeeJuicePortal__AlreadyInitialized(); // 0xc7a172fe @@ -184,6 +188,9 @@ 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__ProvingCostCooldown(uint256 nextAllowed); + error FeeLib__ProvingCostStepExceeded(uint256 current, uint256 requested); // SignatureLib (duplicated) error SignatureLib__InvalidSignature(address, address); // 0xd9cbae6c diff --git a/l1-contracts/src/core/libraries/rollup/FeeLib.sol b/l1-contracts/src/core/libraries/rollup/FeeLib.sol index 8133a5d8d3e4..9fff822640fa 100644 --- a/l1-contracts/src/core/libraries/rollup/FeeLib.sol +++ b/l1-contracts/src/core/libraries/rollup/FeeLib.sol @@ -71,6 +71,25 @@ 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; + struct OracleInput { int256 feeAssetPriceModifier; } @@ -85,6 +104,7 @@ struct ManaMinFeeComponents { struct FeeStore { CompressedFeeConfig config; L1GasOracleValues l1GasOracleValues; + uint64 provingCostLastUpdate; } library FeeLib { @@ -119,6 +139,14 @@ 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) + ); + // Validate initial ETH per fee asset is within bounds uint256 initialPrice = EthPerFeeAssetE12.unwrap(_initialEthPerFeeAsset); require( @@ -158,8 +186,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..4508612c888f 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]; @@ -160,19 +181,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..c0fdf92ef2b0 100644 --- a/l1-contracts/src/core/libraries/rollup/StakingLib.sol +++ b/l1-contracts/src/core/libraries/rollup/StakingLib.sol @@ -79,6 +79,8 @@ struct StakingStorage { IERC20 stakingAsset; address slasher; uint96 localEjectionThreshold; + address pendingSlasher; + CompressedTimestamp pendingSlasherReadyAt; GSE gse; CompressedTimestamp exitDelay; mapping(address attester => Exit) exits; @@ -103,6 +105,9 @@ 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; + function initialize( IERC20 _stakingAsset, GSE _gse, @@ -121,22 +126,42 @@ 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; + Timestamp readyAt = Timestamp.wrap(block.timestamp + SLASHER_EXECUTION_DELAY); + store.pendingSlasher = _slasher; + store.pendingSlasherReadyAt = readyAt.compress(); - emit IStakingCore.SlasherUpdated(oldSlasher, _slasher); + 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); + } - emit IStakingCore.LocalEjectionThresholdUpdated(oldLocalEjectionThreshold, _localEjectionThreshold); + 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 oldSlasher = store.slasher; + address newSlasher = store.pendingSlasher; + store.slasher = newSlasher; + store.pendingSlasher = address(0); + store.pendingSlasherReadyAt = CompressedTimestamp.wrap(0); + + emit IStakingCore.SlasherUpdated(oldSlasher, newSlasher); } /** @@ -465,6 +490,7 @@ library StakingLib { } function updateStakingQueueConfig(StakingQueueConfig memory _config) internal { + assertValidQueueConfig(_config); getStorage().queueConfig = _config.compress(); emit IStakingCore.StakingQueueConfigUpdated(_config); } @@ -603,6 +629,14 @@ library StakingLib { return getStorage().availableValidatorFlushes; } + /// @notice Enforces invariants on a {StakingQueueConfig}. Both fields must stay non-zero + /// because {getEntryQueueFlushSize} divides by `normalFlushSizeQuotient` and because + /// a zero `normalFlushSizeMin` can close the queue on a running rollup. + function assertValidQueueConfig(StakingQueueConfig memory _config) internal pure { + require(_config.normalFlushSizeMin > 0, Errors.Staking__InvalidStakingQueueConfig()); + require(_config.normalFlushSizeQuotient > 0, Errors.Staking__InvalidNormalFlushSizeQuotient()); + } + function getStorage() internal pure returns (StakingStorage storage storageStruct) { bytes32 position = STAKING_SLOT; assembly { 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..ed13137cc23e 100644 --- a/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol @@ -148,18 +148,24 @@ 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()); + + 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/governance/RewardDistributor.sol b/l1-contracts/src/governance/RewardDistributor.sol index 632b49b53581..df1879681e4d 100644 --- a/l1-contracts/src/governance/RewardDistributor.sol +++ b/l1-contracts/src/governance/RewardDistributor.sol @@ -12,6 +12,13 @@ import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; /** * @title RewardDistributor * @notice This contract is responsible for distributing rewards. + * + * Individual rollups may be specifically funded via `subsidizeRollup`. + * ASSETs transferred to this contract directly (not via subsidizeRollup) + * may be `claim`ed by the presently canonical rollup, in addition to any + * funds earmarked to that rollup via `subsidizeRollup`. + * + * Governance may recover all funds, earmarked or otherwise. */ contract RewardDistributor is IRewardDistributor { using SafeERC20 for IERC20; @@ -19,23 +26,76 @@ contract RewardDistributor is IRewardDistributor { IERC20 public immutable ASSET; IRegistry public immutable REGISTRY; + // Stores a mapping of funds that are held in this contract, but earmarked for a specific rollup. + // Funds sent directly to this contract (instead of using `subsidizeRollup`) are implicitly available + // to the presently canonical rollup. + mapping(address rollup => uint256 amount) public specificRollupBalance; + + // The sum total of amounts earmarked for specific rollup versions + uint256 public totalEarmarkedBalance; + constructor(IERC20 _asset, IRegistry _registry) { ASSET = _asset; REGISTRY = _registry; } + function subsidizeRollup(address _rollup, uint256 _amount) external override(IRewardDistributor) { + require(_rollup != address(0), Errors.RewardDistributor__ZeroRollup()); + specificRollupBalance[_rollup] += _amount; + totalEarmarkedBalance += _amount; + ASSET.safeTransferFrom(msg.sender, address(this), _amount); + } + + // When a rollup is canonical, it can claim the "implicit" pool and funds earmarked for it. 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); + } + + // Basically the same as a claim, but only callable by Governance + function recover(address _fromRollup, address _to, uint256 _amount) external override(IRewardDistributor) { + address owner = Ownable(address(REGISTRY)).owner(); + require(msg.sender == owner, Errors.RewardDistributor__InvalidCaller(msg.sender, owner)); + _transfer(_fromRollup, _to, _amount); } - function recover(address _asset, address _to, uint256 _amount) external override(IRewardDistributor) { + 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)); IERC20(_asset).safeTransfer(_to, _amount); } + function availableTo(address _rollup) public view override(IRewardDistributor) returns (uint256) { + address canonical = canonicalRollup(); + uint256 claimableAsCanonical = _rollup == canonical ? ASSET.balanceOf(address(this)) - totalEarmarkedBalance : 0; + return claimableAsCanonical + specificRollupBalance[_rollup]; + } + function canonicalRollup() public view override(IRewardDistributor) returns (address) { return address(REGISTRY.getCanonicalRollup()); } + + 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); + return; + } + + // Canonical balance couldn't cover the requested amount, + // see if we can get there with funds earmarked for this rollup + uint256 earmarked = specificRollupBalance[_from]; + uint256 totalAvailable = claimableAsCanonical + earmarked; + require(totalAvailable >= _amount, Errors.RewardDistributor__InsufficientAvailable(_amount, totalAvailable)); + + // Reduce this rollup'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; + specificRollupBalance[_from] -= earmarkedFundsUsed; + totalEarmarkedBalance -= earmarkedFundsUsed; + ASSET.safeTransfer(_to, _amount); + } } diff --git a/l1-contracts/src/governance/interfaces/IRewardDistributor.sol b/l1-contracts/src/governance/interfaces/IRewardDistributor.sol index 60616169d0a2..5f7ded70bf3f 100644 --- a/l1-contracts/src/governance/interfaces/IRewardDistributor.sol +++ b/l1-contracts/src/governance/interfaces/IRewardDistributor.sol @@ -3,6 +3,9 @@ pragma solidity >=0.8.27; interface IRewardDistributor { function claim(address _to, uint256 _amount) external; - function recover(address _asset, address _to, uint256 _amount) external; + function recover(address _fromRollup, address _to, uint256 _amount) external; + function recoverWrongAsset(address _asset, address _to, uint256 _amount) external; + function subsidizeRollup(address _rollup, uint256 _amount) external; function canonicalRollup() external view returns (address); + function availableTo(address _rollup) external view returns (uint256); } diff --git a/l1-contracts/src/governance/libraries/Errors.sol b/l1-contracts/src/governance/libraries/Errors.sol index ae25f43e4421..95d21cc74024 100644 --- a/l1-contracts/src/governance/libraries/Errors.sol +++ b/l1-contracts/src/governance/libraries/Errors.sol @@ -65,6 +65,8 @@ library Errors { error Registry__NoRollupsRegistered(); error RewardDistributor__InvalidCaller(address caller, address canonical); // 0xb95e39f6 + error RewardDistributor__InsufficientAvailable(uint256 requested, uint256 available); + error RewardDistributor__ZeroRollup(); error GSE__NotRollup(address); error GSE__GovernanceAlreadySet(); diff --git a/l1-contracts/test/Rollup.t.sol b/l1-contracts/test/Rollup.t.sol index ea2e74d38c86..c0dbd9f6fb96 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( 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..84089cfc4c59 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 recover(address _rollup, address _to, uint256 _amount) external {} + function recoverWrongAsset(address _asset, address _to, uint256 _amount) external {} + + function subsidizeRollup(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/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..2710d06eb5fa 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 recover(address _rollup, 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 subsidizeRollup(address, uint256) external {} } /** diff --git a/l1-contracts/test/escape-hatch/base.sol b/l1-contracts/test/escape-hatch/base.sol index 55879f0fd4a6..2f5c5cdbaa0e 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,20 @@ 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)); + } + /// @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..9ad22995849b --- /dev/null +++ b/l1-contracts/test/escape-hatch/integration/setEscapeHatchOneShot.t.sol @@ -0,0 +1,102 @@ +// 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_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/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..0bbd94e52469 100644 --- a/l1-contracts/test/governance/reward-distributor/claim.t.sol +++ b/l1-contracts/test/governance/reward-distributor/claim.t.sol @@ -1,47 +1,336 @@ // 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"; +// Authorization for `claim` is `_amount <= availableTo(msg.sender)`. +// availableTo == specificRollupBalance for non-canonical callers, and +// (balance - totalEarmarked) + specificRollupBalance 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); + + uint256 callerBalance = token.balanceOf(canonical); + vm.prank(canonical); + rewardDistributor.claim(canonical, amount); - assertEq(token.balanceOf(caller), callerBalance + 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.specificRollupBalance(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.specificRollupBalance(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.specificRollupBalance(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.specificRollupBalance(_old), subsidy); + assertEq(rewardDistributor.totalEarmarkedBalance(), subsidy); + + address recipient = makeAddr("oldRecipient"); + vm.prank(_old); + rewardDistributor.claim(recipient, claimAmt); + + assertEq(token.balanceOf(recipient), claimAmt); + assertEq(rewardDistributor.specificRollupBalance(_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.specificRollupBalance(_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.specificRollupBalance(_a), sa); + assertEq(rewardDistributor.specificRollupBalance(_c), sc); + assertEq(rewardDistributor.specificRollupBalance(_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 specificRollupBalance. + 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.specificRollupBalance(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.specificRollupBalance(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.specificRollupBalance(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.specificRollupBalance(r1), s1); + + registry.addRollup(IRollup(r3)); + assertEq(rewardDistributor.specificRollupBalance(r1), s1); + assertEq(rewardDistributor.specificRollupBalance(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"); + } + + // --------------------------------------------------------------- + // 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.subsidizeRollup(_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..bd17cbd7e62a --- /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(specificRollupBalance) == totalEarmarkedBalance +// availableTo(canonical) == (balance - totalEarmarked) + specificRollupBalance[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.subsidizeRollup(_r1, s1); + vm.prank(funder); + rewardDistributor.subsidizeRollup(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.recover(canonical, owner, 4e18); + _assertIdentity(_r1); + + vm.prank(owner); + rewardDistributor.recover(_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.specificRollupBalance(_r1) + rewardDistributor.specificRollupBalance(canonical); + assertEq(sumSpecific, total, "sum of tracked specifics == totalEarmarked"); + uint256 canonicalAvail = rewardDistributor.availableTo(canonical); + assertEq( + canonicalAvail, balance - total + rewardDistributor.specificRollupBalance(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.subsidizeRollup(r, 0); + _track(r); + return; + } + token.mint(address(this), amount); + token.approve(address(distributor), amount); + distributor.subsidizeRollup(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.recover(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.specificRollupBalance(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.specificRollupBalance(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..233cb35091fb 100644 --- a/l1-contracts/test/governance/reward-distributor/recover.t.sol +++ b/l1-contracts/test/governance/reward-distributor/recover.t.sol @@ -1,50 +1,268 @@ // 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"; +// `recover(_fromRollup, _to, _amount)` is owner-only and shares the accounting path with `claim`: +// - canonical: draws from unearmarked first, then specificRollupBalance[canonical]. +// - non-canonical: draws only from specificRollupBalance[_fromRollup]. +// +// `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.recover(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.recover(canonical, owner, 1); + + vm.prank(newOwner); + rewardDistributor.recover(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.recover(canonical, owner, amount); - assertEq(token.balanceOf(caller), callerBalance + amount); + assertEq(token.balanceOf(owner), amount); assertEq(token.balanceOf(address(rewardDistributor)), balance - amount); + assertEq(rewardDistributor.specificRollupBalance(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.recover(canonical, owner, amount); + + assertEq(token.balanceOf(owner), amount); + assertEq(rewardDistributor.specificRollupBalance(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.recover(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.recover(_old, owner, amount); + + assertEq(token.balanceOf(owner), amount); + assertEq(rewardDistributor.specificRollupBalance(_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.recover(_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.recover(_a, owner, subsidyA); + + assertEq(rewardDistributor.specificRollupBalance(_a), 0, "A drained"); + assertEq(rewardDistributor.specificRollupBalance(_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.recover(canonical, owner, unearmarked + subsidyCanonical); + vm.prank(owner); + rewardDistributor.recover(_a, owner, subsidyA); + + assertEq(token.balanceOf(owner), unearmarked + subsidyCanonical + subsidyA); + assertEq(rewardDistributor.totalEarmarkedBalance(), 0); + assertEq(rewardDistributor.specificRollupBalance(_a), 0); + assertEq(rewardDistributor.specificRollupBalance(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.recover(r1, owner, unearmarked + subsidy); + assertEq(rewardDistributor.specificRollupBalance(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); + } + + // The require has no error message, so we match a generic revert. + function test_recoverWrongAsset_revertsForAsset() external { + token.mint(address(rewardDistributor), 100e18); + vm.expectRevert(); + 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.specificRollupBalance(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.specificRollupBalance(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.subsidizeRollup(_rollup, _amount); } } diff --git a/l1-contracts/test/governance/reward-distributor/subsidizeRollup.t.sol b/l1-contracts/test/governance/reward-distributor/subsidizeRollup.t.sol new file mode 100644 index 000000000000..fbbcb16adbfb --- /dev/null +++ b/l1-contracts/test/governance/reward-distributor/subsidizeRollup.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {RewardDistributorBase} from "./Base.t.sol"; + +import {Errors} from "@aztec/governance/libraries/Errors.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.subsidizeRollup(address(0), _amount); + } + + // subsidizeRollup 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.specificRollupBalance(_rollup); + uint256 prevDebt = rewardDistributor.totalEarmarkedBalance(); + uint256 prevBalance = token.balanceOf(address(rewardDistributor)); + + vm.prank(_funder); + rewardDistributor.subsidizeRollup(_rollup, amount); + + assertEq(rewardDistributor.specificRollupBalance(_rollup), prevSpecific + amount, "specificRollupBalance"); + 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.subsidizeRollup(_rollup, first); + vm.prank(funder); + rewardDistributor.subsidizeRollup(_rollup, second); + + assertEq(rewardDistributor.specificRollupBalance(_rollup), first + second); + assertEq(rewardDistributor.totalEarmarkedBalance(), first + second); + } + + 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.specificRollupBalance(_rollup); + uint256 prevDebt = rewardDistributor.totalEarmarkedBalance(); + + vm.prank(funder); + rewardDistributor.subsidizeRollup(_rollup, 0); + + assertEq(rewardDistributor.specificRollupBalance(_rollup), prevSpecific); + assertEq(rewardDistributor.totalEarmarkedBalance(), prevDebt); + } +} 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..c564c5ae9362 100644 --- a/l1-contracts/test/rollup/libraries/feelib/initialize.t.sol +++ b/l1-contracts/test/rollup/libraries/feelib/initialize.t.sol @@ -31,6 +31,22 @@ 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_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..33df4727258f 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 }); 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..d3500658b52e 100644 --- a/l1-contracts/test/staking/setSlasher.t.sol +++ b/l1-contracts/test/staking/setSlasher.t.sol @@ -3,28 +3,142 @@ 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(); + } + + function test_queueSetSlasher_whenNotOwner(address _caller) external { + vm.assume(_caller != _owner()); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller)); + vm.prank(_caller); + staking.queueSetSlasher(address(1)); + } - vm.assume(_caller != owner); + function test_cancelSetSlasher_whenNotOwner(address _caller) external { + vm.assume(_caller != _owner()); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller)); vm.prank(_caller); - staking.setSlasher(address(1)); + staking.cancelSetSlasher(); + } + + function test_finalizeSetSlasher_callableByAnyone(address _caller, address _newSlasher) external { + address oldSlasher = staking.getSlasher(); + + 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(); + + 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); + + 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 { + 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.setSlasher(_newSlasher); + staking.finalizeSetSlasher(); + } + + function test_finalizeSetSlasher_revertsBeforeReady(address _newSlasher, uint256 _earlyOffset) external { + uint256 delay = _delay(); + uint256 earlyOffset = bound(_earlyOffset, 0, delay - 1); + address owner = _owner(); + + 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_finalizeSetSlasher_appliesAfterDelay(address _newSlasher) external { + address oldSlasher = staking.getSlasher(); + + 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/updateStakingQueueConfig.t.sol b/l1-contracts/test/staking/updateStakingQueueConfig.t.sol index d3ba64204ee7..175683e7de49 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,11 +32,12 @@ 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. Min and quotient must stay + // strictly positive -- zero is rejected by assertValidQueueConfig. _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.normalFlushSizeMin = bound(_config.normalFlushSizeMin, 1, type(uint32).max); + _config.normalFlushSizeQuotient = bound(_config.normalFlushSizeQuotient, 1, type(uint32).max); _config.maxQueueFlushSize = bound(_config.maxQueueFlushSize, 0, type(uint32).max); Rollup rollup = Rollup(address(registry.getCanonicalRollup())); @@ -44,4 +46,29 @@ 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); + } } 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 2c025c2cb526..f76067e77424 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 @@ -136,6 +136,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); const higherMinFees = lowerMinFees.mul(2); 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 f6ffbe12c371..684de34e133e 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', () => { @@ -85,6 +88,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, }, }); @@ -144,53 +151,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, @@ -209,46 +169,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 a333e9129db4..4fb903fe07a3 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 @@ -134,7 +134,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/ethereum/src/test/rollup_cheat_codes.ts b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts index 48ef7ad3df77..61448cc37d93 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'; @@ -323,7 +324,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) { @@ -333,8 +335,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`); + } }