diff --git a/src/FourSixTwoSixAgg.sol b/src/FourSixTwoSixAgg.sol index 5f79f742..4ef2915d 100644 --- a/src/FourSixTwoSixAgg.sol +++ b/src/FourSixTwoSixAgg.sol @@ -222,6 +222,8 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn /// @param _strategy Address of strategy to rebalance. function rebalance(address _strategy) external nonReentrant { _rebalance(_strategy); + + _gulp(); } /// @notice Rebalance multiple strategies. @@ -230,9 +232,11 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn for (uint256 i; i < _strategies.length; ++i) { _rebalance(_strategies[i]); } + + _gulp(); } - /// @notice Harvest positive yield. + /// @notice Harvest strategy. /// @param strategy address of strategy function harvest(address strategy) external nonReentrant { _harvest(strategy); @@ -240,6 +244,8 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn _gulp(); } + /// @notice Harvest multiple strategies. + /// @param _strategies an array of strategy addresses. function harvestMultipleStrategies(address[] calldata _strategies) external nonReentrant { for (uint256 i; i < _strategies.length; ++i) { _harvest(_strategies[i]); @@ -338,6 +344,10 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn withdrawalQueue.pop(); } + function updateInterestAccrued() external returns (ESRSlot memory) { + return _updateInterestAccrued(); + } + function gulp() external nonReentrant { _gulp(); } @@ -364,7 +374,7 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn /// @notice Return the accrued interest /// @return uint256 accrued interest function interestAccrued() external view returns (uint256) { - return interestAccruedFromCache(esrSlot); + return _interestAccruedFromCache(esrSlot); } /// @notice Transfers a certain amount of tokens to a recipient. @@ -416,7 +426,7 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn returns (uint256 shares) { // Move interest to totalAssetsDeposited - updateInterestAndReturnESRSlotCache(); + _updateInterestAccrued(); return super.withdraw(assets, receiver, owner); } @@ -430,14 +440,13 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn returns (uint256 assets) { // Move interest to totalAssetsDeposited - updateInterestAndReturnESRSlotCache(); + _updateInterestAccrued(); return super.redeem(shares, receiver, owner); } - function updateInterestAndReturnESRSlotCache() public returns (ESRSlot memory) { + function _updateInterestAccrued() internal returns (ESRSlot memory) { ESRSlot memory esrSlotCache = esrSlot; - uint256 accruedInterest = interestAccruedFromCache(esrSlotCache); - + uint256 accruedInterest = _interestAccruedFromCache(esrSlotCache); // it's safe to down-cast because the accrued interest is a fraction of interest left esrSlotCache.interestLeft -= uint168(accruedInterest); esrSlotCache.lastInterestUpdate = uint40(block.timestamp); @@ -452,7 +461,7 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn /// @notice Return the total amount of assets deposited, plus the accrued interest. /// @return uint256 total amount function totalAssets() public view override returns (uint256) { - return totalAssetsDeposited + interestAccruedFromCache(esrSlot); + return totalAssetsDeposited + _interestAccruedFromCache(esrSlot); } /// @notice get the total assets allocatable @@ -519,12 +528,15 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn super._withdraw(caller, receiver, owner, assets, shares); } + /// @dev gulp positive yield and increment the left interest function _gulp() internal { - ESRSlot memory esrSlotCache = updateInterestAndReturnESRSlotCache(); + ESRSlot memory esrSlotCache = _updateInterestAccrued(); if (totalAssetsDeposited == 0) return; uint256 toGulp = totalAssetsAllocatable() - totalAssetsDeposited - esrSlotCache.interestLeft; + if (toGulp == 0) return; + uint256 maxGulp = type(uint168).max - esrSlotCache.interestLeft; if (toGulp > maxGulp) toGulp = maxGulp; // cap interest, allowing the vault to function @@ -549,7 +561,6 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn // Harvest profits, also gulps and updates interest _harvest(_strategy); - _gulp(); Strategy memory strategyData = strategies[_strategy]; @@ -606,7 +617,6 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn Strategy memory strategyData = strategies[strategy]; if (strategyData.allocated == 0) return; - uint256 sharesBalance = IERC4626(strategy).balanceOf(address(this)); uint256 underlyingBalance = IERC4626(strategy).convertToAssets(sharesBalance); @@ -620,8 +630,19 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn _accruePerformanceFee(yield); } else { - // TODO handle losses - revert NegativeYield(); + uint256 loss = strategyData.allocated - underlyingBalance; + + strategies[strategy].allocated = uint120(underlyingBalance); + totalAllocated -= loss; + + ESRSlot memory esrSlotCache = esrSlot; + if (esrSlotCache.interestLeft >= loss) { + esrSlotCache.interestLeft -= uint168(loss); + } else { + totalAssetsDeposited -= loss - esrSlotCache.interestLeft; + esrSlotCache.interestLeft = 0; + } + esrSlot = esrSlotCache; } } @@ -652,7 +673,7 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn /// @dev Get accrued interest without updating it. /// @param esrSlotCache Cached esrSlot /// @return uint256 accrued interest - function interestAccruedFromCache(ESRSlot memory esrSlotCache) internal view returns (uint256) { + function _interestAccruedFromCache(ESRSlot memory esrSlotCache) internal view returns (uint256) { // If distribution ended, full amount is accrued if (block.timestamp > esrSlotCache.interestSmearEnd) { return esrSlotCache.interestLeft; diff --git a/test/unit/GulpTest.t.sol b/test/unit/GulpTest.t.sol new file mode 100644 index 00000000..3f6da94d --- /dev/null +++ b/test/unit/GulpTest.t.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {FourSixTwoSixAggBase, FourSixTwoSixAgg, console2, EVault} from "../common/FourSixTwoSixAggBase.t.sol"; + +contract GulpTest is FourSixTwoSixAggBase { + uint256 user1InitialBalance = 100000e18; + uint256 amountToDeposit = 10000e18; + + function setUp() public virtual override { + super.setUp(); + + uint256 initialStrategyAllocationPoints = 500e18; + _addStrategy(manager, address(eTST), initialStrategyAllocationPoints); + + assetTST.mint(user1, user1InitialBalance); + + // deposit into aggregator + { + uint256 balanceBefore = fourSixTwoSixAgg.balanceOf(user1); + uint256 totalSupplyBefore = fourSixTwoSixAgg.totalSupply(); + uint256 totalAssetsDepositedBefore = fourSixTwoSixAgg.totalAssetsDeposited(); + uint256 userAssetBalanceBefore = assetTST.balanceOf(user1); + + vm.startPrank(user1); + assetTST.approve(address(fourSixTwoSixAgg), amountToDeposit); + fourSixTwoSixAgg.deposit(amountToDeposit, user1); + vm.stopPrank(); + + assertEq(fourSixTwoSixAgg.balanceOf(user1), balanceBefore + amountToDeposit); + assertEq(fourSixTwoSixAgg.totalSupply(), totalSupplyBefore + amountToDeposit); + assertEq(fourSixTwoSixAgg.totalAssetsDeposited(), totalAssetsDepositedBefore + amountToDeposit); + assertEq(assetTST.balanceOf(user1), userAssetBalanceBefore - amountToDeposit); + } + + // rebalance into strategy + vm.warp(block.timestamp + 86400); + { + FourSixTwoSixAgg.Strategy memory strategyBefore = fourSixTwoSixAgg.getStrategy(address(eTST)); + + assertEq(eTST.convertToAssets(eTST.balanceOf(address(fourSixTwoSixAgg))), strategyBefore.allocated); + + uint256 expectedStrategyCash = fourSixTwoSixAgg.totalAssetsAllocatable() * strategyBefore.allocationPoints + / fourSixTwoSixAgg.totalAllocationPoints(); + + vm.prank(user1); + fourSixTwoSixAgg.rebalance(address(eTST)); + + assertEq(fourSixTwoSixAgg.totalAllocated(), expectedStrategyCash); + assertEq(eTST.convertToAssets(eTST.balanceOf(address(fourSixTwoSixAgg))), expectedStrategyCash); + assertEq((fourSixTwoSixAgg.getStrategy(address(eTST))).allocated, expectedStrategyCash); + } + } + + function testGulpAfterNegativeYieldEqualToInterestLeft() public { + fourSixTwoSixAgg.gulp(); + FourSixTwoSixAgg.ESRSlot memory ers = fourSixTwoSixAgg.getESRSlot(); + assertEq(fourSixTwoSixAgg.interestAccrued(), 0); + assertEq(ers.interestLeft, 0); + + vm.warp(block.timestamp + 2 days); + fourSixTwoSixAgg.gulp(); + assertEq(fourSixTwoSixAgg.interestAccrued(), 0); + + vm.warp(block.timestamp + 1 days); + assertEq(fourSixTwoSixAgg.interestAccrued(), 0); + uint256 yield; + { + uint256 aggrCurrentStrategyShareBalance = eTST.balanceOf(address(fourSixTwoSixAgg)); + uint256 aggrCurrentStrategyUnderlyingBalance = eTST.convertToAssets(aggrCurrentStrategyShareBalance); + uint256 aggrNewStrategyUnderlyingBalance = aggrCurrentStrategyUnderlyingBalance * 11e17 / 1e18; + yield = aggrNewStrategyUnderlyingBalance - aggrCurrentStrategyUnderlyingBalance; + assetTST.mint(address(eTST), yield); + eTST.skim(type(uint256).max, address(fourSixTwoSixAgg)); + } + vm.prank(user1); + fourSixTwoSixAgg.harvest(address(eTST)); + + assertEq(fourSixTwoSixAgg.interestAccrued(), 0); + + vm.warp(block.timestamp + 1 days); + // interest per day 23.809523809523 + assertEq(fourSixTwoSixAgg.interestAccrued(), 23809523809523809523); + fourSixTwoSixAgg.gulp(); + ers = fourSixTwoSixAgg.getESRSlot(); + assertEq(ers.interestLeft, yield - 23809523809523809523); + + // move close to end of smearing + vm.warp(block.timestamp + 11 days); + fourSixTwoSixAgg.gulp(); + ers = fourSixTwoSixAgg.getESRSlot(); + + // mock a decrease of strategy balance by ers.interestLeft + uint256 aggrCurrentStrategyBalance = eTST.balanceOf(address(fourSixTwoSixAgg)); + uint256 aggrCurrentStrategyBalanceAfterNegYield = aggrCurrentStrategyBalance - ers.interestLeft; + vm.mockCall( + address(eTST), + abi.encodeWithSelector(EVault.balanceOf.selector, address(fourSixTwoSixAgg)), + abi.encode(aggrCurrentStrategyBalanceAfterNegYield) + ); + vm.prank(user1); + fourSixTwoSixAgg.harvest(address(eTST)); + } + + function testGulpAfterNegativeYieldBiggerThanInterestLeft() public { + fourSixTwoSixAgg.gulp(); + FourSixTwoSixAgg.ESRSlot memory ers = fourSixTwoSixAgg.getESRSlot(); + assertEq(fourSixTwoSixAgg.interestAccrued(), 0); + assertEq(ers.interestLeft, 0); + + vm.warp(block.timestamp + 2 days); + fourSixTwoSixAgg.gulp(); + assertEq(fourSixTwoSixAgg.interestAccrued(), 0); + + vm.warp(block.timestamp + 1 days); + assertEq(fourSixTwoSixAgg.interestAccrued(), 0); + uint256 yield; + { + uint256 aggrCurrentStrategyShareBalance = eTST.balanceOf(address(fourSixTwoSixAgg)); + uint256 aggrCurrentStrategyUnderlyingBalance = eTST.convertToAssets(aggrCurrentStrategyShareBalance); + uint256 aggrNewStrategyUnderlyingBalance = aggrCurrentStrategyUnderlyingBalance * 11e17 / 1e18; + yield = aggrNewStrategyUnderlyingBalance - aggrCurrentStrategyUnderlyingBalance; + assetTST.mint(address(eTST), yield); + eTST.skim(type(uint256).max, address(fourSixTwoSixAgg)); + } + vm.prank(user1); + fourSixTwoSixAgg.harvest(address(eTST)); + + assertEq(fourSixTwoSixAgg.interestAccrued(), 0); + + vm.warp(block.timestamp + 1 days); + // interest per day 23.809523809523 + assertEq(fourSixTwoSixAgg.interestAccrued(), 23809523809523809523); + fourSixTwoSixAgg.gulp(); + ers = fourSixTwoSixAgg.getESRSlot(); + assertEq(ers.interestLeft, yield - 23809523809523809523); + + // move close to end of smearing + vm.warp(block.timestamp + 11 days); + fourSixTwoSixAgg.gulp(); + ers = fourSixTwoSixAgg.getESRSlot(); + + // mock a decrease of strategy balance by ers.interestLeft + uint256 aggrCurrentStrategyBalance = eTST.balanceOf(address(fourSixTwoSixAgg)); + uint256 aggrCurrentStrategyBalanceAfterNegYield = aggrCurrentStrategyBalance - (ers.interestLeft * 2); + vm.mockCall( + address(eTST), + abi.encodeWithSelector(EVault.balanceOf.selector, address(fourSixTwoSixAgg)), + abi.encode(aggrCurrentStrategyBalanceAfterNegYield) + ); + vm.prank(user1); + fourSixTwoSixAgg.harvest(address(eTST)); + } +} diff --git a/test/unit/HarvestTest.t.sol b/test/unit/HarvestTest.t.sol index 0f68a5ae..e5039756 100644 --- a/test/unit/HarvestTest.t.sol +++ b/test/unit/HarvestTest.t.sol @@ -95,7 +95,7 @@ contract HarvestTest is FourSixTwoSixAggBase { function testHarvestNegativeYield() public { vm.warp(block.timestamp + 86400); - // mock an increase of strategy balance by 10% + // mock a decrease of strategy balance by 10% uint256 aggrCurrentStrategyBalance = eTST.balanceOf(address(fourSixTwoSixAgg)); vm.mockCall( address(eTST), @@ -107,8 +107,89 @@ contract HarvestTest is FourSixTwoSixAggBase { assertTrue(eTST.convertToAssets(eTST.balanceOf(address(fourSixTwoSixAgg))) < strategyBefore.allocated); + vm.prank(user1); + fourSixTwoSixAgg.harvest(address(eTST)); + } + + function testHarvestNegativeYieldAndWithdrawSingleUser() public { + vm.warp(block.timestamp + 86400); + + // mock a decrease of strategy balance by 10% + uint256 aggrCurrentStrategyBalance = eTST.balanceOf(address(fourSixTwoSixAgg)); + uint256 aggrCurrentStrategyBalanceAfterNegYield = aggrCurrentStrategyBalance * 9e17 / 1e18; + vm.mockCall( + address(eTST), + abi.encodeWithSelector(EVault.balanceOf.selector, address(fourSixTwoSixAgg)), + abi.encode(aggrCurrentStrategyBalanceAfterNegYield) + ); + + FourSixTwoSixAgg.Strategy memory strategyBefore = fourSixTwoSixAgg.getStrategy(address(eTST)); + assertTrue(eTST.convertToAssets(eTST.balanceOf(address(fourSixTwoSixAgg))) < strategyBefore.allocated); + uint256 negativeYield = + strategyBefore.allocated - eTST.convertToAssets(eTST.balanceOf(address(fourSixTwoSixAgg))); + + uint256 user1SharesBefore = fourSixTwoSixAgg.balanceOf(user1); + uint256 user1SocializedLoss = user1SharesBefore * negativeYield / fourSixTwoSixAgg.totalSupply(); + uint256 expectedUser1Assets = + user1SharesBefore * amountToDeposit / fourSixTwoSixAgg.totalSupply() - user1SocializedLoss; + uint256 user1AssetTSTBalanceBefore = assetTST.balanceOf(user1); + vm.startPrank(user1); - vm.expectRevert(FourSixTwoSixAgg.NegativeYield.selector); fourSixTwoSixAgg.harvest(address(eTST)); + fourSixTwoSixAgg.redeem(user1SharesBefore, user1, user1); + vm.stopPrank(); + + uint256 user1SharesAfter = fourSixTwoSixAgg.balanceOf(user1); + + assertEq(user1SharesAfter, 0); + assertApproxEqAbs(assetTST.balanceOf(user1), user1AssetTSTBalanceBefore + expectedUser1Assets, 1); + } + + function testHarvestNegativeYieldwMultipleUser() public { + uint256 user2InitialBalance = 5000e18; + assetTST.mint(user2, user2InitialBalance); + // deposit into aggregator + { + vm.startPrank(user2); + assetTST.approve(address(fourSixTwoSixAgg), user2InitialBalance); + fourSixTwoSixAgg.deposit(user2InitialBalance, user2); + vm.stopPrank(); + } + + vm.warp(block.timestamp + 86400); + + // mock a decrease of strategy balance by 10% + uint256 aggrCurrentStrategyBalance = eTST.balanceOf(address(fourSixTwoSixAgg)); + uint256 aggrCurrentStrategyBalanceAfterNegYield = aggrCurrentStrategyBalance * 9e17 / 1e18; + vm.mockCall( + address(eTST), + abi.encodeWithSelector(EVault.balanceOf.selector, address(fourSixTwoSixAgg)), + abi.encode(aggrCurrentStrategyBalanceAfterNegYield) + ); + + FourSixTwoSixAgg.Strategy memory strategyBefore = fourSixTwoSixAgg.getStrategy(address(eTST)); + assertTrue(eTST.convertToAssets(eTST.balanceOf(address(fourSixTwoSixAgg))) < strategyBefore.allocated); + uint256 negativeYield = + strategyBefore.allocated - eTST.convertToAssets(eTST.balanceOf(address(fourSixTwoSixAgg))); + uint256 user1SharesBefore = fourSixTwoSixAgg.balanceOf(user1); + uint256 user1SocializedLoss = user1SharesBefore * negativeYield / fourSixTwoSixAgg.totalSupply(); + uint256 user2SharesBefore = fourSixTwoSixAgg.balanceOf(user2); + uint256 user2SocializedLoss = user2SharesBefore * negativeYield / fourSixTwoSixAgg.totalSupply(); + + uint256 expectedUser1Assets = user1SharesBefore * (amountToDeposit + user2InitialBalance) + / fourSixTwoSixAgg.totalSupply() - user1SocializedLoss; + uint256 expectedUser2Assets = user2SharesBefore * (amountToDeposit + user2InitialBalance) + / fourSixTwoSixAgg.totalSupply() - user2SocializedLoss; + + vm.prank(user1); + fourSixTwoSixAgg.harvest(address(eTST)); + + uint256 user1SharesAfter = fourSixTwoSixAgg.balanceOf(user1); + uint256 user1AssetsAfter = fourSixTwoSixAgg.convertToAssets(user1SharesAfter); + uint256 user2SharesAfter = fourSixTwoSixAgg.balanceOf(user2); + uint256 user2AssetsAfter = fourSixTwoSixAgg.convertToAssets(user2SharesAfter); + + assertApproxEqAbs(user1AssetsAfter, expectedUser1Assets, 1); + assertApproxEqAbs(user2AssetsAfter, expectedUser2Assets, 1); } }