From eaab8221f9d1a8e7166a46fca3ee2150a3c4b229 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 7 Nov 2025 18:03:28 +0100 Subject: [PATCH 01/95] 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 02/95] 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 03/95] 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 04/95] 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 05/95] 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 43d7c6848f159954e092cdcbaa33805b36413a70 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 20 Nov 2025 16:19:45 +0100 Subject: [PATCH 06/95] feat: add durations --- src/adapters/MorphoMarketV2Adapter.sol | 92 +++++-- .../interfaces/IMorphoMarketV2Adapter.sol | 13 +- src/adapters/libraries/DurationsLib.sol | 34 +++ test/DurationsLibTest.sol | 63 +++++ test/MorphoMarketV2AdapterTest.sol | 231 +++++++++++++++--- 5 files changed, 381 insertions(+), 52 deletions(-) create mode 100644 src/adapters/libraries/DurationsLib.sol create mode 100644 test/DurationsLibTest.sol diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 837fd931b..da14940ba 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (c) 2025 Morpho Association -pragma solidity ^0.8.0; +pragma solidity 0.8.28; import {MorphoV2} from "lib/morpho-v2/src/MorphoV2.sol"; import {Offer, Signature, Obligation, Collateral, Seizure, Proof} from "lib/morpho-v2/src/interfaces/IMorphoV2.sol"; @@ -10,6 +10,7 @@ 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"; +import {DurationsLib, MAX_DURATIONS} from "./libraries/DurationsLib.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 @@ -17,18 +18,18 @@ import {IMorphoMarketV2Adapter, ObligationPosition, Maturity, IAdapter} from "./ /// @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; + using DurationsLib for bytes32; /* IMMUTABLES */ address public immutable asset; address public immutable parentVault; address public immutable morphoV2; + bytes32 public immutable adapterId; /* MANAGEMENT */ address public skimRecipient; - uint256 public minTimeToMaturity; - uint256 public minRate; /* ACCOUNTING */ @@ -38,6 +39,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint128 public currentGrowth; mapping(uint256 timestamp => Maturity) public _maturities; mapping(bytes32 obligationId => ObligationPosition) public _positions; + bytes32 public _durations; /* CONSTRUCTOR */ constructor(address _parentVault, address _morphoV2) { @@ -48,6 +50,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { SafeERC20Lib.safeApprove(asset, _morphoV2, type(uint256).max); SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); firstMaturity = type(uint48).max; + adapterId = keccak256(abi.encode("this", address(this))); } /* GETTERS */ @@ -60,6 +63,19 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return _maturities[date]; } + function durations() external view returns (uint256[] memory) { + uint256[] memory durationsArray = new uint256[](MAX_DURATIONS); + uint256 durationsCount = 0; + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + uint256 duration = _durations.get(i); + if (duration != 0) durationsArray[durationsCount++] = duration; + } + assembly ("memory-safe") { + mstore(durationsArray, durationsCount) + } + return durationsArray; + } + /* SKIM FUNCTIONS */ function setSkimRecipient(address newSkimRecipient) external { @@ -79,10 +95,31 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* VAULT CURATOR FUNCTIONS */ - function setMinTimeToMaturity(uint256 _minTimeToMaturity) external { + /// @dev Adds a new duration to the adapter. + function addDuration(uint256 addedDuration) external { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); - require(_minTimeToMaturity <= type(uint48).max, IncorrectMinTimeToMaturity()); - minTimeToMaturity = _minTimeToMaturity; + require(addedDuration != 0, IncorrectDuration()); + uint256 freePosition = type(uint256).max; + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + uint256 duration = _durations.get(i); + require(addedDuration != duration, NoDuplicates()); + if (duration == 0 && freePosition == type(uint256).max) freePosition = i; + } + if (freePosition == type(uint256).max) revert MaxDurationsExceeded(); + _durations = _durations.set(freePosition, addedDuration); + emit AddDuration(addedDuration); + } + + /// @dev Future obligation that match this duration will no longer consume the duration cap in the vault. + function removeDuration(uint256 removedDuration) external { + require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + if (_durations.get(i) == removedDuration) { + _durations = _durations.set(i, 0); + break; + } + } + emit RemoveDuration(removedDuration); } /* VAULT ALLOCATORS FUNCTIONS */ @@ -92,8 +129,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { 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); + IVaultV2(parentVault).deallocate(address(this), abi.encode(obligationUnits, ids(obligation)), obligationUnits); } /* ACCRUAL */ @@ -143,7 +179,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint256 lostUnits = _positions[obligationId].units - remainingUnits; removeUnits(obligation, lostUnits); - IVaultV2(parentVault).deallocate(address(this), abi.encode(lostUnits, vaultIds(obligation)), 0); + IVaultV2(parentVault).deallocate(address(this), abi.encode(lostUnits, ids(obligation)), 0); } /* ALLOCATION FUNCTIONS */ @@ -180,7 +216,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(MorphoV2(morphoV2).debtOf(address(this), _obligationId(offer.obligation)) == 0, NoBorrowing()); removeUnits(offer.obligation, obligationUnits); - return (vaultIds(offer.obligation), -obligationUnits.toInt256()); + return (ids(offer.obligation), -obligationUnits.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); (uint256 obligationUnits, bytes32[] memory _ids) = abi.decode(data, (uint256, bytes32[])); @@ -197,7 +233,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { 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()); @@ -256,7 +291,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } } - IVaultV2(parentVault).allocate(address(this), abi.encode(obligationUnits, vaultIds(obligation)), buyerAssets); + IVaultV2(parentVault).allocate(address(this), abi.encode(obligationUnits, ids(obligation)), buyerAssets); } function onSell( @@ -283,7 +318,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { removeUnits(obligation, obligationUnits); require(vaultBuffer >= _totalAssetsBefore.zeroFloorSub(_totalAssets), BufferTooLow()); - IVaultV2(parentVault).deallocate(address(this), abi.encode(obligationUnits, vaultIds(obligation)), sellerAssets); + IVaultV2(parentVault).deallocate(address(this), abi.encode(obligationUnits, ids(obligation)), sellerAssets); } /// INTERNAL FUNCTIONS /// @@ -310,9 +345,34 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return keccak256(abi.encode(obligation)); } - function vaultIds(Obligation memory) internal pure returns (bytes32[] memory) { - // TODO return correct ids - return new bytes32[](0); + function ids(Obligation memory obligation) public view returns (bytes32[] memory) { + uint256 baseLength = 1 + obligation.collaterals.length * 2; + bytes32[] memory _ids = new bytes32[](baseLength + MAX_DURATIONS); + uint256 j = 0; + _ids[j++] = adapterId; + for (uint256 i = 0; i < obligation.collaterals.length; i++) { + address collateralToken = obligation.collaterals[i].token; + _ids[j++] = keccak256(abi.encode("collateralToken", collateralToken)); + _ids[j++] = keccak256( + abi.encode( + "collateral", collateralToken, obligation.collaterals[i].oracle, obligation.collaterals[i].lltv + ) + ); + } + uint256 timeToMaturity = (obligation.maturity - block.timestamp); + uint256 durationIdCount = 0; + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + uint256 duration = _durations.get(i); + + if (duration != 0 && timeToMaturity >= duration) { + durationIdCount++; + _ids[j++] = keccak256(abi.encode("duration", duration)); + } + } + assembly ("memory-safe") { + mstore(_ids, add(baseLength, durationIdCount)) + } + return _ids; } function onLiquidate(Seizure[] memory, address, address, bytes memory) external pure { diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index 11796569d..de741c297 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -25,6 +25,8 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { event SetSkimRecipient(address indexed newSkimRecipient); event Skim(address indexed token, uint256 assets); + event AddDuration(uint256 duration); + event RemoveDuration(uint256 duration); /* ERRORS */ @@ -33,6 +35,7 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { error IncorrectCallbackAddress(); error IncorrectCallbackData(); error IncorrectCollateralSet(); + error IncorrectDuration(); error IncorrectExpiry(); error IncorrectHint(); error IncorrectMaturity(); @@ -45,7 +48,9 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { error IncorrectStart(); error IncorrectUnits(); error LoanAssetMismatch(); + error MaxDurationsExceeded(); error NoBorrowing(); + error NoDuplicates(); error NotAuthorized(); error NotMorphoV2(); error NotSelf(); @@ -58,14 +63,16 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { function lastUpdate() external view returns (uint48); function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); + function adapterId() external view returns (bytes32); 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 addDuration(uint256 duration) external; + function removeDuration(uint256 duration) external; + function durations() external view returns (uint256[] memory); function withdraw(Obligation memory obligation, uint256 units, uint256 shares) external; - function minTimeToMaturity() external view returns (uint256); - function minRate() external view returns (uint256); + function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); function accrueInterest() external; diff --git a/src/adapters/libraries/DurationsLib.sol b/src/adapters/libraries/DurationsLib.sol new file mode 100644 index 000000000..4f7faa4e3 --- /dev/null +++ b/src/adapters/libraries/DurationsLib.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +uint256 constant MAX_DURATIONS = 8; + +library DurationsLib { + /* ERRORS */ + + error InvalidValue(); + error InvalidIndex(); + + /* FUNCTIONS */ + + function array(bytes32 durations) internal pure returns (uint256[] memory) {} + + function get(bytes32 durations, uint256 index) internal pure returns (uint256 duration) { + require(index < MAX_DURATIONS, InvalidIndex()); + assembly { + let shift := sub(224, mul(32, index)) + duration := and(shr(shift, durations), 0xffffffff) + } + } + + function set(bytes32 durations, uint256 index, uint256 value) internal pure returns (bytes32 newDurations) { + require(value <= type(uint32).max, InvalidValue()); + require(index < MAX_DURATIONS, InvalidIndex()); + assembly { + let shift := sub(224, mul(32, index)) + let masked := and(durations, not(shl(shift, 0xffffffff))) + newDurations := or(masked, shl(shift, value)) + } + } +} diff --git a/test/DurationsLibTest.sol b/test/DurationsLibTest.sol new file mode 100644 index 000000000..2f75a1ea2 --- /dev/null +++ b/test/DurationsLibTest.sol @@ -0,0 +1,63 @@ +// 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"; +import {DurationsLib, MAX_DURATIONS} from "../src/adapters/libraries/DurationsLib.sol"; + +contract DurationsLibTest is Test { + /// forge-config: default.allow_internal_expect_revert = true + function testGetInvalidIndex(bytes32 durations, uint256 index) public { + index = bound(index, MAX_DURATIONS, type(uint256).max); + vm.expectRevert(DurationsLib.InvalidIndex.selector); + DurationsLib.get(durations, index); + } + + /// forge-config: default.allow_internal_expect_revert = true + function testSetInvalidIndex(bytes32 durations, uint256 index, uint32 value) public { + index = bound(index, MAX_DURATIONS, type(uint256).max); + vm.expectRevert(DurationsLib.InvalidIndex.selector); + DurationsLib.set(durations, index, value); + } + + /// forge-config: default.allow_internal_expect_revert = true + function testSetInvalidValue(bytes32 durations, uint256 index, uint256 value) public { + value = bound(value, uint256(type(uint32).max) + 1, type(uint256).max); + vm.expectRevert(DurationsLib.InvalidValue.selector); + DurationsLib.set(durations, index, value); + } + + function testGetValid(bytes32 durations, uint256 index) public pure { + index = bound(index, 0, MAX_DURATIONS - 1); + uint256 expectedValue = uint256(uint32(bytes4(durations << (32 * index)))); + assertEq(DurationsLib.get(durations, index), expectedValue); + } + + function testSetValid(bytes32 durations, uint256 writtenIndex, uint256 value, uint256 readIndex) public pure { + value = bound(value, 0, type(uint32).max); + writtenIndex = bound(writtenIndex, 0, MAX_DURATIONS - 1); + readIndex = bound(readIndex, 0, MAX_DURATIONS - 2); + if (readIndex == writtenIndex) readIndex = MAX_DURATIONS - 1; + + uint256 readValue = DurationsLib.get(durations, readIndex); + bytes32 newDurations = DurationsLib.set(durations, writtenIndex, value); + assertEq(DurationsLib.get(newDurations, writtenIndex), value); + assertEq(DurationsLib.get(newDurations, readIndex), readValue); + } +} diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index 98c5680f9..e024a02e9 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -19,6 +19,7 @@ import {Offer, Signature, Obligation, Collateral, Proof} from "../lib/morpho-v2/ 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"; +import {DurationsLib, MAX_DURATIONS} from "../src/adapters/libraries/DurationsLib.sol"; struct Step { uint256 assets; @@ -160,18 +161,6 @@ 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(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; @@ -213,9 +202,7 @@ contract MorphoMarketV2AdapterTest is Test { /* RATIFICATION */ - function _ratificationSetup() internal returns (Offer memory offer, uint256 minTimeToMaturity) { - minTimeToMaturity = bound(vm.randomUint(), 1, 10 * 365 days); - + function _ratificationSetup() internal returns (Offer memory offer) { offer.buy = true; offer.maker = address(adapter); offer.assets = 100; @@ -229,7 +216,7 @@ contract MorphoMarketV2AdapterTest is Test { 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.obligation.maturity = bound(vm.randomUint(), vm.getBlockTimestamp(), type(uint48).max); offer.start = bound(vm.randomUint(), 0, vm.getBlockTimestamp()); offer.expiry = bound(vm.randomUint(), offer.start, type(uint48).max); @@ -239,15 +226,12 @@ contract MorphoMarketV2AdapterTest is Test { } 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(); + Offer memory offer = _ratificationSetup(); vm.expectRevert(IMorphoMarketV2Adapter.IncorrectSigner.selector); vm.prank(address(morphoV2)); adapter.onRatify(offer, otherSigner); @@ -257,7 +241,7 @@ contract MorphoMarketV2AdapterTest is Test { vm.setSeed(seed); vm.assume(otherSigner != signerAllocator); vm.assume(otherSigner != address(adapter)); - (Offer memory offer,) = _ratificationSetup(); + Offer memory offer = _ratificationSetup(); vm.expectRevert(IMorphoMarketV2Adapter.IncorrectSigner.selector); vm.prank(address(morphoV2)); adapter.onRatify(offer, otherSigner); @@ -265,7 +249,7 @@ contract MorphoMarketV2AdapterTest is Test { function testRatifyLoanAssetMismatch(uint256 seed, address otherToken) public { vm.setSeed(seed); - (Offer memory offer,) = _ratificationSetup(); + Offer memory offer = _ratificationSetup(); vm.assume(otherToken != offer.obligation.loanToken); offer.obligation.loanToken = otherToken; vm.expectRevert(IMorphoMarketV2Adapter.LoanAssetMismatch.selector); @@ -275,7 +259,7 @@ contract MorphoMarketV2AdapterTest is Test { function testRatifyIncorrectOwner(uint256 seed, address otherMaker) public { vm.setSeed(seed); - (Offer memory offer,) = _ratificationSetup(); + Offer memory offer = _ratificationSetup(); vm.assume(otherMaker != address(adapter)); offer.maker = otherMaker; vm.expectRevert(IMorphoMarketV2Adapter.IncorrectOwner.selector); @@ -285,20 +269,16 @@ contract MorphoMarketV2AdapterTest is Test { 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); - } + Offer memory offer = _ratificationSetup(); + offer.obligation.maturity = vm.randomUint(type(uint48).max, type(uint256).max); + 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 memory offer = _ratificationSetup(); offer.start = vm.getBlockTimestamp() + 1; vm.expectRevert(IMorphoMarketV2Adapter.IncorrectStart.selector); vm.prank(address(morphoV2)); @@ -307,7 +287,7 @@ contract MorphoMarketV2AdapterTest is Test { function testRatifyIncorrectCallbackAddress(uint256 seed) public { vm.setSeed(seed); - (Offer memory offer,) = _ratificationSetup(); + Offer memory offer = _ratificationSetup(); offer.callback = address(0); vm.expectRevert(IMorphoMarketV2Adapter.IncorrectCallbackAddress.selector); vm.prank(address(morphoV2)); @@ -316,7 +296,7 @@ contract MorphoMarketV2AdapterTest is Test { function testRatifyIncorrectExpiry(uint256 seed) public { vm.setSeed(seed); - (Offer memory offer,) = _ratificationSetup(); + Offer memory offer = _ratificationSetup(); vm.prank(address(morphoV2)); adapter.onRatify(offer, signerAllocator); } @@ -508,6 +488,159 @@ contract MorphoMarketV2AdapterTest is Test { accrueInterestViewTest(steps01, growth, _totalAssets, elapsed); } + /* DURATIONS */ + + function testDurationsUpdate() public { + vm.startPrank(parentVault.curator()); + vm.expectEmit(); + emit IMorphoMarketV2Adapter.AddDuration(10); + adapter.addDuration(10); + assertEq(adapter.durations().length, 1); + assertEq(adapter.durations(), [uint256(10)]); + + vm.expectEmit(); + emit IMorphoMarketV2Adapter.RemoveDuration(10); + adapter.removeDuration(10); + assertEq(adapter.durations().length, 0); + + vm.expectEmit(); + emit IMorphoMarketV2Adapter.AddDuration(20); + adapter.addDuration(20); + assertEq(adapter.durations().length, 1); + assertEq(adapter.durations()[0], 20); + + vm.expectEmit(); + emit IMorphoMarketV2Adapter.AddDuration(1); + adapter.addDuration(1); + assertEq(adapter.durations().length, 2); + assertEq(adapter.durations()[0], 20); + assertEq(adapter.durations()[1], 1); + + vm.expectEmit(); + emit IMorphoMarketV2Adapter.RemoveDuration(20); + adapter.removeDuration(20); + assertEq(adapter.durations().length, 1); + + adapter.addDuration(19); + adapter.addDuration(20); + adapter.addDuration(99); + adapter.addDuration(98); + adapter.addDuration(97); + adapter.addDuration(96); + adapter.addDuration(95); + assertEq(adapter.durations().length, 8); + assertEq(adapter.durations()[0], 19); + assertEq(adapter.durations()[1], 1); + assertEq(adapter.durations()[2], 20); + assertEq(adapter.durations()[3], 99); + assertEq(adapter.durations()[4], 98); + assertEq(adapter.durations()[5], 97); + assertEq(adapter.durations()[6], 96); + assertEq(adapter.durations()[7], 95); + adapter.removeDuration(95); + assertEq(adapter.durations().length, 7); + adapter.addDuration(94); + vm.expectRevert(IMorphoMarketV2Adapter.MaxDurationsExceeded.selector); + adapter.addDuration(2); + vm.stopPrank(); + } + + function testSetDurationsAccess(address sender, uint256 duration) public { + vm.assume(sender != parentVault.curator()); + vm.prank(sender); + vm.expectRevert(IMorphoMarketV2Adapter.NotAuthorized.selector); + adapter.addDuration(duration); + + vm.prank(sender); + vm.expectRevert(IMorphoMarketV2Adapter.NotAuthorized.selector); + adapter.removeDuration(duration); + } + + function testAddDurationIncorrectOrDuplicate(bytes32 _durations, uint256 duplicated) public { + vm.assume(uint256(_durations) > type(uint16).max); + set_Durations(_durations); + uint256[] memory durationsArray = adapter.durations(); + duplicated = bound(duplicated, 0, durationsArray.length - 1); + if (durationsArray[duplicated] == 0 || durationsArray[duplicated] > type(uint32).max) { + vm.prank(parentVault.curator()); + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectDuration.selector); + adapter.addDuration(durationsArray[duplicated]); + } else { + vm.prank(parentVault.curator()); + vm.expectRevert(IMorphoMarketV2Adapter.NoDuplicates.selector); + adapter.addDuration(durationsArray[duplicated]); + } + } + + function testDurationsGetter(bytes32 _durations) public { + set_Durations(_durations); + uint256[] memory durationsArray = adapter.durations(); + uint256 arrayIndex = 0; + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + uint256 duration = uint256(uint32(bytes4(_durations << (32 * i)))); + if (duration != 0) assertEq(durationsArray[arrayIndex++], duration); + } + } + + /* IDS */ + + function testIds(uint256 collateralCount, uint256 durationsCount, uint256 maturity) public { + uint256[] memory possibleDurations = new uint256[](4); + possibleDurations[0] = 1 days; + possibleDurations[1] = 10 days; + possibleDurations[2] = 300 days; + possibleDurations[3] = 700 days; + possibleDurations = vm.shuffle(possibleDurations); + + collateralCount = bound(collateralCount, 0, 5); + durationsCount = bound(durationsCount, 0, 4); + + Obligation memory obligation; + + Collateral[] memory collaterals = new Collateral[](collateralCount); + for (uint256 i = 0; i < collateralCount; i++) { + collaterals[i].token = address(uint160(i)); + } + obligation.collaterals = storedCollaterals; + obligation.maturity = bound(maturity, 1, 700 days); + vm.startPrank(parentVault.curator()); + for (uint256 i = 0; i < durationsCount; i++) { + adapter.addDuration(possibleDurations[i]); + } + vm.stopPrank(); + + bytes32[] memory ids = adapter.ids(obligation); + assertEq(ids[0], adapter.adapterId()); + for (uint256 i = 0; i < obligation.collaterals.length; i++) { + assertEq(ids[i * 2 + 1], keccak256(abi.encode("collateralToken", obligation.collaterals[i].token))); + assertEq( + ids[i * 2 + 2], + keccak256( + abi.encode( + "collateral", + obligation.collaterals[i].token, + obligation.collaterals[i].oracle, + obligation.collaterals[i].lltv + ) + ) + ); + } + + uint256[] memory durationsArray = adapter.durations(); + uint256 durationIdCount = 0; + for (uint256 i = 0; i < durationsArray.length; i++) { + if ((obligation.maturity - block.timestamp) >= durationsArray[i]) { + assertEq( + ids[1 + obligation.collaterals.length * 2 + durationIdCount], + keccak256(abi.encode("duration", durationsArray[i])) + ); + durationIdCount++; + } + } + + assertEq(ids.length, 1 + obligation.collaterals.length * 2 + durationIdCount); + } + /* UTILITIES */ function setCurrentGrowth(uint128 growth) internal { @@ -518,6 +651,10 @@ contract MorphoMarketV2AdapterTest is Test { stdstore.target(address(adapter)).sig("_totalAssets()").checked_write(_totalAssets); } + function set_Durations(bytes32 _durations) internal { + stdstore.target(address(adapter)).sig("_durations()").checked_write(_durations); + } + function removeCopies(uint256[] storage array) internal returns (uint256[] memory) { uint256[] memory sorted = vm.sort(array); uint256 numCopies = 0; @@ -580,4 +717,32 @@ contract MorphoMarketV2AdapterTest is Test { function sort(bytes32 x, bytes32 y) internal pure returns (bytes memory) { return x < y ? abi.encodePacked(x, y) : abi.encodePacked(y, x); } + + function assertEq(uint256[] memory left, uint256[1] memory right) internal pure { + require(left.length == right.length, "lengths don't match (1)"); + for (uint256 i = 0; i < right.length; i++) { + assertEq(left[i], right[i], "durations[i]"); + } + } + + function assertEq(uint256[] memory left, uint256[2] memory right) internal pure { + require(left.length == right.length, "lengths don't match (2)"); + for (uint256 i = 0; i < right.length; i++) { + assertEq(left[i], right[i], "durations[i]"); + } + } + + function assertEq(uint256[] memory left, uint256[3] memory right) internal pure { + require(left.length == right.length, "lengths don't match (3)"); + for (uint256 i = 0; i < right.length; i++) { + assertEq(left[i], right[i], "durations[i]"); + } + } + + function assertEq(uint256[] memory left, uint256[8] memory right) internal pure { + require(left.length == right.length, "lengths don't match (10)"); + for (uint256 i = 0; i < right.length; i++) { + assertEq(left[i], right[i], "durations[i]"); + } + } } From 1f3ff72294a945ffb6a01c8b57622ff116c9ecd5 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 20 Nov 2025 16:24:53 +0100 Subject: [PATCH 07/95] bump forge std --- lib/forge-std | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/forge-std b/lib/forge-std index 100b0d756..3f9995236 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 100b0d756adda67bc70aab816fa5a1a95dcf78b6 +Subproject commit 3f999523613ab5454a5c4ae4abeaa8ea2ba7bcae From 4a3b955793f9b6fd031e66b56215628f3765a10f Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 25 Nov 2025 14:24:19 +0100 Subject: [PATCH 08/95] feat: simplify durations lib --- lib/forge-std | 2 +- src/adapters/MorphoMarketV2Adapter.sol | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/forge-std b/lib/forge-std index 3f9995236..8e40513d6 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 3f999523613ab5454a5c4ae4abeaa8ea2ba7bcae +Subproject commit 8e40513d678f392f398620b3ef2b418648b33e89 diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index da14940ba..8349638cf 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -346,9 +346,10 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } function ids(Obligation memory obligation) public view returns (bytes32[] memory) { - uint256 baseLength = 1 + obligation.collaterals.length * 2; - bytes32[] memory _ids = new bytes32[](baseLength + MAX_DURATIONS); - uint256 j = 0; + uint256[] memory durations = _durations.array(); + bytes32[] memory _ids = new bytes32[](1 + obligation.collaterals.length * 2+ durations.length); + + uint256 j; _ids[j++] = adapterId; for (uint256 i = 0; i < obligation.collaterals.length; i++) { address collateralToken = obligation.collaterals[i].token; @@ -361,17 +362,12 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } uint256 timeToMaturity = (obligation.maturity - block.timestamp); uint256 durationIdCount = 0; - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _durations.get(i); - - if (duration != 0 && timeToMaturity >= duration) { + for (uint256 i = 0; i < durations.length; i++) { + if (timeToMaturity >= durations[i]) { durationIdCount++; - _ids[j++] = keccak256(abi.encode("duration", duration)); + _ids[j++] = keccak256(abi.encode("duration", durations[i])); } } - assembly ("memory-safe") { - mstore(_ids, add(baseLength, durationIdCount)) - } return _ids; } From 81cbeb9a911939eceaff904216a7529be357024e Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 25 Nov 2025 18:41:41 +0100 Subject: [PATCH 09/95] feat: make durations dynamic --- src/adapters/MorphoMarketV2Adapter.sol | 166 ++++++++++++------ .../interfaces/IMorphoMarketV2Adapter.sol | 2 + src/adapters/libraries/DurationsLib.sol | 2 - test/MorphoMarketV2AdapterTest.sol | 17 +- 4 files changed, 124 insertions(+), 63 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 8349638cf..06a594f05 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -18,6 +18,7 @@ import {DurationsLib, MAX_DURATIONS} from "./libraries/DurationsLib.sol"; /// @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; + using MathLib for uint128; using DurationsLib for bytes32; /* IMMUTABLES */ @@ -39,7 +40,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint128 public currentGrowth; mapping(uint256 timestamp => Maturity) public _maturities; mapping(bytes32 obligationId => ObligationPosition) public _positions; - bytes32 public _durations; + bytes32 public durationsPacked; /* CONSTRUCTOR */ constructor(address _parentVault, address _morphoV2) { @@ -64,14 +65,16 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } function durations() external view returns (uint256[] memory) { - uint256[] memory durationsArray = new uint256[](MAX_DURATIONS); uint256 durationsCount = 0; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _durations.get(i); - if (duration != 0) durationsArray[durationsCount++] = duration; + if (durationsPacked.get(i) != 0) durationsCount++; } - assembly ("memory-safe") { - mstore(durationsArray, durationsCount) + + uint256[] memory durationsArray = new uint256[](durationsCount); + uint256 j; + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + uint256 duration = durationsPacked.get(i); + if (duration != 0) durationsArray[j++] = duration; } return durationsArray; } @@ -96,26 +99,28 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* VAULT CURATOR FUNCTIONS */ /// @dev Adds a new duration to the adapter. + /// @dev Currently held obligations that match this duration must be touched to be accounted for in the caps. function addDuration(uint256 addedDuration) external { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); require(addedDuration != 0, IncorrectDuration()); uint256 freePosition = type(uint256).max; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _durations.get(i); + uint256 duration = durationsPacked.get(i); require(addedDuration != duration, NoDuplicates()); if (duration == 0 && freePosition == type(uint256).max) freePosition = i; } if (freePosition == type(uint256).max) revert MaxDurationsExceeded(); - _durations = _durations.set(freePosition, addedDuration); + durationsPacked = durationsPacked.set(freePosition, addedDuration); emit AddDuration(addedDuration); } /// @dev Future obligation that match this duration will no longer consume the duration cap in the vault. + /// @dev Currently held obligations that match this duration must be touched to be de-accounted for in the caps. function removeDuration(uint256 removedDuration) external { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (_durations.get(i) == removedDuration) { - _durations = _durations.set(i, 0); + if (durationsPacked.get(i) == removedDuration) { + durationsPacked = durationsPacked.set(i, 0); break; } } @@ -125,11 +130,13 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* 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 { + function withdraw(Obligation memory obligation, uint256 withdrawn, 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, ids(obligation)), obligationUnits); + (, shares) = MorphoV2(morphoV2).withdraw(obligation, withdrawn, shares, address(this)); + ObligationPosition storage position = _positions[_obligationId(obligation)]; + selfDeallocate(obligation, position, -position.units.toInt256(), withdrawn); + removeUnits(obligation, position, withdrawn); + selfDeallocate(obligation, position, position.units.toInt256(), 0); } /* ACCRUAL */ @@ -177,13 +184,29 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { 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, ids(obligation)), 0); + ObligationPosition storage position = _positions[obligationId]; + uint256 lostUnits = position.units - remainingUnits; + selfDeallocate(obligation, position, -position.units.toInt256(), 0); + removeUnits(obligation, position, lostUnits); + selfDeallocate(obligation, position, position.units.toInt256(), 0); } /* ALLOCATION FUNCTIONS */ + function selfDeallocate( + Obligation memory obligation, + ObligationPosition storage position, + int256 change, + uint256 assets + ) internal { + IVaultV2(parentVault) + .deallocate( + address(this), + abi.encode(_ids(obligation, position.lastDurationsPacked, position.lastUpdate), change), + assets + ); + } + /// @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 @@ -191,8 +214,9 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { returns (bytes32[] memory, int256) { require(vaultAllocator == address(this), SelfAllocationOnly()); - (uint256 obligationUnits, bytes32[] memory _ids) = abi.decode(data, (uint256, bytes32[])); - return (_ids, obligationUnits.toInt256()); + assembly ("memory-safe") { + return(add(data, 32), mload(data)) + } } /// @dev Can be called from vault.deallocate from a sell callback where the adapter is the maker, @@ -210,17 +234,24 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { IncorrectOffer() ); - (,, uint256 obligationUnits,) = MorphoV2(morphoV2) + (,, uint256 removedObligationUnits,) = 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()); + bytes32 obligationId = _obligationId(offer.obligation); + require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); - removeUnits(offer.obligation, obligationUnits); - return (ids(offer.obligation), -obligationUnits.toInt256()); + ObligationPosition storage position = _positions[obligationId]; + selfDeallocate(offer.obligation, position, -position.units.toInt256(), 0); + removeUnits(offer.obligation, position, removedObligationUnits); + return ( + _ids(offer.obligation, position.lastDurationsPacked, position.lastUpdate), + uint256(position.units).toInt256() + ); } else { require(caller == address(this), SelfAllocationOnly()); - (uint256 obligationUnits, bytes32[] memory _ids) = abi.decode(data, (uint256, bytes32[])); - return (_ids, -obligationUnits.toInt256()); + assembly ("memory-safe") { + return(add(data, 32), mload(data)) + } } } @@ -254,20 +285,25 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { bytes32 obligationId = _obligationId(obligation); uint48 prevMaturity = abi.decode(data, (uint48)); require(prevMaturity < obligation.maturity, IncorrectHint()); + ObligationPosition storage position = _positions[obligationId]; + + selfDeallocate(obligation, position, -position.units.toInt256(), 0); accrueInterest(); + position.lastUpdate = uint48(block.timestamp); + position.lastDurationsPacked = durationsPacked; 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; + position.growth += gainedGrowth; _maturities[obligation.maturity].growthLostAtMaturity += gainedGrowth; currentGrowth += gainedGrowth; } else { _totalAssets += obligationUnits; } - _positions[obligationId].units += obligationUnits.toUint128(); + position.units += obligationUnits.toUint128(); uint48 nextMaturity; if (prevMaturity == 0) { @@ -291,7 +327,14 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } } - IVaultV2(parentVault).allocate(address(this), abi.encode(obligationUnits, ids(obligation)), buyerAssets); + IVaultV2(parentVault) + .allocate( + address(this), + abi.encode( + _ids(obligation, position.lastDurationsPacked, position.lastUpdate), position.units.toInt256() + ), + buyerAssets + ); } function onSell( @@ -299,13 +342,14 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { address seller, uint256, uint256 sellerAssets, - uint256 obligationUnits, + uint256 soldObligationUnits, uint256, bytes memory ) external { + bytes32 obligationId = _obligationId(obligation); require(msg.sender == address(morphoV2), NotMorphoV2()); require(seller == address(this), NotSelf()); - require(MorphoV2(morphoV2).debtOf(seller, _obligationId(obligation)) == 0, NoBorrowing()); + require(MorphoV2(morphoV2).debtOf(seller, obligationId) == 0, NoBorrowing()); uint256 vaultRealAssets = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -315,29 +359,34 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { 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, ids(obligation)), sellerAssets); + ObligationPosition storage position = _positions[obligationId]; + selfDeallocate(obligation, position, -position.units.toInt256(), sellerAssets); + removeUnits(obligation, position, soldObligationUnits); + require(vaultBuffer >= _totalAssetsBefore.zeroFloorSub(_totalAssets), BufferTooLow()); + selfDeallocate(obligation, position, position.units.toInt256(), 0); } /// 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 { + function removeUnits(Obligation memory obligation, ObligationPosition storage position, uint256 removedUnits) + internal + { accrueInterest(); - bytes32 obligationId = _obligationId(obligation); + position.lastUpdate = uint48(block.timestamp); + position.lastDurationsPacked = durationsPacked; + if (obligation.maturity > block.timestamp) { uint256 timeToMaturity = obligation.maturity - block.timestamp; - uint128 removedGrowth = uint256(_positions[obligationId].growth) - .mulDivUp(removedUnits, _positions[obligationId].units).toUint128(); + uint128 removedGrowth = uint256(position.growth).mulDivUp(removedUnits, position.units).toUint128(); _maturities[obligation.maturity].growthLostAtMaturity -= removedGrowth; - _positions[obligationId].growth -= removedGrowth; - _positions[obligationId].units -= removedUnits.toUint128(); + position.growth -= removedGrowth; + position.units -= removedUnits.toUint128(); _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { _totalAssets -= removedUnits; - _positions[obligationId].units -= removedUnits.toUint128(); + position.units -= removedUnits.toUint128(); } } @@ -345,30 +394,41 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return keccak256(abi.encode(obligation)); } - function ids(Obligation memory obligation) public view returns (bytes32[] memory) { - uint256[] memory durations = _durations.array(); - bytes32[] memory _ids = new bytes32[](1 + obligation.collaterals.length * 2+ durations.length); + function ids(Obligation memory obligation) external view returns (bytes32[] memory) { + return _ids(obligation, durationsPacked, block.timestamp); + } + + function _ids(Obligation memory obligation, bytes32 _durationsPacked, uint256 currentTime) + internal + view + returns (bytes32[] memory) + { + uint256 durationIdCount = 0; + uint256 timeToMaturity = (obligation.maturity - currentTime); + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + uint256 duration = _durationsPacked.get(i); + if (duration != 0 && timeToMaturity >= duration) durationIdCount++; + } + bytes32[] memory idsArray = new bytes32[](1 + obligation.collaterals.length * 2 + durationIdCount); uint256 j; - _ids[j++] = adapterId; + idsArray[j++] = adapterId; for (uint256 i = 0; i < obligation.collaterals.length; i++) { address collateralToken = obligation.collaterals[i].token; - _ids[j++] = keccak256(abi.encode("collateralToken", collateralToken)); - _ids[j++] = keccak256( + idsArray[j++] = keccak256(abi.encode("collateralToken", collateralToken)); + idsArray[j++] = keccak256( abi.encode( "collateral", collateralToken, obligation.collaterals[i].oracle, obligation.collaterals[i].lltv ) ); } - uint256 timeToMaturity = (obligation.maturity - block.timestamp); - uint256 durationIdCount = 0; - for (uint256 i = 0; i < durations.length; i++) { - if (timeToMaturity >= durations[i]) { - durationIdCount++; - _ids[j++] = keccak256(abi.encode("duration", durations[i])); + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + uint256 duration = _durationsPacked.get(i); + if (duration != 0 && timeToMaturity >= duration) { + idsArray[j++] = keccak256(abi.encode("duration", duration)); } } - return _ids; + return idsArray; } function onLiquidate(Seizure[] memory, address, address, bytes memory) external pure { diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index de741c297..fbfdc8b84 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -11,6 +11,8 @@ import {ICallbacks} from "lib/morpho-v2/src/interfaces/ICallbacks.sol"; struct ObligationPosition { uint128 units; uint128 growth; + uint48 lastUpdate; + bytes32 lastDurationsPacked; } // Chain of maturities, each can represent multiple obligations. diff --git a/src/adapters/libraries/DurationsLib.sol b/src/adapters/libraries/DurationsLib.sol index 4f7faa4e3..4498270e1 100644 --- a/src/adapters/libraries/DurationsLib.sol +++ b/src/adapters/libraries/DurationsLib.sol @@ -12,8 +12,6 @@ library DurationsLib { /* FUNCTIONS */ - function array(bytes32 durations) internal pure returns (uint256[] memory) {} - function get(bytes32 durations, uint256 index) internal pure returns (uint256 duration) { require(index < MAX_DURATIONS, InvalidIndex()); assembly { diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index e024a02e9..b7c34899c 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -31,6 +31,7 @@ struct Step { contract MorphoMarketV2AdapterTest is Test { using stdStorage for StdStorage; using MathLib for uint256; + using DurationsLib for bytes32; MorphoV2 internal morphoV2; IMorphoMarketV2AdapterFactory internal factory; @@ -556,9 +557,9 @@ contract MorphoMarketV2AdapterTest is Test { adapter.removeDuration(duration); } - function testAddDurationIncorrectOrDuplicate(bytes32 _durations, uint256 duplicated) public { - vm.assume(uint256(_durations) > type(uint16).max); - set_Durations(_durations); + function testAddDurationIncorrectOrDuplicate(bytes32 _durationsPacked, uint256 duplicated) public { + vm.assume(uint256(_durationsPacked) > type(uint16).max); + setDurationsPacked(_durationsPacked); uint256[] memory durationsArray = adapter.durations(); duplicated = bound(duplicated, 0, durationsArray.length - 1); if (durationsArray[duplicated] == 0 || durationsArray[duplicated] > type(uint32).max) { @@ -572,12 +573,12 @@ contract MorphoMarketV2AdapterTest is Test { } } - function testDurationsGetter(bytes32 _durations) public { - set_Durations(_durations); + function testDurationsGetter(bytes32 _durationsPacked) public { + setDurationsPacked(_durationsPacked); uint256[] memory durationsArray = adapter.durations(); uint256 arrayIndex = 0; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = uint256(uint32(bytes4(_durations << (32 * i)))); + uint256 duration = uint256(uint32(bytes4(_durationsPacked << (32 * i)))); if (duration != 0) assertEq(durationsArray[arrayIndex++], duration); } } @@ -651,8 +652,8 @@ contract MorphoMarketV2AdapterTest is Test { stdstore.target(address(adapter)).sig("_totalAssets()").checked_write(_totalAssets); } - function set_Durations(bytes32 _durations) internal { - stdstore.target(address(adapter)).sig("_durations()").checked_write(_durations); + function setDurationsPacked(bytes32 _durationsPacked) internal { + stdstore.target(address(adapter)).sig("durationsPacked()").checked_write(_durationsPacked); } function removeCopies(uint256[] storage array) internal returns (uint256[] memory) { From df60b713a06cb898d6758ed710d07b9aefa3ce80 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 26 Nov 2025 17:46:30 +0100 Subject: [PATCH 10/95] small fixes --- src/adapters/MorphoMarketV2Adapter.sol | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 06a594f05..25e0e0057 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -207,7 +207,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ); } - /// @dev Can only be called from a buy callback where the adapter is the maker. + /// @dev Can be called by this adapter from a buy callback. function allocate(bytes memory data, uint256, bytes4, address vaultAllocator) external view @@ -219,8 +219,8 @@ 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 Can be called by this adapter from a sell callback, a withdraw, or a loss realization. + /// @dev Can be called by a user through 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) @@ -263,7 +263,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { 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.start <= block.timestamp, IncorrectStart()); // uint48.max is the list end pointer require(offer.obligation.maturity < type(uint48).max, IncorrectMaturity()); @@ -349,7 +348,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { bytes32 obligationId = _obligationId(obligation); require(msg.sender == address(morphoV2), NotMorphoV2()); require(seller == address(this), NotSelf()); - require(MorphoV2(morphoV2).debtOf(seller, obligationId) == 0, NoBorrowing()); + require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); + accrueInterest(); uint256 vaultRealAssets = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -357,13 +357,12 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { vaultRealAssets += IAdapter(IVaultV2(parentVault).adapters(i)).realAssets(); } uint256 vaultBuffer = vaultRealAssets.zeroFloorSub(IVaultV2(parentVault).totalAssets()); - - uint256 _totalAssetsBefore = _totalAssets; + uint256 totalAssetsBefore = _totalAssets; ObligationPosition storage position = _positions[obligationId]; selfDeallocate(obligation, position, -position.units.toInt256(), sellerAssets); removeUnits(obligation, position, soldObligationUnits); - require(vaultBuffer >= _totalAssetsBefore.zeroFloorSub(_totalAssets), BufferTooLow()); + require(vaultBuffer >= totalAssetsBefore.zeroFloorSub(_totalAssets), BufferTooLow()); selfDeallocate(obligation, position, position.units.toInt256(), 0); } From 4f41f7d01bebe8addd878535acfc6b2c064ac0a7 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 26 Nov 2025 18:41:42 +0100 Subject: [PATCH 11/95] use solidity's packed arrays --- src/adapters/MorphoMarketV2Adapter.sol | 64 +++++++------- .../interfaces/IMorphoMarketV2Adapter.sol | 4 +- src/adapters/libraries/DurationsLib.sol | 32 ------- test/DurationsLibTest.sol | 63 ------------- test/MorphoMarketV2AdapterTest.sol | 88 +++++++------------ 5 files changed, 67 insertions(+), 184 deletions(-) delete mode 100644 src/adapters/libraries/DurationsLib.sol delete mode 100644 test/DurationsLibTest.sol diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 25e0e0057..660b8212e 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -9,8 +9,13 @@ 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"; -import {DurationsLib, MAX_DURATIONS} from "./libraries/DurationsLib.sol"; +import { + IMorphoMarketV2Adapter, + ObligationPosition, + Maturity, + IAdapter, + MAX_DURATIONS +} 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 @@ -19,7 +24,6 @@ import {DurationsLib, MAX_DURATIONS} from "./libraries/DurationsLib.sol"; contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { using MathLib for uint256; using MathLib for uint128; - using DurationsLib for bytes32; /* IMMUTABLES */ @@ -40,7 +44,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint128 public currentGrowth; mapping(uint256 timestamp => Maturity) public _maturities; mapping(bytes32 obligationId => ObligationPosition) public _positions; - bytes32 public durationsPacked; + uint32[8] public _durations; /* CONSTRUCTOR */ constructor(address _parentVault, address _morphoV2) { @@ -65,15 +69,16 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } function durations() external view returns (uint256[] memory) { + uint32[8] memory durationsLocal = _durations; uint256 durationsCount = 0; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (durationsPacked.get(i) != 0) durationsCount++; + if (durationsLocal[i] != 0) durationsCount++; } uint256[] memory durationsArray = new uint256[](durationsCount); uint256 j; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = durationsPacked.get(i); + uint256 duration = durationsLocal[i]; if (duration != 0) durationsArray[j++] = duration; } return durationsArray; @@ -102,15 +107,19 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /// @dev Currently held obligations that match this duration must be touched to be accounted for in the caps. function addDuration(uint256 addedDuration) external { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); - require(addedDuration != 0, IncorrectDuration()); - uint256 freePosition = type(uint256).max; + require(addedDuration <= type(uint32).max && addedDuration != 0, IncorrectDuration()); + bool placed = false; + uint32[8] memory durationsLocal = _durations; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = durationsPacked.get(i); + uint32 duration = durationsLocal[i]; require(addedDuration != duration, NoDuplicates()); - if (duration == 0 && freePosition == type(uint256).max) freePosition = i; + if (duration == 0 && !placed) { + durationsLocal[i] = uint32(addedDuration); + placed = true; + } } - if (freePosition == type(uint256).max) revert MaxDurationsExceeded(); - durationsPacked = durationsPacked.set(freePosition, addedDuration); + _durations = durationsLocal; + if (!placed) revert MaxDurationsExceeded(); emit AddDuration(addedDuration); } @@ -118,12 +127,14 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /// @dev Currently held obligations that match this duration must be touched to be de-accounted for in the caps. function removeDuration(uint256 removedDuration) external { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); + uint32[8] memory durationsLocal = _durations; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (durationsPacked.get(i) == removedDuration) { - durationsPacked = durationsPacked.set(i, 0); + if (durationsLocal[i] == removedDuration) { + durationsLocal[i] = 0; break; } } + _durations = durationsLocal; emit RemoveDuration(removedDuration); } @@ -201,9 +212,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ) internal { IVaultV2(parentVault) .deallocate( - address(this), - abi.encode(_ids(obligation, position.lastDurationsPacked, position.lastUpdate), change), - assets + address(this), abi.encode(_ids(obligation, position.durations, position.lastUpdate), change), assets ); } @@ -243,10 +252,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; selfDeallocate(offer.obligation, position, -position.units.toInt256(), 0); removeUnits(offer.obligation, position, removedObligationUnits); - return ( - _ids(offer.obligation, position.lastDurationsPacked, position.lastUpdate), - uint256(position.units).toInt256() - ); + return (_ids(offer.obligation, position.durations, position.lastUpdate), uint256(position.units).toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); assembly ("memory-safe") { @@ -290,7 +296,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { accrueInterest(); position.lastUpdate = uint48(block.timestamp); - position.lastDurationsPacked = durationsPacked; + position.durations = _durations; if (obligation.maturity > block.timestamp) { uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); uint128 gainedGrowth = ((obligationUnits - buyerAssets) / timeToMaturity).toUint128(); @@ -329,9 +335,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { IVaultV2(parentVault) .allocate( address(this), - abi.encode( - _ids(obligation, position.lastDurationsPacked, position.lastUpdate), position.units.toInt256() - ), + abi.encode(_ids(obligation, position.durations, position.lastUpdate), position.units.toInt256()), buyerAssets ); } @@ -374,7 +378,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { { accrueInterest(); position.lastUpdate = uint48(block.timestamp); - position.lastDurationsPacked = durationsPacked; + position.durations = _durations; if (obligation.maturity > block.timestamp) { uint256 timeToMaturity = obligation.maturity - block.timestamp; @@ -394,10 +398,10 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } function ids(Obligation memory obligation) external view returns (bytes32[] memory) { - return _ids(obligation, durationsPacked, block.timestamp); + return _ids(obligation, _durations, block.timestamp); } - function _ids(Obligation memory obligation, bytes32 _durationsPacked, uint256 currentTime) + function _ids(Obligation memory obligation, uint32[8] memory usedDurations, uint256 currentTime) internal view returns (bytes32[] memory) @@ -405,7 +409,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint256 durationIdCount = 0; uint256 timeToMaturity = (obligation.maturity - currentTime); for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _durationsPacked.get(i); + uint256 duration = usedDurations[i]; if (duration != 0 && timeToMaturity >= duration) durationIdCount++; } bytes32[] memory idsArray = new bytes32[](1 + obligation.collaterals.length * 2 + durationIdCount); @@ -422,7 +426,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ); } for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _durationsPacked.get(i); + uint256 duration = usedDurations[i]; if (duration != 0 && timeToMaturity >= duration) { idsArray[j++] = keccak256(abi.encode("duration", duration)); } diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index fbfdc8b84..b42bd1f24 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -7,12 +7,14 @@ import {IAdapter} from "../../interfaces/IAdapter.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"; +uint256 constant MAX_DURATIONS = 8; + // Position in an obligation struct ObligationPosition { uint128 units; uint128 growth; uint48 lastUpdate; - bytes32 lastDurationsPacked; + uint32[8] durations; } // Chain of maturities, each can represent multiple obligations. diff --git a/src/adapters/libraries/DurationsLib.sol b/src/adapters/libraries/DurationsLib.sol deleted file mode 100644 index 4498270e1..000000000 --- a/src/adapters/libraries/DurationsLib.sol +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -// Copyright (c) 2025 Morpho Association -pragma solidity ^0.8.0; - -uint256 constant MAX_DURATIONS = 8; - -library DurationsLib { - /* ERRORS */ - - error InvalidValue(); - error InvalidIndex(); - - /* FUNCTIONS */ - - function get(bytes32 durations, uint256 index) internal pure returns (uint256 duration) { - require(index < MAX_DURATIONS, InvalidIndex()); - assembly { - let shift := sub(224, mul(32, index)) - duration := and(shr(shift, durations), 0xffffffff) - } - } - - function set(bytes32 durations, uint256 index, uint256 value) internal pure returns (bytes32 newDurations) { - require(value <= type(uint32).max, InvalidValue()); - require(index < MAX_DURATIONS, InvalidIndex()); - assembly { - let shift := sub(224, mul(32, index)) - let masked := and(durations, not(shl(shift, 0xffffffff))) - newDurations := or(masked, shl(shift, value)) - } - } -} diff --git a/test/DurationsLibTest.sol b/test/DurationsLibTest.sol deleted file mode 100644 index 2f75a1ea2..000000000 --- a/test/DurationsLibTest.sol +++ /dev/null @@ -1,63 +0,0 @@ -// 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"; -import {DurationsLib, MAX_DURATIONS} from "../src/adapters/libraries/DurationsLib.sol"; - -contract DurationsLibTest is Test { - /// forge-config: default.allow_internal_expect_revert = true - function testGetInvalidIndex(bytes32 durations, uint256 index) public { - index = bound(index, MAX_DURATIONS, type(uint256).max); - vm.expectRevert(DurationsLib.InvalidIndex.selector); - DurationsLib.get(durations, index); - } - - /// forge-config: default.allow_internal_expect_revert = true - function testSetInvalidIndex(bytes32 durations, uint256 index, uint32 value) public { - index = bound(index, MAX_DURATIONS, type(uint256).max); - vm.expectRevert(DurationsLib.InvalidIndex.selector); - DurationsLib.set(durations, index, value); - } - - /// forge-config: default.allow_internal_expect_revert = true - function testSetInvalidValue(bytes32 durations, uint256 index, uint256 value) public { - value = bound(value, uint256(type(uint32).max) + 1, type(uint256).max); - vm.expectRevert(DurationsLib.InvalidValue.selector); - DurationsLib.set(durations, index, value); - } - - function testGetValid(bytes32 durations, uint256 index) public pure { - index = bound(index, 0, MAX_DURATIONS - 1); - uint256 expectedValue = uint256(uint32(bytes4(durations << (32 * index)))); - assertEq(DurationsLib.get(durations, index), expectedValue); - } - - function testSetValid(bytes32 durations, uint256 writtenIndex, uint256 value, uint256 readIndex) public pure { - value = bound(value, 0, type(uint32).max); - writtenIndex = bound(writtenIndex, 0, MAX_DURATIONS - 1); - readIndex = bound(readIndex, 0, MAX_DURATIONS - 2); - if (readIndex == writtenIndex) readIndex = MAX_DURATIONS - 1; - - uint256 readValue = DurationsLib.get(durations, readIndex); - bytes32 newDurations = DurationsLib.set(durations, writtenIndex, value); - assertEq(DurationsLib.get(newDurations, writtenIndex), value); - assertEq(DurationsLib.get(newDurations, readIndex), readValue); - } -} diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index b7c34899c..67dd1eb9c 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -10,7 +10,7 @@ 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 {IMorphoMarketV2Adapter, MAX_DURATIONS} 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"; @@ -19,7 +19,6 @@ import {Offer, Signature, Obligation, Collateral, Proof} from "../lib/morpho-v2/ 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"; -import {DurationsLib, MAX_DURATIONS} from "../src/adapters/libraries/DurationsLib.sol"; struct Step { uint256 assets; @@ -31,7 +30,6 @@ struct Step { contract MorphoMarketV2AdapterTest is Test { using stdStorage for StdStorage; using MathLib for uint256; - using DurationsLib for bytes32; MorphoV2 internal morphoV2; IMorphoMarketV2AdapterFactory internal factory; @@ -497,7 +495,7 @@ contract MorphoMarketV2AdapterTest is Test { emit IMorphoMarketV2Adapter.AddDuration(10); adapter.addDuration(10); assertEq(adapter.durations().length, 1); - assertEq(adapter.durations(), [uint256(10)]); + assertEq(adapter.durations()[0], 10); vm.expectEmit(); emit IMorphoMarketV2Adapter.RemoveDuration(10); @@ -557,30 +555,36 @@ contract MorphoMarketV2AdapterTest is Test { adapter.removeDuration(duration); } - function testAddDurationIncorrectOrDuplicate(bytes32 _durationsPacked, uint256 duplicated) public { - vm.assume(uint256(_durationsPacked) > type(uint16).max); - setDurationsPacked(_durationsPacked); - uint256[] memory durationsArray = adapter.durations(); - duplicated = bound(duplicated, 0, durationsArray.length - 1); - if (durationsArray[duplicated] == 0 || durationsArray[duplicated] > type(uint32).max) { - vm.prank(parentVault.curator()); - vm.expectRevert(IMorphoMarketV2Adapter.IncorrectDuration.selector); - adapter.addDuration(durationsArray[duplicated]); - } else { - vm.prank(parentVault.curator()); - vm.expectRevert(IMorphoMarketV2Adapter.NoDuplicates.selector); - adapter.addDuration(durationsArray[duplicated]); - } - } + function testAddDurationIncorrectOrDuplicate() public { + vm.startPrank(parentVault.curator()); - function testDurationsGetter(bytes32 _durationsPacked) public { - setDurationsPacked(_durationsPacked); - uint256[] memory durationsArray = adapter.durations(); - uint256 arrayIndex = 0; - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = uint256(uint32(bytes4(_durationsPacked << (32 * i)))); - if (duration != 0) assertEq(durationsArray[arrayIndex++], duration); + adapter.addDuration(1); + adapter.addDuration(100); + adapter.addDuration(12); + adapter.addDuration(99); + adapter.addDuration(101); + adapter.addDuration(102); + adapter.addDuration(2); + adapter.addDuration(103); + adapter.removeDuration(103); + adapter.removeDuration(102); + adapter.removeDuration(101); + uint256[] memory actualArray = adapter.durations(); + uint256[5] memory expectedArray = [uint256(1), 100, 12, 99, 2]; + assertEq(actualArray.length, expectedArray.length); + for (uint256 i = 0; i < actualArray.length; i++) { + assertEq(actualArray[i], expectedArray[i]); } + + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectDuration.selector); + adapter.addDuration(0); + vm.expectRevert(IMorphoMarketV2Adapter.IncorrectDuration.selector); + adapter.addDuration(uint256(type(uint32).max) + 1); + vm.expectRevert(IMorphoMarketV2Adapter.NoDuplicates.selector); + adapter.addDuration(1); + vm.expectRevert(IMorphoMarketV2Adapter.NoDuplicates.selector); + adapter.addDuration(2); + vm.stopPrank(); } /* IDS */ @@ -652,10 +656,6 @@ contract MorphoMarketV2AdapterTest is Test { stdstore.target(address(adapter)).sig("_totalAssets()").checked_write(_totalAssets); } - function setDurationsPacked(bytes32 _durationsPacked) internal { - stdstore.target(address(adapter)).sig("durationsPacked()").checked_write(_durationsPacked); - } - function removeCopies(uint256[] storage array) internal returns (uint256[] memory) { uint256[] memory sorted = vm.sort(array); uint256 numCopies = 0; @@ -718,32 +718,4 @@ contract MorphoMarketV2AdapterTest is Test { function sort(bytes32 x, bytes32 y) internal pure returns (bytes memory) { return x < y ? abi.encodePacked(x, y) : abi.encodePacked(y, x); } - - function assertEq(uint256[] memory left, uint256[1] memory right) internal pure { - require(left.length == right.length, "lengths don't match (1)"); - for (uint256 i = 0; i < right.length; i++) { - assertEq(left[i], right[i], "durations[i]"); - } - } - - function assertEq(uint256[] memory left, uint256[2] memory right) internal pure { - require(left.length == right.length, "lengths don't match (2)"); - for (uint256 i = 0; i < right.length; i++) { - assertEq(left[i], right[i], "durations[i]"); - } - } - - function assertEq(uint256[] memory left, uint256[3] memory right) internal pure { - require(left.length == right.length, "lengths don't match (3)"); - for (uint256 i = 0; i < right.length; i++) { - assertEq(left[i], right[i], "durations[i]"); - } - } - - function assertEq(uint256[] memory left, uint256[8] memory right) internal pure { - require(left.length == right.length, "lengths don't match (10)"); - for (uint256 i = 0; i < right.length; i++) { - assertEq(left[i], right[i], "durations[i]"); - } - } } From 9a380aada7402f9c3fdbdd578e28312c81b5729e Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 27 Nov 2025 13:20:34 +0100 Subject: [PATCH 12/95] update buffer compoutation & naming & docs style --- src/adapters/MorphoMarketV2Adapter.sol | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 660b8212e..532be0c43 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -140,7 +140,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* VAULT ALLOCATORS FUNCTIONS */ - // Do not cleanup the linked list if we end up at 0 growth function withdraw(Obligation memory obligation, uint256 withdrawn, uint256 shares) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); (, shares) = MorphoV2(morphoV2).withdraw(obligation, withdrawn, shares, address(this)); @@ -172,11 +171,11 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { function accrueInterest() public { if (lastUpdate != block.timestamp) { - (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets) = accrueInterestView(); + (uint48 newFirstMaturity, uint128 newCurrentGrowth, uint256 newTotalAssets) = accrueInterestView(); _totalAssets = newTotalAssets; lastUpdate = uint48(block.timestamp); - firstMaturity = nextMaturity; - currentGrowth = newGrowth; + firstMaturity = newFirstMaturity; + currentGrowth = newCurrentGrowth; } } @@ -297,6 +296,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { accrueInterest(); position.lastUpdate = uint48(block.timestamp); position.durations = _durations; + if (obligation.maturity > block.timestamp) { uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); uint128 gainedGrowth = ((obligationUnits - buyerAssets) / timeToMaturity).toUint128(); @@ -353,21 +353,20 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(msg.sender == address(morphoV2), NotMorphoV2()); require(seller == address(this), NotSelf()); require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); - accrueInterest(); - 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; + uint vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); ObligationPosition storage position = _positions[obligationId]; selfDeallocate(obligation, position, -position.units.toInt256(), sellerAssets); removeUnits(obligation, position, soldObligationUnits); - require(vaultBuffer >= totalAssetsBefore.zeroFloorSub(_totalAssets), BufferTooLow()); selfDeallocate(obligation, position, position.units.toInt256(), 0); + + uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); + uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); + for (uint256 i = 0; i < adaptersLength; i++) { + vaultRealAssetsAfter += IAdapter(IVaultV2(parentVault).adapters(i)).realAssets(); + } + require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); } /// INTERNAL FUNCTIONS /// @@ -385,6 +384,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint128 removedGrowth = uint256(position.growth).mulDivUp(removedUnits, position.units).toUint128(); _maturities[obligation.maturity].growthLostAtMaturity -= removedGrowth; position.growth -= removedGrowth; + // Do not cleanup the linked list if we end up at 0 growth position.units -= removedUnits.toUint128(); _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { From c935db476fd7344a0be6ddaf5e3e8b5dc59502ea Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 28 Nov 2025 16:49:55 +0100 Subject: [PATCH 13/95] remove lastUpdate in position, add tests --- src/adapters/MorphoMarketV2Adapter.sol | 110 +++++---- .../interfaces/IMorphoMarketV2Adapter.sol | 5 +- ...phoMarketV2AdapterAllocationUpdateTest.sol | 225 ++++++++++++++++++ test/MorphoMarketV2AdapterTest.sol | 7 +- test/mocks/VaultV2Mock.sol | 15 ++ 5 files changed, 302 insertions(+), 60 deletions(-) create mode 100644 test/MorphoMarketV2AdapterAllocationUpdateTest.sol diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 532be0c43..7faee2b18 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -3,11 +3,10 @@ pragma solidity 0.8.28; 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 {Offer, Signature, Obligation, 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, @@ -84,6 +83,10 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return durationsArray; } + function ids(Obligation memory obligation) external view returns (bytes32[] memory) { + return _ids(obligation, syncedDurations(obligation.maturity)); + } + /* SKIM FUNCTIONS */ function setSkimRecipient(address newSkimRecipient) external { @@ -109,16 +112,16 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); require(addedDuration <= type(uint32).max && addedDuration != 0, IncorrectDuration()); bool placed = false; - uint32[8] memory durationsLocal = _durations; + uint32[8] memory localDurations = _durations; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint32 duration = durationsLocal[i]; + uint32 duration = localDurations[i]; require(addedDuration != duration, NoDuplicates()); if (duration == 0 && !placed) { - durationsLocal[i] = uint32(addedDuration); + localDurations[i] = uint32(addedDuration); placed = true; } } - _durations = durationsLocal; + _durations = localDurations; if (!placed) revert MaxDurationsExceeded(); emit AddDuration(addedDuration); } @@ -127,14 +130,14 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /// @dev Currently held obligations that match this duration must be touched to be de-accounted for in the caps. function removeDuration(uint256 removedDuration) external { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); - uint32[8] memory durationsLocal = _durations; + uint32[8] memory localDurations = _durations; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (durationsLocal[i] == removedDuration) { - durationsLocal[i] = 0; + if (localDurations[i] == removedDuration) { + localDurations[i] = 0; break; } } - _durations = durationsLocal; + _durations = localDurations; emit RemoveDuration(removedDuration); } @@ -144,9 +147,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); (, shares) = MorphoV2(morphoV2).withdraw(obligation, withdrawn, shares, address(this)); ObligationPosition storage position = _positions[_obligationId(obligation)]; - selfDeallocate(obligation, position, -position.units.toInt256(), withdrawn); removeUnits(obligation, position, withdrawn); - selfDeallocate(obligation, position, position.units.toInt256(), 0); + selfDeallocate(obligation, position, -withdrawn.toInt256(), withdrawn); } /* ACCRUAL */ @@ -196,32 +198,19 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; uint256 lostUnits = position.units - remainingUnits; - selfDeallocate(obligation, position, -position.units.toInt256(), 0); removeUnits(obligation, position, lostUnits); - selfDeallocate(obligation, position, position.units.toInt256(), 0); + selfDeallocate(obligation, position, -lostUnits.toInt256(), 0); } /* ALLOCATION FUNCTIONS */ - function selfDeallocate( - Obligation memory obligation, - ObligationPosition storage position, - int256 change, - uint256 assets - ) internal { - IVaultV2(parentVault) - .deallocate( - address(this), abi.encode(_ids(obligation, position.durations, position.lastUpdate), change), assets - ); - } - /// @dev Can be called by this adapter from a buy callback. - function allocate(bytes memory data, uint256, bytes4, address vaultAllocator) + function allocate(bytes memory data, uint256, bytes4, address caller) external view returns (bytes32[] memory, int256) { - require(vaultAllocator == address(this), SelfAllocationOnly()); + require(caller == address(this), SelfAllocationOnly()); assembly ("memory-safe") { return(add(data, 32), mload(data)) } @@ -249,9 +238,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); ObligationPosition storage position = _positions[obligationId]; - selfDeallocate(offer.obligation, position, -position.units.toInt256(), 0); removeUnits(offer.obligation, position, removedObligationUnits); - return (_ids(offer.obligation, position.durations, position.lastUpdate), uint256(position.units).toInt256()); + return (_ids(offer.obligation, position.durations), -position.units.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); assembly ("memory-safe") { @@ -294,8 +282,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { selfDeallocate(obligation, position, -position.units.toInt256(), 0); accrueInterest(); - position.lastUpdate = uint48(block.timestamp); - position.durations = _durations; if (obligation.maturity > block.timestamp) { uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); @@ -332,11 +318,10 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } } + position.durations = syncedDurations(obligation.maturity); IVaultV2(parentVault) .allocate( - address(this), - abi.encode(_ids(obligation, position.durations, position.lastUpdate), position.units.toInt256()), - buyerAssets + address(this), abi.encode(_ids(obligation, position.durations), position.units.toInt256()), buyerAssets ); } @@ -354,12 +339,11 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(seller == address(this), NotSelf()); require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); - uint vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); + uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); ObligationPosition storage position = _positions[obligationId]; - selfDeallocate(obligation, position, -position.units.toInt256(), sellerAssets); removeUnits(obligation, position, soldObligationUnits); - selfDeallocate(obligation, position, position.units.toInt256(), 0); + selfDeallocate(obligation, position, -soldObligationUnits.toInt256(), sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -369,48 +353,58 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); } - /// INTERNAL FUNCTIONS /// + // Convenience function to sync the durations of an obligation. + function syncDurations(Obligation memory obligation) external { + ObligationPosition storage position = _positions[_obligationId(obligation)]; + selfDeallocate(obligation, position, -position.units.toInt256(), 0); + + position.durations = syncedDurations(obligation.maturity); + IVaultV2(parentVault) + .allocate(address(this), abi.encode(_ids(obligation, position.durations), position.units.toInt256()), 0); + } + + /* 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, ObligationPosition storage position, uint256 removedUnits) internal { accrueInterest(); - position.lastUpdate = uint48(block.timestamp); - position.durations = _durations; if (obligation.maturity > block.timestamp) { uint256 timeToMaturity = obligation.maturity - block.timestamp; uint128 removedGrowth = uint256(position.growth).mulDivUp(removedUnits, position.units).toUint128(); _maturities[obligation.maturity].growthLostAtMaturity -= removedGrowth; + // Do not cleanup the linked list if we end up at 0 growth. position.growth -= removedGrowth; - // Do not cleanup the linked list if we end up at 0 growth - position.units -= removedUnits.toUint128(); _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { _totalAssets -= removedUnits; - position.units -= removedUnits.toUint128(); } + position.units -= removedUnits.toUint128(); } function _obligationId(Obligation memory obligation) internal pure returns (bytes32) { return keccak256(abi.encode(obligation)); } - function ids(Obligation memory obligation) external view returns (bytes32[] memory) { - return _ids(obligation, _durations, block.timestamp); + function syncedDurations(uint256 maturity) internal view returns (uint32[8] memory) { + uint32[8] memory localDurations = _durations; + uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + if (timeToMaturity < localDurations[i]) localDurations[i] = 0; + } + return localDurations; } - function _ids(Obligation memory obligation, uint32[8] memory usedDurations, uint256 currentTime) + function _ids(Obligation memory obligation, uint32[8] memory localDurations) internal view returns (bytes32[] memory) { uint256 durationIdCount = 0; - uint256 timeToMaturity = (obligation.maturity - currentTime); for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = usedDurations[i]; - if (duration != 0 && timeToMaturity >= duration) durationIdCount++; + if (localDurations[i] != 0) durationIdCount++; } bytes32[] memory idsArray = new bytes32[](1 + obligation.collaterals.length * 2 + durationIdCount); @@ -426,14 +420,26 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ); } for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = usedDurations[i]; - if (duration != 0 && timeToMaturity >= duration) { + uint256 duration = localDurations[i]; + if (duration != 0) { idsArray[j++] = keccak256(abi.encode("duration", duration)); } } return idsArray; } + function selfDeallocate( + Obligation memory obligation, + ObligationPosition storage position, + int256 change, + uint256 assets + ) internal { + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(_ids(obligation, position.durations), change), assets); + } + + /* TO REMOVE */ + function onLiquidate(Seizure[] memory, address, address, bytes memory) external pure { revert(); } diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index b42bd1f24..0e3e5b85c 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -3,8 +3,7 @@ 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 {Obligation, Seizure} from "lib/morpho-v2/src/interfaces/IMorphoV2.sol"; import {ICallbacks} from "lib/morpho-v2/src/interfaces/ICallbacks.sol"; uint256 constant MAX_DURATIONS = 8; @@ -13,7 +12,6 @@ uint256 constant MAX_DURATIONS = 8; struct ObligationPosition { uint128 units; uint128 growth; - uint48 lastUpdate; uint32[8] durations; } @@ -81,6 +79,7 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { function accrueInterestView() external view returns (uint48, uint128, uint256); function accrueInterest() external; function realizeLoss(Obligation memory obligation) external; + function syncDurations(Obligation memory obligation) external; function allocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) external returns (bytes32[] memory, int256); diff --git a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol new file mode 100644 index 000000000..6cefc5d07 --- /dev/null +++ b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol @@ -0,0 +1,225 @@ +// 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 {MorphoMarketV2AdapterTest} from "./MorphoMarketV2AdapterTest.sol"; +import {IERC20} from "../src/interfaces/IERC20.sol"; +import {MathLib} from "../src/libraries/MathLib.sol"; +import {MorphoV2} from "../lib/morpho-v2/src/MorphoV2.sol"; +import {Offer, Obligation} from "../lib/morpho-v2/src/interfaces/IMorphoV2.sol"; +import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; +import {Oracle} from "../lib/morpho-v2/test/helpers/Oracle.sol"; +import {Seizure} from "../lib/morpho-v2/src/interfaces/IMorphoV2.sol"; + +contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest { + using stdStorage for StdStorage; + using MathLib for uint256; + + address internal allocator; + + function setUp() public override { + super.setUp(); + + storedCollaterals[0].lltv = 1e18; + storedCollaterals[1].lltv = 1e18; + storedOffer.obligation.collaterals = storedCollaterals; + + 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); + loanToken.approve(address(morphoV2), type(uint256).max); + vm.stopPrank(); + } + + function buy(uint256 duration, uint256 assets) internal returns (Offer memory) { + Offer memory offer = storedOffer; + + offer.obligation.maturity = block.timestamp + duration; + offer.buy = true; + offer.startPrice = 1e18; + offer.expiryPrice = 1e18; + offer.assets = assets; + offer.expiry = block.timestamp; + offer.callback = address(adapter); + offer.callbackData = abi.encode(0); + + vm.startPrank(taker); + morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[0].token), assets / 2, taker); + morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[1].token), assets / 2, taker); + morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); + vm.stopPrank(); + return offer; + } + + function sell(Obligation memory obligation, uint256 assets) internal { + Offer memory offer = storedOffer; + + offer.obligation = obligation; + offer.buy = false; + offer.startPrice = 1e18; + offer.expiryPrice = 1e18; + offer.assets = assets; + offer.expiry = block.timestamp; + offer.callback = address(adapter); + offer.group = bytes32(vm.randomUint()); + offer.callbackData = abi.encode(0); + vm.prank(taker); + morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); + } + + function durationId(uint32 duration) internal pure returns (bytes32) { + return keccak256(abi.encode("duration", duration)); + } + + function testExactDuration(uint32 duration) public { + duration = uint32(bound(duration, 1, type(uint32).max)); + vm.prank(curator); + adapter.addDuration(duration); + buy(duration, 1e18); + assertEq(parentVault.allocation(durationId(duration)), 1e18); + } + + function testExitDuration(uint32 duration, uint256 timeToMaturity, uint256 extraSkip) public { + duration = uint32(bound(duration, 1, type(uint32).max)); + timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); + extraSkip = bound(extraSkip, 1, 10 * 365 days); + + vm.prank(curator); + adapter.addDuration(duration); + Offer memory offer = buy(timeToMaturity, 1e18); + assertEq(parentVault.allocation(durationId(duration)), 1e18); + + skip(timeToMaturity - duration + extraSkip); + + adapter.syncDurations(offer.obligation); + + assertEq(parentVault.allocation(durationId(duration)), 0); + } + + function testAddDuration(uint32 duration, uint256 timeToMaturity) public { + duration = uint32(bound(duration, 1, type(uint32).max)); + timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); + Offer memory offer = buy(timeToMaturity, 1e18); + assertEq(parentVault.allocation(durationId(duration)), 0); + + vm.prank(curator); + adapter.addDuration(duration); + adapter.syncDurations(offer.obligation); + + assertEq(parentVault.allocation(durationId(duration)), 1e18); + } + + function testRemoveDuration(uint32 duration, uint256 timeToMaturity) public { + duration = uint32(bound(duration, 1, type(uint32).max)); + vm.prank(curator); + adapter.addDuration(duration); + + timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); + Offer memory offer = buy(timeToMaturity, 1e18); + assertEq(parentVault.allocation(durationId(duration)), 1e18); + + vm.prank(curator); + adapter.removeDuration(duration); + assertEq(parentVault.allocation(durationId(duration)), 1e18); + + adapter.syncDurations(offer.obligation); + + assertEq(parentVault.allocation(durationId(duration)), 0); + } + + function testRepeatSync(uint32 duration, uint32 timeToMaturity, uint256 skipAmount) public { + duration = uint32(bound(duration, 1, type(uint32).max)); + vm.prank(curator); + adapter.addDuration(duration); + skipAmount = bound(skipAmount, 0, uint256(duration) * 2); + + Offer memory offer = buy(timeToMaturity, 1e18); + skip(skipAmount); + adapter.syncDurations(offer.obligation); + uint256 savedAllocation = parentVault.allocation(durationId(duration)); + adapter.syncDurations(offer.obligation); + assertEq(parentVault.allocation(durationId(duration)), savedAllocation); + } + + function testUpdateOnRealizeLoss() public { + vm.prank(curator); + adapter.addDuration(1 weeks); + vm.prank(curator); + adapter.addDuration(2 weeks); + Offer memory offer = buy(2 weeks, 1e18); + assertEq(parentVault.allocation(durationId(1 weeks)), 1e18, "1 week, before"); + assertEq(parentVault.allocation(durationId(2 weeks)), 1e18, "2 weeks, before"); + + skip(1); + + vm.prank(curator); + adapter.addDuration(1 days); + + Oracle(offer.obligation.collaterals[0].oracle).setPrice(0); + morphoV2.liquidate(offer.obligation, new Seizure[](0), taker, ""); + adapter.realizeLoss(offer.obligation); + + bytes32 obligationId = _obligationId(offer.obligation); + uint256 remainingUnits = MorphoV2(morphoV2).sharesOf(address(adapter), obligationId) + .mulDivDown( + MorphoV2(morphoV2).totalUnits(obligationId) + 1, MorphoV2(morphoV2).totalShares(obligationId) + 1 + ); + + assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(parentVault.allocation(durationId(1 weeks)), remainingUnits, "1 week"); + assertEq(parentVault.allocation(durationId(2 weeks)), remainingUnits, "2 weeks"); + } + + function testUpdateOnWithdraw() public { + vm.prank(curator); + adapter.addDuration(1 weeks); + vm.prank(curator); + adapter.addDuration(2 weeks); + Offer memory offer = buy(2 weeks, 1e18); + assertEq(parentVault.allocation(durationId(1 weeks)), 1e18, "1 week, before"); + assertEq(parentVault.allocation(durationId(2 weeks)), 1e18, "2 weeks, before"); + + skip(2 weeks); + + vm.prank(curator); + adapter.addDuration(1 days); + + vm.prank(taker); + morphoV2.repay(offer.obligation, 1e18, taker); + vm.prank(signerAllocator); + adapter.withdraw(offer.obligation, 0.5e18, 0); + + assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(parentVault.allocation(durationId(1 weeks)), 0.5e18, "1 week"); + assertEq(parentVault.allocation(durationId(2 weeks)), 0.5e18, "2 weeks"); + } + + function testUpdateOnSell() public { + vm.prank(curator); + adapter.addDuration(1 weeks); + vm.prank(curator); + adapter.addDuration(2 weeks); + Offer memory offer = buy(2 weeks, 1e18); + assertEq(parentVault.allocation(durationId(1 weeks)), 1e18, "1 week, before"); + assertEq(parentVault.allocation(durationId(2 weeks)), 1e18, "2 weeks, before"); + + skip(1); + + vm.prank(curator); + adapter.addDuration(1 days); + + parentVault.setTotalAssets(1e18); + parentVault.setAdaptersLength(1); + address[] memory adapters = new address[](1); + adapters[0] = address(adapter); + parentVault.setAdapters(adapters); + sell(offer.obligation, 0.5e18); + + assertEq(parentVault.allocation(durationId(1 weeks)), 0.5e18, "1 week"); + assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(parentVault.allocation(durationId(2 weeks)), 0.5e18, "2 weeks"); + } +} diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index 67dd1eb9c..e3e45d499 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -9,15 +9,12 @@ 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, MAX_DURATIONS} from "../src/adapters/interfaces/IMorphoMarketV2Adapter.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 { @@ -66,7 +63,7 @@ contract MorphoMarketV2AdapterTest is Test { uint256 internal expectedAddedGrowth; uint256 internal expectedAddedAssets; - function setUp() public { + function setUp() public virtual { owner = makeAddr("owner"); curator = makeAddr("curator"); (signerAllocator, signerAllocatorPrivateKey) = makeAddrAndKey("signerAllocator"); diff --git a/test/mocks/VaultV2Mock.sol b/test/mocks/VaultV2Mock.sol index 6aec6c525..acced6cd3 100644 --- a/test/mocks/VaultV2Mock.sol +++ b/test/mocks/VaultV2Mock.sol @@ -13,6 +13,9 @@ contract VaultV2Mock { mapping(address => bool) public isAllocator; mapping(address => bool) public isSentinel; mapping(bytes32 => uint256) public allocation; + uint256 public totalAssets; + uint256 public adaptersLength; + address[] public adapters; constructor(address _asset, address _owner, address _curator, address _allocator, address _sentinel) { asset = _asset; @@ -44,4 +47,16 @@ contract VaultV2Mock { SafeERC20Lib.safeTransferFrom(asset, adapter, address(this), assets); return (ids, change); } + + function setTotalAssets(uint256 newTotalAssets) external { + totalAssets = newTotalAssets; + } + + function setAdaptersLength(uint256 newAdaptersLength) external { + adaptersLength = newAdaptersLength; + } + + function setAdapters(address[] memory newAdapters) external { + adapters = newAdapters; + } } From 3fda20444cf5e21e1c0ad813a9758ae09164064b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 28 Nov 2025 16:55:35 +0100 Subject: [PATCH 14/95] save a call on first buy --- src/adapters/MorphoMarketV2Adapter.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 7faee2b18..1b7b01ab9 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -279,7 +279,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(prevMaturity < obligation.maturity, IncorrectHint()); ObligationPosition storage position = _positions[obligationId]; - selfDeallocate(obligation, position, -position.units.toInt256(), 0); + if (position.units > 0) selfDeallocate(obligation, position, -position.units.toInt256(), 0); accrueInterest(); From 9620e818c4fa956cb676bc71b824359d0c533e77 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Sat, 29 Nov 2025 01:42:04 +0100 Subject: [PATCH 15/95] fix force deallocate change, refactor selfDeallocate --- src/adapters/MorphoMarketV2Adapter.sol | 18 +++++++++--------- ...rphoMarketV2AdapterAllocationUpdateTest.sol | 2 ++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 1b7b01ab9..1befef14a 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -148,7 +148,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { (, shares) = MorphoV2(morphoV2).withdraw(obligation, withdrawn, shares, address(this)); ObligationPosition storage position = _positions[_obligationId(obligation)]; removeUnits(obligation, position, withdrawn); - selfDeallocate(obligation, position, -withdrawn.toInt256(), withdrawn); + selfDeallocate(obligation, position.durations, withdrawn, withdrawn); } /* ACCRUAL */ @@ -199,7 +199,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; uint256 lostUnits = position.units - remainingUnits; removeUnits(obligation, position, lostUnits); - selfDeallocate(obligation, position, -lostUnits.toInt256(), 0); + selfDeallocate(obligation, position.durations, lostUnits, 0); } /* ALLOCATION FUNCTIONS */ @@ -239,7 +239,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; removeUnits(offer.obligation, position, removedObligationUnits); - return (_ids(offer.obligation, position.durations), -position.units.toInt256()); + return (_ids(offer.obligation, position.durations), -removedObligationUnits.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); assembly ("memory-safe") { @@ -279,7 +279,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(prevMaturity < obligation.maturity, IncorrectHint()); ObligationPosition storage position = _positions[obligationId]; - if (position.units > 0) selfDeallocate(obligation, position, -position.units.toInt256(), 0); + if (position.units > 0) selfDeallocate(obligation, position.durations, position.units, 0); accrueInterest(); @@ -343,7 +343,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; removeUnits(obligation, position, soldObligationUnits); - selfDeallocate(obligation, position, -soldObligationUnits.toInt256(), sellerAssets); + selfDeallocate(obligation, position.durations, soldObligationUnits, sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -356,7 +356,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { // Convenience function to sync the durations of an obligation. function syncDurations(Obligation memory obligation) external { ObligationPosition storage position = _positions[_obligationId(obligation)]; - selfDeallocate(obligation, position, -position.units.toInt256(), 0); + selfDeallocate(obligation, position.durations, position.units, 0); position.durations = syncedDurations(obligation.maturity); IVaultV2(parentVault) @@ -430,12 +430,12 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { function selfDeallocate( Obligation memory obligation, - ObligationPosition storage position, - int256 change, + uint32[8] memory localDurations, + uint256 removed, uint256 assets ) internal { IVaultV2(parentVault) - .deallocate(address(this), abi.encode(_ids(obligation, position.durations), change), assets); + .deallocate(address(this), abi.encode(_ids(obligation, localDurations), -removed.toInt256()), assets); } /* TO REMOVE */ diff --git a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol index 6cefc5d07..4bf7bf9a3 100644 --- a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol +++ b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol @@ -222,4 +222,6 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); assertEq(parentVault.allocation(durationId(2 weeks)), 0.5e18, "2 weeks"); } + + // TODO force deallocate test } From 9ce37d316f4717a06cc77a7a8a528dc1a3cb0b5c Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Dec 2025 00:05:47 +0100 Subject: [PATCH 16/95] durations library --- src/adapters/MorphoMarketV2Adapter.sol | 95 ++++++++----------- .../interfaces/IMorphoMarketV2Adapter.sol | 9 +- src/libraries/DurationsLib.sol | 45 +++++++++ test/MorphoMarketV2AdapterTest.sol | 6 +- 4 files changed, 91 insertions(+), 64 deletions(-) create mode 100644 src/libraries/DurationsLib.sol diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 1befef14a..c7cf8db4a 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -8,13 +8,8 @@ import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; import {IVaultV2} from "../interfaces/IVaultV2.sol"; -import { - IMorphoMarketV2Adapter, - ObligationPosition, - Maturity, - IAdapter, - MAX_DURATIONS -} from "./interfaces/IMorphoMarketV2Adapter.sol"; +import {IMorphoMarketV2Adapter, ObligationPosition, Maturity, IAdapter} from "./interfaces/IMorphoMarketV2Adapter.sol"; +import {DurationsLib, MAX_DURATIONS} from "../libraries/DurationsLib.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 @@ -23,6 +18,7 @@ import { contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { using MathLib for uint256; using MathLib for uint128; + using DurationsLib for bytes32; /* IMMUTABLES */ @@ -43,7 +39,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint128 public currentGrowth; mapping(uint256 timestamp => Maturity) public _maturities; mapping(bytes32 obligationId => ObligationPosition) public _positions; - uint32[8] public _durations; + bytes32 public packedDurations; /* CONSTRUCTOR */ constructor(address _parentVault, address _morphoV2) { @@ -68,19 +64,14 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } function durations() external view returns (uint256[] memory) { - uint32[8] memory durationsLocal = _durations; - uint256 durationsCount = 0; + bytes32 _packedDurations = packedDurations; + uint256[] memory _array = new uint256[](_packedDurations.count()); + uint256 j = 0; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (durationsLocal[i] != 0) durationsCount++; + uint256 duration = _packedDurations.get(i); + if (duration > 0) _array[j++] = duration; } - - uint256[] memory durationsArray = new uint256[](durationsCount); - uint256 j; - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = durationsLocal[i]; - if (duration != 0) durationsArray[j++] = duration; - } - return durationsArray; + return _array; } function ids(Obligation memory obligation) external view returns (bytes32[] memory) { @@ -110,19 +101,20 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /// @dev Currently held obligations that match this duration must be touched to be accounted for in the caps. function addDuration(uint256 addedDuration) external { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); - require(addedDuration <= type(uint32).max && addedDuration != 0, IncorrectDuration()); + require(addedDuration > 0 && addedDuration <= type(uint32).max, IncorrectDuration()); bool placed = false; - uint32[8] memory localDurations = _durations; + bytes32 _packedDurations = packedDurations; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint32 duration = localDurations[i]; - require(addedDuration != duration, NoDuplicates()); - if (duration == 0 && !placed) { - localDurations[i] = uint32(addedDuration); + uint256 duration = _packedDurations.get(i); + require(duration != addedDuration, DurationAlreadyExists()); + if (!placed && duration == 0) { + _packedDurations = _packedDurations.set(i, addedDuration); placed = true; } } - _durations = localDurations; - if (!placed) revert MaxDurationsExceeded(); + if (!placed) revert TooManyDurations(); + packedDurations = _packedDurations; + emit AddDuration(addedDuration); } @@ -130,14 +122,17 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /// @dev Currently held obligations that match this duration must be touched to be de-accounted for in the caps. function removeDuration(uint256 removedDuration) external { require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); - uint32[8] memory localDurations = _durations; + + bytes32 _packedDurations = packedDurations; for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (localDurations[i] == removedDuration) { - localDurations[i] = 0; + uint256 duration = _packedDurations.get(i); + if (duration == removedDuration) { + _packedDurations = _packedDurations.set(i, 0); break; } } - _durations = localDurations; + packedDurations = _packedDurations; + emit RemoveDuration(removedDuration); } @@ -301,7 +296,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { nextMaturity = firstMaturity; } else { nextMaturity = _maturities[prevMaturity].nextMaturity; - require(nextMaturity != 0, IncorrectHint()); + require(nextMaturity > 0, IncorrectHint()); } while (nextMaturity < obligation.maturity) { @@ -388,25 +383,18 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return keccak256(abi.encode(obligation)); } - function syncedDurations(uint256 maturity) internal view returns (uint32[8] memory) { - uint32[8] memory localDurations = _durations; + function syncedDurations(uint256 maturity) internal view returns (bytes32) { + bytes32 _packedDurations = packedDurations; uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (timeToMaturity < localDurations[i]) localDurations[i] = 0; + if (timeToMaturity < _packedDurations.get(i)) _packedDurations = _packedDurations.set(i, 0); } - return localDurations; + return _packedDurations; } - function _ids(Obligation memory obligation, uint32[8] memory localDurations) - internal - view - returns (bytes32[] memory) - { - uint256 durationIdCount = 0; - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (localDurations[i] != 0) durationIdCount++; - } - bytes32[] memory idsArray = new bytes32[](1 + obligation.collaterals.length * 2 + durationIdCount); + function _ids(Obligation memory obligation, bytes32 _packedDurations) internal view returns (bytes32[] memory) { + uint256 durationsCount = _packedDurations.count(); + bytes32[] memory idsArray = new bytes32[](1 + obligation.collaterals.length * 2 + durationsCount); uint256 j; idsArray[j++] = adapterId; @@ -420,22 +408,19 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ); } for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = localDurations[i]; - if (duration != 0) { + uint256 duration = _packedDurations.get(i); + if (duration > 0) { idsArray[j++] = keccak256(abi.encode("duration", duration)); } } return idsArray; } - function selfDeallocate( - Obligation memory obligation, - uint32[8] memory localDurations, - uint256 removed, - uint256 assets - ) internal { + function selfDeallocate(Obligation memory obligation, bytes32 _packedDurations, uint256 removed, uint256 assets) + internal + { IVaultV2(parentVault) - .deallocate(address(this), abi.encode(_ids(obligation, localDurations), -removed.toInt256()), assets); + .deallocate(address(this), abi.encode(_ids(obligation, _packedDurations), -removed.toInt256()), assets); } /* TO REMOVE */ diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index 0e3e5b85c..dd4b54f68 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -6,13 +6,11 @@ import {IAdapter} from "../../interfaces/IAdapter.sol"; import {Obligation, Seizure} from "lib/morpho-v2/src/interfaces/IMorphoV2.sol"; import {ICallbacks} from "lib/morpho-v2/src/interfaces/ICallbacks.sol"; -uint256 constant MAX_DURATIONS = 8; - // Position in an obligation struct ObligationPosition { uint128 units; uint128 growth; - uint32[8] durations; + bytes32 durations; } // Chain of maturities, each can represent multiple obligations. @@ -32,8 +30,8 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { /* ERRORS */ - error BelowMinRate(); error BufferTooLow(); + error DurationAlreadyExists(); error IncorrectCallbackAddress(); error IncorrectCallbackData(); error IncorrectCollateralSet(); @@ -50,14 +48,13 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { error IncorrectStart(); error IncorrectUnits(); error LoanAssetMismatch(); - error MaxDurationsExceeded(); error NoBorrowing(); - error NoDuplicates(); error NotAuthorized(); error NotMorphoV2(); error NotSelf(); error PriceBelowOne(); error SelfAllocationOnly(); + error TooManyDurations(); /* FUNCTIONS */ diff --git a/src/libraries/DurationsLib.sol b/src/libraries/DurationsLib.sol new file mode 100644 index 000000000..a897e32d8 --- /dev/null +++ b/src/libraries/DurationsLib.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +bytes32 constant M0 = 0xFFFFFFFF00000000000000000000000000000000000000000000000000000000; +bytes32 constant M1 = 0x00000000FFFFFFFF000000000000000000000000000000000000000000000000; +bytes32 constant M2 = 0x0000000000000000FFFFFFFF0000000000000000000000000000000000000000; +bytes32 constant M3 = 0x000000000000000000000000FFFFFFFF00000000000000000000000000000000; +bytes32 constant M4 = 0x00000000000000000000000000000000FFFFFFFF000000000000000000000000; +bytes32 constant M5 = 0x0000000000000000000000000000000000000000FFFFFFFF0000000000000000; +bytes32 constant M6 = 0x000000000000000000000000000000000000000000000000FFFFFFFF00000000; +bytes32 constant M7 = 0x00000000000000000000000000000000000000000000000000000000FFFFFFFF; + +uint256 constant MAX_DURATIONS = 8; + +library DurationsLib { + error OutOfBounds(); + + function count(bytes32 duration) internal pure returns (uint256 len) { + assembly { + len := add(len, gt(and(duration, M0), 0)) + len := add(len, gt(and(duration, M1), 0)) + len := add(len, gt(and(duration, M2), 0)) + len := add(len, gt(and(duration, M3), 0)) + len := add(len, gt(and(duration, M4), 0)) + len := add(len, gt(and(duration, M5), 0)) + len := add(len, gt(and(duration, M6), 0)) + len := add(len, gt(and(duration, M7), 0)) + } + } + + function get(bytes32 durations, uint256 index) internal pure returns (uint256) { + require(index < MAX_DURATIONS, OutOfBounds()); + unchecked { + return uint256((durations >> (32 * (7 - index))) & M7); + } + } + + function set(bytes32 durations, uint256 index, uint256 value) internal pure returns (bytes32) { + require(index < MAX_DURATIONS, OutOfBounds()); + unchecked { + return durations & ~(M0 >> (32 * index)) | bytes32(uint256(uint32(value)) << (32 * (7 - index))); + } + } +} diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index e3e45d499..3c5b42a6b 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -536,7 +536,7 @@ contract MorphoMarketV2AdapterTest is Test { adapter.removeDuration(95); assertEq(adapter.durations().length, 7); adapter.addDuration(94); - vm.expectRevert(IMorphoMarketV2Adapter.MaxDurationsExceeded.selector); + vm.expectRevert(IMorphoMarketV2Adapter.TooManyDurations.selector); adapter.addDuration(2); vm.stopPrank(); } @@ -577,9 +577,9 @@ contract MorphoMarketV2AdapterTest is Test { adapter.addDuration(0); vm.expectRevert(IMorphoMarketV2Adapter.IncorrectDuration.selector); adapter.addDuration(uint256(type(uint32).max) + 1); - vm.expectRevert(IMorphoMarketV2Adapter.NoDuplicates.selector); + vm.expectRevert(IMorphoMarketV2Adapter.DurationAlreadyExists.selector); adapter.addDuration(1); - vm.expectRevert(IMorphoMarketV2Adapter.NoDuplicates.selector); + vm.expectRevert(IMorphoMarketV2Adapter.DurationAlreadyExists.selector); adapter.addDuration(2); vm.stopPrank(); } From d354fa51b78b34fdcd7658e609d81902b3a0e86b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Dec 2025 14:02:56 +0100 Subject: [PATCH 17/95] move durations lib --- src/adapters/MorphoMarketV2Adapter.sol | 2 +- src/{ => adapters}/libraries/DurationsLib.sol | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{ => adapters}/libraries/DurationsLib.sol (100%) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index c7cf8db4a..e2e7aa4b5 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -9,7 +9,7 @@ import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; import {IVaultV2} from "../interfaces/IVaultV2.sol"; import {IMorphoMarketV2Adapter, ObligationPosition, Maturity, IAdapter} from "./interfaces/IMorphoMarketV2Adapter.sol"; -import {DurationsLib, MAX_DURATIONS} from "../libraries/DurationsLib.sol"; +import {DurationsLib, MAX_DURATIONS} from "./libraries/DurationsLib.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 diff --git a/src/libraries/DurationsLib.sol b/src/adapters/libraries/DurationsLib.sol similarity index 100% rename from src/libraries/DurationsLib.sol rename to src/adapters/libraries/DurationsLib.sol From a40c730caea0e653e97fba07ad4a95a102ad1e9c Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Dec 2025 14:13:43 +0100 Subject: [PATCH 18/95] rename matching duration function --- src/adapters/MorphoMarketV2Adapter.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index e2e7aa4b5..cb27fba2e 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -75,7 +75,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } function ids(Obligation memory obligation) external view returns (bytes32[] memory) { - return _ids(obligation, syncedDurations(obligation.maturity)); + return _ids(obligation, matchingDurations(obligation.maturity)); } /* SKIM FUNCTIONS */ @@ -313,7 +313,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } } - position.durations = syncedDurations(obligation.maturity); + position.durations = matchingDurations(obligation.maturity); IVaultV2(parentVault) .allocate( address(this), abi.encode(_ids(obligation, position.durations), position.units.toInt256()), buyerAssets @@ -353,7 +353,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[_obligationId(obligation)]; selfDeallocate(obligation, position.durations, position.units, 0); - position.durations = syncedDurations(obligation.maturity); + position.durations = matchingDurations(obligation.maturity); IVaultV2(parentVault) .allocate(address(this), abi.encode(_ids(obligation, position.durations), position.units.toInt256()), 0); } @@ -383,7 +383,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return keccak256(abi.encode(obligation)); } - function syncedDurations(uint256 maturity) internal view returns (bytes32) { + function matchingDurations(uint256 maturity) internal view returns (bytes32) { bytes32 _packedDurations = packedDurations; uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); for (uint256 i = 0; i < MAX_DURATIONS; i++) { From f8c2c678328abaaf5a7d499012c9e31c70f8d31b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Dec 2025 14:46:53 +0100 Subject: [PATCH 19/95] 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 9430b8ff9273c0c069a1b0af221936c649383fe1 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Dec 2025 17:27:02 +0100 Subject: [PATCH 20/95] simpler line order --- src/adapters/MorphoMarketV2Adapter.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index cb27fba2e..4796c8ea5 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -112,8 +112,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { placed = true; } } - if (!placed) revert TooManyDurations(); packedDurations = _packedDurations; + if (!placed) revert TooManyDurations(); emit AddDuration(addedDuration); } From dc76ebcbf142b183838d477519676887193b5d6a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 8 Dec 2025 13:52:06 +0100 Subject: [PATCH 21/95] feat: immutable durations --- src/adapters/MorphoMarketV2Adapter.sol | 164 ++++++++---------- src/adapters/MorphoMarketV2AdapterFactory.sol | 13 +- .../interfaces/IMorphoMarketV2Adapter.sol | 7 +- src/adapters/libraries/DurationsLib.sol | 36 +--- test/DurationsLibTest.sol | 49 ++++++ test/MorphoMarketV1AdapterTest.sol | 2 +- ...phoMarketV2AdapterAllocationUpdateTest.sol | 117 ++++--------- test/MorphoMarketV2AdapterTest.sol | 121 +------------ 8 files changed, 181 insertions(+), 328 deletions(-) create mode 100644 test/DurationsLibTest.sol diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 4796c8ea5..ba9ee152d 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -9,7 +9,7 @@ import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; import {IVaultV2} from "../interfaces/IVaultV2.sol"; import {IMorphoMarketV2Adapter, ObligationPosition, Maturity, IAdapter} from "./interfaces/IMorphoMarketV2Adapter.sol"; -import {DurationsLib, MAX_DURATIONS} from "./libraries/DurationsLib.sol"; +import {DurationsLib} from "./libraries/DurationsLib.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 @@ -26,6 +26,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { address public immutable parentVault; address public immutable morphoV2; bytes32 public immutable adapterId; + bytes32 public immutable packedDurations; + uint256 public immutable durationsLength; /* MANAGEMENT */ @@ -39,10 +41,9 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint128 public currentGrowth; mapping(uint256 timestamp => Maturity) public _maturities; mapping(bytes32 obligationId => ObligationPosition) public _positions; - bytes32 public packedDurations; /* CONSTRUCTOR */ - constructor(address _parentVault, address _morphoV2) { + constructor(address _parentVault, address _morphoV2, uint256[] memory _durations) { asset = IVaultV2(_parentVault).asset(); parentVault = _parentVault; morphoV2 = _morphoV2; @@ -51,6 +52,17 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); firstMaturity = type(uint48).max; adapterId = keccak256(abi.encode("this", address(this))); + + bytes32 _packedDurations; + uint256 currentDuration; + for (uint256 i = 0; i < _durations.length; i++) { + require(_durations[i] > currentDuration, IncorrectDuration()); + require(_durations[i] <= type(uint32).max, IncorrectDuration()); + currentDuration = _durations[i]; + _packedDurations = _packedDurations.set(i, _durations[i]); + } + packedDurations = _packedDurations; + durationsLength = _durations.length; } /* GETTERS */ @@ -63,19 +75,12 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return _maturities[date]; } - function durations() external view returns (uint256[] memory) { - bytes32 _packedDurations = packedDurations; - uint256[] memory _array = new uint256[](_packedDurations.count()); - uint256 j = 0; - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _packedDurations.get(i); - if (duration > 0) _array[j++] = duration; + function durations() public view returns (uint256[] memory) { + uint256[] memory _durations = new uint256[](durationsLength); + for (uint256 i = 0; i < durationsLength; i++) { + _durations[i] = packedDurations.get(i); } - return _array; - } - - function ids(Obligation memory obligation) external view returns (bytes32[] memory) { - return _ids(obligation, matchingDurations(obligation.maturity)); + return _durations; } /* SKIM FUNCTIONS */ @@ -95,55 +100,39 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { emit Skim(token, balance); } - /* VAULT CURATOR FUNCTIONS */ - - /// @dev Adds a new duration to the adapter. - /// @dev Currently held obligations that match this duration must be touched to be accounted for in the caps. - function addDuration(uint256 addedDuration) external { - require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); - require(addedDuration > 0 && addedDuration <= type(uint32).max, IncorrectDuration()); - bool placed = false; - bytes32 _packedDurations = packedDurations; - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _packedDurations.get(i); - require(duration != addedDuration, DurationAlreadyExists()); - if (!placed && duration == 0) { - _packedDurations = _packedDurations.set(i, addedDuration); - placed = true; - } - } - packedDurations = _packedDurations; - if (!placed) revert TooManyDurations(); - - emit AddDuration(addedDuration); - } - - /// @dev Future obligation that match this duration will no longer consume the duration cap in the vault. - /// @dev Currently held obligations that match this duration must be touched to be de-accounted for in the caps. - function removeDuration(uint256 removedDuration) external { - require(msg.sender == IVaultV2(parentVault).curator(), NotAuthorized()); - - bytes32 _packedDurations = packedDurations; - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _packedDurations.get(i); - if (duration == removedDuration) { - _packedDurations = _packedDurations.set(i, 0); - break; - } - } - packedDurations = _packedDurations; - - emit RemoveDuration(removedDuration); - } - /* VAULT ALLOCATORS FUNCTIONS */ function withdraw(Obligation memory obligation, uint256 withdrawn, uint256 shares) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); (, shares) = MorphoV2(morphoV2).withdraw(obligation, withdrawn, shares, address(this)); ObligationPosition storage position = _positions[_obligationId(obligation)]; + + deallocateExpiredDurations(obligation, position); removeUnits(obligation, position, withdrawn); - selfDeallocate(obligation, position.durations, withdrawn, withdrawn); + selfDeallocate(ids(obligation), withdrawn, withdrawn); + } + + function deallocateExpiredDurations(Obligation memory obligation, ObligationPosition storage position) internal { + uint256 previousTimeToMaturity = obligation.maturity.zeroFloorSub(position.lastUpdate); + uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + + uint256 zeroedDurationsCount = 0; + for (uint256 i = 0; i < durationsLength && previousTimeToMaturity >= packedDurations.get(i); i++) { + if (timeToMaturity < packedDurations.get(i)) zeroedDurationsCount++; + } + + if (zeroedDurationsCount > 0) { + bytes32[] memory zeroedDurationsIds = new bytes32[](zeroedDurationsCount); + uint256 j = 0; + for (uint256 i = 0; i < durationsLength; i++) { + if (previousTimeToMaturity >= packedDurations.get(i) && timeToMaturity < packedDurations.get(i)) { + zeroedDurationsIds[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); + } + } + selfDeallocate(zeroedDurationsIds, position.units, 0); + } + + position.lastUpdate = uint48(block.timestamp); } /* ACCRUAL */ @@ -193,8 +182,9 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; uint256 lostUnits = position.units - remainingUnits; + deallocateExpiredDurations(obligation, position); removeUnits(obligation, position, lostUnits); - selfDeallocate(obligation, position.durations, lostUnits, 0); + selfDeallocate(ids(obligation), lostUnits, 0); } /* ALLOCATION FUNCTIONS */ @@ -226,15 +216,16 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { IncorrectOffer() ); - (,, uint256 removedObligationUnits,) = MorphoV2(morphoV2) + (,, uint256 deallocated,) = MorphoV2(morphoV2) .take(0, sellerAssets, 0, 0, address(this), offer, proof, signature, address(0), hex""); bytes32 obligationId = _obligationId(offer.obligation); require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); ObligationPosition storage position = _positions[obligationId]; - removeUnits(offer.obligation, position, removedObligationUnits); - return (_ids(offer.obligation, position.durations), -removedObligationUnits.toInt256()); + deallocateExpiredDurations(offer.obligation, position); + removeUnits(offer.obligation, position, deallocated); + return (ids(offer.obligation), -deallocated.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); assembly ("memory-safe") { @@ -273,11 +264,10 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint48 prevMaturity = abi.decode(data, (uint48)); require(prevMaturity < obligation.maturity, IncorrectHint()); ObligationPosition storage position = _positions[obligationId]; - - if (position.units > 0) selfDeallocate(obligation, position.durations, position.units, 0); - accrueInterest(); + if (position.units > 0) deallocateExpiredDurations(obligation, position); + if (obligation.maturity > block.timestamp) { uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); uint128 gainedGrowth = ((obligationUnits - buyerAssets) / timeToMaturity).toUint128(); @@ -313,11 +303,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } } - position.durations = matchingDurations(obligation.maturity); IVaultV2(parentVault) - .allocate( - address(this), abi.encode(_ids(obligation, position.durations), position.units.toInt256()), buyerAssets - ); + .allocate(address(this), abi.encode(ids(obligation), position.units.toInt256()), buyerAssets); } function onSell( @@ -337,8 +324,9 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); ObligationPosition storage position = _positions[obligationId]; + deallocateExpiredDurations(obligation, position); removeUnits(obligation, position, soldObligationUnits); - selfDeallocate(obligation, position.durations, soldObligationUnits, sellerAssets); + selfDeallocate(ids(obligation), soldObligationUnits, sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -349,13 +337,9 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } // Convenience function to sync the durations of an obligation. - function syncDurations(Obligation memory obligation) external { + function deallocateExpiredDurations(Obligation memory obligation) external { ObligationPosition storage position = _positions[_obligationId(obligation)]; - selfDeallocate(obligation, position.durations, position.units, 0); - - position.durations = matchingDurations(obligation.maturity); - IVaultV2(parentVault) - .allocate(address(this), abi.encode(_ids(obligation, position.durations), position.units.toInt256()), 0); + deallocateExpiredDurations(obligation, position); } /* INTERNAL FUNCTIONS */ @@ -383,17 +367,14 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return keccak256(abi.encode(obligation)); } - function matchingDurations(uint256 maturity) internal view returns (bytes32) { - bytes32 _packedDurations = packedDurations; - uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (timeToMaturity < _packedDurations.get(i)) _packedDurations = _packedDurations.set(i, 0); + function ids(Obligation memory obligation) public view returns (bytes32[] memory) { + uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + + uint256 durationsCount = 0; + for (uint256 i = 0; i < durationsLength && timeToMaturity >= packedDurations.get(i); i++) { + durationsCount++; } - return _packedDurations; - } - function _ids(Obligation memory obligation, bytes32 _packedDurations) internal view returns (bytes32[] memory) { - uint256 durationsCount = _packedDurations.count(); bytes32[] memory idsArray = new bytes32[](1 + obligation.collaterals.length * 2 + durationsCount); uint256 j; @@ -407,20 +388,15 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ) ); } - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - uint256 duration = _packedDurations.get(i); - if (duration > 0) { - idsArray[j++] = keccak256(abi.encode("duration", duration)); - } + for (uint256 i = 0; i < durationsLength && timeToMaturity >= packedDurations.get(i); i++) { + idsArray[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); } + return idsArray; } - function selfDeallocate(Obligation memory obligation, bytes32 _packedDurations, uint256 removed, uint256 assets) - internal - { - IVaultV2(parentVault) - .deallocate(address(this), abi.encode(_ids(obligation, _packedDurations), -removed.toInt256()), assets); + function selfDeallocate(bytes32[] memory _ids, uint256 deallocated, uint256 assets) internal { + IVaultV2(parentVault).deallocate(address(this), abi.encode(_ids, -deallocated.toInt256()), assets); } /* TO REMOVE */ diff --git a/src/adapters/MorphoMarketV2AdapterFactory.sol b/src/adapters/MorphoMarketV2AdapterFactory.sol index 0b4599b1c..d4e91b257 100644 --- a/src/adapters/MorphoMarketV2AdapterFactory.sol +++ b/src/adapters/MorphoMarketV2AdapterFactory.sol @@ -14,10 +14,21 @@ contract MorphoMarketV2AdapterFactory is IMorphoMarketV2AdapterFactory { /* FUNCTIONS */ function createMorphoMarketV2Adapter(address parentVault, address morpho) external returns (address) { - address _morphoMarketV2Adapter = address(new MorphoMarketV2Adapter{salt: bytes32(0)}(parentVault, morpho)); + address _morphoMarketV2Adapter = + address(new MorphoMarketV2Adapter{salt: bytes32(0)}(parentVault, morpho, durations())); morphoMarketV2Adapter[parentVault][morpho] = _morphoMarketV2Adapter; isMorphoMarketV2Adapter[_morphoMarketV2Adapter] = true; emit CreateMorphoMarketV2Adapter(parentVault, morpho, _morphoMarketV2Adapter); return _morphoMarketV2Adapter; } + + function durations() public pure returns (uint256[] memory) { + uint256[] memory _durations = new uint256[](5); + _durations[0] = 1 days; + _durations[1] = 7 days; + _durations[2] = 30 days; + _durations[3] = 90 days; + _durations[4] = 180 days; + return _durations; + } } diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index dd4b54f68..be770b477 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -10,7 +10,7 @@ import {ICallbacks} from "lib/morpho-v2/src/interfaces/ICallbacks.sol"; struct ObligationPosition { uint128 units; uint128 growth; - bytes32 durations; + uint48 lastUpdate; } // Chain of maturities, each can represent multiple obligations. @@ -67,16 +67,15 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { function maturities(uint256 date) external view returns (Maturity memory); function setSkimRecipient(address newSkimRecipient) external; function skim(address token) external; - function addDuration(uint256 duration) external; - function removeDuration(uint256 duration) external; function durations() external view returns (uint256[] memory); + function durationsLength() external view returns (uint256); + function deallocateExpiredDurations(Obligation memory obligation) external; function withdraw(Obligation memory obligation, uint256 units, uint256 shares) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); function accrueInterest() external; function realizeLoss(Obligation memory obligation) external; - function syncDurations(Obligation memory obligation) external; function allocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) external returns (bytes32[] memory, int256); diff --git a/src/adapters/libraries/DurationsLib.sol b/src/adapters/libraries/DurationsLib.sol index a897e32d8..4977c36ce 100644 --- a/src/adapters/libraries/DurationsLib.sol +++ b/src/adapters/libraries/DurationsLib.sol @@ -2,44 +2,26 @@ // Copyright (c) 2025 Morpho Association pragma solidity ^0.8.0; -bytes32 constant M0 = 0xFFFFFFFF00000000000000000000000000000000000000000000000000000000; -bytes32 constant M1 = 0x00000000FFFFFFFF000000000000000000000000000000000000000000000000; -bytes32 constant M2 = 0x0000000000000000FFFFFFFF0000000000000000000000000000000000000000; -bytes32 constant M3 = 0x000000000000000000000000FFFFFFFF00000000000000000000000000000000; -bytes32 constant M4 = 0x00000000000000000000000000000000FFFFFFFF000000000000000000000000; -bytes32 constant M5 = 0x0000000000000000000000000000000000000000FFFFFFFF0000000000000000; -bytes32 constant M6 = 0x000000000000000000000000000000000000000000000000FFFFFFFF00000000; -bytes32 constant M7 = 0x00000000000000000000000000000000000000000000000000000000FFFFFFFF; - uint256 constant MAX_DURATIONS = 8; library DurationsLib { - error OutOfBounds(); - - function count(bytes32 duration) internal pure returns (uint256 len) { - assembly { - len := add(len, gt(and(duration, M0), 0)) - len := add(len, gt(and(duration, M1), 0)) - len := add(len, gt(and(duration, M2), 0)) - len := add(len, gt(and(duration, M3), 0)) - len := add(len, gt(and(duration, M4), 0)) - len := add(len, gt(and(duration, M5), 0)) - len := add(len, gt(and(duration, M6), 0)) - len := add(len, gt(and(duration, M7), 0)) - } - } + error IndexOutOfBounds(); + error ValueOutOfBounds(); function get(bytes32 durations, uint256 index) internal pure returns (uint256) { - require(index < MAX_DURATIONS, OutOfBounds()); + require(index < MAX_DURATIONS, IndexOutOfBounds()); unchecked { - return uint256((durations >> (32 * (7 - index))) & M7); + return uint32(uint256(durations >> (32 * index))); } } function set(bytes32 durations, uint256 index, uint256 value) internal pure returns (bytes32) { - require(index < MAX_DURATIONS, OutOfBounds()); + require(index < MAX_DURATIONS, IndexOutOfBounds()); + require(value <= type(uint32).max, ValueOutOfBounds()); unchecked { - return durations & ~(M0 >> (32 * index)) | bytes32(uint256(uint32(value)) << (32 * (7 - index))); + uint256 s = 32 * index; + /// forge-lint: disable-next-line(incorrect-shift) + return bytes32((uint256(durations) & ~(0xFFFFFFFF << s)) | (value << s)); } } } diff --git a/test/DurationsLibTest.sol b/test/DurationsLibTest.sol new file mode 100644 index 000000000..7a47b69f7 --- /dev/null +++ b/test/DurationsLibTest.sol @@ -0,0 +1,49 @@ +// 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 {DurationsLib, MAX_DURATIONS} from "../src/adapters/libraries/DurationsLib.sol"; + +contract DurationsLibTest is Test { + using DurationsLib for bytes32; + + /// forge-config: default.allow_internal_expect_revert = true + function testGetInvalidIndex(bytes32 durations, uint256 index) public { + index = bound(index, MAX_DURATIONS, type(uint256).max); + vm.expectRevert(DurationsLib.IndexOutOfBounds.selector); + durations.get(index); + } + + /// forge-config: default.allow_internal_expect_revert = true + function testSetInvalidIndex(bytes32 durations, uint256 index, uint32 value) public { + index = bound(index, MAX_DURATIONS, type(uint256).max); + vm.expectRevert(DurationsLib.IndexOutOfBounds.selector); + durations.set(index, value); + } + + /// forge-config: default.allow_internal_expect_revert = true + function testSetInvalidValue(bytes32 durations, uint256 index, uint256 value) public { + index = bound(index, 0, MAX_DURATIONS - 1); + value = bound(value, uint256(type(uint32).max) + 1, type(uint256).max); + vm.expectRevert(DurationsLib.ValueOutOfBounds.selector); + durations.set(index, value); + } + + function testGetAndSet(bytes32 durations, uint32 value, uint256 index) public pure { + index = bound(index, 0, MAX_DURATIONS - 1); + bytes32 newDurations = durations.set(index, value); + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + if (i == index) { + assertEq(newDurations.get(i), value, "set"); + } else { + assertEq(newDurations.get(i), durations.get(i), "not set"); + } + } + } + + function testLayout(bytes32 durations, uint32 value) public pure { + assertEq(uint32(uint256(durations.set(0, value))), value, "first"); + assertEq(uint32(bytes4(durations.set(7, value))), value, "last"); + } +} diff --git a/test/MorphoMarketV1AdapterTest.sol b/test/MorphoMarketV1AdapterTest.sol index f18d122ca..459f3ee2f 100644 --- a/test/MorphoMarketV1AdapterTest.sol +++ b/test/MorphoMarketV1AdapterTest.sol @@ -433,7 +433,7 @@ contract MorphoMarketV1AdapterTest is Test { function testBurnShares(uint256 timelockDuration, uint256 extraSkip) public { uint256 assets = _boundAssets(1000); - deal(address(loanToken), address(adapter), assets); + deal(address(loanToken), address(parentVault), assets); parentVault.allocate(address(adapter), abi.encode(marketParams), assets); uint256 supplyShares = adapter.supplyShares(marketId); diff --git a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol index 4bf7bf9a3..3dce6baad 100644 --- a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol +++ b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol @@ -70,94 +70,56 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); } - function durationId(uint32 duration) internal pure returns (bytes32) { + function durationId(uint256 duration) internal pure returns (bytes32) { return keccak256(abi.encode("duration", duration)); } - function testExactDuration(uint32 duration) public { - duration = uint32(bound(duration, 1, type(uint32).max)); - vm.prank(curator); - adapter.addDuration(duration); + function testExactDuration(uint32 durationIndex) public { + durationIndex = uint32(bound(durationIndex, 0, adapter.durationsLength() - 1)); + uint256 duration = adapter.durations()[durationIndex]; buy(duration, 1e18); assertEq(parentVault.allocation(durationId(duration)), 1e18); } - function testExitDuration(uint32 duration, uint256 timeToMaturity, uint256 extraSkip) public { - duration = uint32(bound(duration, 1, type(uint32).max)); + function testExitDuration(uint256 durationIndex, uint256 timeToMaturity, uint256 extraSkip) public { + durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); + uint256 duration = adapter.durations()[durationIndex]; timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); extraSkip = bound(extraSkip, 1, 10 * 365 days); - vm.prank(curator); - adapter.addDuration(duration); Offer memory offer = buy(timeToMaturity, 1e18); assertEq(parentVault.allocation(durationId(duration)), 1e18); skip(timeToMaturity - duration + extraSkip); - adapter.syncDurations(offer.obligation); + adapter.deallocateExpiredDurations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), 0); } - function testAddDuration(uint32 duration, uint256 timeToMaturity) public { - duration = uint32(bound(duration, 1, type(uint32).max)); + function testRepeatDeallocateExpiredDurations(uint256 durationIndex, uint256 timeToMaturity, uint256 skipAmount) + public + { + durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); + uint256 duration = adapter.durations()[durationIndex]; timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); - Offer memory offer = buy(timeToMaturity, 1e18); - assertEq(parentVault.allocation(durationId(duration)), 0); - - vm.prank(curator); - adapter.addDuration(duration); - adapter.syncDurations(offer.obligation); - - assertEq(parentVault.allocation(durationId(duration)), 1e18); - } - - function testRemoveDuration(uint32 duration, uint256 timeToMaturity) public { - duration = uint32(bound(duration, 1, type(uint32).max)); - vm.prank(curator); - adapter.addDuration(duration); - - timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); - Offer memory offer = buy(timeToMaturity, 1e18); - assertEq(parentVault.allocation(durationId(duration)), 1e18); - - vm.prank(curator); - adapter.removeDuration(duration); - assertEq(parentVault.allocation(durationId(duration)), 1e18); - - adapter.syncDurations(offer.obligation); - - assertEq(parentVault.allocation(durationId(duration)), 0); - } - - function testRepeatSync(uint32 duration, uint32 timeToMaturity, uint256 skipAmount) public { - duration = uint32(bound(duration, 1, type(uint32).max)); - vm.prank(curator); - adapter.addDuration(duration); - skipAmount = bound(skipAmount, 0, uint256(duration) * 2); + skipAmount = bound(skipAmount, 0, duration * 2); Offer memory offer = buy(timeToMaturity, 1e18); skip(skipAmount); - adapter.syncDurations(offer.obligation); + adapter.deallocateExpiredDurations(offer.obligation); uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.syncDurations(offer.obligation); + adapter.deallocateExpiredDurations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } function testUpdateOnRealizeLoss() public { - vm.prank(curator); - adapter.addDuration(1 weeks); - vm.prank(curator); - adapter.addDuration(2 weeks); - Offer memory offer = buy(2 weeks, 1e18); - assertEq(parentVault.allocation(durationId(1 weeks)), 1e18, "1 week, before"); - assertEq(parentVault.allocation(durationId(2 weeks)), 1e18, "2 weeks, before"); + Offer memory offer = buy(7 days, 1e18); + assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 week, before"); + assertEq(parentVault.allocation(durationId(7 days)), 1e18, "2 weeks, before"); skip(1); - vm.prank(curator); - adapter.addDuration(1 days); - Oracle(offer.obligation.collaterals[0].oracle).setPrice(0); morphoV2.liquidate(offer.obligation, new Seizure[](0), taker, ""); adapter.realizeLoss(offer.obligation); @@ -168,24 +130,16 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest MorphoV2(morphoV2).totalUnits(obligationId) + 1, MorphoV2(morphoV2).totalShares(obligationId) + 1 ); - assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); - assertEq(parentVault.allocation(durationId(1 weeks)), remainingUnits, "1 week"); - assertEq(parentVault.allocation(durationId(2 weeks)), remainingUnits, "2 weeks"); + assertEq(parentVault.allocation(durationId(1 days)), remainingUnits, "1 day"); + assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } function testUpdateOnWithdraw() public { - vm.prank(curator); - adapter.addDuration(1 weeks); - vm.prank(curator); - adapter.addDuration(2 weeks); - Offer memory offer = buy(2 weeks, 1e18); - assertEq(parentVault.allocation(durationId(1 weeks)), 1e18, "1 week, before"); - assertEq(parentVault.allocation(durationId(2 weeks)), 1e18, "2 weeks, before"); - - skip(2 weeks); + Offer memory offer = buy(7 days, 1e18); + assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); + assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); - vm.prank(curator); - adapter.addDuration(1 days); + skip(7 days); vm.prank(taker); morphoV2.repay(offer.obligation, 1e18, taker); @@ -193,24 +147,16 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest adapter.withdraw(offer.obligation, 0.5e18, 0); assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); - assertEq(parentVault.allocation(durationId(1 weeks)), 0.5e18, "1 week"); - assertEq(parentVault.allocation(durationId(2 weeks)), 0.5e18, "2 weeks"); + assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } function testUpdateOnSell() public { - vm.prank(curator); - adapter.addDuration(1 weeks); - vm.prank(curator); - adapter.addDuration(2 weeks); - Offer memory offer = buy(2 weeks, 1e18); - assertEq(parentVault.allocation(durationId(1 weeks)), 1e18, "1 week, before"); - assertEq(parentVault.allocation(durationId(2 weeks)), 1e18, "2 weeks, before"); + Offer memory offer = buy(7 days, 1e18); + assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); + assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); skip(1); - vm.prank(curator); - adapter.addDuration(1 days); - parentVault.setTotalAssets(1e18); parentVault.setAdaptersLength(1); address[] memory adapters = new address[](1); @@ -218,9 +164,8 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest parentVault.setAdapters(adapters); sell(offer.obligation, 0.5e18); - assertEq(parentVault.allocation(durationId(1 weeks)), 0.5e18, "1 week"); - assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); - assertEq(parentVault.allocation(durationId(2 weeks)), 0.5e18, "2 weeks"); + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); + assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } // TODO force deallocate test diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index 3c5b42a6b..7c155ff6f 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -486,116 +486,12 @@ contract MorphoMarketV2AdapterTest is Test { /* DURATIONS */ - function testDurationsUpdate() public { - vm.startPrank(parentVault.curator()); - vm.expectEmit(); - emit IMorphoMarketV2Adapter.AddDuration(10); - adapter.addDuration(10); - assertEq(adapter.durations().length, 1); - assertEq(adapter.durations()[0], 10); - - vm.expectEmit(); - emit IMorphoMarketV2Adapter.RemoveDuration(10); - adapter.removeDuration(10); - assertEq(adapter.durations().length, 0); - - vm.expectEmit(); - emit IMorphoMarketV2Adapter.AddDuration(20); - adapter.addDuration(20); - assertEq(adapter.durations().length, 1); - assertEq(adapter.durations()[0], 20); - - vm.expectEmit(); - emit IMorphoMarketV2Adapter.AddDuration(1); - adapter.addDuration(1); - assertEq(adapter.durations().length, 2); - assertEq(adapter.durations()[0], 20); - assertEq(adapter.durations()[1], 1); - - vm.expectEmit(); - emit IMorphoMarketV2Adapter.RemoveDuration(20); - adapter.removeDuration(20); - assertEq(adapter.durations().length, 1); - - adapter.addDuration(19); - adapter.addDuration(20); - adapter.addDuration(99); - adapter.addDuration(98); - adapter.addDuration(97); - adapter.addDuration(96); - adapter.addDuration(95); - assertEq(adapter.durations().length, 8); - assertEq(adapter.durations()[0], 19); - assertEq(adapter.durations()[1], 1); - assertEq(adapter.durations()[2], 20); - assertEq(adapter.durations()[3], 99); - assertEq(adapter.durations()[4], 98); - assertEq(adapter.durations()[5], 97); - assertEq(adapter.durations()[6], 96); - assertEq(adapter.durations()[7], 95); - adapter.removeDuration(95); - assertEq(adapter.durations().length, 7); - adapter.addDuration(94); - vm.expectRevert(IMorphoMarketV2Adapter.TooManyDurations.selector); - adapter.addDuration(2); - vm.stopPrank(); - } - - function testSetDurationsAccess(address sender, uint256 duration) public { - vm.assume(sender != parentVault.curator()); - vm.prank(sender); - vm.expectRevert(IMorphoMarketV2Adapter.NotAuthorized.selector); - adapter.addDuration(duration); - - vm.prank(sender); - vm.expectRevert(IMorphoMarketV2Adapter.NotAuthorized.selector); - adapter.removeDuration(duration); - } - - function testAddDurationIncorrectOrDuplicate() public { - vm.startPrank(parentVault.curator()); - - adapter.addDuration(1); - adapter.addDuration(100); - adapter.addDuration(12); - adapter.addDuration(99); - adapter.addDuration(101); - adapter.addDuration(102); - adapter.addDuration(2); - adapter.addDuration(103); - adapter.removeDuration(103); - adapter.removeDuration(102); - adapter.removeDuration(101); - uint256[] memory actualArray = adapter.durations(); - uint256[5] memory expectedArray = [uint256(1), 100, 12, 99, 2]; - assertEq(actualArray.length, expectedArray.length); - for (uint256 i = 0; i < actualArray.length; i++) { - assertEq(actualArray[i], expectedArray[i]); - } - - vm.expectRevert(IMorphoMarketV2Adapter.IncorrectDuration.selector); - adapter.addDuration(0); - vm.expectRevert(IMorphoMarketV2Adapter.IncorrectDuration.selector); - adapter.addDuration(uint256(type(uint32).max) + 1); - vm.expectRevert(IMorphoMarketV2Adapter.DurationAlreadyExists.selector); - adapter.addDuration(1); - vm.expectRevert(IMorphoMarketV2Adapter.DurationAlreadyExists.selector); - adapter.addDuration(2); - vm.stopPrank(); - } + // Add constructor tests /* IDS */ - function testIds(uint256 collateralCount, uint256 durationsCount, uint256 maturity) public { - uint256[] memory possibleDurations = new uint256[](4); - possibleDurations[0] = 1 days; - possibleDurations[1] = 10 days; - possibleDurations[2] = 300 days; - possibleDurations[3] = 700 days; - possibleDurations = vm.shuffle(possibleDurations); - + function testIds(uint256 collateralCount, uint256 maturity) public view { collateralCount = bound(collateralCount, 0, 5); - durationsCount = bound(durationsCount, 0, 4); Obligation memory obligation; @@ -605,11 +501,6 @@ contract MorphoMarketV2AdapterTest is Test { } obligation.collaterals = storedCollaterals; obligation.maturity = bound(maturity, 1, 700 days); - vm.startPrank(parentVault.curator()); - for (uint256 i = 0; i < durationsCount; i++) { - adapter.addDuration(possibleDurations[i]); - } - vm.stopPrank(); bytes32[] memory ids = adapter.ids(obligation); assertEq(ids[0], adapter.adapterId()); @@ -628,13 +519,13 @@ contract MorphoMarketV2AdapterTest is Test { ); } - uint256[] memory durationsArray = adapter.durations(); + uint256[] memory durations = adapter.durations(); uint256 durationIdCount = 0; - for (uint256 i = 0; i < durationsArray.length; i++) { - if ((obligation.maturity - block.timestamp) >= durationsArray[i]) { + for (uint256 i = 0; i < durations.length; i++) { + if ((obligation.maturity - block.timestamp) >= durations[i]) { assertEq( ids[1 + obligation.collaterals.length * 2 + durationIdCount], - keccak256(abi.encode("duration", durationsArray[i])) + keccak256(abi.encode("duration", durations[i])) ); durationIdCount++; } From 9f22da353bc04e241e044093c093b02813f605ae Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 8 Dec 2025 16:11:39 +0100 Subject: [PATCH 22/95] fix currentGrowth update and simplify interface --- src/adapters/MorphoMarketV2Adapter.sol | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index ba9ee152d..2026e6841 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -108,7 +108,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[_obligationId(obligation)]; deallocateExpiredDurations(obligation, position); - removeUnits(obligation, position, withdrawn); + removeUnits(obligation.maturity, position, withdrawn); selfDeallocate(ids(obligation), withdrawn, withdrawn); } @@ -183,7 +183,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; uint256 lostUnits = position.units - remainingUnits; deallocateExpiredDurations(obligation, position); - removeUnits(obligation, position, lostUnits); + removeUnits(obligation.maturity, position, lostUnits); selfDeallocate(ids(obligation), lostUnits, 0); } @@ -224,7 +224,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; deallocateExpiredDurations(offer.obligation, position); - removeUnits(offer.obligation, position, deallocated); + removeUnits(offer.obligation.maturity, position, deallocated); return (ids(offer.obligation), -deallocated.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); @@ -281,6 +281,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { position.units += obligationUnits.toUint128(); + // Insert the maturity in the list if needed uint48 nextMaturity; if (prevMaturity == 0) { nextMaturity = firstMaturity; @@ -325,7 +326,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; deallocateExpiredDurations(obligation, position); - removeUnits(obligation, position, soldObligationUnits); + removeUnits(obligation.maturity, position, soldObligationUnits); selfDeallocate(ids(obligation), soldObligationUnits, sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); @@ -345,17 +346,16 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* 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, ObligationPosition storage position, uint256 removedUnits) - internal - { + function removeUnits(uint256 maturity, ObligationPosition storage position, uint256 removedUnits) internal { accrueInterest(); - if (obligation.maturity > block.timestamp) { - uint256 timeToMaturity = obligation.maturity - block.timestamp; + if (maturity > block.timestamp) { + uint256 timeToMaturity = maturity - block.timestamp; uint128 removedGrowth = uint256(position.growth).mulDivUp(removedUnits, position.units).toUint128(); - _maturities[obligation.maturity].growthLostAtMaturity -= removedGrowth; + _maturities[maturity].growthLostAtMaturity -= removedGrowth; // Do not cleanup the linked list if we end up at 0 growth. position.growth -= removedGrowth; + currentGrowth -= removedGrowth; _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { _totalAssets -= removedUnits; From 5dd09b862b12f57620970c0ab3454c68d7a6c868 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 8 Dec 2025 16:14:55 +0100 Subject: [PATCH 23/95] fix: currentGrowth update --- src/adapters/MorphoMarketV2Adapter.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index da14940ba..1a03cd97f 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -334,6 +334,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { _maturities[obligation.maturity].growthLostAtMaturity -= removedGrowth; _positions[obligationId].growth -= removedGrowth; _positions[obligationId].units -= removedUnits.toUint128(); + currentGrowth -= removedGrowth; _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { _totalAssets -= removedUnits; From 00582cb6354d32395ed83aa24c3804714cc2fca2 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 9 Dec 2025 00:56:15 +0100 Subject: [PATCH 24/95] 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(); From b3f24044a238a971da63629c323583d1cc31a805 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 9 Dec 2025 01:14:03 +0100 Subject: [PATCH 25/95] fix onBuy return value --- src/adapters/MorphoMarketV2Adapter.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 171d5184a..c413b776d 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -305,7 +305,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } IVaultV2(parentVault) - .allocate(address(this), abi.encode(ids(obligation), position.units.toInt256()), buyerAssets); + .allocate(address(this), abi.encode(ids(obligation), obligationUnits.toInt256()), buyerAssets); } function onSell( From 881cfcc9280227231bb7b15241ada13bad4eb791 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 9 Dec 2025 12:17:40 +0100 Subject: [PATCH 26/95] fix first lastUpdate --- src/adapters/MorphoMarketV2Adapter.sol | 36 ++++++++++++++------------ 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index c413b776d..0e6f64f4c 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -113,26 +113,29 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } function deallocateExpiredDurations(Obligation memory obligation, ObligationPosition storage position) internal { - uint256 previousTimeToMaturity = obligation.maturity.zeroFloorSub(position.lastUpdate); - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + if (position.lastUpdate > 0) { + uint256 previousTimeToMaturity = obligation.maturity.zeroFloorSub(position.lastUpdate); + uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); - uint256 zeroedDurationsCount = 0; - for (uint256 i = 0; i < durationsLength && previousTimeToMaturity >= packedDurations.get(i); i++) { - if (timeToMaturity < packedDurations.get(i)) zeroedDurationsCount++; - } + uint256 zeroedDurationsCount = 0; + for (uint256 i = 0; i < durationsLength && previousTimeToMaturity >= packedDurations.get(i); i++) { + if (timeToMaturity < packedDurations.get(i)) zeroedDurationsCount++; + } - if (zeroedDurationsCount > 0) { - bytes32[] memory zeroedDurationsIds = new bytes32[](zeroedDurationsCount); - uint256 j = 0; - for (uint256 i = 0; i < durationsLength; i++) { - if (previousTimeToMaturity >= packedDurations.get(i) && timeToMaturity < packedDurations.get(i)) { - zeroedDurationsIds[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); + if (zeroedDurationsCount > 0) { + bytes32[] memory zeroedDurationsIds = new bytes32[](zeroedDurationsCount); + uint256 j = 0; + for (uint256 i = 0; i < durationsLength; i++) { + if (previousTimeToMaturity >= packedDurations.get(i) && timeToMaturity < packedDurations.get(i)) { + zeroedDurationsIds[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); + } } + selfDeallocate(zeroedDurationsIds, position.units, 0); } - selfDeallocate(zeroedDurationsIds, position.units, 0); } position.lastUpdate = uint48(block.timestamp); + } /* ACCRUAL */ @@ -211,8 +214,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { (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, + offer.buy && offer.obligation.loanToken == asset && offer.startPrice == 1e18, IncorrectOffer() ); @@ -266,7 +268,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { ObligationPosition storage position = _positions[obligationId]; accrueInterest(); - if (position.units > 0) deallocateExpiredDurations(obligation, position); + deallocateExpiredDurations(obligation, position); if (obligation.maturity > block.timestamp) { uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); @@ -280,6 +282,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { } position.units += obligationUnits.toUint128(); + position.lastUpdate = uint48(block.timestamp); // Insert the maturity in the list if needed uint48 nextMaturity; @@ -306,6 +309,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { IVaultV2(parentVault) .allocate(address(this), abi.encode(ids(obligation), obligationUnits.toInt256()), buyerAssets); + } function onSell( From dd27eb6cc481ccf872dfc41eff664a590a9c891f Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 9 Dec 2025 18:50:54 +0100 Subject: [PATCH 27/95] remove positions --- src/adapters/MorphoMarketV2Adapter.sol | 86 ++++++++----------- .../interfaces/IMorphoMarketV2Adapter.sol | 17 ++-- test/MorphoMarketV2AdapterTest.sol | 33 +++---- 3 files changed, 56 insertions(+), 80 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 0e6f64f4c..d15d08089 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -8,7 +8,7 @@ import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; import {IVaultV2} from "../interfaces/IVaultV2.sol"; -import {IMorphoMarketV2Adapter, ObligationPosition, Maturity, IAdapter} from "./interfaces/IMorphoMarketV2Adapter.sol"; +import {IMorphoMarketV2Adapter, MaturityData, IAdapter} from "./interfaces/IMorphoMarketV2Adapter.sol"; import {DurationsLib} from "./libraries/DurationsLib.sol"; /// @dev Approximates held assets by linearly accounting for interest separately for each obligation. @@ -39,8 +39,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint48 public lastUpdate; uint48 public firstMaturity; uint128 public currentGrowth; - mapping(uint256 timestamp => Maturity) public _maturities; - mapping(bytes32 obligationId => ObligationPosition) public _positions; + mapping(uint256 timestamp => MaturityData) public _maturities; + mapping(bytes32 obligationId => uint256) public _units; /* CONSTRUCTOR */ constructor(address _parentVault, address _morphoV2, uint256[] memory _durations) { @@ -67,11 +67,11 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* GETTERS */ - function positions(bytes32 obligationId) public view returns (ObligationPosition memory) { - return _positions[obligationId]; + function units(bytes32 obligationId) public view returns (uint256) { + return _units[obligationId]; } - function maturities(uint256 date) public view returns (Maturity memory) { + function maturities(uint256 date) public view returns (MaturityData memory) { return _maturities[date]; } @@ -105,16 +105,16 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { function withdraw(Obligation memory obligation, uint256 withdrawn, uint256 shares) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); (, shares) = MorphoV2(morphoV2).withdraw(obligation, withdrawn, shares, address(this)); - ObligationPosition storage position = _positions[_obligationId(obligation)]; - deallocateExpiredDurations(obligation, position); - removeUnits(obligation.maturity, position, withdrawn); + deallocateExpiredDurations(obligation); + removeUnits(obligation, withdrawn); selfDeallocate(ids(obligation), withdrawn, withdrawn); } - function deallocateExpiredDurations(Obligation memory obligation, ObligationPosition storage position) internal { - if (position.lastUpdate > 0) { - uint256 previousTimeToMaturity = obligation.maturity.zeroFloorSub(position.lastUpdate); + function deallocateExpiredDurations(Obligation memory obligation) public { + MaturityData storage maturityData = _maturities[obligation.maturity]; + if (maturityData.lastUpdate > 0) { + uint256 previousTimeToMaturity = obligation.maturity.zeroFloorSub(maturityData.lastUpdate); uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); uint256 zeroedDurationsCount = 0; @@ -130,12 +130,11 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { zeroedDurationsIds[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); } } - selfDeallocate(zeroedDurationsIds, position.units, 0); + selfDeallocate(zeroedDurationsIds, maturityData.units, 0); } } - position.lastUpdate = uint48(block.timestamp); - + maturityData.lastUpdate = uint48(block.timestamp); } /* ACCRUAL */ @@ -148,7 +147,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { while (nextMaturity < block.timestamp) { gainedAssets += uint256(newGrowth) * (nextMaturity - lastChange); - newGrowth -= _maturities[nextMaturity].growthLostAtMaturity; + newGrowth -= _maturities[nextMaturity].growth; lastChange = nextMaturity; nextMaturity = _maturities[nextMaturity].nextMaturity; } @@ -183,10 +182,9 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { MorphoV2(morphoV2).totalUnits(obligationId) + 1, MorphoV2(morphoV2).totalShares(obligationId) + 1 ); - ObligationPosition storage position = _positions[obligationId]; - uint256 lostUnits = position.units - remainingUnits; - deallocateExpiredDurations(obligation, position); - removeUnits(obligation.maturity, position, lostUnits); + uint256 lostUnits = _units[obligationId] - remainingUnits; + deallocateExpiredDurations(obligation); + removeUnits(obligation, lostUnits); selfDeallocate(ids(obligation), lostUnits, 0); } @@ -213,10 +211,7 @@ 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 && offer.startPrice == 1e18, - IncorrectOffer() - ); + require(offer.buy && offer.obligation.loanToken == asset && offer.startPrice == 1e18, IncorrectOffer()); (,, uint256 deallocated,) = MorphoV2(morphoV2) .take(0, sellerAssets, 0, 0, address(this), offer, proof, signature, address(0), hex""); @@ -224,9 +219,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { bytes32 obligationId = _obligationId(offer.obligation); require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); - ObligationPosition storage position = _positions[obligationId]; - deallocateExpiredDurations(offer.obligation, position); - removeUnits(offer.obligation.maturity, position, deallocated); + deallocateExpiredDurations(offer.obligation); + removeUnits(offer.obligation, deallocated); return (ids(offer.obligation), -deallocated.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); @@ -265,24 +259,23 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { bytes32 obligationId = _obligationId(obligation); uint48 prevMaturity = abi.decode(data, (uint48)); require(prevMaturity < obligation.maturity, IncorrectHint()); - ObligationPosition storage position = _positions[obligationId]; + MaturityData storage maturityData = _maturities[obligation.maturity]; accrueInterest(); - deallocateExpiredDurations(obligation, position); + deallocateExpiredDurations(obligation); if (obligation.maturity > block.timestamp) { uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); uint128 gainedGrowth = ((obligationUnits - buyerAssets) / timeToMaturity).toUint128(); _totalAssets += buyerAssets + (obligationUnits - buyerAssets) % timeToMaturity; - position.growth += gainedGrowth; - _maturities[obligation.maturity].growthLostAtMaturity += gainedGrowth; + maturityData.growth += gainedGrowth; currentGrowth += gainedGrowth; } else { _totalAssets += obligationUnits; } - position.units += obligationUnits.toUint128(); - position.lastUpdate = uint48(block.timestamp); + maturityData.units += obligationUnits.toUint128(); + _units[obligationId] += obligationUnits.toUint128(); // Insert the maturity in the list if needed uint48 nextMaturity; @@ -309,7 +302,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { IVaultV2(parentVault) .allocate(address(this), abi.encode(ids(obligation), obligationUnits.toInt256()), buyerAssets); - } function onSell( @@ -328,9 +320,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); - ObligationPosition storage position = _positions[obligationId]; - deallocateExpiredDurations(obligation, position); - removeUnits(obligation.maturity, position, soldObligationUnits); + deallocateExpiredDurations(obligation); + removeUnits(obligation, soldObligationUnits); selfDeallocate(ids(obligation), soldObligationUnits, sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); @@ -341,30 +332,25 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); } - // Convenience function to sync the durations of an obligation. - function deallocateExpiredDurations(Obligation memory obligation) external { - ObligationPosition storage position = _positions[_obligationId(obligation)]; - deallocateExpiredDurations(obligation, position); - } - /* INTERNAL FUNCTIONS */ /// @dev The total assets can go up after removing units to compensate for the rounded up lost growth. - function removeUnits(uint256 maturity, ObligationPosition storage position, uint256 removedUnits) internal { + function removeUnits(Obligation memory obligation, uint256 removedUnits) internal { + MaturityData storage maturityData = _maturities[obligation.maturity]; accrueInterest(); - if (maturity > block.timestamp) { - uint256 timeToMaturity = maturity - block.timestamp; - uint128 removedGrowth = position.growth.mulDivUp(removedUnits, position.units).toUint128(); - _maturities[maturity].growthLostAtMaturity -= removedGrowth; + if (obligation.maturity > block.timestamp) { + uint256 timeToMaturity = obligation.maturity - block.timestamp; + uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.units).toUint128(); // Do not cleanup the linked list if we end up at 0 growth. - position.growth -= removedGrowth; + maturityData.growth -= removedGrowth; currentGrowth -= removedGrowth; _totalAssets = _totalAssets + (removedUnits - (removedGrowth * timeToMaturity)); } else { _totalAssets -= removedUnits; } - position.units -= removedUnits.toUint128(); + maturityData.units -= removedUnits.toUint128(); + _units[_obligationId(obligation)] -= removedUnits.toUint128(); } function _obligationId(Obligation memory obligation) internal pure returns (bytes32) { diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index be770b477..cc2e30ae0 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -6,18 +6,13 @@ import {IAdapter} from "../../interfaces/IAdapter.sol"; import {Obligation, 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; - uint48 lastUpdate; -} - // Chain of maturities, each can represent multiple obligations. // nextMaturity is type(uint48).max if no next maturity -struct Maturity { - uint128 growthLostAtMaturity; +struct MaturityData { + uint128 units; + uint128 growth; uint48 nextMaturity; + uint48 lastUpdate; } interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { @@ -63,8 +58,8 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); function adapterId() external view returns (bytes32); - function positions(bytes32 obligationId) external view returns (ObligationPosition memory); - function maturities(uint256 date) external view returns (Maturity memory); + function units(bytes32 obligationId) external view returns (uint256); + function maturities(uint256 date) external view returns (MaturityData memory); function setSkimRecipient(address newSkimRecipient) external; function skim(address token) external; function durations() external view returns (uint256[] memory); diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index 7c155ff6f..ce42378d4 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.0; import "../lib/forge-std/src/Test.sol"; -import {MorphoMarketV2Adapter, Maturity, ObligationPosition} from "../src/adapters/MorphoMarketV2Adapter.sol"; +import {MorphoMarketV2Adapter, MaturityData} 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"; @@ -56,7 +56,7 @@ contract MorphoMarketV2AdapterTest is Test { Step[] internal steps01; // Expected values after setting up obligations - mapping(bytes32 obligationId => ObligationPosition) expectedPositions; + mapping(bytes32 obligationId => uint256) expectedUnits; mapping(uint256 timestamp => uint256) expectedMaturityGrowths; uint256[] internal expectedPositionsList; uint256[] internal expectedMaturitiesList; @@ -187,13 +187,12 @@ contract MorphoMarketV2AdapterTest is Test { 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"); + MaturityData memory maturityData = adapter.maturities(offer.obligation.maturity); + assertEq(maturityData.growth, newGrowth, "growth"); + assertEq(maturityData.nextMaturity, type(uint48).max, "nextMaturity"); - ObligationPosition memory position = adapter.positions(_obligationId(offer.obligation)); - assertEq(position.growth, newGrowth, "growth"); - assertEq(position.units, assets + totalInterest, "units"); + uint256 actualUnits = adapter.units(_obligationId(offer.obligation)); + assertEq(actualUnits, assets + totalInterest, "units"); } /* RATIFICATION */ @@ -351,18 +350,17 @@ contract MorphoMarketV2AdapterTest is Test { 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); + uint256 unitsBefore = adapter.units(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"); + assertEq(adapter.units(obligationId), unitsBefore + units, "setup: units 1"); - expectedPositions[obligationId].units += units.toUint128(); - expectedPositions[obligationId].growth += actualGrowth.toUint128(); + expectedUnits[obligationId] += units; + expectedMaturityGrowths[step.maturity] += actualGrowth; if (timeToMaturity > 0) { - expectedMaturityGrowths[step.maturity] += actualGrowth.toUint128(); expectedAddedGrowth += actualGrowth.toUint128(); } expectedAddedAssets += step.assets + zeroPeriodGain; @@ -394,9 +392,9 @@ contract MorphoMarketV2AdapterTest is Test { // Check maturities growth and linked list structure for (uint256 i = 0; i < expectedMaturitiesList.length; i++) { assertEq( - adapter.maturities(expectedMaturitiesList[i]).growthLostAtMaturity, + adapter.maturities(expectedMaturitiesList[i]).growth, expectedMaturityGrowths[expectedMaturitiesList[i]], - "growthLostAtMaturity" + "growth" ); if (i == expectedMaturitiesList.length - 1) { assertEq( @@ -414,10 +412,7 @@ contract MorphoMarketV2AdapterTest is Test { // 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"); + assertEq(adapter.units(obligationId), expectedUnits[obligationId], "units"); } } From 485ab91f029cee68cbc99fc124efe2ce44b6af95 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 10 Dec 2025 18:35:19 +0100 Subject: [PATCH 28/95] rename withdraw fn, simplify check, add comments, parameterize durations --- src/adapters/MorphoMarketV2Adapter.sol | 5 ++-- src/adapters/MorphoMarketV2AdapterFactory.sol | 26 +++++++++++-------- .../interfaces/IMorphoMarketV2Adapter.sol | 2 +- .../IMorphoMarketV2AdapterFactory.sol | 2 ++ ...phoMarketV2AdapterAllocationUpdateTest.sol | 2 +- test/MorphoMarketV2AdapterTest.sol | 4 ++- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index d15d08089..998b0ab0b 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -57,7 +57,6 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { uint256 currentDuration; for (uint256 i = 0; i < _durations.length; i++) { require(_durations[i] > currentDuration, IncorrectDuration()); - require(_durations[i] <= type(uint32).max, IncorrectDuration()); currentDuration = _durations[i]; _packedDurations = _packedDurations.set(i, _durations[i]); } @@ -102,7 +101,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdraw(Obligation memory obligation, uint256 withdrawn, uint256 shares) external { + function withdrawToVault(Obligation memory obligation, uint256 withdrawn, uint256 shares) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); (, shares) = MorphoV2(morphoV2).withdraw(obligation, withdrawn, shares, address(this)); @@ -197,6 +196,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { returns (bytes32[] memory, int256) { require(caller == address(this), SelfAllocationOnly()); + // Return exactly the data passed to the function. assembly ("memory-safe") { return(add(data, 32), mload(data)) } @@ -224,6 +224,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { return (ids(offer.obligation), -deallocated.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); + // Return exactly the data passed to the function. assembly ("memory-safe") { return(add(data, 32), mload(data)) } diff --git a/src/adapters/MorphoMarketV2AdapterFactory.sol b/src/adapters/MorphoMarketV2AdapterFactory.sol index d4e91b257..c6d93d586 100644 --- a/src/adapters/MorphoMarketV2AdapterFactory.sol +++ b/src/adapters/MorphoMarketV2AdapterFactory.sol @@ -4,31 +4,35 @@ pragma solidity 0.8.28; import {MorphoMarketV2Adapter} from "./MorphoMarketV2Adapter.sol"; import {IMorphoMarketV2AdapterFactory} from "./interfaces/IMorphoMarketV2AdapterFactory.sol"; +import {DurationsLib, MAX_DURATIONS} from "./libraries/DurationsLib.sol"; contract MorphoMarketV2AdapterFactory is IMorphoMarketV2AdapterFactory { /* STORAGE */ mapping(address parentVault => mapping(address morpho => address)) public morphoMarketV2Adapter; mapping(address account => bool) public isMorphoMarketV2Adapter; + uint256[] public durations; + + /* CONSTRUCTOR */ + + constructor(uint256[] memory _durations) { + durations = _durations; + } + + /* GETTERS */ + + function durationsLength() external view returns (uint256) { + return durations.length; + } /* FUNCTIONS */ function createMorphoMarketV2Adapter(address parentVault, address morpho) external returns (address) { address _morphoMarketV2Adapter = - address(new MorphoMarketV2Adapter{salt: bytes32(0)}(parentVault, morpho, durations())); + address(new MorphoMarketV2Adapter{salt: bytes32(0)}(parentVault, morpho, durations)); morphoMarketV2Adapter[parentVault][morpho] = _morphoMarketV2Adapter; isMorphoMarketV2Adapter[_morphoMarketV2Adapter] = true; emit CreateMorphoMarketV2Adapter(parentVault, morpho, _morphoMarketV2Adapter); return _morphoMarketV2Adapter; } - - function durations() public pure returns (uint256[] memory) { - uint256[] memory _durations = new uint256[](5); - _durations[0] = 1 days; - _durations[1] = 7 days; - _durations[2] = 30 days; - _durations[3] = 90 days; - _durations[4] = 180 days; - return _durations; - } } diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index cc2e30ae0..c16fec91e 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -65,7 +65,7 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); function deallocateExpiredDurations(Obligation memory obligation) external; - function withdraw(Obligation memory obligation, uint256 units, uint256 shares) external; + function withdrawToVault(Obligation memory obligation, uint256 units, uint256 shares) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); diff --git a/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol b/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol index 5065b1d64..500cb2ce5 100644 --- a/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol +++ b/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol @@ -11,6 +11,8 @@ interface IMorphoMarketV2AdapterFactory { /* FUNCTIONS */ + function durations(uint256 index) external view returns (uint256); + function durationsLength() external view returns (uint256); 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/test/MorphoMarketV2AdapterAllocationUpdateTest.sol b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol index 3dce6baad..dc47cc8b7 100644 --- a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol +++ b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol @@ -144,7 +144,7 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest vm.prank(taker); morphoV2.repay(offer.obligation, 1e18, taker); vm.prank(signerAllocator); - adapter.withdraw(offer.obligation, 0.5e18, 0); + adapter.withdrawToVault(offer.obligation, 0.5e18, 0); assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index ce42378d4..2895a8142 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -63,6 +63,8 @@ contract MorphoMarketV2AdapterTest is Test { uint256 internal expectedAddedGrowth; uint256 internal expectedAddedAssets; + uint256[] internal allDurations = [1 days, 7 days, 30 days, 90 days, 180 days]; + function setUp() public virtual { owner = makeAddr("owner"); curator = makeAddr("curator"); @@ -82,7 +84,7 @@ contract MorphoMarketV2AdapterTest is Test { parentVault = new VaultV2Mock(address(loanToken), owner, curator, signerAllocator, address(0)); - factory = new MorphoMarketV2AdapterFactory(); + factory = new MorphoMarketV2AdapterFactory(allDurations); adapter = MorphoMarketV2Adapter(factory.createMorphoMarketV2Adapter(address(parentVault), address(morphoV2))); storedCollaterals.push( From 9f8e924e144f847776f4d851a733a51b4802b9fd Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 13 Jan 2026 12:41:57 +0100 Subject: [PATCH 29/95] cleanup events and add forceDeallocate update test --- src/adapters/MorphoMarketV2AdapterFactory.sol | 1 - .../interfaces/IMorphoMarketV2Adapter.sol | 12 ------ ...phoMarketV2AdapterAllocationUpdateTest.sol | 37 ++++++++++++++++++- test/mocks/VaultV2Mock.sol | 12 ++++++ 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/adapters/MorphoMarketV2AdapterFactory.sol b/src/adapters/MorphoMarketV2AdapterFactory.sol index c6d93d586..7d58c81b5 100644 --- a/src/adapters/MorphoMarketV2AdapterFactory.sol +++ b/src/adapters/MorphoMarketV2AdapterFactory.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.28; import {MorphoMarketV2Adapter} from "./MorphoMarketV2Adapter.sol"; import {IMorphoMarketV2AdapterFactory} from "./interfaces/IMorphoMarketV2AdapterFactory.sol"; -import {DurationsLib, MAX_DURATIONS} from "./libraries/DurationsLib.sol"; contract MorphoMarketV2AdapterFactory is IMorphoMarketV2AdapterFactory { /* STORAGE */ diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index c16fec91e..b9d64d85c 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -20,36 +20,24 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { event SetSkimRecipient(address indexed newSkimRecipient); event Skim(address indexed token, uint256 assets); - event AddDuration(uint256 duration); - event RemoveDuration(uint256 duration); /* ERRORS */ error BufferTooLow(); - error DurationAlreadyExists(); error IncorrectCallbackAddress(); - error IncorrectCallbackData(); - error IncorrectCollateralSet(); error IncorrectDuration(); - 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(); - error TooManyDurations(); /* FUNCTIONS */ diff --git a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol index dc47cc8b7..5318def08 100644 --- a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol +++ b/test/MorphoMarketV2AdapterAllocationUpdateTest.sol @@ -70,6 +70,30 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); } + function forceDeallocate(Obligation memory obligation, uint256 assets) internal { + (address buyer, uint256 buyerPrivateKey) = makeAddrAndKey("buyer"); + privateKey[buyer] = buyerPrivateKey; + + Offer memory offer = storedOffer; + offer.obligation = obligation; + offer.buy = true; + offer.maker = buyer; + offer.startPrice = 1e18; + offer.expiryPrice = 1e18; + offer.assets = assets; + offer.expiry = block.timestamp; + offer.callback = address(0); + offer.ratifier = address(0); + offer.group = bytes32(vm.randomUint()); + + deal(address(loanToken), buyer, assets); + vm.prank(buyer); + loanToken.approve(address(morphoV2), type(uint256).max); + + bytes memory data = abi.encode(offer, proof([offer]), sign([offer])); + parentVault.forceDeallocate(address(adapter), data, assets, address(this)); + } + function durationId(uint256 duration) internal pure returns (bytes32) { return keccak256(abi.encode("duration", duration)); } @@ -168,5 +192,16 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } - // TODO force deallocate test + function testUpdateOnForceDeallocate() public { + Offer memory offer = buy(7 days, 1e18); + assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); + assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); + + skip(1); + + forceDeallocate(offer.obligation, 0.5e18); + + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); + assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); + } } diff --git a/test/mocks/VaultV2Mock.sol b/test/mocks/VaultV2Mock.sol index 9c5e4aed8..3ccf11ba6 100644 --- a/test/mocks/VaultV2Mock.sol +++ b/test/mocks/VaultV2Mock.sol @@ -50,6 +50,18 @@ contract VaultV2Mock { return (ids, change); } + function forceDeallocate(address adapter, bytes memory data, uint256 assets, address) + external + returns (bytes32[] memory, int256) + { + (bytes32[] memory ids, int256 change) = IAdapter(adapter).deallocate(data, assets, msg.sig, msg.sender); + 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); + } + function setTotalAssets(uint256 newTotalAssets) external { totalAssets = newTotalAssets; } From b501788c02e6dd8629351f486cb653a85df7b6c0 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 29 Jan 2026 17:45:56 +0100 Subject: [PATCH 30/95] fix: accounting & interface, add comments --- src/adapters/MorphoMarketV2Adapter.sol | 5 +++-- src/adapters/interfaces/IMorphoMarketV2Adapter.sol | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 998b0ab0b..29b685697 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -12,7 +12,7 @@ import {IMorphoMarketV2Adapter, MaturityData, IAdapter} from "./interfaces/IMorp import {DurationsLib} from "./libraries/DurationsLib.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 +/// @dev Losses are immediately 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 { @@ -213,6 +213,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { abi.decode(data, (Offer, Proof, Signature)); require(offer.buy && offer.obligation.loanToken == asset && offer.startPrice == 1e18, IncorrectOffer()); + // Already in a deallocate call so we skip the onSell callback and return the deallocation here. (,, uint256 deallocated,) = MorphoV2(morphoV2) .take(0, sellerAssets, 0, 0, address(this), offer, proof, signature, address(0), hex""); @@ -346,7 +347,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { // Do not cleanup the linked list if we end up at 0 growth. maturityData.growth -= removedGrowth; currentGrowth -= removedGrowth; - _totalAssets = _totalAssets + (removedUnits - (removedGrowth * timeToMaturity)); + _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { _totalAssets -= removedUnits; } diff --git a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol index b9d64d85c..78157940e 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMorphoMarketV2Adapter.sol @@ -48,6 +48,7 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { function adapterId() external view returns (bytes32); function units(bytes32 obligationId) external view returns (uint256); function maturities(uint256 date) external view returns (MaturityData memory); + function skimRecipient() external view returns (address); function setSkimRecipient(address newSkimRecipient) external; function skim(address token) external; function durations() external view returns (uint256[] memory); From 6eca160606fc2e2c90a0f19a850aaed61ee692ed Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 4 Mar 2026 16:13:09 +0100 Subject: [PATCH 31/95] fix: dont insert passed maturity on buy --- src/adapters/MorphoMarketV2Adapter.sol | 37 ++++++++------- test/MorphoMarketV2AdapterTest.sol | 62 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MorphoMarketV2Adapter.sol index 29b685697..a60a4b2e8 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MorphoMarketV2Adapter.sol @@ -273,6 +273,7 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { maturityData.growth += gainedGrowth; currentGrowth += gainedGrowth; } else { + // No need to update past growth to zero since it won't be read again. _totalAssets += obligationUnits; } @@ -280,25 +281,27 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { _units[obligationId] += obligationUnits.toUint128(); // Insert the maturity in the list if needed - 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 (obligation.maturity >= block.timestamp) { + uint48 nextMaturity; if (prevMaturity == 0) { - firstMaturity = obligation.maturity.toUint48(); + nextMaturity = firstMaturity; } else { - _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); + 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(); + } } } diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MorphoMarketV2AdapterTest.sol index 2895a8142..73918d8f5 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MorphoMarketV2AdapterTest.sol @@ -197,6 +197,68 @@ contract MorphoMarketV2AdapterTest is Test { assertEq(actualUnits, assets + totalInterest, "units"); } + function testBuyAtPastMaturityWithExistingGrowth() public { + Offer memory offer = storedOffer; + uint256 maturity = offer.obligation.maturity; + + 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, 10_000e18); + deal(storedCollaterals[1].token, taker, 10_000e18); + morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[0].token), 10_000e18, taker); + morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[1].token), 10_000e18, taker); + vm.stopPrank(); + + // Step 1: Buy at maturity M (future) + uint256 assets1 = 1e18; + offer.assets = assets1; + offer.callback = address(adapter); + offer.callbackData = abi.encode(0); + + vm.prank(taker); + morphoV2.take(assets1, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); + + uint256 units1 = assets1 * 1e18 / offer.startPrice; + uint256 timeToMaturity = maturity - block.timestamp; + uint128 growth1 = uint128((units1 - assets1) / timeToMaturity); + assertGt(growth1, 0, "growth should be nonzero"); + assertEq(adapter.currentGrowth(), growth1, "currentGrowth after buy1"); + + // Step 2: Advance time past maturity M + skip(timeToMaturity + 1); + assertGt(block.timestamp, maturity, "should be past maturity"); + + // Step 3: Trigger accrueInterest so the walk subtracts growth from currentGrowth + adapter.accrueInterest(); + assertEq(adapter.currentGrowth(), 0, "currentGrowth after accrual should be 0"); + assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity should be sentinel"); + uint256 totalAssetsAfterAccrual = adapter._totalAssets(); + + // Step 4: Buy again at the SAME (now past) maturity M + uint256 assets2 = 0.5e18; + Offer memory offer2 = offer; + offer2.assets = assets2; + offer2.startPrice = 1e18; + offer2.group = bytes32(uint256(1)); + offer2.start = block.timestamp; + offer2.expiry = block.timestamp + 1; + + vm.prank(taker); + morphoV2.take(assets2, 0, 0, 0, taker, offer2, proof([offer2]), sign([offer2], signerAllocator), address(0), ""); + + uint256 units2 = assets2 * 1e18 / offer2.startPrice; + assertEq(units2, assets2, "units2 should equal assets2 at price 1e18"); + + // Step 5: Verify realAssets is correct + assertEq(adapter.currentGrowth(), 0, "currentGrowth should still be 0 after past-maturity buy"); + assertEq(adapter._totalAssets(), totalAssetsAfterAccrual + units2, "_totalAssets after buy2"); + assertEq(adapter.realAssets(), totalAssetsAfterAccrual + units2, "realAssets after buy2"); + + // Stale growth remains in storage but is harmless — M is not re-inserted into the linked list + assertEq(adapter.firstMaturity(), type(uint48).max, "past maturity not re-inserted into list"); + } + /* RATIFICATION */ function _ratificationSetup() internal returns (Offer memory offer) { From 639b496723cb398f9a9b9959457df6c6b6fde556 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 9 Apr 2026 13:58:00 +0200 Subject: [PATCH 32/95] midnight --- .gitmodules | 3 + foundry.toml | 2 +- lib/midnight | 1 + lib/morpho-v2 | 1 - ...arketV2Adapter.sol => MidnightAdapter.sol} | 93 +++-- src/adapters/MidnightAdapterFactory.sol | 36 ++ src/adapters/MorphoMarketV2AdapterFactory.sol | 37 -- ...rketV2Adapter.sol => IMidnightAdapter.sol} | 38 +- .../interfaces/IMidnightAdapterFactory.sol | 17 + .../IMorphoMarketV2AdapterFactory.sol | 19 - ...> MidnightAdapterAllocationUpdateTest.sol} | 82 ++-- ...dapterTest.sol => MidnightAdapterTest.sol} | 363 ++++++++++-------- 12 files changed, 402 insertions(+), 290 deletions(-) create mode 160000 lib/midnight delete mode 160000 lib/morpho-v2 rename src/adapters/{MorphoMarketV2Adapter.sol => MidnightAdapter.sol} (81%) create mode 100644 src/adapters/MidnightAdapterFactory.sol delete mode 100644 src/adapters/MorphoMarketV2AdapterFactory.sol rename src/adapters/interfaces/{IMorphoMarketV2Adapter.sol => IMidnightAdapter.sol} (77%) create mode 100644 src/adapters/interfaces/IMidnightAdapterFactory.sol delete mode 100644 src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol rename test/{MorphoMarketV2AdapterAllocationUpdateTest.sol => MidnightAdapterAllocationUpdateTest.sol} (70%) rename test/{MorphoMarketV2AdapterTest.sol => MidnightAdapterTest.sol} (63%) diff --git a/.gitmodules b/.gitmodules index 2c2d590e7..4f525383b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,6 @@ [submodule "lib/morpho-blue"] path = lib/morpho-blue url = https://github.com/morpho-org/morpho-blue +[submodule "lib/midnight"] + path = lib/midnight + url = https://github.com/morpho-org/midnight diff --git a/foundry.toml b/foundry.toml index 60885de11..dfd6c93ad 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,7 +3,7 @@ via_ir = true optimizer = true optimizer_runs = 100000 bytecode_hash = "none" -evm_version = "cancun" +evm_version = "osaka" dynamic_test_linking = true [profile.default.fmt] diff --git a/lib/midnight b/lib/midnight new file mode 160000 index 000000000..41c8405a5 --- /dev/null +++ b/lib/midnight @@ -0,0 +1 @@ +Subproject commit 41c8405a5e4c9ea5785bdbe506d8880c4bcbd596 diff --git a/lib/morpho-v2 b/lib/morpho-v2 deleted file mode 160000 index 6b36faa2b..000000000 --- a/lib/morpho-v2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6b36faa2b7e0b362cccb852e7064aaa70c8a49d6 diff --git a/src/adapters/MorphoMarketV2Adapter.sol b/src/adapters/MidnightAdapter.sol similarity index 81% rename from src/adapters/MorphoMarketV2Adapter.sol rename to src/adapters/MidnightAdapter.sol index a60a4b2e8..58fae5f2f 100644 --- a/src/adapters/MorphoMarketV2Adapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -1,21 +1,26 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (c) 2025 Morpho Association -pragma solidity 0.8.28; - -import {MorphoV2} from "lib/morpho-v2/src/MorphoV2.sol"; -import {Offer, Signature, Obligation, Seizure, Proof} from "lib/morpho-v2/src/interfaces/IMorphoV2.sol"; +pragma solidity 0.8.34; + +import {Midnight} from "lib/midnight/src/Midnight.sol"; +import {Offer, Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; +import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; +import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "lib/midnight/src/interfaces/IEcrecover.sol"; +import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; +import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; +import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; import {IVaultV2} from "../interfaces/IVaultV2.sol"; -import {IMorphoMarketV2Adapter, MaturityData, IAdapter} from "./interfaces/IMorphoMarketV2Adapter.sol"; +import {IMidnightAdapter, MaturityData, IAdapter} from "./interfaces/IMidnightAdapter.sol"; import {DurationsLib} from "./libraries/DurationsLib.sol"; /// @dev Approximates held assets by linearly accounting for interest separately for each obligation. /// @dev Losses are immediately 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 { +contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; using DurationsLib for bytes32; @@ -101,13 +106,13 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdrawToVault(Obligation memory obligation, uint256 withdrawn, uint256 shares) external { + function withdrawToVault(Obligation memory obligation, uint256 units) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); - (, shares) = MorphoV2(morphoV2).withdraw(obligation, withdrawn, shares, address(this)); + Midnight(morphoV2).withdraw(obligation, units, address(this), address(this)); deallocateExpiredDurations(obligation); - removeUnits(obligation, withdrawn); - selfDeallocate(ids(obligation), withdrawn, withdrawn); + removeUnits(obligation, units); + selfDeallocate(ids(obligation), units, units); } function deallocateExpiredDurations(Obligation memory obligation) public { @@ -176,10 +181,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { 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 - ); + bytes32 midnightId = IdLib.toId(obligation, block.chainid, morphoV2); + uint256 remainingUnits = Midnight(morphoV2).creditOf(midnightId, address(this)); uint256 lostUnits = _units[obligationId] - remainingUnits; deallocateExpiredDurations(obligation); @@ -209,16 +212,17 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { 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, IncorrectOffer()); + (Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof) = + abi.decode(data, (Offer, bytes, bytes32, bytes32[])); + require(offer.buy && offer.obligation.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); // Already in a deallocate call so we skip the onSell callback and return the deallocation here. - (,, uint256 deallocated,) = MorphoV2(morphoV2) - .take(0, sellerAssets, 0, 0, address(this), offer, proof, signature, address(0), hex""); + bytes32 midnightId = IdLib.toId(offer.obligation, block.chainid, morphoV2); + uint256 units = TakeAmountsLib.sellerAssetsToUnits(Midnight(morphoV2), midnightId, offer, sellerAssets); + (,, uint256 deallocated) = Midnight(morphoV2) + .take(units, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); - bytes32 obligationId = _obligationId(offer.obligation); - require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); + require(Midnight(morphoV2).debtOf(midnightId, address(this)) == 0, NoBorrowing()); deallocateExpiredDurations(offer.obligation); removeUnits(offer.obligation, deallocated); @@ -234,28 +238,35 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* MORPHO V2 CALLBACKS */ - function onRatify(Offer memory offer, address signer) external view returns (bool) { + function onRatify(Offer memory offer, bytes32 root, bytes memory data) external view returns (bytes32) { // 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(offer.start <= block.timestamp, IncorrectStart()); // uint48.max is the list end pointer require(offer.obligation.maturity < type(uint48).max, IncorrectMaturity()); + + // Signature verification (inlined from EcrecoverRatifier). + Signature memory sig = abi.decode(data, (Signature)); + bytes32 structHash = keccak256(abi.encode(ROOT_TYPEHASH, root)); + bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); + bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); + address signer = ecrecover(digest, sig.v, sig.r, sig.s); + require(signer != address(0), IncorrectSigner()); require(IVaultV2(parentVault).isAllocator(signer), IncorrectSigner()); - return true; + + return CALLBACK_SUCCESS; } function onBuy( + bytes32, Obligation memory obligation, address buyer, uint256 buyerAssets, - uint256, uint256 obligationUnits, - uint256, bytes memory data - ) external { + ) external returns (bytes32) { require(msg.sender == address(morphoV2), NotMorphoV2()); require(buyer == address(this), NotSelf()); bytes32 obligationId = _obligationId(obligation); @@ -307,21 +318,22 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { IVaultV2(parentVault) .allocate(address(this), abi.encode(ids(obligation), obligationUnits.toInt256()), buyerAssets); + + return CALLBACK_SUCCESS; } function onSell( + bytes32 midnightId, Obligation memory obligation, address seller, - uint256, uint256 sellerAssets, uint256 soldObligationUnits, - uint256, bytes memory - ) external { + ) external returns (bytes32) { bytes32 obligationId = _obligationId(obligation); require(msg.sender == address(morphoV2), NotMorphoV2()); require(seller == address(this), NotSelf()); - require(MorphoV2(morphoV2).debtOf(address(this), obligationId) == 0, NoBorrowing()); + require(Midnight(morphoV2).debtOf(midnightId, address(this)) == 0, NoBorrowing()); uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); @@ -335,6 +347,8 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { vaultRealAssetsAfter += IAdapter(IVaultV2(parentVault).adapters(i)).realAssets(); } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); + + return CALLBACK_SUCCESS; } /* INTERNAL FUNCTIONS */ @@ -370,16 +384,19 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { durationsCount++; } - bytes32[] memory idsArray = new bytes32[](1 + obligation.collaterals.length * 2 + durationsCount); + bytes32[] memory idsArray = new bytes32[](1 + obligation.collateralParams.length * 2 + durationsCount); uint256 j; idsArray[j++] = adapterId; - for (uint256 i = 0; i < obligation.collaterals.length; i++) { - address collateralToken = obligation.collaterals[i].token; + for (uint256 i = 0; i < obligation.collateralParams.length; i++) { + address collateralToken = obligation.collateralParams[i].token; idsArray[j++] = keccak256(abi.encode("collateralToken", collateralToken)); idsArray[j++] = keccak256( abi.encode( - "collateral", collateralToken, obligation.collaterals[i].oracle, obligation.collaterals[i].lltv + "collateral", + collateralToken, + obligation.collateralParams[i].oracle, + obligation.collateralParams[i].lltv ) ); } @@ -396,7 +413,11 @@ contract MorphoMarketV2Adapter is IMorphoMarketV2Adapter { /* TO REMOVE */ - function onLiquidate(Seizure[] memory, address, address, bytes memory) external pure { + function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { + revert(); + } + + function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external pure { revert(); } } diff --git a/src/adapters/MidnightAdapterFactory.sol b/src/adapters/MidnightAdapterFactory.sol new file mode 100644 index 000000000..512acc02e --- /dev/null +++ b/src/adapters/MidnightAdapterFactory.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.34; + +import {MidnightAdapter} from "./MidnightAdapter.sol"; +import {IMidnightAdapterFactory} from "./interfaces/IMidnightAdapterFactory.sol"; + +contract MidnightAdapterFactory is IMidnightAdapterFactory { + /* STORAGE */ + + mapping(address parentVault => mapping(address morpho => address)) public midnightAdapter; + mapping(address account => bool) public isMidnightAdapter; + uint256[] public durations; + + /* CONSTRUCTOR */ + + constructor(uint256[] memory _durations) { + durations = _durations; + } + + /* GETTERS */ + + function durationsLength() external view returns (uint256) { + return durations.length; + } + + /* FUNCTIONS */ + + function createMidnightAdapter(address parentVault, address morpho) external returns (address) { + address _midnightAdapter = address(new MidnightAdapter{salt: bytes32(0)}(parentVault, morpho, durations)); + midnightAdapter[parentVault][morpho] = _midnightAdapter; + isMidnightAdapter[_midnightAdapter] = true; + emit CreateMidnightAdapter(parentVault, morpho, _midnightAdapter); + return _midnightAdapter; + } +} diff --git a/src/adapters/MorphoMarketV2AdapterFactory.sol b/src/adapters/MorphoMarketV2AdapterFactory.sol deleted file mode 100644 index 7d58c81b5..000000000 --- a/src/adapters/MorphoMarketV2AdapterFactory.sol +++ /dev/null @@ -1,37 +0,0 @@ -// 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; - uint256[] public durations; - - /* CONSTRUCTOR */ - - constructor(uint256[] memory _durations) { - durations = _durations; - } - - /* GETTERS */ - - function durationsLength() external view returns (uint256) { - return durations.length; - } - - /* FUNCTIONS */ - - function createMorphoMarketV2Adapter(address parentVault, address morpho) external returns (address) { - address _morphoMarketV2Adapter = - address(new MorphoMarketV2Adapter{salt: bytes32(0)}(parentVault, morpho, durations)); - 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/IMidnightAdapter.sol similarity index 77% rename from src/adapters/interfaces/IMorphoMarketV2Adapter.sol rename to src/adapters/interfaces/IMidnightAdapter.sol index 78157940e..f327c8f02 100644 --- a/src/adapters/interfaces/IMorphoMarketV2Adapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -3,8 +3,9 @@ pragma solidity >=0.5.0; import {IAdapter} from "../../interfaces/IAdapter.sol"; -import {Obligation, Seizure} from "lib/morpho-v2/src/interfaces/IMorphoV2.sol"; -import {ICallbacks} from "lib/morpho-v2/src/interfaces/ICallbacks.sol"; +import {Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; +import {ICallbacks} from "lib/midnight/src/interfaces/ICallbacks.sol"; +import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple obligations. // nextMaturity is type(uint48).max if no next maturity @@ -15,7 +16,7 @@ struct MaturityData { uint48 lastUpdate; } -interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { +interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { /* EVENTS */ event SetSkimRecipient(address indexed newSkimRecipient); @@ -54,7 +55,7 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); function deallocateExpiredDurations(Obligation memory obligation) external; - function withdrawToVault(Obligation memory obligation, uint256 units, uint256 shares) external; + function withdrawToVault(Obligation memory obligation, uint256 units) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); @@ -67,22 +68,35 @@ interface IMorphoMarketV2Adapter is IAdapter, ICallbacks { external returns (bytes32[] memory, int256); function onBuy( + bytes32 id, Obligation memory obligation, address buyer, uint256 buyerAssets, - uint256 sellerAssets, - uint256 obligationUnits, - uint256 obligationShares, + uint256 units, bytes memory data - ) external; + ) external returns (bytes32); function onSell( + bytes32 id, Obligation memory obligation, address seller, - uint256 buyerAssets, uint256 sellerAssets, - uint256 obligationUnits, - uint256 obligationShares, + uint256 units, + bytes memory data + ) external returns (bytes32); + function onLiquidate( + bytes32 id, + Obligation memory obligation, + uint256 collateralIndex, + uint256 seizedAssets, + uint256 repaidUnits, + address borrower, + bytes memory data + ) external; + function onRepay( + bytes32 obligationId, + Obligation memory obligation, + uint256 units, + address onBehalf, bytes memory data ) external; - function onLiquidate(Seizure[] memory seizures, address borrower, address liquidator, bytes memory data) external; } diff --git a/src/adapters/interfaces/IMidnightAdapterFactory.sol b/src/adapters/interfaces/IMidnightAdapterFactory.sol new file mode 100644 index 000000000..8211abfbc --- /dev/null +++ b/src/adapters/interfaces/IMidnightAdapterFactory.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity >=0.5.0; + +interface IMidnightAdapterFactory { + /* EVENTS */ + + event CreateMidnightAdapter(address indexed parentVault, address indexed morpho, address indexed midnightAdapter); + + /* FUNCTIONS */ + + function durations(uint256 index) external view returns (uint256); + function durationsLength() external view returns (uint256); + function midnightAdapter(address parentVault, address morpho) external view returns (address); + function isMidnightAdapter(address account) external view returns (bool); + function createMidnightAdapter(address parentVault, address morpho) external returns (address); +} diff --git a/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol b/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol deleted file mode 100644 index 500cb2ce5..000000000 --- a/src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol +++ /dev/null @@ -1,19 +0,0 @@ -// 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 durations(uint256 index) external view returns (uint256); - function durationsLength() external view returns (uint256); - 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/test/MorphoMarketV2AdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol similarity index 70% rename from test/MorphoMarketV2AdapterAllocationUpdateTest.sol rename to test/MidnightAdapterAllocationUpdateTest.sol index 5318def08..a6b96a52f 100644 --- a/test/MorphoMarketV2AdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -3,16 +3,18 @@ pragma solidity ^0.8.0; import "../lib/forge-std/src/Test.sol"; -import {MorphoMarketV2AdapterTest} from "./MorphoMarketV2AdapterTest.sol"; +import {MidnightAdapterTest} from "./MidnightAdapterTest.sol"; import {IERC20} from "../src/interfaces/IERC20.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; -import {MorphoV2} from "../lib/morpho-v2/src/MorphoV2.sol"; -import {Offer, Obligation} from "../lib/morpho-v2/src/interfaces/IMorphoV2.sol"; +import {Midnight} from "../lib/midnight/src/Midnight.sol"; +import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; +import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; -import {Oracle} from "../lib/morpho-v2/test/helpers/Oracle.sol"; -import {Seizure} from "../lib/morpho-v2/src/interfaces/IMorphoV2.sol"; +import {Oracle} from "../lib/midnight/test/helpers/Oracle.sol"; +import {ApprovalRatifier} from "../lib/midnight/src/ratifiers/ApprovalRatifier.sol"; +import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; -contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest { +contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { using stdStorage for StdStorage; using MathLib for uint256; @@ -22,8 +24,10 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest super.setUp(); storedCollaterals[0].lltv = 1e18; + storedCollaterals[0].maxLif = morphoV2.maxLif(1e18, 0.25e18); storedCollaterals[1].lltv = 1e18; - storedOffer.obligation.collaterals = storedCollaterals; + storedCollaterals[1].maxLif = morphoV2.maxLif(1e18, 0.25e18); + storedOffer.obligation.collateralParams = storedCollaterals; vm.startPrank(taker); IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); @@ -39,17 +43,20 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest offer.obligation.maturity = block.timestamp + duration; offer.buy = true; - offer.startPrice = 1e18; - offer.expiryPrice = 1e18; - offer.assets = assets; + offer.tick = MAX_TICK; + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 units = assets * 1e18 / price; + offer.maxUnits = units; offer.expiry = block.timestamp; offer.callback = address(adapter); offer.callbackData = abi.encode(0); vm.startPrank(taker); - morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[0].token), assets / 2, taker); - morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[1].token), assets / 2, taker); - morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); + morphoV2.supplyCollateral(offer.obligation, 0, assets / 2, taker); + morphoV2.supplyCollateral(offer.obligation, 1, assets / 2, taker); + morphoV2.take( + units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) + ); vm.stopPrank(); return offer; } @@ -59,38 +66,48 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest offer.obligation = obligation; offer.buy = false; - offer.startPrice = 1e18; - offer.expiryPrice = 1e18; - offer.assets = assets; + offer.tick = MAX_TICK; + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 units = assets * 1e18 / price; + offer.maxUnits = units; offer.expiry = block.timestamp; offer.callback = address(adapter); + offer.receiverIfMakerIsSeller = address(adapter); offer.group = bytes32(vm.randomUint()); offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take(assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); + morphoV2.take( + units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) + ); } function forceDeallocate(Obligation memory obligation, uint256 assets) internal { - (address buyer, uint256 buyerPrivateKey) = makeAddrAndKey("buyer"); - privateKey[buyer] = buyerPrivateKey; + address buyer = makeAddr("buyer"); + ApprovalRatifier approvalRatifier = new ApprovalRatifier(); Offer memory offer = storedOffer; offer.obligation = obligation; offer.buy = true; offer.maker = buyer; - offer.startPrice = 1e18; - offer.expiryPrice = 1e18; - offer.assets = assets; + offer.tick = MAX_TICK; + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 units = assets * 1e18 / price; + offer.maxUnits = units; offer.expiry = block.timestamp; offer.callback = address(0); - offer.ratifier = address(0); + offer.callbackData = hex""; + offer.ratifier = address(approvalRatifier); offer.group = bytes32(vm.randomUint()); deal(address(loanToken), buyer, assets); - vm.prank(buyer); + vm.startPrank(buyer); loanToken.approve(address(morphoV2), type(uint256).max); + morphoV2.setIsAuthorized(buyer, address(approvalRatifier), true); + bytes32 _root = root([offer]); + approvalRatifier.setApproval(_root, true); + vm.stopPrank(); - bytes memory data = abi.encode(offer, proof([offer]), sign([offer])); + bytes memory data = abi.encode(offer, hex"", _root, proof([offer])); parentVault.forceDeallocate(address(adapter), data, assets, address(this)); } @@ -144,15 +161,12 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest skip(1); - Oracle(offer.obligation.collaterals[0].oracle).setPrice(0); - morphoV2.liquidate(offer.obligation, new Seizure[](0), taker, ""); + Oracle(offer.obligation.collateralParams[0].oracle).setPrice(0); + morphoV2.liquidate(offer.obligation, 0, 0, 0, taker, ""); adapter.realizeLoss(offer.obligation); - bytes32 obligationId = _obligationId(offer.obligation); - uint256 remainingUnits = MorphoV2(morphoV2).sharesOf(address(adapter), obligationId) - .mulDivDown( - MorphoV2(morphoV2).totalUnits(obligationId) + 1, MorphoV2(morphoV2).totalShares(obligationId) + 1 - ); + bytes32 midnightId = IdLib.toId(offer.obligation, block.chainid, address(morphoV2)); + uint256 remainingUnits = Midnight(morphoV2).creditOf(midnightId, address(adapter)); assertEq(parentVault.allocation(durationId(1 days)), remainingUnits, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); @@ -166,9 +180,9 @@ contract MorphoMarketV2AdapterAllocationUpdateTest is MorphoMarketV2AdapterTest skip(7 days); vm.prank(taker); - morphoV2.repay(offer.obligation, 1e18, taker); + morphoV2.repay(offer.obligation, 1e18, taker, ""); vm.prank(signerAllocator); - adapter.withdrawToVault(offer.obligation, 0.5e18, 0); + adapter.withdrawToVault(offer.obligation, 0.5e18); assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); diff --git a/test/MorphoMarketV2AdapterTest.sol b/test/MidnightAdapterTest.sol similarity index 63% rename from test/MorphoMarketV2AdapterTest.sol rename to test/MidnightAdapterTest.sol index 73918d8f5..7baa10e31 100644 --- a/test/MorphoMarketV2AdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -3,17 +3,20 @@ pragma solidity ^0.8.0; import "../lib/forge-std/src/Test.sol"; -import {MorphoMarketV2Adapter, MaturityData} from "../src/adapters/MorphoMarketV2Adapter.sol"; -import {MorphoMarketV2AdapterFactory} from "../src/adapters/MorphoMarketV2AdapterFactory.sol"; +import {MidnightAdapter, MaturityData} from "../src/adapters/MidnightAdapter.sol"; +import {MidnightAdapterFactory} from "../src/adapters/MidnightAdapterFactory.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 {IMorphoMarketV2Adapter} from "../src/adapters/interfaces/IMorphoMarketV2Adapter.sol"; -import {IMorphoMarketV2AdapterFactory} from "../src/adapters/interfaces/IMorphoMarketV2AdapterFactory.sol"; +import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol"; +import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../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 {Midnight} from "../lib/midnight/src/Midnight.sol"; +import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; +import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../lib/midnight/src/interfaces/IEcrecover.sol"; +import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; +import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; @@ -21,16 +24,16 @@ struct Step { uint256 assets; uint256 approxGrowth; uint256 maturity; - Collateral[] collaterals; + CollateralParams[] collaterals; } -contract MorphoMarketV2AdapterTest is Test { +contract MidnightAdapterTest is Test { using stdStorage for StdStorage; using MathLib for uint256; - MorphoV2 internal morphoV2; - IMorphoMarketV2AdapterFactory internal factory; - IMorphoMarketV2Adapter internal adapter; + Midnight internal morphoV2; + IMidnightAdapterFactory internal factory; + IMidnightAdapter internal adapter; VaultV2Mock internal parentVault; IERC20 internal loanToken; IERC20 internal rewardToken; @@ -41,8 +44,8 @@ contract MorphoMarketV2AdapterTest is Test { address internal taker; address internal recipient; address internal tradingFeeRecipient = makeAddr("tradingFeeRecipient"); - Collateral[] internal storedCollaterals; - Collateral[] internal storedSingleCollateral; + CollateralParams[] internal storedCollaterals; + CollateralParams[] internal storedSingleCollateral; mapping(address => uint256) internal privateKey; @@ -74,24 +77,40 @@ contract MorphoMarketV2AdapterTest is Test { recipient = makeAddr("recipient"); taker = makeAddr("taker"); - morphoV2 = new MorphoV2(); - - vm.prank(morphoV2.owner()); - morphoV2.setTradingFeeRecipient(tradingFeeRecipient); + morphoV2 = new Midnight(); 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(allDurations); - adapter = MorphoMarketV2Adapter(factory.createMorphoMarketV2Adapter(address(parentVault), address(morphoV2))); + factory = new MidnightAdapterFactory(allDurations); + adapter = MidnightAdapter(factory.createMidnightAdapter(address(parentVault), address(morphoV2))); + + // Adapter authorizes itself as ratifier + vm.prank(address(adapter)); + morphoV2.setIsAuthorized(address(adapter), address(adapter), true); + + address collToken0 = address(new ERC20Mock(18)); + address collToken1 = address(new ERC20Mock(18)); + address oracle0 = address(new OracleMock()); + address oracle1 = address(new OracleMock()); + + // Ensure collateral tokens are sorted ascending by address + if (collToken0 > collToken1) { + (collToken0, collToken1) = (collToken1, collToken0); + (oracle0, oracle1) = (oracle1, oracle0); + } storedCollaterals.push( - Collateral({token: address(new ERC20Mock(18)), lltv: 0.8 ether, oracle: address(new OracleMock())}) + CollateralParams({ + token: collToken0, lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracle0 + }) ); storedCollaterals.push( - Collateral({token: address(new ERC20Mock(18)), lltv: 0.9 ether, oracle: address(new OracleMock())}) + CollateralParams({ + token: collToken1, lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracle1 + }) ); OracleMock(storedCollaterals[0].oracle).setPrice(ORACLE_PRICE_SCALE); @@ -100,28 +119,30 @@ contract MorphoMarketV2AdapterTest is Test { 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 + collateralParams: storedCollaterals, + maturity: maturity, + rcfThreshold: 0, + enterGate: address(0), + liquidatorGate: address(0) }), start: vm.getBlockTimestamp(), expiry: maturity, - startPrice: 1e36 / (1e18 + rate * (maturity - vm.getBlockTimestamp()) / 365 days), - expiryPrice: 1e18, + tick: MAX_TICK, group: bytes32(0), session: bytes32(0), - ratifier: address(adapter), callback: address(adapter), - callbackData: bytes("") + callbackData: bytes(""), + receiverIfMakerIsSeller: address(0), + ratifier: address(adapter), + reduceOnly: false, + maxUnits: 0, + maxSellerAssets: 0, + maxBuyerAssets: 0 }); deal(address(loanToken), address(parentVault), 1_000_000e18); @@ -161,31 +182,35 @@ contract MorphoMarketV2AdapterTest is Test { function testSimpleBuy() public { Offer memory offer = storedOffer; + offer.tick = TickLib.priceToTick(0.95e18); 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); + morphoV2.supplyCollateral(offer.obligation, 0, 1_000e18, taker); + morphoV2.supplyCollateral(offer.obligation, 1, 1_000e18, taker); vm.stopPrank(); uint256 assets = 1e18; + uint256 price = TickLib.tickToPrice(offer.tick); + uint256 units = assets * 1e18 / price; - offer.assets = 1e18; + offer.maxUnits = units; 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), ""); + morphoV2.take( + units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) + ); - 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 totalInterest = units - assets; uint256 duration = offer.obligation.maturity - vm.getBlockTimestamp(); uint256 newGrowth = totalInterest / duration; assertEq(adapter.currentGrowth(), newGrowth, "currentGrowth"); @@ -194,32 +219,37 @@ contract MorphoMarketV2AdapterTest is Test { assertEq(maturityData.nextMaturity, type(uint48).max, "nextMaturity"); uint256 actualUnits = adapter.units(_obligationId(offer.obligation)); - assertEq(actualUnits, assets + totalInterest, "units"); + assertEq(actualUnits, units, "units"); } function testBuyAtPastMaturityWithExistingGrowth() public { Offer memory offer = storedOffer; + offer.tick = TickLib.priceToTick(0.95e18); uint256 maturity = offer.obligation.maturity; 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, 10_000e18); - deal(storedCollaterals[1].token, taker, 10_000e18); - morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[0].token), 10_000e18, taker); - morphoV2.supplyCollateral(offer.obligation, address(storedCollaterals[1].token), 10_000e18, taker); + deal(storedCollaterals[0].token, taker, 100_000e18); + deal(storedCollaterals[1].token, taker, 100_000e18); + morphoV2.supplyCollateral(offer.obligation, 0, 100_000e18, taker); + morphoV2.supplyCollateral(offer.obligation, 1, 100_000e18, taker); vm.stopPrank(); // Step 1: Buy at maturity M (future) uint256 assets1 = 1e18; - offer.assets = assets1; + uint256 price1 = TickLib.tickToPrice(offer.tick); + uint256 units1 = assets1 * 1e18 / price1; + + offer.maxUnits = units1; offer.callback = address(adapter); offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take(assets1, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), ""); + morphoV2.take( + units1, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) + ); - uint256 units1 = assets1 * 1e18 / offer.startPrice; uint256 timeToMaturity = maturity - block.timestamp; uint128 growth1 = uint128((units1 - assets1) / timeToMaturity); assertGt(growth1, 0, "growth should be nonzero"); @@ -235,28 +265,13 @@ contract MorphoMarketV2AdapterTest is Test { assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity should be sentinel"); uint256 totalAssetsAfterAccrual = adapter._totalAssets(); - // Step 4: Buy again at the SAME (now past) maturity M - uint256 assets2 = 0.5e18; - Offer memory offer2 = offer; - offer2.assets = assets2; - offer2.startPrice = 1e18; - offer2.group = bytes32(uint256(1)); - offer2.start = block.timestamp; - offer2.expiry = block.timestamp + 1; - - vm.prank(taker); - morphoV2.take(assets2, 0, 0, 0, taker, offer2, proof([offer2]), sign([offer2], signerAllocator), address(0), ""); - - uint256 units2 = assets2 * 1e18 / offer2.startPrice; - assertEq(units2, assets2, "units2 should equal assets2 at price 1e18"); - - // Step 5: Verify realAssets is correct - assertEq(adapter.currentGrowth(), 0, "currentGrowth should still be 0 after past-maturity buy"); - assertEq(adapter._totalAssets(), totalAssetsAfterAccrual + units2, "_totalAssets after buy2"); - assertEq(adapter.realAssets(), totalAssetsAfterAccrual + units2, "realAssets after buy2"); - - // Stale growth remains in storage but is harmless — M is not re-inserted into the linked list + // In midnight, any seller with debt past maturity is always liquidatable + // (isLiquidatable returns true if block.timestamp > maturity && debt > 0), + // so we can't test a second buy at past maturity. Just verify accrual state. assertEq(adapter.firstMaturity(), type(uint48).max, "past maturity not re-inserted into list"); + + // Note: In midnight, any seller with debt past maturity is always liquidatable, + // so the second buy at past maturity from the original test cannot be executed. } /* RATIFICATION */ @@ -264,46 +279,72 @@ contract MorphoMarketV2AdapterTest is Test { function _ratificationSetup() internal returns (Offer memory offer) { 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); + uint256 numCollaterals = bound(vm.randomUint(), 1, 3); + CollateralParams[] memory collateralParams = new CollateralParams[](numCollaterals); + address[] memory tokens = new address[](numCollaterals); + address[] memory oracles = new address[](numCollaterals); + for (uint256 i = 0; i < numCollaterals; i++) { + tokens[i] = address(new ERC20Mock(18)); + oracles[i] = address(new OracleMock()); + } + // Sort tokens ascending (bubble sort) + for (uint256 i = 0; i < numCollaterals; i++) { + for (uint256 j = i + 1; j < numCollaterals; j++) { + if (tokens[i] > tokens[j]) { + (tokens[i], tokens[j]) = (tokens[j], tokens[i]); + (oracles[i], oracles[j]) = (oracles[j], oracles[i]); + } + } + } for (uint256 i = 0; i < numCollaterals; i++) { - collaterals[i] = - Collateral({token: address(new ERC20Mock(18)), lltv: 0.8 ether, oracle: address(new OracleMock())}); + collateralParams[i] = CollateralParams({ + token: tokens[i], lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracles[i] + }); } - offer.obligation.collaterals = collaterals; - offer.obligation.maturity = bound(vm.randomUint(), vm.getBlockTimestamp(), type(uint48).max); + offer.obligation.collateralParams = collateralParams; + offer.obligation.maturity = bound(vm.randomUint(), vm.getBlockTimestamp(), type(uint48).max - 1); + offer.obligation.rcfThreshold = 0; + offer.obligation.enterGate = address(0); + offer.obligation.liquidatorGate = address(0); 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.tick = bound(vm.randomUint(), 0, MAX_TICK); offer.callback = address(adapter); offer.callbackData = bytes(""); + offer.receiverIfMakerIsSeller = address(0); + offer.ratifier = address(adapter); + offer.reduceOnly = false; + offer.maxUnits = 0; + offer.maxSellerAssets = 0; + offer.maxBuyerAssets = 0; } - function testRatifyIncorrectOfferBadSellSigner(uint256 seed, address otherSigner) public { + function testRatifyIncorrectOfferBadSellSigner(uint256 seed) public { vm.setSeed(seed); + (address otherSigner, uint256 otherSignerKey) = makeAddrAndKey("otherSigner"); + privateKey[otherSigner] = otherSignerKey; vm.assume(otherSigner != signerAllocator); Offer memory offer = _ratificationSetup(); - vm.expectRevert(IMorphoMarketV2Adapter.IncorrectSigner.selector); - vm.prank(address(morphoV2)); - adapter.onRatify(offer, otherSigner); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, otherSigner); + vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); + adapter.onRatify(offer, _root, data); } - function testRatifyIncorrectOfferBadBuySigner(uint256 seed, address otherSigner) public { + function testRatifyIncorrectOfferBadBuySigner(uint256 seed) public { vm.setSeed(seed); + (address otherSigner, uint256 otherSignerKey) = makeAddrAndKey("otherSigner2"); + privateKey[otherSigner] = otherSignerKey; 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); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, otherSigner); + vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); + adapter.onRatify(offer, _root, data); } function testRatifyLoanAssetMismatch(uint256 seed, address otherToken) public { @@ -311,9 +352,10 @@ contract MorphoMarketV2AdapterTest is Test { 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); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.LoanAssetMismatch.selector); + adapter.onRatify(offer, _root, data); } function testRatifyIncorrectOwner(uint256 seed, address otherMaker) public { @@ -321,43 +363,48 @@ contract MorphoMarketV2AdapterTest is Test { 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); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.IncorrectOwner.selector); + adapter.onRatify(offer, _root, data); } function testRatifyIncorrectMaturity(uint256 seed) public { vm.setSeed(seed); Offer memory offer = _ratificationSetup(); offer.obligation.maturity = vm.randomUint(type(uint48).max, type(uint256).max); - vm.expectRevert(IMorphoMarketV2Adapter.IncorrectMaturity.selector); - vm.prank(address(morphoV2)); - adapter.onRatify(offer, signerAllocator); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.IncorrectMaturity.selector); + adapter.onRatify(offer, _root, data); } 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); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.IncorrectStart.selector); + adapter.onRatify(offer, _root, data); } 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); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.IncorrectCallbackAddress.selector); + adapter.onRatify(offer, _root, data); } function testRatifyIncorrectExpiry(uint256 seed) public { vm.setSeed(seed); Offer memory offer = _ratificationSetup(); - vm.prank(address(morphoV2)); - adapter.onRatify(offer, signerAllocator); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + adapter.onRatify(offer, _root, data); } /* STEPS SETUP */ @@ -373,24 +420,25 @@ contract MorphoMarketV2AdapterTest is Test { maker: address(adapter), start: vm.getBlockTimestamp(), expiry: vm.getBlockTimestamp() + 1, - expiryPrice: 1e18, + tick: MAX_TICK, callback: address(adapter), callbackData: abi.encode(0), obligation: Obligation({ - chainId: block.chainid, loanToken: address(loanToken), - collaterals: storedCollaterals, - // will be adjusted in loop - maturity: 0 + collateralParams: storedCollaterals, + maturity: 0, + rcfThreshold: 0, + enterGate: address(0), + liquidatorGate: address(0) }), - // will be adjusted in loop - startPrice: 0, - assets: 0, - obligationUnits: 0, - obligationShares: 0, group: bytes32(0), session: bytes32(0), - ratifier: address(adapter) + ratifier: address(adapter), + receiverIfMakerIsSeller: address(0), + reduceOnly: false, + maxUnits: 0, + maxSellerAssets: 0, + maxBuyerAssets: 0 }); for (uint256 i = 0; i < steps.length; i++) { @@ -399,24 +447,36 @@ contract MorphoMarketV2AdapterTest is Test { 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; + + // Compute tick from desired price: price = assets / (assets + approxInterest) + uint256 desiredPrice = step.assets.mulDivDown(1e18, step.assets + approxInterest); + if (desiredPrice > 1e18) desiredPrice = 1e18; + offer.tick = TickLib.priceToTick(desiredPrice); + uint256 actualPrice = TickLib.tickToPrice(offer.tick); + uint256 units = step.assets.mulDivDown(1e18, actualPrice); + uint256 actualGrowth = (units - step.assets) / timeToMaturity; + uint256 zeroPeriodGain = (units - step.assets) % timeToMaturity; + offer.maxUnits = units; 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); + morphoV2.supplyCollateral(offer.obligation, 0, 1_000e18, taker); + morphoV2.supplyCollateral(offer.obligation, 1, 1_000e18, taker); uint256 unitsBefore = adapter.units(obligationId); morphoV2.take( - step.assets, 0, 0, 0, taker, offer, proof([offer]), sign([offer], signerAllocator), address(0), "" + units, + taker, + address(0), + "", + taker, + offer, + sign([offer], signerAllocator), + root([offer]), + proof([offer]) ); vm.stopPrank(); @@ -554,25 +614,25 @@ contract MorphoMarketV2AdapterTest is Test { Obligation memory obligation; - Collateral[] memory collaterals = new Collateral[](collateralCount); + CollateralParams[] memory collateralParams = new CollateralParams[](collateralCount); for (uint256 i = 0; i < collateralCount; i++) { - collaterals[i].token = address(uint160(i)); + collateralParams[i].token = address(uint160(i)); } - obligation.collaterals = storedCollaterals; + obligation.collateralParams = storedCollaterals; obligation.maturity = bound(maturity, 1, 700 days); bytes32[] memory ids = adapter.ids(obligation); assertEq(ids[0], adapter.adapterId()); - for (uint256 i = 0; i < obligation.collaterals.length; i++) { - assertEq(ids[i * 2 + 1], keccak256(abi.encode("collateralToken", obligation.collaterals[i].token))); + for (uint256 i = 0; i < obligation.collateralParams.length; i++) { + assertEq(ids[i * 2 + 1], keccak256(abi.encode("collateralToken", obligation.collateralParams[i].token))); assertEq( ids[i * 2 + 2], keccak256( abi.encode( "collateral", - obligation.collaterals[i].token, - obligation.collaterals[i].oracle, - obligation.collaterals[i].lltv + obligation.collateralParams[i].token, + obligation.collateralParams[i].oracle, + obligation.collateralParams[i].lltv ) ) ); @@ -583,14 +643,14 @@ contract MorphoMarketV2AdapterTest is Test { for (uint256 i = 0; i < durations.length; i++) { if ((obligation.maturity - block.timestamp) >= durations[i]) { assertEq( - ids[1 + obligation.collaterals.length * 2 + durationIdCount], + ids[1 + obligation.collateralParams.length * 2 + durationIdCount], keccak256(abi.encode("duration", durations[i])) ); durationIdCount++; } } - assertEq(ids.length, 1 + obligation.collaterals.length * 2 + durationIdCount); + assertEq(ids.length, 1 + obligation.collateralParams.length * 2 + durationIdCount); } /* UTILITIES */ @@ -621,27 +681,27 @@ contract MorphoMarketV2AdapterTest is Test { 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) internal view returns (bytes memory) { + return ratifierData(root(offers), offers[0].maker); } - function sign(Offer[1] memory offers, address signer) internal view returns (Signature memory) { - return messageSig(root(offers), signer); + function sign(Offer[1] memory offers, address signer) internal view returns (bytes memory) { + return ratifierData(root(offers), signer); } - function proof(Offer[1] memory offers) internal pure returns (Proof memory) { - return Proof({root: root(offers), path: new bytes32[](0)}); + function proof(Offer[1] memory) internal pure returns (bytes32[] memory) { + return 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 (bytes32[] memory) { + bytes32[] memory path = new bytes32[](1); + path[0] = keccak256(abi.encode(offers[1])); + return path; } - // 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 sign(Offer[2] memory offers) internal view returns (bytes memory) { + return ratifierData(root(offers), offers[0].maker); } function root(Offer memory offer) internal pure returns (bytes32) { @@ -656,9 +716,12 @@ contract MorphoMarketV2AdapterTest is Test { 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); + function ratifierData(bytes32 _root, address signer) internal view returns (bytes memory) { + bytes32 structHash = keccak256(abi.encode(ROOT_TYPEHASH, _root)); + bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(adapter))); + bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey[signer], digest); + return abi.encode(Signature({v: v, r: r, s: s})); } /// @dev Returns the concatenation of x and y, sorted lexicographically. From 740eb3210518ba3f970e6d3bef1ec95219313270 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 9 Apr 2026 14:12:47 +0200 Subject: [PATCH 33/95] fix shadowing --- src/adapters/MidnightAdapter.sol | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 58fae5f2f..008d4dc8a 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -106,13 +106,13 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdrawToVault(Obligation memory obligation, uint256 units) external { + function withdrawToVault(Obligation memory obligation, uint256 withdrawnUnits) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); - Midnight(morphoV2).withdraw(obligation, units, address(this), address(this)); + Midnight(morphoV2).withdraw(obligation, withdrawnUnits, address(this), address(this)); deallocateExpiredDurations(obligation); - removeUnits(obligation, units); - selfDeallocate(ids(obligation), units, units); + removeUnits(obligation, withdrawnUnits); + selfDeallocate(ids(obligation), withdrawnUnits, withdrawnUnits); } function deallocateExpiredDurations(Obligation memory obligation) public { @@ -218,9 +218,9 @@ contract MidnightAdapter is IMidnightAdapter { // Already in a deallocate call so we skip the onSell callback and return the deallocation here. bytes32 midnightId = IdLib.toId(offer.obligation, block.chainid, morphoV2); - uint256 units = TakeAmountsLib.sellerAssetsToUnits(Midnight(morphoV2), midnightId, offer, sellerAssets); + uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(Midnight(morphoV2), midnightId, offer, sellerAssets); (,, uint256 deallocated) = Midnight(morphoV2) - .take(units, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); + .take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); require(Midnight(morphoV2).debtOf(midnightId, address(this)) == 0, NoBorrowing()); @@ -330,7 +330,6 @@ contract MidnightAdapter is IMidnightAdapter { uint256 soldObligationUnits, bytes memory ) external returns (bytes32) { - bytes32 obligationId = _obligationId(obligation); require(msg.sender == address(morphoV2), NotMorphoV2()); require(seller == address(this), NotSelf()); require(Midnight(morphoV2).debtOf(midnightId, address(this)) == 0, NoBorrowing()); From bd2f671510d938d771297dd913d32910fa8cc065 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 10 Apr 2026 16:15:28 +0200 Subject: [PATCH 34/95] cleaner realizeLoss , prioritize removing future growth --- src/adapters/MidnightAdapter.sol | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 008d4dc8a..280bdbc3c 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -9,6 +9,7 @@ import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "lib/midnight/src import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; +import {UtilsLib} from "lib/midnight/src/libraries/UtilsLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; @@ -184,10 +185,12 @@ contract MidnightAdapter is IMidnightAdapter { bytes32 midnightId = IdLib.toId(obligation, block.chainid, morphoV2); uint256 remainingUnits = Midnight(morphoV2).creditOf(midnightId, address(this)); - uint256 lostUnits = _units[obligationId] - remainingUnits; + uint256 lostUnits = _units[obligationId].zeroFloorSub(remainingUnits); deallocateExpiredDurations(obligation); - removeUnits(obligation, lostUnits); - selfDeallocate(ids(obligation), lostUnits, 0); + if (lostUnits > 0) { + removeUnits(obligation, lostUnits); + selfDeallocate(ids(obligation), lostUnits, 0); + } } /* ALLOCATION FUNCTIONS */ @@ -352,14 +355,17 @@ contract MidnightAdapter is IMidnightAdapter { /* INTERNAL FUNCTIONS */ - /// @dev The total assets can go up after removing units to compensate for the rounded up lost growth. + /// @dev Removes units from tracking. Absorbs the loss from future interest (growth) first, + /// and only reduces principal (_totalAssets) for the remainder that growth can't cover. + /// @dev removedUnits can exceed tracked units (pending fee gap or bad debt). function removeUnits(Obligation memory obligation, uint256 removedUnits) internal { MaturityData storage maturityData = _maturities[obligation.maturity]; accrueInterest(); if (obligation.maturity > block.timestamp) { uint256 timeToMaturity = obligation.maturity - block.timestamp; - uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.units).toUint128(); + uint128 removedGrowth = + UtilsLib.min(removedUnits.mulDivUp(1, timeToMaturity), maturityData.growth).toUint128(); // Do not cleanup the linked list if we end up at 0 growth. maturityData.growth -= removedGrowth; currentGrowth -= removedGrowth; From 0f0f1339e4f3e2f9dbeb59ac1a59ab9071050c07 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 14 Apr 2026 16:19:34 +0200 Subject: [PATCH 35/95] autorealize loss, more midnight update, fixes --- foundry.lock | 5 +- foundry.toml | 9 + lib/forge-std | 2 +- lib/midnight | 2 +- src/adapters/MidnightAdapter.sol | 190 ++++++++++-------- src/adapters/interfaces/IMidnightAdapter.sol | 16 +- .../interfaces/IMidnightAdapterFactory.sol | 6 +- src/imports/MidnightImport.sol | 6 + test/MidnightAdapterAllocationUpdateTest.sol | 51 ++--- test/MidnightAdapterTest.sol | 68 ++++--- test/mocks/VaultV2Mock.sol | 8 + 11 files changed, 197 insertions(+), 166 deletions(-) create mode 100644 src/imports/MidnightImport.sol diff --git a/foundry.lock b/foundry.lock index 9f29faf48..daac600ed 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,6 +1,6 @@ { "lib/forge-std": { - "rev": "77041d2ce690e692d6e03cc812b57d1ddaa4d505" + "rev": "0844d7e1fc5e60d77b68e469bff60265f236c398" }, "lib/metamorpho": { "rev": "00da9ad27da8051bce663eeac02f3b9c0c0aa8d8" @@ -8,6 +8,9 @@ "lib/metamorpho-v1.1": { "rev": "2d160ba9bb945ca3bf12efb182427445dce59c27" }, + "lib/midnight": { + "rev": "7385a905d087367689fbdd16eef04e68b5146dc2" + }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" }, diff --git a/foundry.toml b/foundry.toml index dfd6c93ad..5090036e0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,11 +5,20 @@ optimizer_runs = 100000 bytecode_hash = "none" evm_version = "osaka" dynamic_test_linking = true +ignored_error_codes = ["transient-storage"] [profile.default.fmt] wrap_comments = true [lint] +ignore = [ + "lib/**", + "src/imports/MorphoImport.sol", + "src/imports/MidnightImport.sol", + "src/imports/MetaMorphoImport.sol", + "src/imports/MetaMorphoV1_1Import.sol", + "src/imports/AdaptiveCurveIrmImport.sol", +] exclude_lints = [ "unsafe-typecast", "erc20-unchecked-transfer", diff --git a/lib/forge-std b/lib/forge-std index 8e40513d6..0844d7e1f 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 8e40513d678f392f398620b3ef2b418648b33e89 +Subproject commit 0844d7e1fc5e60d77b68e469bff60265f236c398 diff --git a/lib/midnight b/lib/midnight index 41c8405a5..7385a905d 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit 41c8405a5e4c9ea5785bdbe506d8880c4bcbd596 +Subproject commit 7385a905d087367689fbdd16eef04e68b5146dc2 diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 280bdbc3c..f5638d536 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -2,14 +2,12 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {Midnight} from "lib/midnight/src/Midnight.sol"; -import {Offer, Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; +import {IMidnight, Offer, Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "lib/midnight/src/interfaces/IEcrecover.sol"; import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; -import {UtilsLib} from "lib/midnight/src/libraries/UtilsLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; @@ -24,13 +22,14 @@ import {DurationsLib} from "./libraries/DurationsLib.sol"; contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; + using MathLib for int256; using DurationsLib for bytes32; /* IMMUTABLES */ address public immutable asset; address public immutable parentVault; - address public immutable morphoV2; + address public immutable midnight; bytes32 public immutable adapterId; bytes32 public immutable packedDurations; uint256 public immutable durationsLength; @@ -46,15 +45,15 @@ contract MidnightAdapter is IMidnightAdapter { uint48 public firstMaturity; uint128 public currentGrowth; mapping(uint256 timestamp => MaturityData) public _maturities; - mapping(bytes32 obligationId => uint256) public _units; + mapping(bytes32 obligationId => uint256) public netCredit; /* CONSTRUCTOR */ - constructor(address _parentVault, address _morphoV2, uint256[] memory _durations) { + constructor(address _parentVault, address _midnight, uint256[] memory _durations) { asset = IVaultV2(_parentVault).asset(); parentVault = _parentVault; - morphoV2 = _morphoV2; + midnight = _midnight; lastUpdate = uint48(block.timestamp); - SafeERC20Lib.safeApprove(asset, _morphoV2, type(uint256).max); + SafeERC20Lib.safeApprove(asset, _midnight, type(uint256).max); SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); firstMaturity = type(uint48).max; adapterId = keccak256(abi.encode("this", address(this))); @@ -72,10 +71,6 @@ contract MidnightAdapter is IMidnightAdapter { /* GETTERS */ - function units(bytes32 obligationId) public view returns (uint256) { - return _units[obligationId]; - } - function maturities(uint256 date) public view returns (MaturityData memory) { return _maturities[date]; } @@ -107,13 +102,29 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdrawToVault(Obligation memory obligation, uint256 withdrawnUnits) external { + function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); - Midnight(morphoV2).withdraw(obligation, withdrawnUnits, address(this), address(this)); + bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); + uint256 pendingFeeDecrease = + IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); + accrueInterest(); deallocateExpiredDurations(obligation); - removeUnits(obligation, withdrawnUnits); - selfDeallocate(ids(obligation), withdrawnUnits, withdrawnUnits); + + uint256 withdrawNetCreditDecrease = withdrawnAssets - pendingFeeDecrease; + uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + // new net credit cannot be > old credit + uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + + if (totalNetCreditDecrease > withdrawNetCreditDecrease) { + removeUnits(obligation, totalNetCreditDecrease - withdrawNetCreditDecrease); + } + + if (withdrawNetCreditDecrease > 0) removeUnits(obligation, withdrawNetCreditDecrease); + + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), withdrawnAssets); } function deallocateExpiredDurations(Obligation memory obligation) public { @@ -135,7 +146,10 @@ contract MidnightAdapter is IMidnightAdapter { zeroedDurationsIds[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); } } - selfDeallocate(zeroedDurationsIds, maturityData.units, 0); + IVaultV2(parentVault) + .deallocate( + address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))), 0 + ); } } @@ -162,37 +176,21 @@ contract MidnightAdapter is IMidnightAdapter { return (nextMaturity, newGrowth, _totalAssets + gainedAssets); } - function accrueInterest() public { + function accrueInterest() public returns (uint48, uint128, uint256) { if (lastUpdate != block.timestamp) { - (uint48 newFirstMaturity, uint128 newCurrentGrowth, uint256 newTotalAssets) = accrueInterestView(); - _totalAssets = newTotalAssets; + (firstMaturity, currentGrowth, _totalAssets) = accrueInterestView(); lastUpdate = uint48(block.timestamp); - firstMaturity = newFirstMaturity; - currentGrowth = newCurrentGrowth; } + return (firstMaturity, currentGrowth, _totalAssets); } - /// @dev Returns an estimate of the real assets. + /// @dev Returns an estimate of the real assets assigned to the adapter. + /// @dev Excludes assets reserved for users. function realAssets() external view returns (uint256) { (,, uint256 newTotalAssets) = accrueInterestView(); return newTotalAssets; } - /* LOSS REALIZATION */ - - function realizeLoss(Obligation memory obligation) external { - bytes32 obligationId = _obligationId(obligation); - bytes32 midnightId = IdLib.toId(obligation, block.chainid, morphoV2); - uint256 remainingUnits = Midnight(morphoV2).creditOf(midnightId, address(this)); - - uint256 lostUnits = _units[obligationId].zeroFloorSub(remainingUnits); - deallocateExpiredDurations(obligation); - if (lostUnits > 0) { - removeUnits(obligation, lostUnits); - selfDeallocate(ids(obligation), lostUnits, 0); - } - } - /* ALLOCATION FUNCTIONS */ /// @dev Can be called by this adapter from a buy callback. @@ -214,18 +212,20 @@ contract MidnightAdapter is IMidnightAdapter { external returns (bytes32[] memory, int256) { + require(msg.sender == parentVault, NotAuthorized()); if (messageSig == IVaultV2.forceDeallocate.selector) { (Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof) = abi.decode(data, (Offer, bytes, bytes32, bytes32[])); require(offer.buy && offer.obligation.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); // Already in a deallocate call so we skip the onSell callback and return the deallocation here. - bytes32 midnightId = IdLib.toId(offer.obligation, block.chainid, morphoV2); - uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(Midnight(morphoV2), midnightId, offer, sellerAssets); - (,, uint256 deallocated) = Midnight(morphoV2) + bytes32 obligationId = IdLib.toId(offer.obligation, block.chainid, midnight); + uint256 takeUnits = + TakeAmountsLib.sellerAssetsToUnits(IMidnight(midnight), obligationId, offer, sellerAssets); + (,, uint256 deallocated) = IMidnight(midnight) .take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); - require(Midnight(morphoV2).debtOf(midnightId, address(this)) == 0, NoBorrowing()); + require(IMidnight(midnight).debtOf(obligationId, address(this)) == 0, NoBorrowing()); deallocateExpiredDurations(offer.obligation); removeUnits(offer.obligation, deallocated); @@ -239,18 +239,18 @@ contract MidnightAdapter is IMidnightAdapter { } } - /* MORPHO V2 CALLBACKS */ + /* MIDNIGHT CALLBACKS */ function onRatify(Offer memory offer, bytes32 root, bytes memory data) external view returns (bytes32) { - // Collaterals will be checked at the level of vault ids. + // Collaterals will be checked through vault ids. require(offer.obligation.loanToken == asset, LoanAssetMismatch()); require(offer.maker == address(this), IncorrectOwner()); require(offer.callback == address(this), IncorrectCallbackAddress()); require(offer.start <= block.timestamp, IncorrectStart()); // uint48.max is the list end pointer require(offer.obligation.maturity < type(uint48).max, IncorrectMaturity()); + require(offer.buy || offer.reduceOnly, NoDebtCreation()); - // Signature verification (inlined from EcrecoverRatifier). Signature memory sig = abi.decode(data, (Signature)); bytes32 structHash = keccak256(abi.encode(ROOT_TYPEHASH, root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); @@ -263,36 +263,46 @@ contract MidnightAdapter is IMidnightAdapter { } function onBuy( - bytes32, + bytes32 obligationId, Obligation memory obligation, address buyer, - uint256 buyerAssets, - uint256 obligationUnits, + uint256 paidAssets, + uint256 boughtCredit, + uint256 buyPendingFeeIncrease, bytes memory data ) external returns (bytes32) { - 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()); MaturityData storage maturityData = _maturities[obligation.maturity]; - accrueInterest(); + require(msg.sender == midnight, NotMidnight()); + require(buyer == address(this), NotSelf()); + require(prevMaturity < obligation.maturity, IncorrectHint()); + accrueInterest(); deallocateExpiredDurations(obligation); - if (obligation.maturity > block.timestamp) { - uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); - uint128 gainedGrowth = ((obligationUnits - buyerAssets) / timeToMaturity).toUint128(); - _totalAssets += buyerAssets + (obligationUnits - buyerAssets) % timeToMaturity; + uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; + require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); + + uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + int256 change = newNetCredit.toInt256() - netCredit[obligationId].toInt256(); + // change is at most buyNetCreditIncrease + if (change < buyNetCreditIncrease.toInt256()) { + removeUnits(obligation, (buyNetCreditIncrease.toInt256() - change).toUint256()); + } + + if (timeToMaturity > 0) { + uint128 gainedGrowth = ((buyNetCreditIncrease - paidAssets) / timeToMaturity).toUint128(); + _totalAssets += paidAssets + (buyNetCreditIncrease - paidAssets) % timeToMaturity; maturityData.growth += gainedGrowth; currentGrowth += gainedGrowth; } else { - // No need to update past growth to zero since it won't be read again. - _totalAssets += obligationUnits; + _totalAssets += buyNetCreditIncrease; } - maturityData.units += obligationUnits.toUint128(); - _units[obligationId] += obligationUnits.toUint128(); + maturityData.netCredit += buyNetCreditIncrease.toUint128(); + netCredit[obligationId] += buyNetCreditIncrease.toUint128(); // Insert the maturity in the list if needed if (obligation.maturity >= block.timestamp) { @@ -319,29 +329,40 @@ contract MidnightAdapter is IMidnightAdapter { } } - IVaultV2(parentVault) - .allocate(address(this), abi.encode(ids(obligation), obligationUnits.toInt256()), buyerAssets); + IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); return CALLBACK_SUCCESS; } function onSell( - bytes32 midnightId, + bytes32 obligationId, Obligation memory obligation, address seller, uint256 sellerAssets, - uint256 soldObligationUnits, + uint256 units, + uint256 sellPendingFeeDecrease, bytes memory ) external returns (bytes32) { - require(msg.sender == address(morphoV2), NotMorphoV2()); - require(seller == address(this), NotSelf()); - require(Midnight(morphoV2).debtOf(midnightId, address(this)) == 0, NoBorrowing()); - uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); + require(msg.sender == midnight, NotMidnight()); + require(seller == address(this), NotSelf()); + + accrueInterest(); deallocateExpiredDurations(obligation); - removeUnits(obligation, soldObligationUnits); - selfDeallocate(ids(obligation), soldObligationUnits, sellerAssets); + + uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; + uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + // new net credit cannot be > old credit + uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + + // The sell itself removes exactly `netCreditDecrease` of net credit; any excess is a concurrent loss. + if (totalNetCreditDecrease > sellNetCreditDecrease) { + removeUnits(obligation, totalNetCreditDecrease - sellNetCreditDecrease); + } + + if (sellNetCreditDecrease > 0) removeUnits(obligation, sellNetCreditDecrease); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -350,35 +371,30 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), sellerAssets); + return CALLBACK_SUCCESS; } /* INTERNAL FUNCTIONS */ - /// @dev Removes units from tracking. Absorbs the loss from future interest (growth) first, - /// and only reduces principal (_totalAssets) for the remainder that growth can't cover. - /// @dev removedUnits can exceed tracked units (pending fee gap or bad debt). + /// @dev Removes units from tracking. + /// @dev Changes the implied price of the obligation as little as possible. function removeUnits(Obligation memory obligation, uint256 removedUnits) internal { MaturityData storage maturityData = _maturities[obligation.maturity]; - accrueInterest(); if (obligation.maturity > block.timestamp) { uint256 timeToMaturity = obligation.maturity - block.timestamp; - uint128 removedGrowth = - UtilsLib.min(removedUnits.mulDivUp(1, timeToMaturity), maturityData.growth).toUint128(); - // Do not cleanup the linked list if we end up at 0 growth. + uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.netCredit).toUint128(); maturityData.growth -= removedGrowth; currentGrowth -= removedGrowth; _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { _totalAssets -= removedUnits; } - maturityData.units -= removedUnits.toUint128(); - _units[_obligationId(obligation)] -= removedUnits.toUint128(); - } - - function _obligationId(Obligation memory obligation) internal pure returns (bytes32) { - return keccak256(abi.encode(obligation)); + maturityData.netCredit -= removedUnits.toUint128(); + netCredit[IdLib.toId(obligation, block.chainid, midnight)] -= removedUnits.toUint128(); } function ids(Obligation memory obligation) public view returns (bytes32[] memory) { @@ -412,11 +428,7 @@ contract MidnightAdapter is IMidnightAdapter { return idsArray; } - function selfDeallocate(bytes32[] memory _ids, uint256 deallocated, uint256 assets) internal { - IVaultV2(parentVault).deallocate(address(this), abi.encode(_ids, -deallocated.toInt256()), assets); - } - - /* TO REMOVE */ + /* UNUSED CALLBACKS */ function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert(); diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index f327c8f02..4c8075f44 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -10,7 +10,7 @@ import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple obligations. // nextMaturity is type(uint48).max if no next maturity struct MaturityData { - uint128 units; + uint128 netCredit; uint128 growth; uint48 nextMaturity; uint48 lastUpdate; @@ -25,6 +25,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { /* ERRORS */ error BufferTooLow(); + error BuyAtLoss(); error IncorrectCallbackAddress(); error IncorrectDuration(); error IncorrectHint(); @@ -35,19 +36,23 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { error IncorrectStart(); error LoanAssetMismatch(); error NoBorrowing(); + error NoDebtCreation(); error NotAuthorized(); - error NotMorphoV2(); + error NotMidnight(); error NotSelf(); error SelfAllocationOnly(); /* FUNCTIONS */ + function asset() external view returns (address); 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 midnight() external view returns (address); function adapterId() external view returns (bytes32); - function units(bytes32 obligationId) external view returns (uint256); + function packedDurations() external view returns (bytes32); + function netCredit(bytes32 obligationId) external view returns (uint256); function maturities(uint256 date) external view returns (MaturityData memory); function skimRecipient() external view returns (address); function setSkimRecipient(address newSkimRecipient) external; @@ -59,8 +64,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); - function accrueInterest() external; - function realizeLoss(Obligation memory obligation) external; + function accrueInterest() external returns (uint48, uint128, uint256); function allocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) external returns (bytes32[] memory, int256); @@ -73,6 +77,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { address buyer, uint256 buyerAssets, uint256 units, + uint256 buyerPendingFeeIncrease, bytes memory data ) external returns (bytes32); function onSell( @@ -81,6 +86,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { address seller, uint256 sellerAssets, uint256 units, + uint256 sellerPendingFeeDecrease, bytes memory data ) external returns (bytes32); function onLiquidate( diff --git a/src/adapters/interfaces/IMidnightAdapterFactory.sol b/src/adapters/interfaces/IMidnightAdapterFactory.sol index 8211abfbc..b890c0746 100644 --- a/src/adapters/interfaces/IMidnightAdapterFactory.sol +++ b/src/adapters/interfaces/IMidnightAdapterFactory.sol @@ -5,13 +5,13 @@ pragma solidity >=0.5.0; interface IMidnightAdapterFactory { /* EVENTS */ - event CreateMidnightAdapter(address indexed parentVault, address indexed morpho, address indexed midnightAdapter); + event CreateMidnightAdapter(address indexed parentVault, address indexed midnight, address indexed midnightAdapter); /* FUNCTIONS */ function durations(uint256 index) external view returns (uint256); function durationsLength() external view returns (uint256); - function midnightAdapter(address parentVault, address morpho) external view returns (address); + function midnightAdapter(address parentVault, address midnight) external view returns (address); function isMidnightAdapter(address account) external view returns (bool); - function createMidnightAdapter(address parentVault, address morpho) external returns (address); + function createMidnightAdapter(address parentVault, address midnight) external returns (address); } diff --git a/src/imports/MidnightImport.sol b/src/imports/MidnightImport.sol new file mode 100644 index 000000000..7db2e5d50 --- /dev/null +++ b/src/imports/MidnightImport.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.34; +// Force foundry to compile Midnight without importing it in the tests. + +import {Midnight} from "../../lib/midnight/src/Midnight.sol"; diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index a6b96a52f..8d2a1365c 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -6,13 +6,11 @@ import "../lib/forge-std/src/Test.sol"; import {MidnightAdapterTest} from "./MidnightAdapterTest.sol"; import {IERC20} from "../src/interfaces/IERC20.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; -import {Midnight} from "../lib/midnight/src/Midnight.sol"; import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {Oracle} from "../lib/midnight/test/helpers/Oracle.sol"; -import {ApprovalRatifier} from "../lib/midnight/src/ratifiers/ApprovalRatifier.sol"; -import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; +import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { using stdStorage for StdStorage; @@ -24,17 +22,17 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { super.setUp(); storedCollaterals[0].lltv = 1e18; - storedCollaterals[0].maxLif = morphoV2.maxLif(1e18, 0.25e18); + storedCollaterals[0].maxLif = midnight.maxLif(1e18, 0.25e18); storedCollaterals[1].lltv = 1e18; - storedCollaterals[1].maxLif = morphoV2.maxLif(1e18, 0.25e18); + storedCollaterals[1].maxLif = midnight.maxLif(1e18, 0.25e18); storedOffer.obligation.collateralParams = storedCollaterals; vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - loanToken.approve(address(morphoV2), type(uint256).max); + loanToken.approve(address(midnight), type(uint256).max); vm.stopPrank(); } @@ -52,9 +50,9 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.callbackData = abi.encode(0); vm.startPrank(taker); - morphoV2.supplyCollateral(offer.obligation, 0, assets / 2, taker); - morphoV2.supplyCollateral(offer.obligation, 1, assets / 2, taker); - morphoV2.take( + midnight.supplyCollateral(offer.obligation, 0, assets / 2, taker); + midnight.supplyCollateral(offer.obligation, 1, assets / 2, taker); + midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); vm.stopPrank(); @@ -66,6 +64,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.obligation = obligation; offer.buy = false; + offer.reduceOnly = true; offer.tick = MAX_TICK; uint256 price = TickLib.tickToPrice(MAX_TICK); uint256 units = assets * 1e18 / price; @@ -76,14 +75,14 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.group = bytes32(vm.randomUint()); offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take( + midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); } function forceDeallocate(Obligation memory obligation, uint256 assets) internal { address buyer = makeAddr("buyer"); - ApprovalRatifier approvalRatifier = new ApprovalRatifier(); + SetterRatifier approvalRatifier = new SetterRatifier(address(midnight)); Offer memory offer = storedOffer; offer.obligation = obligation; @@ -101,10 +100,10 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { deal(address(loanToken), buyer, assets); vm.startPrank(buyer); - loanToken.approve(address(morphoV2), type(uint256).max); - morphoV2.setIsAuthorized(buyer, address(approvalRatifier), true); + loanToken.approve(address(midnight), type(uint256).max); + midnight.setIsAuthorized(buyer, address(approvalRatifier), true); bytes32 _root = root([offer]); - approvalRatifier.setApproval(_root, true); + approvalRatifier.setApproval(buyer, _root, true); vm.stopPrank(); bytes memory data = abi.encode(offer, hex"", _root, proof([offer])); @@ -154,24 +153,6 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } - function testUpdateOnRealizeLoss() public { - Offer memory offer = buy(7 days, 1e18); - assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 week, before"); - assertEq(parentVault.allocation(durationId(7 days)), 1e18, "2 weeks, before"); - - skip(1); - - Oracle(offer.obligation.collateralParams[0].oracle).setPrice(0); - morphoV2.liquidate(offer.obligation, 0, 0, 0, taker, ""); - adapter.realizeLoss(offer.obligation); - - bytes32 midnightId = IdLib.toId(offer.obligation, block.chainid, address(morphoV2)); - uint256 remainingUnits = Midnight(morphoV2).creditOf(midnightId, address(adapter)); - - assertEq(parentVault.allocation(durationId(1 days)), remainingUnits, "1 day"); - assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); - } - function testUpdateOnWithdraw() public { Offer memory offer = buy(7 days, 1e18); assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); @@ -180,7 +161,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(7 days); vm.prank(taker); - morphoV2.repay(offer.obligation, 1e18, taker, ""); + midnight.repay(offer.obligation, 1e18, taker, ""); vm.prank(signerAllocator); adapter.withdrawToVault(offer.obligation, 0.5e18); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 7baa10e31..4deb4eba9 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -13,7 +13,7 @@ import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol" import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; import {Midnight} from "../lib/midnight/src/Midnight.sol"; -import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; +import {IMidnight, Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../lib/midnight/src/interfaces/IEcrecover.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; @@ -31,7 +31,7 @@ contract MidnightAdapterTest is Test { using stdStorage for StdStorage; using MathLib for uint256; - Midnight internal morphoV2; + IMidnight internal midnight; IMidnightAdapterFactory internal factory; IMidnightAdapter internal adapter; VaultV2Mock internal parentVault; @@ -77,7 +77,7 @@ contract MidnightAdapterTest is Test { recipient = makeAddr("recipient"); taker = makeAddr("taker"); - morphoV2 = new Midnight(); + midnight = IMidnight(address(new Midnight())); loanToken = IERC20(address(new ERC20Mock(18))); rewardToken = IERC20(address(new ERC20Mock(18))); @@ -85,11 +85,11 @@ contract MidnightAdapterTest is Test { parentVault = new VaultV2Mock(address(loanToken), owner, curator, signerAllocator, address(0)); factory = new MidnightAdapterFactory(allDurations); - adapter = MidnightAdapter(factory.createMidnightAdapter(address(parentVault), address(morphoV2))); + adapter = MidnightAdapter(factory.createMidnightAdapter(address(parentVault), address(midnight))); // Adapter authorizes itself as ratifier vm.prank(address(adapter)); - morphoV2.setIsAuthorized(address(adapter), address(adapter), true); + midnight.setIsAuthorized(address(adapter), address(adapter), true); address collToken0 = address(new ERC20Mock(18)); address collToken1 = address(new ERC20Mock(18)); @@ -104,12 +104,12 @@ contract MidnightAdapterTest is Test { storedCollaterals.push( CollateralParams({ - token: collToken0, lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracle0 + token: collToken0, lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracle0 }) ); storedCollaterals.push( CollateralParams({ - token: collToken1, lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracle1 + token: collToken1, lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracle1 }) ); @@ -185,12 +185,12 @@ contract MidnightAdapterTest is Test { offer.tick = TickLib.priceToTick(0.95e18); vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - morphoV2.supplyCollateral(offer.obligation, 0, 1_000e18, taker); - morphoV2.supplyCollateral(offer.obligation, 1, 1_000e18, taker); + midnight.supplyCollateral(offer.obligation, 0, 1_000e18, taker); + midnight.supplyCollateral(offer.obligation, 1, 1_000e18, taker); vm.stopPrank(); uint256 assets = 1e18; @@ -201,7 +201,7 @@ contract MidnightAdapterTest is Test { offer.callback = address(adapter); offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take( + midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); @@ -218,7 +218,7 @@ contract MidnightAdapterTest is Test { assertEq(maturityData.growth, newGrowth, "growth"); assertEq(maturityData.nextMaturity, type(uint48).max, "nextMaturity"); - uint256 actualUnits = adapter.units(_obligationId(offer.obligation)); + uint256 actualUnits = adapter.netCredit(_obligationId(offer.obligation)); assertEq(actualUnits, units, "units"); } @@ -228,12 +228,12 @@ contract MidnightAdapterTest is Test { uint256 maturity = offer.obligation.maturity; vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 100_000e18); deal(storedCollaterals[1].token, taker, 100_000e18); - morphoV2.supplyCollateral(offer.obligation, 0, 100_000e18, taker); - morphoV2.supplyCollateral(offer.obligation, 1, 100_000e18, taker); + midnight.supplyCollateral(offer.obligation, 0, 100_000e18, taker); + midnight.supplyCollateral(offer.obligation, 1, 100_000e18, taker); vm.stopPrank(); // Step 1: Buy at maturity M (future) @@ -246,7 +246,7 @@ contract MidnightAdapterTest is Test { offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take( + midnight.take( units1, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); @@ -263,7 +263,6 @@ contract MidnightAdapterTest is Test { adapter.accrueInterest(); assertEq(adapter.currentGrowth(), 0, "currentGrowth after accrual should be 0"); assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity should be sentinel"); - uint256 totalAssetsAfterAccrual = adapter._totalAssets(); // In midnight, any seller with debt past maturity is always liquidatable // (isLiquidatable returns true if block.timestamp > maturity && debt > 0), @@ -300,7 +299,7 @@ contract MidnightAdapterTest is Test { } for (uint256 i = 0; i < numCollaterals; i++) { collateralParams[i] = CollateralParams({ - token: tokens[i], lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracles[i] + token: tokens[i], lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracles[i] }); } offer.obligation.collateralParams = collateralParams; @@ -411,8 +410,8 @@ contract MidnightAdapterTest is Test { 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); + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); vm.stopPrank(); Offer memory offer = Offer({ @@ -463,11 +462,11 @@ contract MidnightAdapterTest is Test { vm.startPrank(taker); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - morphoV2.supplyCollateral(offer.obligation, 0, 1_000e18, taker); - morphoV2.supplyCollateral(offer.obligation, 1, 1_000e18, taker); + midnight.supplyCollateral(offer.obligation, 0, 1_000e18, taker); + midnight.supplyCollateral(offer.obligation, 1, 1_000e18, taker); - uint256 unitsBefore = adapter.units(obligationId); - morphoV2.take( + uint256 unitsBefore = adapter.netCredit(obligationId); + midnight.take( units, taker, address(0), @@ -480,7 +479,7 @@ contract MidnightAdapterTest is Test { ); vm.stopPrank(); - assertEq(adapter.units(obligationId), unitsBefore + units, "setup: units 1"); + assertEq(adapter.netCredit(obligationId), unitsBefore + units, "setup: units 1"); expectedUnits[obligationId] += units; expectedMaturityGrowths[step.maturity] += actualGrowth; @@ -536,7 +535,7 @@ contract MidnightAdapterTest is Test { // Check positions growth and size for (uint256 i = 0; i < expectedPositionsList.length; i++) { bytes32 obligationId = bytes32(expectedPositionsList[i]); - assertEq(adapter.units(obligationId), expectedUnits[obligationId], "units"); + assertEq(adapter.netCredit(obligationId), expectedUnits[obligationId], "units"); } } @@ -605,7 +604,14 @@ contract MidnightAdapterTest is Test { /* DURATIONS */ - // Add constructor tests + function testConstructorGetters() public view { + assertEq(adapter.asset(), address(loanToken), "asset"); + assertEq(adapter.parentVault(), address(parentVault), "parentVault"); + assertEq(adapter.midnight(), address(midnight), "midnight"); + assertEq(adapter.skimRecipient(), address(0), "skimRecipient"); + assertEq(adapter.durationsLength(), allDurations.length, "durationsLength"); + assertEq(adapter.packedDurations(), MidnightAdapter(address(adapter)).packedDurations(), "packedDurations"); + } /* IDS */ @@ -677,8 +683,8 @@ contract MidnightAdapterTest is Test { return res; } - function _obligationId(Obligation memory obligation) internal pure returns (bytes32) { - return keccak256(abi.encode(obligation)); + function _obligationId(Obligation memory obligation) internal view returns (bytes32) { + return IdLib.toId(obligation, block.chainid, address(midnight)); } function sign(Offer[1] memory offers) internal view returns (bytes memory) { diff --git a/test/mocks/VaultV2Mock.sol b/test/mocks/VaultV2Mock.sol index 3ccf11ba6..5fd57bc88 100644 --- a/test/mocks/VaultV2Mock.sol +++ b/test/mocks/VaultV2Mock.sol @@ -62,6 +62,14 @@ contract VaultV2Mock { return (ids, change); } + function forceDeallocateInKind(address adapter, bytes memory data) external returns (bytes32[] memory, int256) { + (bytes32[] memory ids, int256 change) = IAdapter(adapter).deallocate(data, 0, msg.sig, msg.sender); + for (uint256 i; i < ids.length; i++) { + allocation[ids[i]] = uint256(int256(allocation[ids[i]]) + change); + } + return (ids, change); + } + function setTotalAssets(uint256 newTotalAssets) external { totalAssets = newTotalAssets; } From 27673c6230e274dd7246d4b83f12eaca6fbf74dd Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 16 Apr 2026 18:09:33 +0200 Subject: [PATCH 36/95] fix accounting based on net credit --- src/adapters/MidnightAdapter.sol | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index f5638d536..02084f10f 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -222,14 +222,21 @@ contract MidnightAdapter is IMidnightAdapter { bytes32 obligationId = IdLib.toId(offer.obligation, block.chainid, midnight); uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(IMidnight(midnight), obligationId, offer, sellerAssets); - (,, uint256 deallocated) = IMidnight(midnight) + IMidnight(midnight) .take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); require(IMidnight(midnight).debtOf(obligationId, address(this)) == 0, NoBorrowing()); + accrueInterest(); deallocateExpiredDurations(offer.obligation); - removeUnits(offer.obligation, deallocated); - return (ids(offer.obligation), -deallocated.toInt256()); + uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + // new net credit cannot be > old credit + uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + + if (totalNetCreditDecrease > 0) removeUnits(offer.obligation, totalNetCreditDecrease); + + return (ids(offer.obligation), -totalNetCreditDecrease.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. From 1e3ecd7b19afd7c3db408ce0a04e3714abdc2a95 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 16 Apr 2026 18:13:15 +0200 Subject: [PATCH 37/95] simplify durations --- src/adapters/MidnightAdapter.sol | 57 ++++++++------------ src/adapters/interfaces/IMidnightAdapter.sol | 4 +- test/MidnightAdapterAllocationUpdateTest.sol | 6 +-- 3 files changed, 27 insertions(+), 40 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 02084f10f..0f8a04d73 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -109,7 +109,7 @@ contract MidnightAdapter is IMidnightAdapter { IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); accrueInterest(); - deallocateExpiredDurations(obligation); + updateDurationIndexAndAllocations(obligation); uint256 withdrawNetCreditDecrease = withdrawnAssets - pendingFeeDecrease; uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) @@ -127,33 +127,20 @@ contract MidnightAdapter is IMidnightAdapter { .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), withdrawnAssets); } - function deallocateExpiredDurations(Obligation memory obligation) public { + function updateDurationIndexAndAllocations(Obligation memory obligation) public { MaturityData storage maturityData = _maturities[obligation.maturity]; - if (maturityData.lastUpdate > 0) { - uint256 previousTimeToMaturity = obligation.maturity.zeroFloorSub(maturityData.lastUpdate); - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); - - uint256 zeroedDurationsCount = 0; - for (uint256 i = 0; i < durationsLength && previousTimeToMaturity >= packedDurations.get(i); i++) { - if (timeToMaturity < packedDurations.get(i)) zeroedDurationsCount++; - } - - if (zeroedDurationsCount > 0) { - bytes32[] memory zeroedDurationsIds = new bytes32[](zeroedDurationsCount); - uint256 j = 0; - for (uint256 i = 0; i < durationsLength; i++) { - if (previousTimeToMaturity >= packedDurations.get(i) && timeToMaturity < packedDurations.get(i)) { - zeroedDurationsIds[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); - } - } - IVaultV2(parentVault) - .deallocate( - address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))), 0 - ); + uint256 oldDurationIndex = maturityData.durationIndex; + uint256 newDurationIndex = durationIndex(obligation.maturity); + maturityData.durationIndex = uint8(newDurationIndex); + // VaultV2.deallocate requires allocation > 0 for each returned id. + if (newDurationIndex < oldDurationIndex && maturityData.netCredit > 0) { + bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationIndex - newDurationIndex); + for (uint256 i = 0; i < zeroedDurationsIds.length; i++) { + zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationIndex + i))); } + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))), 0); } - - maturityData.lastUpdate = uint48(block.timestamp); } /* ACCRUAL */ @@ -228,7 +215,7 @@ contract MidnightAdapter is IMidnightAdapter { require(IMidnight(midnight).debtOf(obligationId, address(this)) == 0, NoBorrowing()); accrueInterest(); - deallocateExpiredDurations(offer.obligation); + updateDurationIndexAndAllocations(offer.obligation); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); // new net credit cannot be > old credit @@ -285,7 +272,7 @@ contract MidnightAdapter is IMidnightAdapter { require(prevMaturity < obligation.maturity, IncorrectHint()); accrueInterest(); - deallocateExpiredDurations(obligation); + updateDurationIndexAndAllocations(obligation); uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; @@ -356,7 +343,7 @@ contract MidnightAdapter is IMidnightAdapter { require(seller == address(this), NotSelf()); accrueInterest(); - deallocateExpiredDurations(obligation); + updateDurationIndexAndAllocations(obligation); uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) @@ -404,13 +391,13 @@ contract MidnightAdapter is IMidnightAdapter { netCredit[IdLib.toId(obligation, block.chainid, midnight)] -= removedUnits.toUint128(); } - function ids(Obligation memory obligation) public view returns (bytes32[] memory) { - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + function durationIndex(uint256 maturity) internal view returns (uint256 index) { + uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); + while (index < durationsLength && timeToMaturity >= packedDurations.get(index)) index++; + } - uint256 durationsCount = 0; - for (uint256 i = 0; i < durationsLength && timeToMaturity >= packedDurations.get(i); i++) { - durationsCount++; - } + function ids(Obligation memory obligation) public view returns (bytes32[] memory) { + uint256 durationsCount = durationIndex(obligation.maturity); bytes32[] memory idsArray = new bytes32[](1 + obligation.collateralParams.length * 2 + durationsCount); @@ -428,7 +415,7 @@ contract MidnightAdapter is IMidnightAdapter { ) ); } - for (uint256 i = 0; i < durationsLength && timeToMaturity >= packedDurations.get(i); i++) { + for (uint256 i = 0; i < durationsCount; i++) { idsArray[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 4c8075f44..7aa4f7fef 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -13,7 +13,7 @@ struct MaturityData { uint128 netCredit; uint128 growth; uint48 nextMaturity; - uint48 lastUpdate; + uint8 durationIndex; } interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { @@ -59,7 +59,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function skim(address token) external; function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); - function deallocateExpiredDurations(Obligation memory obligation) external; + function updateDurationIndexAndAllocations(Obligation memory obligation) external; function withdrawToVault(Obligation memory obligation, uint256 units) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index 8d2a1365c..549ec98b0 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -132,7 +132,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(timeToMaturity - duration + extraSkip); - adapter.deallocateExpiredDurations(offer.obligation); + adapter.updateDurationIndexAndAllocations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), 0); } @@ -147,9 +147,9 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { Offer memory offer = buy(timeToMaturity, 1e18); skip(skipAmount); - adapter.deallocateExpiredDurations(offer.obligation); + adapter.updateDurationIndexAndAllocations(offer.obligation); uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.deallocateExpiredDurations(offer.obligation); + adapter.updateDurationIndexAndAllocations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } From 909693df08cd7c5051e001637d5326fab693ff19 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 00:25:12 +0200 Subject: [PATCH 38/95] simplify --- src/adapters/MidnightAdapter.sol | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 0f8a04d73..69338fbe4 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -117,14 +117,12 @@ contract MidnightAdapter is IMidnightAdapter { // new net credit cannot be > old credit uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; - if (totalNetCreditDecrease > withdrawNetCreditDecrease) { - removeUnits(obligation, totalNetCreditDecrease - withdrawNetCreditDecrease); + if (totalNetCreditDecrease > 0) { + removeUnits(obligation, totalNetCreditDecrease); } - if (withdrawNetCreditDecrease > 0) removeUnits(obligation, withdrawNetCreditDecrease); - - IVaultV2(parentVault) - .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), withdrawnAssets); + int256 change = -int256(totalNetCreditDecrease); + IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); } function updateDurationIndexAndAllocations(Obligation memory obligation) public { @@ -138,8 +136,8 @@ contract MidnightAdapter is IMidnightAdapter { for (uint256 i = 0; i < zeroedDurationsIds.length; i++) { zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationIndex + i))); } - IVaultV2(parentVault) - .deallocate(address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))), 0); + int256 change = -int256(uint256(maturityData.netCredit)); + IVaultV2(parentVault).deallocate(address(this), abi.encode(zeroedDurationsIds, change), 0); } } @@ -223,7 +221,8 @@ contract MidnightAdapter is IMidnightAdapter { if (totalNetCreditDecrease > 0) removeUnits(offer.obligation, totalNetCreditDecrease); - return (ids(offer.obligation), -totalNetCreditDecrease.toInt256()); + int256 change = -int256(totalNetCreditDecrease); + return (ids(offer.obligation), change); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. @@ -351,13 +350,10 @@ contract MidnightAdapter is IMidnightAdapter { // new net credit cannot be > old credit uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; - // The sell itself removes exactly `netCreditDecrease` of net credit; any excess is a concurrent loss. - if (totalNetCreditDecrease > sellNetCreditDecrease) { - removeUnits(obligation, totalNetCreditDecrease - sellNetCreditDecrease); + if (totalNetCreditDecrease > 0) { + removeUnits(obligation, totalNetCreditDecrease); } - if (sellNetCreditDecrease > 0) removeUnits(obligation, sellNetCreditDecrease); - uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); for (uint256 i = 0; i < adaptersLength; i++) { @@ -365,8 +361,8 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); - IVaultV2(parentVault) - .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), sellerAssets); + int256 change = -int256(totalNetCreditDecrease); + IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); return CALLBACK_SUCCESS; } From ccf4d655df70d785722f86ec39c29fa55b5cdc8d Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 00:48:47 +0200 Subject: [PATCH 39/95] simplify --- src/adapters/MidnightAdapter.sol | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 69338fbe4..050b7dd4c 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -117,9 +117,7 @@ contract MidnightAdapter is IMidnightAdapter { // new net credit cannot be > old credit uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; - if (totalNetCreditDecrease > 0) { - removeUnits(obligation, totalNetCreditDecrease); - } + if (totalNetCreditDecrease > 0) removeUnits(obligation, totalNetCreditDecrease); int256 change = -int256(totalNetCreditDecrease); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); @@ -282,9 +280,12 @@ contract MidnightAdapter is IMidnightAdapter { int256 change = newNetCredit.toInt256() - netCredit[obligationId].toInt256(); // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { - removeUnits(obligation, (buyNetCreditIncrease.toInt256() - change).toUint256()); + uint256 loss = (int256(buyNetCreditIncrease) - change).toUint256(); + removeUnits(obligation, loss); } + IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); + if (timeToMaturity > 0) { uint128 gainedGrowth = ((buyNetCreditIncrease - paidAssets) / timeToMaturity).toUint128(); _totalAssets += paidAssets + (buyNetCreditIncrease - paidAssets) % timeToMaturity; @@ -322,8 +323,6 @@ contract MidnightAdapter is IMidnightAdapter { } } - IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); - return CALLBACK_SUCCESS; } @@ -350,9 +349,10 @@ contract MidnightAdapter is IMidnightAdapter { // new net credit cannot be > old credit uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; - if (totalNetCreditDecrease > 0) { - removeUnits(obligation, totalNetCreditDecrease); - } + if (totalNetCreditDecrease > 0) removeUnits(obligation, totalNetCreditDecrease); + + int256 change = -int256(totalNetCreditDecrease); + IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -361,9 +361,6 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); - int256 change = -int256(totalNetCreditDecrease); - IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); - return CALLBACK_SUCCESS; } From fde73367e521edaa3ba69c028503e092d4250091 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 01:06:19 +0200 Subject: [PATCH 40/95] reorder eliminate dead code --- src/adapters/MidnightAdapter.sol | 51 ++++++++++++++++---------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 050b7dd4c..7c4b895c9 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -107,17 +107,15 @@ contract MidnightAdapter is IMidnightAdapter { bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); uint256 pendingFeeDecrease = IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); - - accrueInterest(); - updateDurationIndexAndAllocations(obligation); - - uint256 withdrawNetCreditDecrease = withdrawnAssets - pendingFeeDecrease; uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); // new net credit cannot be > old credit uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; - if (totalNetCreditDecrease > 0) removeUnits(obligation, totalNetCreditDecrease); + accrueInterest(); + updateDurationIndexAndAllocations(obligation); + + if (totalNetCreditDecrease > 0) removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); int256 change = -int256(totalNetCreditDecrease); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); @@ -217,7 +215,9 @@ contract MidnightAdapter is IMidnightAdapter { // new net credit cannot be > old credit uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; - if (totalNetCreditDecrease > 0) removeUnits(offer.obligation, totalNetCreditDecrease); + if (totalNetCreditDecrease > 0) { + removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease); + } int256 change = -int256(totalNetCreditDecrease); return (ids(offer.obligation), change); @@ -264,24 +264,25 @@ contract MidnightAdapter is IMidnightAdapter { ) external returns (bytes32) { uint48 prevMaturity = abi.decode(data, (uint48)); MaturityData storage maturityData = _maturities[obligation.maturity]; + uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; + uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); require(prevMaturity < obligation.maturity, IncorrectHint()); - - accrueInterest(); - updateDurationIndexAndAllocations(obligation); - - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); - uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); int256 change = newNetCredit.toInt256() - netCredit[obligationId].toInt256(); + + accrueInterest(); + updateDurationIndexAndAllocations(obligation); + // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { uint256 loss = (int256(buyNetCreditIncrease) - change).toUint256(); - removeUnits(obligation, loss); + removeUnits(obligationId, obligation.maturity, loss); } IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); @@ -336,6 +337,10 @@ contract MidnightAdapter is IMidnightAdapter { bytes memory ) external returns (bytes32) { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); + uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + // new net credit cannot be > old credit + uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; require(msg.sender == midnight, NotMidnight()); require(seller == address(this), NotSelf()); @@ -343,13 +348,7 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationIndexAndAllocations(obligation); - uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; - uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - - IMidnight(midnight).pendingFee(obligationId, address(this)); - // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; - - if (totalNetCreditDecrease > 0) removeUnits(obligation, totalNetCreditDecrease); + if (totalNetCreditDecrease > 0) removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); int256 change = -int256(totalNetCreditDecrease); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); @@ -368,11 +367,11 @@ contract MidnightAdapter is IMidnightAdapter { /// @dev Removes units from tracking. /// @dev Changes the implied price of the obligation as little as possible. - function removeUnits(Obligation memory obligation, uint256 removedUnits) internal { - MaturityData storage maturityData = _maturities[obligation.maturity]; + function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits) internal { + MaturityData storage maturityData = _maturities[maturity]; - if (obligation.maturity > block.timestamp) { - uint256 timeToMaturity = obligation.maturity - block.timestamp; + if (maturity > block.timestamp) { + uint256 timeToMaturity = maturity - block.timestamp; uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.netCredit).toUint128(); maturityData.growth -= removedGrowth; currentGrowth -= removedGrowth; @@ -381,7 +380,7 @@ contract MidnightAdapter is IMidnightAdapter { _totalAssets -= removedUnits; } maturityData.netCredit -= removedUnits.toUint128(); - netCredit[IdLib.toId(obligation, block.chainid, midnight)] -= removedUnits.toUint128(); + netCredit[obligationId] -= removedUnits.toUint128(); } function durationIndex(uint256 maturity) internal view returns (uint256 index) { From fdfb07910c31acbefb3736b96bca51d9fe072e25 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 02:27:08 +0200 Subject: [PATCH 41/95] reject stale pointer hint --- src/adapters/MidnightAdapter.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 7c4b895c9..0f651ea09 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -305,6 +305,7 @@ contract MidnightAdapter is IMidnightAdapter { if (prevMaturity == 0) { nextMaturity = firstMaturity; } else { + require(prevMaturity >= block.timestamp, IncorrectHint()); nextMaturity = _maturities[prevMaturity].nextMaturity; require(nextMaturity > 0, IncorrectHint()); } From a31b7bce9d44e0bf6847456dd59d8a725a6e81ea Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 02:27:08 +0200 Subject: [PATCH 42/95] reject stale pointer hint --- src/adapters/MidnightAdapter.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 7c4b895c9..0f651ea09 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -305,6 +305,7 @@ contract MidnightAdapter is IMidnightAdapter { if (prevMaturity == 0) { nextMaturity = firstMaturity; } else { + require(prevMaturity >= block.timestamp, IncorrectHint()); nextMaturity = _maturities[prevMaturity].nextMaturity; require(nextMaturity > 0, IncorrectHint()); } From b3984f363b8b0abaac19d2cede89875fff384f40 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 02:44:52 +0200 Subject: [PATCH 43/95] reuse storage pointer --- src/adapters/MidnightAdapter.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 0f651ea09..54ae579be 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -316,7 +316,7 @@ contract MidnightAdapter is IMidnightAdapter { } if (nextMaturity > obligation.maturity) { - _maturities[obligation.maturity].nextMaturity = nextMaturity; + maturityData.nextMaturity = nextMaturity; if (prevMaturity == 0) { firstMaturity = obligation.maturity.toUint48(); } else { From 208962c7e237f961fc09620d49d4f8dfa77b4558 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 02:48:01 +0200 Subject: [PATCH 44/95] remove unused vars --- src/adapters/MidnightAdapter.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 54ae579be..68ca4ad8a 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -105,8 +105,7 @@ contract MidnightAdapter is IMidnightAdapter { function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); - uint256 pendingFeeDecrease = - IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); + IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); // new net credit cannot be > old credit @@ -333,8 +332,8 @@ contract MidnightAdapter is IMidnightAdapter { Obligation memory obligation, address seller, uint256 sellerAssets, - uint256 units, - uint256 sellPendingFeeDecrease, + uint256, + uint256, bytes memory ) external returns (bytes32) { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); From 7cec5c9f9ba88f4d64e3c1c10637f21d07e06a72 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 02:50:59 +0200 Subject: [PATCH 45/95] docs --- src/adapters/MidnightAdapter.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 68ca4ad8a..d479a4399 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -252,6 +252,9 @@ contract MidnightAdapter is IMidnightAdapter { return CALLBACK_SUCCESS; } + /// @dev `data` is used for new maturity insertions. + /// @dev It should encode a maturity present in the linked list. + /// @dev That maturity should be earlier than the inserted obligation maturity. function onBuy( bytes32 obligationId, Obligation memory obligation, From 189cc77534ee392fa98d9b9f8ae4739f2cb0848b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 03:05:37 +0200 Subject: [PATCH 46/95] doubly linked list and max active maturities --- src/adapters/MidnightAdapter.sol | 24 ++++++++++++++++++++ src/adapters/interfaces/IMidnightAdapter.sol | 7 +++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index d479a4399..93710e3bc 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -44,6 +44,7 @@ contract MidnightAdapter is IMidnightAdapter { uint48 public lastUpdate; uint48 public firstMaturity; uint128 public currentGrowth; + uint256 public activableMaturities = 50; mapping(uint256 timestamp => MaturityData) public _maturities; mapping(bytes32 obligationId => uint256) public netCredit; /* CONSTRUCTOR */ @@ -287,6 +288,8 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(obligationId, obligation.maturity, loss); } + if (maturityData.netCredit == 0 && buyNetCreditIncrease > 0) activableMaturities--; + IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); if (timeToMaturity > 0) { @@ -318,12 +321,16 @@ contract MidnightAdapter is IMidnightAdapter { } if (nextMaturity > obligation.maturity) { + maturityData.prevMaturity = prevMaturity; maturityData.nextMaturity = nextMaturity; if (prevMaturity == 0) { firstMaturity = obligation.maturity.toUint48(); } else { _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); } + if (nextMaturity < type(uint48).max) { + _maturities[nextMaturity].prevMaturity = obligation.maturity.toUint48(); + } } } @@ -384,6 +391,23 @@ contract MidnightAdapter is IMidnightAdapter { } maturityData.netCredit -= removedUnits.toUint128(); netCredit[obligationId] -= removedUnits.toUint128(); + + if (removedUnits > 0 && maturityData.netCredit == 0) { + activableMaturities++; + if (maturity > block.timestamp) { + uint48 prevMaturity = maturityData.prevMaturity; + uint48 nextMaturity = maturityData.nextMaturity; + if (maturity == firstMaturity) { + firstMaturity = nextMaturity; + } else { + _maturities[prevMaturity].nextMaturity = nextMaturity; + } + if (nextMaturity < type(uint48).max) { + _maturities[nextMaturity].prevMaturity = prevMaturity; + } + maturityData.nextMaturity = 0; + } + } } function durationIndex(uint256 maturity) internal view returns (uint256 index) { diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 7aa4f7fef..1e08b26bd 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -8,10 +8,12 @@ import {ICallbacks} from "lib/midnight/src/interfaces/ICallbacks.sol"; import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple obligations. -// nextMaturity is type(uint48).max if no next maturity +// prevMaturity is 0 if no previous maturity. +// nextMaturity is type(uint48).max if no next maturity. struct MaturityData { uint128 netCredit; uint128 growth; + uint48 prevMaturity; uint48 nextMaturity; uint8 durationIndex; } @@ -41,14 +43,17 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { error NotMidnight(); error NotSelf(); error SelfAllocationOnly(); + error TooManyActiveMaturities(); /* FUNCTIONS */ + function MAX_ACTIVE_MATURITIES() external view returns (uint256); function asset() external view returns (address); 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 activeMaturities() external view returns (uint256); function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); From 322d29c3316986f5818e15091a3812706b691163 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 03:09:29 +0200 Subject: [PATCH 47/95] fixes --- src/adapters/interfaces/IMidnightAdapter.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 1e08b26bd..ad85c21e9 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -8,7 +8,6 @@ import {ICallbacks} from "lib/midnight/src/interfaces/ICallbacks.sol"; import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple obligations. -// prevMaturity is 0 if no previous maturity. // nextMaturity is type(uint48).max if no next maturity. struct MaturityData { uint128 netCredit; @@ -43,11 +42,9 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { error NotMidnight(); error NotSelf(); error SelfAllocationOnly(); - error TooManyActiveMaturities(); /* FUNCTIONS */ - function MAX_ACTIVE_MATURITIES() external view returns (uint256); function asset() external view returns (address); function _totalAssets() external view returns (uint256); function lastUpdate() external view returns (uint48); From 20cb1095edd06545b40eb39467f9fa6f9eb2165a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 23:50:06 +0200 Subject: [PATCH 48/95] prevent stale hints without resetting next pointer --- src/adapters/MidnightAdapter.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 93710e3bc..5769a2a70 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -310,9 +310,8 @@ contract MidnightAdapter is IMidnightAdapter { if (prevMaturity == 0) { nextMaturity = firstMaturity; } else { - require(prevMaturity >= block.timestamp, IncorrectHint()); + require(prevMaturity >= firstMaturity && _maturities[prevMaturity].netCredit > 0, IncorrectHint()); nextMaturity = _maturities[prevMaturity].nextMaturity; - require(nextMaturity > 0, IncorrectHint()); } while (nextMaturity < obligation.maturity) { @@ -405,7 +404,6 @@ contract MidnightAdapter is IMidnightAdapter { if (nextMaturity < type(uint48).max) { _maturities[nextMaturity].prevMaturity = prevMaturity; } - maturityData.nextMaturity = 0; } } } From d29e0ab88a414ee5bb73a8805dc86c983e826173 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 20 Apr 2026 15:52:51 +0200 Subject: [PATCH 49/95] fixes and renames --- src/adapters/MidnightAdapter.sol | 28 ++++++++++---------- src/adapters/interfaces/IMidnightAdapter.sol | 6 ++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 5769a2a70..3d1d89349 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -113,7 +113,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; accrueInterest(); - updateDurationIndexAndAllocations(obligation); + updateDurationCountAndAllocations(obligation); if (totalNetCreditDecrease > 0) removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); @@ -121,16 +121,16 @@ contract MidnightAdapter is IMidnightAdapter { IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); } - function updateDurationIndexAndAllocations(Obligation memory obligation) public { + function updateDurationCountAndAllocations(Obligation memory obligation) public { MaturityData storage maturityData = _maturities[obligation.maturity]; - uint256 oldDurationIndex = maturityData.durationIndex; - uint256 newDurationIndex = durationIndex(obligation.maturity); - maturityData.durationIndex = uint8(newDurationIndex); + uint256 oldDurationCount = maturityData.durationCount; + uint256 newDurationCount = durationCount(obligation.maturity); + maturityData.durationCount = uint8(newDurationCount); // VaultV2.deallocate requires allocation > 0 for each returned id. - if (newDurationIndex < oldDurationIndex && maturityData.netCredit > 0) { - bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationIndex - newDurationIndex); + if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { + bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationCount - newDurationCount); for (uint256 i = 0; i < zeroedDurationsIds.length; i++) { - zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationIndex + i))); + zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationCount + i))); } int256 change = -int256(uint256(maturityData.netCredit)); IVaultV2(parentVault).deallocate(address(this), abi.encode(zeroedDurationsIds, change), 0); @@ -209,7 +209,7 @@ contract MidnightAdapter is IMidnightAdapter { require(IMidnight(midnight).debtOf(obligationId, address(this)) == 0, NoBorrowing()); accrueInterest(); - updateDurationIndexAndAllocations(offer.obligation); + updateDurationCountAndAllocations(offer.obligation); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); // new net credit cannot be > old credit @@ -280,7 +280,7 @@ contract MidnightAdapter is IMidnightAdapter { int256 change = newNetCredit.toInt256() - netCredit[obligationId].toInt256(); accrueInterest(); - updateDurationIndexAndAllocations(obligation); + updateDurationCountAndAllocations(obligation); // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { @@ -355,7 +355,7 @@ contract MidnightAdapter is IMidnightAdapter { require(seller == address(this), NotSelf()); accrueInterest(); - updateDurationIndexAndAllocations(obligation); + updateDurationCountAndAllocations(obligation); if (totalNetCreditDecrease > 0) removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); @@ -408,13 +408,13 @@ contract MidnightAdapter is IMidnightAdapter { } } - function durationIndex(uint256 maturity) internal view returns (uint256 index) { + function durationCount(uint256 maturity) internal view returns (uint256 count) { uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); - while (index < durationsLength && timeToMaturity >= packedDurations.get(index)) index++; + while (count < durationsLength && timeToMaturity >= packedDurations.get(count)) count++; } function ids(Obligation memory obligation) public view returns (bytes32[] memory) { - uint256 durationsCount = durationIndex(obligation.maturity); + uint256 durationsCount = durationCount(obligation.maturity); bytes32[] memory idsArray = new bytes32[](1 + obligation.collateralParams.length * 2 + durationsCount); diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index ad85c21e9..60ae57661 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -14,7 +14,7 @@ struct MaturityData { uint128 growth; uint48 prevMaturity; uint48 nextMaturity; - uint8 durationIndex; + uint8 durationCount; } interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { @@ -50,7 +50,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function lastUpdate() external view returns (uint48); function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); - function activeMaturities() external view returns (uint256); + function activableMaturities() external view returns (uint256); function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); @@ -61,7 +61,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function skim(address token) external; function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); - function updateDurationIndexAndAllocations(Obligation memory obligation) external; + function updateDurationCountAndAllocations(Obligation memory obligation) external; function withdrawToVault(Obligation memory obligation, uint256 units) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); From 2b761c61c42f0fa5b0a5945ef8f8d86ffb93d99e Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 5 May 2026 12:10:41 +0200 Subject: [PATCH 50/95] singly linked list --- src/adapters/MidnightAdapter.sol | 50 ++++++++++++-------- src/adapters/interfaces/IMidnightAdapter.sol | 3 +- test/MidnightAdapterAllocationUpdateTest.sol | 44 +++++++++++++---- test/MidnightAdapterTest.sol | 9 ++++ 4 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 3d1d89349..2ed51f974 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -103,7 +103,7 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external { + function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets, uint48 prevMaturity) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); @@ -115,7 +115,9 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationCountAndAllocations(obligation); - if (totalNetCreditDecrease > 0) removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); + if (totalNetCreditDecrease > 0) { + removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease, prevMaturity); + } int256 change = -int256(totalNetCreditDecrease); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); @@ -195,8 +197,8 @@ contract MidnightAdapter is IMidnightAdapter { { require(msg.sender == parentVault, NotAuthorized()); if (messageSig == IVaultV2.forceDeallocate.selector) { - (Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof) = - abi.decode(data, (Offer, bytes, bytes32, bytes32[])); + (Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof, uint48 prevMaturity) = + abi.decode(data, (Offer, bytes, bytes32, bytes32[], uint48)); require(offer.buy && offer.obligation.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); // Already in a deallocate call so we skip the onSell callback and return the deallocation here. @@ -216,7 +218,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease); + removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease, prevMaturity); } int256 change = -int256(totalNetCreditDecrease); @@ -285,7 +287,7 @@ contract MidnightAdapter is IMidnightAdapter { // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { uint256 loss = (int256(buyNetCreditIncrease) - change).toUint256(); - removeUnits(obligationId, obligation.maturity, loss); + removeUnits(obligationId, obligation.maturity, loss, prevMaturity); } if (maturityData.netCredit == 0 && buyNetCreditIncrease > 0) activableMaturities--; @@ -320,16 +322,12 @@ contract MidnightAdapter is IMidnightAdapter { } if (nextMaturity > obligation.maturity) { - maturityData.prevMaturity = prevMaturity; maturityData.nextMaturity = nextMaturity; if (prevMaturity == 0) { firstMaturity = obligation.maturity.toUint48(); } else { _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); } - if (nextMaturity < type(uint48).max) { - _maturities[nextMaturity].prevMaturity = obligation.maturity.toUint48(); - } } } @@ -343,8 +341,9 @@ contract MidnightAdapter is IMidnightAdapter { uint256 sellerAssets, uint256, uint256, - bytes memory + bytes memory data ) external returns (bytes32) { + uint48 prevMaturity = abi.decode(data, (uint48)); uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); @@ -357,7 +356,9 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationCountAndAllocations(obligation); - if (totalNetCreditDecrease > 0) removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); + if (totalNetCreditDecrease > 0) { + removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease, prevMaturity); + } int256 change = -int256(totalNetCreditDecrease); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); @@ -376,7 +377,7 @@ contract MidnightAdapter is IMidnightAdapter { /// @dev Removes units from tracking. /// @dev Changes the implied price of the obligation as little as possible. - function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits) internal { + function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits, uint48 prevMaturity) internal { MaturityData storage maturityData = _maturities[maturity]; if (maturity > block.timestamp) { @@ -394,15 +395,24 @@ contract MidnightAdapter is IMidnightAdapter { if (removedUnits > 0 && maturityData.netCredit == 0) { activableMaturities++; if (maturity > block.timestamp) { - uint48 prevMaturity = maturityData.prevMaturity; - uint48 nextMaturity = maturityData.nextMaturity; - if (maturity == firstMaturity) { - firstMaturity = nextMaturity; + uint48 nextMaturity; + if (prevMaturity == 0) { + nextMaturity = firstMaturity; } else { - _maturities[prevMaturity].nextMaturity = nextMaturity; + require(prevMaturity >= firstMaturity && _maturities[prevMaturity].netCredit > 0, IncorrectHint()); + nextMaturity = _maturities[prevMaturity].nextMaturity; } - if (nextMaturity < type(uint48).max) { - _maturities[nextMaturity].prevMaturity = prevMaturity; + + while (nextMaturity < maturity) { + prevMaturity = nextMaturity; + nextMaturity = _maturities[prevMaturity].nextMaturity; + } + + require(nextMaturity == maturity, IncorrectHint()); + if (prevMaturity == 0) { + firstMaturity = maturityData.nextMaturity; + } else { + _maturities[prevMaturity].nextMaturity = maturityData.nextMaturity; } } } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 60ae57661..ebca01d94 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -12,7 +12,6 @@ import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; struct MaturityData { uint128 netCredit; uint128 growth; - uint48 prevMaturity; uint48 nextMaturity; uint8 durationCount; } @@ -62,7 +61,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); function updateDurationCountAndAllocations(Obligation memory obligation) external; - function withdrawToVault(Obligation memory obligation, uint256 units) external; + function withdrawToVault(Obligation memory obligation, uint256 units, uint48 prevMaturity) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index 549ec98b0..27d589676 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -42,12 +42,13 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.obligation.maturity = block.timestamp + duration; offer.buy = true; offer.tick = MAX_TICK; + offer.group = bytes32(duration); uint256 price = TickLib.tickToPrice(MAX_TICK); uint256 units = assets * 1e18 / price; offer.maxUnits = units; offer.expiry = block.timestamp; offer.callback = address(adapter); - offer.callbackData = abi.encode(0); + offer.callbackData = abi.encode(prevMaturity(offer.obligation.maturity)); vm.startPrank(taker); midnight.supplyCollateral(offer.obligation, 0, assets / 2, taker); @@ -59,7 +60,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { return offer; } - function sell(Obligation memory obligation, uint256 assets) internal { + function sell(Obligation memory obligation, uint256 assets, uint48 prevMaturity) internal { Offer memory offer = storedOffer; offer.obligation = obligation; @@ -73,7 +74,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.callback = address(adapter); offer.receiverIfMakerIsSeller = address(adapter); offer.group = bytes32(vm.randomUint()); - offer.callbackData = abi.encode(0); + offer.callbackData = abi.encode(prevMaturity); vm.prank(taker); midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) @@ -106,7 +107,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { approvalRatifier.setApproval(buyer, _root, true); vm.stopPrank(); - bytes memory data = abi.encode(offer, hex"", _root, proof([offer])); + bytes memory data = abi.encode(offer, hex"", _root, proof([offer]), uint48(0)); parentVault.forceDeallocate(address(adapter), data, assets, address(this)); } @@ -132,7 +133,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(timeToMaturity - duration + extraSkip); - adapter.updateDurationIndexAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), 0); } @@ -147,9 +148,9 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { Offer memory offer = buy(timeToMaturity, 1e18); skip(skipAmount); - adapter.updateDurationIndexAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.obligation); uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.updateDurationIndexAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } @@ -163,7 +164,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { vm.prank(taker); midnight.repay(offer.obligation, 1e18, taker, ""); vm.prank(signerAllocator); - adapter.withdrawToVault(offer.obligation, 0.5e18); + adapter.withdrawToVault(offer.obligation, 0.5e18, 0); assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); @@ -181,12 +182,37 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { address[] memory adapters = new address[](1); adapters[0] = address(adapter); parentVault.setAdapters(adapters); - sell(offer.obligation, 0.5e18); + sell(offer.obligation, 0.5e18, 0); assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } + function testSellClearsMaturityAndReactivatesSlot() public { + Offer memory firstOffer; + Offer memory secondOffer; + for (uint256 i = 0; i < 50; i++) { + Offer memory offer = buy(1 days + i, 1e18); + if (i == 0) firstOffer = offer; + if (i == 1) secondOffer = offer; + } + assertEq(adapter.activableMaturities(), 0, "activableMaturities before"); + + parentVault.setTotalAssets(1e18); + parentVault.setAdaptersLength(1); + address[] memory adapters = new address[](1); + adapters[0] = address(adapter); + parentVault.setAdapters(adapters); + sell(secondOffer.obligation, 1e18, 0); + + assertEq(adapter.activableMaturities(), 1, "activableMaturities after"); + assertEq(adapter.firstMaturity(), firstOffer.obligation.maturity, "firstMaturity after"); + + buy(60 days, 1e18); + + assertEq(adapter.activableMaturities(), 0, "activableMaturities final"); + } + function testUpdateOnForceDeallocate() public { Offer memory offer = buy(7 days, 1e18); assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 4deb4eba9..839c724d2 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -447,6 +447,7 @@ contract MidnightAdapterTest is Test { uint256 approxInterest = step.approxGrowth * timeToMaturity; offer.group = bytes32(i); offer.obligation.maturity = step.maturity; + offer.callbackData = abi.encode(prevMaturity(step.maturity)); // Compute tick from desired price: price = assets / (assets + approxInterest) uint256 desiredPrice = step.assets.mulDivDown(1e18, step.assets + approxInterest); @@ -687,6 +688,14 @@ contract MidnightAdapterTest is Test { return IdLib.toId(obligation, block.chainid, address(midnight)); } + function prevMaturity(uint256 maturity) internal view returns (uint48 prev) { + uint48 nextMaturity = adapter.firstMaturity(); + while (nextMaturity < maturity) { + prev = nextMaturity; + nextMaturity = adapter.maturities(prev).nextMaturity; + } + } + function sign(Offer[1] memory offers) internal view returns (bytes memory) { return ratifierData(root(offers), offers[0].maker); } From d414c15fff8381e8d4415f06eda4e8cb9456712a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 5 May 2026 15:05:36 +0200 Subject: [PATCH 51/95] Update midnight submodule --- lib/midnight | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/midnight b/lib/midnight index 7385a905d..beac3761a 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit 7385a905d087367689fbdd16eef04e68b5146dc2 +Subproject commit beac3761ab37644386ea4e4d9e639aa916a2dd59 From 30d34240de803bbe53aed9132d20c30c30acb1f6 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 7 May 2026 11:12:46 +0200 Subject: [PATCH 52/95] WIP --- foundry.lock | 2 +- src/adapters/MidnightAdapter.sol | 160 +++++++++---------- src/adapters/interfaces/IMidnightAdapter.sol | 43 +++-- test/MidnightAdapterAllocationUpdateTest.sol | 46 +++++- test/MidnightAdapterTest.sol | 21 ++- 5 files changed, 153 insertions(+), 119 deletions(-) diff --git a/foundry.lock b/foundry.lock index 6ba231271..e8d159f81 100644 --- a/foundry.lock +++ b/foundry.lock @@ -9,7 +9,7 @@ "rev": "2d160ba9bb945ca3bf12efb182427445dce59c27" }, "lib/midnight": { - "rev": "7385a905d087367689fbdd16eef04e68b5146dc2" + "rev": "beac3761ab37644386ea4e4d9e639aa916a2dd59" }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 2ed51f974..f4e71642d 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -4,10 +4,11 @@ pragma solidity 0.8.34; import {IMidnight, Offer, Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; -import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "lib/midnight/src/interfaces/IEcrecover.sol"; +import {Signature, EIP712_DOMAIN_TYPEHASH} from "lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; +import {UtilsLib} from "lib/midnight/src/libraries/UtilsLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; @@ -42,9 +43,10 @@ contract MidnightAdapter is IMidnightAdapter { uint256 public _totalAssets; uint48 public lastUpdate; - uint48 public firstMaturity; uint128 public currentGrowth; - uint256 public activableMaturities = 50; + /// @dev Maximum steps of an accrual. + /// @dev A maturity uses an availability slot iff it has some units and is >= now after accrual. + uint256 public availableMaturities = 50; mapping(uint256 timestamp => MaturityData) public _maturities; mapping(bytes32 obligationId => uint256) public netCredit; /* CONSTRUCTOR */ @@ -56,7 +58,7 @@ contract MidnightAdapter is IMidnightAdapter { lastUpdate = uint48(block.timestamp); SafeERC20Lib.safeApprove(asset, _midnight, type(uint256).max); SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); - firstMaturity = type(uint48).max; + _maturities[0].nextMaturity = type(uint48).max; adapterId = keccak256(abi.encode("this", address(this))); bytes32 _packedDurations; @@ -72,6 +74,10 @@ contract MidnightAdapter is IMidnightAdapter { /* GETTERS */ + function firstMaturity() public view returns (uint48) { + return _maturities[0].nextMaturity; + } + function maturities(uint256 date) public view returns (MaturityData memory) { return _maturities[date]; } @@ -103,7 +109,7 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets, uint48 prevMaturity) external { + function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets, uint48 prevMaturityHint) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); @@ -116,11 +122,12 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationCountAndAllocations(obligation); if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease, prevMaturity); + removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease, prevMaturityHint); } int256 change = -int256(totalNetCreditDecrease); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); + emit WithdrawToVault(obligationId, withdrawnAssets, totalNetCreditDecrease); } function updateDurationCountAndAllocations(Obligation memory obligation) public { @@ -128,6 +135,9 @@ contract MidnightAdapter is IMidnightAdapter { uint256 oldDurationCount = maturityData.durationCount; uint256 newDurationCount = durationCount(obligation.maturity); maturityData.durationCount = uint8(newDurationCount); + emit UpdateDurationCountAndAllocations( + obligation.maturity, oldDurationCount, newDurationCount, maturityData.netCredit + ); // VaultV2.deallocate requires allocation > 0 for each returned id. if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationCount - newDurationCount); @@ -141,10 +151,11 @@ contract MidnightAdapter is IMidnightAdapter { /* ACCRUAL */ - function accrueInterestView() public view returns (uint48, uint128, uint256) { + function accrueInterestView() public view returns (uint48, uint128, uint256, uint256) { uint256 lastChange = lastUpdate; - uint48 nextMaturity = firstMaturity; + uint48 nextMaturity = _maturities[0].nextMaturity; uint128 newGrowth = currentGrowth; + uint256 newAvailableMaturities = availableMaturities; uint256 gainedAssets; while (nextMaturity < block.timestamp) { @@ -152,25 +163,27 @@ contract MidnightAdapter is IMidnightAdapter { newGrowth -= _maturities[nextMaturity].growth; lastChange = nextMaturity; nextMaturity = _maturities[nextMaturity].nextMaturity; + newAvailableMaturities++; } gainedAssets += uint256(newGrowth) * (block.timestamp - lastChange); - return (nextMaturity, newGrowth, _totalAssets + gainedAssets); + return (nextMaturity, newGrowth, _totalAssets + gainedAssets, newAvailableMaturities); } function accrueInterest() public returns (uint48, uint128, uint256) { if (lastUpdate != block.timestamp) { - (firstMaturity, currentGrowth, _totalAssets) = accrueInterestView(); + (_maturities[0].nextMaturity, currentGrowth, _totalAssets, availableMaturities) = accrueInterestView(); lastUpdate = uint48(block.timestamp); + emit AccrueInterest(_maturities[0].nextMaturity, currentGrowth, _totalAssets, availableMaturities); } - return (firstMaturity, currentGrowth, _totalAssets); + return (_maturities[0].nextMaturity, currentGrowth, _totalAssets); } /// @dev Returns an estimate of the real assets assigned to the adapter. /// @dev Excludes assets reserved for users. function realAssets() external view returns (uint256) { - (,, uint256 newTotalAssets) = accrueInterestView(); + (,, uint256 newTotalAssets,) = accrueInterestView(); return newTotalAssets; } @@ -197,14 +210,18 @@ contract MidnightAdapter is IMidnightAdapter { { require(msg.sender == parentVault, NotAuthorized()); if (messageSig == IVaultV2.forceDeallocate.selector) { - (Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof, uint48 prevMaturity) = - abi.decode(data, (Offer, bytes, bytes32, bytes32[], uint48)); + ( + Offer memory offer, + bytes memory ratifierData, + bytes32 root, + bytes32[] memory proof, + uint48 prevMaturityHint + ) = abi.decode(data, (Offer, bytes, bytes32, bytes32[], uint48)); require(offer.buy && offer.obligation.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); // Already in a deallocate call so we skip the onSell callback and return the deallocation here. bytes32 obligationId = IdLib.toId(offer.obligation, block.chainid, midnight); - uint256 takeUnits = - TakeAmountsLib.sellerAssetsToUnits(IMidnight(midnight), obligationId, offer, sellerAssets); + uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, obligationId, offer, sellerAssets); IMidnight(midnight) .take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); @@ -218,10 +235,11 @@ contract MidnightAdapter is IMidnightAdapter { uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease, prevMaturity); + removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease, prevMaturityHint); } int256 change = -int256(totalNetCreditDecrease); + emit ForceDeallocate(obligationId, sellerAssets, totalNetCreditDecrease); return (ids(offer.obligation), change); } else { require(caller == address(this), SelfAllocationOnly()); @@ -244,8 +262,8 @@ contract MidnightAdapter is IMidnightAdapter { require(offer.obligation.maturity < type(uint48).max, IncorrectMaturity()); require(offer.buy || offer.reduceOnly, NoDebtCreation()); - Signature memory sig = abi.decode(data, (Signature)); - bytes32 structHash = keccak256(abi.encode(ROOT_TYPEHASH, root)); + (Signature memory sig, uint256 height) = abi.decode(data, (Signature, uint256)); + bytes32 structHash = keccak256(abi.encode(UtilsLib.offerTreeTypeHash(height), root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); address signer = ecrecover(digest, sig.v, sig.r, sig.s); @@ -255,9 +273,8 @@ contract MidnightAdapter is IMidnightAdapter { return CALLBACK_SUCCESS; } - /// @dev `data` is used for new maturity insertions. - /// @dev It should encode a maturity present in the linked list. - /// @dev That maturity should be earlier than the inserted obligation maturity. + /// @dev `data` encodes a hint, it should be zero or a maturity present in the linked list before the obligation + /// maturity. function onBuy( bytes32 obligationId, Obligation memory obligation, @@ -267,14 +284,13 @@ contract MidnightAdapter is IMidnightAdapter { uint256 buyPendingFeeIncrease, bytes memory data ) external returns (bytes32) { - uint48 prevMaturity = abi.decode(data, (uint48)); + uint48 prevMaturityHint = abi.decode(data, (uint48)); MaturityData storage maturityData = _maturities[obligation.maturity]; uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); - require(prevMaturity < obligation.maturity, IncorrectHint()); require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) @@ -287,11 +303,9 @@ contract MidnightAdapter is IMidnightAdapter { // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { uint256 loss = (int256(buyNetCreditIncrease) - change).toUint256(); - removeUnits(obligationId, obligation.maturity, loss, prevMaturity); + removeUnits(obligationId, obligation.maturity, loss, prevMaturityHint); } - if (maturityData.netCredit == 0 && buyNetCreditIncrease > 0) activableMaturities--; - IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); if (timeToMaturity > 0) { @@ -307,30 +321,18 @@ contract MidnightAdapter is IMidnightAdapter { netCredit[obligationId] += buyNetCreditIncrease.toUint128(); // Insert the maturity in the list if needed - if (obligation.maturity >= block.timestamp) { - uint48 nextMaturity; - if (prevMaturity == 0) { - nextMaturity = firstMaturity; - } else { - require(prevMaturity >= firstMaturity && _maturities[prevMaturity].netCredit > 0, IncorrectHint()); - nextMaturity = _maturities[prevMaturity].nextMaturity; - } - - while (nextMaturity < obligation.maturity) { - prevMaturity = nextMaturity; - nextMaturity = _maturities[prevMaturity].nextMaturity; - } - - if (nextMaturity > obligation.maturity) { - maturityData.nextMaturity = nextMaturity; - if (prevMaturity == 0) { - firstMaturity = obligation.maturity.toUint48(); - } else { - _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); - } - } + if ( + maturityData.netCredit == buyNetCreditIncrease && buyNetCreditIncrease > 0 + && obligation.maturity >= block.timestamp + ) { + availableMaturities--; + uint48 prevMaturity = findPrev(prevMaturityHint, obligation.maturity); + maturityData.nextMaturity = _maturities[prevMaturity].nextMaturity; + _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); + emit InsertMaturity(obligation.maturity, prevMaturity, availableMaturities); } + emit Buy(obligationId, paidAssets, buyNetCreditIncrease, change); return CALLBACK_SUCCESS; } @@ -343,7 +345,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256, bytes memory data ) external returns (bytes32) { - uint48 prevMaturity = abi.decode(data, (uint48)); + uint48 prevMaturityHint = abi.decode(data, (uint48)); uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); @@ -357,7 +359,7 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationCountAndAllocations(obligation); if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease, prevMaturity); + removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease, prevMaturityHint); } int256 change = -int256(totalNetCreditDecrease); @@ -370,6 +372,7 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); + emit Sell(obligationId, sellerAssets, totalNetCreditDecrease); return CALLBACK_SUCCESS; } @@ -377,7 +380,9 @@ contract MidnightAdapter is IMidnightAdapter { /// @dev Removes units from tracking. /// @dev Changes the implied price of the obligation as little as possible. - function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits, uint48 prevMaturity) internal { + function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits, uint48 prevMaturityHint) + internal + { MaturityData storage maturityData = _maturities[maturity]; if (maturity > block.timestamp) { @@ -392,32 +397,29 @@ contract MidnightAdapter is IMidnightAdapter { maturityData.netCredit -= removedUnits.toUint128(); netCredit[obligationId] -= removedUnits.toUint128(); - if (removedUnits > 0 && maturityData.netCredit == 0) { - activableMaturities++; - if (maturity > block.timestamp) { - uint48 nextMaturity; - if (prevMaturity == 0) { - nextMaturity = firstMaturity; - } else { - require(prevMaturity >= firstMaturity && _maturities[prevMaturity].netCredit > 0, IncorrectHint()); - nextMaturity = _maturities[prevMaturity].nextMaturity; - } - - while (nextMaturity < maturity) { - prevMaturity = nextMaturity; - nextMaturity = _maturities[prevMaturity].nextMaturity; - } - - require(nextMaturity == maturity, IncorrectHint()); - if (prevMaturity == 0) { - firstMaturity = maturityData.nextMaturity; - } else { - _maturities[prevMaturity].nextMaturity = maturityData.nextMaturity; - } - } + if (removedUnits > 0 && maturityData.netCredit == 0 && maturity >= block.timestamp) { + availableMaturities++; + uint48 prevMaturity = findPrev(prevMaturityHint, maturity); + _maturities[prevMaturity].nextMaturity = maturityData.nextMaturity; + emit RemoveMaturity(maturity, prevMaturity, availableMaturities); } } + /// @dev Finds the last active maturity earlier than `maturity`, starting from a hint. + function findPrev(uint48 prevMaturity, uint256 maturity) internal view returns (uint48) { + require( + prevMaturity == 0 + || (prevMaturity < maturity + && prevMaturity >= _maturities[0].nextMaturity + && _maturities[prevMaturity].netCredit > 0), + InvalidHint() + ); + + while (_maturities[prevMaturity].nextMaturity < maturity) prevMaturity = _maturities[prevMaturity].nextMaturity; + + return prevMaturity; + } + function durationCount(uint256 maturity) internal view returns (uint256 count) { uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); while (count < durationsLength && timeToMaturity >= packedDurations.get(count)) count++; @@ -450,12 +452,4 @@ contract MidnightAdapter is IMidnightAdapter { } /* UNUSED CALLBACKS */ - - function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { - revert(); - } - - function onRepay(bytes32, Obligation memory, uint256, address, bytes memory) external pure { - revert(); - } } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index ebca01d94..abe61718a 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -4,7 +4,12 @@ pragma solidity >=0.5.0; import {IAdapter} from "../../interfaces/IAdapter.sol"; import {Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; -import {ICallbacks} from "lib/midnight/src/interfaces/ICallbacks.sol"; +import { + IBuyCallback, + ISellCallback, + ILiquidateCallback, + IRepayCallback +} from "lib/midnight/src/interfaces/ICallbacks.sol"; import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple obligations. @@ -16,11 +21,21 @@ struct MaturityData { uint8 durationCount; } -interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { +interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { /* EVENTS */ event SetSkimRecipient(address indexed newSkimRecipient); event Skim(address indexed token, uint256 assets); + event WithdrawToVault(bytes32 indexed obligationId, uint256 withdrawnAssets, uint256 netCreditDecrease); + event UpdateDurationCountAndAllocations( + uint256 indexed maturity, uint256 oldDurationCount, uint256 newDurationCount, uint256 netCredit + ); + event ForceDeallocate(bytes32 indexed obligationId, uint256 sellerAssets, uint256 netCreditDecrease); + event Buy(bytes32 indexed obligationId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); + event Sell(bytes32 indexed obligationId, uint256 sellerAssets, uint256 netCreditDecrease); + event AccrueInterest(uint48 firstMaturity, uint128 currentGrowth, uint256 totalAssets, uint256 availableMaturities); + event RemoveMaturity(uint256 indexed maturity, uint48 prevMaturity, uint256 availableMaturities); + event InsertMaturity(uint256 indexed maturity, uint48 prevMaturity, uint256 availableMaturities); /* ERRORS */ @@ -28,7 +43,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { error BuyAtLoss(); error IncorrectCallbackAddress(); error IncorrectDuration(); - error IncorrectHint(); + error InvalidHint(); error IncorrectMaturity(); error IncorrectOffer(); error IncorrectOwner(); @@ -49,7 +64,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function lastUpdate() external view returns (uint48); function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); - function activableMaturities() external view returns (uint256); + function availableMaturities() external view returns (uint256); function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); @@ -61,10 +76,10 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); function updateDurationCountAndAllocations(Obligation memory obligation) external; - function withdrawToVault(Obligation memory obligation, uint256 units, uint48 prevMaturity) external; + function withdrawToVault(Obligation memory obligation, uint256 units, uint48 prevMaturityHint) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); - function accrueInterestView() external view returns (uint48, uint128, uint256); + function accrueInterestView() external view returns (uint48, uint128, uint256, uint256); function accrueInterest() external returns (uint48, uint128, uint256); function allocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) external @@ -90,20 +105,4 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { uint256 sellerPendingFeeDecrease, bytes memory data ) external returns (bytes32); - function onLiquidate( - bytes32 id, - Obligation memory obligation, - uint256 collateralIndex, - uint256 seizedAssets, - uint256 repaidUnits, - address borrower, - bytes memory data - ) external; - function onRepay( - bytes32 obligationId, - Obligation memory obligation, - uint256 units, - address onBehalf, - bytes memory data - ) external; } diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index 27d589676..f5b00fe3b 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.0; import "../lib/forge-std/src/Test.sol"; import {MidnightAdapterTest} from "./MidnightAdapterTest.sol"; import {IERC20} from "../src/interfaces/IERC20.sol"; +import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; @@ -104,7 +105,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { loanToken.approve(address(midnight), type(uint256).max); midnight.setIsAuthorized(buyer, address(approvalRatifier), true); bytes32 _root = root([offer]); - approvalRatifier.setApproval(buyer, _root, true); + approvalRatifier.setIsRatified(buyer, _root, true); vm.stopPrank(); bytes memory data = abi.encode(offer, hex"", _root, proof([offer]), uint48(0)); @@ -115,6 +116,11 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { return keccak256(abi.encode("duration", duration)); } + function setMidnightCredit(bytes32 obligationId, address account, uint256 credit) internal { + stdstore.target(address(midnight)).sig("creditOf(bytes32,address)").with_key(obligationId).with_key(account) + .checked_write(credit); + } + function testExactDuration(uint32 durationIndex) public { durationIndex = uint32(bound(durationIndex, 0, adapter.durationsLength() - 1)); uint256 duration = adapter.durations()[durationIndex]; @@ -162,7 +168,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(7 days); vm.prank(taker); - midnight.repay(offer.obligation, 1e18, taker, ""); + midnight.repay(offer.obligation, 1e18, taker, address(0), ""); vm.prank(signerAllocator); adapter.withdrawToVault(offer.obligation, 0.5e18, 0); @@ -188,6 +194,36 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } + function testOnBuyUsesSingleHintForRemoveAndInsert() public { + Offer memory firstOffer = buy(1 days, 1e18); + Offer memory offer = buy(7 days, 1e18); + Offer memory lastOffer = buy(30 days, 1e18); + bytes32 obligationId = _obligationId(offer.obligation); + setMidnightCredit(obligationId, address(adapter), 0); + + offer.group = bytes32("second buy"); + uint256 units = 1e18 * 1e18 / TickLib.tickToPrice(MAX_TICK); + offer.maxUnits = units; + + vm.startPrank(taker); + midnight.supplyCollateral(offer.obligation, 0, 0.5e18, taker); + midnight.supplyCollateral(offer.obligation, 1, 0.5e18, taker); + vm.stopPrank(); + + offer.callbackData = abi.encode(uint48(lastOffer.obligation.maturity)); + vm.expectRevert(IMidnightAdapter.InvalidHint.selector); + vm.prank(taker); + midnight.take( + units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) + ); + + offer.callbackData = abi.encode(uint48(firstOffer.obligation.maturity)); + vm.prank(taker); + midnight.take( + units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) + ); + } + function testSellClearsMaturityAndReactivatesSlot() public { Offer memory firstOffer; Offer memory secondOffer; @@ -196,7 +232,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { if (i == 0) firstOffer = offer; if (i == 1) secondOffer = offer; } - assertEq(adapter.activableMaturities(), 0, "activableMaturities before"); + assertEq(adapter.availableMaturities(), 0, "availableMaturities before"); parentVault.setTotalAssets(1e18); parentVault.setAdaptersLength(1); @@ -205,12 +241,12 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { parentVault.setAdapters(adapters); sell(secondOffer.obligation, 1e18, 0); - assertEq(adapter.activableMaturities(), 1, "activableMaturities after"); + assertEq(adapter.availableMaturities(), 1, "availableMaturities after"); assertEq(adapter.firstMaturity(), firstOffer.obligation.maturity, "firstMaturity after"); buy(60 days, 1e18); - assertEq(adapter.activableMaturities(), 0, "activableMaturities final"); + assertEq(adapter.availableMaturities(), 0, "availableMaturities final"); } function testUpdateOnForceDeallocate() public { diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 839c724d2..99df40618 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -14,7 +14,8 @@ import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapt import {MathLib} from "../src/libraries/MathLib.sol"; import {Midnight} from "../lib/midnight/src/Midnight.sol"; import {IMidnight, Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; -import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../lib/midnight/src/interfaces/IEcrecover.sol"; +import {Signature, EIP712_DOMAIN_TYPEHASH} from "../lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; +import {UtilsLib} from "../lib/midnight/src/libraries/UtilsLib.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; @@ -199,7 +200,7 @@ contract MidnightAdapterTest is Test { offer.maxUnits = units; offer.callback = address(adapter); - offer.callbackData = abi.encode(0); + offer.callbackData = abi.encode(uint48(0)); vm.prank(taker); midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) @@ -243,7 +244,7 @@ contract MidnightAdapterTest is Test { offer.maxUnits = units1; offer.callback = address(adapter); - offer.callbackData = abi.encode(0); + offer.callbackData = abi.encode(uint48(0)); vm.prank(taker); midnight.take( @@ -421,7 +422,7 @@ contract MidnightAdapterTest is Test { expiry: vm.getBlockTimestamp() + 1, tick: MAX_TICK, callback: address(adapter), - callbackData: abi.encode(0), + callbackData: abi.encode(uint48(0)), obligation: Obligation({ loanToken: address(loanToken), collateralParams: storedCollaterals, @@ -572,7 +573,7 @@ contract MidnightAdapterTest is Test { skip(elapsed); - (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets) = adapter.accrueInterestView(); + (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets,) = adapter.accrueInterestView(); uint256 lostGrowth = 0; uint256 interest = initialGrowth * elapsed; @@ -716,7 +717,7 @@ contract MidnightAdapterTest is Test { } function sign(Offer[2] memory offers) internal view returns (bytes memory) { - return ratifierData(root(offers), offers[0].maker); + return ratifierData(root(offers), offers[0].maker, 1); } function root(Offer memory offer) internal pure returns (bytes32) { @@ -732,11 +733,15 @@ contract MidnightAdapterTest is Test { } function ratifierData(bytes32 _root, address signer) internal view returns (bytes memory) { - bytes32 structHash = keccak256(abi.encode(ROOT_TYPEHASH, _root)); + return ratifierData(_root, signer, 0); + } + + function ratifierData(bytes32 _root, address signer, uint256 height) internal view returns (bytes memory) { + bytes32 structHash = keccak256(abi.encode(UtilsLib.offerTreeTypeHash(height), _root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(adapter))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey[signer], digest); - return abi.encode(Signature({v: v, r: r, s: s})); + return abi.encode(Signature({v: v, r: r, s: s}), height); } /// @dev Returns the concatenation of x and y, sorted lexicographically. From a8f4b495c0468dabfe5f093f0d5184f33ef9c031 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 7 May 2026 16:37:05 +0200 Subject: [PATCH 53/95] dll again, no hints --- src/adapters/MidnightAdapter.sol | 69 +++++++------------- src/adapters/interfaces/IMidnightAdapter.sol | 11 ++-- test/MidnightAdapterAllocationUpdateTest.sol | 30 ++++----- test/MidnightAdapterTest.sol | 42 ++++-------- 4 files changed, 51 insertions(+), 101 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index f4e71642d..d3e3c76d3 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -58,7 +58,6 @@ contract MidnightAdapter is IMidnightAdapter { lastUpdate = uint48(block.timestamp); SafeERC20Lib.safeApprove(asset, _midnight, type(uint256).max); SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); - _maturities[0].nextMaturity = type(uint48).max; adapterId = keccak256(abi.encode("this", address(this))); bytes32 _packedDurations; @@ -109,7 +108,7 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets, uint48 prevMaturityHint) external { + function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); @@ -122,7 +121,7 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationCountAndAllocations(obligation); if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease, prevMaturityHint); + removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); } int256 change = -int256(totalNetCreditDecrease); @@ -158,7 +157,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 newAvailableMaturities = availableMaturities; uint256 gainedAssets; - while (nextMaturity < block.timestamp) { + while (nextMaturity != 0 && nextMaturity < block.timestamp) { gainedAssets += uint256(newGrowth) * (nextMaturity - lastChange); newGrowth -= _maturities[nextMaturity].growth; lastChange = nextMaturity; @@ -174,6 +173,7 @@ contract MidnightAdapter is IMidnightAdapter { function accrueInterest() public returns (uint48, uint128, uint256) { if (lastUpdate != block.timestamp) { (_maturities[0].nextMaturity, currentGrowth, _totalAssets, availableMaturities) = accrueInterestView(); + _maturities[_maturities[0].nextMaturity].prevMaturity = 0; lastUpdate = uint48(block.timestamp); emit AccrueInterest(_maturities[0].nextMaturity, currentGrowth, _totalAssets, availableMaturities); } @@ -210,13 +210,8 @@ contract MidnightAdapter is IMidnightAdapter { { require(msg.sender == parentVault, NotAuthorized()); if (messageSig == IVaultV2.forceDeallocate.selector) { - ( - Offer memory offer, - bytes memory ratifierData, - bytes32 root, - bytes32[] memory proof, - uint48 prevMaturityHint - ) = abi.decode(data, (Offer, bytes, bytes32, bytes32[], uint48)); + (Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof) = + abi.decode(data, (Offer, bytes, bytes32, bytes32[])); require(offer.buy && offer.obligation.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); // Already in a deallocate call so we skip the onSell callback and return the deallocation here. @@ -235,7 +230,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease, prevMaturityHint); + removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease); } int256 change = -int256(totalNetCreditDecrease); @@ -258,8 +253,6 @@ contract MidnightAdapter is IMidnightAdapter { require(offer.maker == address(this), IncorrectOwner()); require(offer.callback == address(this), IncorrectCallbackAddress()); require(offer.start <= block.timestamp, IncorrectStart()); - // uint48.max is the list end pointer - require(offer.obligation.maturity < type(uint48).max, IncorrectMaturity()); require(offer.buy || offer.reduceOnly, NoDebtCreation()); (Signature memory sig, uint256 height) = abi.decode(data, (Signature, uint256)); @@ -273,8 +266,6 @@ contract MidnightAdapter is IMidnightAdapter { return CALLBACK_SUCCESS; } - /// @dev `data` encodes a hint, it should be zero or a maturity present in the linked list before the obligation - /// maturity. function onBuy( bytes32 obligationId, Obligation memory obligation, @@ -282,9 +273,8 @@ contract MidnightAdapter is IMidnightAdapter { uint256 paidAssets, uint256 boughtCredit, uint256 buyPendingFeeIncrease, - bytes memory data + bytes memory ) external returns (bytes32) { - uint48 prevMaturityHint = abi.decode(data, (uint48)); MaturityData storage maturityData = _maturities[obligation.maturity]; uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); @@ -303,7 +293,7 @@ contract MidnightAdapter is IMidnightAdapter { // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { uint256 loss = (int256(buyNetCreditIncrease) - change).toUint256(); - removeUnits(obligationId, obligation.maturity, loss, prevMaturityHint); + removeUnits(obligationId, obligation.maturity, loss); } IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); @@ -326,10 +316,17 @@ contract MidnightAdapter is IMidnightAdapter { && obligation.maturity >= block.timestamp ) { availableMaturities--; - uint48 prevMaturity = findPrev(prevMaturityHint, obligation.maturity); + uint48 prevMaturity = 0; + uint48 nextMaturity = _maturities[0].nextMaturity; + while (nextMaturity != 0 && nextMaturity < obligation.maturity) { + prevMaturity = nextMaturity; + nextMaturity = _maturities[prevMaturity].nextMaturity; + } maturityData.nextMaturity = _maturities[prevMaturity].nextMaturity; + maturityData.prevMaturity = prevMaturity; _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); - emit InsertMaturity(obligation.maturity, prevMaturity, availableMaturities); + _maturities[maturityData.nextMaturity].prevMaturity = obligation.maturity.toUint48(); + emit InsertMaturity(obligation.maturity, availableMaturities); } emit Buy(obligationId, paidAssets, buyNetCreditIncrease, change); @@ -343,9 +340,8 @@ contract MidnightAdapter is IMidnightAdapter { uint256 sellerAssets, uint256, uint256, - bytes memory data + bytes memory ) external returns (bytes32) { - uint48 prevMaturityHint = abi.decode(data, (uint48)); uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); @@ -359,7 +355,7 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationCountAndAllocations(obligation); if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease, prevMaturityHint); + removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); } int256 change = -int256(totalNetCreditDecrease); @@ -380,9 +376,7 @@ contract MidnightAdapter is IMidnightAdapter { /// @dev Removes units from tracking. /// @dev Changes the implied price of the obligation as little as possible. - function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits, uint48 prevMaturityHint) - internal - { + function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits) internal { MaturityData storage maturityData = _maturities[maturity]; if (maturity > block.timestamp) { @@ -399,27 +393,12 @@ contract MidnightAdapter is IMidnightAdapter { if (removedUnits > 0 && maturityData.netCredit == 0 && maturity >= block.timestamp) { availableMaturities++; - uint48 prevMaturity = findPrev(prevMaturityHint, maturity); - _maturities[prevMaturity].nextMaturity = maturityData.nextMaturity; - emit RemoveMaturity(maturity, prevMaturity, availableMaturities); + _maturities[maturityData.prevMaturity].nextMaturity = maturityData.nextMaturity; + _maturities[maturityData.nextMaturity].prevMaturity = maturityData.prevMaturity; + emit RemoveMaturity(maturity, availableMaturities); } } - /// @dev Finds the last active maturity earlier than `maturity`, starting from a hint. - function findPrev(uint48 prevMaturity, uint256 maturity) internal view returns (uint48) { - require( - prevMaturity == 0 - || (prevMaturity < maturity - && prevMaturity >= _maturities[0].nextMaturity - && _maturities[prevMaturity].netCredit > 0), - InvalidHint() - ); - - while (_maturities[prevMaturity].nextMaturity < maturity) prevMaturity = _maturities[prevMaturity].nextMaturity; - - return prevMaturity; - } - function durationCount(uint256 maturity) internal view returns (uint256 count) { uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); while (count < durationsLength && timeToMaturity >= packedDurations.get(count)) count++; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index abe61718a..d031a198f 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -13,10 +13,11 @@ import { import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple obligations. -// nextMaturity is type(uint48).max if no next maturity. +// nextMaturity is 0 if no next maturity. struct MaturityData { uint128 netCredit; uint128 growth; + uint48 prevMaturity; uint48 nextMaturity; uint8 durationCount; } @@ -34,8 +35,8 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event Buy(bytes32 indexed obligationId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); event Sell(bytes32 indexed obligationId, uint256 sellerAssets, uint256 netCreditDecrease); event AccrueInterest(uint48 firstMaturity, uint128 currentGrowth, uint256 totalAssets, uint256 availableMaturities); - event RemoveMaturity(uint256 indexed maturity, uint48 prevMaturity, uint256 availableMaturities); - event InsertMaturity(uint256 indexed maturity, uint48 prevMaturity, uint256 availableMaturities); + event RemoveMaturity(uint256 indexed maturity, uint256 availableMaturities); + event InsertMaturity(uint256 indexed maturity, uint256 availableMaturities); /* ERRORS */ @@ -43,8 +44,6 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { error BuyAtLoss(); error IncorrectCallbackAddress(); error IncorrectDuration(); - error InvalidHint(); - error IncorrectMaturity(); error IncorrectOffer(); error IncorrectOwner(); error IncorrectSigner(); @@ -76,7 +75,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); function updateDurationCountAndAllocations(Obligation memory obligation) external; - function withdrawToVault(Obligation memory obligation, uint256 units, uint48 prevMaturityHint) external; + function withdrawToVault(Obligation memory obligation, uint256 units) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256, uint256); diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index f5b00fe3b..6c30abfe7 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -5,7 +5,6 @@ pragma solidity ^0.8.0; import "../lib/forge-std/src/Test.sol"; import {MidnightAdapterTest} from "./MidnightAdapterTest.sol"; import {IERC20} from "../src/interfaces/IERC20.sol"; -import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; @@ -49,7 +48,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.maxUnits = units; offer.expiry = block.timestamp; offer.callback = address(adapter); - offer.callbackData = abi.encode(prevMaturity(offer.obligation.maturity)); + offer.callbackData = hex""; vm.startPrank(taker); midnight.supplyCollateral(offer.obligation, 0, assets / 2, taker); @@ -61,7 +60,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { return offer; } - function sell(Obligation memory obligation, uint256 assets, uint48 prevMaturity) internal { + function sell(Obligation memory obligation, uint256 assets) internal { Offer memory offer = storedOffer; offer.obligation = obligation; @@ -75,7 +74,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.callback = address(adapter); offer.receiverIfMakerIsSeller = address(adapter); offer.group = bytes32(vm.randomUint()); - offer.callbackData = abi.encode(prevMaturity); + offer.callbackData = hex""; vm.prank(taker); midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) @@ -108,7 +107,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { approvalRatifier.setIsRatified(buyer, _root, true); vm.stopPrank(); - bytes memory data = abi.encode(offer, hex"", _root, proof([offer]), uint48(0)); + bytes memory data = abi.encode(offer, hex"", _root, proof([offer])); parentVault.forceDeallocate(address(adapter), data, assets, address(this)); } @@ -170,7 +169,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { vm.prank(taker); midnight.repay(offer.obligation, 1e18, taker, address(0), ""); vm.prank(signerAllocator); - adapter.withdrawToVault(offer.obligation, 0.5e18, 0); + adapter.withdrawToVault(offer.obligation, 0.5e18); assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); @@ -188,16 +187,16 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { address[] memory adapters = new address[](1); adapters[0] = address(adapter); parentVault.setAdapters(adapters); - sell(offer.obligation, 0.5e18, 0); + sell(offer.obligation, 0.5e18); assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } - function testOnBuyUsesSingleHintForRemoveAndInsert() public { - Offer memory firstOffer = buy(1 days, 1e18); + function testOnBuyRemovesAndReinsertsMaturity() public { + buy(1 days, 1e18); Offer memory offer = buy(7 days, 1e18); - Offer memory lastOffer = buy(30 days, 1e18); + buy(30 days, 1e18); bytes32 obligationId = _obligationId(offer.obligation); setMidnightCredit(obligationId, address(adapter), 0); @@ -210,14 +209,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { midnight.supplyCollateral(offer.obligation, 1, 0.5e18, taker); vm.stopPrank(); - offer.callbackData = abi.encode(uint48(lastOffer.obligation.maturity)); - vm.expectRevert(IMidnightAdapter.InvalidHint.selector); - vm.prank(taker); - midnight.take( - units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) - ); - - offer.callbackData = abi.encode(uint48(firstOffer.obligation.maturity)); + offer.callbackData = hex""; vm.prank(taker); midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) @@ -239,7 +231,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { address[] memory adapters = new address[](1); adapters[0] = address(adapter); parentVault.setAdapters(adapters); - sell(secondOffer.obligation, 1e18, 0); + sell(secondOffer.obligation, 1e18); assertEq(adapter.availableMaturities(), 1, "availableMaturities after"); assertEq(adapter.firstMaturity(), firstOffer.obligation.maturity, "firstMaturity after"); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 99df40618..ffadfa4e8 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -200,7 +200,7 @@ contract MidnightAdapterTest is Test { offer.maxUnits = units; offer.callback = address(adapter); - offer.callbackData = abi.encode(uint48(0)); + offer.callbackData = hex""; vm.prank(taker); midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) @@ -217,7 +217,7 @@ contract MidnightAdapterTest is Test { assertEq(adapter.currentGrowth(), newGrowth, "currentGrowth"); MaturityData memory maturityData = adapter.maturities(offer.obligation.maturity); assertEq(maturityData.growth, newGrowth, "growth"); - assertEq(maturityData.nextMaturity, type(uint48).max, "nextMaturity"); + assertEq(maturityData.nextMaturity, 0, "nextMaturity"); uint256 actualUnits = adapter.netCredit(_obligationId(offer.obligation)); assertEq(actualUnits, units, "units"); @@ -244,7 +244,7 @@ contract MidnightAdapterTest is Test { offer.maxUnits = units1; offer.callback = address(adapter); - offer.callbackData = abi.encode(uint48(0)); + offer.callbackData = hex""; vm.prank(taker); midnight.take( @@ -263,12 +263,12 @@ contract MidnightAdapterTest is Test { // Step 3: Trigger accrueInterest so the walk subtracts growth from currentGrowth adapter.accrueInterest(); assertEq(adapter.currentGrowth(), 0, "currentGrowth after accrual should be 0"); - assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity should be sentinel"); + assertEq(adapter.firstMaturity(), 0, "firstMaturity should be sentinel"); // In midnight, any seller with debt past maturity is always liquidatable // (isLiquidatable returns true if block.timestamp > maturity && debt > 0), // so we can't test a second buy at past maturity. Just verify accrual state. - assertEq(adapter.firstMaturity(), type(uint48).max, "past maturity not re-inserted into list"); + assertEq(adapter.firstMaturity(), 0, "past maturity not re-inserted into list"); // Note: In midnight, any seller with debt past maturity is always liquidatable, // so the second buy at past maturity from the original test cannot be executed. @@ -369,16 +369,6 @@ contract MidnightAdapterTest is Test { adapter.onRatify(offer, _root, data); } - function testRatifyIncorrectMaturity(uint256 seed) public { - vm.setSeed(seed); - Offer memory offer = _ratificationSetup(); - offer.obligation.maturity = vm.randomUint(type(uint48).max, type(uint256).max); - bytes32 _root = root(offer); - bytes memory data = ratifierData(_root, signerAllocator); - vm.expectRevert(IMidnightAdapter.IncorrectMaturity.selector); - adapter.onRatify(offer, _root, data); - } - function testRatifyIncorrectStart(uint256 seed) public { vm.setSeed(seed); Offer memory offer = _ratificationSetup(); @@ -422,7 +412,7 @@ contract MidnightAdapterTest is Test { expiry: vm.getBlockTimestamp() + 1, tick: MAX_TICK, callback: address(adapter), - callbackData: abi.encode(uint48(0)), + callbackData: hex"", obligation: Obligation({ loanToken: address(loanToken), collateralParams: storedCollaterals, @@ -448,7 +438,7 @@ contract MidnightAdapterTest is Test { uint256 approxInterest = step.approxGrowth * timeToMaturity; offer.group = bytes32(i); offer.obligation.maturity = step.maturity; - offer.callbackData = abi.encode(prevMaturity(step.maturity)); + offer.callbackData = hex""; // Compute tick from desired price: price = assets / (assets + approxInterest) uint256 desiredPrice = step.assets.mulDivDown(1e18, step.assets + approxInterest); @@ -511,7 +501,7 @@ contract MidnightAdapterTest is Test { if (steps.length > 0) { assertEq(adapter.firstMaturity(), steps[0].maturity, "firstMaturity"); } else { - assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity"); + assertEq(adapter.firstMaturity(), 0, "firstMaturity"); } // Check maturities growth and linked list structure @@ -522,9 +512,7 @@ contract MidnightAdapterTest is Test { "growth" ); if (i == expectedMaturitiesList.length - 1) { - assertEq( - adapter.maturities(expectedMaturitiesList[i]).nextMaturity, type(uint48).max, "nextMaturity end" - ); + assertEq(adapter.maturities(expectedMaturitiesList[i]).nextMaturity, 0, "nextMaturity end"); } else { assertEq( adapter.maturities(expectedMaturitiesList[i]).nextMaturity, @@ -577,7 +565,7 @@ contract MidnightAdapterTest is Test { uint256 lostGrowth = 0; uint256 interest = initialGrowth * elapsed; - uint256 expectedNextMaturity = type(uint48).max; + uint256 expectedNextMaturity; for (uint256 i = 0; i < expectedMaturitiesList.length; i++) { uint256 maturity = expectedMaturitiesList[i]; @@ -587,7 +575,7 @@ contract MidnightAdapterTest is Test { } else { interest += expectedMaturityGrowths[maturity] * elapsed; } - if (maturity >= vm.getBlockTimestamp() && maturity < expectedNextMaturity) { + if (maturity >= vm.getBlockTimestamp() && (expectedNextMaturity == 0 || maturity < expectedNextMaturity)) { expectedNextMaturity = maturity; } } @@ -689,14 +677,6 @@ contract MidnightAdapterTest is Test { return IdLib.toId(obligation, block.chainid, address(midnight)); } - function prevMaturity(uint256 maturity) internal view returns (uint48 prev) { - uint48 nextMaturity = adapter.firstMaturity(); - while (nextMaturity < maturity) { - prev = nextMaturity; - nextMaturity = adapter.maturities(prev).nextMaturity; - } - } - function sign(Offer[1] memory offers) internal view returns (bytes memory) { return ratifierData(root(offers), offers[0].maker); } From fbd5728639007783af60edb238cc538ac79a4cba Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 7 May 2026 16:39:04 +0200 Subject: [PATCH 54/95] fix tests --- test/MidnightAdapterTest.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index ffadfa4e8..f63d675fe 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -692,7 +692,7 @@ contract MidnightAdapterTest is Test { // assumes the offer is the first one! function proof(Offer[2] memory offers) internal pure returns (bytes32[] memory) { bytes32[] memory path = new bytes32[](1); - path[0] = keccak256(abi.encode(offers[1])); + path[0] = UtilsLib.hashOffer(offers[1]); return path; } @@ -701,15 +701,15 @@ contract MidnightAdapterTest is Test { } function root(Offer memory offer) internal pure returns (bytes32) { - return keccak256(abi.encode(offer)); + return UtilsLib.hashOffer(offer); } function root(Offer[1] memory offers) internal pure returns (bytes32) { - return keccak256(abi.encode(offers[0])); + return UtilsLib.hashOffer(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])))); + return UtilsLib.commutativeHash(UtilsLib.hashOffer(offers[0]), UtilsLib.hashOffer(offers[1])); } function ratifierData(bytes32 _root, address signer) internal view returns (bytes memory) { From f1118e93ca15ac918df715625dd7c633aed76e66 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 7 May 2026 17:00:10 +0200 Subject: [PATCH 55/95] dynamic casts --- src/adapters/MidnightAdapter.sol | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index d3e3c76d3..7be8be3f9 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -124,7 +124,7 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); } - int256 change = -int256(totalNetCreditDecrease); + int256 change = -totalNetCreditDecrease.toInt256(); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); emit WithdrawToVault(obligationId, withdrawnAssets, totalNetCreditDecrease); } @@ -233,7 +233,7 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease); } - int256 change = -int256(totalNetCreditDecrease); + int256 change = -totalNetCreditDecrease.toInt256(); emit ForceDeallocate(obligationId, sellerAssets, totalNetCreditDecrease); return (ids(offer.obligation), change); } else { @@ -292,7 +292,9 @@ contract MidnightAdapter is IMidnightAdapter { // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { - uint256 loss = (int256(buyNetCreditIncrease) - change).toUint256(); + // forge-lint: disable-next-item(unsafe-typecast) safe because change < buyNetCreditIncrease (checked + // above). + uint256 loss = uint256(int256(buyNetCreditIncrease) - change); removeUnits(obligationId, obligation.maturity, loss); } @@ -358,7 +360,7 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); } - int256 change = -int256(totalNetCreditDecrease); + int256 change = -totalNetCreditDecrease.toInt256(); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); From 12fc8175ec928cea3478728c5a9f258f61da83da Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 7 May 2026 17:58:07 +0200 Subject: [PATCH 56/95] var renamings --- src/adapters/MidnightAdapter.sol | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 7be8be3f9..aea3e1954 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -151,23 +151,23 @@ contract MidnightAdapter is IMidnightAdapter { /* ACCRUAL */ function accrueInterestView() public view returns (uint48, uint128, uint256, uint256) { - uint256 lastChange = lastUpdate; - uint48 nextMaturity = _maturities[0].nextMaturity; + uint48 _firstMaturity = _maturities[0].nextMaturity; uint128 newGrowth = currentGrowth; uint256 newAvailableMaturities = availableMaturities; - uint256 gainedAssets; - - while (nextMaturity != 0 && nextMaturity < block.timestamp) { - gainedAssets += uint256(newGrowth) * (nextMaturity - lastChange); - newGrowth -= _maturities[nextMaturity].growth; - lastChange = nextMaturity; - nextMaturity = _maturities[nextMaturity].nextMaturity; + uint256 gainedAssets = 0; + uint256 accrueFrom = lastUpdate; + + while (_firstMaturity != 0 && _firstMaturity < block.timestamp) { + gainedAssets += uint256(newGrowth) * (_firstMaturity - accrueFrom); + newGrowth -= _maturities[_firstMaturity].growth; + accrueFrom = _firstMaturity; + _firstMaturity = _maturities[_firstMaturity].nextMaturity; newAvailableMaturities++; } - gainedAssets += uint256(newGrowth) * (block.timestamp - lastChange); + gainedAssets += uint256(newGrowth) * (block.timestamp - accrueFrom); - return (nextMaturity, newGrowth, _totalAssets + gainedAssets, newAvailableMaturities); + return (_firstMaturity, newGrowth, _totalAssets + gainedAssets, newAvailableMaturities); } function accrueInterest() public returns (uint48, uint128, uint256) { From 5ad787144dc7f58365780e255f433933626696cf Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 11 May 2026 05:55:59 +0200 Subject: [PATCH 57/95] remove current maturity from pending list, log available maturities diff --- src/adapters/MidnightAdapter.sol | 24 +++++++++++--------- src/adapters/interfaces/IMidnightAdapter.sol | 6 ++--- test/MidnightAdapterTest.sol | 4 ++-- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index aea3e1954..d099576f7 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -45,7 +45,7 @@ contract MidnightAdapter is IMidnightAdapter { uint48 public lastUpdate; uint128 public currentGrowth; /// @dev Maximum steps of an accrual. - /// @dev A maturity uses an availability slot iff it has some units and is >= now after accrual. + /// @dev A maturity uses an availability slot iff it has some units and is > now after accrual. uint256 public availableMaturities = 50; mapping(uint256 timestamp => MaturityData) public _maturities; mapping(bytes32 obligationId => uint256) public netCredit; @@ -153,29 +153,31 @@ contract MidnightAdapter is IMidnightAdapter { function accrueInterestView() public view returns (uint48, uint128, uint256, uint256) { uint48 _firstMaturity = _maturities[0].nextMaturity; uint128 newGrowth = currentGrowth; - uint256 newAvailableMaturities = availableMaturities; + uint256 removedMaturities = 0; uint256 gainedAssets = 0; uint256 accrueFrom = lastUpdate; - while (_firstMaturity != 0 && _firstMaturity < block.timestamp) { + while (_firstMaturity != 0 && _firstMaturity <= block.timestamp) { gainedAssets += uint256(newGrowth) * (_firstMaturity - accrueFrom); newGrowth -= _maturities[_firstMaturity].growth; accrueFrom = _firstMaturity; _firstMaturity = _maturities[_firstMaturity].nextMaturity; - newAvailableMaturities++; + removedMaturities++; } gainedAssets += uint256(newGrowth) * (block.timestamp - accrueFrom); - return (_firstMaturity, newGrowth, _totalAssets + gainedAssets, newAvailableMaturities); + return (_firstMaturity, newGrowth, _totalAssets + gainedAssets, removedMaturities); } function accrueInterest() public returns (uint48, uint128, uint256) { if (lastUpdate != block.timestamp) { - (_maturities[0].nextMaturity, currentGrowth, _totalAssets, availableMaturities) = accrueInterestView(); + uint256 removedMaturities; + (_maturities[0].nextMaturity, currentGrowth, _totalAssets, removedMaturities) = accrueInterestView(); + availableMaturities += removedMaturities; _maturities[_maturities[0].nextMaturity].prevMaturity = 0; lastUpdate = uint48(block.timestamp); - emit AccrueInterest(_maturities[0].nextMaturity, currentGrowth, _totalAssets, availableMaturities); + emit AccrueInterest(_maturities[0].nextMaturity, currentGrowth, _totalAssets, removedMaturities); } return (_maturities[0].nextMaturity, currentGrowth, _totalAssets); } @@ -315,7 +317,7 @@ contract MidnightAdapter is IMidnightAdapter { // Insert the maturity in the list if needed if ( maturityData.netCredit == buyNetCreditIncrease && buyNetCreditIncrease > 0 - && obligation.maturity >= block.timestamp + && obligation.maturity > block.timestamp ) { availableMaturities--; uint48 prevMaturity = 0; @@ -328,7 +330,7 @@ contract MidnightAdapter is IMidnightAdapter { maturityData.prevMaturity = prevMaturity; _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); _maturities[maturityData.nextMaturity].prevMaturity = obligation.maturity.toUint48(); - emit InsertMaturity(obligation.maturity, availableMaturities); + emit InsertMaturity(obligation.maturity); } emit Buy(obligationId, paidAssets, buyNetCreditIncrease, change); @@ -393,11 +395,11 @@ contract MidnightAdapter is IMidnightAdapter { maturityData.netCredit -= removedUnits.toUint128(); netCredit[obligationId] -= removedUnits.toUint128(); - if (removedUnits > 0 && maturityData.netCredit == 0 && maturity >= block.timestamp) { + if (removedUnits > 0 && maturityData.netCredit == 0 && maturity > block.timestamp) { availableMaturities++; _maturities[maturityData.prevMaturity].nextMaturity = maturityData.nextMaturity; _maturities[maturityData.nextMaturity].prevMaturity = maturityData.prevMaturity; - emit RemoveMaturity(maturity, availableMaturities); + emit RemoveMaturity(maturity); } } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index d031a198f..377929587 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -34,9 +34,9 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event ForceDeallocate(bytes32 indexed obligationId, uint256 sellerAssets, uint256 netCreditDecrease); event Buy(bytes32 indexed obligationId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); event Sell(bytes32 indexed obligationId, uint256 sellerAssets, uint256 netCreditDecrease); - event AccrueInterest(uint48 firstMaturity, uint128 currentGrowth, uint256 totalAssets, uint256 availableMaturities); - event RemoveMaturity(uint256 indexed maturity, uint256 availableMaturities); - event InsertMaturity(uint256 indexed maturity, uint256 availableMaturities); + event AccrueInterest(uint48 firstMaturity, uint128 currentGrowth, uint256 totalAssets, uint256 removedMaturities); + event RemoveMaturity(uint256 indexed maturity); + event InsertMaturity(uint256 indexed maturity); /* ERRORS */ diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index f63d675fe..6e5abe0bf 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -569,13 +569,13 @@ contract MidnightAdapterTest is Test { for (uint256 i = 0; i < expectedMaturitiesList.length; i++) { uint256 maturity = expectedMaturitiesList[i]; - if (maturity < vm.getBlockTimestamp()) { + if (maturity <= vm.getBlockTimestamp()) { lostGrowth += expectedMaturityGrowths[maturity]; interest += expectedMaturityGrowths[maturity] * (maturity - begin); } else { interest += expectedMaturityGrowths[maturity] * elapsed; } - if (maturity >= vm.getBlockTimestamp() && (expectedNextMaturity == 0 || maturity < expectedNextMaturity)) { + if (maturity > vm.getBlockTimestamp() && (expectedNextMaturity == 0 || maturity < expectedNextMaturity)) { expectedNextMaturity = maturity; } } From 7b0e8ccfd5f6447e8123058e50bd9540dda756d4 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 15 May 2026 17:55:33 +0200 Subject: [PATCH 58/95] update to new midnight --- lib/midnight | 2 +- src/adapters/MidnightAdapter.sol | 166 +++++++++---------- src/adapters/interfaces/IMidnightAdapter.sol | 27 ++- 3 files changed, 94 insertions(+), 101 deletions(-) diff --git a/lib/midnight b/lib/midnight index beac3761a..b2e93a2a6 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit beac3761ab37644386ea4e4d9e639aa916a2dd59 +Subproject commit b2e93a2a6872e15a0022e3c5694fb7732346d199 diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index d099576f7..2481b8c1e 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -2,13 +2,13 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {IMidnight, Offer, Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; +import {IMidnight, Offer, Market} from "lib/midnight/src/interfaces/IMidnight.sol"; import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH} from "lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; -import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; +import {CALLBACK_SUCCESS, WAD} from "lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; -import {UtilsLib} from "lib/midnight/src/libraries/UtilsLib.sol"; +import {HashLib} from "lib/midnight/src/ratifiers/libraries/HashLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; @@ -16,10 +16,10 @@ import {IVaultV2} from "../interfaces/IVaultV2.sol"; import {IMidnightAdapter, MaturityData, IAdapter} from "./interfaces/IMidnightAdapter.sol"; import {DurationsLib} from "./libraries/DurationsLib.sol"; -/// @dev Approximates held assets by linearly accounting for interest separately for each obligation. +/// @dev Approximates held assets by linearly accounting for interest separately for each market. /// @dev Losses are immediately 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. +/// to the relative sizes of the loss and the adapter's position in the market hit by the loss. +/// @dev The adapter must have the allocator role in its parent vault to be able to buy & sell on markets. contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; @@ -48,7 +48,7 @@ contract MidnightAdapter is IMidnightAdapter { /// @dev A maturity uses an availability slot iff it has some units and is > now after accrual. uint256 public availableMaturities = 50; mapping(uint256 timestamp => MaturityData) public _maturities; - mapping(bytes32 obligationId => uint256) public netCredit; + mapping(bytes32 marketId => uint256) public netCredit; /* CONSTRUCTOR */ constructor(address _parentVault, address _midnight, uint256[] memory _durations) { @@ -108,34 +108,34 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external { + function withdrawToVault(Market memory market, uint256 withdrawnAssets) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); - bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); - IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); - uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - - IMidnight(midnight).pendingFee(obligationId, address(this)); + bytes32 marketId = IdLib.toId(market, block.chainid, midnight); + IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); + uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + - IMidnight(midnight).pendingFee(marketId, address(this)); // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + uint256 totalNetCreditDecrease = netCredit[marketId] - newNetCredit; accrueInterest(); - updateDurationCountAndAllocations(obligation); + updateDurationCountAndAllocations(market); if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); + removeUnits(marketId, market.maturity, totalNetCreditDecrease); } int256 change = -totalNetCreditDecrease.toInt256(); - IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); - emit WithdrawToVault(obligationId, withdrawnAssets, totalNetCreditDecrease); + IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(market), change), withdrawnAssets); + emit WithdrawToVault(marketId, withdrawnAssets, totalNetCreditDecrease); } - function updateDurationCountAndAllocations(Obligation memory obligation) public { - MaturityData storage maturityData = _maturities[obligation.maturity]; + function updateDurationCountAndAllocations(Market memory market) public { + MaturityData storage maturityData = _maturities[market.maturity]; uint256 oldDurationCount = maturityData.durationCount; - uint256 newDurationCount = durationCount(obligation.maturity); + uint256 newDurationCount = durationCount(market.maturity); maturityData.durationCount = uint8(newDurationCount); emit UpdateDurationCountAndAllocations( - obligation.maturity, oldDurationCount, newDurationCount, maturityData.netCredit + market.maturity, oldDurationCount, newDurationCount, maturityData.netCredit ); // VaultV2.deallocate requires allocation > 0 for each returned id. if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { @@ -212,32 +212,30 @@ contract MidnightAdapter is IMidnightAdapter { { require(msg.sender == parentVault, NotAuthorized()); if (messageSig == IVaultV2.forceDeallocate.selector) { - (Offer memory offer, bytes memory ratifierData, bytes32 root, bytes32[] memory proof) = - abi.decode(data, (Offer, bytes, bytes32, bytes32[])); - require(offer.buy && offer.obligation.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); + (Offer memory offer, bytes memory ratifierData) = abi.decode(data, (Offer, bytes)); + require(offer.buy && offer.market.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); // Already in a deallocate call so we skip the onSell callback and return the deallocation here. - bytes32 obligationId = IdLib.toId(offer.obligation, block.chainid, midnight); - uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, obligationId, offer, sellerAssets); - IMidnight(midnight) - .take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); + bytes32 marketId = IdLib.toId(offer.market, block.chainid, midnight); + uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, marketId, offer, sellerAssets); + IMidnight(midnight).take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData); - require(IMidnight(midnight).debtOf(obligationId, address(this)) == 0, NoBorrowing()); + require(IMidnight(midnight).debtOf(marketId, address(this)) == 0, NoBorrowing()); accrueInterest(); - updateDurationCountAndAllocations(offer.obligation); - uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - - IMidnight(midnight).pendingFee(obligationId, address(this)); + updateDurationCountAndAllocations(offer.market); + uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + - IMidnight(midnight).pendingFee(marketId, address(this)); // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + uint256 totalNetCreditDecrease = netCredit[marketId] - newNetCredit; if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease); + removeUnits(marketId, offer.market.maturity, totalNetCreditDecrease); } int256 change = -totalNetCreditDecrease.toInt256(); - emit ForceDeallocate(obligationId, sellerAssets, totalNetCreditDecrease); - return (ids(offer.obligation), change); + emit ForceDeallocate(marketId, sellerAssets, totalNetCreditDecrease); + return (ids(offer.market), change); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. @@ -249,16 +247,18 @@ contract MidnightAdapter is IMidnightAdapter { /* MIDNIGHT CALLBACKS */ - function onRatify(Offer memory offer, bytes32 root, bytes memory data) external view returns (bytes32) { + function isRatified(Offer memory offer, bytes memory data) external view returns (bytes32) { // Collaterals will be checked through vault ids. - require(offer.obligation.loanToken == asset, LoanAssetMismatch()); + require(offer.market.loanToken == asset, LoanAssetMismatch()); require(offer.maker == address(this), IncorrectOwner()); require(offer.callback == address(this), IncorrectCallbackAddress()); require(offer.start <= block.timestamp, IncorrectStart()); require(offer.buy || offer.reduceOnly, NoDebtCreation()); - (Signature memory sig, uint256 height) = abi.decode(data, (Signature, uint256)); - bytes32 structHash = keccak256(abi.encode(UtilsLib.offerTreeTypeHash(height), root)); + (Signature memory sig, uint256 height, bytes32 root, bytes32[] memory proof) = + abi.decode(data, (Signature, uint256, bytes32, bytes32[])); + require(HashLib.isLeaf(root, HashLib.hashOffer(offer), proof), InvalidProof()); + bytes32 structHash = keccak256(abi.encode(HashLib.offerTreeTypeHash(height), root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); address signer = ecrecover(digest, sig.v, sig.r, sig.s); @@ -269,38 +269,40 @@ contract MidnightAdapter is IMidnightAdapter { } function onBuy( - bytes32 obligationId, - Obligation memory obligation, + bytes32 marketId, + Market memory market, address buyer, uint256 paidAssets, uint256 boughtCredit, - uint256 buyPendingFeeIncrease, bytes memory ) external returns (bytes32) { - MaturityData storage maturityData = _maturities[obligation.maturity]; + MaturityData storage maturityData = _maturities[market.maturity]; + uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); + // Works because the adapter never takes on debt and the fee is the one used by midnight. + uint256 buyPendingFeeIncrease = + boughtCredit.mulDivDown(IMidnight(midnight).continuousFee(marketId) * timeToMaturity, WAD); uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); - uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - - IMidnight(midnight).pendingFee(obligationId, address(this)); - int256 change = newNetCredit.toInt256() - netCredit[obligationId].toInt256(); + uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + - IMidnight(midnight).pendingFee(marketId, address(this)); + int256 change = newNetCredit.toInt256() - netCredit[marketId].toInt256(); accrueInterest(); - updateDurationCountAndAllocations(obligation); + updateDurationCountAndAllocations(market); // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { // forge-lint: disable-next-item(unsafe-typecast) safe because change < buyNetCreditIncrease (checked // above). uint256 loss = uint256(int256(buyNetCreditIncrease) - change); - removeUnits(obligationId, obligation.maturity, loss); + removeUnits(marketId, market.maturity, loss); } - IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); + IVaultV2(parentVault).allocate(address(this), abi.encode(ids(market), change), paidAssets); if (timeToMaturity > 0) { uint128 gainedGrowth = ((buyNetCreditIncrease - paidAssets) / timeToMaturity).toUint128(); @@ -312,58 +314,53 @@ contract MidnightAdapter is IMidnightAdapter { } maturityData.netCredit += buyNetCreditIncrease.toUint128(); - netCredit[obligationId] += buyNetCreditIncrease.toUint128(); + netCredit[marketId] += buyNetCreditIncrease.toUint128(); // Insert the maturity in the list if needed if ( maturityData.netCredit == buyNetCreditIncrease && buyNetCreditIncrease > 0 - && obligation.maturity > block.timestamp + && market.maturity > block.timestamp ) { availableMaturities--; uint48 prevMaturity = 0; uint48 nextMaturity = _maturities[0].nextMaturity; - while (nextMaturity != 0 && nextMaturity < obligation.maturity) { + while (nextMaturity != 0 && nextMaturity < market.maturity) { prevMaturity = nextMaturity; nextMaturity = _maturities[prevMaturity].nextMaturity; } maturityData.nextMaturity = _maturities[prevMaturity].nextMaturity; maturityData.prevMaturity = prevMaturity; - _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); - _maturities[maturityData.nextMaturity].prevMaturity = obligation.maturity.toUint48(); - emit InsertMaturity(obligation.maturity); + _maturities[prevMaturity].nextMaturity = market.maturity.toUint48(); + _maturities[maturityData.nextMaturity].prevMaturity = market.maturity.toUint48(); + emit InsertMaturity(market.maturity); } - emit Buy(obligationId, paidAssets, buyNetCreditIncrease, change); + emit Buy(marketId, paidAssets, buyNetCreditIncrease, change); return CALLBACK_SUCCESS; } - function onSell( - bytes32 obligationId, - Obligation memory obligation, - address seller, - uint256 sellerAssets, - uint256, - uint256, - bytes memory - ) external returns (bytes32) { + function onSell(bytes32 marketId, Market memory market, address seller, uint256 sellerAssets, uint256, bytes memory) + external + returns (bytes32) + { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); - uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - - IMidnight(midnight).pendingFee(obligationId, address(this)); + uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + - IMidnight(midnight).pendingFee(marketId, address(this)); // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + uint256 totalNetCreditDecrease = netCredit[marketId] - newNetCredit; require(msg.sender == midnight, NotMidnight()); require(seller == address(this), NotSelf()); accrueInterest(); - updateDurationCountAndAllocations(obligation); + updateDurationCountAndAllocations(market); if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); + removeUnits(marketId, market.maturity, totalNetCreditDecrease); } int256 change = -totalNetCreditDecrease.toInt256(); - IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); + IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(market), change), sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -372,15 +369,15 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); - emit Sell(obligationId, sellerAssets, totalNetCreditDecrease); + emit Sell(marketId, sellerAssets, totalNetCreditDecrease); return CALLBACK_SUCCESS; } /* INTERNAL FUNCTIONS */ /// @dev Removes units from tracking. - /// @dev Changes the implied price of the obligation as little as possible. - function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits) internal { + /// @dev Changes the implied price of the market as little as possible. + function removeUnits(bytes32 marketId, uint256 maturity, uint256 removedUnits) internal { MaturityData storage maturityData = _maturities[maturity]; if (maturity > block.timestamp) { @@ -393,7 +390,7 @@ contract MidnightAdapter is IMidnightAdapter { _totalAssets -= removedUnits; } maturityData.netCredit -= removedUnits.toUint128(); - netCredit[obligationId] -= removedUnits.toUint128(); + netCredit[marketId] -= removedUnits.toUint128(); if (removedUnits > 0 && maturityData.netCredit == 0 && maturity > block.timestamp) { availableMaturities++; @@ -408,22 +405,19 @@ contract MidnightAdapter is IMidnightAdapter { while (count < durationsLength && timeToMaturity >= packedDurations.get(count)) count++; } - function ids(Obligation memory obligation) public view returns (bytes32[] memory) { - uint256 durationsCount = durationCount(obligation.maturity); + function ids(Market memory market) public view returns (bytes32[] memory) { + uint256 durationsCount = durationCount(market.maturity); - bytes32[] memory idsArray = new bytes32[](1 + obligation.collateralParams.length * 2 + durationsCount); + bytes32[] memory idsArray = new bytes32[](1 + market.collateralParams.length * 2 + durationsCount); uint256 j; idsArray[j++] = adapterId; - for (uint256 i = 0; i < obligation.collateralParams.length; i++) { - address collateralToken = obligation.collateralParams[i].token; + for (uint256 i = 0; i < market.collateralParams.length; i++) { + address collateralToken = market.collateralParams[i].token; idsArray[j++] = keccak256(abi.encode("collateralToken", collateralToken)); idsArray[j++] = keccak256( abi.encode( - "collateral", - collateralToken, - obligation.collateralParams[i].oracle, - obligation.collateralParams[i].lltv + "collateral", collateralToken, market.collateralParams[i].oracle, market.collateralParams[i].lltv ) ); } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 377929587..6ebfd9328 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -3,7 +3,7 @@ pragma solidity >=0.5.0; import {IAdapter} from "../../interfaces/IAdapter.sol"; -import {Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; +import {Market} from "lib/midnight/src/interfaces/IMidnight.sol"; import { IBuyCallback, ISellCallback, @@ -12,7 +12,7 @@ import { } from "lib/midnight/src/interfaces/ICallbacks.sol"; import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; -// Chain of maturities, each can represent multiple obligations. +// Chain of maturities, each can represent multiple markets. // nextMaturity is 0 if no next maturity. struct MaturityData { uint128 netCredit; @@ -27,13 +27,13 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event SetSkimRecipient(address indexed newSkimRecipient); event Skim(address indexed token, uint256 assets); - event WithdrawToVault(bytes32 indexed obligationId, uint256 withdrawnAssets, uint256 netCreditDecrease); + event WithdrawToVault(bytes32 indexed marketId, uint256 withdrawnAssets, uint256 netCreditDecrease); event UpdateDurationCountAndAllocations( uint256 indexed maturity, uint256 oldDurationCount, uint256 newDurationCount, uint256 netCredit ); - event ForceDeallocate(bytes32 indexed obligationId, uint256 sellerAssets, uint256 netCreditDecrease); - event Buy(bytes32 indexed obligationId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); - event Sell(bytes32 indexed obligationId, uint256 sellerAssets, uint256 netCreditDecrease); + event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); + event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); + event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); event AccrueInterest(uint48 firstMaturity, uint128 currentGrowth, uint256 totalAssets, uint256 removedMaturities); event RemoveMaturity(uint256 indexed maturity); event InsertMaturity(uint256 indexed maturity); @@ -48,6 +48,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { error IncorrectOwner(); error IncorrectSigner(); error IncorrectStart(); + error InvalidProof(); error LoanAssetMismatch(); error NoBorrowing(); error NoDebtCreation(); @@ -67,16 +68,16 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); - function netCredit(bytes32 obligationId) external view returns (uint256); + function netCredit(bytes32 marketId) external view returns (uint256); function maturities(uint256 date) external view returns (MaturityData memory); function skimRecipient() external view returns (address); function setSkimRecipient(address newSkimRecipient) external; function skim(address token) external; function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); - function updateDurationCountAndAllocations(Obligation memory obligation) external; - function withdrawToVault(Obligation memory obligation, uint256 units) external; - function ids(Obligation memory obligation) external view returns (bytes32[] memory); + function updateDurationCountAndAllocations(Market memory market) external; + function withdrawToVault(Market memory market, uint256 units) external; + function ids(Market memory market) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256, uint256); function accrueInterest() external returns (uint48, uint128, uint256); @@ -88,20 +89,18 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { returns (bytes32[] memory, int256); function onBuy( bytes32 id, - Obligation memory obligation, + Market memory market, address buyer, uint256 buyerAssets, uint256 units, - uint256 buyerPendingFeeIncrease, bytes memory data ) external returns (bytes32); function onSell( bytes32 id, - Obligation memory obligation, + Market memory market, address seller, uint256 sellerAssets, uint256 units, - uint256 sellerPendingFeeDecrease, bytes memory data ) external returns (bytes32); } From 585e535d0d2818dd387d2912ffa7a5e1d3cc0ce0 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 15 May 2026 18:11:51 +0200 Subject: [PATCH 59/95] midnight to main --- lib/midnight | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/midnight b/lib/midnight index b2e93a2a6..b6aa1cffd 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit b2e93a2a6872e15a0022e3c5694fb7732346d199 +Subproject commit b6aa1cffd366ed3ef3955ea35650338e13d7a131 From 8c600dbca601016d40a24818bf5a5f590b34c24b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 15 May 2026 18:23:02 +0200 Subject: [PATCH 60/95] cleaner accrue --- src/adapters/MidnightAdapter.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 2481b8c1e..277e0f1fa 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -172,12 +172,14 @@ contract MidnightAdapter is IMidnightAdapter { function accrueInterest() public returns (uint48, uint128, uint256) { if (lastUpdate != block.timestamp) { + uint48 newHead; uint256 removedMaturities; - (_maturities[0].nextMaturity, currentGrowth, _totalAssets, removedMaturities) = accrueInterestView(); + (newHead, currentGrowth, _totalAssets, removedMaturities) = accrueInterestView(); availableMaturities += removedMaturities; - _maturities[_maturities[0].nextMaturity].prevMaturity = 0; + _maturities[0].nextMaturity = newHead; + _maturities[newHead].prevMaturity = 0; lastUpdate = uint48(block.timestamp); - emit AccrueInterest(_maturities[0].nextMaturity, currentGrowth, _totalAssets, removedMaturities); + emit AccrueInterest(newHead, currentGrowth, _totalAssets, removedMaturities); } return (_maturities[0].nextMaturity, currentGrowth, _totalAssets); } From 93755281b041620f6d727abfd1d0cdf5fe551ebf Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 20 May 2026 11:12:27 +0200 Subject: [PATCH 61/95] misc fixes --- foundry.lock | 4 +- lib/midnight | 2 +- src/adapters/MidnightAdapter.sol | 69 +++++---- src/adapters/interfaces/IMidnightAdapter.sol | 24 ++- test/MidnightAdapterAllocationUpdateTest.sol | 69 ++++----- test/MidnightAdapterTest.sol | 155 +++++++++---------- 6 files changed, 151 insertions(+), 172 deletions(-) diff --git a/foundry.lock b/foundry.lock index e8d159f81..75cb45474 100644 --- a/foundry.lock +++ b/foundry.lock @@ -9,7 +9,7 @@ "rev": "2d160ba9bb945ca3bf12efb182427445dce59c27" }, "lib/midnight": { - "rev": "beac3761ab37644386ea4e4d9e639aa916a2dd59" + "rev": "a9988d997c03458e260f8a39e408937eea8506d2" }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" @@ -20,4 +20,4 @@ "lib/openzeppelin-contracts": { "rev": "b72e3da0ec1f47e4a7911a4c06dc92e78c646607" } -} \ No newline at end of file +} diff --git a/lib/midnight b/lib/midnight index b6aa1cffd..a9988d997 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit b6aa1cffd366ed3ef3955ea35650338e13d7a131 +Subproject commit a9988d997c03458e260f8a39e408937eea8506d2 diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 277e0f1fa..30ef04422 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.34; import {IMidnight, Offer, Market} from "lib/midnight/src/interfaces/IMidnight.sol"; import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH} from "lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; -import {CALLBACK_SUCCESS, WAD} from "lib/midnight/src/libraries/ConstantsLib.sol"; +import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; import {HashLib} from "lib/midnight/src/ratifiers/libraries/HashLib.sol"; @@ -32,6 +32,8 @@ contract MidnightAdapter is IMidnightAdapter { address public immutable parentVault; address public immutable midnight; bytes32 public immutable adapterId; + /// @dev Sorted durations that can be used to cap the time to maturity. + /// @dev Sorted in ascending order. bytes32 public immutable packedDurations; uint256 public immutable durationsLength; @@ -41,12 +43,12 @@ contract MidnightAdapter is IMidnightAdapter { /* ACCOUNTING */ - uint256 public _totalAssets; - uint48 public lastUpdate; + uint128 public totalAssets; uint128 public currentGrowth; + uint48 public lastUpdate; /// @dev Maximum steps of an accrual. /// @dev A maturity uses an availability slot iff it has some units and is > now after accrual. - uint256 public availableMaturities = 50; + uint8 public availableMaturities = 50; mapping(uint256 timestamp => MaturityData) public _maturities; mapping(bytes32 marketId => uint256) public netCredit; /* CONSTRUCTOR */ @@ -124,8 +126,8 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(marketId, market.maturity, totalNetCreditDecrease); } - int256 change = -totalNetCreditDecrease.toInt256(); - IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(market), change), withdrawnAssets); + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(market), -totalNetCreditDecrease.toInt256()), withdrawnAssets); emit WithdrawToVault(marketId, withdrawnAssets, totalNetCreditDecrease); } @@ -134,9 +136,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 oldDurationCount = maturityData.durationCount; uint256 newDurationCount = durationCount(market.maturity); maturityData.durationCount = uint8(newDurationCount); - emit UpdateDurationCountAndAllocations( - market.maturity, oldDurationCount, newDurationCount, maturityData.netCredit - ); + emit UpdateDurationCountAndAllocations(market.maturity, newDurationCount, maturityData.netCredit); // VaultV2.deallocate requires allocation > 0 for each returned id. if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationCount - newDurationCount); @@ -150,7 +150,7 @@ contract MidnightAdapter is IMidnightAdapter { /* ACCRUAL */ - function accrueInterestView() public view returns (uint48, uint128, uint256, uint256) { + function accrueInterestView() public view returns (uint48, uint128, uint128, uint256) { uint48 _firstMaturity = _maturities[0].nextMaturity; uint128 newGrowth = currentGrowth; uint256 removedMaturities = 0; @@ -167,21 +167,21 @@ contract MidnightAdapter is IMidnightAdapter { gainedAssets += uint256(newGrowth) * (block.timestamp - accrueFrom); - return (_firstMaturity, newGrowth, _totalAssets + gainedAssets, removedMaturities); + return (_firstMaturity, newGrowth, (totalAssets + gainedAssets).toUint128(), removedMaturities); } function accrueInterest() public returns (uint48, uint128, uint256) { if (lastUpdate != block.timestamp) { uint48 newHead; uint256 removedMaturities; - (newHead, currentGrowth, _totalAssets, removedMaturities) = accrueInterestView(); - availableMaturities += removedMaturities; + (newHead, currentGrowth, totalAssets, removedMaturities) = accrueInterestView(); + availableMaturities += uint8(removedMaturities); _maturities[0].nextMaturity = newHead; _maturities[newHead].prevMaturity = 0; lastUpdate = uint48(block.timestamp); - emit AccrueInterest(newHead, currentGrowth, _totalAssets, removedMaturities); + emit AccrueInterest(newHead, currentGrowth, totalAssets, removedMaturities); } - return (_maturities[0].nextMaturity, currentGrowth, _totalAssets); + return (_maturities[0].nextMaturity, currentGrowth, totalAssets); } /// @dev Returns an estimate of the real assets assigned to the adapter. @@ -220,7 +220,7 @@ contract MidnightAdapter is IMidnightAdapter { // Already in a deallocate call so we skip the onSell callback and return the deallocation here. bytes32 marketId = IdLib.toId(offer.market, block.chainid, midnight); uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, marketId, offer, sellerAssets); - IMidnight(midnight).take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData); + IMidnight(midnight).take(offer, takeUnits, address(this), address(this), address(0), hex"", ratifierData); require(IMidnight(midnight).debtOf(marketId, address(this)) == 0, NoBorrowing()); @@ -257,10 +257,10 @@ contract MidnightAdapter is IMidnightAdapter { require(offer.start <= block.timestamp, IncorrectStart()); require(offer.buy || offer.reduceOnly, NoDebtCreation()); - (Signature memory sig, uint256 height, bytes32 root, bytes32[] memory proof) = - abi.decode(data, (Signature, uint256, bytes32, bytes32[])); - require(HashLib.isLeaf(root, HashLib.hashOffer(offer), proof), InvalidProof()); - bytes32 structHash = keccak256(abi.encode(HashLib.offerTreeTypeHash(height), root)); + (Signature memory sig, bytes32 root, uint256 leafIndex, bytes32[] memory proof) = + abi.decode(data, (Signature, bytes32, uint256, bytes32[])); + require(HashLib.isLeaf(root, HashLib.hashOffer(offer), leafIndex, proof), InvalidProof()); + bytes32 structHash = keccak256(abi.encode(HashLib.offerTreeTypeHash(proof.length), root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); address signer = ecrecover(digest, sig.v, sig.r, sig.s); @@ -273,16 +273,14 @@ contract MidnightAdapter is IMidnightAdapter { function onBuy( bytes32 marketId, Market memory market, - address buyer, uint256 paidAssets, uint256 boughtCredit, + uint256 buyPendingFeeIncrease, + address buyer, bytes memory ) external returns (bytes32) { MaturityData storage maturityData = _maturities[market.maturity]; uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); - // Works because the adapter never takes on debt and the fee is the one used by midnight. - uint256 buyPendingFeeIncrease = - boughtCredit.mulDivDown(IMidnight(midnight).continuousFee(marketId) * timeToMaturity, WAD); uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; require(msg.sender == midnight, NotMidnight()); @@ -308,11 +306,11 @@ contract MidnightAdapter is IMidnightAdapter { if (timeToMaturity > 0) { uint128 gainedGrowth = ((buyNetCreditIncrease - paidAssets) / timeToMaturity).toUint128(); - _totalAssets += paidAssets + (buyNetCreditIncrease - paidAssets) % timeToMaturity; + totalAssets += (paidAssets + (buyNetCreditIncrease - paidAssets) % timeToMaturity).toUint128(); maturityData.growth += gainedGrowth; currentGrowth += gainedGrowth; } else { - _totalAssets += buyNetCreditIncrease; + totalAssets += buyNetCreditIncrease.toUint128(); } maturityData.netCredit += buyNetCreditIncrease.toUint128(); @@ -341,10 +339,16 @@ contract MidnightAdapter is IMidnightAdapter { return CALLBACK_SUCCESS; } - function onSell(bytes32 marketId, Market memory market, address seller, uint256 sellerAssets, uint256, bytes memory) - external - returns (bytes32) - { + function onSell( + bytes32 marketId, + Market memory market, + uint256 sellerAssets, + uint256, + uint256, + address seller, + address, + bytes memory + ) external returns (bytes32) { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - IMidnight(midnight).pendingFee(marketId, address(this)); @@ -387,9 +391,9 @@ contract MidnightAdapter is IMidnightAdapter { uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.netCredit).toUint128(); maturityData.growth -= removedGrowth; currentGrowth -= removedGrowth; - _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; + totalAssets = (totalAssets + (removedGrowth * timeToMaturity) - removedUnits).toUint128(); } else { - _totalAssets -= removedUnits; + totalAssets -= removedUnits.toUint128(); } maturityData.netCredit -= removedUnits.toUint128(); netCredit[marketId] -= removedUnits.toUint128(); @@ -402,6 +406,7 @@ contract MidnightAdapter is IMidnightAdapter { } } + /// @dev Returns the number of possibly capped durations that are less than or equal to the time to maturity. function durationCount(uint256 maturity) internal view returns (uint256 count) { uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); while (count < durationsLength && timeToMaturity >= packedDurations.get(count)) count++; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 6ebfd9328..d317a81a1 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -4,12 +4,7 @@ pragma solidity >=0.5.0; import {IAdapter} from "../../interfaces/IAdapter.sol"; import {Market} from "lib/midnight/src/interfaces/IMidnight.sol"; -import { - IBuyCallback, - ISellCallback, - ILiquidateCallback, - IRepayCallback -} from "lib/midnight/src/interfaces/ICallbacks.sol"; +import {IBuyCallback, ISellCallback} from "lib/midnight/src/interfaces/ICallbacks.sol"; import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple markets. @@ -28,9 +23,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event SetSkimRecipient(address indexed newSkimRecipient); event Skim(address indexed token, uint256 assets); event WithdrawToVault(bytes32 indexed marketId, uint256 withdrawnAssets, uint256 netCreditDecrease); - event UpdateDurationCountAndAllocations( - uint256 indexed maturity, uint256 oldDurationCount, uint256 newDurationCount, uint256 netCredit - ); + event UpdateDurationCountAndAllocations(uint256 indexed maturity, uint256 newDurationCount, uint256 netCredit); event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); @@ -60,11 +53,11 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { /* FUNCTIONS */ function asset() external view returns (address); - function _totalAssets() external view returns (uint256); + function totalAssets() external view returns (uint128); function lastUpdate() external view returns (uint48); function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); - function availableMaturities() external view returns (uint256); + function availableMaturities() external view returns (uint8); function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); @@ -79,7 +72,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function withdrawToVault(Market memory market, uint256 units) external; function ids(Market memory market) external view returns (bytes32[] memory); function parentVault() external view returns (address); - function accrueInterestView() external view returns (uint48, uint128, uint256, uint256); + function accrueInterestView() external view returns (uint48, uint128, uint128, uint256); function accrueInterest() external returns (uint48, uint128, uint256); function allocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) external @@ -90,17 +83,20 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function onBuy( bytes32 id, Market memory market, - address buyer, uint256 buyerAssets, uint256 units, + uint256 pendingFeeIncrease, + address buyer, bytes memory data ) external returns (bytes32); function onSell( bytes32 id, Market memory market, - address seller, uint256 sellerAssets, uint256 units, + uint256 pendingFeeDecrease, + address seller, + address receiver, bytes memory data ) external returns (bytes32); } diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index 6c30abfe7..9413263b9 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -6,11 +6,12 @@ import "../lib/forge-std/src/Test.sol"; import {MidnightAdapterTest} from "./MidnightAdapterTest.sol"; import {IERC20} from "../src/interfaces/IERC20.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; -import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; +import {Offer, Market, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {Oracle} from "../lib/midnight/test/helpers/Oracle.sol"; import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; +import {maxLif} from "../lib/midnight/src/libraries/ConstantsLib.sol"; contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { using stdStorage for StdStorage; @@ -22,10 +23,10 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { super.setUp(); storedCollaterals[0].lltv = 1e18; - storedCollaterals[0].maxLif = midnight.maxLif(1e18, 0.25e18); + storedCollaterals[0].maxLif = maxLif(1e18, 0.25e18); storedCollaterals[1].lltv = 1e18; - storedCollaterals[1].maxLif = midnight.maxLif(1e18, 0.25e18); - storedOffer.obligation.collateralParams = storedCollaterals; + storedCollaterals[1].maxLif = maxLif(1e18, 0.25e18); + storedOffer.market.collateralParams = storedCollaterals; vm.startPrank(taker); IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); @@ -39,7 +40,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { function buy(uint256 duration, uint256 assets) internal returns (Offer memory) { Offer memory offer = storedOffer; - offer.obligation.maturity = block.timestamp + duration; + offer.market.maturity = block.timestamp + duration; offer.buy = true; offer.tick = MAX_TICK; offer.group = bytes32(duration); @@ -51,19 +52,17 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.callbackData = hex""; vm.startPrank(taker); - midnight.supplyCollateral(offer.obligation, 0, assets / 2, taker); - midnight.supplyCollateral(offer.obligation, 1, assets / 2, taker); - midnight.take( - units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) - ); + midnight.supplyCollateral(offer.market, 0, assets / 2, taker); + midnight.supplyCollateral(offer.market, 1, assets / 2, taker); + midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); vm.stopPrank(); return offer; } - function sell(Obligation memory obligation, uint256 assets) internal { + function sell(Market memory obligation, uint256 assets) internal { Offer memory offer = storedOffer; - offer.obligation = obligation; + offer.market = obligation; offer.buy = false; offer.reduceOnly = true; offer.tick = MAX_TICK; @@ -76,17 +75,15 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.group = bytes32(vm.randomUint()); offer.callbackData = hex""; vm.prank(taker); - midnight.take( - units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) - ); + midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); } - function forceDeallocate(Obligation memory obligation, uint256 assets) internal { + function forceDeallocate(Market memory obligation, uint256 assets) internal { address buyer = makeAddr("buyer"); SetterRatifier approvalRatifier = new SetterRatifier(address(midnight)); Offer memory offer = storedOffer; - offer.obligation = obligation; + offer.market = obligation; offer.buy = true; offer.maker = buyer; offer.tick = MAX_TICK; @@ -102,12 +99,12 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { deal(address(loanToken), buyer, assets); vm.startPrank(buyer); loanToken.approve(address(midnight), type(uint256).max); - midnight.setIsAuthorized(buyer, address(approvalRatifier), true); + midnight.setIsAuthorized(address(approvalRatifier), true, buyer); bytes32 _root = root([offer]); - approvalRatifier.setIsRatified(buyer, _root, true); + approvalRatifier.setIsRootRatified(buyer, _root, true); vm.stopPrank(); - bytes memory data = abi.encode(offer, hex"", _root, proof([offer])); + bytes memory data = abi.encode(offer, abi.encode(_root, 0, proof([offer]))); parentVault.forceDeallocate(address(adapter), data, assets, address(this)); } @@ -130,7 +127,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { function testExitDuration(uint256 durationIndex, uint256 timeToMaturity, uint256 extraSkip) public { durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); uint256 duration = adapter.durations()[durationIndex]; - timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); + timeToMaturity = bound(timeToMaturity, duration, 100 * 365 days); extraSkip = bound(extraSkip, 1, 10 * 365 days); Offer memory offer = buy(timeToMaturity, 1e18); @@ -138,7 +135,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(timeToMaturity - duration + extraSkip); - adapter.updateDurationCountAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.market); assertEq(parentVault.allocation(durationId(duration)), 0); } @@ -148,14 +145,14 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { { durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); uint256 duration = adapter.durations()[durationIndex]; - timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); + timeToMaturity = bound(timeToMaturity, duration, 100 * 365 days); skipAmount = bound(skipAmount, 0, duration * 2); Offer memory offer = buy(timeToMaturity, 1e18); skip(skipAmount); - adapter.updateDurationCountAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.market); uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.updateDurationCountAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.market); assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } @@ -167,9 +164,9 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(7 days); vm.prank(taker); - midnight.repay(offer.obligation, 1e18, taker, address(0), ""); + midnight.repay(offer.market, 1e18, taker, address(0), ""); vm.prank(signerAllocator); - adapter.withdrawToVault(offer.obligation, 0.5e18); + adapter.withdrawToVault(offer.market, 0.5e18); assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); @@ -187,7 +184,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { address[] memory adapters = new address[](1); adapters[0] = address(adapter); parentVault.setAdapters(adapters); - sell(offer.obligation, 0.5e18); + sell(offer.market, 0.5e18); assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); @@ -197,7 +194,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { buy(1 days, 1e18); Offer memory offer = buy(7 days, 1e18); buy(30 days, 1e18); - bytes32 obligationId = _obligationId(offer.obligation); + bytes32 obligationId = _obligationId(offer.market); setMidnightCredit(obligationId, address(adapter), 0); offer.group = bytes32("second buy"); @@ -205,15 +202,13 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.maxUnits = units; vm.startPrank(taker); - midnight.supplyCollateral(offer.obligation, 0, 0.5e18, taker); - midnight.supplyCollateral(offer.obligation, 1, 0.5e18, taker); + midnight.supplyCollateral(offer.market, 0, 0.5e18, taker); + midnight.supplyCollateral(offer.market, 1, 0.5e18, taker); vm.stopPrank(); offer.callbackData = hex""; vm.prank(taker); - midnight.take( - units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) - ); + midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); } function testSellClearsMaturityAndReactivatesSlot() public { @@ -231,10 +226,10 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { address[] memory adapters = new address[](1); adapters[0] = address(adapter); parentVault.setAdapters(adapters); - sell(secondOffer.obligation, 1e18); + sell(secondOffer.market, 1e18); assertEq(adapter.availableMaturities(), 1, "availableMaturities after"); - assertEq(adapter.firstMaturity(), firstOffer.obligation.maturity, "firstMaturity after"); + assertEq(adapter.firstMaturity(), firstOffer.market.maturity, "firstMaturity after"); buy(60 days, 1e18); @@ -248,7 +243,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(1); - forceDeallocate(offer.obligation, 0.5e18); + forceDeallocate(offer.market, 0.5e18); assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 6e5abe0bf..d264d92f8 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -13,13 +13,14 @@ import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol" import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; import {Midnight} from "../lib/midnight/src/Midnight.sol"; -import {IMidnight, Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; +import {IMidnight, Offer, Market, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH} from "../lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; -import {UtilsLib} from "../lib/midnight/src/libraries/UtilsLib.sol"; +import {HashLib} from "../lib/midnight/src/ratifiers/libraries/HashLib.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; +import {maxLif} from "../lib/midnight/src/libraries/ConstantsLib.sol"; struct Step { uint256 assets; @@ -90,7 +91,7 @@ contract MidnightAdapterTest is Test { // Adapter authorizes itself as ratifier vm.prank(address(adapter)); - midnight.setIsAuthorized(address(adapter), address(adapter), true); + midnight.setIsAuthorized(address(adapter), true, address(adapter)); address collToken0 = address(new ERC20Mock(18)); address collToken1 = address(new ERC20Mock(18)); @@ -104,14 +105,10 @@ contract MidnightAdapterTest is Test { } storedCollaterals.push( - CollateralParams({ - token: collToken0, lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracle0 - }) + CollateralParams({token: collToken0, lltv: 1 ether, maxLif: maxLif(1 ether, 0.25e18), oracle: oracle0}) ); storedCollaterals.push( - CollateralParams({ - token: collToken1, lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracle1 - }) + CollateralParams({token: collToken1, lltv: 1 ether, maxLif: maxLif(1 ether, 0.25e18), oracle: oracle1}) ); OracleMock(storedCollaterals[0].oracle).setPrice(ORACLE_PRICE_SCALE); @@ -123,7 +120,7 @@ contract MidnightAdapterTest is Test { storedOffer = Offer({ buy: true, maker: address(adapter), - obligation: Obligation({ + market: Market({ loanToken: address(loanToken), collateralParams: storedCollaterals, maturity: maturity, @@ -135,15 +132,13 @@ contract MidnightAdapterTest is Test { expiry: maturity, tick: MAX_TICK, group: bytes32(0), - session: bytes32(0), callback: address(adapter), callbackData: bytes(""), receiverIfMakerIsSeller: address(0), ratifier: address(adapter), reduceOnly: false, maxUnits: 0, - maxSellerAssets: 0, - maxBuyerAssets: 0 + maxAssets: 0 }); deal(address(loanToken), address(parentVault), 1_000_000e18); @@ -183,15 +178,15 @@ contract MidnightAdapterTest is Test { function testSimpleBuy() public { Offer memory offer = storedOffer; - offer.tick = TickLib.priceToTick(0.95e18); + offer.tick = TickLib.priceToTick(0.95e18, 4); vm.startPrank(taker); IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - midnight.supplyCollateral(offer.obligation, 0, 1_000e18, taker); - midnight.supplyCollateral(offer.obligation, 1, 1_000e18, taker); + midnight.supplyCollateral(offer.market, 0, 1_000e18, taker); + midnight.supplyCollateral(offer.market, 1, 1_000e18, taker); vm.stopPrank(); uint256 assets = 1e18; @@ -202,39 +197,37 @@ contract MidnightAdapterTest is Test { offer.callback = address(adapter); offer.callbackData = hex""; vm.prank(taker); - midnight.take( - units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) - ); + midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); - uint256 remainder = (units - assets) % (offer.obligation.maturity - vm.getBlockTimestamp()); - assertEq(adapter._totalAssets(), assets + remainder, "_totalAssets"); + uint256 remainder = (units - assets) % (offer.market.maturity - vm.getBlockTimestamp()); + assertEq(adapter.totalAssets(), assets + remainder, "_totalAssets"); assertEq(adapter.lastUpdate(), vm.getBlockTimestamp(), "lastUpdate"); assertEq(adapter.firstMaturity(), vm.getBlockTimestamp() + 200, "firstMaturity"); uint256 totalInterest = units - assets; - uint256 duration = offer.obligation.maturity - vm.getBlockTimestamp(); + uint256 duration = offer.market.maturity - vm.getBlockTimestamp(); uint256 newGrowth = totalInterest / duration; assertEq(adapter.currentGrowth(), newGrowth, "currentGrowth"); - MaturityData memory maturityData = adapter.maturities(offer.obligation.maturity); + MaturityData memory maturityData = adapter.maturities(offer.market.maturity); assertEq(maturityData.growth, newGrowth, "growth"); assertEq(maturityData.nextMaturity, 0, "nextMaturity"); - uint256 actualUnits = adapter.netCredit(_obligationId(offer.obligation)); + uint256 actualUnits = adapter.netCredit(_obligationId(offer.market)); assertEq(actualUnits, units, "units"); } function testBuyAtPastMaturityWithExistingGrowth() public { Offer memory offer = storedOffer; - offer.tick = TickLib.priceToTick(0.95e18); - uint256 maturity = offer.obligation.maturity; + offer.tick = TickLib.priceToTick(0.95e18, 4); + uint256 maturity = offer.market.maturity; vm.startPrank(taker); IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 100_000e18); deal(storedCollaterals[1].token, taker, 100_000e18); - midnight.supplyCollateral(offer.obligation, 0, 100_000e18, taker); - midnight.supplyCollateral(offer.obligation, 1, 100_000e18, taker); + midnight.supplyCollateral(offer.market, 0, 100_000e18, taker); + midnight.supplyCollateral(offer.market, 1, 100_000e18, taker); vm.stopPrank(); // Step 1: Buy at maturity M (future) @@ -247,9 +240,7 @@ contract MidnightAdapterTest is Test { offer.callbackData = hex""; vm.prank(taker); - midnight.take( - units1, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) - ); + midnight.take(offer, units1, taker, taker, address(0), "", sign([offer], signerAllocator)); uint256 timeToMaturity = maturity - block.timestamp; uint128 growth1 = uint128((units1 - assets1) / timeToMaturity); @@ -280,7 +271,7 @@ contract MidnightAdapterTest is Test { offer.buy = true; offer.maker = address(adapter); - offer.obligation.loanToken = address(loanToken); + offer.market.loanToken = address(loanToken); uint256 numCollaterals = bound(vm.randomUint(), 1, 3); CollateralParams[] memory collateralParams = new CollateralParams[](numCollaterals); address[] memory tokens = new address[](numCollaterals); @@ -300,14 +291,14 @@ contract MidnightAdapterTest is Test { } for (uint256 i = 0; i < numCollaterals; i++) { collateralParams[i] = CollateralParams({ - token: tokens[i], lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracles[i] + token: tokens[i], lltv: 1 ether, maxLif: maxLif(1 ether, 0.25e18), oracle: oracles[i] }); } - offer.obligation.collateralParams = collateralParams; - offer.obligation.maturity = bound(vm.randomUint(), vm.getBlockTimestamp(), type(uint48).max - 1); - offer.obligation.rcfThreshold = 0; - offer.obligation.enterGate = address(0); - offer.obligation.liquidatorGate = address(0); + offer.market.collateralParams = collateralParams; + offer.market.maturity = bound(vm.randomUint(), vm.getBlockTimestamp(), type(uint48).max - 1); + offer.market.rcfThreshold = 0; + offer.market.enterGate = address(0); + offer.market.liquidatorGate = address(0); offer.start = bound(vm.randomUint(), 0, vm.getBlockTimestamp()); offer.expiry = bound(vm.randomUint(), offer.start, type(uint48).max); @@ -318,8 +309,7 @@ contract MidnightAdapterTest is Test { offer.ratifier = address(adapter); offer.reduceOnly = false; offer.maxUnits = 0; - offer.maxSellerAssets = 0; - offer.maxBuyerAssets = 0; + offer.maxAssets = 0; } function testRatifyIncorrectOfferBadSellSigner(uint256 seed) public { @@ -331,7 +321,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, otherSigner); vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); - adapter.onRatify(offer, _root, data); + adapter.isRatified(offer, data); } function testRatifyIncorrectOfferBadBuySigner(uint256 seed) public { @@ -344,18 +334,18 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, otherSigner); vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); - adapter.onRatify(offer, _root, data); + adapter.isRatified(offer, data); } 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.assume(otherToken != offer.market.loanToken); + offer.market.loanToken = otherToken; bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.LoanAssetMismatch.selector); - adapter.onRatify(offer, _root, data); + adapter.isRatified(offer, data); } function testRatifyIncorrectOwner(uint256 seed, address otherMaker) public { @@ -366,7 +356,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.IncorrectOwner.selector); - adapter.onRatify(offer, _root, data); + adapter.isRatified(offer, data); } function testRatifyIncorrectStart(uint256 seed) public { @@ -376,7 +366,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.IncorrectStart.selector); - adapter.onRatify(offer, _root, data); + adapter.isRatified(offer, data); } function testRatifyIncorrectCallbackAddress(uint256 seed) public { @@ -386,7 +376,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.IncorrectCallbackAddress.selector); - adapter.onRatify(offer, _root, data); + adapter.isRatified(offer, data); } function testRatifyIncorrectExpiry(uint256 seed) public { @@ -394,7 +384,7 @@ contract MidnightAdapterTest is Test { Offer memory offer = _ratificationSetup(); bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); - adapter.onRatify(offer, _root, data); + adapter.isRatified(offer, data); } /* STEPS SETUP */ @@ -413,7 +403,7 @@ contract MidnightAdapterTest is Test { tick: MAX_TICK, callback: address(adapter), callbackData: hex"", - obligation: Obligation({ + market: Market({ loanToken: address(loanToken), collateralParams: storedCollaterals, maturity: 0, @@ -422,13 +412,11 @@ contract MidnightAdapterTest is Test { liquidatorGate: address(0) }), group: bytes32(0), - session: bytes32(0), ratifier: address(adapter), receiverIfMakerIsSeller: address(0), reduceOnly: false, maxUnits: 0, - maxSellerAssets: 0, - maxBuyerAssets: 0 + maxAssets: 0 }); for (uint256 i = 0; i < steps.length; i++) { @@ -437,38 +425,28 @@ contract MidnightAdapterTest is Test { require(timeToMaturity > 0 || step.approxGrowth == 0, "nonzero growth on 0 duration"); uint256 approxInterest = step.approxGrowth * timeToMaturity; offer.group = bytes32(i); - offer.obligation.maturity = step.maturity; + offer.market.maturity = step.maturity; offer.callbackData = hex""; // Compute tick from desired price: price = assets / (assets + approxInterest) uint256 desiredPrice = step.assets.mulDivDown(1e18, step.assets + approxInterest); if (desiredPrice > 1e18) desiredPrice = 1e18; - offer.tick = TickLib.priceToTick(desiredPrice); + offer.tick = TickLib.priceToTick(desiredPrice, 4); uint256 actualPrice = TickLib.tickToPrice(offer.tick); uint256 units = step.assets.mulDivDown(1e18, actualPrice); uint256 actualGrowth = (units - step.assets) / timeToMaturity; uint256 zeroPeriodGain = (units - step.assets) % timeToMaturity; offer.maxUnits = units; - bytes32 obligationId = _obligationId(offer.obligation); + bytes32 obligationId = _obligationId(offer.market); vm.startPrank(taker); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - midnight.supplyCollateral(offer.obligation, 0, 1_000e18, taker); - midnight.supplyCollateral(offer.obligation, 1, 1_000e18, taker); + midnight.supplyCollateral(offer.market, 0, 1_000e18, taker); + midnight.supplyCollateral(offer.market, 1, 1_000e18, taker); uint256 unitsBefore = adapter.netCredit(obligationId); - midnight.take( - units, - taker, - address(0), - "", - taker, - offer, - sign([offer], signerAllocator), - root([offer]), - proof([offer]) - ); + midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); vm.stopPrank(); assertEq(adapter.netCredit(obligationId), unitsBefore + units, "setup: units 1"); @@ -546,10 +524,10 @@ contract MidnightAdapterTest is Test { 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()); + initialGrowth = bound(initialGrowth, 0, 1e24); + _totalAssets = bound(_totalAssets, 0, type(uint128).max / 2); elapsed = bound(elapsed, 0, maxElapsed); setCurrentGrowth(uint128(initialGrowth)); @@ -557,7 +535,7 @@ contract MidnightAdapterTest is Test { setupObligations(steps); uint256 expectedCurrentGrowth = initialGrowth + expectedAddedGrowth; assertEq(adapter.currentGrowth(), expectedCurrentGrowth, "currentGrowth"); - assertEq(adapter._totalAssets(), _totalAssets + expectedAddedAssets, "_totalAssets"); + assertEq(adapter.totalAssets(), _totalAssets + expectedAddedAssets, "_totalAssets"); skip(elapsed); @@ -608,7 +586,7 @@ contract MidnightAdapterTest is Test { function testIds(uint256 collateralCount, uint256 maturity) public view { collateralCount = bound(collateralCount, 0, 5); - Obligation memory obligation; + Market memory obligation; CollateralParams[] memory collateralParams = new CollateralParams[](collateralCount); for (uint256 i = 0; i < collateralCount; i++) { @@ -656,7 +634,7 @@ contract MidnightAdapterTest is Test { } function set_TotalAssets(uint256 _totalAssets) internal { - stdstore.target(address(adapter)).sig("_totalAssets()").checked_write(_totalAssets); + stdstore.target(address(adapter)).enable_packed_slots().sig("totalAssets()").checked_write(_totalAssets); } function removeCopies(uint256[] storage array) internal returns (uint256[] memory) { @@ -673,16 +651,16 @@ contract MidnightAdapterTest is Test { return res; } - function _obligationId(Obligation memory obligation) internal view returns (bytes32) { + function _obligationId(Market memory obligation) internal view returns (bytes32) { return IdLib.toId(obligation, block.chainid, address(midnight)); } function sign(Offer[1] memory offers) internal view returns (bytes memory) { - return ratifierData(root(offers), offers[0].maker); + return ratifierData(root(offers), offers[0].maker, 0, proof(offers)); } function sign(Offer[1] memory offers, address signer) internal view returns (bytes memory) { - return ratifierData(root(offers), signer); + return ratifierData(root(offers), signer, 0, proof(offers)); } function proof(Offer[1] memory) internal pure returns (bytes32[] memory) { @@ -692,36 +670,41 @@ contract MidnightAdapterTest is Test { // assumes the offer is the first one! function proof(Offer[2] memory offers) internal pure returns (bytes32[] memory) { bytes32[] memory path = new bytes32[](1); - path[0] = UtilsLib.hashOffer(offers[1]); + path[0] = HashLib.hashOffer(offers[1]); return path; } function sign(Offer[2] memory offers) internal view returns (bytes memory) { - return ratifierData(root(offers), offers[0].maker, 1); + return ratifierData(root(offers), offers[0].maker, 0, proof(offers)); } function root(Offer memory offer) internal pure returns (bytes32) { - return UtilsLib.hashOffer(offer); + return HashLib.hashOffer(offer); } function root(Offer[1] memory offers) internal pure returns (bytes32) { - return UtilsLib.hashOffer(offers[0]); + return HashLib.hashOffer(offers[0]); } function root(Offer[2] memory offers) internal pure returns (bytes32) { - return UtilsLib.commutativeHash(UtilsLib.hashOffer(offers[0]), UtilsLib.hashOffer(offers[1])); + return HashLib.hashNode(HashLib.hashOffer(offers[0]), HashLib.hashOffer(offers[1])); } function ratifierData(bytes32 _root, address signer) internal view returns (bytes memory) { - return ratifierData(_root, signer, 0); + bytes32[] memory emptyProof = new bytes32[](0); + return ratifierData(_root, signer, 0, emptyProof); } - function ratifierData(bytes32 _root, address signer, uint256 height) internal view returns (bytes memory) { - bytes32 structHash = keccak256(abi.encode(UtilsLib.offerTreeTypeHash(height), _root)); + function ratifierData(bytes32 _root, address signer, uint256 leafIndex, bytes32[] memory _proof) + internal + view + returns (bytes memory) + { + bytes32 structHash = keccak256(abi.encode(HashLib.offerTreeTypeHash(_proof.length), _root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(adapter))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey[signer], digest); - return abi.encode(Signature({v: v, r: r, s: s}), height); + return abi.encode(Signature({v: v, r: r, s: s}), _root, leafIndex, _proof); } /// @dev Returns the concatenation of x and y, sorted lexicographically. From ff2b8f967b64f8038f0ffce8d6859449ed8a8d53 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 20 May 2026 11:23:16 +0200 Subject: [PATCH 62/95] simplify durations management --- src/adapters/MidnightAdapter.sol | 9 +--- src/adapters/libraries/DurationsLib.sol | 21 ++++++--- test/DurationsLibTest.sol | 58 +++++++++++++++++-------- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 30ef04422..95b454eb2 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -62,14 +62,7 @@ contract MidnightAdapter is IMidnightAdapter { SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); adapterId = keccak256(abi.encode("this", address(this))); - bytes32 _packedDurations; - uint256 currentDuration; - for (uint256 i = 0; i < _durations.length; i++) { - require(_durations[i] > currentDuration, IncorrectDuration()); - currentDuration = _durations[i]; - _packedDurations = _packedDurations.set(i, _durations[i]); - } - packedDurations = _packedDurations; + packedDurations = DurationsLib.pack(_durations); durationsLength = _durations.length; } diff --git a/src/adapters/libraries/DurationsLib.sol b/src/adapters/libraries/DurationsLib.sol index 4977c36ce..b27a4e2d5 100644 --- a/src/adapters/libraries/DurationsLib.sol +++ b/src/adapters/libraries/DurationsLib.sol @@ -6,6 +6,7 @@ uint256 constant MAX_DURATIONS = 8; library DurationsLib { error IndexOutOfBounds(); + error IncorrectDuration(); error ValueOutOfBounds(); function get(bytes32 durations, uint256 index) internal pure returns (uint256) { @@ -15,13 +16,21 @@ library DurationsLib { } } - function set(bytes32 durations, uint256 index, uint256 value) internal pure returns (bytes32) { - require(index < MAX_DURATIONS, IndexOutOfBounds()); - require(value <= type(uint32).max, ValueOutOfBounds()); + function pack(uint256[] memory durations) internal pure returns (bytes32) { + require(durations.length <= MAX_DURATIONS, IndexOutOfBounds()); unchecked { - uint256 s = 32 * index; - /// forge-lint: disable-next-line(incorrect-shift) - return bytes32((uint256(durations) & ~(0xFFFFFFFF << s)) | (value << s)); + bytes32 packedDurations; + uint256 currentDuration; + for (uint256 i = 0; i < durations.length; i++) { + uint256 duration = durations[i]; + require(duration > currentDuration, IncorrectDuration()); + require(duration <= type(uint32).max, ValueOutOfBounds()); + + currentDuration = duration; + packedDurations |= bytes32(duration << (32 * i)); + } + + return packedDurations; } } } diff --git a/test/DurationsLibTest.sol b/test/DurationsLibTest.sol index 7a47b69f7..a4374b338 100644 --- a/test/DurationsLibTest.sol +++ b/test/DurationsLibTest.sol @@ -7,6 +7,7 @@ import {DurationsLib, MAX_DURATIONS} from "../src/adapters/libraries/DurationsLi contract DurationsLibTest is Test { using DurationsLib for bytes32; + using DurationsLib for uint256[]; /// forge-config: default.allow_internal_expect_revert = true function testGetInvalidIndex(bytes32 durations, uint256 index) public { @@ -16,34 +17,53 @@ contract DurationsLibTest is Test { } /// forge-config: default.allow_internal_expect_revert = true - function testSetInvalidIndex(bytes32 durations, uint256 index, uint32 value) public { - index = bound(index, MAX_DURATIONS, type(uint256).max); + function testPackInvalidLength() public { + uint256[] memory durations = new uint256[](MAX_DURATIONS + 1); vm.expectRevert(DurationsLib.IndexOutOfBounds.selector); - durations.set(index, value); + durations.pack(); } /// forge-config: default.allow_internal_expect_revert = true - function testSetInvalidValue(bytes32 durations, uint256 index, uint256 value) public { - index = bound(index, 0, MAX_DURATIONS - 1); + function testPackInvalidValue(uint256 value) public { value = bound(value, uint256(type(uint32).max) + 1, type(uint256).max); + uint256[] memory durations = new uint256[](1); + durations[0] = value; + vm.expectRevert(DurationsLib.ValueOutOfBounds.selector); - durations.set(index, value); + durations.pack(); } - function testGetAndSet(bytes32 durations, uint32 value, uint256 index) public pure { - index = bound(index, 0, MAX_DURATIONS - 1); - bytes32 newDurations = durations.set(index, value); - for (uint256 i = 0; i < MAX_DURATIONS; i++) { - if (i == index) { - assertEq(newDurations.get(i), value, "set"); - } else { - assertEq(newDurations.get(i), durations.get(i), "not set"); - } - } + /// forge-config: default.allow_internal_expect_revert = true + function testPackZeroDuration() public { + uint256[] memory durations = new uint256[](1); + + vm.expectRevert(DurationsLib.IncorrectDuration.selector); + durations.pack(); } - function testLayout(bytes32 durations, uint32 value) public pure { - assertEq(uint32(uint256(durations.set(0, value))), value, "first"); - assertEq(uint32(bytes4(durations.set(7, value))), value, "last"); + /// forge-config: default.allow_internal_expect_revert = true + function testPackNonIncreasing(uint32 first, uint32 second) public { + first = uint32(bound(first, 1, type(uint32).max)); + second = uint32(bound(second, 0, first)); + + uint256[] memory durations = new uint256[](2); + durations[0] = first; + durations[1] = second; + + vm.expectRevert(DurationsLib.IncorrectDuration.selector); + durations.pack(); + } + + function testPackAndGet(uint256 length) public pure { + length = bound(length, 0, MAX_DURATIONS); + uint256[] memory durations = new uint256[](length); + for (uint256 i = 0; i < length; i++) { + durations[i] = i + 1; + } + + bytes32 packedDurations = durations.pack(); + for (uint256 i = 0; i < MAX_DURATIONS; i++) { + assertEq(packedDurations.get(i), i < length ? i + 1 : 0); + } } } From 14edbe89b5a68accec0ad999a954d0db6d19ff95 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 20 May 2026 11:23:40 +0200 Subject: [PATCH 63/95] silence block ts lint --- foundry.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/foundry.toml b/foundry.toml index 609ffa579..bdd2bce41 100644 --- a/foundry.toml +++ b/foundry.toml @@ -28,6 +28,7 @@ exclude_lints = [ "unused-import", "asm-keccak256", "mixed-case-function", + "block-timestamp", ] [profile.no_via_ir] From 152f34cd0e13c9a37530ae55afa6d997b5a67fcc Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 20 May 2026 18:19:27 +0200 Subject: [PATCH 64/95] fixes --- src/adapters/MidnightAdapter.sol | 8 ++------ src/adapters/interfaces/IMidnightAdapter.sol | 3 +-- test/MidnightAdapterAllocationUpdateTest.sol | 2 +- test/MidnightAdapterTest.sol | 10 +++++----- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 95b454eb2..ff30da203 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -68,10 +68,6 @@ contract MidnightAdapter is IMidnightAdapter { /* GETTERS */ - function firstMaturity() public view returns (uint48) { - return _maturities[0].nextMaturity; - } - function maturities(uint256 date) public view returns (MaturityData memory) { return _maturities[date]; } @@ -172,7 +168,7 @@ contract MidnightAdapter is IMidnightAdapter { _maturities[0].nextMaturity = newHead; _maturities[newHead].prevMaturity = 0; lastUpdate = uint48(block.timestamp); - emit AccrueInterest(newHead, currentGrowth, totalAssets, removedMaturities); + emit AccrueInterest(newHead, currentGrowth, totalAssets); } return (_maturities[0].nextMaturity, currentGrowth, totalAssets); } @@ -399,7 +395,7 @@ contract MidnightAdapter is IMidnightAdapter { } } - /// @dev Returns the number of possibly capped durations that are less than or equal to the time to maturity. + /// @dev Returns the number of durations in packedDurations that are most the time to maturity. function durationCount(uint256 maturity) internal view returns (uint256 count) { uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); while (count < durationsLength && timeToMaturity >= packedDurations.get(count)) count++; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index d317a81a1..c11374fa9 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -27,7 +27,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); - event AccrueInterest(uint48 firstMaturity, uint128 currentGrowth, uint256 totalAssets, uint256 removedMaturities); + event AccrueInterest(uint48 firstMaturity, uint128 currentGrowth, uint256 totalAssets); event RemoveMaturity(uint256 indexed maturity); event InsertMaturity(uint256 indexed maturity); @@ -55,7 +55,6 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function asset() external view returns (address); function totalAssets() external view returns (uint128); function lastUpdate() external view returns (uint48); - function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); function availableMaturities() external view returns (uint8); function midnight() external view returns (address); diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index 9413263b9..a2163ed9f 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -229,7 +229,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { sell(secondOffer.market, 1e18); assertEq(adapter.availableMaturities(), 1, "availableMaturities after"); - assertEq(adapter.firstMaturity(), firstOffer.market.maturity, "firstMaturity after"); + assertEq(adapter.maturities(0).nextMaturity, firstOffer.market.maturity, "firstMaturity after"); buy(60 days, 1e18); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index d264d92f8..93df0d1eb 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -202,7 +202,7 @@ contract MidnightAdapterTest is Test { uint256 remainder = (units - assets) % (offer.market.maturity - vm.getBlockTimestamp()); assertEq(adapter.totalAssets(), assets + remainder, "_totalAssets"); assertEq(adapter.lastUpdate(), vm.getBlockTimestamp(), "lastUpdate"); - assertEq(adapter.firstMaturity(), vm.getBlockTimestamp() + 200, "firstMaturity"); + assertEq(adapter.maturities(0).nextMaturity, vm.getBlockTimestamp() + 200, "firstMaturity"); uint256 totalInterest = units - assets; uint256 duration = offer.market.maturity - vm.getBlockTimestamp(); @@ -254,12 +254,12 @@ contract MidnightAdapterTest is Test { // Step 3: Trigger accrueInterest so the walk subtracts growth from currentGrowth adapter.accrueInterest(); assertEq(adapter.currentGrowth(), 0, "currentGrowth after accrual should be 0"); - assertEq(adapter.firstMaturity(), 0, "firstMaturity should be sentinel"); + assertEq(adapter.maturities(0).nextMaturity, 0, "firstMaturity should be sentinel"); // In midnight, any seller with debt past maturity is always liquidatable // (isLiquidatable returns true if block.timestamp > maturity && debt > 0), // so we can't test a second buy at past maturity. Just verify accrual state. - assertEq(adapter.firstMaturity(), 0, "past maturity not re-inserted into list"); + assertEq(adapter.maturities(0).nextMaturity, 0, "past maturity not re-inserted into list"); // Note: In midnight, any seller with debt past maturity is always liquidatable, // so the second buy at past maturity from the original test cannot be executed. @@ -477,9 +477,9 @@ contract MidnightAdapterTest is Test { // Check pointer to first element of maturities list if (steps.length > 0) { - assertEq(adapter.firstMaturity(), steps[0].maturity, "firstMaturity"); + assertEq(adapter.maturities(0).nextMaturity, steps[0].maturity, "firstMaturity"); } else { - assertEq(adapter.firstMaturity(), 0, "firstMaturity"); + assertEq(adapter.maturities(0).nextMaturity, 0, "firstMaturity"); } // Check maturities growth and linked list structure From aabff6728dbb3a2911ff01c5aec9cd143fb0834c Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 21 May 2026 00:55:01 +0200 Subject: [PATCH 65/95] inline change --- src/adapters/MidnightAdapter.sol | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index ff30da203..632e61653 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -132,8 +132,8 @@ contract MidnightAdapter is IMidnightAdapter { for (uint256 i = 0; i < zeroedDurationsIds.length; i++) { zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationCount + i))); } - int256 change = -int256(uint256(maturityData.netCredit)); - IVaultV2(parentVault).deallocate(address(this), abi.encode(zeroedDurationsIds, change), 0); + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))), 0); } } @@ -224,9 +224,8 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(marketId, offer.market.maturity, totalNetCreditDecrease); } - int256 change = -totalNetCreditDecrease.toInt256(); emit ForceDeallocate(marketId, sellerAssets, totalNetCreditDecrease); - return (ids(offer.market), change); + return (ids(offer.market), -totalNetCreditDecrease.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. @@ -354,8 +353,8 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(marketId, market.maturity, totalNetCreditDecrease); } - int256 change = -totalNetCreditDecrease.toInt256(); - IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(market), change), sellerAssets); + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(market), -totalNetCreditDecrease.toInt256()), sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); From 4b01b43f3071f4ad314885c86ab09e850cabff3e Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 21 May 2026 01:07:07 +0200 Subject: [PATCH 66/95] do not log first maturity --- src/adapters/MidnightAdapter.sol | 2 +- src/adapters/interfaces/IMidnightAdapter.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 632e61653..cdb97587b 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -168,7 +168,7 @@ contract MidnightAdapter is IMidnightAdapter { _maturities[0].nextMaturity = newHead; _maturities[newHead].prevMaturity = 0; lastUpdate = uint48(block.timestamp); - emit AccrueInterest(newHead, currentGrowth, totalAssets); + emit AccrueInterest(currentGrowth, totalAssets); } return (_maturities[0].nextMaturity, currentGrowth, totalAssets); } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index c11374fa9..93117bb09 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -27,7 +27,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); - event AccrueInterest(uint48 firstMaturity, uint128 currentGrowth, uint256 totalAssets); + event AccrueInterest(uint128 currentGrowth, uint256 totalAssets); event RemoveMaturity(uint256 indexed maturity); event InsertMaturity(uint256 indexed maturity); From f4ea36262259a84e167a43b15fc3b57ebc1b896a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 21 May 2026 15:27:09 +0200 Subject: [PATCH 67/95] renamings --- test/MidnightAdapterAllocationUpdateTest.sol | 16 +++--- test/MidnightAdapterTest.sol | 56 ++++++++++---------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index a2163ed9f..a0f59bccf 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -59,10 +59,10 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { return offer; } - function sell(Market memory obligation, uint256 assets) internal { + function sell(Market memory market, uint256 assets) internal { Offer memory offer = storedOffer; - offer.market = obligation; + offer.market = market; offer.buy = false; offer.reduceOnly = true; offer.tick = MAX_TICK; @@ -78,12 +78,12 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); } - function forceDeallocate(Market memory obligation, uint256 assets) internal { + function forceDeallocate(Market memory market, uint256 assets) internal { address buyer = makeAddr("buyer"); SetterRatifier approvalRatifier = new SetterRatifier(address(midnight)); Offer memory offer = storedOffer; - offer.market = obligation; + offer.market = market; offer.buy = true; offer.maker = buyer; offer.tick = MAX_TICK; @@ -112,8 +112,8 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { return keccak256(abi.encode("duration", duration)); } - function setMidnightCredit(bytes32 obligationId, address account, uint256 credit) internal { - stdstore.target(address(midnight)).sig("creditOf(bytes32,address)").with_key(obligationId).with_key(account) + function setMidnightCredit(bytes32 marketId, address account, uint256 credit) internal { + stdstore.target(address(midnight)).sig("creditOf(bytes32,address)").with_key(marketId).with_key(account) .checked_write(credit); } @@ -194,8 +194,8 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { buy(1 days, 1e18); Offer memory offer = buy(7 days, 1e18); buy(30 days, 1e18); - bytes32 obligationId = _obligationId(offer.market); - setMidnightCredit(obligationId, address(adapter), 0); + bytes32 marketId = _marketId(offer.market); + setMidnightCredit(marketId, address(adapter), 0); offer.group = bytes32("second buy"); uint256 units = 1e18 * 1e18 / TickLib.tickToPrice(MAX_TICK); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 93df0d1eb..4c9aee212 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -56,12 +56,12 @@ contract MidnightAdapterTest is Test { uint256 internal constant MIN_TEST_ASSETS = 10; uint256 internal constant MAX_TEST_ASSETS = 1e24; - // Hardcoded obligation setups + // Hardcoded market setups Step[] internal steps00; Step[] internal steps01; - // Expected values after setting up obligations - mapping(bytes32 obligationId => uint256) expectedUnits; + // Expected values after setting up markets + mapping(bytes32 marketId => uint256) expectedUnits; mapping(uint256 timestamp => uint256) expectedMaturityGrowths; uint256[] internal expectedPositionsList; uint256[] internal expectedMaturitiesList; @@ -212,7 +212,7 @@ contract MidnightAdapterTest is Test { assertEq(maturityData.growth, newGrowth, "growth"); assertEq(maturityData.nextMaturity, 0, "nextMaturity"); - uint256 actualUnits = adapter.netCredit(_obligationId(offer.market)); + uint256 actualUnits = adapter.netCredit(_marketId(offer.market)); assertEq(actualUnits, units, "units"); } @@ -389,7 +389,7 @@ contract MidnightAdapterTest is Test { /* STEPS SETUP */ - function setupObligations(Step[] memory steps) internal { + function setupMarkets(Step[] memory steps) internal { vm.startPrank(taker); IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); @@ -437,7 +437,7 @@ contract MidnightAdapterTest is Test { uint256 actualGrowth = (units - step.assets) / timeToMaturity; uint256 zeroPeriodGain = (units - step.assets) % timeToMaturity; offer.maxUnits = units; - bytes32 obligationId = _obligationId(offer.market); + bytes32 marketId = _marketId(offer.market); vm.startPrank(taker); deal(storedCollaterals[0].token, taker, 1_000e18); @@ -445,19 +445,19 @@ contract MidnightAdapterTest is Test { midnight.supplyCollateral(offer.market, 0, 1_000e18, taker); midnight.supplyCollateral(offer.market, 1, 1_000e18, taker); - uint256 unitsBefore = adapter.netCredit(obligationId); + uint256 unitsBefore = adapter.netCredit(marketId); midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); vm.stopPrank(); - assertEq(adapter.netCredit(obligationId), unitsBefore + units, "setup: units 1"); + assertEq(adapter.netCredit(marketId), unitsBefore + units, "setup: units 1"); - expectedUnits[obligationId] += units; + expectedUnits[marketId] += units; expectedMaturityGrowths[step.maturity] += actualGrowth; if (timeToMaturity > 0) { expectedAddedGrowth += actualGrowth.toUint128(); } expectedAddedAssets += step.assets + zeroPeriodGain; - expectedPositionsList.push(uint256(obligationId)); + expectedPositionsList.push(uint256(marketId)); expectedMaturitiesList.push(step.maturity); } expectedPositionsList = removeCopies(expectedPositionsList); @@ -473,7 +473,7 @@ contract MidnightAdapterTest is Test { } indices = vm.shuffle(indices); - setupObligations(steps); + setupMarkets(steps); // Check pointer to first element of maturities list if (steps.length > 0) { @@ -502,8 +502,8 @@ contract MidnightAdapterTest is Test { // Check positions growth and size for (uint256 i = 0; i < expectedPositionsList.length; i++) { - bytes32 obligationId = bytes32(expectedPositionsList[i]); - assertEq(adapter.netCredit(obligationId), expectedUnits[obligationId], "units"); + bytes32 marketId = bytes32(expectedPositionsList[i]); + assertEq(adapter.netCredit(marketId), expectedUnits[marketId], "units"); } } @@ -532,7 +532,7 @@ contract MidnightAdapterTest is Test { setCurrentGrowth(uint128(initialGrowth)); set_TotalAssets(_totalAssets); - setupObligations(steps); + setupMarkets(steps); uint256 expectedCurrentGrowth = initialGrowth + expectedAddedGrowth; assertEq(adapter.currentGrowth(), expectedCurrentGrowth, "currentGrowth"); assertEq(adapter.totalAssets(), _totalAssets + expectedAddedAssets, "_totalAssets"); @@ -586,27 +586,27 @@ contract MidnightAdapterTest is Test { function testIds(uint256 collateralCount, uint256 maturity) public view { collateralCount = bound(collateralCount, 0, 5); - Market memory obligation; + Market memory market; CollateralParams[] memory collateralParams = new CollateralParams[](collateralCount); for (uint256 i = 0; i < collateralCount; i++) { collateralParams[i].token = address(uint160(i)); } - obligation.collateralParams = storedCollaterals; - obligation.maturity = bound(maturity, 1, 700 days); + market.collateralParams = storedCollaterals; + market.maturity = bound(maturity, 1, 700 days); - bytes32[] memory ids = adapter.ids(obligation); + bytes32[] memory ids = adapter.ids(market); assertEq(ids[0], adapter.adapterId()); - for (uint256 i = 0; i < obligation.collateralParams.length; i++) { - assertEq(ids[i * 2 + 1], keccak256(abi.encode("collateralToken", obligation.collateralParams[i].token))); + for (uint256 i = 0; i < market.collateralParams.length; i++) { + assertEq(ids[i * 2 + 1], keccak256(abi.encode("collateralToken", market.collateralParams[i].token))); assertEq( ids[i * 2 + 2], keccak256( abi.encode( "collateral", - obligation.collateralParams[i].token, - obligation.collateralParams[i].oracle, - obligation.collateralParams[i].lltv + market.collateralParams[i].token, + market.collateralParams[i].oracle, + market.collateralParams[i].lltv ) ) ); @@ -615,16 +615,16 @@ contract MidnightAdapterTest is Test { uint256[] memory durations = adapter.durations(); uint256 durationIdCount = 0; for (uint256 i = 0; i < durations.length; i++) { - if ((obligation.maturity - block.timestamp) >= durations[i]) { + if ((market.maturity - block.timestamp) >= durations[i]) { assertEq( - ids[1 + obligation.collateralParams.length * 2 + durationIdCount], + ids[1 + market.collateralParams.length * 2 + durationIdCount], keccak256(abi.encode("duration", durations[i])) ); durationIdCount++; } } - assertEq(ids.length, 1 + obligation.collateralParams.length * 2 + durationIdCount); + assertEq(ids.length, 1 + market.collateralParams.length * 2 + durationIdCount); } /* UTILITIES */ @@ -651,8 +651,8 @@ contract MidnightAdapterTest is Test { return res; } - function _obligationId(Market memory obligation) internal view returns (bytes32) { - return IdLib.toId(obligation, block.chainid, address(midnight)); + function _marketId(Market memory market) internal view returns (bytes32) { + return IdLib.toId(market, block.chainid, address(midnight)); } function sign(Offer[1] memory offers) internal view returns (bytes memory) { From 5c017c3faa722c4128ea8273ed84259da8bef4a0 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 21 May 2026 15:31:09 +0200 Subject: [PATCH 68/95] max pending maturities cosntant --- src/adapters/MidnightAdapter.sol | 3 ++- src/adapters/interfaces/IMidnightAdapter.sol | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index cdb97587b..ecab44d92 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -48,7 +48,8 @@ contract MidnightAdapter is IMidnightAdapter { uint48 public lastUpdate; /// @dev Maximum steps of an accrual. /// @dev A maturity uses an availability slot iff it has some units and is > now after accrual. - uint8 public availableMaturities = 50; + uint8 public constant MAX_PENDING_MATURITIES = 50; + uint8 public availableMaturities = MAX_PENDING_MATURITIES; mapping(uint256 timestamp => MaturityData) public _maturities; mapping(bytes32 marketId => uint256) public netCredit; /* CONSTRUCTOR */ diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 93117bb09..d2b5ba30b 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -57,6 +57,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function lastUpdate() external view returns (uint48); function currentGrowth() external view returns (uint128); function availableMaturities() external view returns (uint8); + function MAX_PENDING_MATURITIES() external view returns (uint8); function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); From 2c04dfc1b7042b29885f5ba0d63e2afa2639d78b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Jun 2026 13:51:42 +0200 Subject: [PATCH 69/95] per market growth tracking, var renames --- src/adapters/MidnightAdapter.sol | 122 +-- src/adapters/interfaces/IMidnightAdapter.sol | 12 +- test/MidnightAdapterAllocationUpdateTest.sol | 251 ----- test/MidnightAdapterTest.sol | 920 ++++++++++++------- 4 files changed, 676 insertions(+), 629 deletions(-) delete mode 100644 test/MidnightAdapterAllocationUpdateTest.sol diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index ecab44d92..aa3793169 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -13,10 +13,10 @@ import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; import {IVaultV2} from "../interfaces/IVaultV2.sol"; -import {IMidnightAdapter, MaturityData, IAdapter} from "./interfaces/IMidnightAdapter.sol"; +import {IMidnightAdapter, MaturityData, MarketData, IAdapter} from "./interfaces/IMidnightAdapter.sol"; import {DurationsLib} from "./libraries/DurationsLib.sol"; -/// @dev Approximates held assets by linearly accounting for interest separately for each market. +/// @dev Approximates held assets by linearly accounting for interest per market, aggregated by maturity. /// @dev Losses are immediately 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 market hit by the loss. /// @dev The adapter must have the allocator role in its parent vault to be able to buy & sell on markets. @@ -51,7 +51,7 @@ contract MidnightAdapter is IMidnightAdapter { uint8 public constant MAX_PENDING_MATURITIES = 50; uint8 public availableMaturities = MAX_PENDING_MATURITIES; mapping(uint256 timestamp => MaturityData) public _maturities; - mapping(bytes32 marketId => uint256) public netCredit; + mapping(bytes32 marketId => MarketData) public _markets; /* CONSTRUCTOR */ constructor(address _parentVault, address _midnight, uint256[] memory _durations) { @@ -73,6 +73,11 @@ contract MidnightAdapter is IMidnightAdapter { return _maturities[date]; } + /// @dev Returns the growth of the market. Can be stale after maturity. + function markets(bytes32 marketId) public view returns (MarketData memory) { + return _markets[marketId]; + } + function durations() public view returns (uint256[] memory) { uint256[] memory _durations = new uint256[](durationsLength); for (uint256 i = 0; i < durationsLength; i++) { @@ -104,21 +109,21 @@ contract MidnightAdapter is IMidnightAdapter { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); bytes32 marketId = IdLib.toId(market, block.chainid, midnight); IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); - uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - IMidnight(midnight).pendingFee(marketId, address(this)); - // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[marketId] - newNetCredit; + // current net credit cannot be > accounted net credit + uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit; accrueInterest(); updateDurationCountAndAllocations(market); - if (totalNetCreditDecrease > 0) { - removeUnits(marketId, market.maturity, totalNetCreditDecrease); + if (netCreditDecrease > 0) { + removeNetCredit(marketId, market.maturity, netCreditDecrease); } IVaultV2(parentVault) - .deallocate(address(this), abi.encode(ids(market), -totalNetCreditDecrease.toInt256()), withdrawnAssets); - emit WithdrawToVault(marketId, withdrawnAssets, totalNetCreditDecrease); + .deallocate(address(this), abi.encode(ids(market), -netCreditDecrease.toInt256()), withdrawnAssets); + emit WithdrawToVault(marketId, withdrawnAssets, netCreditDecrease); } function updateDurationCountAndAllocations(Market memory market) public { @@ -212,21 +217,19 @@ contract MidnightAdapter is IMidnightAdapter { uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, marketId, offer, sellerAssets); IMidnight(midnight).take(offer, takeUnits, address(this), address(this), address(0), hex"", ratifierData); - require(IMidnight(midnight).debtOf(marketId, address(this)) == 0, NoBorrowing()); - accrueInterest(); updateDurationCountAndAllocations(offer.market); - uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - IMidnight(midnight).pendingFee(marketId, address(this)); - // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[marketId] - newNetCredit; + // current net credit cannot be > accounted net credit + uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit; - if (totalNetCreditDecrease > 0) { - removeUnits(marketId, offer.market.maturity, totalNetCreditDecrease); + if (netCreditDecrease > 0) { + removeNetCredit(marketId, offer.market.maturity, netCreditDecrease); } - emit ForceDeallocate(marketId, sellerAssets, totalNetCreditDecrease); - return (ids(offer.market), -totalNetCreditDecrease.toInt256()); + emit ForceDeallocate(marketId, sellerAssets, netCreditDecrease); + return (ids(offer.market), -netCreditDecrease.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. @@ -269,47 +272,47 @@ contract MidnightAdapter is IMidnightAdapter { bytes memory ) external returns (bytes32) { MaturityData storage maturityData = _maturities[market.maturity]; + MarketData storage marketData = _markets[marketId]; uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); - uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; + uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); - require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); + require(boughtNetCredit >= paidAssets, BuyAtLoss()); - uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - IMidnight(midnight).pendingFee(marketId, address(this)); - int256 change = newNetCredit.toInt256() - netCredit[marketId].toInt256(); + int256 netCreditChange = currentNetCredit.toInt256() - uint256(marketData.netCredit).toInt256(); accrueInterest(); updateDurationCountAndAllocations(market); - // change is at most buyNetCreditIncrease - if (change < buyNetCreditIncrease.toInt256()) { - // forge-lint: disable-next-item(unsafe-typecast) safe because change < buyNetCreditIncrease (checked + // netCreditChange is at most boughtNetCredit + if (netCreditChange < boughtNetCredit.toInt256()) { + // forge-lint: disable-next-item(unsafe-typecast) safe because netCreditChange < boughtNetCredit (checked // above). - uint256 loss = uint256(int256(buyNetCreditIncrease) - change); - removeUnits(marketId, market.maturity, loss); + uint256 netCreditLoss = uint256(int256(boughtNetCredit) - netCreditChange); + removeNetCredit(marketId, market.maturity, netCreditLoss); } - IVaultV2(parentVault).allocate(address(this), abi.encode(ids(market), change), paidAssets); + IVaultV2(parentVault).allocate(address(this), abi.encode(ids(market), netCreditChange), paidAssets); if (timeToMaturity > 0) { - uint128 gainedGrowth = ((buyNetCreditIncrease - paidAssets) / timeToMaturity).toUint128(); - totalAssets += (paidAssets + (buyNetCreditIncrease - paidAssets) % timeToMaturity).toUint128(); - maturityData.growth += gainedGrowth; - currentGrowth += gainedGrowth; + uint256 interest = boughtNetCredit - paidAssets; + uint128 growthIncrease = (interest / timeToMaturity).toUint128(); + totalAssets += (paidAssets + interest % timeToMaturity).toUint128(); + marketData.growth += growthIncrease; + maturityData.growth += growthIncrease; + currentGrowth += growthIncrease; } else { - totalAssets += buyNetCreditIncrease.toUint128(); + totalAssets += boughtNetCredit.toUint128(); } - maturityData.netCredit += buyNetCreditIncrease.toUint128(); - netCredit[marketId] += buyNetCreditIncrease.toUint128(); + maturityData.netCredit += boughtNetCredit.toUint128(); + marketData.netCredit += boughtNetCredit.toUint128(); // Insert the maturity in the list if needed - if ( - maturityData.netCredit == buyNetCreditIncrease && buyNetCreditIncrease > 0 - && market.maturity > block.timestamp - ) { + if (maturityData.netCredit == boughtNetCredit && boughtNetCredit > 0 && market.maturity > block.timestamp) { availableMaturities--; uint48 prevMaturity = 0; uint48 nextMaturity = _maturities[0].nextMaturity; @@ -324,7 +327,7 @@ contract MidnightAdapter is IMidnightAdapter { emit InsertMaturity(market.maturity); } - emit Buy(marketId, paidAssets, buyNetCreditIncrease, change); + emit Buy(marketId, paidAssets, boughtNetCredit, netCreditChange); return CALLBACK_SUCCESS; } @@ -339,10 +342,10 @@ contract MidnightAdapter is IMidnightAdapter { bytes memory ) external returns (bytes32) { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); - uint256 newNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - IMidnight(midnight).pendingFee(marketId, address(this)); - // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[marketId] - newNetCredit; + // current net credit cannot be > accounted net credit + uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit; require(msg.sender == midnight, NotMidnight()); require(seller == address(this), NotSelf()); @@ -350,12 +353,12 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationCountAndAllocations(market); - if (totalNetCreditDecrease > 0) { - removeUnits(marketId, market.maturity, totalNetCreditDecrease); + if (netCreditDecrease > 0) { + removeNetCredit(marketId, market.maturity, netCreditDecrease); } IVaultV2(parentVault) - .deallocate(address(this), abi.encode(ids(market), -totalNetCreditDecrease.toInt256()), sellerAssets); + .deallocate(address(this), abi.encode(ids(market), -netCreditDecrease.toInt256()), sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -364,30 +367,31 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); - emit Sell(marketId, sellerAssets, totalNetCreditDecrease); + emit Sell(marketId, sellerAssets, netCreditDecrease); return CALLBACK_SUCCESS; } /* INTERNAL FUNCTIONS */ - /// @dev Removes units from tracking. - /// @dev Changes the implied price of the market as little as possible. - function removeUnits(bytes32 marketId, uint256 maturity, uint256 removedUnits) internal { + /// @dev Removes netCredit proportionally from current accounted assets and future growth. + function removeNetCredit(bytes32 marketId, uint256 maturity, uint256 removedNetCredit) internal { MaturityData storage maturityData = _maturities[maturity]; + MarketData storage marketData = _markets[marketId]; if (maturity > block.timestamp) { uint256 timeToMaturity = maturity - block.timestamp; - uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.netCredit).toUint128(); - maturityData.growth -= removedGrowth; - currentGrowth -= removedGrowth; - totalAssets = (totalAssets + (removedGrowth * timeToMaturity) - removedUnits).toUint128(); + uint128 growthDecrease = marketData.growth.mulDivUp(removedNetCredit, marketData.netCredit).toUint128(); + marketData.growth -= growthDecrease; + maturityData.growth -= growthDecrease; + currentGrowth -= growthDecrease; + totalAssets = (totalAssets + (growthDecrease * timeToMaturity) - removedNetCredit).toUint128(); } else { - totalAssets -= removedUnits.toUint128(); + totalAssets -= removedNetCredit.toUint128(); } - maturityData.netCredit -= removedUnits.toUint128(); - netCredit[marketId] -= removedUnits.toUint128(); + maturityData.netCredit -= removedNetCredit.toUint128(); + marketData.netCredit -= removedNetCredit.toUint128(); - if (removedUnits > 0 && maturityData.netCredit == 0 && maturity > block.timestamp) { + if (removedNetCredit > 0 && maturityData.netCredit == 0 && maturity > block.timestamp) { availableMaturities++; _maturities[maturityData.prevMaturity].nextMaturity = maturityData.nextMaturity; _maturities[maturityData.nextMaturity].prevMaturity = maturityData.prevMaturity; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index d2b5ba30b..a867ab6b9 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -17,6 +17,11 @@ struct MaturityData { uint8 durationCount; } +struct MarketData { + uint128 netCredit; + uint128 growth; +} + interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { /* EVENTS */ @@ -25,7 +30,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event WithdrawToVault(bytes32 indexed marketId, uint256 withdrawnAssets, uint256 netCreditDecrease); event UpdateDurationCountAndAllocations(uint256 indexed maturity, uint256 newDurationCount, uint256 netCredit); event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); - event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, int256 change); + event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, int256 netCreditChange); event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); event AccrueInterest(uint128 currentGrowth, uint256 totalAssets); event RemoveMaturity(uint256 indexed maturity); @@ -43,7 +48,6 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { error IncorrectStart(); error InvalidProof(); error LoanAssetMismatch(); - error NoBorrowing(); error NoDebtCreation(); error NotAuthorized(); error NotMidnight(); @@ -61,7 +65,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); - function netCredit(bytes32 marketId) external view returns (uint256); + function _markets(bytes32 marketId) external view returns (uint128 netCredit, uint128 growth); function maturities(uint256 date) external view returns (MaturityData memory); function skimRecipient() external view returns (address); function setSkimRecipient(address newSkimRecipient) external; @@ -69,7 +73,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); function updateDurationCountAndAllocations(Market memory market) external; - function withdrawToVault(Market memory market, uint256 units) external; + function withdrawToVault(Market memory market, uint256 withdrawnAssets) external; function ids(Market memory market) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint128, uint256); diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol deleted file mode 100644 index a0f59bccf..000000000 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ /dev/null @@ -1,251 +0,0 @@ -// 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 {MidnightAdapterTest} from "./MidnightAdapterTest.sol"; -import {IERC20} from "../src/interfaces/IERC20.sol"; -import {MathLib} from "../src/libraries/MathLib.sol"; -import {Offer, Market, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; -import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; -import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; -import {Oracle} from "../lib/midnight/test/helpers/Oracle.sol"; -import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; -import {maxLif} from "../lib/midnight/src/libraries/ConstantsLib.sol"; - -contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { - using stdStorage for StdStorage; - using MathLib for uint256; - - address internal allocator; - - function setUp() public override { - super.setUp(); - - storedCollaterals[0].lltv = 1e18; - storedCollaterals[0].maxLif = maxLif(1e18, 0.25e18); - storedCollaterals[1].lltv = 1e18; - storedCollaterals[1].maxLif = maxLif(1e18, 0.25e18); - storedOffer.market.collateralParams = storedCollaterals; - - vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); - deal(storedCollaterals[0].token, taker, 1_000e18); - deal(storedCollaterals[1].token, taker, 1_000e18); - loanToken.approve(address(midnight), type(uint256).max); - vm.stopPrank(); - } - - function buy(uint256 duration, uint256 assets) internal returns (Offer memory) { - Offer memory offer = storedOffer; - - offer.market.maturity = block.timestamp + duration; - offer.buy = true; - offer.tick = MAX_TICK; - offer.group = bytes32(duration); - uint256 price = TickLib.tickToPrice(MAX_TICK); - uint256 units = assets * 1e18 / price; - offer.maxUnits = units; - offer.expiry = block.timestamp; - offer.callback = address(adapter); - offer.callbackData = hex""; - - vm.startPrank(taker); - midnight.supplyCollateral(offer.market, 0, assets / 2, taker); - midnight.supplyCollateral(offer.market, 1, assets / 2, taker); - midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); - vm.stopPrank(); - return offer; - } - - function sell(Market memory market, uint256 assets) internal { - Offer memory offer = storedOffer; - - offer.market = market; - offer.buy = false; - offer.reduceOnly = true; - offer.tick = MAX_TICK; - uint256 price = TickLib.tickToPrice(MAX_TICK); - uint256 units = assets * 1e18 / price; - offer.maxUnits = units; - offer.expiry = block.timestamp; - offer.callback = address(adapter); - offer.receiverIfMakerIsSeller = address(adapter); - offer.group = bytes32(vm.randomUint()); - offer.callbackData = hex""; - vm.prank(taker); - midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); - } - - function forceDeallocate(Market memory market, uint256 assets) internal { - address buyer = makeAddr("buyer"); - SetterRatifier approvalRatifier = new SetterRatifier(address(midnight)); - - Offer memory offer = storedOffer; - offer.market = market; - offer.buy = true; - offer.maker = buyer; - offer.tick = MAX_TICK; - uint256 price = TickLib.tickToPrice(MAX_TICK); - uint256 units = assets * 1e18 / price; - offer.maxUnits = units; - offer.expiry = block.timestamp; - offer.callback = address(0); - offer.callbackData = hex""; - offer.ratifier = address(approvalRatifier); - offer.group = bytes32(vm.randomUint()); - - deal(address(loanToken), buyer, assets); - vm.startPrank(buyer); - loanToken.approve(address(midnight), type(uint256).max); - midnight.setIsAuthorized(address(approvalRatifier), true, buyer); - bytes32 _root = root([offer]); - approvalRatifier.setIsRootRatified(buyer, _root, true); - vm.stopPrank(); - - bytes memory data = abi.encode(offer, abi.encode(_root, 0, proof([offer]))); - parentVault.forceDeallocate(address(adapter), data, assets, address(this)); - } - - function durationId(uint256 duration) internal pure returns (bytes32) { - return keccak256(abi.encode("duration", duration)); - } - - function setMidnightCredit(bytes32 marketId, address account, uint256 credit) internal { - stdstore.target(address(midnight)).sig("creditOf(bytes32,address)").with_key(marketId).with_key(account) - .checked_write(credit); - } - - function testExactDuration(uint32 durationIndex) public { - durationIndex = uint32(bound(durationIndex, 0, adapter.durationsLength() - 1)); - uint256 duration = adapter.durations()[durationIndex]; - buy(duration, 1e18); - assertEq(parentVault.allocation(durationId(duration)), 1e18); - } - - function testExitDuration(uint256 durationIndex, uint256 timeToMaturity, uint256 extraSkip) public { - durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); - uint256 duration = adapter.durations()[durationIndex]; - timeToMaturity = bound(timeToMaturity, duration, 100 * 365 days); - extraSkip = bound(extraSkip, 1, 10 * 365 days); - - Offer memory offer = buy(timeToMaturity, 1e18); - assertEq(parentVault.allocation(durationId(duration)), 1e18); - - skip(timeToMaturity - duration + extraSkip); - - adapter.updateDurationCountAndAllocations(offer.market); - - assertEq(parentVault.allocation(durationId(duration)), 0); - } - - function testRepeatDeallocateExpiredDurations(uint256 durationIndex, uint256 timeToMaturity, uint256 skipAmount) - public - { - durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); - uint256 duration = adapter.durations()[durationIndex]; - timeToMaturity = bound(timeToMaturity, duration, 100 * 365 days); - skipAmount = bound(skipAmount, 0, duration * 2); - - Offer memory offer = buy(timeToMaturity, 1e18); - skip(skipAmount); - adapter.updateDurationCountAndAllocations(offer.market); - uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.updateDurationCountAndAllocations(offer.market); - assertEq(parentVault.allocation(durationId(duration)), savedAllocation); - } - - function testUpdateOnWithdraw() public { - Offer memory offer = buy(7 days, 1e18); - assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); - assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); - - skip(7 days); - - vm.prank(taker); - midnight.repay(offer.market, 1e18, taker, address(0), ""); - vm.prank(signerAllocator); - adapter.withdrawToVault(offer.market, 0.5e18); - - assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); - assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); - } - - function testUpdateOnSell() public { - Offer memory offer = buy(7 days, 1e18); - assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); - assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); - - skip(1); - - parentVault.setTotalAssets(1e18); - parentVault.setAdaptersLength(1); - address[] memory adapters = new address[](1); - adapters[0] = address(adapter); - parentVault.setAdapters(adapters); - sell(offer.market, 0.5e18); - - assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); - assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); - } - - function testOnBuyRemovesAndReinsertsMaturity() public { - buy(1 days, 1e18); - Offer memory offer = buy(7 days, 1e18); - buy(30 days, 1e18); - bytes32 marketId = _marketId(offer.market); - setMidnightCredit(marketId, address(adapter), 0); - - offer.group = bytes32("second buy"); - uint256 units = 1e18 * 1e18 / TickLib.tickToPrice(MAX_TICK); - offer.maxUnits = units; - - vm.startPrank(taker); - midnight.supplyCollateral(offer.market, 0, 0.5e18, taker); - midnight.supplyCollateral(offer.market, 1, 0.5e18, taker); - vm.stopPrank(); - - offer.callbackData = hex""; - vm.prank(taker); - midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); - } - - function testSellClearsMaturityAndReactivatesSlot() public { - Offer memory firstOffer; - Offer memory secondOffer; - for (uint256 i = 0; i < 50; i++) { - Offer memory offer = buy(1 days + i, 1e18); - if (i == 0) firstOffer = offer; - if (i == 1) secondOffer = offer; - } - assertEq(adapter.availableMaturities(), 0, "availableMaturities before"); - - parentVault.setTotalAssets(1e18); - parentVault.setAdaptersLength(1); - address[] memory adapters = new address[](1); - adapters[0] = address(adapter); - parentVault.setAdapters(adapters); - sell(secondOffer.market, 1e18); - - assertEq(adapter.availableMaturities(), 1, "availableMaturities after"); - assertEq(adapter.maturities(0).nextMaturity, firstOffer.market.maturity, "firstMaturity after"); - - buy(60 days, 1e18); - - assertEq(adapter.availableMaturities(), 0, "availableMaturities final"); - } - - function testUpdateOnForceDeallocate() public { - Offer memory offer = buy(7 days, 1e18); - assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); - assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); - - skip(1); - - forceDeallocate(offer.market, 0.5e18); - - assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); - assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); - } -} diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 4c9aee212..1f1bf9754 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -3,12 +3,13 @@ pragma solidity ^0.8.0; import "../lib/forge-std/src/Test.sol"; -import {MidnightAdapter, MaturityData} from "../src/adapters/MidnightAdapter.sol"; +import {MidnightAdapter} from "../src/adapters/MidnightAdapter.sol"; import {MidnightAdapterFactory} from "../src/adapters/MidnightAdapterFactory.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 {IAdapter} from "../src/interfaces/IAdapter.sol"; import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol"; import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; @@ -20,13 +21,24 @@ import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; -import {maxLif} from "../lib/midnight/src/libraries/ConstantsLib.sol"; +import {maxLif, CALLBACK_SUCCESS} from "../lib/midnight/src/libraries/ConstantsLib.sol"; +import {TakeAmountsLib} from "../lib/midnight/src/periphery/TakeAmountsLib.sol"; +import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; -struct Step { - uint256 assets; - uint256 approxGrowth; - uint256 maturity; - CollateralParams[] collaterals; +contract ExtraAssetsAdapter is IAdapter { + uint256 public realAssets; + + function setRealAssets(uint256 newRealAssets) external { + realAssets = newRealAssets; + } + + function allocate(bytes memory, uint256, bytes4, address) external pure returns (bytes32[] memory, int256) { + return (new bytes32[](0), 0); + } + + function deallocate(bytes memory, uint256, bytes4, address) external pure returns (bytes32[] memory, int256) { + return (new bytes32[](0), 0); + } } contract MidnightAdapterTest is Test { @@ -48,6 +60,7 @@ contract MidnightAdapterTest is Test { address internal tradingFeeRecipient = makeAddr("tradingFeeRecipient"); CollateralParams[] internal storedCollaterals; CollateralParams[] internal storedSingleCollateral; + ExtraAssetsAdapter internal extraAssetsAdapter; mapping(address => uint256) internal privateKey; @@ -56,18 +69,6 @@ contract MidnightAdapterTest is Test { uint256 internal constant MIN_TEST_ASSETS = 10; uint256 internal constant MAX_TEST_ASSETS = 1e24; - // Hardcoded market setups - Step[] internal steps00; - Step[] internal steps01; - - // Expected values after setting up markets - mapping(bytes32 marketId => uint256) expectedUnits; - mapping(uint256 timestamp => uint256) expectedMaturityGrowths; - uint256[] internal expectedPositionsList; - uint256[] internal expectedMaturitiesList; - uint256 internal expectedAddedGrowth; - uint256 internal expectedAddedAssets; - uint256[] internal allDurations = [1 days, 7 days, 30 days, 90 days, 180 days]; function setUp() public virtual { @@ -105,10 +106,10 @@ contract MidnightAdapterTest is Test { } storedCollaterals.push( - CollateralParams({token: collToken0, lltv: 1 ether, maxLif: maxLif(1 ether, 0.25e18), oracle: oracle0}) + CollateralParams({token: collToken0, lltv: 1e18, maxLif: maxLif(1e18, 0.25e18), oracle: oracle0}) ); storedCollaterals.push( - CollateralParams({token: collToken1, lltv: 1 ether, maxLif: maxLif(1 ether, 0.25e18), oracle: oracle1}) + CollateralParams({token: collToken1, lltv: 1e18, maxLif: maxLif(1e18, 0.25e18), oracle: oracle1}) ); OracleMock(storedCollaterals[0].oracle).setPrice(ORACLE_PRICE_SCALE); @@ -143,126 +144,35 @@ contract MidnightAdapterTest is Test { 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 testSimpleBuy() public { - Offer memory offer = storedOffer; - offer.tick = TickLib.priceToTick(0.95e18, 4); - vm.startPrank(taker); IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - midnight.supplyCollateral(offer.market, 0, 1_000e18, taker); - midnight.supplyCollateral(offer.market, 1, 1_000e18, taker); + loanToken.approve(address(midnight), type(uint256).max); + midnight.setIsAuthorized(address(this), true, taker); vm.stopPrank(); - uint256 assets = 1e18; - uint256 price = TickLib.tickToPrice(offer.tick); - uint256 units = assets * 1e18 / price; - - offer.maxUnits = units; - offer.callback = address(adapter); - offer.callbackData = hex""; - vm.prank(taker); - midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); - - uint256 remainder = (units - assets) % (offer.market.maturity - vm.getBlockTimestamp()); - assertEq(adapter.totalAssets(), assets + remainder, "_totalAssets"); - assertEq(adapter.lastUpdate(), vm.getBlockTimestamp(), "lastUpdate"); - assertEq(adapter.maturities(0).nextMaturity, vm.getBlockTimestamp() + 200, "firstMaturity"); - - uint256 totalInterest = units - assets; - uint256 duration = offer.market.maturity - vm.getBlockTimestamp(); - uint256 newGrowth = totalInterest / duration; - assertEq(adapter.currentGrowth(), newGrowth, "currentGrowth"); - MaturityData memory maturityData = adapter.maturities(offer.market.maturity); - assertEq(maturityData.growth, newGrowth, "growth"); - assertEq(maturityData.nextMaturity, 0, "nextMaturity"); - - uint256 actualUnits = adapter.netCredit(_marketId(offer.market)); - assertEq(actualUnits, units, "units"); - } - - function testBuyAtPastMaturityWithExistingGrowth() public { - Offer memory offer = storedOffer; - offer.tick = TickLib.priceToTick(0.95e18, 4); - uint256 maturity = offer.market.maturity; - - vm.startPrank(taker); IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); - deal(storedCollaterals[0].token, taker, 100_000e18); - deal(storedCollaterals[1].token, taker, 100_000e18); - midnight.supplyCollateral(offer.market, 0, 100_000e18, taker); - midnight.supplyCollateral(offer.market, 1, 100_000e18, taker); - vm.stopPrank(); - - // Step 1: Buy at maturity M (future) - uint256 assets1 = 1e18; - uint256 price1 = TickLib.tickToPrice(offer.tick); - uint256 units1 = assets1 * 1e18 / price1; - - offer.maxUnits = units1; - offer.callback = address(adapter); - offer.callbackData = hex""; + deal(storedCollaterals[0].token, address(this), 1_000_000e18); + deal(storedCollaterals[1].token, address(this), 1_000_000e18); - vm.prank(taker); - midnight.take(offer, units1, taker, taker, address(0), "", sign([offer], signerAllocator)); - - uint256 timeToMaturity = maturity - block.timestamp; - uint128 growth1 = uint128((units1 - assets1) / timeToMaturity); - assertGt(growth1, 0, "growth should be nonzero"); - assertEq(adapter.currentGrowth(), growth1, "currentGrowth after buy1"); + extraAssetsAdapter = new ExtraAssetsAdapter(); + address[] memory _adapters = new address[](2); + _adapters[0] = address(adapter); + _adapters[1] = address(extraAssetsAdapter); + parentVault.setAdapters(_adapters); + parentVault.setAdaptersLength(2); + } - // Step 2: Advance time past maturity M - skip(timeToMaturity + 1); - assertGt(block.timestamp, maturity, "should be past maturity"); + /* LAST UPDATE */ - // Step 3: Trigger accrueInterest so the walk subtracts growth from currentGrowth + function testLastUpdate() public { + assertEq(adapter.lastUpdate(), block.timestamp, "set at construction"); + skip(100); adapter.accrueInterest(); - assertEq(adapter.currentGrowth(), 0, "currentGrowth after accrual should be 0"); - assertEq(adapter.maturities(0).nextMaturity, 0, "firstMaturity should be sentinel"); - - // In midnight, any seller with debt past maturity is always liquidatable - // (isLiquidatable returns true if block.timestamp > maturity && debt > 0), - // so we can't test a second buy at past maturity. Just verify accrual state. - assertEq(adapter.maturities(0).nextMaturity, 0, "past maturity not re-inserted into list"); - - // Note: In midnight, any seller with debt past maturity is always liquidatable, - // so the second buy at past maturity from the original test cannot be executed. + assertEq(adapter.lastUpdate(), block.timestamp, "refreshed by accrueInterest"); } /* RATIFICATION */ @@ -387,187 +297,49 @@ contract MidnightAdapterTest is Test { adapter.isRatified(offer, data); } - /* STEPS SETUP */ - - function setupMarkets(Step[] memory steps) internal { - vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); - vm.stopPrank(); - - Offer memory offer = Offer({ - buy: true, - maker: address(adapter), - start: vm.getBlockTimestamp(), - expiry: vm.getBlockTimestamp() + 1, - tick: MAX_TICK, - callback: address(adapter), - callbackData: hex"", - market: Market({ - loanToken: address(loanToken), - collateralParams: storedCollaterals, - maturity: 0, - rcfThreshold: 0, - enterGate: address(0), - liquidatorGate: address(0) - }), - group: bytes32(0), - ratifier: address(adapter), - receiverIfMakerIsSeller: address(0), - reduceOnly: false, - maxUnits: 0, - maxAssets: 0 - }); - - 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.market.maturity = step.maturity; - offer.callbackData = hex""; - - // Compute tick from desired price: price = assets / (assets + approxInterest) - uint256 desiredPrice = step.assets.mulDivDown(1e18, step.assets + approxInterest); - if (desiredPrice > 1e18) desiredPrice = 1e18; - offer.tick = TickLib.priceToTick(desiredPrice, 4); - uint256 actualPrice = TickLib.tickToPrice(offer.tick); - uint256 units = step.assets.mulDivDown(1e18, actualPrice); - uint256 actualGrowth = (units - step.assets) / timeToMaturity; - uint256 zeroPeriodGain = (units - step.assets) % timeToMaturity; - offer.maxUnits = units; - bytes32 marketId = _marketId(offer.market); - - vm.startPrank(taker); - deal(storedCollaterals[0].token, taker, 1_000e18); - deal(storedCollaterals[1].token, taker, 1_000e18); - midnight.supplyCollateral(offer.market, 0, 1_000e18, taker); - midnight.supplyCollateral(offer.market, 1, 1_000e18, taker); - - uint256 unitsBefore = adapter.netCredit(marketId); - midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); - vm.stopPrank(); - - assertEq(adapter.netCredit(marketId), unitsBefore + units, "setup: units 1"); - - expectedUnits[marketId] += units; - expectedMaturityGrowths[step.maturity] += actualGrowth; - if (timeToMaturity > 0) { - expectedAddedGrowth += actualGrowth.toUint128(); - } - expectedAddedAssets += step.assets + zeroPeriodGain; - expectedPositionsList.push(uint256(marketId)); - 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); - - setupMarkets(steps); - - // Check pointer to first element of maturities list - if (steps.length > 0) { - assertEq(adapter.maturities(0).nextMaturity, steps[0].maturity, "firstMaturity"); - } else { - assertEq(adapter.maturities(0).nextMaturity, 0, "firstMaturity"); - } - - // Check maturities growth and linked list structure - for (uint256 i = 0; i < expectedMaturitiesList.length; i++) { - assertEq( - adapter.maturities(expectedMaturitiesList[i]).growth, - expectedMaturityGrowths[expectedMaturitiesList[i]], - "growth" - ); - if (i == expectedMaturitiesList.length - 1) { - assertEq(adapter.maturities(expectedMaturitiesList[i]).nextMaturity, 0, "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 marketId = bytes32(expectedPositionsList[i]); - assertEq(adapter.netCredit(marketId), expectedUnits[marketId], "units"); - } - } - - function testStepsSetup00(uint256 seed) public { + function testRatifyInvalidProof(uint256 seed) public { vm.setSeed(seed); - stepsSetupTest(steps00); + Offer memory offer = _ratificationSetup(); + bytes32 wrongRoot = keccak256("wrong root"); + bytes32[] memory emptyProof = new bytes32[](0); + bytes memory data = ratifierData(wrongRoot, signerAllocator, 0, emptyProof); + vm.expectRevert(IMidnightAdapter.InvalidProof.selector); + adapter.isRatified(offer, data); } - function testStepsSetup01(uint256 seed) public { + function testRatifySignerNotAllocator(uint256 seed) public { vm.setSeed(seed); - stepsSetupTest(steps01); - } - - /* ACCRUE INTEREST USING STEPS */ + (address otherSigner, uint256 otherSignerKey) = makeAddrAndKey("nonAllocatorSigner"); + privateKey[otherSigner] = otherSignerKey; + vm.assume(otherSigner != signerAllocator); + assertFalse(parentVault.isAllocator(otherSigner), "must not be allocator"); - // 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(); - uint256 maxElapsed = - steps.length == 0 ? 365 days : 2 * (steps[steps.length - 1].maturity - vm.getBlockTimestamp()); - initialGrowth = bound(initialGrowth, 0, 1e24); - _totalAssets = bound(_totalAssets, 0, type(uint128).max / 2); - elapsed = bound(elapsed, 0, maxElapsed); - - setCurrentGrowth(uint128(initialGrowth)); - set_TotalAssets(_totalAssets); - setupMarkets(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; - - 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() && (expectedNextMaturity == 0 || maturity < expectedNextMaturity)) { - expectedNextMaturity = maturity; - } - } - assertEq(nextMaturity, expectedNextMaturity, "nextMaturity"); - assertEq(newGrowth, expectedCurrentGrowth - lostGrowth, "newGrowth"); - assertEq(newTotalAssets, _totalAssets + expectedAddedAssets + interest, "newTotalAssets"); + Offer memory offer = _ratificationSetup(); + bytes32 _root = HashLib.hashOffer(offer); + bytes memory data = ratifierData(_root, otherSigner); + vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); + adapter.isRatified(offer, data); } - function testAccrueInterestView00(uint256 growth, uint256 _totalAssets, uint256 elapsed) public { - accrueInterestViewTest(steps00, growth, _totalAssets, elapsed); + function testRatifySellOfferWithoutReduceOnly(uint256 seed) public { + vm.setSeed(seed); + Offer memory offer = _ratificationSetup(); + offer.buy = false; + offer.reduceOnly = false; + bytes32 _root = HashLib.hashOffer(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.NoDebtCreation.selector); + adapter.isRatified(offer, data); } - function testAccrueInterestView01(uint256 growth, uint256 _totalAssets, uint256 elapsed) public { - accrueInterestViewTest(steps01, growth, _totalAssets, elapsed); + function testRatifyReduceOnlySellAccepted(uint256 seed) public { + vm.setSeed(seed); + Offer memory offer = _ratificationSetup(); + offer.buy = false; + offer.reduceOnly = true; + bytes32 _root = HashLib.hashOffer(offer); + bytes memory data = ratifierData(_root, signerAllocator); + assertEq(adapter.isRatified(offer, data), CALLBACK_SUCCESS, "callback success"); } /* DURATIONS */ @@ -627,28 +399,546 @@ contract MidnightAdapterTest is Test { assertEq(ids.length, 1 + market.collateralParams.length * 2 + durationIdCount); } - /* UTILITIES */ + /* ALLOCATION UPDATES */ + + function testExactDuration(uint32 durationIndex) public { + durationIndex = uint32(bound(durationIndex, 0, adapter.durationsLength() - 1)); + uint256 duration = adapter.durations()[durationIndex]; + buy(duration, 1e18); + assertEq(parentVault.allocation(durationId(duration)), 1e18); + } + + function testExitDuration(uint256 durationIndex, uint256 timeToMaturity, uint256 extraSkip) public { + durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); + uint256 duration = adapter.durations()[durationIndex]; + timeToMaturity = bound(timeToMaturity, duration, 100 * 365 days); + extraSkip = bound(extraSkip, 1, 10 * 365 days); + + Offer memory offer = buy(timeToMaturity, 1e18); + assertEq(parentVault.allocation(durationId(duration)), 1e18); + + skip(timeToMaturity - duration + extraSkip); + + adapter.updateDurationCountAndAllocations(offer.market); + + assertEq(parentVault.allocation(durationId(duration)), 0); + } + + function testRepeatDeallocateExpiredDurations(uint256 durationIndex, uint256 timeToMaturity, uint256 skipAmount) + public + { + durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); + uint256 duration = adapter.durations()[durationIndex]; + timeToMaturity = bound(timeToMaturity, duration, 100 * 365 days); + skipAmount = bound(skipAmount, 0, duration * 2); + + Offer memory offer = buy(timeToMaturity, 1e18); + skip(skipAmount); + adapter.updateDurationCountAndAllocations(offer.market); + uint256 savedAllocation = parentVault.allocation(durationId(duration)); + adapter.updateDurationCountAndAllocations(offer.market); + assertEq(parentVault.allocation(durationId(duration)), savedAllocation); + } + + function testUpdateOnWithdraw() public { + Offer memory offer = buy(7 days, 1e18); + assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); + assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); + + skip(7 days); + + vm.prank(taker); + midnight.repay(offer.market, 1e18, taker, address(0), ""); + vm.prank(signerAllocator); + adapter.withdrawToVault(offer.market, 0.5e18); + + assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); + } + + function testUpdateOnSell() public { + Offer memory offer = buy(7 days, 1e18); + assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); + assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); + + skip(1); + + parentVault.setTotalAssets(1e18); + sell(offer.market, 0.5e18); + + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); + assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); + } + + function testOnBuyRemovesAndReinsertsMaturity() public { + buy(1 days, 1e18); + Offer memory offer = buy(7 days, 1e18); + buy(30 days, 1e18); + bytes32 marketId = _marketId(offer.market); + setMidnightCredit(marketId, address(adapter), 0); + + offer.group = bytes32("second buy"); + uint256 units = 1e18 * 1e18 / TickLib.tickToPrice(MAX_TICK); + offer.maxUnits = units; + + vm.startPrank(taker); + midnight.supplyCollateral(offer.market, 0, 0.5e18, taker); + midnight.supplyCollateral(offer.market, 1, 0.5e18, taker); + vm.stopPrank(); + + offer.callbackData = hex""; + vm.prank(taker); + midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); + } + + function testSellClearsMaturityAndReactivatesSlot() public { + Offer memory firstOffer; + Offer memory secondOffer; + for (uint256 i = 0; i < 50; i++) { + Offer memory offer = buy(1 days + i, 1e18); + if (i == 0) firstOffer = offer; + if (i == 1) secondOffer = offer; + } + assertEq(adapter.availableMaturities(), 0, "availableMaturities before"); + + parentVault.setTotalAssets(1e18); + sell(secondOffer.market, 1e18); + + assertEq(adapter.availableMaturities(), 1, "availableMaturities after"); + assertEq(adapter.maturities(0).nextMaturity, firstOffer.market.maturity, "firstMaturity after"); + + buy(60 days, 1e18); - function setCurrentGrowth(uint128 growth) internal { - stdstore.target(address(adapter)).enable_packed_slots().sig("currentGrowth()").checked_write(growth); + assertEq(adapter.availableMaturities(), 0, "availableMaturities final"); } - function set_TotalAssets(uint256 _totalAssets) internal { - stdstore.target(address(adapter)).enable_packed_slots().sig("totalAssets()").checked_write(_totalAssets); + function testUpdateOnForceDeallocate() public { + Offer memory offer = buy(7 days, 1e18); + assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); + assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); + + skip(1); + + forceDeallocate(offer.market, 0.5e18); + + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); + assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } - 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++; + /* AVAILABLE MATURITIES */ + + function testAvailableMaturitiesCap(uint256 boughtNum) public { + boughtNum = bound(boughtNum, 0, 50); + for (uint256 i = 1; i <= boughtNum; i++) { + buy(i, 1e18); } - 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]; + assertEq(adapter.availableMaturities(), 50 - boughtNum); + + for (uint256 i = boughtNum + 1; i <= 50; i++) { + buy(i, 1e18); } - return res; + + Offer memory offer = makeBuyOffer(51, 1e18, MAX_TICK); + midnight.supplyCollateral(offer.market, 0, 0.5e18, taker); + midnight.supplyCollateral(offer.market, 1, 0.5e18, taker); + vm.expectRevert(stdError.arithmeticError); + take(offer); + } + + function testAvailableMaturitiesBuySell(uint256 boughtNum, uint256 soldNum) public { + boughtNum = bound(boughtNum, 1, 50); + soldNum = bound(soldNum, 0, boughtNum); + + parentVault.setTotalAssets(1e18); + + Market[] memory markets = new Market[](boughtNum); + for (uint256 i = 0; i < boughtNum; i++) { + markets[i] = buy(1 days + i, 1e18).market; + } + for (uint256 i = 0; i < soldNum; i++) { + sell(markets[i], 1e18); + } + + assertEq(adapter.availableMaturities(), 50 - boughtNum + soldNum); + } + + function testOnBuyCanRealizeLoss() public { + uint256 tick = TickLib.priceToTick(0.95e18, 4); + uint256 duration = 7 days; + uint256 assets = 1e18; + + Offer memory offer = makeBuyOffer(duration, assets, tick); + uint256 units = offer.maxUnits; + midnight.supplyCollateral(offer.market, 0, units, taker); + midnight.supplyCollateral(offer.market, 1, units, taker); + take(offer); + + bytes32 marketId = _marketId(offer.market); + uint256 loss = 0.5e18; + stdstore.target(address(midnight)).sig("creditOf(bytes32,address)").with_key(marketId) + .with_key(address(adapter)).checked_write(units - loss); + + offer.group = bytes32("second"); + midnight.supplyCollateral(offer.market, 0, units, taker); + midnight.supplyCollateral(offer.market, 1, units, taker); + take(offer); + + uint128 growth = uint128((units - assets) / duration); + uint128 removedGrowth = uint128(uint256(growth).mulDivUp(loss, units)); + assertEq(adapter.maturities(offer.market.maturity).growth, 2 * growth - removedGrowth); + (uint128 marketNetCredit,) = adapter._markets(marketId); + assertEq(marketNetCredit, 2 * units - loss); + } + + function testOnSellBufferTooLowReverts() public { + deal(address(loanToken), address(parentVault), 1e18); + Offer memory offer = buy(0, 1e18); + parentVault.setTotalAssets(1e18); + + vm.expectRevert(IMidnightAdapter.BufferTooLow.selector); + sellUnits(offer.market, 1e18, MAX_TICK - 4); + } + + function testOnSellBufferBigEnough() public { + uint256 loss = 1e18 - TickLib.tickToPrice(MAX_TICK - 4); + + deal(address(loanToken), address(parentVault), 1e18); + Offer memory offer = buy(0, 1e18); + extraAssetsAdapter.setRealAssets(loss); + parentVault.setTotalAssets(1e18); + + sellUnits(offer.market, 1e18, MAX_TICK - 4); + + (uint128 marketNetCredit,) = adapter._markets(_marketId(offer.market)); + assertEq(marketNetCredit, 0); + assertEq(adapter.totalAssets(), 0); + } + + /* MATURITY LIST */ + + function testOutOfOrderInsertsStaySorted() public { + uint256 t0 = block.timestamp; + buy(3, 1e18); + buy(1, 1e18); + buy(2, 1e18); + + assertMaturityList([t0 + 1, t0 + 2, t0 + 3]); + } + + function testMidListRemoval() public { + Offer memory smallest = buy(1, 1e18); + Offer memory middle = buy(2, 1e18); + Offer memory largest = buy(3, 1e18); + + parentVault.setTotalAssets(1e18); + sell(middle.market, 1e18); + + assertMaturityList([smallest.market.maturity, largest.market.maturity]); + } + + function testMultipleConsecutiveElapsedMaturitiesInOneAccrual() public { + buy(1, 1e18); + buy(2, 1e18); + skip(3); + adapter.accrueInterest(); + assertMaturityListEmpty(); + assertEq(adapter.currentGrowth(), 0, "currentGrowth"); + } + + function testTwoMarketsSharingMaturity(uint256 assetsA, uint256 assetsB) public { + assetsA = bound(assetsA, 1, 100_000e18) * 2; + assetsB = bound(assetsB, 1, 100_000e18) * 2; + + address oracleC = address(new OracleMock()); + OracleMock(oracleC).setPrice(ORACLE_PRICE_SCALE); + + Offer memory offerA = buy(0, assetsA); + + Offer memory offerB = makeBuyOffer(0, assetsB, MAX_TICK); + offerB.market.collateralParams[0].oracle = oracleC; + offerB.group = bytes32("B"); + midnight.supplyCollateral(offerB.market, 0, assetsB / 2, taker); + midnight.supplyCollateral(offerB.market, 1, assetsB / 2, taker); + take(offerB); + + (uint128 marketNetCreditA,) = adapter._markets(_marketId(offerA.market)); + (uint128 marketNetCreditB,) = adapter._markets(_marketId(offerB.market)); + assertEq(marketNetCreditA, assetsA, "netCredit A"); + assertEq(marketNetCreditB, assetsB, "netCredit B"); + assertEq(adapter.maturities(block.timestamp).netCredit, assetsA + assetsB, "shared netCredit"); + assertEq(adapter.totalAssets(), assetsA + assetsB, "totalAssets"); + } + + function testSecondBuyAtSameMaturityDoesNotReinsert() public { + Offer memory first = buy(7 days, 1e18); + + Offer memory second = makeBuyOffer(7 days, 1e18, MAX_TICK); + second.group = bytes32("second"); + midnight.supplyCollateral(second.market, 0, 0.5e18, taker); + midnight.supplyCollateral(second.market, 1, 0.5e18, taker); + take(second); + + assertMaturityList([first.market.maturity]); + } + + /* FORCE DEALLOCATE */ + + function testForceDeallocateOK() public { + Offer memory boughtOffer = buy(7 days, 1e18); + bytes32 marketId = _marketId(boughtOffer.market); + + forceDeallocate(boughtOffer.market, 0.5e18); + + (uint128 marketNetCredit,) = adapter._markets(marketId); + assertEq(marketNetCredit, 0.5e18); + } + + function testForceDeallocateRevertsOnSellOffer() public { + Offer memory boughtOffer = buy(7 days, 1e18); + (Offer memory offer,) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); + offer.buy = false; + + vm.expectRevert(IMidnightAdapter.IncorrectOffer.selector); + parentVault.forceDeallocate( + address(adapter), abi.encode(offer, abi.encode(bytes32(0), 0, proof([offer]))), 0.5e18, address(this) + ); + } + + function testForceDeallocateRevertsOnWrongLoanToken() public { + Offer memory boughtOffer = buy(7 days, 1e18); + (Offer memory offer,) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); + offer.market.loanToken = address(new ERC20Mock(18)); + + vm.expectRevert(IMidnightAdapter.IncorrectOffer.selector); + parentVault.forceDeallocate( + address(adapter), abi.encode(offer, abi.encode(bytes32(0), 0, proof([offer]))), 0.5e18, address(this) + ); + } + + function testForceDeallocateRevertsOnNonMaxTick() public { + Offer memory boughtOffer = buy(7 days, 1e18); + (Offer memory offer,) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); + offer.tick = MAX_TICK - 1; + + vm.expectRevert(IMidnightAdapter.IncorrectOffer.selector); + parentVault.forceDeallocate( + address(adapter), abi.encode(offer, abi.encode(bytes32(0), 0, proof([offer]))), 0.5e18, address(this) + ); + } + + /* WITHDRAW TO VAULT */ + + function testWithdrawToVaultUnauthorized(address nonAllocator) public { + vm.assume(!parentVault.isAllocator(nonAllocator)); + Market memory market = storedOffer.market; + vm.prank(nonAllocator); + vm.expectRevert(IMidnightAdapter.NotAuthorized.selector); + adapter.withdrawToVault(market, 0); + } + + function testWithdrawToVaultOK() public { + Offer memory boughtOffer = buy(7 days, 1e18); + bytes32 marketId = _marketId(boughtOffer.market); + (uint128 creditBefore,) = adapter._markets(marketId); + uint256 vaultBalanceBefore = loanToken.balanceOf(address(parentVault)); + + skip(7 days); + + deal(address(loanToken), address(this), 1e18); + loanToken.approve(address(midnight), type(uint256).max); + midnight.repay(boughtOffer.market, 1e18, taker, address(0), ""); + + uint256 withdrawAmount = 0.5e18; + vm.expectEmit(true, false, false, false, address(adapter)); + emit IMidnightAdapter.WithdrawToVault(marketId, withdrawAmount, 0); + vm.prank(signerAllocator); + adapter.withdrawToVault(boughtOffer.market, withdrawAmount); + + (uint128 creditAfter,) = adapter._markets(marketId); + assertLt(creditAfter, creditBefore); + assertEq(loanToken.balanceOf(address(parentVault)), vaultBalanceBefore + withdrawAmount); + } + + /* SKIM */ + + function testSetSkimRecipientUnauthorized(address nonOwner) public { + vm.assume(nonOwner != owner); + vm.prank(nonOwner); + vm.expectRevert(IMidnightAdapter.NotAuthorized.selector); + adapter.setSkimRecipient(recipient); + } + + function testSetSkimRecipientOK() public { + address newRecipient = makeAddr("newRecipient"); + vm.expectEmit(true, false, false, false, address(adapter)); + emit IMidnightAdapter.SetSkimRecipient(newRecipient); + vm.prank(owner); + adapter.setSkimRecipient(newRecipient); + assertEq(adapter.skimRecipient(), newRecipient, "skimRecipient"); + } + + function testSkimUnauthorized(address caller) public { + vm.prank(owner); + adapter.setSkimRecipient(recipient); + vm.assume(caller != recipient); + vm.prank(caller); + vm.expectRevert(IMidnightAdapter.NotAuthorized.selector); + adapter.skim(address(rewardToken)); + } + + function testSkimOK() public { + vm.prank(owner); + adapter.setSkimRecipient(recipient); + + uint256 balance = 123e18; + deal(address(rewardToken), address(adapter), balance); + + vm.expectEmit(true, false, false, true, address(adapter)); + emit IMidnightAdapter.Skim(address(rewardToken), balance); + vm.prank(recipient); + adapter.skim(address(rewardToken)); + + assertEq(rewardToken.balanceOf(recipient), balance, "recipient received"); + assertEq(rewardToken.balanceOf(address(adapter)), 0, "adapter drained"); + } + + /* HELPERS */ + + function makeBuyOffer(uint256 duration, uint256 assets, uint256 tick) internal view returns (Offer memory offer) { + offer = storedOffer; + offer.market.maturity = block.timestamp + duration; + offer.buy = true; + offer.tick = tick; + offer.group = bytes32(duration); + offer.maxUnits = assets * 1e18 / TickLib.tickToPrice(tick); + offer.expiry = block.timestamp; + offer.callback = address(adapter); + offer.callbackData = hex""; + } + + function take(Offer memory offer) internal { + vm.prank(taker); + midnight.take(offer, offer.maxUnits, taker, taker, address(0), "", sign([offer], signerAllocator)); + } + + function buy(uint256 duration, uint256 assets) internal returns (Offer memory offer) { + offer = makeBuyOffer(duration, assets, MAX_TICK); + midnight.supplyCollateral(offer.market, 0, assets / 2, taker); + midnight.supplyCollateral(offer.market, 1, assets / 2, taker); + take(offer); + } + + function makeSellOffer(Market memory market, uint256 units, uint256 tick) + internal + view + returns (Offer memory offer) + { + offer = storedOffer; + offer.market = market; + offer.buy = false; + offer.reduceOnly = true; + offer.tick = tick; + offer.maxUnits = units; + offer.expiry = block.timestamp; + offer.callback = address(adapter); + offer.receiverIfMakerIsSeller = address(adapter); + offer.group = bytes32(vm.randomUint()); + offer.callbackData = hex""; + } + + function sell(Market memory market, uint256 assets) internal { + Offer memory offer = makeSellOffer(market, 0, MAX_TICK); + offer.maxUnits = TakeAmountsLib.sellerAssetsToUnits(address(midnight), _marketId(market), offer, assets); + vm.prank(taker); + midnight.take(offer, offer.maxUnits, taker, taker, address(0), "", sign([offer], signerAllocator)); + } + + function sellUnits(Market memory market, uint256 units, uint256 tick) internal { + Offer memory offer = makeSellOffer(market, units, tick); + vm.prank(taker); + midnight.take(offer, offer.maxUnits, taker, taker, address(0), "", sign([offer], signerAllocator)); + } + + function makeForceDeallocateOffer(Market memory market, uint256 assets) + internal + returns (Offer memory offer, bytes32 root_) + { + address buyer = makeAddr("buyer"); + SetterRatifier approvalRatifier = new SetterRatifier(address(midnight)); + + offer = storedOffer; + offer.market = market; + offer.buy = true; + offer.maker = buyer; + offer.tick = MAX_TICK; + uint256 price = TickLib.tickToPrice(MAX_TICK); + uint256 units = assets * 1e18 / price; + offer.maxUnits = units; + offer.expiry = block.timestamp; + offer.callback = address(0); + offer.callbackData = hex""; + offer.ratifier = address(approvalRatifier); + offer.group = bytes32(vm.randomUint()); + + deal(address(loanToken), buyer, assets); + vm.startPrank(buyer); + loanToken.approve(address(midnight), type(uint256).max); + midnight.setIsAuthorized(address(approvalRatifier), true, buyer); + root_ = root([offer]); + approvalRatifier.setIsRootRatified(buyer, root_, true); + vm.stopPrank(); + } + + function forceDeallocate(Market memory market, uint256 assets) internal { + (Offer memory offer, bytes32 root_) = makeForceDeallocateOffer(market, assets); + bytes memory data = abi.encode(offer, abi.encode(root_, 0, proof([offer]))); + parentVault.forceDeallocate(address(adapter), data, assets, address(this)); + } + + function durationId(uint256 duration) internal pure returns (bytes32) { + return keccak256(abi.encode("duration", duration)); + } + + function setMidnightCredit(bytes32 marketId, address account, uint256 credit) internal { + stdstore.target(address(midnight)).sig("creditOf(bytes32,address)").with_key(marketId).with_key(account) + .checked_write(credit); + } + + function checkMaturityList(uint256[] memory expected) internal view { + uint48 prev = 0; + uint48 current = adapter.maturities(0).nextMaturity; + for (uint256 i = 0; i < expected.length; i++) { + assertEq(current, expected[i].toUint48(), "wrong maturity in list"); + assertEq(adapter.maturities(current).prevMaturity, prev, "wrong prevMaturity"); + prev = current; + current = adapter.maturities(current).nextMaturity; + } + assertEq(current, 0, "list longer than expected"); + } + + function assertMaturityListEmpty() internal view { + checkMaturityList(new uint256[](0)); + } + + function assertMaturityList(uint256[1] memory m) internal view { + uint256[] memory arr = new uint256[](1); + arr[0] = m[0]; + checkMaturityList(arr); + } + + function assertMaturityList(uint256[2] memory m) internal view { + uint256[] memory arr = new uint256[](2); + arr[0] = m[0]; + arr[1] = m[1]; + checkMaturityList(arr); + } + + function assertMaturityList(uint256[3] memory m) internal view { + uint256[] memory arr = new uint256[](3); + arr[0] = m[0]; + arr[1] = m[1]; + arr[2] = m[2]; + checkMaturityList(arr); } function _marketId(Market memory market) internal view returns (bytes32) { From 672bc4a92d006c1cf7a98560681c47f12e19174f Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Jun 2026 18:38:46 +0200 Subject: [PATCH 70/95] harmonize with unsorted --- src/adapters/MidnightAdapter.sol | 11 +++++------ src/adapters/MidnightAdapterFactory.sol | 10 +++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index aa3793169..32f272ae8 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -58,7 +58,7 @@ contract MidnightAdapter is IMidnightAdapter { asset = IVaultV2(_parentVault).asset(); parentVault = _parentVault; midnight = _midnight; - lastUpdate = uint48(block.timestamp); + lastUpdate = block.timestamp.toUint48(); SafeERC20Lib.safeApprove(asset, _midnight, type(uint256).max); SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); adapterId = keccak256(abi.encode("this", address(this))); @@ -173,7 +173,7 @@ contract MidnightAdapter is IMidnightAdapter { availableMaturities += uint8(removedMaturities); _maturities[0].nextMaturity = newHead; _maturities[newHead].prevMaturity = 0; - lastUpdate = uint48(block.timestamp); + lastUpdate = block.timestamp.toUint48(); emit AccrueInterest(currentGrowth, totalAssets); } return (_maturities[0].nextMaturity, currentGrowth, totalAssets); @@ -275,15 +275,14 @@ contract MidnightAdapter is IMidnightAdapter { MarketData storage marketData = _markets[marketId]; uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; + uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) + - IMidnight(midnight).pendingFee(marketId, address(this)); + int256 netCreditChange = currentNetCredit.toInt256() - uint256(marketData.netCredit).toInt256(); require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); require(boughtNetCredit >= paidAssets, BuyAtLoss()); - uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - - IMidnight(midnight).pendingFee(marketId, address(this)); - int256 netCreditChange = currentNetCredit.toInt256() - uint256(marketData.netCredit).toInt256(); - accrueInterest(); updateDurationCountAndAllocations(market); diff --git a/src/adapters/MidnightAdapterFactory.sol b/src/adapters/MidnightAdapterFactory.sol index 512acc02e..370cba03c 100644 --- a/src/adapters/MidnightAdapterFactory.sol +++ b/src/adapters/MidnightAdapterFactory.sol @@ -8,7 +8,7 @@ import {IMidnightAdapterFactory} from "./interfaces/IMidnightAdapterFactory.sol" contract MidnightAdapterFactory is IMidnightAdapterFactory { /* STORAGE */ - mapping(address parentVault => mapping(address morpho => address)) public midnightAdapter; + mapping(address parentVault => mapping(address midnight => address)) public midnightAdapter; mapping(address account => bool) public isMidnightAdapter; uint256[] public durations; @@ -26,11 +26,11 @@ contract MidnightAdapterFactory is IMidnightAdapterFactory { /* FUNCTIONS */ - function createMidnightAdapter(address parentVault, address morpho) external returns (address) { - address _midnightAdapter = address(new MidnightAdapter{salt: bytes32(0)}(parentVault, morpho, durations)); - midnightAdapter[parentVault][morpho] = _midnightAdapter; + function createMidnightAdapter(address parentVault, address midnight) external returns (address) { + address _midnightAdapter = address(new MidnightAdapter{salt: bytes32(0)}(parentVault, midnight, durations)); + midnightAdapter[parentVault][midnight] = _midnightAdapter; isMidnightAdapter[_midnightAdapter] = true; - emit CreateMidnightAdapter(parentVault, morpho, _midnightAdapter); + emit CreateMidnightAdapter(parentVault, midnight, _midnightAdapter); return _midnightAdapter; } } From 63bee3c256fa1916e5c447c9b8e337434af0ee9a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 1 Jun 2026 21:20:29 +0200 Subject: [PATCH 71/95] adopt unsorted simplification --- src/adapters/MidnightAdapter.sol | 54 ++++++++++++++------------------ test/MidnightAdapterTest.sol | 8 ++--- 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 32f272ae8..7fb18e6e6 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -107,15 +107,12 @@ contract MidnightAdapter is IMidnightAdapter { function withdrawToVault(Market memory market, uint256 withdrawnAssets) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); - bytes32 marketId = IdLib.toId(market, block.chainid, midnight); - IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); - uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - - IMidnight(midnight).pendingFee(marketId, address(this)); - // current net credit cannot be > accounted net credit - uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit; - accrueInterest(); updateDurationCountAndAllocations(market); + IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); + bytes32 marketId = IdLib.toId(market, block.chainid, midnight); + // current net credit cannot be > accounted net credit + uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); if (netCreditDecrease > 0) { removeNetCredit(marketId, market.maturity, netCreditDecrease); @@ -212,18 +209,15 @@ contract MidnightAdapter is IMidnightAdapter { (Offer memory offer, bytes memory ratifierData) = abi.decode(data, (Offer, bytes)); require(offer.buy && offer.market.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); - // Already in a deallocate call so we skip the onSell callback and return the deallocation here. + accrueInterest(); + updateDurationCountAndAllocations(offer.market); + + // Skip onSell since we are already in a deallocate call. bytes32 marketId = IdLib.toId(offer.market, block.chainid, midnight); uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, marketId, offer, sellerAssets); IMidnight(midnight).take(offer, takeUnits, address(this), address(this), address(0), hex"", ratifierData); - - accrueInterest(); - updateDurationCountAndAllocations(offer.market); - uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - - IMidnight(midnight).pendingFee(marketId, address(this)); // current net credit cannot be > accounted net credit - uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit; - + uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); if (netCreditDecrease > 0) { removeNetCredit(marketId, offer.market.maturity, netCreditDecrease); } @@ -271,21 +265,18 @@ contract MidnightAdapter is IMidnightAdapter { address buyer, bytes memory ) external returns (bytes32) { - MaturityData storage maturityData = _maturities[market.maturity]; - MarketData storage marketData = _markets[marketId]; - uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); - uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; - uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - - IMidnight(midnight).pendingFee(marketId, address(this)); - int256 netCreditChange = currentNetCredit.toInt256() - uint256(marketData.netCredit).toInt256(); - require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); + uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; require(boughtNetCredit >= paidAssets, BuyAtLoss()); - accrueInterest(); updateDurationCountAndAllocations(market); + MaturityData storage maturityData = _maturities[market.maturity]; + MarketData storage marketData = _markets[marketId]; + uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); + int256 netCreditChange = currentNetCredit(marketId).toInt256() - uint256(marketData.netCredit).toInt256(); + // netCreditChange is at most boughtNetCredit if (netCreditChange < boughtNetCredit.toInt256()) { // forge-lint: disable-next-item(unsafe-typecast) safe because netCreditChange < boughtNetCredit (checked @@ -340,18 +331,15 @@ contract MidnightAdapter is IMidnightAdapter { address, bytes memory ) external returns (bytes32) { - uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); - uint256 currentNetCredit = IMidnight(midnight).creditOf(marketId, address(this)) - - IMidnight(midnight).pendingFee(marketId, address(this)); - // current net credit cannot be > accounted net credit - uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit; - require(msg.sender == midnight, NotMidnight()); require(seller == address(this), NotSelf()); accrueInterest(); updateDurationCountAndAllocations(market); + uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); + // current net credit cannot be > accounted net credit + uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); if (netCreditDecrease > 0) { removeNetCredit(marketId, market.maturity, netCreditDecrease); } @@ -372,6 +360,12 @@ contract MidnightAdapter is IMidnightAdapter { /* INTERNAL FUNCTIONS */ + function currentNetCredit(bytes32 marketId) internal view returns (uint256) { + return + IMidnight(midnight).creditOf(marketId, address(this)) + - IMidnight(midnight).pendingFee(marketId, address(this)); + } + /// @dev Removes netCredit proportionally from current accounted assets and future growth. function removeNetCredit(bytes32 marketId, uint256 maturity, uint256 removedNetCredit) internal { MaturityData storage maturityData = _maturities[maturity]; diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 1f1bf9754..a1c2e7ece 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -661,10 +661,10 @@ contract MidnightAdapterTest is Test { midnight.supplyCollateral(offerB.market, 1, assetsB / 2, taker); take(offerB); - (uint128 marketNetCreditA,) = adapter._markets(_marketId(offerA.market)); - (uint128 marketNetCreditB,) = adapter._markets(_marketId(offerB.market)); - assertEq(marketNetCreditA, assetsA, "netCredit A"); - assertEq(marketNetCreditB, assetsB, "netCredit B"); + (uint128 netCreditA,) = adapter._markets(_marketId(offerA.market)); + (uint128 netCreditB,) = adapter._markets(_marketId(offerB.market)); + assertEq(netCreditA, assetsA, "netCredit A"); + assertEq(netCreditB, assetsB, "netCredit B"); assertEq(adapter.maturities(block.timestamp).netCredit, assetsA + assetsB, "shared netCredit"); assertEq(adapter.totalAssets(), assetsA + assetsB, "totalAssets"); } From 9a1e7cf80a365271754a221a05c702e5d34d6fe4 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 3 Jun 2026 18:57:18 +0200 Subject: [PATCH 72/95] block force deallocate offer callback --- src/adapters/MidnightAdapter.sol | 5 ++++- test/MidnightAdapterTest.sol | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 7fb18e6e6..9b032fa70 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -207,7 +207,10 @@ contract MidnightAdapter is IMidnightAdapter { require(msg.sender == parentVault, NotAuthorized()); if (messageSig == IVaultV2.forceDeallocate.selector) { (Offer memory offer, bytes memory ratifierData) = abi.decode(data, (Offer, bytes)); - require(offer.buy && offer.market.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); + require( + offer.buy && offer.market.loanToken == asset && offer.tick == MAX_TICK && offer.callback == address(0), + IncorrectOffer() + ); accrueInterest(); updateDurationCountAndAllocations(offer.market); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index a1c2e7ece..a0124786a 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -726,6 +726,17 @@ contract MidnightAdapterTest is Test { ); } + function testForceDeallocateRevertsOnCallback() public { + Offer memory boughtOffer = buy(7 days, 1e18); + (Offer memory offer,) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); + offer.callback = address(this); + + vm.expectRevert(IMidnightAdapter.IncorrectOffer.selector); + parentVault.forceDeallocate( + address(adapter), abi.encode(offer, abi.encode(bytes32(0), 0, proof([offer]))), 0.5e18, address(this) + ); + } + /* WITHDRAW TO VAULT */ function testWithdrawToVaultUnauthorized(address nonAllocator) public { From a6f32b21f6f6ec15d7a6a4a86bf9b9a9183cbb5d Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 5 Jun 2026 16:57:35 +0200 Subject: [PATCH 73/95] simplify remove net credit calls --- src/adapters/MidnightAdapter.sol | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 9b032fa70..af0423900 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -114,9 +114,7 @@ contract MidnightAdapter is IMidnightAdapter { // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); - if (netCreditDecrease > 0) { - removeNetCredit(marketId, market.maturity, netCreditDecrease); - } + removeNetCredit(marketId, market.maturity, netCreditDecrease); IVaultV2(parentVault) .deallocate(address(this), abi.encode(ids(market), -netCreditDecrease.toInt256()), withdrawnAssets); @@ -221,9 +219,7 @@ contract MidnightAdapter is IMidnightAdapter { IMidnight(midnight).take(offer, takeUnits, address(this), address(this), address(0), hex"", ratifierData); // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); - if (netCreditDecrease > 0) { - removeNetCredit(marketId, offer.market.maturity, netCreditDecrease); - } + removeNetCredit(marketId, offer.market.maturity, netCreditDecrease); emit ForceDeallocate(marketId, sellerAssets, netCreditDecrease); return (ids(offer.market), -netCreditDecrease.toInt256()); @@ -343,9 +339,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); - if (netCreditDecrease > 0) { - removeNetCredit(marketId, market.maturity, netCreditDecrease); - } + removeNetCredit(marketId, market.maturity, netCreditDecrease); IVaultV2(parentVault) .deallocate(address(this), abi.encode(ids(market), -netCreditDecrease.toInt256()), sellerAssets); @@ -371,6 +365,8 @@ contract MidnightAdapter is IMidnightAdapter { /// @dev Removes netCredit proportionally from current accounted assets and future growth. function removeNetCredit(bytes32 marketId, uint256 maturity, uint256 removedNetCredit) internal { + if (removedNetCredit == 0) return; + MaturityData storage maturityData = _maturities[maturity]; MarketData storage marketData = _markets[marketId]; From 67d3e54c9b050231e48d59422fd31e5a67d04cc1 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 10 Jun 2026 16:58:15 +0200 Subject: [PATCH 74/95] cancel root, no redundant >0 check, live toId --- src/adapters/MidnightAdapter.sol | 21 ++++++++++++++++---- src/adapters/interfaces/IMidnightAdapter.sol | 5 +++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index af0423900..409e0b556 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -7,7 +7,6 @@ import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH} from "lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; -import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; import {HashLib} from "lib/midnight/src/ratifiers/libraries/HashLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; @@ -40,6 +39,7 @@ contract MidnightAdapter is IMidnightAdapter { /* MANAGEMENT */ address public skimRecipient; + mapping(bytes32 root => bool) public isRootCanceled; /* ACCOUNTING */ @@ -52,12 +52,14 @@ contract MidnightAdapter is IMidnightAdapter { uint8 public availableMaturities = MAX_PENDING_MATURITIES; mapping(uint256 timestamp => MaturityData) public _maturities; mapping(bytes32 marketId => MarketData) public _markets; + /* CONSTRUCTOR */ constructor(address _parentVault, address _midnight, uint256[] memory _durations) { asset = IVaultV2(_parentVault).asset(); parentVault = _parentVault; midnight = _midnight; + IMidnight(_midnight).setIsAuthorized(address(this), true, address(this)); lastUpdate = block.timestamp.toUint48(); SafeERC20Lib.safeApprove(asset, _midnight, type(uint256).max); SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); @@ -110,7 +112,7 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationCountAndAllocations(market); IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); - bytes32 marketId = IdLib.toId(market, block.chainid, midnight); + bytes32 marketId = IMidnight(midnight).toId(market); // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); @@ -214,7 +216,7 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationCountAndAllocations(offer.market); // Skip onSell since we are already in a deallocate call. - bytes32 marketId = IdLib.toId(offer.market, block.chainid, midnight); + bytes32 marketId = IMidnight(midnight).toId(offer.market); uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, marketId, offer, sellerAssets); IMidnight(midnight).take(offer, takeUnits, address(this), address(this), address(0), hex"", ratifierData); // current net credit cannot be > accounted net credit @@ -234,17 +236,28 @@ contract MidnightAdapter is IMidnightAdapter { /* MIDNIGHT CALLBACKS */ + function cancelRoot(bytes32 root) external { + require( + IVaultV2(parentVault).isAllocator(msg.sender) || IVaultV2(parentVault).isSentinel(msg.sender), + NotAuthorized() + ); + isRootCanceled[root] = true; + emit CancelRoot(msg.sender, root); + } + function isRatified(Offer memory offer, bytes memory data) external view returns (bytes32) { // Collaterals will be checked through vault ids. require(offer.market.loanToken == asset, LoanAssetMismatch()); require(offer.maker == address(this), IncorrectOwner()); require(offer.callback == address(this), IncorrectCallbackAddress()); + require(offer.receiverIfMakerIsSeller == address(this), IncorrectReceiver()); require(offer.start <= block.timestamp, IncorrectStart()); require(offer.buy || offer.reduceOnly, NoDebtCreation()); (Signature memory sig, bytes32 root, uint256 leafIndex, bytes32[] memory proof) = abi.decode(data, (Signature, bytes32, uint256, bytes32[])); require(HashLib.isLeaf(root, HashLib.hashOffer(offer), leafIndex, proof), InvalidProof()); + require(!isRootCanceled[root], RootCanceled()); bytes32 structHash = keccak256(abi.encode(HashLib.offerTreeTypeHash(proof.length), root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, structHash)); @@ -383,7 +396,7 @@ contract MidnightAdapter is IMidnightAdapter { maturityData.netCredit -= removedNetCredit.toUint128(); marketData.netCredit -= removedNetCredit.toUint128(); - if (removedNetCredit > 0 && maturityData.netCredit == 0 && maturity > block.timestamp) { + if (maturityData.netCredit == 0 && maturity > block.timestamp) { availableMaturities++; _maturities[maturityData.prevMaturity].nextMaturity = maturityData.nextMaturity; _maturities[maturityData.nextMaturity].prevMaturity = maturityData.prevMaturity; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index a867ab6b9..a187f2e63 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -35,6 +35,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event AccrueInterest(uint128 currentGrowth, uint256 totalAssets); event RemoveMaturity(uint256 indexed maturity); event InsertMaturity(uint256 indexed maturity); + event CancelRoot(address indexed caller, bytes32 indexed root); /* ERRORS */ @@ -44,6 +45,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { error IncorrectDuration(); error IncorrectOffer(); error IncorrectOwner(); + error IncorrectReceiver(); error IncorrectSigner(); error IncorrectStart(); error InvalidProof(); @@ -52,6 +54,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { error NotAuthorized(); error NotMidnight(); error NotSelf(); + error RootCanceled(); error SelfAllocationOnly(); /* FUNCTIONS */ @@ -68,7 +71,9 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function _markets(bytes32 marketId) external view returns (uint128 netCredit, uint128 growth); function maturities(uint256 date) external view returns (MaturityData memory); function skimRecipient() external view returns (address); + function isRootCanceled(bytes32 root) external view returns (bool); function setSkimRecipient(address newSkimRecipient) external; + function cancelRoot(bytes32 root) external; function skim(address token) external; function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); From 8e2f0a2231f8b0833f02e073d2ba0eb3972adf2b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 10 Jun 2026 18:18:06 +0200 Subject: [PATCH 75/95] logic and naming --- src/adapters/MidnightAdapter.sol | 43 ++++++++++---------- src/adapters/interfaces/IMidnightAdapter.sol | 2 +- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 409e0b556..dbd64c841 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -116,7 +116,7 @@ contract MidnightAdapter is IMidnightAdapter { // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); - removeNetCredit(marketId, market.maturity, netCreditDecrease); + decreaseNetCredit(marketId, market.maturity, netCreditDecrease); IVaultV2(parentVault) .deallocate(address(this), abi.encode(ids(market), -netCreditDecrease.toInt256()), withdrawnAssets); @@ -221,7 +221,7 @@ contract MidnightAdapter is IMidnightAdapter { IMidnight(midnight).take(offer, takeUnits, address(this), address(this), address(0), hex"", ratifierData); // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); - removeNetCredit(marketId, offer.market.maturity, netCreditDecrease); + decreaseNetCredit(marketId, offer.market.maturity, netCreditDecrease); emit ForceDeallocate(marketId, sellerAssets, netCreditDecrease); return (ids(offer.market), -netCreditDecrease.toInt256()); @@ -287,17 +287,16 @@ contract MidnightAdapter is IMidnightAdapter { MaturityData storage maturityData = _maturities[market.maturity]; MarketData storage marketData = _markets[marketId]; uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); - int256 netCreditChange = currentNetCredit(marketId).toInt256() - uint256(marketData.netCredit).toInt256(); - - // netCreditChange is at most boughtNetCredit - if (netCreditChange < boughtNetCredit.toInt256()) { - // forge-lint: disable-next-item(unsafe-typecast) safe because netCreditChange < boughtNetCredit (checked - // above). - uint256 netCreditLoss = uint256(int256(boughtNetCredit) - netCreditChange); - removeNetCredit(marketId, market.maturity, netCreditLoss); - } + // current net credit cannot be > accounted net credit + bought net credit + uint256 netCreditLoss = uint256(marketData.netCredit) + boughtNetCredit - currentNetCredit(marketId); + decreaseNetCredit(marketId, market.maturity, netCreditLoss); - IVaultV2(parentVault).allocate(address(this), abi.encode(ids(market), netCreditChange), paidAssets); + IVaultV2(parentVault) + .allocate( + address(this), + abi.encode(ids(market), boughtNetCredit.toInt256() - netCreditLoss.toInt256()), + paidAssets + ); if (timeToMaturity > 0) { uint256 interest = boughtNetCredit - paidAssets; @@ -329,7 +328,7 @@ contract MidnightAdapter is IMidnightAdapter { emit InsertMaturity(market.maturity); } - emit Buy(marketId, paidAssets, boughtNetCredit, netCreditChange); + emit Buy(marketId, paidAssets, boughtNetCredit, netCreditLoss); return CALLBACK_SUCCESS; } @@ -352,7 +351,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); - removeNetCredit(marketId, market.maturity, netCreditDecrease); + decreaseNetCredit(marketId, market.maturity, netCreditDecrease); IVaultV2(parentVault) .deallocate(address(this), abi.encode(ids(market), -netCreditDecrease.toInt256()), sellerAssets); @@ -376,25 +375,25 @@ contract MidnightAdapter is IMidnightAdapter { - IMidnight(midnight).pendingFee(marketId, address(this)); } - /// @dev Removes netCredit proportionally from current accounted assets and future growth. - function removeNetCredit(bytes32 marketId, uint256 maturity, uint256 removedNetCredit) internal { - if (removedNetCredit == 0) return; + /// @dev Decreases netCredit proportionally from current accounted assets and future growth. + function decreaseNetCredit(bytes32 marketId, uint256 maturity, uint256 netCreditDecrease) internal { + if (netCreditDecrease == 0) return; MaturityData storage maturityData = _maturities[maturity]; MarketData storage marketData = _markets[marketId]; if (maturity > block.timestamp) { uint256 timeToMaturity = maturity - block.timestamp; - uint128 growthDecrease = marketData.growth.mulDivUp(removedNetCredit, marketData.netCredit).toUint128(); + uint128 growthDecrease = marketData.growth.mulDivUp(netCreditDecrease, marketData.netCredit).toUint128(); marketData.growth -= growthDecrease; maturityData.growth -= growthDecrease; currentGrowth -= growthDecrease; - totalAssets = (totalAssets + (growthDecrease * timeToMaturity) - removedNetCredit).toUint128(); + totalAssets = (totalAssets + (growthDecrease * timeToMaturity) - netCreditDecrease).toUint128(); } else { - totalAssets -= removedNetCredit.toUint128(); + totalAssets -= netCreditDecrease.toUint128(); } - maturityData.netCredit -= removedNetCredit.toUint128(); - marketData.netCredit -= removedNetCredit.toUint128(); + maturityData.netCredit -= netCreditDecrease.toUint128(); + marketData.netCredit -= netCreditDecrease.toUint128(); if (maturityData.netCredit == 0 && maturity > block.timestamp) { availableMaturities++; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index a187f2e63..5a7e00ae8 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -30,7 +30,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event WithdrawToVault(bytes32 indexed marketId, uint256 withdrawnAssets, uint256 netCreditDecrease); event UpdateDurationCountAndAllocations(uint256 indexed maturity, uint256 newDurationCount, uint256 netCredit); event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); - event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, int256 netCreditChange); + event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, uint256 netCreditLoss); event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); event AccrueInterest(uint128 currentGrowth, uint256 totalAssets); event RemoveMaturity(uint256 indexed maturity); From 9f938f6b03de963e49a4c35082503de0ec7d0afc Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 10 Jun 2026 18:31:54 +0200 Subject: [PATCH 76/95] tests --- test/MidnightAdapterTest.sol | 49 ++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index a0124786a..c56e72617 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -135,7 +135,7 @@ contract MidnightAdapterTest is Test { group: bytes32(0), callback: address(adapter), callbackData: bytes(""), - receiverIfMakerIsSeller: address(0), + receiverIfMakerIsSeller: address(adapter), ratifier: address(adapter), reduceOnly: false, maxUnits: 0, @@ -215,7 +215,7 @@ contract MidnightAdapterTest is Test { offer.tick = bound(vm.randomUint(), 0, MAX_TICK); offer.callback = address(adapter); offer.callbackData = bytes(""); - offer.receiverIfMakerIsSeller = address(0); + offer.receiverIfMakerIsSeller = address(adapter); offer.ratifier = address(adapter); offer.reduceOnly = false; offer.maxUnits = 0; @@ -342,6 +342,51 @@ contract MidnightAdapterTest is Test { assertEq(adapter.isRatified(offer, data), CALLBACK_SUCCESS, "callback success"); } + function testRatifyIncorrectReceiver(uint256 seed, address otherReceiver) public { + vm.setSeed(seed); + vm.assume(otherReceiver != address(adapter)); + Offer memory offer = _ratificationSetup(); + offer.receiverIfMakerIsSeller = otherReceiver; + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.IncorrectReceiver.selector); + adapter.isRatified(offer, data); + } + + function testCancelRootByAllocator(uint256 seed) public { + vm.setSeed(seed); + Offer memory offer = _ratificationSetup(); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + assertEq(adapter.isRatified(offer, data), CALLBACK_SUCCESS, "ratifies before cancel"); + vm.prank(signerAllocator); + adapter.cancelRoot(_root); + assertTrue(adapter.isRootCanceled(_root), "root canceled"); + vm.expectRevert(IMidnightAdapter.RootCanceled.selector); + adapter.isRatified(offer, data); + } + + function testCancelRootBySentinel(uint256 seed, address sentinel) public { + vm.setSeed(seed); + vm.assume(sentinel != signerAllocator); + stdstore.target(address(parentVault)).sig("isSentinel(address)").with_key(sentinel).checked_write(true); + Offer memory offer = _ratificationSetup(); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.prank(sentinel); + adapter.cancelRoot(_root); + assertTrue(adapter.isRootCanceled(_root), "root canceled"); + vm.expectRevert(IMidnightAdapter.RootCanceled.selector); + adapter.isRatified(offer, data); + } + + function testCancelRootUnauthorized(address caller) public { + vm.assume(!parentVault.isAllocator(caller) && !parentVault.isSentinel(caller)); + vm.prank(caller); + vm.expectRevert(IMidnightAdapter.NotAuthorized.selector); + adapter.cancelRoot(keccak256("some root")); + } + /* DURATIONS */ function testConstructorGetters() public view { From 3cf8c764dfc8b32007e12fb7e43ae730350a82d5 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 10 Jun 2026 23:37:15 +0200 Subject: [PATCH 77/95] harmonize tests --- test/MidnightAdapterTest.sol | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index c56e72617..c57043fbe 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -659,7 +659,7 @@ contract MidnightAdapterTest is Test { assertEq(adapter.totalAssets(), 0); } - /* MATURITY LIST */ + /* PENDING MATURITIES LIST */ function testOutOfOrderInsertsStaySorted() public { uint256 t0 = block.timestamp; @@ -667,10 +667,10 @@ contract MidnightAdapterTest is Test { buy(1, 1e18); buy(2, 1e18); - assertMaturityList([t0 + 1, t0 + 2, t0 + 3]); + assertPendingMaturities([t0 + 1, t0 + 2, t0 + 3]); } - function testMidListRemoval() public { + function testMidPendingMaturityRemoval() public { Offer memory smallest = buy(1, 1e18); Offer memory middle = buy(2, 1e18); Offer memory largest = buy(3, 1e18); @@ -678,7 +678,7 @@ contract MidnightAdapterTest is Test { parentVault.setTotalAssets(1e18); sell(middle.market, 1e18); - assertMaturityList([smallest.market.maturity, largest.market.maturity]); + assertPendingMaturities([smallest.market.maturity, largest.market.maturity]); } function testMultipleConsecutiveElapsedMaturitiesInOneAccrual() public { @@ -686,7 +686,7 @@ contract MidnightAdapterTest is Test { buy(2, 1e18); skip(3); adapter.accrueInterest(); - assertMaturityListEmpty(); + assertPendingMaturitiesEmpty(); assertEq(adapter.currentGrowth(), 0, "currentGrowth"); } @@ -723,7 +723,7 @@ contract MidnightAdapterTest is Test { midnight.supplyCollateral(second.market, 1, 0.5e18, taker); take(second); - assertMaturityList([first.market.maturity]); + assertPendingMaturities([first.market.maturity]); } /* FORCE DEALLOCATE */ @@ -960,7 +960,7 @@ contract MidnightAdapterTest is Test { .checked_write(credit); } - function checkMaturityList(uint256[] memory expected) internal view { + function checkPendingMaturities(uint256[] memory expected) internal view { uint48 prev = 0; uint48 current = adapter.maturities(0).nextMaturity; for (uint256 i = 0; i < expected.length; i++) { @@ -972,29 +972,29 @@ contract MidnightAdapterTest is Test { assertEq(current, 0, "list longer than expected"); } - function assertMaturityListEmpty() internal view { - checkMaturityList(new uint256[](0)); + function assertPendingMaturitiesEmpty() internal view { + checkPendingMaturities(new uint256[](0)); } - function assertMaturityList(uint256[1] memory m) internal view { + function assertPendingMaturities(uint256[1] memory m) internal view { uint256[] memory arr = new uint256[](1); arr[0] = m[0]; - checkMaturityList(arr); + checkPendingMaturities(arr); } - function assertMaturityList(uint256[2] memory m) internal view { + function assertPendingMaturities(uint256[2] memory m) internal view { uint256[] memory arr = new uint256[](2); arr[0] = m[0]; arr[1] = m[1]; - checkMaturityList(arr); + checkPendingMaturities(arr); } - function assertMaturityList(uint256[3] memory m) internal view { + function assertPendingMaturities(uint256[3] memory m) internal view { uint256[] memory arr = new uint256[](3); arr[0] = m[0]; arr[1] = m[1]; arr[2] = m[2]; - checkMaturityList(arr); + checkPendingMaturities(arr); } function _marketId(Market memory market) internal view returns (bytes32) { From f5545b07f8708369c2a994df73a394de0c47828a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Sun, 14 Jun 2026 22:35:58 +0200 Subject: [PATCH 78/95] restructure accruals --- src/adapters/MidnightAdapter.sol | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index dbd64c841..a40ff53c1 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -143,6 +143,8 @@ contract MidnightAdapter is IMidnightAdapter { /* ACCRUAL */ function accrueInterestView() public view returns (uint48, uint128, uint128, uint256) { + if (block.timestamp == lastUpdate) return (_maturities[0].nextMaturity, currentGrowth, totalAssets, 0); + uint48 _firstMaturity = _maturities[0].nextMaturity; uint128 newGrowth = currentGrowth; uint256 removedMaturities = 0; @@ -163,17 +165,18 @@ contract MidnightAdapter is IMidnightAdapter { } function accrueInterest() public returns (uint48, uint128, uint256) { - if (lastUpdate != block.timestamp) { - uint48 newHead; - uint256 removedMaturities; - (newHead, currentGrowth, totalAssets, removedMaturities) = accrueInterestView(); - availableMaturities += uint8(removedMaturities); - _maturities[0].nextMaturity = newHead; - _maturities[newHead].prevMaturity = 0; - lastUpdate = block.timestamp.toUint48(); - emit AccrueInterest(currentGrowth, totalAssets); - } - return (_maturities[0].nextMaturity, currentGrowth, totalAssets); + if (block.timestamp == lastUpdate) return (_maturities[0].nextMaturity, currentGrowth, totalAssets); + + uint48 newFirstMaturity; + uint256 removedMaturities; + (newFirstMaturity, currentGrowth, totalAssets, removedMaturities) = accrueInterestView(); + availableMaturities += uint8(removedMaturities); + _maturities[0].nextMaturity = newFirstMaturity; + _maturities[newFirstMaturity].prevMaturity = 0; + lastUpdate = block.timestamp.toUint48(); + emit AccrueInterest(currentGrowth, totalAssets); + + return (newFirstMaturity, currentGrowth, totalAssets); } /// @dev Returns an estimate of the real assets assigned to the adapter. From 6dd4cacd96f79e64c3374d1bfd2b51de6b957129 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Sun, 14 Jun 2026 23:20:49 +0200 Subject: [PATCH 79/95] update durations caps --- src/adapters/MidnightAdapter.sol | 18 +++++++++++------- src/adapters/interfaces/IMidnightAdapter.sol | 4 ++-- test/MidnightAdapterTest.sol | 6 +++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index a40ff53c1..047281dba 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -80,6 +80,8 @@ contract MidnightAdapter is IMidnightAdapter { return _markets[marketId]; } + /// @dev Returns the durations that can be capped. + /// @dev A market position fills the cap of any duration that is >= its time to maturity. function durations() public view returns (uint256[] memory) { uint256[] memory _durations = new uint256[](durationsLength); for (uint256 i = 0; i < durationsLength; i++) { @@ -108,11 +110,13 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ function withdrawToVault(Market memory market, uint256 withdrawnAssets) external { + bytes32 marketId = IMidnight(midnight).toId(market); require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); + accrueInterest(); - updateDurationCountAndAllocations(market); + updateDurationCaps(market); + IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); - bytes32 marketId = IMidnight(midnight).toId(market); // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); @@ -123,12 +127,12 @@ contract MidnightAdapter is IMidnightAdapter { emit WithdrawToVault(marketId, withdrawnAssets, netCreditDecrease); } - function updateDurationCountAndAllocations(Market memory market) public { + function updateDurationCaps(Market memory market) public { MaturityData storage maturityData = _maturities[market.maturity]; uint256 oldDurationCount = maturityData.durationCount; uint256 newDurationCount = durationCount(market.maturity); maturityData.durationCount = uint8(newDurationCount); - emit UpdateDurationCountAndAllocations(market.maturity, newDurationCount, maturityData.netCredit); + emit UpdateDurationCaps(market.maturity, newDurationCount, maturityData.netCredit); // VaultV2.deallocate requires allocation > 0 for each returned id. if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationCount - newDurationCount); @@ -216,7 +220,7 @@ contract MidnightAdapter is IMidnightAdapter { ); accrueInterest(); - updateDurationCountAndAllocations(offer.market); + updateDurationCaps(offer.market); // Skip onSell since we are already in a deallocate call. bytes32 marketId = IMidnight(midnight).toId(offer.market); @@ -285,7 +289,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; require(boughtNetCredit >= paidAssets, BuyAtLoss()); accrueInterest(); - updateDurationCountAndAllocations(market); + updateDurationCaps(market); MaturityData storage maturityData = _maturities[market.maturity]; MarketData storage marketData = _markets[marketId]; @@ -349,7 +353,7 @@ contract MidnightAdapter is IMidnightAdapter { require(seller == address(this), NotSelf()); accrueInterest(); - updateDurationCountAndAllocations(market); + updateDurationCaps(market); uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); // current net credit cannot be > accounted net credit diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 5a7e00ae8..93bee7e4a 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -28,7 +28,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event SetSkimRecipient(address indexed newSkimRecipient); event Skim(address indexed token, uint256 assets); event WithdrawToVault(bytes32 indexed marketId, uint256 withdrawnAssets, uint256 netCreditDecrease); - event UpdateDurationCountAndAllocations(uint256 indexed maturity, uint256 newDurationCount, uint256 netCredit); + event UpdateDurationCaps(uint256 indexed maturity, uint256 newDurationCount, uint256 netCredit); event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, uint256 netCreditLoss); event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); @@ -77,7 +77,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function skim(address token) external; function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); - function updateDurationCountAndAllocations(Market memory market) external; + function updateDurationCaps(Market memory market) external; function withdrawToVault(Market memory market, uint256 withdrawnAssets) external; function ids(Market memory market) external view returns (bytes32[] memory); function parentVault() external view returns (address); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index c57043fbe..3d715d9c8 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -464,7 +464,7 @@ contract MidnightAdapterTest is Test { skip(timeToMaturity - duration + extraSkip); - adapter.updateDurationCountAndAllocations(offer.market); + adapter.updateDurationCaps(offer.market); assertEq(parentVault.allocation(durationId(duration)), 0); } @@ -479,9 +479,9 @@ contract MidnightAdapterTest is Test { Offer memory offer = buy(timeToMaturity, 1e18); skip(skipAmount); - adapter.updateDurationCountAndAllocations(offer.market); + adapter.updateDurationCaps(offer.market); uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.updateDurationCountAndAllocations(offer.market); + adapter.updateDurationCaps(offer.market); assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } From a5dd3115ab515596230a695cb3e0a9bc71f993c6 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 20 Jul 2026 14:38:36 +0200 Subject: [PATCH 80/95] update midnight to latest main --- foundry.lock | 2 +- lib/midnight | 2 +- src/adapters/MidnightAdapter.sol | 14 +++--- test/MidnightAdapterTest.sol | 83 +++++++++++++++++--------------- 4 files changed, 55 insertions(+), 46 deletions(-) diff --git a/foundry.lock b/foundry.lock index 75cb45474..49809f418 100644 --- a/foundry.lock +++ b/foundry.lock @@ -9,7 +9,7 @@ "rev": "2d160ba9bb945ca3bf12efb182427445dce59c27" }, "lib/midnight": { - "rev": "a9988d997c03458e260f8a39e408937eea8506d2" + "rev": "799b8176089148bc7d85e2ae2bfbc4eb25ff26a2" }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" diff --git a/lib/midnight b/lib/midnight index a9988d997..799b81760 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit a9988d997c03458e260f8a39e408937eea8506d2 +Subproject commit 799b8176089148bc7d85e2ae2bfbc4eb25ff26a2 diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 047281dba..9338c17b2 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.34; import {IMidnight, Offer, Market} from "lib/midnight/src/interfaces/IMidnight.sol"; +import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH} from "lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; @@ -110,7 +111,7 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ function withdrawToVault(Market memory market, uint256 withdrawnAssets) external { - bytes32 marketId = IMidnight(midnight).toId(market); + bytes32 marketId = IdLib.toId(market); require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); accrueInterest(); @@ -223,9 +224,9 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationCaps(offer.market); // Skip onSell since we are already in a deallocate call. - bytes32 marketId = IMidnight(midnight).toId(offer.market); + bytes32 marketId = IdLib.toId(offer.market); uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, marketId, offer, sellerAssets); - IMidnight(midnight).take(offer, takeUnits, address(this), address(this), address(0), hex"", ratifierData); + IMidnight(midnight).take(offer, ratifierData, takeUnits, address(this), address(this), address(0), hex""); // current net credit cannot be > accounted net credit uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); decreaseNetCredit(marketId, offer.market.maturity, netCreditDecrease); @@ -252,12 +253,13 @@ contract MidnightAdapter is IMidnightAdapter { emit CancelRoot(msg.sender, root); } - function isRatified(Offer memory offer, bytes memory data) external view returns (bytes32) { + function isRatified(Offer memory offer, bytes memory data, address) external view returns (bytes32) { // Collaterals will be checked through vault ids. require(offer.market.loanToken == asset, LoanAssetMismatch()); require(offer.maker == address(this), IncorrectOwner()); require(offer.callback == address(this), IncorrectCallbackAddress()); - require(offer.receiverIfMakerIsSeller == address(this), IncorrectReceiver()); + // For buy offers, Midnight enforces receiverIfMakerIsSeller == address(0). + require(offer.buy || offer.receiverIfMakerIsSeller == address(this), IncorrectReceiver()); require(offer.start <= block.timestamp, IncorrectStart()); require(offer.buy || offer.reduceOnly, NoDebtCreation()); @@ -378,7 +380,7 @@ contract MidnightAdapter is IMidnightAdapter { function currentNetCredit(bytes32 marketId) internal view returns (uint256) { return - IMidnight(midnight).creditOf(marketId, address(this)) + IMidnight(midnight).credit(marketId, address(this)) - IMidnight(midnight).pendingFee(marketId, address(this)); } diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 3d715d9c8..118a3518e 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -21,7 +21,7 @@ import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; -import {maxLif, CALLBACK_SUCCESS} from "../lib/midnight/src/libraries/ConstantsLib.sol"; +import {CALLBACK_SUCCESS} from "../lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "../lib/midnight/src/periphery/TakeAmountsLib.sol"; import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; @@ -81,6 +81,8 @@ contract MidnightAdapterTest is Test { taker = makeAddr("taker"); midnight = IMidnight(address(new Midnight())); + midnight.enableLltv(1e18); + midnight.enableLiquidationCursor(0.25e18); loanToken = IERC20(address(new ERC20Mock(18))); rewardToken = IERC20(address(new ERC20Mock(18))); @@ -106,10 +108,10 @@ contract MidnightAdapterTest is Test { } storedCollaterals.push( - CollateralParams({token: collToken0, lltv: 1e18, maxLif: maxLif(1e18, 0.25e18), oracle: oracle0}) + CollateralParams({token: collToken0, lltv: 1e18, liquidationCursor: 0.25e18, oracle: oracle0}) ); storedCollaterals.push( - CollateralParams({token: collToken1, lltv: 1e18, maxLif: maxLif(1e18, 0.25e18), oracle: oracle1}) + CollateralParams({token: collToken1, lltv: 1e18, liquidationCursor: 0.25e18, oracle: oracle1}) ); OracleMock(storedCollaterals[0].oracle).setPrice(ORACLE_PRICE_SCALE); @@ -119,9 +121,9 @@ contract MidnightAdapterTest is Test { uint256 maturity = vm.getBlockTimestamp() + 200; storedOffer = Offer({ - buy: true, - maker: address(adapter), market: Market({ + chainId: block.chainid, + midnight: address(midnight), loanToken: address(loanToken), collateralParams: storedCollaterals, maturity: maturity, @@ -129,17 +131,20 @@ contract MidnightAdapterTest is Test { enterGate: address(0), liquidatorGate: address(0) }), + buy: true, + maker: address(adapter), start: vm.getBlockTimestamp(), expiry: maturity, tick: MAX_TICK, group: bytes32(0), callback: address(adapter), callbackData: bytes(""), - receiverIfMakerIsSeller: address(adapter), + receiverIfMakerIsSeller: address(0), ratifier: address(adapter), reduceOnly: false, maxUnits: 0, - maxAssets: 0 + maxAssets: 0, + continuousFeeCap: type(uint256).max }); deal(address(loanToken), address(parentVault), 1_000_000e18); @@ -200,9 +205,8 @@ contract MidnightAdapterTest is Test { } } for (uint256 i = 0; i < numCollaterals; i++) { - collateralParams[i] = CollateralParams({ - token: tokens[i], lltv: 1 ether, maxLif: maxLif(1 ether, 0.25e18), oracle: oracles[i] - }); + collateralParams[i] = + CollateralParams({token: tokens[i], lltv: 1 ether, liquidationCursor: 0.25e18, oracle: oracles[i]}); } offer.market.collateralParams = collateralParams; offer.market.maturity = bound(vm.randomUint(), vm.getBlockTimestamp(), type(uint48).max - 1); @@ -231,7 +235,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, otherSigner); vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifyIncorrectOfferBadBuySigner(uint256 seed) public { @@ -244,7 +248,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, otherSigner); vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifyLoanAssetMismatch(uint256 seed, address otherToken) public { @@ -255,7 +259,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.LoanAssetMismatch.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifyIncorrectOwner(uint256 seed, address otherMaker) public { @@ -266,7 +270,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.IncorrectOwner.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifyIncorrectStart(uint256 seed) public { @@ -276,7 +280,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.IncorrectStart.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifyIncorrectCallbackAddress(uint256 seed) public { @@ -286,7 +290,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.IncorrectCallbackAddress.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifyIncorrectExpiry(uint256 seed) public { @@ -294,7 +298,7 @@ contract MidnightAdapterTest is Test { Offer memory offer = _ratificationSetup(); bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifyInvalidProof(uint256 seed) public { @@ -304,7 +308,7 @@ contract MidnightAdapterTest is Test { bytes32[] memory emptyProof = new bytes32[](0); bytes memory data = ratifierData(wrongRoot, signerAllocator, 0, emptyProof); vm.expectRevert(IMidnightAdapter.InvalidProof.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifySignerNotAllocator(uint256 seed) public { @@ -318,7 +322,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = HashLib.hashOffer(offer); bytes memory data = ratifierData(_root, otherSigner); vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifySellOfferWithoutReduceOnly(uint256 seed) public { @@ -329,7 +333,7 @@ contract MidnightAdapterTest is Test { bytes32 _root = HashLib.hashOffer(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.NoDebtCreation.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testRatifyReduceOnlySellAccepted(uint256 seed) public { @@ -339,18 +343,20 @@ contract MidnightAdapterTest is Test { offer.reduceOnly = true; bytes32 _root = HashLib.hashOffer(offer); bytes memory data = ratifierData(_root, signerAllocator); - assertEq(adapter.isRatified(offer, data), CALLBACK_SUCCESS, "callback success"); + assertEq(adapter.isRatified(offer, data, taker), CALLBACK_SUCCESS, "callback success"); } function testRatifyIncorrectReceiver(uint256 seed, address otherReceiver) public { vm.setSeed(seed); vm.assume(otherReceiver != address(adapter)); Offer memory offer = _ratificationSetup(); + offer.buy = false; + offer.reduceOnly = true; offer.receiverIfMakerIsSeller = otherReceiver; bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); vm.expectRevert(IMidnightAdapter.IncorrectReceiver.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testCancelRootByAllocator(uint256 seed) public { @@ -358,12 +364,12 @@ contract MidnightAdapterTest is Test { Offer memory offer = _ratificationSetup(); bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); - assertEq(adapter.isRatified(offer, data), CALLBACK_SUCCESS, "ratifies before cancel"); + assertEq(adapter.isRatified(offer, data, taker), CALLBACK_SUCCESS, "ratifies before cancel"); vm.prank(signerAllocator); adapter.cancelRoot(_root); assertTrue(adapter.isRootCanceled(_root), "root canceled"); vm.expectRevert(IMidnightAdapter.RootCanceled.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testCancelRootBySentinel(uint256 seed, address sentinel) public { @@ -377,7 +383,7 @@ contract MidnightAdapterTest is Test { adapter.cancelRoot(_root); assertTrue(adapter.isRootCanceled(_root), "root canceled"); vm.expectRevert(IMidnightAdapter.RootCanceled.selector); - adapter.isRatified(offer, data); + adapter.isRatified(offer, data, taker); } function testCancelRootUnauthorized(address caller) public { @@ -524,7 +530,7 @@ contract MidnightAdapterTest is Test { offer.group = bytes32("second buy"); uint256 units = 1e18 * 1e18 / TickLib.tickToPrice(MAX_TICK); - offer.maxUnits = units; + offer.maxUnits = uint128(units); vm.startPrank(taker); midnight.supplyCollateral(offer.market, 0, 0.5e18, taker); @@ -533,7 +539,7 @@ contract MidnightAdapterTest is Test { offer.callbackData = hex""; vm.prank(taker); - midnight.take(offer, units, taker, taker, address(0), "", sign([offer], signerAllocator)); + midnight.take(offer, sign([offer], signerAllocator), units, taker, taker, address(0), ""); } function testSellClearsMaturityAndReactivatesSlot() public { @@ -620,8 +626,8 @@ contract MidnightAdapterTest is Test { bytes32 marketId = _marketId(offer.market); uint256 loss = 0.5e18; - stdstore.target(address(midnight)).sig("creditOf(bytes32,address)").with_key(marketId) - .with_key(address(adapter)).checked_write(units - loss); + stdstore.target(address(midnight)).sig("credit(bytes32,address)").with_key(marketId).with_key(address(adapter)) + .checked_write(units - loss); offer.group = bytes32("second"); midnight.supplyCollateral(offer.market, 0, units, taker); @@ -866,7 +872,7 @@ contract MidnightAdapterTest is Test { offer.buy = true; offer.tick = tick; offer.group = bytes32(duration); - offer.maxUnits = assets * 1e18 / TickLib.tickToPrice(tick); + offer.maxUnits = uint128(assets * 1e18 / TickLib.tickToPrice(tick)); offer.expiry = block.timestamp; offer.callback = address(adapter); offer.callbackData = hex""; @@ -874,7 +880,7 @@ contract MidnightAdapterTest is Test { function take(Offer memory offer) internal { vm.prank(taker); - midnight.take(offer, offer.maxUnits, taker, taker, address(0), "", sign([offer], signerAllocator)); + midnight.take(offer, sign([offer], signerAllocator), offer.maxUnits, taker, taker, address(0), ""); } function buy(uint256 duration, uint256 assets) internal returns (Offer memory offer) { @@ -894,7 +900,7 @@ contract MidnightAdapterTest is Test { offer.buy = false; offer.reduceOnly = true; offer.tick = tick; - offer.maxUnits = units; + offer.maxUnits = uint128(units); offer.expiry = block.timestamp; offer.callback = address(adapter); offer.receiverIfMakerIsSeller = address(adapter); @@ -904,15 +910,16 @@ contract MidnightAdapterTest is Test { function sell(Market memory market, uint256 assets) internal { Offer memory offer = makeSellOffer(market, 0, MAX_TICK); - offer.maxUnits = TakeAmountsLib.sellerAssetsToUnits(address(midnight), _marketId(market), offer, assets); + offer.maxUnits = + uint128(TakeAmountsLib.sellerAssetsToUnits(address(midnight), _marketId(market), offer, assets)); vm.prank(taker); - midnight.take(offer, offer.maxUnits, taker, taker, address(0), "", sign([offer], signerAllocator)); + midnight.take(offer, sign([offer], signerAllocator), offer.maxUnits, taker, address(0), address(0), ""); } function sellUnits(Market memory market, uint256 units, uint256 tick) internal { Offer memory offer = makeSellOffer(market, units, tick); vm.prank(taker); - midnight.take(offer, offer.maxUnits, taker, taker, address(0), "", sign([offer], signerAllocator)); + midnight.take(offer, sign([offer], signerAllocator), offer.maxUnits, taker, address(0), address(0), ""); } function makeForceDeallocateOffer(Market memory market, uint256 assets) @@ -929,7 +936,7 @@ contract MidnightAdapterTest is Test { offer.tick = MAX_TICK; uint256 price = TickLib.tickToPrice(MAX_TICK); uint256 units = assets * 1e18 / price; - offer.maxUnits = units; + offer.maxUnits = uint128(units); offer.expiry = block.timestamp; offer.callback = address(0); offer.callbackData = hex""; @@ -956,7 +963,7 @@ contract MidnightAdapterTest is Test { } function setMidnightCredit(bytes32 marketId, address account, uint256 credit) internal { - stdstore.target(address(midnight)).sig("creditOf(bytes32,address)").with_key(marketId).with_key(account) + stdstore.target(address(midnight)).sig("credit(bytes32,address)").with_key(marketId).with_key(account) .checked_write(credit); } @@ -998,7 +1005,7 @@ contract MidnightAdapterTest is Test { } function _marketId(Market memory market) internal view returns (bytes32) { - return IdLib.toId(market, block.chainid, address(midnight)); + return IdLib.toId(market); } function sign(Offer[1] memory offers) internal view returns (bytes memory) { From fa653cc3269ed5138de7df32508124508e93bc96 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 27 Jul 2026 11:33:33 +0200 Subject: [PATCH 81/95] fix comments remove dead code add sentinel role --- src/adapters/MidnightAdapter.sol | 12 ++++++----- src/adapters/interfaces/IMidnightAdapter.sol | 2 -- test/MidnightAdapterTest.sol | 21 ++++++++++---------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 9338c17b2..a3c4c4a81 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -49,6 +49,7 @@ contract MidnightAdapter is IMidnightAdapter { uint48 public lastUpdate; /// @dev Maximum steps of an accrual. /// @dev A maturity uses an availability slot iff it has some units and is > now after accrual. + /// @dev Takers of offers of the adapter can fill slots with dust takes. uint8 public constant MAX_PENDING_MATURITIES = 50; uint8 public availableMaturities = MAX_PENDING_MATURITIES; mapping(uint256 timestamp => MaturityData) public _maturities; @@ -82,7 +83,7 @@ contract MidnightAdapter is IMidnightAdapter { } /// @dev Returns the durations that can be capped. - /// @dev A market position fills the cap of any duration that is >= its time to maturity. + /// @dev A market position fills the cap of any duration that is <= its time to maturity. function durations() public view returns (uint256[] memory) { uint256[] memory _durations = new uint256[](durationsLength); for (uint256 i = 0; i < durationsLength; i++) { @@ -112,7 +113,10 @@ contract MidnightAdapter is IMidnightAdapter { function withdrawToVault(Market memory market, uint256 withdrawnAssets) external { bytes32 marketId = IdLib.toId(market); - require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); + require( + IVaultV2(parentVault).isAllocator(msg.sender) || IVaultV2(parentVault).isSentinel(msg.sender), + NotAuthorized() + ); accrueInterest(); updateDurationCaps(market); @@ -185,7 +189,6 @@ contract MidnightAdapter is IMidnightAdapter { } /// @dev Returns an estimate of the real assets assigned to the adapter. - /// @dev Excludes assets reserved for users. function realAssets() external view returns (uint256) { (,, uint256 newTotalAssets,) = accrueInterestView(); return newTotalAssets; @@ -260,7 +263,6 @@ contract MidnightAdapter is IMidnightAdapter { require(offer.callback == address(this), IncorrectCallbackAddress()); // For buy offers, Midnight enforces receiverIfMakerIsSeller == address(0). require(offer.buy || offer.receiverIfMakerIsSeller == address(this), IncorrectReceiver()); - require(offer.start <= block.timestamp, IncorrectStart()); require(offer.buy || offer.reduceOnly, NoDebtCreation()); (Signature memory sig, bytes32 root, uint256 leafIndex, bytes32[] memory proof) = @@ -412,7 +414,7 @@ contract MidnightAdapter is IMidnightAdapter { } } - /// @dev Returns the number of durations in packedDurations that are most the time to maturity. + /// @dev Returns the number of durations in packedDurations that are at most the time to maturity. function durationCount(uint256 maturity) internal view returns (uint256 count) { uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); while (count < durationsLength && timeToMaturity >= packedDurations.get(count)) count++; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 93bee7e4a..17d1731dc 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -42,12 +42,10 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { error BufferTooLow(); error BuyAtLoss(); error IncorrectCallbackAddress(); - error IncorrectDuration(); error IncorrectOffer(); error IncorrectOwner(); error IncorrectReceiver(); error IncorrectSigner(); - error IncorrectStart(); error InvalidProof(); error LoanAssetMismatch(); error NoDebtCreation(); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 118a3518e..29220f3ff 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -273,16 +273,6 @@ contract MidnightAdapterTest is Test { adapter.isRatified(offer, data, taker); } - function testRatifyIncorrectStart(uint256 seed) public { - vm.setSeed(seed); - Offer memory offer = _ratificationSetup(); - offer.start = vm.getBlockTimestamp() + 1; - bytes32 _root = root(offer); - bytes memory data = ratifierData(_root, signerAllocator); - vm.expectRevert(IMidnightAdapter.IncorrectStart.selector); - adapter.isRatified(offer, data, taker); - } - function testRatifyIncorrectCallbackAddress(uint256 seed) public { vm.setSeed(seed); Offer memory offer = _ratificationSetup(); @@ -791,13 +781,22 @@ contract MidnightAdapterTest is Test { /* WITHDRAW TO VAULT */ function testWithdrawToVaultUnauthorized(address nonAllocator) public { - vm.assume(!parentVault.isAllocator(nonAllocator)); + vm.assume(!parentVault.isAllocator(nonAllocator) && !parentVault.isSentinel(nonAllocator)); Market memory market = storedOffer.market; vm.prank(nonAllocator); vm.expectRevert(IMidnightAdapter.NotAuthorized.selector); adapter.withdrawToVault(market, 0); } + function testWithdrawToVaultBySentinel(address sentinel) public { + vm.assume(sentinel != signerAllocator); + stdstore.target(address(parentVault)).sig("isSentinel(address)").with_key(sentinel).checked_write(true); + Offer memory boughtOffer = buy(7 days, 1e18); + + vm.prank(sentinel); + adapter.withdrawToVault(boughtOffer.market, 0); + } + function testWithdrawToVaultOK() public { Offer memory boughtOffer = buy(7 days, 1e18); bytes32 marketId = _marketId(boughtOffer.market); From 81199da9b53ac835f6b721720db3824447ae4c57 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 30 Jul 2026 22:27:24 +0200 Subject: [PATCH 82/95] check realize loss is possible --- src/adapters/MidnightAdapter.sol | 2 +- test/MidnightAdapterTest.sol | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index a3c4c4a81..d19c50b8b 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -210,7 +210,7 @@ contract MidnightAdapter is IMidnightAdapter { } /// @dev Can be called by this adapter from a sell callback, a withdraw, or a loss realization. - /// @dev Can be called by a user through forceDeallocate to trigger a sell take by the adapter. + /// @dev Can be called through 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) diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 29220f3ff..e386ec137 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -11,6 +11,7 @@ import {VaultV2Mock} from "./mocks/VaultV2Mock.sol"; import {IERC20} from "../src/interfaces/IERC20.sol"; import {IAdapter} from "../src/interfaces/IAdapter.sol"; import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol"; +import {IVaultV2} from "../src/interfaces/IVaultV2.sol"; import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; import {Midnight} from "../lib/midnight/src/Midnight.sol"; @@ -41,6 +42,35 @@ contract ExtraAssetsAdapter is IAdapter { } } +/// @notice Realizes the losses of a midnight adapter in a market. +contract MidnightLossRealizer { + address public immutable midnight; + + constructor(address _midnight) { + midnight = _midnight; + IMidnight(_midnight).setIsAuthorized(address(this), true, address(this)); + } + + function realizeLoss(IMidnightAdapter adapter, Market memory market) external { + Offer memory offer; + offer.market = market; + offer.buy = true; + offer.maker = address(this); + offer.expiry = block.timestamp; + offer.tick = MAX_TICK; + offer.ratifier = address(this); + offer.maxUnits = 1; + offer.continuousFeeCap = type(uint256).max; + + IVaultV2(adapter.parentVault()) + .forceDeallocate(address(adapter), abi.encode(offer, bytes("")), 0, address(this)); + } + + function isRatified(Offer memory, bytes memory, address) external view returns (bytes32) { + return CALLBACK_SUCCESS; + } +} + contract MidnightAdapterTest is Test { using stdStorage for StdStorage; using MathLib for uint256; @@ -778,6 +808,30 @@ contract MidnightAdapterTest is Test { ); } + function testForceDeallocateRealizesLoss() public { + Offer memory boughtOffer = buy(7 days, 1e18); + bytes32 marketId = _marketId(boughtOffer.market); + + // Partial repay so the bad debt below does not max out the loss factor. + deal(address(loanToken), address(this), 0.7e18); + loanToken.approve(address(midnight), type(uint256).max); + midnight.repay(boughtOffer.market, 0.7e18, taker, address(0), ""); + + OracleMock(storedCollaterals[0].oracle).setPrice(0); + OracleMock(storedCollaterals[1].oracle).setPrice(0); + midnight.liquidate(boughtOffer.market, 0, 0, 0, taker, false, address(this), address(0), ""); + + // The slash is only pending: the position's raw credit is untouched. + assertEq(midnight.credit(marketId, address(adapter)), 1e18, "raw credit"); + + MidnightLossRealizer realizer = new MidnightLossRealizer(address(midnight)); + realizer.realizeLoss(IMidnightAdapter(address(adapter)), boughtOffer.market); + + (uint128 marketNetCredit,) = adapter._markets(marketId); + assertApproxEqAbs(marketNetCredit, 0.7e18, 1, "netCredit"); + assertApproxEqAbs(parentVault.allocation(adapter.adapterId()), 0.7e18, 1, "allocation"); + } + /* WITHDRAW TO VAULT */ function testWithdrawToVaultUnauthorized(address nonAllocator) public { From a70c1d6b20db6de17f6a90a4253825b47aa15a8e Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Mon, 17 Aug 2026 17:52:05 +0200 Subject: [PATCH 83/95] reorder vars and comment --- src/adapters/MidnightAdapter.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index d19c50b8b..365d99522 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -32,7 +32,7 @@ contract MidnightAdapter is IMidnightAdapter { address public immutable parentVault; address public immutable midnight; bytes32 public immutable adapterId; - /// @dev Sorted durations that can be used to cap the time to maturity. + /// @dev Durations that can be used to cap the time to maturity. /// @dev Sorted in ascending order. bytes32 public immutable packedDurations; uint256 public immutable durationsLength; @@ -48,7 +48,7 @@ contract MidnightAdapter is IMidnightAdapter { uint128 public currentGrowth; uint48 public lastUpdate; /// @dev Maximum steps of an accrual. - /// @dev A maturity uses an availability slot iff it has some units and is > now after accrual. + /// @dev After accrual, a maturity uses an availability slot iff it has some units and is > now. /// @dev Takers of offers of the adapter can fill slots with dust takes. uint8 public constant MAX_PENDING_MATURITIES = 50; uint8 public availableMaturities = MAX_PENDING_MATURITIES; @@ -154,11 +154,11 @@ contract MidnightAdapter is IMidnightAdapter { function accrueInterestView() public view returns (uint48, uint128, uint128, uint256) { if (block.timestamp == lastUpdate) return (_maturities[0].nextMaturity, currentGrowth, totalAssets, 0); - uint48 _firstMaturity = _maturities[0].nextMaturity; - uint128 newGrowth = currentGrowth; - uint256 removedMaturities = 0; uint256 gainedAssets = 0; + uint128 newGrowth = currentGrowth; uint256 accrueFrom = lastUpdate; + uint48 _firstMaturity = _maturities[0].nextMaturity; + uint256 removedMaturities = 0; while (_firstMaturity != 0 && _firstMaturity <= block.timestamp) { gainedAssets += uint256(newGrowth) * (_firstMaturity - accrueFrom); From f311675095c1e28ff842a12a45d002296088f2b1 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 18 Aug 2026 00:12:13 +0200 Subject: [PATCH 84/95] make sell and id update through forceDeallocate --- src/adapters/MidnightAdapter.sol | 30 ++-- src/adapters/interfaces/IMidnightAdapter.sol | 5 +- test/MidnightAdapterTest.sol | 166 +++++++++++++++++++ 3 files changed, 186 insertions(+), 15 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 365d99522..a4041b5d8 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -19,7 +19,9 @@ import {DurationsLib} from "./libraries/DurationsLib.sol"; /// @dev Approximates held assets by linearly accounting for interest per market, aggregated by maturity. /// @dev Losses are immediately 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 market hit by the loss. -/// @dev The adapter must have the allocator role in its parent vault to be able to buy & sell on markets. +/// @dev The adapter must have the allocator role in its parent vault to buy, and the allocator or sentinel role to +/// make sell offers and to withdraw to the vault. +/// @dev If the parent vault has a sendSharesGate, the gate must allow the adapter to send shares. contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; @@ -138,14 +140,14 @@ contract MidnightAdapter is IMidnightAdapter { uint256 newDurationCount = durationCount(market.maturity); maturityData.durationCount = uint8(newDurationCount); emit UpdateDurationCaps(market.maturity, newDurationCount, maturityData.netCredit); - // VaultV2.deallocate requires allocation > 0 for each returned id. + // VaultV2.forceDeallocate requires allocation > 0 for each returned id. if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationCount - newDurationCount); for (uint256 i = 0; i < zeroedDurationsIds.length; i++) { zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationCount + i))); } - IVaultV2(parentVault) - .deallocate(address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))), 0); + bytes memory data = abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))); + IVaultV2(parentVault).forceDeallocate(address(this), data, 0, address(this)); } } @@ -209,14 +211,22 @@ contract MidnightAdapter is IMidnightAdapter { } } - /// @dev Can be called by this adapter from a sell callback, a withdraw, or a loss realization. - /// @dev Can be called through forceDeallocate to trigger a sell take by the adapter. + /// @dev Can be called by this adapter from a sell callback, a withdraw, or a duration caps update. + /// @dev Can be called by anyone through 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) { require(msg.sender == parentVault, NotAuthorized()); - if (messageSig == IVaultV2.forceDeallocate.selector) { + if (caller == address(this)) { + // Return exactly the data passed to the function. + // Used to update duration caps through forceDeallocate, sell as a maker, or withdraw to the vault. + assembly ("memory-safe") { + return(add(data, 32), mload(data)) + } + } else { + require(messageSig == IVaultV2.forceDeallocate.selector, ForceDeallocateOnly()); + (Offer memory offer, bytes memory ratifierData) = abi.decode(data, (Offer, bytes)); require( offer.buy && offer.market.loanToken == asset && offer.tick == MAX_TICK && offer.callback == address(0), @@ -236,12 +246,6 @@ contract MidnightAdapter is IMidnightAdapter { emit ForceDeallocate(marketId, sellerAssets, netCreditDecrease); return (ids(offer.market), -netCreditDecrease.toInt256()); - } else { - require(caller == address(this), SelfAllocationOnly()); - // Return exactly the data passed to the function. - assembly ("memory-safe") { - return(add(data, 32), mload(data)) - } } } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 17d1731dc..49f5a7ac4 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -41,6 +41,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { error BufferTooLow(); error BuyAtLoss(); + error ForceDeallocateOnly(); error IncorrectCallbackAddress(); error IncorrectOffer(); error IncorrectOwner(); @@ -81,10 +82,10 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint128, uint256); function accrueInterest() external returns (uint48, uint128, uint256); - function allocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) + function allocate(bytes memory data, uint256 assets, bytes4, address caller) external returns (bytes32[] memory, int256); - function deallocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) + function deallocate(bytes memory data, uint256 assets, bytes4, address caller) external returns (bytes32[] memory, int256); function onBuy( diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index e386ec137..2fe7d2930 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -12,6 +12,8 @@ import {IERC20} from "../src/interfaces/IERC20.sol"; import {IAdapter} from "../src/interfaces/IAdapter.sol"; import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol"; import {IVaultV2} from "../src/interfaces/IVaultV2.sol"; +import {ISendSharesGate} from "../src/interfaces/IGate.sol"; +import {ErrorsLib} from "../src/libraries/ErrorsLib.sol"; import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; import {Midnight} from "../lib/midnight/src/Midnight.sol"; @@ -79,6 +81,7 @@ contract MidnightAdapterTest is Test { IMidnightAdapterFactory internal factory; IMidnightAdapter internal adapter; VaultV2Mock internal parentVault; + IVaultV2 internal realVault; IERC20 internal loanToken; IERC20 internal rewardToken; address internal owner; @@ -808,6 +811,105 @@ contract MidnightAdapterTest is Test { ); } + function testForceDeallocateWithoutRole() public { + Offer memory boughtOffer = buy(7 days, 1e18); + skip(1); + + // Simulate the adapter having no role: any vault.deallocate call from the adapter reverts. + vm.mockCallRevert(address(parentVault), abi.encodeWithSelector(VaultV2Mock.deallocate.selector), "no role"); + + forceDeallocate(boughtOffer.market, 0.5e18); + + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); + assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); + (uint128 marketNetCredit,) = adapter._markets(_marketId(boughtOffer.market)); + assertEq(marketNetCredit, 0.5e18, "netCredit"); + } + + /// forge-config: default.isolate = true + /// @dev Runs on a real VaultV2, with a non-zero penalty, fees and maxRate, and with the adapter's allocator role + /// revoked before the exit. + function testForceDeallocateRealVaultWithPenalty() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setIsAllocator, (address(adapter), false))); + + skip(1); + + uint256 sharesBefore = realVault.balanceOf(address(this)); + uint256 expectedPenaltyShares = realVault.previewWithdraw(0.01e18); + uint256 penaltyShares = forceDeallocateOnRealVault(offer.market, 0.5e18); + + assertEq(penaltyShares, expectedPenaltyShares, "penalty shares"); + assertEq(realVault.balanceOf(address(this)), sharesBefore - penaltyShares, "penalty charged to onBehalf"); + assertGt(realVault.balanceOf(recipient), 0, "fee shares minted"); + (uint128 marketNetCredit,) = adapter._markets(_marketId(offer.market)); + assertEq(marketNetCredit, 0.5e18, "netCredit"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days zeroed"); + assertEq(realVault.allocation(durationId(1 days)), 0.5e18, "1 day"); + assertEq(loanToken.balanceOf(address(realVault)), 9.5e18, "vault balance"); + } + + /// forge-config: default.isolate = true + /// @dev A sendSharesGate must allow the adapter: exits work when it does, and revert once a duration boundary + /// has been crossed when it does not. + function testForceDeallocateRealVaultWithGate() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + + address gate = makeAddr("gate"); + vm.etch(gate, hex"01"); + vm.mockCall(gate, abi.encodeWithSelector(ISendSharesGate.canSendShares.selector), abi.encode(true)); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setSendSharesGate, (gate))); + + skip(1); + + forceDeallocateOnRealVault(offer.market, 0.5e18); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days zeroed"); + + vm.mockCall( + gate, abi.encodeWithSelector(ISendSharesGate.canSendShares.selector, address(adapter)), abi.encode(false) + ); + skip(6 days); + (Offer memory extOffer, bytes32 root_) = makeForceDeallocateOffer(offer.market, 0.1e18); + bytes memory data = abi.encode(extOffer, abi.encode(root_, 0, proof([extOffer]))); + vm.expectRevert(ErrorsLib.CannotSendShares.selector); + realVault.forceDeallocate(address(adapter), data, 0.1e18, address(this)); + } + + /// forge-config: default.isolate = true + /// @dev A matured maturity zeroes all its duration ids at once, without the adapter having any role. + function testUpdateDurationCapsMaturedRealVault() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setIsAllocator, (address(adapter), false))); + + skip(7 days + 1); + + adapter.updateDurationCaps(offer.market); + + assertEq(realVault.allocation(durationId(1 days)), 0, "1 day zeroed"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days zeroed"); + assertEq(realVault.allocation(adapter.adapterId()), 1e18, "adapter id untouched"); + } + + /// forge-config: default.isolate = true + /// @dev Zeroing a maturity's stale duration id must not touch other maturities sharing that id. + function testForceDeallocateRealVaultSharedDurationId() public { + setUpRealVault(); + Offer memory offerA = buyOnRealVault(7 days, 1e18); + buyOnRealVault(10 days, 1e18); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setIsAllocator, (address(adapter), false))); + + skip(1); + + forceDeallocateOnRealVault(offerA.market, 0.5e18); + + assertEq(realVault.allocation(durationId(7 days)), 1e18, "7 days keeps the other maturity's part"); + assertEq(realVault.allocation(durationId(1 days)), 1.5e18, "1 day"); + assertEq(realVault.allocation(adapter.adapterId()), 1.5e18, "adapter id"); + } + function testForceDeallocateRealizesLoss() public { Offer memory boughtOffer = buy(7 days, 1e18); bytes32 marketId = _marketId(boughtOffer.market); @@ -1011,6 +1113,70 @@ contract MidnightAdapterTest is Test { parentVault.forceDeallocate(address(adapter), data, assets, address(this)); } + function setUpRealVault() internal { + realVault = IVaultV2(deployCode("VaultV2.sol:VaultV2", abi.encode(owner, address(loanToken)))); + vm.prank(owner); + realVault.setCurator(curator); + adapter = IMidnightAdapter(factory.createMidnightAdapter(address(realVault), address(midnight))); + + submitAndCall(realVault, abi.encodeCall(IVaultV2.addAdapter, (address(adapter)))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setIsAllocator, (address(adapter), true))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setIsAllocator, (signerAllocator, true))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setForceDeallocatePenalty, (address(adapter), 0.02e18))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setPerformanceFeeRecipient, (recipient))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setManagementFeeRecipient, (recipient))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setPerformanceFee, (0.1e18))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.setManagementFee, (1e9))); + vm.prank(signerAllocator); + realVault.setMaxRate(1e18 / uint256(365 days)); + + bytes[] memory idDatas = new bytes[](7); + idDatas[0] = abi.encode("this", address(adapter)); + idDatas[1] = abi.encode("collateralToken", storedCollaterals[0].token); + idDatas[2] = + abi.encode("collateral", storedCollaterals[0].token, storedCollaterals[0].oracle, storedCollaterals[0].lltv); + idDatas[3] = abi.encode("collateralToken", storedCollaterals[1].token); + idDatas[4] = + abi.encode("collateral", storedCollaterals[1].token, storedCollaterals[1].oracle, storedCollaterals[1].lltv); + idDatas[5] = abi.encode("duration", uint256(1 days)); + idDatas[6] = abi.encode("duration", uint256(7 days)); + for (uint256 i = 0; i < idDatas.length; i++) { + submitAndCall(realVault, abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idDatas[i], type(uint128).max))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.increaseRelativeCap, (idDatas[i], 1e18))); + } + + deal(address(loanToken), address(this), 10e18); + loanToken.approve(address(realVault), type(uint256).max); + realVault.deposit(10e18, address(this)); + } + + function buyOnRealVault(uint256 duration, uint256 assets) internal returns (Offer memory offer) { + offer = makeBuyOffer(duration, assets, MAX_TICK); + offer.maker = address(adapter); + offer.callback = address(adapter); + offer.ratifier = address(adapter); + midnight.supplyCollateral(offer.market, 0, assets / 2, taker); + midnight.supplyCollateral(offer.market, 1, assets / 2, taker); + take(offer); + } + + function forceDeallocateOnRealVault(Market memory market, uint256 assets) internal returns (uint256) { + (Offer memory offer, bytes32 root_) = makeForceDeallocateOffer(market, assets); + bytes memory data = abi.encode(offer, abi.encode(root_, 0, proof([offer]))); + return realVault.forceDeallocate(address(adapter), data, assets, address(this)); + } + + function submitAndCall(IVaultV2 vault, bytes memory call_) internal { + vm.prank(curator); + vault.submit(call_); + (bool success, bytes memory returnData) = address(vault).call(call_); + if (!success) { + assembly ("memory-safe") { + revert(add(32, returnData), mload(returnData)) + } + } + } + function durationId(uint256 duration) internal pure returns (bytes32) { return keccak256(abi.encode("duration", duration)); } From 4a3309bc7ff5ae0e48b558018bed5c9a0c1dd5af Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 18 Aug 2026 00:22:50 +0200 Subject: [PATCH 85/95] update midnight --- foundry.lock | 4 ++-- lib/midnight | 2 +- src/adapters/MidnightAdapter.sol | 2 +- test/MidnightAdapterTest.sol | 12 +++++++----- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/foundry.lock b/foundry.lock index 49809f418..e584b7377 100644 --- a/foundry.lock +++ b/foundry.lock @@ -9,7 +9,7 @@ "rev": "2d160ba9bb945ca3bf12efb182427445dce59c27" }, "lib/midnight": { - "rev": "799b8176089148bc7d85e2ae2bfbc4eb25ff26a2" + "rev": "1127f2b4c2844349f34659ff2966120dabacfbc9" }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" @@ -20,4 +20,4 @@ "lib/openzeppelin-contracts": { "rev": "b72e3da0ec1f47e4a7911a4c06dc92e78c646607" } -} +} \ No newline at end of file diff --git a/lib/midnight b/lib/midnight index 799b81760..1127f2b4c 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit 799b8176089148bc7d85e2ae2bfbc4eb25ff26a2 +Subproject commit 1127f2b4c2844349f34659ff2966120dabacfbc9 diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index a4041b5d8..05c1ec5be 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -7,7 +7,7 @@ import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH} from "lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; -import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; +import {TakeAmountsLib} from "lib/midnight/src/periphery/libraries/TakeAmountsLib.sol"; import {HashLib} from "lib/midnight/src/ratifiers/libraries/HashLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 2fe7d2930..309843036 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -25,7 +25,7 @@ import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; import {CALLBACK_SUCCESS} from "../lib/midnight/src/libraries/ConstantsLib.sol"; -import {TakeAmountsLib} from "../lib/midnight/src/periphery/TakeAmountsLib.sol"; +import {TakeAmountsLib} from "../lib/midnight/src/periphery/libraries/TakeAmountsLib.sol"; import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; contract ExtraAssetsAdapter is IAdapter { @@ -1133,11 +1133,13 @@ contract MidnightAdapterTest is Test { bytes[] memory idDatas = new bytes[](7); idDatas[0] = abi.encode("this", address(adapter)); idDatas[1] = abi.encode("collateralToken", storedCollaterals[0].token); - idDatas[2] = - abi.encode("collateral", storedCollaterals[0].token, storedCollaterals[0].oracle, storedCollaterals[0].lltv); + idDatas[2] = abi.encode( + "collateral", storedCollaterals[0].token, storedCollaterals[0].oracle, storedCollaterals[0].lltv + ); idDatas[3] = abi.encode("collateralToken", storedCollaterals[1].token); - idDatas[4] = - abi.encode("collateral", storedCollaterals[1].token, storedCollaterals[1].oracle, storedCollaterals[1].lltv); + idDatas[4] = abi.encode( + "collateral", storedCollaterals[1].token, storedCollaterals[1].oracle, storedCollaterals[1].lltv + ); idDatas[5] = abi.encode("duration", uint256(1 days)); idDatas[6] = abi.encode("duration", uint256(7 days)); for (uint256 i = 0; i < idDatas.length; i++) { From 5b39214b713ab4c83a1e43192a1cb6d77bcdeb73 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 18 Aug 2026 14:50:19 +0200 Subject: [PATCH 86/95] reduce midnight adapter size --- foundry.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/foundry.toml b/foundry.toml index bdd2bce41..674036c44 100644 --- a/foundry.toml +++ b/foundry.toml @@ -52,6 +52,10 @@ paths = "src/imports/MetaMorphoImport.sol" optimizer_runs = 200 evm_version = "cancun" +[[profile.default.compilation_restrictions]] +paths = "src/adapters/MidnightAdapter.sol" +optimizer_runs = 65000 + # For every restriction above there must be some compatible foundry profile. # The first matching profile will be used. For clarity, all profiles below should be incompatible with each other. @@ -65,4 +69,8 @@ name = "200-cancun" optimizer_runs = 200 evm_version = "cancun" +[[profile.default.additional_compiler_profiles]] +name = "65000-osaka" +optimizer_runs = 65000 + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options From e17b839e7604613c0bd6cf164ac780d332c6a12a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 19 Aug 2026 11:38:30 +0200 Subject: [PATCH 87/95] cleanup --- .github/workflows/certora.yml | 1 - .github/workflows/foundry.yml | 1 - foundry.lock | 2 +- lib/midnight | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index b9dfe23d3..9f0580413 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -23,7 +23,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - token: ${{ secrets.MORPHO_V2_READ_TOKEN }} - name: Install Jq uses: sergeysova/jq-action@v2 diff --git a/.github/workflows/foundry.yml b/.github/workflows/foundry.yml index 5b71d3296..9a08957ba 100644 --- a/.github/workflows/foundry.yml +++ b/.github/workflows/foundry.yml @@ -61,7 +61,6 @@ 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 e584b7377..3c59a5099 100644 --- a/foundry.lock +++ b/foundry.lock @@ -9,7 +9,7 @@ "rev": "2d160ba9bb945ca3bf12efb182427445dce59c27" }, "lib/midnight": { - "rev": "1127f2b4c2844349f34659ff2966120dabacfbc9" + "rev": "709dab354d8f03e64effc2a3dcdd08f5013a0758" }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" diff --git a/lib/midnight b/lib/midnight index 1127f2b4c..709dab354 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit 1127f2b4c2844349f34659ff2966120dabacfbc9 +Subproject commit 709dab354d8f03e64effc2a3dcdd08f5013a0758 From e59f2bb5b5b47cbd3d89239c05764d823548977b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 19 Aug 2026 18:53:49 +0200 Subject: [PATCH 88/95] fix CI: compile Midnight at home-repo params, bump forge-old to v1.7.0 --- .github/workflows/forge-old.yml | 4 +- foundry.toml | 9 +++ test/MidnightAdapterTest.sol | 105 +++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/.github/workflows/forge-old.yml b/.github/workflows/forge-old.yml index aa61a8833..2f1c72a8d 100644 --- a/.github/workflows/forge-old.yml +++ b/.github/workflows/forge-old.yml @@ -18,10 +18,10 @@ jobs: - name: Install Foundry (old) uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 # v1 with: - version: v1.5.1 + version: v1.7.0 - name: Verify forge executable hash env: - EXPECTED_FORGE_SHA256: a39fc1913b53dde6a35b603756f65d799e13817ef694107ec0d3704c20435cce + EXPECTED_FORGE_SHA256: d15400d654462d005659f2f987e9c09c4b4cfb41795e0864a51d77c5a56361de run: | forge_path="$(which forge)" echo "forge path: $forge_path" diff --git a/foundry.toml b/foundry.toml index 674036c44..64607cf7e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -56,6 +56,11 @@ evm_version = "cancun" paths = "src/adapters/MidnightAdapter.sol" optimizer_runs = 65000 +# Midnight is compiled with its home repo's settings so it fits under EIP-170. +[[profile.default.compilation_restrictions]] +paths = "lib/midnight/src/Midnight.sol" +optimizer_runs = 466 + # For every restriction above there must be some compatible foundry profile. # The first matching profile will be used. For clarity, all profiles below should be incompatible with each other. @@ -73,4 +78,8 @@ evm_version = "cancun" name = "65000-osaka" optimizer_runs = 65000 +[[profile.default.additional_compiler_profiles]] +name = "466-osaka" +optimizer_runs = 466 + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 309843036..5b94fe6b4 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -16,7 +16,6 @@ import {ISendSharesGate} from "../src/interfaces/IGate.sol"; import {ErrorsLib} from "../src/libraries/ErrorsLib.sol"; import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; -import {Midnight} from "../lib/midnight/src/Midnight.sol"; import {IMidnight, Offer, Market, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH} from "../lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {HashLib} from "../lib/midnight/src/ratifiers/libraries/HashLib.sol"; @@ -113,7 +112,8 @@ contract MidnightAdapterTest is Test { recipient = makeAddr("recipient"); taker = makeAddr("taker"); - midnight = IMidnight(address(new Midnight())); + // Deployed from the artifact so the test unit does not compile Midnight (see foundry.toml). + midnight = IMidnight(deployCode("Midnight.sol:Midnight")); midnight.enableLltv(1e18); midnight.enableLiquidationCursor(0.25e18); @@ -688,6 +688,36 @@ contract MidnightAdapterTest is Test { assertEq(adapter.totalAssets(), 0); } + // Same buffer check, reached through the allocator take path: taking a buy offer makes the adapter sell, + // below par here, dropping the vault's real assets under its reported total. + function testTakeBufferTooLowReverts() public { + deal(address(loanToken), address(parentVault), 1e18); + Offer memory offer = buy(0, 1e18); + parentVault.setTotalAssets(1e18); + + Offer memory buyOffer = makeExternalOffer(offer.market, true, 1e18, MAX_TICK - 4); + vm.expectRevert(IMidnightAdapter.BufferTooLow.selector); + vm.prank(signerAllocator); + adapter.take(buyOffer, "", 1e18); + } + + function testTakeBufferBigEnough() public { + uint256 loss = 1e18 - TickLib.tickToPrice(MAX_TICK - 4); + + deal(address(loanToken), address(parentVault), 1e18); + Offer memory offer = buy(0, 1e18); + extraAssetsAdapter.setRealAssets(loss); + parentVault.setTotalAssets(1e18); + + Offer memory buyOffer = makeExternalOffer(offer.market, true, 1e18, MAX_TICK - 4); + vm.prank(signerAllocator); + adapter.take(buyOffer, "", 1e18); + + (uint128 marketNetCredit,) = adapter._markets(_marketId(offer.market)); + assertEq(marketNetCredit, 0); + assertEq(adapter.totalAssets(), 0); + } + /* PENDING MATURITIES LIST */ function testOutOfOrderInsertsStaySorted() public { @@ -910,6 +940,40 @@ contract MidnightAdapterTest is Test { assertEq(realVault.allocation(adapter.adapterId()), 1.5e18, "adapter id"); } + /// forge-config: default.isolate = true + /// @dev An allocator takes external offers directly: taking a sell offer buys credit, taking a buy offer + /// sells it. Both route through the same onBuy/onSell accounting as the maker flows. + function testAllocatorTakeRealVault() public { + setUpRealVault(); + Market memory market = makeBuyOffer(7 days, 1e18, MAX_TICK).market; + bytes32 marketId = _marketId(market); + + Offer memory sellOffer = makeExternalOffer(market, false, 1e18, MAX_TICK); + vm.expectRevert(IMidnightAdapter.NotAuthorized.selector); + adapter.take(sellOffer, "", uint256(sellOffer.maxUnits)); + + // Buy 1e18 credit by taking the external sell offer, funded by the vault. + vm.prank(signerAllocator); + adapter.take(sellOffer, "", uint256(sellOffer.maxUnits)); + + assertEq(realVault.allocation(adapter.adapterId()), 1e18, "allocation after buy"); + assertEq(realVault.allocation(durationId(7 days)), 1e18, "duration allocation after buy"); + assertEq(loanToken.balanceOf(address(realVault)), 9e18, "vault funded the buy"); + assertEq(adapter.totalAssets(), 1e18, "adapter totalAssets after buy"); + + skip(1); + + // Sell 0.5e18 credit by taking an external buy offer, proceeds forwarded to the vault. + Offer memory buyOffer = makeExternalOffer(market, true, 0.5e18, MAX_TICK); + vm.prank(signerAllocator); + adapter.take(buyOffer, "", uint256(buyOffer.maxUnits)); + + (uint128 marketNetCredit,) = adapter._markets(marketId); + assertEq(marketNetCredit, 0.5e18, "netCredit after sell"); + assertEq(realVault.allocation(adapter.adapterId()), 0.5e18, "allocation after sell"); + assertEq(loanToken.balanceOf(address(realVault)), 9.5e18, "proceeds back in the vault"); + } + function testForceDeallocateRealizesLoss() public { Offer memory boughtOffer = buy(7 days, 1e18); bytes32 marketId = _marketId(boughtOffer.market); @@ -1107,6 +1171,43 @@ contract MidnightAdapterTest is Test { vm.stopPrank(); } + /// @dev Builds an external offer at `tick`, ratified by this contract. Buy offers get a funded maker, sell + /// offers get a collateralized one. + function makeExternalOffer(Market memory market, bool buy, uint256 assets, uint256 tick) + internal + returns (Offer memory offer) + { + address maker = makeAddr(buy ? "externalBuyer" : "externalSeller"); + vm.prank(maker); + midnight.setIsAuthorized(address(this), true, maker); + + offer = storedOffer; + offer.market = market; + offer.buy = buy; + offer.maker = maker; + offer.tick = tick; + offer.maxUnits = uint128(assets * 1e18 / TickLib.tickToPrice(tick)); + offer.expiry = block.timestamp; + offer.callback = address(0); + offer.receiverIfMakerIsSeller = buy ? address(0) : maker; + offer.ratifier = address(this); + offer.group = bytes32(vm.randomUint()); + + if (buy) { + deal(address(loanToken), maker, assets); + vm.prank(maker); + loanToken.approve(address(midnight), type(uint256).max); + } else { + midnight.supplyCollateral(market, 0, assets / 2, maker); + midnight.supplyCollateral(market, 1, assets / 2, maker); + } + } + + /// @dev Ratifier for external offers built by makeExternalOffer. + function isRatified(Offer memory, bytes memory, address) external pure returns (bytes32) { + return CALLBACK_SUCCESS; + } + function forceDeallocate(Market memory market, uint256 assets) internal { (Offer memory offer, bytes32 root_) = makeForceDeallocateOffer(market, assets); bytes memory data = abi.encode(offer, abi.encode(root_, 0, proof([offer]))); From ac10a00ec052aabc6986c63a4319f23ca47ffe56 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 19 Aug 2026 19:05:21 +0200 Subject: [PATCH 89/95] add take --- src/adapters/MidnightAdapter.sol | 9 +++++++++ src/adapters/interfaces/IMidnightAdapter.sol | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 05c1ec5be..3c76be568 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -134,6 +134,15 @@ contract MidnightAdapter is IMidnightAdapter { emit WithdrawToVault(marketId, withdrawnAssets, netCreditDecrease); } + function take(Offer memory offer, bytes memory ratifierData, uint256 units) external { + require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); + require(offer.market.loanToken == asset, LoanAssetMismatch()); + IMidnight(midnight) + .take( + offer, ratifierData, units, address(this), offer.buy ? address(this) : address(0), address(this), hex"" + ); + } + function updateDurationCaps(Market memory market) public { MaturityData storage maturityData = _maturities[market.maturity]; uint256 oldDurationCount = maturityData.durationCount; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 49f5a7ac4..1c1ddb903 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -3,7 +3,7 @@ pragma solidity >=0.5.0; import {IAdapter} from "../../interfaces/IAdapter.sol"; -import {Market} from "lib/midnight/src/interfaces/IMidnight.sol"; +import {Market, Offer} from "lib/midnight/src/interfaces/IMidnight.sol"; import {IBuyCallback, ISellCallback} from "lib/midnight/src/interfaces/ICallbacks.sol"; import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; @@ -78,6 +78,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function durationsLength() external view returns (uint256); function updateDurationCaps(Market memory market) external; function withdrawToVault(Market memory market, uint256 withdrawnAssets) external; + function take(Offer memory offer, bytes memory ratifierData, uint256 units) external; function ids(Market memory market) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint128, uint256); From 4c17ba9e7e23eedc3f6990f67f755f7ff995b8a9 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 20 Aug 2026 11:52:34 +0200 Subject: [PATCH 90/95] compile midnight adapter unit at 10000 optimizer runs to fit the factory under EIP-170 --- foundry.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/foundry.toml b/foundry.toml index 64607cf7e..a2d57ff58 100644 --- a/foundry.toml +++ b/foundry.toml @@ -54,7 +54,7 @@ evm_version = "cancun" [[profile.default.compilation_restrictions]] paths = "src/adapters/MidnightAdapter.sol" -optimizer_runs = 65000 +optimizer_runs = 10000 # Midnight is compiled with its home repo's settings so it fits under EIP-170. [[profile.default.compilation_restrictions]] @@ -75,8 +75,8 @@ optimizer_runs = 200 evm_version = "cancun" [[profile.default.additional_compiler_profiles]] -name = "65000-osaka" -optimizer_runs = 65000 +name = "10000-osaka" +optimizer_runs = 10000 [[profile.default.additional_compiler_profiles]] name = "466-osaka" From d21eec28bcb5dea3298870a093c6505c59f4fb4d Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 20 Aug 2026 12:59:34 +0200 Subject: [PATCH 91/95] raise midnight adapter unit to 17000 optimizer runs, the largest 1e3 multiple fitting EIP-170 --- foundry.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/foundry.toml b/foundry.toml index a2d57ff58..a22ddf5be 100644 --- a/foundry.toml +++ b/foundry.toml @@ -54,7 +54,7 @@ evm_version = "cancun" [[profile.default.compilation_restrictions]] paths = "src/adapters/MidnightAdapter.sol" -optimizer_runs = 10000 +optimizer_runs = 17000 # Midnight is compiled with its home repo's settings so it fits under EIP-170. [[profile.default.compilation_restrictions]] @@ -75,8 +75,8 @@ optimizer_runs = 200 evm_version = "cancun" [[profile.default.additional_compiler_profiles]] -name = "10000-osaka" -optimizer_runs = 10000 +name = "17000-osaka" +optimizer_runs = 17000 [[profile.default.additional_compiler_profiles]] name = "466-osaka" From b4a0b3247a22d00e0a1b8060fe4b388b0198b794 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 26 Aug 2026 16:57:56 +0200 Subject: [PATCH 92/95] more tests --- test/MidnightAdapterTest.sol | 349 ++++++++++++++++++++++++++++++++--- 1 file changed, 323 insertions(+), 26 deletions(-) diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 5b94fe6b4..4261e85a8 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -23,7 +23,8 @@ import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; -import {CALLBACK_SUCCESS} from "../lib/midnight/src/libraries/ConstantsLib.sol"; +import {CALLBACK_SUCCESS, DEFAULT_TICK_SPACING, MAX_CONTINUOUS_FEE, CBP} from + "../lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "../lib/midnight/src/periphery/libraries/TakeAmountsLib.sol"; import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; @@ -102,6 +103,7 @@ contract MidnightAdapterTest is Test { uint256 internal constant MAX_TEST_ASSETS = 1e24; uint256[] internal allDurations = [1 days, 7 days, 30 days, 90 days, 180 days]; + uint256 internal discountTick = TickLib.priceToTick(0.95e18, DEFAULT_TICK_SPACING); function setUp() public virtual { owner = makeAddr("owner"); @@ -116,6 +118,7 @@ contract MidnightAdapterTest is Test { midnight = IMidnight(deployCode("Midnight.sol:Midnight")); midnight.enableLltv(1e18); midnight.enableLiquidationCursor(0.25e18); + midnight.setFeeSetter(address(this)); loanToken = IERC20(address(new ERC20Mock(18))); rewardToken = IERC20(address(new ERC20Mock(18))); @@ -316,12 +319,31 @@ contract MidnightAdapterTest is Test { adapter.isRatified(offer, data, taker); } - function testRatifyIncorrectExpiry(uint256 seed) public { + function testRatifyOK(uint256 seed) public { vm.setSeed(seed); Offer memory offer = _ratificationSetup(); bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); - adapter.isRatified(offer, data, taker); + assertEq(adapter.isRatified(offer, data, taker), CALLBACK_SUCCESS, "callback success"); + } + + function testRatifyTwoOfferTree(uint256 seed) public { + vm.setSeed(seed); + Offer memory offer = _ratificationSetup(); + Offer memory sibling = _ratificationSetup(); + bytes32 _root = root([offer, sibling]); + + bytes memory data = ratifierData(_root, signerAllocator, 0, proof([offer, sibling])); + assertEq(adapter.isRatified(offer, data, taker), CALLBACK_SUCCESS, "first leaf"); + + bytes32[] memory siblingProof = new bytes32[](1); + siblingProof[0] = HashLib.hashOffer(offer); + data = ratifierData(_root, signerAllocator, 1, siblingProof); + assertEq(adapter.isRatified(sibling, data, taker), CALLBACK_SUCCESS, "second leaf"); + + data = ratifierData(_root, signerAllocator, 0, siblingProof); + vm.expectRevert(IMidnightAdapter.InvalidProof.selector); + adapter.isRatified(sibling, data, taker); } function testRatifyInvalidProof(uint256 seed) public { @@ -416,6 +438,27 @@ contract MidnightAdapterTest is Test { adapter.cancelRoot(keccak256("some root")); } + /* FACTORY */ + + function testFactoryCreateMidnightAdapter() public { + VaultV2Mock newVault = new VaultV2Mock(address(loanToken), owner, curator, signerAllocator, address(0)); + + vm.expectEmit(true, true, false, false, address(factory)); + emit IMidnightAdapterFactory.CreateMidnightAdapter(address(newVault), address(midnight), address(0)); + address newAdapter = factory.createMidnightAdapter(address(newVault), address(midnight)); + + assertEq(factory.midnightAdapter(address(newVault), address(midnight)), newAdapter, "midnightAdapter"); + assertTrue(factory.isMidnightAdapter(newAdapter), "isMidnightAdapter"); + assertEq(IMidnightAdapter(newAdapter).parentVault(), address(newVault), "parentVault"); + assertEq(IMidnightAdapter(newAdapter).midnight(), address(midnight), "midnight"); + assertEq(IMidnightAdapter(newAdapter).durations(), allDurations, "durations"); + assertTrue(midnight.isAuthorized(newAdapter, newAdapter), "adapter is its own ratifier"); + + // Fixed salt: one adapter per (vault, midnight) pair. + vm.expectRevert(); + factory.createMidnightAdapter(address(newVault), address(midnight)); + } + /* DURATIONS */ function testConstructorGetters() public view { @@ -438,7 +481,7 @@ contract MidnightAdapterTest is Test { for (uint256 i = 0; i < collateralCount; i++) { collateralParams[i].token = address(uint160(i)); } - market.collateralParams = storedCollaterals; + market.collateralParams = collateralParams; market.maturity = bound(maturity, 1, 700 days); bytes32[] memory ids = adapter.ids(market); @@ -545,24 +588,28 @@ contract MidnightAdapterTest is Test { } function testOnBuyRemovesAndReinsertsMaturity() public { + uint256 t0 = block.timestamp; buy(1 days, 1e18); Offer memory offer = buy(7 days, 1e18); buy(30 days, 1e18); bytes32 marketId = _marketId(offer.market); setMidnightCredit(marketId, address(adapter), 0); + // Buying again books the full loss, which empties the maturity, then adds the bought net credit back. offer.group = bytes32("second buy"); - uint256 units = 1e18 * 1e18 / TickLib.tickToPrice(MAX_TICK); - offer.maxUnits = uint128(units); - - vm.startPrank(taker); - midnight.supplyCollateral(offer.market, 0, 0.5e18, taker); - midnight.supplyCollateral(offer.market, 1, 0.5e18, taker); - vm.stopPrank(); + midnight.supplyCollateral(offer.market, 0, 1e18, taker); + midnight.supplyCollateral(offer.market, 1, 1e18, taker); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.RemoveMaturity(offer.market.maturity); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.InsertMaturity(offer.market.maturity); + take(offer); - offer.callbackData = hex""; - vm.prank(taker); - midnight.take(offer, sign([offer], signerAllocator), units, taker, taker, address(0), ""); + (uint128 netCredit,) = adapter._markets(marketId); + assertEq(netCredit, 1e18, "netCredit"); + assertEq(adapter.totalAssets(), 3e18, "totalAssets"); + assertEq(adapter.availableMaturities(), 47, "availableMaturities"); + assertPendingMaturities([t0 + 1 days, t0 + 7 days, t0 + 30 days]); } function testSellClearsMaturityAndReactivatesSlot() public { @@ -747,6 +794,8 @@ contract MidnightAdapterTest is Test { adapter.accrueInterest(); assertPendingMaturitiesEmpty(); assertEq(adapter.currentGrowth(), 0, "currentGrowth"); + assertEq(adapter.totalAssets(), 2e18, "totalAssets"); + assertEq(adapter.availableMaturities(), 50, "availableMaturities"); } function testTwoMarketsSharingMaturity(uint256 assetsA, uint256 assetsB) public { @@ -785,8 +834,229 @@ contract MidnightAdapterTest is Test { assertPendingMaturities([first.market.maturity]); } + /* ACCRUAL */ + + function testAccrueInterestLinear() public { + uint256 duration = 30 days; + uint256 vaultBalanceBefore = loanToken.balanceOf(address(parentVault)); + Offer memory offer = buy(duration, 1e18, discountTick); + uint256 paid = vaultBalanceBefore - loanToken.balanceOf(address(parentVault)); + uint256 interest = offer.maxUnits - paid; + assertGt(interest, 0, "bought at a discount"); + + // The growth is an integer per second, the remainder is credited at buy time. + uint256 valueAtBuy = paid + interest % duration; + assertEq(adapter.realAssets(), valueAtBuy, "at buy"); + skip(duration / 3); + assertEq(adapter.realAssets(), valueAtBuy + (interest / duration) * (duration / 3), "a third of the way"); + skip(2 * duration / 3); + assertEq(adapter.realAssets(), offer.maxUnits, "net credit at maturity"); + skip(365 days); + assertEq(adapter.realAssets(), offer.maxUnits, "flat after maturity"); + } + + function testAccrueInterestMatchesView() public { + buy(30 days, 1e18, discountTick); + buy(60 days, 2e18, discountTick); + skip(45 days); + + (uint48 viewFirstMaturity, uint128 viewGrowth, uint128 viewTotalAssets, uint256 removedMaturities) = + adapter.accrueInterestView(); + (uint48 firstMaturity, uint128 growth, uint256 totalAssets) = adapter.accrueInterest(); + + assertEq(removedMaturities, 1, "removedMaturities"); + assertEq(firstMaturity, viewFirstMaturity, "firstMaturity"); + assertEq(growth, viewGrowth, "growth"); + assertEq(totalAssets, viewTotalAssets, "totalAssets"); + assertEq(adapter.maturities(0).nextMaturity, firstMaturity, "stored firstMaturity"); + assertEq(adapter.currentGrowth(), growth, "stored growth"); + assertEq(adapter.totalAssets(), totalAssets, "stored totalAssets"); + } + + function testAccrueInterestPastAllMaturities() public { + Offer memory offerA = buy(7 days, 1e18, discountTick); + Offer memory offerB = buy(30 days, 2e18, discountTick); + Offer memory offerC = buy(90 days, 3e18, discountTick); + skip(100 days); + + adapter.accrueInterest(); + + assertEq(adapter.totalAssets(), offerA.maxUnits + offerB.maxUnits + offerC.maxUnits, "sum of net credits"); + assertEq(adapter.currentGrowth(), 0, "currentGrowth"); + assertEq(adapter.availableMaturities(), 50, "availableMaturities"); + assertPendingMaturitiesEmpty(); + } + + function testSellBeforeMaturityRemovesLinearValue() public { + uint256 duration = 30 days; + Offer memory offer = buy(duration, 1e18, discountTick); + skip(duration / 2); + uint256 valueBefore = adapter.realAssets(); + + sell(offer.market, offer.maxUnits / 2); + + // The growth is an integer per second, so the rounding is bounded by the duration. + assertApproxEqAbs(adapter.realAssets(), valueBefore / 2, duration, "half the value is removed"); + skip(duration / 2); + assertEq(adapter.realAssets(), offer.maxUnits - offer.maxUnits / 2, "the rest reaches its net credit"); + } + + function testSellAllBeforeMaturity() public { + Offer memory offer = buy(30 days, 1e18, discountTick); + skip(10 days); + deal(address(loanToken), taker, offer.maxUnits); + + sell(offer.market, offer.maxUnits); + + assertEq(adapter.totalAssets(), 0, "totalAssets"); + assertEq(adapter.currentGrowth(), 0, "currentGrowth"); + assertEq(adapter.availableMaturities(), 50, "availableMaturities"); + assertPendingMaturitiesEmpty(); + } + + function testLossBeforeMaturityRemovesLinearValue() public { + uint256 duration = 30 days; + Offer memory offer = buy(duration, 1e18, discountTick); + bytes32 marketId = _marketId(offer.market); + skip(duration / 2); + uint256 valueBefore = adapter.realAssets(); + + uint256 loss = offer.maxUnits / 2; + setMidnightCredit(marketId, address(adapter), offer.maxUnits - loss); + new MidnightLossRealizer(address(midnight)).realizeLoss(adapter, offer.market); + + assertApproxEqAbs(adapter.realAssets(), valueBefore / 2, duration, "half the value is lost"); + skip(duration / 2); + assertEq(adapter.realAssets(), offer.maxUnits - loss, "the rest reaches its net credit"); + } + + /* FEES */ + + function testContinuousFeeIsNotALoss() public { + midnight.setDefaultContinuousFee(address(loanToken), MAX_CONTINUOUS_FEE); + uint256 duration = 30 days; + Offer memory offer = buy(duration, 1e18, discountTick); + bytes32 marketId = _marketId(offer.market); + + uint256 pendingFee = midnight.pendingFee(marketId, address(adapter)); + assertGt(pendingFee, 0, "pendingFee"); + (uint128 netCredit,) = adapter._markets(marketId); + assertEq(netCredit, offer.maxUnits - pendingFee, "net credit excludes the pending fee"); + + // The fee accrues out of the credit and of the pending fee alike, so the net credit does not move. + skip(duration / 2); + uint256 valueBefore = adapter.realAssets(); + new MidnightLossRealizer(address(midnight)).realizeLoss(adapter, offer.market); + assertLt(midnight.pendingFee(marketId, address(adapter)), pendingFee, "fee accrued"); + (uint128 netCreditAfter,) = adapter._markets(marketId); + assertEq(netCreditAfter, netCredit, "net credit unchanged"); + assertEq(adapter.realAssets(), valueBefore, "no loss booked"); + + skip(duration / 2); + assertEq(adapter.realAssets(), netCredit, "net credit at maturity"); + } + + function testBuyAtLossReverts() public { + midnight.setDefaultContinuousFee(address(loanToken), MAX_CONTINUOUS_FEE); + // At par, the pending fee makes the net credit lower than the assets paid. + Offer memory offer = makeBuyOffer(30 days, 1e18, MAX_TICK); + midnight.supplyCollateral(offer.market, 0, 1e18, taker); + midnight.supplyCollateral(offer.market, 1, 1e18, taker); + vm.expectRevert(IMidnightAdapter.BuyAtLoss.selector); + take(offer); + } + + function testForceDeallocateWithSettlementFee() public { + for (uint256 i = 0; i <= 6; i++) midnight.setDefaultSettlementFee(address(loanToken), i, 10 * CBP); + Offer memory offer = buy(7 days, 1e18); + skip(1); + uint256 vaultBalanceBefore = loanToken.balanceOf(address(parentVault)); + + forceDeallocate(offer.market, 0.5e18); + + assertEq(loanToken.balanceOf(address(parentVault)), vaultBalanceBefore + 0.5e18, "vault balance"); + // The fee is paid by the seller, so more than 0.5e18 of net credit is sold. + (uint128 netCredit,) = adapter._markets(_marketId(offer.market)); + assertLt(netCredit, 0.5e18, "netCredit"); + } + + /* CALLBACKS */ + + function testOnBuyNotMidnight(address caller) public { + vm.assume(caller != address(midnight)); + vm.prank(caller); + vm.expectRevert(IMidnightAdapter.NotMidnight.selector); + adapter.onBuy(bytes32(0), storedOffer.market, 0, 0, 0, address(adapter), ""); + } + + function testOnBuyNotSelf(address buyer) public { + vm.assume(buyer != address(adapter)); + vm.prank(address(midnight)); + vm.expectRevert(IMidnightAdapter.NotSelf.selector); + adapter.onBuy(bytes32(0), storedOffer.market, 0, 0, 0, buyer, ""); + } + + function testOnSellNotMidnight(address caller) public { + vm.assume(caller != address(midnight)); + vm.prank(caller); + vm.expectRevert(IMidnightAdapter.NotMidnight.selector); + adapter.onSell(bytes32(0), storedOffer.market, 0, 0, 0, address(adapter), address(adapter), ""); + } + + function testOnSellNotSelf(address seller) public { + vm.assume(seller != address(adapter)); + vm.prank(address(midnight)); + vm.expectRevert(IMidnightAdapter.NotSelf.selector); + adapter.onSell(bytes32(0), storedOffer.market, 0, 0, 0, seller, address(adapter), ""); + } + + function testDeallocateNotParentVault(address caller) public { + vm.assume(caller != address(parentVault)); + vm.prank(caller); + vm.expectRevert(IMidnightAdapter.NotAuthorized.selector); + adapter.deallocate("", 0, bytes4(0), caller); + } + + /// @dev Only the adapter can allocate and deallocate through the vault, so it cannot be a liquidity adapter. + function testVaultAllocateAndDeallocateRevert() public { + vm.expectRevert(IMidnightAdapter.SelfAllocationOnly.selector); + parentVault.allocate(address(adapter), "", 0); + vm.expectRevert(IMidnightAdapter.ForceDeallocateOnly.selector); + parentVault.deallocate(address(adapter), "", 0); + } + + /* TAKE */ + + function testTakeLoanAssetMismatch() public { + Offer memory offer = storedOffer; + offer.market.loanToken = address(rewardToken); + vm.prank(signerAllocator); + vm.expectRevert(IMidnightAdapter.LoanAssetMismatch.selector); + adapter.take(offer, "", 0); + } + + /// @dev Selling more than its credit would put the adapter in debt, which it has no collateral for. + function testTakeMoreThanPositionReverts() public { + Offer memory offer = buy(7 days, 1e18); + Offer memory buyOffer = makeExternalOffer(offer.market, true, 2e18, MAX_TICK); + + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); + vm.prank(signerAllocator); + adapter.take(buyOffer, "", 2e18); + } + /* FORCE DEALLOCATE */ + function testForceDeallocateMoreThanPositionReverts() public { + Offer memory boughtOffer = buy(7 days, 1e18); + (Offer memory offer, bytes32 root_) = makeForceDeallocateOffer(boughtOffer.market, 2e18); + + vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); + parentVault.forceDeallocate( + address(adapter), abi.encode(offer, abi.encode(root_, 0, proof([offer]))), 2e18, address(this) + ); + } + function testForceDeallocateOK() public { Offer memory boughtOffer = buy(7 days, 1e18); bytes32 marketId = _marketId(boughtOffer.market); @@ -1030,14 +1300,38 @@ contract MidnightAdapterTest is Test { midnight.repay(boughtOffer.market, 1e18, taker, address(0), ""); uint256 withdrawAmount = 0.5e18; - vm.expectEmit(true, false, false, false, address(adapter)); - emit IMidnightAdapter.WithdrawToVault(marketId, withdrawAmount, 0); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.WithdrawToVault(marketId, withdrawAmount, withdrawAmount); vm.prank(signerAllocator); adapter.withdrawToVault(boughtOffer.market, withdrawAmount); (uint128 creditAfter,) = adapter._markets(marketId); - assertLt(creditAfter, creditBefore); - assertEq(loanToken.balanceOf(address(parentVault)), vaultBalanceBefore + withdrawAmount); + assertEq(creditAfter, creditBefore - withdrawAmount, "netCredit"); + assertEq(adapter.totalAssets(), creditBefore - withdrawAmount, "totalAssets"); + assertEq(loanToken.balanceOf(address(parentVault)), vaultBalanceBefore + withdrawAmount, "vault balance"); + } + + function testWithdrawToVaultAfterLoss() public { + Offer memory boughtOffer = buy(7 days, 1e18); + bytes32 marketId = _marketId(boughtOffer.market); + + // The borrower repays 0.7e18 and defaults on the rest. + deal(address(loanToken), address(this), 0.7e18); + loanToken.approve(address(midnight), type(uint256).max); + midnight.repay(boughtOffer.market, 0.7e18, taker, address(0), ""); + OracleMock(storedCollaterals[0].oracle).setPrice(0); + OracleMock(storedCollaterals[1].oracle).setPrice(0); + midnight.liquidate(boughtOffer.market, 0, 0, 0, taker, false, address(this), address(0), ""); + skip(7 days); + + vm.prank(signerAllocator); + adapter.withdrawToVault(boughtOffer.market, 0.5e18); + + // 0.5e18 withdrawn, 0.3e18 lost. + (uint128 netCredit,) = adapter._markets(marketId); + assertApproxEqAbs(netCredit, 0.2e18, 1, "netCredit"); + assertApproxEqAbs(adapter.totalAssets(), 0.2e18, 1, "totalAssets"); + assertApproxEqAbs(parentVault.allocation(adapter.adapterId()), 0.2e18, 1, "allocation"); } /* SKIM */ @@ -1102,10 +1396,14 @@ contract MidnightAdapterTest is Test { midnight.take(offer, sign([offer], signerAllocator), offer.maxUnits, taker, taker, address(0), ""); } - function buy(uint256 duration, uint256 assets) internal returns (Offer memory offer) { - offer = makeBuyOffer(duration, assets, MAX_TICK); - midnight.supplyCollateral(offer.market, 0, assets / 2, taker); - midnight.supplyCollateral(offer.market, 1, assets / 2, taker); + function buy(uint256 duration, uint256 assets) internal returns (Offer memory) { + return buy(duration, assets, MAX_TICK); + } + + function buy(uint256 duration, uint256 assets, uint256 tick) internal returns (Offer memory offer) { + offer = makeBuyOffer(duration, assets, tick); + midnight.supplyCollateral(offer.market, 0, offer.maxUnits, taker); + midnight.supplyCollateral(offer.market, 1, offer.maxUnits, taker); take(offer); } @@ -1153,16 +1451,15 @@ contract MidnightAdapterTest is Test { offer.buy = true; offer.maker = buyer; offer.tick = MAX_TICK; - uint256 price = TickLib.tickToPrice(MAX_TICK); - uint256 units = assets * 1e18 / price; - offer.maxUnits = uint128(units); + offer.maxUnits = + uint128(TakeAmountsLib.sellerAssetsToUnits(address(midnight), _marketId(market), offer, assets)); offer.expiry = block.timestamp; offer.callback = address(0); offer.callbackData = hex""; offer.ratifier = address(approvalRatifier); offer.group = bytes32(vm.randomUint()); - deal(address(loanToken), buyer, assets); + deal(address(loanToken), buyer, offer.maxUnits); vm.startPrank(buyer); loanToken.approve(address(midnight), type(uint256).max); midnight.setIsAuthorized(address(approvalRatifier), true, buyer); From 6b1f04aa8a1d3859c6d4f84a26831ee204a9ed4f Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 27 Aug 2026 15:31:55 +0200 Subject: [PATCH 93/95] take a maturity in updateDurationCaps --- src/adapters/MidnightAdapter.sol | 16 ++++++++-------- src/adapters/interfaces/IMidnightAdapter.sol | 2 +- test/MidnightAdapterTest.sol | 20 +++++++++++++------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 3c76be568..1eb1eda5f 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -121,7 +121,7 @@ contract MidnightAdapter is IMidnightAdapter { ); accrueInterest(); - updateDurationCaps(market); + updateDurationCaps(market.maturity); IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); // current net credit cannot be > accounted net credit @@ -143,12 +143,12 @@ contract MidnightAdapter is IMidnightAdapter { ); } - function updateDurationCaps(Market memory market) public { - MaturityData storage maturityData = _maturities[market.maturity]; + function updateDurationCaps(uint256 maturity) public { + MaturityData storage maturityData = _maturities[maturity]; uint256 oldDurationCount = maturityData.durationCount; - uint256 newDurationCount = durationCount(market.maturity); + uint256 newDurationCount = durationCount(maturity); maturityData.durationCount = uint8(newDurationCount); - emit UpdateDurationCaps(market.maturity, newDurationCount, maturityData.netCredit); + emit UpdateDurationCaps(maturity, newDurationCount, maturityData.netCredit); // VaultV2.forceDeallocate requires allocation > 0 for each returned id. if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationCount - newDurationCount); @@ -243,7 +243,7 @@ contract MidnightAdapter is IMidnightAdapter { ); accrueInterest(); - updateDurationCaps(offer.market); + updateDurationCaps(offer.market.maturity); // Skip onSell since we are already in a deallocate call. bytes32 marketId = IdLib.toId(offer.market); @@ -306,7 +306,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; require(boughtNetCredit >= paidAssets, BuyAtLoss()); accrueInterest(); - updateDurationCaps(market); + updateDurationCaps(market.maturity); MaturityData storage maturityData = _maturities[market.maturity]; MarketData storage marketData = _markets[marketId]; @@ -370,7 +370,7 @@ contract MidnightAdapter is IMidnightAdapter { require(seller == address(this), NotSelf()); accrueInterest(); - updateDurationCaps(market); + updateDurationCaps(market.maturity); uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); // current net credit cannot be > accounted net credit diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 1c1ddb903..b960f6635 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -76,7 +76,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function skim(address token) external; function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); - function updateDurationCaps(Market memory market) external; + function updateDurationCaps(uint256 maturity) external; function withdrawToVault(Market memory market, uint256 withdrawnAssets) external; function take(Offer memory offer, bytes memory ratifierData, uint256 units) external; function ids(Market memory market) external view returns (bytes32[] memory); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 4261e85a8..79241ccda 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -23,8 +23,12 @@ import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {ORACLE_PRICE_SCALE} from "../lib/morpho-blue/src/libraries/ConstantsLib.sol"; -import {CALLBACK_SUCCESS, DEFAULT_TICK_SPACING, MAX_CONTINUOUS_FEE, CBP} from - "../lib/midnight/src/libraries/ConstantsLib.sol"; +import { + CALLBACK_SUCCESS, + DEFAULT_TICK_SPACING, + MAX_CONTINUOUS_FEE, + CBP +} from "../lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "../lib/midnight/src/periphery/libraries/TakeAmountsLib.sol"; import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; @@ -536,7 +540,7 @@ contract MidnightAdapterTest is Test { skip(timeToMaturity - duration + extraSkip); - adapter.updateDurationCaps(offer.market); + adapter.updateDurationCaps(offer.market.maturity); assertEq(parentVault.allocation(durationId(duration)), 0); } @@ -551,9 +555,9 @@ contract MidnightAdapterTest is Test { Offer memory offer = buy(timeToMaturity, 1e18); skip(skipAmount); - adapter.updateDurationCaps(offer.market); + adapter.updateDurationCaps(offer.market.maturity); uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.updateDurationCaps(offer.market); + adapter.updateDurationCaps(offer.market.maturity); assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } @@ -967,7 +971,9 @@ contract MidnightAdapterTest is Test { } function testForceDeallocateWithSettlementFee() public { - for (uint256 i = 0; i <= 6; i++) midnight.setDefaultSettlementFee(address(loanToken), i, 10 * CBP); + for (uint256 i = 0; i <= 6; i++) { + midnight.setDefaultSettlementFee(address(loanToken), i, 10 * CBP); + } Offer memory offer = buy(7 days, 1e18); skip(1); uint256 vaultBalanceBefore = loanToken.balanceOf(address(parentVault)); @@ -1186,7 +1192,7 @@ contract MidnightAdapterTest is Test { skip(7 days + 1); - adapter.updateDurationCaps(offer.market); + adapter.updateDurationCaps(offer.market.maturity); assertEq(realVault.allocation(durationId(1 days)), 0, "1 day zeroed"); assertEq(realVault.allocation(durationId(7 days)), 0, "7 days zeroed"); From ff85032a33a27620bea381c96ad6cd4c44be39c7 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 27 Aug 2026 15:32:37 +0200 Subject: [PATCH 94/95] update duration caps separately --- src/adapters/MidnightAdapter.sol | 36 ++- src/adapters/interfaces/IMidnightAdapter.sol | 1 - test/MidnightAdapterTest.sol | 230 ++++++++++++++++--- 3 files changed, 216 insertions(+), 51 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 1eb1eda5f..71106fc4b 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -20,8 +20,7 @@ import {DurationsLib} from "./libraries/DurationsLib.sol"; /// @dev Losses are immediately 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 market hit by the loss. /// @dev The adapter must have the allocator role in its parent vault to buy, and the allocator or sentinel role to -/// make sell offers and to withdraw to the vault. -/// @dev If the parent vault has a sendSharesGate, the gate must allow the adapter to send shares. +/// make sell offers, to withdraw to the vault and to update duration caps. contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; @@ -85,7 +84,8 @@ contract MidnightAdapter is IMidnightAdapter { } /// @dev Returns the durations that can be capped. - /// @dev A market position fills the cap of any duration that is <= its time to maturity. + /// @dev A market position fills the cap of any duration that was <= its time to maturity at the first buy of its + /// maturity, or at the last updateDurationCaps call for its maturity. function durations() public view returns (uint256[] memory) { uint256[] memory _durations = new uint256[](durationsLength); for (uint256 i = 0; i < durationsLength; i++) { @@ -121,7 +121,6 @@ contract MidnightAdapter is IMidnightAdapter { ); accrueInterest(); - updateDurationCaps(market.maturity); IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); // current net credit cannot be > accounted net credit @@ -143,20 +142,21 @@ contract MidnightAdapter is IMidnightAdapter { ); } - function updateDurationCaps(uint256 maturity) public { + /// @dev Remove the maturity allocation from the duration ids that are > its time to maturity. + function updateDurationCaps(uint256 maturity) external { MaturityData storage maturityData = _maturities[maturity]; uint256 oldDurationCount = maturityData.durationCount; uint256 newDurationCount = durationCount(maturity); maturityData.durationCount = uint8(newDurationCount); emit UpdateDurationCaps(maturity, newDurationCount, maturityData.netCredit); - // VaultV2.forceDeallocate requires allocation > 0 for each returned id. + // VaultV2.deallocate requires allocation > 0 for each returned id. if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationCount - newDurationCount); for (uint256 i = 0; i < zeroedDurationsIds.length; i++) { zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationCount + i))); } bytes memory data = abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))); - IVaultV2(parentVault).forceDeallocate(address(this), data, 0, address(this)); + IVaultV2(parentVault).deallocate(address(this), data, 0); } } @@ -227,15 +227,7 @@ contract MidnightAdapter is IMidnightAdapter { returns (bytes32[] memory, int256) { require(msg.sender == parentVault, NotAuthorized()); - if (caller == address(this)) { - // Return exactly the data passed to the function. - // Used to update duration caps through forceDeallocate, sell as a maker, or withdraw to the vault. - assembly ("memory-safe") { - return(add(data, 32), mload(data)) - } - } else { - require(messageSig == IVaultV2.forceDeallocate.selector, ForceDeallocateOnly()); - + if (messageSig == IVaultV2.forceDeallocate.selector) { (Offer memory offer, bytes memory ratifierData) = abi.decode(data, (Offer, bytes)); require( offer.buy && offer.market.loanToken == asset && offer.tick == MAX_TICK && offer.callback == address(0), @@ -243,7 +235,6 @@ contract MidnightAdapter is IMidnightAdapter { ); accrueInterest(); - updateDurationCaps(offer.market.maturity); // Skip onSell since we are already in a deallocate call. bytes32 marketId = IdLib.toId(offer.market); @@ -255,6 +246,12 @@ contract MidnightAdapter is IMidnightAdapter { emit ForceDeallocate(marketId, sellerAssets, netCreditDecrease); return (ids(offer.market), -netCreditDecrease.toInt256()); + } else { + require(caller == address(this), SelfAllocationOnly()); + // Return exactly the data passed to the function. + assembly ("memory-safe") { + return(add(data, 32), mload(data)) + } } } @@ -306,10 +303,10 @@ contract MidnightAdapter is IMidnightAdapter { uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; require(boughtNetCredit >= paidAssets, BuyAtLoss()); accrueInterest(); - updateDurationCaps(market.maturity); MaturityData storage maturityData = _maturities[market.maturity]; MarketData storage marketData = _markets[marketId]; + if (maturityData.netCredit == 0) maturityData.durationCount = uint8(durationCount(market.maturity)); uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); // current net credit cannot be > accounted net credit + bought net credit uint256 netCreditLoss = uint256(marketData.netCredit) + boughtNetCredit - currentNetCredit(marketId); @@ -370,7 +367,6 @@ contract MidnightAdapter is IMidnightAdapter { require(seller == address(this), NotSelf()); accrueInterest(); - updateDurationCaps(market.maturity); uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); // current net credit cannot be > accounted net credit @@ -434,7 +430,7 @@ contract MidnightAdapter is IMidnightAdapter { } function ids(Market memory market) public view returns (bytes32[] memory) { - uint256 durationsCount = durationCount(market.maturity); + uint256 durationsCount = _maturities[market.maturity].durationCount; bytes32[] memory idsArray = new bytes32[](1 + market.collateralParams.length * 2 + durationsCount); diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index b960f6635..c99b0bb96 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -41,7 +41,6 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { error BufferTooLow(); error BuyAtLoss(); - error ForceDeallocateOnly(); error IncorrectCallbackAddress(); error IncorrectOffer(); error IncorrectOwner(); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 79241ccda..71cd01ede 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -505,19 +505,28 @@ contract MidnightAdapterTest is Test { ); } - uint256[] memory durations = adapter.durations(); - uint256 durationIdCount = 0; - for (uint256 i = 0; i < durations.length; i++) { - if ((market.maturity - block.timestamp) >= durations[i]) { - assertEq( - ids[1 + market.collateralParams.length * 2 + durationIdCount], - keccak256(abi.encode("duration", durations[i])) - ); - durationIdCount++; - } + // Duration ids come from the stored duration count: none for a maturity that was never bought. + assertEq(ids.length, 1 + market.collateralParams.length * 2); + } + + function testIdsDurations(uint256 durationIndex, uint256 elapsed) public { + durationIndex = bound(durationIndex, 0, allDurations.length - 1); + uint256 duration = allDurations[durationIndex]; + elapsed = bound(elapsed, 0, duration); + Offer memory offer = buy(duration, 1e18); + uint256 fixedIds = 1 + offer.market.collateralParams.length * 2; + + skip(elapsed); + bytes32[] memory ids = adapter.ids(offer.market); + assertEq(ids.length, fixedIds + durationIndex + 1, "stale until updated"); + for (uint256 i = 0; i <= durationIndex; i++) { + assertEq(ids[fixedIds + i], durationId(allDurations[i]), "duration id"); } - assertEq(ids.length, 1 + market.collateralParams.length * 2 + durationIdCount); + adapter.updateDurationCaps(offer.market.maturity); + uint256 count = 0; + while (count < allDurations.length && duration - elapsed >= allDurations[count]) count++; + assertEq(adapter.ids(offer.market).length, fixedIds + count, "updated"); } /* ALLOCATION UPDATES */ @@ -561,7 +570,7 @@ contract MidnightAdapterTest is Test { assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } - function testUpdateOnWithdraw() public { + function testWithdrawThenUpdateDurationCaps() public { Offer memory offer = buy(7 days, 1e18); assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); @@ -573,11 +582,16 @@ contract MidnightAdapterTest is Test { vm.prank(signerAllocator); adapter.withdrawToVault(offer.market, 0.5e18); + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day, stale"); + assertEq(parentVault.allocation(durationId(7 days)), 0.5e18, "7 days, stale"); + + adapter.updateDurationCaps(offer.market.maturity); + assertEq(parentVault.allocation(durationId(1 days)), 0, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } - function testUpdateOnSell() public { + function testSellThenUpdateDurationCaps() public { Offer memory offer = buy(7 days, 1e18); assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); @@ -587,6 +601,11 @@ contract MidnightAdapterTest is Test { parentVault.setTotalAssets(1e18); sell(offer.market, 0.5e18); + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day, stale"); + assertEq(parentVault.allocation(durationId(7 days)), 0.5e18, "7 days, stale"); + + adapter.updateDurationCaps(offer.market.maturity); + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } @@ -637,7 +656,7 @@ contract MidnightAdapterTest is Test { assertEq(adapter.availableMaturities(), 0, "availableMaturities final"); } - function testUpdateOnForceDeallocate() public { + function testForceDeallocateThenUpdateDurationCaps() public { Offer memory offer = buy(7 days, 1e18); assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); @@ -646,6 +665,11 @@ contract MidnightAdapterTest is Test { forceDeallocate(offer.market, 0.5e18); + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day, stale"); + assertEq(parentVault.allocation(durationId(7 days)), 0.5e18, "7 days, stale"); + + adapter.updateDurationCaps(offer.market.maturity); + assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } @@ -1027,7 +1051,7 @@ contract MidnightAdapterTest is Test { function testVaultAllocateAndDeallocateRevert() public { vm.expectRevert(IMidnightAdapter.SelfAllocationOnly.selector); parentVault.allocate(address(adapter), "", 0); - vm.expectRevert(IMidnightAdapter.ForceDeallocateOnly.selector); + vm.expectRevert(IMidnightAdapter.SelfAllocationOnly.selector); parentVault.deallocate(address(adapter), "", 0); } @@ -1127,9 +1151,12 @@ contract MidnightAdapterTest is Test { forceDeallocate(boughtOffer.market, 0.5e18); assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); - assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); + assertEq(parentVault.allocation(durationId(7 days)), 0.5e18, "7 days, stale"); (uint128 marketNetCredit,) = adapter._markets(_marketId(boughtOffer.market)); assertEq(marketNetCredit, 0.5e18, "netCredit"); + + vm.expectRevert(bytes("no role")); + adapter.updateDurationCaps(boughtOffer.market.maturity); } /// forge-config: default.isolate = true @@ -1151,14 +1178,13 @@ contract MidnightAdapterTest is Test { assertGt(realVault.balanceOf(recipient), 0, "fee shares minted"); (uint128 marketNetCredit,) = adapter._markets(_marketId(offer.market)); assertEq(marketNetCredit, 0.5e18, "netCredit"); - assertEq(realVault.allocation(durationId(7 days)), 0, "7 days zeroed"); + assertEq(realVault.allocation(durationId(7 days)), 0.5e18, "7 days stale"); assertEq(realVault.allocation(durationId(1 days)), 0.5e18, "1 day"); assertEq(loanToken.balanceOf(address(realVault)), 9.5e18, "vault balance"); } /// forge-config: default.isolate = true - /// @dev A sendSharesGate must allow the adapter: exits work when it does, and revert once a duration boundary - /// has been crossed when it does not. + /// @dev A sendSharesGate blocking the adapter affects neither exits nor duration caps updates. function testForceDeallocateRealVaultWithGate() public { setUpRealVault(); Offer memory offer = buyOnRealVault(7 days, 1e18); @@ -1166,25 +1192,24 @@ contract MidnightAdapterTest is Test { address gate = makeAddr("gate"); vm.etch(gate, hex"01"); vm.mockCall(gate, abi.encodeWithSelector(ISendSharesGate.canSendShares.selector), abi.encode(true)); + vm.mockCall( + gate, abi.encodeWithSelector(ISendSharesGate.canSendShares.selector, address(adapter)), abi.encode(false) + ); submitAndCall(realVault, abi.encodeCall(IVaultV2.setSendSharesGate, (gate))); skip(1); forceDeallocateOnRealVault(offer.market, 0.5e18); - assertEq(realVault.allocation(durationId(7 days)), 0, "7 days zeroed"); + assertEq(realVault.allocation(durationId(7 days)), 0.5e18, "7 days stale"); - vm.mockCall( - gate, abi.encodeWithSelector(ISendSharesGate.canSendShares.selector, address(adapter)), abi.encode(false) - ); - skip(6 days); - (Offer memory extOffer, bytes32 root_) = makeForceDeallocateOffer(offer.market, 0.1e18); - bytes memory data = abi.encode(extOffer, abi.encode(root_, 0, proof([extOffer]))); - vm.expectRevert(ErrorsLib.CannotSendShares.selector); - realVault.forceDeallocate(address(adapter), data, 0.1e18, address(this)); + adapter.updateDurationCaps(offer.market.maturity); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days zeroed"); + assertEq(realVault.allocation(durationId(1 days)), 0.5e18, "1 day"); } /// forge-config: default.isolate = true - /// @dev A matured maturity zeroes all its duration ids at once, without the adapter having any role. + /// @dev A matured maturity zeroes all its duration ids at once, without touching Midnight. The adapter needs the + /// allocator or sentinel role. function testUpdateDurationCapsMaturedRealVault() public { setUpRealVault(); Offer memory offer = buyOnRealVault(7 days, 1e18); @@ -1192,6 +1217,11 @@ contract MidnightAdapterTest is Test { skip(7 days + 1); + vm.expectRevert(ErrorsLib.Unauthorized.selector); + adapter.updateDurationCaps(offer.market.maturity); + + vm.prank(owner); + realVault.setIsSentinel(address(adapter), true); adapter.updateDurationCaps(offer.market.maturity); assertEq(realVault.allocation(durationId(1 days)), 0, "1 day zeroed"); @@ -1205,11 +1235,13 @@ contract MidnightAdapterTest is Test { setUpRealVault(); Offer memory offerA = buyOnRealVault(7 days, 1e18); buyOnRealVault(10 days, 1e18); - submitAndCall(realVault, abi.encodeCall(IVaultV2.setIsAllocator, (address(adapter), false))); skip(1); forceDeallocateOnRealVault(offerA.market, 0.5e18); + assertEq(realVault.allocation(durationId(7 days)), 1.5e18, "7 days stale"); + + adapter.updateDurationCaps(offerA.market.maturity); assertEq(realVault.allocation(durationId(7 days)), 1e18, "7 days keeps the other maturity's part"); assertEq(realVault.allocation(durationId(1 days)), 1.5e18, "1 day"); @@ -1274,6 +1306,142 @@ contract MidnightAdapterTest is Test { assertApproxEqAbs(parentVault.allocation(adapter.adapterId()), 0.7e18, 1, "allocation"); } + /* STALE DURATION IDS */ + + /// forge-config: default.isolate = true + /// @dev Duration ids go stale as time passes, a full sell still removes the maturity from all of them. + function testStaleDurationIdsSyncedOnFullSell(uint256 elapsed) public { + elapsed = bound(elapsed, 1, 7 days - 1); + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + + skip(elapsed); + assertEq(realVault.allocation(durationId(7 days)), 1e18, "7 days stale before sell"); + + sellUnits(offer.market, 1e18, MAX_TICK); + + assertEq(realVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days"); + assertEq(realVault.allocation(adapter.adapterId()), 0, "adapter id"); + } + + /// forge-config: default.isolate = true + function testStaleDurationIdsSyncedOnFullWithdraw() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + + skip(7 days); + vm.prank(taker); + midnight.repay(offer.market, 1e18, taker, address(0), ""); + assertEq(realVault.allocation(durationId(1 days)), 1e18, "1 day stale before withdraw"); + assertEq(realVault.allocation(durationId(7 days)), 1e18, "7 days stale before withdraw"); + + vm.prank(signerAllocator); + adapter.withdrawToVault(offer.market, 1e18); + + assertEq(realVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days"); + assertEq(realVault.allocation(adapter.adapterId()), 0, "adapter id"); + } + + /// forge-config: default.isolate = true + function testStaleDurationIdsSyncedOnFullForceDeallocate() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + + skip(6 days + 1); + assertEq(realVault.allocation(durationId(1 days)), 1e18, "1 day stale before exit"); + assertEq(realVault.allocation(durationId(7 days)), 1e18, "7 days stale before exit"); + + forceDeallocateOnRealVault(offer.market, 1e18); + + assertEq(realVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days"); + assertEq(realVault.allocation(adapter.adapterId()), 0, "adapter id"); + } + + /// forge-config: default.isolate = true + /// @dev Partial exits decrease stale ids too, so they stay consistent with the stored duration count. + function testStaleDurationIdsPartialSellThenUpdateThenFullSell() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + + skip(1 days); + sellUnits(offer.market, 0.25e18, MAX_TICK); + assertEq(realVault.allocation(durationId(1 days)), 0.75e18, "1 day after partial sell"); + assertEq(realVault.allocation(durationId(7 days)), 0.75e18, "7 days stale after partial sell"); + + adapter.updateDurationCaps(offer.market.maturity); + assertEq(realVault.allocation(durationId(1 days)), 0.75e18, "1 day after update"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days after update"); + + sellUnits(offer.market, 0.25e18, MAX_TICK); + assertEq(realVault.allocation(durationId(1 days)), 0.5e18, "1 day after second partial sell"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days after second partial sell"); + + skip(5 days + 1); + sellUnits(offer.market, 0.5e18, MAX_TICK); + assertEq(realVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days"); + assertEq(realVault.allocation(adapter.adapterId()), 0, "adapter id"); + } + + /// forge-config: default.isolate = true + /// @dev A buy on a maturity with stale ids is counted on them too, so that a full exit zeroes them. + function testStaleDurationIdsSecondBuyThenFullSell() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + + skip(1 days); + buyOnRealVault(6 days, 1e18); + assertEq(realVault.allocation(durationId(1 days)), 2e18, "1 day counts both buys"); + assertEq(realVault.allocation(durationId(7 days)), 2e18, "7 days stale counts both buys"); + + sellUnits(offer.market, 2e18, MAX_TICK); + + assertEq(realVault.allocation(durationId(1 days)), 0, "1 day"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days"); + assertEq(realVault.allocation(adapter.adapterId()), 0, "adapter id"); + } + + /// forge-config: default.isolate = true + /// @dev Once a maturity is emptied, its next buy is only counted on the durations it currently fills. + function testDurationIdsResetOnRebuyAfterFullSell() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + + skip(1 days); + sellUnits(offer.market, 1e18, MAX_TICK); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days after full sell"); + + buyOnRealVault(6 days, 1e18); + assertEq(realVault.allocation(durationId(1 days)), 1e18, "1 day after rebuy"); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days after rebuy"); + } + + /// forge-config: default.isolate = true + /// @dev A full sell only removes its own maturity from the shared duration ids. + function testStaleDurationIdsFullSellKeepsOtherMaturity() public { + setUpRealVault(); + bytes memory idData = abi.encode("duration", uint256(30 days)); + submitAndCall(realVault, abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, type(uint128).max))); + submitAndCall(realVault, abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, 1e18))); + Offer memory offerA = buyOnRealVault(7 days, 1e18); + Offer memory offerB = buyOnRealVault(30 days, 2e18); + + skip(6 days + 1); + sellUnits(offerA.market, 1e18, MAX_TICK); + + assertEq(realVault.allocation(durationId(1 days)), 2e18, "1 day"); + assertEq(realVault.allocation(durationId(7 days)), 2e18, "7 days"); + assertEq(realVault.allocation(durationId(30 days)), 2e18, "30 days stale for the other maturity"); + assertEq(realVault.allocation(adapter.adapterId()), 2e18, "adapter id"); + + adapter.updateDurationCaps(offerB.market.maturity); + assertEq(realVault.allocation(durationId(7 days)), 2e18, "7 days after update"); + assertEq(realVault.allocation(durationId(30 days)), 0, "30 days after update"); + } + /* WITHDRAW TO VAULT */ function testWithdrawToVaultUnauthorized(address nonAllocator) public { @@ -1425,7 +1593,9 @@ contract MidnightAdapterTest is Test { offer.tick = tick; offer.maxUnits = uint128(units); offer.expiry = block.timestamp; + offer.maker = address(adapter); offer.callback = address(adapter); + offer.ratifier = address(adapter); offer.receiverIfMakerIsSeller = address(adapter); offer.group = bytes32(vm.randomUint()); offer.callbackData = hex""; From a3acc396fa5b8b2bf1f51c6989b0cfcf610f0ce2 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Sun, 30 Aug 2026 01:29:57 +0200 Subject: [PATCH 95/95] fix naming and a test --- src/adapters/interfaces/IMidnightAdapter.sol | 2 +- test/MidnightAdapterTest.sol | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index c99b0bb96..990092f2b 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -30,7 +30,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event WithdrawToVault(bytes32 indexed marketId, uint256 withdrawnAssets, uint256 netCreditDecrease); event UpdateDurationCaps(uint256 indexed maturity, uint256 newDurationCount, uint256 netCredit); event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); - event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, uint256 netCreditLoss); + event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 boughtNetCredit, uint256 netCreditLoss); event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); event AccrueInterest(uint128 currentGrowth, uint256 totalAssets); event RemoveMaturity(uint256 indexed maturity); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 71cd01ede..1635961a3 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -216,6 +216,8 @@ contract MidnightAdapterTest is Test { function testLastUpdate() public { assertEq(adapter.lastUpdate(), block.timestamp, "set at construction"); skip(100); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.AccrueInterest(0, 0); adapter.accrueInterest(); assertEq(adapter.lastUpdate(), block.timestamp, "refreshed by accrueInterest"); } @@ -414,6 +416,8 @@ contract MidnightAdapterTest is Test { bytes32 _root = root(offer); bytes memory data = ratifierData(_root, signerAllocator); assertEq(adapter.isRatified(offer, data, taker), CALLBACK_SUCCESS, "ratifies before cancel"); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.CancelRoot(signerAllocator, _root); vm.prank(signerAllocator); adapter.cancelRoot(_root); assertTrue(adapter.isRootCanceled(_root), "root canceled"); @@ -599,6 +603,8 @@ contract MidnightAdapterTest is Test { skip(1); parentVault.setTotalAssets(1e18); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.Sell(_marketId(offer.market), 0.5e18, 0.5e18); sell(offer.market, 0.5e18); assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day, stale"); @@ -626,6 +632,8 @@ contract MidnightAdapterTest is Test { emit IMidnightAdapter.RemoveMaturity(offer.market.maturity); vm.expectEmit(address(adapter)); emit IMidnightAdapter.InsertMaturity(offer.market.maturity); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.Buy(marketId, 1e18, 1e18, 1e18); take(offer); (uint128 netCredit,) = adapter._markets(marketId); @@ -1091,7 +1099,12 @@ contract MidnightAdapterTest is Test { Offer memory boughtOffer = buy(7 days, 1e18); bytes32 marketId = _marketId(boughtOffer.market); - forceDeallocate(boughtOffer.market, 0.5e18); + (Offer memory offer, bytes32 root_) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.ForceDeallocate(marketId, 0.5e18, 0.5e18); + parentVault.forceDeallocate( + address(adapter), abi.encode(offer, abi.encode(root_, 0, proof([offer]))), 0.5e18, address(this) + ); (uint128 marketNetCredit,) = adapter._markets(marketId); assertEq(marketNetCredit, 0.5e18); @@ -1222,6 +1235,8 @@ contract MidnightAdapterTest is Test { vm.prank(owner); realVault.setIsSentinel(address(adapter), true); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.UpdateDurationCaps(offer.market.maturity, 0, 1e18); adapter.updateDurationCaps(offer.market.maturity); assertEq(realVault.allocation(durationId(1 days)), 0, "1 day zeroed");