diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 9f0580413..b9dfe23d3 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -23,6 +23,7 @@ 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 9a08957ba..5b71d3296 100644 --- a/.github/workflows/foundry.yml +++ b/.github/workflows/foundry.yml @@ -61,6 +61,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 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.lock b/foundry.lock index 1255fbebf..49809f418 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": "799b8176089148bc7d85e2ae2bfbc4eb25ff26a2" + }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" }, @@ -17,4 +20,4 @@ "lib/openzeppelin-contracts": { "rev": "b72e3da0ec1f47e4a7911a4c06dc92e78c646607" } -} \ No newline at end of file +} diff --git a/foundry.toml b/foundry.toml index 91be8443f..bdd2bce41 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,19 +3,32 @@ via_ir = true optimizer = true optimizer_runs = 100000 bytecode_hash = "none" -evm_version = "cancun" +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", + "test/**/*.sol", +] exclude_lints = [ + "unsafe-typecast", + "erc20-unchecked-transfer", "screaming-snake-case-immutable", - "asm-keccak256" -] -ignore = [ - 'test/**/*.sol' + "unaliased-plain-import", + "unused-import", + "asm-keccak256", + "mixed-case-function", + "block-timestamp", ] [profile.no_via_ir] diff --git a/lib/forge-std b/lib/forge-std index 77041d2ce..0844d7e1f 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 77041d2ce690e692d6e03cc812b57d1ddaa4d505 +Subproject commit 0844d7e1fc5e60d77b68e469bff60265f236c398 diff --git a/lib/midnight b/lib/midnight new file mode 160000 index 000000000..799b81760 --- /dev/null +++ b/lib/midnight @@ -0,0 +1 @@ +Subproject commit 799b8176089148bc7d85e2ae2bfbc4eb25ff26a2 diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol new file mode 100644 index 000000000..d19c50b8b --- /dev/null +++ b/src/adapters/MidnightAdapter.sol @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +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"; +import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.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"; +import {IVaultV2} from "../interfaces/IVaultV2.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 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. +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 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; + + /* MANAGEMENT */ + + address public skimRecipient; + mapping(bytes32 root => bool) public isRootCanceled; + + /* ACCOUNTING */ + + 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. + /// @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; + 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); + adapterId = keccak256(abi.encode("this", address(this))); + + packedDurations = DurationsLib.pack(_durations); + durationsLength = _durations.length; + } + + /* GETTERS */ + + function maturities(uint256 date) public view returns (MaturityData memory) { + 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]; + } + + /// @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++) { + _durations[i] = packedDurations.get(i); + } + return _durations; + } + + /* SKIM FUNCTIONS */ + + function setSkimRecipient(address newSkimRecipient) external { + require(msg.sender == IVaultV2(parentVault).owner(), NotAuthorized()); + skimRecipient = newSkimRecipient; + emit SetSkimRecipient(newSkimRecipient); + } + + /// @dev Skims the adapter's balance of `token` and sends it to `skimRecipient`. + /// @dev This is useful to handle rewards that the adapter has earned. + function skim(address token) external { + require(msg.sender == skimRecipient, NotAuthorized()); + uint256 balance = IERC20(token).balanceOf(address(this)); + SafeERC20Lib.safeTransfer(token, skimRecipient, balance); + emit Skim(token, balance); + } + + /* VAULT ALLOCATORS FUNCTIONS */ + + function withdrawToVault(Market memory market, uint256 withdrawnAssets) external { + bytes32 marketId = IdLib.toId(market); + require( + IVaultV2(parentVault).isAllocator(msg.sender) || IVaultV2(parentVault).isSentinel(msg.sender), + NotAuthorized() + ); + + accrueInterest(); + updateDurationCaps(market); + + IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); + // current net credit cannot be > accounted net credit + uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); + + decreaseNetCredit(marketId, market.maturity, netCreditDecrease); + + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(market), -netCreditDecrease.toInt256()), withdrawnAssets); + emit WithdrawToVault(marketId, withdrawnAssets, netCreditDecrease); + } + + 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 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); + 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); + } + } + + /* 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; + 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; + removedMaturities++; + } + + gainedAssets += uint256(newGrowth) * (block.timestamp - accrueFrom); + + return (_firstMaturity, newGrowth, (totalAssets + gainedAssets).toUint128(), removedMaturities); + } + + function accrueInterest() public returns (uint48, uint128, uint256) { + 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. + function realAssets() external view returns (uint256) { + (,, uint256 newTotalAssets,) = accrueInterestView(); + return newTotalAssets; + } + + /* ALLOCATION FUNCTIONS */ + + /// @dev Can be called by this adapter from a buy callback. + function allocate(bytes memory data, uint256, bytes4, address caller) + external + view + 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)) + } + } + + /// @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. + 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) { + (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), + IncorrectOffer() + ); + + accrueInterest(); + updateDurationCaps(offer.market); + + // Skip onSell since we are already in a deallocate call. + bytes32 marketId = IdLib.toId(offer.market); + uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, marketId, offer, sellerAssets); + 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); + + 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)) + } + } + } + + /* 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, 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()); + // For buy offers, Midnight enforces receiverIfMakerIsSeller == address(0). + require(offer.buy || offer.receiverIfMakerIsSeller == address(this), IncorrectReceiver()); + 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)); + address signer = ecrecover(digest, sig.v, sig.r, sig.s); + require(signer != address(0), IncorrectSigner()); + require(IVaultV2(parentVault).isAllocator(signer), IncorrectSigner()); + + return CALLBACK_SUCCESS; + } + + function onBuy( + bytes32 marketId, + Market memory market, + uint256 paidAssets, + uint256 boughtCredit, + uint256 buyPendingFeeIncrease, + address buyer, + bytes memory + ) external returns (bytes32) { + require(msg.sender == midnight, NotMidnight()); + require(buyer == address(this), NotSelf()); + uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; + require(boughtNetCredit >= paidAssets, BuyAtLoss()); + accrueInterest(); + updateDurationCaps(market); + + MaturityData storage maturityData = _maturities[market.maturity]; + MarketData storage marketData = _markets[marketId]; + 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); + decreaseNetCredit(marketId, market.maturity, netCreditLoss); + + IVaultV2(parentVault) + .allocate( + address(this), + abi.encode(ids(market), boughtNetCredit.toInt256() - netCreditLoss.toInt256()), + paidAssets + ); + + if (timeToMaturity > 0) { + uint256 interest = boughtNetCredit - paidAssets; + uint128 growthIncrease = (interest / timeToMaturity).toUint128(); + totalAssets += (paidAssets + interest % timeToMaturity).toUint128(); + marketData.growth += growthIncrease; + maturityData.growth += growthIncrease; + currentGrowth += growthIncrease; + } else { + totalAssets += boughtNetCredit.toUint128(); + } + + maturityData.netCredit += boughtNetCredit.toUint128(); + marketData.netCredit += boughtNetCredit.toUint128(); + + // Insert the maturity in the list if needed + if (maturityData.netCredit == boughtNetCredit && boughtNetCredit > 0 && market.maturity > block.timestamp) { + availableMaturities--; + uint48 prevMaturity = 0; + uint48 nextMaturity = _maturities[0].nextMaturity; + while (nextMaturity != 0 && nextMaturity < market.maturity) { + prevMaturity = nextMaturity; + nextMaturity = _maturities[prevMaturity].nextMaturity; + } + maturityData.nextMaturity = _maturities[prevMaturity].nextMaturity; + maturityData.prevMaturity = prevMaturity; + _maturities[prevMaturity].nextMaturity = market.maturity.toUint48(); + _maturities[maturityData.nextMaturity].prevMaturity = market.maturity.toUint48(); + emit InsertMaturity(market.maturity); + } + + emit Buy(marketId, paidAssets, boughtNetCredit, netCreditLoss); + return CALLBACK_SUCCESS; + } + + function onSell( + bytes32 marketId, + Market memory market, + uint256 sellerAssets, + uint256, + uint256, + address seller, + address, + bytes memory + ) external returns (bytes32) { + require(msg.sender == midnight, NotMidnight()); + require(seller == address(this), NotSelf()); + + accrueInterest(); + updateDurationCaps(market); + + uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); + // current net credit cannot be > accounted net credit + uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); + decreaseNetCredit(marketId, market.maturity, netCreditDecrease); + + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(market), -netCreditDecrease.toInt256()), sellerAssets); + + 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()); + + emit Sell(marketId, sellerAssets, netCreditDecrease); + return CALLBACK_SUCCESS; + } + + /* INTERNAL FUNCTIONS */ + + function currentNetCredit(bytes32 marketId) internal view returns (uint256) { + return + IMidnight(midnight).credit(marketId, address(this)) + - IMidnight(midnight).pendingFee(marketId, address(this)); + } + + /// @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(netCreditDecrease, marketData.netCredit).toUint128(); + marketData.growth -= growthDecrease; + maturityData.growth -= growthDecrease; + currentGrowth -= growthDecrease; + totalAssets = (totalAssets + (growthDecrease * timeToMaturity) - netCreditDecrease).toUint128(); + } else { + totalAssets -= netCreditDecrease.toUint128(); + } + maturityData.netCredit -= netCreditDecrease.toUint128(); + marketData.netCredit -= netCreditDecrease.toUint128(); + + if (maturityData.netCredit == 0 && maturity > block.timestamp) { + availableMaturities++; + _maturities[maturityData.prevMaturity].nextMaturity = maturityData.nextMaturity; + _maturities[maturityData.nextMaturity].prevMaturity = maturityData.prevMaturity; + emit RemoveMaturity(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++; + } + + function ids(Market memory market) public view returns (bytes32[] memory) { + uint256 durationsCount = durationCount(market.maturity); + + bytes32[] memory idsArray = new bytes32[](1 + market.collateralParams.length * 2 + durationsCount); + + uint256 j; + idsArray[j++] = adapterId; + 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, market.collateralParams[i].oracle, market.collateralParams[i].lltv + ) + ); + } + for (uint256 i = 0; i < durationsCount; i++) { + idsArray[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); + } + + return idsArray; + } + + /* UNUSED CALLBACKS */ +} diff --git a/src/adapters/MidnightAdapterFactory.sol b/src/adapters/MidnightAdapterFactory.sol new file mode 100644 index 000000000..370cba03c --- /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 midnight => 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 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, midnight, _midnightAdapter); + return _midnightAdapter; + } +} diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol new file mode 100644 index 000000000..17d1731dc --- /dev/null +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -0,0 +1,109 @@ +// 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 {Market} 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"; + +// Chain of maturities, each can represent multiple markets. +// nextMaturity is 0 if no next maturity. +struct MaturityData { + uint128 netCredit; + uint128 growth; + uint48 prevMaturity; + uint48 nextMaturity; + uint8 durationCount; +} + +struct MarketData { + uint128 netCredit; + uint128 growth; +} + +interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { + /* EVENTS */ + + event SetSkimRecipient(address indexed newSkimRecipient); + event Skim(address indexed token, uint256 assets); + 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 Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); + 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 */ + + error BufferTooLow(); + error BuyAtLoss(); + error IncorrectCallbackAddress(); + error IncorrectOffer(); + error IncorrectOwner(); + error IncorrectReceiver(); + error IncorrectSigner(); + error InvalidProof(); + error LoanAssetMismatch(); + error NoDebtCreation(); + error NotAuthorized(); + error NotMidnight(); + error NotSelf(); + error RootCanceled(); + error SelfAllocationOnly(); + + /* FUNCTIONS */ + + function asset() external view returns (address); + function totalAssets() external view returns (uint128); + 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); + 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); + 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); + 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 + returns (bytes32[] memory, int256); + function deallocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) + external + returns (bytes32[] memory, int256); + function onBuy( + bytes32 id, + Market memory market, + uint256 buyerAssets, + uint256 units, + uint256 pendingFeeIncrease, + address buyer, + bytes memory data + ) external returns (bytes32); + function onSell( + bytes32 id, + Market memory market, + uint256 sellerAssets, + uint256 units, + uint256 pendingFeeDecrease, + address seller, + address receiver, + bytes memory data + ) external returns (bytes32); +} diff --git a/src/adapters/interfaces/IMidnightAdapterFactory.sol b/src/adapters/interfaces/IMidnightAdapterFactory.sol new file mode 100644 index 000000000..b890c0746 --- /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 midnight, address indexed midnightAdapter); + + /* FUNCTIONS */ + + function durations(uint256 index) external view returns (uint256); + function durationsLength() external view returns (uint256); + function midnightAdapter(address parentVault, address midnight) external view returns (address); + function isMidnightAdapter(address account) external view returns (bool); + function createMidnightAdapter(address parentVault, address midnight) external returns (address); +} diff --git a/src/adapters/libraries/DurationsLib.sol b/src/adapters/libraries/DurationsLib.sol new file mode 100644 index 000000000..b27a4e2d5 --- /dev/null +++ b/src/adapters/libraries/DurationsLib.sol @@ -0,0 +1,36 @@ +// 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 { + error IndexOutOfBounds(); + error IncorrectDuration(); + error ValueOutOfBounds(); + + function get(bytes32 durations, uint256 index) internal pure returns (uint256) { + require(index < MAX_DURATIONS, IndexOutOfBounds()); + unchecked { + return uint32(uint256(durations >> (32 * index))); + } + } + + function pack(uint256[] memory durations) internal pure returns (bytes32) { + require(durations.length <= MAX_DURATIONS, IndexOutOfBounds()); + unchecked { + 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/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/src/libraries/MathLib.sol b/src/libraries/MathLib.sol index e2f04f683..2c892d262 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()); @@ -36,6 +42,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/DurationsLibTest.sol b/test/DurationsLibTest.sol new file mode 100644 index 000000000..a4374b338 --- /dev/null +++ b/test/DurationsLibTest.sol @@ -0,0 +1,69 @@ +// 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; + using DurationsLib for uint256[]; + + /// 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 testPackInvalidLength() public { + uint256[] memory durations = new uint256[](MAX_DURATIONS + 1); + vm.expectRevert(DurationsLib.IndexOutOfBounds.selector); + durations.pack(); + } + + /// forge-config: default.allow_internal_expect_revert = true + 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.pack(); + } + + /// forge-config: default.allow_internal_expect_revert = true + function testPackZeroDuration() public { + uint256[] memory durations = new uint256[](1); + + vm.expectRevert(DurationsLib.IncorrectDuration.selector); + durations.pack(); + } + + /// 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); + } + } +} diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol new file mode 100644 index 000000000..e386ec137 --- /dev/null +++ b/test/MidnightAdapterTest.sol @@ -0,0 +1,1120 @@ +// 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 {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 {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"; +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"; +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 {TakeAmountsLib} from "../lib/midnight/src/periphery/TakeAmountsLib.sol"; +import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; + +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); + } +} + +/// @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; + + IMidnight internal midnight; + IMidnightAdapterFactory internal factory; + IMidnightAdapter internal adapter; + VaultV2Mock internal parentVault; + IERC20 internal loanToken; + IERC20 internal rewardToken; + address internal owner; + address internal curator; + address internal signerAllocator; + uint256 internal signerAllocatorPrivateKey; + address internal taker; + address internal recipient; + address internal tradingFeeRecipient = makeAddr("tradingFeeRecipient"); + CollateralParams[] internal storedCollaterals; + CollateralParams[] internal storedSingleCollateral; + ExtraAssetsAdapter internal extraAssetsAdapter; + + mapping(address => uint256) internal privateKey; + + Offer storedOffer; + + uint256 internal constant MIN_TEST_ASSETS = 10; + uint256 internal constant MAX_TEST_ASSETS = 1e24; + + uint256[] internal allDurations = [1 days, 7 days, 30 days, 90 days, 180 days]; + + function setUp() public virtual { + owner = makeAddr("owner"); + curator = makeAddr("curator"); + (signerAllocator, signerAllocatorPrivateKey) = makeAddrAndKey("signerAllocator"); + privateKey[signerAllocator] = signerAllocatorPrivateKey; + + recipient = makeAddr("recipient"); + 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))); + + parentVault = new VaultV2Mock(address(loanToken), owner, curator, signerAllocator, address(0)); + + factory = new MidnightAdapterFactory(allDurations); + adapter = MidnightAdapter(factory.createMidnightAdapter(address(parentVault), address(midnight))); + + // Adapter authorizes itself as ratifier + vm.prank(address(adapter)); + midnight.setIsAuthorized(address(adapter), true, address(adapter)); + + 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( + CollateralParams({token: collToken0, lltv: 1e18, liquidationCursor: 0.25e18, oracle: oracle0}) + ); + storedCollaterals.push( + CollateralParams({token: collToken1, lltv: 1e18, liquidationCursor: 0.25e18, oracle: oracle1}) + ); + + 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; + storedOffer = Offer({ + market: Market({ + chainId: block.chainid, + midnight: address(midnight), + loanToken: address(loanToken), + collateralParams: storedCollaterals, + maturity: maturity, + rcfThreshold: 0, + 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(0), + ratifier: address(adapter), + reduceOnly: false, + maxUnits: 0, + maxAssets: 0, + continuousFeeCap: type(uint256).max + }); + + deal(address(loanToken), address(parentVault), 1_000_000e18); + + 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); + midnight.setIsAuthorized(address(this), true, taker); + vm.stopPrank(); + + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); + deal(storedCollaterals[0].token, address(this), 1_000_000e18); + deal(storedCollaterals[1].token, address(this), 1_000_000e18); + + extraAssetsAdapter = new ExtraAssetsAdapter(); + address[] memory _adapters = new address[](2); + _adapters[0] = address(adapter); + _adapters[1] = address(extraAssetsAdapter); + parentVault.setAdapters(_adapters); + parentVault.setAdaptersLength(2); + } + + /* LAST UPDATE */ + + function testLastUpdate() public { + assertEq(adapter.lastUpdate(), block.timestamp, "set at construction"); + skip(100); + adapter.accrueInterest(); + assertEq(adapter.lastUpdate(), block.timestamp, "refreshed by accrueInterest"); + } + + /* RATIFICATION */ + + function _ratificationSetup() internal returns (Offer memory offer) { + offer.buy = true; + offer.maker = address(adapter); + + offer.market.loanToken = address(loanToken); + 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++) { + 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); + 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); + offer.tick = bound(vm.randomUint(), 0, MAX_TICK); + offer.callback = address(adapter); + offer.callbackData = bytes(""); + offer.receiverIfMakerIsSeller = address(adapter); + offer.ratifier = address(adapter); + offer.reduceOnly = false; + offer.maxUnits = 0; + offer.maxAssets = 0; + } + + 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(); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, otherSigner); + vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); + adapter.isRatified(offer, data, taker); + } + + 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(); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, otherSigner); + vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); + adapter.isRatified(offer, data, taker); + } + + function testRatifyLoanAssetMismatch(uint256 seed, address otherToken) public { + vm.setSeed(seed); + Offer memory offer = _ratificationSetup(); + 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.isRatified(offer, data, taker); + } + + function testRatifyIncorrectOwner(uint256 seed, address otherMaker) public { + vm.setSeed(seed); + Offer memory offer = _ratificationSetup(); + vm.assume(otherMaker != address(adapter)); + offer.maker = otherMaker; + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.IncorrectOwner.selector); + adapter.isRatified(offer, data, taker); + } + + function testRatifyIncorrectCallbackAddress(uint256 seed) public { + vm.setSeed(seed); + Offer memory offer = _ratificationSetup(); + offer.callback = address(0); + bytes32 _root = root(offer); + bytes memory data = ratifierData(_root, signerAllocator); + vm.expectRevert(IMidnightAdapter.IncorrectCallbackAddress.selector); + adapter.isRatified(offer, data, taker); + } + + function testRatifyIncorrectExpiry(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); + } + + function testRatifyInvalidProof(uint256 seed) public { + vm.setSeed(seed); + 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, taker); + } + + function testRatifySignerNotAllocator(uint256 seed) public { + vm.setSeed(seed); + (address otherSigner, uint256 otherSignerKey) = makeAddrAndKey("nonAllocatorSigner"); + privateKey[otherSigner] = otherSignerKey; + vm.assume(otherSigner != signerAllocator); + assertFalse(parentVault.isAllocator(otherSigner), "must not be allocator"); + + Offer memory offer = _ratificationSetup(); + bytes32 _root = HashLib.hashOffer(offer); + bytes memory data = ratifierData(_root, otherSigner); + vm.expectRevert(IMidnightAdapter.IncorrectSigner.selector); + adapter.isRatified(offer, data, taker); + } + + 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, taker); + } + + 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, 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, taker); + } + + 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, 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, taker); + } + + 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, taker); + } + + 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 { + 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 */ + + function testIds(uint256 collateralCount, uint256 maturity) public view { + collateralCount = bound(collateralCount, 0, 5); + + Market memory market; + + CollateralParams[] memory collateralParams = new CollateralParams[](collateralCount); + for (uint256 i = 0; i < collateralCount; i++) { + collateralParams[i].token = address(uint160(i)); + } + market.collateralParams = storedCollaterals; + market.maturity = bound(maturity, 1, 700 days); + + bytes32[] memory ids = adapter.ids(market); + assertEq(ids[0], adapter.adapterId()); + 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", + market.collateralParams[i].token, + market.collateralParams[i].oracle, + market.collateralParams[i].lltv + ) + ) + ); + } + + 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++; + } + } + + assertEq(ids.length, 1 + market.collateralParams.length * 2 + durationIdCount); + } + + /* 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.updateDurationCaps(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.updateDurationCaps(offer.market); + uint256 savedAllocation = parentVault.allocation(durationId(duration)); + adapter.updateDurationCaps(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 = uint128(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, sign([offer], signerAllocator), units, taker, taker, address(0), ""); + } + + 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); + + 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"); + } + + /* AVAILABLE MATURITIES */ + + function testAvailableMaturitiesCap(uint256 boughtNum) public { + boughtNum = bound(boughtNum, 0, 50); + for (uint256 i = 1; i <= boughtNum; i++) { + buy(i, 1e18); + } + assertEq(adapter.availableMaturities(), 50 - boughtNum); + + for (uint256 i = boughtNum + 1; i <= 50; i++) { + buy(i, 1e18); + } + + 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("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); + 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); + } + + /* PENDING MATURITIES LIST */ + + function testOutOfOrderInsertsStaySorted() public { + uint256 t0 = block.timestamp; + buy(3, 1e18); + buy(1, 1e18); + buy(2, 1e18); + + assertPendingMaturities([t0 + 1, t0 + 2, t0 + 3]); + } + + function testMidPendingMaturityRemoval() 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); + + assertPendingMaturities([smallest.market.maturity, largest.market.maturity]); + } + + function testMultipleConsecutiveElapsedMaturitiesInOneAccrual() public { + buy(1, 1e18); + buy(2, 1e18); + skip(3); + adapter.accrueInterest(); + assertPendingMaturitiesEmpty(); + 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 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"); + } + + 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); + + assertPendingMaturities([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) + ); + } + + 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) + ); + } + + 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 { + 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); + (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 = uint128(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, 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); + 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 = uint128(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 = + uint128(TakeAmountsLib.sellerAssetsToUnits(address(midnight), _marketId(market), offer, assets)); + vm.prank(taker); + 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, sign([offer], signerAllocator), offer.maxUnits, taker, address(0), address(0), ""); + } + + 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 = uint128(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("credit(bytes32,address)").with_key(marketId).with_key(account) + .checked_write(credit); + } + + function checkPendingMaturities(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 assertPendingMaturitiesEmpty() internal view { + checkPendingMaturities(new uint256[](0)); + } + + function assertPendingMaturities(uint256[1] memory m) internal view { + uint256[] memory arr = new uint256[](1); + arr[0] = m[0]; + checkPendingMaturities(arr); + } + + function assertPendingMaturities(uint256[2] memory m) internal view { + uint256[] memory arr = new uint256[](2); + arr[0] = m[0]; + arr[1] = m[1]; + checkPendingMaturities(arr); + } + + 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]; + checkPendingMaturities(arr); + } + + function _marketId(Market memory market) internal view returns (bytes32) { + return IdLib.toId(market); + } + + function sign(Offer[1] memory offers) internal view returns (bytes memory) { + 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, 0, proof(offers)); + } + + function proof(Offer[1] memory) internal pure returns (bytes32[] memory) { + return new bytes32[](0); + } + + // 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] = HashLib.hashOffer(offers[1]); + return path; + } + + function sign(Offer[2] memory offers) internal view returns (bytes memory) { + return ratifierData(root(offers), offers[0].maker, 0, proof(offers)); + } + + function root(Offer memory offer) internal pure returns (bytes32) { + return HashLib.hashOffer(offer); + } + + function root(Offer[1] memory offers) internal pure returns (bytes32) { + return HashLib.hashOffer(offers[0]); + } + + function root(Offer[2] memory offers) internal pure returns (bytes32) { + return HashLib.hashNode(HashLib.hashOffer(offers[0]), HashLib.hashOffer(offers[1])); + } + + function ratifierData(bytes32 _root, address signer) internal view returns (bytes memory) { + bytes32[] memory emptyProof = new bytes32[](0); + return ratifierData(_root, signer, 0, emptyProof); + } + + 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}), _root, leafIndex, _proof); + } + + /// @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/MorphoMarketV1AdapterV2Test.sol b/test/MorphoMarketV1AdapterV2Test.sol index ba5bde862..4e10d838c 100644 --- a/test/MorphoMarketV1AdapterV2Test.sol +++ b/test/MorphoMarketV1AdapterV2Test.sol @@ -132,10 +132,9 @@ contract MorphoMarketV1AdapterV2Test is Test { function testAllocate(uint256 assets) public { assets = _boundAssets(assets); - deal(address(loanToken), address(adapter), assets); + deal(address(loanToken), address(parentVault), assets); - (bytes32[] memory ids, int256 change) = - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), assets); + (bytes32[] memory ids, int256 change) = parentVault.allocate(address(adapter), abi.encode(marketParams), assets); uint256 allocation = adapter.allocation(marketParams); assertEq(allocation, assets, "Incorrect allocation"); @@ -152,21 +151,21 @@ contract MorphoMarketV1AdapterV2Test is Test { initialAssets = _boundAssets(initialAssets); withdrawAssets = bound(withdrawAssets, 1, initialAssets); - deal(address(loanToken), address(adapter), initialAssets); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), initialAssets); + deal(address(loanToken), address(parentVault), initialAssets); + parentVault.allocate(address(adapter), abi.encode(marketParams), initialAssets); uint256 beforeSupply = morpho.expectedSupplyAssets(marketParams, address(adapter)); assertEq(beforeSupply, initialAssets, "Precondition failed: supply not set"); (bytes32[] memory ids, int256 change) = - parentVault.deallocateMocked(address(adapter), abi.encode(marketParams), withdrawAssets); + parentVault.deallocate(address(adapter), abi.encode(marketParams), withdrawAssets); assertEq(change, -int256(withdrawAssets), "Incorrect change returned"); uint256 allocation = adapter.allocation(marketParams); assertEq(allocation, initialAssets - withdrawAssets, "Incorrect allocation"); uint256 afterSupply = morpho.expectedSupplyAssets(marketParams, address(adapter)); assertEq(afterSupply, initialAssets - withdrawAssets, "Supply not decreased correctly"); - assertEq(loanToken.balanceOf(address(adapter)), withdrawAssets, "Adapter did not receive withdrawn tokens"); + assertEq(loanToken.balanceOf(address(parentVault)), withdrawAssets, "Vault did not receive withdrawn tokens"); assertEq(ids.length, expectedIds.length, "Unexpected number of ids returned"); assertEq(ids, expectedIds, "Incorrect ids returned"); } @@ -174,13 +173,13 @@ contract MorphoMarketV1AdapterV2Test is Test { function testDeallocateAll(uint256 initialAssets) public { initialAssets = _boundAssets(initialAssets); - deal(address(loanToken), address(adapter), initialAssets); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), initialAssets); + deal(address(loanToken), address(parentVault), initialAssets); + parentVault.allocate(address(adapter), abi.encode(marketParams), initialAssets); uint256 beforeSupply = morpho.expectedSupplyAssets(marketParams, address(adapter)); assertEq(beforeSupply, initialAssets, "Precondition failed: supply not set"); - parentVault.deallocateMocked(address(adapter), abi.encode(marketParams), initialAssets); + parentVault.deallocate(address(adapter), abi.encode(marketParams), initialAssets); assertEq(adapter.marketIdsLength(), 0, "Incorrect number of market params"); } @@ -347,8 +346,8 @@ contract MorphoMarketV1AdapterV2Test 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"); @@ -369,8 +368,8 @@ contract MorphoMarketV1AdapterV2Test 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"); @@ -380,8 +379,8 @@ contract MorphoMarketV1AdapterV2Test 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. @@ -507,8 +506,8 @@ contract MorphoMarketV1AdapterV2Test is Test { function testBurnShares(uint256 timelockDuration, uint256 extraSkip) public { uint256 assets = _boundAssets(1000); - deal(address(loanToken), address(adapter), assets); - parentVault.allocateMocked(address(adapter), abi.encode(marketParams), assets); + deal(address(loanToken), address(parentVault), assets); + parentVault.allocate(address(adapter), abi.encode(marketParams), assets); uint256 supplyShares = adapter.supplyShares(marketId); uint256 allocation = adapter.allocation(marketParams); diff --git a/test/MorphoVaultV1AdapterTest.sol b/test/MorphoVaultV1AdapterTest.sol index c9ed99b27..dcec77167 100644 --- a/test/MorphoVaultV1AdapterTest.sol +++ b/test/MorphoVaultV1AdapterTest.sol @@ -44,6 +44,8 @@ contract MorphoVaultV1AdapterTest is Test { factory = new MorphoVaultV1AdapterFactory(); adapter = MorphoVaultV1Adapter(factory.createMorphoVaultV1Adapter(address(parentVault), address(morphoVaultV1))); + vm.prank(address(adapter)); + asset.approve(address(morphoVaultV1), type(uint256).max); deal(address(asset), address(this), type(uint256).max); asset.approve(address(morphoVaultV1), type(uint256).max); @@ -76,9 +78,9 @@ contract MorphoVaultV1AdapterTest is Test { function testAllocate(uint256 assets) public { assets = bound(assets, 0, MAX_TEST_ASSETS); - deal(address(asset), address(adapter), assets); - (bytes32[] memory ids, int256 change) = parentVault.allocateMocked(address(adapter), hex"", assets); + deal(address(asset), address(parentVault), assets); + (bytes32[] memory ids, int256 change) = parentVault.allocate(address(adapter), hex"", assets); uint256 adapterShares = morphoVaultV1.balanceOf(address(adapter)); assertEq(adapterShares, assets * EXCHANGE_RATE, "Incorrect share balance after deposit"); @@ -91,20 +93,21 @@ contract MorphoVaultV1AdapterTest is Test { initialAssets = bound(initialAssets, 0, MAX_TEST_ASSETS); withdrawAssets = bound(withdrawAssets, 0, initialAssets); - deal(address(asset), address(adapter), initialAssets); - parentVault.allocateMocked(address(adapter), hex"", initialAssets); + deal(address(asset), address(parentVault), initialAssets); + parentVault.allocate(address(adapter), hex"", initialAssets); uint256 beforeShares = morphoVaultV1.balanceOf(address(adapter)); assertEq(beforeShares, initialAssets * EXCHANGE_RATE, "Precondition failed: shares not set"); - (bytes32[] memory ids, int256 change) = parentVault.deallocateMocked(address(adapter), hex"", withdrawAssets); + (bytes32[] memory ids, int256 change) = parentVault.deallocate(address(adapter), hex"", withdrawAssets); assertEq(adapter.allocation(), initialAssets - withdrawAssets, "incorrect allocation"); uint256 afterShares = morphoVaultV1.balanceOf(address(adapter)); assertEq(afterShares, (initialAssets - withdrawAssets) * EXCHANGE_RATE, "Share balance not decreased correctly"); - uint256 adapterBalance = asset.balanceOf(address(adapter)); - assertEq(adapterBalance, withdrawAssets, "Adapter did not receive withdrawn tokens"); + uint256 parentVaultBalance = asset.balanceOf(address(parentVault)); + assertEq(parentVaultBalance, withdrawAssets, "Parent vault did not receive withdrawn tokens"); + assertEq(ids.length, expectedIds.length, "Incorrect ids returned"); assertEq(ids, expectedIds, "Incorrect ids returned"); assertEq(change, -int256(withdrawAssets), "Incorrect change returned"); } @@ -217,8 +220,8 @@ contract MorphoVaultV1AdapterTest is Test { ERC4626MockExtended otherVault = new ERC4626MockExtended(address(asset)); // Deposit some assets - deal(address(asset), address(adapter), deposit * 2); - parentVault.allocateMocked(address(adapter), hex"", deposit); + deal(address(asset), address(parentVault), deposit); + parentVault.allocate(address(adapter), hex"", deposit); uint256 realAssetsBefore = adapter.realAssets(); @@ -239,8 +242,8 @@ contract MorphoVaultV1AdapterTest is Test { deposit = bound(deposit, 1, MAX_TEST_ASSETS); loss = bound(loss, 1, deposit); - deal(address(asset), address(adapter), deposit); - parentVault.allocateMocked(address(adapter), hex"", deposit); + deal(address(asset), address(parentVault), deposit); + parentVault.allocate(address(adapter), hex"", deposit); morphoVaultV1.lose(loss); assertEq(adapter.realAssets(), deposit - loss, "realAssets"); @@ -250,8 +253,8 @@ contract MorphoVaultV1AdapterTest is Test { deposit = bound(deposit, 1, MAX_TEST_ASSETS); interest = bound(interest, 1, deposit); - deal(address(asset), address(adapter), deposit); - parentVault.allocateMocked(address(adapter), hex"", deposit); + deal(address(asset), address(parentVault), deposit); + parentVault.allocate(address(adapter), hex"", deposit); asset.transfer(address(morphoVaultV1), interest); // approx because of the virtual shares. diff --git a/test/mocks/VaultV2Mock.sol b/test/mocks/VaultV2Mock.sol index 10e6746a2..5fd57bc88 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 { @@ -12,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; uint256 public _timelock; constructor(address _asset, address _owner, address _curator, address _allocator, address _sentinel) { @@ -25,18 +29,28 @@ contract VaultV2Mock { function accrueInterest() public {} - function allocateMocked(address adapter, bytes memory data, uint256 assets) + 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); + } + return (ids, change); + } + + function deallocate(address adapter, bytes memory data, uint256 assets) external returns (bytes32[] memory, int256) { - (bytes32[] memory ids, int256 change) = IAdapter(adapter).allocate(data, assets, msg.sig, msg.sender); + (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 deallocateMocked(address adapter, bytes memory data, uint256 assets) + function forceDeallocate(address adapter, bytes memory data, uint256 assets, address) external returns (bytes32[] memory, int256) { @@ -44,9 +58,30 @@ 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); } + 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; + } + + function setAdaptersLength(uint256 newAdaptersLength) external { + adaptersLength = newAdaptersLength; + } + + function setAdapters(address[] memory newAdapters) external { + adapters = newAdapters; + } + function setTimelock(uint256 newTimelock) external { _timelock = newTimelock; }