diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 1d93fc334..757dade71 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -47,6 +47,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 ccbc65fae..4da8a4427 100644 --- a/.github/workflows/foundry.yml +++ b/.github/workflows/foundry.yml @@ -21,6 +21,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/foundry.lock b/foundry.lock index 1255fbebf..9f29faf48 100644 --- a/foundry.lock +++ b/foundry.lock @@ -17,4 +17,4 @@ "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..0cbdab148 --- /dev/null +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -0,0 +1,322 @@ +// 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 skimRecipient; + uint256 public minTimeToMaturity; + uint256 public minRate; + + /* ACCOUNTING */ + + uint256 public _totalAssets; + 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); + 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); + } + + /* VAULT CURATOR FUNCTIONS */ + + function setMinTimeToMaturity(uint256 _minTimeToMaturity) external { + require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); + require(_minTimeToMaturity <= type(uint48).max, IncorrectMinTimeToMaturity()); + minTimeToMaturity = _minTimeToMaturity; + } + + /* 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(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); + } + + /* 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, _totalAssets + gainedAssets); + } + + function accrueInterest() public { + if (lastUpdate != block.timestamp) { + (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets) = accrueInterestView(); + _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; + } + + /* 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 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. + 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 && offer.startPrice == 1e18 + && offer.expiryPrice == 1e18, + IncorrectOffer() + ); + + (,, uint256 obligationUnits,) = MorphoV2(morphoV2) + .take(0, sellerAssets, 0, 0, address(this), offer, proof, signature, address(0), hex""); + + 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(IVaultV2(parentVault).isAllocator(signer), 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(); + _totalAssets += buyerAssets + (obligationUnits - buyerAssets) % timeToMaturity; + _positions[obligationId].growth += gainedGrowth; + _maturities[obligation.maturity].growthLostAtMaturity += gainedGrowth; + currentGrowth += gainedGrowth; + } else { + _totalAssets += 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 _totalAssetsBefore = _totalAssets; + removeUnits(obligation, obligationUnits); + require(vaultBuffer >= _totalAssetsBefore.zeroFloorSub(_totalAssets), BufferTooLow()); + + IVaultV2(parentVault).deallocate(address(this), abi.encode(obligationUnits, vaultIds(obligation)), sellerAssets); + } + + /// INTERNAL FUNCTIONS /// + + /// @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); + 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(); + currentGrowth -= removedGrowth; + _totalAssets = _totalAssets + (removedUnits - (removedGrowth * timeToMaturity)); + } else { + _totalAssets -= 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..11796569d --- /dev/null +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -0,0 +1,98 @@ +// 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 _totalAssets() 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 withdraw(Obligation memory obligation, uint256 units, uint256 shares) external; + function minTimeToMaturity() external view returns (uint256); + function minRate() external view returns (uint256); + 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 f1817b6c5..459f3ee2f 100644 --- a/test/MorphoMarketV1AdapterTest.sol +++ b/test/MorphoMarketV1AdapterTest.sol @@ -128,10 +128,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); uint256 allocation = adapter.allocation(marketParams); assertEq(allocation, assets, "Incorrect allocation"); @@ -148,21 +147,21 @@ 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"); uint256 allocation = adapter.allocation(marketParams); assertEq(allocation, 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.marketIdsLength(), 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. @@ -434,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); diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol new file mode 100644 index 000000000..98c5680f9 --- /dev/null +++ b/test/MorphoMarketV2AdapterTest.sol @@ -0,0 +1,583 @@ +// 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 signerAllocator; + uint256 internal signerAllocatorPrivateKey; + 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"); + (signerAllocator, signerAllocatorPrivateKey) = makeAddrAndKey("signerAllocator"); + privateKey[signerAllocator] = signerAllocatorPrivateKey; + + 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, signerAllocator, address(0)); + + factory = new MorphoMarketV2AdapterFactory(); + adapter = MorphoMarketV2Adapter(factory.createMorphoMarketV2Adapter(address(parentVault), address(morphoV2))); + + 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(parentVault.curator()); + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectMinTimeToMaturity.selector); + adapter.setMinTimeToMaturity(badMinTimeToMaturity); + + vm.prank(parentVault.curator()); + adapter.setMinTimeToMaturity(goodMinTimeToMaturity); + assertEq(adapter.minTimeToMaturity(), goodMinTimeToMaturity); + } + + 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], signerAllocator), address(0), ""); + + uint256 units = assets * 1e18 / offer.startPrice; + uint256 remainder = (units - assets) % (offer.obligation.maturity - vm.getBlockTimestamp()); + assertEq(adapter._totalAssets(), assets + remainder, "_totalAssets"); + 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(parentVault.curator()); + adapter.setMinTimeToMaturity(minTimeToMaturity); + } + + function testRatifyIncorrectOfferBadSellSigner(uint256 seed, address otherSigner) public { + vm.setSeed(seed); + vm.assume(otherSigner != signerAllocator); + (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 != signerAllocator); + vm.assume(otherSigner != address(adapter)); + (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, signerAllocator); + } + + 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, signerAllocator); + } + + 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, signerAllocator); + } + + 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, signerAllocator); + } + + 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, signerAllocator); + } + + function testRatifyIncorrectExpiry(uint256 seed) public { + vm.setSeed(seed); + (Offer memory offer,) = _ratificationSetup(); + vm.prank(address(morphoV2)); + adapter.onRatify(offer, signerAllocator); + } + + /* 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], signerAllocator), 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 _totalAssets, uint256 elapsed) + internal + { + uint256 begin = vm.getBlockTimestamp(); + initialGrowth = bound(initialGrowth, 0, 1e36); + _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)); + set_TotalAssets(_totalAssets); + setupObligations(steps); + uint256 expectedCurrentGrowth = initialGrowth + expectedAddedGrowth; + assertEq(adapter.currentGrowth(), expectedCurrentGrowth, "currentGrowth"); + assertEq(adapter._totalAssets(), _totalAssets + expectedAddedAssets, "_totalAssets"); + + skip(elapsed); + + (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets) = 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(newTotalAssets, _totalAssets + expectedAddedAssets + interest, "newTotalAssets"); + } + + function testAccrueInterestView00(uint256 growth, uint256 _totalAssets, uint256 elapsed) public { + accrueInterestViewTest(steps00, growth, _totalAssets, elapsed); + } + + function testAccrueInterestView01(uint256 growth, uint256 _totalAssets, uint256 elapsed) public { + accrueInterestViewTest(steps01, growth, _totalAssets, elapsed); + } + + /* UTILITIES */ + + function setCurrentGrowth(uint128 growth) internal { + stdstore.target(address(adapter)).enable_packed_slots().sig("currentGrowth()").checked_write(growth); + } + + function set_TotalAssets(uint256 _totalAssets) internal { + stdstore.target(address(adapter)).sig("_totalAssets()").checked_write(_totalAssets); + } + + 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 10e6746a2..5c7f99bf0 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 { @@ -25,10 +26,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); @@ -36,7 +35,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) { @@ -44,6 +43,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); }