From 0f78d89a16850cb880f0348bcc3d272aa0c2ee77 Mon Sep 17 00:00:00 2001 From: Jun Kim <64379343+junkim012@users.noreply.github.com> Date: Mon, 29 Apr 2024 02:12:45 -0400 Subject: [PATCH] chore: natspec --- src/interfaces/IIonPool.sol | 1 + src/vault/Vault.sol | 260 +++++++++++++----- src/vault/VaultFactory.sol | 6 +- test/fork/concrete/vault/VaultFactory.t.sol | 41 ++- test/helpers/VaultSharedSetup.sol | 30 +- test/unit/concrete/vault/Vault.t.sol | 103 +++---- test/unit/concrete/vault/VaultWithYield.t.sol | 124 +++++---- test/unit/fuzz/vault/Vault.t.sol | 120 +++++++- 8 files changed, 454 insertions(+), 231 deletions(-) diff --git a/src/interfaces/IIonPool.sol b/src/interfaces/IIonPool.sol index 9cf2be55..b28d071f 100644 --- a/src/interfaces/IIonPool.sol +++ b/src/interfaces/IIonPool.sol @@ -233,4 +233,5 @@ interface IIonPool { function getTotalUnderlyingClaims() external view returns (uint256); function getUnderlyingClaimOf(address user) external view returns (uint256); + function extsload(bytes32 slot) external view returns (bytes32); } diff --git a/src/vault/Vault.sol b/src/vault/Vault.sol index a42fa70c..fc3231db 100644 --- a/src/vault/Vault.sol +++ b/src/vault/Vault.sol @@ -3,20 +3,19 @@ pragma solidity 0.8.21; import { IIonPool } from "./../interfaces/IIonPool.sol"; import { IIonPool } from "./../interfaces/IIonPool.sol"; -import { IIonLens } from "./../interfaces/IIonLens.sol"; import { RAY } from "./../libraries/math/WadRayMath.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; -import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import { IERC20Metadata } from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import { Multicall } from "@openzeppelin/contracts/utils/Multicall.sol"; +import { ReentrancyGuard } from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; import { AccessControlDefaultAdminRules } from "@openzeppelin/contracts/access/extensions/AccessControlDefaultAdminRules.sol"; -import { ReentrancyGuard } from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; /** * @title Ion Lending Vault @@ -33,19 +32,19 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy using EnumerableSet for EnumerableSet.AddressSet; using Math for uint256; - error InvalidQueueLength(); - error AllocationCapExceeded(); + error InvalidQueueLength(uint256 queueLength, uint256 supportedMarketsLength); + error AllocationCapExceeded(uint256 resultingSupplied, uint256 allocationCap); + error InvalidReallocation(uint256 totalSupplied, uint256 totalWithdrawn); + error InvalidMarketRemovalNonZeroSupply(IIonPool pool); + error InvalidUnderlyingAsset(IIonPool pool); + error MarketAlreadySupported(IIonPool pool); + error MarketNotSupported(IIonPool pool); error AllSupplyCapsReached(); error NotEnoughLiquidityToWithdraw(); error InvalidIdleMarketRemovalNonZeroBalance(); - error InvalidMarketRemovalNonZeroSupply(); - error InvalidSupportedMarkets(); error InvalidQueueContainsDuplicates(); error MarketsAndAllocationCapLengthMustBeEqual(); - error MarketAlreadySupported(); - error MarketNotSupported(); error IonPoolsArrayAndNewCapsArrayMustBeOfEqualLength(); - error InvalidReallocation(); error InvalidFeePercentage(); event UpdateSupplyQueue(address indexed caller, IIonPool[] newSupplyQueue); @@ -63,8 +62,12 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy uint8 public immutable DECIMALS_OFFSET; - IIonLens public immutable ionLens; - IERC20 public immutable baseAsset; + bytes32 public immutable ION_POOL_SUPPLY_CAP_SLOT = + 0xceba3d526b4d5afd91d1b752bf1fd37917c20a6daf576bcb41dd1c57c1f67e09; + bytes32 public immutable ION_POOL_LIQUIDITY_SLOT = + 0xceba3d526b4d5afd91d1b752bf1fd37917c20a6daf576bcb41dd1c57c1f67e08; + + IERC20 public immutable BASE_ASSET; EnumerableSet.AddressSet supportedMarkets; @@ -84,7 +87,6 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy } constructor( - IIonLens _ionLens, IERC20 _baseAsset, address _feeRecipient, uint256 _feePercentage, @@ -97,8 +99,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy ERC20(_name, _symbol) AccessControlDefaultAdminRules(initialDelay, initialDefaultAdmin) { - ionLens = _ionLens; - baseAsset = _baseAsset; + BASE_ASSET = _baseAsset; feePercentage = _feePercentage; feeRecipient = _feeRecipient; @@ -109,6 +110,8 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @notice Updates the fee percentage. * @dev Input must be in [RAY]. Ex) 2% would be 0.02e27. + * @param _feePercentage The percentage of the interest accrued to take as a + * management fee. */ function updateFeePercentage(uint256 _feePercentage) external onlyRole(OWNER_ROLE) { if (_feePercentage > RAY) revert InvalidFeePercentage(); @@ -117,6 +120,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @notice Updates the fee recipient. + * @param _feeRecipient The recipient address of the shares minted as fees. */ function updateFeeRecipient(address _feeRecipient) external onlyRole(OWNER_ROLE) { feeRecipient = _feeRecipient; @@ -126,8 +130,12 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy * @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. + * addition to the EnumerableSet will revert. The allocationCaps of the + * new markets being introduced must be set. + * @param marketsToAdd Array of new markets to be added. + * @param allocationCaps Array of allocation caps for only the markets to be added. + * @param newSupplyQueue Desired supply queue of IonPools for all resulting supported markets. + * @param newWithdrawQueue Desired withdraw queue of IonPools for all resulting supported markets. */ function addSupportedMarkets( IIonPool[] calldata marketsToAdd, @@ -144,13 +152,13 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy IIonPool pool = marketsToAdd[i]; if (pool != IDLE) { - if (address(pool.underlying()) != address(baseAsset)) { - revert InvalidSupportedMarkets(); + if (address(pool.underlying()) != address(BASE_ASSET)) { + revert InvalidUnderlyingAsset(pool); } - baseAsset.approve(address(pool), type(uint256).max); + BASE_ASSET.approve(address(pool), type(uint256).max); } - if (!supportedMarkets.add(address(pool))) revert MarketAlreadySupported(); + if (!supportedMarkets.add(address(pool))) revert MarketAlreadySupported(pool); caps[pool] = allocationCaps[i]; @@ -164,11 +172,16 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy } /** - * @notice Removes a supported market and updates the supply/withdraw queues - * without the removed market. + * @notice Removes a supported market and updates the supply and 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. + * @param marketsToRemove Markets being removed. + * @param newSupplyQueue Desired supply queue of all supported markets after + * the removal. + * @param newWithdrawQueue Desired withdraw queue of all supported markets + * after the removal. */ function removeSupportedMarkets( IIonPool[] calldata marketsToRemove, @@ -182,16 +195,16 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy IIonPool pool = marketsToRemove[i]; if (pool == IDLE) { - if (baseAsset.balanceOf(address(this)) != 0) revert InvalidIdleMarketRemovalNonZeroBalance(); + if (BASE_ASSET.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(); - baseAsset.approve(address(pool), 0); + if (pool.balanceOf(address(this)) != 0) revert InvalidMarketRemovalNonZeroSupply(pool); + BASE_ASSET.approve(address(pool), 0); } - if (!supportedMarkets.remove(address(pool))) revert MarketNotSupported(); + if (!supportedMarkets.remove(address(pool))) revert MarketNotSupported(pool); delete caps[pool]; unchecked { @@ -204,7 +217,8 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @notice Update the order of the markets in which user deposits are supplied. - * @dev The IonPool in the queue must be part of `supportedMarkets`. + * @dev Each IonPool in the queue must be part of the `supportedMarkets` set. + * @param newSupplyQueue The new supply queue ordering. */ function updateSupplyQueue(IIonPool[] calldata newSupplyQueue) public onlyRole(ALLOCATOR_ROLE) { _validateQueueInput(newSupplyQueue); @@ -216,7 +230,8 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @notice Update the order of the markets in which the deposits are withdrawn. - * @dev The IonPool in the queue must be part of `supportedMarkets`. + * @dev The IonPool in the queue must be part of the `supportedMarkets` set. + * @param newWithdrawQueue The new withdraw queue ordering. */ function updateWithdrawQueue(IIonPool[] calldata newWithdrawQueue) public onlyRole(ALLOCATOR_ROLE) { _validateQueueInput(newWithdrawQueue); @@ -232,12 +247,13 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy * - 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. + * @param queue The queue being validated. */ - function _validateQueueInput(IIonPool[] memory queue) internal view { + function _validateQueueInput(IIonPool[] calldata queue) internal view { uint256 _supportedMarketsLength = supportedMarkets.length(); uint256 queueLength = queue.length; - if (queueLength != _supportedMarketsLength) revert InvalidQueueLength(); + if (queueLength != _supportedMarketsLength) revert InvalidQueueLength(queueLength, _supportedMarketsLength); bool[] memory seen = new bool[](queueLength); @@ -256,9 +272,13 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy } /** + * @notice Update allocation caps for specified IonPools or the IDLE pool. * @dev The allocation caps are applied to pools in the order of the array * within `supportedMarkets`. The elements inside `ionPools` must exist in - * `supportedMarkets`. + * `supportedMarkets`. To update the `IDLE` pool, use the `IDLE` constant + * address. + * @param ionPools The array of IonPools whose caps will be updated. + * @param newCaps The array of new allocation caps to be applied. */ function updateAllocationCaps( IIonPool[] calldata ionPools, @@ -271,7 +291,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy for (uint256 i; i != ionPools.length;) { IIonPool pool = ionPools[i]; - if (!supportedMarkets.contains(address(pool))) revert MarketNotSupported(); + if (!supportedMarkets.contains(address(pool))) revert MarketNotSupported(pool); caps[pool] = newCaps[i]; unchecked { @@ -289,12 +309,16 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy * 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. + * - Negative value indicates a withdrawal. + * - Positive value indicates a supply. + * @param allocations Array that indicates how much to deposit or withdraw + * from each market. */ function reallocate(MarketAllocation[] calldata allocations) external onlyRole(ALLOCATOR_ROLE) nonReentrant { uint256 totalSupplied; uint256 totalWithdrawn; - uint256 currentIdleDeposits = baseAsset.balanceOf(address(this)); + uint256 currentIdleDeposits = BASE_ASSET.balanceOf(address(this)); for (uint256 i; i != allocations.length;) { MarketAllocation calldata allocation = allocations[i]; IIonPool pool = allocation.pool; @@ -337,8 +361,10 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy transferAmt = uint256(assets); } - if (currentSupplied + transferAmt > caps[pool]) { - revert AllocationCapExceeded(); + uint256 resultingSupplied = currentSupplied + transferAmt; + uint256 allocationCap = caps[pool]; + if (resultingSupplied > allocationCap) { + revert AllocationCapExceeded(resultingSupplied, allocationCap); } // If the assets are being deposited to IDLE, then no need for @@ -358,9 +384,12 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy } } - if (totalSupplied != totalWithdrawn) revert InvalidReallocation(); + if (totalSupplied != totalWithdrawn) revert InvalidReallocation(totalSupplied, totalWithdrawn); } + /** + * @notice Manually accrues fees and mints shares to the fee recipient. + */ function accrueFee() external onlyRole(OWNER_ROLE) returns (uint256 newTotalAssets) { return _accrueFee(); } @@ -368,17 +397,18 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy // --- IonPool Interactions --- /** - * @notice Iterates through the supply queue to deposit as much as possible. - * Reverts if the deposit amount cannot be filled due to the allocation cap - * or the supply cap. - * @dev Must be non-reentrant in case the underlying IonPool implements - * callback logic. + * @notice Iterates through the supply queue to deposit the desired amount + * of assets. Reverts if the deposit amount cannot be filled due to the + * allocation cap or the supply cap. + * @dev External functions calling this must be non-reentrant in case the + * underlying IonPool implements callback logic. + * @param assets The amount of assets that will attempt to be supplied. */ function _supplyToIonPool(uint256 assets) internal { - // This function is called after the `baseAsset` is transferred to the + // This function is called after the `BASE_ASSET` is transferred to the // contract for the supply iterations. The `assets` is subtracted to - // retrieve the `baseAsset` balance before this transaction began. - uint256 currentIdleDeposits = baseAsset.balanceOf(address(this)) - assets; + // retrieve the `BASE_ASSET` balance before this transaction began. + uint256 currentIdleDeposits = BASE_ASSET.balanceOf(address(this)) - assets; uint256 supplyQueueLength = supplyQueue.length; for (uint256 i; i != supplyQueueLength;) { @@ -407,8 +437,16 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy if (assets != 0) revert AllSupplyCapsReached(); } + /** + * @notice Iterates through the withdraw queue to withdraw the desired + * amount of assets. Will revert if there is not enough liquidity or if + * trying to withdraw more than the caller owns. + * @dev External functions calling this must be non-reentrant in case the + * underlying IonPool implements callback logic. + * @param assets The desired amount of assets to be withdrawn. + */ function _withdrawFromIonPool(uint256 assets) internal { - uint256 currentIdleDeposits = baseAsset.balanceOf(address(this)); + uint256 currentIdleDeposits = BASE_ASSET.balanceOf(address(this)); uint256 withdrawQueueLength = withdrawQueue.length; for (uint256 i; i != withdrawQueueLength;) { @@ -441,8 +479,12 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @inheritdoc IERC4626 - * @notice Receives deposits from the sender and supplies into the underlying IonPool markets. + * @notice Transfers the specified amount of assets from the sender, + * supplies into the underlying + * IonPool markets, and mints a corresponding amount of shares. * @dev All incoming deposits are deposited in the order specified in the deposit queue. + * @param assets Amount of tokens to be deposited. + * @param receiver The address to receive the minted shares. */ function deposit(uint256 assets, address receiver) public override nonReentrant returns (uint256 shares) { uint256 newTotalAssets = _accrueFee(); @@ -453,7 +495,12 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @inheritdoc IERC4626 - * @dev + * @notice Mints the specified amount of shares and deposits a corresponding + * amount of assets. + * @dev Converts the shares to assets and iterates through the deposit queue + * to allocate the deposit across the supported markets. + * @param shares The exact amount of shares to be minted. + * @param receiver The address to receive the minted shares. */ function mint(uint256 shares, address receiver) public override nonReentrant returns (uint256 assets) { uint256 newTotalAssets = _accrueFee(); @@ -467,8 +514,13 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy } /** - * @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. + * @notice Withdraws specified amount of assets from IonPools and sends them + * to the receiver in exchange for burning the owner's vault shares. + * @dev All withdraws are withdrawn in the order specified in the withdraw + * queue. The owner needs to approve the caller to spend their shares. + * @param assets The exact amount of assets to be transferred out. + * @param receiver The receiver of the assets transferred. + * @param owner The owner of the vault shares. */ function withdraw( uint256 assets, @@ -489,6 +541,8 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @inheritdoc IERC4626 + * @notice Redeems the exact amount of shares and receives a corresponding + * amount of assets. * @dev 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 the pool's favor. @@ -503,6 +557,9 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy * If the `lastTotalAssets` is slightly greater than the actual `totalAssets`, * the impact will be that the calculated interest accrued during fee distribution will be slightly less than the * true value. + * @param shares The exact amount of shares to be burned and redeemed. + * @param receiver The address that receives the transferred assets. + * @param owner The address that holds the shares to be redeemed. */ function redeem( uint256 shares, @@ -523,13 +580,20 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy _withdraw(_msgSender(), receiver, owner, assets, shares); } + /** + * @inheritdoc IERC20Metadata + */ 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 Ion. + * @inheritdoc IERC4626 + * @notice Returns the maximum amount of assets that the vault can supply on + * Ion. + * @dev The max deposit amount is limited by the vault's allocation cap and + * the underlying IonPools' supply caps. + * @return The max amount of assets that can be supplied. */ function maxDeposit(address) public view override returns (uint256) { return _maxDeposit(); @@ -537,9 +601,11 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @inheritdoc IERC4626 + * @notice Returns the maximum amount of vault shares that can be minted. * @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. + * @return The max amount of shares that can be minted. */ function maxMint(address) public view override returns (uint256) { uint256 suppliable = _maxDeposit(); @@ -549,8 +615,13 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @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. + * @notice Returns the maximum amount of assets that can be withdrawn. + * @dev Max withdraw is limited by the owner's shares and 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. + * @param owner The address that holds the assets. + * @return assets The max amount of assets that can be withdrawn. */ function maxWithdraw(address owner) public view override returns (uint256 assets) { (assets,,) = _maxWithdraw(owner); @@ -558,9 +629,14 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @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. + * @notice Calculates the total withdrawable amount based on the available + * liquidity in the underlying pools and converts it to redeemable shares. + * @dev Max redeem is derived from çonverting the `_maxWithdraw` 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. + * @param owner The address that holds the shares. + * @return The max amount of shares that can be withdrawn. */ function maxRedeem(address owner) public view override returns (uint256) { (uint256 assets, uint256 newTotalSupply, uint256 newTotalAssets) = _maxWithdraw(owner); @@ -571,6 +647,8 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy * @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. + * @return assets The total assets held on the contract and inside the underlying + * pools by this vault. */ function totalAssets() public view override returns (uint256 assets) { uint256 _supportedMarketsLength = supportedMarkets.length(); @@ -578,7 +656,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy IIonPool pool = IIonPool(supportedMarkets.at(i)); uint256 assetsInPool = - pool == IDLE ? baseAsset.balanceOf(address(this)) : pool.getUnderlyingClaimOf(address(this)); + pool == IDLE ? BASE_ASSET.balanceOf(address(this)) : pool.getUnderlyingClaimOf(address(this)); assets += assetsInPool; @@ -654,7 +732,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy IIonPool pool = IIonPool(supportedMarkets.at(i)); uint256 depositable = - pool == IDLE ? _zeroFloorSub(caps[pool], baseAsset.balanceOf(address(this))) : _depositable(pool); + pool == IDLE ? _zeroFloorSub(caps[pool], BASE_ASSET.balanceOf(address(this))) : _depositable(pool); maxDepositable += depositable; @@ -664,10 +742,6 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy } } - /** - * @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 @@ -696,7 +770,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @dev The total accrued vault revenue is the difference in the total - * iToken holdings from the last accrued timestamp. + * iToken holdings from the last accrued timestamp and now. */ function _accruedFeeShares() internal view returns (uint256 feeShares, uint256 newTotalAssets) { newTotalAssets = totalAssets(); @@ -713,6 +787,13 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy } } + /** + * @dev NOTE The IERC4626 natspec recommends 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 _convertToSharesWithFees(uint256 assets, Math.Rounding rounding) internal view returns (uint256) { (uint256 feeShares, uint256 newTotalAssets) = _accruedFeeShares(); @@ -720,7 +801,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy } /** - * @dev NOTE The IERC4626 natspec recomments that the `_convertToAssets` and `_convertToShares` "MUST NOT be + * @dev NOTE The IERC4626 natspec recommends 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. @@ -734,7 +815,9 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy /** * @dev Returns the amount of shares that the vault would exchange for the - * amount of `assets` provided. + * amount of `assets` provided. This function is used to calculate the + * conversion between shares and assets with parameterizable total supply + * and total assets variables. */ function _convertToSharesWithTotals( uint256 assets, @@ -749,6 +832,12 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy return assets.mulDiv(newTotalSupply + 10 ** _decimalsOffset(), newTotalAssets + 1, rounding); } + /** + * @dev Returns the amount of assets that the vault would exchange for the + * amount of `shares` provided. This function is used to calculate the + * conversion between shares and assets with parameterizable total supply + * and total assets variables. + */ function _convertToAssetsWithTotals( uint256 shares, uint256 newTotalSupply, @@ -785,7 +874,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy for (uint256 i; i != withdrawQueueLength;) { IIonPool pool = withdrawQueue[i]; - uint256 withdrawable = pool == IDLE ? baseAsset.balanceOf(address(this)) : _withdrawable(pool); + uint256 withdrawable = pool == IDLE ? BASE_ASSET.balanceOf(address(this)) : _withdrawable(pool); uint256 toWithdraw = Math.min(withdrawable, assets); assets -= toWithdraw; @@ -805,10 +894,11 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy * considering the vault's claim and the available liquidity. A minimum of * this contract's total claim on the underlying and the available liquidity * in the pool. + * @return The max amount of assets withdrawable from this IonPool. */ function _withdrawable(IIonPool pool) internal view returns (uint256) { uint256 currentSupplied = pool.getUnderlyingClaimOf(address(this)); - uint256 availableLiquidity = ionLens.liquidity(pool); + uint256 availableLiquidity = uint256(pool.extsload(ION_POOL_LIQUIDITY_SLOT)); return Math.min(currentSupplied, availableLiquidity); } @@ -817,23 +907,42 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy * @dev The max amount of assets depositable to a given IonPool. Depositing * the minimum between the two diffs ensures that the deposit will not * violate the allocation cap or the supply cap. + * @return The max amount of assets depositable to this IonPool. */ function _depositable(IIonPool pool) internal view returns (uint256) { uint256 allocationCapDiff = _zeroFloorSub(caps[pool], pool.getUnderlyingClaimOf(address(this))); - uint256 supplyCapDiff = _zeroFloorSub(ionLens.supplyCap(pool), pool.getTotalUnderlyingClaims()); + uint256 supplyCapDiff = + _zeroFloorSub(uint256(pool.extsload(ION_POOL_SUPPLY_CAP_SLOT)), pool.getTotalUnderlyingClaims()); return Math.min(allocationCapDiff, supplyCapDiff); } // --- EnumerableSet.Address Getters --- + + /** + * @notice Returns the array representation of the `supportedMarkets` set. + * @return Array of supported IonPools. + */ function getSupportedMarkets() external view returns (address[] memory) { return supportedMarkets.values(); } + /** + * @notice Returns whether the market is part of the `supportedMarkets` set. + * @param pool The address of the IonPool to be checked. + * @return The pool is supported if true. If not, false. + */ function containsSupportedMarket(address pool) external view returns (bool) { return supportedMarkets.contains(pool); } + /** + * @notice Returns the element in the array representation of + * `supportedMarkets`. `index` must be strictly less than the length of the + * array. + * @param index The index to be queried on the `supportedMarkets` array. + * @return Address at the index of `supportedMarkets`. + */ function supportedMarketsAt(uint256 index) external view returns (address) { return supportedMarkets.at(index); } @@ -845,6 +954,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy * value of 0 means that the value is not in the set. If the value is not in * the set, this call will revert. Otherwise, it will return the `position - * 1` value to return the index of the element in the array. + * @param pool The address of the IonPool to be queried. * @return The index of the pool's location in the array. The return value * will always be greater than zero as this function would revert if the * market is not part of the set. @@ -853,6 +963,10 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy return _supportedMarketsIndexOf(pool); } + /** + * @notice Length of the array representation of `supportedMarkets`. + * @return The length of the `supportedMarkets` array. + */ function supportedMarketsLength() external view returns (uint256) { return supportedMarkets.length(); } @@ -860,7 +974,7 @@ contract Vault is ERC4626, Multicall, AccessControlDefaultAdminRules, Reentrancy function _supportedMarketsIndexOf(address pool) internal view returns (uint256) { bytes32 key = bytes32(uint256(uint160(pool))); uint256 position = supportedMarkets._inner._positions[key]; - if (position == 0) revert MarketNotSupported(); + if (position == 0) revert MarketNotSupported(IIonPool(pool)); return --position; } } diff --git a/src/vault/VaultFactory.sol b/src/vault/VaultFactory.sol index fd0085b7..4bee1097 100644 --- a/src/vault/VaultFactory.sol +++ b/src/vault/VaultFactory.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.21; import { Vault } from "./Vault.sol"; import { IERC20 } from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; -import { IIonLens } from "./../interfaces/IIonLens.sol"; /** * @title Ion Lending Vault Factory @@ -27,7 +26,6 @@ contract VaultFactory { /** * @notice Deploys a new Ion Lending Vault. - * @param ionLens The IonLens contract for querying data. * @param baseAsset The asset that is being lent out to IonPools. * @param feeRecipient Address that receives the accrued manager fees. * @param feePercentage Fee percentage to be set. @@ -38,7 +36,6 @@ contract VaultFactory { * @param salt The salt used for CREATE2 deployment. */ function createVault( - IIonLens ionLens, IERC20 baseAsset, address feeRecipient, uint256 feePercentage, @@ -51,8 +48,9 @@ contract VaultFactory { external returns (Vault vault) { + // TODO use named args syntax vault = new Vault{ salt: salt }( - ionLens, baseAsset, feeRecipient, feePercentage, name, symbol, initialDelay, initialDefaultAdmin + baseAsset, feeRecipient, feePercentage, name, symbol, initialDelay, initialDefaultAdmin ); emit CreateVault(address(vault), baseAsset, feeRecipient, feePercentage, name, symbol, initialDefaultAdmin); diff --git a/test/fork/concrete/vault/VaultFactory.t.sol b/test/fork/concrete/vault/VaultFactory.t.sol index cf1a41fa..1715b1a3 100644 --- a/test/fork/concrete/vault/VaultFactory.t.sol +++ b/test/fork/concrete/vault/VaultFactory.t.sol @@ -24,61 +24,52 @@ contract VaultFactoryTest is VaultSharedSetup { function test_CreateVault() public { bytes32 salt = keccak256("random salt"); - Vault vault = factory.createVault( - ionLens, baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt - ); + Vault vault = + factory.createVault(baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt); assertEq(VAULT_ADMIN, vault.defaultAdmin(), "default admin"); assertEq(feeRecipient, vault.feeRecipient(), "fee recipient"); - assertEq(address(baseAsset), address(vault.baseAsset()), "base asset"); - assertEq(address(ionLens), address(vault.ionLens()), "ion lens"); + assertEq(address(baseAsset), address(vault.BASE_ASSET()), "base asset"); } function test_CreateVault_Twice() public { bytes32 salt = keccak256("first random salt"); - Vault vault = factory.createVault( - ionLens, baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt - ); + Vault vault = + factory.createVault(baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt); bytes32 salt2 = keccak256("second random salt"); - Vault vault2 = factory.createVault( - ionLens, baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt2 - ); + Vault vault2 = + factory.createVault(baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt2); assertEq(VAULT_ADMIN, vault.defaultAdmin(), "default admin"); assertEq(feeRecipient, vault.feeRecipient(), "fee recipient"); - assertEq(address(baseAsset), address(vault.baseAsset()), "base asset"); - assertEq(address(ionLens), address(vault.ionLens()), "ion lens"); + assertEq(address(baseAsset), address(vault.BASE_ASSET()), "base asset"); assertEq(VAULT_ADMIN, vault2.defaultAdmin(), "default admin"); assertEq(feeRecipient, vault2.feeRecipient(), "fee recipient"); - assertEq(address(baseAsset), address(vault2.baseAsset()), "base asset"); - assertEq(address(ionLens), address(vault2.ionLens()), "ion lens"); + assertEq(address(baseAsset), address(vault2.BASE_ASSET()), "base asset"); } function test_Revert_CreateVault_SameSaltTwice() public { bytes32 salt = keccak256("random salt"); - Vault vault = factory.createVault( - ionLens, baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt - ); + Vault vault = + factory.createVault(baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt); vm.expectRevert(); - Vault vault2 = factory.createVault( - ionLens, baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt - ); + Vault vault2 = + factory.createVault(baseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt); } function test_CreateVault_SameSaltDifferentBytecode() public { bytes32 salt = keccak256("random salt"); - Vault vault = factory.createVault( - ionLens, BASE_ASSET, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt - ); + Vault vault = + factory.createVault(BASE_ASSET, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt); IERC20 diffBaseAsset = IERC20(address(new ERC20PresetMinterPauser("Another Wrapped Staked ETH", "wstETH2"))); Vault vault2 = factory.createVault( - ionLens, diffBaseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt + diffBaseAsset, feeRecipient, feePercentage, name, symbol, INITIAL_DELAY, VAULT_ADMIN, salt ); require(address(vault) != address(vault2), "different deployment address"); diff --git a/test/helpers/VaultSharedSetup.sol b/test/helpers/VaultSharedSetup.sol index 29bb7924..39618c79 100644 --- a/test/helpers/VaultSharedSetup.sol +++ b/test/helpers/VaultSharedSetup.sol @@ -5,7 +5,6 @@ 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"; @@ -33,7 +32,6 @@ contract VaultSharedSetup is IonPoolSharedSetup { StdStorage stdstore1; Vault vault; - IonLens ionLens; // roles address constant VAULT_ADMIN = address(uint160(uint256(keccak256("VAULT_ADMIN")))); @@ -71,11 +69,7 @@ contract VaultSharedSetup is IonPoolSharedSetup { rsEthIonPool = deployIonPool(BASE_ASSET, RSETH, address(this)); rswEthIonPool = deployIonPool(BASE_ASSET, RSWETH, address(this)); - ionLens = new IonLens(); - - vault = new Vault( - ionLens, BASE_ASSET, FEE_RECIPIENT, ZERO_FEES, "Ion Vault Token", "IVT", INITIAL_DELAY, VAULT_ADMIN - ); + vault = new Vault(BASE_ASSET, FEE_RECIPIENT, ZERO_FEES, "Ion Vault Token", "IVT", INITIAL_DELAY, VAULT_ADMIN); vm.startPrank(vault.defaultAdmin()); @@ -220,12 +214,6 @@ contract VaultSharedSetup is IonPoolSharedSetup { _vault.updateWithdrawQueue(queue); } - // -- Queries --- - - // function expectedSupplyAmounts(Vault _vault, uint256 assets) internal returns (uint256[]) { - - // } - // -- Exact Rounding Error Equations --- function postDepositClaimRE(uint256 depositAmount, uint256 supplyFactor) internal returns (uint256) { @@ -242,10 +230,6 @@ contract VaultSharedSetup is IonPoolSharedSetup { 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, @@ -288,4 +272,16 @@ contract VaultSharedSetup is IonPoolSharedSetup { function newAddress(bytes memory str) internal returns (address) { return address(uint160(uint256(keccak256(str)))); } + + function withSupplyFactor() internal { + IonPoolExposed(address(weEthIonPool)).setSupplyFactor(1.13131323e27); + IonPoolExposed(address(rsEthIonPool)).setSupplyFactor(1.1585678e27); + IonPoolExposed(address(rswEthIonPool)).setSupplyFactor(1.838194e27); + } + + function withInflatedSupplyFactor() internal { + IonPoolExposed(address(weEthIonPool)).setSupplyFactor(5.1336673e27); + IonPoolExposed(address(rsEthIonPool)).setSupplyFactor(7.1336673e27); + IonPoolExposed(address(rswEthIonPool)).setSupplyFactor(10.1336673e27); + } } diff --git a/test/unit/concrete/vault/Vault.t.sol b/test/unit/concrete/vault/Vault.t.sol index ae24d2f6..cafcdb22 100644 --- a/test/unit/concrete/vault/Vault.t.sol +++ b/test/unit/concrete/vault/Vault.t.sol @@ -8,7 +8,6 @@ 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 { EnumerableSet } from "openzeppelin-contracts/contracts/utils/structs/EnumerableSet.sol"; import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; import { IAccessControl } from "openzeppelin-contracts/contracts/access/IAccessControl.sol"; @@ -26,9 +25,7 @@ contract VaultSetUpTest is VaultSharedSetup { } function test_AddSupportedMarketsSeparately() public { - vault = new Vault( - ionLens, BASE_ASSET, FEE_RECIPIENT, ZERO_FEES, "Ion Vault Token", "IVT", INITIAL_DELAY, VAULT_ADMIN - ); + vault = new Vault(BASE_ASSET, FEE_RECIPIENT, ZERO_FEES, "Ion Vault Token", "IVT", INITIAL_DELAY, VAULT_ADMIN); vm.startPrank(vault.defaultAdmin()); vault.grantRole(vault.OWNER_ROLE(), OWNER); @@ -90,9 +87,7 @@ contract VaultSetUpTest is VaultSharedSetup { } function test_AddSupportedMarketsTogether() public { - vault = new Vault( - ionLens, BASE_ASSET, FEE_RECIPIENT, ZERO_FEES, "Ion Vault Token", "IVT", INITIAL_DELAY, VAULT_ADMIN - ); + vault = new Vault(BASE_ASSET, FEE_RECIPIENT, ZERO_FEES, "Ion Vault Token", "IVT", INITIAL_DELAY, VAULT_ADMIN); vm.startPrank(vault.defaultAdmin()); vault.grantRole(vault.OWNER_ROLE(), OWNER); @@ -154,7 +149,7 @@ contract VaultSetUpTest is VaultSharedSetup { vm.startPrank(OWNER); // wrong base asset revert - vm.expectRevert(Vault.InvalidSupportedMarkets.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.InvalidUnderlyingAsset.selector, newIonPool)); vault.addSupportedMarkets(markets, allocationCaps, queue, queue); // zero address revert @@ -165,6 +160,8 @@ contract VaultSetUpTest is VaultSharedSetup { vm.stopPrank(); } + function test_Revert_AddSupportedMarkets_MarketAlreadySupported() public { } + function test_RemoveSingleSupportedMarket() public { uint256[] memory allocationCaps = new uint256[](1); allocationCaps[0] = 1e18; @@ -304,7 +301,7 @@ contract VaultSetUpTest is VaultSharedSetup { assertGt(weEthIonPool.balanceOf(address(vault)), 0, "deposited to weEthIonPool"); vm.prank(OWNER); - vm.expectRevert(Vault.InvalidMarketRemovalNonZeroSupply.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.InvalidMarketRemovalNonZeroSupply.selector, weEthIonPool)); vault.removeSupportedMarkets(market, queue, queue); } @@ -318,7 +315,7 @@ contract VaultSetUpTest is VaultSharedSetup { queue[2] = rswEthIonPool; vm.prank(OWNER); - vm.expectRevert(Vault.MarketNotSupported.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.MarketNotSupported.selector, IDLE)); vault.removeSupportedMarkets(market, queue, queue); } @@ -334,7 +331,7 @@ contract VaultSetUpTest is VaultSharedSetup { queue[2] = rswEthIonPool; vm.prank(OWNER); - vm.expectRevert(Vault.InvalidQueueLength.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.InvalidQueueLength.selector, 3, 2)); vault.removeSupportedMarkets(market, queue, queue); } @@ -380,7 +377,7 @@ contract VaultSetUpTest is VaultSharedSetup { vm.prank(OWNER); vault.multicall(multicallData); - vm.expectRevert(Vault.MarketNotSupported.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.MarketNotSupported.selector, weEthIonPool)); vault.supportedMarketsIndexOf(address(weEthIonPool)); assertEq(vault.supportedMarketsLength(), 2, "supported markets length"); @@ -409,20 +406,21 @@ contract VaultSetUpTest is VaultSharedSetup { vm.startPrank(OWNER); - vm.expectRevert(Vault.InvalidQueueLength.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.InvalidQueueLength.selector, 5, 3)); vault.updateSupplyQueue(invalidLengthQueue); IIonPool[] memory zeroAddressQueue = new IIonPool[](3); - vm.expectRevert(Vault.MarketNotSupported.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.MarketNotSupported.selector, address(0))); vault.updateSupplyQueue(zeroAddressQueue); + IIonPool wrongIonPool = IIonPool(address(uint160(uint256(keccak256("address not in supported markets"))))); IIonPool[] memory notSupportedQueue = new IIonPool[](3); notSupportedQueue[0] = rsEthIonPool; notSupportedQueue[1] = rswEthIonPool; - notSupportedQueue[2] = IIonPool(address(uint160(uint256(keccak256("address not in supported markets"))))); + notSupportedQueue[2] = wrongIonPool; - vm.expectRevert(Vault.MarketNotSupported.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.MarketNotSupported.selector, wrongIonPool)); vault.updateSupplyQueue(notSupportedQueue); } @@ -605,7 +603,7 @@ contract VaultRolesAndPrivilegedFunctions is VaultSharedSetup { } } -contract VaultDeposit is VaultSharedSetup { +abstract contract VaultDeposit is VaultSharedSetup { function setUp() public virtual override { super.setUp(); } @@ -798,7 +796,7 @@ contract VaultDeposit is VaultSharedSetup { function test_Mint_AllMarkets() public { } } -contract VaultWithdraw is VaultSharedSetup { +abstract contract VaultWithdraw is VaultSharedSetup { function setUp() public virtual override { super.setUp(); } @@ -942,7 +940,7 @@ contract VaultWithdraw is VaultSharedSetup { function test_DepositAndWithdraw_MultipleUsers() public { } } -contract VaultReallocate is VaultSharedSetup { +abstract contract VaultReallocate is VaultSharedSetup { function setUp() public virtual override { super.setUp(); } @@ -1068,10 +1066,6 @@ contract VaultReallocate is VaultSharedSetup { ); } - function test_Reallocate_WithIdleAsset() public { - // - } - function test_Revert_Reallocate_AllocationCapExceeded() public { uint256 depositAmount = 10e18; @@ -1087,14 +1081,16 @@ contract VaultReallocate is VaultSharedSetup { uint256 prevTotalAssets = vault.totalAssets(); - // tries to deposit 10e18 to 9e18 allocation cap + uint256 weEthCurrentSupplied = weEthIonPool.getUnderlyingClaimOf(address(vault)); + + // tries to deposit 2e18 + 2e18 to 3e18 allocation cap Vault.MarketAllocation[] memory allocs = new Vault.MarketAllocation[](3); allocs[0] = Vault.MarketAllocation({ pool: rswEthIonPool, assets: -1e18 }); allocs[1] = Vault.MarketAllocation({ pool: rsEthIonPool, assets: -1e18 }); allocs[2] = Vault.MarketAllocation({ pool: weEthIonPool, assets: 2e18 }); vm.prank(ALLOCATOR); - vm.expectRevert(Vault.AllocationCapExceeded.selector); + vm.expectRevert(abi.encodeWithSelector(Vault.AllocationCapExceeded.selector, weEthCurrentSupplied + 2e18, 3e18)); vault.reallocate(allocs); } @@ -1127,16 +1123,14 @@ contract VaultReallocate is VaultSharedSetup { } } -contract VaultWithIdlePool is VaultSharedSetup { +abstract contract VaultWithIdlePool is VaultSharedSetup { IIonPool[] marketsToAdd; uint256[] allocationCaps; function setUp() public virtual override { super.setUp(); - vault = new Vault( - ionLens, BASE_ASSET, FEE_RECIPIENT, ZERO_FEES, "Ion Vault Token", "IVT", INITIAL_DELAY, VAULT_ADMIN - ); + vault = new Vault(BASE_ASSET, FEE_RECIPIENT, ZERO_FEES, "Ion Vault Token", "IVT", INITIAL_DELAY, VAULT_ADMIN); BASE_ASSET.approve(address(vault), type(uint256).max); @@ -1184,12 +1178,12 @@ contract VaultWithIdlePool is VaultSharedSetup { "weEthIonPool" ); assertEq(BASE_ASSET.balanceOf(address(vault)), 20e18, "IDLE"); - assertLt( + assertLe( 30e18 - rsEthIonPool.getUnderlyingClaimOf(address(vault)), postDepositClaimRE(30e18, rsEthIonPoolSF), "rsEthIonPool" ); - assertLt( + assertLe( 10e18 - rswEthIonPool.getUnderlyingClaimOf(address(vault)), postDepositClaimRE(10e18, rswEthIonPoolSF), "rswEthIonPool" @@ -1232,12 +1226,12 @@ contract VaultWithIdlePool is VaultSharedSetup { "weEthIonPool" ); assertEq(BASE_ASSET.balanceOf(address(vault)), expectedIdleClaim, "IDLE"); - assertLt( + assertLe( expectedRsEthIonPoolClaim - rsEthIonPool.getUnderlyingClaimOf(address(vault)), postDepositClaimRE(expectedWeEthIonPoolClaim, rsEthIonPoolSF), "rsEthIonPool" ); - assertLt( + assertLe( expectedRswEthIonPoolClaim - rswEthIonPool.getUnderlyingClaimOf(address(vault)), postDepositClaimRE(expectedRswEthIonPoolClaim, rswEthIonPoolSF), "rswEthIonPool" @@ -1302,7 +1296,7 @@ contract VaultWithIdlePool is VaultSharedSetup { assertEq(BASE_ASSET.balanceOf(address(this)), withdrawAmount, "user base asset balance"); } - function test_FullWithdraw() public { + function test_MaxWithdraw() public { uint256 depositAmount = 70e18; setERC20Balance(address(BASE_ASSET), address(this), depositAmount); vault.deposit(depositAmount, address(this)); @@ -1317,20 +1311,28 @@ contract VaultWithIdlePool is VaultSharedSetup { assertEq(vault.balanceOf(address(this)), 0, "user shares balance"); } - function test_FullRedeem() public { + function test_MaxRedeem() public { uint256 depositAmount = 70e18; setERC20Balance(address(BASE_ASSET), address(this), depositAmount); vault.deposit(depositAmount, address(this)); + // all deposits are available to be withdrawn. uint256 redeemAmount = vault.maxRedeem(address(this)); - vault.redeem(redeemAmount, address(this), address(this)); + uint256 withdrawnAssets = vault.redeem(redeemAmount, address(this), address(this)); - assertEq(vault.totalAssets(), 0, "vault total assets"); - assertEq(vault.totalSupply(), 0, "vault total shares"); + uint256 weEthRoundingError = (weEthIonPool.supplyFactor()) / RAY + 1; + uint256 rsEthRoundingError = (rsEthIonPool.supplyFactor()) / RAY + 1; + uint256 rswEthRoundingError = (rswEthIonPool.supplyFactor()) / RAY + 1; + uint256 roundingError = weEthRoundingError + rsEthRoundingError + rswEthRoundingError; - assertEq(BASE_ASSET.balanceOf(address(this)), redeemAmount, "user base asset balance"); - assertEq(vault.balanceOf(address(this)), 0, "user shares balance"); + // _maxWithdraw rounds down inside the `IonPool` to calculate the claims + // and the shares conversion rounds down again. + assertLe(vault.totalAssets(), roundingError, "vault total assets"); + assertLe(vault.totalSupply(), roundingError, "vault total shares"); + + assertEq(withdrawnAssets, BASE_ASSET.balanceOf(address(this)), "user base asset balance"); + assertLe(vault.balanceOf(address(this)), 1, "user shares balance"); } function test_Reallocate_DepositToIdle() public { @@ -1463,17 +1465,7 @@ contract VaultERC4626ExternalViews is VaultSharedSetup { assertEq(maxDeposit, 55e18, "max deposit after update supply cap"); } - function test_MaxDeposit_AfterDeposits() public { - // // deposit - // uint256 depositAmount = 35e18; - // setERC20Balance(address(BASE_ASSET), address(this), depositAmount); - - // // Out of 60e18 room, 35e18 was taken up. - - // maxDeposit = vault.maxDeposit(NULL); - - // assertEq(maxDeposit, 35e18, "max deposit after deposit"); - } + function test_MaxDeposit_AfterDeposits() public { } function test_MaxMint_MintAmount() public { uint256[] memory allocationCaps = new uint256[](3); @@ -1505,6 +1497,7 @@ contract VaultERC4626ExternalViews is VaultSharedSetup { function test_MaxRedeem() public { } // --- Previews --- + // Check the difference between preview and actual function test_PreviewDeposit() public { } @@ -1525,12 +1518,14 @@ contract VaultDeposit_WithoutSupplyFactor is VaultDeposit { contract VaultDeposit_WithSupplyFactor is VaultDeposit { function setUp() public override(VaultDeposit) { super.setUp(); + withSupplyFactor(); } } contract VaultDeposit_WithInflatedSupplyFactor is VaultDeposit { function setUp() public override(VaultDeposit) { super.setUp(); + withInflatedSupplyFactor(); } } @@ -1543,12 +1538,14 @@ contract VaultWithdraw_WithoutSupplyFactor is VaultWithdraw { contract VaultWithdraw_WithSupplyFactor is VaultWithdraw { function setUp() public override(VaultWithdraw) { super.setUp(); + withSupplyFactor(); } } contract VaultWithdraw_WithInflatedSupplyFactor is VaultWithdraw { function setUp() public override(VaultWithdraw) { super.setUp(); + withInflatedSupplyFactor(); } } @@ -1561,12 +1558,14 @@ contract VaultReallocate_WithoutSupplyFactor is VaultReallocate { contract VaultReallocate_WithSupplyFactor is VaultReallocate { function setUp() public override(VaultReallocate) { super.setUp(); + withSupplyFactor(); } } contract VaultReallocate_WithInflatedSupplyFactor is VaultReallocate { function setUp() public override(VaultReallocate) { super.setUp(); + withInflatedSupplyFactor(); } } @@ -1579,11 +1578,13 @@ contract VaultWithIdlePool_WithoutSupplyFactor is VaultWithIdlePool { contract VaultWithIdlePool_WithSupplyFactor is VaultWithIdlePool { function setUp() public override(VaultWithIdlePool) { super.setUp(); + withSupplyFactor(); } } contract VaultWithIdlePool_WithInflatedSupplyFactor is VaultWithIdlePool { function setUp() public override(VaultWithIdlePool) { super.setUp(); + withInflatedSupplyFactor(); } } diff --git a/test/unit/concrete/vault/VaultWithYield.t.sol b/test/unit/concrete/vault/VaultWithYield.t.sol index 152f0919..cbdfe74b 100644 --- a/test/unit/concrete/vault/VaultWithYield.t.sol +++ b/test/unit/concrete/vault/VaultWithYield.t.sol @@ -1,86 +1,90 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.21; +// // SPDX-License-Identifier: GPL-2.0-or-later +// pragma solidity 0.8.21; -import { VaultSharedSetup } from "../../../helpers/VaultSharedSetup.sol"; +// import { VaultSharedSetup } from "../../../helpers/VaultSharedSetup.sol"; -import { console2 } from "forge-std/console2.sol"; +// import { console2 } from "forge-std/console2.sol"; -contract Vault_WithYieldAndFee is VaultSharedSetup { - uint256 constant INITIAL_SUPPLY_AMT = 1000e18; +// contract Vault_WithYieldWithoutFee is VaultSharedSetup { +// uint256 constant INITIAL_SUPPLY_AMT = 1000e18; - function setUp() public virtual override { - super.setUp(); +// function setUp() public virtual override { +// super.setUp(); - weEthIonPool.updateSupplyCap(type(uint256).max); - rsEthIonPool.updateSupplyCap(type(uint256).max); - rswEthIonPool.updateSupplyCap(type(uint256).max); +// weEthIonPool.updateSupplyCap(type(uint256).max); +// rsEthIonPool.updateSupplyCap(type(uint256).max); +// rswEthIonPool.updateSupplyCap(type(uint256).max); - weEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); - rsEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); - rswEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); +// weEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); +// rsEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); +// rswEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); - supply(address(this), weEthIonPool, INITIAL_SUPPLY_AMT); - borrow(address(this), weEthIonPool, weEthGemJoin, 100e18, 70e18); +// supply(address(this), weEthIonPool, INITIAL_SUPPLY_AMT); +// borrow(address(this), weEthIonPool, weEthGemJoin, 100e18, 70e18); - supply(address(this), rsEthIonPool, INITIAL_SUPPLY_AMT); - borrow(address(this), rsEthIonPool, rsEthGemJoin, 100e18, 70e18); +// supply(address(this), rsEthIonPool, INITIAL_SUPPLY_AMT); +// borrow(address(this), rsEthIonPool, rsEthGemJoin, 100e18, 70e18); - supply(address(this), rswEthIonPool, INITIAL_SUPPLY_AMT); - borrow(address(this), rswEthIonPool, rswEthGemJoin, 100e18, 70e18); - } +// supply(address(this), rswEthIonPool, INITIAL_SUPPLY_AMT); +// borrow(address(this), rswEthIonPool, rswEthGemJoin, 100e18, 70e18); +// } - function test_AccrueYieldSingleMarket() public { - // When yield is accrued, - // the total assets increases, - // the vault shares should stay the same. +// function test_AccrueYieldSingleMarket_NoFee() public { +// // When yield is accrued, +// // the total assets increases, +// // the vault shares should stay the same. - uint256 depositAmount = 100e18; - setERC20Balance(address(BASE_ASSET), address(this), depositAmount); +// uint256 depositAmount = 100e18; +// setERC20Balance(address(BASE_ASSET), address(this), depositAmount); - updateSupplyQueue(vault, weEthIonPool, rsEthIonPool, rswEthIonPool); - updateSupplyCaps(vault, type(uint256).max, type(uint256).max, type(uint256).max); - updateAllocationCaps(vault, type(uint256).max, type(uint256).max, type(uint256).max); +// updateSupplyQueue(vault, weEthIonPool, rsEthIonPool, rswEthIonPool); +// updateSupplyCaps(vault, type(uint256).max, type(uint256).max, type(uint256).max); +// updateAllocationCaps(vault, type(uint256).max, type(uint256).max, type(uint256).max); - vault.deposit(depositAmount, address(this)); +// vault.deposit(depositAmount, address(this)); - // before yield accrual - uint256 prevTotalAssets = vault.totalAssets(); - uint256 prevWeEthIonPoolClaim = weEthIonPool.getUnderlyingClaimOf(address(vault)); +// // before yield accrual +// uint256 prevTotalAssets = vault.totalAssets(); +// uint256 prevWeEthIonPoolClaim = weEthIonPool.getUnderlyingClaimOf(address(vault)); - vm.warp(block.timestamp + 365 days); +// vm.warp(block.timestamp + 365 days); - (uint256 totalSupplyFactorIncrease,,,,) = weEthIonPool.calculateRewardAndDebtDistribution(); - console2.log("totalSupplyFactorIncrease: ", totalSupplyFactorIncrease); - assertGt(totalSupplyFactorIncrease, 0, "total supply factor increase"); +// (uint256 totalSupplyFactorIncrease,,,,) = weEthIonPool.calculateRewardAndDebtDistribution(); +// console2.log("totalSupplyFactorIncrease: ", totalSupplyFactorIncrease); +// assertGt(totalSupplyFactorIncrease, 0, "total supply factor increase"); - weEthIonPool.accrueInterest(); +// weEthIonPool.accrueInterest(); - // after yield accrual - uint256 newTotalAssets = vault.totalAssets(); - uint256 newWeEthIonPoolClaim = weEthIonPool.getUnderlyingClaimOf(address(vault)); +// // after yield accrual +// uint256 newTotalAssets = vault.totalAssets(); +// uint256 newWeEthIonPoolClaim = weEthIonPool.getUnderlyingClaimOf(address(vault)); - console2.log("prevTotalAssets: ", prevTotalAssets); - console2.log("newTotalAssets: ", newTotalAssets); +// console2.log("prevTotalAssets: ", prevTotalAssets); +// console2.log("newTotalAssets: ", newTotalAssets); - // ionPool - assertEq( - newTotalAssets - prevTotalAssets, newWeEthIonPoolClaim - prevTotalAssets, "yield accrual to total assets" - ); +// // ionPool +// assertEq( +// newTotalAssets - prevTotalAssets, newWeEthIonPoolClaim - prevTotalAssets, "yield accrual to total assets" +// ); - // vault +// // vault - // users - } +// // users +// } - function test_AccrueYieldAllMarkets() public { } +// function test_AccrueYieldSingleMarket_WithFee() public { - function test_WithFee_AccrueYieldSingleMarket() public { } +// } - function test_WithFee_AccrueYieldAllMarkets() public { } -} +// function test_AccrueYieldAllMarkets() public { } -contract Vault_WithRate_WithYieldAndFee is Vault_WithYieldAndFee { - function setUp() public override { - super.setUp(); - } -} +// function test_WithFee_AccrueYieldSingleMarket() public { } + +// function test_WithFee_AccrueYieldAllMarkets() public { } +// } + +// contract Vault_WithRate_WithYieldAndFee is Vault_WithYieldAndFee { +// function setUp() public override { +// super.setUp(); +// } +// } diff --git a/test/unit/fuzz/vault/Vault.t.sol b/test/unit/fuzz/vault/Vault.t.sol index 3901a22b..5c8011ef 100644 --- a/test/unit/fuzz/vault/Vault.t.sol +++ b/test/unit/fuzz/vault/Vault.t.sol @@ -3,8 +3,11 @@ pragma solidity 0.8.21; import { IonPoolExposed } from "../../../helpers/IonPoolSharedSetup.sol"; import { VaultSharedSetup } from "../../../helpers/VaultSharedSetup.sol"; - +import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; import { WadRayMath, RAY, WAD } from "./../../../../src/libraries/math/WadRayMath.sol"; +import { console2 } from "forge-std/console2.sol"; + +using Math for uint256; contract Vault_Fuzz is VaultSharedSetup { function setUp() public override { @@ -40,3 +43,118 @@ contract Vault_Fuzz is VaultSharedSetup { assertLe(expectedClaim - resultingClaim, (supplyFactor - 2) / RAY + 1); } } + +contract VaultWithYieldAndFee_Fuzz is VaultSharedSetup { + uint256 constant INITIAL_SUPPLY_AMT = 1000e18; + + function setUp() public override { + super.setUp(); + + uint256 initialSupplyAmt = 1000e18; + + weEthIonPool.updateSupplyCap(type(uint256).max); + rsEthIonPool.updateSupplyCap(type(uint256).max); + rswEthIonPool.updateSupplyCap(type(uint256).max); + + weEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); + rsEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); + rswEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); + + supply(address(this), weEthIonPool, INITIAL_SUPPLY_AMT); + borrow(address(this), weEthIonPool, weEthGemJoin, 100e18, 70e18); + + supply(address(this), rsEthIonPool, INITIAL_SUPPLY_AMT); + borrow(address(this), rsEthIonPool, rsEthGemJoin, 100e18, 70e18); + + supply(address(this), rswEthIonPool, INITIAL_SUPPLY_AMT); + borrow(address(this), rswEthIonPool, rswEthGemJoin, 100e18, 70e18); + + uint256 weEthIonPoolCap = 10e18; + uint256 rsEthIonPoolCap = 20e18; + uint256 rswEthIonPoolCap = 30e18; + + vm.prank(OWNER); + updateAllocationCaps(vault, weEthIonPoolCap, rsEthIonPoolCap, rswEthIonPoolCap); + } + + function testFuzz_AccruedFeeShares(uint256 initialDeposit, uint256 feePerc, uint256 daysAccrued) public { + // fee percentage + feePerc = bound(feePerc, 0, RAY - 1); + + vm.prank(OWNER); + vault.updateFeePercentage(feePerc); + + // initial deposit + uint256 initialMaxDeposit = vault.maxDeposit(NULL); + initialDeposit = bound(initialDeposit, 1e18, initialMaxDeposit); + + setERC20Balance(address(BASE_ASSET), address(this), initialDeposit); + vault.deposit(initialDeposit, address(this)); + + // initial state + uint256 prevTotalAssets = vault.totalAssets(); + uint256 prevUserShares = vault.balanceOf(address(this)); + uint256 prevUserAssets = vault.previewRedeem(prevUserShares); + + // interest accrues over a year + daysAccrued = bound(daysAccrued, 1, 10_000 days); + vm.warp(block.timestamp + daysAccrued); + + (uint256 totalSupplyFactorIncrease,,,,) = weEthIonPool.calculateRewardAndDebtDistribution(); + uint256 newTotalAssets = vault.totalAssets(); + uint256 interestAccrued = newTotalAssets - prevTotalAssets; // [WAD] + + assertGt(totalSupplyFactorIncrease, 0, "total supply factor increase"); + assertGt(vault.totalAssets(), prevTotalAssets, "total assets increased"); + assertGt(interestAccrued, 0, "interest accrued"); + + // expected resulting state + + uint256 expectedFeeAssets = interestAccrued.mulDiv(feePerc, RAY); + uint256 expectedFeeShares = + expectedFeeAssets.mulDiv(vault.totalSupply(), newTotalAssets - expectedFeeAssets, Math.Rounding.Floor); + + uint256 expectedUserAssets = prevUserAssets + interestAccrued.mulDiv(RAY - feePerc, RAY); + + vm.prank(OWNER); + vault.accrueFee(); + assertEq(vault.lastTotalAssets(), vault.totalAssets(), "last total assets updated"); + + // actual resulting values + uint256 feeRecipientShares = vault.balanceOf(FEE_RECIPIENT); + uint256 feeRecipientAssets = vault.previewRedeem(feeRecipientShares); + + uint256 userShares = vault.balanceOf(address(this)); + uint256 userAssets = vault.previewRedeem(userShares); + + // fee recipient + // 1. The actual shares minted must be exactly equal to the expected + // shares calculation. + // 2. The actual claim from previewRedeem versus the expected claim to the + // underlying assets will differ due to the vault rounding in its favor + // inside the `preview` calculation. Even though the correct number of + // shares were minted, the actual 'withdrawable' amount will be rounded + // down in vault's favor. The actual must always be less than expected. + assertEq(feeRecipientShares, expectedFeeShares, "fee shares"); + assertLe(expectedFeeAssets - feeRecipientAssets, 2, "fee assets with rounding error"); + + // the diluted user + // Expected to increase their assets by (interestAccrued * (1 - feePerc)) + // 1. The shares balance for the user does not change. + // 2. The withdrawable assets after the fee should have increased by + // their portion of interest accrued. + assertEq(userShares, prevUserShares, "user shares"); + // Sometimes userAssets > expectedUserAssets, sometimes less than. + assertApproxEqAbs(userAssets, expectedUserAssets, 1, "user assets"); + + // fee recipient and user + // 1. The user and the fee recipient are the only shareholders. + // 2. The total withdrawable by the user and the fee recipient should equal total assets. + assertEq(userShares + feeRecipientShares, vault.totalSupply(), "vault total supply"); + assertLe(vault.totalAssets() - (userAssets + feeRecipientAssets), 2, "vault total assets"); + } + + function testFuzz_MaxWithdrawAndMaxRedeem(uint256 assets) public { } + + function testFuzz_MaxDepositAndMaxMint(uint256 assets) public { } +}