diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 9fb5b1f1..8433819f 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -45,14 +45,14 @@ jobs: - name: run linter check on *.sol file run: yarn lint - commits: - runs-on: ubuntu-latest + # commits: + # runs-on: ubuntu-latest - steps: - - name: Check out github repository - uses: actions/checkout@v3 - with: - fetch-depth: 0 + # steps: + # - name: Check out github repository + # uses: actions/checkout@v3 + # with: + # fetch-depth: 0 - - name: Run commitlint - uses: wagoid/commitlint-github-action@v5 + # - name: Run commitlint + # uses: wagoid/commitlint-github-action@v5 diff --git a/.gitignore b/.gitignore index 01c15f79..704727d4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ docs/ node_modules/ .gas-snapshot -yarn.lock \ No newline at end of file +yarn.lock + +temp/ diff --git a/.gitmodules b/.gitmodules index a5533c1d..c16e716d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable diff --git a/foundry.toml b/foundry.toml index f3e432f9..519f9116 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,7 +5,8 @@ libs = ['lib'] solc = "0.8.18" remappings = [ - "@openzeppelin/=lib/openzeppelin-contracts/", + "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", "forge-std/=lib/forge-std/src/", "@tokenized-strategy/=lib/tokenized-strategy/src/", "@periphery/=lib/tokenized-strategy-periphery/src/", diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 00000000..2d081f24 --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 2d081f24cac1a867f6f73d512f2022e1fa987854 diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol new file mode 100644 index 00000000..41e2a7cd --- /dev/null +++ b/src/RepoTokenList.sol @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import "forge-std/console.sol"; +import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; +import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; +import {ITermRepoCollateralManager} from "./interfaces/term/ITermRepoCollateralManager.sol"; +import {ITermController, AuctionMetadata} from "./interfaces/term/ITermController.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {RepoTokenUtils} from "./RepoTokenUtils.sol"; + +struct RepoTokenListNode { + address next; +} + +struct RepoTokenListData { + address head; + mapping(address => RepoTokenListNode) nodes; + mapping(address => uint256) auctionRates; + /// @notice keyed by collateral token + mapping(address => uint256) collateralTokenParams; +} + +library RepoTokenList { + address public constant NULL_NODE = address(0); + uint256 internal constant INVALID_AUCTION_RATE = 0; + + error InvalidRepoToken(address token); + + function getRepoTokenMaturity(address repoToken) internal view returns (uint256 redemptionTimestamp) { + (redemptionTimestamp, , ,) = ITermRepoToken(repoToken).config(); + } + + function _getRepoTokenTimeToMaturity(uint256 redemptionTimestamp, address repoToken) private view returns (uint256) { + return redemptionTimestamp - block.timestamp; + } + + function _getNext(RepoTokenListData storage listData, address current) private view returns (address) { + return listData.nodes[current].next; + } + + function _count(RepoTokenListData storage listData) private view returns (uint256 count) { + if (listData.head == NULL_NODE) return 0; + address current = listData.head; + while (current != NULL_NODE) { + count++; + current = _getNext(listData, current); + } + } + + function holdings(RepoTokenListData storage listData) internal view returns (address[] memory holdings) { + uint256 count = _count(listData); + if (count > 0) { + holdings = new address[](count); + uint256 i; + address current = listData.head; + while (current != NULL_NODE) { + holdings[i++] = current; + current = _getNext(listData, current); + } + } + } + + 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 cumulativeWeightedTimeToMaturity, uint256 cumulativeRepoTokenAmount, bool found) { + if (listData.head == NULL_NODE) return (0, 0, false); + + address current = listData.head; + while (current != NULL_NODE) { + uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); + + if (repoTokenBalance > 0) { + 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 weightedTimeToMaturity = getRepoTokenWeightedTimeToMaturity( + current, repoTokenBalanceInBaseAssetPrecision + ); + + cumulativeWeightedTimeToMaturity += weightedTimeToMaturity; + cumulativeRepoTokenAmount += repoTokenBalanceInBaseAssetPrecision; + } + + current = _getNext(listData, current); + } + } + + function removeAndRedeemMaturedTokens(RepoTokenListData storage listData) internal { + if (listData.head == NULL_NODE) return; + + address current = listData.head; + address prev = current; + while (current != NULL_NODE) { + address next; + if (getRepoTokenMaturity(current) < block.timestamp) { + bool removeMaturedToken; + uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); + + if (repoTokenBalance > 0) { + (, , address termRepoServicer,) = ITermRepoToken(current).config(); + try ITermRepoServicer(termRepoServicer).redeemTermRepoTokens( + address(this), + repoTokenBalance + ) { + removeMaturedToken = true; + } catch { + // redemption failed, do not remove token from the list + } + } else { + // already redeemed + removeMaturedToken = true; + } + + next = _getNext(listData, current); + + if (removeMaturedToken) { + if (current == listData.head) { + listData.head = next; + } + + listData.nodes[prev].next = next; + delete listData.nodes[current]; + delete listData.auctionRates[current]; + } + } else { + /// @dev early exit because list is sorted + break; + } + + prev = current; + current = next; + } + } + + function getAuctionRate(ITermController termController, ITermRepoToken repoToken) internal view returns (uint256) { + (AuctionMetadata[] memory auctionMetadata, ) = termController.getTermAuctionResults(repoToken.termRepoId()); + + uint256 len = auctionMetadata.length; + + if (len == 0) { + revert InvalidRepoToken(address(repoToken)); + } + + return auctionMetadata[len - 1].auctionClearingRate; + } + + function validateRepoToken( + RepoTokenListData storage listData, + ITermRepoToken repoToken, + ITermController termController, + address asset + ) internal view returns (uint256 redemptionTimestamp) { + if (!termController.isTermDeployed(address(repoToken))) { + revert InvalidRepoToken(address(repoToken)); + } + + address purchaseToken; + address collateralManager; + (redemptionTimestamp, purchaseToken, , collateralManager) = repoToken.config(); + if (purchaseToken != address(asset)) { + revert InvalidRepoToken(address(repoToken)); + } + + // skip matured repo tokens + if (redemptionTimestamp < block.timestamp) { + revert InvalidRepoToken(address(repoToken)); + } + + uint256 numTokens = ITermRepoCollateralManager(collateralManager).numOfAcceptedCollateralTokens(); + + for (uint256 i; i < numTokens; i++) { + address currentToken = ITermRepoCollateralManager(collateralManager).collateralTokens(i); + uint256 minCollateralRatio = listData.collateralTokenParams[currentToken]; + + if (minCollateralRatio == 0) { + revert InvalidRepoToken(address(repoToken)); + } else if ( + ITermRepoCollateralManager(collateralManager).maintenanceCollateralRatios(currentToken) < minCollateralRatio + ) { + revert InvalidRepoToken(address(repoToken)); + } + } + } + + function validateAndInsertRepoToken( + RepoTokenListData storage listData, + ITermRepoToken repoToken, + ITermController termController, + address asset + ) internal returns (uint256 auctionRate, uint256 redemptionTimestamp) + { + auctionRate = listData.auctionRates[address(repoToken)]; + if (auctionRate != INVALID_AUCTION_RATE) { + (redemptionTimestamp, , ,) = repoToken.config(); + + // skip matured repo tokens + if (redemptionTimestamp < block.timestamp) { + revert InvalidRepoToken(address(repoToken)); + } + + uint256 oracleRate = getAuctionRate(termController, repoToken); + if (oracleRate != INVALID_AUCTION_RATE) { + if (auctionRate != oracleRate) { + listData.auctionRates[address(repoToken)] = oracleRate; + } + } + } else { + auctionRate = getAuctionRate(termController, repoToken); + + redemptionTimestamp = validateRepoToken(listData, repoToken, termController, asset); + + insertSorted(listData, address(repoToken)); + listData.auctionRates[address(repoToken)] = auctionRate; + } + } + + function insertSorted(RepoTokenListData storage listData, address repoToken) internal { + address current = listData.head; + + if (current == NULL_NODE) { + listData.head = repoToken; + return; + } + + address prev; + while (current != NULL_NODE) { + + // already in list + if (current == repoToken) { + break; + } + + uint256 currentMaturity = getRepoTokenMaturity(current); + uint256 maturityToInsert = getRepoTokenMaturity(repoToken); + + if (maturityToInsert <= currentMaturity) { + if (prev == NULL_NODE) { + listData.head = repoToken; + } else { + listData.nodes[prev].next = repoToken; + } + listData.nodes[repoToken].next = current; + break; + } + + address next = _getNext(listData, current); + + if (next == NULL_NODE) { + listData.nodes[current].next = repoToken; + break; + } + + prev = current; + current = next; + } + } + + function getPresentValue( + RepoTokenListData storage listData, + uint256 purchaseTokenPrecision + ) internal view returns (uint256 totalPresentValue) { + if (listData.head == NULL_NODE) return 0; + + address current = listData.head; + while (current != NULL_NODE) { + uint256 currentMaturity = getRepoTokenMaturity(current); + uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); + uint256 repoTokenPrecision = 10**ERC20(current).decimals(); + uint256 auctionRate = listData.auctionRates[current]; + + // (ratePrecision * repoPrecision * purchasePrecision) / (repoPrecision * ratePrecision) = purchasePrecision + uint256 repoTokenBalanceInBaseAssetPrecision = + (ITermRepoToken(current).redemptionValue() * repoTokenBalance * purchaseTokenPrecision) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + + if (currentMaturity > block.timestamp) { + totalPresentValue += RepoTokenUtils.calculatePresentValue( + repoTokenBalanceInBaseAssetPrecision, purchaseTokenPrecision, currentMaturity, auctionRate + ); + } else { + totalPresentValue += repoTokenBalanceInBaseAssetPrecision; + } + + current = _getNext(listData, current); + } + } +} diff --git a/src/RepoTokenUtils.sol b/src/RepoTokenUtils.sol new file mode 100644 index 00000000..37653209 --- /dev/null +++ b/src/RepoTokenUtils.sol @@ -0,0 +1,54 @@ +// 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; + + function repoToPurchasePrecision( + uint256 repoTokenPrecision, + uint256 purchaseTokenPrecision, + uint256 purchaseTokenAmountInRepoPrecision + ) internal pure returns (uint256) { + return (purchaseTokenAmountInRepoPrecision * purchaseTokenPrecision) / repoTokenPrecision; + } + + function purchaseToRepoPrecision( + uint256 repoTokenPrecision, + uint256 purchaseTokenPrecision, + uint256 repoTokenAmount + ) internal pure returns (uint256) { + return (repoTokenAmount * repoTokenPrecision) / purchaseTokenPrecision; + } + + function calculatePresentValue( + uint256 repoTokenAmountInBaseAssetPrecision, + uint256 purchaseTokenPrecision, + uint256 redemptionTimestamp, + uint256 auctionRate + ) internal view returns (uint256 presentValue) { + uint256 timeLeftToMaturityDayFraction = + ((redemptionTimestamp - block.timestamp) * purchaseTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; + + // repoTokenAmountInBaseAssetPrecision / (1 + r * days / 360) + presentValue = + (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 72499cd9..36f287d5 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -2,10 +2,18 @@ pragma solidity 0.8.18; import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -// Import interfaces for many popular DeFi projects, or add your own! -//import "../interfaces//.sol"; +import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; +import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; +import {ITermController} from "./interfaces/term/ITermController.sol"; +import {ITermVaultEvents} from "./interfaces/term/ITermVaultEvents.sol"; +import {ITermAuctionOfferLocker} from "./interfaces/term/ITermAuctionOfferLocker.sol"; +import {ITermRepoCollateralManager} from "./interfaces/term/ITermRepoCollateralManager.sol"; +import {ITermAuction} from "./interfaces/term/ITermAuction.sol"; +import {RepoTokenList, RepoTokenListData} from "./RepoTokenList.sol"; +import {TermAuctionList, TermAuctionListData, PendingOffer} from "./TermAuctionList.sol"; +import {RepoTokenUtils} from "./RepoTokenUtils.sol"; /** * The `TokenizedStrategy` variable can be used to retrieve the strategies @@ -21,12 +29,420 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol // NOTE: To implement permissioned functions you can use the onlyManagement, onlyEmergencyAuthorized and onlyKeepers modifiers contract Strategy is BaseStrategy { - using SafeERC20 for ERC20; + using SafeERC20 for IERC20; + using RepoTokenList for RepoTokenListData; + using TermAuctionList for TermAuctionListData; + + error InvalidTermAuction(address auction); + error TimeToMaturityAboveThreshold(); + error BalanceBelowLiquidityThreshold(); + error InsufficientLiquidBalance(uint256 have, uint256 want); + + ITermVaultEvents public immutable TERM_VAULT_EVENT_EMITTER; + uint256 public immutable PURCHASE_TOKEN_PRECISION; + IERC4626 public immutable YEARN_VAULT; + + ITermController public termController; + RepoTokenListData internal repoTokenListData; + TermAuctionListData internal termAuctionListData; + uint256 public timeToMaturityThreshold; // seconds + uint256 public liquidityThreshold; // purchase token precision (underlying) + uint256 public auctionRateMarkup; // 1e18 (TODO: check this) + + // These governance functions should have a different role + function setTermController(address newTermController) external onlyManagement { + require(newTermController != address(0)); + TERM_VAULT_EVENT_EMITTER.emitTermControllerUpdated(address(termController), newTermController); + termController = ITermController(newTermController); + } + + function setTimeToMaturityThreshold(uint256 newTimeToMaturityThreshold) external onlyManagement { + TERM_VAULT_EVENT_EMITTER.emitTimeToMaturityThresholdUpdated(timeToMaturityThreshold, newTimeToMaturityThreshold); + timeToMaturityThreshold = newTimeToMaturityThreshold; + } + + function setLiquidityThreshold(uint256 newLiquidityThreshold) external onlyManagement { + TERM_VAULT_EVENT_EMITTER.emitLiquidityThresholdUpdated(liquidityThreshold, newLiquidityThreshold); + liquidityThreshold = newLiquidityThreshold; + } + + function setAuctionRateMarkup(uint256 newAuctionRateMarkup) external onlyManagement { + TERM_VAULT_EVENT_EMITTER.emitAuctionRateMarkupUpdated(auctionRateMarkup, newAuctionRateMarkup); + auctionRateMarkup = newAuctionRateMarkup; + } + + function setCollateralTokenParams(address tokenAddr, uint256 minCollateralRatio) external onlyManagement { + TERM_VAULT_EVENT_EMITTER.emitMinCollateralRatioUpdated(tokenAddr, minCollateralRatio); + repoTokenListData.collateralTokenParams[tokenAddr] = minCollateralRatio; + } + + function repoTokenHoldings() external view returns (address[] memory) { + return repoTokenListData.holdings(); + } + + function pendingOffers() external view returns (bytes32[] memory) { + return termAuctionListData.pendingOffers(); + } + + function _calculateWeightedMaturity( + address repoToken, + uint256 repoTokenAmount, + uint256 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) { + // do not validate if we are simulating with existing repo tokens + if (repoToken != address(0)) { + repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); + } + return _calculateWeightedMaturity(repoToken, amount, _totalLiquidBalance(address(this))); + } + + function calculateRepoTokenPresentValue( + address repoToken, + uint256 auctionRate, + uint256 amount + ) external view returns (uint256) { + (uint256 redemptionTimestamp, , ,) = ITermRepoToken(repoToken).config(); + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 repoTokenAmountInBaseAssetPrecision = + (ITermRepoToken(repoToken).redemptionValue() * amount * PURCHASE_TOKEN_PRECISION) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + return RepoTokenUtils.calculatePresentValue( + repoTokenAmountInBaseAssetPrecision, + PURCHASE_TOKEN_PRECISION, + redemptionTimestamp, + auctionRate + ); + } + + function _totalLiquidBalance(address addr) private view returns (uint256) { + uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); + return _assetBalance() + underlyingBalance; + } + + function _sweepAssetAndRedeemRepoTokens(uint256 liquidAmountRequired) private { + termAuctionListData.removeCompleted(repoTokenListData, termController, address(asset)); + repoTokenListData.removeAndRedeemMaturedTokens(); + + uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); + if (underlyingBalance > liquidAmountRequired) { + unchecked { + YEARN_VAULT.deposit(underlyingBalance - liquidAmountRequired, address(this)); + } + } else if (underlyingBalance < liquidAmountRequired) { + unchecked { + _withdrawAsset(liquidAmountRequired - underlyingBalance); + } + } + } + + function _withdrawAsset(uint256 amount) private { + YEARN_VAULT.withdraw(YEARN_VAULT.convertToShares(amount), address(this), address(this)); + } + + function _assetBalance() private view returns (uint256) { + return YEARN_VAULT.convertToAssets(YEARN_VAULT.balanceOf(address(this))); + } + + // TODO: reentrancy check + function sellRepoToken(address repoToken, uint256 repoTokenAmount) external { + require(repoTokenAmount > 0); + + (uint256 auctionRate, uint256 redemptionTimestamp) = repoTokenListData.validateAndInsertRepoToken( + ITermRepoToken(repoToken), + termController, + address(asset) + ); + + _sweepAssetAndRedeemRepoTokens(0); + + uint256 liquidBalance = _totalLiquidBalance(address(this)); + require(liquidBalance > 0); + + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 repoTokenAmountInBaseAssetPrecision = + (ITermRepoToken(repoToken).redemptionValue() * repoTokenAmount * PURCHASE_TOKEN_PRECISION) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + uint256 proceeds = RepoTokenUtils.calculatePresentValue( + repoTokenAmountInBaseAssetPrecision, + PURCHASE_TOKEN_PRECISION, + redemptionTimestamp, + auctionRate + auctionRateMarkup + ); + + if (liquidBalance < proceeds) { + revert InsufficientLiquidBalance(liquidBalance, proceeds); + } + + uint256 resultingTimeToMaturity = _calculateWeightedMaturity( + repoToken, repoTokenAmount, liquidBalance - proceeds + ); + + if (resultingTimeToMaturity > timeToMaturityThreshold) { + revert TimeToMaturityAboveThreshold(); + } + + liquidBalance -= proceeds; + + if (liquidBalance < liquidityThreshold) { + revert BalanceBelowLiquidityThreshold(); + } + + // withdraw from underlying vault + _withdrawAsset(proceeds); + + IERC20(repoToken).safeTransferFrom(msg.sender, address(this), repoTokenAmount); + IERC20(asset).safeTransfer(msg.sender, proceeds); + } + + function deleteAuctionOffers(address termAuction, bytes32[] calldata offerIds) external onlyManagement { + if (!termController.isTermDeployed(termAuction)) { + revert InvalidTermAuction(termAuction); + } + + ITermAuction auction = ITermAuction(termAuction); + ITermAuctionOfferLocker offerLocker = ITermAuctionOfferLocker(auction.termAuctionOfferLocker()); + + offerLocker.unlockOffers(offerIds); + + termAuctionListData.removeCompleted(repoTokenListData, termController, address(asset)); + + _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, + bytes32 idHash, + bytes32 offerPriceHash, + uint256 purchaseTokenAmount + ) external onlyManagement returns (bytes32[] memory offerIds) { + require(purchaseTokenAmount > 0); + + if (!termController.isTermDeployed(termAuction)) { + revert InvalidTermAuction(termAuction); + } + + ITermAuction auction = ITermAuction(termAuction); + + require(auction.termRepoId() == ITermRepoToken(repoToken).termRepoId()); + + // validate purchase token and min collateral ratio + repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); + + ITermAuctionOfferLocker offerLocker = ITermAuctionOfferLocker(auction.termAuctionOfferLocker()); + require( + block.timestamp > offerLocker.auctionStartTime() + || block.timestamp < auction.auctionEndTime(), + "Auction not open" + ); + + _sweepAssetAndRedeemRepoTokens(0); //@dev sweep to ensure liquid balances up to date + + uint256 liquidBalance = _totalLiquidBalance(address(this)); + 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 = currentOfferAmount > 0 ? offerId : idHash; + offer.offeror = address(this); + offer.offerPriceHash = offerPriceHash; + offer.amount = purchaseTokenAmount; + offer.purchaseToken = address(asset); + + offerIds = _submitOffer( + auction, + offerLocker, + offer, + repoToken, + newOfferAmount, + currentOfferAmount + ); + } + + function _submitOffer( + ITermAuction auction, + ITermAuctionOfferLocker offerLocker, + ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer, + address repoToken, + uint256 newOfferAmount, + uint256 currentOfferAmount + ) private returns (bytes32[] memory offerIds) { + ITermRepoServicer repoServicer = ITermRepoServicer(offerLocker.termRepoServicer()); + + ITermAuctionOfferLocker.TermAuctionOfferSubmission[] memory offerSubmissions = + new ITermAuctionOfferLocker.TermAuctionOfferSubmission[](1); + offerSubmissions[0] = offer; + + 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 (currentOfferAmount == 0) { + // new offer + termAuctionListData.insertPending(offerIds[0], PendingOffer({ + repoToken: repoToken, + offerAmount: offer.amount, + termAuction: auction, + offerLocker: offerLocker + })); + } else { + // edit offer, overwrite existing + termAuctionListData.offers[offerIds[0]] = PendingOffer({ + repoToken: repoToken, + offerAmount: offer.amount, + termAuction: auction, + offerLocker: offerLocker + }); + } + } + + function auctionClosed() external { + _sweepAssetAndRedeemRepoTokens(0); + } + + function totalAssetValue() external view returns (uint256) { + return _totalAssetValue(); + } + + function totalLiquidBalance() external view returns (uint256) { + return _totalLiquidBalance(address(this)); + } + + function _totalAssetValue() internal view returns (uint256 totalValue) { + return _totalLiquidBalance(address(this)) + + repoTokenListData.getPresentValue(PURCHASE_TOKEN_PRECISION) + + termAuctionListData.getPresentValue(repoTokenListData, termController, PURCHASE_TOKEN_PRECISION); + } constructor( address _asset, - string memory _name - ) BaseStrategy(_asset, _name) {} + string memory _name, + address _yearnVault, + address _eventEmitter + ) BaseStrategy(_asset, _name) { + YEARN_VAULT = IERC4626(_yearnVault); + TERM_VAULT_EVENT_EMITTER = ITermVaultEvents(_eventEmitter); + PURCHASE_TOKEN_PRECISION = 10**ERC20(asset).decimals(); + + IERC20(_asset).safeApprove(_yearnVault, type(uint256).max); + } /*////////////////////////////////////////////////////////////// NEEDED TO BE OVERRIDDEN BY STRATEGIST @@ -43,10 +459,8 @@ contract Strategy is BaseStrategy { * @param _amount The amount of 'asset' that the strategy can attempt * to deposit in the yield source. */ - function _deployFunds(uint256 _amount) internal override { - // TODO: implement deposit logic EX: - // - // lendingPool.deposit(address(asset), _amount ,0); + function _deployFunds(uint256 _amount) internal override { + _sweepAssetAndRedeemRepoTokens(0); } /** @@ -70,10 +484,8 @@ contract Strategy is BaseStrategy { * * @param _amount, The amount of 'asset' to be freed. */ - function _freeFunds(uint256 _amount) internal override { - // TODO: implement withdraw logic EX: - // - // lendingPool.withdraw(address(asset), _amount); + function _freeFunds(uint256 _amount) internal override { + _sweepAssetAndRedeemRepoTokens(_amount); } /** @@ -103,14 +515,8 @@ contract Strategy is BaseStrategy { override returns (uint256 _totalAssets) { - // TODO: Implement harvesting logic and accurate accounting EX: - // - // if(!TokenizedStrategy.isShutdown()) { - // _claimAndSellRewards(); - // } - // _totalAssets = aToken.balanceOf(address(this)) + asset.balanceOf(address(this)); - // - _totalAssets = asset.balanceOf(address(this)); + _sweepAssetAndRedeemRepoTokens(0); + return _totalAssetValue(); } /*////////////////////////////////////////////////////////////// diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol new file mode 100644 index 00000000..0a87d899 --- /dev/null +++ b/src/TermAuctionList.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermAuction} from "./interfaces/term/ITermAuction.sol"; +import {ITermAuctionOfferLocker} from "./interfaces/term/ITermAuctionOfferLocker.sol"; +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 { + bytes32 next; +} + +struct TermAuctionListData { + bytes32 head; + mapping(bytes32 => TermAuctionListNode) nodes; + mapping(bytes32 => PendingOffer) offers; +} + +library TermAuctionList { + using RepoTokenList for RepoTokenListData; + + bytes32 public constant NULL_NODE = bytes32(0); + + function _getNext(TermAuctionListData storage listData, bytes32 current) private view returns (bytes32) { + return listData.nodes[current].next; + } + + function _count(TermAuctionListData storage listData) internal view returns (uint256 count) { + if (listData.head == NULL_NODE) return 0; + bytes32 current = listData.head; + while (current != NULL_NODE) { + count++; + current = _getNext(listData, current); + } + } + + function pendingOffers(TermAuctionListData storage listData) internal view returns (bytes32[] memory offers) { + uint256 count = _count(listData); + if (count > 0) { + offers = new bytes32[](count); + uint256 i; + bytes32 current = listData.head; + while (current != NULL_NODE) { + offers[i++] = current; + current = _getNext(listData, current); + } + } + } + + function insertPending(TermAuctionListData storage listData, bytes32 offerId, PendingOffer memory pendingOffer) internal { + bytes32 current = listData.head; + + if (current != NULL_NODE) { + listData.nodes[offerId].next = current; + } + + listData.head = offerId; + listData.offers[offerId] = pendingOffer; + } + + function removeCompleted( + TermAuctionListData storage listData, + RepoTokenListData storage repoTokenListData, + 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; + bytes32 prev = current; + while (current != NULL_NODE) { + PendingOffer memory offer = listData.offers[current]; + bytes32 next = _getNext(listData, current); + + uint256 offerAmount = offer.offerLocker.lockedOffer(current).amount; + bool removeNode; + bool insertRepoToken; + + if (offer.termAuction.auctionCompleted()) { + removeNode = true; + insertRepoToken = true; + } else { + if (offerAmount == 0) { + // auction canceled or deleted + removeNode = true; + } else { + // offer pending, do nothing + } + + if (offer.termAuction.auctionCancelledForWithdrawal()) { + removeNode = true; + + // withdraw manually + bytes32[] memory offerIds = new bytes32[](1); + offerIds[0] = current; + offer.offerLocker.unlockOffers(offerIds); + } + } + + if (removeNode) { + if (current == listData.head) { + listData.head = next; + } + + listData.nodes[prev].next = next; + delete listData.nodes[current]; + delete listData.offers[current]; + } + + if (insertRepoToken) { + repoTokenListData.validateAndInsertRepoToken(ITermRepoToken(offer.repoToken), termController, asset); + } + + prev = current; + current = next; + } + } + + 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, + ITermController termController, + uint256 purchaseTokenPrecision + ) internal view returns (uint256 totalValue) { + if (listData.head == NULL_NODE) return 0; + + PendingOfferMemory[] memory offers = _loadOffers(listData); + + 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() && 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; + } + } + } + + 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/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol new file mode 100644 index 00000000..bb427eb6 --- /dev/null +++ b/src/TermVaultEventEmitter.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.18; + +import "./interfaces/term/ITermVaultEvents.sol"; +import "@openzeppelin/contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; + +contract TermVaultEventEmitter is Initializable, UUPSUpgradeable, AccessControlUpgradeable, ITermVaultEvents { + + bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); + bytes32 public constant DEVOPS_ROLE = keccak256("DEVOPS_ROLE"); + bytes32 public constant VAULT_CONTRACT = keccak256("VAULT_CONTRACT"); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// Initializes the contract + /// @dev See: https://docs.openzeppelin.com/contracts/4.x/upgradeable + function initialize( + address adminWallet_, + address devopsWallet_ + ) + external initializer { + UUPSUpgradeable.__UUPSUpgradeable_init(); + AccessControlUpgradeable.__AccessControl_init(); + + _grantRole(ADMIN_ROLE, adminWallet_); + _grantRole(DEVOPS_ROLE, devopsWallet_); + } + + function pairVaultContract(address vaultContract) external onlyRole(ADMIN_ROLE){ + _grantRole(VAULT_CONTRACT, vaultContract); + } + + function emitTermControllerUpdated(address oldController, address newController) external onlyRole(VAULT_CONTRACT) { + emit TermControllerUpdated(oldController, newController); + } + + function emitTimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external onlyRole(VAULT_CONTRACT) { + emit TimeToMaturityThresholdUpdated(oldThreshold, newThreshold); + } + + function emitLiquidityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external onlyRole(VAULT_CONTRACT) { + emit LiquidityThresholdUpdated(oldThreshold, newThreshold); + } + + function emitAuctionRateMarkupUpdated(uint256 oldMarkup, uint256 newMarkup) external onlyRole(VAULT_CONTRACT) { + emit AuctionRateMarkupUpdated(oldMarkup, newMarkup); + } + + function emitMinCollateralRatioUpdated(address collateral, uint256 minCollateralRatio) external onlyRole(VAULT_CONTRACT) { + emit MinCollateralRatioUpdated(collateral, minCollateralRatio); + } + + // ======================================================================== + // = Admin =============================================================== + // ======================================================================== + + // solhint-disable no-empty-blocks + ///@dev required override by the OpenZeppelin UUPS module + function _authorizeUpgrade( + address + ) internal view override onlyRole(DEVOPS_ROLE) {} + // solhint-enable no-empty-blocks +} \ No newline at end of file diff --git a/src/interfaces/IStrategyInterface.sol b/src/interfaces/IStrategyInterface.sol index 49799bac..ab596629 100644 --- a/src/interfaces/IStrategyInterface.sol +++ b/src/interfaces/IStrategyInterface.sol @@ -4,5 +4,5 @@ pragma solidity 0.8.18; import {IStrategy} from "@tokenized-strategy/interfaces/IStrategy.sol"; interface IStrategyInterface is IStrategy { - //TODO: Add your specific implementation interface in here. + function setTermController(address newTermController) external; } diff --git a/src/interfaces/term/ITermAuction.sol b/src/interfaces/term/ITermAuction.sol new file mode 100644 index 00000000..84ed42a5 --- /dev/null +++ b/src/interfaces/term/ITermAuction.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermAuction { + function termAuctionOfferLocker() external view returns (address); + + function termRepoId() external view returns (bytes32); + + function auctionEndTime() external view returns (uint256); + + function auctionCompleted() external view returns (bool); + + function auctionCancelledForWithdrawal() external view returns (bool); +} \ No newline at end of file diff --git a/src/interfaces/term/ITermAuctionOfferLocker.sol b/src/interfaces/term/ITermAuctionOfferLocker.sol new file mode 100644 index 00000000..f7777f38 --- /dev/null +++ b/src/interfaces/term/ITermAuctionOfferLocker.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermAuctionOfferLocker { + /// @dev TermAuctionOfferSubmission represents an offer submission to offeror an amount of money for a specific interest rate + struct TermAuctionOfferSubmission { + /// @dev For an existing offer this is the unique onchain identifier for this offer. For a new offer this is a randomized input that will be used to generate the unique onchain identifier. + bytes32 id; + /// @dev The address of the offeror + address offeror; + /// @dev Hash of the offered price as a percentage of the initial loaned amount vs amount returned at maturity. This stores 9 decimal places + bytes32 offerPriceHash; + /// @dev The maximum amount of purchase tokens that can be lent + uint256 amount; + /// @dev The address of the ERC20 purchase token + address purchaseToken; + } + + /// @dev TermAuctionOffer represents an offer to offeror an amount of money for a specific interest rate + struct TermAuctionOffer { + /// @dev Unique identifier for this bid + bytes32 id; + /// @dev The address of the offeror + address offeror; + /// @dev Hash of the offered price as a percentage of the initial loaned amount vs amount returned at maturity. This stores 9 decimal places + bytes32 offerPriceHash; + /// @dev Revealed offer price. This is not valid unless isRevealed is true. This stores 18 decimal places + uint256 offerPriceRevealed; + /// @dev The maximum amount of purchase tokens that can be lent + uint256 amount; + /// @dev The address of the ERC20 purchase token + address purchaseToken; + /// @dev Is offer price revealed + bool isRevealed; + } + + function termRepoId() external view returns (bytes32); + + function termAuctionId() external view returns (bytes32); + + function auctionStartTime() external view returns (uint256); + + function auctionEndTime() external view returns (uint256); + + function revealTime() external view returns (uint256); + + function purchaseToken() external view returns (address); + + function termRepoServicer() external view returns (address); + + function lockedOffer(bytes32 id) external view returns (TermAuctionOffer memory); + + /// @param offerSubmissions An array of offer submissions + /// @return A bytes32 array of unique on chain offer ids. + function lockOffers( + TermAuctionOfferSubmission[] calldata offerSubmissions + ) external returns (bytes32[] memory); + + function unlockOffers(bytes32[] calldata offerIds) external; +} diff --git a/src/interfaces/term/ITermController.sol b/src/interfaces/term/ITermController.sol new file mode 100644 index 00000000..7077cfaa --- /dev/null +++ b/src/interfaces/term/ITermController.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +struct AuctionMetadata { + bytes32 termAuctionId; + uint256 auctionClearingRate; + uint256 auctionClearingBlockTimestamp; +} + +interface ITermController { + function isTermDeployed(address contractAddress) external view returns (bool); + + function getTermAuctionResults(bytes32 termRepoId) external view returns (AuctionMetadata[] memory auctionMetadata, uint8 numOfAuctions); +} diff --git a/src/interfaces/term/ITermRepoCollateralManager.sol b/src/interfaces/term/ITermRepoCollateralManager.sol new file mode 100644 index 00000000..a1fd1583 --- /dev/null +++ b/src/interfaces/term/ITermRepoCollateralManager.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermRepoCollateralManager { + function maintenanceCollateralRatios( + address + ) external view returns (uint256); + + function numOfAcceptedCollateralTokens() external view returns (uint8); + + function collateralTokens(uint256 index) external view returns (address); +} diff --git a/src/interfaces/term/ITermRepoServicer.sol b/src/interfaces/term/ITermRepoServicer.sol new file mode 100644 index 00000000..2b2e9427 --- /dev/null +++ b/src/interfaces/term/ITermRepoServicer.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermRepoServicer { + function redeemTermRepoTokens( + address redeemer, + uint256 amountToRedeem + ) external; + + function termRepoToken() external view returns (address); + + function termRepoLocker() external view returns (address); + + function purchaseToken() external view returns (address); +} diff --git a/src/interfaces/term/ITermRepoToken.sol b/src/interfaces/term/ITermRepoToken.sol new file mode 100644 index 00000000..84951298 --- /dev/null +++ b/src/interfaces/term/ITermRepoToken.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface ITermRepoToken is IERC20 { + function redemptionValue() external view returns (uint256); + + function config() external view returns ( + uint256 redemptionTimestamp, + address purchaseToken, + address termRepoServicer, + address termRepoCollateralManager + ); + + function termRepoId() external view returns (bytes32); +} diff --git a/src/interfaces/term/ITermVaultEvents.sol b/src/interfaces/term/ITermVaultEvents.sol new file mode 100644 index 00000000..29fe4682 --- /dev/null +++ b/src/interfaces/term/ITermVaultEvents.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermVaultEvents { + event TermControllerUpdated(address oldController, address newController); + + event TimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold); + + event LiquidityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold); + + event AuctionRateMarkupUpdated(uint256 oldMarkup, uint256 newMarkup); + + event MinCollateralRatioUpdated(address collateral, uint256 minCollateralRatio); + + function emitTermControllerUpdated(address oldController, address newController) external; + + function emitTimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external; + + function emitLiquidityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external; + + function emitAuctionRateMarkupUpdated(uint256 oldMarkup, uint256 newMarkup) external; + + function emitMinCollateralRatioUpdated(address collateral, uint256 minCollateralRatio) external; +} diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol new file mode 100644 index 00000000..1a11bfe7 --- /dev/null +++ b/src/test/TestUSDCOffers.t.sol @@ -0,0 +1,287 @@ +pragma solidity ^0.8.18; + +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"; +import {MockUSDC} from "./mocks/MockUSDC.sol"; +import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol"; +import {Strategy} from "../Strategy.sol"; + +contract TestUSDCSubmitOffer is Setup { + uint256 internal constant TEST_REPO_TOKEN_RATE = 0.05e18; + + MockUSDC internal mockUSDC; + ERC20Mock internal mockCollateral; + MockTermRepoToken internal repoToken1Week; + MockTermAuction internal repoToken1WeekAuction; + Strategy internal termStrategy; + StrategySnapshot internal initialState; + + function setUp() public override { + mockUSDC = new MockUSDC(); + mockCollateral = new ERC20Mock(); + + _setUp(ERC20(address(mockUSDC))); + + repoToken1Week = new MockTermRepoToken( + bytes32("test repo token 1"), address(mockUSDC), address(mockCollateral), 1e18, 1 weeks + ); + termController.setOracleRate(MockTermRepoToken(repoToken1Week).termRepoId(), TEST_REPO_TOKEN_RATE); + + termStrategy = Strategy(address(strategy)); + + repoToken1WeekAuction = new MockTermAuction(repoToken1Week); + + vm.startPrank(management); + termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); + termStrategy.setTimeToMaturityThreshold(3 weeks); + vm.stopPrank(); + + // start with some initial funds + mockUSDC.mint(address(strategy), 100e6); + + initialState.totalAssetValue = termStrategy.totalAssetValue(); + initialState.totalLiquidBalance = termStrategy.totalLiquidBalance(); + } + + 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), idHash, bytes32("test price"), offerAmount + ); + + vm.prank(management); + offerIds = termStrategy.submitAuctionOffer( + address(repoToken1WeekAuction), address(repoToken1Week), idHash, bytes32("test price"), offerAmount + ); + + assertEq(offerIds.length, 1); + + return offerIds[0]; + } + + function testSubmitOffer() public { + _submitOffer(bytes32("offer id hash 1"), 1e6); + + assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - 1e6); + // test: totalAssetValue = total liquid balance + pending offer amount + assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance() + 1e6); + } + + function testEditOffer() public { + 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), idHash1, bytes32("test price"), offerAmount + ); + + assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - offerAmount); + // test: totalAssetValue = total liquid balance + pending offer amount + assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance() + offerAmount); + } + + function testDeleteOffers() public { + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); + + bytes32[] memory offerIds = new bytes32[](1); + + offerIds[0] = offerId1; + + vm.expectRevert("!management"); + termStrategy.deleteAuctionOffers(address(repoToken1WeekAuction), offerIds); + + vm.prank(management); + termStrategy.deleteAuctionOffers(address(repoToken1WeekAuction), offerIds); + + assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance); + assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance()); + } + + uint256 public constant THREESIXTY_DAYCOUNT_SECONDS = 360 days; + uint256 public constant RATE_PRECISION = 1e18; + + function _getRepoTokenAmountGivenPurchaseTokenAmount( + uint256 purchaseTokenAmount, + MockTermRepoToken termRepoToken, + uint256 auctionRate + ) private view returns (uint256) { + (uint256 redemptionTimestamp, address purchaseToken, ,) = termRepoToken.config(); + + uint256 purchaseTokenPrecision = 10**ERC20(purchaseToken).decimals(); + uint256 repoTokenPrecision = 10**ERC20(address(termRepoToken)).decimals(); + + uint256 timeLeftToMaturityDayFraction = + ((redemptionTimestamp - block.timestamp) * purchaseTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; + + // purchaseTokenAmount * (1 + r * days / 360) = repoTokenAmountInBaseAssetPrecision + uint256 repoTokenAmountInBaseAssetPrecision = + purchaseTokenAmount * (purchaseTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)) / purchaseTokenPrecision; + + return repoTokenAmountInBaseAssetPrecision * repoTokenPrecision / purchaseTokenPrecision; + } + + function testCompleteAuctionSuccessFull() public { + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); + uint256 fillAmount = 1e6; + + bytes32[] memory offerIds = new bytes32[](1); + offerIds[0] = offerId1; + uint256[] memory fillAmounts = new uint256[](1); + fillAmounts[0] = fillAmount; + uint256[] memory repoTokenAmounts = new uint256[](1); + repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( + 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); + + address[] memory holdings = termStrategy.repoTokenHoldings(); + + // test: 0 holding because auctionClosed not yet called + assertEq(holdings.length, 0); + + termStrategy.auctionClosed(); + + // test: asset value should equal to initial asset value (liquid + repo tokens) + assertEq(termStrategy.totalAssetValue(), initialState.totalAssetValue); + + holdings = termStrategy.repoTokenHoldings(); + + // test: check repo token holdings + assertEq(holdings.length, 1); + assertEq(holdings[0], address(repoToken1Week)); + + bytes32[] memory offers = termStrategy.pendingOffers(); + + assertEq(offers.length, 0); + } + + function testCompleteAuctionSuccessPartial() public { + bytes32 offerId1 = _submitOffer(bytes32("offer id 1"), 1e6); + uint256 fillAmount = 0.5e6; + + bytes32[] memory offerIds = new bytes32[](1); + offerIds[0] = offerId1; + uint256[] memory fillAmounts = new uint256[](1); + + // test: 50% filled + fillAmounts[0] = fillAmount; + uint256[] memory repoTokenAmounts = new uint256[](1); + repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( + fillAmount, repoToken1Week, TEST_REPO_TOKEN_RATE + ); + + repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); + + // test: asset value should equal to initial asset value (liquid + pending offers) + assertEq(termStrategy.totalAssetValue(), initialState.totalAssetValue); + + address[] memory holdings = termStrategy.repoTokenHoldings(); + + // test: 0 holding because auctionClosed not yet called + assertEq(holdings.length, 0); + + termStrategy.auctionClosed(); + + // test: asset value should equal to initial asset value (liquid + repo tokens) + assertEq(termStrategy.totalAssetValue(), initialState.totalAssetValue); + + holdings = termStrategy.repoTokenHoldings(); + + // test: check repo token holdings + assertEq(holdings.length, 1); + assertEq(holdings[0], address(repoToken1Week)); + + bytes32[] memory offers = termStrategy.pendingOffers(); + + assertEq(offers.length, 0); + } + + function testCompleteAuctionCanceled() public { + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); + + repoToken1WeekAuction.auctionCanceled(); + + // test: check value before calling complete auction + termStrategy.auctionClosed(); + + bytes32[] memory offers = termStrategy.pendingOffers(); + + assertEq(offers.length, 0); + } + + function testMultipleOffers() public { + 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 + assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance() + 6e6); + + bytes32[] memory offers = termStrategy.pendingOffers(); + + assertEq(offers.length, 2); + 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 new file mode 100644 index 00000000..c18635f4 --- /dev/null +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -0,0 +1,512 @@ +pragma solidity ^0.8.18; + +import "forge-std/console.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/ERC20Mock.sol"; +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"; +import {RepoTokenList} from "../RepoTokenList.sol"; +import {Strategy} from "../Strategy.sol"; + +contract TestUSDCSellRepoToken is Setup { + + MockUSDC internal mockUSDC; + ERC20Mock internal mockCollateral; + MockTermRepoToken internal repoToken1Week; + MockTermRepoToken internal repoToken2Week; + MockTermRepoToken internal repoToken4Week; + MockTermRepoToken internal repoTokenMatured; + Strategy internal termStrategy; + StrategySnapshot internal initialState; + + function setUp() public override { + mockUSDC = new MockUSDC(); + mockCollateral = new ERC20Mock(); + + _setUp(ERC20(address(mockUSDC))); + + repoToken1Week = new MockTermRepoToken( + bytes32("test repo token 1"), address(mockUSDC), address(mockCollateral), 1e18, block.timestamp + 1 weeks + ); + repoToken2Week = new MockTermRepoToken( + bytes32("test repo token 2"), address(mockUSDC), address(mockCollateral), 1e18, block.timestamp + 2 weeks + ); + repoToken4Week = new MockTermRepoToken( + bytes32("test repo token 3"), address(mockUSDC), address(mockCollateral), 1e18, block.timestamp + 4 weeks + ); + repoTokenMatured = new MockTermRepoToken( + bytes32("test repo token 4"), address(mockUSDC), address(mockCollateral), 1e18, block.timestamp - 1 + ); + + termStrategy = Strategy(address(strategy)); + + vm.startPrank(management); + termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); + termStrategy.setTimeToMaturityThreshold(10 weeks); + vm.stopPrank(); + + } + + function _initState() private { + initialState.totalAssetValue = termStrategy.totalAssetValue(); + initialState.totalLiquidBalance = termStrategy.totalLiquidBalance(); + } + + function testSellSingleRepoToken() public { + // start with some initial funds + mockUSDC.mint(address(strategy), 100e6); + _initState(); + + // TODO: fuzz this + uint256 repoTokenSellAmount = 1e18; + + address testUser = vm.addr(0x11111); + + repoToken1Week.mint(testUser, 1000e18); + + vm.prank(testUser); + repoToken1Week.approve(address(strategy), type(uint256).max); + + termController.setOracleRate(repoToken1Week.termRepoId(), 0.05e18); + + vm.startPrank(management); + termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); + termStrategy.setTimeToMaturityThreshold(3 weeks); + vm.stopPrank(); + + vm.prank(testUser); + termStrategy.sellRepoToken(address(repoToken1Week), repoTokenSellAmount); + + uint256 expectedProceeds = termStrategy.calculateRepoTokenPresentValue( + address(repoToken1Week), 0.05e18, repoTokenSellAmount + ); + + assertEq(mockUSDC.balanceOf(testUser), expectedProceeds); + assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - expectedProceeds); + assertEq(termStrategy.totalAssetValue(), initialState.totalAssetValue); + + uint256 weightedTimeToMaturity = termStrategy.simulateWeightedTimeToMaturity(address(0), 0); + + (uint256 redemptionTimestamp, , ,) = ITermRepoToken(repoToken1Week).config(); + + // TODO: validate this math (weighted time to maturity) + uint256 repoTokenBalanceInBaseAssetPrecision = + (ITermRepoToken(repoToken1Week).redemptionValue() * repoTokenSellAmount * 1e6) / (1e18 * 1e18); + uint256 cumulativeWeightedTimeToMaturity = + (redemptionTimestamp - block.timestamp) * repoTokenBalanceInBaseAssetPrecision; + uint256 expectedWeightedTimeToMaturity = + cumulativeWeightedTimeToMaturity / (repoTokenBalanceInBaseAssetPrecision + termStrategy.totalLiquidBalance()); + + assertEq(weightedTimeToMaturity, expectedWeightedTimeToMaturity); + } + + // Test with different precisions + function testCalculateRepoTokenPresentValue() public { + // 0.05 0.075 0.1687 + // 7 999028 998544 996730 + // 14 998059 997092 993482 + // 28 996127 994200 987049 + + // 7 days, 0.5 = 999028 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken1Week), 0.05e18, 1e18), 999028); + // 7 days, 0.075 = 99854 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken1Week), 0.075e18, 1e18), 998544); + // 7 days, 0.1687 = 996730 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken1Week), 0.1687e18, 1e18), 996730); + + // 14 days, 0.5 = 999028 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken2Week), 0.05e18, 1e18), 998059); + // 14 days, 0.075 = 99854 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken2Week), 0.075e18, 1e18), 997092); + // 14 days, 0.1687 = 996730 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken2Week), 0.1687e18, 1e18), 993482); + + // 28 days, 0.5 = 999028 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken4Week), 0.05e18, 1e18), 996127); + // 28 days, 0.075 = 99854 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken4Week), 0.075e18, 1e18), 994200); + // 28 days, 0.1687 = 996730 + assertEq(termStrategy.calculateRepoTokenPresentValue(address(repoToken4Week), 0.1687e18, 1e18), 987049); + } + + function _sell1RepoToken(MockTermRepoToken rt1, uint256 amount1) private { + address[] memory tokens = new address[](1); + tokens[0] = address(rt1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount1; + + _sellRepoTokens(tokens, amounts, false, true, ""); + } + + function _sell1RepoTokenNoMint(MockTermRepoToken rt1, uint256 amount1) private { + address[] memory tokens = new address[](1); + tokens[0] = address(rt1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount1; + + _sellRepoTokens(tokens, amounts, false, false, ""); + } + + function _sell1RepoTokenExpectRevert(MockTermRepoToken rt1, uint256 amount1, bytes memory err) private { + address[] memory tokens = new address[](1); + tokens[0] = address(rt1); + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount1; + + _sellRepoTokens(tokens, amounts, true, true, err); + } + + function _sell3RepoTokens( + MockTermRepoToken rt1, + uint256 amount1, + MockTermRepoToken rt2, + uint256 amount2, + MockTermRepoToken rt3, + uint256 amount3 + ) private { + address[] memory tokens = new address[](3); + tokens[0] = address(rt1); + tokens[1] = address(rt2); + tokens[2] = address(rt3); + uint256[] memory amounts = new uint256[](3); + amounts[0] = amount1; + amounts[1] = amount2; + amounts[2] = amount3; + + _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(); + + // 3 repo tokens + assertEq(holdings.length, 3); + + // sorted by time to maturity + assertEq(holdings[0], address(repoToken1Week)); + assertEq(holdings[1], address(repoToken2Week)); + assertEq(holdings[2], address(repoToken4Week)); + } + + function _sellRepoTokens(address[] memory tokens, uint256[] memory amounts, bool expectRevert, bool mintUnderlying, bytes memory err) private { + address testUser = vm.addr(0x11111); + + for (uint256 i; i < tokens.length; i++) { + address token = tokens[i]; + uint256 amount = amounts[i]; + + termController.setOracleRate(MockTermRepoToken(token).termRepoId(), 0.05e18); + + MockTermRepoToken(token).mint(testUser, amount); + if (mintUnderlying) { + mockUSDC.mint( + address(strategy), + termStrategy.calculateRepoTokenPresentValue(token, 0.05e18, amount) + ); + } + + vm.startPrank(testUser); + MockTermRepoToken(token).approve(address(strategy), type(uint256).max); + + if (expectRevert) { + vm.expectRevert(err); + termStrategy.sellRepoToken(token, amount); + } else { + termStrategy.sellRepoToken(token, amount); + } + vm.stopPrank(); + } + } + + // 7 days (3), 14 days (9), 28 days (3) + function testSellMultipleRepoTokens_7_14_28_3_9_3() public { + _sell3RepoTokens(repoToken1Week, 3e18, repoToken2Week, 9e18, repoToken4Week, 3e18); + _sell3RepoTokensCheckHoldings(); + assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1330560); + } + + // 14 days (9), 7 days (3), 28 days (3) + function testSellMultipleRepoTokens_14_7_28_9_3_3() public { + _sell3RepoTokens(repoToken2Week, 9e18, repoToken1Week, 3e18, repoToken4Week, 3e18); + _sell3RepoTokensCheckHoldings(); + assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1330560); + } + + // 28 days (3), 14 days (9), 7 days (3) + function testSellMultipleRepoTokens_28_14_7_3_9_3() public { + _sell3RepoTokens(repoToken4Week, 3e18, repoToken2Week, 9e18, repoToken1Week, 3e18); + _sell3RepoTokensCheckHoldings(); + assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1330560); + } + + // 28 days (3), 7 days (3), 14 days (9) + function testSellMultipleRepoTokens_28_7_14_3_3_9() public { + _sell3RepoTokens(repoToken4Week, 3e18, repoToken1Week, 3e18, repoToken2Week, 9e18); + _sell3RepoTokensCheckHoldings(); + assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1330560); + } + + // 7 days (6), 14 days (2), 28 days (8) + function testSellMultipleRepoTokens_7_14_28_6_2_8() public { + _sell3RepoTokens(repoToken1Week, 6e18, repoToken2Week, 2e18, repoToken4Week, 8e18); + _sell3RepoTokensCheckHoldings(); + assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1587600); + } + + // 7 days (8), 14 days (1), 28 days (3) + function testSellMultipleRepoTokens_7_14_28_8_1_3() public { + _sell3RepoTokens(repoToken1Week, 8e18, repoToken2Week, 1e18, repoToken4Week, 3e18); + _sell3RepoTokensCheckHoldings(); + 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(); + + vm.expectRevert("!management"); + termStrategy.setTermController(address(newController)); + + vm.expectRevert(); + vm.prank(management); + termStrategy.setTermController(address(0)); + + vm.prank(management); + termStrategy.setTermController(address(newController)); + assertEq(address(termStrategy.termController()), address(newController)); + + vm.expectRevert("!management"); + termStrategy.setTimeToMaturityThreshold(12345); + + vm.prank(management); + termStrategy.setTimeToMaturityThreshold(12345); + assertEq(termStrategy.timeToMaturityThreshold(), 12345); + + vm.expectRevert("!management"); + termStrategy.setLiquidityThreshold(12345); + + vm.prank(management); + termStrategy.setLiquidityThreshold(12345); + assertEq(termStrategy.liquidityThreshold(), 12345); + + vm.expectRevert("!management"); + termStrategy.setAuctionRateMarkup(12345); + + vm.prank(management); + termStrategy.setAuctionRateMarkup(12345); + assertEq(termStrategy.auctionRateMarkup(), 12345); + + vm.expectRevert("!management"); + termStrategy.setCollateralTokenParams(address(mockCollateral), 12345); + + vm.prank(management); + termStrategy.setCollateralTokenParams(address(mockCollateral), 12345); + assertEq(termStrategy.auctionRateMarkup(), 12345); + } + + function testRepoTokenValidationFailures() public { + // start with some initial funds + mockUSDC.mint(address(strategy), 100e6); + _initState(); + + address testUser = vm.addr(0x11111); + + repoToken1Week.mint(testUser, 1000e18); + repoTokenMatured.mint(testUser, 1000e18); + + // test: token has no auction clearing rate + vm.expectRevert(abi.encodeWithSelector(RepoTokenList.InvalidRepoToken.selector, address(repoToken1Week))); + vm.prank(testUser); + termStrategy.sellRepoToken(address(repoToken1Week), 1e18); + + termController.setOracleRate(repoToken1Week.termRepoId(), 1.05e18); + termController.setOracleRate(repoTokenMatured.termRepoId(), 1.05e18); + + vm.prank(management); + termStrategy.setCollateralTokenParams(address(mockCollateral), 0); + + // test: min collateral ratio not set + vm.expectRevert(abi.encodeWithSelector(RepoTokenList.InvalidRepoToken.selector, address(repoToken1Week))); + vm.prank(testUser); + termStrategy.sellRepoToken(address(repoToken1Week), 1e18); + + vm.startPrank(management); + termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); + termStrategy.setTimeToMaturityThreshold(3 weeks); + vm.stopPrank(); + + // test: matured repo token + vm.expectRevert(abi.encodeWithSelector(RepoTokenList.InvalidRepoToken.selector, address(repoTokenMatured))); + vm.prank(testUser); + termStrategy.sellRepoToken(address(repoTokenMatured), 1e18); + } + + function testAboveMaturityThresholdFailure() public { + _sell1RepoToken(repoToken2Week, 2e18); + + uint256 timeToMat = termStrategy.simulateWeightedTimeToMaturity(address(0), 0); + + vm.prank(management); + termStrategy.setTimeToMaturityThreshold(timeToMat); + + // test: can't sell 4 week repo token because of time to maturity threshold + _sell1RepoTokenExpectRevert(repoToken4Week, 4e18, abi.encodeWithSelector(Strategy.TimeToMaturityAboveThreshold.selector)); + + // test: can still sell 1 week repo token + _sell1RepoToken(repoToken1Week, 4e18); + } + + function testRedeemMaturedRepoTokensInternal() public { + // start with some initial funds + address testDepositor = vm.addr(0x111111); + uint256 depositAmount = 1000e6; + + mockUSDC.mint(testDepositor, depositAmount); + + vm.startPrank(testDepositor); + mockUSDC.approve(address(termStrategy), type(uint256).max); + IERC4626(address(termStrategy)).deposit(depositAmount, testDepositor); + vm.stopPrank(); + + _sell1RepoTokenNoMint(repoToken2Week, 2e18); + + address[] memory holdings = termStrategy.repoTokenHoldings(); + + assertEq(holdings.length, 1); + + vm.warp(block.timestamp + 3 weeks); + + vm.prank(keeper); + ITokenizedStrategy(address(termStrategy)).report(); + + holdings = termStrategy.repoTokenHoldings(); + + assertEq(holdings.length, 0); + + vm.startPrank(testDepositor); + IERC4626(address(termStrategy)).withdraw( + IERC4626(address(termStrategy)).balanceOf(testDepositor), + testDepositor, + testDepositor + ); + vm.stopPrank(); + } + + function testRedeemMaturedRepoTokensExternal() public { + // start with some initial funds + address testDepositor = vm.addr(0x111111); + uint256 depositAmount = 1000e6; + + mockUSDC.mint(testDepositor, depositAmount); + + vm.startPrank(testDepositor); + mockUSDC.approve(address(termStrategy), type(uint256).max); + IERC4626(address(termStrategy)).deposit(depositAmount, testDepositor); + vm.stopPrank(); + + console.log("totalLiquidBalance", termStrategy.totalLiquidBalance()); + + _sell1RepoTokenNoMint(repoToken2Week, 2e18); + + address[] memory holdings = termStrategy.repoTokenHoldings(); + + assertEq(holdings.length, 1); + + vm.warp(block.timestamp + 3 weeks); + + console.log("totalLiquidBalance", termStrategy.totalLiquidBalance()); + console.log("totalAssetValue", termStrategy.totalAssetValue()); + + // external redemption + repoToken2Week.mockServicer().redeemTermRepoTokens(address(termStrategy), repoToken2Week.balanceOf(address(termStrategy))); + + console.log("totalLiquidBalance", termStrategy.totalLiquidBalance()); + console.log("totalAssetValue", termStrategy.totalAssetValue()); + + vm.prank(keeper); + ITokenizedStrategy(address(termStrategy)).report(); + + holdings = termStrategy.repoTokenHoldings(); + + assertEq(holdings.length, 0); + + vm.startPrank(testDepositor); + IERC4626(address(termStrategy)).withdraw( + IERC4626(address(termStrategy)).balanceOf(testDepositor), + testDepositor, + testDepositor + ); + vm.stopPrank(); + } + + function testRedeemMaturedRepoTokensFailure() public { + // start with some initial funds + address testDepositor = vm.addr(0x111111); + uint256 depositAmount = 1000e6; + + mockUSDC.mint(testDepositor, depositAmount); + + vm.startPrank(testDepositor); + mockUSDC.approve(address(termStrategy), type(uint256).max); + IERC4626(address(termStrategy)).deposit(depositAmount, testDepositor); + vm.stopPrank(); + + _sell1RepoTokenNoMint(repoToken2Week, 2e18); + + address[] memory holdings = termStrategy.repoTokenHoldings(); + + assertEq(holdings.length, 1); + + vm.warp(block.timestamp + 3 weeks); + + repoToken2Week.mockServicer().setRedemptionFailure(true); + + vm.prank(keeper); + ITokenizedStrategy(address(termStrategy)).report(); + + holdings = termStrategy.repoTokenHoldings(); + + // TEST: still has 1 repo token because redemption failure + assertEq(holdings.length, 1); + + console.log("totalAssetValue", termStrategy.totalAssetValue()); + } +} diff --git a/src/test/mocks/MockTermAuction.sol b/src/test/mocks/MockTermAuction.sol new file mode 100644 index 00000000..3f17afda --- /dev/null +++ b/src/test/mocks/MockTermAuction.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermAuction} from "../../interfaces/term/ITermAuction.sol"; +import {ITermRepoToken} from "../../interfaces/term/ITermRepoToken.sol"; +import {ITermRepoServicer} from "../../interfaces/term/ITermRepoServicer.sol"; +import {MockTermAuctionOfferLocker} from "./MockTermAuctionOfferLocker.sol"; +import {MockTermRepoToken} from "./MockTermRepoToken.sol"; + +contract MockTermAuction is ITermAuction { + + address public termAuctionOfferLocker; + bytes32 public termRepoId; + uint256 public auctionEndTime; + bool public auctionCompleted; + bool public auctionCancelledForWithdrawal; + ITermRepoToken internal repoToken; + + constructor(ITermRepoToken _repoToken) { + termRepoId = _repoToken.termRepoId(); + repoToken = _repoToken; + ( + uint256 redemptionTimestamp, + address purchaseToken, + address termRepoServicer, + address termRepoCollateralManager + ) = _repoToken.config(); + termAuctionOfferLocker = address(new MockTermAuctionOfferLocker( + ITermAuction(address(this)), + ITermRepoServicer(termRepoServicer).termRepoLocker(), + termRepoServicer, + purchaseToken + )); + auctionEndTime = block.timestamp + 1 weeks; + } + + function auctionSuccess(bytes32[] calldata offerIds, uint256[] calldata fillAmounts, uint256[] calldata repoTokenAmounts) external { + auctionCompleted = true; + auctionEndTime = block.timestamp; + + for (uint256 i; i < offerIds.length; i++) { + MockTermAuctionOfferLocker(termAuctionOfferLocker).processOffer( + MockTermRepoToken(address(repoToken)), offerIds[i], fillAmounts[i], repoTokenAmounts[i] + ); + } + } + + function auctionCanceled() external { + auctionCancelledForWithdrawal = true; + } +} diff --git a/src/test/mocks/MockTermAuctionOfferLocker.sol b/src/test/mocks/MockTermAuctionOfferLocker.sol new file mode 100644 index 00000000..ff7e9f93 --- /dev/null +++ b/src/test/mocks/MockTermAuctionOfferLocker.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermAuctionOfferLocker} from "../../interfaces/term/ITermAuctionOfferLocker.sol"; +import {ITermAuction} from "../../interfaces/term/ITermAuction.sol"; +import {MockTermRepoLocker} from "./MockTermRepoLocker.sol"; +import {MockTermRepoToken} from "./MockTermRepoToken.sol"; + +contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { + + address public purchaseToken; + address public termRepoServicer; + uint256 public auctionStartTime; + MockTermRepoLocker internal repoLocker; + ITermAuction internal auction; + mapping(bytes32 => TermAuctionOffer) internal lockedOffers; + + constructor( + ITermAuction _auction, + address _repoLocker, + address _repoServicer, + address _purchaseToken + ) { + auction = _auction; + purchaseToken = _purchaseToken; + termRepoServicer = _repoServicer; + repoLocker = MockTermRepoLocker(_repoLocker); + auctionStartTime = block.timestamp; + } + + function termRepoId() external view returns (bytes32) { + return auction.termRepoId(); + } + + function termAuctionId() external view returns (bytes32) { + return auction.termRepoId(); + } + + function auctionEndTime() external view returns (uint256) { + return auction.auctionEndTime(); + } + + function revealTime() external view returns (uint256) { + + } + + function lockedOffer(bytes32 id) external view returns (TermAuctionOffer memory) { + return lockedOffers[id]; + } + + function lockOffers( + TermAuctionOfferSubmission[] calldata offerSubmissions + ) external returns (bytes32[] memory offerIds) { + offerIds = new bytes32[](offerSubmissions.length); + + for (uint256 i; i < offerSubmissions.length; i++) { + TermAuctionOfferSubmission memory submission = offerSubmissions[i]; + TermAuctionOffer memory offer = lockedOffers[submission.id]; + + // existing offer + if (offer.amount > 0) { + if (offer.amount > submission.amount) { + // current amount > new amount, release tokens + repoLocker.releasePurchaseTokens(msg.sender, offer.amount - submission.amount); + } else if (offer.amount < submission.amount) { + repoLocker.lockPurchaseTokens(msg.sender, submission.amount - offer.amount); + } + // update locked amount + offer.amount = submission.amount; + } else { + 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; + offer.purchaseToken = submission.purchaseToken; + + repoLocker.lockPurchaseTokens(msg.sender, offer.amount); + } + lockedOffers[offer.id] = offer; + offerIds[i] = offer.id; + } + } + + function processOffer(MockTermRepoToken mockRepoToken, bytes32 offerId, uint256 fillAmount, uint256 repoTokenAmount) external { + require(lockedOffers[offerId].amount >= fillAmount); + uint256 remainingAmount = lockedOffers[offerId].amount - fillAmount; + + lockedOffers[offerId].amount = remainingAmount; + + mockRepoToken.mint(lockedOffers[offerId].offeror, repoTokenAmount); + repoLocker.releasePurchaseTokens(lockedOffers[offerId].offeror, remainingAmount); + } + + function unlockOffers(bytes32[] calldata offerIds) external { + for (uint256 i; i < offerIds.length; i++) { + bytes32 offerId = offerIds[i]; + repoLocker.releasePurchaseTokens(msg.sender, lockedOffers[offerId].amount); + delete lockedOffers[offerId]; + } + } +} diff --git a/src/test/mocks/MockTermController.sol b/src/test/mocks/MockTermController.sol new file mode 100644 index 00000000..c1afd406 --- /dev/null +++ b/src/test/mocks/MockTermController.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermController, AuctionMetadata} from "../../interfaces/term/ITermController.sol"; + +struct TermAuctionResults { + AuctionMetadata[] auctionMetadata; + uint8 numOfAuctions; +} + +contract MockTermController is ITermController { + mapping(bytes32 => TermAuctionResults) internal auctionResults; + + function isTermDeployed(address contractAddress) external view returns (bool) { + return true; + } + + function setOracleRate(bytes32 termRepoId, uint256 oracleRate) external { + AuctionMetadata memory metadata; + + metadata.auctionClearingRate = oracleRate; + + delete auctionResults[termRepoId]; + auctionResults[termRepoId].auctionMetadata.push(metadata); + auctionResults[termRepoId].numOfAuctions = 1; + } + + function getTermAuctionResults(bytes32 termRepoId) external view returns (AuctionMetadata[] memory auctionMetadata, uint8 numOfAuctions) { + return (auctionResults[termRepoId].auctionMetadata, auctionResults[termRepoId].numOfAuctions); + } +} \ No newline at end of file diff --git a/src/test/mocks/MockTermRepoCollateralManager.sol b/src/test/mocks/MockTermRepoCollateralManager.sol new file mode 100644 index 00000000..376d985a --- /dev/null +++ b/src/test/mocks/MockTermRepoCollateralManager.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermRepoCollateralManager} from "../../interfaces/term/ITermRepoCollateralManager.sol"; +import {ITermRepoToken} from "../../interfaces/term/ITermRepoToken.sol"; + +contract MockTermRepoCollateralManager is ITermRepoCollateralManager { + ITermRepoToken internal repoToken; + mapping(address => uint256) public maintenanceCollateralRatios; + address[] internal collateralTokenList; + + constructor(ITermRepoToken _repoToken, address _collateral, uint256 _maintenanceRatio) { + repoToken = _repoToken; + addCollateralToken(_collateral, _maintenanceRatio); + } + + function addCollateralToken(address _collateral, uint256 _maintenanceRatio) public { + collateralTokenList.push(_collateral); + maintenanceCollateralRatios[_collateral] = _maintenanceRatio; + } + + function numOfAcceptedCollateralTokens() external view returns (uint8) { + return uint8(collateralTokenList.length); + } + + function collateralTokens(uint256 index) external view returns (address) { + return collateralTokenList[index]; + } +} diff --git a/src/test/mocks/MockTermRepoLocker.sol b/src/test/mocks/MockTermRepoLocker.sol new file mode 100644 index 00000000..c88ddbcf --- /dev/null +++ b/src/test/mocks/MockTermRepoLocker.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract MockTermRepoLocker { + IERC20 internal purchaseToken; + + constructor(address _purchaseToken) { + purchaseToken = IERC20(_purchaseToken); + } + + function lockPurchaseTokens(address from, uint256 amount) external { + purchaseToken.transferFrom(from, address(this), amount); + } + + function releasePurchaseTokens(address to, uint256 amount) external { + purchaseToken.transfer(to, amount); + } +} diff --git a/src/test/mocks/MockTermRepoServicer.sol b/src/test/mocks/MockTermRepoServicer.sol new file mode 100644 index 00000000..0e68ff54 --- /dev/null +++ b/src/test/mocks/MockTermRepoServicer.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermRepoServicer} from "../../interfaces/term/ITermRepoServicer.sol"; +import {ITermRepoToken} from "../../interfaces/term/ITermRepoToken.sol"; +import {MockTermRepoLocker} from "./MockTermRepoLocker.sol"; + +interface IMockERC20 { + function mint(address account, uint256 amount) external; + function burn(address account, uint256 amount) external; + function decimals() external view returns (uint256); +} + +contract MockTermRepoServicer is ITermRepoServicer { + ITermRepoToken internal repoToken; + MockTermRepoLocker internal repoLocker; + address public purchaseToken; + bool public redemptionFailure; + + constructor(ITermRepoToken _repoToken, address _purchaseToken) { + repoToken = _repoToken; + repoLocker = new MockTermRepoLocker(_purchaseToken); + purchaseToken = _purchaseToken; + } + + function setRedemptionFailure(bool hasFailure) external { + redemptionFailure = hasFailure; + } + + function redeemTermRepoTokens( + address redeemer, + uint256 amountToRedeem + ) external { + if (redemptionFailure) revert("redemption failured"); + uint256 amountToRedeemInAssetPrecision = + amountToRedeem * (10**IMockERC20(purchaseToken).decimals()) / + (10**IMockERC20(address(repoToken)).decimals()); + IMockERC20(purchaseToken).mint(redeemer, amountToRedeemInAssetPrecision); + IMockERC20(address(repoToken)).burn(redeemer, amountToRedeem); + } + + function termRepoToken() external view returns (address) { + return address(repoToken); + } + + function termRepoLocker() external view returns (address) { + return address(repoLocker); + } +} diff --git a/src/test/mocks/MockTermRepoToken.sol b/src/test/mocks/MockTermRepoToken.sol new file mode 100644 index 00000000..31689c7f --- /dev/null +++ b/src/test/mocks/MockTermRepoToken.sol @@ -0,0 +1,71 @@ +// 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"; +import {ITermRepoServicer} from "../../interfaces/term/ITermRepoServicer.sol"; +import {ITermRepoCollateralManager} from "../../interfaces/term/ITermRepoCollateralManager.sol"; +import {MockTermRepoServicer} from "./MockTermRepoServicer.sol"; +import {MockTermRepoCollateralManager} from "./MockTermRepoCollateralManager.sol"; + +contract MockTermRepoToken is ERC20, ITermRepoToken { + struct RepoTokenContext { + uint256 redemptionTimestamp; + address purchaseToken; + ITermRepoServicer termRepoServicer; + ITermRepoCollateralManager termRepoCollateralManager; + } + + bytes32 public termRepoId; + RepoTokenContext internal repoTokenContext; + + constructor( + bytes32 _termRepoId, + address _purchaseToken, + address _collateral, + uint256 _maintenanceRatio, + uint256 _redemptionTimestamp + ) ERC20("MockRepo", "MockRepo") { + termRepoId = _termRepoId; + repoTokenContext.redemptionTimestamp = _redemptionTimestamp; + repoTokenContext.purchaseToken = _purchaseToken; + repoTokenContext.termRepoServicer = new MockTermRepoServicer(ITermRepoToken(address(this)), _purchaseToken); + repoTokenContext.termRepoCollateralManager = new MockTermRepoCollateralManager( + ITermRepoToken(address(this)), _collateral, _maintenanceRatio + ); + } + + function redemptionValue() external view returns (uint256) { + return 1e18; + } + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function burn(address account, uint256 amount) external { + _burn(account, amount); + } + + function mockServicer() external returns (MockTermRepoServicer) { + return MockTermRepoServicer(address(repoTokenContext.termRepoServicer)); + } + + function config() + external + view + returns ( + uint256 redemptionTimestamp, + address purchaseToken, + address termRepoServicer, + address termRepoCollateralManager + ) + { + return ( + repoTokenContext.redemptionTimestamp, + repoTokenContext.purchaseToken, + address(repoTokenContext.termRepoServicer), + address(repoTokenContext.termRepoCollateralManager) + ); + } +} diff --git a/src/test/mocks/MockUSDC.sol b/src/test/mocks/MockUSDC.sol new file mode 100644 index 00000000..a6cfbf78 --- /dev/null +++ b/src/test/mocks/MockUSDC.sol @@ -0,0 +1,19 @@ +pragma solidity ^0.8.18; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockUSDC is ERC20 { + constructor() ERC20("USDC", "USDC") {} + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function burn(address account, uint256 amount) external { + _burn(account, amount); + } + + function decimals() public view override returns (uint8) { + return 6; + } +} diff --git a/src/test/utils/Setup.sol b/src/test/utils/Setup.sol index 99793794..f9cbd9e5 100644 --- a/src/test/utils/Setup.sol +++ b/src/test/utils/Setup.sol @@ -9,6 +9,18 @@ import {IStrategyInterface} from "../../interfaces/IStrategyInterface.sol"; // Inherit the events so they can be checked if desired. import {IEvents} from "@tokenized-strategy/interfaces/IEvents.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC4626Mock} from "@openzeppelin/contracts/mocks/ERC4626Mock.sol"; + +import {TokenizedStrategy} from "@tokenized-strategy/TokenizedStrategy.sol"; +import {MockFactory} from "@tokenized-strategy/test/mocks/MockFactory.sol"; +import {TermVaultEventEmitter} from "../../TermVaultEventEmitter.sol"; +import {MockTermAuction} from "../mocks/MockTermAuction.sol"; +import {MockTermAuctionOfferLocker} from "../mocks/MockTermAuctionOfferLocker.sol"; +import {MockTermController} from "../mocks/MockTermController.sol"; +import {MockTermRepoCollateralManager} from "../mocks/MockTermRepoCollateralManager.sol"; +import {MockTermRepoServicer} from "../mocks/MockTermRepoServicer.sol"; +import {MockTermRepoToken} from "../mocks/MockTermRepoToken.sol"; interface IFactory { function governance() external view returns (address); @@ -19,6 +31,11 @@ interface IFactory { } contract Setup is ExtendedTest, IEvents { + struct StrategySnapshot { + uint256 totalAssetValue; + uint256 totalLiquidBalance; + } + // Contract instances that we will use repeatedly. ERC20 public asset; IStrategyInterface public strategy; @@ -30,6 +47,8 @@ contract Setup is ExtendedTest, IEvents { address public keeper = address(4); address public management = address(1); address public performanceFeeRecipient = address(3); + address public adminWallet = address(111); + address public devopsWallet = address(222); // Address of the real deployed Factory address public factory; @@ -45,15 +64,41 @@ contract Setup is ExtendedTest, IEvents { // Default profit max unlock time is set for 10 days uint256 public profitMaxUnlockTime = 10 days; + MockFactory internal mockFactory; + + // Term finance mocks + MockTermController internal termController; + TermVaultEventEmitter internal termVaultEventEmitterImpl; + TermVaultEventEmitter internal termVaultEventEmitter; + ERC4626Mock internal mockYearnVault; + TokenizedStrategy internal tokenizedStrategy; + function setUp() public virtual { _setTokenAddrs(); + _setUp(ERC20(tokenAddrs["DAI"])); + } + + function _setUp(ERC20 _underlying) internal { // Set asset - asset = ERC20(tokenAddrs["DAI"]); + asset = _underlying; // Set decimals decimals = asset.decimals(); + mockFactory = new MockFactory(0, address(0)); + + // Factory from mainnet, tokenized strategy needs to be hardcoded to 0xBB51273D6c746910C7C06fe718f30c936170feD0 + tokenizedStrategy = new TokenizedStrategy(address(mockFactory)); + vm.etch(0xBB51273D6c746910C7C06fe718f30c936170feD0, address(tokenizedStrategy).code); + + termController = new MockTermController(); + termVaultEventEmitterImpl = new TermVaultEventEmitter(); + termVaultEventEmitter = TermVaultEventEmitter(address(new ERC1967Proxy(address(termVaultEventEmitterImpl), ""))); + mockYearnVault = new ERC4626Mock(address(asset)); + + termVaultEventEmitter.initialize(adminWallet, devopsWallet); + // Deploy strategy and set variables strategy = IStrategyInterface(setUpStrategy()); @@ -71,9 +116,12 @@ contract Setup is ExtendedTest, IEvents { function setUpStrategy() public returns (address) { // we save the strategy as a IStrategyInterface to give it the needed interface IStrategyInterface _strategy = IStrategyInterface( - address(new Strategy(address(asset), "Tokenized Strategy")) + address(new Strategy(address(asset), "Tokenized Strategy", address(mockYearnVault), address(termVaultEventEmitter))) ); + vm.prank(adminWallet); + termVaultEventEmitter.pairVaultContract(address(_strategy)); + // set keeper _strategy.setKeeper(keeper); // set treasury @@ -84,6 +132,9 @@ contract Setup is ExtendedTest, IEvents { vm.prank(management); _strategy.acceptManagement(); + vm.prank(management); + _strategy.setTermController(address(termController)); + return address(_strategy); }