From eaab8221f9d1a8e7166a46fca3ee2150a3c4b229 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 7 Nov 2025 18:03:28 +0100 Subject: [PATCH 1/7] V2 Adapter --- .github/workflows/certora.yml | 1 + .github/workflows/foundry-sizes.yml | 1 + .github/workflows/foundry.yml | 1 + .gitmodules | 3 + foundry.lock | 17 + lib/forge-std | 2 +- lib/morpho-v2 | 1 + src/adapters/MorphoMarketV2Adapter.sol | 335 ++++++++++ src/adapters/MorphoMarketV2AdapterFactory.sol | 23 + .../interfaces/IMorphoMarketV2Adapter.sol | 107 +++ .../IMorphoMarketV2AdapterFactory.sol | 17 + src/libraries/MathLib.sol | 12 + test/MorphoMarketV1AdapterTest.sol | 31 +- test/MorphoMarketV2AdapterTest.sol | 608 ++++++++++++++++++ test/MorphoVaultV1AdapterTest.sol | 29 +- test/mocks/VaultV2Mock.sol | 10 +- 16 files changed, 1163 insertions(+), 35 deletions(-) create mode 100644 foundry.lock create mode 160000 lib/morpho-v2 create mode 100644 src/adapters/MorphoMarketV2Adapter.sol create mode 100644 src/adapters/MorphoMarketV2AdapterFactory.sol create mode 100644 src/adapters/interfaces/IMorphoMarketV2Adapter.sol create mode 100644 src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol create mode 100644 test/MorphoMarketV2AdapterTest.sol diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 20ca3a7fc..996ba3e0a 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -38,6 +38,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + token: ${{ secrets.MORPHO_V2_READ_TOKEN }} - uses: actions/setup-java@v4 with: diff --git a/.github/workflows/foundry-sizes.yml b/.github/workflows/foundry-sizes.yml index 41c2df5fa..39ce1ab76 100644 --- a/.github/workflows/foundry-sizes.yml +++ b/.github/workflows/foundry-sizes.yml @@ -17,6 +17,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + token: ${{ secrets.MORPHO_V2_READ_TOKEN }} - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 diff --git a/.github/workflows/foundry.yml b/.github/workflows/foundry.yml index 0d9e23d05..baa9a7a93 100644 --- a/.github/workflows/foundry.yml +++ b/.github/workflows/foundry.yml @@ -20,6 +20,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + token: ${{ secrets.MORPHO_V2_READ_TOKEN }} - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 diff --git a/.gitmodules b/.gitmodules index d77d99180..734c9af2b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "lib/metamorpho-v1.1"] path = lib/metamorpho-v1.1 url = git@github.com:morpho-org/metamorpho-v1.1.git +[submodule "lib/morpho-v2"] + path = lib/morpho-v2 + url = https://github.com/morpho-org/morpho-v2 diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 000000000..2ce731196 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,17 @@ +{ + "lib/forge-std": { + "rev": "77041d2ce690e692d6e03cc812b57d1ddaa4d505" + }, + "lib/metamorpho": { + "rev": "00da9ad27da8051bce663eeac02f3b9c0c0aa8d8" + }, + "lib/metamorpho-v1.1": { + "rev": "2d160ba9bb945ca3bf12efb182427445dce59c27" + }, + "lib/morpho-blue": { + "rev": "d89ca53ff6cbbacf8717a8ce819ee58f49bcc592" + }, + "lib/openzeppelin-contracts": { + "rev": "b72e3da0ec1f47e4a7911a4c06dc92e78c646607" + } +} \ No newline at end of file diff --git a/lib/forge-std b/lib/forge-std index 77041d2ce..100b0d756 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 77041d2ce690e692d6e03cc812b57d1ddaa4d505 +Subproject commit 100b0d756adda67bc70aab816fa5a1a95dcf78b6 diff --git a/lib/morpho-v2 b/lib/morpho-v2 new file mode 160000 index 000000000..6b36faa2b --- /dev/null +++ b/lib/morpho-v2 @@ -0,0 +1 @@ +Subproject commit 6b36faa2b7e0b362cccb852e7064aaa70c8a49d6 diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol new file mode 100644 index 000000000..d06cf7dc7 --- /dev/null +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import {MorphoV2} from "lib/morpho-v2/src/MorphoV2.sol"; +import {Offer, Signature, Obligation, Collateral, Seizure, Proof} from "lib/morpho-v2/src/interfaces/IMorphoV2.sol"; +import {IERC20} from "../interfaces/IERC20.sol"; +import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; +import {MathLib} from "../libraries/MathLib.sol"; +import {MathLib as MorphoV2MathLib} from "lib/morpho-v2/src/libraries/MathLib.sol"; +import {IVaultV2} from "../interfaces/IVaultV2.sol"; +import {IMorphoMarketV2Adapter, ObligationPosition, Maturity, IAdapter} from "./interfaces/IMorphoMarketV2Adapter.sol"; + +/// @dev Approximates held assets by linearly accounting for interest separately for each obligation. +/// @dev Losses are immdiately accounted minus a discount applied to the remaining interest to be earned, in proportion +/// to the relative sizes of the loss and the adapter's position in the obligation hit by the loss. +/// @dev The adapter must have the allocator role in its parent vault to be able to buy & sell obligations. +contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { + using MathLib for uint256; + + /* IMMUTABLES */ + + address public immutable asset; + address public immutable parentVault; + address public immutable morphoV2; + + /* MANAGEMENT */ + + address public manager; + address public skimRecipient; + uint256 public minTimeToMaturity; + uint256 public minRate; + + /* ACCOUNTING */ + + uint256 public lastRealAssetsEstimate; + uint48 public lastUpdate; + uint48 public firstMaturity; + uint128 public currentGrowth; + mapping(uint256 timestamp => Maturity) public _maturities; + mapping(bytes32 obligationId => ObligationPosition) public _positions; + /* CONSTRUCTOR */ + + constructor(address _parentVault, address _morphoV2) { + asset = IVaultV2(_parentVault).asset(); + parentVault = _parentVault; + morphoV2 = _morphoV2; + lastUpdate = uint48(block.timestamp); + manager = IVaultV2(parentVault).curator(); + SafeERC20Lib.safeApprove(asset, _morphoV2, type(uint256).max); + SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); + firstMaturity = type(uint48).max; + } + + /* GETTERS */ + + function positions(bytes32 obligationId) public view returns (ObligationPosition memory) { + return _positions[obligationId]; + } + + function maturities(uint256 date) public view returns (Maturity memory) { + return _maturities[date]; + } + + /* SKIM FUNCTIONS */ + + function setSkimRecipient(address newSkimRecipient) external { + require(msg.sender == IVaultV2(parentVault).owner(), NotAuthorized()); + skimRecipient = newSkimRecipient; + emit SetSkimRecipient(newSkimRecipient); + } + + /// @dev Skims the adapter's balance of `token` and sends it to `skimRecipient`. + /// @dev This is useful to handle rewards that the adapter has earned. + function skim(address token) external { + require(msg.sender == skimRecipient, NotAuthorized()); + uint256 balance = IERC20(token).balanceOf(address(this)); + SafeERC20Lib.safeTransfer(token, skimRecipient, balance); + emit Skim(token, balance); + } + + /* MANAGEMENT FUNCTIONS */ + + function setMinTimeToMaturity(uint256 _minTimeToMaturity) external { + require(msg.sender == manager, NotAuthorized()); + require(_minTimeToMaturity <= type(uint48).max, IncorrectMinTimeToMaturity()); + minTimeToMaturity = _minTimeToMaturity; + } + + function setManager(address _manager) external { + require(msg.sender == manager || msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); + manager = _manager; + } + + // Do not cleanup the linked list if we end up at 0 growth + function withdraw(Obligation memory obligation, uint256 obligationUnits, uint256 shares) external { + require(msg.sender == manager, NotAuthorized()); + (obligationUnits, shares) = MorphoV2(morphoV2).withdraw(obligation, obligationUnits, shares, address(this)); + removeUnits(obligation, obligationUnits); + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(obligationUnits, vaultIds(obligation)), obligationUnits); + } + + /* RATIFICATION FUNCTIONS */ + + function setRatified( + Offer memory offer, + Signature memory signature, + bytes32 root, + bytes32[] memory proof, + bool isRatified + ) external {} + + /* ACCRUAL */ + + function accrueInterestView() public view returns (uint48, uint128, uint256) { + uint256 lastChange = lastUpdate; + uint48 nextMaturity = firstMaturity; + uint128 newGrowth = currentGrowth; + uint256 gainedAssets; + + while (nextMaturity < block.timestamp) { + gainedAssets += uint256(newGrowth) * (nextMaturity - lastChange); + newGrowth -= _maturities[nextMaturity].growthLostAtMaturity; + lastChange = nextMaturity; + nextMaturity = _maturities[nextMaturity].nextMaturity; + } + + gainedAssets += uint256(newGrowth) * (block.timestamp - lastChange); + + return (nextMaturity, newGrowth, lastRealAssetsEstimate + gainedAssets); + } + + function accrueInterest() public { + if (lastUpdate != block.timestamp) { + (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets) = accrueInterestView(); + lastRealAssetsEstimate = newTotalAssets; + lastUpdate = uint48(block.timestamp); + firstMaturity = nextMaturity; + currentGrowth = newGrowth; + } + } + + function realAssets() external view returns (uint256) { + (,, uint256 newTotalAssets) = accrueInterestView(); + return newTotalAssets; + } + + /* LOSS REALIZATION */ + + function realizeLoss(Obligation memory obligation) external { + bytes32 obligationId = _obligationId(obligation); + uint256 remainingUnits = MorphoV2(morphoV2).sharesOf(address(this), obligationId) + .mulDivDown( + MorphoV2(morphoV2).totalUnits(obligationId) + 1, MorphoV2(morphoV2).totalShares(obligationId) + 1 + ); + + uint256 lostUnits = _positions[obligationId].units - remainingUnits; + removeUnits(obligation, lostUnits); + IVaultV2(parentVault).deallocate(address(this), abi.encode(lostUnits, vaultIds(obligation)), 0); + } + + /* ALLOCATION FUNCTIONS */ + + /// @dev Can only be called from a buy callback where the adapter is the maker. + function allocate(bytes memory data, uint256, bytes4, address vaultAllocator) + external + view + returns (bytes32[] memory, int256) + { + require(vaultAllocator == address(this), SelfAllocationOnly()); + (uint256 obligationUnits, bytes32[] memory _ids) = abi.decode(data, (uint256, bytes32[])); + return (_ids, obligationUnits.toInt256()); + } + + /// @dev Can only be called from vault.deallocate from a sell callback where the adapter is the maker. + /// @dev Can be called from vault.forceDeallocate to trigger a sell take by the adapter. + /// @dev In a forceDeallocate, the user may have to set a buyer price above 1 so that the seller price is at least 1 + /// despite the fees. + function deallocate(bytes memory data, uint256 sellerAssets, bytes4 messageSig, address caller) + external + returns (bytes32[] memory, int256) + { + if (messageSig == IVaultV2.forceDeallocate.selector) { + (Offer memory offer, Proof memory proof, Signature memory signature) = + abi.decode(data, (Offer, Proof, Signature)); + require(offer.buy && offer.obligation.loanToken == asset, IncorrectOffer()); + require(offer.maker == caller, IncorrectOwner()); + + (,, uint256 obligationUnits,) = MorphoV2(morphoV2) + .take(0, sellerAssets, 0, 0, address(this), offer, proof, signature, address(0), hex""); + + require(sellerAssets >= obligationUnits, PriceBelowOne()); + require(MorphoV2(morphoV2).debtOf(address(this), _obligationId(offer.obligation)) == 0, NoBorrowing()); + + removeUnits(offer.obligation, obligationUnits); + return (vaultIds(offer.obligation), -obligationUnits.toInt256()); + } else { + require(caller == address(this), SelfAllocationOnly()); + (uint256 obligationUnits, bytes32[] memory _ids) = abi.decode(data, (uint256, bytes32[])); + return (_ids, -obligationUnits.toInt256()); + } + } + + /* MORPHO V2 CALLBACKS */ + + function onRatify(Offer memory offer, address signer) external view returns (bool) { + // Collaterals will be checked at the level of vault ids. + require(msg.sender == address(morphoV2), NotMorphoV2()); + require(offer.obligation.loanToken == asset, LoanAssetMismatch()); + require(offer.maker == address(this), IncorrectOwner()); + require(offer.callback == address(this), IncorrectCallbackAddress()); + require(bytes32(offer.callbackData) != "forceDeallocate", IncorrectCallbackData()); + require(offer.obligation.maturity >= minTimeToMaturity + block.timestamp, IncorrectMaturity()); + require(offer.start <= block.timestamp, IncorrectStart()); + // uint48.max is the list end pointer + require(offer.obligation.maturity < type(uint48).max, IncorrectMaturity()); + require(signer == manager, IncorrectSigner()); + return true; + } + + function onBuy( + Obligation memory obligation, + address buyer, + uint256 buyerAssets, + uint256, + uint256 obligationUnits, + uint256, + bytes memory data + ) external { + require(msg.sender == address(morphoV2), NotMorphoV2()); + require(buyer == address(this), NotSelf()); + bytes32 obligationId = _obligationId(obligation); + uint48 prevMaturity = abi.decode(data, (uint48)); + require(prevMaturity < obligation.maturity, IncorrectHint()); + + accrueInterest(); + if (obligation.maturity > block.timestamp) { + uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); + uint128 gainedGrowth = ((obligationUnits - buyerAssets) / timeToMaturity).toUint128(); + lastRealAssetsEstimate += buyerAssets + (obligationUnits - buyerAssets) % timeToMaturity; + _positions[obligationId].growth += gainedGrowth; + _maturities[obligation.maturity].growthLostAtMaturity += gainedGrowth; + currentGrowth += gainedGrowth; + } else { + lastRealAssetsEstimate += obligationUnits; + } + + _positions[obligationId].units += obligationUnits.toUint128(); + + uint48 nextMaturity; + if (prevMaturity == 0) { + nextMaturity = firstMaturity; + } else { + nextMaturity = _maturities[prevMaturity].nextMaturity; + require(nextMaturity != 0, IncorrectHint()); + } + + while (nextMaturity < obligation.maturity) { + prevMaturity = nextMaturity; + nextMaturity = _maturities[prevMaturity].nextMaturity; + } + + if (nextMaturity > obligation.maturity) { + _maturities[obligation.maturity].nextMaturity = nextMaturity; + if (prevMaturity == 0) { + firstMaturity = obligation.maturity.toUint48(); + } else { + _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); + } + } + + IVaultV2(parentVault).allocate(address(this), abi.encode(obligationUnits, vaultIds(obligation)), buyerAssets); + } + + function onSell( + Obligation memory obligation, + address seller, + uint256, + uint256 sellerAssets, + uint256 obligationUnits, + uint256, + bytes memory + ) external { + require(msg.sender == address(morphoV2), NotMorphoV2()); + require(seller == address(this), NotSelf()); + require(MorphoV2(morphoV2).debtOf(seller, _obligationId(obligation)) == 0, NoBorrowing()); + + uint256 vaultRealAssets = IERC20(asset).balanceOf(address(parentVault)); + uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); + for (uint256 i = 0; i < adaptersLength; i++) { + vaultRealAssets += IAdapter(IVaultV2(parentVault).adapters(i)).realAssets(); + } + uint256 vaultBuffer = vaultRealAssets.zeroFloorSub(IVaultV2(parentVault).totalAssets()); + + uint256 realAssetsEstimateBefore = lastRealAssetsEstimate; + removeUnits(obligation, obligationUnits); + require(vaultBuffer >= realAssetsEstimateBefore.zeroFloorSub(lastRealAssetsEstimate), BufferTooLow()); + + IVaultV2(parentVault).deallocate(address(this), abi.encode(obligationUnits, vaultIds(obligation)), sellerAssets); + } + + /// INTERNAL FUNCTIONS /// + + /// @dev The assets estimate can go up after removing units to compensate for the rounded up lost growth. + function removeUnits(Obligation memory obligation, uint256 removedUnits) internal { + accrueInterest(); + bytes32 obligationId = _obligationId(obligation); + if (obligation.maturity > block.timestamp) { + uint256 timeToMaturity = obligation.maturity - block.timestamp; + uint128 removedGrowth = uint256(_positions[obligationId].growth) + .mulDivUp(removedUnits, _positions[obligationId].units).toUint128(); + _maturities[obligation.maturity].growthLostAtMaturity -= removedGrowth; + _positions[obligationId].growth -= removedGrowth; + _positions[obligationId].units -= removedUnits.toUint128(); + lastRealAssetsEstimate = lastRealAssetsEstimate + (removedGrowth * timeToMaturity) - removedUnits; + } else { + lastRealAssetsEstimate -= removedUnits; + _positions[obligationId].units -= removedUnits.toUint128(); + } + } + + function _obligationId(Obligation memory obligation) internal pure returns (bytes32) { + return keccak256(abi.encode(obligation)); + } + + function vaultIds(Obligation memory) internal pure returns (bytes32[] memory) { + // TODO return correct ids + return new bytes32[](0); + } + + function onLiquidate(Seizure[] memory, address, address, bytes memory) external pure { + revert(); + } +} diff --git a/src/adapters/MorphoMarketV2AdapterFactory.sol b/src/adapters/MorphoMarketV2AdapterFactory.sol new file mode 100644 index 000000000..0b4599b1c --- /dev/null +++ b/src/adapters/MorphoMarketV2AdapterFactory.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.28; + +import {MorphoMarketV2Adapter} from "./MorphoMarketV2Adapter.sol"; +import {IMorphoMarketV2AdapterFactory} from "./interfaces/IMorphoMarketV2AdapterFactory.sol"; + +contract MorphoMarketV2AdapterFactory is IMorphoMarketV2AdapterFactory { + /* STORAGE */ + + mapping(address parentVault => mapping(address morpho => address)) public morphoMarketV2Adapter; + mapping(address account => bool) public isMorphoMarketV2Adapter; + + /* FUNCTIONS */ + + function createMorphoMarketV2Adapter(address parentVault, address morpho) external returns (address) { + address _morphoMarketV2Adapter = address(new MorphoMarketV2Adapter{salt: bytes32(0)}(parentVault, morpho)); + morphoMarketV2Adapter[parentVault][morpho] = _morphoMarketV2Adapter; + isMorphoMarketV2Adapter[_morphoMarketV2Adapter] = true; + emit CreateMorphoMarketV2Adapter(parentVault, morpho, _morphoMarketV2Adapter); + return _morphoMarketV2Adapter; + } +} diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol new file mode 100644 index 000000000..6e8f5ab92 --- /dev/null +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity >=0.5.0; + +import {IAdapter} from "../../interfaces/IAdapter.sol"; +// import {Id, MarketParams} from "../../../lib/morpho-blue/src/interfaces/IMorpho.sol"; +import {Offer, Signature, Obligation, Collateral, Seizure} from "lib/morpho-v2/src/interfaces/IMorphoV2.sol"; +import {ICallbacks} from "lib/morpho-v2/src/interfaces/ICallbacks.sol"; + +// Position in an obligation +struct ObligationPosition { + uint128 units; + uint128 growth; +} + +// Chain of maturities, each can represent multiple obligations. +// nextMaturity is type(uint48).max if no next maturity +struct Maturity { + uint128 growthLostAtMaturity; + uint48 nextMaturity; +} + +interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { + /* EVENTS */ + + event SetSkimRecipient(address indexed newSkimRecipient); + event Skim(address indexed token, uint256 assets); + + /* ERRORS */ + + error BelowMinRate(); + error BufferTooLow(); + error IncorrectCallbackAddress(); + error IncorrectCallbackData(); + error IncorrectCollateralSet(); + error IncorrectExpiry(); + error IncorrectHint(); + error IncorrectMaturity(); + error IncorrectMinTimeToMaturity(); + error IncorrectOffer(); + error IncorrectOwner(); + error IncorrectProof(); + error IncorrectSignature(); + error IncorrectSigner(); + error IncorrectStart(); + error IncorrectUnits(); + error LoanAssetMismatch(); + error NoBorrowing(); + error NotAuthorized(); + error NotMorphoV2(); + error NotSelf(); + error PriceBelowOne(); + error SelfAllocationOnly(); + + /* FUNCTIONS */ + + function lastRealAssetsEstimate() external view returns (uint256); + function lastUpdate() external view returns (uint48); + function firstMaturity() external view returns (uint48); + function currentGrowth() external view returns (uint128); + function positions(bytes32 obligationId) external view returns (ObligationPosition memory); + function maturities(uint256 date) external view returns (Maturity memory); + function setSkimRecipient(address newSkimRecipient) external; + function skim(address token) external; + function setMinTimeToMaturity(uint256 minTimeToMaturity) external; + function setManager(address _manager) external; + function withdraw(Obligation memory obligation, uint256 units, uint256 shares) external; + function setRatified( + Offer memory offer, + Signature memory signature, + bytes32 root, + bytes32[] memory proof, + bool isRatified + ) external; + function minTimeToMaturity() external view returns (uint256); + function minRate() external view returns (uint256); + function manager() external view returns (address); + function parentVault() external view returns (address); + function accrueInterestView() external view returns (uint48, uint128, uint256); + function accrueInterest() external; + function realizeLoss(Obligation memory obligation) external; + function allocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) + external + returns (bytes32[] memory, int256); + function deallocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) + external + returns (bytes32[] memory, int256); + function onBuy( + Obligation memory obligation, + address buyer, + uint256 buyerAssets, + uint256 sellerAssets, + uint256 obligationUnits, + uint256 obligationShares, + bytes memory data + ) external; + function onSell( + Obligation memory obligation, + address seller, + uint256 buyerAssets, + uint256 sellerAssets, + uint256 obligationUnits, + uint256 obligationShares, + bytes memory data + ) external; + function onLiquidate(Seizure[] memory seizures, address borrower, address liquidator, bytes memory data) external; +} diff --git a/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol b/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol new file mode 100644 index 000000000..5065b1d64 --- /dev/null +++ b/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity >=0.5.0; + +interface IMorphoMarketV2AdapterFactory { + /* EVENTS */ + + event CreateMorphoMarketV2Adapter( + address indexed parentVault, address indexed morpho, address indexed morphoMarketV2Adapter + ); + + /* FUNCTIONS */ + + function morphoMarketV2Adapter(address parentVault, address morpho) external view returns (address); + function isMorphoMarketV2Adapter(address account) external view returns (bool); + function createMorphoMarketV2Adapter(address parentVault, address morpho) external returns (address); +} diff --git a/src/libraries/MathLib.sol b/src/libraries/MathLib.sol index 5306a92e4..b97820660 100644 --- a/src/libraries/MathLib.sol +++ b/src/libraries/MathLib.sol @@ -22,6 +22,12 @@ library MathLib { } } + /// @dev Casts from uint256 to uint48, reverting if input number is too large. + function toUint48(uint256 x) internal pure returns (uint48) { + require(x <= type(uint48).max, ErrorsLib.CastOverflow()); + return uint48(x); + } + /// @dev Casts from uint256 to uint128, reverting if input number is too large. function toUint128(uint256 x) internal pure returns (uint128) { require(x <= type(uint128).max, ErrorsLib.CastOverflow()); @@ -34,6 +40,12 @@ library MathLib { return uint256(x); } + /// @dev Casts from uint256 to int256, reverting if input number overflows. + function toInt256(uint256 x) internal pure returns (int256) { + require(x <= uint256(type(int256).max), ErrorsLib.CastOverflow()); + return int256(x); + } + /// @dev Returns min(x, y). function min(uint256 x, uint256 y) internal pure returns (uint256 z) { assembly { diff --git a/test/MorphoMarketV1AdapterTest.sol b/test/MorphoMarketV1AdapterTest.sol index 68d1e3dc5..63b51a2ba 100644 --- a/test/MorphoMarketV1AdapterTest.sol +++ b/test/MorphoMarketV1AdapterTest.sol @@ -125,10 +125,9 @@ contract MorphoMarketV1AdapterTest is Test { function testAllocate(uint256 assets) public { assets = _boundAssets(assets); - deal(address(loanToken), address(adapter), assets); + deal(address(loanToken), address(parentVault), assets); - (bytes32[] memory ids, int256 change) = - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), assets); + (bytes32[] memory ids, int256 change) = parentVault.allocate(address(adapter), abi.encode(marketParams), assets); assertEq(adapter.allocation(marketParams), assets, "Incorrect allocation"); assertEq(morpho.expectedSupplyAssets(marketParams, address(adapter)), assets, "Incorrect assets in Morpho"); @@ -149,20 +148,20 @@ contract MorphoMarketV1AdapterTest is Test { initialAssets = _boundAssets(initialAssets); withdrawAssets = bound(withdrawAssets, 1, initialAssets); - deal(address(loanToken), address(adapter), initialAssets); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), initialAssets); + deal(address(loanToken), address(parentVault), initialAssets); + parentVault.allocate(address(adapter), abi.encode(marketParams), initialAssets); uint256 beforeSupply = morpho.expectedSupplyAssets(marketParams, address(adapter)); assertEq(beforeSupply, initialAssets, "Precondition failed: supply not set"); (bytes32[] memory ids, int256 change) = - parentVault.deallocateMocked(address(adapter), abi.encode(marketParams), withdrawAssets); + parentVault.deallocate(address(adapter), abi.encode(marketParams), withdrawAssets); assertEq(change, -int256(withdrawAssets), "Incorrect change returned"); assertEq(adapter.allocation(marketParams), initialAssets - withdrawAssets, "Incorrect allocation"); uint256 afterSupply = morpho.expectedSupplyAssets(marketParams, address(adapter)); assertEq(afterSupply, initialAssets - withdrawAssets, "Supply not decreased correctly"); - assertEq(loanToken.balanceOf(address(adapter)), withdrawAssets, "Adapter did not receive withdrawn tokens"); + assertEq(loanToken.balanceOf(address(parentVault)), withdrawAssets, "Vault did not receive withdrawn tokens"); assertEq(ids.length, expectedIds.length, "Unexpected number of ids returned"); assertEq(ids, expectedIds, "Incorrect ids returned"); } @@ -170,13 +169,13 @@ contract MorphoMarketV1AdapterTest is Test { function testDeallocateAll(uint256 initialAssets) public { initialAssets = _boundAssets(initialAssets); - deal(address(loanToken), address(adapter), initialAssets); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), initialAssets); + deal(address(loanToken), address(parentVault), initialAssets); + parentVault.allocate(address(adapter), abi.encode(marketParams), initialAssets); uint256 beforeSupply = morpho.expectedSupplyAssets(marketParams, address(adapter)); assertEq(beforeSupply, initialAssets, "Precondition failed: supply not set"); - parentVault.deallocateMocked(address(adapter), abi.encode(marketParams), initialAssets); + parentVault.deallocate(address(adapter), abi.encode(marketParams), initialAssets); assertEq(adapter.marketParamsListLength(), 0, "Incorrect number of market params"); } @@ -302,8 +301,8 @@ contract MorphoMarketV1AdapterTest is Test { morpho.createMarket(otherMarketParams); // Deposit some assets - deal(address(loanToken), address(adapter), deposit * 2); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), deposit); + deal(address(loanToken), address(parentVault), deposit * 2); + parentVault.allocate(address(adapter), abi.encode(marketParams), deposit); uint256 realAssetsBefore = adapter.realAssets(); assertEq(realAssetsBefore, deposit, "realAssets not set correctly"); @@ -324,8 +323,8 @@ contract MorphoMarketV1AdapterTest is Test { deposit = bound(deposit, 1, MAX_TEST_ASSETS); loss = bound(loss, 1, deposit); - deal(address(loanToken), address(adapter), deposit); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), deposit); + deal(address(loanToken), address(parentVault), deposit); + parentVault.allocate(address(adapter), abi.encode(marketParams), deposit); _overrideMarketTotalSupplyAssets(-int256(loss)); assertEq(adapter.realAssets(), deposit - loss, "realAssets"); @@ -335,8 +334,8 @@ contract MorphoMarketV1AdapterTest is Test { deposit = bound(deposit, 1, MAX_TEST_ASSETS); interest = bound(interest, 1, deposit); - deal(address(loanToken), address(adapter), deposit); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), deposit); + deal(address(loanToken), address(parentVault), deposit); + parentVault.allocate(address(adapter), abi.encode(marketParams), deposit); _overrideMarketTotalSupplyAssets(int256(interest)); // approx because of the virtual shares. diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol new file mode 100644 index 000000000..bc90a87b7 --- /dev/null +++ b/test/MorphoMarketV2AdapterTest.sol @@ -0,0 +1,608 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +import "../lib/forge-std/src/Test.sol"; +import {MorphoMarketV2Adapter, Maturity, ObligationPosition} from "../src/adapters/MorphoMarketV2Adapter.sol"; +import {MorphoMarketV2AdapterFactory} from "../src/adapters/MorphoMarketV2AdapterFactory.sol"; +import {ERC20Mock} from "./mocks/ERC20Mock.sol"; +import {OracleMock} from "../lib/morpho-blue/src/mocks/OracleMock.sol"; +import {VaultV2Mock} from "./mocks/VaultV2Mock.sol"; +import {IERC20} from "../src/interfaces/IERC20.sol"; +import {IVaultV2} from "../src/interfaces/IVaultV2.sol"; +import {IMorphoMarketV2Adapter} from "../src/adapters/interfaces/IMorphoMarketV2Adapter.sol"; +import {IMorphoMarketV2AdapterFactory} from "../src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol"; +import {MathLib} from "../src/libraries/MathLib.sol"; +import {MathLib as MorphoV2MathLib} from "lib/morpho-v2/src/libraries/MathLib.sol"; +import {MorphoV2} from "../lib/morpho-v2/src/MorphoV2.sol"; +import {Offer, Signature, Obligation, Collateral, Proof} from "../lib/morpho-v2/src/interfaces/IMorphoV2.sol"; +import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; +import {stdError} from "../lib/forge-std/src/StdError.sol"; +import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; + +struct Step { + uint256 assets; + uint256 approxGrowth; + uint256 maturity; + Collateral[] collaterals; +} + +contract MorphoMarketV2AdapterTest is Test { + using stdStorage for StdStorage; + using MathLib for uint256; + + MorphoV2 internal morphoV2; + IMorphoMarketV2AdapterFactory internal factory; + IMorphoMarketV2Adapter internal adapter; + VaultV2Mock internal parentVault; + IERC20 internal loanToken; + IERC20 internal rewardToken; + address internal owner; + address internal curator; + address internal manager; + uint256 internal managerPrivateKey; + address internal taker; + address internal recipient; + address internal tradingFeeRecipient = makeAddr("tradingFeeRecipient"); + Collateral[] internal storedCollaterals; + Collateral[] internal storedSingleCollateral; + + mapping(address => uint256) internal privateKey; + + Offer storedOffer; + + uint256 internal constant MIN_TEST_ASSETS = 10; + uint256 internal constant MAX_TEST_ASSETS = 1e24; + + // Hardcoded obligation setups + Step[] internal steps00; + Step[] internal steps01; + + // Expected values after setting up obligations + mapping(bytes32 obligationId => ObligationPosition) expectedPositions; + mapping(uint256 timestamp => uint256) expectedMaturityGrowths; + uint256[] internal expectedPositionsList; + uint256[] internal expectedMaturitiesList; + uint256 internal expectedAddedGrowth; + uint256 internal expectedAddedAssets; + + function setUp() public { + owner = makeAddr("owner"); + curator = makeAddr("curator"); + (manager, managerPrivateKey) = makeAddrAndKey("manager"); + privateKey[manager] = managerPrivateKey; + + recipient = makeAddr("recipient"); + taker = makeAddr("taker"); + + morphoV2 = new MorphoV2(); + + vm.prank(morphoV2.owner()); + morphoV2.setTradingFeeRecipient(tradingFeeRecipient); + + loanToken = IERC20(address(new ERC20Mock(18))); + rewardToken = IERC20(address(new ERC20Mock(18))); + + parentVault = new VaultV2Mock(address(loanToken), owner, curator, address(0), address(0)); + + factory = new MorphoMarketV2AdapterFactory(); + adapter = MorphoMarketV2Adapter(factory.createMorphoMarketV2Adapter(address(parentVault), address(morphoV2))); + + vm.prank(parentVault.curator()); + adapter.setManager(manager); + + storedCollaterals.push( + Collateral({token: address(new ERC20Mock(18)), lltv: 0.8 ether, oracle: address(new OracleMock())}) + ); + storedCollaterals.push( + Collateral({token: address(new ERC20Mock(18)), lltv: 0.9 ether, oracle: address(new OracleMock())}) + ); + + OracleMock(storedCollaterals[0].oracle).setPrice(ORACLE_PRICE_SCALE); + OracleMock(storedCollaterals[1].oracle).setPrice(ORACLE_PRICE_SCALE); + + storedSingleCollateral.push(storedCollaterals[0]); + + uint256 maturity = vm.getBlockTimestamp() + 200; + uint256 rate = 0.05e18; + storedOffer = Offer({ + buy: true, + maker: address(adapter), + assets: 100, + obligationUnits: 0, + obligationShares: 0, + obligation: Obligation({ + chainId: block.chainid, + loanToken: address(loanToken), + collaterals: storedCollaterals, + maturity: maturity + }), + start: vm.getBlockTimestamp(), + expiry: maturity, + startPrice: 1e36 / (1e18 + rate * (maturity - vm.getBlockTimestamp()) / 365 days), + expiryPrice: 1e18, + group: bytes32(0), + session: bytes32(0), + ratifier: address(adapter), + callback: address(adapter), + callbackData: bytes("") + }); + + deal(address(loanToken), address(parentVault), 1_000_000e18); + + // steps00 is empty + + // 1.5e15 is ~1M dai lent at 5%/yr + steps01.push( + Step({ + assets: 100, approxGrowth: 1.5e15, maturity: vm.getBlockTimestamp() + 1, collaterals: storedCollaterals + }) + ); + steps01.push( + Step({ + assets: 100, approxGrowth: 2e15, maturity: vm.getBlockTimestamp() + 100, collaterals: storedCollaterals + }) + ); + steps01.push( + Step({ + assets: 100, approxGrowth: 1e15, maturity: vm.getBlockTimestamp() + 200, collaterals: storedCollaterals + }) + ); + steps01.push( + Step({ + assets: 100, approxGrowth: 1e15, maturity: vm.getBlockTimestamp() + 200, collaterals: storedCollaterals + }) + ); + steps01.push( + Step({ + assets: 100, + approxGrowth: 1e15, + maturity: vm.getBlockTimestamp() + 200, + collaterals: storedSingleCollateral + }) + ); + } + + function testSetMinTimeToMaturity(uint256 minTimeToMaturity) public { + uint256 goodMinTimeToMaturity = bound(minTimeToMaturity, 0, type(uint48).max); + uint256 badMinTimeToMaturity = bound(minTimeToMaturity, uint256(type(uint48).max) + 1, type(uint256).max); + vm.prank(adapter.manager()); + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectMinTimeToMaturity.selector); + adapter.setMinTimeToMaturity(badMinTimeToMaturity); + + vm.prank(adapter.manager()); + adapter.setMinTimeToMaturity(goodMinTimeToMaturity); + assertEq(adapter.minTimeToMaturity(), goodMinTimeToMaturity); + } + + function testSetManager(address sender, address newManager) public { + vm.assume(sender != adapter.manager()); + vm.assume(sender != IVaultV2(adapter.parentVault()).curator()); + vm.expectRevert(IMorphoMarketV2Adapter.NotAuthorized.selector); + adapter.setManager(newManager); + + uint256 snap = vm.snapshotState(); + + vm.prank(adapter.manager()); + adapter.setManager(newManager); + assertEq(adapter.manager(), newManager); + + vm.revertToStateAndDelete(snap); + vm.prank(IVaultV2(adapter.parentVault()).curator()); + adapter.setManager(newManager); + assertEq(adapter.manager(), newManager); + } + + function testSimpleBuy() public { + Offer memory offer = storedOffer; + + vm.startPrank(taker); + IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + deal(storedCollaterals[0].token, taker, 1_000e18); + deal(storedCollaterals[1].token, taker, 1_000e18); + morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[0].token), 1_000e18, taker); + morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[1].token), 1_000e18, taker); + vm.stopPrank(); + + uint256 assets = 1e18; + + offer.assets = 1e18; + offer.callback = address(adapter); + offer.callbackData = abi.encode(0); + vm.prank(taker); + morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], manager), address(0), ""); + + uint256 units = assets * 1e18 / offer.startPrice; + uint256 remainder = (units - assets) % (offer.obligation.maturity - vm.getBlockTimestamp()); + assertEq(adapter.lastRealAssetsEstimate(), assets + remainder, "lastRealAssetsEstimate"); + assertEq(adapter.lastUpdate(), vm.getBlockTimestamp(), "lastUpdate"); + assertEq(adapter.firstMaturity(), vm.getBlockTimestamp() + 200, "firstMaturity"); + + uint256 totalInterest = assets * 1e18 / offer.startPrice - assets; + uint256 duration = offer.obligation.maturity - vm.getBlockTimestamp(); + uint256 newGrowth = totalInterest / duration; + assertEq(adapter.currentGrowth(), newGrowth, "currentGrowth"); + Maturity memory maturity = adapter.maturities(offer.obligation.maturity); + assertEq(maturity.growthLostAtMaturity, newGrowth, "growthLostAtMaturity"); + assertEq(maturity.nextMaturity, type(uint48).max, "nextMaturity"); + + ObligationPosition memory position = adapter.positions(_obligationId(offer.obligation)); + assertEq(position.growth, newGrowth, "growth"); + assertEq(position.units, assets + totalInterest, "units"); + } + + /* RATIFICATION */ + + function _ratificationSetup() internal returns (Offer memory offer, uint256 minTimeToMaturity) { + minTimeToMaturity = bound(vm.randomUint(), 1, 10 * 365 days); + + offer.buy = true; + offer.maker = address(adapter); + offer.assets = 100; + + offer.obligation.chainId = block.chainid; + offer.obligation.loanToken = address(loanToken); + uint256 numCollaterals = bound(vm.randomUint(), 0, 3); + Collateral[] memory collaterals = new Collateral[](numCollaterals); + for (uint256 i = 0; i < numCollaterals; i++) { + collaterals[i] = + Collateral({token: address(new ERC20Mock(18)), lltv: 0.8 ether, oracle: address(new OracleMock())}); + } + offer.obligation.collaterals = collaterals; + offer.obligation.maturity = bound(vm.randomUint(), vm.getBlockTimestamp() + minTimeToMaturity, type(uint48).max); + + offer.start = bound(vm.randomUint(), 0, vm.getBlockTimestamp()); + offer.expiry = bound(vm.randomUint(), offer.start, type(uint48).max); + offer.startPrice = bound(vm.randomUint(), 1, 1e18); + if (offer.expiry > offer.start) { + offer.expiryPrice = bound(vm.randomUint(), offer.startPrice, 1e18); + } + offer.callback = address(adapter); + offer.callbackData = bytes(""); + + vm.prank(manager); + adapter.setMinTimeToMaturity(minTimeToMaturity); + } + + function testRatifyIncorrectOfferBadSellSigner(uint256 seed, address otherSigner) public { + vm.setSeed(seed); + vm.assume(otherSigner != manager); + (Offer memory offer,) = _ratificationSetup(); + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectSigner.selector); + vm.prank(address(morphoV2)); + adapter.onRatify(offer, otherSigner); + } + + function testRatifyIncorrectOfferBadBuySigner(uint256 seed, address otherSigner) public { + vm.setSeed(seed); + vm.assume(otherSigner != manager); + (Offer memory offer,) = _ratificationSetup(); + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectSigner.selector); + vm.prank(address(morphoV2)); + adapter.onRatify(offer, otherSigner); + } + + function testRatifyLoanAssetMismatch(uint256 seed, address otherToken) public { + vm.setSeed(seed); + (Offer memory offer,) = _ratificationSetup(); + vm.assume(otherToken != offer.obligation.loanToken); + offer.obligation.loanToken = otherToken; + vm.expectRevert(IMorphoMarketV2Adapter.LoanAssetMismatch.selector); + vm.prank(address(morphoV2)); + adapter.onRatify(offer, manager); + } + + function testRatifyIncorrectOwner(uint256 seed, address otherMaker) public { + vm.setSeed(seed); + (Offer memory offer,) = _ratificationSetup(); + vm.assume(otherMaker != address(adapter)); + offer.maker = otherMaker; + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectOwner.selector); + vm.prank(address(morphoV2)); + adapter.onRatify(offer, manager); + } + + function testRatifyIncorrectMaturity(uint256 seed) public { + vm.setSeed(seed); + (Offer memory offer, uint256 minTimeToMaturity) = _ratificationSetup(); + offer.obligation.maturity = vm.getBlockTimestamp() + minTimeToMaturity - 1; + if (offer.obligation.maturity < vm.getBlockTimestamp()) { + vm.expectRevert(stdError.arithmeticError); + } else { + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectMaturity.selector); + } + vm.prank(address(morphoV2)); + adapter.onRatify(offer, manager); + } + + function testRatifyIncorrectStart(uint256 seed) public { + vm.setSeed(seed); + (Offer memory offer,) = _ratificationSetup(); + offer.start = vm.getBlockTimestamp() + 1; + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectStart.selector); + vm.prank(address(morphoV2)); + adapter.onRatify(offer, manager); + } + + function testRatifyIncorrectCallbackAddress(uint256 seed) public { + vm.setSeed(seed); + (Offer memory offer,) = _ratificationSetup(); + offer.callback = address(0); + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectCallbackAddress.selector); + vm.prank(address(morphoV2)); + adapter.onRatify(offer, manager); + } + + function testRatifyIncorrectExpiry(uint256 seed) public { + vm.setSeed(seed); + (Offer memory offer,) = _ratificationSetup(); + vm.prank(address(morphoV2)); + adapter.onRatify(offer, manager); + } + + /* STEPS SETUP */ + + function setupObligations(Step[] memory steps) internal { + vm.startPrank(taker); + IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + vm.stopPrank(); + + Offer memory offer = Offer({ + buy: true, + maker: address(adapter), + start: vm.getBlockTimestamp(), + expiry: vm.getBlockTimestamp() + 1, + expiryPrice: 1e18, + callback: address(adapter), + callbackData: abi.encode(0), + obligation: Obligation({ + chainId: block.chainid, + loanToken: address(loanToken), + collaterals: storedCollaterals, + // will be adjusted in loop + maturity: 0 + }), + // will be adjusted in loop + startPrice: 0, + assets: 0, + obligationUnits: 0, + obligationShares: 0, + group: bytes32(0), + session: bytes32(0), + ratifier: address(adapter) + }); + + for (uint256 i = 0; i < steps.length; i++) { + Step memory step = steps[i]; + uint256 timeToMaturity = step.maturity - vm.getBlockTimestamp(); + require(timeToMaturity > 0 || step.approxGrowth == 0, "nonzero growth on 0 duration"); + uint256 approxInterest = step.approxGrowth * timeToMaturity; + offer.group = bytes32(i); + offer.assets = step.assets; + offer.obligation.maturity = step.maturity; + offer.startPrice = step.assets.mulDivDown(1e18, step.assets + approxInterest); + uint256 units = step.assets.mulDivDown(1e18, offer.startPrice); + uint256 actualGrowth = (units - offer.assets) / timeToMaturity; + uint256 zeroPeriodGain = (units - offer.assets) % timeToMaturity; + // uint actualInterest = actualGrowth * timeToMaturity; + bytes32 obligationId = _obligationId(offer.obligation); + + vm.startPrank(taker); + deal(storedCollaterals[0].token, taker, 1_000e18); + deal(storedCollaterals[1].token, taker, 1_000e18); + morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[0].token), 1_000e18, taker); + morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[1].token), 1_000e18, taker); + + ObligationPosition memory positionBefore = adapter.positions(obligationId); + morphoV2.take(step.assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], manager), address(0), ""); + vm.stopPrank(); + + assertEq(adapter.positions(obligationId).units, positionBefore.units + units, "setup: units 1"); + + expectedPositions[obligationId].units += units.toUint128(); + expectedPositions[obligationId].growth += actualGrowth.toUint128(); + if (timeToMaturity > 0) { + expectedMaturityGrowths[step.maturity] += actualGrowth.toUint128(); + expectedAddedGrowth += actualGrowth.toUint128(); + } + expectedAddedAssets += step.assets + zeroPeriodGain; + expectedPositionsList.push(uint256(obligationId)); + expectedMaturitiesList.push(step.maturity); + } + expectedPositionsList = removeCopies(expectedPositionsList); + expectedMaturitiesList = removeCopies(expectedMaturitiesList); + } + + // Apply steps in random order and test that the effect on the state is correct. + // TODO when building a list must move forward in time so that coverage is complete + function stepsSetupTest(Step[] storage steps) internal { + uint256[] memory indices = new uint256[](steps.length); + for (uint256 i = 0; i < steps.length; i++) { + indices[i] = i; + } + indices = vm.shuffle(indices); + + setupObligations(steps); + + // Check pointer to first element of maturities list + if (steps.length > 0) { + assertEq(adapter.firstMaturity(), steps[0].maturity, "firstMaturity"); + } else { + assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity"); + } + + // Check maturities growth and linked list structure + for (uint256 i = 0; i < expectedMaturitiesList.length; i++) { + assertEq( + adapter.maturities(expectedMaturitiesList[i]).growthLostAtMaturity, + expectedMaturityGrowths[expectedMaturitiesList[i]], + "growthLostAtMaturity" + ); + if (i == expectedMaturitiesList.length - 1) { + assertEq( + adapter.maturities(expectedMaturitiesList[i]).nextMaturity, type(uint48).max, "nextMaturity end" + ); + } else { + assertEq( + adapter.maturities(expectedMaturitiesList[i]).nextMaturity, + expectedMaturitiesList[i + 1], + "nextMaturity middle" + ); + } + } + + // Check positions growth and size + for (uint256 i = 0; i < expectedPositionsList.length; i++) { + bytes32 obligationId = bytes32(expectedPositionsList[i]); + ObligationPosition memory position = adapter.positions(obligationId); + ObligationPosition memory expectedPosition = expectedPositions[obligationId]; + assertEq(position.growth, expectedPosition.growth, "growth"); + assertEq(position.units, expectedPosition.units, "units"); + } + } + + function testStepsSetup00(uint256 seed) public { + vm.setSeed(seed); + stepsSetupTest(steps00); + } + + function testStepsSetup01(uint256 seed) public { + vm.setSeed(seed); + stepsSetupTest(steps01); + } + + /* ACCRUE INTEREST USING STEPS */ + + // Apply steps and test that accrueInterestView over time is correct. + function accrueInterestViewTest( + Step[] memory steps, + uint256 initialGrowth, + uint256 lastRealAssetsEstimate, + uint256 elapsed + ) internal { + uint256 begin = vm.getBlockTimestamp(); + initialGrowth = bound(initialGrowth, 0, 1e36); + lastRealAssetsEstimate = bound(lastRealAssetsEstimate, 0, type(uint128).max); + uint256 maxElapsed = + steps.length == 0 ? 365 days : 2 * (steps[steps.length - 1].maturity - vm.getBlockTimestamp()); + elapsed = bound(elapsed, 0, maxElapsed); + + setCurrentGrowth(uint128(initialGrowth)); + setLastRealAssetsEstimate(lastRealAssetsEstimate); + setupObligations(steps); + uint256 expectedCurrentGrowth = initialGrowth + expectedAddedGrowth; + assertEq(adapter.currentGrowth(), expectedCurrentGrowth, "currentGrowth"); + assertEq( + adapter.lastRealAssetsEstimate(), lastRealAssetsEstimate + expectedAddedAssets, "lastRealAssetsEstimate" + ); + + skip(elapsed); + + (uint48 nextMaturity, uint128 newGrowth, uint256 newRealAssetsEstimate) = adapter.accrueInterestView(); + + uint256 lostGrowth = 0; + uint256 interest = initialGrowth * elapsed; + uint256 expectedNextMaturity = type(uint48).max; + + for (uint256 i = 0; i < expectedMaturitiesList.length; i++) { + uint256 maturity = expectedMaturitiesList[i]; + if (maturity < vm.getBlockTimestamp()) { + lostGrowth += expectedMaturityGrowths[maturity]; + interest += expectedMaturityGrowths[maturity] * (maturity - begin); + } else { + interest += expectedMaturityGrowths[maturity] * elapsed; + } + if (maturity >= vm.getBlockTimestamp() && maturity < expectedNextMaturity) { + expectedNextMaturity = maturity; + } + } + assertEq(nextMaturity, expectedNextMaturity, "nextMaturity"); + assertEq(newGrowth, expectedCurrentGrowth - lostGrowth, "newGrowth"); + assertEq( + newRealAssetsEstimate, lastRealAssetsEstimate + expectedAddedAssets + interest, "newRealAssetsEstimate" + ); + } + + function testAccrueInterestView00(uint256 growth, uint256 lastRealAssetsEstimate, uint256 elapsed) public { + accrueInterestViewTest(steps00, growth, lastRealAssetsEstimate, elapsed); + } + + function testAccrueInterestView01(uint256 growth, uint256 lastRealAssetsEstimate, uint256 elapsed) public { + accrueInterestViewTest(steps01, growth, lastRealAssetsEstimate, elapsed); + } + + /* UTILITIES */ + + function setCurrentGrowth(uint128 growth) internal { + stdstore.target(address(adapter)).enable_packed_slots().sig("currentGrowth()").checked_write(growth); + } + + function setLastRealAssetsEstimate(uint256 lastRealAssetsEstimate) internal { + stdstore.target(address(adapter)).sig("lastRealAssetsEstimate()").checked_write(lastRealAssetsEstimate); + } + + function removeCopies(uint256[] storage array) internal returns (uint256[] memory) { + uint256[] memory sorted = vm.sort(array); + uint256 numCopies = 0; + for (uint256 i = 0; i + 1 < sorted.length; i++) { + if (sorted[i] == sorted[i + 1]) numCopies++; + } + uint256[] memory res = new uint256[](sorted.length - numCopies); + uint256 resIndex = 0; + for (uint256 i = 0; i < sorted.length; i++) { + if (i == 0 || sorted[i - 1] != sorted[i]) res[resIndex++] = sorted[i]; + } + return res; + } + + function _obligationId(Obligation memory obligation) internal pure returns (bytes32) { + return keccak256(abi.encode(obligation)); + } + + function sign(Offer[1] memory offers) internal view returns (Signature memory) { + return messageSig(root(offers), offers[0].maker); + } + + function sign(Offer[1] memory offers, address signer) internal view returns (Signature memory) { + return messageSig(root(offers), signer); + } + + function proof(Offer[1] memory offers) internal pure returns (Proof memory) { + return Proof({root: root(offers), path: new bytes32[](0)}); + } + + function sign(Offer[2] memory offers) internal view returns (Signature memory) { + return messageSig(root(offers), offers[0].maker); + } + + // assumes the offer is the first one! + function proof(Offer[2] memory offers) internal pure returns (Proof memory) { + Proof memory _proof = Proof({root: root(offers), path: new bytes32[](1)}); + _proof.path[0] = keccak256(abi.encode(offers[1])); + return _proof; + } + + function root(Offer memory offer) internal pure returns (bytes32) { + return keccak256(abi.encode(offer)); + } + + function root(Offer[1] memory offers) internal pure returns (bytes32) { + return keccak256(abi.encode(offers[0])); + } + + function root(Offer[2] memory offers) internal pure returns (bytes32) { + return keccak256(sort(keccak256(abi.encode(offers[0])), keccak256(abi.encode(offers[1])))); + } + + function messageSig(bytes32 _root, address signer) internal view returns (Signature memory sig) { + bytes32 messageHash = keccak256(bytes.concat("\x19\x45thereum Signed Message:\n32", _root)); + (sig.v, sig.r, sig.s) = vm.sign(privateKey[signer], messageHash); + } + + /// @dev Returns the concatenation of x and y, sorted lexicographically. + function sort(bytes32 x, bytes32 y) internal pure returns (bytes memory) { + return x < y ? abi.encodePacked(x, y) : abi.encodePacked(y, x); + } +} diff --git a/test/MorphoVaultV1AdapterTest.sol b/test/MorphoVaultV1AdapterTest.sol index c9ed99b27..dcec77167 100644 --- a/test/MorphoVaultV1AdapterTest.sol +++ b/test/MorphoVaultV1AdapterTest.sol @@ -44,6 +44,8 @@ contract MorphoVaultV1AdapterTest is Test { factory = new MorphoVaultV1AdapterFactory(); adapter = MorphoVaultV1Adapter(factory.createMorphoVaultV1Adapter(address(parentVault), address(morphoVaultV1))); + vm.prank(address(adapter)); + asset.approve(address(morphoVaultV1), type(uint256).max); deal(address(asset), address(this), type(uint256).max); asset.approve(address(morphoVaultV1), type(uint256).max); @@ -76,9 +78,9 @@ contract MorphoVaultV1AdapterTest is Test { function testAllocate(uint256 assets) public { assets = bound(assets, 0, MAX_TEST_ASSETS); - deal(address(asset), address(adapter), assets); - (bytes32[] memory ids, int256 change) = parentVault.allocateMocked(address(adapter), hex"", assets); + deal(address(asset), address(parentVault), assets); + (bytes32[] memory ids, int256 change) = parentVault.allocate(address(adapter), hex"", assets); uint256 adapterShares = morphoVaultV1.balanceOf(address(adapter)); assertEq(adapterShares, assets * EXCHANGE_RATE, "Incorrect share balance after deposit"); @@ -91,20 +93,21 @@ contract MorphoVaultV1AdapterTest is Test { initialAssets = bound(initialAssets, 0, MAX_TEST_ASSETS); withdrawAssets = bound(withdrawAssets, 0, initialAssets); - deal(address(asset), address(adapter), initialAssets); - parentVault.allocateMocked(address(adapter), hex"", initialAssets); + deal(address(asset), address(parentVault), initialAssets); + parentVault.allocate(address(adapter), hex"", initialAssets); uint256 beforeShares = morphoVaultV1.balanceOf(address(adapter)); assertEq(beforeShares, initialAssets * EXCHANGE_RATE, "Precondition failed: shares not set"); - (bytes32[] memory ids, int256 change) = parentVault.deallocateMocked(address(adapter), hex"", withdrawAssets); + (bytes32[] memory ids, int256 change) = parentVault.deallocate(address(adapter), hex"", withdrawAssets); assertEq(adapter.allocation(), initialAssets - withdrawAssets, "incorrect allocation"); uint256 afterShares = morphoVaultV1.balanceOf(address(adapter)); assertEq(afterShares, (initialAssets - withdrawAssets) * EXCHANGE_RATE, "Share balance not decreased correctly"); - uint256 adapterBalance = asset.balanceOf(address(adapter)); - assertEq(adapterBalance, withdrawAssets, "Adapter did not receive withdrawn tokens"); + uint256 parentVaultBalance = asset.balanceOf(address(parentVault)); + assertEq(parentVaultBalance, withdrawAssets, "Parent vault did not receive withdrawn tokens"); + assertEq(ids.length, expectedIds.length, "Incorrect ids returned"); assertEq(ids, expectedIds, "Incorrect ids returned"); assertEq(change, -int256(withdrawAssets), "Incorrect change returned"); } @@ -217,8 +220,8 @@ contract MorphoVaultV1AdapterTest is Test { ERC4626MockExtended otherVault = new ERC4626MockExtended(address(asset)); // Deposit some assets - deal(address(asset), address(adapter), deposit * 2); - parentVault.allocateMocked(address(adapter), hex"", deposit); + deal(address(asset), address(parentVault), deposit); + parentVault.allocate(address(adapter), hex"", deposit); uint256 realAssetsBefore = adapter.realAssets(); @@ -239,8 +242,8 @@ contract MorphoVaultV1AdapterTest is Test { deposit = bound(deposit, 1, MAX_TEST_ASSETS); loss = bound(loss, 1, deposit); - deal(address(asset), address(adapter), deposit); - parentVault.allocateMocked(address(adapter), hex"", deposit); + deal(address(asset), address(parentVault), deposit); + parentVault.allocate(address(adapter), hex"", deposit); morphoVaultV1.lose(loss); assertEq(adapter.realAssets(), deposit - loss, "realAssets"); @@ -250,8 +253,8 @@ contract MorphoVaultV1AdapterTest is Test { deposit = bound(deposit, 1, MAX_TEST_ASSETS); interest = bound(interest, 1, deposit); - deal(address(asset), address(adapter), deposit); - parentVault.allocateMocked(address(adapter), hex"", deposit); + deal(address(asset), address(parentVault), deposit); + parentVault.allocate(address(adapter), hex"", deposit); asset.transfer(address(morphoVaultV1), interest); // approx because of the virtual shares. diff --git a/test/mocks/VaultV2Mock.sol b/test/mocks/VaultV2Mock.sol index ad6f8fa47..6aec6c525 100644 --- a/test/mocks/VaultV2Mock.sol +++ b/test/mocks/VaultV2Mock.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import {IAdapter} from "../../src/interfaces/IAdapter.sol"; +import {SafeERC20Lib} from "../../src/libraries/SafeERC20Lib.sol"; /// @notice Minimal stub contract used as the parent vault to test adapters. contract VaultV2Mock { @@ -23,10 +24,8 @@ contract VaultV2Mock { function accrueInterest() public {} - function allocateMocked(address adapter, bytes memory data, uint256 assets) - external - returns (bytes32[] memory, int256) - { + function allocate(address adapter, bytes memory data, uint256 assets) external returns (bytes32[] memory, int256) { + SafeERC20Lib.safeTransfer(asset, adapter, assets); (bytes32[] memory ids, int256 change) = IAdapter(adapter).allocate(data, assets, msg.sig, msg.sender); for (uint256 i; i < ids.length; i++) { allocation[ids[i]] = uint256(int256(allocation[ids[i]]) + change); @@ -34,7 +33,7 @@ contract VaultV2Mock { return (ids, change); } - function deallocateMocked(address adapter, bytes memory data, uint256 assets) + function deallocate(address adapter, bytes memory data, uint256 assets) external returns (bytes32[] memory, int256) { @@ -42,6 +41,7 @@ contract VaultV2Mock { for (uint256 i; i < ids.length; i++) { allocation[ids[i]] = uint256(int256(allocation[ids[i]]) + change); } + SafeERC20Lib.safeTransferFrom(asset, adapter, address(this), assets); return (ids, change); } } From 0999c10272e6ca8a0bfb68690a6d3645a0cc1343 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 11 Nov 2025 14:50:42 +0100 Subject: [PATCH 2/7] docs: clarify --- src/adapters/MorphoMarketV2Adapter.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index d06cf7dc7..fdb887a07 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -173,8 +173,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return (_ids, obligationUnits.toInt256()); } - /// @dev Can only be called from vault.deallocate from a sell callback where the adapter is the maker. - /// @dev Can be called from vault.forceDeallocate to trigger a sell take by the adapter. + /// @dev Can be called from vault.deallocate from a sell callback where the adapter is the maker, + /// @dev or from vault.forceDeallocate to trigger a sell take by the adapter. /// @dev In a forceDeallocate, the user may have to set a buyer price above 1 so that the seller price is at least 1 /// despite the fees. function deallocate(bytes memory data, uint256 sellerAssets, bytes4 messageSig, address caller) From c56bf187d5fa5d2997071a92d961020501961fdc Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 12 Nov 2025 11:28:22 +0100 Subject: [PATCH 3/7] fix: enforce price of 1 in force deallocate --- src/adapters/MorphoMarketV2Adapter.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index fdb887a07..ceb0d75b4 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -184,13 +184,16 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { if (messageSig == IVaultV2.forceDeallocate.selector) { (Offer memory offer, Proof memory proof, Signature memory signature) = abi.decode(data, (Offer, Proof, Signature)); - require(offer.buy && offer.obligation.loanToken == asset, IncorrectOffer()); + require( + offer.buy && offer.obligation.loanToken == asset && offer.startPrice == 1e18 + && offer.expiryPrice == 1e18, + IncorrectOffer() + ); require(offer.maker == caller, IncorrectOwner()); (,, uint256 obligationUnits,) = MorphoV2(morphoV2) .take(0, sellerAssets, 0, 0, address(this), offer, proof, signature, address(0), hex""); - require(sellerAssets >= obligationUnits, PriceBelowOne()); require(MorphoV2(morphoV2).debtOf(address(this), _obligationId(offer.obligation)) == 0, NoBorrowing()); removeUnits(offer.obligation, obligationUnits); From 70db510f86207d5dcdfee9360521bad0f803033f Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 17 Nov 2025 15:21:08 +0100 Subject: [PATCH 4/7] feat: no manager, rename vars, doc --- src/adapters/MorphoMarketV2Adapter.sol | 49 +++++++++----------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index ceb0d75b4..837fd931b 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -26,14 +26,13 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* MANAGEMENT */ - address public manager; address public skimRecipient; uint256 public minTimeToMaturity; uint256 public minRate; /* ACCOUNTING */ - uint256 public lastRealAssetsEstimate; + uint256 public _totalAssets; uint48 public lastUpdate; uint48 public firstMaturity; uint128 public currentGrowth; @@ -46,7 +45,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { parentVault = _parentVault; morphoV2 = _morphoV2; lastUpdate = uint48(block.timestamp); - manager = IVaultV2(parentVault).curator(); SafeERC20Lib.safeApprove(asset, _morphoV2, type(uint256).max); SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); firstMaturity = type(uint48).max; @@ -79,38 +77,25 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { emit Skim(token, balance); } - /* MANAGEMENT FUNCTIONS */ + /* VAULT CURATOR FUNCTIONS */ function setMinTimeToMaturity(uint256 _minTimeToMaturity) external { - require(msg.sender == manager, NotAuthorized()); + require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); require(_minTimeToMaturity <= type(uint48).max, IncorrectMinTimeToMaturity()); minTimeToMaturity = _minTimeToMaturity; } - function setManager(address _manager) external { - require(msg.sender == manager || msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); - manager = _manager; - } + /* VAULT ALLOCATORS FUNCTIONS */ // Do not cleanup the linked list if we end up at 0 growth function withdraw(Obligation memory obligation, uint256 obligationUnits, uint256 shares) external { - require(msg.sender == manager, NotAuthorized()); + require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); (obligationUnits, shares) = MorphoV2(morphoV2).withdraw(obligation, obligationUnits, shares, address(this)); removeUnits(obligation, obligationUnits); IVaultV2(parentVault) .deallocate(address(this), abi.encode(obligationUnits, vaultIds(obligation)), obligationUnits); } - /* RATIFICATION FUNCTIONS */ - - function setRatified( - Offer memory offer, - Signature memory signature, - bytes32 root, - bytes32[] memory proof, - bool isRatified - ) external {} - /* ACCRUAL */ function accrueInterestView() public view returns (uint48, uint128, uint256) { @@ -128,19 +113,20 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { gainedAssets += uint256(newGrowth) * (block.timestamp - lastChange); - return (nextMaturity, newGrowth, lastRealAssetsEstimate + gainedAssets); + return (nextMaturity, newGrowth, _totalAssets + gainedAssets); } function accrueInterest() public { if (lastUpdate != block.timestamp) { (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets) = accrueInterestView(); - lastRealAssetsEstimate = newTotalAssets; + _totalAssets = newTotalAssets; lastUpdate = uint48(block.timestamp); firstMaturity = nextMaturity; currentGrowth = newGrowth; } } + /// @dev Returns an estimate of the real assets. function realAssets() external view returns (uint256) { (,, uint256 newTotalAssets) = accrueInterestView(); return newTotalAssets; @@ -175,8 +161,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /// @dev Can be called from vault.deallocate from a sell callback where the adapter is the maker, /// @dev or from vault.forceDeallocate to trigger a sell take by the adapter. - /// @dev In a forceDeallocate, the user may have to set a buyer price above 1 so that the seller price is at least 1 - /// despite the fees. function deallocate(bytes memory data, uint256 sellerAssets, bytes4 messageSig, address caller) external returns (bytes32[] memory, int256) @@ -189,7 +173,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { && offer.expiryPrice == 1e18, IncorrectOffer() ); - require(offer.maker == caller, IncorrectOwner()); (,, uint256 obligationUnits,) = MorphoV2(morphoV2) .take(0, sellerAssets, 0, 0, address(this), offer, proof, signature, address(0), hex""); @@ -218,7 +201,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(offer.start <= block.timestamp, IncorrectStart()); // uint48.max is the list end pointer require(offer.obligation.maturity < type(uint48).max, IncorrectMaturity()); - require(signer == manager, IncorrectSigner()); + require(IVaultV2(parentVault).isAllocator(signer), IncorrectSigner()); return true; } @@ -241,12 +224,12 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { if (obligation.maturity > block.timestamp) { uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); uint128 gainedGrowth = ((obligationUnits - buyerAssets) / timeToMaturity).toUint128(); - lastRealAssetsEstimate += buyerAssets + (obligationUnits - buyerAssets) % timeToMaturity; + _totalAssets += buyerAssets + (obligationUnits - buyerAssets) % timeToMaturity; _positions[obligationId].growth += gainedGrowth; _maturities[obligation.maturity].growthLostAtMaturity += gainedGrowth; currentGrowth += gainedGrowth; } else { - lastRealAssetsEstimate += obligationUnits; + _totalAssets += obligationUnits; } _positions[obligationId].units += obligationUnits.toUint128(); @@ -296,16 +279,16 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } uint256 vaultBuffer = vaultRealAssets.zeroFloorSub(IVaultV2(parentVault).totalAssets()); - uint256 realAssetsEstimateBefore = lastRealAssetsEstimate; + uint256 _totalAssetsBefore = _totalAssets; removeUnits(obligation, obligationUnits); - require(vaultBuffer >= realAssetsEstimateBefore.zeroFloorSub(lastRealAssetsEstimate), BufferTooLow()); + require(vaultBuffer >= _totalAssetsBefore.zeroFloorSub(_totalAssets), BufferTooLow()); IVaultV2(parentVault).deallocate(address(this), abi.encode(obligationUnits, vaultIds(obligation)), sellerAssets); } /// INTERNAL FUNCTIONS /// - /// @dev The assets estimate can go up after removing units to compensate for the rounded up lost growth. + /// @dev The total assets can go up after removing units to compensate for the rounded up lost growth. function removeUnits(Obligation memory obligation, uint256 removedUnits) internal { accrueInterest(); bytes32 obligationId = _obligationId(obligation); @@ -316,9 +299,9 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { _maturities[obligation.maturity].growthLostAtMaturity -= removedGrowth; _positions[obligationId].growth -= removedGrowth; _positions[obligationId].units -= removedUnits.toUint128(); - lastRealAssetsEstimate = lastRealAssetsEstimate + (removedGrowth * timeToMaturity) - removedUnits; + _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { - lastRealAssetsEstimate -= removedUnits; + _totalAssets -= removedUnits; _positions[obligationId].units -= removedUnits.toUint128(); } } From 55511bc4df2b2a7c471e7e50d6d8338d427a11dc Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 17 Nov 2025 17:06:15 +0100 Subject: [PATCH 5/7] fix tests and interface --- .../interfaces/IMorphoMarketV2Adapter.sol | 11 +-- test/MorphoMarketV2AdapterTest.sol | 97 +++++++------------ 2 files changed, 37 insertions(+), 71 deletions(-) diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index 6e8f5ab92..11796569d 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -54,7 +54,7 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { /* FUNCTIONS */ - function lastRealAssetsEstimate() external view returns (uint256); + function _totalAssets() external view returns (uint256); function lastUpdate() external view returns (uint48); function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); @@ -63,18 +63,9 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { function setSkimRecipient(address newSkimRecipient) external; function skim(address token) external; function setMinTimeToMaturity(uint256 minTimeToMaturity) external; - function setManager(address _manager) external; function withdraw(Obligation memory obligation, uint256 units, uint256 shares) external; - function setRatified( - Offer memory offer, - Signature memory signature, - bytes32 root, - bytes32[] memory proof, - bool isRatified - ) external; function minTimeToMaturity() external view returns (uint256); function minRate() external view returns (uint256); - function manager() external view returns (address); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); function accrueInterest() external; diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index bc90a87b7..98c5680f9 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -39,8 +39,8 @@ contract MorphoMarketV2AdapterTest is Test { IERC20 internal rewardToken; address internal owner; address internal curator; - address internal manager; - uint256 internal managerPrivateKey; + address internal signerAllocator; + uint256 internal signerAllocatorPrivateKey; address internal taker; address internal recipient; address internal tradingFeeRecipient = makeAddr("tradingFeeRecipient"); @@ -69,8 +69,8 @@ contract MorphoMarketV2AdapterTest is Test { function setUp() public { owner = makeAddr("owner"); curator = makeAddr("curator"); - (manager, managerPrivateKey) = makeAddrAndKey("manager"); - privateKey[manager] = managerPrivateKey; + (signerAllocator, signerAllocatorPrivateKey) = makeAddrAndKey("signerAllocator"); + privateKey[signerAllocator] = signerAllocatorPrivateKey; recipient = makeAddr("recipient"); taker = makeAddr("taker"); @@ -83,14 +83,11 @@ contract MorphoMarketV2AdapterTest is Test { loanToken = IERC20(address(new ERC20Mock(18))); rewardToken = IERC20(address(new ERC20Mock(18))); - parentVault = new VaultV2Mock(address(loanToken), owner, curator, address(0), address(0)); + parentVault = new VaultV2Mock(address(loanToken), owner, curator, signerAllocator, address(0)); factory = new MorphoMarketV2AdapterFactory(); adapter = MorphoMarketV2Adapter(factory.createMorphoMarketV2Adapter(address(parentVault), address(morphoV2))); - vm.prank(parentVault.curator()); - adapter.setManager(manager); - storedCollaterals.push( Collateral({token: address(new ERC20Mock(18)), lltv: 0.8 ether, oracle: address(new OracleMock())}) ); @@ -166,33 +163,15 @@ contract MorphoMarketV2AdapterTest is Test { function testSetMinTimeToMaturity(uint256 minTimeToMaturity) public { uint256 goodMinTimeToMaturity = bound(minTimeToMaturity, 0, type(uint48).max); uint256 badMinTimeToMaturity = bound(minTimeToMaturity, uint256(type(uint48).max) + 1, type(uint256).max); - vm.prank(adapter.manager()); + vm.prank(parentVault.curator()); vm.expectRevert(IMorphoMarketV2Adapter.IncorrectMinTimeToMaturity.selector); adapter.setMinTimeToMaturity(badMinTimeToMaturity); - vm.prank(adapter.manager()); + vm.prank(parentVault.curator()); adapter.setMinTimeToMaturity(goodMinTimeToMaturity); assertEq(adapter.minTimeToMaturity(), goodMinTimeToMaturity); } - function testSetManager(address sender, address newManager) public { - vm.assume(sender != adapter.manager()); - vm.assume(sender != IVaultV2(adapter.parentVault()).curator()); - vm.expectRevert(IMorphoMarketV2Adapter.NotAuthorized.selector); - adapter.setManager(newManager); - - uint256 snap = vm.snapshotState(); - - vm.prank(adapter.manager()); - adapter.setManager(newManager); - assertEq(adapter.manager(), newManager); - - vm.revertToStateAndDelete(snap); - vm.prank(IVaultV2(adapter.parentVault()).curator()); - adapter.setManager(newManager); - assertEq(adapter.manager(), newManager); - } - function testSimpleBuy() public { Offer memory offer = storedOffer; @@ -211,11 +190,11 @@ contract MorphoMarketV2AdapterTest is Test { offer.callback = address(adapter); offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], manager), address(0), ""); + morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); uint256 units = assets * 1e18 / offer.startPrice; uint256 remainder = (units - assets) % (offer.obligation.maturity - vm.getBlockTimestamp()); - assertEq(adapter.lastRealAssetsEstimate(), assets + remainder, "lastRealAssetsEstimate"); + assertEq(adapter._totalAssets(), assets + remainder, "_totalAssets"); assertEq(adapter.lastUpdate(), vm.getBlockTimestamp(), "lastUpdate"); assertEq(adapter.firstMaturity(), vm.getBlockTimestamp() + 200, "firstMaturity"); @@ -261,13 +240,13 @@ contract MorphoMarketV2AdapterTest is Test { offer.callback = address(adapter); offer.callbackData = bytes(""); - vm.prank(manager); + vm.prank(parentVault.curator()); adapter.setMinTimeToMaturity(minTimeToMaturity); } function testRatifyIncorrectOfferBadSellSigner(uint256 seed, address otherSigner) public { vm.setSeed(seed); - vm.assume(otherSigner != manager); + vm.assume(otherSigner != signerAllocator); (Offer memory offer,) = _ratificationSetup(); vm.expectRevert(IMorphoMarketV2Adapter.IncorrectSigner.selector); vm.prank(address(morphoV2)); @@ -276,7 +255,8 @@ contract MorphoMarketV2AdapterTest is Test { function testRatifyIncorrectOfferBadBuySigner(uint256 seed, address otherSigner) public { vm.setSeed(seed); - vm.assume(otherSigner != manager); + vm.assume(otherSigner != signerAllocator); + vm.assume(otherSigner != address(adapter)); (Offer memory offer,) = _ratificationSetup(); vm.expectRevert(IMorphoMarketV2Adapter.IncorrectSigner.selector); vm.prank(address(morphoV2)); @@ -290,7 +270,7 @@ contract MorphoMarketV2AdapterTest is Test { offer.obligation.loanToken = otherToken; vm.expectRevert(IMorphoMarketV2Adapter.LoanAssetMismatch.selector); vm.prank(address(morphoV2)); - adapter.onRatify(offer, manager); + adapter.onRatify(offer, signerAllocator); } function testRatifyIncorrectOwner(uint256 seed, address otherMaker) public { @@ -300,7 +280,7 @@ contract MorphoMarketV2AdapterTest is Test { offer.maker = otherMaker; vm.expectRevert(IMorphoMarketV2Adapter.IncorrectOwner.selector); vm.prank(address(morphoV2)); - adapter.onRatify(offer, manager); + adapter.onRatify(offer, signerAllocator); } function testRatifyIncorrectMaturity(uint256 seed) public { @@ -313,7 +293,7 @@ contract MorphoMarketV2AdapterTest is Test { vm.expectRevert(IMorphoMarketV2Adapter.IncorrectMaturity.selector); } vm.prank(address(morphoV2)); - adapter.onRatify(offer, manager); + adapter.onRatify(offer, signerAllocator); } function testRatifyIncorrectStart(uint256 seed) public { @@ -322,7 +302,7 @@ contract MorphoMarketV2AdapterTest is Test { offer.start = vm.getBlockTimestamp() + 1; vm.expectRevert(IMorphoMarketV2Adapter.IncorrectStart.selector); vm.prank(address(morphoV2)); - adapter.onRatify(offer, manager); + adapter.onRatify(offer, signerAllocator); } function testRatifyIncorrectCallbackAddress(uint256 seed) public { @@ -331,14 +311,14 @@ contract MorphoMarketV2AdapterTest is Test { offer.callback = address(0); vm.expectRevert(IMorphoMarketV2Adapter.IncorrectCallbackAddress.selector); vm.prank(address(morphoV2)); - adapter.onRatify(offer, manager); + adapter.onRatify(offer, signerAllocator); } function testRatifyIncorrectExpiry(uint256 seed) public { vm.setSeed(seed); (Offer memory offer,) = _ratificationSetup(); vm.prank(address(morphoV2)); - adapter.onRatify(offer, manager); + adapter.onRatify(offer, signerAllocator); } /* STEPS SETUP */ @@ -396,7 +376,9 @@ contract MorphoMarketV2AdapterTest is Test { morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[1].token), 1_000e18, taker); ObligationPosition memory positionBefore = adapter.positions(obligationId); - morphoV2.take(step.assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], manager), address(0), ""); + morphoV2.take( + step.assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), "" + ); vm.stopPrank(); assertEq(adapter.positions(obligationId).units, positionBefore.units + units, "setup: units 1"); @@ -476,31 +458,26 @@ contract MorphoMarketV2AdapterTest is Test { /* ACCRUE INTEREST USING STEPS */ // Apply steps and test that accrueInterestView over time is correct. - function accrueInterestViewTest( - Step[] memory steps, - uint256 initialGrowth, - uint256 lastRealAssetsEstimate, - uint256 elapsed - ) internal { + function accrueInterestViewTest(Step[] memory steps, uint256 initialGrowth, uint256 _totalAssets, uint256 elapsed) + internal + { uint256 begin = vm.getBlockTimestamp(); initialGrowth = bound(initialGrowth, 0, 1e36); - lastRealAssetsEstimate = bound(lastRealAssetsEstimate, 0, type(uint128).max); + _totalAssets = bound(_totalAssets, 0, type(uint128).max); uint256 maxElapsed = steps.length == 0 ? 365 days : 2 * (steps[steps.length - 1].maturity - vm.getBlockTimestamp()); elapsed = bound(elapsed, 0, maxElapsed); setCurrentGrowth(uint128(initialGrowth)); - setLastRealAssetsEstimate(lastRealAssetsEstimate); + set_TotalAssets(_totalAssets); setupObligations(steps); uint256 expectedCurrentGrowth = initialGrowth + expectedAddedGrowth; assertEq(adapter.currentGrowth(), expectedCurrentGrowth, "currentGrowth"); - assertEq( - adapter.lastRealAssetsEstimate(), lastRealAssetsEstimate + expectedAddedAssets, "lastRealAssetsEstimate" - ); + assertEq(adapter._totalAssets(), _totalAssets + expectedAddedAssets, "_totalAssets"); skip(elapsed); - (uint48 nextMaturity, uint128 newGrowth, uint256 newRealAssetsEstimate) = adapter.accrueInterestView(); + (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets) = adapter.accrueInterestView(); uint256 lostGrowth = 0; uint256 interest = initialGrowth * elapsed; @@ -520,17 +497,15 @@ contract MorphoMarketV2AdapterTest is Test { } assertEq(nextMaturity, expectedNextMaturity, "nextMaturity"); assertEq(newGrowth, expectedCurrentGrowth - lostGrowth, "newGrowth"); - assertEq( - newRealAssetsEstimate, lastRealAssetsEstimate + expectedAddedAssets + interest, "newRealAssetsEstimate" - ); + assertEq(newTotalAssets, _totalAssets + expectedAddedAssets + interest, "newTotalAssets"); } - function testAccrueInterestView00(uint256 growth, uint256 lastRealAssetsEstimate, uint256 elapsed) public { - accrueInterestViewTest(steps00, growth, lastRealAssetsEstimate, elapsed); + function testAccrueInterestView00(uint256 growth, uint256 _totalAssets, uint256 elapsed) public { + accrueInterestViewTest(steps00, growth, _totalAssets, elapsed); } - function testAccrueInterestView01(uint256 growth, uint256 lastRealAssetsEstimate, uint256 elapsed) public { - accrueInterestViewTest(steps01, growth, lastRealAssetsEstimate, elapsed); + function testAccrueInterestView01(uint256 growth, uint256 _totalAssets, uint256 elapsed) public { + accrueInterestViewTest(steps01, growth, _totalAssets, elapsed); } /* UTILITIES */ @@ -539,8 +514,8 @@ contract MorphoMarketV2AdapterTest is Test { stdstore.target(address(adapter)).enable_packed_slots().sig("currentGrowth()").checked_write(growth); } - function setLastRealAssetsEstimate(uint256 lastRealAssetsEstimate) internal { - stdstore.target(address(adapter)).sig("lastRealAssetsEstimate()").checked_write(lastRealAssetsEstimate); + function set_TotalAssets(uint256 _totalAssets) internal { + stdstore.target(address(adapter)).sig("_totalAssets()").checked_write(_totalAssets); } function removeCopies(uint256[] storage array) internal returns (uint256[] memory) { From f8c2c678328abaaf5a7d499012c9e31c70f8d31b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Dec 2025 14:46:53 +0100 Subject: [PATCH 6/7] fix test --- test/MorphoMarketV1AdapterTest.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/MorphoMarketV1AdapterTest.sol b/test/MorphoMarketV1AdapterTest.sol index 8d87e0772..459f3ee2f 100644 --- a/test/MorphoMarketV1AdapterTest.sol +++ b/test/MorphoMarketV1AdapterTest.sol @@ -433,8 +433,8 @@ contract MorphoMarketV1AdapterTest is Test { function testBurnShares(uint256 timelockDuration, uint256 extraSkip) public { uint256 assets = _boundAssets(1000); - deal(address(loanToken), address(adapter), assets); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), assets); + deal(address(loanToken), address(parentVault), assets); + parentVault.allocate(address(adapter), abi.encode(marketParams), assets); uint256 supplyShares = adapter.supplyShares(marketId); uint256 allocation = adapter.allocation(marketParams); From 00582cb6354d32395ed83aa24c3804714cc2fca2 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 9 Dec 2025 00:56:15 +0100 Subject: [PATCH 7/7] fix missing currentGrowth update and reorg update --- src/adapters/MorphoMarketV2Adapter.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 837fd931b..0cbdab148 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -299,7 +299,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { _maturities[obligation.maturity].growthLostAtMaturity -= removedGrowth; _positions[obligationId].growth -= removedGrowth; _positions[obligationId].units -= removedUnits.toUint128(); - _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; + currentGrowth -= removedGrowth; + _totalAssets = _totalAssets + (removedUnits - (removedGrowth * timeToMaturity)); } else { _totalAssets -= removedUnits; _positions[obligationId].units -= removedUnits.toUint128();