From 8eba0ad2ebf277ef577c5c8bece5bcc7d2d742eb Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Fri, 19 Apr 2024 04:19:06 -0400 Subject: [PATCH] feat: add ERC4626 external view function overrides --- src/Vault.sol | 374 -------------- src/interfaces/IIonPool.sol | 2 +- src/vault/Vault.sol | 722 +++++++++++++++++++++++++++ test/fork/concrete/vault/Vault.t.sol | 2 +- test/helpers/VaultSharedSetup.sol | 215 ++++++++ test/unit/concrete/Vault.t.sol | 407 ++++++++------- 6 files changed, 1153 insertions(+), 569 deletions(-) delete mode 100644 src/Vault.sol create mode 100644 src/vault/Vault.sol create mode 100644 test/helpers/VaultSharedSetup.sol diff --git a/src/Vault.sol b/src/Vault.sol deleted file mode 100644 index 5d29541e..00000000 --- a/src/Vault.sol +++ /dev/null @@ -1,374 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.21; - -import { IIonPool } from "./interfaces/IIonPool.sol"; -import { IIonLens } from "./interfaces/IIonLens.sol"; -import { WAD } from "./libraries/math/WadRayMath.sol"; - -import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; -import { ERC4626 } from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol"; -import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import { Ownable2Step } from "openzeppelin-contracts/contracts/access/Ownable2Step.sol"; -import { Ownable } from "openzeppelin-contracts/contracts/access/Ownable.sol"; -import { IERC20 } from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; -import { ERC20 } from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; - -import { console2 } from "forge-std/console2.sol"; - -/** - * @notice Vault contract that can allocate a single lender asset over various - * isolated pairs on Ion Protocol. This contract is a fork of the Metamorpho - * contract licnesed under GPL-2.0 with minimal changes to apply the - * reallocation logic to the Ion isolated pairs. - * - * @custom:security-contact security@molecularlabs.io - */ -contract Vault is ERC4626, Ownable2Step { - using EnumerableSet for EnumerableSet.AddressSet; - using Math for uint256; - - error InvalidUnderlyingAsset(); - error InvalidSupplyQueueLength(); - error InvalidWithdrawQueueLength(); - error AllocationCapExceeded(); - error InvalidReallocation(); - error AllSupplyCapsReached(); - error NotEnoughLiquidityToWithdraw(); - error InvalidSupplyQueuePool(); - error InvalidWithdrawQueuePool(); - - event UpdateSupplyQueue(address indexed caller, IIonPool[] newSupplyQueue); - event UpdateWithdrawQueue(address indexed caller, IIonPool[] newWithdrawQueue); - - event ReallocateWithdraw(IIonPool indexed pool, uint256 assets); - event ReallocateSupply(IIonPool indexed pool, uint256 assets); - event FeeAccrued(uint256 feeShares, uint256 newTotalAssets); - event UpdateLastTotalAssets(uint256 lastTotalAssets, uint256 newLastTotalAssets); - - EnumerableSet.AddressSet supportedMarkets; - IIonPool[] public supplyQueue; - IIonPool[] public withdrawQueue; - - address feeRecipient; - uint256 public feePercentage; - - mapping(IIonPool => uint256) public caps; - uint256 public lastTotalAssets; - - IIonLens public immutable ionLens; - IERC20 public immutable baseAsset; - - struct MarketAllocation { - IIonPool pool; - uint256 targetAssets; - } - - constructor( - address _owner, - address _feeRecipient, - IERC20 _baseAsset, - IIonLens _ionLens, - string memory _name, - string memory _symbol - ) - ERC4626(IERC20(_baseAsset)) - ERC20(_name, _symbol) - Ownable(_owner) - { - feeRecipient = _feeRecipient; - ionLens = _ionLens; - baseAsset = _baseAsset; - } - - /** - * @notice Add markets that can be supplied and withdrawn from. - * @dev TODO when removing markets, revoke all approvals to the market. - */ - function addSupportedMarkets(IIonPool[] calldata markets) external onlyOwner { - for (uint256 i; i < markets.length; ++i) { - IIonPool pool = markets[i]; - if (address(pool.underlying()) != address(baseAsset)) revert InvalidUnderlyingAsset(); - supportedMarkets.add(address(pool)); - - address[] memory values = supportedMarkets.values(); - - baseAsset.approve(address(pool), type(uint256).max); - } - } - - function _validateIonPoolArrayInput(IIonPool[] memory pools) internal view { - uint256 length = pools.length; - if (length != supportedMarkets.length()) revert InvalidSupplyQueueLength(); - for (uint256 i; i < length; ++i) { - address pool = address(pools[i]); - if (!supportedMarkets.contains(pool) || pool == address(0)) revert InvalidSupplyQueuePool(); - } - } - - /** - * TODO Should you be able to change allocation caps to be below current deposit amount? - * How does this affect supply and deposit behavior? - */ - function updateAllocationCaps(IIonPool[] calldata pools, uint256[] calldata newCaps) external onlyOwner { - _validateIonPoolArrayInput(pools); - - for (uint256 i; i < pools.length; ++i) { - caps[pools[i]] = newCaps[i]; - } - } - - /** - * @notice Update the order of the markets in which user deposits are supplied. - * @dev The IIonPool in the queue must be part of `supportedMarkets`. - */ - function updateSupplyQueue(IIonPool[] calldata newSupplyQueue) external onlyOwner { - _validateIonPoolArrayInput(newSupplyQueue); - - supplyQueue = newSupplyQueue; - - emit UpdateSupplyQueue(msg.sender, newSupplyQueue); - } - - /** - * @notice Update the order of the markets in which the deposits are withdrawn. - * @dev The IonPool in the queue must be part of `supportedMarkets`. - */ - function updateWithdrawQueue(IIonPool[] calldata newWithdrawQueue) external onlyOwner { - _validateIonPoolArrayInput(newWithdrawQueue); - - withdrawQueue = newWithdrawQueue; - - emit UpdateWithdrawQueue(msg.sender, newWithdrawQueue); - } - - /** - * @notice Receives deposits from the sender and supplies into the underlying IonPool markets. - * @dev All incoming deposits are deposited in the order specified in the deposit queue. - */ - function deposit(uint256 assets, address receiver) public override returns (uint256 shares) { - uint256 newTotalAssets = _accrueFee(); - - shares = _convertToSharesWithTotals(assets, totalSupply(), newTotalAssets, Math.Rounding.Floor); - _deposit(msg.sender, receiver, assets, shares); - } - - function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { - super._deposit(caller, receiver, assets, shares); - console2.log("baseAsset: ", address(baseAsset)); - console2.log("balance after super._deposit", baseAsset.balanceOf(address(this))); - _supplyToIonPool(assets); - - _updateLastTotalAssets(lastTotalAssets + assets); - } - - /** - * @notice Withdraws supplied assets from IonPools and sends them to the receiver in exchange for vault shares. - * @dev All withdraws are withdrawn in the order specified in the withdraw queue. - */ - function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256 shares) { - uint256 newTotalAssets = _accrueFee(); - console2.log("newTotalAssets: ", newTotalAssets); - shares = _convertToSharesWithTotals(assets, totalSupply(), newTotalAssets, Math.Rounding.Ceil); - console2.log("shares: ", shares); - _updateLastTotalAssets(newTotalAssets - assets); - - _withdraw(msg.sender, receiver, owner, assets, shares); - } - - function _withdraw( - address caller, - address receiver, - address owner, - uint256 assets, - uint256 shares - ) - internal - override - { - _withdrawFromIonPool(assets); - - super._withdraw(caller, receiver, owner, assets, shares); - } - - /** - * @notice Reallocates the base asset supply position across the specified IonPools. - * @dev Depending on the order of deposits and withdrawals to and from - * markets, the function could revert if there is not enough assets - * withdrawn to deposit later in the loop. A key invariant is that the total - * assets withdrawn should be equal to the total assets supplied. Otherwise, - * revert. - */ - function reallocate(MarketAllocation[] calldata allocations) external onlyOwner { - uint256 totalSupplied; - uint256 totalWithdrawn; - for (uint256 i; i < allocations.length; ++i) { - MarketAllocation memory allocation = allocations[i]; - IIonPool pool = allocation.pool; - - uint256 targetAssets = allocation.targetAssets; - uint256 currentSupplied = pool.balanceOf(address(this)); - - uint256 toWithdraw = _zeroFloorSub(currentSupplied, targetAssets); - - if (toWithdraw > 0) { - // if `targetAsset` is zero, this means fully withdraw from the market - if (targetAssets == 0) { - toWithdraw = currentSupplied; - } - - pool.withdraw(address(this), toWithdraw); - - totalWithdrawn += toWithdraw; - - emit ReallocateWithdraw(pool, toWithdraw); - } else { - // if `targetAsset` is `type(uint256).max`, then supply all - // assets that have been withdrawn but not yet supplied so far - // in the for loop into this market. - uint256 toSupply = targetAssets == type(uint256).max - ? _zeroFloorSub(totalWithdrawn, totalSupplied) - : _zeroFloorSub(targetAssets, currentSupplied); - - if (toSupply == 0) continue; - - if (currentSupplied + toSupply > caps[pool]) revert AllocationCapExceeded(); - - pool.supply(address(this), toSupply, new bytes32[](0)); - - totalSupplied += toSupply; - - emit ReallocateSupply(pool, toSupply); - } - } - - if (totalWithdrawn != totalSupplied) revert InvalidReallocation(); - } - - // --- IonPool Interactions --- - - function _supplyToIonPool(uint256 assets) internal { - for (uint256 i; i < supplyQueue.length; ++i) { - IIonPool pool = supplyQueue[i]; - - console2.log("i: ", i); - console2.log("pool: ", address(pool)); - console2.log("supply assets: ", assets); - console2.log("caps[pool]: ", caps[pool]); - console2.log("ionLens.wethSupplyCap(pool): ", ionLens.wethSupplyCap(pool)); - - uint256 supplyCeil = Math.min(caps[pool], ionLens.wethSupplyCap(pool)); - console2.log("supplyCeil: ", supplyCeil); - - if (supplyCeil == 0) continue; - - pool.accrueInterest(); - - // supply as much assets we can to fill the maximum available - // deposit for each market - // TODO What happens if the supplyCap or the allocationCap goes - // below the current supplied? - uint256 currentSupplied = pool.balanceOf(address(this)); - console2.log("currentSupplied: ", currentSupplied); - uint256 toSupply = Math.min(_zeroFloorSub(supplyCeil, currentSupplied), assets); - console2.log("toSupply: ", toSupply); - if (toSupply > 0) { - pool.supply(address(this), toSupply, new bytes32[](0)); - assets -= toSupply; // `supply` might take 1 more wei than expected - } - if (assets == 0) return; - } - console2.log("assets out of loop: ", assets); - if (assets != 0) revert AllSupplyCapsReached(); - } - - function _withdrawFromIonPool(uint256 assets) internal { - for (uint256 i; i < supplyQueue.length; ++i) { - IIonPool pool = withdrawQueue[i]; - - uint256 currentSupplied = pool.balanceOf(address(this)); - uint256 toWithdraw = Math.min(currentSupplied, assets); - - // If `assets` is greater than `currentSupplied`, we want to fully withdraw from this market. - // In IonPool, the shares to burn is rounded up as - // ceil(assets / supplyFactor) - if (toWithdraw > 0) { - pool.withdraw(address(this), toWithdraw); - assets -= toWithdraw; - } - if (assets == 0) return; - } - - if (assets != 0) revert NotEnoughLiquidityToWithdraw(); - } - - function _accrueFee() internal returns (uint256 newTotalAssets) { - uint256 feeShares; - (feeShares, newTotalAssets) = _accruedFeeShares(); - if (feeShares != 0) _mint(feeRecipient, feeShares); - - lastTotalAssets = newTotalAssets; // This update happens outside of this function in Metamorpho. - - emit FeeAccrued(feeShares, newTotalAssets); - } - - /** - * @dev The total accrued vault revenue is the difference in the total iToken holdings from the last accrued - * timestamp. - */ - function _accruedFeeShares() internal view returns (uint256 feeShares, uint256 newTotalAssets) { - newTotalAssets = totalAssets(); - uint256 totalInterest = _zeroFloorSub(newTotalAssets, lastTotalAssets); - - // totalInterest amount of new iTokens were created for this vault - // a portion of this should be claimable by depositors - // a portion of this should be claimable by the fee recipient - if (totalInterest != 0 && feePercentage != 0) { - uint256 feeAssets = totalInterest.mulDiv(feePercentage, WAD); - - feeShares = - _convertToSharesWithTotals(feeAssets, totalSupply(), newTotalAssets - feeAssets, Math.Rounding.Floor); - } - } - - /** - * @notice Returns the total claim that the vault has across all supported IonPools. - * @dev `IonPool.balanceOf` returns the rebasing balance of the lender receipt token that is pegged 1:1 to the - * underlying supplied asset. - */ - function totalAssets() public view override returns (uint256 assets) { - for (uint256 i; i < supportedMarkets.length(); ++i) { - assets += IIonPool(supportedMarkets.at(i)).balanceOf(address(this)); - } - } - - /** - * @dev Returns the amount of shares that the vault would exchange for the amount of `assets` provided. - */ - function _convertToSharesWithTotals( - uint256 assets, - uint256 newTotalSupply, - uint256 newTotalAssets, - Math.Rounding rounding - ) - internal - view - returns (uint256) - { - return assets.mulDiv(newTotalSupply + 10 ** _decimalsOffset(), newTotalAssets + 1, rounding); - } - - function _updateLastTotalAssets(uint256 newLastTotalAssets) internal { - lastTotalAssets = newLastTotalAssets; - emit UpdateLastTotalAssets(lastTotalAssets, newLastTotalAssets); - } - - function _zeroFloorSub(uint256 x, uint256 y) internal pure returns (uint256 z) { - assembly { - z := mul(gt(x, y), sub(x, y)) - } - } - - function getSupportedMarkets() external view returns (address[] memory) { - return supportedMarkets.values(); - } -} diff --git a/src/interfaces/IIonPool.sol b/src/interfaces/IIonPool.sol index fa1f98d2..1f511840 100644 --- a/src/interfaces/IIonPool.sol +++ b/src/interfaces/IIonPool.sol @@ -232,5 +232,5 @@ interface IIonPool { function withdrawCollateral(uint8 ilkIndex, address user, address recipient, uint256 amount) external; function getTotalUnderlyingClaims() external returns (uint256); - function getUnderlyingClaimOf(address user) external returns (uint256); + function getUnderlyingClaimOf(address user) external view returns (uint256); } diff --git a/src/vault/Vault.sol b/src/vault/Vault.sol new file mode 100644 index 00000000..a9e311c3 --- /dev/null +++ b/src/vault/Vault.sol @@ -0,0 +1,722 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.21; + +import { IIonPool } from "./../interfaces/IIonPool.sol"; +import { IIonPool } from "./../interfaces/IIonPool.sol"; +import { IIonLens } from "./../interfaces/IIonLens.sol"; +import { WAD } from "./../libraries/math/WadRayMath.sol"; + +import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; +import { IERC4626 } from "openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; +import { ERC4626 } from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import { Ownable2Step } from "openzeppelin-contracts/contracts/access/Ownable2Step.sol"; +import { Ownable } from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import { IERC20 } from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import { ERC20 } from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import { IERC20Metadata } from "openzeppelin-contracts/contracts/interfaces/IERC20Metadata.sol"; + +/** + * @title Ion Lending Vault + * @author Molecular Labs + * @notice Vault contract that can allocate a single lender asset over various + * isolated lending pairs on Ion Protocol. This contract is a fork of the + * Metamorpho contract licnesed under GPL-2.0 with changes to administrative + * logic, underlying data structures, and applying the lending interactions to + * Ion Protocol. + * @custom:security-contact security@molecularlabs.io + */ +contract Vault is ERC4626, Ownable2Step { + using EnumerableSet for EnumerableSet.AddressSet; + using Math for uint256; + + error InvalidQueueLength(); + error AllocationCapOrSupplyCapExceeded(); + error AllSupplyCapsReached(); + error NotEnoughLiquidityToWithdraw(); + error InvalidIdleMarketRemovalNonZeroBalance(); + error InvalidMarketRemovalNonZeroSupply(); + error InvalidSupportedMarkets(); + error InvalidQueueContainsDuplicates(); + error MarketsAndAllocationCapLengthMustBeEqual(); + error MarketAlreadySupported(); + error MarketNotSupported(); + error InvalidQueueMarketNotSupported(); + error IonPoolsArrayAndNewCapsArrayMustBeOfEqualLength(); + + event UpdateSupplyQueue(address indexed caller, IIonPool[] newSupplyQueue); + event UpdateWithdrawQueue(address indexed caller, IIonPool[] newWithdrawQueue); + + event ReallocateWithdraw(IIonPool indexed pool, uint256 assets); + event ReallocateSupply(IIonPool indexed pool, uint256 assets); + event FeeAccrued(uint256 feeShares, uint256 newTotalAssets); + event UpdateLastTotalAssets(uint256 lastTotalAssets, uint256 newLastTotalAssets); + + IIonPool constant IDLE = IIonPool(address(uint160(uint256(keccak256("IDLE_ASSET_HOLDINGS"))))); + + uint8 public immutable DECIMALS_OFFSET; + + IIonLens public immutable ionLens; + IERC20 public immutable baseAsset; + + EnumerableSet.AddressSet supportedMarkets; + + IIonPool[] public supplyQueue; + IIonPool[] public withdrawQueue; + + address feeRecipient; + uint256 public feePercentage; + uint256 public lastTotalAssets; + + mapping(IIonPool => uint256) public caps; + + struct MarketAllocation { + IIonPool pool; + int256 assets; + } + + constructor( + address _owner, + address _feeRecipient, + IERC20 _baseAsset, + IIonLens _ionLens, + string memory _name, + string memory _symbol + ) + ERC4626(IERC20(_baseAsset)) + ERC20(_name, _symbol) + Ownable(_owner) + { + feeRecipient = _feeRecipient; + ionLens = _ionLens; + baseAsset = _baseAsset; + + DECIMALS_OFFSET = uint8(_zeroFloorSub(uint256(18), IERC20Metadata(address(_baseAsset)).decimals())); + } + + /** + * @notice Add markets that can be supplied and withdrawn from. + * @dev Elements in `supportedMarkets` must be a valid IonPool or an IDLE + * address. Valid IonPools require the base asset to be the same. Duplicate + * addition to the EnumerableSet will revert. Sets the allocationCaps of the + * new markets being introduced. + */ + function addSupportedMarkets( + IIonPool[] calldata marketsToAdd, + uint256[] calldata allocationCaps, + IIonPool[] calldata newSupplyQueue, + IIonPool[] calldata newWithdrawQueue + ) + public + onlyOwner + { + if (marketsToAdd.length != allocationCaps.length) revert MarketsAndAllocationCapLengthMustBeEqual(); + + for (uint256 i; i < marketsToAdd.length; ++i) { + IIonPool pool = marketsToAdd[i]; + + if (pool != IDLE) { + if (address(pool.underlying()) != address(baseAsset) || (address(pool) == address(0))) { + revert InvalidSupportedMarkets(); + } + } + + if (!supportedMarkets.add(address(pool))) revert MarketAlreadySupported(); + + caps[pool] = allocationCaps[i]; + + if (pool != IDLE) baseAsset.approve(address(pool), type(uint256).max); + } + + updateSupplyQueue(newSupplyQueue); + updateWithdrawQueue(newWithdrawQueue); + } + + /** + * @notice Removes a supported market and updates the supply/withdraw queues + * without the removed market. + * @dev The allocationCap values of the markets being removed are + * automatically deleted. Whenever a market is removed, the queues must be + * updated without the removed market. + */ + function removeSupportedMarkets( + IIonPool[] calldata marketsToRemove, + IIonPool[] calldata newSupplyQueue, + IIonPool[] calldata newWithdrawQueue + ) + external + onlyOwner + { + for (uint256 i; i < marketsToRemove.length; ++i) { + IIonPool pool = marketsToRemove[i]; + + if (pool == IDLE) { + if (baseAsset.balanceOf(address(this)) != 0) revert InvalidIdleMarketRemovalNonZeroBalance(); + } else { + // Checks `balanceOf` as it may be possible that + // `getUnderlyingClaimOf` returns zero even though the + // `normalizedBalance` is zero. + if (pool.balanceOf(address(this)) != 0) revert InvalidMarketRemovalNonZeroSupply(); + } + + if (!supportedMarkets.remove(address(pool))) revert MarketNotSupported(); + delete caps[pool]; + + if (pool != IDLE) baseAsset.approve(address(pool), 0); + } + updateSupplyQueue(newSupplyQueue); + updateWithdrawQueue(newWithdrawQueue); + } + + /** + * @notice Update the order of the markets in which user deposits are supplied. + * @dev The IonPool in the queue must be part of `supportedMarkets`. + */ + function updateSupplyQueue(IIonPool[] calldata newSupplyQueue) public onlyOwner { + _validateQueueInput(newSupplyQueue); + + supplyQueue = newSupplyQueue; + + emit UpdateSupplyQueue(msg.sender, newSupplyQueue); + } + + /** + * @notice Update the order of the markets in which the deposits are withdrawn. + * @dev The IonPool in the queue must be part of `supportedMarkets`. + */ + function updateWithdrawQueue(IIonPool[] calldata newWithdrawQueue) public onlyOwner { + _validateQueueInput(newWithdrawQueue); + + withdrawQueue = newWithdrawQueue; + + emit UpdateWithdrawQueue(msg.sender, newWithdrawQueue); + } + + /** + * @dev The input array contains ordered indices of the `supportedMarkets`. + * - Must not contain duplicates. + * - Must be the same length as the `supportedMarkets` array. + * - Must not contain indices that are out of bounds of the `supportedMarkets` EnumerableSet's underlying array. + * The above rule enforces that the queue must have all and only the elements in the `supportedMarkets` set. + */ + function _validateQueueInput(IIonPool[] memory queue) internal view { + uint256 supportedMarketsLength = supportedMarkets.length(); + uint256 queueLength = queue.length; + + if (queueLength != supportedMarketsLength) revert InvalidQueueLength(); + + bool[] memory seen = new bool[](queueLength); + + for (uint256 i; i < queueLength; ++i) { + // the `_positions` mapping returns `index + 1` and 0 means the value is not in the set + bytes32 key = bytes32(uint256(uint160(address(queue[i])))); + uint256 index = supportedMarkets._inner._positions[key]; + + if (index == 0) revert InvalidQueueMarketNotSupported(); + + index--; + + if (seen[index] == true) revert InvalidQueueContainsDuplicates(); + + seen[index] = true; + } + } + + /** + * @dev The allocation caps are applied to pools in the order of the array + * within `supportedMarkets`. The elements inside `ionPools` must exist in + * `supportedMarkets`. + */ + function updateAllocationCaps(IIonPool[] calldata ionPools, uint256[] calldata newCaps) external onlyOwner { + if (ionPools.length != newCaps.length) revert IonPoolsArrayAndNewCapsArrayMustBeOfEqualLength(); + + for (uint256 i; i < ionPools.length; ++i) { + IIonPool pool = ionPools[i]; + if (!supportedMarkets.contains(address(pool))) revert MarketNotSupported(); + caps[pool] = newCaps[i]; + } + } + + /** + * @notice Reallocates the base asset supply position across the specified IonPools. + * @dev Depending on the order of deposits and withdrawals to and from + * markets, the function could revert if there is not enough assets + * withdrawn to deposit later in the loop. A key invariant is that the total + * assets withdrawn should be equal to the total assets supplied. Otherwise, + * revert. + */ + function reallocate(MarketAllocation[] calldata allocations) external onlyOwner { + uint256 totalSupplied; + uint256 totalWithdrawn; + for (uint256 i; i < allocations.length; ++i) { + MarketAllocation memory allocation = allocations[i]; + IIonPool pool = allocation.pool; + + uint256 currentSupplied = pool.getUnderlyingClaimOf(address(this)); + int256 assets = allocation.assets; // to deposit or withdraw + + // if `assets` is zero, this means fully withdraw from the market. + // This prevents frontrunning in case the market needs to be fully withdrawn + // from in order to remove the market. + uint256 transferAmt; + if (assets < 0) { + if (assets == type(int256).min) { + // The resulting shares from full withdraw must be zero. + transferAmt = currentSupplied; + } else { + transferAmt = uint256(-assets); + } + pool.withdraw(address(this), transferAmt); + + totalWithdrawn += transferAmt; + + emit ReallocateWithdraw(pool, transferAmt); + } else if (assets > 0) { + // Deposit all assets that have been withdrawn so far + if (assets == type(int256).max) { + transferAmt = totalWithdrawn; + } else { + transferAmt = uint256(assets); + } + + if (currentSupplied + transferAmt > Math.min(caps[pool], ionLens.wethSupplyCap(pool))) { + revert AllocationCapOrSupplyCapExceeded(); + } + pool.supply(address(this), transferAmt, new bytes32[](0)); + + totalSupplied += transferAmt; + + emit ReallocateSupply(pool, transferAmt); + } else { + continue; + } + } + // totalSupplied must be less than or equal to totalWithdrawn + } + + // --- IonPool Interactions --- + + function _supplyToIonPool(uint256 assets) internal { + for (uint256 i; i < supplyQueue.length; ++i) { + IIonPool pool = supplyQueue[i]; + + // handle case where assets are kept on balance without any transfer + // to IonPools. + if (pool == IDLE) { + uint256 allocationCap = caps[pool]; + if (allocationCap == 0) continue; + uint256 toKeepIdle = Math.min(_zeroFloorSub(allocationCap, baseAsset.balanceOf(address(this))), assets); + assets -= toKeepIdle; + if (assets == 0) return; + continue; + } + + uint256 supplyCeil = Math.min(caps[pool], ionLens.wethSupplyCap(pool)); + + if (supplyCeil == 0) continue; + + pool.accrueInterest(); + + // supply as much assets we can to fill the maximum available + // deposit for each market + uint256 currentSupplied = pool.getUnderlyingClaimOf(address(this)); + + uint256 toSupply = Math.min(_zeroFloorSub(supplyCeil, currentSupplied), assets); + + if (toSupply > 0) { + pool.supply(address(this), toSupply, new bytes32[](0)); + assets -= toSupply; + } + if (assets == 0) return; + } + if (assets != 0) revert AllSupplyCapsReached(); + } + + function _withdrawFromIonPool(uint256 assets) internal { + for (uint256 i; i < supplyQueue.length; ++i) { + IIonPool pool = withdrawQueue[i]; + + // if the assets are IDLE, they are already on this contract's + // balance. Update `assets` accumulator but don't actually transfer. + uint256 toWithdraw; + if (pool == IDLE) { + uint256 currentIdleBalance = baseAsset.balanceOf(address(this)); + toWithdraw = Math.min(currentIdleBalance, assets); + assets -= toWithdraw; + if (assets == 0) return; + continue; + } + + uint256 withdrawable = _withdrawable(pool); + toWithdraw = Math.min(assets, withdrawable); + + // If `assets` is greater than `currentSupplied`, we want to fully withdraw from this market. + // In IonPool, the shares to burn is rounded up as + // ceil(assets / supplyFactor) + if (toWithdraw > 0) { + pool.withdraw(address(this), toWithdraw); + assets -= toWithdraw; + } + if (assets == 0) return; + } + + if (assets != 0) revert NotEnoughLiquidityToWithdraw(); + } + + // --- ERC4626 External Functions --- + + /** + * @inheritdoc IERC4626 + * @notice Receives deposits from the sender and supplies into the underlying IonPool markets. + * @dev All incoming deposits are deposited in the order specified in the deposit queue. + */ + function deposit(uint256 assets, address receiver) public override returns (uint256 shares) { + uint256 newTotalAssets = _accrueFee(); + + shares = _convertToSharesWithTotals(assets, totalSupply(), newTotalAssets, Math.Rounding.Floor); + + _deposit(msg.sender, receiver, assets, shares); + } + + /** + * @inheritdoc IERC4626 + * @dev + */ + function mint(uint256 shares, address receiver) public override returns (uint256 assets) { + uint256 newTotalAssets = _accrueFee(); + + lastTotalAssets = newTotalAssets; // TODO: does this need to be updated here? + + assets = _convertToAssetsWithTotals(shares, totalSupply(), newTotalAssets, Math.Rounding.Ceil); + + _deposit(_msgSender(), receiver, assets, shares); + } + + /** + * @notice Withdraws supplied assets from IonPools and sends them to the receiver in exchange for vault shares. + * @dev All withdraws are withdrawn in the order specified in the withdraw queue. + */ + function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256 shares) { + uint256 newTotalAssets = _accrueFee(); + shares = _convertToSharesWithTotals(assets, totalSupply(), newTotalAssets, Math.Rounding.Ceil); + _updateLastTotalAssets(newTotalAssets - assets); + + _withdraw(msg.sender, receiver, owner, assets, shares); + } + + /** + * @inheritdoc IERC4626 + * @dev + */ + function redeem(uint256 shares, address receiver, address owner) public override returns (uint256 assets) { + uint256 newTotalAssets = _accrueFee(); + + assets = _convertToAssetsWithTotals(shares, totalSupply(), newTotalAssets, Math.Rounding.Floor); + + // After withdrawing `assets`, the user gets exact `assets` out. + // But in the IonPool, the resulting total underlying claim may have decreased by a bit above the `assets` + // amount due to rounding in protocol favor. + // In that case, the resulting totalAssets() will be smaller than just the `newTotalAssets - assets`. + // Predicting the exact resulting totalAssets() requires knowing how much liquidity is being withdrawn from each + // pool, which is not possible to know + // until the actual iteration on the withdraw queue. So we acknowledge the dust difference here. + // The `lastTotalAssets` will be greater than the actual `totalAssets`, so in practice the impact will be that + // the calculated interest accrued during fee distribution will be slightly less than the true value. + + // TODO: Should this be zeroFloorSub or a normal `-` + _updateLastTotalAssets(_zeroFloorSub(newTotalAssets, assets)); + + _withdraw(_msgSender(), receiver, owner, assets, shares); + } + + function decimals() public view override(ERC4626) returns (uint8) { + return ERC4626.decimals(); + } + + /** + * @inheritdoc ERC4626 + * @dev Returns the maximum amount of assets that the vault can supply on Morpho. + */ + function maxDeposit(address) public view override returns (uint256) { + return _maxDeposit(); + } + + /** + * @inheritdoc IERC4626 + * @dev Max mint is limited by the max deposit based on the Vault's + * allocation caps and the IonPools' supply caps. The conversion from max + * suppliable assets to shares preempts the shares minted from fee accrual. + */ + function maxMint(address) public view override returns (uint256) { + uint256 suppliable = _maxDeposit(); + + return _convertToSharesWithFees(suppliable, Math.Rounding.Floor); + } + + /** + * @inheritdoc IERC4626 + * @dev Max withdraw is limited by the liquidity available to be withdrawn from the underlying IonPools. + * The max withdrawable claim is inclusive of accrued interest and the extra shares minted to the fee recipient. + */ + function maxWithdraw(address owner) public view override returns (uint256 assets) { + (assets,,) = _maxWithdraw(owner); + } + + /** + * @inheritdoc IERC4626 + * @dev Max redeem is derived from çonverting the max withdraw to shares. + * The conversion takes into account the total supply and total assets inclusive of accrued interest and the extra + * shares minted to the fee recipient. + */ + 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); + } + + /** + * @notice Returns the total claim that the vault has across all supported IonPools. + * @dev `IonPool.getUnderlyingClaimOf` returns the rebasing balance of the + * lender receipt token that is pegged 1:1 to the underlying supplied asset. + */ + function totalAssets() public view override returns (uint256 assets) { + for (uint256 i; i < supportedMarkets.length(); ++i) { + address pool = supportedMarkets.at(i); + if (pool == address(IDLE)) { + assets += baseAsset.balanceOf(address(this)); + } else { + assets += IIonPool(pool).getUnderlyingClaimOf(address(this)); + } + } + } + + /** + * @inheritdoc IERC4626 + * @dev Inclusive of manager fee. + */ + function previewDeposit(uint256 assets) public view override returns (uint256) { + return _convertToSharesWithFees(assets, Math.Rounding.Floor); + } + + /** + * @inheritdoc IERC4626 + * @dev Inclusive of manager fee. + */ + function previewMint(uint256 shares) public view override returns (uint256) { + return _convertToAssetsWithFees(shares, Math.Rounding.Ceil); + } + + /** + * @inheritdoc IERC4626 + * @dev Inclusive of manager fee. + */ + function previewWithdraw(uint256 assets) public view override returns (uint256) { + return _convertToSharesWithFees(assets, Math.Rounding.Ceil); + } + + /** + * @inheritdoc IERC4626 + * @dev Inclusive of manager fee. + */ + function previewRedeem(uint256 shares) public view override returns (uint256) { + return _convertToAssetsWithFees(shares, Math.Rounding.Floor); + } + + // --- ERC4626 Internal Functions --- + + function _decimalsOffset() internal view override returns (uint8) { + return DECIMALS_OFFSET; + } + + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { + super._deposit(caller, receiver, assets, shares); + _supplyToIonPool(assets); + + _updateLastTotalAssets(lastTotalAssets + assets); + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) + internal + override + { + _withdrawFromIonPool(assets); + + super._withdraw(caller, receiver, owner, assets, shares); + } + + function _maxDeposit() internal view returns (uint256 maxDepositable) { + for (uint256 i; i < supportedMarkets.length(); ++i) { + IIonPool pool = IIonPool(supportedMarkets.at(i)); + + if (pool == IDLE) { + maxDepositable += _zeroFloorSub(caps[pool], baseAsset.balanceOf(address(this))); + continue; + } + + uint256 supplyCeil = Math.min(caps[pool], ionLens.wethSupplyCap(pool)); + uint256 currentSupplied = pool.getUnderlyingClaimOf(address(this)); + + uint256 suppliable = _zeroFloorSub(supplyCeil, currentSupplied); + maxDepositable += suppliable; + } + } + + /** + * @dev Takes the current shares balance of the owner and returns how much assets can be withdrawn considering the + * available liquidity. + */ + function _maxWithdraw(address owner) + internal + view + returns (uint256 assets, uint256 newTotalSupply, uint256 newTotalAssets) + { + uint256 feeShares; + (feeShares, newTotalAssets) = _accruedFeeShares(); + + assets = + _convertToAssetsWithTotals(balanceOf(owner), totalSupply() + feeShares, newTotalAssets, Math.Rounding.Floor); + + assets -= _simulateWithdrawIon(assets); + } + + // --- Internal --- + + function _accrueFee() internal returns (uint256 newTotalAssets) { + uint256 feeShares; + (feeShares, newTotalAssets) = _accruedFeeShares(); + if (feeShares != 0) _mint(feeRecipient, feeShares); + + lastTotalAssets = newTotalAssets; // This update happens outside of this function in Metamorpho. + + emit FeeAccrued(feeShares, newTotalAssets); + } + + /** + * @dev The total accrued vault revenue is the difference in the total + * iToken holdings from the last accrued timestamp. + */ + function _accruedFeeShares() internal view returns (uint256 feeShares, uint256 newTotalAssets) { + newTotalAssets = totalAssets(); + uint256 totalInterest = _zeroFloorSub(newTotalAssets, lastTotalAssets); + + // TotalInterest amount of new iTokens were created for this vault. A + // portion of this should be claimable by depositors and some portion of + // this should be claimable by the fee recipient + if (totalInterest != 0 && feePercentage != 0) { + uint256 feeAssets = totalInterest.mulDiv(feePercentage, WAD); + + feeShares = + _convertToSharesWithTotals(feeAssets, totalSupply(), newTotalAssets - feeAssets, Math.Rounding.Floor); + } + } + + function _convertToSharesWithFees(uint256 assets, Math.Rounding rounding) internal view returns (uint256) { + (uint256 feeShares, uint256 newTotalAssets) = _accruedFeeShares(); + + return _convertToSharesWithTotals(assets, totalSupply() + feeShares, newTotalAssets, rounding); + } + + /** + * @dev NOTE The IERC4626 natspec recomments that the `_convertToAssets` and `_convertToShares` "MUST NOT be + * inclusive of any fees that are charged against assets in the Vault." + * However, all deposit/mint/withdraw/redeem flow will accrue fees before + * processing user requests, so manager fee must be accounted for to accurately reflect the resulting state. + * All preview functions will rely on this `WithFees` version of the `_convertTo` function. + */ + function _convertToAssetsWithFees(uint256 shares, Math.Rounding rounding) internal view returns (uint256) { + (uint256 feeShares, uint256 newTotalAssets) = _accruedFeeShares(); + + return _convertToAssetsWithTotals(shares, totalSupply() + feeShares, newTotalAssets, rounding); + } + + /** + * @dev Returns the amount of shares that the vault would exchange for the + * amount of `assets` provided. + */ + function _convertToSharesWithTotals( + uint256 assets, + uint256 newTotalSupply, + uint256 newTotalAssets, + Math.Rounding rounding + ) + internal + view + returns (uint256) + { + return assets.mulDiv(newTotalSupply + 10 ** _decimalsOffset(), newTotalAssets + 1, rounding); + } + + function _convertToAssetsWithTotals( + uint256 shares, + uint256 newTotalSupply, + uint256 newTotalAssets, + Math.Rounding rounding + ) + internal + view + returns (uint256) + { + return shares.mulDiv(newTotalAssets + 1, newTotalSupply + 10 ** _decimalsOffset(), rounding); + } + + function _updateLastTotalAssets(uint256 newLastTotalAssets) internal { + lastTotalAssets = newLastTotalAssets; + emit UpdateLastTotalAssets(lastTotalAssets, newLastTotalAssets); + } + + function _zeroFloorSub(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + z := mul(gt(x, y), sub(x, y)) + } + } + + function getSupportedMarkets() external view returns (address[] memory) { + return supportedMarkets.values(); + } + + /** + * @dev Emulates the actual `_withdrawFromIonPool` accounting to predict + * accurately how much liquidity will be left after the withdraw. The + * difference between this return value and the input `assets` is the exact + * amount that will be withdrawn. + * @return The remaining assets to be withdrawn. NOT the amount of assets that were withdrawn. + */ + function _simulateWithdrawIon(uint256 assets) internal view returns (uint256) { + for (uint8 i; i < withdrawQueue.length; ++i) { + IIonPool pool = withdrawQueue[i]; + + if (pool == IDLE) { + uint256 currentIdleBalance = baseAsset.balanceOf(address(this)); + toWithdraw = Math.min(currentIdleBalance, assets); + assets -= toWithdraw; + if (assets == 0) return; + continue; + } + + uint256 withdrawable = _withdrawable(pool); + uint256 toWithdraw = Math.min(assets, withdrawable); + + assets -= toWithdraw; // TODO Should this be `_zeroFloorSub` instead. + + if (assets == 0) break; + } + + return assets; // the remaining assets after withdraw + } + + /** + * @notice The max amount of assets withdrawable from a given IonPool considering the vault's claim and the + * available liquidity. + * @dev A minimum of this contract's total claim on the underlying and the available liquidity in the pool. + */ + function _withdrawable(IIonPool pool) internal view returns (uint256) { + uint256 currentSupplied = pool.getUnderlyingClaimOf(address(this)); + uint256 availableLiquidity = ionLens.weth(pool); + return Math.min(currentSupplied, availableLiquidity); + } +} diff --git a/test/fork/concrete/vault/Vault.t.sol b/test/fork/concrete/vault/Vault.t.sol index e5bda6e6..adf7bc77 100644 --- a/test/fork/concrete/vault/Vault.t.sol +++ b/test/fork/concrete/vault/Vault.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.21; -import { Vault } from "./../../../../src/Vault.sol"; +import { Vault } from "./../../../../src/vault/Vault.sol"; import { IonPool } from "./../../../../src/IonPool.sol"; import { Whitelist } from "./../../../../src/Whitelist.sol"; import { IIonPool } from "./../../../../src/interfaces/IIonPool.sol"; diff --git a/test/helpers/VaultSharedSetup.sol b/test/helpers/VaultSharedSetup.sol new file mode 100644 index 00000000..b1ed64d4 --- /dev/null +++ b/test/helpers/VaultSharedSetup.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import { WadRayMath, RAY } from "./../../src/libraries/math/WadRayMath.sol"; +import { Vault } from "./../../src/vault/Vault.sol"; +import { IonPool } from "./../../src/IonPool.sol"; +import { IIonPool } from "./../../src/interfaces/IIonPool.sol"; +import { IonLens } from "./../../src/periphery/IonLens.sol"; +import { GemJoin } from "./../../src/join/GemJoin.sol"; +import { YieldOracle } from "./../../src/YieldOracle.sol"; +import { IYieldOracle } from "./../../src/interfaces/IYieldOracle.sol"; +import { InterestRate } from "./../../src/InterestRate.sol"; +import { Whitelist } from "./../../src/Whitelist.sol"; +import { ProxyAdmin } from "./../../src/admin/ProxyAdmin.sol"; +import { TransparentUpgradeableProxy } from "./../../src/admin/TransparentUpgradeableProxy.sol"; +import { WSTETH_ADDRESS } from "./../../src/Constants.sol"; +import { IonPoolSharedSetup, IonPoolExposed, MockSpotOracle } from "./IonPoolSharedSetup.sol"; +import { ERC20PresetMinterPauser } from "./ERC20PresetMinterPauser.sol"; +import { IERC20 } from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import { ERC20 } from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import { EnumerableSet } from "openzeppelin-contracts/contracts/utils/structs/EnumerableSet.sol"; +import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; +// import { StdStorage, stdStorage } from "../../../../lib/forge-safe/lib/forge-std/src/StdStorage.sol"; + +import "forge-std/Test.sol"; +import { console2 } from "forge-std/console2.sol"; + +using EnumerableSet for EnumerableSet.AddressSet; +using WadRayMath for uint256; +using Math for uint256; + +address constant VAULT_OWNER = address(1); +address constant FEE_RECIPIENT = address(2); + +contract VaultSharedSetup is IonPoolSharedSetup { + using stdStorage for StdStorage; + + StdStorage stdstore1; + + Vault vault; + IonLens ionLens; + + IERC20 immutable BASE_ASSET = IERC20(address(new ERC20PresetMinterPauser("Lido Wrapped Staked ETH", "wstETH"))); + IERC20 immutable WEETH = IERC20(address(new ERC20PresetMinterPauser("EtherFi Restaked ETH", "weETH"))); + IERC20 immutable RSETH = IERC20(address(new ERC20PresetMinterPauser("KelpDAO Restaked ETH", "rsETH"))); + IERC20 immutable RSWETH = IERC20(address(new ERC20PresetMinterPauser("Swell Restaked ETH", "rswETH"))); + + IIonPool weEthIonPool; + IIonPool rsEthIonPool; + IIonPool rswEthIonPool; + + IIonPool[] pools; + + function setUp() public virtual override { + super.setUp(); + + weEthIonPool = deployIonPool(BASE_ASSET, WEETH, address(this)); + rsEthIonPool = deployIonPool(BASE_ASSET, RSETH, address(this)); + rswEthIonPool = deployIonPool(BASE_ASSET, RSWETH, address(this)); + + ionLens = new IonLens(); + + vault = new Vault(VAULT_OWNER, FEE_RECIPIENT, BASE_ASSET, ionLens, "Ion Vault Token", "IVT"); + vm.startPrank(vault.owner()); + IIonPool[] memory markets = new IIonPool[](3); + markets[0] = weEthIonPool; + markets[1] = rsEthIonPool; + markets[2] = rswEthIonPool; + + vault.addSupportedMarkets(markets); + vm.stopPrank(); + + BASE_ASSET.approve(address(vault), type(uint256).max); + + pools = new IIonPool[](3); + pools[0] = weEthIonPool; + pools[1] = rsEthIonPool; + pools[2] = rswEthIonPool; + } + + function setERC20Balance(address token, address usr, uint256 amt) public { + stdstore1.target(token).sig(IERC20(token).balanceOf.selector).with_key(usr).checked_write(amt); + require(IERC20(token).balanceOf(usr) == amt, "balance not set"); + } + + // deploys a single IonPool with default configs + function deployIonPool( + IERC20 underlying, + IERC20 collateral, + address initialDefaultAdmin + ) + internal + returns (IIonPool ionPool) + { + IYieldOracle yieldOracle = _getYieldOracle(); + interestRateModule = new InterestRate(ilkConfigs, yieldOracle); + + Whitelist whitelist = _getWhitelist(); + + bytes memory initializeBytes = abi.encodeWithSelector( + IonPool.initialize.selector, + underlying, + address(this), + DECIMALS, + NAME, + SYMBOL, + initialDefaultAdmin, + interestRateModule, + whitelist + ); + + IonPoolExposed ionPoolImpl = new IonPoolExposed(); + ProxyAdmin ionProxyAdmin = new ProxyAdmin(address(this)); + + IonPoolExposed ionPoolProxy = IonPoolExposed( + address(new TransparentUpgradeableProxy(address(ionPoolImpl), address(ionProxyAdmin), initializeBytes)) + ); + + ionPool = IIonPool(address(ionPoolProxy)); + + ionPool.grantRole(ionPool.ION(), address(this)); + ionPool.grantRole(ionPool.PAUSE_ROLE(), address(this)); + ionPool.updateSupplyCap(type(uint256).max); + + ionPool.initializeIlk(address(collateral)); + ionPool.updateIlkSpot(0, address(_getSpotOracle())); + ionPool.updateIlkDebtCeiling(0, _getDebtCeiling(0)); + + GemJoin gemJoin = new GemJoin(IonPool(address(ionPool)), collateral, 0, address(this)); + ionPool.grantRole(ionPool.GEM_JOIN_ROLE(), address(gemJoin)); + } + + function claimAfterDeposit(uint256 currShares, uint256 amount, uint256 supplyFactor) internal returns (uint256) { + uint256 sharesMinted = amount.rayDivDown(supplyFactor); + uint256 resultingShares = currShares + sharesMinted; + return resultingShares.rayMulDown(supplyFactor); + } + + function claimAfterWithdraw(uint256 currShares, uint256 amount, uint256 supplyFactor) internal returns (uint256) { + uint256 sharesBurned = amount.rayDivUp(supplyFactor); + uint256 resultingShares = currShares - sharesBurned; + return resultingShares.rayMulDown(supplyFactor); + } + + // --- Privileged Helper Functions --- + + // In the order of supply queue + function updateSupplyCaps(Vault _vault, uint256 cap1, uint256 cap2, uint256 cap3) internal { + _vault.supplyQueue(0).updateSupplyCap(cap1); + _vault.supplyQueue(1).updateSupplyCap(cap2); + _vault.supplyQueue(2).updateSupplyCap(cap3); + } + + // In the order of supply queue + function updateAllocationCaps(Vault _vault, uint256 cap1, uint256 cap2, uint256 cap3) internal { + uint256[] memory caps = new uint256[](3); + caps[0] = cap1; + caps[1] = cap2; + caps[2] = cap3; + + IIonPool[] memory queue = new IIonPool[](3); + queue[0] = _vault.supplyQueue(0); + queue[1] = _vault.supplyQueue(1); + queue[2] = _vault.supplyQueue(2); + + vm.prank(_vault.owner()); + _vault.updateAllocationCaps(queue, caps); + } + + function updateSupplyQueue(Vault _vault, IIonPool pool1, IIonPool pool2, IIonPool pool3) internal { + IIonPool[] memory supplyQueue = new IIonPool[](3); + supplyQueue[0] = pool1; + supplyQueue[1] = pool2; + supplyQueue[2] = pool3; + vm.prank(_vault.owner()); + _vault.updateSupplyQueue(supplyQueue); + } + + function updateWithdrawQueue(Vault _vault, IIonPool pool1, IIonPool pool2, IIonPool pool3) internal { + IIonPool[] memory queue = new IIonPool[](3); + queue[0] = pool1; + queue[1] = pool2; + queue[2] = pool3; + vm.prank(_vault.owner()); + _vault.updateWithdrawQueue(queue); + } + + // -- Exact Rounding Error Equations --- + + // The difference between the expected total assets after withdrawal and the + // actual total assets after withdrawal. + // expected = prev total assets - withdraw amount + // actual = resulting total assets on contract + // rounding error = expected - actual + // This equation is max bounded to supplyFactor / RAY. + function totalAssetsREAfterWithdraw(uint256 withdrawAmount, uint256 supplyFactor) internal returns (uint256) { + return (supplyFactor - withdrawAmount * RAY % supplyFactor) / RAY; + } + + // Resulting vault shares? + // The difference between the expected max withdraw after withdrawal and the + // actual max withdraw after withdrawal. + // TODO: totalSupply needs to change when _decimalsOffset is added + function maxWithdrawREAfterWithdraw( + uint256 withdrawAmount, + uint256 totalAssets, + uint256 totalSupply + ) + internal + returns (uint256) + { + totalAssets += 1; + return (totalAssets - withdrawAmount * totalSupply % totalAssets) / totalSupply; + } +} diff --git a/test/unit/concrete/Vault.t.sol b/test/unit/concrete/Vault.t.sol index 0c95896d..de42b994 100644 --- a/test/unit/concrete/Vault.t.sol +++ b/test/unit/concrete/Vault.t.sol @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.21; -import { Vault } from "./../../../src/Vault.sol"; +import { VaultSharedSetup } from "./../../helpers/VaultSharedSetup.sol"; + +import { WadRayMath, RAY } from "./../../../src/libraries/math/WadRayMath.sol"; +import { Vault } from "./../../../src/vault/Vault.sol"; import { IonPool } from "./../../../src/IonPool.sol"; import { IIonPool } from "./../../../src/interfaces/IIonPool.sol"; import { IonLens } from "./../../../src/periphery/IonLens.sol"; @@ -18,115 +21,19 @@ import { ERC20PresetMinterPauser } from "../../helpers/ERC20PresetMinterPauser.s import { IERC20 } from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; import { ERC20 } from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import { EnumerableSet } from "openzeppelin-contracts/contracts/utils/structs/EnumerableSet.sol"; +import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; // import { StdStorage, stdStorage } from "../../../../lib/forge-safe/lib/forge-std/src/StdStorage.sol"; import "forge-std/Test.sol"; import { console2 } from "forge-std/console2.sol"; using EnumerableSet for EnumerableSet.AddressSet; +using WadRayMath for uint256; +using Math for uint256; address constant VAULT_OWNER = address(1); address constant FEE_RECIPIENT = address(2); -struct InitializeIonPool { - address underlying; - address treasury; - uint8 decimals; - string name; - string symbol; - address initialDefaultAdmin; -} - -contract VaultSharedSetup is IonPoolSharedSetup { - using stdStorage for StdStorage; - - StdStorage stdstore1; - - Vault vault; - IonLens ionLens; - - IERC20 immutable BASE_ASSET = IERC20(address(new ERC20PresetMinterPauser("Lido Wrapped Staked ETH", "wstETH"))); - IERC20 immutable WEETH = IERC20(address(new ERC20PresetMinterPauser("EtherFi Restaked ETH", "weETH"))); - IERC20 immutable RSETH = IERC20(address(new ERC20PresetMinterPauser("KelpDAO Restaked ETH", "rsETH"))); - IERC20 immutable RSWETH = IERC20(address(new ERC20PresetMinterPauser("Swell Restaked ETH", "rswETH"))); - - IIonPool weEthIonPool; - IIonPool rsEthIonPool; - IIonPool rswEthIonPool; - - function setUp() public virtual override { - super.setUp(); - - weEthIonPool = deployIonPool(BASE_ASSET, WEETH, address(this)); - rsEthIonPool = deployIonPool(BASE_ASSET, RSETH, address(this)); - rswEthIonPool = deployIonPool(BASE_ASSET, RSWETH, address(this)); - - ionLens = new IonLens(); - - vault = new Vault(VAULT_OWNER, FEE_RECIPIENT, BASE_ASSET, ionLens, "Ion Vault Token", "IVT"); - vm.startPrank(vault.owner()); - IIonPool[] memory markets = new IIonPool[](3); - markets[0] = weEthIonPool; - markets[1] = rsEthIonPool; - markets[2] = rswEthIonPool; - - vault.addSupportedMarkets(markets); - vm.stopPrank(); - } - - function setERC20Balance(address token, address usr, uint256 amt) public { - stdstore1.target(token).sig(IERC20(token).balanceOf.selector).with_key(usr).checked_write(amt); - require(IERC20(token).balanceOf(usr) == amt, "balance not set"); - } - - // deploys a single IonPool with default configs - function deployIonPool( - IERC20 underlying, - IERC20 collateral, - address initialDefaultAdmin - ) - internal - returns (IIonPool ionPool) - { - IYieldOracle yieldOracle = _getYieldOracle(); - interestRateModule = new InterestRate(ilkConfigs, yieldOracle); - - Whitelist whitelist = _getWhitelist(); - - bytes memory initializeBytes = abi.encodeWithSelector( - IonPool.initialize.selector, - underlying, - address(this), - DECIMALS, - NAME, - SYMBOL, - initialDefaultAdmin, - interestRateModule, - whitelist - ); - - IonPoolExposed ionPoolImpl = new IonPoolExposed(); - ProxyAdmin ionProxyAdmin = new ProxyAdmin(address(this)); - - IonPoolExposed ionPoolProxy = IonPoolExposed( - address(new TransparentUpgradeableProxy(address(ionPoolImpl), address(ionProxyAdmin), initializeBytes)) - ); - - ionPool = IIonPool(address(ionPoolProxy)); - - ionPool.grantRole(ionPool.ION(), address(this)); - ionPool.grantRole(ionPool.PAUSE_ROLE(), address(this)); - ionPool.updateSupplyCap(type(uint256).max); - - ionPool.initializeIlk(address(collateral)); - ionPool.updateIlkSpot(0, address(_getSpotOracle())); - ionPool.updateIlkDebtCeiling(0, _getDebtCeiling(0)); - - GemJoin gemJoin = new GemJoin(IonPool(address(ionPool)), collateral, 0, address(this)); - ionPool.grantRole(ionPool.GEM_JOIN_ROLE(), address(gemJoin)); - } -} - contract VaultSetUpTest is VaultSharedSetup { function setUp() public override { super.setUp(); @@ -219,58 +126,9 @@ contract VaultSetUpTest is VaultSharedSetup { function test_Revert_UpdateWithdrawQUeue() public { } } -contract VaultInteraction_WithoutRate is VaultSharedSetup { - IIonPool[] pools; - - function setUp() public override { +abstract contract VaultDeposit is VaultSharedSetup { + function setUp() public virtual override { super.setUp(); - BASE_ASSET.approve(address(vault), type(uint256).max); - - pools = new IIonPool[](3); - pools[0] = weEthIonPool; - pools[1] = rsEthIonPool; - pools[2] = rswEthIonPool; - } - - // In the order of supply queue - function updateSupplyCaps(Vault _vault, uint256 cap1, uint256 cap2, uint256 cap3) public { - _vault.supplyQueue(0).updateSupplyCap(cap1); - _vault.supplyQueue(1).updateSupplyCap(cap2); - _vault.supplyQueue(2).updateSupplyCap(cap3); - } - - // In the order of supply queue - function updateAllocationCaps(Vault _vault, uint256 cap1, uint256 cap2, uint256 cap3) public { - uint256[] memory caps = new uint256[](3); - caps[0] = cap1; - caps[1] = cap2; - caps[2] = cap3; - - IIonPool[] memory queue = new IIonPool[](3); - queue[0] = _vault.supplyQueue(0); - queue[1] = _vault.supplyQueue(1); - queue[2] = _vault.supplyQueue(2); - - vm.prank(_vault.owner()); - _vault.updateAllocationCaps(queue, caps); - } - - function updateSupplyQueue(Vault _vault, IIonPool pool1, IIonPool pool2, IIonPool pool3) public { - IIonPool[] memory supplyQueue = new IIonPool[](3); - supplyQueue[0] = pool1; - supplyQueue[1] = pool2; - supplyQueue[2] = pool3; - vm.prank(_vault.owner()); - _vault.updateSupplyQueue(supplyQueue); - } - - function updateWithdrawQueue(Vault _vault, IIonPool pool1, IIonPool pool2, IIonPool pool3) public { - IIonPool[] memory queue = new IIonPool[](3); - queue[0] = pool1; - queue[1] = pool2; - queue[2] = pool3; - vm.prank(_vault.owner()); - _vault.updateWithdrawQueue(queue); } function test_Deposit_WithoutSupplyCap_WithoutAllocationCap() public { @@ -281,12 +139,18 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { updateSupplyCaps(vault, type(uint256).max, type(uint256).max, type(uint256).max); updateAllocationCaps(vault, type(uint256).max, type(uint256).max, type(uint256).max); + uint256 prevWeEthShares = weEthIonPool.balanceOf(address(vault)); + vault.deposit(depositAmount, address(this)); assertEq(vault.totalSupply(), depositAmount, "vault shares total supply"); assertEq(vault.balanceOf(address(this)), depositAmount, "user vault shares balance"); assertEq(BASE_ASSET.balanceOf(address(vault)), 0, "base asset balance should be zero"); - assertEq(weEthIonPool.getUnderlyingClaimOf(address(vault)), depositAmount, "vault iToken claim"); + assertEq( + weEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevWeEthShares, depositAmount, weEthIonPool.supplyFactor()), + "vault iToken claim" + ); } function test_Deposit_WithoutSupplyCap_WithAllocationCap_EqualDeposits() public { @@ -297,6 +161,10 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { updateSupplyCaps(vault, type(uint256).max, type(uint256).max, type(uint256).max); updateAllocationCaps(vault, 1e18, 1e18, 1e18); + uint256 prevWeEthShares = weEthIonPool.balanceOf(address(vault)); + uint256 prevRsEthShares = rsEthIonPool.balanceOf(address(vault)); + uint256 prevRswEthShares = rswEthIonPool.balanceOf(address(vault)); + // 3e18 gets spread out equally amongst the three pools vault.deposit(depositAmount, address(this)); @@ -304,9 +172,21 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { assertEq(vault.balanceOf(address(this)), depositAmount, "user vault shares balance"); assertEq(BASE_ASSET.balanceOf(address(vault)), 0, "base asset balance should be zero"); - assertEq(weEthIonPool.getUnderlyingClaimOf(address(vault)), 1e18, "weEth vault iToken claim"); - assertEq(rsEthIonPool.getUnderlyingClaimOf(address(vault)), 1e18, "rsEth vault iToken claim"); - assertEq(rswEthIonPool.getUnderlyingClaimOf(address(vault)), 1e18, "rswEth vault iToken claim"); + assertEq( + weEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevWeEthShares, 1e18, weEthIonPool.supplyFactor()), + "weEth vault iToken claim" + ); + assertEq( + rsEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevRsEthShares, 1e18, rsEthIonPool.supplyFactor()), + "rsEth vault iToken claim" + ); + assertEq( + rswEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevRswEthShares, 1e18, rswEthIonPool.supplyFactor()), + "rswEth vault iToken claim" + ); } function test_Deposit_WithoutSupplyCap_WithAllocationCap_DifferentDeposits() public { @@ -317,6 +197,10 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { updateSupplyCaps(vault, type(uint256).max, type(uint256).max, type(uint256).max); updateAllocationCaps(vault, 3e18, 5e18, 7e18); + uint256 prevWeEthShares = weEthIonPool.balanceOf(address(vault)); + uint256 prevRsEthShares = rsEthIonPool.balanceOf(address(vault)); + uint256 prevRswEthShares = rswEthIonPool.balanceOf(address(vault)); + // 3e18 gets spread out equally amongst the three pools vault.deposit(depositAmount, address(this)); @@ -324,9 +208,21 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { assertEq(vault.balanceOf(address(this)), depositAmount, "user vault shares balance"); assertEq(BASE_ASSET.balanceOf(address(vault)), 0, "base asset balance should be zero"); - assertEq(rsEthIonPool.getUnderlyingClaimOf(address(vault)), 3e18, "rsEth vault iToken claim"); - assertEq(rswEthIonPool.getUnderlyingClaimOf(address(vault)), 5e18, "rswEth vault iToken claim"); - assertEq(weEthIonPool.getUnderlyingClaimOf(address(vault)), 2e18, "weEth vault iToken claim"); + assertEq( + weEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevWeEthShares, 2e18, weEthIonPool.supplyFactor()), + "weEth vault iToken claim" + ); + assertEq( + rsEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevRsEthShares, 3e18, rsEthIonPool.supplyFactor()), + "rsEth vault iToken claim" + ); + assertEq( + rswEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevRswEthShares, 5e18, rswEthIonPool.supplyFactor()), + "rswEth vault iToken claim" + ); } function test_Deposit_SupplyCap_Below_AllocationCap_DifferentDeposits() public { @@ -337,16 +233,31 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { updateSupplyCaps(vault, 3e18, 10e18, 5e18); updateAllocationCaps(vault, 5e18, 7e18, 20e18); + uint256 prevWeEthShares = weEthIonPool.balanceOf(address(vault)); + uint256 prevRsEthShares = rsEthIonPool.balanceOf(address(vault)); + uint256 prevRswEthShares = rswEthIonPool.balanceOf(address(vault)); + vault.deposit(depositAmount, address(this)); assertEq(vault.totalSupply(), depositAmount, "vault shares total supply"); assertEq(vault.balanceOf(address(this)), depositAmount, "user vault shares balance"); assertEq(BASE_ASSET.balanceOf(address(vault)), 0, "base asset balance should be zero"); - // pool1 3e18, pool2 7e18, pool3 2e18 - assertEq(rsEthIonPool.getUnderlyingClaimOf(address(vault)), 3e18, "rsEth vault iToken claim"); - assertEq(rswEthIonPool.getUnderlyingClaimOf(address(vault)), 7e18, "rswEth vault iToken claim"); - assertEq(weEthIonPool.getUnderlyingClaimOf(address(vault)), 2e18, "weEth vault iToken claim"); + assertEq( + weEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevWeEthShares, 2e18, weEthIonPool.supplyFactor()), + "weEth vault iToken claim" + ); + assertEq( + rsEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevRsEthShares, 3e18, rsEthIonPool.supplyFactor()), + "rsEth vault iToken claim" + ); + assertEq( + rswEthIonPool.getUnderlyingClaimOf(address(vault)), + claimAfterDeposit(prevRswEthShares, 7e18, rswEthIonPool.supplyFactor()), + "rswEth vault iToken claim" + ); } function test_Revert_Deposit_AllCaps_Filled() public { @@ -360,6 +271,12 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { vm.expectRevert(Vault.AllSupplyCapsReached.selector); vault.deposit(depositAmount, address(this)); } +} + +contract VaultWithdraw is VaultSharedSetup { + function setUp() public virtual override { + super.setUp(); + } function test_Withdraw_SingleMarket() public { uint256 depositAmount = 10e18; @@ -375,13 +292,45 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { vault.deposit(depositAmount, address(this)); + // state before withdraw + uint256 prevTotalAssets = vault.totalAssets(); + uint256 prevTotalSupply = vault.totalSupply(); + uint256 prevMaxWithdraw = vault.maxWithdraw(address(this)); + vault.withdraw(withdrawAmount, address(this), address(this)); - assertEq(vault.totalSupply(), depositAmount - withdrawAmount, "vault shares total supply"); - assertEq(vault.balanceOf(address(this)), withdrawAmount, "user vault shares balance"); - assertEq(BASE_ASSET.balanceOf(address(vault)), 0, "base asset balance should be zero"); + // expectation ignoring rounding errors + uint256 expectedNewTotalAssets = prevTotalAssets - withdrawAmount; + uint256 expectedMaxWithdraw = prevMaxWithdraw - withdrawAmount; + + uint256 expectedSharesBurned = + withdrawAmount.mulDiv(prevTotalSupply + 1, prevTotalAssets + 1, Math.Rounding.Ceil); + uint256 expectedNewTotalSupply = prevTotalSupply - expectedSharesBurned; + + // error bound for resulting total assets after a withdraw + uint256 totalAssetsRoundingError = totalAssetsREAfterWithdraw(withdrawAmount, rsEthIonPool.supplyFactor()); + uint256 maxWithdrawRoundingError = maxWithdrawREAfterWithdraw(withdrawAmount, prevTotalAssets, prevTotalSupply); + + // vault + assertLe(vault.totalAssets(), expectedNewTotalAssets, "vault total assets"); + assertEq( + expectedNewTotalAssets - vault.totalAssets(), totalAssetsRoundingError, "vault total assets rounding error" + ); + assertEq(vault.totalSupply(), expectedNewTotalSupply, "vault shares total supply"); + + assertEq( + vault.totalAssets(), rsEthIonPool.getUnderlyingClaimOf(address(vault)), "single market for total assets" + ); + assertEq(BASE_ASSET.balanceOf(address(vault)), 0, "valt's base asset balance should be zero"); + + // user + assertLe(vault.maxWithdraw(address(this)), expectedMaxWithdraw, "user max withdraw"); + assertEq( + expectedMaxWithdraw - vault.maxWithdraw(address(this)), + maxWithdrawRoundingError, + "user max withdraw rounding error" + ); - assertEq(rsEthIonPool.balanceOf(address(vault)), depositAmount - withdrawAmount, "vault pool balance"); assertEq(BASE_ASSET.balanceOf(address(this)), withdrawAmount, "user base asset balance"); } @@ -399,24 +348,48 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { vault.deposit(depositAmount, address(this)); + // state before withdraw + uint256 prevTotalAssets = vault.totalAssets(); + uint256 prevTotalSupply = vault.totalSupply(); + uint256 prevMaxWithdraw = vault.maxWithdraw(address(this)); + vault.withdraw(withdrawAmount, address(this), address(this)); + uint256 expectedNewTotalAssets = prevTotalAssets - withdrawAmount; + uint256 expectedMaxWithdraw = prevMaxWithdraw - withdrawAmount; + + uint256 expectedSharesBurned = + withdrawAmount.mulDiv(prevTotalSupply + 1, prevTotalAssets + 1, Math.Rounding.Ceil); + uint256 expectedNewTotalSupply = prevTotalSupply - expectedSharesBurned; + + // error bound for resulting total assets after a withdraw + uint256 totalAssetsRoundingError = totalAssetsREAfterWithdraw(4e18, weEthIonPool.supplyFactor()); + console2.log("totalAssetsRoundingError: ", totalAssetsRoundingError); + uint256 maxWithdrawRoundingError = maxWithdrawREAfterWithdraw(withdrawAmount, prevTotalAssets, prevTotalSupply); + // pool1 deposit 2 withdraw 2 // pool2 deposit 3 withdraw 3 // pool3 deposit 5 withdraw 4 // vault - assertEq(vault.totalSupply(), depositAmount - withdrawAmount, "vault shares total supply"); + assertLe(vault.totalAssets(), expectedNewTotalAssets, "vault total assets"); + assertEq( + expectedNewTotalAssets - vault.totalAssets(), totalAssetsRoundingError, "vault total assets rounding error" + ); + assertEq(vault.totalSupply(), expectedNewTotalSupply, "vault shares total supply"); + + // assertEq(rsEthIonPool.getUnderlyingClaimOf(address(vault)), 0, "vault pool1 balance"); + // assertEq(rswEthIonPool.getUnderlyingClaimOf(address(vault)), 0, "vault pool2 balance"); + // assertEq(weEthIonPool.getUnderlyingClaimOf(address(vault)), 1e18, "vault pool3 balance"); + assertEq(BASE_ASSET.balanceOf(address(vault)), 0, "vault base asset balance should be zero"); - assertEq(rsEthIonPool.balanceOf(address(vault)), 0, "vault pool1 balance"); - assertEq(rswEthIonPool.balanceOf(address(vault)), 0, "vault pool2 balance"); - assertEq(weEthIonPool.balanceOf(address(vault)), 1e18, "vault pool3 balance"); // users - assertEq(vault.balanceOf(address(this)), depositAmount - withdrawAmount, "user vault shares balance"); - assertEq(BASE_ASSET.balanceOf(address(this)), withdrawAmount, "user base asset balance"); + // assertEq(vault.balanceOf(address(this)), depositAmount - withdrawAmount, "user vault shares balance"); + // assertEq(BASE_ASSET.balanceOf(address(this)), withdrawAmount, "user base asset balance"); } + // try to deposit and withdraw same amounts function test_Withdraw_FullWithdraw() public { } function test_Withdraw_Different_Queue_Order() public { } @@ -424,6 +397,12 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { function test_DepositAndWithdraw_MultipleUsers() public { } function test_Revert_Withdraw() public { } +} + +contract VaultReallocate is VaultSharedSetup { + function setUp() public virtual override { + super.setUp(); + } // --- Reallocate --- @@ -442,26 +421,35 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { uint256 prevTotalAssets = vault.totalAssets(); + int256 rswEthDiff = -2e18; + int256 weEthDiff = -1e18; + int256 rsEthDiff = 3e18; + // withdraw 2 from pool2 // withdraw 1 from pool3 // deposit 3 to pool1 - // pool1 2 -> 5 - // pool2 3 -> 1 - // pool3 5 -> 4 Vault.MarketAllocation[] memory allocs = new Vault.MarketAllocation[](3); - allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, targetAssets: 1e18 }); - allocs[1] = Vault.MarketAllocation({ pool: weEthIonPool, targetAssets: 4e18 }); - allocs[2] = Vault.MarketAllocation({ pool: rsEthIonPool, targetAssets: 5e18 }); + allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, assets: rswEthDiff }); + allocs[1] = Vault.MarketAllocation({ pool: weEthIonPool, assets: weEthDiff }); + allocs[2] = Vault.MarketAllocation({ pool: rsEthIonPool, assets: rsEthDiff }); vm.prank(vault.owner()); vault.reallocate(allocs); uint256 newTotalAssets = vault.totalAssets(); + // Underlying claim goes down by the difference + // Resulting claim for the vault after withdrawing an amount + // - currentClaim. + // - withdrawAmount + // resulting claim diff = ceiling(withdrawAmount / SF) * SF assertEq(rsEthIonPool.getUnderlyingClaimOf(address(vault)), 5e18, "rsEth vault iToken claim"); assertEq(rswEthIonPool.getUnderlyingClaimOf(address(vault)), 1e18, "rswEth vault iToken claim"); assertEq(weEthIonPool.getUnderlyingClaimOf(address(vault)), 4e18, "weEth vault iToken claim"); + // Resulting underlying balance in the vault should exactly be the sum. + // assertEq(BASE_ASSET.balanceOf(address(this)), rswEthDiff + weEthDiff + rsEthDiff, "base asset balance"); + assertEq(prevTotalAssets, newTotalAssets, "total assets should remain the same"); } @@ -487,9 +475,9 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { // pool2 3 -> 10 // pool3 5 -> 0 Vault.MarketAllocation[] memory allocs = new Vault.MarketAllocation[](3); - allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, targetAssets: 0 }); - allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, targetAssets: 0 }); - allocs[2] = Vault.MarketAllocation({ pool: weEthIonPool, targetAssets: 10e18 }); + allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, assets: 0 }); + allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, assets: 0 }); + allocs[2] = Vault.MarketAllocation({ pool: weEthIonPool, assets: 10e18 }); vm.prank(vault.owner()); vault.reallocate(allocs); @@ -520,9 +508,9 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { // tries to deposit 10e18 to 9e18 allocation cap Vault.MarketAllocation[] memory allocs = new Vault.MarketAllocation[](3); - allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, targetAssets: 0 }); - allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, targetAssets: 0 }); - allocs[2] = Vault.MarketAllocation({ pool: weEthIonPool, targetAssets: 10e18 }); + allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, assets: 0 }); + allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, assets: 0 }); + allocs[2] = Vault.MarketAllocation({ pool: weEthIonPool, assets: 10e18 }); vm.prank(vault.owner()); vm.expectRevert(Vault.AllocationCapExceeded.selector); @@ -547,9 +535,9 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { // tries to deposit 10e18 to 9e18 allocation cap Vault.MarketAllocation[] memory allocs = new Vault.MarketAllocation[](3); - allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, targetAssets: 0 }); - allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, targetAssets: 0 }); - allocs[2] = Vault.MarketAllocation({ pool: weEthIonPool, targetAssets: 10e18 }); + allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, assets: 0 }); + allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, assets: 0 }); + allocs[2] = Vault.MarketAllocation({ pool: weEthIonPool, assets: 10e18 }); vm.prank(vault.owner()); vm.expectRevert(abi.encodeWithSelector(IIonPool.DepositSurpassesSupplyCap.selector, 8e18, 5e18)); @@ -571,9 +559,9 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { // tries to deposit less than total withdrawn Vault.MarketAllocation[] memory allocs = new Vault.MarketAllocation[](3); - allocs[0] = Vault.MarketAllocation({ pool: weEthIonPool, targetAssets: 5e18 }); - allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, targetAssets: 4e18 }); - allocs[2] = Vault.MarketAllocation({ pool: rswEthIonPool, targetAssets: 6e18 }); + allocs[0] = Vault.MarketAllocation({ pool: weEthIonPool, assets: 5e18 }); + allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, assets: 4e18 }); + allocs[2] = Vault.MarketAllocation({ pool: rswEthIonPool, assets: 6e18 }); vm.prank(vault.owner()); vm.expectRevert(Vault.InvalidReallocation.selector); @@ -581,10 +569,43 @@ contract VaultInteraction_WithoutRate is VaultSharedSetup { } } -contract VaultInteraction_WithRate is VaultSharedSetup { - function setUp() public override { +contract Vault_WithoutRate is VaultDeposit, VaultWithdraw, VaultReallocate { + function setUp() public override(VaultDeposit, VaultWithdraw, VaultReallocate) { super.setUp(); } } -contract VaultInteraction_WithYield is VaultShapredSetup { } +contract VaultDeposit_WithRate is VaultDeposit, VaultWithdraw, VaultReallocate { + function setUp() public override(VaultDeposit, VaultWithdraw, VaultReallocate) { + super.setUp(); + + IonPoolExposed(address(weEthIonPool)).setSupplyFactor(1.12332323424e27); + IonPoolExposed(address(rsEthIonPool)).setSupplyFactor(1.7273727372e27); + IonPoolExposed(address(rswEthIonPool)).setSupplyFactor(1.293828382e27); + } +} + +contract VaultInteraction_WithInflatedRate is VaultDeposit, VaultWithdraw, VaultReallocate { + function setUp() public override(VaultDeposit, VaultWithdraw, VaultReallocate) { + super.setUp(); + + IonPoolExposed(address(weEthIonPool)).setSupplyFactor(5.12332323424e27); + IonPoolExposed(address(rsEthIonPool)).setSupplyFactor(5.7273727372e27); + IonPoolExposed(address(rswEthIonPool)).setSupplyFactor(5.293828382e27); + } +} + +// contract VaultInteraction_WithFee is VaultDeposit, VaultWithdraw, VaultReallocate { +// function setUp() public override { +// super.setUp(); + +// // set fees +// // abstract tests can be modified to calculate for fees and additional asserts with if statements +// // also do VaultInteraction_WithRate_WithFee +// } +// } + +// contract VaultInteraction_WithYield is VaultSharedSetup { } + +// contract VaultInteraction_WithRate_WithFee_WithYield { } +// WithYield warps time forward and has interest accrue during each of the test executions