From 62b3c6f1f04c542bceac333404cc9ef9c49af4ae Mon Sep 17 00:00:00 2001 From: "Guillermo M. Narvaja" Date: Tue, 14 Apr 2026 11:14:33 -0300 Subject: [PATCH 1/3] New Stratetegy MorphoV2 Vault New strategy tailored for Morpho V2 Vaults, that don't comply with ERC-4626 and return 0 in all the max{Deposit,Mint,Redeem,Withdraw} methods. It also uses exposed cached `_totalAssets()` is not older than one day, to save gas. --- AGENTS.md | 32 +++ README.md | 8 +- contracts/mock/VaultV2Mock.sol | 33 +++ .../strategies/ERC4626InvestStrategy.sol | 2 +- .../MorphoVaultV2InvestStrategy.sol | 119 ++++++++ test/test-morpho-vault-v2-invest-strategy.js | 270 ++++++++++++++++++ 6 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 100644 contracts/mock/VaultV2Mock.sol create mode 100644 contracts/strategies/MorphoVaultV2InvestStrategy.sol create mode 100644 test/test-morpho-vault-v2-invest-strategy.js 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..2dd499c --- /dev/null +++ b/contracts/strategies/MorphoVaultV2InvestStrategy.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IInvestStrategy} from "../interfaces/IInvestStrategy.sol"; +import {InvestStrategyClient} from "../InvestStrategyClient.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 IInvestStrategy { + using Math for uint256; + address private immutable __self = address(this); + bytes32 public immutable storageSlot = InvestStrategyClient.makeStorageSlot(this); + uint64 private constant CACHED_TOTAL_ASSETS_MAX_AGE = 1 days; + + IVaultV2 internal immutable _vault; + IERC20 internal immutable _asset; + + error CanBeCalledOnlyThroughDelegateCall(); + error CannotDisconnectWithAssets(); + error NoExtraDataAllowed(); + + modifier onlyDelegCall() { + if (address(this) == __self) revert CanBeCalledOnlyThroughDelegateCall(); + _; + } + + constructor(IVaultV2 vault_) { + _vault = vault_; + _asset = IERC20(vault_.asset()); + } + + /// @inheritdoc IInvestStrategy + function connect(bytes memory initData) external virtual override onlyDelegCall { + if (initData.length != 0) revert NoExtraDataAllowed(); + } + + /// @inheritdoc IInvestStrategy + function disconnect(bool force) external virtual override onlyDelegCall { + // Here I check _vault.balanceOf() instead of totalAssets(). In an extreme cases, when the vault lost all its + // value these can differ, but on those cases I think it's safer to block the disconnection unless forced + if (!force && _vault.balanceOf(address(this)) != 0) revert CannotDisconnectWithAssets(); + } + + /// @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 asset(address) public view virtual override returns (address) { + return address(_asset); + } + + /** + * @dev Returns the ERC4626 where this strategy invests the funds + */ + function investVault() public view returns (IERC4626) { + return _vault; + } + + /// @inheritdoc IInvestStrategy + function totalAssets(address contract_) public view virtual override returns (uint256 assets) { + uint256 shares = _vault.balanceOf(contract_); + if (shares == 0) return 0; + uint64 lastUpdate = _vault.lastUpdate(); + if (lastUpdate + CACHED_TOTAL_ASSETS_MAX_AGE < block.timestamp) { + // Vault "cached" _totalAssets is too old, normal implementation + return _vault.previewRedeem(shares); + } else { + uint256 totAssets = uint256(_vault._totalAssets()); + uint256 totShares = _vault.totalSupply(); + return totAssets.mulDiv(shares, totShares); + } + } + + /// @inheritdoc IInvestStrategy + function withdraw(uint256 assets) external virtual override onlyDelegCall { + _vault.withdraw(assets, address(this), address(this)); + } + + /// @inheritdoc IInvestStrategy + function deposit(uint256 assets) external virtual override onlyDelegCall { + _asset.approve(address(_vault), assets); + _vault.deposit(assets, address(this)); + } + + /// @inheritdoc IInvestStrategy + function forwardEntryPoint(uint8, bytes memory) external view onlyDelegCall returns (bytes memory) { + // solhint-disable-next-line gas-custom-errors,reason-string + revert(); + } +} 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..ff197a9 --- /dev/null +++ b/test/test-morpho-vault-v2-invest-strategy.js @@ -0,0 +1,270 @@ +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)); + 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)); + expect(await strategy.totalAssets(vault)).to.closeTo(_A(140), MCENT); + + await investVault.updateCachedTotalAssets(); + 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.updateCachedTotalAssets(); + 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.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); + await investVault.updateCachedTotalAssets(); + + 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); + + 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); + }); +}); From fc018d8aced1a4614e7771e3bdc34e0b9ff2f6fc Mon Sep 17 00:00:00 2001 From: "Guillermo M. Narvaja" Date: Tue, 14 Apr 2026 13:24:44 -0300 Subject: [PATCH 2/3] Fix broken test + MorphoVaultV2InvestStrategy inherit ERC4626 strategy --- .github/workflows/tests.yaml | 2 + .../MorphoVaultV2InvestStrategy.sol | 74 ++--------- test/test-integration-mainnet-fork.js | 118 ++++++++++++++++++ test/test-morpho-vault-v2-invest-strategy.js | 2 +- 4 files changed, 129 insertions(+), 67 deletions(-) create mode 100644 test/test-integration-mainnet-fork.js 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/contracts/strategies/MorphoVaultV2InvestStrategy.sol b/contracts/strategies/MorphoVaultV2InvestStrategy.sol index 2dd499c..f482fbd 100644 --- a/contracts/strategies/MorphoVaultV2InvestStrategy.sol +++ b/contracts/strategies/MorphoVaultV2InvestStrategy.sol @@ -1,11 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.0; -import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IInvestStrategy} from "../interfaces/IInvestStrategy.sol"; -import {InvestStrategyClient} from "../InvestStrategyClient.sol"; +import {ERC4626InvestStrategy} from "./ERC4626InvestStrategy.sol"; /** * @title IVaultV2 @@ -28,40 +27,11 @@ interface IVaultV2 is IERC4626 { * @custom:security-contact security@ensuro.co * @author Ensuro */ -contract MorphoVaultV2InvestStrategy is IInvestStrategy { +contract MorphoVaultV2InvestStrategy is ERC4626InvestStrategy { using Math for uint256; - address private immutable __self = address(this); - bytes32 public immutable storageSlot = InvestStrategyClient.makeStorageSlot(this); uint64 private constant CACHED_TOTAL_ASSETS_MAX_AGE = 1 days; - IVaultV2 internal immutable _vault; - IERC20 internal immutable _asset; - - error CanBeCalledOnlyThroughDelegateCall(); - error CannotDisconnectWithAssets(); - error NoExtraDataAllowed(); - - modifier onlyDelegCall() { - if (address(this) == __self) revert CanBeCalledOnlyThroughDelegateCall(); - _; - } - - constructor(IVaultV2 vault_) { - _vault = vault_; - _asset = IERC20(vault_.asset()); - } - - /// @inheritdoc IInvestStrategy - function connect(bytes memory initData) external virtual override onlyDelegCall { - if (initData.length != 0) revert NoExtraDataAllowed(); - } - - /// @inheritdoc IInvestStrategy - function disconnect(bool force) external virtual override onlyDelegCall { - // Here I check _vault.balanceOf() instead of totalAssets(). In an extreme cases, when the vault lost all its - // value these can differ, but on those cases I think it's safer to block the disconnection unless forced - if (!force && _vault.balanceOf(address(this)) != 0) revert CannotDisconnectWithAssets(); - } + constructor(IERC4626 vault_) ERC4626InvestStrategy(vault_) {} /// @inheritdoc IInvestStrategy function maxWithdraw(address contract_) public view virtual override returns (uint256) { @@ -73,47 +43,19 @@ contract MorphoVaultV2InvestStrategy is IInvestStrategy { return type(uint256).max; } - /// @inheritdoc IInvestStrategy - function asset(address) public view virtual override returns (address) { - return address(_asset); - } - - /** - * @dev Returns the ERC4626 where this strategy invests the funds - */ - function investVault() public view returns (IERC4626) { - return _vault; - } - /// @inheritdoc IInvestStrategy function totalAssets(address contract_) public view virtual override returns (uint256 assets) { uint256 shares = _vault.balanceOf(contract_); if (shares == 0) return 0; - uint64 lastUpdate = _vault.lastUpdate(); + 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 _vault.previewRedeem(shares); + return vaultV2.previewRedeem(shares); } else { - uint256 totAssets = uint256(_vault._totalAssets()); - uint256 totShares = _vault.totalSupply(); + uint256 totAssets = uint256(vaultV2._totalAssets()); + uint256 totShares = vaultV2.totalSupply(); return totAssets.mulDiv(shares, totShares); } } - - /// @inheritdoc IInvestStrategy - function withdraw(uint256 assets) external virtual override onlyDelegCall { - _vault.withdraw(assets, address(this), address(this)); - } - - /// @inheritdoc IInvestStrategy - function deposit(uint256 assets) external virtual override onlyDelegCall { - _asset.approve(address(_vault), assets); - _vault.deposit(assets, address(this)); - } - - /// @inheritdoc IInvestStrategy - function forwardEntryPoint(uint8, bytes memory) external view onlyDelegCall returns (bytes memory) { - // solhint-disable-next-line gas-custom-errors,reason-string - revert(); - } } 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 index ff197a9..9755e32 100644 --- a/test/test-morpho-vault-v2-invest-strategy.js +++ b/test/test-morpho-vault-v2-invest-strategy.js @@ -140,7 +140,7 @@ describe("MorphoVaultV2InvestStrategy contract tests", function () { it("Checks methods can't be called directly", async () => { const { strategy } = await helpers.loadFixture(setUpCommon); - await expect(strategy.connect(ethers.toUtf8Bytes(""))).to.be.revertedWithCustomError( + await expect(strategy.getFunction("connect")(ethers.toUtf8Bytes(""))).to.be.revertedWithCustomError( strategy, "CanBeCalledOnlyThroughDelegateCall" ); From fd8c4fcfa217204634bf12dd24e1ea8d0f56fa6d Mon Sep 17 00:00:00 2001 From: "Guillermo M. Narvaja" Date: Wed, 15 Apr 2026 05:50:12 -0300 Subject: [PATCH 3/3] More clarity in the tests --- test/test-morpho-vault-v2-invest-strategy.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/test-morpho-vault-v2-invest-strategy.js b/test/test-morpho-vault-v2-invest-strategy.js index 9755e32..4f452b1 100644 --- a/test/test-morpho-vault-v2-invest-strategy.js +++ b/test/test-morpho-vault-v2-invest-strategy.js @@ -98,15 +98,17 @@ describe("MorphoVaultV2InvestStrategy contract tests", function () { expect(await USDC.allowance(vault, investVault)).to.equal(0); await investVault.discreteEarning(_A(40)); - expect(await strategy.totalAssets(vault)).to.closeTo(_A(100), MCENT); + // 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)); - expect(await strategy.totalAssets(vault)).to.closeTo(_A(140), MCENT); - await investVault.updateCachedTotalAssets(); + // 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); }); @@ -118,7 +120,6 @@ describe("MorphoVaultV2InvestStrategy contract tests", function () { await vault.connect(lp).withdraw(_A(80), lp, lp); expect(await strategy.totalAssets(vault)).to.closeTo(_A(20), MCENT); - await investVault.updateCachedTotalAssets(); await investVault.discreteEarning(_A(40)); expect(await strategy.totalAssets(vault)).to.closeTo(_A(20), MCENT); @@ -241,7 +242,6 @@ describe("MorphoVaultV2InvestStrategy contract tests", function () { const { investVault, vault, strategy, lp } = await helpers.loadFixture(setUpCommon); await vault.connect(lp).deposit(_A(100), lp); - await investVault.updateCachedTotalAssets(); const shares = await investVault.balanceOf(vault); const cachedTotalAssets = await investVault._totalAssets(); @@ -249,6 +249,7 @@ describe("MorphoVaultV2InvestStrategy contract tests", function () { const expectedFromCached = (cachedTotalAssets * shares) / totalSupply; const actualTotalAssets = await strategy.totalAssets(vault); + await investVault.discreteEarning(_A(1)); expect(actualTotalAssets).to.equal(expectedFromCached); });