Skip to content

Commit e991db2

Browse files
committed
tests for metamorpho added
1 parent ddfa6eb commit e991db2

4 files changed

Lines changed: 725 additions & 4 deletions

File tree

‎src/facets/strategies/MorphoStrategyFacet.sol‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,23 @@ contract MorphoStrategyFacet {
106106
if (shares < expected) revert MorphoSlippage(expected, shares);
107107
}
108108

109-
/// @notice Withdraw `assets` of underlying from Metamorpho back to the diamond.
109+
/// @notice Withdraw `amount` of underlying from Metamorpho back to the diamond.
110110
/// @dev Strategy-internal primitive. Underlying always returns to the diamond
111111
/// itself — forwarding to a user-chosen receiver is the responsibility
112112
/// of the user-facing redeem path that also burns shares.
113-
/// @param assets Quantity of underlying asset to pull out of Morpho.
114-
function morphoWithdraw(uint256 assets) external {
113+
/// @param amount Quantity of underlying asset to pull out of Morpho.
114+
function morphoWithdraw(uint256 amount) external {
115115
MorphoStorage storage s = _ms();
116116
if (address(s.vault) == address(0)) revert MorphoVaultNotConfigured();
117-
s.vault.withdraw(assets, address(this), address(this));
117+
s.vault.withdraw(amount, address(this), address(this));
118118
}
119119

120+
/// @notice No-op for Metamorpho — supply yield auto-compounds into the
121+
/// vault's share price, so there is nothing to claim.
122+
/// @dev Present so the facet exposes the full strategy surface
123+
/// (`harvestSelector`) the allocator's `StrategyConfig` expects.
124+
function morphoHarvest() external pure { }
125+
120126
//view
121127

122128
/// @notice Return the currently configured Metamorpho vault.
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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+
}

‎test/mocks/MockMetamorpho.sol‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.24;
3+
4+
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6+
import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
7+
8+
import { IMintable } from "./MockProtocol.sol";
9+
10+
/// @title MockMetamorpho
11+
/// @notice Test-only stand-in for a Metamorpho ERC4626 vault. Behaves like a
12+
/// vanilla OpenZeppelin ERC4626 vault, plus two test hooks the real
13+
/// thing doesn't need:
14+
/// - `setShortchangeBps` makes `deposit` mint fewer shares than
15+
/// `previewDeposit` quotes, to exercise the strategy facet's
16+
/// `MorphoSlippage` guard.
17+
/// - `_testAccrueYield` donates underlying into the vault, lifting the
18+
/// share price to simulate supply-yield accrual without modelling a
19+
/// real interest curve.
20+
contract MockMetamorpho is ERC4626 {
21+
/// @notice When non-zero, `deposit` mints `(10_000 - bps) / 10_000` of the
22+
/// shares `previewDeposit` returns. `previewDeposit` itself is left
23+
/// honest, so the caller's slippage check has something to catch on.
24+
uint256 public shortchangeBps;
25+
26+
constructor(IERC20 asset_) ERC20("Mock Metamorpho USDC", "mmUSDC") ERC4626(asset_) { }
27+
28+
/// @notice Test-only — set the share shortfall applied by `deposit`.
29+
function setShortchangeBps(uint256 bps) external {
30+
shortchangeBps = bps;
31+
}
32+
33+
/// @notice Test-only — donate `amount` of underlying to the vault so the
34+
/// share price rises, simulating accrued supply yield.
35+
function _testAccrueYield(uint256 amount) external {
36+
IMintable(asset()).mint(address(this), amount);
37+
}
38+
39+
/// @dev Mirrors OZ's `deposit` but optionally mints fewer shares than quoted.
40+
function deposit(uint256 assets, address receiver) public override returns (uint256) {
41+
uint256 shares = previewDeposit(assets);
42+
if (shortchangeBps != 0) {
43+
shares = (shares * (10_000 - shortchangeBps)) / 10_000;
44+
}
45+
_deposit(_msgSender(), receiver, assets, shares);
46+
return shares;
47+
}
48+
}

0 commit comments

Comments
 (0)