diff --git a/README.md b/README.md index 3cfb7439..3f4f6a02 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Only one address can have this role. It can: - Do what the curator can do. +- Do what the guardian can do. - Transfer or renounce the ownership. - Set the curator. - Set allocators. @@ -56,9 +57,9 @@ Only one address can have this role. It can: -- Do what the allocators can do. -- [Timelocked] Enable or disable a market by setting a cap to a specific market. - - The cap must be set to 0 to disable the market. +- Do what allocators can do. +- [Timelocked] Enable or disable a market by setting a supply cap to a specific market. + - The supply cap must be set to 0 to disable the market. - Disabling a market can then only be done if the vault has no liquidity supplied on the market. #### Allocator @@ -68,11 +69,11 @@ Multiple addresses can have this role. It can: - Set the `supplyQueue` and `withdrawQueue`, i.e. decide on the order of the markets to supply/withdraw from. - - Upon a deposit, the vault will supply up to the cap of each Morpho Blue market in the supply queue in the order set. - - Upon a withdrawal, the vault will withdraw up to the liquidity of each Morpho Blue market in the withdrawal queue in the order set. - - The `supplyQueue` contains only enabled markets (enabled market are markets with non-zero cap or with non-zero vault's supply). - - The `withdrawQueue` contains all enabled markets. -- Instantaneously reallocate funds among the enabled market at any moment. + - Upon a deposit, the vault will supply up to the cap of each Morpho Blue market in the `supplyQueue` in the order set. + - Upon a withdrawal, the vault will first withdraw from the idle supply and then withdraw up to the liquidity of each Morpho Blue market in the `withdrawalQueue` in the order set. + - The `supplyQueue` only contains markets which cap has previously been non-zero. + - The `withdrawQueue` contains all markets that have a non-zero cap or a non-zero vault allocation. +- Instantaneously reallocate funds by supplying on markets of the `withdrawQueue` and withdrawing from markets that have the same loan asset as the vault's asset. > **Warning** > If `supplyQueue` is empty, depositing to the vault is disabled. @@ -83,7 +84,9 @@ Only one address can have this role. It can: -- Revoke any timelocked action except it cannot revoke a pending fee. +- Revoke the pending timelock. +- Revoke the pending guardian (which means it can revoke any attempt to change the guardian). +- Revoke the pending cap of any market. ### Idle Supply diff --git a/src/MetaMorpho.sol b/src/MetaMorpho.sol index b3f800b7..03dbce99 100644 --- a/src/MetaMorpho.sol +++ b/src/MetaMorpho.sol @@ -3,10 +3,15 @@ pragma solidity 0.8.21; import {IMorphoMarketParams} from "./interfaces/IMorphoMarketParams.sol"; import { - IMetaMorpho, MarketConfig, PendingUint192, PendingAddress, MarketAllocation + MarketConfig, + PendingUint192, + PendingAddress, + MarketAllocation, + IMetaMorphoStaticTyping } from "./interfaces/IMetaMorpho.sol"; import {Id, MarketParams, Market, IMorpho} from "@morpho-blue/interfaces/IMorpho.sol"; +import {PendingUint192, PendingAddress, PendingLib} from "./libraries/PendingLib.sol"; import {ConstantsLib} from "./libraries/ConstantsLib.sol"; import {ErrorsLib} from "./libraries/ErrorsLib.sol"; import {EventsLib} from "./libraries/EventsLib.sol"; @@ -28,7 +33,7 @@ import {IERC20, IERC4626, ERC20, ERC4626, Math, SafeERC20} from "@openzeppelin/t /// @author Morpho Labs /// @custom:contact security@morpho.org /// @notice ERC4626 compliant vault allowing users to deposit assets to Morpho. -contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorpho { +contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorphoStaticTyping { using Math for uint256; using UtilsLib for uint256; using SafeCast for uint256; @@ -37,6 +42,8 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph using SharesMathLib for uint256; using MorphoBalancesLib for IMorpho; using MarketParamsLib for MarketParams; + using PendingLib for PendingUint192; + using PendingLib for PendingAddress; /* IMMUTABLES */ @@ -60,15 +67,6 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph /// @notice The current timelock. uint256 public timelock; - /// @notice The current fee. - uint96 public fee; - - /// @notice The fee recipient. - address public feeRecipient; - - /// @notice The skim recipient. - address public skimRecipient; - /// @notice The pending guardian. PendingAddress public pendingGuardian; @@ -78,8 +76,14 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph /// @notice The pending timelock. PendingUint192 public pendingTimelock; - /// @notice The pending fee. - PendingUint192 public pendingFee; + /// @notice The current fee. + uint96 public fee; + + /// @notice The fee recipient. + address public feeRecipient; + + /// @notice The skim recipient. + address public skimRecipient; /// @dev Stores the order of markets on which liquidity is supplied upon deposit. /// @dev Can contain any market. A market is skipped as soon as its supply cap is reached. @@ -140,9 +144,18 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph _; } - /// @dev Reverts if the caller is not the `guardian`. - modifier onlyGuardian() { - if (_msgSender() != guardian) revert ErrorsLib.NotGuardian(); + /// @dev Reverts if the caller doesn't have the guardian role. + modifier onlyGuardianRole() { + if (_msgSender() != owner() && _msgSender() != guardian) revert ErrorsLib.NotGuardianRole(); + + _; + } + + /// @dev Reverts if the caller doesn't have the curator nor the guardian role. + modifier onlyCuratorOrGuardianRole() { + if (_msgSender() != guardian && _msgSender() != curator && _msgSender() != owner()) { + revert ErrorsLib.NotCuratorNorGuardianRole(); + } _; } @@ -151,9 +164,9 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph /// @dev Reverts if: /// - there's no pending value; /// - the timelock has not elapsed since the pending value has been submitted. - modifier afterTimelock(uint256 submittedAt) { - if (submittedAt == 0) revert ErrorsLib.NoPendingValue(); - if (block.timestamp < submittedAt + timelock) revert ErrorsLib.TimelockNotElapsed(); + modifier afterTimelock(uint256 validAt) { + if (validAt == 0) revert ErrorsLib.NoPendingValue(); + if (block.timestamp < validAt) revert ErrorsLib.TimelockNotElapsed(); _; } @@ -197,28 +210,29 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph if (newTimelock > timelock) { _setTimelock(newTimelock); } else { + // newTimelock >= MIN_TIMELOCK > 0 so there's no need to check `pendingTimelock.validAt != 0`. + if (newTimelock == pendingTimelock.value) revert ErrorsLib.AlreadyPending(); + // Safe "unchecked" cast because newTimelock <= MAX_TIMELOCK. - pendingTimelock = PendingUint192(uint192(newTimelock), uint64(block.timestamp)); + pendingTimelock.update(uint192(newTimelock), timelock); emit EventsLib.SubmitTimelock(newTimelock); } } - /// @notice Submits a `newFee`. - /// @dev In case the new fee is lower than the current one, the fee is set immediately. - /// @dev Warning: Submitting a fee will overwrite the current pending fee. - function submitFee(uint256 newFee) external onlyOwner { + /// @notice Sets the `fee` to `newFee`. + function setFee(uint256 newFee) external onlyOwner { if (newFee == fee) revert ErrorsLib.AlreadySet(); if (newFee > ConstantsLib.MAX_FEE) revert ErrorsLib.MaxFeeExceeded(); + if (newFee != 0 && feeRecipient == address(0)) revert ErrorsLib.ZeroFeeRecipient(); - if (newFee < fee) { - _setFee(newFee); - } else { - // Safe "unchecked" cast because newFee <= MAX_FEE. - pendingFee = PendingUint192(uint192(newFee), uint64(block.timestamp)); + // Accrue interest using the previous fee set before changing it. + _updateLastTotalAssets(_accrueFee()); - emit EventsLib.SubmitFee(newFee); - } + // Safe "unchecked" cast because newFee <= MAX_FEE. + fee = uint96(newFee); + + emit EventsLib.SetFee(_msgSender(), fee); } /// @notice Sets `feeRecipient` to `newFeeRecipient`. @@ -245,7 +259,11 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph if (guardian == address(0)) { _setGuardian(newGuardian); } else { - pendingGuardian = PendingAddress(newGuardian, uint64(block.timestamp)); + if (pendingGuardian.validAt != 0 && newGuardian == pendingGuardian.value) { + revert ErrorsLib.AlreadyPending(); + } + + pendingGuardian.update(newGuardian, timelock); emit EventsLib.SubmitGuardian(newGuardian); } @@ -267,7 +285,10 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph if (newSupplyCap < supplyCap) { _setCap(id, newSupplyCap.toUint192()); } else { - pendingCap[id] = PendingUint192(newSupplyCap.toUint192(), uint64(block.timestamp)); + // newSupplyCap > supplyCap >= 0 so there's no need to check `pendingCap[id].validAt != 0`. + if (newSupplyCap == pendingCap[id].value) revert ErrorsLib.AlreadyPending(); + + pendingCap[id].update(newSupplyCap.toUint192(), timelock); emit EventsLib.SubmitCap(_msgSender(), id, newSupplyCap); } @@ -393,24 +414,30 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph if (totalWithdrawn != totalSupplied) revert ErrorsLib.InconsistentReallocation(); } - /* ONLY GUARDIAN FUNCTIONS */ + /* REVOKE FUNCTIONS */ + + /// @notice Revokes the pending timelock. + function revokePendingTimelock() external onlyGuardianRole { + if (pendingTimelock.validAt == 0) revert ErrorsLib.NoPendingValue(); - /// @notice Revokes the `pendingTimelock`. - function revokePendingTimelock() external onlyGuardian { delete pendingTimelock; emit EventsLib.RevokePendingTimelock(_msgSender()); } - /// @notice Revokes the `pendingGuardian`. - function revokePendingGuardian() external onlyGuardian { + /// @notice Revokes the pending guardian. + function revokePendingGuardian() external onlyGuardianRole { + if (pendingGuardian.validAt == 0) revert ErrorsLib.NoPendingValue(); + delete pendingGuardian; emit EventsLib.RevokePendingGuardian(_msgSender()); } /// @notice Revokes the pending cap of the market defined by `id`. - function revokePendingCap(Id id) external onlyGuardian { + function revokePendingCap(Id id) external onlyCuratorOrGuardianRole { + if (pendingCap[id].validAt == 0) revert ErrorsLib.NoPendingValue(); + delete pendingCap[id]; emit EventsLib.RevokePendingCap(_msgSender(), id); @@ -428,23 +455,18 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph return withdrawQueue.length; } - /// @notice Accepts the `pendingTimelock`. - function acceptTimelock() external afterTimelock(pendingTimelock.submittedAt) { + /// @notice Accepts the pending timelock. + function acceptTimelock() external afterTimelock(pendingTimelock.validAt) { _setTimelock(pendingTimelock.value); } - /// @notice Accepts the `pendingFee`. - function acceptFee() external afterTimelock(pendingFee.submittedAt) { - _setFee(pendingFee.value); - } - - /// @notice Accepts the `pendingGuardian`. - function acceptGuardian() external afterTimelock(pendingGuardian.submittedAt) { + /// @notice Accepts the pending guardian. + function acceptGuardian() external afterTimelock(pendingGuardian.validAt) { _setGuardian(pendingGuardian.value); } /// @notice Accepts the pending cap of the market defined by `id`. - function acceptCap(Id id) external afterTimelock(pendingCap[id].submittedAt) { + function acceptCap(Id id) external afterTimelock(pendingCap[id].validAt) { _setCap(id, pendingCap[id].value); } @@ -462,7 +484,7 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph /* ERC4626 (PUBLIC) */ /// @inheritdoc IERC20Metadata - function decimals() public view override(IERC20Metadata, ERC20, ERC4626) returns (uint8) { + function decimals() public view override(ERC20, ERC4626) returns (uint8) { return ERC4626.decimals(); } @@ -481,21 +503,21 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph /// @inheritdoc IERC4626 /// @dev Warning: May be lower than the actual amount of assets that can be withdrawn by `owner` due to conversion /// roundings between shares and assets. - function maxWithdraw(address owner) public view override(IERC4626, ERC4626) returns (uint256 assets) { + function maxWithdraw(address owner) public view override returns (uint256 assets) { (assets,,) = _maxWithdraw(owner); } /// @inheritdoc IERC4626 /// @dev Warning: May be lower than the actual amount of shares that can be redeemed by `owner` due to conversion /// roundings between shares and assets. - function maxRedeem(address owner) public view override(IERC4626, ERC4626) returns (uint256) { + function maxRedeem(address owner) public view override returns (uint256) { (uint256 assets, uint256 newTotalSupply, uint256 newTotalAssets) = _maxWithdraw(owner); return _convertToSharesWithTotals(assets, newTotalSupply, newTotalAssets, Math.Rounding.Floor); } /// @inheritdoc IERC4626 - function deposit(uint256 assets, address receiver) public override(IERC4626, ERC4626) returns (uint256 shares) { + function deposit(uint256 assets, address receiver) public override returns (uint256 shares) { uint256 newTotalAssets = _accrueFee(); shares = _convertToSharesWithTotals(assets, totalSupply(), newTotalAssets, Math.Rounding.Floor); @@ -503,7 +525,7 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph } /// @inheritdoc IERC4626 - function mint(uint256 shares, address receiver) public override(IERC4626, ERC4626) returns (uint256 assets) { + function mint(uint256 shares, address receiver) public override returns (uint256 assets) { uint256 newTotalAssets = _accrueFee(); assets = _convertToAssetsWithTotals(shares, totalSupply(), newTotalAssets, Math.Rounding.Ceil); @@ -511,11 +533,7 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph } /// @inheritdoc IERC4626 - function withdraw(uint256 assets, address receiver, address owner) - public - override(IERC4626, ERC4626) - returns (uint256 shares) - { + function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256 shares) { uint256 newTotalAssets = _accrueFee(); // Do not call expensive `maxWithdraw` and optimistically withdraw assets. @@ -525,11 +543,7 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph } /// @inheritdoc IERC4626 - function redeem(uint256 shares, address receiver, address owner) - public - override(IERC4626, ERC4626) - returns (uint256 assets) - { + function redeem(uint256 shares, address receiver, address owner) public override returns (uint256 assets) { uint256 newTotalAssets = _accrueFee(); // Do not call expensive `maxRedeem` and optimistically redeem shares. @@ -539,7 +553,7 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph } /// @inheritdoc IERC4626 - function totalAssets() public view override(IERC4626, ERC4626) returns (uint256 assets) { + function totalAssets() public view override returns (uint256 assets) { for (uint256 i; i < withdrawQueue.length; ++i) { assets += _supplyBalance(_marketParams(withdrawQueue[i])); } @@ -706,21 +720,6 @@ contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, Multicall, IMetaMorph delete pendingCap[id]; } - /// @dev Sets `fee` to `newFee`. - function _setFee(uint256 newFee) internal { - if (newFee != 0 && feeRecipient == address(0)) revert ErrorsLib.ZeroFeeRecipient(); - - // Accrue interest using the previous fee set before changing it. - _updateLastTotalAssets(_accrueFee()); - - // Safe "unchecked" cast because newFee <= MAX_FEE. - fee = uint96(newFee); - - emit EventsLib.SetFee(_msgSender(), newFee); - - delete pendingFee; - } - /* LIQUIDITY ALLOCATION */ /// @dev Supplies `assets` to Morpho. diff --git a/src/interfaces/IMetaMorpho.sol b/src/interfaces/IMetaMorpho.sol index 22c7cd6d..4a0c392f 100644 --- a/src/interfaces/IMetaMorpho.sol +++ b/src/interfaces/IMetaMorpho.sol @@ -3,6 +3,9 @@ pragma solidity >=0.5.0; import {IMorpho, Id, MarketParams} from "@morpho-blue/interfaces/IMorpho.sol"; import {IERC4626} from "@openzeppelin/interfaces/IERC4626.sol"; +import {IERC20Permit} from "@openzeppelin/token/ERC20/extensions/IERC20Permit.sol"; + +import {PendingUint192, PendingAddress} from "../libraries/PendingLib.sol"; struct MarketConfig { /// @notice The maximum amount of assets that can be allocated to the market. @@ -11,20 +14,6 @@ struct MarketConfig { uint64 withdrawRank; } -struct PendingUint192 { - /// @notice The pending value to set. - uint192 value; - /// @notice The timestamp at which the value was submitted. - uint64 submittedAt; -} - -struct PendingAddress { - /// @notice The pending value to set. - address value; - /// @notice The timestamp at which the value was submitted. - uint64 submittedAt; -} - /// @dev Either `assets` or `shares` should be zero. struct MarketAllocation { /// @notice The market to allocate. @@ -35,7 +24,21 @@ struct MarketAllocation { uint256 shares; } -interface IMetaMorpho is IERC4626 { +interface IMulticall { + function multicall(bytes[] calldata) external returns (bytes[] memory); +} + +interface IOwnable { + function owner() external returns (address); + function transferOwnership(address) external; + function renounceOwnership() external; + function acceptOwnership() external; + function pendingOwner() external view returns (address); +} + +/// @dev This interface is used for factorizing IMetaMorphoStaticTyping and IMetaMorpho. +/// @dev Consider using the IMetaMorpho interface instead of this one. +interface IMetaMorphoBase { function MORPHO() external view returns (IMorpho); function curator() external view returns (address); @@ -50,33 +53,26 @@ interface IMetaMorpho is IERC4626 { function supplyQueueLength() external view returns (uint256); function withdrawQueue(uint256) external view returns (Id); function withdrawQueueLength() external view returns (uint256); - function config(Id) external view returns (uint192 cap, uint64 withdrawRank); function lastTotalAssets() external view returns (uint256); function submitTimelock(uint256 newTimelock) external; function acceptTimelock() external; function revokePendingTimelock() external; - function pendingTimelock() external view returns (uint192 value, uint64 submittedAt); function submitCap(MarketParams memory marketParams, uint256 supplyCap) external; function acceptCap(Id id) external; function revokePendingCap(Id id) external; - function pendingCap(Id) external view returns (uint192 value, uint64 submittedAt); - - function submitFee(uint256 newFee) external; - function acceptFee() external; - function pendingFee() external view returns (uint192 value, uint64 submittedAt); function submitGuardian(address newGuardian) external; function acceptGuardian() external; function revokePendingGuardian() external; - function pendingGuardian() external view returns (address guardian, uint64 submittedAt); function skim(address) external; function setIsAllocator(address newAllocator, bool newIsAllocator) external; function setCurator(address newCurator) external; + function setFee(uint256 newFee) external; function setFeeRecipient(address newFeeRecipient) external; function setSkimRecipient(address) external; @@ -85,8 +81,22 @@ interface IMetaMorpho is IERC4626 { function reallocate(MarketAllocation[] calldata withdrawn, MarketAllocation[] calldata supplied) external; } -interface IPending { - function pendingTimelock() external view returns (PendingUint192 memory); - function pendingCap(Id) external view returns (PendingUint192 memory); +/// @dev This interface is inherited by MetaMorpho so that function signatures are checked by the compiler. +/// @dev Consider using the IMetaMorpho interface instead of this one. +interface IMetaMorphoStaticTyping is IMetaMorphoBase { + function config(Id) external view returns (uint192 cap, uint64 withdrawRank); + function pendingGuardian() external view returns (address guardian, uint64 validAt); + function pendingCap(Id) external view returns (uint192 value, uint64 validAt); + function pendingTimelock() external view returns (uint192 value, uint64 validAt); +} + +/// @title IMetaMorpho +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @dev Use this interface for MetaMorpho to have access to all the functions with the appropriate function signatures. +interface IMetaMorpho is IMetaMorphoBase, IERC4626, IERC20Permit, IOwnable, IMulticall { + function config(Id) external view returns (MarketConfig memory); function pendingGuardian() external view returns (PendingAddress memory); + function pendingCap(Id) external view returns (PendingUint192 memory); + function pendingTimelock() external view returns (PendingUint192 memory); } diff --git a/src/libraries/ErrorsLib.sol b/src/libraries/ErrorsLib.sol index 29c6e74c..ae925646 100644 --- a/src/libraries/ErrorsLib.sol +++ b/src/libraries/ErrorsLib.sol @@ -17,8 +17,11 @@ library ErrorsLib { /// @notice Thrown when the caller doesn't have the allocator role. error NotAllocatorRole(); - /// @notice Thrown when the caller is not the guardian. - error NotGuardian(); + /// @notice Thrown when the caller doesn't have the guardian role. + error NotGuardianRole(); + + /// @notice Thrown when the caller doesn't have the curator nor the guardian role. + error NotCuratorNorGuardianRole(); /// @notice Thrown when the market `id` cannot be set in the supply queue. error UnauthorizedMarket(Id id); @@ -36,6 +39,9 @@ library ErrorsLib { /// @notice Thrown when the value is already set. error AlreadySet(); + /// @notice Thrown when the value is already pending. + error AlreadyPending(); + /// @notice Thrown when market `id` is a duplicate in the new withdraw queue to set. error DuplicateMarket(Id id); diff --git a/src/libraries/EventsLib.sol b/src/libraries/EventsLib.sol index 555dd87b..48531020 100644 --- a/src/libraries/EventsLib.sol +++ b/src/libraries/EventsLib.sol @@ -2,7 +2,8 @@ pragma solidity ^0.8.0; import {Id} from "@morpho-blue/interfaces/IMorpho.sol"; -import {PendingUint192, PendingAddress} from "../interfaces/IMetaMorpho.sol"; + +import {PendingUint192, PendingAddress} from "./PendingLib.sol"; /// @title EventsLib /// @author Morpho Labs @@ -18,9 +19,6 @@ library EventsLib { /// @notice Emitted when `skimRecipient` is set to `newSkimRecipient`. event SetSkimRecipient(address indexed newSkimRecipient); - /// @notice Emitted when a pending `newFee` is submitted. - event SubmitFee(uint256 newFee); - /// @notice Emitted `fee` is set to `newFee`. event SetFee(address indexed caller, uint256 newFee); @@ -57,7 +55,7 @@ library EventsLib { /// @notice Emitted when a `pendingGuardian` is revoked. event RevokePendingGuardian(address indexed caller); - /// @notice Emitted when the `supplyQgueue` is set to `newSupplyQueue`. + /// @notice Emitted when the `supplyQueue` is set to `newSupplyQueue`. event SetSupplyQueue(address indexed caller, Id[] newSupplyQueue); /// @notice Emitted when the `withdrawQueue` is set to `newWithdrawQueue`. diff --git a/src/libraries/PendingLib.sol b/src/libraries/PendingLib.sol new file mode 100644 index 00000000..88022107 --- /dev/null +++ b/src/libraries/PendingLib.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +struct PendingUint192 { + /// @notice The pending value to set. + uint192 value; + /// @notice The timestamp at which the pending value becomes valid. + uint64 validAt; +} + +struct PendingAddress { + /// @notice The pending value to set. + address value; + /// @notice The timestamp at which the pending value becomes valid. + uint64 validAt; +} + +/// @title PendingLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library to manage pending values and their validity timestamp. +library PendingLib { + /// @dev Updates `pending`'s value to `newValue` and its corresponding `validAt` timestamp. + /// @dev Assumes `timelock` <= `MAX_TIMELOCK`. + function update(PendingUint192 storage pending, uint192 newValue, uint256 timelock) internal { + pending.value = newValue; + // Safe "unchecked" cast because timelock <= MAX_TIMELOCK. + pending.validAt = uint64(block.timestamp + timelock); + } + + /// @dev Updates `pending`'s value to `newValue` and its corresponding `validAt` timestamp. + /// @dev Assumes `timelock` <= `MAX_TIMELOCK`. + function update(PendingAddress storage pending, address newValue, uint256 timelock) internal { + pending.value = newValue; + // Safe "unchecked" cast because timelock <= MAX_TIMELOCK. + pending.validAt = uint64(block.timestamp + timelock); + } +} diff --git a/test/forge/FeeTest.sol b/test/forge/FeeTest.sol index 283fd2d7..b4dd472d 100644 --- a/test/forge/FeeTest.sol +++ b/test/forge/FeeTest.sol @@ -41,6 +41,18 @@ contract FeeTest is IntegrationTest { _sortSupplyQueueIdleLast(); } + function testSetFee(uint256 fee) public { + fee = bound(fee, 0, ConstantsLib.MAX_FEE); + vm.assume(fee != vault.fee()); + + vm.expectEmit(address(vault)); + emit EventsLib.SetFee(OWNER, fee); + vm.prank(OWNER); + vault.setFee(fee); + + assertEq(vault.fee(), fee, "fee"); + } + function _feeShares(uint256 totalAssetsBefore) internal view returns (uint256) { uint256 totalAssetsAfter = vault.totalAssets(); uint256 interest = totalAssetsAfter - totalAssetsBefore; @@ -249,23 +261,37 @@ contract FeeTest is IntegrationTest { assertEq(vault.balanceOf(address(1)), 0, "vault.balanceOf(address(1))"); } - function testSubmitFeeNotOwner(uint256 fee) public { + function testSetFeeNotOwner(uint256 fee) public { vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); - vault.submitFee(fee); + vault.setFee(fee); } - function testSubmitFeeMaxFeeExceeded(uint256 fee) public { + function testSetFeeMaxFeeExceeded(uint256 fee) public { fee = bound(fee, ConstantsLib.MAX_FEE + 1, type(uint256).max); vm.prank(OWNER); vm.expectRevert(ErrorsLib.MaxFeeExceeded.selector); - vault.submitFee(fee); + vault.setFee(fee); } - function testSubmitFeeAlreadySet() public { + function testSetFeeAlreadySet() public { vm.prank(OWNER); vm.expectRevert(ErrorsLib.AlreadySet.selector); - vault.submitFee(FEE); + vault.setFee(FEE); + } + + function testSetFeeZeroFeeRecipient(uint256 fee) public { + fee = bound(fee, 1, ConstantsLib.MAX_FEE); + + vm.startPrank(OWNER); + + vault.setFee(0); + vault.setFeeRecipient(address(0)); + + vm.expectRevert(ErrorsLib.ZeroFeeRecipient.selector); + vault.setFee(fee); + + vm.stopPrank(); } function testSetFeeRecipientAlreadySet() public { diff --git a/test/forge/GuardianTest.sol b/test/forge/GuardianTest.sol index ea917401..0c338024 100644 --- a/test/forge/GuardianTest.sol +++ b/test/forge/GuardianTest.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "./helpers/IntegrationTest.sol"; +uint256 constant FEE = 0.1 ether; // 10% uint256 constant TIMELOCK = 1 weeks; contract GuardianTest is IntegrationTest { @@ -13,6 +14,10 @@ contract GuardianTest is IntegrationTest { function setUp() public override { super.setUp(); + vm.prank(OWNER); + vault.setFeeRecipient(FEE_RECIPIENT); + + _setFee(FEE); _setTimelock(TIMELOCK); _setGuardian(GUARDIAN); } @@ -28,7 +33,7 @@ contract GuardianTest is IntegrationTest { vault.submitGuardian(GUARDIAN); } - function testRevokePendingTimelockDecreased(uint256 timelock, uint256 elapsed) public { + function testGuardianRevokePendingTimelockDecreased(uint256 timelock, uint256 elapsed) public { timelock = bound(timelock, ConstantsLib.MIN_TIMELOCK, TIMELOCK - 1); elapsed = bound(elapsed, 0, TIMELOCK - 1); @@ -43,14 +48,36 @@ contract GuardianTest is IntegrationTest { vault.revokePendingTimelock(); uint256 newTimelock = vault.timelock(); - (uint256 pendingTimelock, uint64 submittedAt) = vault.pendingTimelock(); + PendingUint192 memory pendingTimelock = vault.pendingTimelock(); + + assertEq(newTimelock, TIMELOCK, "newTimelock"); + assertEq(pendingTimelock.value, 0, "pendingTimelock.value"); + assertEq(pendingTimelock.validAt, 0, "pendingTimelock.validAt"); + } + + function testOwnerRevokePendingTimelockDecreased(uint256 timelock, uint256 elapsed) public { + timelock = bound(timelock, ConstantsLib.MIN_TIMELOCK, TIMELOCK - 1); + elapsed = bound(elapsed, 0, TIMELOCK - 1); + + vm.prank(OWNER); + vault.submitTimelock(timelock); + + vm.warp(block.timestamp + elapsed); + + vm.expectEmit(); + emit EventsLib.RevokePendingTimelock(OWNER); + vm.prank(OWNER); + vault.revokePendingTimelock(); + + uint256 newTimelock = vault.timelock(); + PendingUint192 memory pendingTimelock = vault.pendingTimelock(); assertEq(newTimelock, TIMELOCK, "newTimelock"); - assertEq(pendingTimelock, 0, "pendingTimelock"); - assertEq(submittedAt, 0, "submittedAt"); + assertEq(pendingTimelock.value, 0, "value"); + assertEq(pendingTimelock.validAt, 0, "validAt"); } - function testRevokePendingCapIncreased(uint256 seed, uint256 cap, uint256 elapsed) public { + function testGuardianRevokePendingCapIncreased(uint256 seed, uint256 cap, uint256 elapsed) public { MarketParams memory marketParams = _randomMarketParams(seed); elapsed = bound(elapsed, 0, TIMELOCK - 1); cap = bound(cap, 1, type(uint192).max); @@ -67,16 +94,16 @@ contract GuardianTest is IntegrationTest { vm.prank(GUARDIAN); vault.revokePendingCap(id); - (uint192 newCap, uint64 withdrawRank) = vault.config(id); - (uint256 pendingCap, uint64 submittedAt) = vault.pendingCap(id); + MarketConfig memory marketConfig = vault.config(id); + PendingUint192 memory pendingCap = vault.pendingCap(id); - assertEq(newCap, 0, "newCap"); - assertEq(withdrawRank, 0, "withdrawRank"); - assertEq(pendingCap, 0, "pendingCap"); - assertEq(submittedAt, 0, "submittedAt"); + assertEq(marketConfig.cap, 0, "marketConfig.cap"); + assertEq(marketConfig.withdrawRank, 0, "marketConfig.withdrawRank"); + assertEq(pendingCap.value, 0, "pendingCap.value"); + assertEq(pendingCap.validAt, 0, "pendingCap.validAt"); } - function testRevokePendingGuardian(uint256 elapsed) public { + function testGuardianRevokePendingGuardian(uint256 elapsed) public { elapsed = bound(elapsed, 0, TIMELOCK - 1); address guardian = makeAddr("Guardian2"); @@ -92,10 +119,10 @@ contract GuardianTest is IntegrationTest { vault.revokePendingGuardian(); address newGuardian = vault.guardian(); - (address pendingGuardian, uint96 submittedAt) = vault.pendingGuardian(); + PendingAddress memory pendingGuardian = vault.pendingGuardian(); assertEq(newGuardian, GUARDIAN, "newGuardian"); - assertEq(pendingGuardian, address(0), "pendingGuardian"); - assertEq(submittedAt, 0, "submittedAt"); + assertEq(pendingGuardian.value, address(0), "pendingGuardian.value"); + assertEq(pendingGuardian.validAt, 0, "pendingGuardian.validAt"); } } diff --git a/test/forge/RevokeTest.sol b/test/forge/RevokeTest.sol new file mode 100644 index 00000000..26cd2ce4 --- /dev/null +++ b/test/forge/RevokeTest.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "./helpers/IntegrationTest.sol"; + +uint256 constant FEE = 0.1 ether; // 10% +uint256 constant TIMELOCK = 1 weeks; + +contract RevokeTest is IntegrationTest { + using Math for uint256; + using MathLib for uint256; + using MarketParamsLib for MarketParams; + + function setUp() public override { + super.setUp(); + + vm.prank(OWNER); + vault.setFeeRecipient(FEE_RECIPIENT); + + _setFee(FEE); + _setTimelock(TIMELOCK); + _setGuardian(GUARDIAN); + } + + function testOwnerRevokeTimelockDecreased(uint256 timelock, uint256 elapsed) public { + timelock = bound(timelock, ConstantsLib.MIN_TIMELOCK, TIMELOCK - 1); + elapsed = bound(elapsed, 0, TIMELOCK - 1); + + vm.prank(OWNER); + vault.submitTimelock(timelock); + + vm.warp(block.timestamp + elapsed); + + vm.expectEmit(); + emit EventsLib.RevokePendingTimelock(OWNER); + vm.prank(OWNER); + vault.revokePendingTimelock(); + + uint256 newTimelock = vault.timelock(); + PendingUint192 memory pendingTimelock = vault.pendingTimelock(); + + assertEq(newTimelock, TIMELOCK, "newTimelock"); + assertEq(pendingTimelock.value, 0, "value"); + assertEq(pendingTimelock.validAt, 0, "validAt"); + } + + function testCuratorRevokeCapIncreased(uint256 seed, uint256 cap, uint256 elapsed) public { + MarketParams memory marketParams = _randomMarketParams(seed); + elapsed = bound(elapsed, 0, TIMELOCK - 1); + cap = bound(cap, 1, type(uint192).max); + + vm.prank(OWNER); + vault.submitCap(marketParams, cap); + + vm.warp(block.timestamp + elapsed); + + Id id = marketParams.id(); + + vm.expectEmit(); + emit EventsLib.RevokePendingCap(CURATOR, id); + vm.prank(CURATOR); + vault.revokePendingCap(id); + + MarketConfig memory marketConfig = vault.config(id); + PendingUint192 memory pendingCap = vault.pendingCap(id); + + assertEq(marketConfig.cap, 0, "cap"); + assertEq(marketConfig.withdrawRank, 0, "withdrawRank"); + assertEq(pendingCap.value, 0, "value"); + assertEq(pendingCap.validAt, 0, "validAt"); + } + + function testOwnerRevokeCapIncreased(uint256 seed, uint256 cap, uint256 elapsed) public { + MarketParams memory marketParams = _randomMarketParams(seed); + elapsed = bound(elapsed, 0, TIMELOCK - 1); + cap = bound(cap, 1, type(uint192).max); + + vm.prank(OWNER); + vault.submitCap(marketParams, cap); + + vm.warp(block.timestamp + elapsed); + + Id id = marketParams.id(); + + vm.expectEmit(); + emit EventsLib.RevokePendingCap(OWNER, id); + vm.prank(OWNER); + vault.revokePendingCap(id); + + MarketConfig memory marketConfig = vault.config(id); + PendingUint192 memory pendingCap = vault.pendingCap(id); + + assertEq(marketConfig.cap, 0, "cap"); + assertEq(marketConfig.withdrawRank, 0, "withdrawRank"); + assertEq(pendingCap.value, 0, "value"); + assertEq(pendingCap.validAt, 0, "validAt"); + } + + function testOwnerRevokeGuardian(uint256 elapsed) public { + elapsed = bound(elapsed, 0, TIMELOCK - 1); + + address guardian = makeAddr("Guardian2"); + + vm.prank(OWNER); + vault.submitGuardian(guardian); + + vm.warp(block.timestamp + elapsed); + + vm.expectEmit(); + emit EventsLib.RevokePendingGuardian(GUARDIAN); + vm.prank(GUARDIAN); + vault.revokePendingGuardian(); + + address newGuardian = vault.guardian(); + PendingAddress memory pendingGuardian = vault.pendingGuardian(); + + assertEq(newGuardian, GUARDIAN, "newGuardian"); + assertEq(pendingGuardian.value, address(0), "value"); + assertEq(pendingGuardian.validAt, 0, "validAt"); + } + + function testOwnerRevokePendingCapNoPendingValue(uint256 seed) public { + MarketParams memory marketParams = _randomMarketParams(seed); + + vm.prank(OWNER); + vm.expectRevert(ErrorsLib.NoPendingValue.selector); + vault.revokePendingCap(marketParams.id()); + } + + function testOwnerRevokePendingTimelockNoPendingValue() public { + vm.prank(OWNER); + vm.expectRevert(ErrorsLib.NoPendingValue.selector); + vault.revokePendingTimelock(); + } + + function testOwnerRevokePendingGuardianNoPendingValue() public { + vm.prank(OWNER); + vm.expectRevert(ErrorsLib.NoPendingValue.selector); + vault.revokePendingGuardian(); + } +} diff --git a/test/forge/RoleTest.sol b/test/forge/RoleTest.sol index 9a66c9a7..d6fd7659 100644 --- a/test/forge/RoleTest.sol +++ b/test/forge/RoleTest.sol @@ -64,7 +64,7 @@ contract RoleTest is IntegrationTest { vault.submitTimelock(1); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(caller))); - vault.submitFee(1); + vault.setFee(1); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(caller))); vault.submitGuardian(address(1)); diff --git a/test/forge/TimelockTest.sol b/test/forge/TimelockTest.sol index a484b986..2c9eae81 100644 --- a/test/forge/TimelockTest.sol +++ b/test/forge/TimelockTest.sol @@ -31,11 +31,11 @@ contract TimelockTest is IntegrationTest { vault.submitTimelock(timelock); uint256 newTimelock = vault.timelock(); - (uint256 pendingTimelock, uint64 submittedAt) = vault.pendingTimelock(); + PendingUint192 memory pendingTimelock = vault.pendingTimelock(); assertEq(newTimelock, timelock, "newTimelock"); - assertEq(pendingTimelock, 0, "pendingTimelock"); - assertEq(submittedAt, 0, "submittedAt"); + assertEq(pendingTimelock.value, 0, "pendingTimelock.value"); + assertEq(pendingTimelock.validAt, 0, "pendingTimelock.validAt"); } function testSubmitTimelockDecreased(uint256 timelock) public { @@ -47,11 +47,22 @@ contract TimelockTest is IntegrationTest { vault.submitTimelock(timelock); uint256 newTimelock = vault.timelock(); - (uint256 pendingTimelock, uint64 submittedAt) = vault.pendingTimelock(); + PendingUint192 memory pendingTimelock = vault.pendingTimelock(); assertEq(newTimelock, TIMELOCK, "newTimelock"); - assertEq(pendingTimelock, timelock, "pendingTimelock"); - assertEq(submittedAt, block.timestamp, "submittedAt"); + assertEq(pendingTimelock.value, timelock, "pendingTimelock.value"); + assertEq(pendingTimelock.validAt, block.timestamp + TIMELOCK, "pendingTimelock.validAt"); + } + + function testSubmitTimelockAlreadyPending(uint256 timelock) public { + timelock = bound(timelock, ConstantsLib.MIN_TIMELOCK, TIMELOCK - 1); + + vm.prank(OWNER); + vault.submitTimelock(timelock); + + vm.expectRevert(ErrorsLib.AlreadyPending.selector); + vm.prank(OWNER); + vault.submitTimelock(timelock); } function testSubmitTimelockNotOwner(uint256 timelock) public { @@ -110,11 +121,11 @@ contract TimelockTest is IntegrationTest { vault.acceptTimelock(); uint256 newTimelock = vault.timelock(); - (uint256 pendingTimelock, uint64 submittedAt) = vault.pendingTimelock(); + PendingUint192 memory pendingTimelock = vault.pendingTimelock(); assertEq(newTimelock, timelock, "newTimelock"); - assertEq(pendingTimelock, 0, "pendingTimelock"); - assertEq(submittedAt, 0, "submittedAt"); + assertEq(pendingTimelock.value, 0, "pendingTimelock.value"); + assertEq(pendingTimelock.validAt, 0, "pendingTimelock.validAt"); } function testAcceptTimelockNoPendingValue() public { @@ -135,78 +146,6 @@ contract TimelockTest is IntegrationTest { vault.acceptTimelock(); } - function testSubmitFeeDecreased(uint256 fee) public { - fee = bound(fee, 0, FEE - 1); - - vm.expectEmit(); - emit EventsLib.UpdateLastTotalAssets(vault.totalAssets()); - emit EventsLib.SetFee(OWNER, fee); - vm.prank(OWNER); - vault.submitFee(fee); - - uint256 newFee = vault.fee(); - (uint256 pendingFee, uint64 submittedAt) = vault.pendingFee(); - - assertEq(newFee, fee, "newFee"); - assertEq(pendingFee, 0, "pendingFee"); - assertEq(submittedAt, 0, "submittedAt"); - } - - function testSubmitFeeIncreased(uint256 fee) public { - fee = bound(fee, FEE + 1, ConstantsLib.MAX_FEE); - - vm.expectEmit(); - emit EventsLib.SubmitFee(fee); - vm.prank(OWNER); - vault.submitFee(fee); - - uint256 newFee = vault.fee(); - (uint256 pendingFee, uint64 submittedAt) = vault.pendingFee(); - - assertEq(newFee, FEE, "newFee"); - assertEq(pendingFee, fee, "pendingFee"); - assertEq(submittedAt, block.timestamp, "submittedAt"); - } - - function testAcceptFee(uint256 fee) public { - fee = bound(fee, FEE + 1, ConstantsLib.MAX_FEE); - - vm.prank(OWNER); - vault.submitFee(fee); - - vm.warp(block.timestamp + TIMELOCK); - - vm.expectEmit(address(vault)); - emit EventsLib.UpdateLastTotalAssets(vault.totalAssets()); - emit EventsLib.SetFee(address(this), fee); - vault.acceptFee(); - - uint256 newFee = vault.fee(); - (uint256 pendingFee, uint64 submittedAt) = vault.pendingFee(); - - assertEq(newFee, fee, "newFee"); - assertEq(pendingFee, 0, "pendingFee"); - assertEq(submittedAt, 0, "submittedAt"); - } - - function testAcceptFeeNoPendingValue() public { - vm.expectRevert(ErrorsLib.NoPendingValue.selector); - vault.acceptFee(); - } - - function testAcceptFeeTimelockNotElapsed(uint256 fee, uint256 elapsed) public { - fee = bound(fee, FEE + 1, ConstantsLib.MAX_FEE); - elapsed = bound(elapsed, 1, TIMELOCK - 1); - - vm.prank(OWNER); - vault.submitFee(fee); - - vm.warp(block.timestamp + elapsed); - - vm.expectRevert(ErrorsLib.TimelockNotElapsed.selector); - vault.acceptFee(); - } - function testSubmitGuardian() public { address guardian = makeAddr("Guardian2"); @@ -216,11 +155,11 @@ contract TimelockTest is IntegrationTest { vault.submitGuardian(guardian); address newGuardian = vault.guardian(); - (address pendingGuardian, uint96 submittedAt) = vault.pendingGuardian(); + PendingAddress memory pendingGuardian = vault.pendingGuardian(); assertEq(newGuardian, GUARDIAN, "newGuardian"); - assertEq(pendingGuardian, guardian, "pendingGuardian"); - assertEq(submittedAt, block.timestamp, "submittedAt"); + assertEq(pendingGuardian.value, guardian, "pendingGuardian.value"); + assertEq(pendingGuardian.validAt, block.timestamp + TIMELOCK, "pendingGuardian.validAt"); } function testSubmitGuardianFromZero() public { @@ -232,11 +171,11 @@ contract TimelockTest is IntegrationTest { vault.submitGuardian(GUARDIAN); address newGuardian = vault.guardian(); - (address pendingGuardian, uint96 submittedAt) = vault.pendingGuardian(); + PendingAddress memory pendingGuardian = vault.pendingGuardian(); assertEq(newGuardian, GUARDIAN, "newGuardian"); - assertEq(pendingGuardian, address(0), "pendingGuardian"); - assertEq(submittedAt, 0, "submittedAt"); + assertEq(pendingGuardian.value, address(0), "pendingGuardian.value"); + assertEq(pendingGuardian.validAt, 0, "pendingGuardian.validAt"); } function testSubmitGuardianZero() public { @@ -244,11 +183,22 @@ contract TimelockTest is IntegrationTest { vault.submitGuardian(address(0)); address newGuardian = vault.guardian(); - (address pendingGuardian, uint96 submittedAt) = vault.pendingGuardian(); + PendingAddress memory pendingGuardian = vault.pendingGuardian(); assertEq(newGuardian, GUARDIAN, "newGuardian"); - assertEq(pendingGuardian, address(0), "pendingGuardian"); - assertEq(submittedAt, block.timestamp, "submittedAt"); + assertEq(pendingGuardian.value, address(0), "pendingGuardian.value"); + assertEq(pendingGuardian.validAt, block.timestamp + TIMELOCK, "pendingGuardian.validAt"); + } + + function testSubmitGuardianAlreadyPending() public { + address guardian = makeAddr("Guardian2"); + + vm.prank(OWNER); + vault.submitGuardian(guardian); + + vm.expectRevert(ErrorsLib.AlreadyPending.selector); + vm.prank(OWNER); + vault.submitGuardian(guardian); } function testAcceptGuardian() public { @@ -264,11 +214,58 @@ contract TimelockTest is IntegrationTest { vault.acceptGuardian(); address newGuardian = vault.guardian(); - (address pendingGuardian, uint96 submittedAt) = vault.pendingGuardian(); + PendingAddress memory pendingGuardian = vault.pendingGuardian(); + + assertEq(newGuardian, guardian, "newGuardian"); + assertEq(pendingGuardian.value, address(0), "pendingGuardian.value"); + assertEq(pendingGuardian.validAt, 0, "pendingGuardian.validAt"); + } + + function testAcceptGuardianTimelockIncreased(uint256 timelock, uint256 elapsed) public { + timelock = bound(timelock, TIMELOCK + 1, ConstantsLib.MAX_TIMELOCK); + elapsed = bound(elapsed, TIMELOCK + 1, timelock); + + address guardian = makeAddr("Guardian2"); + + vm.prank(OWNER); + vault.submitGuardian(guardian); + + _setTimelock(timelock); + + vm.warp(block.timestamp + elapsed); + + vm.expectEmit(address(vault)); + emit EventsLib.SetGuardian(address(this), guardian); + vault.acceptGuardian(); + + address newGuardian = vault.guardian(); + PendingAddress memory pendingGuardian = vault.pendingGuardian(); assertEq(newGuardian, guardian, "newGuardian"); - assertEq(pendingGuardian, address(0), "pendingGuardian"); - assertEq(submittedAt, 0, "submittedAt"); + assertEq(pendingGuardian.value, address(0), "pendingGuardian.value"); + assertEq(pendingGuardian.validAt, 0, "pendingGuardian.validAt"); + } + + function testAcceptGuardianTimelockDecreased(uint256 timelock, uint256 elapsed) public { + timelock = bound(timelock, ConstantsLib.MIN_TIMELOCK, TIMELOCK - 1); + elapsed = bound(elapsed, 1, TIMELOCK - 1); + + vm.prank(OWNER); + vault.submitTimelock(timelock); + + vm.warp(block.timestamp + elapsed); + + address guardian = makeAddr("Guardian2"); + + vm.prank(OWNER); + vault.submitGuardian(guardian); + + vm.warp(block.timestamp + TIMELOCK - elapsed); + + vault.acceptTimelock(); + + vm.expectRevert(ErrorsLib.TimelockNotElapsed.selector); + vault.acceptGuardian(); } function testAcceptGuardianNoPendingValue() public { @@ -301,13 +298,13 @@ contract TimelockTest is IntegrationTest { vm.prank(CURATOR); vault.submitCap(marketParams, cap); - (uint192 newCap, uint64 withdrawRank) = vault.config(id); - (uint192 pendingCap, uint64 submittedAt) = vault.pendingCap(id); + MarketConfig memory marketConfig = vault.config(id); + PendingUint192 memory pendingCap = vault.pendingCap(id); - assertEq(newCap, cap, "newCap"); - assertEq(withdrawRank, 2, "withdrawRank"); - assertEq(pendingCap, 0, "pendingCap"); - assertEq(submittedAt, 0, "submittedAt"); + assertEq(marketConfig.cap, cap, "marketConfig.cap"); + assertEq(marketConfig.withdrawRank, 2, "marketConfig.withdrawRank"); + assertEq(pendingCap.value, 0, "pendingCap.value"); + assertEq(pendingCap.validAt, 0, "pendingCap.validAt"); } function testSubmitCapIncreased(uint256 cap) public { @@ -321,17 +318,30 @@ contract TimelockTest is IntegrationTest { vm.prank(CURATOR); vault.submitCap(marketParams, cap); - (uint192 newCap, uint64 withdrawRank) = vault.config(id); - (uint192 pendingCap, uint64 submittedAt) = vault.pendingCap(id); + MarketConfig memory marketConfig = vault.config(id); + PendingUint192 memory pendingCap = vault.pendingCap(id); - assertEq(newCap, 0, "newCap"); - assertEq(withdrawRank, 0, "withdrawRank"); - assertEq(pendingCap, cap, "pendingCap"); - assertEq(submittedAt, block.timestamp, "submittedAt"); + assertEq(marketConfig.cap, 0, "marketConfig.cap"); + assertEq(marketConfig.withdrawRank, 0, "marketConfig.withdrawRank"); + assertEq(pendingCap.value, cap, "pendingCap.value"); + assertEq(pendingCap.validAt, block.timestamp + TIMELOCK, "pendingCap.validAt"); assertEq(vault.supplyQueueLength(), 2, "supplyQueueLength"); assertEq(vault.withdrawQueueLength(), 2, "withdrawQueueLength"); } + function testSubmitCapAlreadyPending(uint256 cap) public { + cap = bound(cap, 1, type(uint192).max); + + MarketParams memory marketParams = allMarkets[1]; + + vm.prank(CURATOR); + vault.submitCap(marketParams, cap); + + vm.expectRevert(ErrorsLib.AlreadyPending.selector); + vm.prank(CURATOR); + vault.submitCap(marketParams, cap); + } + function testAcceptCapIncreased(uint256 cap) public { cap = bound(cap, CAP + 1, type(uint192).max); @@ -347,17 +357,71 @@ contract TimelockTest is IntegrationTest { emit EventsLib.SetCap(address(this), id, cap); vault.acceptCap(id); - (uint192 newCap, uint64 withdrawRank) = vault.config(id); - (uint192 pendingCapAfter, uint64 submittedAtAfter) = vault.pendingCap(id); + MarketConfig memory marketConfig = vault.config(id); + PendingUint192 memory pendingCap = vault.pendingCap(id); - assertEq(newCap, cap, "newCap"); - assertEq(withdrawRank, 2, "withdrawRank"); - assertEq(pendingCapAfter, 0, "pendingCapAfter"); - assertEq(submittedAtAfter, 0, "submittedAtAfter"); + assertEq(marketConfig.cap, cap, "marketConfig.cap"); + assertEq(marketConfig.withdrawRank, 2, "marketConfig.withdrawRank"); + assertEq(pendingCap.value, 0, "pendingCap.value"); + assertEq(pendingCap.validAt, 0, "pendingCap.validAt"); assertEq(Id.unwrap(vault.supplyQueue(1)), Id.unwrap(id), "supplyQueue"); assertEq(Id.unwrap(vault.withdrawQueue(1)), Id.unwrap(id), "withdrawQueue"); } + function testAcceptCapIncreasedTimelockIncreased(uint256 cap, uint256 timelock, uint256 elapsed) public { + cap = bound(cap, CAP + 1, type(uint192).max); + timelock = bound(timelock, TIMELOCK + 1, ConstantsLib.MAX_TIMELOCK); + elapsed = bound(elapsed, TIMELOCK + 1, timelock); + + MarketParams memory marketParams = allMarkets[0]; + Id id = marketParams.id(); + + vm.prank(CURATOR); + vault.submitCap(marketParams, cap); + + _setTimelock(timelock); + + vm.warp(block.timestamp + elapsed); + + vm.expectEmit(); + emit EventsLib.SetCap(address(this), id, cap); + vault.acceptCap(id); + + MarketConfig memory marketConfig = vault.config(id); + PendingUint192 memory pendingCap = vault.pendingCap(id); + + assertEq(marketConfig.cap, cap, "marketConfig.cap"); + assertEq(marketConfig.withdrawRank, 1, "marketConfig.withdrawRank"); + assertEq(pendingCap.value, 0, "pendingCap.value"); + assertEq(pendingCap.validAt, 0, "pendingCap.validAt"); + assertEq(Id.unwrap(vault.supplyQueue(0)), Id.unwrap(id), "supplyQueue"); + assertEq(Id.unwrap(vault.withdrawQueue(0)), Id.unwrap(id), "withdrawQueue"); + } + + function testAcceptCapIncreasedTimelockDecreased(uint256 cap, uint256 timelock, uint256 elapsed) public { + cap = bound(cap, CAP + 1, type(uint192).max); + timelock = bound(timelock, ConstantsLib.MIN_TIMELOCK, TIMELOCK - 1); + elapsed = bound(elapsed, 1, TIMELOCK - 1); + + vm.prank(OWNER); + vault.submitTimelock(timelock); + + vm.warp(block.timestamp + elapsed); + + MarketParams memory marketParams = allMarkets[0]; + Id id = marketParams.id(); + + vm.prank(CURATOR); + vault.submitCap(marketParams, cap); + + vm.warp(block.timestamp + TIMELOCK - elapsed); + + vault.acceptTimelock(); + + vm.expectRevert(ErrorsLib.TimelockNotElapsed.selector); + vault.acceptCap(id); + } + function testAcceptCapNoPendingValue() public { vm.expectRevert(ErrorsLib.NoPendingValue.selector); vault.acceptCap(allMarkets[0].id()); diff --git a/test/forge/helpers/BaseTest.sol b/test/forge/helpers/BaseTest.sol index ac8dd0bc..fd4c50e4 100644 --- a/test/forge/helpers/BaseTest.sol +++ b/test/forge/helpers/BaseTest.sol @@ -9,7 +9,7 @@ import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol"; import {MorphoLib} from "@morpho-blue/libraries/periphery/MorphoLib.sol"; import {MorphoBalancesLib} from "@morpho-blue/libraries/periphery/MorphoBalancesLib.sol"; -import {IPending} from "src/interfaces/IMetaMorpho.sol"; +import "src/interfaces/IMetaMorpho.sol"; import "src/libraries/ConstantsLib.sol"; import {ErrorsLib} from "src/libraries/ErrorsLib.sol"; diff --git a/test/forge/helpers/IntegrationTest.sol b/test/forge/helpers/IntegrationTest.sol index a9de9a22..0ceb15ba 100644 --- a/test/forge/helpers/IntegrationTest.sol +++ b/test/forge/helpers/IntegrationTest.sol @@ -8,13 +8,16 @@ contract IntegrationTest is BaseTest { using MorphoBalancesLib for IMorpho; using MarketParamsLib for MarketParams; - MetaMorpho internal vault; + IMetaMorpho internal vault; function setUp() public virtual override { super.setUp(); - vault = - new MetaMorpho(OWNER, address(morpho), ConstantsLib.MIN_TIMELOCK, address(loanToken), "MetaMorpho Vault", "MMV"); + vault = IMetaMorpho( + address( + new MetaMorpho(OWNER, address(morpho), ConstantsLib.MIN_TIMELOCK, address(loanToken), "MetaMorpho Vault", "MMV") + ) + ); vm.startPrank(OWNER); vault.setCurator(CURATOR); @@ -52,10 +55,13 @@ contract IntegrationTest is BaseTest { // block.timestamp defaults to 1 which may lead to an unrealistic state: block.timestamp < timelock. if (block.timestamp < timelock) vm.warp(block.timestamp + timelock); - vm.prank(OWNER); - vault.submitTimelock(newTimelock); + PendingUint192 memory pendingTimelock = vault.pendingTimelock(); + if (pendingTimelock.validAt == 0 || newTimelock != pendingTimelock.value) { + vm.prank(OWNER); + vault.submitTimelock(newTimelock); + } - if (newTimelock > timelock || timelock == 0) return; + if (newTimelock > timelock) return; vm.warp(block.timestamp + timelock); @@ -68,13 +74,15 @@ contract IntegrationTest is BaseTest { address guardian = vault.guardian(); if (newGuardian == guardian) return; - vm.prank(OWNER); - vault.submitGuardian(newGuardian); + PendingAddress memory pendingGuardian = vault.pendingGuardian(); + if (pendingGuardian.validAt == 0 || newGuardian != pendingGuardian.value) { + vm.prank(OWNER); + vault.submitGuardian(newGuardian); + } - uint256 timelock = vault.timelock(); - if (guardian == address(0) || timelock == 0) return; + if (guardian == address(0)) return; - vm.warp(block.timestamp + timelock); + vm.warp(block.timestamp + vault.timelock()); vault.acceptGuardian(); @@ -86,36 +94,29 @@ contract IntegrationTest is BaseTest { if (newFee == fee) return; vm.prank(OWNER); - vault.submitFee(newFee); - - uint256 timelock = vault.timelock(); - if (newFee < fee || timelock == 0) return; - - vm.warp(block.timestamp + timelock); - - vault.acceptFee(); + vault.setFee(newFee); assertEq(vault.fee(), newFee, "_setFee"); } function _setCap(MarketParams memory marketParams, uint256 newCap) internal { Id id = marketParams.id(); - (uint256 cap,) = vault.config(id); + uint256 cap = vault.config(id).cap; if (newCap == cap) return; - vm.prank(CURATOR); - vault.submitCap(marketParams, newCap); + PendingUint192 memory pendingCap = vault.pendingCap(id); + if (pendingCap.validAt == 0 || newCap != pendingCap.value) { + vm.prank(CURATOR); + vault.submitCap(marketParams, newCap); + } - uint256 timelock = vault.timelock(); if (newCap < cap) return; - vm.warp(block.timestamp + timelock); + vm.warp(block.timestamp + vault.timelock()); vault.acceptCap(id); - (cap,) = vault.config(id); - - assertEq(cap, newCap, "_setCap"); + assertEq(vault.config(id).cap, newCap, "_setCap"); } function _sortSupplyQueueIdleLast() internal { diff --git a/test/hardhat/MetaMorpho.spec.ts b/test/hardhat/MetaMorpho.spec.ts index e5e3fb53..fa96a2dd 100644 --- a/test/hardhat/MetaMorpho.spec.ts +++ b/test/hardhat/MetaMorpho.spec.ts @@ -215,10 +215,7 @@ describe("MetaMorpho", () => { await metaMorpho.setIsAllocator(allocator.address, true); await metaMorpho.setFeeRecipient(admin.address); - await metaMorpho.submitFee(BigInt.WAD / 10n); - - await forwardTimestamp(timelock); - await metaMorpho.connect(admin).acceptFee(); + await metaMorpho.setFee(BigInt.WAD / 10n); supplyCap = (BigInt.WAD * 20n * toBigInt(suppliers.length)) / toBigInt(nbMarkets); for (const marketParams of allMarketParams) { diff --git a/test/metamorpho_tests.tree b/test/metamorpho_tests.tree index 5c92d6e4..5a8e58b7 100644 --- a/test/metamorpho_tests.tree +++ b/test/metamorpho_tests.tree @@ -58,18 +58,18 @@ . └── acceptTimelock() external - ├── when pendingTimelock.submittedAt == 0 + ├── when pendingTimelock.validAt == 0 │ └── revert with NoPendingValue() - └── when pendingTimelock.submittedAt != 0 - ├── when block.timestamp < pendingTimelock.submittedAt + timelock + └── when pendingTimelock.validAt != 0 + ├── when block.timestamp < pendingTimelock.validAt │ └── revert with TimelockNotElapsed() - └── when block.timestamp >= pendingTimelock.submittedAt + timelock + └── when block.timestamp >= pendingTimelock.validAt ├── it should set timelock to pendingTimelock.value ├── it should emit SetTimelock(pendingTimelock.value) └── it should delete pendingTimelock . -└── submitFee(uint256 newFee) external +└── setFee(uint256 newFee) external ├── when msg.sender not owner │ └── revert with NOT_OWNER └── when msg.sender is owner @@ -79,27 +79,12 @@ ├── when newFee == fee │ └── revert with AlreadySet() └── when newFee != fee - ├── when newFee < fee - │ ├── it should accrue fees - │ ├── it should set fee to newFee - │ ├── it should emit SetFee(newFee) - │ └── it should delete pendingFee - └── when newFee > fee - ├── it should set pendingFee to PendingUint192(uint192(newFee), uint64(block.timestamp)) - └── it should emit SubmitFee(newFee) - -. -└── acceptFee() external - ├── when pendingFee.submittedAt == 0 - │ └── revert with NoPendingValue() - └── when pendingFee.submittedAt != 0 - ├── when block.timestamp < pendingFee.submittedAt + timelock - │ └── revert with TimelockNotElapsed() - └── when block.timestamp >= pendingFee.submittedAt + timelock - ├── it should accrue fees - ├── it should set fee to pendingFee.value - ├── it should emit SetFee(pendingFee.value) - └── it should delete pendingFee + ├── when newFee != 0 and feeRecipient == address(0) + │ └── revert with ZeroFeeRecipient() + └── when newFee == 0 or feeRecipient != address(0) + ├── it should accrue fees + ├── it should set fee to newFee + ├── it should emit SetFee(caller, newFee) . └── setFeeRecipient(address newFeeRecipient) external @@ -134,12 +119,12 @@ . └── acceptGuardian() external - ├── when pendingGuardian.submittedAt == 0 + ├── when pendingGuardian.validAt == 0 │ └── revert with NoPendingValue() - └── when pendingGuardian.submittedAt != 0 - ├── when block.timestamp < pendingGuardian.submittedAt + timelock + └── when pendingGuardian.validAt != 0 + ├── when block.timestamp < pendingGuardian.validAt │ └── revert with TimelockNotElapsed() - └── when block.timestamp >= pendingGuardian.submittedAt + timelock + └── when block.timestamp >= pendingGuardian.validAt ├── it should set guardian to pendingGuardian ├── it should emit SetGuardian(pendingGuardian) └── it should delete pendingGuardian @@ -178,12 +163,12 @@ . └── acceptCap(Id id) external - ├── when pendingCap[id].submittedAt == 0 + ├── when pendingCap[id].validAt == 0 │ └── revert with NoPendingValue() - └── when pendingCap[id].submittedAt != 0 - ├── when block.timestamp < pendingCap[id].submittedAt + timelock + └── when pendingCap[id].validAt != 0 + ├── when block.timestamp < pendingCap[id].validAt │ └── revert with TimelockNotElapsed() - └── when block.timestamp >= pendingCap[id].submittedAt + timelock + └── when block.timestamp >= pendingCap[id].validAt ├── when supplyCap > 0 and marketConfig.withdrawRank == 0 │ ├── it should push id to supplyQueue │ ├── it should push id to withdrawQueue @@ -271,32 +256,38 @@ └── it should emit Skim(msg.sender, skimRecipient, token, amount) -/* ONLY GUARDIAN FUNCTIONS */ +/* REVOKE FUNCTIONS */ . └── revokePendingTimelock() external - ├── when msg.sender not guardian - │ └── revert with NotGuardian() + ├── when msg.sender not guardian nor owner + │ └── revert with NotGuardianRole() + ├── when pending timelock's validAt is zero + │ └── revert with NoPendingValue() └── when msg.sender is guardian - ├── it should emit RevokePendingTimelock(msg.sender) - └── it should delete pendingTimelock + ├── it should delete pendingTimelock + └── it should emit RevokePendingTimelock(msg.sender) . -└── revokePendingCap(Id id) external - ├── when msg.sender not guardian - │ └── revert with NotGuardian() +└── revokeGuardian() external + ├── when msg.sender not guardian nor owner + │ └── revert with NotGuardianRole() + ├── when pending guardian's validAt is zero + │ └── revert with NoPendingValue() └── when msg.sender is guardian - ├── it should emit RevokePendingCap(msg.sender, id) - └── it should delete pendingCap[id] + ├── it should delete pendingGuardian + └── it should emit RevokePendingGuardian(msg.sender) . -└── revokePendingGuardian() external - ├── when msg.sender not guardian - │ └── revert with NotGuardian() - └── when msg.sender is guardian - ├── it should emit RevokePendingGuardian(msg.sender); - └── it should delete pendingGuardian +└── revokePendingCap(Id id) external + ├── when msg.sender not curator nor guardian nor owner + │ └── revert with NotCuratorNorGuardian() + ├── when pending cap's validAt is zero + │ └── revert with NoPendingValue() + └── when msg.sender is curator or guardian or owner + ├── it should delete pendingCap[id] + └── it should emit RevokePendingCap(msg.sender, id, pendingCap[id]) /* PUBLIC */