diff --git a/src/adapters/MidnightAdapter.sol b/src/adapters/MidnightAdapter.sol index 3d1d89349..5d8cb604c 100644 --- a/src/adapters/MidnightAdapter.sol +++ b/src/adapters/MidnightAdapter.sol @@ -14,6 +14,7 @@ import {MathLib} from "../libraries/MathLib.sol"; import {IVaultV2} from "../interfaces/IVaultV2.sol"; import {IMidnightAdapter, MaturityData, IAdapter} from "./interfaces/IMidnightAdapter.sol"; import {DurationsLib} from "./libraries/DurationsLib.sol"; +import {MaturitiesLib} from "./libraries/MaturitiesLib.sol"; /// @dev Approximates held assets by linearly accounting for interest separately for each obligation. /// @dev Losses are immediately accounted minus a discount applied to the remaining interest to be earned, in proportion @@ -24,6 +25,8 @@ contract MidnightAdapter is IMidnightAdapter { using MathLib for uint128; using MathLib for int256; using DurationsLib for bytes32; + using MaturitiesLib for uint256; + using MaturitiesLib for uint48; /* IMMUTABLES */ @@ -33,6 +36,7 @@ contract MidnightAdapter is IMidnightAdapter { bytes32 public immutable adapterId; bytes32 public immutable packedDurations; uint256 public immutable durationsLength; + uint256 public constant maxTtmWhenBuying = 10 * 52 weeks; /* MANAGEMENT */ @@ -42,11 +46,12 @@ contract MidnightAdapter is IMidnightAdapter { uint256 public _totalAssets; uint48 public lastUpdate; - uint48 public firstMaturity; uint128 public currentGrowth; uint256 public activableMaturities = 50; - mapping(uint256 timestamp => MaturityData) public _maturities; + /// @dev Maturities must be rounded to the next hour. + mapping(uint256 maturity => MaturityData) public maturitiesData; mapping(bytes32 obligationId => uint256) public netCredit; + mapping(uint256 index => uint256) public bitmaps; /* CONSTRUCTOR */ constructor(address _parentVault, address _midnight, uint256[] memory _durations) { @@ -56,7 +61,6 @@ contract MidnightAdapter is IMidnightAdapter { lastUpdate = uint48(block.timestamp); 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))); bytes32 _packedDurations; @@ -73,7 +77,7 @@ contract MidnightAdapter is IMidnightAdapter { /* GETTERS */ function maturities(uint256 date) public view returns (MaturityData memory) { - return _maturities[date]; + return maturitiesData[date.align()]; } function durations() public view returns (uint256[] memory) { @@ -115,16 +119,19 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationCountAndAllocations(obligation); - if (totalNetCreditDecrease > 0) removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); + if (totalNetCreditDecrease > 0) { + removeUnits(obligationId, obligation.maturity.align(), totalNetCreditDecrease); + } int256 change = -int256(totalNetCreditDecrease); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), withdrawnAssets); } function updateDurationCountAndAllocations(Obligation memory obligation) public { - MaturityData storage maturityData = _maturities[obligation.maturity]; + uint256 maturity = obligation.maturity.align(); + MaturityData storage maturityData = maturitiesData[maturity]; uint256 oldDurationCount = maturityData.durationCount; - uint256 newDurationCount = durationCount(obligation.maturity); + uint256 newDurationCount = durationCount(maturity); maturityData.durationCount = uint8(newDurationCount); // VaultV2.deallocate requires allocation > 0 for each returned id. if (newDurationCount < oldDurationCount && maturityData.netCredit > 0) { @@ -139,36 +146,59 @@ contract MidnightAdapter is IMidnightAdapter { /* ACCRUAL */ - function accrueInterestView() public view returns (uint48, uint128, uint256) { + function accrueInterestView() public view returns (uint128, uint256) { uint256 lastChange = lastUpdate; - uint48 nextMaturity = firstMaturity; uint128 newGrowth = currentGrowth; uint256 gainedAssets; - - while (nextMaturity < block.timestamp) { - gainedAssets += uint256(newGrowth) * (nextMaturity - lastChange); - newGrowth -= _maturities[nextMaturity].growth; - lastChange = nextMaturity; - nextMaturity = _maturities[nextMaturity].nextMaturity; + uint256 firstIndex = lastUpdate.bitmapIndex(); + uint256 lastIndex = block.timestamp.zeroFloorSub(1).bitmapIndex(); + for (uint256 index = firstIndex; index <= lastIndex; index++) { + for (uint256 bitmap = bitmaps[index]; bitmap != 0; bitmap &= bitmap - 1) { + uint256 maturity = index.maturity() + bitmap.lsb() * 1 hours; + if (maturity >= block.timestamp) break; + gainedAssets += uint256(newGrowth) * (maturity - lastChange); + newGrowth -= maturitiesData[maturity].growth; + lastChange = maturity; + } } gainedAssets += uint256(newGrowth) * (block.timestamp - lastChange); - return (nextMaturity, newGrowth, _totalAssets + gainedAssets); + return (newGrowth, _totalAssets + gainedAssets); } - function accrueInterest() public returns (uint48, uint128, uint256) { + function accrueInterest() public returns (uint128, uint256) { if (lastUpdate != block.timestamp) { - (firstMaturity, currentGrowth, _totalAssets) = accrueInterestView(); + uint256 lastChange = lastUpdate; + uint128 newGrowth = currentGrowth; + uint256 gainedAssets; + uint256 firstIndex = lastUpdate.bitmapIndex(); + uint256 lastIndex = block.timestamp.zeroFloorSub(1).bitmapIndex(); + for (uint256 index = firstIndex; index <= lastIndex; index++) { + uint256 bitmap; + for (bitmap = bitmaps[index]; bitmap != 0; bitmap &= bitmap - 1) { + uint256 maturity = index.maturity() + bitmap.lsb() * 1 hours; + if (maturity >= block.timestamp) break; + gainedAssets += uint256(newGrowth) * (maturity - lastChange); + newGrowth -= maturitiesData[maturity].growth; + lastChange = maturity; + activableMaturities++; + } + bitmaps[index] = bitmap; + } + + gainedAssets += uint256(newGrowth) * (block.timestamp - lastChange); + currentGrowth = newGrowth; + _totalAssets += gainedAssets; lastUpdate = uint48(block.timestamp); } - return (firstMaturity, currentGrowth, _totalAssets); + return (currentGrowth, _totalAssets); } /// @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(); + (, uint256 newTotalAssets) = accrueInterestView(); return newTotalAssets; } @@ -216,7 +246,7 @@ contract MidnightAdapter is IMidnightAdapter { uint256 totalNetCreditDecrease = netCredit[obligationId] - newNetCredit; if (totalNetCreditDecrease > 0) { - removeUnits(obligationId, offer.obligation.maturity, totalNetCreditDecrease); + removeUnits(obligationId, offer.obligation.maturity.align(), totalNetCreditDecrease); } int256 change = -int256(totalNetCreditDecrease); @@ -238,8 +268,6 @@ contract MidnightAdapter is IMidnightAdapter { 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 memory sig = abi.decode(data, (Signature)); @@ -253,9 +281,6 @@ contract MidnightAdapter is IMidnightAdapter { return CALLBACK_SUCCESS; } - /// @dev `data` is used for new maturity insertions. - /// @dev It should encode a maturity present in the linked list. - /// @dev That maturity should be earlier than the inserted obligation maturity. function onBuy( bytes32 obligationId, Obligation memory obligation, @@ -263,17 +288,17 @@ contract MidnightAdapter is IMidnightAdapter { uint256 paidAssets, uint256 boughtCredit, uint256 buyPendingFeeIncrease, - bytes memory data + bytes memory ) external returns (bytes32) { - uint48 prevMaturity = abi.decode(data, (uint48)); - MaturityData storage maturityData = _maturities[obligation.maturity]; + uint256 maturity = obligation.maturity.align(); + MaturityData storage maturityData = maturitiesData[maturity]; uint256 buyNetCreditIncrease = boughtCredit - buyPendingFeeIncrease; - uint256 timeToMaturity = obligation.maturity.zeroFloorSub(block.timestamp); + uint256 timeToMaturity = maturity.zeroFloorSub(block.timestamp); require(msg.sender == midnight, NotMidnight()); require(buyer == address(this), NotSelf()); - require(prevMaturity < obligation.maturity, IncorrectHint()); require(buyNetCreditIncrease >= paidAssets, BuyAtLoss()); + require(timeToMaturity <= maxTtmWhenBuying, MaturityTooFar()); uint256 newNetCredit = IMidnight(midnight).creditOf(obligationId, address(this)) - IMidnight(midnight).pendingFee(obligationId, address(this)); @@ -285,10 +310,15 @@ contract MidnightAdapter is IMidnightAdapter { // change is at most buyNetCreditIncrease if (change < buyNetCreditIncrease.toInt256()) { uint256 loss = (int256(buyNetCreditIncrease) - change).toUint256(); - removeUnits(obligationId, obligation.maturity, loss); + removeUnits(obligationId, maturity, loss); } - if (maturityData.netCredit == 0 && buyNetCreditIncrease > 0) activableMaturities--; + if (maturityData.netCredit == 0 && buyNetCreditIncrease > 0 && maturity > block.timestamp) { + activableMaturities--; + uint256 index = maturity.bitmapIndex(); + uint256 bit = (maturity / 1 hours) % 256; + bitmaps[index] = bitmaps[index] | (uint256(1) << bit); + } IVaultV2(parentVault).allocate(address(this), abi.encode(ids(obligation), change), paidAssets); @@ -304,35 +334,6 @@ contract MidnightAdapter is IMidnightAdapter { maturityData.netCredit += buyNetCreditIncrease.toUint128(); netCredit[obligationId] += buyNetCreditIncrease.toUint128(); - // Insert the maturity in the list if needed - if (obligation.maturity >= block.timestamp) { - uint48 nextMaturity; - if (prevMaturity == 0) { - nextMaturity = firstMaturity; - } else { - require(prevMaturity >= firstMaturity && _maturities[prevMaturity].netCredit > 0, IncorrectHint()); - nextMaturity = _maturities[prevMaturity].nextMaturity; - } - - while (nextMaturity < obligation.maturity) { - prevMaturity = nextMaturity; - nextMaturity = _maturities[prevMaturity].nextMaturity; - } - - if (nextMaturity > obligation.maturity) { - maturityData.prevMaturity = prevMaturity; - maturityData.nextMaturity = nextMaturity; - if (prevMaturity == 0) { - firstMaturity = obligation.maturity.toUint48(); - } else { - _maturities[prevMaturity].nextMaturity = obligation.maturity.toUint48(); - } - if (nextMaturity < type(uint48).max) { - _maturities[nextMaturity].prevMaturity = obligation.maturity.toUint48(); - } - } - } - return CALLBACK_SUCCESS; } @@ -357,7 +358,9 @@ contract MidnightAdapter is IMidnightAdapter { accrueInterest(); updateDurationCountAndAllocations(obligation); - if (totalNetCreditDecrease > 0) removeUnits(obligationId, obligation.maturity, totalNetCreditDecrease); + if (totalNetCreditDecrease > 0) { + removeUnits(obligationId, obligation.maturity.align(), totalNetCreditDecrease); + } int256 change = -int256(totalNetCreditDecrease); IVaultV2(parentVault).deallocate(address(this), abi.encode(ids(obligation), change), sellerAssets); @@ -377,7 +380,7 @@ contract MidnightAdapter is IMidnightAdapter { /// @dev Removes units from tracking. /// @dev Changes the implied price of the obligation as little as possible. function removeUnits(bytes32 obligationId, uint256 maturity, uint256 removedUnits) internal { - MaturityData storage maturityData = _maturities[maturity]; + MaturityData storage maturityData = maturitiesData[maturity]; if (maturity > block.timestamp) { uint256 timeToMaturity = maturity - block.timestamp; @@ -391,20 +394,11 @@ contract MidnightAdapter is IMidnightAdapter { maturityData.netCredit -= removedUnits.toUint128(); netCredit[obligationId] -= removedUnits.toUint128(); - if (removedUnits > 0 && maturityData.netCredit == 0) { + if (removedUnits > 0 && maturityData.netCredit == 0 && maturity > block.timestamp) { activableMaturities++; - if (maturity > block.timestamp) { - uint48 prevMaturity = maturityData.prevMaturity; - uint48 nextMaturity = maturityData.nextMaturity; - if (maturity == firstMaturity) { - firstMaturity = nextMaturity; - } else { - _maturities[prevMaturity].nextMaturity = nextMaturity; - } - if (nextMaturity < type(uint48).max) { - _maturities[nextMaturity].prevMaturity = prevMaturity; - } - } + uint256 index = maturity.bitmapIndex(); + uint256 bit = (maturity / 1 hours) % 256; + bitmaps[index] = bitmaps[index] & ~(uint256(1) << bit); } } @@ -414,7 +408,7 @@ contract MidnightAdapter is IMidnightAdapter { } function ids(Obligation memory obligation) public view returns (bytes32[] memory) { - uint256 durationsCount = durationCount(obligation.maturity); + uint256 durationsCount = durationCount(obligation.maturity.align()); bytes32[] memory idsArray = new bytes32[](1 + obligation.collateralParams.length * 2 + durationsCount); diff --git a/src/adapters/interfaces/IMidnightAdapter.sol b/src/adapters/interfaces/IMidnightAdapter.sol index 60ae57661..f1761d902 100644 --- a/src/adapters/interfaces/IMidnightAdapter.sol +++ b/src/adapters/interfaces/IMidnightAdapter.sol @@ -7,13 +7,9 @@ import {Obligation} from "lib/midnight/src/interfaces/IMidnight.sol"; import {ICallbacks} from "lib/midnight/src/interfaces/ICallbacks.sol"; 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 netCredit; uint128 growth; - uint48 prevMaturity; - uint48 nextMaturity; uint8 durationCount; } @@ -29,13 +25,12 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { error BuyAtLoss(); error IncorrectCallbackAddress(); error IncorrectDuration(); - error IncorrectHint(); - error IncorrectMaturity(); error IncorrectOffer(); error IncorrectOwner(); error IncorrectSigner(); error IncorrectStart(); error LoanAssetMismatch(); + error MaturityTooFar(); error NoBorrowing(); error NoDebtCreation(); error NotAuthorized(); @@ -48,9 +43,10 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { 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 activableMaturities() external view returns (uint256); + function maxTtmWhenBuying() external view returns (uint256); + function bitmaps(uint256 group) external view returns (uint256); function midnight() external view returns (address); function adapterId() external view returns (bytes32); function packedDurations() external view returns (bytes32); @@ -65,8 +61,8 @@ interface IMidnightAdapter is IAdapter, ICallbacks, IRatifier { function withdrawToVault(Obligation memory obligation, uint256 units) 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); - 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 vaultAllocator) external returns (bytes32[] memory, int256); diff --git a/src/adapters/libraries/MaturitiesLib.sol b/src/adapters/libraries/MaturitiesLib.sol new file mode 100644 index 000000000..b57e48f31 --- /dev/null +++ b/src/adapters/libraries/MaturitiesLib.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2025 Morpho Association +pragma solidity ^0.8.0; + +library MaturitiesLib { + /// @dev Align maturity to the next hour. + function align(uint256 _maturity) internal pure returns (uint256) { + uint256 h = 1 hours; + return (_maturity + h - 1) / h * h; + } + + /// @dev Index of the bitmap containing the already aligned maturity. + function bitmapIndex(uint256 alignedMaturity) internal pure returns (uint256) { + return alignedMaturity / (256 * 1 hours); + } + + /// @dev Earliest maturity in the bitmap at index. + /// @dev Always aligned to an hour. + function maturity(uint256 index) internal pure returns (uint256) { + return index * 256 * 1 hours; + } + + /// @dev Least significant set bit. Assumes `bitmap` is not zero. + function lsb(uint256 bitmap) internal pure returns (uint256 res) { + assembly { + res := sub(255, clz(and(bitmap, sub(0, bitmap)))) + } + } +} diff --git a/test/MidnightAdapterAllocationUpdateTest.sol b/test/MidnightAdapterAllocationUpdateTest.sol index 549ec98b0..425a2aba8 100644 --- a/test/MidnightAdapterAllocationUpdateTest.sol +++ b/test/MidnightAdapterAllocationUpdateTest.sol @@ -114,42 +114,42 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { return keccak256(abi.encode("duration", duration)); } - function testExactDuration(uint32 durationIndex) public { - durationIndex = uint32(bound(durationIndex, 0, adapter.durationsLength() - 1)); - uint256 duration = adapter.durations()[durationIndex]; + function testExactDuration(uint32 durationCount) public { + durationCount = uint32(bound(durationCount, 0, adapter.durationsLength() - 1)); + uint256 duration = adapter.durations()[durationCount]; buy(duration, 1e18); assertEq(parentVault.allocation(durationId(duration)), 1e18); } - function testExitDuration(uint256 durationIndex, uint256 timeToMaturity, uint256 extraSkip) public { - durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); - uint256 duration = adapter.durations()[durationIndex]; - timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); - extraSkip = bound(extraSkip, 1, 10 * 365 days); + function testExitDuration(uint256 durationCount, uint256 timeToMaturity, uint256 extraSkip) public { + durationCount = bound(durationCount, 0, adapter.durationsLength() - 1); + uint256 duration = adapter.durations()[durationCount]; + timeToMaturity = bound(timeToMaturity, duration, adapter.maxTtmWhenBuying() - 1 hours); + extraSkip = bound(extraSkip, 1 hours + 1, 10 * 365 days); Offer memory offer = buy(timeToMaturity, 1e18); assertEq(parentVault.allocation(durationId(duration)), 1e18); skip(timeToMaturity - duration + extraSkip); - adapter.updateDurationIndexAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), 0); } - function testRepeatDeallocateExpiredDurations(uint256 durationIndex, uint256 timeToMaturity, uint256 skipAmount) + function testRepeatDeallocateExpiredDurations(uint256 durationCount, uint256 timeToMaturity, uint256 skipAmount) public { - durationIndex = bound(durationIndex, 0, adapter.durationsLength() - 1); - uint256 duration = adapter.durations()[durationIndex]; - timeToMaturity = bound(timeToMaturity, duration, type(uint32).max); + durationCount = bound(durationCount, 0, adapter.durationsLength() - 1); + uint256 duration = adapter.durations()[durationCount]; + timeToMaturity = bound(timeToMaturity, duration, adapter.maxTtmWhenBuying() - 1 hours); skipAmount = bound(skipAmount, 0, duration * 2); Offer memory offer = buy(timeToMaturity, 1e18); skip(skipAmount); - adapter.updateDurationIndexAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.obligation); uint256 savedAllocation = parentVault.allocation(durationId(duration)); - adapter.updateDurationIndexAndAllocations(offer.obligation); + adapter.updateDurationCountAndAllocations(offer.obligation); assertEq(parentVault.allocation(durationId(duration)), savedAllocation); } @@ -174,7 +174,8 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); - skip(1); + // Skip past the 1-hour alignment overshoot so the bucketed TTM crosses the 7-day boundary. + skip(1 hours + 1); parentVault.setTotalAssets(1e18); parentVault.setAdaptersLength(1); @@ -192,7 +193,8 @@ contract MidnightAdapterAllocationUpdateTest is MidnightAdapterTest { assertEq(parentVault.allocation(durationId(1 days)), 1e18, "1 day, before"); assertEq(parentVault.allocation(durationId(7 days)), 1e18, "7 days, before"); - skip(1); + // Skip past the 1-hour alignment overshoot so the bucketed TTM crosses the 7-day boundary. + skip(1 hours + 1); forceDeallocate(offer.obligation, 0.5e18); diff --git a/test/MidnightAdapterTest.sol b/test/MidnightAdapterTest.sol index 4deb4eba9..e7347a6f2 100644 --- a/test/MidnightAdapterTest.sol +++ b/test/MidnightAdapterTest.sol @@ -12,6 +12,7 @@ import {IERC20} from "../src/interfaces/IERC20.sol"; import {IMidnightAdapter} from "../src/adapters/interfaces/IMidnightAdapter.sol"; import {IMidnightAdapterFactory} from "../src/adapters/interfaces/IMidnightAdapterFactory.sol"; import {MathLib} from "../src/libraries/MathLib.sol"; +import {MaturitiesLib} from "../src/adapters/libraries/MaturitiesLib.sol"; import {Midnight} from "../lib/midnight/src/Midnight.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"; @@ -30,6 +31,7 @@ struct Step { contract MidnightAdapterTest is Test { using stdStorage for StdStorage; using MathLib for uint256; + using MaturitiesLib for uint256; IMidnight internal midnight; IMidnightAdapterFactory internal factory; @@ -205,18 +207,17 @@ contract MidnightAdapterTest is Test { units, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); - uint256 remainder = (units - assets) % (offer.obligation.maturity - vm.getBlockTimestamp()); + uint256 alignedMaturity = offer.obligation.maturity.align(); + uint256 duration = alignedMaturity - vm.getBlockTimestamp(); + uint256 remainder = (units - assets) % duration; assertEq(adapter._totalAssets(), assets + remainder, "_totalAssets"); assertEq(adapter.lastUpdate(), vm.getBlockTimestamp(), "lastUpdate"); - assertEq(adapter.firstMaturity(), vm.getBlockTimestamp() + 200, "firstMaturity"); uint256 totalInterest = units - assets; - uint256 duration = offer.obligation.maturity - vm.getBlockTimestamp(); uint256 newGrowth = totalInterest / duration; assertEq(adapter.currentGrowth(), newGrowth, "currentGrowth"); MaturityData memory maturityData = adapter.maturities(offer.obligation.maturity); assertEq(maturityData.growth, newGrowth, "growth"); - assertEq(maturityData.nextMaturity, type(uint48).max, "nextMaturity"); uint256 actualUnits = adapter.netCredit(_obligationId(offer.obligation)); assertEq(actualUnits, units, "units"); @@ -250,24 +251,19 @@ contract MidnightAdapterTest is Test { units1, taker, address(0), "", taker, offer, sign([offer], signerAllocator), root([offer]), proof([offer]) ); - uint256 timeToMaturity = maturity - block.timestamp; + uint256 alignedMaturity = maturity.align(); + uint256 timeToMaturity = alignedMaturity - block.timestamp; uint128 growth1 = uint128((units1 - assets1) / timeToMaturity); assertGt(growth1, 0, "growth should be nonzero"); assertEq(adapter.currentGrowth(), growth1, "currentGrowth after buy1"); // Step 2: Advance time past maturity M skip(timeToMaturity + 1); - assertGt(block.timestamp, maturity, "should be past maturity"); + assertGt(block.timestamp, alignedMaturity, "should be past aligned maturity"); // Step 3: Trigger accrueInterest so the walk subtracts growth from currentGrowth adapter.accrueInterest(); assertEq(adapter.currentGrowth(), 0, "currentGrowth after accrual should be 0"); - assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity should be sentinel"); - - // In midnight, any seller with debt past maturity is always liquidatable - // (isLiquidatable returns true if block.timestamp > maturity && debt > 0), - // so we can't test a second buy at past maturity. Just verify accrual state. - assertEq(adapter.firstMaturity(), type(uint48).max, "past maturity not re-inserted into list"); // Note: In midnight, any seller with debt past maturity is always liquidatable, // so the second buy at past maturity from the original test cannot be executed. @@ -368,16 +364,6 @@ contract MidnightAdapterTest is Test { adapter.onRatify(offer, _root, data); } - function testRatifyIncorrectMaturity(uint256 seed) public { - vm.setSeed(seed); - Offer memory offer = _ratificationSetup(); - offer.obligation.maturity = vm.randomUint(type(uint48).max, type(uint256).max); - bytes32 _root = root(offer); - bytes memory data = ratifierData(_root, signerAllocator); - vm.expectRevert(IMidnightAdapter.IncorrectMaturity.selector); - adapter.onRatify(offer, _root, data); - } - function testRatifyIncorrectStart(uint256 seed) public { vm.setSeed(seed); Offer memory offer = _ratificationSetup(); @@ -442,7 +428,8 @@ contract MidnightAdapterTest is Test { for (uint256 i = 0; i < steps.length; i++) { Step memory step = steps[i]; - uint256 timeToMaturity = step.maturity - vm.getBlockTimestamp(); + uint256 alignedMaturity = step.maturity.align(); + uint256 timeToMaturity = alignedMaturity - vm.getBlockTimestamp(); require(timeToMaturity > 0 || step.approxGrowth == 0, "nonzero growth on 0 duration"); uint256 approxInterest = step.approxGrowth * timeToMaturity; offer.group = bytes32(i); @@ -482,13 +469,13 @@ contract MidnightAdapterTest is Test { assertEq(adapter.netCredit(obligationId), unitsBefore + units, "setup: units 1"); expectedUnits[obligationId] += units; - expectedMaturityGrowths[step.maturity] += actualGrowth; + expectedMaturityGrowths[alignedMaturity] += actualGrowth; if (timeToMaturity > 0) { expectedAddedGrowth += actualGrowth.toUint128(); } expectedAddedAssets += step.assets + zeroPeriodGain; expectedPositionsList.push(uint256(obligationId)); - expectedMaturitiesList.push(step.maturity); + expectedMaturitiesList.push(alignedMaturity); } expectedPositionsList = removeCopies(expectedPositionsList); expectedMaturitiesList = removeCopies(expectedMaturitiesList); @@ -505,31 +492,13 @@ contract MidnightAdapterTest is Test { setupObligations(steps); - // Check pointer to first element of maturities list - if (steps.length > 0) { - assertEq(adapter.firstMaturity(), steps[0].maturity, "firstMaturity"); - } else { - assertEq(adapter.firstMaturity(), type(uint48).max, "firstMaturity"); - } - - // Check maturities growth and linked list structure + // Check maturities growth for (uint256 i = 0; i < expectedMaturitiesList.length; i++) { assertEq( adapter.maturities(expectedMaturitiesList[i]).growth, expectedMaturityGrowths[expectedMaturitiesList[i]], "growth" ); - if (i == expectedMaturitiesList.length - 1) { - assertEq( - adapter.maturities(expectedMaturitiesList[i]).nextMaturity, type(uint48).max, "nextMaturity end" - ); - } else { - assertEq( - adapter.maturities(expectedMaturitiesList[i]).nextMaturity, - expectedMaturitiesList[i + 1], - "nextMaturity middle" - ); - } } // Check positions growth and size @@ -571,11 +540,10 @@ contract MidnightAdapterTest is Test { skip(elapsed); - (uint48 nextMaturity, uint128 newGrowth, uint256 newTotalAssets) = adapter.accrueInterestView(); + (uint128 newGrowth, uint256 newTotalAssets) = adapter.accrueInterestView(); uint256 lostGrowth = 0; uint256 interest = initialGrowth * elapsed; - uint256 expectedNextMaturity = type(uint48).max; for (uint256 i = 0; i < expectedMaturitiesList.length; i++) { uint256 maturity = expectedMaturitiesList[i]; @@ -585,11 +553,7 @@ contract MidnightAdapterTest is Test { } else { interest += expectedMaturityGrowths[maturity] * elapsed; } - if (maturity >= vm.getBlockTimestamp() && maturity < expectedNextMaturity) { - expectedNextMaturity = maturity; - } } - assertEq(nextMaturity, expectedNextMaturity, "nextMaturity"); assertEq(newGrowth, expectedCurrentGrowth - lostGrowth, "newGrowth"); assertEq(newTotalAssets, _totalAssets + expectedAddedAssets + interest, "newTotalAssets"); } @@ -625,7 +589,7 @@ contract MidnightAdapterTest is Test { collateralParams[i].token = address(uint160(i)); } obligation.collateralParams = storedCollaterals; - obligation.maturity = bound(maturity, 1, 700 days); + obligation.maturity = bound(maturity, block.timestamp + 1, block.timestamp + 700 days); bytes32[] memory ids = adapter.ids(obligation); assertEq(ids[0], adapter.adapterId()); @@ -646,8 +610,9 @@ contract MidnightAdapterTest is Test { uint256[] memory durations = adapter.durations(); uint256 durationIdCount = 0; + uint256 alignedTtm = obligation.maturity.align() - block.timestamp; for (uint256 i = 0; i < durations.length; i++) { - if ((obligation.maturity - block.timestamp) >= durations[i]) { + if (alignedTtm >= durations[i]) { assertEq( ids[1 + obligation.collateralParams.length * 2 + durationIdCount], keccak256(abi.encode("duration", durations[i]))