diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4e137e0..d6049ef 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -22,6 +22,8 @@ jobs: env: REPORT_GAS: "1" ALCHEMY_URL: ${{ secrets.ALCHEMY_URL }} + ALCHEMY_URL_MAINNET: ${{ secrets.ALCHEMY_URL_MAINNET }} - run: npx hardhat coverage env: ALCHEMY_URL: ${{ secrets.ALCHEMY_URL }} + ALCHEMY_URL_MAINNET: ${{ secrets.ALCHEMY_URL_MAINNET }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4d978e4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md + +## Dev Commands + +```bash +npx hardhat test # Run all tests +GAS_REPORT=true npx hardhat test # Run tests with gas report +npm run solhint # Lint Solidity +npm run prettier # Format Solidity/JS +npx hardhat compile # Compile contracts +npx hardhat coverage # Coverage report +npx hardhat size-contracts # Contract size check +``` + +## CI Order + +`compile -> size-contracts -> solhint -> test -> coverage` + +## Testing + +- Fork tests require `ALCHEMY_URL` or `INFURA_URL` env var (Polygon mainnet) +- Default fork block: 81382684 (override with `TEST_BLOCK` env var) + +## Architecture + +- **Vaults**: `AccessManagedMSV`, `OutflowLimitedAMMSV` inherit from `MSVBase` +- **Strategies**: Located in `contracts/strategies/` (AaveV3, CompoundV3, SwapStable, MerklRewards, etc.) +- **Strategy warning**: Each strategy's underlying asset must be unique; overlapping causes double-counting in `totalAssets()` + +## Publishing + +Package is published from `npm-package/` subdirectory (not root). diff --git a/README.md b/README.md index 622a5df..5f5a83c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,6 @@ execute in the context of the vault, managing the vault assets. Only trusted str The repositoy includes three MultiStrategyVault alternatives, all inheriting from MSVBase, difering on how they manage access control or other features: -- **MultiStrategyERC4626**: uses OZ's AccessControl contract for managing the permissions. - **AccessManagedMSV**: this one is intented to be deployed behing an AccessManagedProxy, a modified ERC1967 proxy that checks with an AccessManager (OZ 5.x) contract for each method called. The contract itself doesn't implement any access control policy. @@ -51,6 +50,13 @@ The current implemented strategies are: has a 1:1 equivalence with the asset. Useful for yield bearing assets like USDM or Lido ETH. - **SwapStableAaveV3InvestStrategy**: it swaps the asset and invests it into AAVE. Useful for equivalent assets that have different returns on AAVE like Bridged USDC vs Native USDC. +- **ERC4626InvestStrategy**: invest the funds received in an ERC4626-compliant vault. The `asset()` of the vault must + be the same as the one used by the MSV. +- **MorphoVaultV2InvestStrategy**: strategy that invests in Morpho V2 vaults. These vaults are not fully ERC-4626 + compatible. Uses cached `_totalAssets()` of the Morpho v2 vault, unless older than 1 day. +- **MerklRewardsInvestStrategy**: strategy that collects the Merkl Rewards and accounts them. Later, rewards can be + swapped and reinjected into the MSV as liquidity. Doesn't support deposits. Uses Chainlink oracles. +- **IdleInvestStrategy**: strategy that just keeps the funds liquid in `MSV.asset()` without generating any yield. **WARNING**: the underlying asset of each strategy should be different, and not overlap with other strategies' underlying assets, because this can produce double-counting in the totalAssets() method. Be careful of this when diff --git a/contracts/mock/VaultV2Mock.sol b/contracts/mock/VaultV2Mock.sol new file mode 100644 index 0000000..c492a38 --- /dev/null +++ b/contracts/mock/VaultV2Mock.sol @@ -0,0 +1,33 @@ +//SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {TestERC4626} from "@ensuro/utils/contracts/TestERC4626.sol"; + +contract VaultV2Mock is TestERC4626 { + uint128 public _totalAssets; + uint64 public lastUpdate; + + constructor(string memory name_, string memory symbol_, IERC20Metadata asset_) TestERC4626(name_, symbol_, asset_) {} + + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { + super._deposit(caller, receiver, assets, shares); + updateCachedTotalAssets(); + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual override { + super._withdraw(caller, receiver, owner, assets, shares); + updateCachedTotalAssets(); + } + + function updateCachedTotalAssets() public { + _totalAssets = uint128(totalAssets()); + lastUpdate = uint64(block.timestamp); + } +} diff --git a/contracts/strategies/ERC4626InvestStrategy.sol b/contracts/strategies/ERC4626InvestStrategy.sol index b69e915..a471d92 100644 --- a/contracts/strategies/ERC4626InvestStrategy.sol +++ b/contracts/strategies/ERC4626InvestStrategy.sol @@ -7,7 +7,7 @@ import {IInvestStrategy} from "../interfaces/IInvestStrategy.sol"; import {InvestStrategyClient} from "../InvestStrategyClient.sol"; /** - * @title AaveV3InvestStrategy + * @title ERC4626InvestStrategy * * @dev Strategy that invests/deinvests into a 4626 vault * diff --git a/contracts/strategies/MorphoVaultV2InvestStrategy.sol b/contracts/strategies/MorphoVaultV2InvestStrategy.sol new file mode 100644 index 0000000..f482fbd --- /dev/null +++ b/contracts/strategies/MorphoVaultV2InvestStrategy.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IInvestStrategy} from "../interfaces/IInvestStrategy.sol"; +import {ERC4626InvestStrategy} from "./ERC4626InvestStrategy.sol"; + +/** + * @title IVaultV2 + * + * @notice Interface of VaultV2 Morpho contracts, only relevant methods. + * + * @dev See https://github.com/morpho-org/vault-v2/blob/main/src/interfaces/IVaultV2.sol + */ +interface IVaultV2 is IERC4626 { + function _totalAssets() external view returns (uint128); + function lastUpdate() external view returns (uint64); +} + +/** + * @title MorphoVaultV2InvestStrategy + * + * @dev Strategy that invests/deinvests into a MorphoV2 vault + * See https://github.com/morpho-org/vault-v2/blob/main/src/VaultV2.sol + * + * @custom:security-contact security@ensuro.co + * @author Ensuro + */ +contract MorphoVaultV2InvestStrategy is ERC4626InvestStrategy { + using Math for uint256; + uint64 private constant CACHED_TOTAL_ASSETS_MAX_AGE = 1 days; + + constructor(IERC4626 vault_) ERC4626InvestStrategy(vault_) {} + + /// @inheritdoc IInvestStrategy + function maxWithdraw(address contract_) public view virtual override returns (uint256) { + return totalAssets(contract_); + } + + /// @inheritdoc IInvestStrategy + function maxDeposit(address) public view virtual override returns (uint256) { + return type(uint256).max; + } + + /// @inheritdoc IInvestStrategy + function totalAssets(address contract_) public view virtual override returns (uint256 assets) { + uint256 shares = _vault.balanceOf(contract_); + if (shares == 0) return 0; + IVaultV2 vaultV2 = IVaultV2(address(_vault)); + uint64 lastUpdate = vaultV2.lastUpdate(); + if (lastUpdate + CACHED_TOTAL_ASSETS_MAX_AGE < block.timestamp) { + // Vault "cached" _totalAssets is too old, normal implementation + return vaultV2.previewRedeem(shares); + } else { + uint256 totAssets = uint256(vaultV2._totalAssets()); + uint256 totShares = vaultV2.totalSupply(); + return totAssets.mulDiv(shares, totShares); + } + } +} diff --git a/test/test-integration-mainnet-fork.js b/test/test-integration-mainnet-fork.js new file mode 100644 index 0000000..1690371 --- /dev/null +++ b/test/test-integration-mainnet-fork.js @@ -0,0 +1,118 @@ +const { expect } = require("chai"); +const { amountFunction, _W } = require("@ensuro/utils/js/utils"); +const { initForkCurrency, setupChain } = require("@ensuro/utils/js/test-utils"); +const { buildUniswapConfig } = require("@ensuro/swaplibrary/js/utils"); +const { encodeSwapConfig, makeAllPublic } = require("./utils"); +const { deployAMPProxy } = require("@ensuro/access-managed-proxy/js/deployProxy"); +const hre = require("hardhat"); +const helpers = require("@nomicfoundation/hardhat-network-helpers"); + +const { ethers } = hre; +const { MaxUint256 } = hre.ethers; + +const ADDRESSES = { + USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + USDCWhale: "0x05ff6964D21e5dAE3b1010D5AE0465b3c450F381", + AAVEv3: "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2", + aUSDCv3: "0x625E7708f30cA75bfd92586e17077590C60eb4cD", + MORPHO_SKY_V2_VAULT: "0x56bfa6f53669B836D1E0Dfa5e99706b12c373ecf", +}; + +const CURRENCY_DECIMALS = 6; +const _A = amountFunction(CURRENCY_DECIMALS); +const TEST_BLOCK = 24878000; +const CENT = _A("0.01"); +const HOUR = 3600; +const DAY = HOUR * 24; +const MONTH = DAY * 30; +const INITIAL = 10000; +const NAME = "Ensuro MultiStrategy"; +const SYMB = "USDCmulti"; + +async function setUp() { + const [, lp, lp2, anon, guardian, admin] = await ethers.getSigners(); + const currency = await initForkCurrency(ADDRESSES.USDC, ADDRESSES.USDCWhale, [lp, lp2], [_A(INITIAL), _A(INITIAL)]); + + const adminAddr = await ethers.resolveAddress(admin); + + const AaveV3InvestStrategy = await ethers.getContractFactory("AaveV3InvestStrategy"); + const aaveStrategy = await AaveV3InvestStrategy.deploy(ADDRESSES.USDC, ADDRESSES.AAVEv3); + + const MorphoVaultV2InvestStrategy = await ethers.getContractFactory("MorphoVaultV2InvestStrategy"); + const morphoStrategy = await MorphoVaultV2InvestStrategy.deploy(ADDRESSES.MORPHO_SKY_V2_VAULT); + + const AccessManagedMSV = await ethers.getContractFactory("AccessManagedMSV"); + const AccessManager = await ethers.getContractFactory("AccessManager"); + const acMgr = await AccessManager.deploy(admin); + const vault = await deployAMPProxy( + AccessManagedMSV, + [ + NAME, + SYMB, + await ethers.resolveAddress(currency), + await Promise.all([aaveStrategy, morphoStrategy].map(ethers.resolveAddress)), + [ethers.toUtf8Bytes(""), ethers.toUtf8Bytes("")], + [0, 1], + [0, 1], + ], + { + kind: "uups", + unsafeAllow: ["delegatecall"], + acMgr, + skipViewsAndPure: true, + } + ); + await makeAllPublic(vault, acMgr.connect(admin)); + await currency.connect(lp).approve(vault, MaxUint256); + await currency.connect(lp2).approve(vault, MaxUint256); + + return { + currency, + adminAddr, + lp, + lp2, + anon, + guardian, + admin, + AaveV3InvestStrategy, + MorphoVaultV2InvestStrategy, + AccessManagedMSV, + aaveStrategy, + morphoStrategy, + vault, + acMgr, + }; +} + +describe("MultiStrategy Mainnet Fork Integration Tests", function () { + before(async function () { + await setupChain(TEST_BLOCK, "ALCHEMY_URL_MAINNET"); + }); + + it("Can perform a basic smoke test", async function () { + const { vault, currency, lp, lp2, admin, aaveStrategy, morphoStrategy, acMgr } = await helpers.loadFixture(setUp); + expect(await vault.name()).to.equal(NAME); + await vault.connect(lp).deposit(_A(5000), lp); + await vault.connect(lp2).deposit(_A(7000), lp2); + + expect(await vault.totalAssets()).to.be.closeTo(_A(12000), CENT); + + await vault.connect(admin).rebalance(0, 1, _A(7000)); + + expect(await aaveStrategy.totalAssets(vault)).to.closeTo(_A(5000), CENT); + expect(await morphoStrategy.totalAssets(vault)).to.closeTo(_A(7000), CENT); + + await helpers.time.increase(MONTH); + expect(await aaveStrategy.totalAssets(vault)).to.closeTo(_A("5009.419771"), CENT); + expect(await morphoStrategy.totalAssets(vault)).to.be.gt(_A(7000)); + expect(await vault.totalAssets()).to.be.gt(_A(12000)); + + // Withdraw all the funds + await vault.connect(lp).redeem(_A(5000), lp, lp); + await vault.connect(lp2).redeem(await vault.balanceOf(lp2), lp2, lp2); + expect(await vault.totalAssets()).to.be.closeTo(_A("0"), CENT); + + expect(await currency.balanceOf(lp)).to.be.gt(_A(INITIAL)); + expect(await currency.balanceOf(lp2)).to.be.gt(_A(INITIAL)); + }); +}); diff --git a/test/test-morpho-vault-v2-invest-strategy.js b/test/test-morpho-vault-v2-invest-strategy.js new file mode 100644 index 0000000..4f452b1 --- /dev/null +++ b/test/test-morpho-vault-v2-invest-strategy.js @@ -0,0 +1,271 @@ +const { expect } = require("chai"); +const { amountFunction } = require("@ensuro/utils/js/utils"); +const { encodeDummyStorage } = require("./utils"); +const { initCurrency } = require("@ensuro/utils/js/test-utils"); +const hre = require("hardhat"); +const helpers = require("@nomicfoundation/hardhat-network-helpers"); + +const { ethers } = hre; +const { MaxUint256 } = hre.ethers; + +const CURRENCY_DECIMALS = 6; +const _A = amountFunction(CURRENCY_DECIMALS); +const INITIAL = 10000; +const NAME = "Single Strategy Vault"; +const SYMB = "SSV"; + +const CENT = _A("0.01"); +const MCENT = CENT / 1000n; + +const ONE_DAY_SECONDS = 86400; + +async function setUp() { + const [, lp, lp2, anon, guardian, admin] = await ethers.getSigners(); + + const USDC = await initCurrency( + { + name: "Test Currency with 6 decimals", + symbol: "USDC", + decimals: 6, + initial_supply: _A(50000), + }, + [lp, lp2], + [_A(INITIAL), _A(INITIAL)] + ); + + const adminAddr = await ethers.resolveAddress(admin); + const DummyInvestStrategy = await ethers.getContractFactory("DummyInvestStrategy"); + const MorphoVaultV2InvestStrategy = await ethers.getContractFactory("MorphoVaultV2InvestStrategy"); + const SingleStrategyERC4626 = await ethers.getContractFactory("SingleStrategyERC4626"); + const VaultV2Mock = await ethers.getContractFactory("VaultV2Mock"); + const investVault = await VaultV2Mock.deploy("Morpho Vault V2", "MV2", USDC); + + async function setupVault(asset, strategy, strategyData = ethers.toUtf8Bytes("")) { + const vault = await hre.upgrades.deployProxy( + SingleStrategyERC4626, + [NAME, SYMB, await ethers.resolveAddress(asset), await ethers.resolveAddress(strategy), strategyData], + { + kind: "uups", + unsafeAllow: ["delegatecall"], + } + ); + await asset.connect(lp).approve(vault, MaxUint256); + await asset.connect(lp2).approve(vault, MaxUint256); + return vault; + } + + return { + USDC, + SingleStrategyERC4626, + MorphoVaultV2InvestStrategy, + DummyInvestStrategy, + adminAddr, + lp, + lp2, + anon, + guardian, + admin, + investVault, + setupVault, + }; +} + +async function setUpCommon() { + const ret = await setUp(); + const strategy = await ret.MorphoVaultV2InvestStrategy.deploy(ret.investVault); + const vault = await ret.setupVault(ret.USDC, strategy); + return { ...ret, vault, strategy }; +} + +describe("MorphoVaultV2InvestStrategy contract tests", function () { + it("Initializes the vault correctly", async () => { + const { USDC, investVault, vault, strategy } = await helpers.loadFixture(setUpCommon); + expect(await vault.name()).to.equal(NAME); + expect(await vault.symbol()).to.equal(SYMB); + expect(await vault.strategy()).to.equal(strategy); + expect(await vault.asset()).to.equal(USDC); + expect(await vault.totalAssets()).to.equal(0); + expect(await strategy.asset(vault)).to.equal(USDC); + expect(await strategy.investVault(vault)).to.equal(investVault); + }); + + it("Deposit and accounting works", async () => { + const { USDC, investVault, vault, lp, strategy } = await helpers.loadFixture(setUpCommon); + await vault.connect(lp).deposit(_A(100), lp); + expect(await vault.totalAssets()).to.equal(_A(100)); + expect(await investVault.convertToAssets(await investVault.balanceOf(vault))).to.equal(_A(100)); + + expect(await USDC.allowance(vault, investVault)).to.equal(0); + + await investVault.discreteEarning(_A(40)); + + // Profits not recorded until cached totalAssets updated + expect(await strategy.totalAssets(vault)).to.closeTo(_A(100), MCENT); + await investVault.updateCachedTotalAssets(); + expect(await strategy.totalAssets(vault)).to.closeTo(_A(140), MCENT); + + await investVault.discreteEarning(-_A(50)); + + // Or... when cached totalAssets is too old + expect(await strategy.totalAssets(vault)).to.closeTo(_A(140), MCENT); + await helpers.time.increase(ONE_DAY_SECONDS + 1); + expect(await strategy.totalAssets(vault)).to.closeTo(_A(90), MCENT); + }); + + it("Withdraws and reduces the assets", async () => { + const { USDC, investVault, vault, lp, strategy } = await helpers.loadFixture(setUpCommon); + await vault.connect(lp).deposit(_A(100), lp); + expect(await strategy.totalAssets(vault)).to.closeTo(_A(100), MCENT); + + await vault.connect(lp).withdraw(_A(80), lp, lp); + expect(await strategy.totalAssets(vault)).to.closeTo(_A(20), MCENT); + + await investVault.discreteEarning(_A(40)); + expect(await strategy.totalAssets(vault)).to.closeTo(_A(20), MCENT); + + await investVault.updateCachedTotalAssets(); + expect(await strategy.totalAssets(vault)).to.closeTo(_A(60), MCENT); + + await vault.connect(lp).redeem(_A(20), lp, lp); + expect(await USDC.balanceOf(lp)).to.closeTo(_A(INITIAL + 40), MCENT); + }); + + it("Checks maxWithdraw returns totalAssets and maxDeposit returns type(uint256).max", async () => { + const { vault, lp, strategy } = await helpers.loadFixture(setUpCommon); + + await vault.connect(lp).deposit(_A(100), lp); + expect(await strategy.maxDeposit(vault)).to.equal(MaxUint256); + expect(await strategy.maxWithdraw(vault)).to.equal(_A(100)); + }); + + it("Checks methods can't be called directly", async () => { + const { strategy } = await helpers.loadFixture(setUpCommon); + + await expect(strategy.getFunction("connect")(ethers.toUtf8Bytes(""))).to.be.revertedWithCustomError( + strategy, + "CanBeCalledOnlyThroughDelegateCall" + ); + + await expect(strategy.disconnect(false)).to.be.revertedWithCustomError( + strategy, + "CanBeCalledOnlyThroughDelegateCall" + ); + + await expect(strategy.deposit(123)).to.be.revertedWithCustomError(strategy, "CanBeCalledOnlyThroughDelegateCall"); + + await expect(strategy.withdraw(123)).to.be.revertedWithCustomError(strategy, "CanBeCalledOnlyThroughDelegateCall"); + + await expect(strategy.forwardEntryPoint(1, ethers.toUtf8Bytes(""))).to.be.revertedWithCustomError( + strategy, + "CanBeCalledOnlyThroughDelegateCall" + ); + }); + + it("Checks forwardToStrategy fails with any input", async () => { + const { vault } = await helpers.loadFixture(setUpCommon); + await expect(vault.forwardToStrategy(123, ethers.toUtf8Bytes(""))).to.be.reverted; + }); + + it("Verifies an investVault with a different asset doesn't work", async () => { + const { setupVault, investVault, SingleStrategyERC4626, MorphoVaultV2InvestStrategy } = + await helpers.loadFixture(setUp); + const strategy = await MorphoVaultV2InvestStrategy.deploy(investVault); + const EURC = await initCurrency( + { + name: "Euro", + symbol: "EURC", + decimals: 6, + initial_supply: _A(50000), + }, + [], + [] + ); + await expect(setupVault(EURC, strategy)).to.be.revertedWithCustomError( + SingleStrategyERC4626, + "InvalidStrategyAsset" + ); + }); + + it("Verifies connect doesn't accept extra data", async () => { + const { setupVault, investVault, USDC, MorphoVaultV2InvestStrategy } = await helpers.loadFixture(setUp); + const strategy = await MorphoVaultV2InvestStrategy.deploy(investVault); + await expect(setupVault(USDC, strategy, ethers.toUtf8Bytes("foobar"))).to.be.revertedWithCustomError( + strategy, + "NoExtraDataAllowed" + ); + }); + + it("Checks the strategy can't be disconnected with assets unless forced", async () => { + const { USDC, investVault, vault, lp, DummyInvestStrategy, admin, strategy } = + await helpers.loadFixture(setUpCommon); + await vault.connect(lp).deposit(_A(100), lp); + + const dummy = await DummyInvestStrategy.deploy(USDC); + + expect(await investVault.totalAssets()).to.equal(_A(100)); + expect(await strategy.totalAssets(vault)).to.equal(_A(100)); + + await vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), false); + + expect(await strategy.totalAssets(vault)).to.equal(_A(0)); + expect(await vault.totalAssets()).to.equal(_A(100)); + + await vault.connect(admin).setStrategy(strategy, ethers.toUtf8Bytes(""), false); + + await investVault.setBroken(true); + + await expect(vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), false)).to.be.revertedWithCustomError( + investVault, + "VaultIsBroken" + ); + await expect(vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), true)).not.to.be.reverted; + }); + + it("Checks the strategy can't be disconnected with SHARES in the investVault unless forced", async () => { + const { USDC, investVault, vault, lp, DummyInvestStrategy, admin, strategy } = + await helpers.loadFixture(setUpCommon); + await vault.connect(lp).deposit(_A(100), lp); + + const dummy = await DummyInvestStrategy.deploy(USDC); + + await investVault.discreteEarning(-_A(100)); + expect(await investVault.totalAssets()).to.closeTo(_A(0), MCENT); + + expect(await strategy.totalAssets(vault)).to.closeTo(_A(100), MCENT); + + await expect(vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), false)).to.be.reverted; + + await expect(vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), true)).not.to.be.reverted; + }); + + it("Uses cached _totalAssets when lastUpdate is recent", async () => { + const { investVault, vault, strategy, lp } = await helpers.loadFixture(setUpCommon); + + await vault.connect(lp).deposit(_A(100), lp); + + const shares = await investVault.balanceOf(vault); + const cachedTotalAssets = await investVault._totalAssets(); + const totalSupply = await investVault.totalSupply(); + + const expectedFromCached = (cachedTotalAssets * shares) / totalSupply; + const actualTotalAssets = await strategy.totalAssets(vault); + await investVault.discreteEarning(_A(1)); + + expect(actualTotalAssets).to.equal(expectedFromCached); + }); + + it("Falls back to previewRedeem when cached is stale (> 1 day)", async () => { + const { investVault, vault, strategy, lp } = await helpers.loadFixture(setUpCommon); + + await vault.connect(lp).deposit(_A(100), lp); + await investVault.updateCachedTotalAssets(); + + await helpers.time.increase(ONE_DAY_SECONDS + 1); + + const shares = await investVault.balanceOf(vault); + const expectedFromPreview = await investVault.previewRedeem(shares); + const actualTotalAssets = await strategy.totalAssets(vault); + + expect(actualTotalAssets).to.equal(expectedFromPreview); + }); +});