Skip to content
Open
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
308 changes: 308 additions & 0 deletions test/VaultExitMarginTest.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (c) 2026 Morpho Association
pragma solidity ^0.8.0;

import {Test, StdStorage, stdStorage} from "../lib/forge-std/src/Test.sol";
import {ERC20Mock} from "../lib/vault-v2/test/mocks/ERC20Mock.sol";

import {VaultExitBundlesV1} from "../src/vault-exit/VaultExitBundlesV1.sol";
import {SharesPermit} from "../src/vault-exit/interfaces/IVaultExitBundlesV1.sol";

import {IMetaMorpho} from "../lib/metamorpho/src/interfaces/IMetaMorpho.sol";
import {IMorpho, MarketParams, Id} from "../lib/metamorpho/lib/morpho-blue/src/interfaces/IMorpho.sol";
import {MarketParamsLib} from "../lib/metamorpho/lib/morpho-blue/src/libraries/MarketParamsLib.sol";
import {MorphoBalancesLib} from "../lib/metamorpho/lib/morpho-blue/src/libraries/periphery/MorphoBalancesLib.sol";
import {MorphoStorageLib} from "../lib/metamorpho/lib/morpho-blue/src/libraries/periphery/MorphoStorageLib.sol";
import {ORACLE_PRICE_SCALE} from "../lib/metamorpho/lib/morpho-blue/src/libraries/ConstantsLib.sol";
import {OracleMock} from "../lib/metamorpho/lib/morpho-blue/src/mocks/OracleMock.sol";

import {IVaultV2} from "../lib/vault-v2/src/interfaces/IVaultV2.sol";
import {IVaultV2Factory} from "../lib/vault-v2/src/interfaces/IVaultV2Factory.sol";
import {MAX_MAX_RATE, WAD} from "../lib/vault-v2/src/libraries/ConstantsLib.sol";
import {IMorphoMarketV1AdapterV2} from "../lib/vault-v2/src/adapters/interfaces/IMorphoMarketV1AdapterV2.sol";
import {
IMorphoMarketV1AdapterV2Factory
} from "../lib/vault-v2/src/adapters/interfaces/IMorphoMarketV1AdapterV2Factory.sol";

/// @dev Harness verifying the theoretical safety margin `shares` for which exitAssets = previewRedeem(balanceOf(sender) - shares) does not revert, for the three exit functions and a varying number of markets.
contract VaultExitMarginTest is Test {
using MarketParamsLib for MarketParams;
using MorphoBalancesLib for IMorpho;
using stdStorage for StdStorage;

uint256 internal constant V1_ILLIQUID = 0;
uint256 internal constant V2_ILLIQUID = 1;
uint256 internal constant V2_LIQUID = 2;
uint256 internal constant MAX_NUMBER_OF_MARKETS = 20;

uint256 internal constant PENALTY = 0.01e18;
uint256 internal constant PER_MARKET = 100e18;
uint256 internal constant SHARE_PRICE = 1.07e18; // non-round vault share/asset ratio
// Huge amount to allow for flash-loan and supply callback global liquidity needs.
uint256 internal constant GLOBAL_LIQUIDITY = 1_000_000e18;
SharesPermit internal noSharesPermit =
SharesPermit({value: 0, nonce: 0, deadline: 0, v: 0, r: bytes32(0), s: bytes32(0)});

IMorpho internal morpho;
VaultExitBundlesV1 internal exitBundles;
ERC20Mock internal loanToken;
ERC20Mock internal collateralToken;
OracleMock internal oracle;

address internal owner = makeAddr("owner");
address internal curator = makeAddr("curator");
address internal allocator = makeAddr("allocator");
address internal borrower = makeAddr("borrower");

address internal vault;
address internal adapter;
MarketParams[] internal marketList;

function setUp() public {
morpho = IMorpho(deployCode("Morpho.sol:Morpho", abi.encode(owner)));
loanToken = new ERC20Mock(18);
collateralToken = new ERC20Mock(18);
oracle = new OracleMock();
oracle.setPrice(ORACLE_PRICE_SCALE);

vm.startPrank(owner);
morpho.enableIrm(address(0));
for (uint256 i = 0; i < 32; i++) {
morpho.enableLltv(_lltv(i));
}
morpho.enableLltv(0.95e18); // dedicated to the global-liquidity market
vm.stopPrank();

exitBundles = new VaultExitBundlesV1(address(morpho));

MarketParams memory m =
MarketParams(address(loanToken), address(collateralToken), address(oracle), address(0), 0.95e18);
morpho.createMarket(m);
address supplier = makeAddr("supplier");
deal(address(loanToken), supplier, GLOBAL_LIQUIDITY);
vm.startPrank(supplier);
loanToken.approve(address(morpho), type(uint256).max);
morpho.supply(m, GLOBAL_LIQUIDITY, 0, supplier, "");
vm.stopPrank();
}

/// HELPERS ///

function _lltv(uint256 i) internal pure returns (uint256) {
return 0.1e18 + i * 0.01e18; // distinct; _borrowOut sizes collateral to the lltv, so any value is fine.
}

function _market(uint256 i) internal view returns (MarketParams memory) {
return MarketParams(address(loanToken), address(collateralToken), address(oracle), address(0), _lltv(i));
}

// Per-market allocation, distinct per market to stress rounding.
function _amt(uint256 i) internal pure returns (uint256) {
return 1e18 + uint256(keccak256(abi.encode(i))) % PER_MARKET;
}
Comment thread
QGarchery marked this conversation as resolved.

function _total(uint256 numberOfMarkets) internal pure returns (uint256 s) {
for (uint256 i = 0; i < numberOfMarkets; i++) {
s += _amt(i);
}
}

/// @dev Simulates accrued yield on a market via a storage cheat so its share/asset ratio is non-round.
/// @dev The yield is a pseudo-random fraction of the market's assets, distinct per market to stress rounding.
function _accrueYield(MarketParams memory marketParams) internal {
bytes32 slot = MorphoStorageLib.marketTotalSupplyAssetsAndSharesSlot(marketParams.id());
uint256 packed = uint256(vm.load(address(morpho), slot));
// forge-lint:disable-next-line(unsafe-typecast) truncating on purpose.
uint256 totalSupplyAssets = uint128(packed);
uint256 totalSupplyShares = packed >> 128;
uint256 yield = uint256(keccak256(abi.encode(Id.unwrap(marketParams.id())))) % totalSupplyAssets;
if (yield == 0) return;
vm.store(address(morpho), slot, bytes32((totalSupplyShares << 128) | (totalSupplyAssets + yield)));
deal(address(loanToken), address(morpho), loanToken.balanceOf(address(morpho)) + yield);
}

/// @dev Borrows `amount` out of `marketParams` (over-collateralized) to remove that much liquidity.
function _borrowOut(MarketParams memory marketParams, uint256 amount) internal {
if (amount == 0) return;
uint256 collateral = amount * WAD / marketParams.lltv * 2;
deal(address(collateralToken), borrower, collateralToken.balanceOf(borrower) + collateral);
vm.startPrank(borrower);
collateralToken.approve(address(morpho), type(uint256).max);
morpho.supplyCollateral(marketParams, collateral, borrower, "");
morpho.borrow(marketParams, amount, 0, borrower, borrower);
vm.stopPrank();
}

/// SETUPS ///

function _setupV1(uint256 numberOfMarkets) internal {
vault = address(
IMetaMorpho(
deployCode(
"MetaMorpho.sol:MetaMorpho",
abi.encode(owner, address(morpho), 1 days, address(loanToken), "V1", "V1")
)
)
);

Id[] memory queue = new Id[](numberOfMarkets);
vm.startPrank(owner);
for (uint256 i = 0; i < numberOfMarkets; i++) {
MarketParams memory m = _market(i);
morpho.createMarket(m);
marketList.push(m);
// forge-lint:disable-next-line(unsafe-typecast)
IMetaMorpho(vault).submitCap(m, uint184(_amt(i)));
queue[i] = m.id();
}
vm.warp(block.timestamp + 1 days);
for (uint256 i = 0; i < numberOfMarkets; i++) {
IMetaMorpho(vault).acceptCap(marketList[i]);
}
IMetaMorpho(vault).setSupplyQueue(queue);
vm.stopPrank();

deal(address(loanToken), address(this), _total(numberOfMarkets));
loanToken.approve(vault, type(uint256).max);
IMetaMorpho(vault).deposit(_total(numberOfMarkets), address(this));

for (uint256 i = 0; i < numberOfMarkets; i++) {
_accrueYield(marketList[i]);
_borrowOut(marketList[i], morpho.expectedSupplyAssets(marketList[i], address(vault)));
}

// Set the vault share price to SHARE_PRICE, keeping the sole holder's balance equal to the total supply.
uint256 newShares = IMetaMorpho(vault).totalAssets() * WAD / SHARE_PRICE;
stdstore.target(vault).sig("totalSupply()").checked_write(newShares);
stdstore.target(vault).sig("balanceOf(address)").with_key(address(this)).checked_write(newShares);
assertApproxEqAbs(
IMetaMorpho(vault).totalAssets() * WAD / IMetaMorpho(vault).totalSupply(), SHARE_PRICE, 1, "share price"
);

IMetaMorpho(vault).approve(address(exitBundles), type(uint256).max);
deal(address(loanToken), address(this), 0);
}

function _setupV2(uint256 numberOfMarkets, bool illiquid) internal {
IVaultV2Factory vaultFactory = IVaultV2Factory(deployCode("VaultV2Factory.sol:VaultV2Factory"));
vault = address(IVaultV2(vaultFactory.createVaultV2(owner, address(loanToken), bytes32(0))));

vm.prank(owner);
IVaultV2(vault).setCurator(curator);
_submitAndExec(abi.encodeCall(IVaultV2.setIsAllocator, (allocator, true)));

IMorphoMarketV1AdapterV2Factory adapterFactory = IMorphoMarketV1AdapterV2Factory(
deployCode(
"MorphoMarketV1AdapterV2Factory.sol:MorphoMarketV1AdapterV2Factory", abi.encode(morpho, address(0))
)
);
adapter = address(IMorphoMarketV1AdapterV2(adapterFactory.createMorphoMarketV1AdapterV2(address(vault))));
_submitAndExec(abi.encodeCall(IVaultV2.addAdapter, (adapter)));

vm.prank(allocator);
IVaultV2(vault).setMaxRate(MAX_MAX_RATE);

_setMaxCaps(abi.encode("this", adapter));
_setMaxCaps(abi.encode("collateralToken", address(collateralToken)));
for (uint256 i = 0; i < numberOfMarkets; i++) {
MarketParams memory m = _market(i);
morpho.createMarket(m);
marketList.push(m);
_setMaxCaps(abi.encode("this/marketParams", adapter, m));
}

_submitAndExec(abi.encodeCall(IVaultV2.setForceDeallocatePenalty, (adapter, PENALTY)));

deal(address(loanToken), address(this), _total(numberOfMarkets));
loanToken.approve(address(vault), type(uint256).max);
IVaultV2(vault).deposit(_total(numberOfMarkets), address(this));

for (uint256 i = 0; i < numberOfMarkets; i++) {
vm.prank(allocator);
IVaultV2(vault).allocate(adapter, abi.encode(marketList[i]), _amt(i));
_accrueYield(marketList[i]);
if (illiquid) _borrowOut(marketList[i], morpho.expectedSupplyAssets(marketList[i], address(adapter)));
}

// Set the vault share price to SHARE_PRICE, keeping the sole holder's balance equal to the total supply.
uint256 newShares = IVaultV2(vault).totalAssets() * WAD / SHARE_PRICE;
stdstore.target(vault).sig("totalSupply()").checked_write(newShares);
stdstore.target(vault).sig("balanceOf(address)").with_key(address(this)).checked_write(newShares);
assertApproxEqAbs(
IVaultV2(vault).totalAssets() * WAD / IVaultV2(vault).totalSupply(), SHARE_PRICE, 1, "share price"
);

IVaultV2(vault).approve(address(exitBundles), type(uint256).max);
deal(address(loanToken), address(this), 0);
}

function _submitAndExec(bytes memory data) internal {
vm.prank(curator);
IVaultV2(vault).submit(data);
(bool success,) = vault.call(data);
require(success, "exec failed");
}

function _setMaxCaps(bytes memory idData) internal {
_submitAndExec(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, type(uint128).max)));
_submitAndExec(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, WAD)));
}

/// THEORETICAL BOUND ///

// Theoretical safe margin (in shares): the exit is split into independent vault withdrawals.
// Each withdrawal burns previewWithdraw(assets) shares (mulDivUp), so it rounds up by at most one share.
Comment thread
QGarchery marked this conversation as resolved.
// The V1 path withdraws exitAssets = previewRedeem(balance - margin), so it needs no margin (0).
// The illiquid V2 path makes two withdrawals per market (penalty and deallocated assets), hence 2 * numberOfMarkets.
// The liquid V2 path makes one upfront withdrawal, one penalty withdrawal per market, and one final withdrawal, hence numberOfMarkets + 2.
function _margin(uint256 scenario, uint256 numberOfMarkets) internal pure returns (uint256) {
if (scenario == V1_ILLIQUID) return 0;
if (scenario == V2_ILLIQUID) return 2 * numberOfMarkets;
return numberOfMarkets + 2;
}

function testMarginV1(uint256 numberOfMarkets) public {
numberOfMarkets = bound(numberOfMarkets, 1, MAX_NUMBER_OF_MARKETS);
_setupV1(numberOfMarkets);

uint256 margin = _margin(V1_ILLIQUID, numberOfMarkets);
uint256 balance = IMetaMorpho(vault).balanceOf(address(this));
if (margin >= balance) return;
uint256 exitAssets = IMetaMorpho(vault).previewRedeem(balance - margin);
if (exitAssets == 0) return;

exitBundles.vaultExitBundlesV1InKindRedemptionVaultV1(
vault, marketList, exitAssets, noSharesPermit, block.timestamp
);
}

function testMarginV2Illiquid(uint256 numberOfMarkets) public {
numberOfMarkets = bound(numberOfMarkets, 1, MAX_NUMBER_OF_MARKETS);
_setupV2(numberOfMarkets, true);

uint256 margin = _margin(V2_ILLIQUID, numberOfMarkets);
uint256 balance = IVaultV2(vault).balanceOf(address(this));
if (margin >= balance) return;
uint256 exitAssets = IVaultV2(vault).previewRedeem(balance - margin);
if (exitAssets == 0) return;

exitBundles.vaultExitBundlesV1InKindRedemptionVaultV2(
vault, adapter, marketList, exitAssets, noSharesPermit, block.timestamp
);
}

function testMarginV2Liquid(uint256 numberOfMarkets) public {
numberOfMarkets = bound(numberOfMarkets, 1, MAX_NUMBER_OF_MARKETS);
_setupV2(numberOfMarkets, false);
Comment on lines +294 to +296

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exercise a nonzero upfront withdrawal in the liquid scenario

In this setup every deposited asset is allocated and no liquidity adapter is configured, so withdrawableAssets in VaultExitBundlesV1.sol:173-186 is zero and the claimed upfront withdrawal is always withdraw(0). Consequently this case never exercises the additional rounding operation used to justify the numberOfMarkets + 2 margin; leave some idle assets or configure a liquidity adapter, and assert that the upfront amount is nonzero before checking this bound.

AGENTS.md reference: AGENTS.md:L77-L77

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@prd-carapulse[bot] fix this, in this PR: add a liquidity adapter

@prd-carapulse prd-carapulse Bot Jul 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⏳ Working — 9 min — iteration 21/200, terminal


uint256 margin = _margin(V2_LIQUID, numberOfMarkets);
uint256 balance = IVaultV2(vault).balanceOf(address(this));
if (margin >= balance) return;
uint256 exitAssets = IVaultV2(vault).previewRedeem(balance - margin);
if (exitAssets == 0) return;

exitBundles.vaultExitBundlesV1ForceWithdrawVaultV2(
vault, adapter, exitAssets, noSharesPermit, 0, address(0), block.timestamp
);
}
}
14 changes: 5 additions & 9 deletions test/VaultV2ExitBundlesTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -599,8 +599,7 @@ contract VaultV2ExitBundlesTest is Test {
assertApproxEqAbs(vault.balanceOf(address(this)), 0, 1, "vault balance");
}

/// @dev The fee is deducted from the withdrawn assets; the remainder is sent to the user.
function testForceWithdrawWithReferralFee(uint256 assets, uint256 referralFeePct) public {
function testExitWithReferralFee(uint256 assets, uint256 referralFeePct) public {
assets = bound(assets, MIN_ASSETS, MAX_ASSETS);
referralFeePct = bound(referralFeePct, 0, WAD - 1);
_setUpLiquid(assets);
Expand Down Expand Up @@ -634,14 +633,11 @@ contract VaultV2ExitBundlesTest is Test {
);
}

/// @dev Passing assets = previewRedeem(balanceOf(sender) - 8) sweeps the three markets and leaves the sender with
/// almost nothing in the vault. The 8 shares margin keeps the ceil-rounded withdrawals (one per penalty plus the
/// final one) from over-burning.
function testForceWithdrawThreeMarkets() public {
_setUpLiquidThreeMarkets(50e18, 30e18, 20e18);

uint256 sharesBefore = vault.balanceOf(address(this));
uint256 amount = vault.previewRedeem(sharesBefore - 8);
uint256 amount = vault.previewRedeem(sharesBefore - 4);
uint256 deallocate = optimalDeallocateAssets(amount);
assertGt(deallocate, 80e18, "precondition: all three markets are needed");

Expand All @@ -653,10 +649,10 @@ contract VaultV2ExitBundlesTest is Test {
// The first two markets are drained, the remainder is pulled from the third.
assertEq(morpho.expectedSupplyAssets(marketParams, address(adapter)), 0, "first market position");
assertEq(morpho.expectedSupplyAssets(otherMarket, address(adapter)), 0, "second market position");
assertApproxEqAbs(
morpho.expectedSupplyAssets(thirdMarket, address(adapter)), 100e18 - deallocate, 3, "third market position"
assertEq(
morpho.expectedSupplyAssets(thirdMarket, address(adapter)), 100e18 - deallocate, "third market position"
);
assertLe(vault.previewRedeem(vault.balanceOf(address(this))), 10, "almost nothing left in the vault");
assertEq(vault.balanceOf(address(this)), 4, "exactly the margin is left in the vault");
}

/// @dev The first market's liquidity is partially borrowed out, so only its available liquidity is taken from it
Expand Down