From 9e8d3cf229470c355c1a9989997be760eec80029 Mon Sep 17 00:00:00 2001 From: Jongseung Lim Date: Wed, 27 Mar 2024 17:24:11 -0400 Subject: [PATCH 1/5] fix(YAUDIT-COVE-6): correct yearn vault v2 deposit and withdraws --- src/Yearn4626RouterExt.sol | 85 ++++++++++---- .../deps/yearn/veYFI/IYearnVaultV2.sol | 5 + test/forked/Router.t.sol | 106 ++++++++++++++++-- 3 files changed, 160 insertions(+), 36 deletions(-) diff --git a/src/Yearn4626RouterExt.sol b/src/Yearn4626RouterExt.sol index 50fb5004..becd1a6c 100644 --- a/src/Yearn4626RouterExt.sol +++ b/src/Yearn4626RouterExt.sol @@ -223,12 +223,7 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { (success, data) = vault.staticcall(abi.encodeCall(IYearnVaultV2.token, ())); if (success) { vaultAsset = abi.decode(data, (address)); - sharesOut[i] = Math.mulDiv( - assetsIn, - 10 ** IERC20Metadata(vault).decimals(), - IYearnVaultV2(vault).pricePerShare(), - Math.Rounding.Down - ) - 1; + sharesOut[i] = _yearnVaultV2_previewDeposit(IYearnVaultV2(vault), assetsIn); } else { revert PreviewNonVaultAddressInPath(vault); } @@ -279,12 +274,7 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { (success, data) = vault.staticcall(abi.encodeCall(IYearnVaultV2.token, ())); if (success) { vaultAsset = abi.decode(data, (address)); - assetsIn[i] = Math.mulDiv( - sharesOut, - IYearnVaultV2(vault).pricePerShare(), - 10 ** IERC20Metadata(vault).decimals(), - Math.Rounding.Up - ) + 1; + assetsIn[i] = _yearnVaultV2_previewMint(IYearnVaultV2(vault), sharesOut); } else { revert PreviewNonVaultAddressInPath(vault); } @@ -337,12 +327,7 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { (success, data) = vault.staticcall(abi.encodeCall(IYearnVaultV2.token, ())); if (success) { vaultAsset = abi.decode(data, (address)); - sharesIn[i] = Math.mulDiv( - assetsOut, - 10 ** IERC20Metadata(vault).decimals(), - IYearnVaultV2(vault).pricePerShare(), - Math.Rounding.Up - ); + sharesIn[i] = _yearnVaultV2_previewWithdraw(IYearnVaultV2(vault), assetsOut); } else { // StakeDAO gauge token // StakeDaoGauge.staking_token().token() is the yearn vault v2 token @@ -401,12 +386,7 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { (success, data) = vault.staticcall(abi.encodeCall(IYearnVaultV2.token, ())); if (success) { vaultAsset = abi.decode(data, (address)); - assetsOut[i] = Math.mulDiv( - sharesIn, - IYearnVaultV2(vault).pricePerShare(), - 10 ** IERC20Metadata(vault).decimals(), - Math.Rounding.Down - ); + assetsOut[i] = _yearnVaultV2_previewRedeem(IYearnVaultV2(vault), sharesIn); } else { // StakeDAO gauge token // StakeDaoGauge.staking_token().token() is the yearn vault v2 token @@ -432,4 +412,61 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { } } // slither-disable-end calls-loop,low-level-calls + + /// @dev Yearn Vault V2 contract's calculate locked profit logic + /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L829-L842 + function _yearnVaultV2_calculateLockedProfit(IYearnVaultV2 vault) internal view returns (uint256) { + uint256 lockedProfit = vault.lockedProfit(); + uint256 lockedFundsRatio = (block.timestamp - vault.lastReport()) * vault.lockedProfitDegradation(); + if (lockedFundsRatio < 1e18) { + lockedProfit -= (lockedProfit * lockedFundsRatio) / 1e18; + } else { + lockedProfit = 0; + } + return lockedProfit; + } + + /// @dev Yearn Vault V2 contract's free funds calculation logic + /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L844-L847 + function _yearnVaultV2_freeFunds(IYearnVaultV2 vault) internal view returns (uint256) { + return vault.totalAssets() - _yearnVaultV2_calculateLockedProfit(vault); + } + + /// @dev Yearn Vault V2 contract's deposit() and _issueSharesForAmount() logic + /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L849-L872 + function _yearnVaultV2_previewDeposit(IYearnVaultV2 vault, uint256 assetsIn) internal view returns (uint256) { + uint256 totalSupply = vault.totalSupply(); + uint256 freeFunds = _yearnVaultV2_freeFunds(vault); + if (totalSupply > 0) { + return Math.mulDiv(assetsIn, totalSupply, freeFunds, Math.Rounding.Down); + } + return assetsIn; + } + + function _yearnVaultV2_previewMint(IYearnVaultV2 vault, uint256 sharesOut) internal view returns (uint256) { + uint256 totalSupply = vault.totalSupply(); + uint256 freeFunds = _yearnVaultV2_freeFunds(vault); + if (totalSupply > 0) { + return Math.mulDiv(sharesOut, freeFunds, totalSupply, Math.Rounding.Up); + } + return sharesOut; + } + + function _yearnVaultV2_previewRedeem(IYearnVaultV2 vault, uint256 sharesIn) internal view returns (uint256) { + uint256 totalSupply = vault.totalSupply(); + uint256 freeFunds = _yearnVaultV2_freeFunds(vault); + if (totalSupply > 0) { + return Math.mulDiv(sharesIn, freeFunds, totalSupply, Math.Rounding.Down); + } + return 0; + } + + function _yearnVaultV2_previewWithdraw(IYearnVaultV2 vault, uint256 assetsOut) internal view returns (uint256) { + uint256 totalSupply = vault.totalSupply(); + uint256 freeFunds = _yearnVaultV2_freeFunds(vault); + if (totalSupply > 0) { + return Math.mulDiv(assetsOut, totalSupply, freeFunds, Math.Rounding.Up); + } + return 0; + } } diff --git a/src/interfaces/deps/yearn/veYFI/IYearnVaultV2.sol b/src/interfaces/deps/yearn/veYFI/IYearnVaultV2.sol index a991e4ab..18c13b19 100644 --- a/src/interfaces/deps/yearn/veYFI/IYearnVaultV2.sol +++ b/src/interfaces/deps/yearn/veYFI/IYearnVaultV2.sol @@ -8,4 +8,9 @@ interface IYearnVaultV2 { function deposit(uint256 amount) external returns (uint256 shares); function withdraw(uint256 shares, address recipient) external returns (uint256 amount); function pricePerShare() external view returns (uint256); + function totalSupply() external view returns (uint256); + function totalAssets() external view returns (uint256); + function lastReport() external view returns (uint256); + function lockedProfitDegradation() external view returns (uint256); + function lockedProfit() external view returns (uint256); } diff --git a/test/forked/Router.t.sol b/test/forked/Router.t.sol index d78448d2..b3ea006c 100644 --- a/test/forked/Router.t.sol +++ b/test/forked/Router.t.sol @@ -10,6 +10,7 @@ import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import { IPermit2 } from "permit2/interfaces/IPermit2.sol"; import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import { IStakeDaoGauge } from "src/interfaces/deps/stakeDAO/IStakeDaoGauge.sol"; +import { IYearn4626 } from "Yearn-ERC4626-Router/interfaces/IYearn4626.sol"; import { ERC20 } from "solmate/tokens/ERC20.sol"; contract Router_ForkedTest is BaseTest { @@ -215,8 +216,31 @@ contract Router_ForkedTest is BaseTest { } // ------------------ Preview tests ------------------ - function test_previewDeposits() public { - uint256 assetInAmount = 1 ether; + function testFuzz_previewDeposits(uint256 assetInAmount) public { + assetInAmount = bound(assetInAmount, 2, 100e18); + + // Snapshot the state before the deposit + uint256 beforeDeposit = vm.snapshot(); + + airdrop(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), address(router), assetInAmount, false); + router.approve(ERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), MAINNET_ETH_YFI_VAULT_V2, assetInAmount); + + // Deposit to the vault + uint256 expectedVaultSharesOut = + router.depositToVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), assetInAmount, address(router), 0); + assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), expectedVaultSharesOut); + router.approve(ERC20(MAINNET_ETH_YFI_VAULT_V2), MAINNET_ETH_YFI_GAUGE, expectedVaultSharesOut); + + // Deposit to the gauge + uint256 expectedGaugeSharesOut = + router.deposit(IYearn4626(MAINNET_ETH_YFI_GAUGE), expectedVaultSharesOut, user, 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(user), 0); + assertEq(IERC20(MAINNET_ETH_YFI_GAUGE).balanceOf(user), expectedGaugeSharesOut); + + // Revert to the snapshot before the deposit + vm.revertToAndDelete(beforeDeposit); + address[] memory path = new address[](3); path[0] = MAINNET_ETH_YFI_POOL_LP_TOKEN; path[1] = MAINNET_ETH_YFI_VAULT_V2; @@ -224,8 +248,8 @@ contract Router_ForkedTest is BaseTest { uint256[] memory sharesOut = router.previewDeposits(path, assetInAmount); assertEq(sharesOut.length, 2); - assertEq(sharesOut[0], 949_289_266_142_683_599); - assertEq(sharesOut[1], 949_289_266_142_683_599); + assertEq(sharesOut[0], expectedVaultSharesOut); + assertEq(sharesOut[1], expectedGaugeSharesOut); } function test_previewDeposits_v2Vault() public { @@ -279,8 +303,9 @@ contract Router_ForkedTest is BaseTest { router.previewDeposits(path, assetInAmount); } - function test_previewMints() public { - uint256 shareOutAmount = 949_289_266_142_683_599; + function testFuzz_previewMints(uint256 shareOutAmount) public { + shareOutAmount = bound(shareOutAmount, 1, 100e18); + address[] memory path = new address[](3); path[0] = MAINNET_ETH_YFI_POOL_LP_TOKEN; path[1] = MAINNET_ETH_YFI_VAULT_V2; @@ -288,8 +313,21 @@ contract Router_ForkedTest is BaseTest { uint256[] memory assetsIn = router.previewMints(path, shareOutAmount); assertEq(assetsIn.length, 2); - assertEq(assetsIn[0], 1 ether); - assertEq(assetsIn[1], 1 ether); + + airdrop(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), address(router), assetsIn[0], false); + // Deposit the returned assetIn amount to the vault + router.approve(ERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), MAINNET_ETH_YFI_VAULT_V2, assetsIn[0]); + uint256 actualVaultShareAmount = + router.depositToVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), assetsIn[0], address(router), 0); + assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), actualVaultShareAmount); + // Confirm the actual share amount is equal to the expected share amount + assertEq(assetsIn[1], actualVaultShareAmount); + // Deposit the return assetIn amount to the gauge + router.approve(ERC20(MAINNET_ETH_YFI_VAULT_V2), MAINNET_ETH_YFI_GAUGE, assetsIn[1]); + uint256 actualGaugeShareAmount = router.deposit(IYearn4626(MAINNET_ETH_YFI_GAUGE), assetsIn[1], user, 0); + // Confirm the final share amount is equal to the expected share amount + assertEq(actualGaugeShareAmount, shareOutAmount); } function test_previewMints_v2Vault() public { @@ -343,6 +381,31 @@ contract Router_ForkedTest is BaseTest { router.previewMints(path, shareOutAmount); } + function testFuzz_previewWithdraws(uint256 assetOutAmount) public { + assetOutAmount = bound(assetOutAmount, 1, IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2).totalAssets()); + + address[] memory path = new address[](3); + path[0] = MAINNET_ETH_YFI_GAUGE; + path[1] = MAINNET_ETH_YFI_VAULT_V2; + path[2] = MAINNET_ETH_YFI_POOL_LP_TOKEN; + + uint256[] memory sharesIn = router.previewWithdraws(path, assetOutAmount); + assertEq(sharesIn.length, 2); + + // Redeem the returned sharesIn amount of gauge tokens + airdrop(IERC20(MAINNET_ETH_YFI_GAUGE), address(router), sharesIn[0], false); + uint256 vaultShares = router.redeemFromRouter(IERC4626(MAINNET_ETH_YFI_GAUGE), sharesIn[0], address(router), 0); + assertEq(IERC20(MAINNET_ETH_YFI_GAUGE).balanceOf(address(router)), 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), vaultShares); + assertEq(sharesIn[1], vaultShares); + // Redeem the returned sharesIn amount of vault tokens + uint256 finalAssetOut = + router.redeemVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), vaultShares, address(router), 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), 0); + assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), finalAssetOut); + assertEq(finalAssetOut, assetOutAmount); + } + function test_previewWithdraws() public { uint256 assetOutAmount = 999_999_999_999_999_999; address[] memory path = new address[](3); @@ -418,8 +481,27 @@ contract Router_ForkedTest is BaseTest { router.previewWithdraws(path, assetOutAmount); } - function test_previewRedeems() public { - uint256 shareInAmount = 949_289_266_142_683_599; + function testFuzz_previewRedeems(uint256 shareInAmount) public { + shareInAmount = bound(shareInAmount, 1, IERC20(MAINNET_ETH_YFI_GAUGE).totalSupply()); + + // Snapshot the state before the redeem + uint256 beforeRedeem = vm.snapshot(); + + airdrop(IERC20(MAINNET_ETH_YFI_GAUGE), address(router), shareInAmount, false); + + // Redeem from the gauge + uint256 expectedVaultTokenOut = + router.redeemFromRouter(IERC4626(MAINNET_ETH_YFI_GAUGE), shareInAmount, address(router), 0); + assertEq(IERC20(MAINNET_ETH_YFI_GAUGE).balanceOf(address(router)), 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), expectedVaultTokenOut); + uint256 expectedLPTokenOut = + router.redeemVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), expectedVaultTokenOut, address(router), 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), 0); + assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), expectedLPTokenOut); + + // Revert to the snapshot before the redeem + vm.revertToAndDelete(beforeRedeem); + address[] memory path = new address[](3); path[0] = MAINNET_ETH_YFI_GAUGE; path[1] = MAINNET_ETH_YFI_VAULT_V2; @@ -427,8 +509,8 @@ contract Router_ForkedTest is BaseTest { (uint256[] memory assetsOut) = router.previewRedeems(path, shareInAmount); assertEq(assetsOut.length, 2); - assertEq(assetsOut[0], 949_289_266_142_683_599); - assertEq(assetsOut[1], 999_999_999_999_999_998); + assertEq(assetsOut[0], expectedVaultTokenOut); + assertEq(assetsOut[1], expectedLPTokenOut); } function test_previewRedeems_v2Vault() public { From c8cbf5673ac8ecf4d69962a330b391d08dedae4d Mon Sep 17 00:00:00 2001 From: Jongseung Lim Date: Thu, 28 Mar 2024 14:56:13 -0400 Subject: [PATCH 2/5] refactor: move the yearn vault v2 logic into a library --- src/Yearn4626RouterExt.sol | 94 ++------------------------ src/interfaces/IYearn4626RouterExt.sol | 10 --- src/libraries/YearnVaultV2Helper.sol | 65 ++++++++++++++++++ test/forked/Router.t.sol | 79 ++++++++++++++-------- 4 files changed, 123 insertions(+), 125 deletions(-) create mode 100644 src/libraries/YearnVaultV2Helper.sol diff --git a/src/Yearn4626RouterExt.sol b/src/Yearn4626RouterExt.sol index 60c13c00..c257a46c 100644 --- a/src/Yearn4626RouterExt.sol +++ b/src/Yearn4626RouterExt.sol @@ -7,8 +7,8 @@ import { IPermit2 } from "permit2/interfaces/IPermit2.sol"; import { ISignatureTransfer } from "permit2/interfaces/ISignatureTransfer.sol"; import { IWETH9 } from "Yearn-ERC4626-Router/external/PeripheryPayments.sol"; import { IYearn4626RouterExt } from "./interfaces/IYearn4626RouterExt.sol"; -import { IERC20Metadata, IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { YearnVaultV2Helper } from "./libraries/YearnVaultV2Helper.sol"; +import { SafeERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; @@ -25,6 +25,7 @@ import { IStakeDaoVault } from "./interfaces/deps/stakeDAO/IStakeDaoVault.sol"; */ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { using SafeERC20 for IERC20; + using YearnVaultV2Helper for IYearnVaultV2; // slither-disable-next-line naming-convention IPermit2 private immutable _PERMIT2; @@ -60,30 +61,6 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { // ------------- YEARN VAULT V2 FUNCTIONS ------------- // - /** - * @notice Deposits the specified `amount` of tokens into the Yearn Vault V2. - * @dev Calls the `deposit` function of the Yearn Vault V2 contract and checks if the returned shares are above the - * `minSharesOut`. - * Reverts with `InsufficientShares` if the condition is not met. - * @param vault The Yearn Vault V2 contract instance. - * @param amount The amount of tokens to deposit. - * @param to The address to which the shares will be minted. - * @param minSharesOut The minimum number of shares expected to be received. - * @return sharesOut The actual number of shares minted to the `to` address. - */ - function depositToVaultV2( - IYearnVaultV2 vault, - uint256 amount, - address to, - uint256 minSharesOut - ) - public - payable - returns (uint256 sharesOut) - { - if ((sharesOut = vault.deposit(amount, to)) < minSharesOut) revert InsufficientShares(); - } - /** * @notice Redeems the specified `shares` from the Yearn Vault V2. * @dev The shares must exist in this router before calling this function. @@ -230,7 +207,7 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { (success, data) = vault.staticcall(abi.encodeCall(IYearnVaultV2.token, ())); if (success) { vaultAsset = abi.decode(data, (address)); - sharesOut[i] = _yearnVaultV2_previewDeposit(IYearnVaultV2(vault), assetsIn); + sharesOut[i] = IYearnVaultV2(vault).previewDeposit(assetsIn); } else { revert PreviewNonVaultAddressInPath(vault); } @@ -284,7 +261,7 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { (success, data) = vault.staticcall(abi.encodeCall(IYearnVaultV2.token, ())); if (success) { vaultAsset = abi.decode(data, (address)); - assetsIn[i] = _yearnVaultV2_previewMint(IYearnVaultV2(vault), sharesOut); + assetsIn[i - 1] = IYearnVaultV2(vault).previewMint(sharesOut); } else { revert PreviewNonVaultAddressInPath(vault); } @@ -339,7 +316,7 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { (success, data) = vault.staticcall(abi.encodeCall(IYearnVaultV2.token, ())); if (success) { vaultAsset = abi.decode(data, (address)); - sharesIn[i] = _yearnVaultV2_previewWithdraw(IYearnVaultV2(vault), assetsOut); + sharesIn[i] = IYearnVaultV2(vault).previewWithdraw(assetsOut); } else { // StakeDAO gauge token // StakeDaoGauge.staking_token().token() is the yearn vault v2 token @@ -398,7 +375,7 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { (success, data) = vault.staticcall(abi.encodeCall(IYearnVaultV2.token, ())); if (success) { vaultAsset = abi.decode(data, (address)); - assetsOut[i] = _yearnVaultV2_previewRedeem(IYearnVaultV2(vault), sharesIn); + assetsOut[i] = IYearnVaultV2(vault).previewRedeem(sharesIn); } else { // StakeDAO gauge token // StakeDaoGauge.staking_token().token() is the yearn vault v2 token @@ -424,61 +401,4 @@ contract Yearn4626RouterExt is IYearn4626RouterExt, Yearn4626Router { } } // slither-disable-end calls-loop,low-level-calls - - /// @dev Yearn Vault V2 contract's calculate locked profit logic - /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L829-L842 - function _yearnVaultV2_calculateLockedProfit(IYearnVaultV2 vault) internal view returns (uint256) { - uint256 lockedProfit = vault.lockedProfit(); - uint256 lockedFundsRatio = (block.timestamp - vault.lastReport()) * vault.lockedProfitDegradation(); - if (lockedFundsRatio < 1e18) { - lockedProfit -= (lockedProfit * lockedFundsRatio) / 1e18; - } else { - lockedProfit = 0; - } - return lockedProfit; - } - - /// @dev Yearn Vault V2 contract's free funds calculation logic - /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L844-L847 - function _yearnVaultV2_freeFunds(IYearnVaultV2 vault) internal view returns (uint256) { - return vault.totalAssets() - _yearnVaultV2_calculateLockedProfit(vault); - } - - /// @dev Yearn Vault V2 contract's deposit() and _issueSharesForAmount() logic - /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L849-L872 - function _yearnVaultV2_previewDeposit(IYearnVaultV2 vault, uint256 assetsIn) internal view returns (uint256) { - uint256 totalSupply = vault.totalSupply(); - uint256 freeFunds = _yearnVaultV2_freeFunds(vault); - if (totalSupply > 0) { - return Math.mulDiv(assetsIn, totalSupply, freeFunds, Math.Rounding.Down); - } - return assetsIn; - } - - function _yearnVaultV2_previewMint(IYearnVaultV2 vault, uint256 sharesOut) internal view returns (uint256) { - uint256 totalSupply = vault.totalSupply(); - uint256 freeFunds = _yearnVaultV2_freeFunds(vault); - if (totalSupply > 0) { - return Math.mulDiv(sharesOut, freeFunds, totalSupply, Math.Rounding.Up); - } - return sharesOut; - } - - function _yearnVaultV2_previewRedeem(IYearnVaultV2 vault, uint256 sharesIn) internal view returns (uint256) { - uint256 totalSupply = vault.totalSupply(); - uint256 freeFunds = _yearnVaultV2_freeFunds(vault); - if (totalSupply > 0) { - return Math.mulDiv(sharesIn, freeFunds, totalSupply, Math.Rounding.Down); - } - return 0; - } - - function _yearnVaultV2_previewWithdraw(IYearnVaultV2 vault, uint256 assetsOut) internal view returns (uint256) { - uint256 totalSupply = vault.totalSupply(); - uint256 freeFunds = _yearnVaultV2_freeFunds(vault); - if (totalSupply > 0) { - return Math.mulDiv(assetsOut, totalSupply, freeFunds, Math.Rounding.Up); - } - return 0; - } } diff --git a/src/interfaces/IYearn4626RouterExt.sol b/src/interfaces/IYearn4626RouterExt.sol index c41a1bcb..d0cae29d 100644 --- a/src/interfaces/IYearn4626RouterExt.sol +++ b/src/interfaces/IYearn4626RouterExt.sol @@ -8,16 +8,6 @@ import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import { IStakeDaoGauge } from "./deps/stakeDAO/IStakeDaoGauge.sol"; interface IYearn4626RouterExt is IYearn4626Router { - function depositToVaultV2( - IYearnVaultV2 vault, - uint256 amount, - address to, - uint256 minSharesOut - ) - external - payable - returns (uint256 sharesOut); - function redeemVaultV2( IYearnVaultV2 vault, uint256 shares, diff --git a/src/libraries/YearnVaultV2Helper.sol b/src/libraries/YearnVaultV2Helper.sol new file mode 100644 index 00000000..e2fcb519 --- /dev/null +++ b/src/libraries/YearnVaultV2Helper.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.18; + +import { IYearnVaultV2 } from "src/interfaces/deps/yearn/veYFI/IYearnVaultV2.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @title YearnVaultV2Helper +/// @notice Helper functions for Yearn Vault V2 contracts. Since Yearn Vault V2 contracts are not ERC-4626 compliant, we +/// need to implement custom preview functions for deposit, mint, redeem, and withdraw. +library YearnVaultV2Helper { + /// @dev Yearn Vault V2 contract's calculate locked profit logic + /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L829-L842 + function calculateLockedProfit(IYearnVaultV2 vault) internal view returns (uint256) { + uint256 lockedProfit = vault.lockedProfit(); + uint256 lockedFundsRatio = (block.timestamp - vault.lastReport()) * vault.lockedProfitDegradation(); + if (lockedFundsRatio < 1e18) { + lockedProfit -= (lockedProfit * lockedFundsRatio) / 1e18; + } else { + lockedProfit = 0; + } + return lockedProfit; + } + + /// @dev Yearn Vault V2 contract's free funds calculation logic + /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L844-L847 + function freeFunds(IYearnVaultV2 vault) internal view returns (uint256) { + return vault.totalAssets() - calculateLockedProfit(vault); + } + + /// @dev Yearn Vault V2 contract's _issueSharesForAmount() logic + /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L849-L872 + function previewDeposit(IYearnVaultV2 vault, uint256 assetsIn) internal view returns (uint256) { + uint256 totalSupply = vault.totalSupply(); + if (totalSupply > 0) { + return Math.mulDiv(assetsIn, totalSupply, freeFunds(vault), Math.Rounding.Down); + } + return assetsIn; + } + + /// @dev Yearn Vault V2 contract's _issueSharesForAmount() logic + /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L849-L872 + function previewMint(IYearnVaultV2 vault, uint256 sharesOut) internal view returns (uint256) { + uint256 totalSupply = vault.totalSupply(); + if (totalSupply > 0) { + return Math.mulDiv(sharesOut, freeFunds(vault), totalSupply, Math.Rounding.Up); + } + return sharesOut; + } + + function previewRedeem(IYearnVaultV2 vault, uint256 sharesIn) internal view returns (uint256) { + uint256 totalSupply = vault.totalSupply(); + if (totalSupply > 0) { + return Math.mulDiv(sharesIn, freeFunds(vault), totalSupply, Math.Rounding.Down); + } + return 0; + } + + function previewWithdraw(IYearnVaultV2 vault, uint256 assetsOut) internal view returns (uint256) { + uint256 totalSupply = vault.totalSupply(); + if (totalSupply > 0) { + return Math.mulDiv(assetsOut, totalSupply, freeFunds(vault), Math.Rounding.Up); + } + return 0; + } +} diff --git a/test/forked/Router.t.sol b/test/forked/Router.t.sol index 12588ce7..ad66c875 100644 --- a/test/forked/Router.t.sol +++ b/test/forked/Router.t.sol @@ -14,8 +14,11 @@ import { IYearn4626 } from "Yearn-ERC4626-Router/interfaces/IYearn4626.sol"; import { ERC20 } from "solmate/tokens/ERC20.sol"; import { ERC4626Mock } from "@openzeppelin/contracts/mocks/ERC4626Mock.sol"; import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol"; +import { YearnVaultV2Helper } from "src/libraries/YearnVaultV2Helper.sol"; contract Router_ForkedTest is BaseTest { + using YearnVaultV2Helper for IYearnVaultV2; + Yearn4626RouterExt public router; event Log(string message); @@ -133,22 +136,22 @@ contract Router_ForkedTest is BaseTest { // ------------------ Yearn Vault V2 tests ------------------ - function test_depositToVaultV2() public { + function test_deposit_passWhen_WithYearnVaultV2() public { uint256 depositAmount = 1 ether; airdrop(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), address(router), depositAmount, false); router.approve(ERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), MAINNET_ETH_YFI_VAULT_V2, depositAmount); - router.depositToVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), depositAmount, user, 0); + router.deposit(IYearn4626(MAINNET_ETH_YFI_VAULT_V2), depositAmount, user, 0); assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(user), 0); assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(user)), 949_289_266_142_683_599); } - function test_depositToVaultV2_revertWhen_InsufficientShares() public { + function test_deposit_revertWhen_WithYearnVaultV2_MinShares() public { uint256 depositAmount = 1 ether; airdrop(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), address(router), depositAmount, false); router.approve(ERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), MAINNET_ETH_YFI_VAULT_V2, depositAmount); - vm.expectRevert(abi.encodeWithSelector(Yearn4626RouterExt.InsufficientShares.selector)); - router.depositToVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), depositAmount, address(user), 100 ether); + vm.expectRevert("!MinShares"); + router.deposit(IYearn4626(MAINNET_ETH_YFI_VAULT_V2), depositAmount, address(user), 100 ether); } function test_redeemVaultV2() public { @@ -245,7 +248,7 @@ contract Router_ForkedTest is BaseTest { // Deposit to the vault uint256 expectedVaultSharesOut = - router.depositToVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), assetInAmount, address(router), 0); + router.deposit(IYearn4626(MAINNET_ETH_YFI_VAULT_V2), assetInAmount, address(router), 0); assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), 0); assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), expectedVaultSharesOut); router.approve(ERC20(MAINNET_ETH_YFI_VAULT_V2), MAINNET_ETH_YFI_GAUGE, expectedVaultSharesOut); @@ -336,7 +339,7 @@ contract Router_ForkedTest is BaseTest { // Deposit the returned assetIn amount to the vault router.approve(ERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), MAINNET_ETH_YFI_VAULT_V2, assetsIn[0]); uint256 actualVaultShareAmount = - router.depositToVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), assetsIn[0], address(router), 0); + router.deposit(IYearn4626(MAINNET_ETH_YFI_VAULT_V2), assetsIn[0], address(router), 0); assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), 0); assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), actualVaultShareAmount); // Confirm the actual share amount is equal to the expected share amount @@ -440,9 +443,8 @@ contract Router_ForkedTest is BaseTest { router.previewMints(path, shareOutAmount); } - function testFuzz_previewWithdraws(uint256 assetOutAmount) public { - assetOutAmount = bound(assetOutAmount, 1, IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2).totalAssets()); - + function test_previewWithdraws() public { + uint256 assetOutAmount = 1e18; address[] memory path = new address[](3); path[0] = MAINNET_ETH_YFI_GAUGE; path[1] = MAINNET_ETH_YFI_VAULT_V2; @@ -450,32 +452,53 @@ contract Router_ForkedTest is BaseTest { uint256[] memory sharesIn = router.previewWithdraws(path, assetOutAmount); assertEq(sharesIn.length, 2); + assertEq(sharesIn[0], 949_289_266_142_683_600); + assertEq(sharesIn[1], 949_289_266_142_683_600); - // Redeem the returned sharesIn amount of gauge tokens + // Redeem shares and verify the preview result matches the actual result airdrop(IERC20(MAINNET_ETH_YFI_GAUGE), address(router), sharesIn[0], false); uint256 vaultShares = router.redeemFromRouter(IERC4626(MAINNET_ETH_YFI_GAUGE), sharesIn[0], address(router), 0); - assertEq(IERC20(MAINNET_ETH_YFI_GAUGE).balanceOf(address(router)), 0); - assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), vaultShares); - assertEq(sharesIn[1], vaultShares); - // Redeem the returned sharesIn amount of vault tokens - uint256 finalAssetOut = + assertEq(vaultShares, sharesIn[1]); + uint256 lpShares = router.redeemVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), vaultShares, address(router), 0); - assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), 0); - assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), finalAssetOut); - assertEq(finalAssetOut, assetOutAmount); + assertEq(lpShares, assetOutAmount); } - function test_previewWithdraws() public { - uint256 assetOutAmount = 999_999_999_999_999_999; + function testFuzz_previewWithdraws(uint256 assetOut) public { + // Bound the assetOut amount to the max asset out amount + // This is the total amount of the underlying LP token that can be withdrawn by the total supply amount of the + // gauge. + uint256 maxAssetOut = + IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2).previewRedeem(IERC4626(MAINNET_ETH_YFI_GAUGE).totalAssets()); + assertEq(maxAssetOut, 128_760_754_733_967_560_587); + assetOut = bound(assetOut, 1, maxAssetOut); + address[] memory path = new address[](3); path[0] = MAINNET_ETH_YFI_GAUGE; path[1] = MAINNET_ETH_YFI_VAULT_V2; path[2] = MAINNET_ETH_YFI_POOL_LP_TOKEN; - uint256[] memory sharesIn = router.previewWithdraws(path, assetOutAmount); + uint256[] memory sharesIn = router.previewWithdraws(path, assetOut); assertEq(sharesIn.length, 2); - assertEq(sharesIn[0], 949_289_266_142_683_600); - assertEq(sharesIn[1], 949_289_266_142_683_600); + + // Redeem the returned sharesIn amount of gauge tokens + airdrop(IERC20(MAINNET_ETH_YFI_GAUGE), address(router), sharesIn[0], false); + uint256 vaultShares = router.redeemFromRouter(IERC4626(MAINNET_ETH_YFI_GAUGE), sharesIn[0], address(router), 0); + assertEq(IERC20(MAINNET_ETH_YFI_GAUGE).balanceOf(address(router)), 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), vaultShares); + assertEq(sharesIn[1], vaultShares, "sharesIn[1] should be equal to vaultShares"); + // Redeem the returned sharesIn amount of vault tokens + uint256 actualAssetOut = + router.redeemVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), vaultShares, address(router), 0); + assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), 0); + assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), actualAssetOut); + // In some cases, the actual asset out you get back is greater than the previewed asset out + // due to the way Yearn Vault v2 calculates the share price. + // At most this is off by 1, in favor of the vault. + assertApproxEqAbs( + assetOut, actualAssetOut, 1, "finalAssetOut should be approximately equal to previewed assetOut" + ); + assertLe(assetOut, actualAssetOut, "finalAssetOut should be greater than or equal to previewed assetOut"); } function test_previewWithdraws_Multiple4626() public { @@ -529,7 +552,7 @@ contract Router_ForkedTest is BaseTest { uint256[] memory sharesOut = router.previewWithdraws(path, assetInAmount); assertEq(sharesOut.length, 1); - assertEq(sharesOut[0], 944_892); + assertEq(sharesOut[0], 944_891); } function test_previewWithdraws_StakeDAO() public { @@ -701,7 +724,7 @@ contract Router_ForkedTest is BaseTest { PeripheryPayments.approve.selector, MAINNET_ETH_YFI_POOL_LP_TOKEN, MAINNET_ETH_YFI_VAULT_V2, _MAX_UINT256 ); data[3] = abi.encodeWithSelector( - Yearn4626RouterExt.depositToVaultV2.selector, + Yearn4626RouterBase.deposit.selector, MAINNET_ETH_YFI_VAULT_V2, depositAmount, address(router), @@ -728,7 +751,7 @@ contract Router_ForkedTest is BaseTest { assertEq(ret[0], "", "SelfPermit should return empty bytes"); assertEq(ret[1], "", "pullToken should return empty bytes"); assertEq(ret[2], "", "approve should return empty bytes"); - assertEq(abi.decode(ret[3], (uint256)), 949_289_266_142_683_599, "depositToVaultV2 should return minted shares"); + assertEq(abi.decode(ret[3], (uint256)), 949_289_266_142_683_599, "deposit should return minted shares"); assertEq(ret[4], "", "approve should return empty bytes"); assertEq(abi.decode(ret[5], (uint256)), 949_289_266_142_683_599, "deposit should return minted shares"); @@ -819,7 +842,7 @@ contract Router_ForkedTest is BaseTest { PeripheryPayments.approve.selector, MAINNET_ETH_YFI_POOL_LP_TOKEN, MAINNET_ETH_YFI_VAULT_V2, _MAX_UINT256 ); data[3] = abi.encodeWithSelector( - Yearn4626RouterExt.depositToVaultV2.selector, + Yearn4626RouterBase.deposit.selector, MAINNET_ETH_YFI_VAULT_V2, depositAmount, address(router), From d1311e63ff5eb11a0f6db0e1e846119df9dd18b4 Mon Sep 17 00:00:00 2001 From: Jongseung Lim Date: Thu, 28 Mar 2024 15:02:51 -0400 Subject: [PATCH 3/5] test: remove vm.snapshot --- test/forked/Router.t.sol | 44 ++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/test/forked/Router.t.sol b/test/forked/Router.t.sol index ad66c875..e28e0276 100644 --- a/test/forked/Router.t.sol +++ b/test/forked/Router.t.sol @@ -240,8 +240,13 @@ contract Router_ForkedTest is BaseTest { function testFuzz_previewDeposits(uint256 assetInAmount) public { assetInAmount = bound(assetInAmount, 2, 100e18); - // Snapshot the state before the deposit - uint256 beforeDeposit = vm.snapshot(); + address[] memory path = new address[](3); + path[0] = MAINNET_ETH_YFI_POOL_LP_TOKEN; + path[1] = MAINNET_ETH_YFI_VAULT_V2; + path[2] = MAINNET_ETH_YFI_GAUGE; + + uint256[] memory sharesOut = router.previewDeposits(path, assetInAmount); + assertEq(sharesOut.length, 2); airdrop(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), address(router), assetInAmount, false); router.approve(ERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN), MAINNET_ETH_YFI_VAULT_V2, assetInAmount); @@ -259,16 +264,7 @@ contract Router_ForkedTest is BaseTest { assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(user), 0); assertEq(IERC20(MAINNET_ETH_YFI_GAUGE).balanceOf(user), expectedGaugeSharesOut); - // Revert to the snapshot before the deposit - vm.revertToAndDelete(beforeDeposit); - - address[] memory path = new address[](3); - path[0] = MAINNET_ETH_YFI_POOL_LP_TOKEN; - path[1] = MAINNET_ETH_YFI_VAULT_V2; - path[2] = MAINNET_ETH_YFI_GAUGE; - - uint256[] memory sharesOut = router.previewDeposits(path, assetInAmount); - assertEq(sharesOut.length, 2); + // Verify the previewed sharesOut amount matches the actual sharesOut amount assertEq(sharesOut[0], expectedVaultSharesOut); assertEq(sharesOut[1], expectedGaugeSharesOut); } @@ -609,31 +605,27 @@ contract Router_ForkedTest is BaseTest { function testFuzz_previewRedeems(uint256 shareInAmount) public { shareInAmount = bound(shareInAmount, 1, IERC20(MAINNET_ETH_YFI_GAUGE).totalSupply()); - // Snapshot the state before the redeem - uint256 beforeRedeem = vm.snapshot(); + address[] memory path = new address[](3); + path[0] = MAINNET_ETH_YFI_GAUGE; + path[1] = MAINNET_ETH_YFI_VAULT_V2; + path[2] = MAINNET_ETH_YFI_POOL_LP_TOKEN; - airdrop(IERC20(MAINNET_ETH_YFI_GAUGE), address(router), shareInAmount, false); + (uint256[] memory assetsOut) = router.previewRedeems(path, shareInAmount); + assertEq(assetsOut.length, 2); - // Redeem from the gauge + // Redeem gauge shares + airdrop(IERC20(MAINNET_ETH_YFI_GAUGE), address(router), shareInAmount, false); uint256 expectedVaultTokenOut = router.redeemFromRouter(IERC4626(MAINNET_ETH_YFI_GAUGE), shareInAmount, address(router), 0); assertEq(IERC20(MAINNET_ETH_YFI_GAUGE).balanceOf(address(router)), 0); assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), expectedVaultTokenOut); + // Redeem VaultV2 shares uint256 expectedLPTokenOut = router.redeemVaultV2(IYearnVaultV2(MAINNET_ETH_YFI_VAULT_V2), expectedVaultTokenOut, address(router), 0); assertEq(IERC20(MAINNET_ETH_YFI_VAULT_V2).balanceOf(address(router)), 0); assertEq(IERC20(MAINNET_ETH_YFI_POOL_LP_TOKEN).balanceOf(address(router)), expectedLPTokenOut); - // Revert to the snapshot before the redeem - vm.revertToAndDelete(beforeRedeem); - - address[] memory path = new address[](3); - path[0] = MAINNET_ETH_YFI_GAUGE; - path[1] = MAINNET_ETH_YFI_VAULT_V2; - path[2] = MAINNET_ETH_YFI_POOL_LP_TOKEN; - - (uint256[] memory assetsOut) = router.previewRedeems(path, shareInAmount); - assertEq(assetsOut.length, 2); + // Verify the previewed assetsOut amount matches the actual assetsOut amount assertEq(assetsOut[0], expectedVaultTokenOut); assertEq(assetsOut[1], expectedLPTokenOut); } From 30bfcd0d6a21eb586a09dac0aa325387239218b0 Mon Sep 17 00:00:00 2001 From: Jongseung Lim Date: Thu, 28 Mar 2024 15:45:51 -0400 Subject: [PATCH 4/5] fix: slither issues and docs cleanup --- src/libraries/YearnVaultV2Helper.sol | 73 ++++++++++++++++++++-------- test/forked/Router.t.sol | 6 ++- 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/libraries/YearnVaultV2Helper.sol b/src/libraries/YearnVaultV2Helper.sol index e2fcb519..b03362c8 100644 --- a/src/libraries/YearnVaultV2Helper.sol +++ b/src/libraries/YearnVaultV2Helper.sol @@ -4,31 +4,40 @@ pragma solidity 0.8.18; import { IYearnVaultV2 } from "src/interfaces/deps/yearn/veYFI/IYearnVaultV2.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; -/// @title YearnVaultV2Helper -/// @notice Helper functions for Yearn Vault V2 contracts. Since Yearn Vault V2 contracts are not ERC-4626 compliant, we -/// need to implement custom preview functions for deposit, mint, redeem, and withdraw. +/** + * @title YearnVaultV2Helper + * @notice Helper functions for Yearn Vault V2 contracts. Since Yearn Vault V2 contracts are not ERC-4626 compliant, + * they do not provide `previewDeposit`, `previewMint`, `previewRedeem`, and `previewWithdraw` functions. This library + * provides these functions for previewing share based deposit/mint/redeem/withdraw estimations. + * @dev These functions are only to be used off-chain for previewing. Due to how Yearn Vault V2 contracts work, + * share based withdraw/redeem estimations may not be accurate if the vault incurs a loss, thus share price changes. + */ library YearnVaultV2Helper { - /// @dev Yearn Vault V2 contract's calculate locked profit logic - /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L829-L842 - function calculateLockedProfit(IYearnVaultV2 vault) internal view returns (uint256) { + /** + * @notice Calculates the currently free funds in a Yearn Vault V2 contract. + * @param vault The Yearn Vault V2 contract. + * @return The free funds in the vault. + * @dev This is based on Yearn Vault V2 contract's free funds calculation logic. + * https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L844-L847 + */ + function freeFunds(IYearnVaultV2 vault) internal view returns (uint256) { uint256 lockedProfit = vault.lockedProfit(); uint256 lockedFundsRatio = (block.timestamp - vault.lastReport()) * vault.lockedProfitDegradation(); + // slither-disable-next-line timestamp if (lockedFundsRatio < 1e18) { lockedProfit -= (lockedProfit * lockedFundsRatio) / 1e18; } else { lockedProfit = 0; } - return lockedProfit; - } - - /// @dev Yearn Vault V2 contract's free funds calculation logic - /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L844-L847 - function freeFunds(IYearnVaultV2 vault) internal view returns (uint256) { - return vault.totalAssets() - calculateLockedProfit(vault); + return vault.totalAssets() - lockedProfit; } - /// @dev Yearn Vault V2 contract's _issueSharesForAmount() logic - /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L849-L872 + /** + * @notice Preview the amount of shares to be issued for a given deposit amount. + * @param vault The Yearn Vault V2 contract. + * @param assetsIn The amount of assets to be deposited. + * @return The number of shares that would be issued for the deposited assets. + */ function previewDeposit(IYearnVaultV2 vault, uint256 assetsIn) internal view returns (uint256) { uint256 totalSupply = vault.totalSupply(); if (totalSupply > 0) { @@ -37,8 +46,12 @@ library YearnVaultV2Helper { return assetsIn; } - /// @dev Yearn Vault V2 contract's _issueSharesForAmount() logic - /// https://github.com/yearn/yearn-vaults/blob/97ca1b2e4fcf20f4be0ff456dabd020bfeb6697b/contracts/Vault.vy#L849-L872 + /** + * @notice Preview the amount of assets required to mint a given amount of shares. + * @param vault The Yearn Vault V2 contract. + * @param sharesOut The number of shares to be minted. + * @return The amount of assets required to mint the specified number of shares. + */ function previewMint(IYearnVaultV2 vault, uint256 sharesOut) internal view returns (uint256) { uint256 totalSupply = vault.totalSupply(); if (totalSupply > 0) { @@ -47,18 +60,38 @@ library YearnVaultV2Helper { return sharesOut; } + /** + * @notice Preview the amount of assets to be received for redeeming a given amount of shares. + * @param vault The Yearn Vault V2 contract. + * @param sharesIn The number of shares to be redeemed. + * @return The amount of assets that would be received for the redeemed shares. + */ function previewRedeem(IYearnVaultV2 vault, uint256 sharesIn) internal view returns (uint256) { uint256 totalSupply = vault.totalSupply(); + if (sharesIn > totalSupply) { + return freeFunds(vault); + } if (totalSupply > 0) { return Math.mulDiv(sharesIn, freeFunds(vault), totalSupply, Math.Rounding.Down); } return 0; } + /** + * @notice Preview the number of shares to be redeemed for a given withdrawal amount of assets. + * @param vault The Yearn Vault V2 contract. + * @param assetsOut The amount of assets to be withdrawn. + * @return The number of shares that would be redeemed for the withdrawn assets. + */ function previewWithdraw(IYearnVaultV2 vault, uint256 assetsOut) internal view returns (uint256) { - uint256 totalSupply = vault.totalSupply(); - if (totalSupply > 0) { - return Math.mulDiv(assetsOut, totalSupply, freeFunds(vault), Math.Rounding.Up); + uint256 freeFunds_ = freeFunds(vault); + // slither-disable-next-line timestamp + if (assetsOut > freeFunds_) { + return vault.totalSupply(); + } + // slither-disable-next-line timestamp + if (freeFunds_ > 0) { + return Math.mulDiv(assetsOut, vault.totalSupply(), freeFunds(vault), Math.Rounding.Up); } return 0; } diff --git a/test/forked/Router.t.sol b/test/forked/Router.t.sol index e28e0276..61fc729d 100644 --- a/test/forked/Router.t.sol +++ b/test/forked/Router.t.sol @@ -238,7 +238,8 @@ contract Router_ForkedTest is BaseTest { // ------------------ Preview tests ------------------ function testFuzz_previewDeposits(uint256 assetInAmount) public { - assetInAmount = bound(assetInAmount, 2, 100e18); + // When a vault's pricePerShare > 1, depositing with asset amount of 1 will be reverted due to minting 0 shares. + assetInAmount = bound(assetInAmount, 2, 10_000e18); address[] memory path = new address[](3); path[0] = MAINNET_ETH_YFI_POOL_LP_TOKEN; @@ -321,7 +322,7 @@ contract Router_ForkedTest is BaseTest { } function testFuzz_previewMints(uint256 shareOutAmount) public { - shareOutAmount = bound(shareOutAmount, 1, 100e18); + shareOutAmount = bound(shareOutAmount, 1, 10_000e18); address[] memory path = new address[](3); path[0] = MAINNET_ETH_YFI_POOL_LP_TOKEN; @@ -603,6 +604,7 @@ contract Router_ForkedTest is BaseTest { } function testFuzz_previewRedeems(uint256 shareInAmount) public { + // Bound share in amount [1, totalSupply] shareInAmount = bound(shareInAmount, 1, IERC20(MAINNET_ETH_YFI_GAUGE).totalSupply()); address[] memory path = new address[](3); From 4f3fc7714648e03192493e5d3217dcc7dbcfe200 Mon Sep 17 00:00:00 2001 From: Jongseung Lim Date: Thu, 28 Mar 2024 16:16:38 -0400 Subject: [PATCH 5/5] ci: exclude library from coverage --- codecov.yml | 1 + src/libraries/YearnVaultV2Helper.sol | 2 ++ 2 files changed, 3 insertions(+) diff --git a/codecov.yml b/codecov.yml index d7934a4e..24d64fa3 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,3 +6,4 @@ ignore: - "src/deps/*" - "src/deps/**/*" - "src/interfaces/deps/**/*" + - "src/libraries/YearnVaultV2Helper.sol" diff --git a/src/libraries/YearnVaultV2Helper.sol b/src/libraries/YearnVaultV2Helper.sol index b03362c8..d9a0e17a 100644 --- a/src/libraries/YearnVaultV2Helper.sol +++ b/src/libraries/YearnVaultV2Helper.sol @@ -11,6 +11,8 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; * provides these functions for previewing share based deposit/mint/redeem/withdraw estimations. * @dev These functions are only to be used off-chain for previewing. Due to how Yearn Vault V2 contracts work, * share based withdraw/redeem estimations may not be accurate if the vault incurs a loss, thus share price changes. + * Coverage is currently disabled for this library due to forge limitations. TODO: Once the fix PR is merged, + * https://github.com/foundry-rs/foundry/pull/7510 coverage should be re-enabled. */ library YearnVaultV2Helper { /**