|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | +pragma solidity ^0.8.24; |
| 3 | + |
| 4 | +import { Test } from "forge-std/Test.sol"; |
| 5 | +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; |
| 6 | + |
| 7 | +import { Vault } from "../../src/Vault.sol"; |
| 8 | +import { IDiamond } from "../../src/interfaces/IDiamond.sol"; |
| 9 | +import { IDiamondCut } from "../../src/interfaces/IDiamondCut.sol"; |
| 10 | +import { IDiamondLoupe } from "../../src/interfaces/IDiamondLoupe.sol"; |
| 11 | +import { IERC173 } from "../../src/interfaces/IERC173.sol"; |
| 12 | +import { DiamondCutFacet } from "../../src/facets/DiamondCutFacet.sol"; |
| 13 | +import { DiamondLoupeFacet } from "../../src/facets/DiamondLoupeFacet.sol"; |
| 14 | +import { OwnershipFacet } from "../../src/facets/OwnershipFacet.sol"; |
| 15 | +import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; |
| 16 | +import { MorphoStrategyFacet } from "../../src/facets/strategies/MorphoStrategyFacet.sol"; |
| 17 | +import { IMorpho } from "../../src/interfaces/external/IMorpho.sol"; |
| 18 | +import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; |
| 19 | + |
| 20 | +/// @title MorphoStrategyForkTest |
| 21 | +/// @notice Exercises the MorphoStrategyFacet end-to-end against the real |
| 22 | +/// Moonwell Flagship USDC Metamorpho vault on Base mainnet. Skipped |
| 23 | +/// automatically when no Base RPC is available — set BASE_RPC_URL to |
| 24 | +/// opt in. Mirrors AaveStrategy.fork.t.sol. |
| 25 | +contract MorphoStrategyForkTest is Test { |
| 26 | + // ----------------------------------------------------------------------- |
| 27 | + // Base mainnet — Moonwell Flagship USDC Metamorpho vault (curated by |
| 28 | + // Block Analitica & B.Protocol). Deployed long before block 25_000_000. |
| 29 | + // ----------------------------------------------------------------------- |
| 30 | + address internal constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; |
| 31 | + address internal constant BASE_MORPHO_VAULT = 0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca; |
| 32 | + |
| 33 | + bytes32 internal constant MORPHO_ID = bytes32("morpho"); |
| 34 | + |
| 35 | + Vault internal vault; |
| 36 | + address internal owner = makeAddr("owner"); |
| 37 | + address internal alice = makeAddr("alice"); |
| 38 | + |
| 39 | + function setUp() public { |
| 40 | + // Fork tests require a dedicated Base RPC. Set BASE_RPC_URL in your |
| 41 | + // shell or .env to opt in; otherwise the whole suite is skipped. |
| 42 | + string memory rpc = vm.envOr("BASE_RPC_URL", string("")); |
| 43 | + if (bytes(rpc).length == 0) { |
| 44 | + vm.skip(true); |
| 45 | + return; |
| 46 | + } |
| 47 | + // Pin a block for determinism. Defaults to 25_000_000 (matches the |
| 48 | + // Aave fork test); override with BASE_FORK_BLOCK when your RPC has |
| 49 | + // pruned state that far back (most non-archive nodes have). |
| 50 | + vm.createSelectFork(rpc, vm.envOr("BASE_FORK_BLOCK", uint256(25_000_000))); |
| 51 | + |
| 52 | + vault = _deployVault(); |
| 53 | + |
| 54 | + vm.startPrank(owner); |
| 55 | + MorphoStrategyFacet(address(vault)).MorphoSetVaultConfig(IMorpho(BASE_MORPHO_VAULT)); |
| 56 | + AllocatorFacet(address(vault)).registerStrategy(MORPHO_ID, _morphoStrategyConfig()); |
| 57 | + _setSingleAllocation(MORPHO_ID, 8000); // 80% to Morpho |
| 58 | + vm.stopPrank(); |
| 59 | + } |
| 60 | + |
| 61 | + // ----------------------------------------------------------------------- |
| 62 | + // Tests |
| 63 | + // ----------------------------------------------------------------------- |
| 64 | + |
| 65 | + function test_DepositRebalanceDeploysToMorpho() public { |
| 66 | + _seedAndDeposit(alice, 1000 * 1e6); |
| 67 | + |
| 68 | + assertEq(IERC20(BASE_USDC).balanceOf(address(vault)), 1000 * 1e6, "USDC sits idle pre-rebalance"); |
| 69 | + |
| 70 | + vm.roll(block.number + 1); |
| 71 | + vm.prank(owner); |
| 72 | + AllocatorFacet(address(vault)).rebalance(); |
| 73 | + |
| 74 | + // 80% routed to Morpho, 20% stays idle. Metamorpho shares are not 1:1 |
| 75 | + // with assets, so the position is read in underlying units via the |
| 76 | + // facet's `morphoTotalAssets` (share-price NAV). |
| 77 | + assertEq(IERC20(BASE_USDC).balanceOf(address(vault)), 200 * 1e6, "20% idle"); |
| 78 | + assertApproxEqRel( |
| 79 | + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), |
| 80 | + 800 * 1e6, |
| 81 | + 1e15, // 0.1% — absorbs ERC4626 share-rounding |
| 82 | + "80% deployed into Metamorpho" |
| 83 | + ); |
| 84 | + assertApproxEqRel(vault.totalAssets(), 1000 * 1e6, 1e15, "totalAssets unchanged across rebalance"); |
| 85 | + } |
| 86 | + |
| 87 | + function test_YieldAccruesIntoMorphoPosition() public { |
| 88 | + _seedAndDeposit(alice, 1000 * 1e6); |
| 89 | + vm.roll(block.number + 1); |
| 90 | + vm.prank(owner); |
| 91 | + AllocatorFacet(address(vault)).rebalance(); |
| 92 | + |
| 93 | + uint256 navBefore = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); |
| 94 | + |
| 95 | + // Roll forward ~30 days. Block time on Base is ~2s; 30 days ≈ 1_296_000 |
| 96 | + // blocks. Metamorpho's `totalAssets()` accrues market interest by |
| 97 | + // timestamp, so the share-price NAV grows without any interaction. |
| 98 | + vm.warp(block.timestamp + 30 days); |
| 99 | + vm.roll(block.number + 1_296_000); |
| 100 | + |
| 101 | + uint256 navAfter = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); |
| 102 | + assertGt(navAfter, navBefore, "Morpho position grew from supply interest"); |
| 103 | + assertGt(vault.totalAssets(), 1000 * 1e6, "vault TVL grew"); |
| 104 | + } |
| 105 | + |
| 106 | + function test_RebalancePullsBackFromMorpho() public { |
| 107 | + _seedAndDeposit(alice, 1000 * 1e6); |
| 108 | + vm.roll(block.number + 1); |
| 109 | + vm.prank(owner); |
| 110 | + AllocatorFacet(address(vault)).rebalance(); |
| 111 | + |
| 112 | + // Drop the Morpho allocation to 0 and rebalance — pass 1 of rebalance |
| 113 | + // withdraws the whole position back to idle via `morphoWithdraw`. |
| 114 | + vm.prank(owner); |
| 115 | + _setSingleAllocation(MORPHO_ID, 0); |
| 116 | + vm.roll(block.number + 1); |
| 117 | + vm.prank(owner); |
| 118 | + AllocatorFacet(address(vault)).rebalance(); |
| 119 | + |
| 120 | + assertApproxEqAbs( |
| 121 | + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), 0, 1, "Morpho position drained back to idle" |
| 122 | + ); |
| 123 | + assertApproxEqRel( |
| 124 | + IERC20(BASE_USDC).balanceOf(address(vault)), 1000 * 1e6, 1e15, "all assets back idle in the vault" |
| 125 | + ); |
| 126 | + } |
| 127 | + |
| 128 | + /// @dev `Vault._withdraw` is a thin `super._withdraw` with no hook to pull |
| 129 | + /// capital back out of strategies, so a redeem can only be serviced |
| 130 | + /// from the idle balance. This test redeems within that idle headroom; |
| 131 | + /// a redeem exceeding it would revert in the underlying transfer. |
| 132 | + function test_RedeemWithinIdleLiquidityReturnsAssets() public { |
| 133 | + _seedAndDeposit(alice, 1000 * 1e6); |
| 134 | + vm.roll(block.number + 1); |
| 135 | + vm.prank(owner); |
| 136 | + AllocatorFacet(address(vault)).rebalance(); // 200 USDC idle, 800 in Morpho |
| 137 | + |
| 138 | + uint256 navBefore = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); |
| 139 | + |
| 140 | + // Redeem ~10% of alice's shares (~100 USDC) — well inside the 200 idle. |
| 141 | + uint256 redeemShares = vault.balanceOf(alice) / 10; |
| 142 | + vm.prank(alice); |
| 143 | + uint256 assetsReturned = vault.redeem(redeemShares, alice, alice); |
| 144 | + |
| 145 | + assertGt(assetsReturned, 0, "alice received underlying"); |
| 146 | + assertEq(IERC20(BASE_USDC).balanceOf(alice), assetsReturned, "alice's wallet credited"); |
| 147 | + assertApproxEqAbs( |
| 148 | + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), |
| 149 | + navBefore, |
| 150 | + 1, |
| 151 | + "Morpho position untouched - redeem served from idle" |
| 152 | + ); |
| 153 | + } |
| 154 | + |
| 155 | + // ----------------------------------------------------------------------- |
| 156 | + // Helpers |
| 157 | + // ----------------------------------------------------------------------- |
| 158 | + |
| 159 | + function _seedAndDeposit(address from, uint256 amount) internal { |
| 160 | + deal(BASE_USDC, from, amount); |
| 161 | + vm.startPrank(from); |
| 162 | + IERC20(BASE_USDC).approve(address(vault), amount); |
| 163 | + vault.deposit(amount, from); |
| 164 | + vm.stopPrank(); |
| 165 | + } |
| 166 | + |
| 167 | + function _setSingleAllocation(bytes32 id, uint16 bps) internal { |
| 168 | + bytes32[] memory ids = new bytes32[](1); |
| 169 | + uint16[] memory b = new uint16[](1); |
| 170 | + ids[0] = id; |
| 171 | + b[0] = bps; |
| 172 | + AllocatorFacet(address(vault)).setAllocation(ids, b); |
| 173 | + } |
| 174 | + |
| 175 | + function _deployVault() internal returns (Vault) { |
| 176 | + DiamondCutFacet cut = new DiamondCutFacet(); |
| 177 | + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); |
| 178 | + OwnershipFacet ownership = new OwnershipFacet(); |
| 179 | + AllocatorFacet allocator = new AllocatorFacet(); |
| 180 | + MorphoStrategyFacet morpho = new MorphoStrategyFacet(); |
| 181 | + |
| 182 | + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](5); |
| 183 | + cuts[0] = IDiamond.FacetCut({ |
| 184 | + facetAddress: address(cut), action: IDiamond.FacetCutAction.Add, functionSelectors: _diamondCutSelectors() |
| 185 | + }); |
| 186 | + cuts[1] = IDiamond.FacetCut({ |
| 187 | + facetAddress: address(loupe), |
| 188 | + action: IDiamond.FacetCutAction.Add, |
| 189 | + functionSelectors: _diamondLoupeSelectors() |
| 190 | + }); |
| 191 | + cuts[2] = IDiamond.FacetCut({ |
| 192 | + facetAddress: address(ownership), |
| 193 | + action: IDiamond.FacetCutAction.Add, |
| 194 | + functionSelectors: _ownershipSelectors() |
| 195 | + }); |
| 196 | + cuts[3] = IDiamond.FacetCut({ |
| 197 | + facetAddress: address(allocator), |
| 198 | + action: IDiamond.FacetCutAction.Add, |
| 199 | + functionSelectors: _allocatorSelectors() |
| 200 | + }); |
| 201 | + cuts[4] = IDiamond.FacetCut({ |
| 202 | + facetAddress: address(morpho), action: IDiamond.FacetCutAction.Add, functionSelectors: _morphoSelectors() |
| 203 | + }); |
| 204 | + |
| 205 | + return new Vault(IERC20(BASE_USDC), "Vault Router", "vUSDC", owner, cuts, address(0), ""); |
| 206 | + } |
| 207 | + |
| 208 | + function _morphoStrategyConfig() internal pure returns (LibAllocator.StrategyConfig memory) { |
| 209 | + return LibAllocator.StrategyConfig({ |
| 210 | + totalAssetsSelector: MorphoStrategyFacet.morphoTotalAssets.selector, |
| 211 | + depositSelector: MorphoStrategyFacet.morphoDeposit.selector, |
| 212 | + withdrawSelector: MorphoStrategyFacet.morphoWithdraw.selector, |
| 213 | + harvestSelector: MorphoStrategyFacet.morphoHarvest.selector, |
| 214 | + capBps: 0, |
| 215 | + active: false |
| 216 | + }); |
| 217 | + } |
| 218 | + |
| 219 | + function _diamondCutSelectors() internal pure returns (bytes4[] memory s) { |
| 220 | + s = new bytes4[](1); |
| 221 | + s[0] = IDiamondCut.diamondCut.selector; |
| 222 | + } |
| 223 | + |
| 224 | + function _diamondLoupeSelectors() internal pure returns (bytes4[] memory s) { |
| 225 | + s = new bytes4[](4); |
| 226 | + s[0] = IDiamondLoupe.facets.selector; |
| 227 | + s[1] = IDiamondLoupe.facetFunctionSelectors.selector; |
| 228 | + s[2] = IDiamondLoupe.facetAddresses.selector; |
| 229 | + s[3] = IDiamondLoupe.facetAddress.selector; |
| 230 | + } |
| 231 | + |
| 232 | + function _ownershipSelectors() internal pure returns (bytes4[] memory s) { |
| 233 | + s = new bytes4[](2); |
| 234 | + s[0] = IERC173.owner.selector; |
| 235 | + s[1] = IERC173.transferOwnership.selector; |
| 236 | + } |
| 237 | + |
| 238 | + function _allocatorSelectors() internal pure returns (bytes4[] memory s) { |
| 239 | + s = new bytes4[](13); |
| 240 | + s[0] = AllocatorFacet.registerStrategy.selector; |
| 241 | + s[1] = AllocatorFacet.removeStrategy.selector; |
| 242 | + s[2] = AllocatorFacet.setAllocation.selector; |
| 243 | + s[3] = AllocatorFacet.setIdleReserve.selector; |
| 244 | + s[4] = AllocatorFacet.setStrategyCap.selector; |
| 245 | + s[5] = AllocatorFacet.setGlobalStrategyCap.selector; |
| 246 | + s[6] = AllocatorFacet.rebalance.selector; |
| 247 | + s[7] = AllocatorFacet.strategies.selector; |
| 248 | + s[8] = AllocatorFacet.strategyConfig.selector; |
| 249 | + s[9] = AllocatorFacet.targetAllocation.selector; |
| 250 | + s[10] = AllocatorFacet.idleReserveBps.selector; |
| 251 | + s[11] = AllocatorFacet.strategyTotalAssets.selector; |
| 252 | + s[12] = AllocatorFacet.idleAssets.selector; |
| 253 | + } |
| 254 | + |
| 255 | + function _morphoSelectors() internal pure returns (bytes4[] memory s) { |
| 256 | + s = new bytes4[](6); |
| 257 | + s[0] = MorphoStrategyFacet.MorphoSetVaultConfig.selector; |
| 258 | + s[1] = MorphoStrategyFacet.morphoTotalAssets.selector; |
| 259 | + s[2] = MorphoStrategyFacet.morphoDeposit.selector; |
| 260 | + s[3] = MorphoStrategyFacet.morphoWithdraw.selector; |
| 261 | + s[4] = MorphoStrategyFacet.morphoHarvest.selector; |
| 262 | + s[5] = MorphoStrategyFacet.morphoVault.selector; |
| 263 | + } |
| 264 | +} |
0 commit comments