diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 00000000..1d9650e9 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 1d9650e951204a0ddce9ff89c32f1997984cef4d diff --git a/lib/morpho-blue b/lib/morpho-blue index cdc0f008..08472125 160000 --- a/lib/morpho-blue +++ b/lib/morpho-blue @@ -1 +1 @@ -Subproject commit cdc0f0080e49949e50b87a6cd206fd73f118e7a0 +Subproject commit 084721252cca3c40b8c289837b9ed3a33e54b36c diff --git a/remappings.txt b/remappings.txt index 36d5b578..405dc941 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,5 +1,6 @@ src/=src/ test/=test/ +@forge-std/=lib/forge-std/src/ @morpho-blue/=lib/morpho-blue/src/ @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ diff --git a/src/MetaMorpho.sol b/src/MetaMorpho.sol index a3d45217..97cdf6e9 100644 --- a/src/MetaMorpho.sol +++ b/src/MetaMorpho.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.21; import {IMorphoMarketParams} from "./interfaces/IMorphoMarketParams.sol"; -import {MarketAllocation, Pending, IMetaMorpho} from "./interfaces/IMetaMorpho.sol"; +import {IMetaMorpho, MarketConfig, Pending, MarketAllocation} from "./interfaces/IMetaMorpho.sol"; import {Id, MarketParams, Market, IMorpho} from "@morpho-blue/interfaces/IMorpho.sol"; import "src/libraries/ConstantsLib.sol"; @@ -10,16 +10,18 @@ import {ErrorsLib} from "./libraries/ErrorsLib.sol"; import {EventsLib} from "./libraries/EventsLib.sol"; import {WAD} from "@morpho-blue/libraries/MathLib.sol"; import {UtilsLib} from "@morpho-blue/libraries/UtilsLib.sol"; -import {VaultMarket, ConfigSet, ConfigSetLib} from "./libraries/ConfigSetLib.sol"; +import {SharesMathLib} from "@morpho-blue/libraries/SharesMathLib.sol"; +import {MorphoLib} from "@morpho-blue/libraries/periphery/MorphoLib.sol"; import {MorphoBalancesLib} from "@morpho-blue/libraries/periphery/MorphoBalancesLib.sol"; import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import { IERC20, + IERC4626, ERC20, ERC4626, - Context, Math, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; @@ -27,9 +29,11 @@ import { contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { using Math for uint256; using UtilsLib for uint256; - using ConfigSetLib for ConfigSet; + using SafeCast for uint256; + using SharesMathLib for uint256; using MarketParamsLib for MarketParams; using MorphoBalancesLib for IMorpho; + using MorphoLib for IMorpho; /* IMMUTABLES */ @@ -38,24 +42,31 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { /* STORAGE */ mapping(address => uint256) internal _roleOf; - mapping(Id => Pending) public pendingMarket; - Id[] public supplyAllocationOrder; - Id[] public withdrawAllocationOrder; + mapping(Id => MarketConfig) public config; + mapping(Id => Pending) public pendingCap; + + /// @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. + Id[] public supplyQueue; + + /// @dev Stores the order of markets from which liquidity is withdrawn upon withdrawal. + /// @dev Always contain all non-zero cap markets or markets on which the vault supplies liquidity, without + /// duplicate. + Id[] public withdrawQueue; Pending public pendingFee; + Pending public pendingTimelock; + uint96 public fee; - address feeRecipient; + address public feeRecipient; - Pending public pendingTimelock; uint256 public timelock; /// @dev Stores the total assets owned by this vault when the fee was last accrued. uint256 public lastTotalAssets; uint256 public idle; - ConfigSet private _config; - /* CONSTRUCTOR */ constructor(address morpho, uint256 initialTimelock, address _asset, string memory _name, string memory _symbol) @@ -65,7 +76,8 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { require(initialTimelock <= MAX_TIMELOCK, ErrorsLib.MAX_TIMELOCK_EXCEEDED); MORPHO = IMorpho(morpho); - timelock = initialTimelock; + + _setTimelock(initialTimelock); SafeERC20.safeApprove(IERC20(_asset), morpho, type(uint256).max); } @@ -84,59 +96,62 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { _; } - modifier timelockElapsed(uint128 timestamp) { - require(block.timestamp >= timestamp + timelock, ErrorsLib.TIMELOCK_NOT_ELAPSED); - require(block.timestamp <= timestamp + timelock + TIMELOCK_EXPIRATION, ErrorsLib.TIMELOCK_EXPIRATION_EXCEEDED); + modifier timelockElapsed(uint64 submittedAt) { + require(block.timestamp >= submittedAt + timelock, ErrorsLib.TIMELOCK_NOT_ELAPSED); + require(block.timestamp <= submittedAt + timelock + TIMELOCK_EXPIRATION, ErrorsLib.TIMELOCK_EXPIRATION_EXCEEDED); _; } /* ONLY OWNER FUNCTIONS */ - function submitTimelock(uint256 newTimelock) external onlyOwner { - require(newTimelock <= MAX_TIMELOCK, ErrorsLib.MAX_TIMELOCK_EXCEEDED); - - // Safe "unchecked" cast because newTimelock <= MAX_TIMELOCK. - pendingTimelock = Pending(uint128(newTimelock), uint128(block.timestamp)); + function setIsRiskManager(address newRiskManager, bool newIsRiskManager) external onlyOwner { + _setRole(newRiskManager, RISK_MANAGER_ROLE, newIsRiskManager); + } - emit EventsLib.SubmitTimelock(newTimelock); + function setIsAllocator(address newAllocator, bool newIsAllocator) external onlyOwner { + _setRole(newAllocator, ALLOCATOR_ROLE, newIsAllocator); } - function acceptTimelock() external timelockElapsed(pendingTimelock.timestamp) onlyOwner { - timelock = pendingTimelock.value; + function submitTimelock(uint256 newTimelock) external onlyOwner { + require(newTimelock != timelock, ErrorsLib.ALREADY_SET); + require(newTimelock <= MAX_TIMELOCK, ErrorsLib.MAX_TIMELOCK_EXCEEDED); - emit EventsLib.AcceptTimelock(pendingTimelock.value); + if (timelock == 0) { + _setTimelock(newTimelock); + } else { + // Safe "unchecked" cast because newTimelock <= MAX_TIMELOCK. + pendingTimelock = Pending(uint192(newTimelock), uint64(block.timestamp)); - delete pendingTimelock; + emit EventsLib.SubmitTimelock(newTimelock); + } } - function setIsRiskManager(address newRiskManager, bool newIsRiskManager) external onlyOwner { - _setRole(newRiskManager, RISK_MANAGER_ROLE, newIsRiskManager); - } + function acceptTimelock() external timelockElapsed(pendingTimelock.submittedAt) onlyOwner { + _setTimelock(pendingTimelock.value); - function setIsAllocator(address newAllocator, bool newIsAllocator) external onlyOwner { - _setRole(newAllocator, ALLOCATOR_ROLE, newIsAllocator); + delete pendingTimelock; } function submitFee(uint256 newFee) external onlyOwner { require(newFee != fee, ErrorsLib.ALREADY_SET); require(newFee <= WAD, ErrorsLib.MAX_FEE_EXCEEDED); - // Safe "unchecked" cast because newFee <= WAD. - pendingFee = Pending(uint128(newFee), uint128(block.timestamp)); + if (newFee == 0 || timelock == 0) { + _setFee(newFee); + } else { + // Safe "unchecked" cast because newFee <= WAD. + pendingFee = Pending(uint192(newFee), uint64(block.timestamp)); - emit EventsLib.SubmitFee(newFee); + emit EventsLib.SubmitFee(newFee); + } } - function acceptFee() external timelockElapsed(pendingFee.timestamp) onlyOwner { + function acceptFee() external timelockElapsed(pendingFee.submittedAt) onlyOwner { // Accrue interest using the previous fee set before changing it. _updateLastTotalAssets(_accrueFee()); - fee = uint96(pendingFee.value); - - emit EventsLib.AcceptFee(pendingFee.value); - - delete pendingFee; + _setFee(pendingFee.value); } function setFeeRecipient(address newFeeRecipient) external onlyOwner { @@ -152,59 +167,75 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { /* ONLY RISK MANAGER FUNCTIONS */ - function submitMarket(MarketParams memory marketParams, uint128 cap) external onlyRiskManager { + function submitCap(MarketParams memory marketParams, uint256 marketCap) external onlyRiskManager { require(marketParams.borrowableToken == asset(), ErrorsLib.INCONSISTENT_ASSET); - (,,,, uint128 lastUpdate,) = MORPHO.market(marketParams.id()); - require(lastUpdate != 0, ErrorsLib.MARKET_NOT_CREATED); + Id id = marketParams.id(); - require(!_config.contains(id)); + require(MORPHO.lastUpdate(id) != 0, ErrorsLib.MARKET_NOT_CREATED); + + if (marketCap == 0 || timelock == 0) { + _setCap(id, marketCap.toUint192()); + } else { + pendingCap[id] = Pending(marketCap.toUint192(), uint64(block.timestamp)); + + emit EventsLib.SubmitCap(id, marketCap); + } + } - pendingMarket[id] = Pending(cap, uint128(block.timestamp)); + function acceptCap(Id id) external timelockElapsed(pendingCap[id].submittedAt) onlyRiskManager { + _setCap(id, pendingCap[id].value); - emit EventsLib.SubmitMarket(id); + delete pendingCap[id]; } - function enableMarket(Id id) external timelockElapsed(pendingMarket[id].timestamp) onlyRiskManager { - supplyAllocationOrder.push(id); - withdrawAllocationOrder.push(id); + /* ONLY ALLOCATOR FUNCTIONS */ - MarketParams memory marketParams = IMorphoMarketParams(address(MORPHO)).idToMarketParams(id); - uint128 cap = pendingMarket[id].value; + /// @dev The supply queue can be set containing duplicate markets, but it would only increase the cost of depositing + /// to the vault. + function setSupplyQueue(Id[] calldata newSupplyQueue) external onlyAllocator { + uint256 length = newSupplyQueue.length; - require(_config.update(marketParams, uint256(cap)), ErrorsLib.ENABLE_MARKET_FAILED); + for (uint256 i; i < length; ++i) { + require(config[newSupplyQueue[i]].cap > 0, ErrorsLib.UNAUTHORIZED_MARKET); + } - emit EventsLib.EnableMarket(id, cap); + supplyQueue = newSupplyQueue; } - function setCap(MarketParams memory marketParams, uint128 cap) external onlyRiskManager { - require(_config.contains(marketParams.id()), ErrorsLib.MARKET_NOT_ENABLED); + function sortWithdrawQueue(uint256[] calldata indexes) external onlyAllocator { + uint256 newLength = indexes.length; + uint256 currLength = withdrawQueue.length; - _config.update(marketParams, cap); + bool[] memory seen = new bool[](currLength); + Id[] memory newWithdrawQueue = new Id[](newLength); - emit EventsLib.SetCap(cap); - } + for (uint256 i; i < newLength; ++i) { + uint256 prevIndex = indexes[i]; - function disableMarket(Id id) external onlyRiskManager { - _removeFromAllocationOrder(supplyAllocationOrder, id); - _removeFromAllocationOrder(withdrawAllocationOrder, id); + // If prevIndex >= currLength, reverts with native "Index out of bounds". + require(!seen[prevIndex], ErrorsLib.DUPLICATE_MARKET); - require(_config.remove(id), ErrorsLib.DISABLE_MARKET_FAILED); + seen[prevIndex] = true; - emit EventsLib.DisableMarket(id); - } + Id id = withdrawQueue[prevIndex]; - /* ONLY ALLOCATOR FUNCTIONS */ + newWithdrawQueue[i] = id; - function setSupplyAllocationOrder(Id[] calldata newSupplyAllocationOrder) external onlyAllocator { - _checkAllocationOrder(supplyAllocationOrder, newSupplyAllocationOrder); + // Safe "unchecked" cast because i < currLength. + config[id].withdrawRank = uint64(i + 1); + } - supplyAllocationOrder = newSupplyAllocationOrder; - } + for (uint256 i; i < currLength; ++i) { + if (!seen[i]) { + Id id = withdrawQueue[i]; - function setWithdrawAllocationOrder(Id[] calldata newWithdrawAllocationOrder) external onlyAllocator { - _checkAllocationOrder(withdrawAllocationOrder, newWithdrawAllocationOrder); + require(MORPHO.supplyShares(id, address(this)) == 0, ErrorsLib.MISSING_MARKET); - withdrawAllocationOrder = newWithdrawAllocationOrder; + delete config[id].withdrawRank; + } + } + + withdrawQueue = newWithdrawQueue; } function reallocate(MarketAllocation[] calldata withdrawn, MarketAllocation[] calldata supplied) @@ -224,23 +255,19 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { return _hasRole(target, ALLOCATOR_ROLE); } - function marketCap(Id id) public view returns (uint256) { - return _market(id).cap; - } - /* ERC4626 (PUBLIC) */ - function maxWithdraw(address owner) public view override returns (uint256) { - _accruedFeeShares(); - - return _staticWithdrawOrder(super.maxWithdraw(owner)); + function maxWithdraw(address owner) public view override(IERC4626, ERC4626) returns (uint256 assets) { + (assets,) = _maxWithdraw(owner); } - function maxRedeem(address owner) public view override returns (uint256) { - return _convertToShares(maxWithdraw(owner), Math.Rounding.Down); + function maxRedeem(address owner) public view override(IERC4626, ERC4626) returns (uint256) { + (uint256 assets, uint256 newTotalAssets) = _maxWithdraw(owner); + + return _convertToSharesWithFeeAccrued(assets, newTotalAssets, Math.Rounding.Down); } - function deposit(uint256 assets, address receiver) public override returns (uint256 shares) { + function deposit(uint256 assets, address receiver) public override(IERC4626, ERC4626) returns (uint256 shares) { uint256 newTotalAssets = _accrueFee(); shares = _convertToSharesWithFeeAccrued(assets, newTotalAssets, Math.Rounding.Down); @@ -249,7 +276,7 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { _updateLastTotalAssets(newTotalAssets + assets); } - function mint(uint256 shares, address receiver) public override returns (uint256 assets) { + function mint(uint256 shares, address receiver) public override(IERC4626, ERC4626) returns (uint256 assets) { uint256 newTotalAssets = _accrueFee(); assets = _convertToAssetsWithFeeAccrued(shares, newTotalAssets, Math.Rounding.Up); @@ -258,7 +285,11 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { _updateLastTotalAssets(newTotalAssets + assets); } - function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256 shares) { + function withdraw(uint256 assets, address receiver, address owner) + public + override(IERC4626, ERC4626) + returns (uint256 shares) + { uint256 newTotalAssets = _accrueFee(); // Do not call expensive `maxWithdraw` and optimistically withdraw assets. @@ -269,7 +300,11 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { _updateLastTotalAssets(newTotalAssets - assets); } - function redeem(uint256 shares, address receiver, address owner) public override returns (uint256 assets) { + function redeem(uint256 shares, address receiver, address owner) + public + override(IERC4626, ERC4626) + returns (uint256 assets) + { uint256 newTotalAssets = _accrueFee(); // Do not call expensive `maxRedeem` and optimistically redeem shares. @@ -280,22 +315,27 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { _updateLastTotalAssets(newTotalAssets - assets); } - function totalAssets() public view override returns (uint256 assets) { - uint256 nbMarkets = _config.length(); + function totalAssets() public view override(IERC4626, ERC4626) returns (uint256 assets) { + uint256 nbMarkets = withdrawQueue.length; for (uint256 i; i < nbMarkets; ++i) { - MarketParams memory marketParams = _config.at(i); - - assets += _supplyBalance(marketParams); + assets += _supplyBalance(_marketParams(withdrawQueue[i])); } - assets += ERC20(asset()).balanceOf(address(this)); + assets += idle; } /* ERC4626 (INTERNAL) */ function _decimalsOffset() internal pure override returns (uint8) { - return 6; + return DECIMALS_OFFSET; + } + + function _maxWithdraw(address owner) internal view returns (uint256 assets, uint256 newTotalAssets) { + (, newTotalAssets) = _accruedFeeShares(); + + assets = super.maxWithdraw(owner); + assets -= _staticWithdrawMorpho(assets); } function _convertToShares(uint256 assets, Math.Rounding rounding) internal view override returns (uint256) { @@ -337,7 +377,7 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { // slither-disable-next-line reentrancy-no-eth SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(this), assets); - _depositOrder(assets); + _supplyMorpho(assets); _mint(owner, shares); @@ -361,7 +401,7 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { // shares are burned and after the assets are transferred, which is a valid state. _burn(owner, shares); - require(_withdrawOrder(assets) == 0, ErrorsLib.WITHDRAW_ORDER_FAILED); + require(_withdrawMorpho(assets) == 0, ErrorsLib.WITHDRAW_FAILED_MORPHO); SafeERC20.safeTransfer(IERC20(asset()), receiver, assets); @@ -370,29 +410,54 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { /* INTERNAL */ - function _market(Id id) internal view returns (VaultMarket storage) { - require(_config.contains(id), ErrorsLib.UNAUTHORIZED_MARKET); - - return _config.getMarket(id); + function _marketParams(Id id) internal view returns (MarketParams memory) { + return IMorphoMarketParams(address(MORPHO)).idToMarketParams(id); } function _supplyBalance(MarketParams memory marketParams) internal view returns (uint256) { return MORPHO.expectedSupplyBalance(marketParams, address(this)); } - function _supplyMorpho(MarketAllocation memory allocation) internal { - Id id = allocation.marketParams.id(); + function _setTimelock(uint256 newTimelock) internal { + // Safe "unchecked" cast because newTimelock <= MAX_TIMELOCK. + timelock = newTimelock; + + emit EventsLib.SetTimelock(newTimelock); + + delete pendingTimelock; + } + + function _setCap(Id id, uint192 marketCap) internal { + MarketConfig storage marketConfig = config[id]; + + if (marketCap > 0 && marketConfig.withdrawRank == 0) { + supplyQueue.push(id); + withdrawQueue.push(id); - uint256 cap = marketCap(id); - if (cap > 0) { - uint256 newSupply = allocation.assets + _supplyBalance(allocation.marketParams); + require(withdrawQueue.length <= MAX_QUEUE_SIZE, ErrorsLib.MAX_QUEUE_SIZE_EXCEEDED); - require(newSupply <= cap, ErrorsLib.SUPPLY_CAP_EXCEEDED); + // Safe "unchecked" cast because withdrawQueue.length <= MAX_QUEUE_SIZE. + marketConfig.withdrawRank = uint64(withdrawQueue.length); } - MORPHO.supply(allocation.marketParams, allocation.assets, 0, address(this), hex""); + marketConfig.cap = marketCap; + + emit EventsLib.SetCap(id, marketCap); + + delete pendingCap[id]; } + function _setFee(uint256 newFee) internal { + // Safe "unchecked" cast because newFee <= WAD. + fee = uint96(newFee); + + emit EventsLib.SetFee(newFee); + + delete pendingFee; + } + + /* LIQUIDITY ALLOCATION */ + function _reallocate(MarketAllocation[] memory withdrawn, MarketAllocation[] memory supplied) internal { uint256 nbWithdrawn = withdrawn.length; @@ -405,32 +470,31 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { uint256 nbSupplied = supplied.length; for (uint256 i; i < nbSupplied; ++i) { - _supplyMorpho(supplied[i]); // TODO: should we check config if supplied is provided by an onchain strategy? - } - } + MarketAllocation memory allocation = supplied[i]; - /// @dev MUST NOT revert on a market. - function _depositOrder(uint256 assets) internal { - uint256 length = supplyAllocationOrder.length; + require( + _suppliable(allocation.marketParams, allocation.marketParams.id()) >= allocation.assets, + ErrorsLib.SUPPLY_CAP_EXCEEDED + ); - for (uint256 i; i < length; ++i) { - Id id = supplyAllocationOrder[i]; - uint256 cap = marketCap(id); - MarketParams memory marketParams = _config.at(_config.getMarket(id).rank - 1); - uint256 toDeposit = assets; + MORPHO.supply(allocation.marketParams, allocation.assets, 0, address(this), hex""); + } + } - if (cap > 0) { - uint256 currentSupply = _supplyBalance(marketParams); + function _supplyMorpho(uint256 assets) internal { + uint256 nbMarkets = supplyQueue.length; - toDeposit = UtilsLib.min(cap.zeroFloorSub(currentSupply), assets); - } + for (uint256 i; i < nbMarkets; ++i) { + Id id = supplyQueue[i]; + MarketParams memory marketParams = _marketParams(id); - if (toDeposit > 0) { - bytes memory encodedCall = - abi.encodeCall(MORPHO.supply, (marketParams, toDeposit, 0, address(this), hex"")); - (bool success,) = address(MORPHO).call(encodedCall); + uint256 toSupply = UtilsLib.min(_suppliable(marketParams, id), assets); - if (success) assets -= toDeposit; + if (toSupply > 0) { + // Using try/catch to skip markets that revert. + try MORPHO.supply(marketParams, toSupply, 0, address(this), hex"") { + assets -= toSupply; + } catch {} } if (assets == 0) return; @@ -439,24 +503,24 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { idle += assets; } - /// @dev MUST NOT revert on a market. - function _staticWithdrawOrder(uint256 assets) internal view returns (uint256) { - (assets,) = _withdrawIdle(assets); + function _withdrawMorpho(uint256 assets) internal returns (uint256) { + (assets, idle) = _withdrawIdle(assets); if (assets == 0) return 0; - uint256 length = withdrawAllocationOrder.length; + uint256 nbMarkets = withdrawQueue.length; - for (uint256 i; i < length; ++i) { - Id id = withdrawAllocationOrder[i]; - (MarketParams memory marketParams, uint256 toWithdraw) = _withdrawable(assets, id); + for (uint256 i; i < nbMarkets; ++i) { + Id id = withdrawQueue[i]; + MarketParams memory marketParams = _marketParams(id); - if (toWithdraw > 0) { - bytes memory encodedCall = - abi.encodeCall(MORPHO.withdraw, (marketParams, toWithdraw, 0, address(this), address(this))); - (bool success,) = address(MORPHO).staticcall(encodedCall); + uint256 toWithdraw = UtilsLib.min(_withdrawable(marketParams, id), assets); - if (success) assets -= toWithdraw; + if (toWithdraw > 0) { + // Using try/catch to skip markets that revert. + try MORPHO.withdraw(marketParams, toWithdraw, 0, address(this), address(this)) { + assets -= toWithdraw; + } catch {} } if (assets == 0) return 0; @@ -465,25 +529,22 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { return assets; } - /// @dev MUST NOT revert on a market. - function _withdrawOrder(uint256 assets) internal returns (uint256) { - (assets, idle) = _withdrawIdle(assets); + function _staticWithdrawMorpho(uint256 assets) internal view returns (uint256) { + (assets,) = _withdrawIdle(assets); if (assets == 0) return 0; - uint256 length = withdrawAllocationOrder.length; - - for (uint256 i; i < length; ++i) { - Id id = withdrawAllocationOrder[i]; - (MarketParams memory marketParams, uint256 toWithdraw) = _withdrawable(assets, id); + uint256 nbMarkets = withdrawQueue.length; - if (toWithdraw > 0) { - bytes memory encodedCall = - abi.encodeCall(MORPHO.withdraw, (marketParams, toWithdraw, 0, address(this), address(this))); - (bool success,) = address(MORPHO).call(encodedCall); + for (uint256 i; i < nbMarkets; ++i) { + Id id = withdrawQueue[i]; + MarketParams memory marketParams = _marketParams(id); - if (success) assets -= toWithdraw; - } + // The vault withdrawing from Morpho cannot fail because: + // 1. oracle.price() is never called (the vault doesn't borrow) + // 2. `_withdrawable` caps to the liquidity available on Morpho + // 3. virtually accruing interest didn't fail in `_withdrawable` + assets = assets.zeroFloorSub(_withdrawable(marketParams, id)); if (assets == 0) return 0; } @@ -491,46 +552,31 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { return assets; } - function _withdrawIdle(uint256 assets) internal view returns (uint256 remaining, uint256 newIdle) { - if (assets > idle) return (assets - idle, 0); - return (0, idle - assets); + function _withdrawIdle(uint256 assets) internal view returns (uint256, uint256) { + return (assets.zeroFloorSub(idle), idle.zeroFloorSub(assets)); } - function _withdrawable(uint256 assets, Id id) - internal - view - returns (MarketParams memory marketParams, uint256 withdrawable) - { - marketParams = _config.at(_config.getMarket(id).rank - 1); - (uint256 totalSupply,, uint256 totalBorrow,) = MORPHO.expectedMarketBalances(marketParams); - uint256 available = totalSupply - totalBorrow; - withdrawable = UtilsLib.min(available, assets); - } - - function _removeFromAllocationOrder(Id[] storage order, Id id) internal { - uint256 length = _config.length(); - - for (uint256 i; i < length; ++i) { - // Do not conserve the previous order. - if (Id.unwrap(order[i]) == Id.unwrap(id)) { - order[i] = order[length - 1]; - order.pop(); + /// @dev Assumes that the inputs `marketParams` and `id` match. + function _suppliable(MarketParams memory marketParams, Id id) internal view returns (uint256) { + uint256 marketCap = config[id].cap; + if (marketCap == 0) return 0; - return; - } - } + return marketCap.zeroFloorSub(_supplyBalance(marketParams)); } - function _checkAllocationOrder(Id[] storage oldOrder, Id[] calldata newOrder) internal view { - uint256 length = newOrder.length; - - require(length == oldOrder.length, ErrorsLib.INVALID_LENGTH); + /// @dev Assumes that the inputs `marketParams` and `id` match. + function _withdrawable(MarketParams memory marketParams, Id id) internal view returns (uint256) { + uint256 supplyShares = MORPHO.supplyShares(id, address(this)); + (uint256 totalSupplyAssets, uint256 totalSupplyShares, uint256 totalBorrowAssets,) = + MORPHO.expectedMarketBalances(marketParams); - for (uint256 i; i < length; ++i) { - require(_config.contains(newOrder[i]), ErrorsLib.MARKET_NOT_ENABLED); - } + return UtilsLib.min( + supplyShares.toAssetsDown(totalSupplyAssets, totalSupplyShares), totalSupplyAssets - totalBorrowAssets + ); } + /* FEE MANAGEMENT */ + function _updateLastTotalAssets(uint256 newTotalAssets) internal { lastTotalAssets = newTotalAssets; diff --git a/src/interfaces/IMetaMorpho.sol b/src/interfaces/IMetaMorpho.sol index 19bb5e30..bbf6e165 100644 --- a/src/interfaces/IMetaMorpho.sol +++ b/src/interfaces/IMetaMorpho.sol @@ -1,16 +1,61 @@ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity >=0.6.2; -import {MarketParams} from "@morpho-blue/interfaces/IMorpho.sol"; +import {IMorpho, Id, MarketParams} from "@morpho-blue/interfaces/IMorpho.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +struct Pending { + uint192 value; + uint64 submittedAt; +} + +struct MarketConfig { + uint192 cap; + uint64 withdrawRank; +} struct MarketAllocation { MarketParams marketParams; uint256 assets; } -struct Pending { - uint128 value; - uint128 timestamp; -} +interface IMetaMorpho is IERC4626 { + function MORPHO() external view returns (IMorpho); + + function isAllocator(address target) external view returns (bool); + function isRiskManager(address target) external view returns (bool); -interface IMetaMorpho {} + function fee() external view returns (uint96); + function feeRecipient() external view returns (address); + function timelock() external view returns (uint256); + function supplyQueue(uint256) external view returns (Id); + function withdrawQueue(uint256) external view returns (Id); + function config(Id) external view returns (uint192 cap, uint64 withdrawRank); + + function idle() external view returns (uint256); + function lastTotalAssets() external view returns (uint256); + + function submitTimelock(uint256 newTimelock) external; + function acceptTimelock() external; + function pendingTimelock() external view returns (uint192 value, uint64 submittedAt); + + function submitCap(MarketParams memory marketParams, uint256 marketCap) external; + function acceptCap(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 setIsAllocator(address newAllocator, bool newIsAllocator) external; + function setIsRiskManager(address newRiskManager, bool newIsRiskManager) external; + function setFeeRecipient(address newFeeRecipient) external; + + function setSupplyQueue(Id[] calldata newSupplyQueue) external; + + /// @notice Changes the order of the withdraw queue, given a permutation. + /// @param indexes The permutation, mapping an Id's previous index in the withdraw queue to its new position in + /// `indexes`. + function sortWithdrawQueue(uint256[] calldata indexes) external; + function reallocate(MarketAllocation[] calldata withdrawn, MarketAllocation[] calldata supplied) external; +} diff --git a/src/interfaces/IMorphoMarketParams.sol b/src/interfaces/IMorphoMarketParams.sol index 1f183a10..bb52a27c 100644 --- a/src/interfaces/IMorphoMarketParams.sol +++ b/src/interfaces/IMorphoMarketParams.sol @@ -4,5 +4,5 @@ pragma solidity >=0.6.2; import {MarketParams, Id} from "@morpho-blue/interfaces/IMorpho.sol"; interface IMorphoMarketParams { - function idToMarketParams(Id id) external returns (MarketParams memory marketParams); + function idToMarketParams(Id id) external view returns (MarketParams memory marketParams); } diff --git a/src/interfaces/ISupplyVault.sol b/src/interfaces/ISupplyVault.sol deleted file mode 100644 index 19bb5e30..00000000 --- a/src/interfaces/ISupplyVault.sol +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.6.2; - -import {MarketParams} from "@morpho-blue/interfaces/IMorpho.sol"; - -struct MarketAllocation { - MarketParams marketParams; - uint256 assets; -} - -struct Pending { - uint128 value; - uint128 timestamp; -} - -interface IMetaMorpho {} diff --git a/src/libraries/ConfigSetLib.sol b/src/libraries/ConfigSetLib.sol deleted file mode 100644 index 8effaef9..00000000 --- a/src/libraries/ConfigSetLib.sol +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.8.0; - -import {Id, MarketParams} from "@morpho-blue/interfaces/IMorpho.sol"; - -import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol"; - -struct VaultMarket { - uint256 rank; - uint256 cap; -} - -struct ConfigSet { - MarketParams[] allMarketParams; - mapping(Id id => VaultMarket) market; -} - -library ConfigSetLib { - using ConfigSetLib for ConfigSet; - using MarketParamsLib for MarketParams; - - /** - * @dev Add a value to a set. O(1). - */ - function update(ConfigSet storage set, MarketParams memory marketParams, uint256 cap) internal returns (bool) { - Id id = marketParams.id(); - VaultMarket storage market = set.getMarket(id); - - market.cap = cap; - - if (set.contains(id)) return false; - - set.allMarketParams.push(marketParams); - // The value is stored at length-1, but we add 1 to all indexes - // and use 0 as a sentinel value - market.rank = set.allMarketParams.length; - - return true; - } - - /** - * @dev Removes a value from a set. O(1). - * - * Returns true if the value was removed from the set, that is if it was - * present. - */ - function remove(ConfigSet storage set, Id id) internal returns (bool) { - // We read and store the value's index to prevent multiple reads from the same storage slot - uint256 rank = set.getMarket(id).rank; - - if (rank == 0) return false; - - // Equivalent to contains(set, value) - // To delete an element from the allMarketParams array in O(1), we swap the element to delete with the last one - // in - // the array, and then remove the last element (sometimes called as 'swap and pop'). - // This modifies the order of the array, as noted in {at}. - - uint256 toDeleteIndex; - uint256 lastIndex; - - unchecked { - toDeleteIndex = rank - 1; - lastIndex = set.allMarketParams.length - 1; - } - - if (lastIndex != toDeleteIndex) { - MarketParams memory lastMarketParams = set.allMarketParams[lastIndex]; - - // Move the last value to the index where the value to delete is - set.allMarketParams[toDeleteIndex] = lastMarketParams; - - // Update the index for the moved value - set.market[lastMarketParams.id()].rank = rank; // Replace lastId's index to rank - } - - // Delete the slot where the moved value was stored - set.allMarketParams.pop(); - - // Delete the index for the deleted slot - delete set.market[id]; - - return true; - } - - /** - * @dev Returns true if the value is in the set. O(1). - */ - function contains(ConfigSet storage set, Id id) internal view returns (bool) { - return set.getMarket(id).rank != 0; - } - - /** - * @dev Returns the number of values on the set. O(1). - */ - function length(ConfigSet storage set) internal view returns (uint256) { - return set.allMarketParams.length; - } - - /** - * @dev Returns the market config stored at position `index` in the set. O(1). - * - * Note that there are no guarantees on the ordering of values inside the - * array, and it may change when more values are added or removed. - * - * Requirements: - * - * - `index` must be strictly less than {length}. - */ - function at(ConfigSet storage set, uint256 index) internal view returns (MarketParams memory) { - return set.allMarketParams[index]; - } - - /** - * @dev Returns the market config stored for a given market. O(1). - */ - function getMarket(ConfigSet storage set, Id id) internal view returns (VaultMarket storage) { - return set.market[id]; - } -} diff --git a/src/libraries/ConstantsLib.sol b/src/libraries/ConstantsLib.sol index 1c6096e3..abdd78fe 100644 --- a/src/libraries/ConstantsLib.sol +++ b/src/libraries/ConstantsLib.sol @@ -3,16 +3,20 @@ pragma solidity ^0.8.0; /// @dev The delay after a timelock ends after which the owner must submit a parameter again. /// It guarantees users that the owner only accepts parameters submitted recently. -uint256 constant TIMELOCK_EXPIRATION = 2 days; +uint256 constant TIMELOCK_EXPIRATION = 3 days; /// @dev The maximum delay of a timelock. uint256 constant MAX_TIMELOCK = 2 weeks; /// @dev OpenZeppelin's decimals offset used in MetaMorpho's ERC4626 implementation. -uint256 constant DECIMALS_OFFSET = 6; +uint8 constant DECIMALS_OFFSET = 6; /// @dev The role assigned to risk managers. Must be greater than the allocator role. uint256 constant RISK_MANAGER_ROLE = 2; /// @dev The role assigned to allocators. uint256 constant ALLOCATOR_ROLE = 1; + +/// @dev The maximum supply/withdraw queue size ensuring the cost of depositing/withdrawing from the vault fits in a +/// block. +uint256 constant MAX_QUEUE_SIZE = 64; diff --git a/src/libraries/ErrorsLib.sol b/src/libraries/ErrorsLib.sol index c0129ebd..b496e2ad 100644 --- a/src/libraries/ErrorsLib.sol +++ b/src/libraries/ErrorsLib.sol @@ -18,15 +18,11 @@ library ErrorsLib { /// @notice Thrown when the value is already set. string internal constant ALREADY_SET = "already set"; - string internal constant ENABLE_MARKET_FAILED = "enable market failed"; + string internal constant DUPLICATE_MARKET = "duplicate market"; - string internal constant DISABLE_MARKET_FAILED = "disable market failed"; + string internal constant MISSING_MARKET = "missing market"; - string internal constant INVALID_LENGTH = "invalid length"; - - string internal constant MARKET_NOT_ENABLED = "market not enabled"; - - string internal constant WITHDRAW_ORDER_FAILED = "withdraw order failed"; + string internal constant WITHDRAW_FAILED_MORPHO = "withdraw failed on Morpho"; string internal constant MARKET_NOT_CREATED = "market not created"; @@ -35,4 +31,6 @@ library ErrorsLib { string internal constant TIMELOCK_NOT_ELAPSED = "timelock not elapsed"; string internal constant TIMELOCK_EXPIRATION_EXCEEDED = "timelock expiration exceeded"; + + string internal constant MAX_QUEUE_SIZE_EXCEEDED = "max queue size exceeded"; } diff --git a/src/libraries/EventsLib.sol b/src/libraries/EventsLib.sol index d796598f..7c816940 100644 --- a/src/libraries/EventsLib.sol +++ b/src/libraries/EventsLib.sol @@ -6,27 +6,23 @@ import {Id} from "@morpho-blue/interfaces/IMorpho.sol"; library EventsLib { event SubmitTimelock(uint256 timelock); - event AcceptTimelock(uint256 timelock); + event SetTimelock(uint256 timelock); event SetRole(address indexed target, uint256 role); event SubmitFee(uint256 fee); /// @notice Emitted when setting a new fee. - /// @param newFee The new fee. - event AcceptFee(uint256 newFee); + /// @param fee The new fee. + event SetFee(uint256 fee); /// @notice Emitted when setting a new fee recipient. - /// @param newFeeRecipient The new fee recipient. - event SetFeeRecipient(address indexed newFeeRecipient); + /// @param feeRecipient The new fee recipient. + event SetFeeRecipient(address indexed feeRecipient); - event SubmitMarket(Id id); + event SubmitCap(Id id, uint256 cap); - event EnableMarket(Id id, uint128 cap); - - event SetCap(uint128 cap); - - event DisableMarket(Id id); + event SetCap(Id id, uint256 cap); /// @notice Emitted when the vault's last total assets is updated. /// @param totalAssets The total amount of assets this vault manages. diff --git a/test/forge/ERC4626Test.sol b/test/forge/ERC4626Test.sol index 8bccad14..a494f3c9 100644 --- a/test/forge/ERC4626Test.sol +++ b/test/forge/ERC4626Test.sol @@ -10,7 +10,7 @@ contract ERC4626Test is BaseTest { function setUp() public override { super.setUp(); - _submitAndEnableMarket(allMarkets[0], CAP); + _setCap(allMarkets[0], CAP); } function testMint(uint256 assets) public { diff --git a/test/forge/MarketTest.sol b/test/forge/MarketTest.sol index 9bb5ed60..93a45e1a 100644 --- a/test/forge/MarketTest.sol +++ b/test/forge/MarketTest.sol @@ -1,256 +1,209 @@ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.0; +import {stdError} from "@forge-std/StdError.sol"; + import "./helpers/BaseTest.sol"; contract MarketTest is BaseTest { using MarketParamsLib for MarketParams; - function testSubmitPendingMarket(uint256 seed, uint128 cap) public { - MarketParams memory marketParamsFuzz = allMarkets[seed % allMarkets.length]; + function testSubmitCap(uint256 seed, uint256 cap) public { + MarketParams memory marketParams = allMarkets[seed % allMarkets.length]; + cap = bound(cap, 1, type(uint192).max); vm.prank(RISK_MANAGER); - vault.submitMarket(marketParamsFuzz, cap); + vault.submitCap(marketParams, cap); + + (uint192 value, uint64 timestamp) = vault.pendingCap(marketParams.id()); - (uint128 value, uint128 timestamp) = vault.pendingMarket(marketParamsFuzz.id()); assertEq(value, cap); assertEq(timestamp, block.timestamp); } - function testEnableMarket(uint256 seed, uint128 cap) public { - MarketParams memory marketParamsFuzz = allMarkets[seed % allMarkets.length]; + function testSubmitCapOverflow(uint256 seed, uint256 cap) public { + MarketParams memory marketParams = allMarkets[seed % allMarkets.length]; - _submitAndEnableMarket(marketParamsFuzz, cap); + cap = bound(cap, uint256(type(uint192).max) + 1, type(uint256).max); - Id id = marketParamsFuzz.id(); - - assertEq(vault.marketCap(id), cap); - assertEq(Id.unwrap(vault.supplyAllocationOrder(0)), Id.unwrap(id)); - assertEq(Id.unwrap(vault.withdrawAllocationOrder(0)), Id.unwrap(id)); + vm.prank(RISK_MANAGER); + vm.expectRevert("SafeCast: value doesn't fit in 192 bits"); + vault.submitCap(marketParams, cap); } - function testEnableMarketShouldRevertWhenAlreadyEnabled() public { - _submitAndEnableMarket(allMarkets[0], CAP); + function testSubmitCapZeroNoTimelock(uint256 seed, uint256 cap) public { + MarketParams memory marketParams = allMarkets[seed % allMarkets.length]; + cap = bound(cap, 1, type(uint192).max); + + _setCap(marketParams, cap); vm.prank(RISK_MANAGER); - vm.expectRevert(bytes(ErrorsLib.ENABLE_MARKET_FAILED)); - vault.enableMarket(allMarkets[0].id()); + vault.submitCap(marketParams, 0); + + (uint192 newCap, uint64 withdrawRank) = vault.config(marketParams.id()); + + assertEq(newCap, 0, "newCap"); + assertEq(withdrawRank, 1, "withdrawRank"); } - function testEnableMarketShouldRevertWhenTimelockNotElapsed(uint128 timelock, uint256 timeElapsed) public { - timelock = uint128(bound(timelock, 1, MAX_TIMELOCK)); - _submitAndSetTimelock(timelock); + function testAcceptCap(uint256 seed, uint256 cap) public { + MarketParams memory marketParams = allMarkets[seed % allMarkets.length]; + cap = bound(cap, 1, type(uint192).max); + + _setCap(marketParams, cap); + + Id id = marketParams.id(); + (uint192 newCap, uint64 withdrawRank) = vault.config(id); + + assertEq(newCap, cap, "newCap"); + assertEq(withdrawRank, 1, "withdrawRank"); + assertEq(Id.unwrap(vault.supplyQueue(0)), Id.unwrap(id), "supplyQueue"); + assertEq(Id.unwrap(vault.withdrawQueue(0)), Id.unwrap(id), "withdrawQueue"); + } + + function testAcceptCapTimelockNotElapsed(uint256 timelock, uint256 timeElapsed) public { + timelock = bound(timelock, 1, MAX_TIMELOCK); + + vm.assume(timelock != vault.timelock()); + + _setTimelock(timelock); timeElapsed = bound(timeElapsed, 0, timelock - 1); - vm.startPrank(RISK_MANAGER); - vault.submitMarket(allMarkets[0], CAP); + vm.prank(RISK_MANAGER); + vault.submitCap(allMarkets[0], CAP); vm.warp(block.timestamp + timeElapsed); + vm.prank(RISK_MANAGER); vm.expectRevert(bytes(ErrorsLib.TIMELOCK_NOT_ELAPSED)); - vault.enableMarket(allMarkets[0].id()); + vault.acceptCap(allMarkets[0].id()); } - function testEnableMarketShouldRevertWhenTimelockExpirationExceeded(uint128 timelock, uint256 timeElapsed) public { - timelock = uint128(bound(timelock, 1, MAX_TIMELOCK)); - _submitAndSetTimelock(timelock); + function testAcceptCapTimelockExpirationExceeded(uint256 timelock, uint256 timeElapsed) public { + timelock = bound(timelock, 1, MAX_TIMELOCK); - timeElapsed = bound(timeElapsed, timelock + TIMELOCK_EXPIRATION + 1, type(uint128).max); + vm.assume(timelock != vault.timelock()); + + _setTimelock(timelock); + + timeElapsed = bound(timeElapsed, timelock + TIMELOCK_EXPIRATION + 1, type(uint64).max); vm.startPrank(RISK_MANAGER); - vault.submitMarket(allMarkets[0], CAP); + vault.submitCap(allMarkets[0], CAP); vm.warp(block.timestamp + timeElapsed); vm.expectRevert(bytes(ErrorsLib.TIMELOCK_EXPIRATION_EXCEEDED)); - vault.enableMarket(allMarkets[0].id()); + vault.acceptCap(allMarkets[0].id()); } - function testSubmitPendingMarketShouldRevertWhenInconsistenAsset(MarketParams memory marketParamsFuzz) public { - vm.assume(marketParamsFuzz.borrowableToken != address(borrowableToken)); + function testSubmitCapInconsistentAsset(MarketParams memory marketParams) public { + vm.assume(marketParams.borrowableToken != address(borrowableToken)); vm.prank(RISK_MANAGER); vm.expectRevert(bytes(ErrorsLib.INCONSISTENT_ASSET)); - vault.submitMarket(marketParamsFuzz, 0); + vault.submitCap(marketParams, 0); } - function testSubmitPendingMarketShouldRevertWhenMarketNotCreated(MarketParams memory marketParamsFuzz) public { - marketParamsFuzz.borrowableToken = address(borrowableToken); - (,,,, uint128 lastUpdate,) = morpho.market(marketParamsFuzz.id()); + function testSubmitCapMarketNotCreated(MarketParams memory marketParams) public { + marketParams.borrowableToken = address(borrowableToken); + (,,,, uint256 lastUpdate,) = morpho.market(marketParams.id()); vm.assume(lastUpdate == 0); vm.prank(RISK_MANAGER); vm.expectRevert(bytes(ErrorsLib.MARKET_NOT_CREATED)); - vault.submitMarket(marketParamsFuzz, 0); + vault.submitCap(marketParams, 0); } - function testDisableMarket() public { - _submitAndEnableMarket(allMarkets[0], CAP); - _submitAndEnableMarket(allMarkets[1], CAP); - _submitAndEnableMarket(allMarkets[2], CAP); + function testSetSupplyQueue() public { + _setCap(allMarkets[0], CAP); + _setCap(allMarkets[1], CAP); + _setCap(allMarkets[2], CAP); - Id id = allMarkets[1].id(); + assertEq(Id.unwrap(vault.supplyQueue(0)), Id.unwrap(allMarkets[0].id())); + assertEq(Id.unwrap(vault.supplyQueue(1)), Id.unwrap(allMarkets[1].id())); + assertEq(Id.unwrap(vault.supplyQueue(2)), Id.unwrap(allMarkets[2].id())); - vm.prank(RISK_MANAGER); - vault.disableMarket(id); - - vm.expectRevert(bytes(ErrorsLib.UNAUTHORIZED_MARKET)); - vault.marketCap(id); - - assertEq(Id.unwrap(vault.supplyAllocationOrder(0)), Id.unwrap(allMarkets[0].id())); - assertEq(Id.unwrap(vault.supplyAllocationOrder(1)), Id.unwrap(allMarkets[2].id())); - } - - function testDisableMarketShouldRevertWhenAlreadyDisabled() public { - _submitAndEnableMarket(allMarkets[0], CAP); - - vm.startPrank(RISK_MANAGER); - vault.disableMarket(allMarkets[0].id()); - - vm.expectRevert(bytes(ErrorsLib.DISABLE_MARKET_FAILED)); - vault.disableMarket(allMarkets[0].id()); - vm.stopPrank(); - } - - function testDisableMarketShouldRevertWhenMarketIsNotEnabled(MarketParams memory marketParamsFuzz) public { - vm.prank(RISK_MANAGER); - vm.expectRevert(bytes(ErrorsLib.DISABLE_MARKET_FAILED)); - vault.disableMarket(marketParamsFuzz.id()); - } - - function testSetSupplyAllocationOrder() public { - _submitAndEnableMarket(allMarkets[0], CAP); - _submitAndEnableMarket(allMarkets[1], CAP); - _submitAndEnableMarket(allMarkets[2], CAP); - - assertEq(Id.unwrap(vault.supplyAllocationOrder(0)), Id.unwrap(allMarkets[0].id())); - assertEq(Id.unwrap(vault.supplyAllocationOrder(1)), Id.unwrap(allMarkets[1].id())); - assertEq(Id.unwrap(vault.supplyAllocationOrder(2)), Id.unwrap(allMarkets[2].id())); - - Id[] memory supplyAllocationOrder = new Id[](3); - supplyAllocationOrder[0] = allMarkets[1].id(); - supplyAllocationOrder[1] = allMarkets[2].id(); - supplyAllocationOrder[2] = allMarkets[0].id(); + Id[] memory supplyQueue = new Id[](2); + supplyQueue[0] = allMarkets[1].id(); + supplyQueue[1] = allMarkets[2].id(); vm.prank(ALLOCATOR); - vault.setSupplyAllocationOrder(supplyAllocationOrder); + vault.setSupplyQueue(supplyQueue); - assertEq(Id.unwrap(vault.supplyAllocationOrder(0)), Id.unwrap(allMarkets[1].id())); - assertEq(Id.unwrap(vault.supplyAllocationOrder(1)), Id.unwrap(allMarkets[2].id())); - assertEq(Id.unwrap(vault.supplyAllocationOrder(2)), Id.unwrap(allMarkets[0].id())); + assertEq(Id.unwrap(vault.supplyQueue(0)), Id.unwrap(allMarkets[1].id())); + assertEq(Id.unwrap(vault.supplyQueue(1)), Id.unwrap(allMarkets[2].id())); } - function testSetSupplyAllocationOrderRevertWhenMissingAtLeastOneMarketInTheAllocationList() public { - _submitAndEnableMarket(allMarkets[0], CAP); - _submitAndEnableMarket(allMarkets[1], CAP); - _submitAndEnableMarket(allMarkets[2], CAP); - - Id[] memory supplyAllocationOrder = new Id[](3); - supplyAllocationOrder[0] = allMarkets[0].id(); - supplyAllocationOrder[1] = allMarkets[1].id(); - - vm.prank(ALLOCATOR); - vm.expectRevert(bytes(ErrorsLib.MARKET_NOT_ENABLED)); - vault.setSupplyAllocationOrder(supplyAllocationOrder); - } + function testSortWithdrawQueue() public { + _setCap(allMarkets[0], CAP); + _setCap(allMarkets[1], CAP); + _setCap(allMarkets[2], CAP); - function testSetSupplyAllocationOrderRevertWhenInvalidLength() public { - _submitAndEnableMarket(allMarkets[0], CAP); - _submitAndEnableMarket(allMarkets[1], CAP); - _submitAndEnableMarket(allMarkets[2], CAP); + assertEq(Id.unwrap(vault.withdrawQueue(0)), Id.unwrap(allMarkets[0].id())); + assertEq(Id.unwrap(vault.withdrawQueue(1)), Id.unwrap(allMarkets[1].id())); + assertEq(Id.unwrap(vault.withdrawQueue(2)), Id.unwrap(allMarkets[2].id())); - Id[] memory supplyAllocationOrder1 = new Id[](2); - supplyAllocationOrder1[0] = allMarkets[0].id(); - supplyAllocationOrder1[1] = allMarkets[1].id(); + uint256[] memory indexes = new uint256[](3); + indexes[0] = 1; + indexes[1] = 2; + indexes[2] = 0; vm.prank(ALLOCATOR); - vm.expectRevert(bytes(ErrorsLib.INVALID_LENGTH)); - vault.setSupplyAllocationOrder(supplyAllocationOrder1); - - Id[] memory supplyAllocationOrder2 = new Id[](4); - supplyAllocationOrder2[0] = allMarkets[0].id(); - supplyAllocationOrder2[1] = allMarkets[1].id(); - supplyAllocationOrder2[2] = allMarkets[2].id(); - supplyAllocationOrder2[3] = allMarkets[3].id(); + vault.sortWithdrawQueue(indexes); - vm.prank(ALLOCATOR); - vm.expectRevert(bytes(ErrorsLib.INVALID_LENGTH)); - vault.setSupplyAllocationOrder(supplyAllocationOrder2); + assertEq(Id.unwrap(vault.withdrawQueue(0)), Id.unwrap(allMarkets[1].id())); + assertEq(Id.unwrap(vault.withdrawQueue(1)), Id.unwrap(allMarkets[2].id())); + assertEq(Id.unwrap(vault.withdrawQueue(2)), Id.unwrap(allMarkets[0].id())); } - function testSetWithdrawAllocationOrder() public { - _submitAndEnableMarket(allMarkets[0], CAP); - _submitAndEnableMarket(allMarkets[1], CAP); - _submitAndEnableMarket(allMarkets[2], CAP); - - assertEq(Id.unwrap(vault.withdrawAllocationOrder(0)), Id.unwrap(allMarkets[0].id())); - assertEq(Id.unwrap(vault.withdrawAllocationOrder(1)), Id.unwrap(allMarkets[1].id())); - assertEq(Id.unwrap(vault.withdrawAllocationOrder(2)), Id.unwrap(allMarkets[2].id())); + function testSortWithdrawQueueInvalidIndex() public { + _setCap(allMarkets[0], CAP); + _setCap(allMarkets[1], CAP); + _setCap(allMarkets[2], CAP); - Id[] memory withdrawAllocationOrder = new Id[](3); - withdrawAllocationOrder[0] = allMarkets[1].id(); - withdrawAllocationOrder[1] = allMarkets[2].id(); - withdrawAllocationOrder[2] = allMarkets[0].id(); + uint256[] memory indexes = new uint256[](3); + indexes[0] = 1; + indexes[1] = 2; + indexes[2] = 3; vm.prank(ALLOCATOR); - vault.setWithdrawAllocationOrder(withdrawAllocationOrder); - - assertEq(Id.unwrap(vault.withdrawAllocationOrder(0)), Id.unwrap(allMarkets[1].id())); - assertEq(Id.unwrap(vault.withdrawAllocationOrder(1)), Id.unwrap(allMarkets[2].id())); - assertEq(Id.unwrap(vault.withdrawAllocationOrder(2)), Id.unwrap(allMarkets[0].id())); + vm.expectRevert(stdError.indexOOBError); + vault.sortWithdrawQueue(indexes); } - function testSetWithdrawAllocationOrderRevertWhenMissingAtLeastOneMarketInTheAllocationList() public { - _submitAndEnableMarket(allMarkets[0], CAP); - _submitAndEnableMarket(allMarkets[1], CAP); - _submitAndEnableMarket(allMarkets[2], CAP); + function testSortWithdrawQueueDuplicateMarket() public { + _setCap(allMarkets[0], CAP); + _setCap(allMarkets[1], CAP); + _setCap(allMarkets[2], CAP); - Id[] memory withdrawAllocationOrder = new Id[](3); - withdrawAllocationOrder[0] = allMarkets[0].id(); - withdrawAllocationOrder[1] = allMarkets[1].id(); + uint256[] memory indexes = new uint256[](3); + indexes[0] = 1; + indexes[1] = 2; + indexes[2] = 1; vm.prank(ALLOCATOR); - vm.expectRevert(bytes(ErrorsLib.MARKET_NOT_ENABLED)); - vault.setWithdrawAllocationOrder(withdrawAllocationOrder); + vm.expectRevert(bytes(ErrorsLib.DUPLICATE_MARKET)); + vault.sortWithdrawQueue(indexes); } - function testSetWithdrawAllocationOrderRevertWhenInvalidLength() public { - _submitAndEnableMarket(allMarkets[0], CAP); - _submitAndEnableMarket(allMarkets[1], CAP); - _submitAndEnableMarket(allMarkets[2], CAP); + function testSortWithdrawQueueMissingMarket() public { + _setCap(allMarkets[0], CAP); + _setCap(allMarkets[1], CAP); + _setCap(allMarkets[2], CAP); - Id[] memory withdrawAllocationOrder1 = new Id[](2); - withdrawAllocationOrder1[0] = allMarkets[0].id(); - withdrawAllocationOrder1[1] = allMarkets[1].id(); + borrowableToken.setBalance(SUPPLIER, 1); - vm.prank(ALLOCATOR); - vm.expectRevert(bytes(ErrorsLib.INVALID_LENGTH)); - vault.setWithdrawAllocationOrder(withdrawAllocationOrder1); + vm.prank(SUPPLIER); + vault.deposit(1, RECEIVER); - Id[] memory withdrawAllocationOrder2 = new Id[](4); - withdrawAllocationOrder2[0] = allMarkets[0].id(); - withdrawAllocationOrder2[1] = allMarkets[1].id(); - withdrawAllocationOrder2[2] = allMarkets[2].id(); - withdrawAllocationOrder2[3] = allMarkets[3].id(); + uint256[] memory indexes = new uint256[](2); + indexes[0] = 1; + indexes[1] = 2; vm.prank(ALLOCATOR); - vm.expectRevert(bytes(ErrorsLib.INVALID_LENGTH)); - vault.setWithdrawAllocationOrder(withdrawAllocationOrder2); - } - - function testSetCap(uint128 cap) public { - _submitAndEnableMarket(allMarkets[0], CAP); - - vm.prank(RISK_MANAGER); - vault.setCap(allMarkets[0], cap); - - assertEq(vault.marketCap(allMarkets[0].id()), cap); - } - - function testSetCapShouldRevertWhenMarketIsNotEnabled(MarketParams memory marketParamsFuzz) public { - vm.prank(RISK_MANAGER); - vm.expectRevert(bytes(ErrorsLib.MARKET_NOT_ENABLED)); - vault.setCap(marketParamsFuzz, CAP); + vm.expectRevert(bytes(ErrorsLib.MISSING_MARKET)); + vault.sortWithdrawQueue(indexes); } } diff --git a/test/forge/RoleTest.sol b/test/forge/RoleTest.sol index 87716ea6..3612b5f4 100644 --- a/test/forge/RoleTest.sol +++ b/test/forge/RoleTest.sol @@ -39,16 +39,10 @@ contract RoleTest is BaseTest { vm.startPrank(caller); vm.expectRevert(bytes(ErrorsLib.NOT_RISK_MANAGER)); - vault.submitMarket(allMarkets[0], CAP); + vault.submitCap(allMarkets[0], CAP); vm.expectRevert(bytes(ErrorsLib.NOT_RISK_MANAGER)); - vault.enableMarket(allMarkets[0].id()); - - vm.expectRevert(bytes(ErrorsLib.NOT_RISK_MANAGER)); - vault.setCap(allMarkets[0], CAP); - - vm.expectRevert(bytes(ErrorsLib.NOT_RISK_MANAGER)); - vault.disableMarket(allMarkets[0].id()); + vault.acceptCap(allMarkets[0].id()); vm.stopPrank(); } @@ -57,14 +51,15 @@ contract RoleTest is BaseTest { vm.assume(caller != vault.owner() && !vault.isRiskManager(caller) && !vault.isAllocator(caller)); vm.startPrank(caller); - Id[] memory order; + Id[] memory supplyQueue; MarketAllocation[] memory allocation; + uint256[] memory withdrawQueueFromRanks; vm.expectRevert(bytes(ErrorsLib.NOT_ALLOCATOR)); - vault.setSupplyAllocationOrder(order); + vault.setSupplyQueue(supplyQueue); vm.expectRevert(bytes(ErrorsLib.NOT_ALLOCATOR)); - vault.setWithdrawAllocationOrder(order); + vault.sortWithdrawQueue(withdrawQueueFromRanks); vm.expectRevert(bytes(ErrorsLib.NOT_ALLOCATOR)); vault.reallocate(allocation, allocation); @@ -73,43 +68,49 @@ contract RoleTest is BaseTest { } function testRiskManagerOrOwnerShouldTriggerRiskManagerFunctions() public { - vm.startPrank(OWNER); - vault.submitMarket(allMarkets[0], CAP); - vault.enableMarket(allMarkets[0].id()); - vault.setCap(allMarkets[0], CAP); - vault.disableMarket(allMarkets[0].id()); - vm.stopPrank(); + vm.prank(OWNER); + vault.submitCap(allMarkets[0], CAP); - vm.startPrank(RISK_MANAGER); - vault.submitMarket(allMarkets[1], CAP); - vault.enableMarket(allMarkets[1].id()); - vault.setCap(allMarkets[1], CAP); - vault.disableMarket(allMarkets[1].id()); - vm.stopPrank(); + vm.warp(block.timestamp + vault.timelock()); + + vm.prank(OWNER); + vault.acceptCap(allMarkets[0].id()); + + vm.prank(RISK_MANAGER); + vault.submitCap(allMarkets[1], CAP); + + vm.warp(block.timestamp + vault.timelock()); + + vm.prank(RISK_MANAGER); + vault.acceptCap(allMarkets[1].id()); } function testAllocatorOrRiskManagerOrOwnerShouldTriggerAllocatorFunctions() public { - Id[] memory order = new Id[](1); - order[0] = allMarkets[0].id(); - MarketAllocation[] memory allocation; + _setCap(allMarkets[0], CAP); + + Id[] memory supplyQueue = new Id[](1); + supplyQueue[0] = allMarkets[0].id(); - _submitAndEnableMarket(allMarkets[0], CAP); + uint256[] memory withdrawQueueFromRanks = new uint256[](1); + withdrawQueueFromRanks[0] = 0; + + MarketAllocation[] memory allocation; vm.startPrank(OWNER); - vault.setSupplyAllocationOrder(order); - vault.setWithdrawAllocationOrder(order); + vault.setSupplyQueue(supplyQueue); + vault.sortWithdrawQueue(withdrawQueueFromRanks); vault.reallocate(allocation, allocation); vm.stopPrank(); vm.startPrank(RISK_MANAGER); - vault.setSupplyAllocationOrder(order); - vault.setWithdrawAllocationOrder(order); + vault.setSupplyQueue(supplyQueue); + vault.sortWithdrawQueue(withdrawQueueFromRanks); vault.reallocate(allocation, allocation); vm.stopPrank(); vm.startPrank(ALLOCATOR); - vault.setSupplyAllocationOrder(order); - vault.setWithdrawAllocationOrder(order); + vault.setSupplyQueue(supplyQueue); + vault.sortWithdrawQueue(withdrawQueueFromRanks); vault.reallocate(allocation, allocation); vm.stopPrank(); } diff --git a/test/forge/TimelockTest.sol b/test/forge/TimelockTest.sol index 0c45272b..2ad889e9 100644 --- a/test/forge/TimelockTest.sol +++ b/test/forge/TimelockTest.sol @@ -4,22 +4,26 @@ pragma solidity ^0.8.0; import "./helpers/BaseTest.sol"; contract TimelockTest is BaseTest { - function testSubmitPendingTimelock(uint256 timelock) public { + function testSubmitTimelock(uint256 timelock) public { timelock = bound(timelock, 0, MAX_TIMELOCK); + + vm.assume(timelock != TIMELOCK); + vm.prank(OWNER); vault.submitTimelock(timelock); - (uint128 value, uint128 timestamp) = vault.pendingTimelock(); + (uint256 value, uint64 timestamp) = vault.pendingTimelock(); + assertEq(value, timelock); assertEq(timestamp, block.timestamp); } - function testSubmitPendingTimelockShouldRevertWhenNotOwner(uint256 timelock) public { + function testSubmitTimelockNotOwner(uint256 timelock) public { vm.expectRevert("Ownable: caller is not the owner"); vault.submitTimelock(timelock); } - function testSubmitPendingTimelockShouldRevertWhenMaxTimelockExceeded(uint256 timelock) public { + function testSubmitTimelockMaxTimelockExceeded(uint256 timelock) public { timelock = bound(timelock, MAX_TIMELOCK + 1, type(uint256).max); vm.prank(OWNER); @@ -27,21 +31,56 @@ contract TimelockTest is BaseTest { vault.submitTimelock(timelock); } - function testSetTimelock(uint256 timelock) public { + function testAcceptTimelock(uint256 timelock) public { timelock = bound(timelock, 0, MAX_TIMELOCK); - vm.startPrank(OWNER); + + vm.assume(timelock != TIMELOCK); + + vm.prank(OWNER); vault.submitTimelock(timelock); - vm.warp(block.timestamp + vault.timelock()); + vm.warp(block.timestamp + TIMELOCK); + vm.prank(OWNER); vault.acceptTimelock(); - vm.stopPrank(); assertEq(vault.timelock(), timelock); } - function testSetTimelockShouldRevertWhenNotOwner() public { + function testAcceptTimelockNotOwner() public { vm.expectRevert("Ownable: caller is not the owner"); vault.acceptTimelock(); } + + function testAcceptTimelockNotElapsed(uint256 timelock, uint256 elapsed) public { + timelock = bound(timelock, 0, MAX_TIMELOCK); + elapsed = bound(elapsed, 1, TIMELOCK - 1); + + vm.assume(timelock != TIMELOCK); + + vm.prank(OWNER); + vault.submitTimelock(timelock); + + vm.warp(block.timestamp + elapsed); + + vm.prank(OWNER); + vm.expectRevert(bytes(ErrorsLib.TIMELOCK_NOT_ELAPSED)); + vault.acceptTimelock(); + } + + function testAcceptTimelockExpirationExceeded(uint256 timelock, uint256 elapsed) public { + timelock = bound(timelock, 0, MAX_TIMELOCK); + elapsed = bound(elapsed, TIMELOCK + TIMELOCK_EXPIRATION + 1, type(uint64).max); + + vm.assume(timelock != TIMELOCK); + + vm.prank(OWNER); + vault.submitTimelock(timelock); + + vm.warp(block.timestamp + elapsed); + + vm.prank(OWNER); + vm.expectRevert(bytes(ErrorsLib.TIMELOCK_EXPIRATION_EXCEEDED)); + vault.acceptTimelock(); + } } diff --git a/test/forge/helpers/BaseTest.sol b/test/forge/helpers/BaseTest.sol index add9750d..3b805dc9 100644 --- a/test/forge/helpers/BaseTest.sol +++ b/test/forge/helpers/BaseTest.sol @@ -15,10 +15,10 @@ import {IrmMock} from "src/mocks/IrmMock.sol"; import {ERC20Mock} from "src/mocks/ERC20Mock.sol"; import {OracleMock} from "src/mocks/OracleMock.sol"; -import {MetaMorpho, IERC20, ErrorsLib, Pending, MarketAllocation} from "src/MetaMorpho.sol"; +import {MetaMorpho, IERC20, ErrorsLib, MarketAllocation} from "src/MetaMorpho.sol"; -import "forge-std/Test.sol"; -import "forge-std/console2.sol"; +import "@forge-std/Test.sol"; +import "@forge-std/console2.sol"; contract BaseTest is Test { using MorphoLib for IMorpho; @@ -32,7 +32,7 @@ contract BaseTest is Test { uint256 internal constant MIN_TEST_LLTV = 0.01 ether; uint256 internal constant MAX_TEST_LLTV = 0.99 ether; uint256 internal constant NB_MARKETS = 10; - uint256 internal constant TIMELOCK = 0; + uint256 internal constant TIMELOCK = 2; uint128 internal constant CAP = type(uint128).max; address internal OWNER; @@ -96,6 +96,9 @@ contract BaseTest is Test { vault.setIsAllocator(ALLOCATOR, true); vm.stopPrank(); + // block.timestamp defaults to 1 which is an unrealistic state. + vm.warp(block.timestamp + TIMELOCK); + for (uint256 i; i < NB_MARKETS; ++i) { uint256 lltv = 0.8 ether / (i + 1); @@ -219,19 +222,23 @@ contract BaseTest is Test { require(deployed != address(0), string.concat("could not deploy `", artifactPath, "`")); } - function _submitAndSetTimelock(uint128 timelock) internal { - vm.startPrank(OWNER); + function _setTimelock(uint256 timelock) internal { + vm.prank(OWNER); vault.submitTimelock(timelock); + vm.warp(block.timestamp + vault.timelock()); + + vm.prank(OWNER); vault.acceptTimelock(); - vm.stopPrank(); } - function _submitAndEnableMarket(MarketParams memory params, uint128 cap) internal { - vm.startPrank(RISK_MANAGER); - vault.submitMarket(params, cap); + function _setCap(MarketParams memory params, uint256 cap) internal { + vm.prank(RISK_MANAGER); + vault.submitCap(params, cap); + vm.warp(block.timestamp + vault.timelock()); - vault.enableMarket(params.id()); - vm.stopPrank(); + + vm.prank(RISK_MANAGER); + vault.acceptCap(params.id()); } } diff --git a/test/hardhat/MetaMorpho.spec.ts b/test/hardhat/MetaMorpho.spec.ts index db85ec2a..a60981d2 100644 --- a/test/hardhat/MetaMorpho.spec.ts +++ b/test/hardhat/MetaMorpho.spec.ts @@ -114,7 +114,7 @@ describe("MetaMorpho", () => { const IMetaMorphoFactory = await hre.ethers.getContractFactory("MetaMorpho", admin); - metaMorpho = await IMetaMorphoFactory.deploy(morphoAddress, 0, borrowableAddress, "MetaMorpho", "mB"); + metaMorpho = await IMetaMorphoFactory.deploy(morphoAddress, 1, borrowableAddress, "MetaMorpho", "mB"); const metaMorphoAddress = await metaMorpho.getAddress(); @@ -128,22 +128,24 @@ describe("MetaMorpho", () => { await metaMorpho.setIsRiskManager(riskManager.address, true); await metaMorpho.setIsAllocator(allocator.address, true); + await metaMorpho.submitTimelock(0); + + const block = await hre.ethers.provider.getBlock("latest"); + await setNextBlockTimestamp(block!.timestamp + 1); + + await metaMorpho.acceptTimelock(); + await metaMorpho.setFeeRecipient(admin.address); - await metaMorpho.submitFee(BigInt.WAD / 5n); - await metaMorpho.acceptFee(); + await metaMorpho.submitFee(BigInt.WAD / 10n); for (const marketParams of allMarketParams) { await metaMorpho .connect(riskManager) - .submitMarket( - marketParams, - (BigInt.WAD * 100n * toBigInt(suppliers.length)) / toBigInt(allMarketParams.length), - ); - await metaMorpho.connect(riskManager).enableMarket(identifier(marketParams)); + .submitCap(marketParams, (BigInt.WAD * 100n * toBigInt(suppliers.length)) / toBigInt(allMarketParams.length)); } - await metaMorpho.connect(riskManager).setSupplyAllocationOrder(allMarketParams.map(identifier)); - await metaMorpho.connect(riskManager).setWithdrawAllocationOrder(allMarketParams.map(identifier)); + await metaMorpho.connect(riskManager).setSupplyQueue(allMarketParams.map(identifier)); + await metaMorpho.connect(riskManager).sortWithdrawQueue(allMarketParams.map((_, i) => nbMarkets - 1 - i)); hre.tracer.nameTags[morphoAddress] = "Morpho"; hre.tracer.nameTags[collateralAddress] = "Collateral";