diff --git a/src/RepoTokenList.sol b/src/RepoTokenList.sol index 29bff868..4e30b1bc 100644 --- a/src/RepoTokenList.sol +++ b/src/RepoTokenList.sol @@ -213,8 +213,7 @@ library RepoTokenList { ITermRepoToken repoToken, ITermController termController, address asset - ) internal returns (uint256 auctionRate, uint256 redemptionTimestamp) - { + ) internal returns (uint256 auctionRate, uint256 redemptionTimestamp) { auctionRate = listData.auctionRates[address(repoToken)]; if (auctionRate != INVALID_AUCTION_RATE) { (redemptionTimestamp, , ,) = repoToken.config(); @@ -283,7 +282,8 @@ library RepoTokenList { function getPresentValue( RepoTokenListData storage listData, - uint256 purchaseTokenPrecision + uint256 purchaseTokenPrecision, + address repoTokenToMatch ) internal view returns (uint256 totalPresentValue) { if (listData.head == NULL_NODE) return 0; @@ -307,6 +307,12 @@ library RepoTokenList { totalPresentValue += repoTokenBalanceInBaseAssetPrecision; } + if (repoTokenToMatch != address(0) && current == repoTokenToMatch) { + // matching a specific repo token and terminate early because the list is sorted + // with no duplicates + break; + } + current = _getNext(listData, current); } } diff --git a/src/Strategy.sol b/src/Strategy.sol index 4534f621..010887d1 100644 --- a/src/Strategy.sol +++ b/src/Strategy.sol @@ -4,6 +4,8 @@ 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 {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; import {ITermRepoToken} from "./interfaces/term/ITermRepoToken.sol"; import {ITermRepoServicer} from "./interfaces/term/ITermRepoServicer.sol"; import {ITermController} from "./interfaces/term/ITermController.sol"; @@ -28,7 +30,7 @@ import {RepoTokenUtils} from "./RepoTokenUtils.sol"; // NOTE: To implement permissioned functions you can use the onlyManagement, onlyEmergencyAuthorized and onlyKeepers modifiers -contract Strategy is BaseStrategy { +contract Strategy is BaseStrategy, Pausable, ReentrancyGuard { using SafeERC20 for IERC20; using RepoTokenList for RepoTokenListData; using TermAuctionList for TermAuctionListData; @@ -37,6 +39,7 @@ contract Strategy is BaseStrategy { error TimeToMaturityAboveThreshold(); error BalanceBelowLiquidityThreshold(); error InsufficientLiquidBalance(uint256 have, uint256 want); + error RepoTokenConcentrationTooHigh(address repoToken); ITermVaultEvents public immutable TERM_VAULT_EVENT_EMITTER; uint256 public immutable PURCHASE_TOKEN_PRECISION; @@ -48,6 +51,23 @@ contract Strategy is BaseStrategy { uint256 public timeToMaturityThreshold; // seconds uint256 public liquidityThreshold; // purchase token precision (underlying) uint256 public auctionRateMarkup; // 1e18 (TODO: check this) + uint256 public repoTokenConcentrationLimit; + + function rescueToken(address token, uint256 amount) external onlyManagement { + if (amount > 0) { + IERC20(token).safeTransfer(msg.sender, amount); + } + } + + function pause() external onlyManagement { + _pause(); + TERM_VAULT_EVENT_EMITTER.emitPaused(); + } + + function unpause() external onlyManagement { + _unpause(); + TERM_VAULT_EVENT_EMITTER.emitUnpaused(); + } // These governance functions should have a different role function setTermController(address newTermController) external onlyManagement { @@ -76,6 +96,13 @@ contract Strategy is BaseStrategy { repoTokenListData.collateralTokenParams[tokenAddr] = minCollateralRatio; } + function setRepoTokenConcentrationLimit(uint256 newRepoTokenConcentrationLimit) external onlyManagement { + TERM_VAULT_EVENT_EMITTER.emitRepoTokenConcentrationLimitUpdated( + repoTokenConcentrationLimit, newRepoTokenConcentrationLimit + ); + repoTokenConcentrationLimit = newRepoTokenConcentrationLimit; + } + function repoTokenHoldings() external view returns (address[] memory) { return repoTokenListData.holdings(); } @@ -141,7 +168,14 @@ contract Strategy is BaseStrategy { // do not validate if we are simulating with existing repo tokens if (repoToken != address(0)) { repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); + + uint256 repoTokenPrecision = 10**ERC20(repoToken).decimals(); + uint256 repoTokenAmountInBaseAssetPrecision = + (ITermRepoToken(repoToken).redemptionValue() * amount * PURCHASE_TOKEN_PRECISION) / + (repoTokenPrecision * RepoTokenUtils.RATE_PRECISION); + _validateRepoTokenConcentration(repoToken, repoTokenAmountInBaseAssetPrecision, 0); } + return _calculateWeightedMaturity(repoToken, amount, _totalLiquidBalance(address(this))); } @@ -192,8 +226,7 @@ contract Strategy is BaseStrategy { return YEARN_VAULT.convertToAssets(YEARN_VAULT.balanceOf(address(this))); } - // TODO: reentrancy check - function sellRepoToken(address repoToken, uint256 repoTokenAmount) external { + function sellRepoToken(address repoToken, uint256 repoTokenAmount) external whenNotPaused nonReentrant { require(repoTokenAmount > 0); require(_totalLiquidBalance(address(this)) > 0); @@ -237,12 +270,38 @@ contract Strategy is BaseStrategy { revert BalanceBelowLiquidityThreshold(); } + if ((liquidBalance - proceeds) < liquidityThreshold) { + revert BalanceBelowLiquidityThreshold(); + } + + _validateRepoTokenConcentration(repoToken, repoTokenAmountInBaseAssetPrecision, proceeds); + // withdraw from underlying vault _withdrawAsset(proceeds); IERC20(repoToken).safeTransferFrom(msg.sender, address(this), repoTokenAmount); IERC20(asset).safeTransfer(msg.sender, proceeds); } + + function _validateRepoTokenConcentration( + address repoToken, + uint256 repoTokenAmountInBaseAssetPrecision, + uint256 liquidBalanceToRemove + ) private view { + // _repoTokenValue returns asset precision + uint256 repoTokenValue = getRepoTokenValue(repoToken) + repoTokenAmountInBaseAssetPrecision; + uint256 totalAsseValue = _totalAssetValue() + repoTokenAmountInBaseAssetPrecision - liquidBalanceToRemove; + + // repoTokenConcentrationLimit is in 1e18 precision + repoTokenValue = repoTokenValue * 1e18 / PURCHASE_TOKEN_PRECISION; + totalAsseValue = totalAsseValue * 1e18 / PURCHASE_TOKEN_PRECISION; + + uint256 repoTokenConcentration = totalAsseValue == 0 ? 0 : repoTokenValue * 1e18 / totalAsseValue; + + if (repoTokenConcentration > repoTokenConcentrationLimit) { + revert RepoTokenConcentrationTooHigh(repoToken); + } + } function deleteAuctionOffers(address termAuction, bytes32[] calldata offerIds) external onlyManagement { if (!termController.isTermDeployed(termAuction)) { @@ -292,7 +351,7 @@ contract Strategy is BaseStrategy { bytes32 idHash, bytes32 offerPriceHash, uint256 purchaseTokenAmount - ) external onlyManagement returns (bytes32[] memory offerIds) { + ) external whenNotPaused nonReentrant onlyManagement returns (bytes32[] memory offerIds) { require(purchaseTokenAmount > 0); if (!termController.isTermDeployed(termAuction)) { @@ -306,6 +365,8 @@ contract Strategy is BaseStrategy { // validate purchase token and min collateral ratio repoTokenListData.validateRepoToken(ITermRepoToken(repoToken), termController, address(asset)); + _validateRepoTokenConcentration(repoToken, purchaseTokenAmount, 0); + ITermAuctionOfferLocker offerLocker = ITermAuctionOfferLocker(auction.termAuctionOfferLocker()); require( block.timestamp > offerLocker.auctionStartTime() @@ -439,11 +500,20 @@ contract Strategy is BaseStrategy { function totalLiquidBalance() external view returns (uint256) { return _totalLiquidBalance(address(this)); } + + function getRepoTokenValue(address repoToken) public view returns (uint256) { + return repoTokenListData.getPresentValue(PURCHASE_TOKEN_PRECISION, repoToken) + + termAuctionListData.getPresentValue( + repoTokenListData, termController, PURCHASE_TOKEN_PRECISION, repoToken + ); + } function _totalAssetValue() internal view returns (uint256 totalValue) { return _totalLiquidBalance(address(this)) + - repoTokenListData.getPresentValue(PURCHASE_TOKEN_PRECISION) + - termAuctionListData.getPresentValue(repoTokenListData, termController, PURCHASE_TOKEN_PRECISION); + repoTokenListData.getPresentValue(PURCHASE_TOKEN_PRECISION, address(0)) + + termAuctionListData.getPresentValue( + repoTokenListData, termController, PURCHASE_TOKEN_PRECISION, address(0) + ); } constructor( @@ -474,7 +544,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 { + function _deployFunds(uint256 _amount) internal override whenNotPaused { _sweepAssetAndRedeemRepoTokens(0); } @@ -499,7 +569,7 @@ 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 whenNotPaused { _sweepAssetAndRedeemRepoTokens(_amount); } @@ -528,7 +598,7 @@ contract Strategy is BaseStrategy { function _harvestAndReport() internal override - returns (uint256 _totalAssets) + whenNotPaused returns (uint256 _totalAssets) { _sweepAssetAndRedeemRepoTokens(0); return _totalAssetValue(); diff --git a/src/TermAuctionList.sol b/src/TermAuctionList.sol index 2a43d592..53864db8 100644 --- a/src/TermAuctionList.sol +++ b/src/TermAuctionList.sol @@ -175,7 +175,8 @@ library TermAuctionList { TermAuctionListData storage listData, RepoTokenListData storage repoTokenListData, ITermController termController, - uint256 purchaseTokenPrecision + uint256 purchaseTokenPrecision, + address repoTokenToMatch ) internal view returns (uint256 totalValue) { if (listData.head == NULL_NODE) return 0; @@ -184,6 +185,11 @@ library TermAuctionList { for (uint256 i; i < offers.length; i++) { PendingOfferMemory memory offer = offers[i]; + // filter by repoTokenToMatch if necessary + if (repoTokenToMatch != address(0) && offer.repoToken != repoTokenToMatch) { + continue; + } + 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 diff --git a/src/TermVaultEventEmitter.sol b/src/TermVaultEventEmitter.sol index bb427eb6..1777e61f 100644 --- a/src/TermVaultEventEmitter.sol +++ b/src/TermVaultEventEmitter.sol @@ -55,6 +55,18 @@ contract TermVaultEventEmitter is Initializable, UUPSUpgradeable, AccessControlU emit MinCollateralRatioUpdated(collateral, minCollateralRatio); } + function emitRepoTokenConcentrationLimitUpdated(uint256 oldLimit, uint256 newLimit) external onlyRole(VAULT_CONTRACT) { + emit RepoTokenConcentrationLimitUpdated(oldLimit, newLimit); + } + + function emitPaused() external onlyRole(VAULT_CONTRACT) { + emit Paused(); + } + + function emitUnpaused() external onlyRole(VAULT_CONTRACT) { + emit Unpaused(); + } + // ======================================================================== // = Admin =============================================================== // ======================================================================== diff --git a/src/interfaces/term/ITermVaultEvents.sol b/src/interfaces/term/ITermVaultEvents.sol index 181b0d61..85e275f7 100644 --- a/src/interfaces/term/ITermVaultEvents.sol +++ b/src/interfaces/term/ITermVaultEvents.sol @@ -12,6 +12,12 @@ interface ITermVaultEvents { event MinCollateralRatioUpdated(address collateral, uint256 minCollateralRatio); + event RepoTokenConcentrationLimitUpdated(uint256 oldLimit, uint256 newLimit); + + event Paused(); + + event Unpaused(); + function emitTermControllerUpdated(address oldController, address newController) external; function emitTimeToMaturityThresholdUpdated(uint256 oldThreshold, uint256 newThreshold) external; @@ -21,4 +27,10 @@ interface ITermVaultEvents { function emitAuctionRateMarkupUpdated(uint256 oldMarkup, uint256 newMarkup) external; function emitMinCollateralRatioUpdated(address collateral, uint256 minCollateralRatio) external; + + function emitRepoTokenConcentrationLimitUpdated(uint256 oldLimit, uint256 newLimit) external; + + function emitPaused() external; + + function emitUnpaused() external; } diff --git a/src/test/TestUSDCOffers.t.sol b/src/test/TestUSDCOffers.t.sol index 1a11bfe7..8b35261a 100644 --- a/src/test/TestUSDCOffers.t.sol +++ b/src/test/TestUSDCOffers.t.sol @@ -36,6 +36,7 @@ contract TestUSDCSubmitOffer is Setup { vm.startPrank(management); termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); termStrategy.setTimeToMaturityThreshold(3 weeks); + termStrategy.setRepoTokenConcentrationLimit(1e18); vm.stopPrank(); // start with some initial funds diff --git a/src/test/TestUSDCSellRepoToken.t.sol b/src/test/TestUSDCSellRepoToken.t.sol index c18635f4..063df090 100644 --- a/src/test/TestUSDCSellRepoToken.t.sol +++ b/src/test/TestUSDCSellRepoToken.t.sol @@ -48,6 +48,7 @@ contract TestUSDCSellRepoToken is Setup { vm.startPrank(management); termStrategy.setCollateralTokenParams(address(mockCollateral), 0.5e18); termStrategy.setTimeToMaturityThreshold(10 weeks); + termStrategy.setRepoTokenConcentrationLimit(1e18); vm.stopPrank(); } @@ -161,6 +162,15 @@ contract TestUSDCSellRepoToken is Setup { _sellRepoTokens(tokens, amounts, true, true, err); } + function _sell1RepoTokenNoMintExpectRevert(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, false, err); + } + function _sell3RepoTokens( MockTermRepoToken rt1, uint256 amount1, @@ -509,4 +519,54 @@ contract TestUSDCSellRepoToken is Setup { console.log("totalAssetValue", termStrategy.totalAssetValue()); } + + function testConcentrationLimitFailure() public { + 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(); + + vm.expectRevert("!management"); + termStrategy.setRepoTokenConcentrationLimit(0.4e18); + + // Set to 40% + vm.prank(management); + termStrategy.setRepoTokenConcentrationLimit(0.4e18); + + _sell1RepoTokenNoMintExpectRevert( + repoToken2Week, + 500e18, + abi.encodeWithSelector(Strategy.RepoTokenConcentrationTooHigh.selector, address(repoToken2Week)) + ); + } + + function testPausing() public { + address testDepositor = vm.addr(0x111111); + uint256 depositAmount = 1000e6; + + mockUSDC.mint(testDepositor, depositAmount); + + vm.expectRevert("!management"); + termStrategy.pause(); + + vm.prank(management); + termStrategy.pause(); + + vm.startPrank(testDepositor); + mockUSDC.approve(address(termStrategy), type(uint256).max); + vm.expectRevert("Pausable: paused"); + IERC4626(address(termStrategy)).deposit(depositAmount, testDepositor); + vm.stopPrank(); + + _sell1RepoTokenExpectRevert( + repoToken2Week, + 2e18, + "Pausable: paused" + ); + } }