diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index 48735def..41e2a7cd 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -27,7 +27,7 @@ library RepoTokenList { error InvalidRepoToken(address token); - function _getRepoTokenMaturity(address repoToken) private view returns (uint256 redemptionTimestamp) { + function getRepoTokenMaturity(address repoToken) internal view returns (uint256 redemptionTimestamp) { (redemptionTimestamp, , ,) = ITermRepoToken(repoToken).config(); } @@ -61,73 +61,54 @@ library RepoTokenList { } } - function simulateWeightedTimeToMaturity( - RepoTokenListData storage listData, + function getRepoTokenWeightedTimeToMaturity( + address repoToken, uint256 repoTokenBalanceInBaseAssetPrecision + ) internal view returns (uint256 weightedTimeToMaturity) { + uint256 currentMaturity = getRepoTokenMaturity(repoToken); + + if (currentMaturity > block.timestamp) { + uint256 timeToMaturity = _getRepoTokenTimeToMaturity(currentMaturity, repoToken); + // Not matured yet + weightedTimeToMaturity = timeToMaturity * repoTokenBalanceInBaseAssetPrecision; + } + } + + function getCumulativeRepoTokenData( + RepoTokenListData storage listData, address repoToken, uint256 repoTokenAmount, uint256 purchaseTokenPrecision, uint256 liquidBalance - ) internal view returns (uint256) { - if (listData.head == NULL_NODE) return 0; + ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeRepoTokenAmount, bool found) { + if (listData.head == NULL_NODE) return (0, 0, false); - uint256 cumulativeWeightedTimeToMaturity; // in seconds - uint256 cumulativeRepoTokenAmount; // in purchase token precision address current = listData.head; - bool found; while (current != NULL_NODE) { uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); if (repoTokenBalance > 0) { - uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); - uint256 repoTokenPrecision = 10**ERC20(current).decimals(); - if (repoToken == current) { repoTokenBalance += repoTokenAmount; found = true; } + uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); + uint256 repoTokenPrecision = 10**ERC20(current).decimals(); + uint256 repoTokenBalanceInBaseAssetPrecision = (redemptionValue * repoTokenBalance * purchaseTokenPrecision) / (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); - uint256 currentMaturity = _getRepoTokenMaturity(current); + uint256 weightedTimeToMaturity = getRepoTokenWeightedTimeToMaturity( + current, repoTokenBalanceInBaseAssetPrecision + ); - if (currentMaturity > block.timestamp) { - uint256 timeToMaturity = _getRepoTokenTimeToMaturity(currentMaturity, current); - // Not matured yet - cumulativeWeightedTimeToMaturity += - timeToMaturity * repoTokenBalanceInBaseAssetPrecision; - } + cumulativeWeightedTimeToMaturity += weightedTimeToMaturity; cumulativeRepoTokenAmount += repoTokenBalanceInBaseAssetPrecision; } current = _getNext(listData, current); } - - /// @dev token is not found in the list (i.e. called from view function) - if (!found && repoToken != address(0)) { - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 redemptionValue = ITermRepoToken(repoToken).redemptionValue(); - uint256 repoTokenAmountInBaseAssetPrecision = - (redemptionValue * repoTokenAmount * purchaseTokenPrecision) / - (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); - - cumulativeRepoTokenAmount += repoTokenAmountInBaseAssetPrecision; - uint256 maturity = _getRepoTokenMaturity(repoToken); - if (maturity > block.timestamp) { - uint256 timeToMaturity = _getRepoTokenTimeToMaturity(maturity, repoToken); - cumulativeWeightedTimeToMaturity += - timeToMaturity * repoTokenAmountInBaseAssetPrecision; - } - } - - /// @dev avoid div by 0 - if (cumulativeRepoTokenAmount == 0 && liquidBalance == 0) { - return 0; - } - - // time * purchaseTokenPrecision / purchaseTokenPrecision - return cumulativeWeightedTimeToMaturity / (cumulativeRepoTokenAmount + liquidBalance); } function removeAndRedeemMaturedTokens(RepoTokenListData storage listData) internal { @@ -137,7 +118,7 @@ library RepoTokenList { address prev = current; while (current != NULL_NODE) { address next; - if (_getRepoTokenMaturity(current) < block.timestamp) { + if (getRepoTokenMaturity(current) < block.timestamp) { bool removeMaturedToken; uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); @@ -177,7 +158,7 @@ library RepoTokenList { } } - function _auctionRate(ITermController termController, ITermRepoToken repoToken) private view returns (uint256) { + function getAuctionRate(ITermController termController, ITermRepoToken repoToken) internal view returns (uint256) { (AuctionMetadata[] memory auctionMetadata, ) = termController.getTermAuctionResults(repoToken.termRepoId()); uint256 len = auctionMetadata.length; @@ -243,14 +224,14 @@ library RepoTokenList { revert InvalidRepoToken(address(repoToken)); } - uint256 oracleRate = _auctionRate(termController, repoToken); + uint256 oracleRate = getAuctionRate(termController, repoToken); if (oracleRate != INVALID_AUCTION_RATE) { if (auctionRate != oracleRate) { listData.auctionRates[address(repoToken)] = oracleRate; } } } else { - auctionRate = _auctionRate(termController, repoToken); + auctionRate = getAuctionRate(termController, repoToken); redemptionTimestamp = validateRepoToken(listData, repoToken, termController, asset); @@ -275,8 +256,8 @@ library RepoTokenList { break; } - uint256 currentMaturity = _getRepoTokenMaturity(current); - uint256 maturityToInsert = _getRepoTokenMaturity(repoToken); + uint256 currentMaturity = getRepoTokenMaturity(current); + uint256 maturityToInsert = getRepoTokenMaturity(repoToken); if (maturityToInsert <= currentMaturity) { if (prev == NULL_NODE) { @@ -308,7 +289,7 @@ library RepoTokenList { address current = listData.head; while (current != NULL_NODE) { - uint256 currentMaturity = _getRepoTokenMaturity(current); + uint256 currentMaturity = getRepoTokenMaturity(current); uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); uint256 repoTokenPrecision = 10**ERC20(current).decimals(); uint256 auctionRate = listData.auctionRates[current]; diff --git a/src/RepoTokenUtils.sol b/src/RepoTokenUtils.sol index 1e2fed9e..37653209 100644 --- a/src/RepoTokenUtils.sol +++ b/src/RepoTokenUtils.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; + library RepoTokenUtils { uint256 public constant THREESIXTY_DAYCOUNT_SECONDS = 360 days; uint256 public constant RATE_PRECISION = 1e18; @@ -35,4 +38,17 @@ library RepoTokenUtils { (repoTokenAmountInBaseAssetPrecision * purchaseTokenPrecision) / (purchaseTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)); } + + // returns repo token amount in base asset precision + function getNormalizedRepoTokenAmount( + address repoToken, + uint256 repoTokenAmount, + uint256 purchaseTokenPrecision + ) internal view returns (uint256 repoTokenAmountInBaseAssetPrecision) { + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 redemptionValue = ITermRepoToken(repoToken).redemptionValue(); + repoTokenAmountInBaseAssetPrecision = + (redemptionValue * repoTokenAmount * purchaseTokenPrecision) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + } } diff --git a/src/Strategy.sol b/src/Strategy.sol index 4b1d225e..36f287d5 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; -import "forge-std/console.sol"; import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; @@ -85,14 +84,57 @@ contract Strategy is BaseStrategy { return termAuctionListData.pendingOffers(); } - function _removeRedeemAndCalculateWeightedMaturity( + function _calculateWeightedMaturity( address repoToken, - uint256 amount, + uint256 repoTokenAmount, uint256 liquidBalance - ) private returns (uint256) { - return repoTokenListData.simulateWeightedTimeToMaturity( - repoToken, amount, PURCHASE_TOKEN_PRECISION, liquidBalance + ) private view returns (uint256) { + uint256 cumulativeWeightedTimeToMaturity; // in seconds + uint256 cumulativeAmount; // in purchase token precision + + ( + uint256 cumulativeRepoTokenWeightedTimeToMaturity, + uint256 cumulativeRepoTokenAmount, + bool foundInRepoTokenList + ) = repoTokenListData.getCumulativeRepoTokenData( + repoToken, repoTokenAmount, PURCHASE_TOKEN_PRECISION, liquidBalance ); + + cumulativeWeightedTimeToMaturity += cumulativeRepoTokenWeightedTimeToMaturity; + cumulativeAmount += cumulativeRepoTokenAmount; + + ( + uint256 cumulativeOfferWeightedTimeToMaturity, + uint256 cumulativeOfferAmount, + bool foundInOfferList + ) = termAuctionListData.getCumulativeOfferData( + repoTokenListData, termController, repoToken, repoTokenAmount, PURCHASE_TOKEN_PRECISION + ); + + cumulativeWeightedTimeToMaturity += cumulativeOfferWeightedTimeToMaturity; + cumulativeAmount += cumulativeOfferAmount; + + if (!foundInRepoTokenList && !foundInOfferList && repoToken != address(0)) { + uint256 repoTokenAmountInBaseAssetPrecision = RepoTokenUtils.getNormalizedRepoTokenAmount( + repoToken, + repoTokenAmount, + PURCHASE_TOKEN_PRECISION + ); + + cumulativeAmount += repoTokenAmountInBaseAssetPrecision; + cumulativeWeightedTimeToMaturity += RepoTokenList.getRepoTokenWeightedTimeToMaturity( + repoToken, + repoTokenAmountInBaseAssetPrecision + ); + } + + /// @dev avoid div by 0 + if (cumulativeAmount == 0 && liquidBalance == 0) { + return 0; + } + + // time * purchaseTokenPrecision / purchaseTokenPrecision + return cumulativeWeightedTimeToMaturity / (cumulativeAmount + liquidBalance); } function simulateWeightedTimeToMaturity(address repoToken, uint256 amount) external view returns (uint256) { @@ -100,9 +142,7 @@ contract Strategy is BaseStrategy { if (repoToken != address(0)) { repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); } - return repoTokenListData.simulateWeightedTimeToMaturity( - repoToken, amount, PURCHASE_TOKEN_PRECISION, _totalLiquidBalance(address(this)) - ); + return _calculateWeightedMaturity(repoToken, amount, _totalLiquidBalance(address(this))); } function calculateRepoTokenPresentValue( @@ -182,7 +222,7 @@ contract Strategy is BaseStrategy { revert InsufficientLiquidBalance(liquidBalance, proceeds); } - uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( + uint256 resultingTimeToMaturity = _calculateWeightedMaturity( repoToken, repoTokenAmount, liquidBalance - proceeds ); @@ -218,6 +258,33 @@ contract Strategy is BaseStrategy { _sweepAssetAndRedeemRepoTokens(0); } + function _generateOfferId( + bytes32 id, + address offerLocker + ) internal view returns (bytes32) { + return keccak256( + abi.encodePacked(id, address(this), offerLocker) + ); + } + + function _validateWeightedMaturity( + address repoToken, + uint256 newOfferAmount, + uint256 newLiquidBalance + ) private { + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 offerAmountInRepoPrecision = RepoTokenUtils.purchaseToRepoPrecision( + repoTokenPrecision, PURCHASE_TOKEN_PRECISION, newOfferAmount + ); + uint256 resultingWeightedTimeToMaturity = _calculateWeightedMaturity( + repoToken, offerAmountInRepoPrecision, newLiquidBalance + ); + + if (resultingWeightedTimeToMaturity > timeToMaturityThreshold) { + revert TimeToMaturityAboveThreshold(); + } + } + function submitAuctionOffer( address termAuction, address repoToken, @@ -247,44 +314,65 @@ contract Strategy is BaseStrategy { _sweepAssetAndRedeemRepoTokens(0); //@dev sweep to ensure liquid balances up to date - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 offerAmount = RepoTokenUtils.purchaseToRepoPrecision( - repoTokenPrecision, PURCHASE_TOKEN_PRECISION, purchaseTokenAmount - ); uint256 liquidBalance = _totalLiquidBalance(address(this)); - - if (liquidBalance < purchaseTokenAmount) { - revert InsufficientLiquidBalance(liquidBalance, purchaseTokenAmount); - } - - uint256 resultingWeightedTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( - repoToken, offerAmount, liquidBalance - purchaseTokenAmount - ); - - if (resultingWeightedTimeToMaturity > timeToMaturityThreshold) { - revert TimeToMaturityAboveThreshold(); - } - - if ((liquidBalance - purchaseTokenAmount) < liquidityThreshold) { - revert BalanceBelowLiquidityThreshold(); + uint256 newOfferAmount = purchaseTokenAmount; + bytes32 offerId = _generateOfferId(idHash, address(offerLocker)); + uint256 currentOfferAmount = termAuctionListData.offers[offerId].offerAmount; + if (newOfferAmount > currentOfferAmount) { + // increasing offer amount + uint256 offerDebit; + unchecked { + // checked above + offerDebit = newOfferAmount - currentOfferAmount; + } + if (liquidBalance < offerDebit) { + revert InsufficientLiquidBalance(liquidBalance, offerDebit); + } + uint256 newLiquidBalance = liquidBalance - offerDebit; + if (newLiquidBalance < liquidityThreshold) { + revert BalanceBelowLiquidityThreshold(); + } + _validateWeightedMaturity(repoToken, newOfferAmount, newLiquidBalance); + } else if (currentOfferAmount > newOfferAmount) { + // decreasing offer amount + uint256 offerCredit; + unchecked { + offerCredit = currentOfferAmount - newOfferAmount; + } + uint256 newLiquidBalance = liquidBalance + offerCredit; + if (newLiquidBalance < liquidityThreshold) { + revert BalanceBelowLiquidityThreshold(); + } + _validateWeightedMaturity(repoToken, newOfferAmount, newLiquidBalance); + } else { + // no change in offer amount, do nothing } ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer; - offer.id = idHash; + offer.id = currentOfferAmount > 0 ? offerId : idHash; offer.offeror = address(this); offer.offerPriceHash = offerPriceHash; offer.amount = purchaseTokenAmount; offer.purchaseToken = address(asset); - offerIds = _submitOffer(auction, offerLocker, offer, repoToken); + offerIds = _submitOffer( + auction, + offerLocker, + offer, + repoToken, + newOfferAmount, + currentOfferAmount + ); } function _submitOffer( ITermAuction auction, ITermAuctionOfferLocker offerLocker, ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer, - address repoToken + address repoToken, + uint256 newOfferAmount, + uint256 currentOfferAmount ) private returns (bytes32[] memory offerIds) { ITermRepoServicer repoServicer = ITermRepoServicer(offerLocker.termRepoServicer()); @@ -292,18 +380,23 @@ contract Strategy is BaseStrategy { new ITermAuctionOfferLocker.TermAuctionOfferSubmission[](1); offerSubmissions[0] = offer; - _withdrawAsset(offer.amount); - - ERC20(asset).approve(address(repoServicer.termRepoLocker()), offer.amount); + if (newOfferAmount > currentOfferAmount) { + uint256 offerDebit; + unchecked { + // checked above + offerDebit = newOfferAmount - currentOfferAmount; + } + _withdrawAsset(offerDebit); + IERC20(asset).safeApprove(address(repoServicer.termRepoLocker()), offerDebit); + } offerIds = offerLocker.lockOffers(offerSubmissions); require(offerIds.length > 0); - if (termAuctionListData.offers[offerIds[0]].offerId == bytes32(0)) { + if (currentOfferAmount == 0) { // new offer - termAuctionListData.insertPending(PendingOffer({ - offerId: offerIds[0], + termAuctionListData.insertPending(offerIds[0], PendingOffer({ repoToken: repoToken, offerAmount: offer.amount, termAuction: auction, @@ -312,7 +405,6 @@ contract Strategy is BaseStrategy { } else { // edit offer, overwrite existing termAuctionListData.offers[offerIds[0]] = PendingOffer({ - offerId: offerIds[0], repoToken: repoToken, offerAmount: offer.amount, termAuction: auction, @@ -336,7 +428,7 @@ contract Strategy is BaseStrategy { function _totalAssetValue() internal view returns (uint256 totalValue) { return _totalLiquidBalance(address(this)) + repoTokenListData.getPresentValue(PURCHASE_TOKEN_PRECISION) + - termAuctionListData.getPresentValue(repoTokenListData); + termAuctionListData.getPresentValue(repoTokenListData, termController, PURCHASE_TOKEN_PRECISION); } constructor( diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index 100d2f2a..0a87d899 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -7,13 +7,22 @@ import {ITermController} from "./interfaces/term/ITermController.sol"; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {RepoTokenList, RepoTokenListData} from "./RepoTokenList.sol"; +import {RepoTokenUtils} from "./RepoTokenUtils.sol"; struct PendingOffer { + address repoToken; + uint256 offerAmount; + ITermAuction termAuction; + ITermAuctionOfferLocker offerLocker; +} + +struct PendingOfferMemory { bytes32 offerId; address repoToken; uint256 offerAmount; ITermAuction termAuction; ITermAuctionOfferLocker offerLocker; + bool isRepoTokenSeen; } struct TermAuctionListNode { @@ -57,16 +66,15 @@ library TermAuctionList { } } - function insertPending(TermAuctionListData storage listData, PendingOffer memory pendingOffer) internal { + function insertPending(TermAuctionListData storage listData, bytes32 offerId, PendingOffer memory pendingOffer) internal { bytes32 current = listData.head; - bytes32 id = pendingOffer.offerId; if (current != NULL_NODE) { - listData.nodes[id].next = current; + listData.nodes[offerId].next = current; } - listData.head = id; - listData.offers[id] = pendingOffer; + listData.head = offerId; + listData.offers[offerId] = pendingOffer; } function removeCompleted( @@ -75,6 +83,13 @@ library TermAuctionList { ITermController termController, address asset ) internal { + /* + offer submitted; auction still open => include offerAmount in totalValue (otherwise locked purchaseToken will be missing from TV) + offer submitted; auction completed; !auctionClosed() => include offer.offerAmount in totalValue (because the offerLocker will have already deleted offer on completeAuction) + + even though repoToken has been transferred it hasn't been added to the repoTokenList + BUT only if it is new not a reopening + offer submitted; auction completed; auctionClosed() => repoToken has been added to the repoTokenList + */ if (listData.head == NULL_NODE) return; bytes32 current = listData.head; @@ -83,7 +98,7 @@ library TermAuctionList { PendingOffer memory offer = listData.offers[current]; bytes32 next = _getNext(listData, current); - uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; + uint256 offerAmount = offer.offerLocker.lockedOffer(current).amount; bool removeNode; bool insertRepoToken; @@ -103,7 +118,7 @@ library TermAuctionList { // withdraw manually bytes32[] memory offerIds = new bytes32[](1); - offerIds[0] = offer.offerId; + offerIds[0] = current; offer.offerLocker.unlockOffers(offerIds); } } @@ -127,35 +142,122 @@ library TermAuctionList { } } + function _loadOffers(TermAuctionListData storage listData) private view returns (PendingOfferMemory[] memory offers) { + uint256 len = _count(listData); + offers = new PendingOfferMemory[](len); + + uint256 i; + bytes32 current = listData.head; + while (current != NULL_NODE) { + PendingOffer memory currentOffer = listData.offers[current]; + PendingOfferMemory memory newOffer = offers[i]; + + newOffer.offerId = current; + newOffer.repoToken = currentOffer.repoToken; + newOffer.offerAmount = currentOffer.offerAmount; + newOffer.termAuction = currentOffer.termAuction; + newOffer.offerLocker = currentOffer.offerLocker; + + i++; + current = _getNext(listData, current); + } + } + + function _markRepoTokenAsSeen(PendingOfferMemory[] memory offers, address repoToken) private view { + for (uint256 i; i < offers.length; i++) { + if (repoToken == offers[i].repoToken) { + offers[i].isRepoTokenSeen = true; + } + } + } + function getPresentValue( TermAuctionListData storage listData, - RepoTokenListData storage repoTokenListData + RepoTokenListData storage repoTokenListData, + ITermController termController, + uint256 purchaseTokenPrecision ) internal view returns (uint256 totalValue) { if (listData.head == NULL_NODE) return 0; + + PendingOfferMemory[] memory offers = _loadOffers(listData); - bytes32 current = listData.head; - while (current != NULL_NODE) { - PendingOffer memory offer = listData.offers[current]; + for (uint256 i; i < offers.length; i++) { + PendingOfferMemory memory offer = offers[i]; uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; /// @dev offer processed, but auctionClosed not yet called and auction is new so repoToken not on List and wont be picked up /// checking repoTokenAuctionRates to make sure we are not double counting on re-openings - if (offer.termAuction.auctionCompleted() && offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { - totalValue += offer.offerAmount; + if (offer.termAuction.auctionCompleted() && repoTokenListData.auctionRates[offer.repoToken] == 0) { + if (!offer.isRepoTokenSeen) { + uint256 repoTokenAmountInBaseAssetPrecision = RepoTokenUtils.getNormalizedRepoTokenAmount( + offer.repoToken, + ITermRepoToken(offer.repoToken).balanceOf(address(this)), + purchaseTokenPrecision + ); + totalValue += RepoTokenUtils.calculatePresentValue( + repoTokenAmountInBaseAssetPrecision, + purchaseTokenPrecision, + RepoTokenList.getRepoTokenMaturity(offer.repoToken), + RepoTokenList.getAuctionRate(termController, ITermRepoToken(offer.repoToken)) + ); + + // since multiple offers can be tied to the same repo token, we need to mark + // the repo tokens we've seen to avoid double counting + _markRepoTokenAsSeen(offers, offer.repoToken); + } } else { totalValue += offerAmount; } - - current = _getNext(listData, current); } } -} -/* -offer submitted; auction still open => include offerAmount in totalValue (otherwise locked purchaseToken will be missing from TV) -offer submitted; auction completed; !auctionClosed() => include offer.offerAmount in totalValue (because the offerLocker will have already deleted offer on completeAuction) - + even though repoToken has been transferred it hasn't been added to the repoTokenList - BUT only if it is new not a reopening -offer submitted; auction completed; auctionClosed() => repoToken has been added to the repoTokenList -*/ \ No newline at end of file + function getCumulativeOfferData( + TermAuctionListData storage listData, + RepoTokenListData storage repoTokenListData, + ITermController termController, + address repoToken, + uint256 newOfferAmount, + uint256 purchaseTokenPrecision + ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeOfferAmount, bool found) { + if (listData.head == NULL_NODE) return (0, 0, false); + + PendingOfferMemory[] memory offers = _loadOffers(listData); + + for (uint256 i; i < offers.length; i++) { + PendingOfferMemory memory offer = offers[i]; + + uint256 offerAmount; + if (offer.repoToken == repoToken) { + offerAmount = newOfferAmount; + found = true; + } else { + offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; + + /// @dev offer processed, but auctionClosed not yet called and auction is new so repoToken not on List and wont be picked up + /// checking repoTokenAuctionRates to make sure we are not double counting on re-openings + if (offer.termAuction.auctionCompleted() && repoTokenListData.auctionRates[offer.repoToken] == 0) { + // use normalized repo token amount if repo token is not in the list + if (!offer.isRepoTokenSeen) { + offerAmount = RepoTokenUtils.getNormalizedRepoTokenAmount( + offer.repoToken, + ITermRepoToken(offer.repoToken).balanceOf(address(this)), + purchaseTokenPrecision + ); + + _markRepoTokenAsSeen(offers, offer.repoToken); + } + } + } + + if (offerAmount > 0) { + uint256 weightedTimeToMaturity = RepoTokenList.getRepoTokenWeightedTimeToMaturity( + offer.repoToken, offerAmount + ); + + cumulativeWeightedTimeToMaturity += weightedTimeToMaturity; + cumulativeOfferAmount += offerAmount; + } + } + } +} diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol index c6800975..1a11bfe7 100644 --- a/src/test/TestUSDCOffers.t.sol +++ b/src/test/TestUSDCOffers.t.sol @@ -1,6 +1,6 @@ pragma solidity ^0.8.18; -import "forge-std/console.sol"; +import "forge-std/console2.sol"; import {ERC20Mock} from "@openzeppelin/contracts/mocks/ERC20Mock.sol"; import {MockTermRepoToken} from "./mocks/MockTermRepoToken.sol"; import {MockTermAuction} from "./mocks/MockTermAuction.sol"; @@ -45,24 +45,25 @@ contract TestUSDCSubmitOffer is Setup { initialState.totalLiquidBalance = termStrategy.totalLiquidBalance(); } - function _submitOffer(bytes32 offerId, uint256 offerAmount) private { + function _submitOffer(bytes32 idHash, uint256 offerAmount) private returns (bytes32) { // test: only management can submit offers vm.expectRevert("!management"); bytes32[] memory offerIds = termStrategy.submitAuctionOffer( - address(repoToken1WeekAuction), address(repoToken1Week), offerId, bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), idHash, bytes32("test price"), offerAmount ); vm.prank(management); offerIds = termStrategy.submitAuctionOffer( - address(repoToken1WeekAuction), address(repoToken1Week), offerId, bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), idHash, bytes32("test price"), offerAmount ); assertEq(offerIds.length, 1); - assertEq(offerIds[0], offerId); + + return offerIds[0]; } function testSubmitOffer() public { - _submitOffer(bytes32("offer id 1"), 1e6); + _submitOffer(bytes32("offer id hash 1"), 1e6); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - 1e6); // test: totalAssetValue = total liquid balance + pending offer amount @@ -70,14 +71,15 @@ contract TestUSDCSubmitOffer is Setup { } function testEditOffer() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 idHash1 = bytes32("offer id hash 1"); + bytes32 offerId1 = _submitOffer(idHash1, 1e6); // TODO: fuzz this uint256 offerAmount = 4e6; vm.prank(management); bytes32[] memory offerIds = termStrategy.submitAuctionOffer( - address(repoToken1WeekAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), idHash1, bytes32("test price"), offerAmount ); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - offerAmount); @@ -86,11 +88,11 @@ contract TestUSDCSubmitOffer is Setup { } function testDeleteOffers() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); bytes32[] memory offerIds = new bytes32[](1); - offerIds[0] = bytes32("offer id 1"); + offerIds[0] = offerId1; vm.expectRevert("!management"); termStrategy.deleteAuctionOffers(address(repoToken1WeekAuction), offerIds); @@ -126,19 +128,22 @@ contract TestUSDCSubmitOffer is Setup { } function testCompleteAuctionSuccessFull() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); + uint256 fillAmount = 1e6; bytes32[] memory offerIds = new bytes32[](1); - offerIds[0] = bytes32("offer id 1"); + offerIds[0] = offerId1; uint256[] memory fillAmounts = new uint256[](1); - fillAmounts[0] = 1e6; + fillAmounts[0] = fillAmount; uint256[] memory repoTokenAmounts = new uint256[](1); repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( - 1e6, repoToken1Week, TEST_REPO_TOKEN_RATE + fillAmount, repoToken1Week, TEST_REPO_TOKEN_RATE ); repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); + //console2.log("repoTokenAmounts[0]", repoTokenAmounts[0]); + // test: asset value should equal to initial asset value (liquid + pending offers) assertEq(termStrategy.totalAssetValue(), initialState.totalAssetValue); @@ -164,17 +169,18 @@ contract TestUSDCSubmitOffer is Setup { } function testCompleteAuctionSuccessPartial() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id 1"), 1e6); + uint256 fillAmount = 0.5e6; bytes32[] memory offerIds = new bytes32[](1); - offerIds[0] = bytes32("offer id 1"); + offerIds[0] = offerId1; uint256[] memory fillAmounts = new uint256[](1); // test: 50% filled - fillAmounts[0] = 0.5e6; + fillAmounts[0] = fillAmount; uint256[] memory repoTokenAmounts = new uint256[](1); repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( - 0.5e6, repoToken1Week, TEST_REPO_TOKEN_RATE + fillAmount, repoToken1Week, TEST_REPO_TOKEN_RATE ); repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); @@ -204,7 +210,7 @@ contract TestUSDCSubmitOffer is Setup { } function testCompleteAuctionCanceled() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); repoToken1WeekAuction.auctionCanceled(); @@ -217,8 +223,8 @@ contract TestUSDCSubmitOffer is Setup { } function testMultipleOffers() public { - _submitOffer(bytes32("offer id 1"), 1e6); - _submitOffer(bytes32("offer id 2"), 5e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); + bytes32 offerId2 = _submitOffer(bytes32("offer id hash 2"), 5e6); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - 6e6); // test: totalAssetValue = total liquid balance + pending offer amount @@ -227,7 +233,55 @@ contract TestUSDCSubmitOffer is Setup { bytes32[] memory offers = termStrategy.pendingOffers(); assertEq(offers.length, 2); - assertEq(offers[0], bytes32("offer id 2")); - assertEq(offers[1], bytes32("offer id 1")); + assertEq(offers[0], offerId2); + assertEq(offers[1], offerId1); + } + + function testMultipleOffersFillAndNoFill() public { + uint256 offer1Amount = 1e6; + uint256 offer2Amount = 5e6; + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), offer1Amount); + bytes32 offerId2 = _submitOffer(bytes32("offer id hash 2"), offer2Amount); + + bytes32[] memory offerIds = new bytes32[](2); + offerIds[0] = offerId1; + offerIds[1] = offerId2; + uint256[] memory fillAmounts = new uint256[](2); + + // test: offer 1 filled, offer 2 not filled + fillAmounts[0] = offer1Amount; + fillAmounts[1] = 0; + uint256[] memory repoTokenAmounts = new uint256[](2); + repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( + offer1Amount, repoToken1Week, TEST_REPO_TOKEN_RATE + ); + repoTokenAmounts[1] = 0; + + repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); + + // test: asset value should equal to initial asset value (liquid + pending offers) + assertEq(termStrategy.totalAssetValue(), initialState.totalAssetValue); + } + + function testEditOfferTotalGreaterThanCurrentLiquidity() public { + bytes32 idHash1 = bytes32("offer id hash 1"); + bytes32 offerId1 = _submitOffer(idHash1, 50e6); + + assertEq(termStrategy.totalLiquidBalance(), 50e6); + + _submitOffer(idHash1, 100e6); + + assertEq(termStrategy.totalLiquidBalance(), 0); + } + + function testEditOfferTotalLessThanCurrentLiquidity() public { + bytes32 idHash1 = bytes32("offer id hash 1"); + bytes32 offerId1 = _submitOffer(idHash1, 100e6); + + assertEq(termStrategy.totalLiquidBalance(), 0); + + _submitOffer(idHash1, 50e6); + + assertEq(termStrategy.totalLiquidBalance(), 50e6); } } diff --git a/src/test/TestUSDCSellRepoToken.t.sol b/src/test/TestUSDCSellRepoToken.t.sol index bbae1946..c18635f4 100644 --- a/src/test/TestUSDCSellRepoToken.t.sol +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -6,6 +6,7 @@ import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {ITokenizedStrategy} from "@tokenized-strategy/interfaces/ITokenizedStrategy.sol"; import {MockTermRepoToken} from "./mocks/MockTermRepoToken.sol"; import {MockTermController} from "./mocks/MockTermController.sol"; +import {MockTermAuction} from "./mocks/MockTermAuction.sol"; import {MockUSDC} from "./mocks/MockUSDC.sol"; import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol"; import {ITermRepoToken} from "../interfaces/term/ITermRepoToken.sol"; @@ -180,6 +181,22 @@ contract TestUSDCSellRepoToken is Setup { _sellRepoTokens(tokens, amounts, false, true, ""); } + function _sell2RepoTokens( + MockTermRepoToken rt1, + uint256 amount1, + MockTermRepoToken rt2, + uint256 amount2 + ) private { + address[] memory tokens = new address[](2); + tokens[0] = address(rt1); + tokens[1] = address(rt2); + uint256[] memory amounts = new uint256[](2); + amounts[0] = amount1; + amounts[1] = amount2; + + _sellRepoTokens(tokens, amounts, false, true, ""); + } + function _sell3RepoTokensCheckHoldings() private { address[] memory holdings = termStrategy.repoTokenHoldings(); @@ -264,6 +281,24 @@ contract TestUSDCSellRepoToken is Setup { assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1108800); } + // test: weighted maturity with both repo tokens and pending offers + function testSellMultipleRepoTokens_7_14_8_1_Offer_28_3() public { + _sell2RepoTokens(repoToken1Week, 8e18, repoToken2Week, 1e18); + + bytes32 idHash = bytes32("offer id hash 1"); + + MockTermAuction repoToken4WeekAuction = new MockTermAuction(repoToken4Week); + + mockUSDC.mint(address(termStrategy), 3e6); + + vm.prank(management); + termStrategy.submitAuctionOffer( + address(repoToken4WeekAuction), address(repoToken4Week), idHash, bytes32("test price"), 3e6 + ); + + assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1108800); + } + function testSetGovernanceParameters() public { MockTermController newController = new MockTermController(); diff --git a/src/test/mocks/MockTermAuctionOfferLocker.sol b/src/test/mocks/MockTermAuctionOfferLocker.sol index e749e05e..ff7e9f93 100644 --- a/src/test/mocks/MockTermAuctionOfferLocker.sol +++ b/src/test/mocks/MockTermAuctionOfferLocker.sol @@ -68,7 +68,9 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { // update locked amount offer.amount = submission.amount; } else { - offer.id = submission.id; + bytes32 offerId = keccak256(abi.encodePacked(submission.id,msg.sender,address(this))); + + offer.id = offerId; offer.offeror = submission.offeror; offer.offerPriceHash = submission.offerPriceHash; offer.amount = submission.amount;