From bac6a0ee3b163f23cea2cbc0bca40a2b75553379 Mon Sep 17 00:00:00 2001 From: Andrew Zhou Date: Tue, 2 Apr 2024 10:37:41 -0700 Subject: [PATCH 01/30] term auction offer locker interface --- .../term/ITermAuctionOfferLocker.sol | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/interfaces/term/ITermAuctionOfferLocker.sol diff --git a/src/interfaces/term/ITermAuctionOfferLocker.sol b/src/interfaces/term/ITermAuctionOfferLocker.sol new file mode 100644 index 00000000..b216a231 --- /dev/null +++ b/src/interfaces/term/ITermAuctionOfferLocker.sol @@ -0,0 +1,37 @@ +// 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; + } + 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); + + /// @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); +} From 41f910a2157d21995dc9ca6513f28efc86f1037b Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Thu, 11 Apr 2024 18:07:45 -0700 Subject: [PATCH 02/30] strategy prototype --- src/RepoTokenList.sol | 122 ++++++++++++++++ src/Strategy.sol | 166 +++++++++++++++++++++- src/TermVaultEventEmitter.sol | 49 +++++++ src/interfaces/IStrategyInterface.sol | 2 +- src/interfaces/term/ITermController.sol | 6 + src/interfaces/term/ITermRepoServicer.sol | 13 ++ src/interfaces/term/ITermRepoToken.sol | 10 ++ src/interfaces/term/ITermVaultEvents.sol | 20 +++ src/test/utils/Setup.sol | 2 +- 9 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 src/RepoTokenList.sol create mode 100644 src/TermVaultEventEmitter.sol create mode 100644 src/interfaces/term/ITermController.sol create mode 100644 src/interfaces/term/ITermRepoServicer.sol create mode 100644 src/interfaces/term/ITermRepoToken.sol create mode 100644 src/interfaces/term/ITermVaultEvents.sol diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol new file mode 100644 index 00000000..1e23a75d --- /dev/null +++ b/src/RepoTokenList.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; +import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; + +struct ListNode { + address next; +} + +struct ListData { + address head; + mapping(address => ListNode) nodes; +} + +library RepoTokenList { + function _getRepoTokenMaturity(address repoToken) private view returns (uint256 redemptionTimestamp) { + (redemptionTimestamp, ) = ITermRepoToken(repoToken).config(); + } + + function _getRepoTokenTimeToMaturity(address repoToken) private view returns (uint256) { + return _getRepoTokenMaturity(repoToken) - block.timestamp; + } + + function _getNext(ListData storage listData, address current) private view returns (address) { + return listData.nodes[current].next; + } + + function getWeightedTimeToMaturity(ListData storage listData, address repoToken, uint256 amount) internal view returns (uint256) { + if (listData.head == NULL_NODE) return 0; + + uint256 cumulativeWeightedMaturityTimestamp; + uint256 cumulativeRepoTokenAmount; + address current = listData.head; + while (current != NULL_NODE) { + uint256 currentMaturity = _getRepoTokenMaturity(current); + uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); + + if (currentMaturity > block.timestamp) { + uint256 timeToMaturity = _getRepoTokenTimeToMaturity(current); + // Not matured yet + cumulativeWeightedMaturityTimestamp += timeToMaturity * repoTokenBalance / repoTokenPrecision; + } + cumulativeRepoTokenAmount += repoTokenBalance; + + current = _getNext(current); + } + + if (repoToken != address(0)) { + cumulativeWeightedMaturityTimestamp += _getRepoTokenTimeToMaturity(repoToken) * amount; + cumulativeRepoTokenAmount += amount; + } + + uint256 excessLiquidity = _assetBalance(address(this)) * repoTokenPrecision / PURCHASE_TOKEN_PRECISION; + + /// @dev avoid div by 0 + if (cumulativeRepoTokenAmount == 0 && excessLiquidity == 0) { + return 0; + } + + return cumulativeWeightedMaturityTimestamp * repoTokenPrecision / (cumulativeRepoTokenAmount + excessLiquidity); + } + + function removeAndRedeemMaturedTokens(ListData storage listData, address repoServicer, uint256 amount) 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) { + next = _getNext(current); + + if (current == listData.head) { + listData.head = next; + } + + listData.nodes[prev].next = next; + delete listData.nodes[current]; + delete repoTokenExists[current]; + + ITermRepoServicer(repoServicer).redeemTermRepoTokens(address(this), amount); + } else { + /// @dev early exit because list is sorted + break; + } + + prev = current; + current = _getNext(current); + } + } + + function insertSorted(ListData storage listData, address repoToken) internal { + address current = listData.head; + + if (current == NULL_NODE) { + listData.head = repoToken; + return; + } + + address prev; + while (current != address(0)) { + + uint256 currentMaturity = _getRepoTokenMaturity(current); + uint256 maturityToInsert = _getRepoTokenMaturity(repoToken); + + if (maturityToInsert <= currentMaturity) { + if (prev == address(0)) { + listData.head = repoToken; + } else { + listData.nodes[prev].next = repoToken; + } + listData.nodes[repoToken].next = current; + break; + } + + prev = current; + current = _getNext(current); + } + } +} diff --git a/src/Strategy.sol b/src/Strategy.sol index 72499cd9..378ff0a2 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -3,9 +3,11 @@ 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 {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; +import {ITermController} from "./interfaces/term/ITermController.sol"; +import {ITermVaultEvents} from "./interfaces/term/ITermVaultEvents.sol"; +import {ITermAuctionOfferLocker} from "./interfaces/term/ITermAuctionOfferLocker.sol"; +import {RepoTokenList, ListData} from "./RepoTokenList.sol"; /** * The `TokenizedStrategy` variable can be used to retrieve the strategies @@ -22,11 +24,165 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol contract Strategy is BaseStrategy { using SafeERC20 for ERC20; + using RepoTokenList for ListData; + + address public constant NULL_NODE = address(0); + uint256 internal constant INVALID_AUCTION_RATE = 0; + uint256 public constant THREESIXTY_DAYCOUNT_SECONDS = 360 days; + uint256 public constant RATE_PRECISION = 1e18; + + error InvalidRepoToken(address token); + error TimeToMaturityAboveThreshold(); + error BalanceBelowLiquidityThreshold(); + + ITermVaultEvents public immutable TERM_VAULT_EVENT_EMITTER; + uint256 public immutable PURCHASE_TOKEN_PRECISION; + + ITermController public termController; + ListData public listData; + uint256 public timeToMaturityThreshold; // seconds + uint256 public liquidityThreshold; // purchase token precision (underlying) + uint256 public auctionRateMarkup; // 1e18 (TODO: check this) + mapping(address => uint256) public repoTokenAuctionRates; + + function setTermController(address newTermController) external onlyManagement { + TERM_VAULT_EVENT_EMITTER.emitTermControllerUpdated(termController, newTermController); + termController = 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 _removeRedeemAndCalculateWeightedMaturity(address repoToken, uint256 amount) private returns (uint256) { + uint256 weightedTimeToMaturity = listData.getWeightedTimeToMaturity(repoToken, amount); + listData.removeAndRedeemMaturedTokens(repoToken, repoTokenAuctionRates, amount); + return weightedTimeToMaturity; + } + + function getWeightedTimeToMaturity(address repoToken, uint256 amount) external view returns (uint256) { + return listData.getWeightedTimeToMaturity(repoToken, amount); + } + + function _validateRepoToken(ITermRepoToken repoToken) private + returns (uint256 auctionRate, uint256 redemptionTimestamp) + { + auctionRate = repoTokenAuctionRates[repoToken]; + if (auctionRate != INVALID_AUCTION_RATE) { + (redemptionTimestamp, ) = repoToken.config(); + + uint256 oracleRate = _auctionRate(); + if (oracleRate != INVALID_AUCTION_RATE) { + if (auctionRate != oracleRate) { + repoTokenAuctionRates[repoToken] = oracleRate; + } + } + } else { + auctionRate = _auctionRate(); + + if (!termController.isTermDeployed(address(repoToken))) { + revert InvalidRepoToken(address(repoToken)); + } + + address purchaseToken; + (redemptionTimestamp, purchaseToken) = repoToken.config(); + if (purchaseToken != asset) { + revert InvalidRepoToken(address(repoToken)); + } + + if (redemptionTimestamp < block.timestamp) { + revert InvalidRepoToken(address(repoToken)); + } + + _insertSorted(repoToken); + repoTokenAuctionRates[repoToken] = auctionRate; + } + } + + function _totalLiquidBalance(address addr) private view returns (uint256) { + // uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); + // return IYearnVault.balanceOf(address(this)) + underlyingBalance; + } + + function _sweepAsset() private { + // uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); + // if underlyingBalance > 0 + // IYearnVault.deposit(underlyingBalance); + } + + function _withdrawAsset(uint256 amount) private { + //IYearVault.withdraw(asset, proceeds); + } + + function _auctionRate() private view returns (uint256) { + // TODO: read from auction rate oracle using termController + // try { ITermController.getAuctionRate } catch {} + } + + // TODO: reentrancy check + function sellRepoToken(address repoToken, uint256 amount) external { + (uint256 redemptionTimestamp, ) = _validateRepoToken(ITermRepoToken(repoToken)); + + _sweepAsset(); + + uint256 resultingTimeToMaturity = listData.removeRedeemAndCalculateWeightedMaturity(repoToken, amount); + + if (resultingTimeToMaturity > timeToMaturityThreshold) { + revert TimeToMaturityAboveThreshold(); + } + + uint256 liquidBalance = _totalLiquidBalance(address(this)); + + /// @dev in repo token precision + uint256 repoTokenPrecision = 10 ** IERC20(repoToken).decimals(); + uint256 timeLeftToMaturityDayFraction = + ((redemptionTimestamp - block.timestamp) * repoTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; + + uint256 purchaseTokenAmountInRepoTokenPrecision = + (repoTokenAmount * repoTokenPrecision) / (repoTokenPrecision + (rate * timeLeftToMaturityDayFraction / RATE_PRECISION)); + + uint256 purchaseTokenAmount = _repoToPurchasePrecision( + repoTokenPrecision, purchaseTokenAmountInRepoTokenPrecision + ); + + liquidBalance -= purchaseTokenAmount; + + if (liquidBalance < liquidityThreshold) { + revert BalanceBelowLiquidityThreshold(); + } + + _withdrawAsset(proceeds); + + IERC20(repoToken).safeTransferFrom(msg.sender, address(this), amount); + IERC20(asset).safeTransfer(msg.sender, proceeds); + } + + function _repoToPurchasePrecision( + uint256 repoTokenPrecision, + uint256 purchaseTokenAmountInRepoPrecision + ) private view returns (uint256) { + return (purchaseTokenAmountInRepoPrecision * purchaseTokenPrecision) / PURCHASE_TOKEN_PRECISION; + } constructor( address _asset, - string memory _name - ) BaseStrategy(_asset, _name) {} + string memory _name, + address _eventEmitter + ) BaseStrategy(_asset, _name) { + TERM_VAULT_EVENT_EMITTER = ITermVaultEvents(_eventEmitter); + PURCHASE_TOKEN_PRECISION = 10**IERC20(asset); + } /*////////////////////////////////////////////////////////////// NEEDED TO BE OVERRIDDEN BY STRATEGIST diff --git a/src/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol new file mode 100644 index 00000000..c966680b --- /dev/null +++ b/src/TermVaultEventEmitter.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import "./interfaces/term/ITermVaultEvents.sol"; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +contract TermListingEventEmitter 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); + } + + // ======================================================================== + // = 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/ITermController.sol b/src/interfaces/term/ITermController.sol new file mode 100644 index 00000000..ca94d725 --- /dev/null +++ b/src/interfaces/term/ITermController.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermController { + function isTermDeployed(address contractAddress) external view returns (bool); +} diff --git a/src/interfaces/term/ITermRepoServicer.sol b/src/interfaces/term/ITermRepoServicer.sol new file mode 100644 index 00000000..1bc3bf3e --- /dev/null +++ b/src/interfaces/term/ITermRepoServicer.sol @@ -0,0 +1,13 @@ +// 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 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..11f97181 --- /dev/null +++ b/src/interfaces/term/ITermRepoToken.sol @@ -0,0 +1,10 @@ +// 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); +} diff --git a/src/interfaces/term/ITermVaultEvents.sol b/src/interfaces/term/ITermVaultEvents.sol new file mode 100644 index 00000000..674ea6cd --- /dev/null +++ b/src/interfaces/term/ITermVaultEvents.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermListingContractEvents { + event TermControllerUpdated(address oldController, address newController); + + event TimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold); + + event LiquidityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold); + + event AuctionRateMarkupUpdated(uint256 oldMarkup, uint256 newMarkup); + + 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; +} diff --git a/src/test/utils/Setup.sol b/src/test/utils/Setup.sol index 99793794..b6b097f9 100644 --- a/src/test/utils/Setup.sol +++ b/src/test/utils/Setup.sol @@ -71,7 +71,7 @@ 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(0))) ); // set keeper From d847bd677293ce27e67e3fc7c032191a56190374 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Thu, 11 Apr 2024 20:11:25 -0700 Subject: [PATCH 03/30] updating interfaces --- src/Strategy.sol | 15 ++++++++++----- src/interfaces/term/ITermController.sol | 13 +++++++++++++ .../term/ITermRepoCollateralManager.sol | 6 ++++++ src/interfaces/term/ITermRepoToken.sol | 9 ++++++++- 4 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 src/interfaces/term/ITermRepoCollateralManager.sol diff --git a/src/Strategy.sol b/src/Strategy.sol index 378ff0a2..27f086dd 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.18; import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; -import {ITermController} from "./interfaces/term/ITermController.sol"; +import {ITermController, TermAuctionResults} from "./interfaces/term/ITermController.sol"; import {ITermVaultEvents} from "./interfaces/term/ITermVaultEvents.sol"; import {ITermAuctionOfferLocker} from "./interfaces/term/ITermAuctionOfferLocker.sol"; import {RepoTokenList, ListData} from "./RepoTokenList.sol"; @@ -30,7 +30,7 @@ contract Strategy is BaseStrategy { uint256 internal constant INVALID_AUCTION_RATE = 0; uint256 public constant THREESIXTY_DAYCOUNT_SECONDS = 360 days; uint256 public constant RATE_PRECISION = 1e18; - + error InvalidRepoToken(address token); error TimeToMaturityAboveThreshold(); error BalanceBelowLiquidityThreshold(); @@ -125,9 +125,14 @@ contract Strategy is BaseStrategy { //IYearVault.withdraw(asset, proceeds); } - function _auctionRate() private view returns (uint256) { - // TODO: read from auction rate oracle using termController - // try { ITermController.getAuctionRate } catch {} + function _auctionRate(ITermRepoToken repoToken) private view returns (uint256) { + TermAuctionResults memory results = termController.getTermAuctionResults(repoToken.termRepoId()); + + uint256 len = results.auctionMetadata.length; + + require(len > 0); + + return results.auctionMetadata[len - 1].auctionClearingRate; } // TODO: reentrancy check diff --git a/src/interfaces/term/ITermController.sol b/src/interfaces/term/ITermController.sol index ca94d725..5d30def5 100644 --- a/src/interfaces/term/ITermController.sol +++ b/src/interfaces/term/ITermController.sol @@ -1,6 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; +struct AuctionMetadata { + bytes32 termAuctionId; + uint256 auctionClearingRate; + uint256 auctionClearingBlockTimestamp; +} + +struct TermAuctionResults { + AuctionMetadata[] auctionMetadata; + uint8 numOfAuctions; +} + interface ITermController { function isTermDeployed(address contractAddress) external view returns (bool); + + function getTermAuctionResults(bytes32 termRepoId) external view returns (TermAuctionResults memory); } diff --git a/src/interfaces/term/ITermRepoCollateralManager.sol b/src/interfaces/term/ITermRepoCollateralManager.sol new file mode 100644 index 00000000..26644f1d --- /dev/null +++ b/src/interfaces/term/ITermRepoCollateralManager.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermRepoCollateralManager { + +} \ No newline at end of file diff --git a/src/interfaces/term/ITermRepoToken.sol b/src/interfaces/term/ITermRepoToken.sol index 11f97181..1209b57d 100644 --- a/src/interfaces/term/ITermRepoToken.sol +++ b/src/interfaces/term/ITermRepoToken.sol @@ -6,5 +6,12 @@ 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); + function config() external view returns ( + uint256 redemptionTimestamp, + address purchaseToken, + address termRepoServicer, + address termRepoCollateralManager + ); + + function termRepoId() external view returns (address); } From 3ec7656dc2121add6196aace9166007c3545dd74 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Wed, 24 Apr 2024 21:25:37 -0700 Subject: [PATCH 04/30] working on offer --- foundry.toml | 1 + src/LevTerm.sol2 | 315 ++++++++++++++++++ src/Strategy.sol | 36 ++ src/TermVaultEventEmitter.sol | 6 +- src/interfaces/term/ITermAuction.sol | 6 + .../term/ITermRepoCollateralManager.sol | 2 +- 6 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 src/LevTerm.sol2 create mode 100644 src/interfaces/term/ITermAuction.sol diff --git a/foundry.toml b/foundry.toml index f3e432f9..d3273268 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,6 +6,7 @@ solc = "0.8.18" remappings = [ "@openzeppelin/=lib/openzeppelin-contracts/", + "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/" "forge-std/=lib/forge-std/src/", "@tokenized-strategy/=lib/tokenized-strategy/src/", "@periphery/=lib/tokenized-strategy-periphery/src/", diff --git a/src/LevTerm.sol2 b/src/LevTerm.sol2 new file mode 100644 index 00000000..41d7b782 --- /dev/null +++ b/src/LevTerm.sol2 @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {ILevTermEscrow} from "src/interfaces/ILevTermEscrow.sol"; + +import {ITermAuction} from "src/interfaces/term/ITermAuction.sol"; +import {ITermRepoServicer} from "src/interfaces/term/ITermRepoServicer.sol"; +import {ExponentialNoError} from "src/lib/ExponentialNoError.sol"; +import {ITermAuctionBidLocker} from "src/interfaces/term/ITermAuctionBidLocker.sol"; +import {ITermRepoCollateralManager} from "src/interfaces/term/ITermRepoCollateralManager.sol"; + +// AAVE interfaces +import {IPool, IPoolAddressesProvider, DataTypes} from "@aave-v3/contracts/interfaces/IPool.sol"; +import {FlashLoanSimpleReceiverBase} from "@aave-v3/contracts/flashloan/base/FlashLoanSimpleReceiverBase.sol"; +import {IStaticATokenLM} from "src/interfaces/bgd/IStaticATokenLM.sol"; + +/// @title LevTerm +/// @notice A Yearn V3 strategy that bids on Term Finance auctions +/// @notice Bridge loan facility provided via LevTermEscrow +contract LevTerm is BaseStrategy, ExponentialNoError, FlashLoanSimpleReceiverBase { + using SafeERC20 for ERC20; + + ILevTermEscrow public immutable termEscrow; + ITermRepoServicer public termRepoServicer; + + // Locks + bool withdrawLock; + bool depositLock; + + // Escrow fee + uint256 escrowFeeBps; + + AuctionInfo public auction; + + struct AuctionInfo { + ITermAuction termAuction; + ITermAuctionBidLocker bidLocker; + ITermRepoServicer repoServicer; + ITermRepoCollateralManager collateralManager; + uint256 collateralAmount; + address purchaseToken; + uint256 purchaseTokenAmount; + uint256 escrowDebt; + uint256 servicingFeeProRatedMantissa; + } + + IPool public constant lendingPool = IPool(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2); + + address public constant staticATokenFactory = 0x90b1255a76e847cC92d41C295DeD5Bf2D4F24B3d; + IStaticATokenLM public statAToken; + address nativeToken; + + /// @param _asset Native asset for the tokenized strategy + /// @param _name Name of the strategy + /// @param _termEscrow Address of the TermEscrow contract that provides bridge loans + constructor(address _asset, string memory _name, address _termEscrow, uint256 _escrowFeeBps) + BaseStrategy(_asset, _name) + FlashLoanSimpleReceiverBase(IPoolAddressesProvider(address(lendingPool))) + { + termEscrow = ILevTermEscrow(_termEscrow); + require(_escrowFeeBps <= 15, "Fee cap of 15bps exceeded"); + escrowFeeBps = _escrowFeeBps; + + statAToken = IStaticATokenLM(_asset); + + nativeToken = statAToken.aTokenUnderlying(); + ERC20(nativeToken).safeApprove(address(statAToken), type(uint256).max); + } + + // ======================================================================== + // = Base strategy override =============================================== + // ======================================================================== + + function _deployFunds(uint256 _amount) internal override {} + + function _freeFunds(uint256 _amount) internal override {} + + function _harvestAndReport() internal override returns (uint256 _totalAssets) { + _totalAssets = asset.balanceOf(address(this)); + } + + function availableDepositLimit(address _owner) public view override returns (uint256) { + if (depositLock) { + return 0; + } + return type(uint256).max; + } + + function availableWithdrawLimit(address _owner) public view override returns (uint256) { + if (withdrawLock) { + return 0; + } + return type(uint256).max; + } + + // ======================================================================== + // = HELPERS ============================================================== + // ======================================================================== + + /// @notice Allows to lock deposits and withdrawals via `availableDepositLimit` and `availableWithdrawLimit` + function _lock(bool _deposits, bool _withdrawals) internal { + depositLock = _deposits; + withdrawLock = _withdrawals; + } + + /// @notice Repayment of debt to escrow contract, and reset accounting + function _repayEscrow() internal { + asset.transfer(address(termEscrow), auction.escrowDebt * (10_000 + escrowFeeBps) / 10_000); + auction.escrowDebt = 0; + } + + // ======================================================================== + // = TERM SPECIFIC ======================================================== + // ======================================================================== + + /// @notice Allows management to place a bid in a Term auction + /// @param _termAuction Address of the auction + /// @param _idHash Obfuscated id hash + /// @param _bidPriceHash Obfuscated bid price hash (interest rate, in 1e18) + /// @param _purchaseTokenAmount Amount of assets to borrow + /// @param _collateralAmount Amount of assets to supply + function bidOnNewAuction( + address _termAuction, + bytes32 _idHash, + bytes32 _bidPriceHash, + uint256 _purchaseTokenAmount, + uint256 _collateralAmount + ) external onlyManagement returns (bytes32[] memory bidIds) { + auction.termAuction = ITermAuction(_termAuction); + auction.bidLocker = ITermAuctionBidLocker(auction.termAuction.termAuctionBidLocker()); + require(auction.bidLocker.collateralTokens(address(asset)), "Wrong collateral"); + require( + block.timestamp > auction.bidLocker.auctionStartTime() + || block.timestamp < auction.termAuction.auctionEndTime(), + "Auction not open" + ); + + auction.repoServicer = ITermRepoServicer(auction.bidLocker.termRepoServicer()); + auction.collateralManager = ITermRepoCollateralManager(auction.bidLocker.termRepoCollateralManager()); + + // Calculate amount required from escrow + uint256 toPullFromEscrow = _collateralAmount - asset.balanceOf(address(this)); + + auction.servicingFeeProRatedMantissa = mul_( + Exp({mantissa: auction.bidLocker.dayCountFractionMantissa()}), + Exp({mantissa: auction.repoServicer.servicingFee()}) + ).mantissa; + + require(asset.balanceOf(address(termEscrow)) >= toPullFromEscrow, "Escrow does not hold sufficient funds"); + require( + _collateralTokenToPurchaseToken(toPullFromEscrow) + <= auction.purchaseTokenAmount * 1e18 / (1e18 - auction.servicingFeeProRatedMantissa), + "Resulting balance insufficient to repay Escrow" + ); + + // Pull funds from escrow + termEscrow.pullFromEscrow(toPullFromEscrow); + + // Store params for accounting + auction.collateralAmount = asset.balanceOf(address(this)); + auction.purchaseToken = auction.bidLocker.purchaseToken(); + auction.purchaseTokenAmount = _purchaseTokenAmount; + auction.escrowDebt = toPullFromEscrow; + + uint256[] memory collateralAmounts = new uint256[](1); + collateralAmounts[0] = auction.collateralAmount; + + address[] memory collateralTokens = new address[](1); + collateralTokens[0] = address(asset); + + // Approve Repo Locker to pull collateral amount + ERC20(asset).approve(address(auction.repoServicer.termRepoLocker()), collateralAmounts[0]); + + // Create bid submission struct and submit bid + ITermAuctionBidLocker.TermAuctionBidSubmission memory bid = ITermAuctionBidLocker.TermAuctionBidSubmission({ + id: _idHash, + bidder: address(this), + bidPriceHash: _bidPriceHash, + amount: _purchaseTokenAmount, + collateralAmounts: collateralAmounts, + purchaseToken: auction.purchaseToken, + collateralTokens: collateralTokens + }); + + ITermAuctionBidLocker.TermAuctionBidSubmission[] memory bids = + new ITermAuctionBidLocker.TermAuctionBidSubmission[](1); + bids[0] = bid; + bidIds = auction.bidLocker.lockBids(bids); + + // Lock deposits and withdrawals + _lock(true, true); + } + + /// @notice Permissionless function callable the auction ended + /// @notice 3 scenarios possible: full fill, partial fill, no fill + function auctionClosed() public { + require(block.timestamp > auction.termAuction.auctionEndTime(), "Auction still open"); + uint256 assetBalance = asset.balanceOf(address(this)); + + // No fill: Collateral borrowed from escrow returned, unlock deposits and withdrawals + if (assetBalance == auction.collateralAmount) { + _repayEscrow(); + _lock(false, false); + return; + } + + uint256 purchaseTokenBalance = ERC20(auction.purchaseToken).balanceOf(address(this)); + + // Partial fill: Unlock collateral pro-rated to the purchase token received + if (purchaseTokenBalance != auction.purchaseTokenAmount * 1e18 / (1e18 - auction.servicingFeeProRatedMantissa)) + { + auction.collateralManager.externalUnlockCollateral( + address(asset), auction.collateralAmount * purchaseTokenBalance / auction.purchaseTokenAmount + ); + } + + // Swap all purchase token to asset and repay escrow + _swapPurchaseTokenToAsset(); + _repayEscrow(); + } + + /// @notice Permissionless function callable after loan maturity + /// @notice Repays loan, settles debt with escrow (if any), calls report and unlocks withdrawals + function closeLoan() public { + require(block.timestamp >= auction.repoServicer.maturityTimestamp(), "Before maturity"); + require(block.timestamp < auction.repoServicer.endOfRepurchaseWindow(), "Repurchase window closed"); + + // Obtain the required amount of purchase token for full repayment + _repayTermLoan(auction.repoServicer.getBorrowerRepurchaseObligation(address(this))); + + // If there is some debt to escrow remaining, repay + if (auction.escrowDebt > 0) { + _repayEscrow(); + } + + // Call report, realizing profit + _delegateCall(abi.encode(TokenizedStrategy.report.selector)); + + // Remove withdrawal lock + _lock(true, false); + } + + /// @notice Allows management to top off collateral, with option to pull from escrow contract + /// @param _collateralToAdd Amount of collateral to add + /// @param _pullFromEscrow Bool to determine if we pull funds from escrow + function lockCollateral(uint256 _collateralToAdd, bool _pullFromEscrow) external onlyManagement { + if (_pullFromEscrow) { + termEscrow.pullFromEscrow(_collateralToAdd); + auction.escrowDebt += _collateralToAdd; + } + auction.collateralManager.externalLockCollateral(address(asset), _collateralToAdd); + } + + /// @notice Emergency function callable by management to repay escrow contract + function emergencyRepayEscrow() external onlyManagement { + uint256 availableAsset = asset.balanceOf(address(this)); + uint256 toRepay = Math.min(availableAsset, auction.escrowDebt); + asset.transfer(address(termEscrow), toRepay); + auction.escrowDebt = auction.escrowDebt - toRepay; + } + + /// @notice Allow management to change escrow fee + /// @param _escrowFeeBps New escrow fee, in bps + function setEscrowFeeBps(uint256 _escrowFeeBps) external onlyManagement { + require(_escrowFeeBps <= 15, "Fee cap of 15bps exceeded"); + escrowFeeBps = _escrowFeeBps; + } + + // ======================================================================== + // = ASSET PAIR SPECIFIC ================================================== + // ======================================================================== + + /// @notice Conversion from collateral token to purchase token + function _collateralTokenToPurchaseToken(uint256 _collateralTokenAmount) internal returns (uint256) { + return statAToken.convertToAssets(_collateralTokenAmount); + } + + /// @notice Purchase token (native token) --> Asset (statAToken) + function _swapPurchaseTokenToAsset() internal { + statAToken.deposit(ERC20(nativeToken).balanceOf(address(this)), address(this), 0, false); + } + + /// @notice Get purchase token via AAVE V3 flashloan + function _repayTermLoan(uint256 _purchaseTokenAmount) internal { + POOL.flashLoanSimple(address(this), nativeToken, _purchaseTokenAmount, "", 0); + } + + /// @notice AAVE V3 flashloan logic + function executeOperation(address asset, uint256 amount, uint256 premium, address initiator, bytes calldata params) + external + override + returns (bool) + { + require(initiator == address(this), "FlashBorrower: Untrusted loan initiator"); + + // Approve RepoLocker, repay Term loan, free up collateral + ERC20(auction.purchaseToken).approve(address(auction.repoServicer.termRepoLocker()), amount); + auction.repoServicer.submitRepurchasePayment(amount); + + // Swap some statAToken to repay flashloan + fee + uint256 totalAmount = amount + premium; + + statAToken.withdraw(statAToken.convertToShares(totalAmount), address(this), address(this)); + + // Repay flashloan + ERC20(asset).approve(address(POOL), totalAmount); + + return true; + } +} diff --git a/src/Strategy.sol b/src/Strategy.sol index 27f086dd..8b457cb2 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -7,6 +7,8 @@ import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ITermController, TermAuctionResults} 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, ListData} from "./RepoTokenList.sol"; /** @@ -35,6 +37,19 @@ contract Strategy is BaseStrategy { error TimeToMaturityAboveThreshold(); error BalanceBelowLiquidityThreshold(); + AuctionInfo public auction; + + struct AuctionInfo { + ITermAuction termAuction; + ITermAuctionOfferLocker offerLocker; + ITermRepoServicer repoServicer; + ITermRepoCollateralManager collateralManager; + uint256 collateralAmount; + uint256 purchaseTokenAmount; + uint256 escrowDebt; + uint256 servicingFeeProRatedMantissa; + } + ITermVaultEvents public immutable TERM_VAULT_EVENT_EMITTER; uint256 public immutable PURCHASE_TOKEN_PRECISION; @@ -173,6 +188,27 @@ contract Strategy is BaseStrategy { IERC20(asset).safeTransfer(msg.sender, proceeds); } + function offerOnNewAuction( + address _termAuction, + bytes32 _idHash, + bytes32 _bidPriceHash, + uint256 _purchaseTokenAmount, + uint256 _collateralAmount + ) external onlyManagement returns (bytes32[] memory offerIds) { + auction.termAuction = ITermAuction(_termAuction); + auction.bidLocker = ITermAuctionOfferLocker(auction.termAuction.termAuctionBidLocker()); + require(auction.bidLocker.collateralTokens(address(asset)), "Wrong collateral"); + require( + block.timestamp > auction.bidLocker.auctionStartTime() + || block.timestamp < auction.termAuction.auctionEndTime(), + "Auction not open" + ); + + auction.repoServicer = ITermRepoServicer(auction.bidLocker.termRepoServicer()); + auction.collateralManager = ITermRepoCollateralManager(auction.bidLocker.termRepoCollateralManager()); + + } + function _repoToPurchasePrecision( uint256 repoTokenPrecision, uint256 purchaseTokenAmountInRepoPrecision diff --git a/src/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol index c966680b..47ff85c6 100644 --- a/src/TermVaultEventEmitter.sol +++ b/src/TermVaultEventEmitter.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.20; import "./interfaces/term/ITermVaultEvents.sol"; -import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.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 TermListingEventEmitter is Initializable, UUPSUpgradeable, AccessControlUpgradeable, ITermVaultEvents { diff --git a/src/interfaces/term/ITermAuction.sol b/src/interfaces/term/ITermAuction.sol new file mode 100644 index 00000000..5d47b44b --- /dev/null +++ b/src/interfaces/term/ITermAuction.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +interface ITermAuction { + +} \ No newline at end of file diff --git a/src/interfaces/term/ITermRepoCollateralManager.sol b/src/interfaces/term/ITermRepoCollateralManager.sol index 26644f1d..f66832a9 100644 --- a/src/interfaces/term/ITermRepoCollateralManager.sol +++ b/src/interfaces/term/ITermRepoCollateralManager.sol @@ -3,4 +3,4 @@ pragma solidity 0.8.18; interface ITermRepoCollateralManager { -} \ No newline at end of file +} From 2a5f722c71b7413a9aa3cc3018a82967667d1207 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Wed, 24 Apr 2024 21:26:48 -0700 Subject: [PATCH 05/30] update foundry --- foundry.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundry.toml b/foundry.toml index d3273268..eab88f3d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,7 +6,7 @@ solc = "0.8.18" remappings = [ "@openzeppelin/=lib/openzeppelin-contracts/", - "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/" + "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", "forge-std/=lib/forge-std/src/", "@tokenized-strategy/=lib/tokenized-strategy/src/", "@periphery/=lib/tokenized-strategy-periphery/src/", From f34023cbbc89d008566e3e956bf193a830b94b84 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Wed, 24 Apr 2024 21:27:33 -0700 Subject: [PATCH 06/30] forge install: openzeppelin-contracts-upgradeable v5.0.2 --- .gitmodules | 3 +++ lib/openzeppelin-contracts-upgradeable | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/openzeppelin-contracts-upgradeable 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/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 00000000..723f8cab --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1 From c51c0c3ec42c10593d1fc6a917f9756d2b44e044 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Wed, 24 Apr 2024 23:40:09 -0700 Subject: [PATCH 07/30] fixing build --- foundry.toml | 2 +- src/TermVaultEventEmitter.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/foundry.toml b/foundry.toml index eab88f3d..4c767806 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,8 +5,8 @@ libs = ['lib'] solc = "0.8.18" remappings = [ - "@openzeppelin/=lib/openzeppelin-contracts/", "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", + "@openzeppelin/=lib/openzeppelin-contracts/", "forge-std/=lib/forge-std/src/", "@tokenized-strategy/=lib/tokenized-strategy/src/", "@periphery/=lib/tokenized-strategy-periphery/src/", diff --git a/src/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol index 47ff85c6..02e6345c 100644 --- a/src/TermVaultEventEmitter.sol +++ b/src/TermVaultEventEmitter.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.20; +pragma solidity ^0.8.18; import "./interfaces/term/ITermVaultEvents.sol"; From ca369dc6b3a588feca602c36138cac9141a35816 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Wed, 24 Apr 2024 23:43:02 -0700 Subject: [PATCH 08/30] forge install: openzeppelin-contracts-upgradeable v4.9.6 --- lib/openzeppelin-contracts-upgradeable | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable index 723f8cab..2d081f24 160000 --- a/lib/openzeppelin-contracts-upgradeable +++ b/lib/openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1 +Subproject commit 2d081f24cac1a867f6f73d512f2022e1fa987854 From f1893e9c01ad5cf17d4ab5e6271089b818033f2b Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Thu, 25 Apr 2024 17:49:04 -0700 Subject: [PATCH 09/30] offerOnNewAuction impl --- foundry.toml | 2 +- src/RepoTokenList.sol | 38 +++++-- src/Strategy.sol | 119 ++++++++++++---------- src/TermVaultEventEmitter.sol | 17 +++- src/interfaces/term/ITermAuction.sol | 4 + src/interfaces/term/ITermRepoServicer.sol | 2 + src/interfaces/term/ITermRepoToken.sol | 2 +- src/interfaces/term/ITermVaultEvents.sol | 2 +- 8 files changed, 119 insertions(+), 67 deletions(-) diff --git a/foundry.toml b/foundry.toml index 4c767806..519f9116 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,7 +6,7 @@ solc = "0.8.18" remappings = [ "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", - "@openzeppelin/=lib/openzeppelin-contracts/", + "@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/src/RepoTokenList.sol b/src/RepoTokenList.sol index 1e23a75d..28429794 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.18; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; struct ListNode { address next; @@ -11,11 +12,14 @@ struct ListNode { struct ListData { address head; mapping(address => ListNode) nodes; + mapping(address => uint256) repoTokenAuctionRates; } library RepoTokenList { + address public constant NULL_NODE = address(0); + function _getRepoTokenMaturity(address repoToken) private view returns (uint256 redemptionTimestamp) { - (redemptionTimestamp, ) = ITermRepoToken(repoToken).config(); + (redemptionTimestamp, , ,) = ITermRepoToken(repoToken).config(); } function _getRepoTokenTimeToMaturity(address repoToken) private view returns (uint256) { @@ -26,9 +30,17 @@ library RepoTokenList { return listData.nodes[current].next; } - function getWeightedTimeToMaturity(ListData storage listData, address repoToken, uint256 amount) internal view returns (uint256) { + function getWeightedTimeToMaturity( + ListData storage listData, + address repoToken, + uint256 repoTokenAmount, + uint256 purchaseTokenPrecision, + uint256 purchaseTokenBalance + ) internal view returns (uint256) { if (listData.head == NULL_NODE) return 0; + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 cumulativeWeightedMaturityTimestamp; uint256 cumulativeRepoTokenAmount; address current = listData.head; @@ -43,15 +55,15 @@ library RepoTokenList { } cumulativeRepoTokenAmount += repoTokenBalance; - current = _getNext(current); + current = _getNext(listData, current); } if (repoToken != address(0)) { - cumulativeWeightedMaturityTimestamp += _getRepoTokenTimeToMaturity(repoToken) * amount; - cumulativeRepoTokenAmount += amount; + cumulativeWeightedMaturityTimestamp += _getRepoTokenTimeToMaturity(repoToken) * repoTokenAmount; + cumulativeRepoTokenAmount += repoTokenAmount; } - uint256 excessLiquidity = _assetBalance(address(this)) * repoTokenPrecision / PURCHASE_TOKEN_PRECISION; + uint256 excessLiquidity = purchaseTokenBalance * repoTokenPrecision / purchaseTokenPrecision; /// @dev avoid div by 0 if (cumulativeRepoTokenAmount == 0 && excessLiquidity == 0) { @@ -61,7 +73,11 @@ library RepoTokenList { return cumulativeWeightedMaturityTimestamp * repoTokenPrecision / (cumulativeRepoTokenAmount + excessLiquidity); } - function removeAndRedeemMaturedTokens(ListData storage listData, address repoServicer, uint256 amount) internal { + function removeAndRedeemMaturedTokens( + ListData storage listData, + address repoServicer, + uint256 amount + ) internal { if (listData.head == NULL_NODE) return; address current = listData.head; @@ -70,7 +86,7 @@ library RepoTokenList { address next; if (_getRepoTokenMaturity(current) >= block.timestamp) { - next = _getNext(current); + next = _getNext(listData, current); if (current == listData.head) { listData.head = next; @@ -78,7 +94,7 @@ library RepoTokenList { listData.nodes[prev].next = next; delete listData.nodes[current]; - delete repoTokenExists[current]; + delete listData.repoTokenAuctionRates[current]; ITermRepoServicer(repoServicer).redeemTermRepoTokens(address(this), amount); } else { @@ -87,7 +103,7 @@ library RepoTokenList { } prev = current; - current = _getNext(current); + current = _getNext(listData, current); } } @@ -116,7 +132,7 @@ library RepoTokenList { } prev = current; - current = _getNext(current); + current = _getNext(listData, current); } } } diff --git a/src/Strategy.sol b/src/Strategy.sol index 8b457cb2..682ffc2e 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -2,8 +2,9 @@ pragma solidity 0.8.18; import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; +import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; import {ITermController, TermAuctionResults} from "./interfaces/term/ITermController.sol"; import {ITermVaultEvents} from "./interfaces/term/ITermVaultEvents.sol"; import {ITermAuctionOfferLocker} from "./interfaces/term/ITermAuctionOfferLocker.sol"; @@ -25,7 +26,7 @@ import {RepoTokenList, ListData} from "./RepoTokenList.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 ListData; address public constant NULL_NODE = address(0); @@ -37,19 +38,6 @@ contract Strategy is BaseStrategy { error TimeToMaturityAboveThreshold(); error BalanceBelowLiquidityThreshold(); - AuctionInfo public auction; - - struct AuctionInfo { - ITermAuction termAuction; - ITermAuctionOfferLocker offerLocker; - ITermRepoServicer repoServicer; - ITermRepoCollateralManager collateralManager; - uint256 collateralAmount; - uint256 purchaseTokenAmount; - uint256 escrowDebt; - uint256 servicingFeeProRatedMantissa; - } - ITermVaultEvents public immutable TERM_VAULT_EVENT_EMITTER; uint256 public immutable PURCHASE_TOKEN_PRECISION; @@ -58,11 +46,10 @@ contract Strategy is BaseStrategy { uint256 public timeToMaturityThreshold; // seconds uint256 public liquidityThreshold; // purchase token precision (underlying) uint256 public auctionRateMarkup; // 1e18 (TODO: check this) - mapping(address => uint256) public repoTokenAuctionRates; function setTermController(address newTermController) external onlyManagement { - TERM_VAULT_EVENT_EMITTER.emitTermControllerUpdated(termController, newTermController); - termController = newTermController; + TERM_VAULT_EVENT_EMITTER.emitTermControllerUpdated(address(termController), newTermController); + termController = ITermController(newTermController); } function setTimeToMaturityThreshold(uint256 newTimeToMaturityThreshold) external onlyManagement { @@ -81,38 +68,42 @@ contract Strategy is BaseStrategy { } function _removeRedeemAndCalculateWeightedMaturity(address repoToken, uint256 amount) private returns (uint256) { - uint256 weightedTimeToMaturity = listData.getWeightedTimeToMaturity(repoToken, amount); - listData.removeAndRedeemMaturedTokens(repoToken, repoTokenAuctionRates, amount); + uint256 weightedTimeToMaturity = listData.getWeightedTimeToMaturity( + repoToken, amount, PURCHASE_TOKEN_PRECISION, _assetBalance() + ); + listData.removeAndRedeemMaturedTokens(repoToken, amount); return weightedTimeToMaturity; } function getWeightedTimeToMaturity(address repoToken, uint256 amount) external view returns (uint256) { - return listData.getWeightedTimeToMaturity(repoToken, amount); + return listData.getWeightedTimeToMaturity( + repoToken, amount, PURCHASE_TOKEN_PRECISION, _assetBalance() + ); } function _validateRepoToken(ITermRepoToken repoToken) private returns (uint256 auctionRate, uint256 redemptionTimestamp) { - auctionRate = repoTokenAuctionRates[repoToken]; + auctionRate = listData.repoTokenAuctionRates[address(repoToken)]; if (auctionRate != INVALID_AUCTION_RATE) { - (redemptionTimestamp, ) = repoToken.config(); + (redemptionTimestamp, , ,) = repoToken.config(); - uint256 oracleRate = _auctionRate(); + uint256 oracleRate = _auctionRate(repoToken); if (oracleRate != INVALID_AUCTION_RATE) { if (auctionRate != oracleRate) { - repoTokenAuctionRates[repoToken] = oracleRate; + listData.repoTokenAuctionRates[address(repoToken)] = oracleRate; } } } else { - auctionRate = _auctionRate(); + auctionRate = _auctionRate(repoToken); if (!termController.isTermDeployed(address(repoToken))) { revert InvalidRepoToken(address(repoToken)); } address purchaseToken; - (redemptionTimestamp, purchaseToken) = repoToken.config(); - if (purchaseToken != asset) { + (redemptionTimestamp, purchaseToken, ,) = repoToken.config(); + if (purchaseToken != address(asset)) { revert InvalidRepoToken(address(repoToken)); } @@ -120,8 +111,8 @@ contract Strategy is BaseStrategy { revert InvalidRepoToken(address(repoToken)); } - _insertSorted(repoToken); - repoTokenAuctionRates[repoToken] = auctionRate; + listData.insertSorted(address(repoToken)); + listData.repoTokenAuctionRates[address(repoToken)] = auctionRate; } } @@ -140,6 +131,10 @@ contract Strategy is BaseStrategy { //IYearVault.withdraw(asset, proceeds); } + function _assetBalance() private view returns (uint256) { + // return IYearnVault.balanceOf(address(this)); + } + function _auctionRate(ITermRepoToken repoToken) private view returns (uint256) { TermAuctionResults memory results = termController.getTermAuctionResults(repoToken.termRepoId()); @@ -151,12 +146,12 @@ contract Strategy is BaseStrategy { } // TODO: reentrancy check - function sellRepoToken(address repoToken, uint256 amount) external { - (uint256 redemptionTimestamp, ) = _validateRepoToken(ITermRepoToken(repoToken)); + function sellRepoToken(address repoToken, uint256 repoTokenAmount) external { + (uint256 auctionRate, uint256 redemptionTimestamp) = _validateRepoToken(ITermRepoToken(repoToken)); _sweepAsset(); - uint256 resultingTimeToMaturity = listData.removeRedeemAndCalculateWeightedMaturity(repoToken, amount); + uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity(repoToken, repoTokenAmount); if (resultingTimeToMaturity > timeToMaturityThreshold) { revert TimeToMaturityAboveThreshold(); @@ -165,18 +160,18 @@ contract Strategy is BaseStrategy { uint256 liquidBalance = _totalLiquidBalance(address(this)); /// @dev in repo token precision - uint256 repoTokenPrecision = 10 ** IERC20(repoToken).decimals(); + uint256 repoTokenPrecision = 10 ** ERC20(repoToken).decimals(); uint256 timeLeftToMaturityDayFraction = ((redemptionTimestamp - block.timestamp) * repoTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; uint256 purchaseTokenAmountInRepoTokenPrecision = - (repoTokenAmount * repoTokenPrecision) / (repoTokenPrecision + (rate * timeLeftToMaturityDayFraction / RATE_PRECISION)); + (repoTokenAmount * repoTokenPrecision) / (repoTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)); - uint256 purchaseTokenAmount = _repoToPurchasePrecision( + uint256 proceeds = _repoToPurchasePrecision( repoTokenPrecision, purchaseTokenAmountInRepoTokenPrecision ); - liquidBalance -= purchaseTokenAmount; + liquidBalance -= proceeds; if (liquidBalance < liquidityThreshold) { revert BalanceBelowLiquidityThreshold(); @@ -184,36 +179,56 @@ contract Strategy is BaseStrategy { _withdrawAsset(proceeds); - IERC20(repoToken).safeTransferFrom(msg.sender, address(this), amount); + IERC20(repoToken).safeTransferFrom(msg.sender, address(this), repoTokenAmount); IERC20(asset).safeTransfer(msg.sender, proceeds); } function offerOnNewAuction( - address _termAuction, - bytes32 _idHash, - bytes32 _bidPriceHash, - uint256 _purchaseTokenAmount, - uint256 _collateralAmount + address termAuction, + bytes32 idHash, + bytes32 offerPriceHash, + uint256 purchaseTokenAmount ) external onlyManagement returns (bytes32[] memory offerIds) { - auction.termAuction = ITermAuction(_termAuction); - auction.bidLocker = ITermAuctionOfferLocker(auction.termAuction.termAuctionBidLocker()); - require(auction.bidLocker.collateralTokens(address(asset)), "Wrong collateral"); + ITermAuction termAuction = ITermAuction(termAuction); + ITermAuctionOfferLocker offerLocker = ITermAuctionOfferLocker(termAuction.termAuctionOfferLocker()); + require(offerLocker.purchaseToken() == address(asset), "Wrong purchase token"); require( - block.timestamp > auction.bidLocker.auctionStartTime() - || block.timestamp < auction.termAuction.auctionEndTime(), + block.timestamp > offerLocker.auctionStartTime() + || block.timestamp < termAuction.auctionEndTime(), "Auction not open" ); - auction.repoServicer = ITermRepoServicer(auction.bidLocker.termRepoServicer()); - auction.collateralManager = ITermRepoCollateralManager(auction.bidLocker.termRepoCollateralManager()); + uint256 liquidBalance = _totalLiquidBalance(address(this)); + if ((liquidBalance - purchaseTokenAmount) < liquidityThreshold) { + revert BalanceBelowLiquidityThreshold(); + } + + ITermRepoServicer repoServicer = ITermRepoServicer(offerLocker.termRepoServicer()); + ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer; + + offer.id = idHash; + offer.offeror = address(this); + offer.offerPriceHash = offerPriceHash; + offer.amount = purchaseTokenAmount; + offer.purchaseToken = address(asset); + + ITermAuctionOfferLocker.TermAuctionOfferSubmission[] memory offerSubmissions = + new ITermAuctionOfferLocker.TermAuctionOfferSubmission[](1); + offerSubmissions[0] = offer; + + ERC20(asset).approve(address(repoServicer.termRepoLocker()), purchaseTokenAmount); + + return offerLocker.lockOffers(offerSubmissions); + + // Lock? } function _repoToPurchasePrecision( uint256 repoTokenPrecision, uint256 purchaseTokenAmountInRepoPrecision ) private view returns (uint256) { - return (purchaseTokenAmountInRepoPrecision * purchaseTokenPrecision) / PURCHASE_TOKEN_PRECISION; + return (purchaseTokenAmountInRepoPrecision * PURCHASE_TOKEN_PRECISION) / repoTokenPrecision; } constructor( @@ -222,7 +237,7 @@ contract Strategy is BaseStrategy { address _eventEmitter ) BaseStrategy(_asset, _name) { TERM_VAULT_EVENT_EMITTER = ITermVaultEvents(_eventEmitter); - PURCHASE_TOKEN_PRECISION = 10**IERC20(asset); + PURCHASE_TOKEN_PRECISION = 10**ERC20(asset).decimals(); } /*////////////////////////////////////////////////////////////// diff --git a/src/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol index 02e6345c..62ec2812 100644 --- a/src/TermVaultEventEmitter.sol +++ b/src/TermVaultEventEmitter.sol @@ -2,7 +2,6 @@ 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"; @@ -36,6 +35,22 @@ contract TermListingEventEmitter is Initializable, UUPSUpgradeable, AccessContro _grantRole(VAULT_CONTRACT, vaultContract); } + function emitTermControllerUpdated(address oldController, address newController) external { + emit TermControllerUpdated(oldController, newController); + } + + function emitTimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external { + emit TimeToMaturityThresholdUpdated(oldThreshold, newThreshold); + } + + function emitLiquidityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external { + emit LiquidityThresholdUpdated(oldThreshold, newThreshold); + } + + function emitAuctionRateMarkupUpdated(uint256 oldMarkup, uint256 newMarkup) external { + emit AuctionRateMarkupUpdated(oldMarkup, newMarkup); + } + // ======================================================================== // = Admin =============================================================== // ======================================================================== diff --git a/src/interfaces/term/ITermAuction.sol b/src/interfaces/term/ITermAuction.sol index 5d47b44b..8f34b2e7 100644 --- a/src/interfaces/term/ITermAuction.sol +++ b/src/interfaces/term/ITermAuction.sol @@ -2,5 +2,9 @@ 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); } \ No newline at end of file diff --git a/src/interfaces/term/ITermRepoServicer.sol b/src/interfaces/term/ITermRepoServicer.sol index 1bc3bf3e..2b2e9427 100644 --- a/src/interfaces/term/ITermRepoServicer.sol +++ b/src/interfaces/term/ITermRepoServicer.sol @@ -9,5 +9,7 @@ interface ITermRepoServicer { 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 index 1209b57d..84951298 100644 --- a/src/interfaces/term/ITermRepoToken.sol +++ b/src/interfaces/term/ITermRepoToken.sol @@ -13,5 +13,5 @@ interface ITermRepoToken is IERC20 { address termRepoCollateralManager ); - function termRepoId() external view returns (address); + function termRepoId() external view returns (bytes32); } diff --git a/src/interfaces/term/ITermVaultEvents.sol b/src/interfaces/term/ITermVaultEvents.sol index 674ea6cd..5c025948 100644 --- a/src/interfaces/term/ITermVaultEvents.sol +++ b/src/interfaces/term/ITermVaultEvents.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; -interface ITermListingContractEvents { +interface ITermVaultEvents { event TermControllerUpdated(address oldController, address newController); event TimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold); From 7c110aa6d6e09b231f00c4b75704fbf90af6707d Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Thu, 25 Apr 2024 18:10:34 -0700 Subject: [PATCH 10/30] adding yearn vault --- src/Strategy.sol | 35 +++++++++++++++++++++++++++-------- src/test/utils/Setup.sol | 2 +- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/Strategy.sol b/src/Strategy.sol index 682ffc2e..c3c83122 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.18; import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; import {ITermController, TermAuctionResults} from "./interfaces/term/ITermController.sol"; @@ -37,9 +38,10 @@ contract Strategy is BaseStrategy { error InvalidRepoToken(address token); error TimeToMaturityAboveThreshold(); error BalanceBelowLiquidityThreshold(); - + ITermVaultEvents public immutable TERM_VAULT_EVENT_EMITTER; uint256 public immutable PURCHASE_TOKEN_PRECISION; + IERC4626 public immutable YEARN_VAULT; ITermController public termController; ListData public listData; @@ -117,22 +119,23 @@ contract Strategy is BaseStrategy { } function _totalLiquidBalance(address addr) private view returns (uint256) { - // uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); - // return IYearnVault.balanceOf(address(this)) + underlyingBalance; + uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); + return _assetBalance() + underlyingBalance; } function _sweepAsset() private { - // uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); - // if underlyingBalance > 0 - // IYearnVault.deposit(underlyingBalance); + uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); + if (underlyingBalance > 0) { + YEARN_VAULT.deposit(underlyingBalance, address(this)); + } } function _withdrawAsset(uint256 amount) private { - //IYearVault.withdraw(asset, proceeds); + YEARN_VAULT.withdraw(YEARN_VAULT.convertToShares(amount), address(this), address(this)); } function _assetBalance() private view returns (uint256) { - // return IYearnVault.balanceOf(address(this)); + return YEARN_VAULT.convertToAssets(YEARN_VAULT.balanceOf(address(this))); } function _auctionRate(ITermRepoToken repoToken) private view returns (uint256) { @@ -185,11 +188,25 @@ contract Strategy is BaseStrategy { function offerOnNewAuction( address termAuction, + address repoToken, bytes32 idHash, bytes32 offerPriceHash, uint256 purchaseTokenAmount ) external onlyManagement returns (bytes32[] memory offerIds) { ITermAuction termAuction = ITermAuction(termAuction); + + require(termAuction.termRepoId() == ITermRepoToken(repoToken).termRepoId()); + + (uint256 auctionRate, uint256 redemptionTimestamp) = _validateRepoToken(ITermRepoToken(repoToken)); + + _sweepAsset(); + +// uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity(repoToken, repoTokenAmount); + + //if (resultingTimeToMaturity > timeToMaturityThreshold) { +// revert TimeToMaturityAboveThreshold(); +// } + ITermAuctionOfferLocker offerLocker = ITermAuctionOfferLocker(termAuction.termAuctionOfferLocker()); require(offerLocker.purchaseToken() == address(asset), "Wrong purchase token"); require( @@ -234,8 +251,10 @@ contract Strategy is BaseStrategy { constructor( address _asset, 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(); } diff --git a/src/test/utils/Setup.sol b/src/test/utils/Setup.sol index b6b097f9..1ac74c0e 100644 --- a/src/test/utils/Setup.sol +++ b/src/test/utils/Setup.sol @@ -71,7 +71,7 @@ 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(0))) + address(new Strategy(address(asset), "Tokenized Strategy", address(0), address(0))) ); // set keeper From 14571fff799f8ce94b41f4168f42d273e8c3e819 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Mon, 29 Apr 2024 00:11:25 -0700 Subject: [PATCH 11/30] adding pending offers --- src/RepoTokenList.sol | 43 ++++++-- src/RepoTokenUtils.sol | 42 ++++++++ src/Strategy.sol | 100 +++++++++++------- src/TermAuctionList.sol | 77 ++++++++++++++ src/interfaces/term/ITermAuction.sol | 2 + .../term/ITermAuctionOfferLocker.sol | 21 ++++ 6 files changed, 236 insertions(+), 49 deletions(-) create mode 100644 src/RepoTokenUtils.sol create mode 100644 src/TermAuctionList.sol diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index 28429794..2685e305 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -4,15 +4,16 @@ pragma solidity 0.8.18; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {RepoTokenUtils} from "./RepoTokenUtils.sol"; -struct ListNode { +struct RepoTokenListNode { address next; } -struct ListData { +struct RepoTokenListData { address head; - mapping(address => ListNode) nodes; - mapping(address => uint256) repoTokenAuctionRates; + mapping(address => RepoTokenListNode) nodes; + mapping(address => uint256) auctionRates; } library RepoTokenList { @@ -26,12 +27,12 @@ library RepoTokenList { return _getRepoTokenMaturity(repoToken) - block.timestamp; } - function _getNext(ListData storage listData, address current) private view returns (address) { + function _getNext(RepoTokenListData storage listData, address current) private view returns (address) { return listData.nodes[current].next; } function getWeightedTimeToMaturity( - ListData storage listData, + RepoTokenListData storage listData, address repoToken, uint256 repoTokenAmount, uint256 purchaseTokenPrecision, @@ -74,7 +75,7 @@ library RepoTokenList { } function removeAndRedeemMaturedTokens( - ListData storage listData, + RepoTokenListData storage listData, address repoServicer, uint256 amount ) internal { @@ -94,7 +95,7 @@ library RepoTokenList { listData.nodes[prev].next = next; delete listData.nodes[current]; - delete listData.repoTokenAuctionRates[current]; + delete listData.auctionRates[current]; ITermRepoServicer(repoServicer).redeemTermRepoTokens(address(this), amount); } else { @@ -107,7 +108,7 @@ library RepoTokenList { } } - function insertSorted(ListData storage listData, address repoToken) internal { + function insertSorted(RepoTokenListData storage listData, address repoToken) internal { address current = listData.head; if (current == NULL_NODE) { @@ -135,4 +136,28 @@ library RepoTokenList { current = _getNext(listData, current); } } + + 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]; + + repoTokenBalance = ITermRepoToken(current).redemptionValue() * repoTokenBalance / RepoTokenUtils.RATE_PRECISION; + + if (currentMaturity > block.timestamp) { + totalPresentValue += RepoTokenUtils.calculateProceeds( + repoTokenBalance, currentMaturity, repoTokenPrecision, purchaseTokenPrecision, auctionRate + ); + } else { + totalPresentValue += RepoTokenUtils.repoToPurchasePrecision(repoTokenPrecision, purchaseTokenPrecision, repoTokenBalance); + } + + current = _getNext(listData, current); + } + } } diff --git a/src/RepoTokenUtils.sol b/src/RepoTokenUtils.sol new file mode 100644 index 00000000..1f01e931 --- /dev/null +++ b/src/RepoTokenUtils.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +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 calculateProceeds( + uint256 repoTokenAmount, + uint256 redemptionTimestamp, + uint256 repoTokenPrecision, + uint256 purchaseTokenPrecision, + uint256 auctionRate + ) internal view returns (uint256) { + uint256 timeLeftToMaturityDayFraction = + ((redemptionTimestamp - block.timestamp) * repoTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; + + uint256 purchaseTokenAmountInRepoTokenPrecision = + (repoTokenAmount * repoTokenPrecision) / + (repoTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)); + + return repoToPurchasePrecision( + repoTokenPrecision, purchaseTokenPrecision, purchaseTokenAmountInRepoTokenPrecision + ); + } +} diff --git a/src/Strategy.sol b/src/Strategy.sol index c3c83122..dca97404 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -11,7 +11,9 @@ 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, ListData} from "./RepoTokenList.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 @@ -28,14 +30,14 @@ import {RepoTokenList, ListData} from "./RepoTokenList.sol"; contract Strategy is BaseStrategy { using SafeERC20 for IERC20; - using RepoTokenList for ListData; + using RepoTokenList for RepoTokenListData; + using TermAuctionList for TermAuctionListData; address public constant NULL_NODE = address(0); uint256 internal constant INVALID_AUCTION_RATE = 0; - uint256 public constant THREESIXTY_DAYCOUNT_SECONDS = 360 days; - uint256 public constant RATE_PRECISION = 1e18; error InvalidRepoToken(address token); + error InvalidTermAuction(address auction); error TimeToMaturityAboveThreshold(); error BalanceBelowLiquidityThreshold(); @@ -44,7 +46,8 @@ contract Strategy is BaseStrategy { IERC4626 public immutable YEARN_VAULT; ITermController public termController; - ListData public listData; + RepoTokenListData public repoTokenListData; + TermAuctionListData public termAuctionListData; uint256 public timeToMaturityThreshold; // seconds uint256 public liquidityThreshold; // purchase token precision (underlying) uint256 public auctionRateMarkup; // 1e18 (TODO: check this) @@ -70,15 +73,15 @@ contract Strategy is BaseStrategy { } function _removeRedeemAndCalculateWeightedMaturity(address repoToken, uint256 amount) private returns (uint256) { - uint256 weightedTimeToMaturity = listData.getWeightedTimeToMaturity( + uint256 weightedTimeToMaturity = repoTokenListData.getWeightedTimeToMaturity( repoToken, amount, PURCHASE_TOKEN_PRECISION, _assetBalance() ); - listData.removeAndRedeemMaturedTokens(repoToken, amount); + repoTokenListData.removeAndRedeemMaturedTokens(repoToken, amount); return weightedTimeToMaturity; } function getWeightedTimeToMaturity(address repoToken, uint256 amount) external view returns (uint256) { - return listData.getWeightedTimeToMaturity( + return repoTokenListData.getWeightedTimeToMaturity( repoToken, amount, PURCHASE_TOKEN_PRECISION, _assetBalance() ); } @@ -86,14 +89,14 @@ contract Strategy is BaseStrategy { function _validateRepoToken(ITermRepoToken repoToken) private returns (uint256 auctionRate, uint256 redemptionTimestamp) { - auctionRate = listData.repoTokenAuctionRates[address(repoToken)]; + auctionRate = repoTokenListData.auctionRates[address(repoToken)]; if (auctionRate != INVALID_AUCTION_RATE) { (redemptionTimestamp, , ,) = repoToken.config(); uint256 oracleRate = _auctionRate(repoToken); if (oracleRate != INVALID_AUCTION_RATE) { if (auctionRate != oracleRate) { - listData.repoTokenAuctionRates[address(repoToken)] = oracleRate; + repoTokenListData.auctionRates[address(repoToken)] = oracleRate; } } } else { @@ -113,8 +116,8 @@ contract Strategy is BaseStrategy { revert InvalidRepoToken(address(repoToken)); } - listData.insertSorted(address(repoToken)); - listData.repoTokenAuctionRates[address(repoToken)] = auctionRate; + repoTokenListData.insertSorted(address(repoToken)); + repoTokenListData.auctionRates[address(repoToken)] = auctionRate; } } @@ -161,17 +164,9 @@ contract Strategy is BaseStrategy { } uint256 liquidBalance = _totalLiquidBalance(address(this)); - - /// @dev in repo token precision - uint256 repoTokenPrecision = 10 ** ERC20(repoToken).decimals(); - uint256 timeLeftToMaturityDayFraction = - ((redemptionTimestamp - block.timestamp) * repoTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; - - uint256 purchaseTokenAmountInRepoTokenPrecision = - (repoTokenAmount * repoTokenPrecision) / (repoTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)); - - uint256 proceeds = _repoToPurchasePrecision( - repoTokenPrecision, purchaseTokenAmountInRepoTokenPrecision + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 proceeds = RepoTokenUtils.calculateProceeds( + repoTokenAmount, redemptionTimestamp, repoTokenPrecision, PURCHASE_TOKEN_PRECISION, auctionRate ); liquidBalance -= proceeds; @@ -193,25 +188,33 @@ contract Strategy is BaseStrategy { bytes32 offerPriceHash, uint256 purchaseTokenAmount ) external onlyManagement returns (bytes32[] memory offerIds) { - ITermAuction termAuction = ITermAuction(termAuction); + require(purchaseTokenAmount > 0); - require(termAuction.termRepoId() == ITermRepoToken(repoToken).termRepoId()); + if (!termController.isTermDeployed(termAuction)) { + revert InvalidTermAuction(termAuction); + } - (uint256 auctionRate, uint256 redemptionTimestamp) = _validateRepoToken(ITermRepoToken(repoToken)); + ITermAuction auction = ITermAuction(termAuction); + + require(auction.termRepoId() == ITermRepoToken(repoToken).termRepoId()); _sweepAsset(); -// uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity(repoToken, repoTokenAmount); + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( + repoToken, + RepoTokenUtils.purchaseToRepoPrecision(repoTokenPrecision, PURCHASE_TOKEN_PRECISION, purchaseTokenAmount) + ); - //if (resultingTimeToMaturity > timeToMaturityThreshold) { -// revert TimeToMaturityAboveThreshold(); -// } + if (resultingTimeToMaturity > timeToMaturityThreshold) { + revert TimeToMaturityAboveThreshold(); + } - ITermAuctionOfferLocker offerLocker = ITermAuctionOfferLocker(termAuction.termAuctionOfferLocker()); + ITermAuctionOfferLocker offerLocker = ITermAuctionOfferLocker(auction.termAuctionOfferLocker()); require(offerLocker.purchaseToken() == address(asset), "Wrong purchase token"); require( block.timestamp > offerLocker.auctionStartTime() - || block.timestamp < termAuction.auctionEndTime(), + || block.timestamp < auction.auctionEndTime(), "Auction not open" ); @@ -234,18 +237,33 @@ contract Strategy is BaseStrategy { new ITermAuctionOfferLocker.TermAuctionOfferSubmission[](1); offerSubmissions[0] = offer; + _withdrawAsset(purchaseTokenAmount); + ERC20(asset).approve(address(repoServicer.termRepoLocker()), purchaseTokenAmount); - return offerLocker.lockOffers(offerSubmissions); - - // Lock? + offerIds = offerLocker.lockOffers(offerSubmissions); + + require(offerIds.length > 0); + + termAuctionListData.insertPending(PendingOffer({ + offerId: offerIds[0], + repoToken: repoToken, + offerAmount: purchaseTokenAmount, + termAuction: auction, + offerLocker: offerLocker + })); } - function _repoToPurchasePrecision( - uint256 repoTokenPrecision, - uint256 purchaseTokenAmountInRepoPrecision - ) private view returns (uint256) { - return (purchaseTokenAmountInRepoPrecision * PURCHASE_TOKEN_PRECISION) / repoTokenPrecision; + function auctionClosed() external { + termAuctionListData.removeCompleted(); + + _sweepAsset(); + } + + function totalAssetValue() internal view returns (uint256 totalValue) { + return _totalLiquidBalance(address(this)) + + repoTokenListData.getPresentValue(PURCHASE_TOKEN_PRECISION) + + termAuctionListData.getPresentValue(repoTokenListData); } constructor( @@ -257,6 +275,8 @@ contract Strategy is BaseStrategy { YEARN_VAULT = IERC4626(_yearnVault); TERM_VAULT_EVENT_EMITTER = ITermVaultEvents(_eventEmitter); PURCHASE_TOKEN_PRECISION = 10**ERC20(asset).decimals(); + + IERC20(_asset).safeApprove(_yearnVault, type(uint256).max); } /*////////////////////////////////////////////////////////////// diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol new file mode 100644 index 00000000..dd7f9bdd --- /dev/null +++ b/src/TermAuctionList.sol @@ -0,0 +1,77 @@ +// 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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {RepoTokenListData} from "./RepoTokenList.sol"; + +struct PendingOffer { + bytes32 offerId; + address repoToken; + uint256 offerAmount; + ITermAuction termAuction; + ITermAuctionOfferLocker offerLocker; +} + +struct TermAuctionListNode { + bytes32 next; + bytes32 offerId; + address repoToken; + uint256 offerAmount; + ITermAuction termAuction; + ITermAuctionOfferLocker offerLocker; +} + +struct TermAuctionListData { + bytes32 head; + mapping(bytes32 => TermAuctionListNode) nodes; +} + +library TermAuctionList { + bytes32 public constant NULL_NODE = bytes32(0); + + function insertPending(TermAuctionListData storage listData, PendingOffer memory pendingOffer) internal { + + } + + function removeCompleted(TermAuctionListData storage listData) internal { + if (listData.head == NULL_NODE) return; + + bytes32 current = listData.head; + bytes32 prev = current; + while (current != NULL_NODE) { + TermAuctionListNode memory currentNode = listData.nodes[current]; + + if (currentNode.termAuction.auctionCompleted()) { + + } + + prev = current; + current = currentNode.next; + } + } + + function getPresentValue( + TermAuctionListData storage listData, + RepoTokenListData storage repoTokenListData + ) internal view returns (uint256 totalValue) { + if (listData.head == NULL_NODE) return 0; + + bytes32 current = listData.head; + while (current != NULL_NODE) { + TermAuctionListNode memory currentNode = listData.nodes[current]; + + uint256 offerAmount = currentNode.offerLocker.lockedOffer(currentNode.offerId).amount; + + /// @dev checking repoTokenAuctionRates to make sure we are not double counting on re-openings + if (offerAmount == 0 && repoTokenListData.auctionRates[currentNode.repoToken] == 0) { + totalValue += currentNode.offerAmount; + } else { + totalValue += offerAmount; + } + + current = currentNode.next; + } + } +} diff --git a/src/interfaces/term/ITermAuction.sol b/src/interfaces/term/ITermAuction.sol index 8f34b2e7..784cd1b0 100644 --- a/src/interfaces/term/ITermAuction.sol +++ b/src/interfaces/term/ITermAuction.sol @@ -7,4 +7,6 @@ interface ITermAuction { function termRepoId() external view returns (bytes32); function auctionEndTime() external view returns (uint256); + + function auctionCompleted() external view returns (bool); } \ No newline at end of file diff --git a/src/interfaces/term/ITermAuctionOfferLocker.sol b/src/interfaces/term/ITermAuctionOfferLocker.sol index b216a231..b668b4b4 100644 --- a/src/interfaces/term/ITermAuctionOfferLocker.sol +++ b/src/interfaces/term/ITermAuctionOfferLocker.sol @@ -15,6 +15,25 @@ interface ITermAuctionOfferLocker { /// @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); @@ -29,6 +48,8 @@ interface ITermAuctionOfferLocker { 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( From c86b8b4aff8d3915c23a16ff9021c44a21686a49 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Mon, 29 Apr 2024 20:10:37 -0700 Subject: [PATCH 12/30] refactor --- src/RepoTokenList.sol | 59 +++++++++++++++++- src/Strategy.sol | 59 +++--------------- src/TermAuctionList.sol | 60 +++++++++++++------ .../term/ITermRepoCollateralManager.sol | 6 -- 4 files changed, 106 insertions(+), 78 deletions(-) delete mode 100644 src/interfaces/term/ITermRepoCollateralManager.sol diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index 2685e305..c05b3ef8 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.18; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; +import {ITermController, TermAuctionResults} from "./interfaces/term/ITermController.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {RepoTokenUtils} from "./RepoTokenUtils.sol"; @@ -18,6 +19,9 @@ struct RepoTokenListData { library RepoTokenList { address public constant NULL_NODE = address(0); + uint256 internal constant INVALID_AUCTION_RATE = 0; + + error InvalidRepoToken(address token); function _getRepoTokenMaturity(address repoToken) private view returns (uint256 redemptionTimestamp) { (redemptionTimestamp, , ,) = ITermRepoToken(repoToken).config(); @@ -104,10 +108,59 @@ library RepoTokenList { } prev = current; - current = _getNext(listData, current); + current = next; } } + function _auctionRate(ITermController termController, ITermRepoToken repoToken) private view returns (uint256) { + TermAuctionResults memory results = termController.getTermAuctionResults(repoToken.termRepoId()); + + uint256 len = results.auctionMetadata.length; + + require(len > 0); + + return results.auctionMetadata[len - 1].auctionClearingRate; + } + + 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(); + + uint256 oracleRate = _auctionRate(termController, repoToken); + if (oracleRate != INVALID_AUCTION_RATE) { + if (auctionRate != oracleRate) { + listData.auctionRates[address(repoToken)] = oracleRate; + } + } + } else { + auctionRate = _auctionRate(termController, repoToken); + + if (!termController.isTermDeployed(address(repoToken))) { + revert InvalidRepoToken(address(repoToken)); + } + + address purchaseToken; + (redemptionTimestamp, purchaseToken, ,) = repoToken.config(); + if (purchaseToken != address(asset)) { + revert InvalidRepoToken(address(repoToken)); + } + + if (redemptionTimestamp < block.timestamp) { + revert InvalidRepoToken(address(repoToken)); + } + + insertSorted(listData, address(repoToken)); + listData.auctionRates[address(repoToken)] = auctionRate; + } + } + function insertSorted(RepoTokenListData storage listData, address repoToken) internal { address current = listData.head; @@ -117,13 +170,13 @@ library RepoTokenList { } address prev; - while (current != address(0)) { + while (current != NULL_NODE) { uint256 currentMaturity = _getRepoTokenMaturity(current); uint256 maturityToInsert = _getRepoTokenMaturity(repoToken); if (maturityToInsert <= currentMaturity) { - if (prev == address(0)) { + if (prev == NULL_NODE) { listData.head = repoToken; } else { listData.nodes[prev].next = repoToken; diff --git a/src/Strategy.sol b/src/Strategy.sol index dca97404..0c016835 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -6,7 +6,7 @@ import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeE import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; -import {ITermController, TermAuctionResults} from "./interfaces/term/ITermController.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"; @@ -33,10 +33,6 @@ contract Strategy is BaseStrategy { using RepoTokenList for RepoTokenListData; using TermAuctionList for TermAuctionListData; - address public constant NULL_NODE = address(0); - uint256 internal constant INVALID_AUCTION_RATE = 0; - - error InvalidRepoToken(address token); error InvalidTermAuction(address auction); error TimeToMaturityAboveThreshold(); error BalanceBelowLiquidityThreshold(); @@ -86,41 +82,6 @@ contract Strategy is BaseStrategy { ); } - function _validateRepoToken(ITermRepoToken repoToken) private - returns (uint256 auctionRate, uint256 redemptionTimestamp) - { - auctionRate = repoTokenListData.auctionRates[address(repoToken)]; - if (auctionRate != INVALID_AUCTION_RATE) { - (redemptionTimestamp, , ,) = repoToken.config(); - - uint256 oracleRate = _auctionRate(repoToken); - if (oracleRate != INVALID_AUCTION_RATE) { - if (auctionRate != oracleRate) { - repoTokenListData.auctionRates[address(repoToken)] = oracleRate; - } - } - } else { - auctionRate = _auctionRate(repoToken); - - if (!termController.isTermDeployed(address(repoToken))) { - revert InvalidRepoToken(address(repoToken)); - } - - address purchaseToken; - (redemptionTimestamp, purchaseToken, ,) = repoToken.config(); - if (purchaseToken != address(asset)) { - revert InvalidRepoToken(address(repoToken)); - } - - if (redemptionTimestamp < block.timestamp) { - revert InvalidRepoToken(address(repoToken)); - } - - repoTokenListData.insertSorted(address(repoToken)); - repoTokenListData.auctionRates[address(repoToken)] = auctionRate; - } - } - function _totalLiquidBalance(address addr) private view returns (uint256) { uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); return _assetBalance() + underlyingBalance; @@ -141,19 +102,13 @@ contract Strategy is BaseStrategy { return YEARN_VAULT.convertToAssets(YEARN_VAULT.balanceOf(address(this))); } - function _auctionRate(ITermRepoToken repoToken) private view returns (uint256) { - TermAuctionResults memory results = termController.getTermAuctionResults(repoToken.termRepoId()); - - uint256 len = results.auctionMetadata.length; - - require(len > 0); - - return results.auctionMetadata[len - 1].auctionClearingRate; - } - // TODO: reentrancy check function sellRepoToken(address repoToken, uint256 repoTokenAmount) external { - (uint256 auctionRate, uint256 redemptionTimestamp) = _validateRepoToken(ITermRepoToken(repoToken)); + (uint256 auctionRate, uint256 redemptionTimestamp) = repoTokenListData.validateAndInsertRepoToken( + ITermRepoToken(repoToken), + termController, + address(asset) + ); _sweepAsset(); @@ -255,7 +210,7 @@ contract Strategy is BaseStrategy { } function auctionClosed() external { - termAuctionListData.removeCompleted(); + termAuctionListData.removeCompleted(repoTokenListData, termController, address(asset)); _sweepAsset(); } diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index dd7f9bdd..1045ece3 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -3,8 +3,10 @@ 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 {RepoTokenListData} from "./RepoTokenList.sol"; +import {RepoTokenList, RepoTokenListData} from "./RepoTokenList.sol"; struct PendingOffer { bytes32 offerId; @@ -16,39 +18,63 @@ struct PendingOffer { struct TermAuctionListNode { bytes32 next; - bytes32 offerId; - address repoToken; - uint256 offerAmount; - ITermAuction termAuction; - ITermAuctionOfferLocker offerLocker; } 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 insertPending(TermAuctionListData storage listData, PendingOffer memory pendingOffer) internal { + bytes32 current = listData.head; + bytes32 id = pendingOffer.offerId; + + if (current != NULL_NODE) { + listData.nodes[id].next = current; + } + listData.head = id; + listData.offers[id] = pendingOffer; } - function removeCompleted(TermAuctionListData storage listData) internal { + function removeCompleted( + TermAuctionListData storage listData, + RepoTokenListData storage repoTokenListData, + ITermController termController, + address asset + ) internal { if (listData.head == NULL_NODE) return; bytes32 current = listData.head; bytes32 prev = current; while (current != NULL_NODE) { - TermAuctionListNode memory currentNode = listData.nodes[current]; - - if (currentNode.termAuction.auctionCompleted()) { - + PendingOffer memory offer = listData.offers[current]; + bytes32 next = _getNext(listData, current); + + if (offer.termAuction.auctionCompleted()) { + if (current == listData.head) { + listData.head = next; + } + + listData.nodes[prev].next = next; + delete listData.nodes[current]; + delete listData.offers[current]; + + repoTokenListData.validateAndInsertRepoToken(ITermRepoToken(offer.repoToken), termController, asset); } prev = current; - current = currentNode.next; + current = next; } } @@ -60,18 +86,18 @@ library TermAuctionList { bytes32 current = listData.head; while (current != NULL_NODE) { - TermAuctionListNode memory currentNode = listData.nodes[current]; + PendingOffer memory offer = listData.offers[current]; - uint256 offerAmount = currentNode.offerLocker.lockedOffer(currentNode.offerId).amount; + uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; /// @dev checking repoTokenAuctionRates to make sure we are not double counting on re-openings - if (offerAmount == 0 && repoTokenListData.auctionRates[currentNode.repoToken] == 0) { - totalValue += currentNode.offerAmount; + if (offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { + totalValue += offer.offerAmount; } else { totalValue += offerAmount; } - current = currentNode.next; + current = _getNext(listData, current); } } } diff --git a/src/interfaces/term/ITermRepoCollateralManager.sol b/src/interfaces/term/ITermRepoCollateralManager.sol deleted file mode 100644 index f66832a9..00000000 --- a/src/interfaces/term/ITermRepoCollateralManager.sol +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity 0.8.18; - -interface ITermRepoCollateralManager { - -} From 7d61f282bd0e331a5c7fa1f7a7fa533873f5bf58 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Thu, 2 May 2024 21:24:14 -0700 Subject: [PATCH 13/30] wrapping up submitAuctionOffer --- src/LevTerm.sol2 | 315 ------------------ src/RepoTokenList.sol | 153 ++++++--- src/RepoTokenUtils.sol | 20 +- src/Strategy.sol | 132 ++++++-- src/TermAuctionList.sol | 40 ++- src/interfaces/term/ITermAuction.sol | 2 + .../term/ITermAuctionOfferLocker.sol | 2 + .../term/ITermRepoCollateralManager.sol | 12 + 8 files changed, 267 insertions(+), 409 deletions(-) delete mode 100644 src/LevTerm.sol2 create mode 100644 src/interfaces/term/ITermRepoCollateralManager.sol diff --git a/src/LevTerm.sol2 b/src/LevTerm.sol2 deleted file mode 100644 index 41d7b782..00000000 --- a/src/LevTerm.sol2 +++ /dev/null @@ -1,315 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity 0.8.18; - -import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; - -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; - -import {ILevTermEscrow} from "src/interfaces/ILevTermEscrow.sol"; - -import {ITermAuction} from "src/interfaces/term/ITermAuction.sol"; -import {ITermRepoServicer} from "src/interfaces/term/ITermRepoServicer.sol"; -import {ExponentialNoError} from "src/lib/ExponentialNoError.sol"; -import {ITermAuctionBidLocker} from "src/interfaces/term/ITermAuctionBidLocker.sol"; -import {ITermRepoCollateralManager} from "src/interfaces/term/ITermRepoCollateralManager.sol"; - -// AAVE interfaces -import {IPool, IPoolAddressesProvider, DataTypes} from "@aave-v3/contracts/interfaces/IPool.sol"; -import {FlashLoanSimpleReceiverBase} from "@aave-v3/contracts/flashloan/base/FlashLoanSimpleReceiverBase.sol"; -import {IStaticATokenLM} from "src/interfaces/bgd/IStaticATokenLM.sol"; - -/// @title LevTerm -/// @notice A Yearn V3 strategy that bids on Term Finance auctions -/// @notice Bridge loan facility provided via LevTermEscrow -contract LevTerm is BaseStrategy, ExponentialNoError, FlashLoanSimpleReceiverBase { - using SafeERC20 for ERC20; - - ILevTermEscrow public immutable termEscrow; - ITermRepoServicer public termRepoServicer; - - // Locks - bool withdrawLock; - bool depositLock; - - // Escrow fee - uint256 escrowFeeBps; - - AuctionInfo public auction; - - struct AuctionInfo { - ITermAuction termAuction; - ITermAuctionBidLocker bidLocker; - ITermRepoServicer repoServicer; - ITermRepoCollateralManager collateralManager; - uint256 collateralAmount; - address purchaseToken; - uint256 purchaseTokenAmount; - uint256 escrowDebt; - uint256 servicingFeeProRatedMantissa; - } - - IPool public constant lendingPool = IPool(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2); - - address public constant staticATokenFactory = 0x90b1255a76e847cC92d41C295DeD5Bf2D4F24B3d; - IStaticATokenLM public statAToken; - address nativeToken; - - /// @param _asset Native asset for the tokenized strategy - /// @param _name Name of the strategy - /// @param _termEscrow Address of the TermEscrow contract that provides bridge loans - constructor(address _asset, string memory _name, address _termEscrow, uint256 _escrowFeeBps) - BaseStrategy(_asset, _name) - FlashLoanSimpleReceiverBase(IPoolAddressesProvider(address(lendingPool))) - { - termEscrow = ILevTermEscrow(_termEscrow); - require(_escrowFeeBps <= 15, "Fee cap of 15bps exceeded"); - escrowFeeBps = _escrowFeeBps; - - statAToken = IStaticATokenLM(_asset); - - nativeToken = statAToken.aTokenUnderlying(); - ERC20(nativeToken).safeApprove(address(statAToken), type(uint256).max); - } - - // ======================================================================== - // = Base strategy override =============================================== - // ======================================================================== - - function _deployFunds(uint256 _amount) internal override {} - - function _freeFunds(uint256 _amount) internal override {} - - function _harvestAndReport() internal override returns (uint256 _totalAssets) { - _totalAssets = asset.balanceOf(address(this)); - } - - function availableDepositLimit(address _owner) public view override returns (uint256) { - if (depositLock) { - return 0; - } - return type(uint256).max; - } - - function availableWithdrawLimit(address _owner) public view override returns (uint256) { - if (withdrawLock) { - return 0; - } - return type(uint256).max; - } - - // ======================================================================== - // = HELPERS ============================================================== - // ======================================================================== - - /// @notice Allows to lock deposits and withdrawals via `availableDepositLimit` and `availableWithdrawLimit` - function _lock(bool _deposits, bool _withdrawals) internal { - depositLock = _deposits; - withdrawLock = _withdrawals; - } - - /// @notice Repayment of debt to escrow contract, and reset accounting - function _repayEscrow() internal { - asset.transfer(address(termEscrow), auction.escrowDebt * (10_000 + escrowFeeBps) / 10_000); - auction.escrowDebt = 0; - } - - // ======================================================================== - // = TERM SPECIFIC ======================================================== - // ======================================================================== - - /// @notice Allows management to place a bid in a Term auction - /// @param _termAuction Address of the auction - /// @param _idHash Obfuscated id hash - /// @param _bidPriceHash Obfuscated bid price hash (interest rate, in 1e18) - /// @param _purchaseTokenAmount Amount of assets to borrow - /// @param _collateralAmount Amount of assets to supply - function bidOnNewAuction( - address _termAuction, - bytes32 _idHash, - bytes32 _bidPriceHash, - uint256 _purchaseTokenAmount, - uint256 _collateralAmount - ) external onlyManagement returns (bytes32[] memory bidIds) { - auction.termAuction = ITermAuction(_termAuction); - auction.bidLocker = ITermAuctionBidLocker(auction.termAuction.termAuctionBidLocker()); - require(auction.bidLocker.collateralTokens(address(asset)), "Wrong collateral"); - require( - block.timestamp > auction.bidLocker.auctionStartTime() - || block.timestamp < auction.termAuction.auctionEndTime(), - "Auction not open" - ); - - auction.repoServicer = ITermRepoServicer(auction.bidLocker.termRepoServicer()); - auction.collateralManager = ITermRepoCollateralManager(auction.bidLocker.termRepoCollateralManager()); - - // Calculate amount required from escrow - uint256 toPullFromEscrow = _collateralAmount - asset.balanceOf(address(this)); - - auction.servicingFeeProRatedMantissa = mul_( - Exp({mantissa: auction.bidLocker.dayCountFractionMantissa()}), - Exp({mantissa: auction.repoServicer.servicingFee()}) - ).mantissa; - - require(asset.balanceOf(address(termEscrow)) >= toPullFromEscrow, "Escrow does not hold sufficient funds"); - require( - _collateralTokenToPurchaseToken(toPullFromEscrow) - <= auction.purchaseTokenAmount * 1e18 / (1e18 - auction.servicingFeeProRatedMantissa), - "Resulting balance insufficient to repay Escrow" - ); - - // Pull funds from escrow - termEscrow.pullFromEscrow(toPullFromEscrow); - - // Store params for accounting - auction.collateralAmount = asset.balanceOf(address(this)); - auction.purchaseToken = auction.bidLocker.purchaseToken(); - auction.purchaseTokenAmount = _purchaseTokenAmount; - auction.escrowDebt = toPullFromEscrow; - - uint256[] memory collateralAmounts = new uint256[](1); - collateralAmounts[0] = auction.collateralAmount; - - address[] memory collateralTokens = new address[](1); - collateralTokens[0] = address(asset); - - // Approve Repo Locker to pull collateral amount - ERC20(asset).approve(address(auction.repoServicer.termRepoLocker()), collateralAmounts[0]); - - // Create bid submission struct and submit bid - ITermAuctionBidLocker.TermAuctionBidSubmission memory bid = ITermAuctionBidLocker.TermAuctionBidSubmission({ - id: _idHash, - bidder: address(this), - bidPriceHash: _bidPriceHash, - amount: _purchaseTokenAmount, - collateralAmounts: collateralAmounts, - purchaseToken: auction.purchaseToken, - collateralTokens: collateralTokens - }); - - ITermAuctionBidLocker.TermAuctionBidSubmission[] memory bids = - new ITermAuctionBidLocker.TermAuctionBidSubmission[](1); - bids[0] = bid; - bidIds = auction.bidLocker.lockBids(bids); - - // Lock deposits and withdrawals - _lock(true, true); - } - - /// @notice Permissionless function callable the auction ended - /// @notice 3 scenarios possible: full fill, partial fill, no fill - function auctionClosed() public { - require(block.timestamp > auction.termAuction.auctionEndTime(), "Auction still open"); - uint256 assetBalance = asset.balanceOf(address(this)); - - // No fill: Collateral borrowed from escrow returned, unlock deposits and withdrawals - if (assetBalance == auction.collateralAmount) { - _repayEscrow(); - _lock(false, false); - return; - } - - uint256 purchaseTokenBalance = ERC20(auction.purchaseToken).balanceOf(address(this)); - - // Partial fill: Unlock collateral pro-rated to the purchase token received - if (purchaseTokenBalance != auction.purchaseTokenAmount * 1e18 / (1e18 - auction.servicingFeeProRatedMantissa)) - { - auction.collateralManager.externalUnlockCollateral( - address(asset), auction.collateralAmount * purchaseTokenBalance / auction.purchaseTokenAmount - ); - } - - // Swap all purchase token to asset and repay escrow - _swapPurchaseTokenToAsset(); - _repayEscrow(); - } - - /// @notice Permissionless function callable after loan maturity - /// @notice Repays loan, settles debt with escrow (if any), calls report and unlocks withdrawals - function closeLoan() public { - require(block.timestamp >= auction.repoServicer.maturityTimestamp(), "Before maturity"); - require(block.timestamp < auction.repoServicer.endOfRepurchaseWindow(), "Repurchase window closed"); - - // Obtain the required amount of purchase token for full repayment - _repayTermLoan(auction.repoServicer.getBorrowerRepurchaseObligation(address(this))); - - // If there is some debt to escrow remaining, repay - if (auction.escrowDebt > 0) { - _repayEscrow(); - } - - // Call report, realizing profit - _delegateCall(abi.encode(TokenizedStrategy.report.selector)); - - // Remove withdrawal lock - _lock(true, false); - } - - /// @notice Allows management to top off collateral, with option to pull from escrow contract - /// @param _collateralToAdd Amount of collateral to add - /// @param _pullFromEscrow Bool to determine if we pull funds from escrow - function lockCollateral(uint256 _collateralToAdd, bool _pullFromEscrow) external onlyManagement { - if (_pullFromEscrow) { - termEscrow.pullFromEscrow(_collateralToAdd); - auction.escrowDebt += _collateralToAdd; - } - auction.collateralManager.externalLockCollateral(address(asset), _collateralToAdd); - } - - /// @notice Emergency function callable by management to repay escrow contract - function emergencyRepayEscrow() external onlyManagement { - uint256 availableAsset = asset.balanceOf(address(this)); - uint256 toRepay = Math.min(availableAsset, auction.escrowDebt); - asset.transfer(address(termEscrow), toRepay); - auction.escrowDebt = auction.escrowDebt - toRepay; - } - - /// @notice Allow management to change escrow fee - /// @param _escrowFeeBps New escrow fee, in bps - function setEscrowFeeBps(uint256 _escrowFeeBps) external onlyManagement { - require(_escrowFeeBps <= 15, "Fee cap of 15bps exceeded"); - escrowFeeBps = _escrowFeeBps; - } - - // ======================================================================== - // = ASSET PAIR SPECIFIC ================================================== - // ======================================================================== - - /// @notice Conversion from collateral token to purchase token - function _collateralTokenToPurchaseToken(uint256 _collateralTokenAmount) internal returns (uint256) { - return statAToken.convertToAssets(_collateralTokenAmount); - } - - /// @notice Purchase token (native token) --> Asset (statAToken) - function _swapPurchaseTokenToAsset() internal { - statAToken.deposit(ERC20(nativeToken).balanceOf(address(this)), address(this), 0, false); - } - - /// @notice Get purchase token via AAVE V3 flashloan - function _repayTermLoan(uint256 _purchaseTokenAmount) internal { - POOL.flashLoanSimple(address(this), nativeToken, _purchaseTokenAmount, "", 0); - } - - /// @notice AAVE V3 flashloan logic - function executeOperation(address asset, uint256 amount, uint256 premium, address initiator, bytes calldata params) - external - override - returns (bool) - { - require(initiator == address(this), "FlashBorrower: Untrusted loan initiator"); - - // Approve RepoLocker, repay Term loan, free up collateral - ERC20(auction.purchaseToken).approve(address(auction.repoServicer.termRepoLocker()), amount); - auction.repoServicer.submitRepurchasePayment(amount); - - // Swap some statAToken to repay flashloan + fee - uint256 totalAmount = amount + premium; - - statAToken.withdraw(statAToken.convertToShares(totalAmount), address(this), address(this)); - - // Repay flashloan - ERC20(asset).approve(address(POOL), totalAmount); - - return true; - } -} diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index c05b3ef8..7b1db179 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.18; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; +import {ITermRepoCollateralManager} from "./interfaces/term/ITermRepoCollateralManager.sol"; import {ITermController, TermAuctionResults} from "./interfaces/term/ITermController.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {RepoTokenUtils} from "./RepoTokenUtils.sol"; @@ -15,6 +16,8 @@ struct RepoTokenListData { address head; mapping(address => RepoTokenListNode) nodes; mapping(address => uint256) auctionRates; + /// @notice keyed by collateral token + mapping(address => uint256) collateralTokenParams; } library RepoTokenList { @@ -27,69 +30,89 @@ library RepoTokenList { (redemptionTimestamp, , ,) = ITermRepoToken(repoToken).config(); } - function _getRepoTokenTimeToMaturity(address repoToken) private view returns (uint256) { - return _getRepoTokenMaturity(repoToken) - block.timestamp; + 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 getWeightedTimeToMaturity( + function simulateWeightedTimeToMaturity( RepoTokenListData storage listData, address repoToken, uint256 repoTokenAmount, uint256 purchaseTokenPrecision, - uint256 purchaseTokenBalance + uint256 liquidBalance ) internal view returns (uint256) { if (listData.head == NULL_NODE) return 0; uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 cumulativeWeightedMaturityTimestamp; - uint256 cumulativeRepoTokenAmount; + uint256 cumulativeWeightedTimeToMaturity; // in seconds + uint256 cumulativeRepoTokenAmount; // in purchase token precision address current = listData.head; + bool found; while (current != NULL_NODE) { - uint256 currentMaturity = _getRepoTokenMaturity(current); uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); + uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); - if (currentMaturity > block.timestamp) { - uint256 timeToMaturity = _getRepoTokenTimeToMaturity(current); - // Not matured yet - cumulativeWeightedMaturityTimestamp += timeToMaturity * repoTokenBalance / repoTokenPrecision; + if (repoToken == current) { + repoTokenBalance += repoTokenAmount; + found = true; + } + + uint256 repoTokenBalanceInBaseAssetPrecision = + (redemptionValue * repoTokenBalance * purchaseTokenPrecision) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + + uint256 currentMaturity = _getRepoTokenMaturity(current); + + if (repoTokenBalance > 0) { + if (currentMaturity > block.timestamp) { + uint256 timeToMaturity = _getRepoTokenTimeToMaturity(currentMaturity, current); + // Not matured yet + cumulativeWeightedTimeToMaturity += + timeToMaturity * repoTokenBalanceInBaseAssetPrecision; + } + cumulativeRepoTokenAmount += repoTokenBalanceInBaseAssetPrecision; } - cumulativeRepoTokenAmount += repoTokenBalance; current = _getNext(listData, current); } - if (repoToken != address(0)) { - cumulativeWeightedMaturityTimestamp += _getRepoTokenTimeToMaturity(repoToken) * repoTokenAmount; - cumulativeRepoTokenAmount += repoTokenAmount; + /// @dev token is not found in the list (i.e. called from view function) + if (!found && repoToken != address(0)) { + uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); + uint256 repoTokenAmountInBaseAssetPrecision = + (redemptionValue * repoTokenAmount * purchaseTokenPrecision) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + + cumulativeRepoTokenAmount += repoTokenAmountInBaseAssetPrecision; + uint256 maturity = _getRepoTokenMaturity(current); + if (maturity > block.timestamp) { + uint256 timeToMaturity = _getRepoTokenTimeToMaturity(maturity, repoToken); + cumulativeWeightedTimeToMaturity += + timeToMaturity * repoTokenAmountInBaseAssetPrecision; + } } - uint256 excessLiquidity = purchaseTokenBalance * repoTokenPrecision / purchaseTokenPrecision; - /// @dev avoid div by 0 - if (cumulativeRepoTokenAmount == 0 && excessLiquidity == 0) { + if (cumulativeRepoTokenAmount == 0 && liquidBalance == 0) { return 0; } - return cumulativeWeightedMaturityTimestamp * repoTokenPrecision / (cumulativeRepoTokenAmount + excessLiquidity); + // time * purchaseTokenPrecision / purchaseTokenPrecision + return cumulativeWeightedTimeToMaturity / (cumulativeRepoTokenAmount + liquidBalance); } - function removeAndRedeemMaturedTokens( - RepoTokenListData storage listData, - address repoServicer, - uint256 amount - ) internal { + 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) { next = _getNext(listData, current); @@ -101,7 +124,12 @@ library RepoTokenList { delete listData.nodes[current]; delete listData.auctionRates[current]; - ITermRepoServicer(repoServicer).redeemTermRepoTokens(address(this), amount); + uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); + + if (repoTokenBalance > 0) { + (, , address termRepoServicer,) = ITermRepoToken(current).config(); + ITermRepoServicer(termRepoServicer).redeemTermRepoTokens(address(this), repoTokenBalance); + } } else { /// @dev early exit because list is sorted break; @@ -122,6 +150,44 @@ library RepoTokenList { return results.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, @@ -133,6 +199,11 @@ library RepoTokenList { if (auctionRate != INVALID_AUCTION_RATE) { (redemptionTimestamp, , ,) = repoToken.config(); + // skip matured repo tokens + if (redemptionTimestamp < block.timestamp) { + revert InvalidRepoToken(address(repoToken)); + } + uint256 oracleRate = _auctionRate(termController, repoToken); if (oracleRate != INVALID_AUCTION_RATE) { if (auctionRate != oracleRate) { @@ -142,19 +213,7 @@ library RepoTokenList { } else { auctionRate = _auctionRate(termController, repoToken); - if (!termController.isTermDeployed(address(repoToken))) { - revert InvalidRepoToken(address(repoToken)); - } - - address purchaseToken; - (redemptionTimestamp, purchaseToken, ,) = repoToken.config(); - if (purchaseToken != address(asset)) { - revert InvalidRepoToken(address(repoToken)); - } - - if (redemptionTimestamp < block.timestamp) { - revert InvalidRepoToken(address(repoToken)); - } + redemptionTimestamp = validateRepoToken(listData, repoToken, termController, asset); insertSorted(listData, address(repoToken)); listData.auctionRates[address(repoToken)] = auctionRate; @@ -190,7 +249,10 @@ library RepoTokenList { } } - function getPresentValue(RepoTokenListData storage listData, uint256 purchaseTokenPrecision) internal view returns (uint256 totalPresentValue) { + function getPresentValue( + RepoTokenListData storage listData, + uint256 purchaseTokenPrecision + ) internal view returns (uint256 totalPresentValue) { if (listData.head == NULL_NODE) return 0; address current = listData.head; @@ -200,14 +262,17 @@ library RepoTokenList { uint256 repoTokenPrecision = 10**ERC20(current).decimals(); uint256 auctionRate = listData.auctionRates[current]; - repoTokenBalance = ITermRepoToken(current).redemptionValue() * repoTokenBalance / RepoTokenUtils.RATE_PRECISION; + // (ratePrecision * repoPrecision * purchasePrecision) / (repoPrecision * ratePrecision) = purchasePrecision + uint256 repoTokenBalanceInBaseAssetPrecision = + (ITermRepoToken(current).redemptionValue() * repoTokenBalance * purchaseTokenPrecision) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); if (currentMaturity > block.timestamp) { - totalPresentValue += RepoTokenUtils.calculateProceeds( - repoTokenBalance, currentMaturity, repoTokenPrecision, purchaseTokenPrecision, auctionRate + totalPresentValue += RepoTokenUtils.calculatePresentValue( + repoTokenBalanceInBaseAssetPrecision, purchaseTokenPrecision, currentMaturity, auctionRate ); } else { - totalPresentValue += RepoTokenUtils.repoToPurchasePrecision(repoTokenPrecision, purchaseTokenPrecision, repoTokenBalance); + totalPresentValue += repoTokenBalanceInBaseAssetPrecision; } current = _getNext(listData, current); diff --git a/src/RepoTokenUtils.sol b/src/RepoTokenUtils.sol index 1f01e931..905be58e 100644 --- a/src/RepoTokenUtils.sol +++ b/src/RepoTokenUtils.sol @@ -21,22 +21,20 @@ library RepoTokenUtils { return (repoTokenAmount * repoTokenPrecision) / purchaseTokenPrecision; } - function calculateProceeds( - uint256 repoTokenAmount, - uint256 redemptionTimestamp, - uint256 repoTokenPrecision, + function calculatePresentValue( + uint256 repoTokenAmountInBaseAssetPrecision, uint256 purchaseTokenPrecision, + uint256 redemptionTimestamp, uint256 auctionRate ) internal view returns (uint256) { uint256 timeLeftToMaturityDayFraction = - ((redemptionTimestamp - block.timestamp) * repoTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; + ((redemptionTimestamp - block.timestamp) * purchaseTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; - uint256 purchaseTokenAmountInRepoTokenPrecision = - (repoTokenAmount * repoTokenPrecision) / - (repoTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)); + // repoTokenAmountInBaseAssetPrecision / (1 + r * days / 360) + uint256 presentValue = + (repoTokenAmountInBaseAssetPrecision * purchaseTokenPrecision) / + (purchaseTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)); - return repoToPurchasePrecision( - repoTokenPrecision, purchaseTokenPrecision, purchaseTokenAmountInRepoTokenPrecision - ); + return presentValue; } } diff --git a/src/Strategy.sol b/src/Strategy.sol index 0c016835..9e6608c5 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -48,6 +48,7 @@ contract Strategy is BaseStrategy { 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 { TERM_VAULT_EVENT_EMITTER.emitTermControllerUpdated(address(termController), newTermController); termController = ITermController(newTermController); @@ -68,17 +69,27 @@ contract Strategy is BaseStrategy { auctionRateMarkup = newAuctionRateMarkup; } - function _removeRedeemAndCalculateWeightedMaturity(address repoToken, uint256 amount) private returns (uint256) { - uint256 weightedTimeToMaturity = repoTokenListData.getWeightedTimeToMaturity( - repoToken, amount, PURCHASE_TOKEN_PRECISION, _assetBalance() + function setCollateralTokenParams(address tokenAddr, uint256 minCollateralRatio) external onlyManagement { + // TERM_VAULT_EVENT_EMITTER.emitMinCollateralRatioUpdated(tokenAddr, minCollateralRatio); + repoTokenListData.collateralTokenParams[tokenAddr] = minCollateralRatio; + } + + function _removeRedeemAndCalculateWeightedMaturity( + address repoToken, + uint256 amount, + uint256 liquidBalance + ) private returns (uint256) { + uint256 weightedTimeToMaturity = repoTokenListData.simulateWeightedTimeToMaturity( + repoToken, amount, PURCHASE_TOKEN_PRECISION, liquidBalance ); - repoTokenListData.removeAndRedeemMaturedTokens(repoToken, amount); + repoTokenListData.removeAndRedeemMaturedTokens(); return weightedTimeToMaturity; } - function getWeightedTimeToMaturity(address repoToken, uint256 amount) external view returns (uint256) { - return repoTokenListData.getWeightedTimeToMaturity( - repoToken, amount, PURCHASE_TOKEN_PRECISION, _assetBalance() + function simulateWeightedTimeToMaturity(address repoToken, uint256 amount) external view returns (uint256) { + repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); + return repoTokenListData.simulateWeightedTimeToMaturity( + repoToken, amount, PURCHASE_TOKEN_PRECISION, _totalLiquidBalance(address(this)) ); } @@ -104,6 +115,8 @@ contract Strategy is BaseStrategy { // TODO: reentrancy check function sellRepoToken(address repoToken, uint256 repoTokenAmount) external { + require(repoTokenAmount > 0); + (uint256 auctionRate, uint256 redemptionTimestamp) = repoTokenListData.validateAndInsertRepoToken( ITermRepoToken(repoToken), termController, @@ -112,31 +125,51 @@ contract Strategy is BaseStrategy { _sweepAsset(); - uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity(repoToken, repoTokenAmount); + uint256 liquidBalance = _totalLiquidBalance(address(this)); + 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 + ); + uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( + repoToken, repoTokenAmount, _totalLiquidBalance(address(this)) - proceeds + ); if (resultingTimeToMaturity > timeToMaturityThreshold) { revert TimeToMaturityAboveThreshold(); } - uint256 liquidBalance = _totalLiquidBalance(address(this)); - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 proceeds = RepoTokenUtils.calculateProceeds( - repoTokenAmount, redemptionTimestamp, repoTokenPrecision, PURCHASE_TOKEN_PRECISION, auctionRate - ); - 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 offerOnNewAuction( + function deleteAuctionOffer(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)); + + _sweepAsset(); + } + + function submitAuctionOffer( address termAuction, address repoToken, bytes32 idHash, @@ -153,33 +186,35 @@ contract Strategy is BaseStrategy { require(auction.termRepoId() == ITermRepoToken(repoToken).termRepoId()); - _sweepAsset(); - - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( - repoToken, - RepoTokenUtils.purchaseToRepoPrecision(repoTokenPrecision, PURCHASE_TOKEN_PRECISION, purchaseTokenAmount) - ); - - if (resultingTimeToMaturity > timeToMaturityThreshold) { - revert TimeToMaturityAboveThreshold(); - } + // validate purchase token and min collateral ratio + repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); ITermAuctionOfferLocker offerLocker = ITermAuctionOfferLocker(auction.termAuctionOfferLocker()); - require(offerLocker.purchaseToken() == address(asset), "Wrong purchase token"); require( block.timestamp > offerLocker.auctionStartTime() || block.timestamp < auction.auctionEndTime(), "Auction not open" ); + _sweepAsset(); //@dev sweep to ensure liquid balances up to date + + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 offerAmount = RepoTokenUtils.purchaseToRepoPrecision( + repoTokenPrecision, PURCHASE_TOKEN_PRECISION, purchaseTokenAmount + ); uint256 liquidBalance = _totalLiquidBalance(address(this)); + uint256 resultingWeightedTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( + repoToken, offerAmount, liquidBalance - offerAmount + ); + + if (resultingWeightedTimeToMaturity > timeToMaturityThreshold) { + revert TimeToMaturityAboveThreshold(); + } if ((liquidBalance - purchaseTokenAmount) < liquidityThreshold) { revert BalanceBelowLiquidityThreshold(); } - ITermRepoServicer repoServicer = ITermRepoServicer(offerLocker.termRepoServicer()); ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer; offer.id = idHash; @@ -188,25 +223,48 @@ contract Strategy is BaseStrategy { offer.amount = purchaseTokenAmount; offer.purchaseToken = address(asset); + offerIds = _submitOffer(auction, offerLocker, offer, repoToken); + } + + function _submitOffer( + ITermAuction auction, + ITermAuctionOfferLocker offerLocker, + ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer, + address repoToken + ) private returns (bytes32[] memory offerIds) { + ITermRepoServicer repoServicer = ITermRepoServicer(offerLocker.termRepoServicer()); + ITermAuctionOfferLocker.TermAuctionOfferSubmission[] memory offerSubmissions = new ITermAuctionOfferLocker.TermAuctionOfferSubmission[](1); offerSubmissions[0] = offer; - _withdrawAsset(purchaseTokenAmount); + _withdrawAsset(offer.amount); - ERC20(asset).approve(address(repoServicer.termRepoLocker()), purchaseTokenAmount); + ERC20(asset).approve(address(repoServicer.termRepoLocker()), offer.amount); offerIds = offerLocker.lockOffers(offerSubmissions); require(offerIds.length > 0); - termAuctionListData.insertPending(PendingOffer({ - offerId: offerIds[0], - repoToken: repoToken, - offerAmount: purchaseTokenAmount, - termAuction: auction, - offerLocker: offerLocker - })); + if (termAuctionListData.offers[offerIds[0]].offerId == bytes32(0)) { + // new offer + termAuctionListData.insertPending(PendingOffer({ + offerId: offerIds[0], + repoToken: repoToken, + offerAmount: offer.amount, + termAuction: auction, + offerLocker: offerLocker + })); + } else { + // edit offer, overwrite existing + termAuctionListData.offers[offerIds[0]] = PendingOffer({ + offerId: offerIds[0], + repoToken: repoToken, + offerAmount: offer.amount, + termAuction: auction, + offerLocker: offerLocker + }); + } } function auctionClosed() external { diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index 1045ece3..59dc79d5 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -61,7 +61,32 @@ library TermAuctionList { PendingOffer memory offer = listData.offers[current]; bytes32 next = _getNext(listData, current); + uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).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] = offer.offerId; + offer.offerLocker.unlockOffers(offerIds); + } + } + + if (removeNode) { if (current == listData.head) { listData.head = next; } @@ -69,7 +94,9 @@ library TermAuctionList { listData.nodes[prev].next = next; delete listData.nodes[current]; delete listData.offers[current]; + } + if (insertRepoToken) { repoTokenListData.validateAndInsertRepoToken(ITermRepoToken(offer.repoToken), termController, asset); } @@ -90,8 +117,9 @@ library TermAuctionList { uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; - /// @dev checking repoTokenAuctionRates to make sure we are not double counting on re-openings - if (offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { + /// @dev offer processed, but auctionClosed not yet called and auction is new so repoToken not on List and wont be picked up + /// checking repoTokenAuctionRates to make sure we are not double counting on re-openings + if (offer.termAuction.auctionCompleted() && offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { totalValue += offer.offerAmount; } else { totalValue += offerAmount; @@ -101,3 +129,11 @@ library TermAuctionList { } } } + +/* +offer submitted; auction still open => include offerAmount in totalValue (otherwise locked purchaseToken will be missing from TV) +offer submitted; auction completed; !auctionClosed() => include offer.offerAmount in totalValue (because the offerLocker will have already deleted offer on completeAuction) + + even though repoToken has been transferred it hasn't been added to the repoTokenList + BUT only if it is new not a reopening +offer submitted; auction completed; auctionClosed() => repoToken has been added to the repoTokenList +*/ \ No newline at end of file diff --git a/src/interfaces/term/ITermAuction.sol b/src/interfaces/term/ITermAuction.sol index 784cd1b0..84ed42a5 100644 --- a/src/interfaces/term/ITermAuction.sol +++ b/src/interfaces/term/ITermAuction.sol @@ -9,4 +9,6 @@ interface ITermAuction { 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 index b668b4b4..f7777f38 100644 --- a/src/interfaces/term/ITermAuctionOfferLocker.sol +++ b/src/interfaces/term/ITermAuctionOfferLocker.sol @@ -55,4 +55,6 @@ interface ITermAuctionOfferLocker { function lockOffers( TermAuctionOfferSubmission[] calldata offerSubmissions ) external returns (bytes32[] memory); + + function unlockOffers(bytes32[] calldata offerIds) external; } 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); +} From 076c3f5ac8222d52f71167b7971f06cfdce509f9 Mon Sep 17 00:00:00 2001 From: 0xsmspps Date: Sun, 12 May 2024 16:53:46 -0700 Subject: [PATCH 14/30] adding unit tests --- src/Strategy.sol | 25 ++---- src/TermVaultEventEmitter.sol | 2 +- src/test/TestSellRepoToken.t.sol | 11 +++ src/test/TestSubmitOffer.t.sol | 8 ++ src/test/mocks/MockTermAuction.sol | 29 +++++++ src/test/mocks/MockTermAuctionOfferLocker.sol | 86 +++++++++++++++++++ src/test/mocks/MockTermController.sol | 14 +++ .../mocks/MockTermRepoCollateralManager.sol | 29 +++++++ src/test/mocks/MockTermRepoLocker.sol | 20 +++++ src/test/mocks/MockTermRepoServicer.sol | 36 ++++++++ src/test/mocks/MockTermRepoToken.sol | 62 +++++++++++++ src/test/utils/Setup.sol | 28 +++++- 12 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 src/test/TestSellRepoToken.t.sol create mode 100644 src/test/TestSubmitOffer.t.sol create mode 100644 src/test/mocks/MockTermAuction.sol create mode 100644 src/test/mocks/MockTermAuctionOfferLocker.sol create mode 100644 src/test/mocks/MockTermController.sol create mode 100644 src/test/mocks/MockTermRepoCollateralManager.sol create mode 100644 src/test/mocks/MockTermRepoLocker.sol create mode 100644 src/test/mocks/MockTermRepoServicer.sol create mode 100644 src/test/mocks/MockTermRepoToken.sol diff --git a/src/Strategy.sol b/src/Strategy.sol index 9e6608c5..c29ade40 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -153,7 +153,7 @@ contract Strategy is BaseStrategy { IERC20(repoToken).safeTransferFrom(msg.sender, address(this), repoTokenAmount); IERC20(asset).safeTransfer(msg.sender, proceeds); } - + function deleteAuctionOffer(address termAuction, bytes32[] calldata offerIds) external onlyManagement { if (!termController.isTermDeployed(termAuction)) { revert InvalidTermAuction(termAuction); @@ -273,7 +273,7 @@ contract Strategy is BaseStrategy { _sweepAsset(); } - function totalAssetValue() internal view returns (uint256 totalValue) { + function _totalAssetValue() internal view returns (uint256 totalValue) { return _totalLiquidBalance(address(this)) + repoTokenListData.getPresentValue(PURCHASE_TOKEN_PRECISION) + termAuctionListData.getPresentValue(repoTokenListData); @@ -307,11 +307,7 @@ 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 { } /** * @dev Should attempt to free the '_amount' of 'asset'. @@ -334,11 +330,7 @@ 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 { } /** * @dev Internal function to harvest all rewards, redeploy any idle @@ -367,14 +359,7 @@ 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)); + return _totalAssetValue(); } /*////////////////////////////////////////////////////////////// diff --git a/src/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol index 62ec2812..5655daf0 100644 --- a/src/TermVaultEventEmitter.sol +++ b/src/TermVaultEventEmitter.sol @@ -6,7 +6,7 @@ import "@openzeppelin/contracts-upgradeable/contracts/access/AccessControlUpgrad import "@openzeppelin/contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; -contract TermListingEventEmitter is Initializable, UUPSUpgradeable, AccessControlUpgradeable, ITermVaultEvents { +contract TermVaultEventEmitter is Initializable, UUPSUpgradeable, AccessControlUpgradeable, ITermVaultEvents { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant DEVOPS_ROLE = keccak256("DEVOPS_ROLE"); diff --git a/src/test/TestSellRepoToken.t.sol b/src/test/TestSellRepoToken.t.sol new file mode 100644 index 00000000..18b73ffe --- /dev/null +++ b/src/test/TestSellRepoToken.t.sol @@ -0,0 +1,11 @@ +pragma solidity ^0.8.18; + +import "forge-std/console.sol"; +import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol"; + +contract TestSellRepoToken is Setup { + + function testSellRepoToken() public { + + } +} diff --git a/src/test/TestSubmitOffer.t.sol b/src/test/TestSubmitOffer.t.sol new file mode 100644 index 00000000..19cd9d7d --- /dev/null +++ b/src/test/TestSubmitOffer.t.sol @@ -0,0 +1,8 @@ +pragma solidity ^0.8.18; + +import "forge-std/console.sol"; +import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol"; + +contract TestSubmitOffer is Setup { + +} diff --git a/src/test/mocks/MockTermAuction.sol b/src/test/mocks/MockTermAuction.sol new file mode 100644 index 00000000..cbacf797 --- /dev/null +++ b/src/test/mocks/MockTermAuction.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermAuction} from "../../interfaces/term/ITermAuction.sol"; + +contract MockTermAuction is ITermAuction { + + address public termAuctionOfferLocker; + bytes32 public termRepoId; + uint256 public auctionEndTime; + bool public auctionCompleted; + bool public auctionCancelledForWithdrawal; + + constructor(bytes32 _termRepoId) { + termRepoId = _termRepoId; + } + + function setOfferLocker(address _termAuctionOfferLocker) external { + termAuctionOfferLocker = _termAuctionOfferLocker; + } + + function startAuction(uint256 duration) external { + auctionEndTime = block.timestamp + duration; + } + + function clearAuction() external { + + } +} diff --git a/src/test/mocks/MockTermAuctionOfferLocker.sol b/src/test/mocks/MockTermAuctionOfferLocker.sol new file mode 100644 index 00000000..067d923a --- /dev/null +++ b/src/test/mocks/MockTermAuctionOfferLocker.sol @@ -0,0 +1,86 @@ +// 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"; + +contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { + + address public purchaseToken; + MockTermRepoLocker internal repoLocker; + ITermAuction internal auction; + mapping(bytes32 => TermAuctionOffer) internal lockedOffers; + + constructor(ITermAuction _auction, address _repoLocker, address _purchaseToken) { + auction = _auction; + purchaseToken = _purchaseToken; + repoLocker = MockTermRepoLocker(_repoLocker); + } + + function termRepoId() external view returns (bytes32) { + return auction.termRepoId(); + } + + function termAuctionId() external view returns (bytes32) { + return auction.termRepoId(); + } + + function auctionStartTime() external view returns (uint256) { + + } + + function auctionEndTime() external view returns (uint256) { + return auction.auctionEndTime(); + } + + function revealTime() external view returns (uint256) { + + } + + function termRepoServicer() external view returns (address) { + + } + + function lockedOffer(bytes32 id) external view returns (TermAuctionOffer memory) { + return lockedOffers[id]; + } + + /// @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) { + for (uint256 i; i < offerSubmissions.length; i++) { + TermAuctionOfferSubmission memory submission = offerSubmissions[i]; + TermAuctionOffer memory offer; + + offer.id = submission.id; + offer.offeror = submission.offeror; + offer.offerPriceHash = submission.offerPriceHash; + offer.amount = submission.amount; + offer.purchaseToken = submission.purchaseToken; + + lockedOffers[offer.id] = offer; + + repoLocker.lockPurchaseTokens(msg.sender, offer.amount); + } + } + + function fillOffer(bytes32 offerId, address receiver, uint256 fillAmount) external { + require(lockedOffers[offerId].amount >= fillAmount); + uint256 remainingAmount = lockedOffers[offerId].amount - fillAmount; + + lockedOffers[offerId].amount = remainingAmount; + + repoLocker.releasePurchaseTokens(receiver, 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..cc83a362 --- /dev/null +++ b/src/test/mocks/MockTermController.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.18; + +import {ITermController, TermAuctionResults} from "../../interfaces/term/ITermController.sol"; + +contract MockTermController is ITermController { + function isTermDeployed(address contractAddress) external view returns (bool) { + return true; + } + + function getTermAuctionResults(bytes32 termRepoId) external view returns (TermAuctionResults memory) { + + } +} \ 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..dde928a7 --- /dev/null +++ b/src/test/mocks/MockTermRepoServicer.sol @@ -0,0 +1,36 @@ +// 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"; + +contract MockTermRepoServicer is ITermRepoServicer { + ITermRepoToken internal repoToken; + MockTermRepoLocker internal repoLocker; + + constructor(ITermRepoToken _repoToken) { + repoToken = _repoToken; + repoLocker = new MockTermRepoLocker(purchaseToken()); + } + + function redeemTermRepoTokens( + address redeemer, + uint256 amountToRedeem + ) external { + + } + + function termRepoToken() external view returns (address) { + return address(repoToken); + } + + function termRepoLocker() external view returns (address) { + return address(repoLocker); + } + + function purchaseToken() public view returns (address) { + (, address purchaseToken, ,) = repoToken.config(); + return purchaseToken; + } +} diff --git a/src/test/mocks/MockTermRepoToken.sol b/src/test/mocks/MockTermRepoToken.sol new file mode 100644 index 00000000..15b0fff1 --- /dev/null +++ b/src/test/mocks/MockTermRepoToken.sol @@ -0,0 +1,62 @@ +// 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; + address internal repoServicer; + address internal collateralManager; + + constructor( + string memory name_, + string memory symbol_, + bytes32 _termRepoId, + address _purchaseToken, + address _collateral, + uint256 _maintenanceRatio + ) ERC20(name_, symbol_) { + termRepoId = _termRepoId; + repoTokenContext.purchaseToken = _purchaseToken; + + repoTokenContext.termRepoServicer = new MockTermRepoServicer(ITermRepoToken(address(this))); + repoTokenContext.termRepoCollateralManager = new MockTermRepoCollateralManager( + ITermRepoToken(address(this)), _collateral, _maintenanceRatio + ); + } + + function redemptionValue() external view returns (uint256) { + return 1e18; + } + + 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/utils/Setup.sol b/src/test/utils/Setup.sol index 1ac74c0e..b7c6db08 100644 --- a/src/test/utils/Setup.sol +++ b/src/test/utils/Setup.sol @@ -9,6 +9,16 @@ 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 {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); @@ -30,6 +40,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,6 +57,12 @@ contract Setup is ExtendedTest, IEvents { // Default profit max unlock time is set for 10 days uint256 public profitMaxUnlockTime = 10 days; + // Term finance mocks + MockTermController termController; + TermVaultEventEmitter termVaultEventEmitterImpl; + TermVaultEventEmitter termVaultEventEmitter; + ERC4626Mock mockYearnVault; + function setUp() public virtual { _setTokenAddrs(); @@ -66,12 +84,18 @@ contract Setup is ExtendedTest, IEvents { vm.label(management, "management"); vm.label(address(strategy), "strategy"); vm.label(performanceFeeRecipient, "performanceFeeRecipient"); + + termController = new MockTermController(); + termVaultEventEmitterImpl = new TermVaultEventEmitter(); + termVaultEventEmitter = TermVaultEventEmitter(address(new ERC1967Proxy(address(termVaultEventEmitterImpl), ""))); + + termVaultEventEmitter.initialize(adminWallet, devopsWallet); } 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(0), address(0))) + address(new Strategy(address(asset), "Tokenized Strategy", address(mockYearnVault), address(termVaultEventEmitter))) ); // set keeper @@ -84,6 +108,8 @@ contract Setup is ExtendedTest, IEvents { vm.prank(management); _strategy.acceptManagement(); + _strategy.setTermController(address(termController)); + return address(_strategy); } From 30cc45e2653703653d889270498d2efa516f14ef Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 15 May 2024 19:35:32 -0700 Subject: [PATCH 15/30] more tests --- src/RepoTokenList.sol | 32 +++---- src/Strategy.sol | 13 ++- src/test/TestSellRepoToken.t.sol | 11 --- src/test/TestSubmitOffer.t.sol | 8 -- src/test/TestUSDCSellRepoToken.t.sol | 84 +++++++++++++++++++ src/test/TestUSDCSubmitOffer.t.sol | 50 +++++++++++ src/test/mocks/MockTermAuction.sol | 23 +++-- src/test/mocks/MockTermAuctionOfferLocker.sol | 20 +++-- src/test/mocks/MockTermController.sol | 16 +++- src/test/mocks/MockTermRepoServicer.sol | 6 +- src/test/mocks/MockTermRepoToken.sol | 19 +++-- src/test/mocks/MockUSDC.sol | 19 +++++ src/test/utils/Setup.sol | 21 +++-- 13 files changed, 251 insertions(+), 71 deletions(-) delete mode 100644 src/test/TestSellRepoToken.t.sol delete mode 100644 src/test/TestSubmitOffer.t.sol create mode 100644 src/test/TestUSDCSellRepoToken.t.sol create mode 100644 src/test/TestUSDCSubmitOffer.t.sol create mode 100644 src/test/mocks/MockUSDC.sol diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index 7b1db179..0fdf7657 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -1,6 +1,7 @@ // 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"; @@ -47,28 +48,28 @@ library RepoTokenList { ) internal view returns (uint256) { if (listData.head == NULL_NODE) return 0; - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 cumulativeWeightedTimeToMaturity; // in seconds uint256 cumulativeRepoTokenAmount; // in purchase token precision address current = listData.head; bool found; while (current != NULL_NODE) { uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); - uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); - if (repoToken == current) { - repoTokenBalance += repoTokenAmount; - found = true; - } + if (repoTokenBalance > 0) { + uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); + uint256 repoTokenPrecision = 10**ERC20(current).decimals(); - uint256 repoTokenBalanceInBaseAssetPrecision = - (redemptionValue * repoTokenBalance * purchaseTokenPrecision) / - (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + if (repoToken == current) { + repoTokenBalance += repoTokenAmount; + found = true; + } - uint256 currentMaturity = _getRepoTokenMaturity(current); + uint256 repoTokenBalanceInBaseAssetPrecision = + (redemptionValue * repoTokenBalance * purchaseTokenPrecision) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + + uint256 currentMaturity = _getRepoTokenMaturity(current); - if (repoTokenBalance > 0) { if (currentMaturity > block.timestamp) { uint256 timeToMaturity = _getRepoTokenTimeToMaturity(currentMaturity, current); // Not matured yet @@ -83,13 +84,14 @@ library RepoTokenList { /// @dev token is not found in the list (i.e. called from view function) if (!found && repoToken != address(0)) { - uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 redemptionValue = ITermRepoToken(repoToken).redemptionValue(); uint256 repoTokenAmountInBaseAssetPrecision = (redemptionValue * repoTokenAmount * purchaseTokenPrecision) / (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); cumulativeRepoTokenAmount += repoTokenAmountInBaseAssetPrecision; - uint256 maturity = _getRepoTokenMaturity(current); + uint256 maturity = _getRepoTokenMaturity(repoToken); if (maturity > block.timestamp) { uint256 timeToMaturity = _getRepoTokenTimeToMaturity(maturity, repoToken); cumulativeWeightedTimeToMaturity += @@ -113,7 +115,7 @@ library RepoTokenList { address prev = current; while (current != NULL_NODE) { address next; - if (_getRepoTokenMaturity(current) >= block.timestamp) { + if (_getRepoTokenMaturity(current) < block.timestamp) { next = _getNext(listData, current); if (current == listData.head) { diff --git a/src/Strategy.sol b/src/Strategy.sol index c29ade40..a5f9d188 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; +import "forge-std/console.sol"; import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; @@ -42,8 +43,8 @@ contract Strategy is BaseStrategy { IERC4626 public immutable YEARN_VAULT; ITermController public termController; - RepoTokenListData public repoTokenListData; - TermAuctionListData public termAuctionListData; + RepoTokenListData internal repoTokenListData; + TermAuctionListData internal termAuctionListData; uint256 public timeToMaturityThreshold; // seconds uint256 public liquidityThreshold; // purchase token precision (underlying) uint256 public auctionRateMarkup; // 1e18 (TODO: check this) @@ -87,7 +88,10 @@ contract Strategy is BaseStrategy { } function simulateWeightedTimeToMaturity(address repoToken, uint256 amount) external view returns (uint256) { - repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); + // do not validate if we are simulating with existing repo tokens + if (repoToken != address(0)) { + repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); + } return repoTokenListData.simulateWeightedTimeToMaturity( repoToken, amount, PURCHASE_TOKEN_PRECISION, _totalLiquidBalance(address(this)) ); @@ -116,6 +120,7 @@ contract Strategy is BaseStrategy { // TODO: reentrancy check function sellRepoToken(address repoToken, uint256 repoTokenAmount) external { require(repoTokenAmount > 0); + require(_totalLiquidBalance(address(this)) > 0); (uint256 auctionRate, uint256 redemptionTimestamp) = repoTokenListData.validateAndInsertRepoToken( ITermRepoToken(repoToken), @@ -204,7 +209,7 @@ contract Strategy is BaseStrategy { ); uint256 liquidBalance = _totalLiquidBalance(address(this)); uint256 resultingWeightedTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( - repoToken, offerAmount, liquidBalance - offerAmount + repoToken, offerAmount, liquidBalance - purchaseTokenAmount ); if (resultingWeightedTimeToMaturity > timeToMaturityThreshold) { diff --git a/src/test/TestSellRepoToken.t.sol b/src/test/TestSellRepoToken.t.sol deleted file mode 100644 index 18b73ffe..00000000 --- a/src/test/TestSellRepoToken.t.sol +++ /dev/null @@ -1,11 +0,0 @@ -pragma solidity ^0.8.18; - -import "forge-std/console.sol"; -import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol"; - -contract TestSellRepoToken is Setup { - - function testSellRepoToken() public { - - } -} diff --git a/src/test/TestSubmitOffer.t.sol b/src/test/TestSubmitOffer.t.sol deleted file mode 100644 index 19cd9d7d..00000000 --- a/src/test/TestSubmitOffer.t.sol +++ /dev/null @@ -1,8 +0,0 @@ -pragma solidity ^0.8.18; - -import "forge-std/console.sol"; -import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol"; - -contract TestSubmitOffer is Setup { - -} diff --git a/src/test/TestUSDCSellRepoToken.t.sol b/src/test/TestUSDCSellRepoToken.t.sol new file mode 100644 index 00000000..dcb6973e --- /dev/null +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -0,0 +1,84 @@ +pragma solidity ^0.8.18; + +import "forge-std/console.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/ERC20Mock.sol"; +import {MockTermRepoToken} from "./mocks/MockTermRepoToken.sol"; +import {MockUSDC} from "./mocks/MockUSDC.sol"; +import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.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; + + 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 + ); + repoToken2Week = new MockTermRepoToken( + bytes32("test repo token 2"), address(mockUSDC), address(mockCollateral), 1e18, 2 weeks + ); + repoToken4Week = new MockTermRepoToken( + bytes32("test repo token 3"), address(mockUSDC), address(mockCollateral), 1e18, 4 weeks + ); + } + + function testSellSingleRepoToken() public { + mockUSDC.mint(address(strategy), 100e6); + + address testUser = vm.addr(0x11111); + + repoToken1Week.mint(testUser, 1000e18); + + vm.prank(testUser); + repoToken1Week.approve(address(strategy), type(uint256).max); + + termController.setOracleRate(repoToken1Week.termRepoId(), 1.05e18); + + vm.startPrank(management); + Strategy(address(strategy)).setCollateralTokenParams(address(mockCollateral), 0.5e18); + Strategy(address(strategy)).setTimeToMaturityThreshold(3 weeks); + vm.stopPrank(); + + vm.prank(testUser); + Strategy(address(strategy)).sellRepoToken(address(repoToken1Week), 1e18); + + uint256 weightedTimeToMaturity = Strategy(address(strategy)).simulateWeightedTimeToMaturity(address(0), 0); + + console.log("weightedTimeToMat", weightedTimeToMaturity); + } + + // + function testSellMultipleRepoTokens() public { + + } + + function testSellMultipleRepoTokensMultipleUsers() public { + + } + + function testSetGovernanceParameters() public { + + } + + function testRepoTokenValidationFailures() public { + + } + + function testAboveMaturityThresholdFailure() public { + + } + + function testBelowLiquidityThresholdFailure() public { + + } +} diff --git a/src/test/TestUSDCSubmitOffer.t.sol b/src/test/TestUSDCSubmitOffer.t.sol new file mode 100644 index 00000000..2c6f5fa3 --- /dev/null +++ b/src/test/TestUSDCSubmitOffer.t.sol @@ -0,0 +1,50 @@ +pragma solidity ^0.8.18; + +import "forge-std/console.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 { + + MockUSDC internal mockUSDC; + ERC20Mock internal mockCollateral; + MockTermRepoToken internal repoToken1Week; + + 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 + ); + } + + function testSubmitOffer() public { + mockUSDC.mint(address(strategy), 100e6); + + MockTermAuction mockAuction = new MockTermAuction(repoToken1Week); + + vm.startPrank(management); + Strategy(address(strategy)).setCollateralTokenParams(address(mockCollateral), 0.5e18); + Strategy(address(strategy)).setTimeToMaturityThreshold(3 weeks); + vm.stopPrank(); + + Strategy(address(strategy)).submitAuctionOffer( + address(mockAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), 1e6 + ); + } + + function testEditOffer() public { + + } + + function testCompleteAuction() public { + + } +} diff --git a/src/test/mocks/MockTermAuction.sol b/src/test/mocks/MockTermAuction.sol index cbacf797..82435919 100644 --- a/src/test/mocks/MockTermAuction.sol +++ b/src/test/mocks/MockTermAuction.sol @@ -2,6 +2,9 @@ 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"; contract MockTermAuction is ITermAuction { @@ -11,12 +14,20 @@ contract MockTermAuction is ITermAuction { bool public auctionCompleted; bool public auctionCancelledForWithdrawal; - constructor(bytes32 _termRepoId) { - termRepoId = _termRepoId; - } - - function setOfferLocker(address _termAuctionOfferLocker) external { - termAuctionOfferLocker = _termAuctionOfferLocker; + constructor(ITermRepoToken _repoToken) { + termRepoId = _repoToken.termRepoId(); + ( + uint256 redemptionTimestamp, + address purchaseToken, + address termRepoServicer, + address termRepoCollateralManager + ) = _repoToken.config(); + termAuctionOfferLocker = address(new MockTermAuctionOfferLocker( + ITermAuction(address(this)), + ITermRepoServicer(termRepoServicer).termRepoLocker(), + termRepoServicer, + purchaseToken + )); } function startAuction(uint256 duration) external { diff --git a/src/test/mocks/MockTermAuctionOfferLocker.sol b/src/test/mocks/MockTermAuctionOfferLocker.sol index 067d923a..ad1642ec 100644 --- a/src/test/mocks/MockTermAuctionOfferLocker.sol +++ b/src/test/mocks/MockTermAuctionOfferLocker.sol @@ -8,13 +8,20 @@ import {MockTermRepoLocker} from "./MockTermRepoLocker.sol"; contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { address public purchaseToken; + address public termRepoServicer; MockTermRepoLocker internal repoLocker; ITermAuction internal auction; mapping(bytes32 => TermAuctionOffer) internal lockedOffers; - constructor(ITermAuction _auction, address _repoLocker, address _purchaseToken) { + constructor( + ITermAuction _auction, + address _repoLocker, + address _repoServicer, + address _purchaseToken + ) { auction = _auction; purchaseToken = _purchaseToken; + termRepoServicer = _repoServicer; repoLocker = MockTermRepoLocker(_repoLocker); } @@ -38,19 +45,15 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { } - function termRepoServicer() external view returns (address) { - - } - function lockedOffer(bytes32 id) external view returns (TermAuctionOffer memory) { return lockedOffers[id]; } - /// @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) { + ) 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; @@ -64,6 +67,7 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { lockedOffers[offer.id] = offer; repoLocker.lockPurchaseTokens(msg.sender, offer.amount); + offerIds[i] = offer.id; } } diff --git a/src/test/mocks/MockTermController.sol b/src/test/mocks/MockTermController.sol index cc83a362..47be58d5 100644 --- a/src/test/mocks/MockTermController.sol +++ b/src/test/mocks/MockTermController.sol @@ -1,14 +1,26 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; -import {ITermController, TermAuctionResults} from "../../interfaces/term/ITermController.sol"; +import {ITermController, AuctionMetadata, TermAuctionResults} from "../../interfaces/term/ITermController.sol"; contract MockTermController is ITermController { + mapping(bytes32 => TermAuctionResults) internal auctionResults; + function isTermDeployed(address contractAddress) external view returns (bool) { return true; } - function getTermAuctionResults(bytes32 termRepoId) external view returns (TermAuctionResults memory) { + 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 (TermAuctionResults memory) { + return auctionResults[termRepoId]; } } \ No newline at end of file diff --git a/src/test/mocks/MockTermRepoServicer.sol b/src/test/mocks/MockTermRepoServicer.sol index dde928a7..ab7fb352 100644 --- a/src/test/mocks/MockTermRepoServicer.sol +++ b/src/test/mocks/MockTermRepoServicer.sol @@ -9,9 +9,9 @@ contract MockTermRepoServicer is ITermRepoServicer { ITermRepoToken internal repoToken; MockTermRepoLocker internal repoLocker; - constructor(ITermRepoToken _repoToken) { + constructor(ITermRepoToken _repoToken, address purchaseToken) { repoToken = _repoToken; - repoLocker = new MockTermRepoLocker(purchaseToken()); + repoLocker = new MockTermRepoLocker(purchaseToken); } function redeemTermRepoTokens( @@ -29,7 +29,7 @@ contract MockTermRepoServicer is ITermRepoServicer { return address(repoLocker); } - function purchaseToken() public view returns (address) { + function purchaseToken() external view returns (address) { (, address purchaseToken, ,) = repoToken.config(); return purchaseToken; } diff --git a/src/test/mocks/MockTermRepoToken.sol b/src/test/mocks/MockTermRepoToken.sol index 15b0fff1..906b1550 100644 --- a/src/test/mocks/MockTermRepoToken.sol +++ b/src/test/mocks/MockTermRepoToken.sol @@ -22,17 +22,16 @@ contract MockTermRepoToken is ERC20, ITermRepoToken { address internal collateralManager; constructor( - string memory name_, - string memory symbol_, bytes32 _termRepoId, address _purchaseToken, address _collateral, - uint256 _maintenanceRatio - ) ERC20(name_, symbol_) { + uint256 _maintenanceRatio, + uint256 _timeToMaturity + ) ERC20("MockRepo", "MockRepo") { termRepoId = _termRepoId; + repoTokenContext.redemptionTimestamp = block.timestamp + _timeToMaturity; repoTokenContext.purchaseToken = _purchaseToken; - - repoTokenContext.termRepoServicer = new MockTermRepoServicer(ITermRepoToken(address(this))); + repoTokenContext.termRepoServicer = new MockTermRepoServicer(ITermRepoToken(address(this)), _purchaseToken); repoTokenContext.termRepoCollateralManager = new MockTermRepoCollateralManager( ITermRepoToken(address(this)), _collateral, _maintenanceRatio ); @@ -42,6 +41,14 @@ contract MockTermRepoToken is ERC20, ITermRepoToken { return 1e18; } + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function burn(address account, uint256 amount) external { + _burn(account, amount); + } + function config() external view 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 b7c6db08..f23e845c 100644 --- a/src/test/utils/Setup.sol +++ b/src/test/utils/Setup.sol @@ -66,16 +66,27 @@ contract Setup is ExtendedTest, IEvents { 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(); + 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()); - factory = strategy.FACTORY(); +// factory = strategy.FACTORY(); // label all the used addresses for traces vm.label(keeper, "keeper"); @@ -84,12 +95,6 @@ contract Setup is ExtendedTest, IEvents { vm.label(management, "management"); vm.label(address(strategy), "strategy"); vm.label(performanceFeeRecipient, "performanceFeeRecipient"); - - termController = new MockTermController(); - termVaultEventEmitterImpl = new TermVaultEventEmitter(); - termVaultEventEmitter = TermVaultEventEmitter(address(new ERC1967Proxy(address(termVaultEventEmitterImpl), ""))); - - termVaultEventEmitter.initialize(adminWallet, devopsWallet); } function setUpStrategy() public returns (address) { From 7b50e6f530ce94c3cd081c5570ccfc0832209de5 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Thu, 16 May 2024 17:12:51 -0700 Subject: [PATCH 16/30] more tests --- src/RepoTokenList.sol | 4 +- src/Strategy.sol | 32 ++++- src/TermVaultEventEmitter.sol | 4 + src/interfaces/term/ITermVaultEvents.sol | 4 + src/test/TestUSDCOffers.t.sol | 106 ++++++++++++++++ src/test/TestUSDCSellRepoToken.t.sol | 119 ++++++++++++++++-- src/test/TestUSDCSubmitOffer.t.sol | 50 -------- src/test/mocks/MockTermAuctionOfferLocker.sol | 31 +++-- src/test/mocks/MockTermRepoToken.sol | 4 +- src/test/utils/Setup.sol | 14 ++- 10 files changed, 292 insertions(+), 76 deletions(-) create mode 100644 src/test/TestUSDCOffers.t.sol delete mode 100644 src/test/TestUSDCSubmitOffer.t.sol diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index 0fdf7657..f159071b 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -147,7 +147,9 @@ library RepoTokenList { uint256 len = results.auctionMetadata.length; - require(len > 0); + if (len == 0) { + revert InvalidRepoToken(address(repoToken)); + } return results.auctionMetadata[len - 1].auctionClearingRate; } diff --git a/src/Strategy.sol b/src/Strategy.sol index a5f9d188..da8486a6 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -37,6 +37,7 @@ contract Strategy is BaseStrategy { 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; @@ -71,7 +72,7 @@ contract Strategy is BaseStrategy { } function setCollateralTokenParams(address tokenAddr, uint256 minCollateralRatio) external onlyManagement { - // TERM_VAULT_EVENT_EMITTER.emitMinCollateralRatioUpdated(tokenAddr, minCollateralRatio); + TERM_VAULT_EVENT_EMITTER.emitMinCollateralRatioUpdated(tokenAddr, minCollateralRatio); repoTokenListData.collateralTokenParams[tokenAddr] = minCollateralRatio; } @@ -97,6 +98,18 @@ contract Strategy is BaseStrategy { ); } + function calculateRepoTokenPresentValue(address repoToken, uint256 amount) external view returns (uint256) { + uint256 auctionRate = repoTokenListData.auctionRates[repoToken]; + (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; @@ -138,8 +151,13 @@ contract Strategy is BaseStrategy { uint256 proceeds = RepoTokenUtils.calculatePresentValue( repoTokenAmountInBaseAssetPrecision, PURCHASE_TOKEN_PRECISION, redemptionTimestamp, auctionRate ); + + if (liquidBalance < proceeds) { + revert InsufficientLiquidBalance(liquidBalance, proceeds); + } + uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( - repoToken, repoTokenAmount, _totalLiquidBalance(address(this)) - proceeds + repoToken, repoTokenAmount, liquidBalance - proceeds // TODO: figure out what to do here ); if (resultingTimeToMaturity > timeToMaturityThreshold) { @@ -159,7 +177,7 @@ contract Strategy is BaseStrategy { IERC20(asset).safeTransfer(msg.sender, proceeds); } - function deleteAuctionOffer(address termAuction, bytes32[] calldata offerIds) external onlyManagement { + function deleteAuctionOffers(address termAuction, bytes32[] calldata offerIds) external onlyManagement { if (!termController.isTermDeployed(termAuction)) { revert InvalidTermAuction(termAuction); } @@ -278,6 +296,14 @@ contract Strategy is BaseStrategy { _sweepAsset(); } + 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) + diff --git a/src/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol index 5655daf0..66a7ad85 100644 --- a/src/TermVaultEventEmitter.sol +++ b/src/TermVaultEventEmitter.sol @@ -51,6 +51,10 @@ contract TermVaultEventEmitter is Initializable, UUPSUpgradeable, AccessControlU emit AuctionRateMarkupUpdated(oldMarkup, newMarkup); } + function emitMinCollateralRatioUpdated(address collateral, uint256 minCollateralRatio) external { + emit MinCollateralRatioUpdated(collateral, minCollateralRatio); + } + // ======================================================================== // = Admin =============================================================== // ======================================================================== diff --git a/src/interfaces/term/ITermVaultEvents.sol b/src/interfaces/term/ITermVaultEvents.sol index 5c025948..29fe4682 100644 --- a/src/interfaces/term/ITermVaultEvents.sol +++ b/src/interfaces/term/ITermVaultEvents.sol @@ -10,6 +10,8 @@ interface ITermVaultEvents { 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; @@ -17,4 +19,6 @@ interface ITermVaultEvents { 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..6e8e620a --- /dev/null +++ b/src/test/TestUSDCOffers.t.sol @@ -0,0 +1,106 @@ +pragma solidity ^0.8.18; + +import "forge-std/console.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 { + MockUSDC internal mockUSDC; + ERC20Mock internal mockCollateral; + MockTermRepoToken internal repoToken1Week; + Strategy internal termStrategy; + MockTermAuction internal mockAuction; + 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 + ); + + termStrategy = Strategy(address(strategy)); + // start with some initial funds + mockUSDC.mint(address(strategy), 100e6); + + initialState.totalAssetValue = termStrategy.totalAssetValue(); + initialState.totalLiquidBalance = termStrategy.totalLiquidBalance(); + } + + function testSubmitOffer() public { + // TODO: fuzz this + uint256 offerAmount = 1e6; + + mockAuction = new MockTermAuction(repoToken1Week); + + vm.startPrank(management); + termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); + termStrategy.setTimeToMaturityThreshold(3 weeks); + vm.stopPrank(); + + // test: only management can submit offers + vm.expectRevert("!management"); + bytes32[] memory offerIds = termStrategy.submitAuctionOffer( + address(mockAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), offerAmount + ); + + vm.prank(management); + offerIds = termStrategy.submitAuctionOffer( + address(mockAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), offerAmount + ); + + assertEq(offerIds.length, 1); + assertEq(offerIds[0], bytes32("offer id 1")); + assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - offerAmount); + // test: totalAssetValue = total liquid balance + pending offer amount + assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance() + offerAmount); + } + + function testEditOffer() public { + testSubmitOffer(); + + // TODO: fuzz this + uint256 offerAmount = 4e6; + + vm.prank(management); + bytes32[] memory offerIds = termStrategy.submitAuctionOffer( + address(mockAuction), address(repoToken1Week), bytes32("offer id 1"), 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 { + testSubmitOffer(); + + bytes32[] memory offerIds = new bytes32[](1); + + offerIds[0] = bytes32("offer id 1"); + + vm.expectRevert("!management"); + termStrategy.deleteAuctionOffers(address(mockAuction), offerIds); + + vm.prank(management); + termStrategy.deleteAuctionOffers(address(mockAuction), offerIds); + + assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance); + assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance()); + } + + function testCompleteAuction() public { + + } + + function testMultipleOffers() public { + + } +} diff --git a/src/test/TestUSDCSellRepoToken.t.sol b/src/test/TestUSDCSellRepoToken.t.sol index dcb6973e..ebc7358f 100644 --- a/src/test/TestUSDCSellRepoToken.t.sol +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -3,8 +3,11 @@ pragma solidity ^0.8.18; import "forge-std/console.sol"; import {ERC20Mock} from "@openzeppelin/contracts/mocks/ERC20Mock.sol"; import {MockTermRepoToken} from "./mocks/MockTermRepoToken.sol"; +import {MockTermController} from "./mocks/MockTermController.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 { @@ -14,6 +17,9 @@ contract TestUSDCSellRepoToken is Setup { 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(); @@ -22,18 +28,29 @@ contract TestUSDCSellRepoToken is Setup { _setUp(ERC20(address(mockUSDC))); repoToken1Week = new MockTermRepoToken( - bytes32("test repo token 1"), address(mockUSDC), address(mockCollateral), 1e18, 1 weeks + 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, 2 weeks + 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, 4 weeks + 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)); + // start with some initial funds + mockUSDC.mint(address(strategy), 100e6); + + initialState.totalAssetValue = termStrategy.totalAssetValue(); + initialState.totalLiquidBalance = termStrategy.totalLiquidBalance(); } function testSellSingleRepoToken() public { - mockUSDC.mint(address(strategy), 100e6); + // TODO: fuzz this + uint256 repoTokenSellAmount = 1e18; address testUser = vm.addr(0x11111); @@ -45,16 +62,37 @@ contract TestUSDCSellRepoToken is Setup { termController.setOracleRate(repoToken1Week.termRepoId(), 1.05e18); vm.startPrank(management); - Strategy(address(strategy)).setCollateralTokenParams(address(mockCollateral), 0.5e18); - Strategy(address(strategy)).setTimeToMaturityThreshold(3 weeks); + termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); + termStrategy.setTimeToMaturityThreshold(3 weeks); vm.stopPrank(); vm.prank(testUser); - Strategy(address(strategy)).sellRepoToken(address(repoToken1Week), 1e18); + termStrategy.sellRepoToken(address(repoToken1Week), repoTokenSellAmount); + + uint256 expectedProceeds = termStrategy.calculateRepoTokenPresentValue(address(repoToken1Week), 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(); - uint256 weightedTimeToMaturity = Strategy(address(strategy)).simulateWeightedTimeToMaturity(address(0), 0); + uint256 repoTokenBalanceInBaseAssetPrecision = + (ITermRepoToken(repoToken1Week).redemptionValue() * repoTokenSellAmount * 1e6) / (1e18 * 1e18); + uint256 cumulativeWeightedTimeToMaturity = + (redemptionTimestamp - block.timestamp) * repoTokenBalanceInBaseAssetPrecision; + uint256 expectedWeightedTimeToMaturity = + cumulativeWeightedTimeToMaturity / (repoTokenBalanceInBaseAssetPrecision + termStrategy.totalLiquidBalance()); + console.log("repoTokenBalanceInBaseAssetPrecision", repoTokenBalanceInBaseAssetPrecision); + console.log("cumulativeWeightedTimeToMaturity", cumulativeWeightedTimeToMaturity); + console.log("totalLiquidBalance", termStrategy.totalLiquidBalance()); + console.log("redemptionTimestamp", redemptionTimestamp); console.log("weightedTimeToMat", weightedTimeToMaturity); + + assertEq(weightedTimeToMaturity, expectedWeightedTimeToMaturity); } // @@ -67,11 +105,74 @@ contract TestUSDCSellRepoToken is Setup { } function testSetGovernanceParameters() public { - + MockTermController newController = new MockTermController(); + + vm.expectRevert("!management"); + termStrategy.setTermController(address(newController)); + + 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 { + mockUSDC.mint(address(strategy), 100e6); + 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); + + // test: min collaterl 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 { diff --git a/src/test/TestUSDCSubmitOffer.t.sol b/src/test/TestUSDCSubmitOffer.t.sol deleted file mode 100644 index 2c6f5fa3..00000000 --- a/src/test/TestUSDCSubmitOffer.t.sol +++ /dev/null @@ -1,50 +0,0 @@ -pragma solidity ^0.8.18; - -import "forge-std/console.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 { - - MockUSDC internal mockUSDC; - ERC20Mock internal mockCollateral; - MockTermRepoToken internal repoToken1Week; - - 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 - ); - } - - function testSubmitOffer() public { - mockUSDC.mint(address(strategy), 100e6); - - MockTermAuction mockAuction = new MockTermAuction(repoToken1Week); - - vm.startPrank(management); - Strategy(address(strategy)).setCollateralTokenParams(address(mockCollateral), 0.5e18); - Strategy(address(strategy)).setTimeToMaturityThreshold(3 weeks); - vm.stopPrank(); - - Strategy(address(strategy)).submitAuctionOffer( - address(mockAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), 1e6 - ); - } - - function testEditOffer() public { - - } - - function testCompleteAuction() public { - - } -} diff --git a/src/test/mocks/MockTermAuctionOfferLocker.sol b/src/test/mocks/MockTermAuctionOfferLocker.sol index ad1642ec..bacab7ef 100644 --- a/src/test/mocks/MockTermAuctionOfferLocker.sol +++ b/src/test/mocks/MockTermAuctionOfferLocker.sol @@ -56,17 +56,28 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { for (uint256 i; i < offerSubmissions.length; i++) { TermAuctionOfferSubmission memory submission = offerSubmissions[i]; - TermAuctionOffer memory offer; - - offer.id = submission.id; - offer.offeror = submission.offeror; - offer.offerPriceHash = submission.offerPriceHash; - offer.amount = submission.amount; - offer.purchaseToken = submission.purchaseToken; - + 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 { + offer.id = submission.id; + 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; - - repoLocker.lockPurchaseTokens(msg.sender, offer.amount); offerIds[i] = offer.id; } } diff --git a/src/test/mocks/MockTermRepoToken.sol b/src/test/mocks/MockTermRepoToken.sol index 906b1550..98d7e1e6 100644 --- a/src/test/mocks/MockTermRepoToken.sol +++ b/src/test/mocks/MockTermRepoToken.sol @@ -26,10 +26,10 @@ contract MockTermRepoToken is ERC20, ITermRepoToken { address _purchaseToken, address _collateral, uint256 _maintenanceRatio, - uint256 _timeToMaturity + uint256 _redemptionTimestamp ) ERC20("MockRepo", "MockRepo") { termRepoId = _termRepoId; - repoTokenContext.redemptionTimestamp = block.timestamp + _timeToMaturity; + repoTokenContext.redemptionTimestamp = _redemptionTimestamp; repoTokenContext.purchaseToken = _purchaseToken; repoTokenContext.termRepoServicer = new MockTermRepoServicer(ITermRepoToken(address(this)), _purchaseToken); repoTokenContext.termRepoCollateralManager = new MockTermRepoCollateralManager( diff --git a/src/test/utils/Setup.sol b/src/test/utils/Setup.sol index f23e845c..2dfcc01e 100644 --- a/src/test/utils/Setup.sol +++ b/src/test/utils/Setup.sol @@ -12,6 +12,7 @@ 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 {TermVaultEventEmitter} from "../../TermVaultEventEmitter.sol"; import {MockTermAuction} from "../mocks/MockTermAuction.sol"; import {MockTermAuctionOfferLocker} from "../mocks/MockTermAuctionOfferLocker.sol"; @@ -29,6 +30,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; @@ -62,6 +68,7 @@ contract Setup is ExtendedTest, IEvents { TermVaultEventEmitter termVaultEventEmitterImpl; TermVaultEventEmitter termVaultEventEmitter; ERC4626Mock mockYearnVault; + TokenizedStrategy tokenizedStrategy; function setUp() public virtual { _setTokenAddrs(); @@ -76,6 +83,10 @@ contract Setup is ExtendedTest, IEvents { // Set decimals decimals = asset.decimals(); + // Factory from mainnet, tokenized strategy needs to be hardcoded to 0xBB51273D6c746910C7C06fe718f30c936170feD0 + tokenizedStrategy = new TokenizedStrategy(0x444045c5C13C246e117eD36437303cac8E250aB0); + vm.etch(0xBB51273D6c746910C7C06fe718f30c936170feD0, address(tokenizedStrategy).code); + termController = new MockTermController(); termVaultEventEmitterImpl = new TermVaultEventEmitter(); termVaultEventEmitter = TermVaultEventEmitter(address(new ERC1967Proxy(address(termVaultEventEmitterImpl), ""))); @@ -86,7 +97,7 @@ contract Setup is ExtendedTest, IEvents { // Deploy strategy and set variables strategy = IStrategyInterface(setUpStrategy()); -// factory = strategy.FACTORY(); + factory = strategy.FACTORY(); // label all the used addresses for traces vm.label(keeper, "keeper"); @@ -113,6 +124,7 @@ contract Setup is ExtendedTest, IEvents { vm.prank(management); _strategy.acceptManagement(); + vm.prank(management); _strategy.setTermController(address(termController)); return address(_strategy); From a0ef8d94e49f09bc4fc206bb6d6b32fd9cc8151f Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 22 May 2024 17:20:56 -0700 Subject: [PATCH 17/30] adding more tess --- src/RepoTokenList.sol | 36 +++- src/RepoTokenUtils.sol | 6 +- src/Strategy.sol | 53 +++-- src/TermAuctionList.sol | 22 ++ src/test/TestUSDCOffers.t.sol | 159 +++++++++++--- src/test/TestUSDCSellRepoToken.t.sol | 199 ++++++++++++++++-- src/test/mocks/MockTermAuction.sol | 21 +- src/test/mocks/MockTermAuctionOfferLocker.sol | 12 +- 8 files changed, 437 insertions(+), 71 deletions(-) diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index f159071b..d0877fd1 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -39,6 +39,28 @@ library RepoTokenList { 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 simulateWeightedTimeToMaturity( RepoTokenListData storage listData, address repoToken, @@ -235,6 +257,11 @@ library RepoTokenList { address prev; while (current != NULL_NODE) { + // already in list + if (current == repoToken) { + break; + } + uint256 currentMaturity = _getRepoTokenMaturity(current); uint256 maturityToInsert = _getRepoTokenMaturity(repoToken); @@ -248,8 +275,15 @@ library RepoTokenList { break; } + address next = _getNext(listData, current); + + if (next == NULL_NODE) { + listData.nodes[current].next = repoToken; + break; + } + prev = current; - current = _getNext(listData, current); + current = next; } } diff --git a/src/RepoTokenUtils.sol b/src/RepoTokenUtils.sol index 905be58e..1e2fed9e 100644 --- a/src/RepoTokenUtils.sol +++ b/src/RepoTokenUtils.sol @@ -26,15 +26,13 @@ library RepoTokenUtils { uint256 purchaseTokenPrecision, uint256 redemptionTimestamp, uint256 auctionRate - ) internal view returns (uint256) { + ) internal view returns (uint256 presentValue) { uint256 timeLeftToMaturityDayFraction = ((redemptionTimestamp - block.timestamp) * purchaseTokenPrecision) / THREESIXTY_DAYCOUNT_SECONDS; // repoTokenAmountInBaseAssetPrecision / (1 + r * days / 360) - uint256 presentValue = + presentValue = (repoTokenAmountInBaseAssetPrecision * purchaseTokenPrecision) / (purchaseTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)); - - return presentValue; } } diff --git a/src/Strategy.sol b/src/Strategy.sol index da8486a6..c6bb6fe6 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -52,6 +52,7 @@ contract Strategy is BaseStrategy { // 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); } @@ -76,16 +77,22 @@ contract Strategy is BaseStrategy { 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 _removeRedeemAndCalculateWeightedMaturity( address repoToken, uint256 amount, uint256 liquidBalance ) private returns (uint256) { - uint256 weightedTimeToMaturity = repoTokenListData.simulateWeightedTimeToMaturity( + return repoTokenListData.simulateWeightedTimeToMaturity( repoToken, amount, PURCHASE_TOKEN_PRECISION, liquidBalance ); - repoTokenListData.removeAndRedeemMaturedTokens(); - return weightedTimeToMaturity; } function simulateWeightedTimeToMaturity(address repoToken, uint256 amount) external view returns (uint256) { @@ -98,16 +105,22 @@ contract Strategy is BaseStrategy { ); } - function calculateRepoTokenPresentValue(address repoToken, uint256 amount) external view returns (uint256) { - uint256 auctionRate = repoTokenListData.auctionRates[repoToken]; + 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 - ); + repoTokenAmountInBaseAssetPrecision, + PURCHASE_TOKEN_PRECISION, + redemptionTimestamp, + auctionRate + ); } function _totalLiquidBalance(address addr) private view returns (uint256) { @@ -115,11 +128,12 @@ contract Strategy is BaseStrategy { return _assetBalance() + underlyingBalance; } - function _sweepAsset() private { + function _sweepAssetAndRedeemRepoTokens() private { uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); if (underlyingBalance > 0) { YEARN_VAULT.deposit(underlyingBalance, address(this)); } + repoTokenListData.removeAndRedeemMaturedTokens(); } function _withdrawAsset(uint256 amount) private { @@ -133,7 +147,6 @@ contract Strategy is BaseStrategy { // TODO: reentrancy check function sellRepoToken(address repoToken, uint256 repoTokenAmount) external { require(repoTokenAmount > 0); - require(_totalLiquidBalance(address(this)) > 0); (uint256 auctionRate, uint256 redemptionTimestamp) = repoTokenListData.validateAndInsertRepoToken( ITermRepoToken(repoToken), @@ -141,15 +154,20 @@ contract Strategy is BaseStrategy { address(asset) ); - _sweepAsset(); + _sweepAssetAndRedeemRepoTokens(); 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 + repoTokenAmountInBaseAssetPrecision, + PURCHASE_TOKEN_PRECISION, + redemptionTimestamp, + auctionRate + auctionRateMarkup ); if (liquidBalance < proceeds) { @@ -157,7 +175,7 @@ contract Strategy is BaseStrategy { } uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( - repoToken, repoTokenAmount, liquidBalance - proceeds // TODO: figure out what to do here + repoToken, repoTokenAmount, liquidBalance - proceeds ); if (resultingTimeToMaturity > timeToMaturityThreshold) { @@ -189,7 +207,7 @@ contract Strategy is BaseStrategy { termAuctionListData.removeCompleted(repoTokenListData, termController, address(asset)); - _sweepAsset(); + _sweepAssetAndRedeemRepoTokens(); } function submitAuctionOffer( @@ -219,13 +237,18 @@ contract Strategy is BaseStrategy { "Auction not open" ); - _sweepAsset(); //@dev sweep to ensure liquid balances up to date + _sweepAssetAndRedeemRepoTokens(); //@dev sweep to ensure liquid balances up to date uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); uint256 offerAmount = RepoTokenUtils.purchaseToRepoPrecision( repoTokenPrecision, PURCHASE_TOKEN_PRECISION, purchaseTokenAmount ); uint256 liquidBalance = _totalLiquidBalance(address(this)); + + if (liquidBalance < purchaseTokenAmount) { + revert InsufficientLiquidBalance(liquidBalance, purchaseTokenAmount); + } + uint256 resultingWeightedTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( repoToken, offerAmount, liquidBalance - purchaseTokenAmount ); @@ -293,7 +316,7 @@ contract Strategy is BaseStrategy { function auctionClosed() external { termAuctionListData.removeCompleted(repoTokenListData, termController, address(asset)); - _sweepAsset(); + _sweepAssetAndRedeemRepoTokens(); } function totalAssetValue() external view returns (uint256) { diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index 59dc79d5..100d2f2a 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -35,6 +35,28 @@ library TermAuctionList { 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, PendingOffer memory pendingOffer) internal { bytes32 current = listData.head; bytes32 id = pendingOffer.offerId; diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol index 6e8e620a..907fbf82 100644 --- a/src/test/TestUSDCOffers.t.sol +++ b/src/test/TestUSDCOffers.t.sol @@ -9,11 +9,13 @@ 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; - MockTermAuction internal mockAuction; StrategySnapshot internal initialState; function setUp() public override { @@ -25,53 +27,57 @@ contract TestUSDCSubmitOffer is Setup { 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)); - // start with some initial funds - mockUSDC.mint(address(strategy), 100e6); - - initialState.totalAssetValue = termStrategy.totalAssetValue(); - initialState.totalLiquidBalance = termStrategy.totalLiquidBalance(); - } - function testSubmitOffer() public { - // TODO: fuzz this - uint256 offerAmount = 1e6; - - mockAuction = new MockTermAuction(repoToken1Week); + 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 offerId, uint256 offerAmount) private { // test: only management can submit offers vm.expectRevert("!management"); bytes32[] memory offerIds = termStrategy.submitAuctionOffer( - address(mockAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), offerId, bytes32("test price"), offerAmount ); vm.prank(management); offerIds = termStrategy.submitAuctionOffer( - address(mockAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), offerId, bytes32("test price"), offerAmount ); assertEq(offerIds.length, 1); - assertEq(offerIds[0], bytes32("offer id 1")); - assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - offerAmount); + assertEq(offerIds[0], offerId); + } + + function testSubmitOffer() public { + _submitOffer(bytes32("offer id 1"), 1e6); + + assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - 1e6); // test: totalAssetValue = total liquid balance + pending offer amount - assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance() + offerAmount); + assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance() + 1e6); } function testEditOffer() public { - testSubmitOffer(); + _submitOffer(bytes32("offer id 1"), 1e6); // TODO: fuzz this uint256 offerAmount = 4e6; vm.prank(management); bytes32[] memory offerIds = termStrategy.submitAuctionOffer( - address(mockAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), offerAmount ); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - offerAmount); @@ -80,27 +86,132 @@ contract TestUSDCSubmitOffer is Setup { } function testDeleteOffers() public { - testSubmitOffer(); + _submitOffer(bytes32("offer id 1"), 1e6); bytes32[] memory offerIds = new bytes32[](1); offerIds[0] = bytes32("offer id 1"); vm.expectRevert("!management"); - termStrategy.deleteAuctionOffers(address(mockAuction), offerIds); + termStrategy.deleteAuctionOffers(address(repoToken1WeekAuction), offerIds); vm.prank(management); - termStrategy.deleteAuctionOffers(address(mockAuction), offerIds); + termStrategy.deleteAuctionOffers(address(repoToken1WeekAuction), offerIds); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance); assertEq(termStrategy.totalAssetValue(), termStrategy.totalLiquidBalance()); } - function testCompleteAuction() public { - + 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 { + _submitOffer(bytes32("offer id 1"), 1e6); + + bytes32[] memory offerIds = new bytes32[](1); + offerIds[0] = bytes32("offer id 1"); + uint256[] memory fillAmounts = new uint256[](1); + fillAmounts[0] = 1e6; + uint256[] memory repoTokenAmounts = new uint256[](1); + repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( + 1e6, repoToken1Week, TEST_REPO_TOKEN_RATE + ); + + repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); + + // test: check value before calling complete auction + + termStrategy.auctionClosed(); + + bytes32[] memory offers = termStrategy.pendingOffers(); + + assertEq(offers.length, 0); + } + + function testCompleteAuctionSuccessPartial() public { + _submitOffer(bytes32("offer id 1"), 1e6); + + bytes32[] memory offerIds = new bytes32[](1); + offerIds[0] = bytes32("offer id 1"); + uint256[] memory fillAmounts = new uint256[](1); + + // test: 50% filled + fillAmounts[0] = 0.5e6; + uint256[] memory repoTokenAmounts = new uint256[](1); + repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( + 0.5e6, repoToken1Week, TEST_REPO_TOKEN_RATE + ); + + console.log("before", termStrategy.totalAssetValue()); + + repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); + + address[] memory holdings = termStrategy.repoTokenHoldings(); + + assertEq(holdings.length, 0); + + // test: check value before calling complete auction + termStrategy.auctionClosed(); + + console.log("after", termStrategy.totalAssetValue()); + + assertEq(termStrategy.totalAssetValue(), initialState.totalAssetValue); + + holdings = termStrategy.repoTokenHoldings(); + + assertEq(holdings.length, 1); + assertEq(holdings[0], address(repoToken1Week)); + + bytes32[] memory offers = termStrategy.pendingOffers(); + + assertEq(offers.length, 0); + } + + function testCompleteAuctionCanceled() public { + _submitOffer(bytes32("offer id 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 { + _submitOffer(bytes32("offer id 1"), 1e6); + _submitOffer(bytes32("offer id 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], bytes32("offer id 2")); + assertEq(offers[1], bytes32("offer id 1")); } } diff --git a/src/test/TestUSDCSellRepoToken.t.sol b/src/test/TestUSDCSellRepoToken.t.sol index ebc7358f..b3680c86 100644 --- a/src/test/TestUSDCSellRepoToken.t.sol +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -41,14 +41,24 @@ contract TestUSDCSellRepoToken is Setup { ); termStrategy = Strategy(address(strategy)); - // start with some initial funds - mockUSDC.mint(address(strategy), 100e6); + 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; @@ -59,7 +69,7 @@ contract TestUSDCSellRepoToken is Setup { vm.prank(testUser); repoToken1Week.approve(address(strategy), type(uint256).max); - termController.setOracleRate(repoToken1Week.termRepoId(), 1.05e18); + termController.setOracleRate(repoToken1Week.termRepoId(), 0.05e18); vm.startPrank(management); termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); @@ -69,7 +79,9 @@ contract TestUSDCSellRepoToken is Setup { vm.prank(testUser); termStrategy.sellRepoToken(address(repoToken1Week), repoTokenSellAmount); - uint256 expectedProceeds = termStrategy.calculateRepoTokenPresentValue(address(repoToken1Week), repoTokenSellAmount); + uint256 expectedProceeds = termStrategy.calculateRepoTokenPresentValue( + address(repoToken1Week), 0.05e18, repoTokenSellAmount + ); assertEq(mockUSDC.balanceOf(testUser), expectedProceeds); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - expectedProceeds); @@ -79,6 +91,7 @@ contract TestUSDCSellRepoToken is Setup { (uint256 redemptionTimestamp, , ,) = ITermRepoToken(repoToken1Week).config(); + // TODO: validate this math (weighted time to maturity) uint256 repoTokenBalanceInBaseAssetPrecision = (ITermRepoToken(repoToken1Week).redemptionValue() * repoTokenSellAmount * 1e6) / (1e18 * 1e18); uint256 cumulativeWeightedTimeToMaturity = @@ -86,22 +99,156 @@ contract TestUSDCSellRepoToken is Setup { uint256 expectedWeightedTimeToMaturity = cumulativeWeightedTimeToMaturity / (repoTokenBalanceInBaseAssetPrecision + termStrategy.totalLiquidBalance()); - console.log("repoTokenBalanceInBaseAssetPrecision", repoTokenBalanceInBaseAssetPrecision); - console.log("cumulativeWeightedTimeToMaturity", cumulativeWeightedTimeToMaturity); - console.log("totalLiquidBalance", termStrategy.totalLiquidBalance()); - console.log("redemptionTimestamp", redemptionTimestamp); - console.log("weightedTimeToMat", weightedTimeToMaturity); - assertEq(weightedTimeToMaturity, expectedWeightedTimeToMaturity); } - // - function testSellMultipleRepoTokens() public { + // 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, ""); + } + + 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, err); } - function testSellMultipleRepoTokensMultipleUsers() public { + 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, ""); + } + + 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, 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); + 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); } function testSetGovernanceParameters() public { @@ -110,6 +257,10 @@ contract TestUSDCSellRepoToken is Setup { 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)); @@ -144,7 +295,9 @@ contract TestUSDCSellRepoToken is Setup { } function testRepoTokenValidationFailures() public { + // start with some initial funds mockUSDC.mint(address(strategy), 100e6); + _initState(); address testUser = vm.addr(0x11111); @@ -159,7 +312,10 @@ contract TestUSDCSellRepoToken is Setup { termController.setOracleRate(repoToken1Week.termRepoId(), 1.05e18); termController.setOracleRate(repoTokenMatured.termRepoId(), 1.05e18); - // test: min collaterl ratio not set + 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); @@ -176,10 +332,21 @@ contract TestUSDCSellRepoToken is Setup { } function testAboveMaturityThresholdFailure() public { + _sell1RepoToken(repoToken2Week, 2e18); - } + uint256 timeToMat = termStrategy.simulateWeightedTimeToMaturity(address(0), 0); + + vm.prank(management); + termStrategy.setTimeToMaturityThreshold(timeToMat); - function testBelowLiquidityThresholdFailure() public { + // 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 testRemoveMaturedRepoTokens() public {} + + function testBelowLiquidityThresholdFailure() public {} } diff --git a/src/test/mocks/MockTermAuction.sol b/src/test/mocks/MockTermAuction.sol index 82435919..3f17afda 100644 --- a/src/test/mocks/MockTermAuction.sol +++ b/src/test/mocks/MockTermAuction.sol @@ -5,6 +5,7 @@ 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 { @@ -13,9 +14,11 @@ contract MockTermAuction is ITermAuction { uint256 public auctionEndTime; bool public auctionCompleted; bool public auctionCancelledForWithdrawal; + ITermRepoToken internal repoToken; constructor(ITermRepoToken _repoToken) { termRepoId = _repoToken.termRepoId(); + repoToken = _repoToken; ( uint256 redemptionTimestamp, address purchaseToken, @@ -28,13 +31,21 @@ contract MockTermAuction is ITermAuction { termRepoServicer, purchaseToken )); + auctionEndTime = block.timestamp + 1 weeks; } - function startAuction(uint256 duration) external { - auctionEndTime = block.timestamp + duration; - } + function auctionSuccess(bytes32[] calldata offerIds, uint256[] calldata fillAmounts, uint256[] calldata repoTokenAmounts) external { + auctionCompleted = true; + auctionEndTime = block.timestamp; - function clearAuction() external { - + 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 index bacab7ef..e749e05e 100644 --- a/src/test/mocks/MockTermAuctionOfferLocker.sol +++ b/src/test/mocks/MockTermAuctionOfferLocker.sol @@ -4,11 +4,13 @@ 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; @@ -23,6 +25,7 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { purchaseToken = _purchaseToken; termRepoServicer = _repoServicer; repoLocker = MockTermRepoLocker(_repoLocker); + auctionStartTime = block.timestamp; } function termRepoId() external view returns (bytes32) { @@ -33,10 +36,6 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { return auction.termRepoId(); } - function auctionStartTime() external view returns (uint256) { - - } - function auctionEndTime() external view returns (uint256) { return auction.auctionEndTime(); } @@ -82,13 +81,14 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { } } - function fillOffer(bytes32 offerId, address receiver, uint256 fillAmount) external { + 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; - repoLocker.releasePurchaseTokens(receiver, remainingAmount); + mockRepoToken.mint(lockedOffers[offerId].offeror, repoTokenAmount); + repoLocker.releasePurchaseTokens(lockedOffers[offerId].offeror, remainingAmount); } function unlockOffers(bytes32[] calldata offerIds) external { From d82d38795ecd817dd51971ba471f6f7c14daa283 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 22 May 2024 17:53:55 -0700 Subject: [PATCH 18/30] adding more checks --- src/test/TestUSDCOffers.t.sol | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol index 907fbf82..c6800975 100644 --- a/src/test/TestUSDCOffers.t.sol +++ b/src/test/TestUSDCOffers.t.sol @@ -139,11 +139,26 @@ contract TestUSDCSubmitOffer is Setup { repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); - // test: check value before calling complete auction + // 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(); - bytes32[] memory offers = termStrategy.pendingOffers(); + // 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); } @@ -162,23 +177,24 @@ contract TestUSDCSubmitOffer is Setup { 0.5e6, repoToken1Week, TEST_REPO_TOKEN_RATE ); - console.log("before", termStrategy.totalAssetValue()); - 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); - // test: check value before calling complete auction termStrategy.auctionClosed(); - console.log("after", termStrategy.totalAssetValue()); - + // 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)); From 63a9dee20d60bd62b584ea859741b11372053466 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 5 Jun 2024 17:26:28 -0700 Subject: [PATCH 19/30] adding auth to event emitter --- .gitignore | 4 +++- src/TermVaultEventEmitter.sol | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) 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/src/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol index 66a7ad85..bb427eb6 100644 --- a/src/TermVaultEventEmitter.sol +++ b/src/TermVaultEventEmitter.sol @@ -35,23 +35,23 @@ contract TermVaultEventEmitter is Initializable, UUPSUpgradeable, AccessControlU _grantRole(VAULT_CONTRACT, vaultContract); } - function emitTermControllerUpdated(address oldController, address newController) external { + function emitTermControllerUpdated(address oldController, address newController) external onlyRole(VAULT_CONTRACT) { emit TermControllerUpdated(oldController, newController); } - function emitTimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external { + function emitTimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external onlyRole(VAULT_CONTRACT) { emit TimeToMaturityThresholdUpdated(oldThreshold, newThreshold); } - function emitLiquidityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external { + function emitLiquidityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external onlyRole(VAULT_CONTRACT) { emit LiquidityThresholdUpdated(oldThreshold, newThreshold); } - function emitAuctionRateMarkupUpdated(uint256 oldMarkup, uint256 newMarkup) external { + function emitAuctionRateMarkupUpdated(uint256 oldMarkup, uint256 newMarkup) external onlyRole(VAULT_CONTRACT) { emit AuctionRateMarkupUpdated(oldMarkup, newMarkup); } - function emitMinCollateralRatioUpdated(address collateral, uint256 minCollateralRatio) external { + function emitMinCollateralRatioUpdated(address collateral, uint256 minCollateralRatio) external onlyRole(VAULT_CONTRACT) { emit MinCollateralRatioUpdated(collateral, minCollateralRatio); } From fea769d72e5a9757cae3602c5eefdf4a63eb6880 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Mon, 17 Jun 2024 21:38:42 -0700 Subject: [PATCH 20/30] adding repo token redemption tests --- src/RepoTokenList.sol | 43 ++++++++++++++++--------- src/Strategy.sol | 35 +++++++++++++------- src/interfaces/term/ITermController.sol | 7 +--- src/test/TestUSDCSellRepoToken.t.sol | 42 ++++++++++++++++++++++-- src/test/mocks/MockTermController.sol | 11 +++++-- src/test/mocks/MockTermRepoServicer.sol | 21 +++++++----- src/test/utils/Setup.sol | 20 ++++++++---- 7 files changed, 127 insertions(+), 52 deletions(-) diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index d0877fd1..48735def 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -5,7 +5,7 @@ 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, TermAuctionResults} from "./interfaces/term/ITermController.sol"; +import {ITermController, AuctionMetadata} from "./interfaces/term/ITermController.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {RepoTokenUtils} from "./RepoTokenUtils.sol"; @@ -138,21 +138,34 @@ library RepoTokenList { while (current != NULL_NODE) { address next; if (_getRepoTokenMaturity(current) < block.timestamp) { - next = _getNext(listData, current); - - if (current == listData.head) { - listData.head = next; - } - - listData.nodes[prev].next = next; - delete listData.nodes[current]; - delete listData.auctionRates[current]; - + bool removeMaturedToken; uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); if (repoTokenBalance > 0) { (, , address termRepoServicer,) = ITermRepoToken(current).config(); - ITermRepoServicer(termRepoServicer).redeemTermRepoTokens(address(this), repoTokenBalance); + 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 @@ -165,15 +178,15 @@ library RepoTokenList { } function _auctionRate(ITermController termController, ITermRepoToken repoToken) private view returns (uint256) { - TermAuctionResults memory results = termController.getTermAuctionResults(repoToken.termRepoId()); + (AuctionMetadata[] memory auctionMetadata, ) = termController.getTermAuctionResults(repoToken.termRepoId()); - uint256 len = results.auctionMetadata.length; + uint256 len = auctionMetadata.length; if (len == 0) { revert InvalidRepoToken(address(repoToken)); } - return results.auctionMetadata[len - 1].auctionClearingRate; + return auctionMetadata[len - 1].auctionClearingRate; } function validateRepoToken( diff --git a/src/Strategy.sol b/src/Strategy.sol index c6bb6fe6..4b1d225e 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -128,12 +128,20 @@ contract Strategy is BaseStrategy { return _assetBalance() + underlyingBalance; } - function _sweepAssetAndRedeemRepoTokens() private { + function _sweepAssetAndRedeemRepoTokens(uint256 liquidAmountRequired) private { + termAuctionListData.removeCompleted(repoTokenListData, termController, address(asset)); + repoTokenListData.removeAndRedeemMaturedTokens(); + uint256 underlyingBalance = IERC20(asset).balanceOf(address(this)); - if (underlyingBalance > 0) { - YEARN_VAULT.deposit(underlyingBalance, address(this)); + if (underlyingBalance > liquidAmountRequired) { + unchecked { + YEARN_VAULT.deposit(underlyingBalance - liquidAmountRequired, address(this)); + } + } else if (underlyingBalance < liquidAmountRequired) { + unchecked { + _withdrawAsset(liquidAmountRequired - underlyingBalance); + } } - repoTokenListData.removeAndRedeemMaturedTokens(); } function _withdrawAsset(uint256 amount) private { @@ -154,7 +162,7 @@ contract Strategy is BaseStrategy { address(asset) ); - _sweepAssetAndRedeemRepoTokens(); + _sweepAssetAndRedeemRepoTokens(0); uint256 liquidBalance = _totalLiquidBalance(address(this)); require(liquidBalance > 0); @@ -207,7 +215,7 @@ contract Strategy is BaseStrategy { termAuctionListData.removeCompleted(repoTokenListData, termController, address(asset)); - _sweepAssetAndRedeemRepoTokens(); + _sweepAssetAndRedeemRepoTokens(0); } function submitAuctionOffer( @@ -237,7 +245,7 @@ contract Strategy is BaseStrategy { "Auction not open" ); - _sweepAssetAndRedeemRepoTokens(); //@dev sweep to ensure liquid balances up to date + _sweepAssetAndRedeemRepoTokens(0); //@dev sweep to ensure liquid balances up to date uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); uint256 offerAmount = RepoTokenUtils.purchaseToRepoPrecision( @@ -314,9 +322,7 @@ contract Strategy is BaseStrategy { } function auctionClosed() external { - termAuctionListData.removeCompleted(repoTokenListData, termController, address(asset)); - - _sweepAssetAndRedeemRepoTokens(); + _sweepAssetAndRedeemRepoTokens(0); } function totalAssetValue() external view returns (uint256) { @@ -361,7 +367,9 @@ 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 { } + function _deployFunds(uint256 _amount) internal override { + _sweepAssetAndRedeemRepoTokens(0); + } /** * @dev Should attempt to free the '_amount' of 'asset'. @@ -384,7 +392,9 @@ contract Strategy is BaseStrategy { * * @param _amount, The amount of 'asset' to be freed. */ - function _freeFunds(uint256 _amount) internal override { } + function _freeFunds(uint256 _amount) internal override { + _sweepAssetAndRedeemRepoTokens(_amount); + } /** * @dev Internal function to harvest all rewards, redeploy any idle @@ -413,6 +423,7 @@ contract Strategy is BaseStrategy { override returns (uint256 _totalAssets) { + _sweepAssetAndRedeemRepoTokens(0); return _totalAssetValue(); } diff --git a/src/interfaces/term/ITermController.sol b/src/interfaces/term/ITermController.sol index 5d30def5..7077cfaa 100644 --- a/src/interfaces/term/ITermController.sol +++ b/src/interfaces/term/ITermController.sol @@ -7,13 +7,8 @@ struct AuctionMetadata { uint256 auctionClearingBlockTimestamp; } -struct TermAuctionResults { - AuctionMetadata[] auctionMetadata; - uint8 numOfAuctions; -} - interface ITermController { function isTermDeployed(address contractAddress) external view returns (bool); - function getTermAuctionResults(bytes32 termRepoId) external view returns (TermAuctionResults memory); + function getTermAuctionResults(bytes32 termRepoId) external view returns (AuctionMetadata[] memory auctionMetadata, uint8 numOfAuctions); } diff --git a/src/test/TestUSDCSellRepoToken.t.sol b/src/test/TestUSDCSellRepoToken.t.sol index b3680c86..c3193e6a 100644 --- a/src/test/TestUSDCSellRepoToken.t.sol +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -2,6 +2,8 @@ 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 {MockUSDC} from "./mocks/MockUSDC.sol"; @@ -346,7 +348,43 @@ contract TestUSDCSellRepoToken is Setup { _sell1RepoToken(repoToken1Week, 4e18); } - function testRemoveMaturedRepoTokens() public {} + 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(); + + _sell1RepoToken(repoToken2Week, 2e18); + + address[] memory holdings = termStrategy.repoTokenHoldings(); + + assertEq(holdings.length, 1); + + vm.warp(block.timestamp + 3 weeks); - function testBelowLiquidityThresholdFailure() public {} + 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 { + + } } diff --git a/src/test/mocks/MockTermController.sol b/src/test/mocks/MockTermController.sol index 47be58d5..c1afd406 100644 --- a/src/test/mocks/MockTermController.sol +++ b/src/test/mocks/MockTermController.sol @@ -1,7 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; -import {ITermController, AuctionMetadata, TermAuctionResults} from "../../interfaces/term/ITermController.sol"; +import {ITermController, AuctionMetadata} from "../../interfaces/term/ITermController.sol"; + +struct TermAuctionResults { + AuctionMetadata[] auctionMetadata; + uint8 numOfAuctions; +} contract MockTermController is ITermController { mapping(bytes32 => TermAuctionResults) internal auctionResults; @@ -20,7 +25,7 @@ contract MockTermController is ITermController { auctionResults[termRepoId].numOfAuctions = 1; } - function getTermAuctionResults(bytes32 termRepoId) external view returns (TermAuctionResults memory) { - return auctionResults[termRepoId]; + 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/MockTermRepoServicer.sol b/src/test/mocks/MockTermRepoServicer.sol index ab7fb352..bb802e69 100644 --- a/src/test/mocks/MockTermRepoServicer.sol +++ b/src/test/mocks/MockTermRepoServicer.sol @@ -5,20 +5,30 @@ 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 decimals() external view returns (uint256); +} + contract MockTermRepoServicer is ITermRepoServicer { ITermRepoToken internal repoToken; MockTermRepoLocker internal repoLocker; + address public purchaseToken; - constructor(ITermRepoToken _repoToken, address purchaseToken) { + constructor(ITermRepoToken _repoToken, address _purchaseToken) { repoToken = _repoToken; - repoLocker = new MockTermRepoLocker(purchaseToken); + repoLocker = new MockTermRepoLocker(_purchaseToken); + purchaseToken = _purchaseToken; } function redeemTermRepoTokens( address redeemer, uint256 amountToRedeem ) external { - + uint256 amountToRedeemInAssetPrecision = + amountToRedeem * (10**IMockERC20(purchaseToken).decimals()) / + (10**IMockERC20(address(repoToken)).decimals()); + IMockERC20(purchaseToken).mint(redeemer, amountToRedeemInAssetPrecision); } function termRepoToken() external view returns (address) { @@ -28,9 +38,4 @@ contract MockTermRepoServicer is ITermRepoServicer { function termRepoLocker() external view returns (address) { return address(repoLocker); } - - function purchaseToken() external view returns (address) { - (, address purchaseToken, ,) = repoToken.config(); - return purchaseToken; - } } diff --git a/src/test/utils/Setup.sol b/src/test/utils/Setup.sol index 2dfcc01e..f9cbd9e5 100644 --- a/src/test/utils/Setup.sol +++ b/src/test/utils/Setup.sol @@ -13,6 +13,7 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s 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"; @@ -63,12 +64,14 @@ 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 termController; - TermVaultEventEmitter termVaultEventEmitterImpl; - TermVaultEventEmitter termVaultEventEmitter; - ERC4626Mock mockYearnVault; - TokenizedStrategy tokenizedStrategy; + MockTermController internal termController; + TermVaultEventEmitter internal termVaultEventEmitterImpl; + TermVaultEventEmitter internal termVaultEventEmitter; + ERC4626Mock internal mockYearnVault; + TokenizedStrategy internal tokenizedStrategy; function setUp() public virtual { _setTokenAddrs(); @@ -83,8 +86,10 @@ contract Setup is ExtendedTest, IEvents { // 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(0x444045c5C13C246e117eD36437303cac8E250aB0); + tokenizedStrategy = new TokenizedStrategy(address(mockFactory)); vm.etch(0xBB51273D6c746910C7C06fe718f30c936170feD0, address(tokenizedStrategy).code); termController = new MockTermController(); @@ -114,6 +119,9 @@ contract Setup is ExtendedTest, IEvents { 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 From d0e9b50c1f5a83d6aa3b47a16cba784e520e6527 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 19 Jun 2024 17:50:39 -0700 Subject: [PATCH 21/30] fixing tests --- src/test/TestUSDCSellRepoToken.t.sol | 105 ++++++++++++++++++++++-- src/test/mocks/MockTermRepoServicer.sol | 8 ++ src/test/mocks/MockTermRepoToken.sol | 6 +- 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/src/test/TestUSDCSellRepoToken.t.sol b/src/test/TestUSDCSellRepoToken.t.sol index c3193e6a..bbae1946 100644 --- a/src/test/TestUSDCSellRepoToken.t.sol +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -139,7 +139,16 @@ contract TestUSDCSellRepoToken is Setup { uint256[] memory amounts = new uint256[](1); amounts[0] = amount1; - _sellRepoTokens(tokens, amounts, false, ""); + _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 { @@ -148,7 +157,7 @@ contract TestUSDCSellRepoToken is Setup { uint256[] memory amounts = new uint256[](1); amounts[0] = amount1; - _sellRepoTokens(tokens, amounts, true, err); + _sellRepoTokens(tokens, amounts, true, true, err); } function _sell3RepoTokens( @@ -168,7 +177,7 @@ contract TestUSDCSellRepoToken is Setup { amounts[1] = amount2; amounts[2] = amount3; - _sellRepoTokens(tokens, amounts, false, ""); + _sellRepoTokens(tokens, amounts, false, true, ""); } function _sell3RepoTokensCheckHoldings() private { @@ -183,7 +192,7 @@ contract TestUSDCSellRepoToken is Setup { assertEq(holdings[2], address(repoToken4Week)); } - function _sellRepoTokens(address[] memory tokens, uint256[] memory amounts, bool expectRevert, bytes memory err) private { + 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++) { @@ -193,10 +202,12 @@ contract TestUSDCSellRepoToken is Setup { termController.setOracleRate(MockTermRepoToken(token).termRepoId(), 0.05e18); MockTermRepoToken(token).mint(testUser, amount); - mockUSDC.mint( - address(strategy), - termStrategy.calculateRepoTokenPresentValue(token, 0.05e18, amount) - ); + if (mintUnderlying) { + mockUSDC.mint( + address(strategy), + termStrategy.calculateRepoTokenPresentValue(token, 0.05e18, amount) + ); + } vm.startPrank(testUser); MockTermRepoToken(token).approve(address(strategy), type(uint256).max); @@ -360,7 +371,7 @@ contract TestUSDCSellRepoToken is Setup { IERC4626(address(termStrategy)).deposit(depositAmount, testDepositor); vm.stopPrank(); - _sell1RepoToken(repoToken2Week, 2e18); + _sell1RepoTokenNoMint(repoToken2Week, 2e18); address[] memory holdings = termStrategy.repoTokenHoldings(); @@ -385,6 +396,82 @@ contract TestUSDCSellRepoToken is Setup { } 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/MockTermRepoServicer.sol b/src/test/mocks/MockTermRepoServicer.sol index bb802e69..0e68ff54 100644 --- a/src/test/mocks/MockTermRepoServicer.sol +++ b/src/test/mocks/MockTermRepoServicer.sol @@ -7,6 +7,7 @@ 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); } @@ -14,6 +15,7 @@ contract MockTermRepoServicer is ITermRepoServicer { ITermRepoToken internal repoToken; MockTermRepoLocker internal repoLocker; address public purchaseToken; + bool public redemptionFailure; constructor(ITermRepoToken _repoToken, address _purchaseToken) { repoToken = _repoToken; @@ -21,14 +23,20 @@ contract MockTermRepoServicer is ITermRepoServicer { 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) { diff --git a/src/test/mocks/MockTermRepoToken.sol b/src/test/mocks/MockTermRepoToken.sol index 98d7e1e6..31689c7f 100644 --- a/src/test/mocks/MockTermRepoToken.sol +++ b/src/test/mocks/MockTermRepoToken.sol @@ -18,8 +18,6 @@ contract MockTermRepoToken is ERC20, ITermRepoToken { bytes32 public termRepoId; RepoTokenContext internal repoTokenContext; - address internal repoServicer; - address internal collateralManager; constructor( bytes32 _termRepoId, @@ -48,6 +46,10 @@ contract MockTermRepoToken is ERC20, ITermRepoToken { function burn(address account, uint256 amount) external { _burn(account, amount); } + + function mockServicer() external returns (MockTermRepoServicer) { + return MockTermRepoServicer(address(repoTokenContext.termRepoServicer)); + } function config() external From 2723b3d875d00ad377d74bfb3b64595dddda2ad5 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 26 Jun 2024 18:43:38 -0700 Subject: [PATCH 22/30] fix edit offer --- src/Strategy.sol | 68 +++++++++++++------ src/test/TestUSDCOffers.t.sol | 51 ++++++++------ src/test/mocks/MockTermAuctionOfferLocker.sol | 5 +- 3 files changed, 83 insertions(+), 41 deletions(-) diff --git a/src/Strategy.sol b/src/Strategy.sol index 4b1d225e..07c7b68e 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; -import "forge-std/console.sol"; import {BaseStrategy, ERC20} from "@tokenized-strategy/BaseStrategy.sol"; import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; @@ -218,6 +217,16 @@ contract Strategy is BaseStrategy { _sweepAssetAndRedeemRepoTokens(0); } + function _generateOfferId( + bytes32 id, + address user, + address offerLocker + ) internal view returns (bytes32) { + return keccak256( + abi.encodePacked(id, user, offerLocker) + ); + } + function submitAuctionOffer( address termAuction, address repoToken, @@ -247,28 +256,38 @@ contract Strategy is BaseStrategy { _sweepAssetAndRedeemRepoTokens(0); //@dev sweep to ensure liquid balances up to date - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 offerAmount = RepoTokenUtils.purchaseToRepoPrecision( - repoTokenPrecision, PURCHASE_TOKEN_PRECISION, purchaseTokenAmount - ); uint256 liquidBalance = _totalLiquidBalance(address(this)); - - if (liquidBalance < purchaseTokenAmount) { - revert InsufficientLiquidBalance(liquidBalance, purchaseTokenAmount); + uint256 actualPurchaseTokenAmount = purchaseTokenAmount; + bytes32 offerId = _generateOfferId(idHash, address(this), address(offerLocker)); + uint256 currentOfferAmount = termAuctionListData.offers[offerId].offerAmount; + if (actualPurchaseTokenAmount > currentOfferAmount) { + unchecked { + actualPurchaseTokenAmount -= currentOfferAmount; + } } - uint256 resultingWeightedTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( - repoToken, offerAmount, liquidBalance - purchaseTokenAmount - ); - - if (resultingWeightedTimeToMaturity > timeToMaturityThreshold) { - revert TimeToMaturityAboveThreshold(); + if (liquidBalance < actualPurchaseTokenAmount) { + revert InsufficientLiquidBalance(liquidBalance, actualPurchaseTokenAmount); } - if ((liquidBalance - purchaseTokenAmount) < liquidityThreshold) { + if ((liquidBalance - actualPurchaseTokenAmount) < liquidityThreshold) { revert BalanceBelowLiquidityThreshold(); } + { + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 offerAmount = RepoTokenUtils.purchaseToRepoPrecision( + repoTokenPrecision, PURCHASE_TOKEN_PRECISION, purchaseTokenAmount + ); + uint256 resultingWeightedTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( + repoToken, offerAmount, liquidBalance - actualPurchaseTokenAmount + ); + + if (resultingWeightedTimeToMaturity > timeToMaturityThreshold) { + revert TimeToMaturityAboveThreshold(); + } + } + ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer; offer.id = idHash; @@ -277,14 +296,23 @@ contract Strategy is BaseStrategy { offer.amount = purchaseTokenAmount; offer.purchaseToken = address(asset); - offerIds = _submitOffer(auction, offerLocker, offer, repoToken); + offerIds = _submitOffer( + auction, + offerLocker, + offer, + repoToken, + actualPurchaseTokenAmount, + currentOfferAmount == 0 + ); } function _submitOffer( ITermAuction auction, ITermAuctionOfferLocker offerLocker, ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer, - address repoToken + address repoToken, + uint256 actualPurchaseTokenAmount, + bool newOffer ) private returns (bytes32[] memory offerIds) { ITermRepoServicer repoServicer = ITermRepoServicer(offerLocker.termRepoServicer()); @@ -292,15 +320,15 @@ contract Strategy is BaseStrategy { new ITermAuctionOfferLocker.TermAuctionOfferSubmission[](1); offerSubmissions[0] = offer; - _withdrawAsset(offer.amount); + _withdrawAsset(actualPurchaseTokenAmount); - ERC20(asset).approve(address(repoServicer.termRepoLocker()), offer.amount); + IERC20(asset).safeApprove(address(repoServicer.termRepoLocker()), actualPurchaseTokenAmount); offerIds = offerLocker.lockOffers(offerSubmissions); require(offerIds.length > 0); - if (termAuctionListData.offers[offerIds[0]].offerId == bytes32(0)) { + if (newOffer) { // new offer termAuctionListData.insertPending(PendingOffer({ offerId: offerIds[0], diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol index c6800975..2e759967 100644 --- a/src/test/TestUSDCOffers.t.sol +++ b/src/test/TestUSDCOffers.t.sol @@ -1,6 +1,6 @@ pragma solidity ^0.8.18; -import "forge-std/console.sol"; +import "forge-std/console2.sol"; import {ERC20Mock} from "@openzeppelin/contracts/mocks/ERC20Mock.sol"; import {MockTermRepoToken} from "./mocks/MockTermRepoToken.sol"; import {MockTermAuction} from "./mocks/MockTermAuction.sol"; @@ -45,24 +45,25 @@ contract TestUSDCSubmitOffer is Setup { initialState.totalLiquidBalance = termStrategy.totalLiquidBalance(); } - function _submitOffer(bytes32 offerId, uint256 offerAmount) private { + function _submitOffer(bytes32 idHash, uint256 offerAmount) private returns (bytes32) { // test: only management can submit offers vm.expectRevert("!management"); bytes32[] memory offerIds = termStrategy.submitAuctionOffer( - address(repoToken1WeekAuction), address(repoToken1Week), offerId, bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), idHash, bytes32("test price"), offerAmount ); vm.prank(management); offerIds = termStrategy.submitAuctionOffer( - address(repoToken1WeekAuction), address(repoToken1Week), offerId, bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), idHash, bytes32("test price"), offerAmount ); assertEq(offerIds.length, 1); - assertEq(offerIds[0], offerId); + + return offerIds[0]; } function testSubmitOffer() public { - _submitOffer(bytes32("offer id 1"), 1e6); + _submitOffer(bytes32("offer id hash 1"), 1e6); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - 1e6); // test: totalAssetValue = total liquid balance + pending offer amount @@ -70,14 +71,15 @@ contract TestUSDCSubmitOffer is Setup { } function testEditOffer() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 idHash1 = bytes32("offer id hash 1"); + bytes32 offerId1 = _submitOffer(idHash1, 1e6); // TODO: fuzz this uint256 offerAmount = 4e6; vm.prank(management); bytes32[] memory offerIds = termStrategy.submitAuctionOffer( - address(repoToken1WeekAuction), address(repoToken1Week), bytes32("offer id 1"), bytes32("test price"), offerAmount + address(repoToken1WeekAuction), address(repoToken1Week), idHash1, bytes32("test price"), offerAmount ); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - offerAmount); @@ -86,11 +88,11 @@ contract TestUSDCSubmitOffer is Setup { } function testDeleteOffers() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); bytes32[] memory offerIds = new bytes32[](1); - offerIds[0] = bytes32("offer id 1"); + offerIds[0] = offerId1; vm.expectRevert("!management"); termStrategy.deleteAuctionOffers(address(repoToken1WeekAuction), offerIds); @@ -126,10 +128,10 @@ contract TestUSDCSubmitOffer is Setup { } function testCompleteAuctionSuccessFull() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); bytes32[] memory offerIds = new bytes32[](1); - offerIds[0] = bytes32("offer id 1"); + offerIds[0] = offerId1; uint256[] memory fillAmounts = new uint256[](1); fillAmounts[0] = 1e6; uint256[] memory repoTokenAmounts = new uint256[](1); @@ -164,10 +166,10 @@ contract TestUSDCSubmitOffer is Setup { } function testCompleteAuctionSuccessPartial() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id 1"), 1e6); bytes32[] memory offerIds = new bytes32[](1); - offerIds[0] = bytes32("offer id 1"); + offerIds[0] = offerId1; uint256[] memory fillAmounts = new uint256[](1); // test: 50% filled @@ -204,7 +206,7 @@ contract TestUSDCSubmitOffer is Setup { } function testCompleteAuctionCanceled() public { - _submitOffer(bytes32("offer id 1"), 1e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); repoToken1WeekAuction.auctionCanceled(); @@ -217,8 +219,8 @@ contract TestUSDCSubmitOffer is Setup { } function testMultipleOffers() public { - _submitOffer(bytes32("offer id 1"), 1e6); - _submitOffer(bytes32("offer id 2"), 5e6); + bytes32 offerId1 = _submitOffer(bytes32("offer id hash 1"), 1e6); + bytes32 offerId2 = _submitOffer(bytes32("offer id hash 2"), 5e6); assertEq(termStrategy.totalLiquidBalance(), initialState.totalLiquidBalance - 6e6); // test: totalAssetValue = total liquid balance + pending offer amount @@ -227,7 +229,18 @@ contract TestUSDCSubmitOffer is Setup { bytes32[] memory offers = termStrategy.pendingOffers(); assertEq(offers.length, 2); - assertEq(offers[0], bytes32("offer id 2")); - assertEq(offers[1], bytes32("offer id 1")); + assertEq(offers[0], offerId2); + assertEq(offers[1], offerId1); + } + + function 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); } } diff --git a/src/test/mocks/MockTermAuctionOfferLocker.sol b/src/test/mocks/MockTermAuctionOfferLocker.sol index e749e05e..2c5213cc 100644 --- a/src/test/mocks/MockTermAuctionOfferLocker.sol +++ b/src/test/mocks/MockTermAuctionOfferLocker.sol @@ -55,7 +55,8 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { for (uint256 i; i < offerSubmissions.length; i++) { TermAuctionOfferSubmission memory submission = offerSubmissions[i]; - TermAuctionOffer memory offer = lockedOffers[submission.id]; + bytes32 offerId = keccak256(abi.encodePacked(submission.id,msg.sender,address(this))); + TermAuctionOffer memory offer = lockedOffers[offerId]; // existing offer if (offer.amount > 0) { @@ -68,7 +69,7 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { // update locked amount offer.amount = submission.amount; } else { - offer.id = submission.id; + offer.id = offerId; offer.offeror = submission.offeror; offer.offerPriceHash = submission.offerPriceHash; offer.amount = submission.amount; From 7168068aeb908acc1107164e4c1a47cca4dca807 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 26 Jun 2024 19:21:05 -0700 Subject: [PATCH 23/30] fixing edit offer --- src/Strategy.sol | 91 +++++++++++++++++++++-------------- src/test/TestUSDCOffers.t.sol | 11 +++++ 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/src/Strategy.sol b/src/Strategy.sol index 07c7b68e..4964b3c2 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -219,14 +219,31 @@ contract Strategy is BaseStrategy { function _generateOfferId( bytes32 id, - address user, address offerLocker ) internal view returns (bytes32) { return keccak256( - abi.encodePacked(id, user, offerLocker) + 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 = _removeRedeemAndCalculateWeightedMaturity( + repoToken, offerAmountInRepoPrecision, newLiquidBalance + ); + + if (resultingWeightedTimeToMaturity > timeToMaturityThreshold) { + revert TimeToMaturityAboveThreshold(); + } + } + function submitAuctionOffer( address termAuction, address repoToken, @@ -257,35 +274,33 @@ contract Strategy is BaseStrategy { _sweepAssetAndRedeemRepoTokens(0); //@dev sweep to ensure liquid balances up to date uint256 liquidBalance = _totalLiquidBalance(address(this)); - uint256 actualPurchaseTokenAmount = purchaseTokenAmount; - bytes32 offerId = _generateOfferId(idHash, address(this), address(offerLocker)); + uint256 newOfferAmount = purchaseTokenAmount; + bytes32 offerId = _generateOfferId(idHash, address(offerLocker)); uint256 currentOfferAmount = termAuctionListData.offers[offerId].offerAmount; - if (actualPurchaseTokenAmount > currentOfferAmount) { + if (newOfferAmount > currentOfferAmount) { + uint256 offerDebit; unchecked { - actualPurchaseTokenAmount -= currentOfferAmount; + // checked above + offerDebit = newOfferAmount - currentOfferAmount; } - } - - if (liquidBalance < actualPurchaseTokenAmount) { - revert InsufficientLiquidBalance(liquidBalance, actualPurchaseTokenAmount); - } - - if ((liquidBalance - actualPurchaseTokenAmount) < liquidityThreshold) { - revert BalanceBelowLiquidityThreshold(); - } - - { - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 offerAmount = RepoTokenUtils.purchaseToRepoPrecision( - repoTokenPrecision, PURCHASE_TOKEN_PRECISION, purchaseTokenAmount - ); - uint256 resultingWeightedTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( - repoToken, offerAmount, liquidBalance - actualPurchaseTokenAmount - ); - - if (resultingWeightedTimeToMaturity > timeToMaturityThreshold) { - revert TimeToMaturityAboveThreshold(); + if (liquidBalance < offerDebit) { + revert InsufficientLiquidBalance(liquidBalance, offerDebit); + } + uint256 newLiquidBalance = liquidBalance - offerDebit; + if (newLiquidBalance < liquidityThreshold) { + revert BalanceBelowLiquidityThreshold(); + } + _validateWeightedMaturity(repoToken, newOfferAmount, newLiquidBalance); + } else { + uint256 offerCredit; + unchecked { + offerCredit = currentOfferAmount - newOfferAmount; + } + uint256 newLiquidBalance = liquidBalance + offerCredit; + if (newLiquidBalance < liquidityThreshold) { + revert BalanceBelowLiquidityThreshold(); } + _validateWeightedMaturity(repoToken, newOfferAmount, newLiquidBalance); } ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer; @@ -301,8 +316,8 @@ contract Strategy is BaseStrategy { offerLocker, offer, repoToken, - actualPurchaseTokenAmount, - currentOfferAmount == 0 + newOfferAmount, + currentOfferAmount ); } @@ -311,8 +326,8 @@ contract Strategy is BaseStrategy { ITermAuctionOfferLocker offerLocker, ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer, address repoToken, - uint256 actualPurchaseTokenAmount, - bool newOffer + uint256 newOfferAmount, + uint256 currentOfferAmount ) private returns (bytes32[] memory offerIds) { ITermRepoServicer repoServicer = ITermRepoServicer(offerLocker.termRepoServicer()); @@ -320,15 +335,21 @@ contract Strategy is BaseStrategy { new ITermAuctionOfferLocker.TermAuctionOfferSubmission[](1); offerSubmissions[0] = offer; - _withdrawAsset(actualPurchaseTokenAmount); - - IERC20(asset).safeApprove(address(repoServicer.termRepoLocker()), actualPurchaseTokenAmount); + 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 (newOffer) { + if (currentOfferAmount == 0) { // new offer termAuctionListData.insertPending(PendingOffer({ offerId: offerIds[0], diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol index 2e759967..4374cb47 100644 --- a/src/test/TestUSDCOffers.t.sol +++ b/src/test/TestUSDCOffers.t.sol @@ -243,4 +243,15 @@ contract TestUSDCSubmitOffer is Setup { 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); + } } From df9772658203b55f1f1437edd167f286b0cbfc41 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 26 Jun 2024 20:44:09 -0700 Subject: [PATCH 24/30] adding pending offers to weighted maturity math --- src/RepoTokenList.sol | 47 +++++++++++++------------- src/Strategy.sol | 50 +++++++++++++++++++++++----- src/TermAuctionList.sol | 47 +++++++++++++++++++++----- src/test/TestUSDCSellRepoToken.t.sol | 35 +++++++++++++++++++ 4 files changed, 138 insertions(+), 41 deletions(-) diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index 48735def..ffb60634 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -61,43 +61,50 @@ library RepoTokenList { } } - function simulateWeightedTimeToMaturity( - RepoTokenListData storage listData, + function getRepoTokenWeightedTimeToMaturity( + address repoToken, uint256 repoTokenBalanceInBaseAssetPrecision + ) internal view returns (uint256 weightedTimeToMaturity) { + uint256 currentMaturity = _getRepoTokenMaturity(repoToken); + + if (currentMaturity > block.timestamp) { + uint256 timeToMaturity = _getRepoTokenTimeToMaturity(currentMaturity, repoToken); + // Not matured yet + weightedTimeToMaturity = timeToMaturity * repoTokenBalanceInBaseAssetPrecision; + } + } + + function getCumulativeRepoTokenData( + RepoTokenListData storage listData, address repoToken, uint256 repoTokenAmount, uint256 purchaseTokenPrecision, uint256 liquidBalance - ) internal view returns (uint256) { - if (listData.head == NULL_NODE) return 0; + ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeRepoTokenAmount) { + if (listData.head == NULL_NODE) return (0, 0); - uint256 cumulativeWeightedTimeToMaturity; // in seconds - uint256 cumulativeRepoTokenAmount; // in purchase token precision address current = listData.head; bool found; while (current != NULL_NODE) { uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); if (repoTokenBalance > 0) { - uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); - uint256 repoTokenPrecision = 10**ERC20(current).decimals(); - if (repoToken == current) { repoTokenBalance += repoTokenAmount; found = true; } + uint256 redemptionValue = ITermRepoToken(current).redemptionValue(); + uint256 repoTokenPrecision = 10**ERC20(current).decimals(); + uint256 repoTokenBalanceInBaseAssetPrecision = (redemptionValue * repoTokenBalance * purchaseTokenPrecision) / (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); - uint256 currentMaturity = _getRepoTokenMaturity(current); + uint256 weightedTimeToMaturity = getRepoTokenWeightedTimeToMaturity( + current, repoTokenBalanceInBaseAssetPrecision + ); - if (currentMaturity > block.timestamp) { - uint256 timeToMaturity = _getRepoTokenTimeToMaturity(currentMaturity, current); - // Not matured yet - cumulativeWeightedTimeToMaturity += - timeToMaturity * repoTokenBalanceInBaseAssetPrecision; - } + cumulativeWeightedTimeToMaturity += weightedTimeToMaturity; cumulativeRepoTokenAmount += repoTokenBalanceInBaseAssetPrecision; } @@ -120,14 +127,6 @@ library RepoTokenList { timeToMaturity * repoTokenAmountInBaseAssetPrecision; } } - - /// @dev avoid div by 0 - if (cumulativeRepoTokenAmount == 0 && liquidBalance == 0) { - return 0; - } - - // time * purchaseTokenPrecision / purchaseTokenPrecision - return cumulativeWeightedTimeToMaturity / (cumulativeRepoTokenAmount + liquidBalance); } function removeAndRedeemMaturedTokens(RepoTokenListData storage listData) internal { diff --git a/src/Strategy.sol b/src/Strategy.sol index 4964b3c2..c6e79707 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -84,14 +84,44 @@ contract Strategy is BaseStrategy { return termAuctionListData.pendingOffers(); } - function _removeRedeemAndCalculateWeightedMaturity( + function _calculateWeightedMaturity( address repoToken, uint256 amount, uint256 liquidBalance - ) private returns (uint256) { - return repoTokenListData.simulateWeightedTimeToMaturity( + ) private view returns (uint256) { + if ( + termAuctionListData.head == TermAuctionList.NULL_NODE && + repoTokenListData.head == RepoTokenList.NULL_NODE + ) return 0; + + uint256 cumulativeWeightedTimeToMaturity; // in seconds + uint256 cumulativeAmount; // in purchase token precision + + ( + uint256 cumulativeRepoTokenWeightedTimeToMaturity, + uint256 cumulativeRepoTokenAmount + ) = repoTokenListData.getCumulativeRepoTokenData( repoToken, amount, PURCHASE_TOKEN_PRECISION, liquidBalance ); + + cumulativeWeightedTimeToMaturity += cumulativeRepoTokenWeightedTimeToMaturity; + cumulativeAmount += cumulativeRepoTokenAmount; + + ( + uint256 cumulativeOfferWeightedTimeToMaturity, + uint256 cumulativeOfferAmount + ) = termAuctionListData.getCumulativeOfferData(repoTokenListData); + + cumulativeWeightedTimeToMaturity += cumulativeOfferWeightedTimeToMaturity; + cumulativeAmount += cumulativeOfferAmount; + + /// @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) { @@ -99,9 +129,7 @@ contract Strategy is BaseStrategy { if (repoToken != address(0)) { repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); } - return repoTokenListData.simulateWeightedTimeToMaturity( - repoToken, amount, PURCHASE_TOKEN_PRECISION, _totalLiquidBalance(address(this)) - ); + return _calculateWeightedMaturity(repoToken, amount, _totalLiquidBalance(address(this))); } function calculateRepoTokenPresentValue( @@ -181,7 +209,7 @@ contract Strategy is BaseStrategy { revert InsufficientLiquidBalance(liquidBalance, proceeds); } - uint256 resultingTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( + uint256 resultingTimeToMaturity = _calculateWeightedMaturity( repoToken, repoTokenAmount, liquidBalance - proceeds ); @@ -235,7 +263,7 @@ contract Strategy is BaseStrategy { uint256 offerAmountInRepoPrecision = RepoTokenUtils.purchaseToRepoPrecision( repoTokenPrecision, PURCHASE_TOKEN_PRECISION, newOfferAmount ); - uint256 resultingWeightedTimeToMaturity = _removeRedeemAndCalculateWeightedMaturity( + uint256 resultingWeightedTimeToMaturity = _calculateWeightedMaturity( repoToken, offerAmountInRepoPrecision, newLiquidBalance ); @@ -278,6 +306,7 @@ contract Strategy is BaseStrategy { bytes32 offerId = _generateOfferId(idHash, address(offerLocker)); uint256 currentOfferAmount = termAuctionListData.offers[offerId].offerAmount; if (newOfferAmount > currentOfferAmount) { + // increasing offer amount uint256 offerDebit; unchecked { // checked above @@ -291,7 +320,8 @@ contract Strategy is BaseStrategy { revert BalanceBelowLiquidityThreshold(); } _validateWeightedMaturity(repoToken, newOfferAmount, newLiquidBalance); - } else { + } else if (currentOfferAmount > newOfferAmount) { + // decreasing offer amount uint256 offerCredit; unchecked { offerCredit = currentOfferAmount - newOfferAmount; @@ -301,6 +331,8 @@ contract Strategy is BaseStrategy { revert BalanceBelowLiquidityThreshold(); } _validateWeightedMaturity(repoToken, newOfferAmount, newLiquidBalance); + } else { + // no change in offer amount, do nothing } ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer; diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index 100d2f2a..b060da92 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -75,6 +75,13 @@ library TermAuctionList { ITermController termController, address asset ) internal { + /* + offer submitted; auction still open => include offerAmount in totalValue (otherwise locked purchaseToken will be missing from TV) + offer submitted; auction completed; !auctionClosed() => include offer.offerAmount in totalValue (because the offerLocker will have already deleted offer on completeAuction) + + even though repoToken has been transferred it hasn't been added to the repoTokenList + BUT only if it is new not a reopening + offer submitted; auction completed; auctionClosed() => repoToken has been added to the repoTokenList + */ if (listData.head == NULL_NODE) return; bytes32 current = listData.head; @@ -150,12 +157,36 @@ library TermAuctionList { current = _getNext(listData, current); } } -} -/* -offer submitted; auction still open => include offerAmount in totalValue (otherwise locked purchaseToken will be missing from TV) -offer submitted; auction completed; !auctionClosed() => include offer.offerAmount in totalValue (because the offerLocker will have already deleted offer on completeAuction) - + even though repoToken has been transferred it hasn't been added to the repoTokenList - BUT only if it is new not a reopening -offer submitted; auction completed; auctionClosed() => repoToken has been added to the repoTokenList -*/ \ No newline at end of file + function getCumulativeOfferData( + TermAuctionListData storage listData, + RepoTokenListData storage repoTokenListData + ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeOfferAmount) { + if (listData.head == NULL_NODE) return (0, 0); + + bytes32 current = listData.head; + while (current != NULL_NODE) { + PendingOffer memory offer = listData.offers[current]; + + uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; + + /// @dev offer processed, but auctionClosed not yet called and auction is new so repoToken not on List and wont be picked up + /// checking repoTokenAuctionRates to make sure we are not double counting on re-openings + if (offer.termAuction.auctionCompleted() && offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { + // set offerAmount to pending offer amount + offerAmount = offer.offerAmount; + } + + if (offerAmount > 0) { + uint256 weightedTimeToMaturity = RepoTokenList.getRepoTokenWeightedTimeToMaturity( + offer.repoToken, offerAmount + ); + + cumulativeWeightedTimeToMaturity += weightedTimeToMaturity; + cumulativeOfferAmount += offerAmount; + } + + current = _getNext(listData, current); + } + } +} diff --git a/src/test/TestUSDCSellRepoToken.t.sol b/src/test/TestUSDCSellRepoToken.t.sol index bbae1946..c18635f4 100644 --- a/src/test/TestUSDCSellRepoToken.t.sol +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -6,6 +6,7 @@ import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {ITokenizedStrategy} from "@tokenized-strategy/interfaces/ITokenizedStrategy.sol"; import {MockTermRepoToken} from "./mocks/MockTermRepoToken.sol"; import {MockTermController} from "./mocks/MockTermController.sol"; +import {MockTermAuction} from "./mocks/MockTermAuction.sol"; import {MockUSDC} from "./mocks/MockUSDC.sol"; import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol"; import {ITermRepoToken} from "../interfaces/term/ITermRepoToken.sol"; @@ -180,6 +181,22 @@ contract TestUSDCSellRepoToken is Setup { _sellRepoTokens(tokens, amounts, false, true, ""); } + function _sell2RepoTokens( + MockTermRepoToken rt1, + uint256 amount1, + MockTermRepoToken rt2, + uint256 amount2 + ) private { + address[] memory tokens = new address[](2); + tokens[0] = address(rt1); + tokens[1] = address(rt2); + uint256[] memory amounts = new uint256[](2); + amounts[0] = amount1; + amounts[1] = amount2; + + _sellRepoTokens(tokens, amounts, false, true, ""); + } + function _sell3RepoTokensCheckHoldings() private { address[] memory holdings = termStrategy.repoTokenHoldings(); @@ -264,6 +281,24 @@ contract TestUSDCSellRepoToken is Setup { assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1108800); } + // test: weighted maturity with both repo tokens and pending offers + function testSellMultipleRepoTokens_7_14_8_1_Offer_28_3() public { + _sell2RepoTokens(repoToken1Week, 8e18, repoToken2Week, 1e18); + + bytes32 idHash = bytes32("offer id hash 1"); + + MockTermAuction repoToken4WeekAuction = new MockTermAuction(repoToken4Week); + + mockUSDC.mint(address(termStrategy), 3e6); + + vm.prank(management); + termStrategy.submitAuctionOffer( + address(repoToken4WeekAuction), address(repoToken4Week), idHash, bytes32("test price"), 3e6 + ); + + assertEq(termStrategy.simulateWeightedTimeToMaturity(address(0), 0), 1108800); + } + function testSetGovernanceParameters() public { MockTermController newController = new MockTermController(); From 319460040abd9ee3de7387a0b341fbde1f4867fb Mon Sep 17 00:00:00 2001 From: 0xddong Date: Thu, 27 Jun 2024 08:24:34 -0700 Subject: [PATCH 25/30] fixing weighted time to maturity --- src/RepoTokenList.sol | 22 ++-------------------- src/Strategy.sol | 26 +++++++++++++++++++++----- src/TermAuctionList.sol | 14 ++++++++++---- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index ffb60634..5e7455d0 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -79,11 +79,10 @@ library RepoTokenList { uint256 repoTokenAmount, uint256 purchaseTokenPrecision, uint256 liquidBalance - ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeRepoTokenAmount) { - if (listData.head == NULL_NODE) return (0, 0); + ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeRepoTokenAmount, bool found) { + if (listData.head == NULL_NODE) return (0, 0, false); address current = listData.head; - bool found; while (current != NULL_NODE) { uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); @@ -110,23 +109,6 @@ library RepoTokenList { current = _getNext(listData, current); } - - /// @dev token is not found in the list (i.e. called from view function) - if (!found && repoToken != address(0)) { - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 redemptionValue = ITermRepoToken(repoToken).redemptionValue(); - uint256 repoTokenAmountInBaseAssetPrecision = - (redemptionValue * repoTokenAmount * purchaseTokenPrecision) / - (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); - - cumulativeRepoTokenAmount += repoTokenAmountInBaseAssetPrecision; - uint256 maturity = _getRepoTokenMaturity(repoToken); - if (maturity > block.timestamp) { - uint256 timeToMaturity = _getRepoTokenTimeToMaturity(maturity, repoToken); - cumulativeWeightedTimeToMaturity += - timeToMaturity * repoTokenAmountInBaseAssetPrecision; - } - } } function removeAndRedeemMaturedTokens(RepoTokenListData storage listData) internal { diff --git a/src/Strategy.sol b/src/Strategy.sol index c6e79707..121a85e3 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -86,7 +86,7 @@ contract Strategy is BaseStrategy { function _calculateWeightedMaturity( address repoToken, - uint256 amount, + uint256 repoTokenAmount, uint256 liquidBalance ) private view returns (uint256) { if ( @@ -99,9 +99,10 @@ contract Strategy is BaseStrategy { ( uint256 cumulativeRepoTokenWeightedTimeToMaturity, - uint256 cumulativeRepoTokenAmount + uint256 cumulativeRepoTokenAmount, + bool foundInRepoTokenList ) = repoTokenListData.getCumulativeRepoTokenData( - repoToken, amount, PURCHASE_TOKEN_PRECISION, liquidBalance + repoToken, repoTokenAmount, PURCHASE_TOKEN_PRECISION, liquidBalance ); cumulativeWeightedTimeToMaturity += cumulativeRepoTokenWeightedTimeToMaturity; @@ -109,12 +110,27 @@ contract Strategy is BaseStrategy { ( uint256 cumulativeOfferWeightedTimeToMaturity, - uint256 cumulativeOfferAmount - ) = termAuctionListData.getCumulativeOfferData(repoTokenListData); + uint256 cumulativeOfferAmount, + bool foundInOfferList + ) = termAuctionListData.getCumulativeOfferData(repoTokenListData, repoToken, repoTokenAmount); cumulativeWeightedTimeToMaturity += cumulativeOfferWeightedTimeToMaturity; cumulativeAmount += cumulativeOfferAmount; + if (!foundInRepoTokenList && !foundInOfferList && repoToken != address(0)) { + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 redemptionValue = ITermRepoToken(repoToken).redemptionValue(); + uint256 repoTokenAmountInBaseAssetPrecision = + (redemptionValue * repoTokenAmount * PURCHASE_TOKEN_PRECISION) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + + cumulativeAmount += repoTokenAmountInBaseAssetPrecision; + cumulativeWeightedTimeToMaturity += RepoTokenList.getRepoTokenWeightedTimeToMaturity( + repoToken, + repoTokenAmountInBaseAssetPrecision + ); + } + /// @dev avoid div by 0 if (cumulativeAmount == 0 && liquidBalance == 0) { return 0; diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index b060da92..aa642b11 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -160,15 +160,21 @@ library TermAuctionList { function getCumulativeOfferData( TermAuctionListData storage listData, - RepoTokenListData storage repoTokenListData - ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeOfferAmount) { - if (listData.head == NULL_NODE) return (0, 0); + RepoTokenListData storage repoTokenListData, + address repoToken, + uint256 newOfferAmount + ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeOfferAmount, bool found) { + if (listData.head == NULL_NODE) return (0, 0, false); bytes32 current = listData.head; while (current != NULL_NODE) { PendingOffer memory offer = listData.offers[current]; - uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; + if (offer.repoToken == repoToken) { + found = true; + } + + uint256 offerAmount = offer.repoToken == repoToken ? newOfferAmount : 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 From 9011155ea2f63c166c061044bc3683b7a6f19902 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Thu, 27 Jun 2024 10:36:16 -0700 Subject: [PATCH 26/30] fix edit offer --- src/Strategy.sol | 7 +------ src/TermAuctionList.sol | 20 ++++++++++--------- src/test/mocks/MockTermAuctionOfferLocker.sol | 5 +++-- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/Strategy.sol b/src/Strategy.sol index 121a85e3..e3446326 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -89,11 +89,6 @@ contract Strategy is BaseStrategy { uint256 repoTokenAmount, uint256 liquidBalance ) private view returns (uint256) { - if ( - termAuctionListData.head == TermAuctionList.NULL_NODE && - repoTokenListData.head == RepoTokenList.NULL_NODE - ) return 0; - uint256 cumulativeWeightedTimeToMaturity; // in seconds uint256 cumulativeAmount; // in purchase token precision @@ -353,7 +348,7 @@ contract Strategy is BaseStrategy { ITermAuctionOfferLocker.TermAuctionOfferSubmission memory offer; - offer.id = idHash; + offer.id = currentOfferAmount > 0 ? offerId : idHash; offer.offeror = address(this); offer.offerPriceHash = offerPriceHash; offer.amount = purchaseTokenAmount; diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index aa642b11..2f1bfe91 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -170,19 +170,21 @@ library TermAuctionList { while (current != NULL_NODE) { PendingOffer memory offer = listData.offers[current]; + 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() && offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { + // set offerAmount to pending offer amount + offerAmount = offer.offerAmount; + } } - uint256 offerAmount = offer.repoToken == repoToken ? newOfferAmount : offer.offerLocker.lockedOffer(offer.offerId).amount; - - /// @dev offer processed, but auctionClosed not yet called and auction is new so repoToken not on List and wont be picked up - /// checking repoTokenAuctionRates to make sure we are not double counting on re-openings - if (offer.termAuction.auctionCompleted() && offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { - // set offerAmount to pending offer amount - offerAmount = offer.offerAmount; - } - if (offerAmount > 0) { uint256 weightedTimeToMaturity = RepoTokenList.getRepoTokenWeightedTimeToMaturity( offer.repoToken, offerAmount diff --git a/src/test/mocks/MockTermAuctionOfferLocker.sol b/src/test/mocks/MockTermAuctionOfferLocker.sol index 2c5213cc..ff7e9f93 100644 --- a/src/test/mocks/MockTermAuctionOfferLocker.sol +++ b/src/test/mocks/MockTermAuctionOfferLocker.sol @@ -55,8 +55,7 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { for (uint256 i; i < offerSubmissions.length; i++) { TermAuctionOfferSubmission memory submission = offerSubmissions[i]; - bytes32 offerId = keccak256(abi.encodePacked(submission.id,msg.sender,address(this))); - TermAuctionOffer memory offer = lockedOffers[offerId]; + TermAuctionOffer memory offer = lockedOffers[submission.id]; // existing offer if (offer.amount > 0) { @@ -69,6 +68,8 @@ contract MockTermAuctionOfferLocker is ITermAuctionOfferLocker { // 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; From 47d843d0d61d1d09a064972c313b5224b5c9fbf1 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 3 Jul 2024 17:59:11 -0700 Subject: [PATCH 27/30] fixing repo token PV math --- src/RepoTokenList.sol | 18 ++++++++--------- src/RepoTokenUtils.sol | 16 +++++++++++++++ src/Strategy.sol | 16 ++++++++------- src/TermAuctionList.sol | 37 +++++++++++++++++++++++++++++------ src/test/TestUSDCOffers.t.sol | 12 ++++++++---- 5 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index 5e7455d0..41e2a7cd 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -27,7 +27,7 @@ library RepoTokenList { error InvalidRepoToken(address token); - function _getRepoTokenMaturity(address repoToken) private view returns (uint256 redemptionTimestamp) { + function getRepoTokenMaturity(address repoToken) internal view returns (uint256 redemptionTimestamp) { (redemptionTimestamp, , ,) = ITermRepoToken(repoToken).config(); } @@ -64,7 +64,7 @@ library RepoTokenList { function getRepoTokenWeightedTimeToMaturity( address repoToken, uint256 repoTokenBalanceInBaseAssetPrecision ) internal view returns (uint256 weightedTimeToMaturity) { - uint256 currentMaturity = _getRepoTokenMaturity(repoToken); + uint256 currentMaturity = getRepoTokenMaturity(repoToken); if (currentMaturity > block.timestamp) { uint256 timeToMaturity = _getRepoTokenTimeToMaturity(currentMaturity, repoToken); @@ -118,7 +118,7 @@ library RepoTokenList { address prev = current; while (current != NULL_NODE) { address next; - if (_getRepoTokenMaturity(current) < block.timestamp) { + if (getRepoTokenMaturity(current) < block.timestamp) { bool removeMaturedToken; uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); @@ -158,7 +158,7 @@ library RepoTokenList { } } - function _auctionRate(ITermController termController, ITermRepoToken repoToken) private view returns (uint256) { + function getAuctionRate(ITermController termController, ITermRepoToken repoToken) internal view returns (uint256) { (AuctionMetadata[] memory auctionMetadata, ) = termController.getTermAuctionResults(repoToken.termRepoId()); uint256 len = auctionMetadata.length; @@ -224,14 +224,14 @@ library RepoTokenList { revert InvalidRepoToken(address(repoToken)); } - uint256 oracleRate = _auctionRate(termController, repoToken); + uint256 oracleRate = getAuctionRate(termController, repoToken); if (oracleRate != INVALID_AUCTION_RATE) { if (auctionRate != oracleRate) { listData.auctionRates[address(repoToken)] = oracleRate; } } } else { - auctionRate = _auctionRate(termController, repoToken); + auctionRate = getAuctionRate(termController, repoToken); redemptionTimestamp = validateRepoToken(listData, repoToken, termController, asset); @@ -256,8 +256,8 @@ library RepoTokenList { break; } - uint256 currentMaturity = _getRepoTokenMaturity(current); - uint256 maturityToInsert = _getRepoTokenMaturity(repoToken); + uint256 currentMaturity = getRepoTokenMaturity(current); + uint256 maturityToInsert = getRepoTokenMaturity(repoToken); if (maturityToInsert <= currentMaturity) { if (prev == NULL_NODE) { @@ -289,7 +289,7 @@ library RepoTokenList { address current = listData.head; while (current != NULL_NODE) { - uint256 currentMaturity = _getRepoTokenMaturity(current); + uint256 currentMaturity = getRepoTokenMaturity(current); uint256 repoTokenBalance = ITermRepoToken(current).balanceOf(address(this)); uint256 repoTokenPrecision = 10**ERC20(current).decimals(); uint256 auctionRate = listData.auctionRates[current]; diff --git a/src/RepoTokenUtils.sol b/src/RepoTokenUtils.sol index 1e2fed9e..37653209 100644 --- a/src/RepoTokenUtils.sol +++ b/src/RepoTokenUtils.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0 pragma solidity 0.8.18; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; + library RepoTokenUtils { uint256 public constant THREESIXTY_DAYCOUNT_SECONDS = 360 days; uint256 public constant RATE_PRECISION = 1e18; @@ -35,4 +38,17 @@ library RepoTokenUtils { (repoTokenAmountInBaseAssetPrecision * purchaseTokenPrecision) / (purchaseTokenPrecision + (auctionRate * timeLeftToMaturityDayFraction / RATE_PRECISION)); } + + // returns repo token amount in base asset precision + function getNormalizedRepoTokenAmount( + address repoToken, + uint256 repoTokenAmount, + uint256 purchaseTokenPrecision + ) internal view returns (uint256 repoTokenAmountInBaseAssetPrecision) { + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 redemptionValue = ITermRepoToken(repoToken).redemptionValue(); + repoTokenAmountInBaseAssetPrecision = + (redemptionValue * repoTokenAmount * purchaseTokenPrecision) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + } } diff --git a/src/Strategy.sol b/src/Strategy.sol index e3446326..c81a0a57 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -107,17 +107,19 @@ contract Strategy is BaseStrategy { uint256 cumulativeOfferWeightedTimeToMaturity, uint256 cumulativeOfferAmount, bool foundInOfferList - ) = termAuctionListData.getCumulativeOfferData(repoTokenListData, repoToken, repoTokenAmount); + ) = termAuctionListData.getCumulativeOfferData( + repoTokenListData, termController, repoToken, repoTokenAmount, PURCHASE_TOKEN_PRECISION + ); cumulativeWeightedTimeToMaturity += cumulativeOfferWeightedTimeToMaturity; cumulativeAmount += cumulativeOfferAmount; if (!foundInRepoTokenList && !foundInOfferList && repoToken != address(0)) { - uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); - uint256 redemptionValue = ITermRepoToken(repoToken).redemptionValue(); - uint256 repoTokenAmountInBaseAssetPrecision = - (redemptionValue * repoTokenAmount * PURCHASE_TOKEN_PRECISION) / - (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + uint256 repoTokenAmountInBaseAssetPrecision = RepoTokenUtils.getNormalizedRepoTokenAmount( + repoToken, + repoTokenAmount, + PURCHASE_TOKEN_PRECISION + ); cumulativeAmount += repoTokenAmountInBaseAssetPrecision; cumulativeWeightedTimeToMaturity += RepoTokenList.getRepoTokenWeightedTimeToMaturity( @@ -428,7 +430,7 @@ contract Strategy is BaseStrategy { function _totalAssetValue() internal view returns (uint256 totalValue) { return _totalLiquidBalance(address(this)) + repoTokenListData.getPresentValue(PURCHASE_TOKEN_PRECISION) + - termAuctionListData.getPresentValue(repoTokenListData); + termAuctionListData.getPresentValue(repoTokenListData, termController, PURCHASE_TOKEN_PRECISION); } constructor( diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index 2f1bfe91..3ac4a7bb 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -7,6 +7,7 @@ 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 { bytes32 offerId; @@ -136,7 +137,9 @@ library TermAuctionList { function getPresentValue( TermAuctionListData storage listData, - RepoTokenListData storage repoTokenListData + RepoTokenListData storage repoTokenListData, + ITermController termController, + uint256 purchaseTokenPrecision ) internal view returns (uint256 totalValue) { if (listData.head == NULL_NODE) return 0; @@ -148,8 +151,18 @@ library TermAuctionList { /// @dev offer processed, but auctionClosed not yet called and auction is new so repoToken not on List and wont be picked up /// checking repoTokenAuctionRates to make sure we are not double counting on re-openings - if (offer.termAuction.auctionCompleted() && offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { - totalValue += offer.offerAmount; + if (offer.termAuction.auctionCompleted() && repoTokenListData.auctionRates[offer.repoToken] == 0) { + 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)) + ); } else { totalValue += offerAmount; } @@ -161,8 +174,10 @@ library TermAuctionList { function getCumulativeOfferData( TermAuctionListData storage listData, RepoTokenListData storage repoTokenListData, + ITermController termController, address repoToken, - uint256 newOfferAmount + uint256 newOfferAmount, + uint256 purchaseTokenPrecision ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeOfferAmount, bool found) { if (listData.head == NULL_NODE) return (0, 0, false); @@ -179,9 +194,19 @@ library TermAuctionList { /// @dev offer processed, but auctionClosed not yet called and auction is new so repoToken not on List and wont be picked up /// checking repoTokenAuctionRates to make sure we are not double counting on re-openings - if (offer.termAuction.auctionCompleted() && offerAmount == 0 && repoTokenListData.auctionRates[offer.repoToken] == 0) { + if (offer.termAuction.auctionCompleted() && repoTokenListData.auctionRates[offer.repoToken] == 0) { // set offerAmount to pending offer amount - offerAmount = offer.offerAmount; + uint256 repoTokenAmountInBaseAssetPrecision = RepoTokenUtils.getNormalizedRepoTokenAmount( + offer.repoToken, + ITermRepoToken(offer.repoToken).balanceOf(address(this)), + purchaseTokenPrecision + ); + offerAmount = RepoTokenUtils.calculatePresentValue( + repoTokenAmountInBaseAssetPrecision, + purchaseTokenPrecision, + RepoTokenList.getRepoTokenMaturity(offer.repoToken), + RepoTokenList.getAuctionRate(termController, ITermRepoToken(offer.repoToken)) + ); } } diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol index 4374cb47..eac062b0 100644 --- a/src/test/TestUSDCOffers.t.sol +++ b/src/test/TestUSDCOffers.t.sol @@ -129,18 +129,21 @@ contract TestUSDCSubmitOffer is Setup { 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] = 1e6; + fillAmounts[0] = fillAmount; uint256[] memory repoTokenAmounts = new uint256[](1); repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( - 1e6, repoToken1Week, TEST_REPO_TOKEN_RATE + fillAmount, repoToken1Week, TEST_REPO_TOKEN_RATE ); repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); + //console2.log("repoTokenAmounts[0]", repoTokenAmounts[0]); + // test: asset value should equal to initial asset value (liquid + pending offers) assertEq(termStrategy.totalAssetValue(), initialState.totalAssetValue); @@ -167,16 +170,17 @@ contract TestUSDCSubmitOffer is Setup { 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] = 0.5e6; + fillAmounts[0] = fillAmount; uint256[] memory repoTokenAmounts = new uint256[](1); repoTokenAmounts[0] = _getRepoTokenAmountGivenPurchaseTokenAmount( - 0.5e6, repoToken1Week, TEST_REPO_TOKEN_RATE + fillAmount, repoToken1Week, TEST_REPO_TOKEN_RATE ); repoToken1WeekAuction.auctionSuccess(offerIds, fillAmounts, repoTokenAmounts); From 2e8dae0dd9a80e372ce23344f2c8d9d8535025b2 Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 3 Jul 2024 18:23:27 -0700 Subject: [PATCH 28/30] fix getCumulativeOfferData --- src/TermAuctionList.sol | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index 3ac4a7bb..4955ab91 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -195,18 +195,12 @@ library TermAuctionList { /// @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) { - // set offerAmount to pending offer amount - uint256 repoTokenAmountInBaseAssetPrecision = RepoTokenUtils.getNormalizedRepoTokenAmount( + // use normalized repo token amount if repo token is not in the list + offerAmount = RepoTokenUtils.getNormalizedRepoTokenAmount( offer.repoToken, ITermRepoToken(offer.repoToken).balanceOf(address(this)), purchaseTokenPrecision ); - offerAmount = RepoTokenUtils.calculatePresentValue( - repoTokenAmountInBaseAssetPrecision, - purchaseTokenPrecision, - RepoTokenList.getRepoTokenMaturity(offer.repoToken), - RepoTokenList.getAuctionRate(termController, ITermRepoToken(offer.repoToken)) - ); } } From 0070809000b9e21d56b5c979f36104c12ae1235d Mon Sep 17 00:00:00 2001 From: 0xddong Date: Wed, 3 Jul 2024 22:15:50 -0700 Subject: [PATCH 29/30] fixing repo token double counting issue --- src/Strategy.sol | 4 +- src/TermAuctionList.sol | 112 +++++++++++++++++++++++----------- src/test/TestUSDCOffers.t.sol | 26 ++++++++ 3 files changed, 105 insertions(+), 37 deletions(-) diff --git a/src/Strategy.sol b/src/Strategy.sol index c81a0a57..36f287d5 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -396,8 +396,7 @@ contract Strategy is BaseStrategy { if (currentOfferAmount == 0) { // new offer - termAuctionListData.insertPending(PendingOffer({ - offerId: offerIds[0], + termAuctionListData.insertPending(offerIds[0], PendingOffer({ repoToken: repoToken, offerAmount: offer.amount, termAuction: auction, @@ -406,7 +405,6 @@ contract Strategy is BaseStrategy { } else { // edit offer, overwrite existing termAuctionListData.offers[offerIds[0]] = PendingOffer({ - offerId: offerIds[0], repoToken: repoToken, offerAmount: offer.amount, termAuction: auction, diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index 4955ab91..0a87d899 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -10,11 +10,19 @@ 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 { @@ -58,16 +66,15 @@ library TermAuctionList { } } - function insertPending(TermAuctionListData storage listData, PendingOffer memory pendingOffer) internal { + function insertPending(TermAuctionListData storage listData, bytes32 offerId, PendingOffer memory pendingOffer) internal { bytes32 current = listData.head; - bytes32 id = pendingOffer.offerId; if (current != NULL_NODE) { - listData.nodes[id].next = current; + listData.nodes[offerId].next = current; } - listData.head = id; - listData.offers[id] = pendingOffer; + listData.head = offerId; + listData.offers[offerId] = pendingOffer; } function removeCompleted( @@ -91,7 +98,7 @@ library TermAuctionList { PendingOffer memory offer = listData.offers[current]; bytes32 next = _getNext(listData, current); - uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; + uint256 offerAmount = offer.offerLocker.lockedOffer(current).amount; bool removeNode; bool insertRepoToken; @@ -111,7 +118,7 @@ library TermAuctionList { // withdraw manually bytes32[] memory offerIds = new bytes32[](1); - offerIds[0] = offer.offerId; + offerIds[0] = current; offer.offerLocker.unlockOffers(offerIds); } } @@ -135,6 +142,35 @@ library TermAuctionList { } } + function _loadOffers(TermAuctionListData storage listData) private view returns (PendingOfferMemory[] memory offers) { + uint256 len = _count(listData); + offers = new PendingOfferMemory[](len); + + uint256 i; + bytes32 current = listData.head; + while (current != NULL_NODE) { + PendingOffer memory currentOffer = listData.offers[current]; + PendingOfferMemory memory newOffer = offers[i]; + + newOffer.offerId = current; + newOffer.repoToken = currentOffer.repoToken; + newOffer.offerAmount = currentOffer.offerAmount; + newOffer.termAuction = currentOffer.termAuction; + newOffer.offerLocker = currentOffer.offerLocker; + + i++; + current = _getNext(listData, current); + } + } + + function _markRepoTokenAsSeen(PendingOfferMemory[] memory offers, address repoToken) private view { + for (uint256 i; i < offers.length; i++) { + if (repoToken == offers[i].repoToken) { + offers[i].isRepoTokenSeen = true; + } + } + } + function getPresentValue( TermAuctionListData storage listData, RepoTokenListData storage repoTokenListData, @@ -142,32 +178,37 @@ library TermAuctionList { uint256 purchaseTokenPrecision ) internal view returns (uint256 totalValue) { if (listData.head == NULL_NODE) return 0; + + PendingOfferMemory[] memory offers = _loadOffers(listData); - bytes32 current = listData.head; - while (current != NULL_NODE) { - PendingOffer memory offer = listData.offers[current]; + for (uint256 i; i < offers.length; i++) { + PendingOfferMemory memory offer = offers[i]; uint256 offerAmount = offer.offerLocker.lockedOffer(offer.offerId).amount; /// @dev offer processed, but auctionClosed not yet called and auction is new so repoToken not on List and wont be picked up /// checking repoTokenAuctionRates to make sure we are not double counting on re-openings if (offer.termAuction.auctionCompleted() && repoTokenListData.auctionRates[offer.repoToken] == 0) { - 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)) - ); + if (!offer.isRepoTokenSeen) { + uint256 repoTokenAmountInBaseAssetPrecision = RepoTokenUtils.getNormalizedRepoTokenAmount( + offer.repoToken, + ITermRepoToken(offer.repoToken).balanceOf(address(this)), + purchaseTokenPrecision + ); + totalValue += RepoTokenUtils.calculatePresentValue( + repoTokenAmountInBaseAssetPrecision, + purchaseTokenPrecision, + RepoTokenList.getRepoTokenMaturity(offer.repoToken), + RepoTokenList.getAuctionRate(termController, ITermRepoToken(offer.repoToken)) + ); + + // since multiple offers can be tied to the same repo token, we need to mark + // the repo tokens we've seen to avoid double counting + _markRepoTokenAsSeen(offers, offer.repoToken); + } } else { totalValue += offerAmount; } - - current = _getNext(listData, current); } } @@ -181,9 +222,10 @@ library TermAuctionList { ) internal view returns (uint256 cumulativeWeightedTimeToMaturity, uint256 cumulativeOfferAmount, bool found) { if (listData.head == NULL_NODE) return (0, 0, false); - bytes32 current = listData.head; - while (current != NULL_NODE) { - PendingOffer memory offer = listData.offers[current]; + PendingOfferMemory[] memory offers = _loadOffers(listData); + + for (uint256 i; i < offers.length; i++) { + PendingOfferMemory memory offer = offers[i]; uint256 offerAmount; if (offer.repoToken == repoToken) { @@ -196,12 +238,16 @@ library TermAuctionList { /// 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 - offerAmount = RepoTokenUtils.getNormalizedRepoTokenAmount( - offer.repoToken, - ITermRepoToken(offer.repoToken).balanceOf(address(this)), - purchaseTokenPrecision - ); - } + if (!offer.isRepoTokenSeen) { + offerAmount = RepoTokenUtils.getNormalizedRepoTokenAmount( + offer.repoToken, + ITermRepoToken(offer.repoToken).balanceOf(address(this)), + purchaseTokenPrecision + ); + + _markRepoTokenAsSeen(offers, offer.repoToken); + } + } } if (offerAmount > 0) { @@ -212,8 +258,6 @@ library TermAuctionList { cumulativeWeightedTimeToMaturity += weightedTimeToMaturity; cumulativeOfferAmount += offerAmount; } - - current = _getNext(listData, current); } } } diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol index eac062b0..1a11bfe7 100644 --- a/src/test/TestUSDCOffers.t.sol +++ b/src/test/TestUSDCOffers.t.sol @@ -237,6 +237,32 @@ contract TestUSDCSubmitOffer is Setup { 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); From c286adc9028b8f6ce3335fb8087f0b7eaafa5d3b Mon Sep 17 00:00:00 2001 From: Robert Chu Date: Mon, 5 Aug 2024 15:42:13 -0700 Subject: [PATCH 30/30] Disable commitlint checks --- .github/workflows/lint.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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