diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 71106fc4b..2debf8765 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -4,10 +4,8 @@ pragma solidity 0.8.34; import {IMidnight, Offer, Market} from "lib/midnight/src/interfaces/IMidnight.sol"; import {IdLib} from "lib/midnight/src/libraries/IdLib.sol"; -import {MAX_TICK} from "lib/midnight/src/libraries/TickLib.sol"; import {Signature, EIP712_DOMAIN_TYPEHASH} from "lib/midnight/src/ratifiers/interfaces/IEcrecoverRatifier.sol"; import {CALLBACK_SUCCESS} from "lib/midnight/src/libraries/ConstantsLib.sol"; -import {TakeAmountsLib} from "lib/midnight/src/periphery/libraries/TakeAmountsLib.sol"; import {HashLib} from "lib/midnight/src/ratifiers/libraries/HashLib.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import {SafeERC20Lib} from "../libraries/SafeERC20Lib.sol"; @@ -21,6 +19,8 @@ import {DurationsLib} from "./libraries/DurationsLib.sol"; /// to the relative sizes of the loss and the adapter's position in the market hit by the loss. /// @dev The adapter must have the allocator role in its parent vault to buy, and the allocator or sentinel role to /// make sell offers, to withdraw to the vault and to update duration caps. +/// @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, so withdrawShares never interacts with the parent vault. contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; @@ -55,6 +55,10 @@ contract MidnightAdapter is IMidnightAdapter { uint8 public availableMaturities = MAX_PENDING_MATURITIES; mapping(uint256 timestamp => MaturityData) public _maturities; mapping(bytes32 marketId => MarketData) public _markets; + mapping(bytes32 marketId => mapping(address user => uint256)) public shares; + /// @dev Vault net credit decreases realized without interacting with the vault, folded into the change reported + /// by the next vault interaction on the same market. + mapping(bytes32 marketId => uint128) public unreportedVaultDecrease; /* CONSTRUCTOR */ @@ -120,17 +124,47 @@ contract MidnightAdapter is IMidnightAdapter { NotAuthorized() ); + MarketData storage marketData = _markets[marketId]; accrueInterest(); + uint256 oldVaultNetCredit = marketData.vaultNetCredit; + uint256 oldAdapterNetCredit = currentNetCredit(marketId); IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), address(this)); - // current net credit cannot be > accounted net credit - uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); + uint256 withdrawNetCreditDecrease = oldAdapterNetCredit - currentNetCredit(marketId); - decreaseNetCredit(marketId, market.maturity, netCreditDecrease); + realizeLoss(marketData, marketId, market.maturity, -int256(withdrawNetCreditDecrease)); + removeNetCredit(marketId, market.maturity, withdrawNetCreditDecrease); + 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), -netCreditDecrease.toInt256()), withdrawnAssets); - emit WithdrawToVault(marketId, withdrawnAssets, netCreditDecrease); + .deallocate(address(this), abi.encode(ids(market), -reportedDecrease.toInt256()), withdrawnAssets); + emit WithdrawToVault(marketId, withdrawnAssets, reportedDecrease); + } + + /// @dev Does not interact with the parent vault, so that claims stay redeemable even if the adapter has been + /// 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); + MarketData storage marketData = _markets[marketId]; + + accrueInterest(); + IMidnight(midnight).updatePosition(market, address(this)); + uint256 oldVaultNetCredit = marketData.vaultNetCredit; + realizeLoss(marketData, marketId, market.maturity, 0); + unreportedVaultDecrease[marketId] += (oldVaultNetCredit - marketData.vaultNetCredit).toUint128(); + + uint256 withdrawnAssets = redeemedShares.mulDivDown(marketData.userNetCredit + 1, marketData.userShares + 1); + + uint256 oldAdapterNetCredit = currentNetCredit(marketId); + IMidnight(midnight).withdraw(market, withdrawnAssets, address(this), msg.sender); + uint256 withdrawNetCreditDecrease = oldAdapterNetCredit - currentNetCredit(marketId); + marketData.userNetCredit -= withdrawNetCreditDecrease.toUint128(); + marketData.userShares -= redeemedShares.toUint128(); + shares[marketId][msg.sender] -= redeemedShares; + emit WithdrawShares(marketId, msg.sender, redeemedShares, withdrawnAssets); } function take(Offer memory offer, bytes memory ratifierData, uint256 units) external { @@ -148,14 +182,14 @@ contract MidnightAdapter is IMidnightAdapter { uint256 oldDurationCount = maturityData.durationCount; uint256 newDurationCount = durationCount(maturity); maturityData.durationCount = uint8(newDurationCount); - emit UpdateDurationCaps(maturity, newDurationCount, maturityData.netCredit); + emit UpdateDurationCaps(maturity, newDurationCount, maturityData.reportedVaultNetCredit); // VaultV2.deallocate requires allocation > 0 for each returned id. - if (newDurationCount < oldDurationCount && maturityData.netCredit > 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.netCredit))); + bytes memory data = abi.encode(zeroedDurationsIds, -int256(uint256(maturityData.reportedVaultNetCredit))); IVaultV2(parentVault).deallocate(address(this), data, 0); } } @@ -200,6 +234,7 @@ contract MidnightAdapter is IMidnightAdapter { } /// @dev Returns an estimate of the real assets assigned to the adapter. + /// @dev Excludes assets reserved for users. function realAssets() external view returns (uint256) { (,, uint256 newTotalAssets,) = accrueInterestView(); return newTotalAssets; @@ -221,31 +256,35 @@ contract MidnightAdapter is IMidnightAdapter { } /// @dev Can be called by this adapter from a sell callback, a withdraw, or a duration caps update. - /// @dev Can be called by anyone through forceDeallocate to trigger a sell take by the adapter. - function deallocate(bytes memory data, uint256 sellerAssets, bytes4 messageSig, address caller) + /// @dev Can be called by anyone 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) = abi.decode(data, (Offer, bytes)); - require( - offer.buy && offer.market.loanToken == asset && offer.tick == MAX_TICK && offer.callback == address(0), - IncorrectOffer() - ); + Market memory market = abi.decode(data, (Market)); + bytes32 marketId = IdLib.toId(market); + MarketData storage marketData = _markets[marketId]; + uint256 oldVaultNetCredit = marketData.vaultNetCredit; accrueInterest(); - - // Skip onSell since we are already in a deallocate call. - bytes32 marketId = IdLib.toId(offer.market); - uint256 takeUnits = TakeAmountsLib.sellerAssetsToUnits(midnight, marketId, offer, sellerAssets); - IMidnight(midnight).take(offer, ratifierData, takeUnits, address(this), address(this), address(0), hex""); - // current net credit cannot be > accounted net credit - uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); - decreaseNetCredit(marketId, offer.market.maturity, netCreditDecrease); - - emit ForceDeallocate(marketId, sellerAssets, netCreditDecrease); - return (ids(offer.market), -netCreditDecrease.toInt256()); + IMidnight(midnight).updatePosition(market, address(this)); + realizeLoss(marketData, marketId, market.maturity, 0); + + uint256 mintedShares = + deallocatedAmount.mulDivDown(uint256(marketData.userShares) + 1, uint256(marketData.userNetCredit) + 1); + shares[marketId][caller] += mintedShares; + marketData.userShares += mintedShares.toUint128(); + marketData.userNetCredit += deallocatedAmount.toUint128(); + removeNetCredit(marketId, market.maturity, deallocatedAmount); + + 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()); } else { require(caller == address(this), SelfAllocationOnly()); // Return exactly the data passed to the function. @@ -302,22 +341,17 @@ contract MidnightAdapter is IMidnightAdapter { require(buyer == address(this), NotSelf()); uint256 boughtNetCredit = boughtCredit - buyPendingFeeIncrease; require(boughtNetCredit >= paidAssets, BuyAtLoss()); + accrueInterest(); MaturityData storage maturityData = _maturities[market.maturity]; MarketData storage marketData = _markets[marketId]; - if (maturityData.netCredit == 0) maturityData.durationCount = uint8(durationCount(market.maturity)); + if (maturityData.reportedVaultNetCredit == 0) { + maturityData.durationCount = uint8(durationCount(market.maturity)); + } uint256 timeToMaturity = market.maturity.zeroFloorSub(block.timestamp); - // current net credit cannot be > accounted net credit + bought net credit - uint256 netCreditLoss = uint256(marketData.netCredit) + boughtNetCredit - currentNetCredit(marketId); - decreaseNetCredit(marketId, market.maturity, netCreditLoss); - - IVaultV2(parentVault) - .allocate( - address(this), - abi.encode(ids(market), boughtNetCredit.toInt256() - netCreditLoss.toInt256()), - paidAssets - ); + uint256 oldVaultNetCredit = marketData.vaultNetCredit; + realizeLoss(marketData, marketId, market.maturity, int256(boughtNetCredit)); if (timeToMaturity > 0) { uint256 interest = boughtNetCredit - paidAssets; @@ -330,11 +364,19 @@ contract MidnightAdapter is IMidnightAdapter { totalAssets += boughtNetCredit.toUint128(); } - maturityData.netCredit += boughtNetCredit.toUint128(); - marketData.netCredit += boughtNetCredit.toUint128(); + maturityData.vaultNetCredit += boughtNetCredit.toUint128(); + marketData.vaultNetCredit += boughtNetCredit.toUint128(); + + 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 - if (maturityData.netCredit == boughtNetCredit && boughtNetCredit > 0 && market.maturity > block.timestamp) { + if (maturityData.vaultNetCredit == boughtNetCredit && boughtNetCredit > 0 && market.maturity > block.timestamp) + { availableMaturities--; uint48 prevMaturity = 0; uint48 nextMaturity = _maturities[0].nextMaturity; @@ -349,7 +391,7 @@ contract MidnightAdapter is IMidnightAdapter { emit InsertMaturity(market.maturity); } - emit Buy(marketId, paidAssets, boughtNetCredit, netCreditLoss); + emit Buy(marketId, paidAssets, boughtNetCredit, netCreditChange); return CALLBACK_SUCCESS; } @@ -357,8 +399,8 @@ contract MidnightAdapter is IMidnightAdapter { bytes32 marketId, Market memory market, uint256 sellerAssets, - uint256, - uint256, + uint256 units, + uint256 sellPendingFeeDecrease, address seller, address, bytes memory @@ -368,13 +410,19 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); + MarketData storage marketData = _markets[marketId]; uint256 vaultTotalAssetsBefore = IVaultV2(parentVault).totalAssets(); - // current net credit cannot be > accounted net credit - uint256 netCreditDecrease = uint256(_markets[marketId].netCredit) - currentNetCredit(marketId); - decreaseNetCredit(marketId, market.maturity, netCreditDecrease); + uint256 oldVaultNetCredit = marketData.vaultNetCredit; + uint256 sellNetCreditDecrease = units - sellPendingFeeDecrease; + realizeLoss(marketData, marketId, market.maturity, -int256(sellNetCreditDecrease)); + removeNetCredit(marketId, market.maturity, sellNetCreditDecrease); + + 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), -netCreditDecrease.toInt256()), sellerAssets); + .deallocate(address(this), abi.encode(ids(market), -reportedDecrease.toInt256()), sellerAssets); uint256 vaultRealAssetsAfter = IERC20(asset).balanceOf(address(parentVault)); uint256 adaptersLength = IVaultV2(parentVault).adaptersLength(); @@ -383,7 +431,7 @@ contract MidnightAdapter is IMidnightAdapter { } require(vaultRealAssetsAfter >= vaultTotalAssetsBefore, BufferTooLow()); - emit Sell(marketId, sellerAssets, netCreditDecrease); + emit Sell(marketId, sellerAssets, reportedDecrease); return CALLBACK_SUCCESS; } @@ -395,27 +443,50 @@ contract MidnightAdapter is IMidnightAdapter { - IMidnight(midnight).pendingFee(marketId, address(this)); } - /// @dev Decreases netCredit proportionally from current accounted assets and future growth. - function decreaseNetCredit(bytes32 marketId, uint256 maturity, uint256 netCreditDecrease) internal { - if (netCreditDecrease == 0) return; + /// @dev Realizes any loss between the expected and actual net credit. + /// @dev Splits the loss between users and vault, and updates vault accounting. + /// @dev The vault-side decrease is not reported here; callers report it or accumulate it in + /// unreportedVaultDecrease. + function realizeLoss( + MarketData storage marketData, + bytes32 marketId, + uint256 maturity, + int256 expectedAdapterNetCreditDelta + ) internal { + uint256 currentAdapterNetCredit = currentNetCredit(marketId); + uint256 oldAdapterNetCredit = marketData.vaultNetCredit + marketData.userNetCredit; + uint256 expectedAdapterNetCredit = (int256(oldAdapterNetCredit) + expectedAdapterNetCreditDelta).toUint256(); + if (expectedAdapterNetCredit > currentAdapterNetCredit) { + uint256 loss = expectedAdapterNetCredit - currentAdapterNetCredit; + uint256 userLoss = + oldAdapterNetCredit == 0 ? 0 : uint256(marketData.userNetCredit).mulDivUp(loss, oldAdapterNetCredit); + uint256 vaultLoss = loss - userLoss; + marketData.userNetCredit -= uint128(userLoss); + if (vaultLoss > 0) removeNetCredit(marketId, maturity, vaultLoss); + } + } + + /// @dev Removes netCredit proportionally from current accounted assets and future growth. + function removeNetCredit(bytes32 marketId, uint256 maturity, uint256 removedNetCredit) internal { + if (removedNetCredit == 0) return; MaturityData storage maturityData = _maturities[maturity]; MarketData storage marketData = _markets[marketId]; if (maturity > block.timestamp) { uint256 timeToMaturity = maturity - block.timestamp; - uint128 growthDecrease = marketData.growth.mulDivUp(netCreditDecrease, marketData.netCredit).toUint128(); + uint128 growthDecrease = marketData.growth.mulDivUp(removedNetCredit, marketData.vaultNetCredit).toUint128(); marketData.growth -= growthDecrease; maturityData.growth -= growthDecrease; currentGrowth -= growthDecrease; - totalAssets = (totalAssets + (growthDecrease * timeToMaturity) - netCreditDecrease).toUint128(); + totalAssets = (totalAssets + (growthDecrease * timeToMaturity) - removedNetCredit).toUint128(); } else { - totalAssets -= netCreditDecrease.toUint128(); + totalAssets -= removedNetCredit.toUint128(); } - maturityData.netCredit -= netCreditDecrease.toUint128(); - marketData.netCredit -= netCreditDecrease.toUint128(); + maturityData.vaultNetCredit -= removedNetCredit.toUint128(); + marketData.vaultNetCredit -= removedNetCredit.toUint128(); - if (maturityData.netCredit == 0 && maturity > block.timestamp) { + if (maturityData.vaultNetCredit == 0 && maturity > block.timestamp) { availableMaturities++; _maturities[maturityData.prevMaturity].nextMaturity = maturityData.nextMaturity; _maturities[maturityData.nextMaturity].prevMaturity = maturityData.prevMaturity; diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index c99b0bb96..e39c497c9 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -9,16 +9,21 @@ 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 netCredit; + uint128 vaultNetCredit; uint128 growth; uint48 prevMaturity; uint48 nextMaturity; uint8 durationCount; + uint128 reportedVaultNetCredit; } struct MarketData { - uint128 netCredit; + uint128 vaultNetCredit; + uint128 userNetCredit; + uint128 userShares; uint128 growth; } @@ -28,9 +33,12 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { event SetSkimRecipient(address indexed newSkimRecipient); event Skim(address indexed token, uint256 assets); event WithdrawToVault(bytes32 indexed marketId, uint256 withdrawnAssets, uint256 netCreditDecrease); - event UpdateDurationCaps(uint256 indexed maturity, uint256 newDurationCount, uint256 netCredit); - event ForceDeallocate(bytes32 indexed marketId, uint256 sellerAssets, uint256 netCreditDecrease); - event Buy(bytes32 indexed marketId, uint256 paidAssets, uint256 netCreditIncrease, uint256 netCreditLoss); + event WithdrawShares( + bytes32 indexed marketId, address indexed user, uint256 redeemedShares, uint256 withdrawnAssets + ); + 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); event AccrueInterest(uint128 currentGrowth, uint256 totalAssets); event RemoveMaturity(uint256 indexed maturity); @@ -66,7 +74,12 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); - function _markets(bytes32 marketId) external view returns (uint128 netCredit, uint128 growth); + function _markets(bytes32 marketId) + external + view + returns (uint128 vaultNetCredit, uint128 userNetCredit, uint128 userShares, uint128 growth); + function shares(bytes32 marketId, address user) external view returns (uint256); + function unreportedVaultDecrease(bytes32 marketId) external view returns (uint128); function maturities(uint256 date) external view returns (MaturityData memory); function skimRecipient() external view returns (address); function isRootCanceled(bytes32 root) external view returns (bool); @@ -77,6 +90,7 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function durationsLength() external view returns (uint256); function updateDurationCaps(uint256 maturity) external; function withdrawToVault(Market memory market, uint256 withdrawnAssets) external; + function withdrawShares(Market memory market, uint256 redeemedShares) external; function take(Offer memory offer, bytes memory ratifierData, uint256 units) external; function ids(Market memory market) external view returns (bytes32[] memory); function parentVault() external view returns (address); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 71cd01ede..412ee726f 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -30,7 +30,6 @@ import { CBP } from "../lib/midnight/src/libraries/ConstantsLib.sol"; import {TakeAmountsLib} from "../lib/midnight/src/periphery/libraries/TakeAmountsLib.sol"; -import {SetterRatifier} from "../lib/midnight/src/ratifiers/SetterRatifier.sol"; contract ExtraAssetsAdapter is IAdapter { uint256 public realAssets; @@ -50,30 +49,8 @@ contract ExtraAssetsAdapter is IAdapter { /// @notice Realizes the losses of a midnight adapter in a market. contract MidnightLossRealizer { - address public immutable midnight; - - constructor(address _midnight) { - midnight = _midnight; - IMidnight(_midnight).setIsAuthorized(address(this), true, address(this)); - } - function realizeLoss(IMidnightAdapter adapter, Market memory market) external { - Offer memory offer; - offer.market = market; - offer.buy = true; - offer.maker = address(this); - offer.expiry = block.timestamp; - offer.tick = MAX_TICK; - offer.ratifier = address(this); - offer.maxUnits = 1; - offer.continuousFeeCap = type(uint256).max; - - IVaultV2(adapter.parentVault()) - .forceDeallocate(address(adapter), abi.encode(offer, bytes("")), 0, address(this)); - } - - function isRatified(Offer memory, bytes memory, address) external view returns (bytes32) { - return CALLBACK_SUCCESS; + IVaultV2(adapter.parentVault()).forceDeallocate(address(adapter), abi.encode(market), 0, address(this)); } } @@ -628,7 +605,7 @@ contract MidnightAdapterTest is Test { emit IMidnightAdapter.InsertMaturity(offer.market.maturity); take(offer); - (uint128 netCredit,) = adapter._markets(marketId); + (uint128 netCredit,,,) = adapter._markets(marketId); assertEq(netCredit, 1e18, "netCredit"); assertEq(adapter.totalAssets(), 3e18, "totalAssets"); assertEq(adapter.availableMaturities(), 47, "availableMaturities"); @@ -735,7 +712,7 @@ contract MidnightAdapterTest is Test { uint128 growth = uint128((units - assets) / duration); uint128 removedGrowth = uint128(uint256(growth).mulDivUp(loss, units)); assertEq(adapter.maturities(offer.market.maturity).growth, 2 * growth - removedGrowth); - (uint128 marketNetCredit,) = adapter._markets(marketId); + (uint128 marketNetCredit,,,) = adapter._markets(marketId); assertEq(marketNetCredit, 2 * units - loss); } @@ -758,7 +735,7 @@ contract MidnightAdapterTest is Test { sellUnits(offer.market, 1e18, MAX_TICK - 4); - (uint128 marketNetCredit,) = adapter._markets(_marketId(offer.market)); + (uint128 marketNetCredit,,,) = adapter._markets(_marketId(offer.market)); assertEq(marketNetCredit, 0); assertEq(adapter.totalAssets(), 0); } @@ -788,7 +765,7 @@ contract MidnightAdapterTest is Test { vm.prank(signerAllocator); adapter.take(buyOffer, "", 1e18); - (uint128 marketNetCredit,) = adapter._markets(_marketId(offer.market)); + (uint128 marketNetCredit,,,) = adapter._markets(_marketId(offer.market)); assertEq(marketNetCredit, 0); assertEq(adapter.totalAssets(), 0); } @@ -842,11 +819,11 @@ contract MidnightAdapterTest is Test { midnight.supplyCollateral(offerB.market, 1, assetsB / 2, taker); take(offerB); - (uint128 netCreditA,) = adapter._markets(_marketId(offerA.market)); - (uint128 netCreditB,) = adapter._markets(_marketId(offerB.market)); - assertEq(netCreditA, assetsA, "netCredit A"); - assertEq(netCreditB, assetsB, "netCredit B"); - assertEq(adapter.maturities(block.timestamp).netCredit, assetsA + assetsB, "shared netCredit"); + (uint128 marketNetCreditA,,,) = adapter._markets(_marketId(offerA.market)); + (uint128 marketNetCreditB,,,) = adapter._markets(_marketId(offerB.market)); + assertEq(marketNetCreditA, assetsA, "netCredit A"); + assertEq(marketNetCreditB, assetsB, "netCredit B"); + assertEq(adapter.maturities(block.timestamp).vaultNetCredit, assetsA + assetsB, "shared netCredit"); assertEq(adapter.totalAssets(), assetsA + assetsB, "totalAssets"); } @@ -951,7 +928,7 @@ contract MidnightAdapterTest is Test { uint256 loss = offer.maxUnits / 2; setMidnightCredit(marketId, address(adapter), offer.maxUnits - loss); - new MidnightLossRealizer(address(midnight)).realizeLoss(adapter, offer.market); + new MidnightLossRealizer().realizeLoss(adapter, offer.market); assertApproxEqAbs(adapter.realAssets(), valueBefore / 2, duration, "half the value is lost"); skip(duration / 2); @@ -968,15 +945,15 @@ contract MidnightAdapterTest is Test { uint256 pendingFee = midnight.pendingFee(marketId, address(adapter)); assertGt(pendingFee, 0, "pendingFee"); - (uint128 netCredit,) = adapter._markets(marketId); + (uint128 netCredit,,,) = adapter._markets(marketId); assertEq(netCredit, offer.maxUnits - pendingFee, "net credit excludes the pending fee"); // The fee accrues out of the credit and of the pending fee alike, so the net credit does not move. skip(duration / 2); uint256 valueBefore = adapter.realAssets(); - new MidnightLossRealizer(address(midnight)).realizeLoss(adapter, offer.market); + new MidnightLossRealizer().realizeLoss(adapter, offer.market); assertLt(midnight.pendingFee(marketId, address(adapter)), pendingFee, "fee accrued"); - (uint128 netCreditAfter,) = adapter._markets(marketId); + (uint128 netCreditAfter,,,) = adapter._markets(marketId); assertEq(netCreditAfter, netCredit, "net credit unchanged"); assertEq(adapter.realAssets(), valueBefore, "no loss booked"); @@ -994,22 +971,6 @@ contract MidnightAdapterTest is Test { take(offer); } - function testForceDeallocateWithSettlementFee() public { - for (uint256 i = 0; i <= 6; i++) { - midnight.setDefaultSettlementFee(address(loanToken), i, 10 * CBP); - } - Offer memory offer = buy(7 days, 1e18); - skip(1); - uint256 vaultBalanceBefore = loanToken.balanceOf(address(parentVault)); - - forceDeallocate(offer.market, 0.5e18); - - assertEq(loanToken.balanceOf(address(parentVault)), vaultBalanceBefore + 0.5e18, "vault balance"); - // The fee is paid by the seller, so more than 0.5e18 of net credit is sold. - (uint128 netCredit,) = adapter._markets(_marketId(offer.market)); - assertLt(netCredit, 0.5e18, "netCredit"); - } - /* CALLBACKS */ function testOnBuyNotMidnight(address caller) public { @@ -1065,80 +1026,30 @@ contract MidnightAdapterTest is Test { adapter.take(offer, "", 0); } - /// @dev Selling more than its credit would put the adapter in debt, which it has no collateral for. + /// @dev Selling more than its credit would put the adapter in debt, which it has no collateral for. The adapter + /// reverts first, when it tries to account a net credit decrease larger than its position. function testTakeMoreThanPositionReverts() public { Offer memory offer = buy(7 days, 1e18); Offer memory buyOffer = makeExternalOffer(offer.market, true, 2e18, MAX_TICK); - vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); + vm.expectRevert(ErrorsLib.CastOverflow.selector); vm.prank(signerAllocator); adapter.take(buyOffer, "", 2e18); } /* FORCE DEALLOCATE */ - function testForceDeallocateMoreThanPositionReverts() public { - Offer memory boughtOffer = buy(7 days, 1e18); - (Offer memory offer, bytes32 root_) = makeForceDeallocateOffer(boughtOffer.market, 2e18); - - vm.expectRevert(IMidnight.SellerIsLiquidatable.selector); - parentVault.forceDeallocate( - address(adapter), abi.encode(offer, abi.encode(root_, 0, proof([offer]))), 2e18, address(this) - ); - } - function testForceDeallocateOK() public { Offer memory boughtOffer = buy(7 days, 1e18); bytes32 marketId = _marketId(boughtOffer.market); forceDeallocate(boughtOffer.market, 0.5e18); - (uint128 marketNetCredit,) = adapter._markets(marketId); + (uint128 marketNetCredit, uint128 userNetCredit, uint128 userShares,) = adapter._markets(marketId); assertEq(marketNetCredit, 0.5e18); - } - - function testForceDeallocateRevertsOnSellOffer() public { - Offer memory boughtOffer = buy(7 days, 1e18); - (Offer memory offer,) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); - offer.buy = false; - - vm.expectRevert(IMidnightAdapter.IncorrectOffer.selector); - parentVault.forceDeallocate( - address(adapter), abi.encode(offer, abi.encode(bytes32(0), 0, proof([offer]))), 0.5e18, address(this) - ); - } - - function testForceDeallocateRevertsOnWrongLoanToken() public { - Offer memory boughtOffer = buy(7 days, 1e18); - (Offer memory offer,) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); - offer.market.loanToken = address(new ERC20Mock(18)); - - vm.expectRevert(IMidnightAdapter.IncorrectOffer.selector); - parentVault.forceDeallocate( - address(adapter), abi.encode(offer, abi.encode(bytes32(0), 0, proof([offer]))), 0.5e18, address(this) - ); - } - - function testForceDeallocateRevertsOnNonMaxTick() public { - Offer memory boughtOffer = buy(7 days, 1e18); - (Offer memory offer,) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); - offer.tick = MAX_TICK - 1; - - vm.expectRevert(IMidnightAdapter.IncorrectOffer.selector); - parentVault.forceDeallocate( - address(adapter), abi.encode(offer, abi.encode(bytes32(0), 0, proof([offer]))), 0.5e18, address(this) - ); - } - - function testForceDeallocateRevertsOnCallback() public { - Offer memory boughtOffer = buy(7 days, 1e18); - (Offer memory offer,) = makeForceDeallocateOffer(boughtOffer.market, 0.5e18); - offer.callback = address(this); - - vm.expectRevert(IMidnightAdapter.IncorrectOffer.selector); - parentVault.forceDeallocate( - address(adapter), abi.encode(offer, abi.encode(bytes32(0), 0, proof([offer]))), 0.5e18, address(this) - ); + assertEq(userNetCredit, 0.5e18); + assertEq(userShares, 0.5e18); + assertEq(adapter.shares(marketId, address(this)), 0.5e18); } function testForceDeallocateWithoutRole() public { @@ -1152,7 +1063,7 @@ contract MidnightAdapterTest is Test { assertEq(parentVault.allocation(durationId(1 days)), 0.5e18, "1 day"); assertEq(parentVault.allocation(durationId(7 days)), 0.5e18, "7 days, stale"); - (uint128 marketNetCredit,) = adapter._markets(_marketId(boughtOffer.market)); + (uint128 marketNetCredit,,,) = adapter._markets(_marketId(boughtOffer.market)); assertEq(marketNetCredit, 0.5e18, "netCredit"); vm.expectRevert(bytes("no role")); @@ -1176,7 +1087,7 @@ contract MidnightAdapterTest is Test { assertEq(penaltyShares, expectedPenaltyShares, "penalty shares"); assertEq(realVault.balanceOf(address(this)), sharesBefore - penaltyShares, "penalty charged to onBehalf"); assertGt(realVault.balanceOf(recipient), 0, "fee shares minted"); - (uint128 marketNetCredit,) = adapter._markets(_marketId(offer.market)); + (uint128 marketNetCredit,,,) = adapter._markets(_marketId(offer.market)); assertEq(marketNetCredit, 0.5e18, "netCredit"); assertEq(realVault.allocation(durationId(7 days)), 0.5e18, "7 days stale"); assertEq(realVault.allocation(durationId(1 days)), 0.5e18, "1 day"); @@ -1276,7 +1187,7 @@ contract MidnightAdapterTest is Test { vm.prank(signerAllocator); adapter.take(buyOffer, "", uint256(buyOffer.maxUnits)); - (uint128 marketNetCredit,) = adapter._markets(marketId); + (uint128 marketNetCredit,,,) = adapter._markets(marketId); assertEq(marketNetCredit, 0.5e18, "netCredit after sell"); assertEq(realVault.allocation(adapter.adapterId()), 0.5e18, "allocation after sell"); assertEq(loanToken.balanceOf(address(realVault)), 9.5e18, "proceeds back in the vault"); @@ -1298,10 +1209,10 @@ contract MidnightAdapterTest is Test { // The slash is only pending: the position's raw credit is untouched. assertEq(midnight.credit(marketId, address(adapter)), 1e18, "raw credit"); - MidnightLossRealizer realizer = new MidnightLossRealizer(address(midnight)); + MidnightLossRealizer realizer = new MidnightLossRealizer(); realizer.realizeLoss(IMidnightAdapter(address(adapter)), boughtOffer.market); - (uint128 marketNetCredit,) = adapter._markets(marketId); + (uint128 marketNetCredit,,,) = adapter._markets(marketId); assertApproxEqAbs(marketNetCredit, 0.7e18, 1, "netCredit"); assertApproxEqAbs(parentVault.allocation(adapter.adapterId()), 0.7e18, 1, "allocation"); } @@ -1464,7 +1375,7 @@ contract MidnightAdapterTest is Test { function testWithdrawToVaultOK() public { Offer memory boughtOffer = buy(7 days, 1e18); bytes32 marketId = _marketId(boughtOffer.market); - (uint128 creditBefore,) = adapter._markets(marketId); + (uint128 creditBefore,,,) = adapter._markets(marketId); uint256 vaultBalanceBefore = loanToken.balanceOf(address(parentVault)); skip(7 days); @@ -1479,7 +1390,7 @@ contract MidnightAdapterTest is Test { vm.prank(signerAllocator); adapter.withdrawToVault(boughtOffer.market, withdrawAmount); - (uint128 creditAfter,) = adapter._markets(marketId); + (uint128 creditAfter,,,) = adapter._markets(marketId); assertEq(creditAfter, creditBefore - withdrawAmount, "netCredit"); assertEq(adapter.totalAssets(), creditBefore - withdrawAmount, "totalAssets"); assertEq(loanToken.balanceOf(address(parentVault)), vaultBalanceBefore + withdrawAmount, "vault balance"); @@ -1502,12 +1413,180 @@ contract MidnightAdapterTest is Test { adapter.withdrawToVault(boughtOffer.market, 0.5e18); // 0.5e18 withdrawn, 0.3e18 lost. - (uint128 netCredit,) = adapter._markets(marketId); + (uint128 netCredit,,,) = adapter._markets(marketId); assertApproxEqAbs(netCredit, 0.2e18, 1, "netCredit"); assertApproxEqAbs(adapter.totalAssets(), 0.2e18, 1, "totalAssets"); assertApproxEqAbs(parentVault.allocation(adapter.adapterId()), 0.2e18, 1, "allocation"); } + /* WITHDRAW SHARES */ + + // withdrawShares never touches the parent vault, so a force deallocator can still redeem after the vault + // removes the adapter from its adapter set. + function testWithdrawSharesAfterVaultRemovesAdapter() public { + Offer memory boughtOffer = buy(7 days, 1e18); + bytes32 marketId = _marketId(boughtOffer.market); + forceDeallocate(boughtOffer.market, 0.5e18); + uint256 userShares = adapter.shares(marketId, address(this)); + + skip(7 days); + deal(address(loanToken), address(this), 1e18); + loanToken.approve(address(midnight), type(uint256).max); + midnight.repay(boughtOffer.market, 1e18, taker, address(0), ""); + + parentVault.setAdapters(new address[](0)); + parentVault.setAdaptersLength(0); + + uint256 balanceBefore = loanToken.balanceOf(address(this)); + vm.expectEmit(true, true, false, false, address(adapter)); + emit IMidnightAdapter.WithdrawShares(marketId, address(this), userShares, 0); + adapter.withdrawShares(boughtOffer.market, userShares); + + assertEq(adapter.shares(marketId, address(this)), 0, "shares burned"); + assertEq(loanToken.balanceOf(address(this)) - balanceBefore, 0.5e18, "assets redeemed"); + } + + // A vault-tranche loss realized inside withdrawShares is not reported to the vault immediately: it + // accumulates in unreportedVaultDecrease and is folded into the next vault interaction's report. + function testWithdrawSharesFoldsUnreportedDecrease() public { + Offer memory boughtOffer = buy(7 days, 1e18); + bytes32 marketId = _marketId(boughtOffer.market); + forceDeallocate(boughtOffer.market, 0.5e18); + + skip(7 days); + deal(address(loanToken), address(this), 1e18); + loanToken.approve(address(midnight), type(uint256).max); + midnight.repay(boughtOffer.market, 1e18, taker, address(0), ""); + + // Realize a 0.4e18 loss, split evenly between the equal vault and user tranches. + setMidnightCredit(marketId, address(adapter), 0.6e18); + + uint256 allocationBefore = parentVault.allocation(adapter.adapterId()); + adapter.withdrawShares(boughtOffer.market, adapter.shares(marketId, address(this))); + + assertEq(adapter.unreportedVaultDecrease(marketId), 0.2e18, "decrease held back"); + assertEq(parentVault.allocation(adapter.adapterId()), allocationBefore, "vault not touched yet"); + + vm.prank(signerAllocator); + adapter.withdrawToVault(boughtOffer.market, 0); + + assertEq(adapter.unreportedVaultDecrease(marketId), 0, "unreported cleared"); + 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.maturity); + 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. + // 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 { @@ -1615,35 +1694,6 @@ contract MidnightAdapterTest is Test { midnight.take(offer, sign([offer], signerAllocator), offer.maxUnits, taker, address(0), address(0), ""); } - function makeForceDeallocateOffer(Market memory market, uint256 assets) - internal - returns (Offer memory offer, bytes32 root_) - { - address buyer = makeAddr("buyer"); - SetterRatifier approvalRatifier = new SetterRatifier(address(midnight)); - - offer = storedOffer; - offer.market = market; - offer.buy = true; - offer.maker = buyer; - offer.tick = MAX_TICK; - offer.maxUnits = - uint128(TakeAmountsLib.sellerAssetsToUnits(address(midnight), _marketId(market), offer, assets)); - offer.expiry = block.timestamp; - offer.callback = address(0); - offer.callbackData = hex""; - offer.ratifier = address(approvalRatifier); - offer.group = bytes32(vm.randomUint()); - - deal(address(loanToken), buyer, offer.maxUnits); - vm.startPrank(buyer); - loanToken.approve(address(midnight), type(uint256).max); - midnight.setIsAuthorized(address(approvalRatifier), true, buyer); - root_ = root([offer]); - approvalRatifier.setIsRootRatified(buyer, root_, true); - vm.stopPrank(); - } - /// @dev Builds an external offer at `tick`, ratified by this contract. Buy offers get a funded maker, sell /// offers get a collateralized one. function makeExternalOffer(Market memory market, bool buy, uint256 assets, uint256 tick) @@ -1676,15 +1726,9 @@ contract MidnightAdapterTest is Test { } } - /// @dev Ratifier for external offers built by makeExternalOffer. - function isRatified(Offer memory, bytes memory, address) external pure returns (bytes32) { - return CALLBACK_SUCCESS; - } - function forceDeallocate(Market memory market, uint256 assets) internal { - (Offer memory offer, bytes32 root_) = makeForceDeallocateOffer(market, assets); - bytes memory data = abi.encode(offer, abi.encode(root_, 0, proof([offer]))); - parentVault.forceDeallocate(address(adapter), data, assets, address(this)); + deal(address(loanToken), address(adapter), assets); + parentVault.forceDeallocate(address(adapter), abi.encode(market), assets, address(this)); } function setUpRealVault() internal { @@ -1737,9 +1781,8 @@ contract MidnightAdapterTest is Test { } function forceDeallocateOnRealVault(Market memory market, uint256 assets) internal returns (uint256) { - (Offer memory offer, bytes32 root_) = makeForceDeallocateOffer(market, assets); - bytes memory data = abi.encode(offer, abi.encode(root_, 0, proof([offer]))); - return realVault.forceDeallocate(address(adapter), data, assets, address(this)); + deal(address(loanToken), address(adapter), assets); + return realVault.forceDeallocate(address(adapter), abi.encode(market), assets, address(this)); } function submitAndCall(IVaultV2 vault, bytes memory call_) internal {