diff --git a/src/facets/strategies/MorphoStrategyFacet.sol b/src/facets/strategies/MorphoStrategyFacet.sol index 8a04108..74704fa 100644 --- a/src/facets/strategies/MorphoStrategyFacet.sol +++ b/src/facets/strategies/MorphoStrategyFacet.sol @@ -106,17 +106,23 @@ contract MorphoStrategyFacet { if (shares < expected) revert MorphoSlippage(expected, shares); } - /// @notice Withdraw `assets` of underlying from Metamorpho back to the diamond. + /// @notice Withdraw `amount` of underlying from Metamorpho back to the diamond. /// @dev Strategy-internal primitive. Underlying always returns to the diamond /// itself — forwarding to a user-chosen receiver is the responsibility /// of the user-facing redeem path that also burns shares. - /// @param assets Quantity of underlying asset to pull out of Morpho. - function morphoWithdraw(uint256 assets) external { + /// @param amount Quantity of underlying asset to pull out of Morpho. + function morphoWithdraw(uint256 amount) external { MorphoStorage storage s = _ms(); if (address(s.vault) == address(0)) revert MorphoVaultNotConfigured(); - s.vault.withdraw(assets, address(this), address(this)); + s.vault.withdraw(amount, address(this), address(this)); } + /// @notice No-op for Metamorpho — supply yield auto-compounds into the + /// vault's share price, so there is nothing to claim. + /// @dev Present so the facet exposes the full strategy surface + /// (`harvestSelector`) the allocator's `StrategyConfig` expects. + function morphoHarvest() external pure { } + //view /// @notice Return the currently configured Metamorpho vault. diff --git a/test/integration/MorphoStrategy.fork.t.sol b/test/integration/MorphoStrategy.fork.t.sol new file mode 100644 index 0000000..8fcc5c4 --- /dev/null +++ b/test/integration/MorphoStrategy.fork.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { Vault } from "../../src/Vault.sol"; +import { IDiamond } from "../../src/interfaces/IDiamond.sol"; +import { IDiamondCut } from "../../src/interfaces/IDiamondCut.sol"; +import { IDiamondLoupe } from "../../src/interfaces/IDiamondLoupe.sol"; +import { IERC173 } from "../../src/interfaces/IERC173.sol"; +import { DiamondCutFacet } from "../../src/facets/DiamondCutFacet.sol"; +import { DiamondLoupeFacet } from "../../src/facets/DiamondLoupeFacet.sol"; +import { OwnershipFacet } from "../../src/facets/OwnershipFacet.sol"; +import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { MorphoStrategyFacet } from "../../src/facets/strategies/MorphoStrategyFacet.sol"; +import { IMorpho } from "../../src/interfaces/external/IMorpho.sol"; +import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; + +/// @title MorphoStrategyForkTest +/// @notice Exercises the MorphoStrategyFacet end-to-end against the real +/// Moonwell Flagship USDC Metamorpho vault on Base mainnet. Skipped +/// automatically when no Base RPC is available — set BASE_RPC_URL to +/// opt in. Mirrors AaveStrategy.fork.t.sol. +contract MorphoStrategyForkTest is Test { + // ----------------------------------------------------------------------- + // Base mainnet — Moonwell Flagship USDC Metamorpho vault (curated by + // Block Analitica & B.Protocol). Deployed long before block 25_000_000. + // ----------------------------------------------------------------------- + address internal constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + address internal constant BASE_MORPHO_VAULT = 0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca; + + bytes32 internal constant MORPHO_ID = bytes32("morpho"); + + Vault internal vault; + address internal owner = makeAddr("owner"); + address internal alice = makeAddr("alice"); + + function setUp() public { + // Fork tests require a dedicated Base RPC. Set BASE_RPC_URL in your + // shell or .env to opt in; otherwise the whole suite is skipped. + string memory rpc = vm.envOr("BASE_RPC_URL", string("")); + if (bytes(rpc).length == 0) { + vm.skip(true); + return; + } + // Pin a block for determinism. Defaults to 25_000_000 (matches the + // Aave fork test); override with BASE_FORK_BLOCK when your RPC has + // pruned state that far back (most non-archive nodes have). + vm.createSelectFork(rpc, vm.envOr("BASE_FORK_BLOCK", uint256(25_000_000))); + + vault = _deployVault(); + + vm.startPrank(owner); + MorphoStrategyFacet(address(vault)).MorphoSetVaultConfig(IMorpho(BASE_MORPHO_VAULT)); + AllocatorFacet(address(vault)).registerStrategy(MORPHO_ID, _morphoStrategyConfig()); + _setSingleAllocation(MORPHO_ID, 8000); // 80% to Morpho + vm.stopPrank(); + } + + // ----------------------------------------------------------------------- + // Tests + // ----------------------------------------------------------------------- + + function test_DepositRebalanceDeploysToMorpho() public { + _seedAndDeposit(alice, 1000 * 1e6); + + assertEq(IERC20(BASE_USDC).balanceOf(address(vault)), 1000 * 1e6, "USDC sits idle pre-rebalance"); + + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // 80% routed to Morpho, 20% stays idle. Metamorpho shares are not 1:1 + // with assets, so the position is read in underlying units via the + // facet's `morphoTotalAssets` (share-price NAV). + assertEq(IERC20(BASE_USDC).balanceOf(address(vault)), 200 * 1e6, "20% idle"); + assertApproxEqRel( + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), + 800 * 1e6, + 1e15, // 0.1% — absorbs ERC4626 share-rounding + "80% deployed into Metamorpho" + ); + assertApproxEqRel(vault.totalAssets(), 1000 * 1e6, 1e15, "totalAssets unchanged across rebalance"); + } + + function test_YieldAccruesIntoMorphoPosition() public { + _seedAndDeposit(alice, 1000 * 1e6); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + uint256 navBefore = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); + + // Roll forward ~30 days. Block time on Base is ~2s; 30 days ≈ 1_296_000 + // blocks. Metamorpho's `totalAssets()` accrues market interest by + // timestamp, so the share-price NAV grows without any interaction. + vm.warp(block.timestamp + 30 days); + vm.roll(block.number + 1_296_000); + + uint256 navAfter = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); + assertGt(navAfter, navBefore, "Morpho position grew from supply interest"); + assertGt(vault.totalAssets(), 1000 * 1e6, "vault TVL grew"); + } + + function test_RebalancePullsBackFromMorpho() public { + _seedAndDeposit(alice, 1000 * 1e6); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // Drop the Morpho allocation to 0 and rebalance — pass 1 of rebalance + // withdraws the whole position back to idle via `morphoWithdraw`. + vm.prank(owner); + _setSingleAllocation(MORPHO_ID, 0); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + assertApproxEqAbs( + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), 0, 1, "Morpho position drained back to idle" + ); + assertApproxEqRel( + IERC20(BASE_USDC).balanceOf(address(vault)), 1000 * 1e6, 1e15, "all assets back idle in the vault" + ); + } + + /// @dev `Vault._withdraw` is a thin `super._withdraw` with no hook to pull + /// capital back out of strategies, so a redeem can only be serviced + /// from the idle balance. This test redeems within that idle headroom; + /// a redeem exceeding it would revert in the underlying transfer. + function test_RedeemWithinIdleLiquidityReturnsAssets() public { + _seedAndDeposit(alice, 1000 * 1e6); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); // 200 USDC idle, 800 in Morpho + + uint256 navBefore = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); + + // Redeem ~10% of alice's shares (~100 USDC) — well inside the 200 idle. + uint256 redeemShares = vault.balanceOf(alice) / 10; + vm.prank(alice); + uint256 assetsReturned = vault.redeem(redeemShares, alice, alice); + + assertGt(assetsReturned, 0, "alice received underlying"); + assertEq(IERC20(BASE_USDC).balanceOf(alice), assetsReturned, "alice's wallet credited"); + assertApproxEqAbs( + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), + navBefore, + 1, + "Morpho position untouched - redeem served from idle" + ); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + function _seedAndDeposit(address from, uint256 amount) internal { + deal(BASE_USDC, from, amount); + vm.startPrank(from); + IERC20(BASE_USDC).approve(address(vault), amount); + vault.deposit(amount, from); + vm.stopPrank(); + } + + function _setSingleAllocation(bytes32 id, uint16 bps) internal { + bytes32[] memory ids = new bytes32[](1); + uint16[] memory b = new uint16[](1); + ids[0] = id; + b[0] = bps; + AllocatorFacet(address(vault)).setAllocation(ids, b); + } + + function _deployVault() internal returns (Vault) { + DiamondCutFacet cut = new DiamondCutFacet(); + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); + OwnershipFacet ownership = new OwnershipFacet(); + AllocatorFacet allocator = new AllocatorFacet(); + MorphoStrategyFacet morpho = new MorphoStrategyFacet(); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](5); + cuts[0] = IDiamond.FacetCut({ + facetAddress: address(cut), action: IDiamond.FacetCutAction.Add, functionSelectors: _diamondCutSelectors() + }); + cuts[1] = IDiamond.FacetCut({ + facetAddress: address(loupe), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _diamondLoupeSelectors() + }); + cuts[2] = IDiamond.FacetCut({ + facetAddress: address(ownership), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _ownershipSelectors() + }); + cuts[3] = IDiamond.FacetCut({ + facetAddress: address(allocator), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _allocatorSelectors() + }); + cuts[4] = IDiamond.FacetCut({ + facetAddress: address(morpho), action: IDiamond.FacetCutAction.Add, functionSelectors: _morphoSelectors() + }); + + return new Vault(IERC20(BASE_USDC), "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + function _morphoStrategyConfig() internal pure returns (LibAllocator.StrategyConfig memory) { + return LibAllocator.StrategyConfig({ + totalAssetsSelector: MorphoStrategyFacet.morphoTotalAssets.selector, + depositSelector: MorphoStrategyFacet.morphoDeposit.selector, + withdrawSelector: MorphoStrategyFacet.morphoWithdraw.selector, + harvestSelector: MorphoStrategyFacet.morphoHarvest.selector, + capBps: 0, + active: false + }); + } + + function _diamondCutSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](1); + s[0] = IDiamondCut.diamondCut.selector; + } + + function _diamondLoupeSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](4); + s[0] = IDiamondLoupe.facets.selector; + s[1] = IDiamondLoupe.facetFunctionSelectors.selector; + s[2] = IDiamondLoupe.facetAddresses.selector; + s[3] = IDiamondLoupe.facetAddress.selector; + } + + function _ownershipSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](2); + s[0] = IERC173.owner.selector; + s[1] = IERC173.transferOwnership.selector; + } + + function _allocatorSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](13); + s[0] = AllocatorFacet.registerStrategy.selector; + s[1] = AllocatorFacet.removeStrategy.selector; + s[2] = AllocatorFacet.setAllocation.selector; + s[3] = AllocatorFacet.setIdleReserve.selector; + s[4] = AllocatorFacet.setStrategyCap.selector; + s[5] = AllocatorFacet.setGlobalStrategyCap.selector; + s[6] = AllocatorFacet.rebalance.selector; + s[7] = AllocatorFacet.strategies.selector; + s[8] = AllocatorFacet.strategyConfig.selector; + s[9] = AllocatorFacet.targetAllocation.selector; + s[10] = AllocatorFacet.idleReserveBps.selector; + s[11] = AllocatorFacet.strategyTotalAssets.selector; + s[12] = AllocatorFacet.idleAssets.selector; + } + + function _morphoSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](6); + s[0] = MorphoStrategyFacet.MorphoSetVaultConfig.selector; + s[1] = MorphoStrategyFacet.morphoTotalAssets.selector; + s[2] = MorphoStrategyFacet.morphoDeposit.selector; + s[3] = MorphoStrategyFacet.morphoWithdraw.selector; + s[4] = MorphoStrategyFacet.morphoHarvest.selector; + s[5] = MorphoStrategyFacet.morphoVault.selector; + } +} diff --git a/test/mocks/MockMetamorpho.sol b/test/mocks/MockMetamorpho.sol new file mode 100644 index 0000000..a6c3b2f --- /dev/null +++ b/test/mocks/MockMetamorpho.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; + +import { IMintable } from "./MockProtocol.sol"; + +/// @title MockMetamorpho +/// @notice Test-only stand-in for a Metamorpho ERC4626 vault. Behaves like a +/// vanilla OpenZeppelin ERC4626 vault, plus two test hooks the real +/// thing doesn't need: +/// - `setShortchangeBps` makes `deposit` mint fewer shares than +/// `previewDeposit` quotes, to exercise the strategy facet's +/// `MorphoSlippage` guard. +/// - `_testAccrueYield` donates underlying into the vault, lifting the +/// share price to simulate supply-yield accrual without modelling a +/// real interest curve. +contract MockMetamorpho is ERC4626 { + /// @notice When non-zero, `deposit` mints `(10_000 - bps) / 10_000` of the + /// shares `previewDeposit` returns. `previewDeposit` itself is left + /// honest, so the caller's slippage check has something to catch on. + uint256 public shortchangeBps; + + constructor(IERC20 asset_) ERC20("Mock Metamorpho USDC", "mmUSDC") ERC4626(asset_) { } + + /// @notice Test-only — set the share shortfall applied by `deposit`. + function setShortchangeBps(uint256 bps) external { + shortchangeBps = bps; + } + + /// @notice Test-only — donate `amount` of underlying to the vault so the + /// share price rises, simulating accrued supply yield. + function _testAccrueYield(uint256 amount) external { + IMintable(asset()).mint(address(this), amount); + } + + /// @dev Mirrors OZ's `deposit` but optionally mints fewer shares than quoted. + function deposit(uint256 assets, address receiver) public override returns (uint256) { + uint256 shares = previewDeposit(assets); + if (shortchangeBps != 0) { + shares = (shares * (10_000 - shortchangeBps)) / 10_000; + } + _deposit(_msgSender(), receiver, assets, shares); + return shares; + } +} diff --git a/test/unit/MorphoStrategy.t.sol b/test/unit/MorphoStrategy.t.sol new file mode 100644 index 0000000..5da51b4 --- /dev/null +++ b/test/unit/MorphoStrategy.t.sol @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { Vault } from "../../src/Vault.sol"; +import { IDiamond } from "../../src/interfaces/IDiamond.sol"; +import { IDiamondCut } from "../../src/interfaces/IDiamondCut.sol"; +import { IDiamondLoupe } from "../../src/interfaces/IDiamondLoupe.sol"; +import { IERC173 } from "../../src/interfaces/IERC173.sol"; +import { DiamondCutFacet } from "../../src/facets/DiamondCutFacet.sol"; +import { DiamondLoupeFacet } from "../../src/facets/DiamondLoupeFacet.sol"; +import { OwnershipFacet } from "../../src/facets/OwnershipFacet.sol"; +import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; +import { MorphoStrategyFacet } from "../../src/facets/strategies/MorphoStrategyFacet.sol"; +import { IMorpho } from "../../src/interfaces/external/IMorpho.sol"; +import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; +import { LibDiamond } from "../../src/libraries/LibDiamond.sol"; + +import { MockMetamorpho } from "../mocks/MockMetamorpho.sol"; + +contract MockUSDC is ERC20 { + constructor() ERC20("USD Coin", "USDC") { } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function decimals() public pure override returns (uint8) { + return 6; + } +} + +/// @title MorphoStrategyTest +/// @notice Unit coverage for `MorphoStrategyFacet` against a mock Metamorpho +/// ERC4626 vault — no RPC required. Mirrors the structure of the Aave +/// strategy fork test but stays fully local so it runs in CI, and adds +/// the revert paths a fork test can't easily trigger (slippage, +/// asset-mismatch, unconfigured-vault). +contract MorphoStrategyTest is Test { + MockUSDC internal usdc; + MockMetamorpho internal morphoVault; + Vault internal vault; + + address internal owner = makeAddr("owner"); + address internal alice = makeAddr("alice"); + + bytes32 internal constant MORPHO_ID = bytes32("morpho"); + + function setUp() public { + usdc = new MockUSDC(); + morphoVault = new MockMetamorpho(IERC20(address(usdc))); + vault = _deployVault(); + // Note: the Morpho vault is intentionally NOT configured here — several + // tests exercise the unconfigured-revert paths. Tests that need a live + // strategy call `_configure()` / `_register()` explicitly. + } + + // ----------------------------------------------------------------------- + // MorphoSetVaultConfig — gating & validation + // ----------------------------------------------------------------------- + + function test_SetVaultConfig_SetsVaultAndEmits() public { + vm.expectEmit(true, false, false, false, address(vault)); + emit MorphoStrategyFacet.MorphoVaultSet(IMorpho(address(morphoVault))); + + vm.prank(owner); + MorphoStrategyFacet(address(vault)).MorphoSetVaultConfig(IMorpho(address(morphoVault))); + + assertEq( + address(MorphoStrategyFacet(address(vault)).morphoVault()), + address(morphoVault), + "configured vault is readable" + ); + } + + function test_SetVaultConfig_RevertsOnZeroAddress() public { + vm.prank(owner); + vm.expectRevert(MorphoStrategyFacet.MorphoVaultNotConfigured.selector); + MorphoStrategyFacet(address(vault)).MorphoSetVaultConfig(IMorpho(address(0))); + } + + function test_SetVaultConfig_RevertsForNonOwner() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibDiamond.NotContractOwner.selector, alice, owner)); + MorphoStrategyFacet(address(vault)).MorphoSetVaultConfig(IMorpho(address(morphoVault))); + } + + function test_SetVaultConfig_RevertsOnAssetMismatch() public { + // A Metamorpho vault whose underlying differs from the diamond's asset. + MockUSDC otherAsset = new MockUSDC(); + MockMetamorpho mismatched = new MockMetamorpho(IERC20(address(otherAsset))); + + vm.prank(owner); + vm.expectRevert(MorphoStrategyFacet.MorphoAssetMismatch.selector); + MorphoStrategyFacet(address(vault)).MorphoSetVaultConfig(IMorpho(address(mismatched))); + } + + // ----------------------------------------------------------------------- + // Unconfigured-vault reverts + // ----------------------------------------------------------------------- + // Divergence from AaveStrategyFacet: `aaveTotalAssets` returns 0 when + // unconfigured, whereas every Morpho reader/primitive reverts hard. + + function test_TotalAssets_RevertsWhenUnconfigured() public { + vm.expectRevert(MorphoStrategyFacet.MorphoVaultNotConfigured.selector); + MorphoStrategyFacet(address(vault)).morphoTotalAssets(); + } + + function test_Deposit_RevertsWhenUnconfigured() public { + vm.expectRevert(MorphoStrategyFacet.MorphoVaultNotConfigured.selector); + MorphoStrategyFacet(address(vault)).morphoDeposit(1e6); + } + + function test_Withdraw_RevertsWhenUnconfigured() public { + vm.expectRevert(MorphoStrategyFacet.MorphoVaultNotConfigured.selector); + MorphoStrategyFacet(address(vault)).morphoWithdraw(1e6); + } + + function test_MorphoVault_RevertsWhenUnconfigured() public { + vm.expectRevert(MorphoStrategyFacet.MorphoVaultNotConfigured.selector); + MorphoStrategyFacet(address(vault)).morphoVault(); + } + + // ----------------------------------------------------------------------- + // Strategy primitives — called directly on the diamond + // ----------------------------------------------------------------------- + + function test_Deposit_MintsSharesAndReportsAssets() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); // seed the diamond's idle balance + + MorphoStrategyFacet(address(vault)).morphoDeposit(amount); + + assertEq(usdc.balanceOf(address(vault)), 0, "idle underlying fully deployed"); + assertGt(morphoVault.balanceOf(address(vault)), 0, "diamond holds Metamorpho shares"); + assertApproxEqAbs( + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), amount, 1, "position reported in underlying units" + ); + } + + function test_Deposit_RevertsOnSlippage() public { + _configure(); + uint256 amount = 1000 * 1e6; + morphoVault.setShortchangeBps(100); // mint 1% fewer shares than quoted + usdc.mint(address(vault), amount); + + // `previewDeposit` is read on the empty vault — same value the facet sees. + uint256 expected = morphoVault.previewDeposit(amount); + uint256 received = (expected * (10_000 - 100)) / 10_000; + + vm.expectRevert(abi.encodeWithSelector(MorphoStrategyFacet.MorphoSlippage.selector, expected, received)); + MorphoStrategyFacet(address(vault)).morphoDeposit(amount); + } + + function test_Withdraw_ReturnsUnderlyingToDiamond() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + MorphoStrategyFacet(address(vault)).morphoDeposit(amount); + + MorphoStrategyFacet(address(vault)).morphoWithdraw(400 * 1e6); + + assertEq(usdc.balanceOf(address(vault)), 400 * 1e6, "withdrawn underlying back to idle"); + assertApproxEqAbs( + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), 600 * 1e6, 1, "remaining position reported" + ); + } + + function test_TotalAssets_TracksYieldAccrual() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + MorphoStrategyFacet(address(vault)).morphoDeposit(amount); + + uint256 beforeYield = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); + morphoVault._testAccrueYield(100 * 1e6); // 10% supply yield donated to the vault + uint256 afterYield = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); + + assertGt(afterYield, beforeYield, "share-price NAV grew with yield"); + assertApproxEqRel(afterYield, 1100 * 1e6, 1e15, "position ~= principal + yield"); // 0.1% tolerance + } + + function test_Harvest_IsNoOp() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + MorphoStrategyFacet(address(vault)).morphoDeposit(amount); + + uint256 before = MorphoStrategyFacet(address(vault)).morphoTotalAssets(); + MorphoStrategyFacet(address(vault)).morphoHarvest(); // must not revert + assertEq(MorphoStrategyFacet(address(vault)).morphoTotalAssets(), before, "harvest is a no-op"); + } + + // ----------------------------------------------------------------------- + // End-to-end through the allocator + // ----------------------------------------------------------------------- + + function test_Rebalance_RoutesAssetsIntoMorpho() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(MORPHO_ID, 8000); // 80% to Morpho + + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + assertApproxEqAbs( + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), 800 * 1e6, 1, "80% routed into Metamorpho" + ); + assertEq(usdc.balanceOf(address(vault)), 200 * 1e6, "20% stays idle"); + assertApproxEqAbs(vault.totalAssets(), 1000 * 1e6, 1, "totalAssets unchanged across rebalance"); + } + + function test_Rebalance_PullsBackWhenAllocationDrops() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(MORPHO_ID, 8000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + // Drop the allocation to 0 and rebalance again — exercises morphoWithdraw. + _setSingleAllocation(MORPHO_ID, 0); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); + + assertApproxEqAbs(MorphoStrategyFacet(address(vault)).morphoTotalAssets(), 0, 1, "Morpho position drained"); + assertApproxEqAbs(usdc.balanceOf(address(vault)), 1000 * 1e6, 1, "all assets back idle"); + } + + // ----------------------------------------------------------------------- + // Redeem — documents current Vault behaviour + // ----------------------------------------------------------------------- + // `Vault._withdraw` is a thin `super._withdraw`: it has no hook to pull + // capital back out of strategies. Redeems therefore succeed only up to the + // idle balance and revert beyond it. (The Aave fork test's redeem case + // depends on a pull-back hook that does not yet exist.) + + function test_Redeem_SucceedsWhenCoveredByIdle() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(MORPHO_ID, 8000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); // 200 USDC idle, 800 in Morpho + + uint256 redeemShares = vault.balanceOf(alice) / 10; // ~10% -> ~100 USDC <= idle + vm.prank(alice); + uint256 assetsOut = vault.redeem(redeemShares, alice, alice); + + assertGt(assetsOut, 0, "alice received underlying"); + assertEq(usdc.balanceOf(alice), assetsOut, "alice's wallet credited"); + assertApproxEqAbs( + MorphoStrategyFacet(address(vault)).morphoTotalAssets(), 800 * 1e6, 1, "Morpho position untouched" + ); + } + + function test_Redeem_RevertsWhenExceedsIdleLiquidity() public { + _configure(); + _register(); + _depositToVault(alice, 1000 * 1e6); + _setSingleAllocation(MORPHO_ID, 8000); + vm.roll(block.number + 1); + vm.prank(owner); + AllocatorFacet(address(vault)).rebalance(); // only 200 USDC left idle + + // Redeeming everything needs ~1000 USDC; the vault holds 200 idle and + // has no strategy pull-back hook, so the underlying transfer reverts. + uint256 allShares = vault.balanceOf(alice); + vm.prank(alice); + vm.expectRevert(); + vault.redeem(allShares, alice, alice); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + function _configure() internal { + vm.prank(owner); + MorphoStrategyFacet(address(vault)).MorphoSetVaultConfig(IMorpho(address(morphoVault))); + } + + function _register() internal { + vm.prank(owner); + AllocatorFacet(address(vault)).registerStrategy(MORPHO_ID, _morphoStrategyConfig()); + } + + function _depositToVault(address from, uint256 amount) internal { + usdc.mint(from, amount); + vm.startPrank(from); + usdc.approve(address(vault), amount); + vault.deposit(amount, from); + vm.stopPrank(); + } + + function _setSingleAllocation(bytes32 id, uint16 bps) internal { + bytes32[] memory ids = new bytes32[](1); + uint16[] memory b = new uint16[](1); + ids[0] = id; + b[0] = bps; + vm.prank(owner); + AllocatorFacet(address(vault)).setAllocation(ids, b); + } + + function _morphoStrategyConfig() internal pure returns (LibAllocator.StrategyConfig memory) { + return LibAllocator.StrategyConfig({ + totalAssetsSelector: MorphoStrategyFacet.morphoTotalAssets.selector, + depositSelector: MorphoStrategyFacet.morphoDeposit.selector, + withdrawSelector: MorphoStrategyFacet.morphoWithdraw.selector, + harvestSelector: MorphoStrategyFacet.morphoHarvest.selector, + capBps: 0, + active: false // overwritten in registerStrategy + }); + } + + function _deployVault() internal returns (Vault) { + DiamondCutFacet cut = new DiamondCutFacet(); + DiamondLoupeFacet loupe = new DiamondLoupeFacet(); + OwnershipFacet ownership = new OwnershipFacet(); + AllocatorFacet allocator = new AllocatorFacet(); + MorphoStrategyFacet morpho = new MorphoStrategyFacet(); + + IDiamond.FacetCut[] memory cuts = new IDiamond.FacetCut[](5); + cuts[0] = IDiamond.FacetCut({ + facetAddress: address(cut), action: IDiamond.FacetCutAction.Add, functionSelectors: _diamondCutSelectors() + }); + cuts[1] = IDiamond.FacetCut({ + facetAddress: address(loupe), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _diamondLoupeSelectors() + }); + cuts[2] = IDiamond.FacetCut({ + facetAddress: address(ownership), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _ownershipSelectors() + }); + cuts[3] = IDiamond.FacetCut({ + facetAddress: address(allocator), + action: IDiamond.FacetCutAction.Add, + functionSelectors: _allocatorSelectors() + }); + cuts[4] = IDiamond.FacetCut({ + facetAddress: address(morpho), action: IDiamond.FacetCutAction.Add, functionSelectors: _morphoSelectors() + }); + + return new Vault(IERC20(address(usdc)), "Vault Router", "vUSDC", owner, cuts, address(0), ""); + } + + function _diamondCutSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](1); + s[0] = IDiamondCut.diamondCut.selector; + } + + function _diamondLoupeSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](4); + s[0] = IDiamondLoupe.facets.selector; + s[1] = IDiamondLoupe.facetFunctionSelectors.selector; + s[2] = IDiamondLoupe.facetAddresses.selector; + s[3] = IDiamondLoupe.facetAddress.selector; + } + + function _ownershipSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](2); + s[0] = IERC173.owner.selector; + s[1] = IERC173.transferOwnership.selector; + } + + function _allocatorSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](13); + s[0] = AllocatorFacet.registerStrategy.selector; + s[1] = AllocatorFacet.removeStrategy.selector; + s[2] = AllocatorFacet.setAllocation.selector; + s[3] = AllocatorFacet.setIdleReserve.selector; + s[4] = AllocatorFacet.setStrategyCap.selector; + s[5] = AllocatorFacet.setGlobalStrategyCap.selector; + s[6] = AllocatorFacet.rebalance.selector; + s[7] = AllocatorFacet.strategies.selector; + s[8] = AllocatorFacet.strategyConfig.selector; + s[9] = AllocatorFacet.targetAllocation.selector; + s[10] = AllocatorFacet.idleReserveBps.selector; + s[11] = AllocatorFacet.strategyTotalAssets.selector; + s[12] = AllocatorFacet.idleAssets.selector; + } + + function _morphoSelectors() internal pure returns (bytes4[] memory s) { + s = new bytes4[](6); + s[0] = MorphoStrategyFacet.MorphoSetVaultConfig.selector; + s[1] = MorphoStrategyFacet.morphoTotalAssets.selector; + s[2] = MorphoStrategyFacet.morphoDeposit.selector; + s[3] = MorphoStrategyFacet.morphoWithdraw.selector; + s[4] = MorphoStrategyFacet.morphoHarvest.selector; + s[5] = MorphoStrategyFacet.morphoVault.selector; + } +}