Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
32 changes: 32 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions contracts/mock/VaultV2Mock.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion contracts/strategies/ERC4626InvestStrategy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
61 changes: 61 additions & 0 deletions contracts/strategies/MorphoVaultV2InvestStrategy.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
118 changes: 118 additions & 0 deletions test/test-integration-mainnet-fork.js
Original file line number Diff line number Diff line change
@@ -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));
});
});
Loading
Loading