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/.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..3c59a5099 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": "709dab354d8f03e64effc2a3dcdd08f5013a0758" + }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" }, diff --git a/foundry.toml b/foundry.toml index 13aecee3f..a22ddf5be 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,19 +3,33 @@ 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", + "unaliased-plain-import", + "unused-import", "asm-keccak256", + "mixed-case-function", "block-timestamp", ] -ignore = ['test/**/*.sol'] [profile.no_via_ir] via_ir = false @@ -38,6 +52,15 @@ paths = "src/imports/MetaMorphoImport.sol" optimizer_runs = 200 evm_version = "cancun" +[[profile.default.compilation_restrictions]] +paths = "src/adapters/MidnightAdapter.sol" +optimizer_runs = 17000 + +# 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. @@ -51,4 +74,12 @@ name = "200-cancun" optimizer_runs = 200 evm_version = "cancun" +[[profile.default.additional_compiler_profiles]] +name = "17000-osaka" +optimizer_runs = 17000 + +[[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/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..709dab354 --- /dev/null +++ b/lib/midnight @@ -0,0 +1 @@ +Subproject commit 709dab354d8f03e64effc2a3dcdd08f5013a0758 diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol new file mode 100644 index 000000000..71106fc4b --- /dev/null +++ b/src/adapters/MidnightAdapter.sol @@ -0,0 +1,456 @@ +// 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/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"; +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 buy, and the allocator or sentinel role to +/// 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; + 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 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 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; + 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 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++) { + _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(); + + 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 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"" + ); + } + + /// @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.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).deallocate(address(this), data, 0); + } + } + + /* ACCRUAL */ + + function accrueInterestView() public view returns (uint48, uint128, uint128, uint256) { + if (block.timestamp == lastUpdate) return (_maturities[0].nextMaturity, currentGrowth, totalAssets, 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); + 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 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) { + (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(); + + // 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(); + + 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); + 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(); + + 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 = _maturities[market.maturity].durationCount; + + 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..990092f2b --- /dev/null +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -0,0 +1,110 @@ +// 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, 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"; + +// 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 boughtNetCredit, 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(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); + 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 caller) + external + returns (bytes32[] memory, int256); + function deallocate(bytes memory data, uint256 assets, bytes4, address caller) + 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..1635961a3 --- /dev/null +++ b/test/MidnightAdapterTest.sol @@ -0,0 +1,1877 @@ +// 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 {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 {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, + 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"; + +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; + IVaultV2 internal realVault; + 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]; + uint256 internal discountTick = TickLib.priceToTick(0.95e18, DEFAULT_TICK_SPACING); + + function setUp() public virtual { + owner = makeAddr("owner"); + curator = makeAddr("curator"); + (signerAllocator, signerAllocatorPrivateKey) = makeAddrAndKey("signerAllocator"); + privateKey[signerAllocator] = signerAllocatorPrivateKey; + + recipient = makeAddr("recipient"); + taker = makeAddr("taker"); + + // 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); + midnight.setFeeSetter(address(this)); + + 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); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.AccrueInterest(0, 0); + 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 testRatifyOK(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, "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 { + 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.expectEmit(address(adapter)); + emit IMidnightAdapter.CancelRoot(signerAllocator, _root); + 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")); + } + + /* 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 { + 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 = collateralParams; + 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 + ) + ) + ); + } + + // 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"); + } + + 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 */ + + 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.maturity); + + 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.maturity); + uint256 savedAllocation = parentVault.allocation(durationId(duration)); + adapter.updateDurationCaps(offer.market.maturity); + assertEq(parentVault.allocation(durationId(duration)), savedAllocation); + } + + 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"); + + 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.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 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"); + + 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"); + 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"); + } + + 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"); + 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); + vm.expectEmit(address(adapter)); + emit IMidnightAdapter.Buy(marketId, 1e18, 1e18, 1e18); + take(offer); + + (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 { + 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 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"); + + skip(1); + + 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"); + } + + /* 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); + } + + // 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 { + 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"); + assertEq(adapter.totalAssets(), 2e18, "totalAssets"); + assertEq(adapter.availableMaturities(), 50, "availableMaturities"); + } + + 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]); + } + + /* 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.SelfAllocationOnly.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); + + (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); + } + + 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 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.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 + /// @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.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 blocking the adapter affects neither exits nor duration caps updates. + 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)); + 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.5e18, "7 days stale"); + + 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 touching Midnight. The adapter needs the + /// allocator or sentinel 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); + + vm.expectRevert(ErrorsLib.Unauthorized.selector); + adapter.updateDurationCaps(offer.market.maturity); + + 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"); + 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); + + 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"); + 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); + + // 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"); + } + + /* 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 { + 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(address(adapter)); + emit IMidnightAdapter.WithdrawToVault(marketId, withdrawAmount, withdrawAmount); + vm.prank(signerAllocator); + adapter.withdrawToVault(boughtOffer.market, withdrawAmount); + + (uint128 creditAfter,) = adapter._markets(marketId); + 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 */ + + 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) { + 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); + } + + 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.maker = address(adapter); + offer.callback = address(adapter); + offer.ratifier = 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; + 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, offer.maxUnits); + 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(); + } + + /// @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]))); + 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)); + } + + 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; }