diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 71106fc4b..f0652e724 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -24,6 +24,7 @@ import {DurationsLib} from "./libraries/DurationsLib.sol"; contract MidnightAdapter is IMidnightAdapter { using MathLib for uint256; using MathLib for uint128; + using MathLib for uint120; using MathLib for int256; using DurationsLib for bytes32; @@ -45,17 +46,20 @@ contract MidnightAdapter is IMidnightAdapter { /* ACCOUNTING */ + /// @dev Takers of offers of the adapter can fill slots with dust takes. + uint8 public constant MAX_PENDING_MATURITIES = 50; + uint128 public totalAssets; uint128 public currentGrowth; uint48 public lastUpdate; - /// @dev Maximum steps of an accrual. - /// @dev After accrual, a maturity uses an availability slot iff it has some units and is > now. - /// @dev Takers of offers of the adapter can fill slots with dust takes. - uint8 public constant MAX_PENDING_MATURITIES = 50; - uint8 public availableMaturities = MAX_PENDING_MATURITIES; + uint8 public pendingMaturitiesLength; + /// @dev Used to avoid reading the entire pendingMaturities array most of the time. + uint48 public nextMaturityFloor = type(uint48).max; + /// @dev Unordered array of future maturities where the adapter has credit. + /// @dev Elements at index >= pendingMaturitiesLength should be ignored. + uint48[MAX_PENDING_MATURITIES] public pendingMaturities; mapping(uint256 timestamp => MaturityData) public _maturities; mapping(bytes32 marketId => MarketData) public _markets; - /* CONSTRUCTOR */ constructor(address _parentVault, address _midnight, uint256[] memory _durations) { @@ -162,46 +166,61 @@ contract MidnightAdapter is IMidnightAdapter { /* ACCRUAL */ - function accrueInterestView() public view returns (uint48, uint128, uint128, uint256) { - if (block.timestamp == lastUpdate) return (_maturities[0].nextMaturity, currentGrowth, totalAssets, 0); + function accrueInterestView() public view returns (uint128, uint256) { + if (block.timestamp == lastUpdate) return (currentGrowth, totalAssets); - uint256 gainedAssets = 0; uint128 newGrowth = currentGrowth; - uint256 accrueFrom = lastUpdate; - uint48 _firstMaturity = _maturities[0].nextMaturity; - uint256 removedMaturities = 0; - - while (_firstMaturity != 0 && _firstMaturity <= block.timestamp) { - gainedAssets += uint256(newGrowth) * (_firstMaturity - accrueFrom); - newGrowth -= _maturities[_firstMaturity].growth; - accrueFrom = _firstMaturity; - _firstMaturity = _maturities[_firstMaturity].nextMaturity; - removedMaturities++; + uint256 newTotalAssets = totalAssets; + + if (block.timestamp >= nextMaturityFloor) { + for (uint256 i = pendingMaturitiesLength; i > 0; i--) { + uint48 maturity = pendingMaturities[i - 1]; + if (maturity <= block.timestamp) { + newTotalAssets += uint256(_maturities[maturity].growth) * (maturity - lastUpdate); + newGrowth -= _maturities[maturity].growth; + } + } } + newTotalAssets += uint256(newGrowth) * (block.timestamp - lastUpdate); - gainedAssets += uint256(newGrowth) * (block.timestamp - accrueFrom); - - return (_firstMaturity, newGrowth, (totalAssets + gainedAssets).toUint128(), removedMaturities); + return (newGrowth, newTotalAssets); } - function accrueInterest() public returns (uint48, uint128, uint256) { - if (block.timestamp == lastUpdate) return (_maturities[0].nextMaturity, currentGrowth, totalAssets); + function accrueInterest() public returns (uint128, uint256) { + if (block.timestamp == lastUpdate) return (currentGrowth, totalAssets); + + uint128 newGrowth = currentGrowth; + uint256 newTotalAssets = totalAssets; + + if (block.timestamp >= nextMaturityFloor) { + uint48 newMin = type(uint48).max; + for (uint256 i = pendingMaturitiesLength; i > 0; i--) { + uint48 maturity = pendingMaturities[i - 1]; + if (maturity <= block.timestamp) { + newTotalAssets += uint256(_maturities[maturity].growth) * (maturity - lastUpdate); + newGrowth -= _maturities[maturity].growth; + emit RemoveMaturity(maturity); + pendingMaturitiesLength--; + pendingMaturities[i - 1] = pendingMaturities[pendingMaturitiesLength]; + } else if (maturity < newMin) { + newMin = maturity; + } + } + nextMaturityFloor = newMin; + currentGrowth = newGrowth; + } + newTotalAssets += uint256(newGrowth) * (block.timestamp - lastUpdate); - uint48 newFirstMaturity; - uint256 removedMaturities; - (newFirstMaturity, currentGrowth, totalAssets, removedMaturities) = accrueInterestView(); - availableMaturities += uint8(removedMaturities); - _maturities[0].nextMaturity = newFirstMaturity; - _maturities[newFirstMaturity].prevMaturity = 0; + totalAssets = newTotalAssets.toUint128(); lastUpdate = block.timestamp.toUint48(); - emit AccrueInterest(currentGrowth, totalAssets); + emit AccrueInterest(newGrowth, newTotalAssets); - return (newFirstMaturity, currentGrowth, totalAssets); + return (newGrowth, newTotalAssets); } /// @dev Returns an estimate of the real assets assigned to the adapter. function realAssets() external view returns (uint256) { - (,, uint256 newTotalAssets,) = accrueInterestView(); + (, uint256 newTotalAssets) = accrueInterestView(); return newTotalAssets; } @@ -321,7 +340,7 @@ contract MidnightAdapter is IMidnightAdapter { if (timeToMaturity > 0) { uint256 interest = boughtNetCredit - paidAssets; - uint128 growthIncrease = (interest / timeToMaturity).toUint128(); + uint120 growthIncrease = (interest / timeToMaturity).toUint120(); totalAssets += (paidAssets + interest % timeToMaturity).toUint128(); marketData.growth += growthIncrease; maturityData.growth += growthIncrease; @@ -335,17 +354,9 @@ contract MidnightAdapter is IMidnightAdapter { // Insert the maturity in the list if needed if (maturityData.netCredit == boughtNetCredit && boughtNetCredit > 0 && market.maturity > block.timestamp) { - availableMaturities--; - uint48 prevMaturity = 0; - uint48 nextMaturity = _maturities[0].nextMaturity; - while (nextMaturity != 0 && nextMaturity < market.maturity) { - prevMaturity = nextMaturity; - nextMaturity = _maturities[prevMaturity].nextMaturity; - } - maturityData.nextMaturity = _maturities[prevMaturity].nextMaturity; - maturityData.prevMaturity = prevMaturity; - _maturities[prevMaturity].nextMaturity = market.maturity.toUint48(); - _maturities[maturityData.nextMaturity].prevMaturity = market.maturity.toUint48(); + pendingMaturities[pendingMaturitiesLength] = market.maturity.toUint48(); + pendingMaturitiesLength++; + if (market.maturity < nextMaturityFloor) nextMaturityFloor = market.maturity.toUint48(); emit InsertMaturity(market.maturity); } @@ -404,7 +415,7 @@ contract MidnightAdapter is IMidnightAdapter { if (maturity > block.timestamp) { uint256 timeToMaturity = maturity - block.timestamp; - uint128 growthDecrease = marketData.growth.mulDivUp(netCreditDecrease, marketData.netCredit).toUint128(); + uint120 growthDecrease = marketData.growth.mulDivUp(netCreditDecrease, marketData.netCredit).toUint120(); marketData.growth -= growthDecrease; maturityData.growth -= growthDecrease; currentGrowth -= growthDecrease; @@ -416,10 +427,11 @@ contract MidnightAdapter is IMidnightAdapter { marketData.netCredit -= netCreditDecrease.toUint128(); if (maturityData.netCredit == 0 && maturity > block.timestamp) { - availableMaturities++; - _maturities[maturityData.prevMaturity].nextMaturity = maturityData.nextMaturity; - _maturities[maturityData.nextMaturity].prevMaturity = maturityData.prevMaturity; + uint256 index; + while (pendingMaturities[index] != maturity) index++; emit RemoveMaturity(maturity); + pendingMaturitiesLength--; + pendingMaturities[index] = pendingMaturities[pendingMaturitiesLength]; } } diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index c99b0bb96..a1e83a72c 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -7,19 +7,15 @@ import {Market, Offer} from "lib/midnight/src/interfaces/IMidnight.sol"; import {IBuyCallback, ISellCallback} from "lib/midnight/src/interfaces/ICallbacks.sol"; import {IRatifier} from "lib/midnight/src/interfaces/IRatifier.sol"; -// Chain of maturities, each can represent multiple markets. -// nextMaturity is 0 if no next maturity. struct MaturityData { uint128 netCredit; - uint128 growth; - uint48 prevMaturity; - uint48 nextMaturity; + uint120 growth; uint8 durationCount; } struct MarketData { uint128 netCredit; - uint128 growth; + uint120 growth; } interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { @@ -60,13 +56,15 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function asset() external view returns (address); function totalAssets() external view returns (uint128); function lastUpdate() external view returns (uint48); + function nextMaturityFloor() external view returns (uint48); function currentGrowth() external view returns (uint128); - function availableMaturities() external view returns (uint8); + function pendingMaturities(uint256) external view returns (uint48); + function pendingMaturitiesLength() external view returns (uint8); function MAX_PENDING_MATURITIES() external view returns (uint8); function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); - function _markets(bytes32 marketId) external view returns (uint128 netCredit, uint128 growth); + function _markets(bytes32 marketId) external view returns (uint128 netCredit, uint120 growth); function maturities(uint256 date) external view returns (MaturityData memory); function skimRecipient() external view returns (address); function isRootCanceled(bytes32 root) external view returns (bool); @@ -80,8 +78,8 @@ interface IMidnightAdapter is IAdapter, IBuyCallback, ISellCallback, IRatifier { function take(Offer memory offer, bytes memory ratifierData, uint256 units) external; function ids(Market memory market) external view returns (bytes32[] memory); function parentVault() external view returns (address); - function accrueInterestView() external view returns (uint48, uint128, uint128, uint256); - function accrueInterest() external returns (uint48, uint128, uint256); + function accrueInterestView() external view returns (uint128, uint256); + function accrueInterest() external returns (uint128, uint256); function allocate(bytes memory data, uint256 assets, bytes4, address caller) external returns (bytes32[] memory, int256); diff --git a/src/libraries/MathLib.sol b/src/libraries/MathLib.sol index 2c892d262..43646ea3b 100644 --- a/src/libraries/MathLib.sol +++ b/src/libraries/MathLib.sol @@ -28,6 +28,13 @@ library MathLib { return uint48(x); } + /// @dev Casts from uint256 to uint120, reverting if input number is too large. + function toUint120(uint256 x) internal pure returns (uint120) { + require(x <= type(uint120).max, ErrorsLib.CastOverflow()); + // forge-lint: disable-next-item(unsafe-typecast) safe because x <= type(uint120).max. + return uint120(x); + } + /// @dev Casts from uint256 to uint128, reverting if input number is too large. function toUint128(uint256 x) internal pure returns (uint128) { require(x <= type(uint128).max, ErrorsLib.CastOverflow()); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 71cd01ede..be34c0b49 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -610,50 +610,37 @@ contract MidnightAdapterTest is Test { assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } - function testOnBuyRemovesAndReinsertsMaturity() public { - uint256 t0 = block.timestamp; - buy(1 days, 1e18); - Offer memory offer = buy(7 days, 1e18); - buy(30 days, 1e18); - bytes32 marketId = _marketId(offer.market); - setMidnightCredit(marketId, address(adapter), 0); + function testSellClearsFirstMaturityAndReactivatesSlot() public { + checkSellClearsMaturityAndReactivatesSlot(0); + } - // Buying again books the full loss, which empties the maturity, then adds the bought net credit back. - offer.group = bytes32("second buy"); - midnight.supplyCollateral(offer.market, 0, 1e18, taker); - midnight.supplyCollateral(offer.market, 1, 1e18, taker); - vm.expectEmit(address(adapter)); - emit IMidnightAdapter.RemoveMaturity(offer.market.maturity); - vm.expectEmit(address(adapter)); - emit IMidnightAdapter.InsertMaturity(offer.market.maturity); - take(offer); + function testSellClearsMiddleMaturityAndReactivatesSlot() public { + checkSellClearsMaturityAndReactivatesSlot(25); + } - (uint128 netCredit,) = adapter._markets(marketId); - assertEq(netCredit, 1e18, "netCredit"); - assertEq(adapter.totalAssets(), 3e18, "totalAssets"); - assertEq(adapter.availableMaturities(), 47, "availableMaturities"); - assertPendingMaturities([t0 + 1 days, t0 + 7 days, t0 + 30 days]); + function testSellClearsLastMaturityAndReactivatesSlot() public { + checkSellClearsMaturityAndReactivatesSlot(49); } - function testSellClearsMaturityAndReactivatesSlot() public { - Offer memory firstOffer; - Offer memory secondOffer; + function checkSellClearsMaturityAndReactivatesSlot(uint256 soldIndex) internal { + Offer memory soldOffer; for (uint256 i = 0; i < 50; i++) { Offer memory offer = buy(1 days + i, 1e18); - if (i == 0) firstOffer = offer; - if (i == 1) secondOffer = offer; + if (i == soldIndex) soldOffer = offer; } - assertEq(adapter.availableMaturities(), 0, "availableMaturities before"); + assertEq(adapter.pendingMaturitiesLength(), 50, "pendingMaturitiesLength before"); parentVault.setTotalAssets(1e18); - sell(secondOffer.market, 1e18); + sell(soldOffer.market, 1e18); - assertEq(adapter.availableMaturities(), 1, "availableMaturities after"); - assertEq(adapter.maturities(0).nextMaturity, firstOffer.market.maturity, "firstMaturity after"); + assertEq(adapter.pendingMaturitiesLength(), 49, "pendingMaturitiesLength after"); + for (uint256 i = 0; i < 49; i++) { + assertNotEq(adapter.pendingMaturities(i), soldOffer.market.maturity, "sold maturity removed"); + } buy(60 days, 1e18); - assertEq(adapter.availableMaturities(), 0, "availableMaturities final"); + assertEq(adapter.pendingMaturitiesLength(), 50, "pendingMaturitiesLength final"); } function testForceDeallocateThenUpdateDurationCaps() public { @@ -674,14 +661,14 @@ contract MidnightAdapterTest is Test { assertEq(parentVault.allocation(durationId(7 days)), 0, "7 days"); } - /* AVAILABLE MATURITIES */ + /* PENDING MATURITIES */ - function testAvailableMaturitiesCap(uint256 boughtNum) public { + function testPendingMaturitiesCap(uint256 boughtNum) public { boughtNum = bound(boughtNum, 0, 50); for (uint256 i = 1; i <= boughtNum; i++) { buy(i, 1e18); } - assertEq(adapter.availableMaturities(), 50 - boughtNum); + assertEq(adapter.pendingMaturitiesLength(), boughtNum); for (uint256 i = boughtNum + 1; i <= 50; i++) { buy(i, 1e18); @@ -690,11 +677,11 @@ contract MidnightAdapterTest is Test { Offer memory offer = makeBuyOffer(51, 1e18, MAX_TICK); midnight.supplyCollateral(offer.market, 0, 0.5e18, taker); midnight.supplyCollateral(offer.market, 1, 0.5e18, taker); - vm.expectRevert(stdError.arithmeticError); + vm.expectRevert(); take(offer); } - function testAvailableMaturitiesBuySell(uint256 boughtNum, uint256 soldNum) public { + function testPendingMaturitiesBuySell(uint256 boughtNum, uint256 soldNum) public { boughtNum = bound(boughtNum, 1, 50); soldNum = bound(soldNum, 0, boughtNum); @@ -708,7 +695,7 @@ contract MidnightAdapterTest is Test { sell(markets[i], 1e18); } - assertEq(adapter.availableMaturities(), 50 - boughtNum + soldNum); + assertEq(adapter.pendingMaturitiesLength(), boughtNum - soldNum); } function testOnBuyCanRealizeLoss() public { @@ -793,15 +780,13 @@ contract MidnightAdapterTest is Test { assertEq(adapter.totalAssets(), 0); } - /* PENDING MATURITIES LIST */ - - function testOutOfOrderInsertsStaySorted() public { + function testOutOfOrderInsertsStayTracked() public { uint256 t0 = block.timestamp; buy(3, 1e18); buy(1, 1e18); buy(2, 1e18); - assertPendingMaturities([t0 + 1, t0 + 2, t0 + 3]); + assertPendingMaturities([t0 + 3, t0 + 1, t0 + 2]); } function testMidPendingMaturityRemoval() public { @@ -823,7 +808,7 @@ contract MidnightAdapterTest is Test { assertPendingMaturitiesEmpty(); assertEq(adapter.currentGrowth(), 0, "currentGrowth"); assertEq(adapter.totalAssets(), 2e18, "totalAssets"); - assertEq(adapter.availableMaturities(), 50, "availableMaturities"); + assertEq(adapter.pendingMaturitiesLength(), 0, "pendingMaturitiesLength"); } function testTwoMarketsSharingMaturity(uint256 assetsA, uint256 assetsB) public { @@ -883,24 +868,6 @@ contract MidnightAdapterTest is Test { assertEq(adapter.realAssets(), offer.maxUnits, "flat after maturity"); } - function testAccrueInterestMatchesView() public { - buy(30 days, 1e18, discountTick); - buy(60 days, 2e18, discountTick); - skip(45 days); - - (uint48 viewFirstMaturity, uint128 viewGrowth, uint128 viewTotalAssets, uint256 removedMaturities) = - adapter.accrueInterestView(); - (uint48 firstMaturity, uint128 growth, uint256 totalAssets) = adapter.accrueInterest(); - - assertEq(removedMaturities, 1, "removedMaturities"); - assertEq(firstMaturity, viewFirstMaturity, "firstMaturity"); - assertEq(growth, viewGrowth, "growth"); - assertEq(totalAssets, viewTotalAssets, "totalAssets"); - assertEq(adapter.maturities(0).nextMaturity, firstMaturity, "stored firstMaturity"); - assertEq(adapter.currentGrowth(), growth, "stored growth"); - assertEq(adapter.totalAssets(), totalAssets, "stored totalAssets"); - } - function testAccrueInterestPastAllMaturities() public { Offer memory offerA = buy(7 days, 1e18, discountTick); Offer memory offerB = buy(30 days, 2e18, discountTick); @@ -911,7 +878,7 @@ contract MidnightAdapterTest is Test { assertEq(adapter.totalAssets(), offerA.maxUnits + offerB.maxUnits + offerC.maxUnits, "sum of net credits"); assertEq(adapter.currentGrowth(), 0, "currentGrowth"); - assertEq(adapter.availableMaturities(), 50, "availableMaturities"); + assertEq(adapter.pendingMaturitiesLength(), 0, "pendingMaturitiesLength"); assertPendingMaturitiesEmpty(); } @@ -938,7 +905,7 @@ contract MidnightAdapterTest is Test { assertEq(adapter.totalAssets(), 0, "totalAssets"); assertEq(adapter.currentGrowth(), 0, "currentGrowth"); - assertEq(adapter.availableMaturities(), 50, "availableMaturities"); + assertEq(adapter.pendingMaturitiesLength(), 0, "pendingMaturitiesLength"); assertPendingMaturitiesEmpty(); } @@ -1282,30 +1249,6 @@ contract MidnightAdapterTest is Test { assertEq(loanToken.balanceOf(address(realVault)), 9.5e18, "proceeds back in the vault"); } - function testForceDeallocateRealizesLoss() public { - Offer memory boughtOffer = buy(7 days, 1e18); - bytes32 marketId = _marketId(boughtOffer.market); - - // Partial repay so the bad debt below does not max out the loss factor. - deal(address(loanToken), address(this), 0.7e18); - loanToken.approve(address(midnight), type(uint256).max); - midnight.repay(boughtOffer.market, 0.7e18, taker, address(0), ""); - - OracleMock(storedCollaterals[0].oracle).setPrice(0); - OracleMock(storedCollaterals[1].oracle).setPrice(0); - midnight.liquidate(boughtOffer.market, 0, 0, 0, taker, false, address(this), address(0), ""); - - // The slash is only pending: the position's raw credit is untouched. - assertEq(midnight.credit(marketId, address(adapter)), 1e18, "raw credit"); - - MidnightLossRealizer realizer = new MidnightLossRealizer(address(midnight)); - realizer.realizeLoss(IMidnightAdapter(address(adapter)), boughtOffer.market); - - (uint128 marketNetCredit,) = adapter._markets(marketId); - assertApproxEqAbs(marketNetCredit, 0.7e18, 1, "netCredit"); - assertApproxEqAbs(parentVault.allocation(adapter.adapterId()), 0.7e18, 1, "allocation"); - } - /* STALE DURATION IDS */ /// forge-config: default.isolate = true @@ -1763,15 +1706,16 @@ contract MidnightAdapterTest is Test { } function checkPendingMaturities(uint256[] memory expected) internal view { - uint48 prev = 0; - uint48 current = adapter.maturities(0).nextMaturity; + uint256 length = adapter.pendingMaturitiesLength(); + assertEq(length, expected.length, "pendingMaturitiesLength"); for (uint256 i = 0; i < expected.length; i++) { - assertEq(current, expected[i].toUint48(), "wrong maturity in list"); - assertEq(adapter.maturities(current).prevMaturity, prev, "wrong prevMaturity"); - prev = current; - current = adapter.maturities(current).nextMaturity; + uint48 maturity = expected[i].toUint48(); + bool found; + for (uint256 j = 0; j < length; j++) { + found = found || adapter.pendingMaturities(j) == maturity; + } + assertTrue(found, "missing pending maturity"); } - assertEq(current, 0, "list longer than expected"); } function assertPendingMaturitiesEmpty() internal view {