From 6b2c552cd8c67610432278cddbb37d9b3174daea Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Tue, 14 Apr 2026 16:19:34 +0200 Subject: [PATCH 01/13] autorealize loss, more midnight update, fixes --- foundry.lock | 5 +- foundry.toml | 9 + lib/forge-std | 2 +- lib/midnight | 2 +- src/adapters/MidnightAdapter.sol | 187 ++++++++++--------- src/adapters/interfaces/IMidnightAdapter.sol | 12 +- src/imports/MidnightImport.sol | 6 + test/MidnightAdapterAllocationUpdateTest.sol | 50 ++--- test/MidnightAdapterTest.sol | 59 +++--- test/mocks/VaultV2Mock.sol | 8 + 10 files changed, 179 insertions(+), 161 deletions(-) create mode 100644 src/imports/MidnightImport.sol diff --git a/foundry.lock b/foundry.lock index 9f29faf48..daac600ed 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,6 +1,6 @@ { "lib/forge-std": { - "rev": "77041d2ce690e692d6e03cc812b57d1ddaa4d505" + "rev": "0844d7e1fc5e60d77b68e469bff60265f236c398" }, "lib/metamorpho": { "rev": "00da9ad27da8051bce663eeac02f3b9c0c0aa8d8" @@ -8,6 +8,9 @@ "lib/metamorpho-v1.1": { "rev": "2d160ba9bb945ca3bf12efb182427445dce59c27" }, + "lib/midnight": { + "rev": "7385a905d087367689fbdd16eef04e68b5146dc2" + }, "lib/morpho-blue": { "rev": "cf3f0ce68db99421bcd808d505cfe49d61f4eaa0" }, diff --git a/foundry.toml b/foundry.toml index dfd6c93ad..5090036e0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,11 +5,20 @@ optimizer_runs = 100000 bytecode_hash = "none" evm_version = "osaka" dynamic_test_linking = true +ignored_error_codes = ["transient-storage"] [profile.default.fmt] wrap_comments = true [lint] +ignore = [ + "lib/**", + "src/imports/MorphoImport.sol", + "src/imports/MidnightImport.sol", + "src/imports/MetaMorphoImport.sol", + "src/imports/MetaMorphoV1_1Import.sol", + "src/imports/AdaptiveCurveIrmImport.sol", +] exclude_lints = [ "unsafe-typecast", "erc20-unchecked-transfer", diff --git a/lib/forge-std b/lib/forge-std index 8e40513d6..0844d7e1f 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 8e40513d678f392f398620b3ef2b418648b33e89 +Subproject commit 0844d7e1fc5e60d77b68e469bff60265f236c398 diff --git a/lib/midnight b/lib/midnight index 41c8405a5..7385a905d 160000 --- a/lib/midnight +++ b/lib/midnight @@ -1 +1 @@ -Subproject commit 41c8405a5e4c9ea5785bdbe506d8880c4bcbd596 +Subproject commit 7385a905d087367689fbdd16eef04e68b5146dc2 diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 280bdbc3c..f64f15503 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -2,14 +2,12 @@ // Copyright (c) 2025 Morpho Association pragma solidity 0.8.34; -import {Midnight} from "lib/midnight/src/Midnight.sol"; -import {Offer, Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; +import {IMidnight, Offer, Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "lib/midnight/src/interfaces/IEcrecover.sol"; import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; -import {UtilsLib} from "lib/midnight/src/libraries/UtilsLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; @@ -24,13 +22,14 @@ import {DurationsLib} from "./libraries/DurationsLib.sol"; contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; + using MathLib for int256; using DurationsLib for bytes32; /* IMMUTABLES */ address public immutable asset; address public immutable parentVault; - address public immutable morphoV2; + address public immutable midnight; bytes32 public immutable adapterId; bytes32 public immutable packedDurations; uint256 public immutable durationsLength; @@ -46,15 +45,15 @@ contract MidnightAdapter is IMidnightAdapter { uint48 public firstMaturity; uint128 public currentGrowth; mapping(uint256 timestamp => MaturityData) public _maturities; - mapping(bytes32 obligationId => uint256) public _units; + mapping(bytes32 obligationId => uint256) public netCredit; /* CONSTRUCTOR */ - constructor(address _parentVault, address _morphoV2, uint256[] memory _durations) { + constructor(address _parentVault, address _midnight, uint256[] memory _durations) { asset = IVaultV2(_parentVault).asset(); parentVault = _parentVault; - morphoV2 = _morphoV2; + midnight = _midnight; lastUpdate = uint48(block.timestamp); - SafeERC20Lib.safeApprove(asset, _morphoV2, type(uint256).max); + SafeERC20Lib.safeApprove(asset, _midnight, type(uint256).max); SafeERC20Lib.safeApprove(asset, _parentVault, type(uint256).max); firstMaturity = type(uint48).max; adapterId = keccak256(abi.encode("this", address(this))); @@ -72,10 +71,6 @@ contract MidnightAdapter is IMidnightAdapter { /* GETTERS */ - function units(bytes32 obligationId) public view returns (uint256) { - return _units[obligationId]; - } - function maturities(uint256 date) public view returns (MaturityData memory) { return _maturities[date]; } @@ -107,13 +102,29 @@ contract MidnightAdapter is IMidnightAdapter { /* VAULT ALLOCATORS FUNCTIONS */ - function withdrawToVault(Obligation memory obligation, uint256 withdrawnUnits) external { + function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); - Midnight(morphoV2).withdraw(obligation, withdrawnUnits, address(this), address(this)); + bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); + uint256 pendingFeeDecrease = + IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); + accrueInterest(); deallocateExpiredDurations(obligation); - removeUnits(obligation, withdrawnUnits); - selfDeallocate(ids(obligation), withdrawnUnits, withdrawnUnits); + + uint256 withdrawNetCreditDecrease = withdrawnAssets - pendingFeeDecrease; + uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + // new net credit cannot be > old credit + uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + + if (totalNetCreditDecrease > withdrawNetCreditDecrease) { + removeUnits(obligation, totalNetCreditDecrease - withdrawNetCreditDecrease); + } + + if (withdrawNetCreditDecrease > 0) removeUnits(obligation, withdrawNetCreditDecrease); + + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), withdrawnAssets); } function deallocateExpiredDurations(Obligation memory obligation) public { @@ -135,7 +146,10 @@ contract MidnightAdapter is IMidnightAdapter { zeroedDurationsIds[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); } } - selfDeallocate(zeroedDurationsIds, maturityData.units, 0); + IVaultV2(parentVault) + .deallocate( + address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))), 0 + ); } } @@ -162,37 +176,21 @@ contract MidnightAdapter is IMidnightAdapter { return (nextMaturity, newGrowth, _totalAssets + gainedAssets); } - function accrueInterest() public { + function accrueInterest() public returns (uint48, uint128, uint256) { if (lastUpdate != block.timestamp) { - (uint48 newFirstMaturity, uint128 newCurrentGrowth, uint256 newTotalAssets) = accrueInterestView(); - _totalAssets = newTotalAssets; + (firstMaturity, currentGrowth, _totalAssets) = accrueInterestView(); lastUpdate = uint48(block.timestamp); - firstMaturity = newFirstMaturity; - currentGrowth = newCurrentGrowth; } + return (firstMaturity, currentGrowth, _totalAssets); } - /// @dev Returns an estimate of the real assets. + /// @dev Returns an estimate of the real assets assigned to the adapter. + /// @dev Excludes assets reserved for users. function realAssets() external view returns (uint256) { (,, uint256 newTotalAssets) = accrueInterestView(); return newTotalAssets; } - /* LOSS REALIZATION */ - - function realizeLoss(Obligation memory obligation) external { - bytes32 obligationId = _obligationId(obligation); - bytes32 midnightId = IdLib.toId(obligation, block.chainid, morphoV2); - uint256 remainingUnits = Midnight(morphoV2).creditOf(midnightId, address(this)); - - uint256 lostUnits = _units[obligationId].zeroFloorSub(remainingUnits); - deallocateExpiredDurations(obligation); - if (lostUnits > 0) { - removeUnits(obligation, lostUnits); - selfDeallocate(ids(obligation), lostUnits, 0); - } - } - /* ALLOCATION FUNCTIONS */ /// @dev Can be called by this adapter from a buy callback. @@ -220,12 +218,13 @@ contract MidnightAdapter is IMidnightAdapter { require(offer.buy && offer.obligation.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); // Already in a deallocate call so we skip the onSell callback and return the deallocation here. - bytes32 midnightId = IdLib.toId(offer.obligation, block.chainid, morphoV2); - uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(Midnight(morphoV2), midnightId, offer, sellerAssets); - (,, uint256 deallocated) = Midnight(morphoV2) + bytes32 obligationId = IdLib.toId(offer.obligation, block.chainid, midnight); + uint256 takeUnits = + TakeAmountsLib.sellerAssetsToUnits(IMidnight(midnight), obligationId, offer, sellerAssets); + (,, uint256 deallocated) = IMidnight(midnight) .take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); - require(Midnight(morphoV2).debtOf(midnightId, address(this)) == 0, NoBorrowing()); + require(IMidnight(midnight).debtOf(obligationId, address(this)) == 0, NoBorrowing()); deallocateExpiredDurations(offer.obligation); removeUnits(offer.obligation, deallocated); @@ -242,15 +241,15 @@ contract MidnightAdapter is IMidnightAdapter { /* MORPHO V2 CALLBACKS */ function onRatify(Offer memory offer, bytes32 root, bytes memory data) external view returns (bytes32) { - // Collaterals will be checked at the level of vault ids. + // Collaterals will be checked through vault ids. require(offer.obligation.loanToken == asset, LoanAssetMismatch()); require(offer.maker == address(this), IncorrectOwner()); require(offer.callback == address(this), IncorrectCallbackAddress()); require(offer.start <= block.timestamp, IncorrectStart()); // uint48.max is the list end pointer require(offer.obligation.maturity < type(uint48).max, IncorrectMaturity()); + require(offer.buy || offer.reduceOnly, NoDebtCreation()); - // Signature verification (inlined from EcrecoverRatifier). Signature memory sig = abi.decode(data, (Signature)); bytes32 structHash = keccak256(abi.encode(ROOT_TYPEHASH, root)); bytes32 domainSeparator = keccak256(abi.encode(EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); @@ -263,36 +262,46 @@ contract MidnightAdapter is IMidnightAdapter { } function onBuy( - bytes32, + bytes32 obligationId, Obligation memory obligation, address buyer, - uint256 buyerAssets, - uint256 obligationUnits, + uint256 paidAssets, + uint256 boughtCredit, + uint256 buyPendingFeeIncrease, bytes memory data ) external returns (bytes32) { - require(msg.sender == address(morphoV2), NotMorphoV2()); - require(buyer == address(this), NotSelf()); - bytes32 obligationId = _obligationId(obligation); uint48 prevMaturity = abi.decode(data, (uint48)); - require(prevMaturity < obligation.maturity, IncorrectHint()); MaturityData storage maturityData = _maturities[obligation.maturity]; - accrueInterest(); + require(msg.sender == midnight, NotMorphoV2()); + require(buyer == address(this), NotSelf()); + require(prevMaturity < obligation.maturity, IncorrectHint()); + accrueInterest(); deallocateExpiredDurations(obligation); - if (obligation.maturity > block.timestamp) { - uint128 timeToMaturity = uint128(obligation.maturity - block.timestamp); - uint128 gainedGrowth = ((obligationUnits - buyerAssets) / timeToMaturity).toUint128(); - _totalAssets += buyerAssets + (obligationUnits - buyerAssets) % timeToMaturity; + uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; + require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); + + uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + int256 change = newNetCredit.toInt256() - netCredit[obligationId].toInt256(); + // change is at most buyNetCreditIncrease + if (change < buyNetCreditIncrease.toInt256()) { + removeUnits(obligation, (buyNetCreditIncrease.toInt256() - change).toUint256()); + } + + if (timeToMaturity > 0) { + uint128 gainedGrowth = ((buyNetCreditIncrease - paidAssets) / timeToMaturity).toUint128(); + _totalAssets += paidAssets + (buyNetCreditIncrease - paidAssets) % timeToMaturity; maturityData.growth += gainedGrowth; currentGrowth += gainedGrowth; } else { - // No need to update past growth to zero since it won't be read again. - _totalAssets += obligationUnits; + _totalAssets += buyNetCreditIncrease; } - maturityData.units += obligationUnits.toUint128(); - _units[obligationId] += obligationUnits.toUint128(); + maturityData.netCredit += buyNetCreditIncrease.toUint128(); + netCredit[obligationId] += buyNetCreditIncrease.toUint128(); // Insert the maturity in the list if needed if (obligation.maturity >= block.timestamp) { @@ -319,29 +328,40 @@ contract MidnightAdapter is IMidnightAdapter { } } - IVaultV2(parentVault) - .allocate(address(this), abi.encode(ids(obligation), obligationUnits.toInt256()), buyerAssets); + IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); return CALLBACK_SUCCESS; } function onSell( - bytes32 midnightId, + bytes32 obligationId, Obligation memory obligation, address seller, uint256 sellerAssets, - uint256 soldObligationUnits, + uint256 units, + uint256 sellPendingFeeDecrease, bytes memory ) external returns (bytes32) { - require(msg.sender == address(morphoV2), NotMorphoV2()); - require(seller == address(this), NotSelf()); - require(Midnight(morphoV2).debtOf(midnightId, address(this)) == 0, NoBorrowing()); - uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); + require(msg.sender == midnight, NotMorphoV2()); + require(seller == address(this), NotSelf()); + + accrueInterest(); deallocateExpiredDurations(obligation); - removeUnits(obligation, soldObligationUnits); - selfDeallocate(ids(obligation), soldObligationUnits, sellerAssets); + + uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; + uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + // new net credit cannot be > old credit + uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + + // The sell itself removes exactly `netCreditDecrease` of net credit; any excess is a concurrent loss. + if (totalNetCreditDecrease > sellNetCreditDecrease) { + removeUnits(obligation, totalNetCreditDecrease - sellNetCreditDecrease); + } + + if (sellNetCreditDecrease > 0) removeUnits(obligation, sellNetCreditDecrease); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -350,35 +370,30 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), sellerAssets); + return CALLBACK_SUCCESS; } /* INTERNAL FUNCTIONS */ - /// @dev Removes units from tracking. Absorbs the loss from future interest (growth) first, - /// and only reduces principal (_totalAssets) for the remainder that growth can't cover. - /// @dev removedUnits can exceed tracked units (pending fee gap or bad debt). + /// @dev Removes units from tracking. + /// @dev Changes the implied price of the obligation as little as possible. function removeUnits(Obligation memory obligation, uint256 removedUnits) internal { MaturityData storage maturityData = _maturities[obligation.maturity]; - accrueInterest(); if (obligation.maturity > block.timestamp) { uint256 timeToMaturity = obligation.maturity - block.timestamp; - uint128 removedGrowth = - UtilsLib.min(removedUnits.mulDivUp(1, timeToMaturity), maturityData.growth).toUint128(); - // Do not cleanup the linked list if we end up at 0 growth. + uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.netCredit).toUint128(); maturityData.growth -= removedGrowth; currentGrowth -= removedGrowth; _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { _totalAssets -= removedUnits; } - maturityData.units -= removedUnits.toUint128(); - _units[_obligationId(obligation)] -= removedUnits.toUint128(); - } - - function _obligationId(Obligation memory obligation) internal pure returns (bytes32) { - return keccak256(abi.encode(obligation)); + maturityData.netCredit -= removedUnits.toUint128(); + netCredit[IdLib.toId(obligation, block.chainid, midnight)] -= removedUnits.toUint128(); } function ids(Obligation memory obligation) public view returns (bytes32[] memory) { @@ -412,11 +427,7 @@ contract MidnightAdapter is IMidnightAdapter { return idsArray; } - function selfDeallocate(bytes32[] memory _ids, uint256 deallocated, uint256 assets) internal { - IVaultV2(parentVault).deallocate(address(this), abi.encode(_ids, -deallocated.toInt256()), assets); - } - - /* TO REMOVE */ + /* UNUSED CALLBACKS */ function onLiquidate(bytes32, Obligation memory, uint256, uint256, uint256, address, bytes memory) external pure { revert(); diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index f327c8f02..1510ae9c7 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -10,7 +10,7 @@ import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple obligations. // nextMaturity is type(uint48).max if no next maturity struct MaturityData { - uint128 units; + uint128 netCredit; uint128 growth; uint48 nextMaturity; uint48 lastUpdate; @@ -25,6 +25,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { /* ERRORS */ error BufferTooLow(); + error BuyAtLoss(); error IncorrectCallbackAddress(); error IncorrectDuration(); error IncorrectHint(); @@ -34,7 +35,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { error IncorrectSigner(); error IncorrectStart(); error LoanAssetMismatch(); - error NoBorrowing(); + error NoDebtCreation(); error NotAuthorized(); error NotMorphoV2(); error NotSelf(); @@ -47,7 +48,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); function adapterId() external view returns (bytes32); - function units(bytes32 obligationId) external view returns (uint256); + function netCredit(bytes32 obligationId) external view returns (uint256); function maturities(uint256 date) external view returns (MaturityData memory); function skimRecipient() external view returns (address); function setSkimRecipient(address newSkimRecipient) external; @@ -59,8 +60,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); - function accrueInterest() external; - function realizeLoss(Obligation memory obligation) external; + function accrueInterest() external returns (uint48, uint128, uint256); function allocate(bytes memory data, uint256 assets, bytes4, address vaultAllocator) external returns (bytes32[] memory, int256); @@ -73,6 +73,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { address buyer, uint256 buyerAssets, uint256 units, + uint256 buyerPendingFeeIncrease, bytes memory data ) external returns (bytes32); function onSell( @@ -81,6 +82,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { address seller, uint256 sellerAssets, uint256 units, + uint256 sellerPendingFeeDecrease, bytes memory data ) external returns (bytes32); function onLiquidate( diff --git a/src/imports/MidnightImport.sol b/src/imports/MidnightImport.sol new file mode 100644 index 000000000..7db2e5d50 --- /dev/null +++ b/src/imports/MidnightImport.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity 0.8.34; +// Force foundry to compile Midnight without importing it in the tests. + +import {Midnight} from "../../lib/midnight/src/Midnight.sol"; diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index a6b96a52f..fcda0e2be 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -6,13 +6,11 @@ import "../lib/forge-std/src/Test.sol"; import {MidnightAdapterTest} from "./MidnightAdapterTest.sol"; import {IERC20} from "../src/interfaces/IERC20.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; -import {Midnight} from "../lib/midnight/src/Midnight.sol"; import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {stdStorage, StdStorage} from "../lib/forge-std/src/Test.sol"; import {Oracle} from "../lib/midnight/test/helpers/Oracle.sol"; -import {ApprovalRatifier} from "../lib/midnight/src/ratifiers/ApprovalRatifier.sol"; -import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; +import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { using stdStorage for StdStorage; @@ -24,17 +22,17 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { super.setUp(); storedCollaterals[0].lltv = 1e18; - storedCollaterals[0].maxLif = morphoV2.maxLif(1e18, 0.25e18); + storedCollaterals[0].maxLif = midnight.maxLif(1e18, 0.25e18); storedCollaterals[1].lltv = 1e18; - storedCollaterals[1].maxLif = morphoV2.maxLif(1e18, 0.25e18); + storedCollaterals[1].maxLif = midnight.maxLif(1e18, 0.25e18); storedOffer.obligation.collateralParams = storedCollaterals; vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - loanToken.approve(address(morphoV2), type(uint256).max); + loanToken.approve(address(midnight), type(uint256).max); vm.stopPrank(); } @@ -52,9 +50,9 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.callbackData = abi.encode(0); vm.startPrank(taker); - morphoV2.supplyCollateral(offer.obligation, 0, assets / 2, taker); - morphoV2.supplyCollateral(offer.obligation, 1, assets / 2, taker); - morphoV2.take( + midnight.supplyCollateral(offer.obligation, 0, assets / 2, taker); + midnight.supplyCollateral(offer.obligation, 1, assets / 2, taker); + midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); vm.stopPrank(); @@ -76,14 +74,14 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.group = bytes32(vm.randomUint()); offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take( + midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); } function forceDeallocate(Obligation memory obligation, uint256 assets) internal { address buyer = makeAddr("buyer"); - ApprovalRatifier approvalRatifier = new ApprovalRatifier(); + SetterRatifier approvalRatifier = new SetterRatifier(address(midnight)); Offer memory offer = storedOffer; offer.obligation = obligation; @@ -101,10 +99,10 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { deal(address(loanToken), buyer, assets); vm.startPrank(buyer); - loanToken.approve(address(morphoV2), type(uint256).max); - morphoV2.setIsAuthorized(buyer, address(approvalRatifier), true); + loanToken.approve(address(midnight), type(uint256).max); + midnight.setIsAuthorized(buyer, address(approvalRatifier), true); bytes32 _root = root([offer]); - approvalRatifier.setApproval(_root, true); + approvalRatifier.setApproval(buyer, _root, true); vm.stopPrank(); bytes memory data = abi.encode(offer, hex"", _root, proof([offer])); @@ -154,24 +152,6 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } - function testUpdateOnRealizeLoss() public { - Offer memory offer = buy(7 days, 1e18); - assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 week, before"); - assertEq(parentVault.allocation(durationId(7 days)), 1e18, "2 weeks, before"); - - skip(1); - - Oracle(offer.obligation.collateralParams[0].oracle).setPrice(0); - morphoV2.liquidate(offer.obligation, 0, 0, 0, taker, ""); - adapter.realizeLoss(offer.obligation); - - bytes32 midnightId = IdLib.toId(offer.obligation, block.chainid, address(morphoV2)); - uint256 remainingUnits = Midnight(morphoV2).creditOf(midnightId, address(adapter)); - - assertEq(parentVault.allocation(durationId(1 days)), remainingUnits, "1 day"); - assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); - } - function testUpdateOnWithdraw() public { Offer memory offer = buy(7 days, 1e18); assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); @@ -180,7 +160,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(7 days); vm.prank(taker); - morphoV2.repay(offer.obligation, 1e18, taker, ""); + midnight.repay(offer.obligation, 1e18, taker, ""); vm.prank(signerAllocator); adapter.withdrawToVault(offer.obligation, 0.5e18); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 7baa10e31..573d9dc2c 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -13,7 +13,7 @@ import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol" import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; import {Midnight} from "../lib/midnight/src/Midnight.sol"; -import {Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; +import {IMidnight, Offer, Obligation, CollateralParams} from "../lib/midnight/src/interfaces/IMidnight.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "../lib/midnight/src/interfaces/IEcrecover.sol"; import {TickLib, MAX_TICK} from "../lib/midnight/src/libraries/TickLib.sol"; import {IdLib} from "../lib/midnight/src/libraries/IdLib.sol"; @@ -31,7 +31,7 @@ contract MidnightAdapterTest is Test { using stdStorage for StdStorage; using MathLib for uint256; - Midnight internal morphoV2; + IMidnight internal midnight; IMidnightAdapterFactory internal factory; IMidnightAdapter internal adapter; VaultV2Mock internal parentVault; @@ -77,7 +77,7 @@ contract MidnightAdapterTest is Test { recipient = makeAddr("recipient"); taker = makeAddr("taker"); - morphoV2 = new Midnight(); + midnight = IMidnight(address(new Midnight())); loanToken = IERC20(address(new ERC20Mock(18))); rewardToken = IERC20(address(new ERC20Mock(18))); @@ -85,11 +85,11 @@ contract MidnightAdapterTest is Test { parentVault = new VaultV2Mock(address(loanToken), owner, curator, signerAllocator, address(0)); factory = new MidnightAdapterFactory(allDurations); - adapter = MidnightAdapter(factory.createMidnightAdapter(address(parentVault), address(morphoV2))); + adapter = MidnightAdapter(factory.createMidnightAdapter(address(parentVault), address(midnight))); // Adapter authorizes itself as ratifier vm.prank(address(adapter)); - morphoV2.setIsAuthorized(address(adapter), address(adapter), true); + midnight.setIsAuthorized(address(adapter), address(adapter), true); address collToken0 = address(new ERC20Mock(18)); address collToken1 = address(new ERC20Mock(18)); @@ -104,12 +104,12 @@ contract MidnightAdapterTest is Test { storedCollaterals.push( CollateralParams({ - token: collToken0, lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracle0 + token: collToken0, lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracle0 }) ); storedCollaterals.push( CollateralParams({ - token: collToken1, lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracle1 + token: collToken1, lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracle1 }) ); @@ -185,12 +185,12 @@ contract MidnightAdapterTest is Test { offer.tick = TickLib.priceToTick(0.95e18); vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - morphoV2.supplyCollateral(offer.obligation, 0, 1_000e18, taker); - morphoV2.supplyCollateral(offer.obligation, 1, 1_000e18, taker); + midnight.supplyCollateral(offer.obligation, 0, 1_000e18, taker); + midnight.supplyCollateral(offer.obligation, 1, 1_000e18, taker); vm.stopPrank(); uint256 assets = 1e18; @@ -201,7 +201,7 @@ contract MidnightAdapterTest is Test { offer.callback = address(adapter); offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take( + midnight.take( units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); @@ -218,7 +218,7 @@ contract MidnightAdapterTest is Test { assertEq(maturityData.growth, newGrowth, "growth"); assertEq(maturityData.nextMaturity, type(uint48).max, "nextMaturity"); - uint256 actualUnits = adapter.units(_obligationId(offer.obligation)); + uint256 actualUnits = adapter.netCredit(_obligationId(offer.obligation)); assertEq(actualUnits, units, "units"); } @@ -228,12 +228,12 @@ contract MidnightAdapterTest is Test { uint256 maturity = offer.obligation.maturity; vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); deal(storedCollaterals[0].token, taker, 100_000e18); deal(storedCollaterals[1].token, taker, 100_000e18); - morphoV2.supplyCollateral(offer.obligation, 0, 100_000e18, taker); - morphoV2.supplyCollateral(offer.obligation, 1, 100_000e18, taker); + midnight.supplyCollateral(offer.obligation, 0, 100_000e18, taker); + midnight.supplyCollateral(offer.obligation, 1, 100_000e18, taker); vm.stopPrank(); // Step 1: Buy at maturity M (future) @@ -246,7 +246,7 @@ contract MidnightAdapterTest is Test { offer.callbackData = abi.encode(0); vm.prank(taker); - morphoV2.take( + midnight.take( units1, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); @@ -263,7 +263,6 @@ contract MidnightAdapterTest is Test { adapter.accrueInterest(); assertEq(adapter.currentGrowth(), 0, "currentGrowth after accrual should be 0"); assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity should be sentinel"); - uint256 totalAssetsAfterAccrual = adapter._totalAssets(); // In midnight, any seller with debt past maturity is always liquidatable // (isLiquidatable returns true if block.timestamp > maturity && debt > 0), @@ -300,7 +299,7 @@ contract MidnightAdapterTest is Test { } for (uint256 i = 0; i < numCollaterals; i++) { collateralParams[i] = CollateralParams({ - token: tokens[i], lltv: 1 ether, maxLif: morphoV2.maxLif(1 ether, 0.25e18), oracle: oracles[i] + token: tokens[i], lltv: 1 ether, maxLif: midnight.maxLif(1 ether, 0.25e18), oracle: oracles[i] }); } offer.obligation.collateralParams = collateralParams; @@ -411,8 +410,8 @@ contract MidnightAdapterTest is Test { function setupObligations(Step[] memory steps) internal { vm.startPrank(taker); - IERC20(storedCollaterals[0].token).approve(address(morphoV2), type(uint256).max); - IERC20(storedCollaterals[1].token).approve(address(morphoV2), type(uint256).max); + IERC20(storedCollaterals[0].token).approve(address(midnight), type(uint256).max); + IERC20(storedCollaterals[1].token).approve(address(midnight), type(uint256).max); vm.stopPrank(); Offer memory offer = Offer({ @@ -463,11 +462,11 @@ contract MidnightAdapterTest is Test { vm.startPrank(taker); deal(storedCollaterals[0].token, taker, 1_000e18); deal(storedCollaterals[1].token, taker, 1_000e18); - morphoV2.supplyCollateral(offer.obligation, 0, 1_000e18, taker); - morphoV2.supplyCollateral(offer.obligation, 1, 1_000e18, taker); + midnight.supplyCollateral(offer.obligation, 0, 1_000e18, taker); + midnight.supplyCollateral(offer.obligation, 1, 1_000e18, taker); - uint256 unitsBefore = adapter.units(obligationId); - morphoV2.take( + uint256 unitsBefore = adapter.netCredit(obligationId); + midnight.take( units, taker, address(0), @@ -480,7 +479,7 @@ contract MidnightAdapterTest is Test { ); vm.stopPrank(); - assertEq(adapter.units(obligationId), unitsBefore + units, "setup: units 1"); + assertEq(adapter.netCredit(obligationId), unitsBefore + units, "setup: units 1"); expectedUnits[obligationId] += units; expectedMaturityGrowths[step.maturity] += actualGrowth; @@ -536,7 +535,7 @@ contract MidnightAdapterTest is Test { // Check positions growth and size for (uint256 i = 0; i < expectedPositionsList.length; i++) { bytes32 obligationId = bytes32(expectedPositionsList[i]); - assertEq(adapter.units(obligationId), expectedUnits[obligationId], "units"); + assertEq(adapter.netCredit(obligationId), expectedUnits[obligationId], "units"); } } @@ -677,8 +676,8 @@ contract MidnightAdapterTest is Test { return res; } - function _obligationId(Obligation memory obligation) internal pure returns (bytes32) { - return keccak256(abi.encode(obligation)); + function _obligationId(Obligation memory obligation) internal view returns (bytes32) { + return IdLib.toId(obligation, block.chainid, address(midnight)); } function sign(Offer[1] memory offers) internal view returns (bytes memory) { diff --git a/test/mocks/VaultV2Mock.sol b/test/mocks/VaultV2Mock.sol index 3ccf11ba6..5fd57bc88 100644 --- a/test/mocks/VaultV2Mock.sol +++ b/test/mocks/VaultV2Mock.sol @@ -62,6 +62,14 @@ contract VaultV2Mock { return (ids, change); } + function forceDeallocateInKind(address adapter, bytes memory data) external returns (bytes32[] memory, int256) { + (bytes32[] memory ids, int256 change) = IAdapter(adapter).deallocate(data, 0, msg.sig, msg.sender); + for (uint256 i; i < ids.length; i++) { + allocation[ids[i]] = uint256(int256(allocation[ids[i]]) + change); + } + return (ids, change); + } + function setTotalAssets(uint256 newTotalAssets) external { totalAssets = newTotalAssets; } From acb48944ce1f4f53850a1e215f0477fab214bd9b Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 16 Apr 2026 15:35:24 +0200 Subject: [PATCH 02/13] force deallocate without midnight take --- src/adapters/MidnightAdapter.sol | 200 ++++++++++++------ src/adapters/interfaces/IMidnightAdapter.sol | 25 ++- .../interfaces/IMidnightAdapterFactory.sol | 6 +- test/MidnightAdapterAllocationUpdateTest.sol | 30 +-- test/MidnightAdapterTest.sol | 23 +- 5 files changed, 175 insertions(+), 109 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index f64f15503..3c9705f1d 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -5,14 +5,14 @@ pragma solidity 0.8.34; import {IMidnight, Offer, Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH, ROOT_TYPEHASH} from "lib/midnight/src/interfaces/IEcrecover.sol"; -import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; +import {CALLBACK_SUCCESS, WAD} from "lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "lib/midnight/src/periphery/TakeAmountsLib.sol"; import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; import {MathLib} from "../libraries/MathLib.sol"; import {IVaultV2} from "../interfaces/IVaultV2.sol"; -import {IMidnightAdapter, MaturityData, IAdapter} from "./interfaces/IMidnightAdapter.sol"; +import {IMidnightAdapter, MaturityData, Position, IAdapter} from "./interfaces/IMidnightAdapter.sol"; import {DurationsLib} from "./libraries/DurationsLib.sol"; /// @dev Approximates held assets by linearly accounting for interest separately for each obligation. @@ -45,7 +45,8 @@ contract MidnightAdapter is IMidnightAdapter { uint48 public firstMaturity; uint128 public currentGrowth; mapping(uint256 timestamp => MaturityData) public _maturities; - mapping(bytes32 obligationId => uint256) public netCredit; + mapping(bytes32 obligationId => Position) public positions; + mapping(bytes32 obligationId => mapping(address user => uint256)) public shares; /* CONSTRUCTOR */ constructor(address _parentVault, address _midnight, uint256[] memory _durations) { @@ -104,27 +105,56 @@ contract MidnightAdapter is IMidnightAdapter { function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external { require(IVaultV2(parentVault).isAllocator(msg.sender), NotAuthorized()); - bytes32 obligationId = IdLib.toId(obligation, block.chainid, midnight); + bytes32 obligationId = _obligationId(obligation); + Position storage position = positions[obligationId]; uint256 pendingFeeDecrease = IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), address(this)); + uint256 withdrawNetCreditDecrease = withdrawnAssets - pendingFeeDecrease; + uint256 oldVaultNetCredit = position.vaultNetCredit; accrueInterest(); deallocateExpiredDurations(obligation); + realizeLoss(position, obligationId, obligation.maturity, -int256(withdrawNetCreditDecrease)); - uint256 withdrawNetCreditDecrease = withdrawnAssets - pendingFeeDecrease; - uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - - IMidnight(midnight).pendingFee(obligationId, address(this)); - // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; + if (withdrawNetCreditDecrease > 0) { + position.vaultNetCredit -= uint128(withdrawNetCreditDecrease); + removeUnits(obligation.maturity, withdrawNetCreditDecrease); + } - if (totalNetCreditDecrease > withdrawNetCreditDecrease) { - removeUnits(obligation, totalNetCreditDecrease - withdrawNetCreditDecrease); + IVaultV2(parentVault) + .deallocate( + address(this), + abi.encode(ids(obligation), -(oldVaultNetCredit - position.vaultNetCredit).toInt256()), + withdrawnAssets + ); + } + + /// @dev To withdraw early, users can sell on midnight and in a callback immediately repay & withdraw here. + function withdrawShares(Obligation memory obligation, uint256 redeemedShares) external { + bytes32 obligationId = _obligationId(obligation); + Position storage position = positions[obligationId]; + uint256 oldVaultNetCredit = position.vaultNetCredit; + + accrueInterest(); + deallocateExpiredDurations(obligation); + realizeLoss(position, obligationId, obligation.maturity, 0); + + if (oldVaultNetCredit > position.vaultNetCredit) { + IVaultV2(parentVault) + .deallocate( + address(this), abi.encode(ids(obligation), -int256(oldVaultNetCredit - position.vaultNetCredit)), 0 + ); } - if (withdrawNetCreditDecrease > 0) removeUnits(obligation, withdrawNetCreditDecrease); + uint256 withdrawnAssets = redeemedShares.mulDivDown(position.userNetCredit + 1, position.userShares + 1); - IVaultV2(parentVault) - .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), withdrawnAssets); + uint256 pendingFeeDecrease = + IMidnight(midnight).withdraw(obligation, withdrawnAssets, address(this), msg.sender); + + uint256 withdrawNetCreditDecrease = withdrawnAssets - pendingFeeDecrease; + position.userNetCredit -= uint128(withdrawNetCreditDecrease); + position.userShares -= redeemedShares.toUint128(); + shares[obligationId][msg.sender] -= redeemedShares; } function deallocateExpiredDurations(Obligation memory obligation) public { @@ -148,7 +178,7 @@ contract MidnightAdapter is IMidnightAdapter { } IVaultV2(parentVault) .deallocate( - address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.netCredit))), 0 + address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.vaultNetCredit))), 0 ); } } @@ -207,28 +237,31 @@ contract MidnightAdapter is IMidnightAdapter { } /// @dev Can be called by this adapter from a sell callback, a withdraw, or a loss realization. - /// @dev Can be called by a user through forceDeallocate to trigger a sell take by the adapter. - function deallocate(bytes memory data, uint256 sellerAssets, bytes4 messageSig, address caller) + /// @dev Can be called by a user through forceDeallocate. + /// @dev A force deallocator forfeits all his share of the pending continuous fee. + function deallocate(bytes memory data, uint256 deallocatedAmount, 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, bytes32 root, bytes32[] memory proof) = - abi.decode(data, (Offer, bytes, bytes32, bytes32[])); - require(offer.buy && offer.obligation.loanToken == asset && offer.tick == MAX_TICK, IncorrectOffer()); - - // Already in a deallocate call so we skip the onSell callback and return the deallocation here. - bytes32 obligationId = IdLib.toId(offer.obligation, block.chainid, midnight); - uint256 takeUnits = - TakeAmountsLib.sellerAssetsToUnits(IMidnight(midnight), obligationId, offer, sellerAssets); - (,, uint256 deallocated) = IMidnight(midnight) - .take(takeUnits, address(this), address(0), hex"", address(this), offer, ratifierData, root, proof); - - require(IMidnight(midnight).debtOf(obligationId, address(this)) == 0, NoBorrowing()); - - deallocateExpiredDurations(offer.obligation); - removeUnits(offer.obligation, deallocated); - return (ids(offer.obligation), -deallocated.toInt256()); + Obligation memory obligation = abi.decode(data, (Obligation)); + bytes32 obligationId = _obligationId(obligation); + Position storage position = positions[obligationId]; + + accrueInterest(); + deallocateExpiredDurations(obligation); + uint256 oldVaultNetCredit = position.vaultNetCredit; + realizeLoss(position, obligationId, obligation.maturity, 0); + + uint256 mintedShares = + deallocatedAmount.mulDivDown(uint256(position.userShares) + 1, uint256(position.userNetCredit) + 1); + shares[obligationId][caller] += mintedShares; + position.userShares += uint128(mintedShares); + position.userNetCredit += uint128(deallocatedAmount); + position.vaultNetCredit -= uint128(deallocatedAmount); + removeUnits(obligation.maturity, deallocatedAmount); + return (ids(obligation), -(oldVaultNetCredit - position.vaultNetCredit).toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. @@ -238,7 +271,7 @@ contract MidnightAdapter is IMidnightAdapter { } } - /* MORPHO V2 CALLBACKS */ + /* MIDNIGHT CALLBACKS */ function onRatify(Offer memory offer, bytes32 root, bytes memory data) external view returns (bytes32) { // Collaterals will be checked through vault ids. @@ -272,25 +305,20 @@ contract MidnightAdapter is IMidnightAdapter { ) external returns (bytes32) { uint48 prevMaturity = abi.decode(data, (uint48)); MaturityData storage maturityData = _maturities[obligation.maturity]; - require(msg.sender == midnight, NotMorphoV2()); + Position storage position = positions[obligationId]; + require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); require(prevMaturity < obligation.maturity, IncorrectHint()); - accrueInterest(); - deallocateExpiredDurations(obligation); - - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); - uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - - IMidnight(midnight).pendingFee(obligationId, address(this)); - int256 change = newNetCredit.toInt256() - netCredit[obligationId].toInt256(); - // change is at most buyNetCreditIncrease - if (change < buyNetCreditIncrease.toInt256()) { - removeUnits(obligation, (buyNetCreditIncrease.toInt256() - change).toUint256()); - } + accrueInterest(); + deallocateExpiredDurations(obligation); + uint256 oldVaultNetCredit = position.vaultNetCredit; + realizeLoss(position, obligationId, obligation.maturity, int256(buyNetCreditIncrease)); + uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); if (timeToMaturity > 0) { uint128 gainedGrowth = ((buyNetCreditIncrease - paidAssets) / timeToMaturity).toUint128(); _totalAssets += paidAssets + (buyNetCreditIncrease - paidAssets) % timeToMaturity; @@ -300,8 +328,8 @@ contract MidnightAdapter is IMidnightAdapter { _totalAssets += buyNetCreditIncrease; } - maturityData.netCredit += buyNetCreditIncrease.toUint128(); - netCredit[obligationId] += buyNetCreditIncrease.toUint128(); + maturityData.vaultNetCredit += buyNetCreditIncrease.toUint128(); + position.vaultNetCredit += uint128(buyNetCreditIncrease); // Insert the maturity in the list if needed if (obligation.maturity >= block.timestamp) { @@ -328,7 +356,12 @@ contract MidnightAdapter is IMidnightAdapter { } } - IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); + IVaultV2(parentVault) + .allocate( + address(this), + abi.encode(ids(obligation), position.vaultNetCredit.toInt256() - oldVaultNetCredit.toInt256()), + paidAssets + ); return CALLBACK_SUCCESS; } @@ -343,26 +376,22 @@ contract MidnightAdapter is IMidnightAdapter { bytes memory ) external returns (bytes32) { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); + Position storage position = positions[obligationId]; - require(msg.sender == midnight, NotMorphoV2()); + require(msg.sender == midnight, NotMidnight()); require(seller == address(this), NotSelf()); + uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; accrueInterest(); deallocateExpiredDurations(obligation); + uint256 oldVaultNetCredit = position.vaultNetCredit; + realizeLoss(position, obligationId, obligation.maturity, -int256(sellNetCreditDecrease)); - uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; - uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - - IMidnight(midnight).pendingFee(obligationId, address(this)); - // new net credit cannot be > old credit - uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; - - // The sell itself removes exactly `netCreditDecrease` of net credit; any excess is a concurrent loss. - if (totalNetCreditDecrease > sellNetCreditDecrease) { - removeUnits(obligation, totalNetCreditDecrease - sellNetCreditDecrease); + if (sellNetCreditDecrease > 0) { + position.vaultNetCredit -= uint128(sellNetCreditDecrease); + removeUnits(obligation.maturity, sellNetCreditDecrease); } - if (sellNetCreditDecrease > 0) removeUnits(obligation, sellNetCreditDecrease); - uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); for (uint256 i = 0; i < adaptersLength; i++) { @@ -371,29 +400,60 @@ contract MidnightAdapter is IMidnightAdapter { require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); IVaultV2(parentVault) - .deallocate(address(this), abi.encode(ids(obligation), -totalNetCreditDecrease.toInt256()), sellerAssets); + .deallocate( + address(this), + abi.encode(ids(obligation), -(oldVaultNetCredit - position.vaultNetCredit).toInt256()), + sellerAssets + ); return CALLBACK_SUCCESS; } /* INTERNAL FUNCTIONS */ + function _obligationId(Obligation memory obligation) internal view returns (bytes32) { + return IdLib.toId(obligation, block.chainid, midnight); + } + + /// @dev Realizes any loss between the expected and actual net credit. + /// @dev Splits the loss between users and vault, and updates vault accounting. + function realizeLoss( + Position storage position, + bytes32 obligationId, + uint256 maturity, + int256 expectedAdapterNetCreditDelta + ) internal { + uint256 newAdapterNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) + - IMidnight(midnight).pendingFee(obligationId, address(this)); + uint256 oldAdapterNetCredit = position.vaultNetCredit + position.userNetCredit; + uint256 expectedAdapterNetCredit = (int256(oldAdapterNetCredit) + expectedAdapterNetCreditDelta).toUint256(); + if (expectedAdapterNetCredit > newAdapterNetCredit) { + uint256 loss = expectedAdapterNetCredit - newAdapterNetCredit; + uint256 userLoss = uint256(position.userNetCredit).mulDivUp(loss, oldAdapterNetCredit); + uint256 vaultLoss = loss - userLoss; + position.userNetCredit -= uint128(userLoss); + if (vaultLoss > 0) { + position.vaultNetCredit -= uint128(vaultLoss); + removeUnits(maturity, vaultLoss); + } + } + } + /// @dev Removes units from tracking. - /// @dev Changes the implied price of the obligation as little as possible. - function removeUnits(Obligation memory obligation, uint256 removedUnits) internal { - MaturityData storage maturityData = _maturities[obligation.maturity]; + /// @dev Changes the implied price as little as possible. + function removeUnits(uint256 maturity, uint256 removedUnits) internal { + MaturityData storage maturityData = _maturities[maturity]; - if (obligation.maturity > block.timestamp) { - uint256 timeToMaturity = obligation.maturity - block.timestamp; - uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.netCredit).toUint128(); + if (maturity > block.timestamp) { + uint256 timeToMaturity = maturity - block.timestamp; + uint128 removedGrowth = maturityData.growth.mulDivUp(removedUnits, maturityData.vaultNetCredit).toUint128(); maturityData.growth -= removedGrowth; currentGrowth -= removedGrowth; _totalAssets = _totalAssets + (removedGrowth * timeToMaturity) - removedUnits; } else { _totalAssets -= removedUnits; } - maturityData.netCredit -= removedUnits.toUint128(); - netCredit[IdLib.toId(obligation, block.chainid, midnight)] -= removedUnits.toUint128(); + maturityData.vaultNetCredit -= removedUnits.toUint128(); } function ids(Obligation memory obligation) public view returns (bytes32[] memory) { diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 1510ae9c7..bf7f932b8 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -9,13 +9,22 @@ import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple obligations. // nextMaturity is type(uint48).max if no next maturity +/// @dev vaultNetCredit is the net credit owned by the vault at that maturity. struct MaturityData { - uint128 netCredit; + uint128 vaultNetCredit; uint128 growth; uint48 nextMaturity; uint48 lastUpdate; } +// vaultNetCredit is the net credit owned by the vault in that obligation. +// userNetCredit is the net credit owned by the users in that obligation. +struct Position { + uint128 vaultNetCredit; + uint128 userNetCredit; + uint128 userShares; +} + interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { /* EVENTS */ @@ -37,18 +46,25 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { error LoanAssetMismatch(); error NoDebtCreation(); error NotAuthorized(); - error NotMorphoV2(); + error NotMidnight(); error NotSelf(); error SelfAllocationOnly(); /* FUNCTIONS */ + function asset() external view returns (address); function _totalAssets() external view returns (uint256); function lastUpdate() external view returns (uint48); function firstMaturity() external view returns (uint48); function currentGrowth() external view returns (uint128); + function midnight() external view returns (address); function adapterId() external view returns (bytes32); - function netCredit(bytes32 obligationId) external view returns (uint256); + function packedDurations() external view returns (bytes32); + function positions(bytes32 obligationId) + external + view + returns (uint128 vaultNetCredit, uint128 userNetCredit, uint128 userShares); + function shares(bytes32 obligationId, address user) external view returns (uint256); function maturities(uint256 date) external view returns (MaturityData memory); function skimRecipient() external view returns (address); function setSkimRecipient(address newSkimRecipient) external; @@ -56,7 +72,8 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); function deallocateExpiredDurations(Obligation memory obligation) external; - function withdrawToVault(Obligation memory obligation, uint256 units) external; + function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external; + function withdrawShares(Obligation memory obligation, uint256 redeemedShares) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); function parentVault() external view returns (address); function accrueInterestView() external view returns (uint48, uint128, uint256); diff --git a/src/adapters/interfaces/IMidnightAdapterFactory.sol b/src/adapters/interfaces/IMidnightAdapterFactory.sol index 8211abfbc..b890c0746 100644 --- a/src/adapters/interfaces/IMidnightAdapterFactory.sol +++ b/src/adapters/interfaces/IMidnightAdapterFactory.sol @@ -5,13 +5,13 @@ pragma solidity >=0.5.0; interface IMidnightAdapterFactory { /* EVENTS */ - event CreateMidnightAdapter(address indexed parentVault, address indexed morpho, address indexed midnightAdapter); + event CreateMidnightAdapter(address indexed parentVault, address indexed midnight, address indexed midnightAdapter); /* FUNCTIONS */ function durations(uint256 index) external view returns (uint256); function durationsLength() external view returns (uint256); - function midnightAdapter(address parentVault, address morpho) external view returns (address); + function midnightAdapter(address parentVault, address midnight) external view returns (address); function isMidnightAdapter(address account) external view returns (bool); - function createMidnightAdapter(address parentVault, address morpho) external returns (address); + function createMidnightAdapter(address parentVault, address midnight) external returns (address); } diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index fcda0e2be..b35e348ca 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -64,6 +64,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { offer.obligation = obligation; offer.buy = false; + offer.reduceOnly = true; offer.tick = MAX_TICK; uint256 price = TickLib.tickToPrice(MAX_TICK); uint256 units = assets * 1e18 / price; @@ -80,33 +81,8 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { } function forceDeallocate(Obligation memory obligation, uint256 assets) internal { - address buyer = makeAddr("buyer"); - SetterRatifier approvalRatifier = new SetterRatifier(address(midnight)); - - Offer memory offer = storedOffer; - offer.obligation = obligation; - offer.buy = true; - offer.maker = buyer; - offer.tick = MAX_TICK; - uint256 price = TickLib.tickToPrice(MAX_TICK); - uint256 units = assets * 1e18 / price; - offer.maxUnits = units; - offer.expiry = block.timestamp; - offer.callback = address(0); - offer.callbackData = hex""; - offer.ratifier = address(approvalRatifier); - offer.group = bytes32(vm.randomUint()); - - deal(address(loanToken), buyer, assets); - vm.startPrank(buyer); - loanToken.approve(address(midnight), type(uint256).max); - midnight.setIsAuthorized(buyer, address(approvalRatifier), true); - bytes32 _root = root([offer]); - approvalRatifier.setApproval(buyer, _root, true); - vm.stopPrank(); - - bytes memory data = abi.encode(offer, hex"", _root, proof([offer])); - parentVault.forceDeallocate(address(adapter), data, assets, address(this)); + deal(address(loanToken), address(adapter), assets); + parentVault.forceDeallocate(address(adapter), abi.encode(obligation), assets, address(this)); } function durationId(uint256 duration) internal pure returns (bytes32) { diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 573d9dc2c..fb2a53e7b 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -218,7 +218,7 @@ contract MidnightAdapterTest is Test { assertEq(maturityData.growth, newGrowth, "growth"); assertEq(maturityData.nextMaturity, type(uint48).max, "nextMaturity"); - uint256 actualUnits = adapter.netCredit(_obligationId(offer.obligation)); + uint256 actualUnits = vaultNetCredit(_obligationId(offer.obligation)); assertEq(actualUnits, units, "units"); } @@ -465,7 +465,7 @@ contract MidnightAdapterTest is Test { midnight.supplyCollateral(offer.obligation, 0, 1_000e18, taker); midnight.supplyCollateral(offer.obligation, 1, 1_000e18, taker); - uint256 unitsBefore = adapter.netCredit(obligationId); + uint256 unitsBefore = vaultNetCredit(obligationId); midnight.take( units, taker, @@ -479,7 +479,7 @@ contract MidnightAdapterTest is Test { ); vm.stopPrank(); - assertEq(adapter.netCredit(obligationId), unitsBefore + units, "setup: units 1"); + assertEq(vaultNetCredit(obligationId), unitsBefore + units, "setup: units 1"); expectedUnits[obligationId] += units; expectedMaturityGrowths[step.maturity] += actualGrowth; @@ -535,7 +535,7 @@ contract MidnightAdapterTest is Test { // Check positions growth and size for (uint256 i = 0; i < expectedPositionsList.length; i++) { bytes32 obligationId = bytes32(expectedPositionsList[i]); - assertEq(adapter.netCredit(obligationId), expectedUnits[obligationId], "units"); + assertEq(vaultNetCredit(obligationId), expectedUnits[obligationId], "units"); } } @@ -604,7 +604,15 @@ contract MidnightAdapterTest is Test { /* DURATIONS */ - // Add constructor tests + function testConstructorGetters() public view { + assertEq(adapter.asset(), address(loanToken), "asset"); + assertEq(adapter.parentVault(), address(parentVault), "parentVault"); + assertEq(adapter.midnight(), address(midnight), "midnight"); + assertEq(adapter.skimRecipient(), address(0), "skimRecipient"); + assertEq(adapter.durationsLength(), allDurations.length, "durationsLength"); + assertEq(adapter.packedDurations(), MidnightAdapter(address(adapter)).packedDurations(), "packedDurations"); + assertEq(adapter.shares(_obligationId(storedOffer.obligation), address(this)), 0, "shares"); + } /* IDS */ @@ -680,6 +688,11 @@ contract MidnightAdapterTest is Test { return IdLib.toId(obligation, block.chainid, address(midnight)); } + function vaultNetCredit(bytes32 obligationId) internal view returns (uint256) { + (uint128 _vaultNetCredit,,) = adapter.positions(obligationId); + return _vaultNetCredit; + } + function sign(Offer[1] memory offers) internal view returns (bytes memory) { return ratifierData(root(offers), offers[0].maker); } From 0292761ca0052dddb38a33e2d25044f9438021fc Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 16 Apr 2026 18:07:42 +0200 Subject: [PATCH 03/13] simpler duration indices --- src/adapters/MidnightAdapter.sol | 61 ++++++++------------ src/adapters/interfaces/IMidnightAdapter.sol | 4 +- test/MidnightAdapterAllocationUpdateTest.sol | 6 +- 3 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 3c9705f1d..98300e3a3 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -113,7 +113,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 oldVaultNetCredit = position.vaultNetCredit; accrueInterest(); - deallocateExpiredDurations(obligation); + updateDurationIndexAndAllocations(obligation); realizeLoss(position, obligationId, obligation.maturity, -int256(withdrawNetCreditDecrease)); if (withdrawNetCreditDecrease > 0) { @@ -136,7 +136,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 oldVaultNetCredit = position.vaultNetCredit; accrueInterest(); - deallocateExpiredDurations(obligation); + updateDurationIndexAndAllocations(obligation); realizeLoss(position, obligationId, obligation.maturity, 0); if (oldVaultNetCredit > position.vaultNetCredit) { @@ -157,33 +157,22 @@ contract MidnightAdapter is IMidnightAdapter { shares[obligationId][msg.sender] -= redeemedShares; } - function deallocateExpiredDurations(Obligation memory obligation) public { + function updateDurationIndexAndAllocations(Obligation memory obligation) public { MaturityData storage maturityData = _maturities[obligation.maturity]; - if (maturityData.lastUpdate > 0) { - uint256 previousTimeToMaturity = obligation.maturity.zeroFloorSub(maturityData.lastUpdate); - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); - - uint256 zeroedDurationsCount = 0; - for (uint256 i = 0; i < durationsLength && previousTimeToMaturity >= packedDurations.get(i); i++) { - if (timeToMaturity < packedDurations.get(i)) zeroedDurationsCount++; - } - - if (zeroedDurationsCount > 0) { - bytes32[] memory zeroedDurationsIds = new bytes32[](zeroedDurationsCount); - uint256 j = 0; - for (uint256 i = 0; i < durationsLength; i++) { - if (previousTimeToMaturity >= packedDurations.get(i) && timeToMaturity < packedDurations.get(i)) { - zeroedDurationsIds[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); - } - } - IVaultV2(parentVault) - .deallocate( - address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.vaultNetCredit))), 0 - ); + uint256 oldDurationIndex = maturityData.durationIndex; + uint256 newDurationIndex = durationIndex(obligation.maturity); + maturityData.durationIndex = uint8(newDurationIndex); + // VaultV2.deallocate requires allocation > 0 for each returned id. + if (newDurationIndex < oldDurationIndex && maturityData.vaultNetCredit > 0) { + bytes32[] memory zeroedDurationsIds = new bytes32[](oldDurationIndex - newDurationIndex); + for (uint256 i = 0; i < zeroedDurationsIds.length; i++) { + zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationIndex + i))); } + IVaultV2(parentVault) + .deallocate( + address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.vaultNetCredit))), 0 + ); } - - maturityData.lastUpdate = uint48(block.timestamp); } /* ACCRUAL */ @@ -250,7 +239,7 @@ contract MidnightAdapter is IMidnightAdapter { Position storage position = positions[obligationId]; accrueInterest(); - deallocateExpiredDurations(obligation); + updateDurationIndexAndAllocations(obligation); uint256 oldVaultNetCredit = position.vaultNetCredit; realizeLoss(position, obligationId, obligation.maturity, 0); @@ -314,7 +303,7 @@ contract MidnightAdapter is IMidnightAdapter { require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); accrueInterest(); - deallocateExpiredDurations(obligation); + updateDurationIndexAndAllocations(obligation); uint256 oldVaultNetCredit = position.vaultNetCredit; realizeLoss(position, obligationId, obligation.maturity, int256(buyNetCreditIncrease)); @@ -383,7 +372,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; accrueInterest(); - deallocateExpiredDurations(obligation); + updateDurationIndexAndAllocations(obligation); uint256 oldVaultNetCredit = position.vaultNetCredit; realizeLoss(position, obligationId, obligation.maturity, -int256(sellNetCreditDecrease)); @@ -456,13 +445,13 @@ contract MidnightAdapter is IMidnightAdapter { maturityData.vaultNetCredit -= removedUnits.toUint128(); } - function ids(Obligation memory obligation) public view returns (bytes32[] memory) { - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + function durationIndex(uint256 maturity) internal view returns (uint256 index) { + uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); + while (index < durationsLength && timeToMaturity >= packedDurations.get(index)) index++; + } - uint256 durationsCount = 0; - for (uint256 i = 0; i < durationsLength && timeToMaturity >= packedDurations.get(i); i++) { - durationsCount++; - } + function ids(Obligation memory obligation) public view returns (bytes32[] memory) { + uint256 durationsCount = durationIndex(obligation.maturity); bytes32[] memory idsArray = new bytes32[](1 + obligation.collateralParams.length * 2 + durationsCount); @@ -480,7 +469,7 @@ contract MidnightAdapter is IMidnightAdapter { ) ); } - for (uint256 i = 0; i < durationsLength && timeToMaturity >= packedDurations.get(i); i++) { + for (uint256 i = 0; i < durationsCount; i++) { idsArray[j++] = keccak256(abi.encode("duration", packedDurations.get(i))); } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index bf7f932b8..25e975508 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -14,7 +14,7 @@ struct MaturityData { uint128 vaultNetCredit; uint128 growth; uint48 nextMaturity; - uint48 lastUpdate; + uint8 durationIndex; } // vaultNetCredit is the net credit owned by the vault in that obligation. @@ -71,7 +71,7 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function skim(address token) external; function durations() external view returns (uint256[] memory); function durationsLength() external view returns (uint256); - function deallocateExpiredDurations(Obligation memory obligation) external; + function updateDurationIndexAndAllocations(Obligation memory obligation) external; function withdrawToVault(Obligation memory obligation, uint256 withdrawnAssets) external; function withdrawShares(Obligation memory obligation, uint256 redeemedShares) external; function ids(Obligation memory obligation) external view returns (bytes32[] memory); diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index b35e348ca..9781b27fd 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -107,7 +107,7 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { skip(timeToMaturity - duration + extraSkip); - adapter.deallocateExpiredDurations(offer.obligation); + adapter.updateDurationIndexAndAllocations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), 0); } @@ -122,9 +122,9 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { Offer memory offer = buy(timeToMaturity, 1e18); skip(skipAmount); - adapter.deallocateExpiredDurations(offer.obligation); + adapter.updateDurationIndexAndAllocations(offer.obligation); uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.deallocateExpiredDurations(offer.obligation); + adapter.updateDurationIndexAndAllocations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } From dc3539d07047ca443f83314905f926753a181efa Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 00:03:03 +0200 Subject: [PATCH 04/13] simplify self deallocate --- src/adapters/MidnightAdapter.sol | 42 ++++++++++---------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 98300e3a3..e4cd87c0f 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -121,12 +121,8 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(obligation.maturity, withdrawNetCreditDecrease); } - IVaultV2(parentVault) - .deallocate( - address(this), - abi.encode(ids(obligation), -(oldVaultNetCredit - position.vaultNetCredit).toInt256()), - withdrawnAssets - ); + int256 change = int256(uint256(position.vaultNetCredit)) - int256(oldVaultNetCredit); + IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); } /// @dev To withdraw early, users can sell on midnight and in a callback immediately repay & withdraw here. @@ -139,11 +135,9 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationIndexAndAllocations(obligation); realizeLoss(position, obligationId, obligation.maturity, 0); - if (oldVaultNetCredit > position.vaultNetCredit) { - IVaultV2(parentVault) - .deallocate( - address(this), abi.encode(ids(obligation), -int256(oldVaultNetCredit - position.vaultNetCredit)), 0 - ); + if (position.vaultNetCredit != oldVaultNetCredit) { + int256 change = int256(uint256(position.vaultNetCredit)) - int256(oldVaultNetCredit); + IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), 0); } uint256 withdrawnAssets = redeemedShares.mulDivDown(position.userNetCredit + 1, position.userShares + 1); @@ -168,10 +162,8 @@ contract MidnightAdapter is IMidnightAdapter { for (uint256 i = 0; i < zeroedDurationsIds.length; i++) { zeroedDurationsIds[i] = keccak256(abi.encode("duration", packedDurations.get(newDurationIndex + i))); } - IVaultV2(parentVault) - .deallocate( - address(this), abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.vaultNetCredit))), 0 - ); + int256 change = -int256(uint256(maturityData.vaultNetCredit)); + IVaultV2(parentVault).deallocate(address(this), abi.encode(zeroedDurationsIds, change), 0); } } @@ -320,6 +312,9 @@ contract MidnightAdapter is IMidnightAdapter { maturityData.vaultNetCredit += buyNetCreditIncrease.toUint128(); position.vaultNetCredit += uint128(buyNetCreditIncrease); + int256 change = int256(uint256(position.vaultNetCredit)) - int256(oldVaultNetCredit); + IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); + // Insert the maturity in the list if needed if (obligation.maturity >= block.timestamp) { uint48 nextMaturity; @@ -345,13 +340,6 @@ contract MidnightAdapter is IMidnightAdapter { } } - IVaultV2(parentVault) - .allocate( - address(this), - abi.encode(ids(obligation), position.vaultNetCredit.toInt256() - oldVaultNetCredit.toInt256()), - paidAssets - ); - return CALLBACK_SUCCESS; } @@ -381,6 +369,9 @@ contract MidnightAdapter is IMidnightAdapter { removeUnits(obligation.maturity, sellNetCreditDecrease); } + int256 change = int256(uint256(position.vaultNetCredit)) - int256(oldVaultNetCredit); + IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); + uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); for (uint256 i = 0; i < adaptersLength; i++) { @@ -388,13 +379,6 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); - IVaultV2(parentVault) - .deallocate( - address(this), - abi.encode(ids(obligation), -(oldVaultNetCredit - position.vaultNetCredit).toInt256()), - sellerAssets - ); - return CALLBACK_SUCCESS; } From 357a30a23fa44b720431c74e6f557fb09f201dda Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 00:47:57 +0200 Subject: [PATCH 05/13] simplify more --- src/adapters/MidnightAdapter.sol | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index e4cd87c0f..4ed8e6714 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -116,10 +116,7 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationIndexAndAllocations(obligation); realizeLoss(position, obligationId, obligation.maturity, -int256(withdrawNetCreditDecrease)); - if (withdrawNetCreditDecrease > 0) { - position.vaultNetCredit -= uint128(withdrawNetCreditDecrease); - removeUnits(obligation.maturity, withdrawNetCreditDecrease); - } + if (withdrawNetCreditDecrease > 0) removeUnits(position, obligation.maturity, withdrawNetCreditDecrease); int256 change = int256(uint256(position.vaultNetCredit)) - int256(oldVaultNetCredit); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); @@ -229,10 +226,10 @@ contract MidnightAdapter is IMidnightAdapter { Obligation memory obligation = abi.decode(data, (Obligation)); bytes32 obligationId = _obligationId(obligation); Position storage position = positions[obligationId]; + uint256 oldVaultNetCredit = position.vaultNetCredit; accrueInterest(); updateDurationIndexAndAllocations(obligation); - uint256 oldVaultNetCredit = position.vaultNetCredit; realizeLoss(position, obligationId, obligation.maturity, 0); uint256 mintedShares = @@ -240,9 +237,9 @@ contract MidnightAdapter is IMidnightAdapter { shares[obligationId][caller] += mintedShares; position.userShares += uint128(mintedShares); position.userNetCredit += uint128(deallocatedAmount); - position.vaultNetCredit -= uint128(deallocatedAmount); - removeUnits(obligation.maturity, deallocatedAmount); - return (ids(obligation), -(oldVaultNetCredit - position.vaultNetCredit).toInt256()); + removeUnits(position, obligation.maturity, deallocatedAmount); + int256 change = int256(uint256(position.vaultNetCredit)) - int256(oldVaultNetCredit); + return (ids(obligation), change); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. @@ -354,20 +351,17 @@ contract MidnightAdapter is IMidnightAdapter { ) external returns (bytes32) { uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); Position storage position = positions[obligationId]; + uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; + uint256 oldVaultNetCredit = position.vaultNetCredit; require(msg.sender == midnight, NotMidnight()); require(seller == address(this), NotSelf()); - uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; accrueInterest(); updateDurationIndexAndAllocations(obligation); - uint256 oldVaultNetCredit = position.vaultNetCredit; realizeLoss(position, obligationId, obligation.maturity, -int256(sellNetCreditDecrease)); - if (sellNetCreditDecrease > 0) { - position.vaultNetCredit -= uint128(sellNetCreditDecrease); - removeUnits(obligation.maturity, sellNetCreditDecrease); - } + if (sellNetCreditDecrease > 0) removeUnits(position, obligation.maturity, sellNetCreditDecrease); int256 change = int256(uint256(position.vaultNetCredit)) - int256(oldVaultNetCredit); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); @@ -405,16 +399,13 @@ contract MidnightAdapter is IMidnightAdapter { uint256 userLoss = uint256(position.userNetCredit).mulDivUp(loss, oldAdapterNetCredit); uint256 vaultLoss = loss - userLoss; position.userNetCredit -= uint128(userLoss); - if (vaultLoss > 0) { - position.vaultNetCredit -= uint128(vaultLoss); - removeUnits(maturity, vaultLoss); - } + if (vaultLoss > 0) removeUnits(position, maturity, vaultLoss); } } /// @dev Removes units from tracking. /// @dev Changes the implied price as little as possible. - function removeUnits(uint256 maturity, uint256 removedUnits) internal { + function removeUnits(Position storage position, uint256 maturity, uint256 removedUnits) internal { MaturityData storage maturityData = _maturities[maturity]; if (maturity > block.timestamp) { @@ -427,6 +418,7 @@ contract MidnightAdapter is IMidnightAdapter { _totalAssets -= removedUnits; } maturityData.vaultNetCredit -= removedUnits.toUint128(); + position.vaultNetCredit -= removedUnits.toUint128(); } function durationIndex(uint256 maturity) internal view returns (uint256 index) { From 7a9d5f7a8c83ce275e1f130287afb68aa2d86c3a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 17 Apr 2026 01:03:13 +0200 Subject: [PATCH 06/13] reorder --- src/adapters/MidnightAdapter.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 4ed8e6714..2da878c4f 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -284,19 +284,19 @@ contract MidnightAdapter is IMidnightAdapter { uint48 prevMaturity = abi.decode(data, (uint48)); MaturityData storage maturityData = _maturities[obligation.maturity]; Position storage position = positions[obligationId]; + uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; + uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + uint256 oldVaultNetCredit = position.vaultNetCredit; + require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); require(prevMaturity < obligation.maturity, IncorrectHint()); - - uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); accrueInterest(); updateDurationIndexAndAllocations(obligation); - uint256 oldVaultNetCredit = position.vaultNetCredit; realizeLoss(position, obligationId, obligation.maturity, int256(buyNetCreditIncrease)); - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); if (timeToMaturity > 0) { uint128 gainedGrowth = ((buyNetCreditIncrease - paidAssets) / timeToMaturity).toUint128(); _totalAssets += paidAssets + (buyNetCreditIncrease - paidAssets) % timeToMaturity; @@ -307,7 +307,7 @@ contract MidnightAdapter is IMidnightAdapter { } maturityData.vaultNetCredit += buyNetCreditIncrease.toUint128(); - position.vaultNetCredit += uint128(buyNetCreditIncrease); + position.vaultNetCredit += buyNetCreditIncrease.toUint128(); int256 change = int256(uint256(position.vaultNetCredit)) - int256(oldVaultNetCredit); IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); From ec4c8d6a334df8800898995034661956546591b1 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Fri, 5 Jun 2026 16:30:11 +0200 Subject: [PATCH 07/13] fixes --- src/adapters/MidnightAdapter.sol | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index a5dcfeaa5..1c61a3676 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -117,14 +117,11 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationCountAndAllocations(market); realizeLoss(marketData, marketId, market.maturity, -int256(withdrawNetCreditDecrease)); + removeNetCredit(marketId, market.maturity, withdrawNetCreditDecrease); - if (withdrawNetCreditDecrease > 0) { - removeNetCredit(marketId, market.maturity, withdrawNetCreditDecrease); - } - - int256 change = int256(uint256(marketData.vaultNetCredit)) - int256(oldVaultNetCredit); uint256 vaultNetCreditDecrease = oldVaultNetCredit - marketData.vaultNetCredit; - IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(market), change), withdrawnAssets); + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(market), -vaultNetCreditDecrease.toInt256()), withdrawnAssets); emit WithdrawToVault(marketId, withdrawnAssets, vaultNetCreditDecrease); } @@ -139,8 +136,9 @@ contract MidnightAdapter is IMidnightAdapter { realizeLoss(marketData, marketId, market.maturity, 0); if (marketData.vaultNetCredit != oldVaultNetCredit) { - int256 change = int256(uint256(marketData.vaultNetCredit)) - int256(oldVaultNetCredit); - IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(market), change), 0); + uint256 vaultNetCreditDecrease = oldVaultNetCredit - marketData.vaultNetCredit; + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(market), -vaultNetCreditDecrease.toInt256()), 0); } uint256 withdrawnAssets = redeemedShares.mulDivDown(marketData.userNetCredit + 1, marketData.userShares + 1); @@ -248,6 +246,7 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationCountAndAllocations(market); + IMidnight(midnight).updatePosition(market, address(this)); realizeLoss(marketData, marketId, market.maturity, 0); uint256 mintedShares = @@ -257,9 +256,9 @@ contract MidnightAdapter is IMidnightAdapter { marketData.userNetCredit += uint128(deallocatedAmount); removeNetCredit(marketId, market.maturity, deallocatedAmount); - int256 change = int256(uint256(marketData.vaultNetCredit)) - int256(oldVaultNetCredit); - emit ForceDeallocate(marketId, deallocatedAmount, oldVaultNetCredit - marketData.vaultNetCredit); - return (ids(market), change); + uint256 vaultNetCreditDecrease = oldVaultNetCredit - marketData.vaultNetCredit; + emit ForceDeallocate(marketId, deallocatedAmount, vaultNetCreditDecrease); + return (ids(market), -vaultNetCreditDecrease.toInt256()); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. @@ -375,12 +374,11 @@ contract MidnightAdapter is IMidnightAdapter { updateDurationCountAndAllocations(market); realizeLoss(marketData, marketId, market.maturity, -int256(sellNetCreditDecrease)); - if (sellNetCreditDecrease > 0) { - removeNetCredit(marketId, market.maturity, sellNetCreditDecrease); - } + removeNetCredit(marketId, market.maturity, sellNetCreditDecrease); - int256 change = int256(uint256(marketData.vaultNetCredit)) - int256(oldVaultNetCredit); - IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(market), change), sellerAssets); + uint256 vaultNetCreditDecrease = oldVaultNetCredit - marketData.vaultNetCredit; + IVaultV2(parentVault) + .deallocate(address(this), abi.encode(ids(market), -vaultNetCreditDecrease.toInt256()), sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); From b33f9b48e502fc4e0341d9429a5d25a499177ab9 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 19 Aug 2026 13:27:00 +0200 Subject: [PATCH 08/13] Apply suggestion from @adhusson Signed-off-by: Adrien Husson --- src/adapters/MidnightAdapter.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 20a309a11..14597b994 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -21,8 +21,7 @@ import {DurationsLib} from "./libraries/DurationsLib.sol"; /// make sell offers and to withdraw to the vault. /// @dev If the parent vault has a sendSharesGate, the gate must allow the adapter to send shares. /// @dev Force deallocators get shares of the adapter's position instead of triggering a market sale. Their claims must -/// stay redeemable even if the vault removes the adapter or its allocator role, so withdrawShares never interacts with -/// the parent vault. +/// stay redeemable even if the vault removes the adapter, so withdrawShares never interacts with the parent vault. contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; From c76653b85503cc152c37a4d8d58f376afbb7de8a Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 19 Aug 2026 13:27:33 +0200 Subject: [PATCH 09/13] Apply suggestion from @adhusson Signed-off-by: Adrien Husson --- src/adapters/MidnightAdapter.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 14597b994..8c4018e11 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -144,7 +144,7 @@ contract MidnightAdapter is IMidnightAdapter { } /// @dev Does not interact with the parent vault, so that claims stay redeemable even if the adapter has been - /// removed from the vault or has lost its allocator role. + /// removed from the vault. /// @dev To withdraw early, users can sell on midnight and in a callback immediately repay & withdraw here. function withdrawShares(Market memory market, uint256 redeemedShares) external { bytes32 marketId = IdLib.toId(market); From 8501c35b61fb0afb748d1ce00e40a40f4dc2225c Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Wed, 19 Aug 2026 16:28:10 +0200 Subject: [PATCH 10/13] test early exit --- test/MidnightAdapterTest.sol | 83 ++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 4595d9c0e..30f2e1bca 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -965,6 +965,89 @@ contract MidnightAdapterTest is Test { assertEq(allocationBefore - parentVault.allocation(adapter.adapterId()), 0.2e18, "folded into report"); } + // Early exit before maturity, per withdrawShares' doc comment: sell the credit on midnight (creating debt), + // then repay + withdrawShares in the sell callback. The repay itself creates the withdrawable liquidity that + // the redemption needs, and midnight's health check passes because the callback cleared the debt. + // The sale pays face value minus the settlement fee while the repay costs full face value, so the exiter needs + // preexisting assets for the difference. They can be flashloaned: simulated here by dealing them beforehand + // and checking they are left over at the end. + function testWithdrawSharesEarlyExitBeforeMaturity() public { + Offer memory boughtOffer = buy(7 days, 1e18); + bytes32 marketId = _marketId(boughtOffer.market); + forceDeallocate(boughtOffer.market, 0.5e18); + + skip(1); + + // Flat settlement fee over the [1 days, 7 days] time-to-maturity range. + uint256 fee = 0.000014e18; + midnight.setFeeSetter(address(this)); + midnight.setMarketSettlementFee(marketId, 1, fee); + midnight.setMarketSettlementFee(marketId, 2, fee); + + // Sell the 0.5e18 credit at price 1: the sale pays 0.5e18 - feeCost, the repay costs 0.5e18. + Offer memory offer = makeExternalBuyOffer(boughtOffer.market, 0.5e18); + uint256 feeCost = 0.5e18 * fee / 1e18; + loanToken.approve(address(midnight), type(uint256).max); + + // Without preexisting assets, the sale proceeds cannot cover the repay in the callback. + vm.expectRevert( + abi.encodeWithSignature( + "ERC20InsufficientBalance(address,uint256,uint256)", address(this), 0.5e18 - feeCost, 0.5e18 + ) + ); + midnight.take(offer, "", offer.maxUnits, address(this), address(this), address(this), ""); + + // "Flashloan" exactly the shortfall and exit. + uint256 flashloaned = feeCost; + deal(address(loanToken), address(this), flashloaned); + midnight.take(offer, "", offer.maxUnits, address(this), address(this), address(this), ""); + + // The flashloan can be paid back: the exit netted 0.5e18 minus the settlement fee, before maturity. + assertLt(block.timestamp, boughtOffer.market.maturity, "before maturity"); + assertEq(loanToken.balanceOf(address(this)), flashloaned + 0.5e18 - feeCost, "flashloan back + net proceeds"); + assertEq(adapter.shares(marketId, address(this)), 0, "shares burned"); + (, uint128 userNetCredit, uint128 userShares,) = adapter._markets(marketId); + assertEq(userNetCredit, 0, "user tranche emptied"); + assertEq(userShares, 0, "user shares emptied"); + } + + /// @dev Builds a buy offer at price 1 (MAX_TICK) from a funded external buyer, ratified by this contract. + function makeExternalBuyOffer(Market memory market, uint256 assets) internal returns (Offer memory offer) { + address buyer = makeAddr("externalBuyer"); + deal(address(loanToken), buyer, assets); + vm.startPrank(buyer); + loanToken.approve(address(midnight), type(uint256).max); + midnight.setIsAuthorized(address(this), true, buyer); + vm.stopPrank(); + + offer = storedOffer; + offer.market = market; + offer.buy = true; + offer.maker = buyer; + offer.tick = MAX_TICK; + offer.maxUnits = uint128(assets * 1e18 / TickLib.tickToPrice(MAX_TICK)); + offer.expiry = block.timestamp; + offer.callback = address(0); + offer.ratifier = address(this); + offer.group = bytes32("external buy"); + } + + // Ratifier for the external buyer's offer. + function isRatified(Offer memory, bytes memory, address) external pure returns (bytes32) { + return CALLBACK_SUCCESS; + } + + // Sell callback of the early exit: repay the debt just created, then redeem the shares. + function onSell(bytes32, Market memory market, uint256, uint256 units, uint256, address, address, bytes memory) + external + returns (bytes32) + { + require(msg.sender == address(midnight), "not midnight"); + midnight.repay(market, units, address(this), address(0), ""); + adapter.withdrawShares(market, adapter.shares(IdLib.toId(market), address(this))); + return CALLBACK_SUCCESS; + } + /* SKIM */ function testSetSkimRecipientUnauthorized(address nonOwner) public { From c9a79c10b8c2ffd4ff192ec4868db16bd8581f72 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 20 Aug 2026 11:48:44 +0200 Subject: [PATCH 11/13] compile midnight adapter unit at 10000 optimizer runs to fit the factory under EIP-170 --- foundry.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/foundry.toml b/foundry.toml index 64607cf7e..a2d57ff58 100644 --- a/foundry.toml +++ b/foundry.toml @@ -54,7 +54,7 @@ evm_version = "cancun" [[profile.default.compilation_restrictions]] paths = "src/adapters/MidnightAdapter.sol" -optimizer_runs = 65000 +optimizer_runs = 10000 # Midnight is compiled with its home repo's settings so it fits under EIP-170. [[profile.default.compilation_restrictions]] @@ -75,8 +75,8 @@ optimizer_runs = 200 evm_version = "cancun" [[profile.default.additional_compiler_profiles]] -name = "65000-osaka" -optimizer_runs = 65000 +name = "10000-osaka" +optimizer_runs = 10000 [[profile.default.additional_compiler_profiles]] name = "466-osaka" From e8bf768d664387c53c31e76054c1ec4eedb3fbb7 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 20 Aug 2026 12:03:07 +0200 Subject: [PATCH 12/13] raise midnight adapter unit to 17000 optimizer runs, the largest 1e3 multiple fitting EIP-170 --- foundry.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/foundry.toml b/foundry.toml index a2d57ff58..a22ddf5be 100644 --- a/foundry.toml +++ b/foundry.toml @@ -54,7 +54,7 @@ evm_version = "cancun" [[profile.default.compilation_restrictions]] paths = "src/adapters/MidnightAdapter.sol" -optimizer_runs = 10000 +optimizer_runs = 17000 # Midnight is compiled with its home repo's settings so it fits under EIP-170. [[profile.default.compilation_restrictions]] @@ -75,8 +75,8 @@ optimizer_runs = 200 evm_version = "cancun" [[profile.default.additional_compiler_profiles]] -name = "10000-osaka" -optimizer_runs = 10000 +name = "17000-osaka" +optimizer_runs = 17000 [[profile.default.additional_compiler_profiles]] name = "466-osaka" From 51b1d262298877ecac75ddef47ddc002fc611bc2 Mon Sep 17 00:00:00 2001 From: Adrien Husson Date: Thu, 27 Aug 2026 18:13:21 +0200 Subject: [PATCH 13/13] track the reported vault net credit per maturity for duration caps updates withdrawShares lowers vaultNetCredit without telling the vault, so updateDurationCaps must subtract what the vault actually holds on the duration ids, otherwise the unreported part stays on the dropped ids forever. --- src/adapters/MidnightAdapter.sol | 11 +++++-- src/adapters/interfaces/IMidnightAdapter.sol | 5 +++- test/MidnightAdapterTest.sol | 30 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 9a1c2c0ec..b72ceb446 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -138,6 +138,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 reportedDecrease = oldVaultNetCredit - marketData.vaultNetCredit + unreportedVaultDecrease[marketId]; unreportedVaultDecrease[marketId] = 0; + _maturities[market.maturity].reportedVaultNetCredit -= reportedDecrease.toUint128(); IVaultV2(parentVault) .deallocate(address(this), abi.encode(ids(market), -reportedDecrease.toInt256()), withdrawnAssets); emit WithdrawToVault(marketId, withdrawnAssets, reportedDecrease); @@ -181,14 +182,14 @@ contract MidnightAdapter is IMidnightAdapter { uint256 oldDurationCount = maturityData.durationCount; uint256 newDurationCount = durationCount(market.maturity); maturityData.durationCount = uint8(newDurationCount); - emit UpdateDurationCaps(market.maturity, newDurationCount, maturityData.vaultNetCredit); + emit UpdateDurationCaps(market.maturity, newDurationCount, maturityData.reportedVaultNetCredit); // VaultV2.forceDeallocate requires allocation > 0 for each returned id. - if (newDurationCount < oldDurationCount && maturityData.vaultNetCredit > 0) { + if (newDurationCount < oldDurationCount && maturityData.reportedVaultNetCredit > 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.vaultNetCredit))); + bytes memory data = abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.reportedVaultNetCredit))); IVaultV2(parentVault).forceDeallocate(address(this), data, 0, address(this)); } } @@ -290,6 +291,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 reportedDecrease = oldVaultNetCredit - marketData.vaultNetCredit + unreportedVaultDecrease[marketId]; unreportedVaultDecrease[marketId] = 0; + _maturities[market.maturity].reportedVaultNetCredit -= reportedDecrease.toUint128(); emit ForceDeallocate(marketId, deallocatedAmount, reportedDecrease); return (ids(market), -reportedDecrease.toInt256()); } @@ -369,6 +371,8 @@ contract MidnightAdapter is IMidnightAdapter { int256 netCreditChange = int256(uint256(marketData.vaultNetCredit)) - int256(oldVaultNetCredit) - int256(uint256(unreportedVaultDecrease[marketId])); unreportedVaultDecrease[marketId] = 0; + maturityData.reportedVaultNetCredit = + (int256(uint256(maturityData.reportedVaultNetCredit)) + netCreditChange).toUint256().toUint128(); IVaultV2(parentVault).allocate(address(this), abi.encode(ids(market), netCreditChange), paidAssets); // Insert the maturity in the list if needed @@ -418,6 +422,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 reportedDecrease = oldVaultNetCredit - marketData.vaultNetCredit + unreportedVaultDecrease[marketId]; unreportedVaultDecrease[marketId] = 0; + _maturities[market.maturity].reportedVaultNetCredit -= reportedDecrease.toUint128(); IVaultV2(parentVault) .deallocate(address(this), abi.encode(ids(market), -reportedDecrease.toInt256()), sellerAssets); diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 3a65040ab..b1a1cac21 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -9,12 +9,15 @@ import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; // Chain of maturities, each can represent multiple markets. // nextMaturity is 0 if no next maturity. +// reportedVaultNetCredit is the allocation of the maturity on each of its duration ids in the vault: vaultNetCredit +// plus the unreported vault decreases of its markets. struct MaturityData { uint128 vaultNetCredit; uint128 growth; uint48 prevMaturity; uint48 nextMaturity; uint8 durationCount; + uint128 reportedVaultNetCredit; } struct MarketData { @@ -33,7 +36,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event WithdrawShares( bytes32 indexed marketId, address indexed user, uint256 redeemedShares, uint256 withdrawnAssets ); - event UpdateDurationCaps(uint256 indexed maturity, uint256 newDurationCount, uint256 netCredit); + event UpdateDurationCaps(uint256 indexed maturity, uint256 newDurationCount, uint256 reportedVaultNetCredit); event ForceDeallocate(bytes32 indexed marketId, uint256 deallocatedAmount, uint256 netCreditDecrease); event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, int256 netCreditChange); event Sell(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 6f7b59fe3..e0a2f5aad 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -1029,6 +1029,36 @@ contract MidnightAdapterTest is Test { assertEq(allocationBefore - parentVault.allocation(adapter.adapterId()), 0.2e18, "folded into report"); } + /// forge-config: default.isolate = true + /// @dev A vault loss realized in withdrawShares stays on the duration ids until the next report. Crossing a + /// duration boundary in between must remove it from the dropped id along with the rest. + function testWithdrawSharesLossThenUpdateDurationCapsRealVault() public { + setUpRealVault(); + Offer memory offer = buyOnRealVault(7 days, 1e18); + bytes32 marketId = _marketId(offer.market); + forceDeallocateOnRealVault(offer.market, 0.5e18); + assertEq(realVault.allocation(durationId(7 days)), 0.5e18, "7 days after exit"); + + // Partial repay so the user can redeem before maturity, then a 0.4e18 loss split evenly between the tranches. + vm.prank(taker); + midnight.repay(offer.market, 0.5e18, taker, address(0), ""); + setMidnightCredit(marketId, address(adapter), 0.6e18); + adapter.withdrawShares(offer.market, adapter.shares(marketId, address(this))); + assertEq(adapter.unreportedVaultDecrease(marketId), 0.2e18, "decrease held back"); + assertEq(adapter.maturities(offer.market.maturity).reportedVaultNetCredit, 0.5e18, "reported unchanged"); + + skip(1); + adapter.updateDurationCaps(offer.market); + assertEq(realVault.allocation(durationId(7 days)), 0, "7 days fully removed"); + assertEq(realVault.allocation(durationId(1 days)), 0.5e18, "1 day untouched"); + + vm.prank(signerAllocator); + adapter.withdrawToVault(offer.market, 0); + assertEq(realVault.allocation(durationId(1 days)), 0.3e18, "1 day after report"); + assertEq(realVault.allocation(adapter.adapterId()), 0.3e18, "adapter id after report"); + assertEq(adapter.maturities(offer.market.maturity).reportedVaultNetCredit, 0.3e18, "reported after report"); + } + // Early exit before maturity, per withdrawShares' doc comment: sell the credit on midnight (creating debt), // then repay + withdrawShares in the sell callback. The repay itself creates the withdrawable liquidity that // the redemption needs, and midnight's health check passes because the callback cleared the debt.