diff --git a/src/TokenizedStrategy.sol b/src/TokenizedStrategy.sol index 70cd261..88ab4a5 100644 --- a/src/TokenizedStrategy.sol +++ b/src/TokenizedStrategy.sol @@ -938,9 +938,11 @@ contract TokenizedStrategy { // Burn unlocked shares. _burnUnlockedShares(); + // Initialize varaibles needed throughout. uint256 totalFees; uint256 protocolFees; uint256 sharesToLock; + uint256 _profitMaxUnlockTime = S.profitMaxUnlockTime; // Calculate profit/loss. if (newTotalAssets > oldTotalAssets) { // We have a profit. @@ -978,13 +980,15 @@ contract TokenizedStrategy { } } - // we have a net profit - // lock (profit - fees) - unchecked { - sharesToLock = convertToShares(profit - totalFees); + // we have a net profit. Check if we are locking proifit. + if (_profitMaxUnlockTime != 0) { + // lock (profit - fees) + unchecked { + sharesToLock = convertToShares(profit - totalFees); + } + // Mint the shares to lock the strategy. + _mint(address(this), sharesToLock); } - // Mint the shares to lock the strategy. - _mint(address(this), sharesToLock); // Mint fees shares to recipients. if (performanceFeeShares != 0) { @@ -1005,8 +1009,8 @@ contract TokenizedStrategy { // We will try and burn shares from any pending profit still unlocking // to offset the loss to prevent any PPS decline post report. uint256 sharesToBurn = Math.min( - convertToShares(loss), - S.balances[address(this)] + S.balances[address(this)], + convertToShares(loss) ); // Check if there is anything to burn. @@ -1036,7 +1040,7 @@ contract TokenizedStrategy { // time of the previously locked shares and the profitMaxUnlockTime. uint256 newProfitLockingPeriod = (previouslyLockedTime + sharesToLock * - S.profitMaxUnlockTime) / totalLockedShares; + _profitMaxUnlockTime) / totalLockedShares; // Calculate how many shares unlock per second. S.profitUnlockingRate = @@ -1450,6 +1454,9 @@ contract TokenizedStrategy { * * Denominated in seconds and cannot be greater than 1 year. * + * NOTE: Setting to 0 will cause all currently locked profit + * to be unlocked instantly and should be done with care. + * * `profitMaxUnlockTime` is stored as a uint32 for packing but can * be passed in as uint256 for simplicity. * @@ -1458,10 +1465,20 @@ contract TokenizedStrategy { function setProfitMaxUnlockTime( uint256 _profitMaxUnlockTime ) external onlyManagement { - // Must be greater than 0, and less than a year. - require(_profitMaxUnlockTime != 0, "too short"); + // Must be less than a year. require(_profitMaxUnlockTime <= SECONDS_PER_YEAR, "too long"); - _strategyStorage().profitMaxUnlockTime = uint32(_profitMaxUnlockTime); + StrategyData storage S = _strategyStorage(); + + // If we are setting to 0 we need to adjust amounts. + if (_profitMaxUnlockTime == 0) { + // Burn all shares if applicable. + _burn(address(this), S.balances[address(this)]); + // Reset unlocking variables + S.profitUnlockingRate = 0; + S.fullProfitUnlockDate = 0; + } + + S.profitMaxUnlockTime = uint32(_profitMaxUnlockTime); emit UpdateProfitMaxUnlockTime(_profitMaxUnlockTime); } diff --git a/src/test/AccessControl.t.sol b/src/test/AccessControl.t.sol index 39fe277..3f6b1d9 100644 --- a/src/test/AccessControl.t.sol +++ b/src/test/AccessControl.t.sol @@ -203,11 +203,6 @@ contract AccesssControlTest is Setup { vm.expectRevert("too long"); strategy.setProfitMaxUnlockTime(_badAmount); - // Can't be 0 - vm.prank(management); - vm.expectRevert("too short"); - strategy.setProfitMaxUnlockTime(0); - assertEq(strategy.profitMaxUnlockTime(), profitMaxUnlockTime); } diff --git a/src/test/ProfitLocking.t.sol b/src/test/ProfitLocking.t.sol index 2d11ce1..e61d88f 100644 --- a/src/test/ProfitLocking.t.sol +++ b/src/test/ProfitLocking.t.sol @@ -130,6 +130,19 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "!pps"); + assertApproxEq( + strategy.convertToAssets( + strategy.balanceOf(performanceFeeRecipient) + ), + expectedPerformanceFee, + 100 + ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount + profit, @@ -232,6 +245,19 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "!pps"); + assertApproxEq( + strategy.convertToAssets( + strategy.balanceOf(performanceFeeRecipient) + ), + expectedPerformanceFee, + 100 + ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount + profit, @@ -336,6 +362,19 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "!pps"); + assertApproxEq( + strategy.convertToAssets( + strategy.balanceOf(performanceFeeRecipient) + ), + expectedPerformanceFee, + 100 + ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount + profit, @@ -562,6 +601,19 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "!pps"); + assertApproxEq( + strategy.convertToAssets( + strategy.balanceOf(performanceFeeRecipient) + ), + expectedPerformanceFee, + 100 + ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount + profit, @@ -691,6 +743,19 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "!pps"); + assertApproxEq( + strategy.convertToAssets( + strategy.balanceOf(performanceFeeRecipient) + ), + expectedPerformanceFee, + 100 + ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount + profit, @@ -818,6 +883,19 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "!pps"); + assertApproxEq( + strategy.convertToAssets( + strategy.balanceOf(performanceFeeRecipient) + ), + expectedPerformanceFee, + 100 + ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount + profit, @@ -1031,6 +1109,12 @@ contract ProfitLockingTest is Setup { MAX_BPS / 10 ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount - loss, @@ -1160,7 +1244,6 @@ contract ProfitLockingTest is Setup { increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime / 2, 0); - console.log("Current bal ", strategy.balanceOf(address(strategy))); checkStrategyTotals( strategy, newAmount - loss, @@ -1218,6 +1301,19 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "!pps"); + assertApproxEq( + strategy.convertToAssets( + strategy.balanceOf(performanceFeeRecipient) + ), + expectedPerformanceFee, + 100 + ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount + loss * 2, @@ -1323,6 +1419,19 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "!pps"); + assertApproxEq( + strategy.convertToAssets( + strategy.balanceOf(performanceFeeRecipient) + ), + expectedPerformanceFee, + 100 + ); + assertApproxEq( + strategy.convertToAssets(strategy.balanceOf(protocolFeeRecipient)), + expectedProtocolFee, + 100 + ); + checkStrategyTotals( strategy, _amount + profit, @@ -1368,4 +1477,478 @@ contract ProfitLockingTest is Setup { assertEq(strategy.pricePerShare(), wad, "pps reset"); } + + function test_gain_NoFeesNoBuffer_noLocking( + address _address, + uint128 amount, + uint16 _profitFactor + ) public { + uint256 _amount = bound(uint256(amount), minFuzzAmount, maxFuzzAmount); + _profitFactor = uint16(bound(uint256(_profitFactor), 10, MAX_BPS)); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != protocolFeeRecipient && + _address != performanceFeeRecipient && + _address != address(yieldSource) + ); + // set all fees to 0 + uint16 protocolFee = 0; + uint16 performanceFee = 0; + setFees(protocolFee, performanceFee); + + // Set max unlocking time to 0. + vm.prank(management); + strategy.setProfitMaxUnlockTime(0); + assertEq(strategy.profitMaxUnlockTime(), 0); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + // Increase time to simulate interest being earned + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime, 0); + + uint256 profit = (_amount * _profitFactor) / MAX_BPS; + uint256 expectedPerformanceFee = (profit * performanceFee) / MAX_BPS; + uint256 expectedProtocolFee = (expectedPerformanceFee * protocolFee) / + MAX_BPS; + + createAndCheckProfit( + strategy, + profit, + expectedProtocolFee, + expectedPerformanceFee + ); + + // All profit should have been unlocked instantly. + assertEq(strategy.profitUnlockingRate(), 0, "!rate"); + assertEq(strategy.fullProfitUnlockDate(), 0, "date"); + assertGt(strategy.pricePerShare(), wad, "!pps"); + assertRelApproxEq( + strategy.pricePerShare(), + wad + ((wad * _profitFactor) / MAX_BPS), + MAX_BPS + ); + + checkStrategyTotals( + strategy, + _amount + profit, + _amount + profit, + 0, + _amount + ); + + // Nothing should change + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime / 2, 0); + + checkStrategyTotals( + strategy, + _amount + profit, + _amount + profit, + 0, + _amount + ); + + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime / 2, 0); + + assertRelApproxEq( + strategy.pricePerShare(), + wad + ((wad * _profitFactor) / MAX_BPS), + MAX_BPS + ); + + checkStrategyTotals( + strategy, + _amount + profit, + _amount + profit, + 0, + _amount + ); + + vm.prank(_address); + strategy.redeem(_amount, _address, _address); + + checkStrategyTotals(strategy, 0, 0, 0, 0); + + assertEq(strategy.pricePerShare(), wad, "pps reset"); + } + + function test_gain_NoFeesNoBuffer_noLocking_withdrawAll( + address _address, + uint128 amount, + uint16 _profitFactor + ) public { + uint256 _amount = bound(uint256(amount), minFuzzAmount, maxFuzzAmount); + _profitFactor = uint16(bound(uint256(_profitFactor), 10, MAX_BPS)); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != protocolFeeRecipient && + _address != performanceFeeRecipient && + _address != address(yieldSource) + ); + // set all fees to 0 + uint16 protocolFee = 0; + uint16 performanceFee = 0; + setFees(protocolFee, performanceFee); + + // Set max unlocking time to 0. + vm.prank(management); + strategy.setProfitMaxUnlockTime(0); + assertEq(strategy.profitMaxUnlockTime(), 0); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + // Increase time to simulate interest being earned + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime, 0); + + uint256 profit = (_amount * _profitFactor) / MAX_BPS; + uint256 expectedPerformanceFee = (profit * performanceFee) / MAX_BPS; + uint256 expectedProtocolFee = (expectedPerformanceFee * protocolFee) / + MAX_BPS; + + createAndCheckProfit( + strategy, + profit, + expectedProtocolFee, + expectedPerformanceFee + ); + + // All profit should have been unlocked instantly. + assertEq(strategy.profitUnlockingRate(), 0, "!rate"); + assertEq(strategy.fullProfitUnlockDate(), 0, "date"); + assertGt(strategy.pricePerShare(), wad, "!pps"); + assertRelApproxEq( + strategy.pricePerShare(), + wad + ((wad * _profitFactor) / MAX_BPS), + MAX_BPS + ); + + checkStrategyTotals( + strategy, + _amount + profit, + _amount + profit, + 0, + _amount + ); + + // Should be able to withdaw all right away + uint256 beforeBalance = asset.balanceOf(_address); + + vm.prank(_address); + strategy.redeem(_amount, _address, _address); + + assertEq(asset.balanceOf(_address), beforeBalance + _amount + profit); + + checkStrategyTotals(strategy, 0, 0, 0, 0); + + assertEq(strategy.pricePerShare(), wad, "pps reset"); + } + + function test_gainFees_NoBuffer_noLocking( + address _address, + uint128 amount, + uint16 _profitFactor + ) public { + uint256 _amount = bound(uint256(amount), minFuzzAmount, maxFuzzAmount); + _profitFactor = uint16(bound(uint256(_profitFactor), 10, MAX_BPS)); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != protocolFeeRecipient && + _address != performanceFeeRecipient && + _address != address(yieldSource) + ); + // set all fees to 0 + uint16 protocolFee = 1_000; + uint16 performanceFee = 1_000; + setFees(protocolFee, performanceFee); + + // Set max unlocking time to 0. + vm.prank(management); + strategy.setProfitMaxUnlockTime(0); + assertEq(strategy.profitMaxUnlockTime(), 0); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + // Increase time to simulate interest being earned + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime, 0); + + uint256 profit = (_amount * _profitFactor) / MAX_BPS; + uint256 expectedPerformanceFee = (profit * performanceFee) / MAX_BPS; + uint256 expectedProtocolFee = (expectedPerformanceFee * protocolFee) / + MAX_BPS; + expectedPerformanceFee -= expectedProtocolFee; + + uint256 totalExpectedFees = expectedPerformanceFee + + expectedProtocolFee; + + asset.mint(address(strategy), profit); + + vm.prank(keeper); + (uint256 _profit, ) = strategy.report(); + + assertEq(profit, _profit, "profit reported wrong"); + + // All profit should have been unlocked instantly. + assertEq(strategy.profitUnlockingRate(), 0, "!rate"); + assertEq(strategy.fullProfitUnlockDate(), 0, "date"); + assertGt(strategy.pricePerShare(), wad, "!pps"); + + checkStrategyTotals( + strategy, + _amount + profit, + _amount + profit, + 0, + _amount + totalExpectedFees + ); + + vm.prank(_address); + strategy.redeem(_amount, _address, _address); + + uint256 expectedAssetsForFees = strategy.convertToAssets( + totalExpectedFees + ); + checkStrategyTotals( + strategy, + expectedAssetsForFees, + expectedAssetsForFees, + 0, + totalExpectedFees + ); + + if (expectedPerformanceFee > 0) { + assertGt(strategy.pricePerShare(), wad, "pps decreased"); + + vm.prank(performanceFeeRecipient); + strategy.redeem( + expectedPerformanceFee, + performanceFeeRecipient, + performanceFeeRecipient + ); + } + + expectedAssetsForFees = strategy.convertToAssets(expectedProtocolFee); + checkStrategyTotals( + strategy, + expectedAssetsForFees, + expectedAssetsForFees, + 0, + expectedProtocolFee + ); + + if (expectedProtocolFee > 0) { + vm.prank(protocolFeeRecipient); + strategy.redeem( + expectedProtocolFee, + protocolFeeRecipient, + protocolFeeRecipient + ); + } + + checkStrategyTotals(strategy, 0, 0, 0, 0); + + assertEq(strategy.pricePerShare(), wad, "pps reset"); + } + + function test_gainBuffer_noFees_noLocking_resets( + address _address, + uint256 _amount, + uint16 _profitFactor + ) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + _profitFactor = uint16(bound(uint256(_profitFactor), 10, MAX_BPS)); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != protocolFeeRecipient && + _address != performanceFeeRecipient && + _address != address(yieldSource) + ); + // set fees to 0 + uint16 protocolFee = 0; + uint16 performanceFee = 0; + setFees(protocolFee, performanceFee); + + assertEq(strategy.profitUnlockingRate(), 0, "!rate"); + assertEq(strategy.fullProfitUnlockDate(), 0, "date"); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + + // Increase time to simulate interest being earned + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime, 0); + + uint256 profit = (_amount * _profitFactor) / MAX_BPS; + + uint256 expectedPerformanceFee = (profit * performanceFee) / MAX_BPS; + uint256 expectedProtocolFee = (expectedPerformanceFee * protocolFee) / + MAX_BPS; + + uint256 totalExpectedFees = expectedPerformanceFee + + expectedProtocolFee; + createAndCheckProfit( + strategy, + profit, + expectedProtocolFee, + expectedPerformanceFee + ); + + assertEq(strategy.pricePerShare(), wad, "!pps"); + + checkStrategyTotals( + strategy, + _amount + profit, + _amount + profit, + 0, + _amount + profit + ); + + increaseTimeAndCheckBuffer( + strategy, + profitMaxUnlockTime / 2, + (profit - totalExpectedFees) / 2 + ); + + checkStrategyTotals( + strategy, + _amount + profit, + _amount + profit, + 0, + _amount + profit - ((profit - totalExpectedFees) / 2) + ); + + // Make sure we have active unlocking + assertGt(strategy.profitUnlockingRate(), 0); + assertGt(strategy.fullProfitUnlockDate(), 0); + assertGt(strategy.balanceOf(address(strategy)), 0); + + // Set max unlocking time to 0. + vm.prank(management); + strategy.setProfitMaxUnlockTime(0); + // Make sure it reset all unlocking rates. + assertEq(strategy.profitMaxUnlockTime(), 0); + assertEq(strategy.profitUnlockingRate(), 0, "!rate"); + assertEq(strategy.fullProfitUnlockDate(), 0, "date"); + assertEq(strategy.balanceOf(address(strategy)), 0); + + assertRelApproxEq( + strategy.pricePerShare(), + wad + ((wad * _profitFactor) / MAX_BPS), + MAX_BPS + ); + + checkStrategyTotals( + strategy, + _amount + profit, + _amount + profit, + 0, + _amount + ); + + uint256 newAmount = _amount + profit; + + createAndCheckProfit( + strategy, + profit, + expectedProtocolFee, + expectedPerformanceFee + ); + + // Should unlock everything right away. + checkStrategyTotals( + strategy, + newAmount + profit, + newAmount + profit, + 0, + _amount + ); + + increaseTimeAndCheckBuffer(strategy, 0, 0); + + vm.prank(_address); + strategy.redeem(newAmount - profit, _address, _address); + + checkStrategyTotals(strategy, 0, 0, 0, 0); + + assertEq(strategy.pricePerShare(), wad, "pps reset"); + } + + function test_loss_NoFeesNoBuffer_noUnlock( + address _address, + uint256 _amount, + uint16 _lossFactor + ) public { + _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); + _lossFactor = uint16(bound(uint256(_lossFactor), 1, 5_000)); + vm.assume( + _address != address(0) && + _address != address(strategy) && + _address != protocolFeeRecipient && + _address != performanceFeeRecipient && + _address != address(yieldSource) + ); + // set all fees to 0 + uint16 protocolFee = 0; + uint16 performanceFee = 0; + setFees(protocolFee, performanceFee); + + // Set max unlocking time to 0. + vm.prank(management); + strategy.setProfitMaxUnlockTime(0); + assertEq(strategy.profitMaxUnlockTime(), 0); + + mintAndDepositIntoStrategy(strategy, _address, _amount); + // Increase time to simulate interest being earned + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime, 0); + + uint256 loss = (_amount * _lossFactor) / MAX_BPS; + uint256 expectedProtocolFee = 0; + + createAndCheckLoss(strategy, loss, expectedProtocolFee, true); + + assertEq(strategy.profitUnlockingRate(), 0, "!rate"); + assertEq(strategy.fullProfitUnlockDate(), 0, "date"); + assertRelApproxEq( + strategy.pricePerShare(), + wad - ((wad * _lossFactor) / MAX_BPS), + MAX_BPS / 10 + ); + + checkStrategyTotals( + strategy, + _amount - loss, + _amount - loss, + 0, + _amount + ); + + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime / 2, 0); + + checkStrategyTotals( + strategy, + _amount - loss, + _amount - loss, + 0, + _amount + ); + + increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime / 2, 0); + + assertRelApproxEq( + strategy.pricePerShare(), + wad - ((wad * _lossFactor) / MAX_BPS), + MAX_BPS / 10 + ); + + checkStrategyTotals( + strategy, + _amount - loss, + _amount - loss, + 0, + _amount + ); + + vm.prank(_address); + strategy.redeem(_amount, _address, _address); + + checkStrategyTotals(strategy, 0, 0, 0, 0); + + assertEq(strategy.pricePerShare(), wad, "pps reset"); + } }