diff --git a/src/FourSixTwoSixAgg.sol b/src/FourSixTwoSixAgg.sol index 89d19dbb..80c32702 100644 --- a/src/FourSixTwoSixAgg.sol +++ b/src/FourSixTwoSixAgg.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import {Context} from "@openzeppelin/utils/Context.sol"; import {ERC20, IERC20} from "@openzeppelin/token/ERC20/ERC20.sol"; import {SafeERC20} from "@openzeppelin/token/ERC20/utils/SafeERC20.sol"; -import {ERC4626, IERC4626} from "@openzeppelin/token/ERC20/extensions/ERC4626.sol"; +import {ERC4626, IERC4626, Math} from "@openzeppelin/token/ERC20/extensions/ERC4626.sol"; import {AccessControlEnumerable} from "@openzeppelin/access/AccessControlEnumerable.sol"; import {EVCUtil, IEVC} from "ethereum-vault-connector/utils/EVCUtil.sol"; import {BalanceForwarder} from "./BalanceForwarder.sol"; @@ -27,6 +27,10 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn error InvalidStrategyAsset(); error StrategyAlreadyExist(); error AlreadyRemoved(); + error PerformanceFeeAlreadySet(); + error MaxPerformanceFeeExceeded(); + error FeeRecipientNotSet(); + error FeeRecipientAlreadySet(); uint8 internal constant REENTRANCYLOCK__UNLOCKED = 1; uint8 internal constant REENTRANCYLOCK__LOCKED = 2; @@ -41,7 +45,12 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn bytes32 public constant STRATEGY_ADDER_ROLE_ADMIN_ROLE = keccak256("STRATEGY_ADDER_ROLE_ADMIN_ROLE"); bytes32 public constant STRATEGY_REMOVER_ROLE = keccak256("STRATEGY_REMOVER_ROLE"); bytes32 public constant STRATEGY_REMOVER_ROLE_ADMIN_ROLE = keccak256("STRATEGY_REMOVER_ROLE_ADMIN_ROLE"); + bytes32 public constant PERFORMANCE_FEE_MANAGER_ROLE = keccak256("PERFORMANCE_FEE_MANAGER_ROLE"); + bytes32 public constant PERFORMANCE_FEE_MANAGER_ROLE_ADMIN_ROLE = + keccak256("PERFORMANCE_FEE_MANAGER_ROLE_ADMIN_ROLE"); + /// @dev The maximum performanceFee the vault can have is 50% + uint256 internal constant MAX_PERFORMANCE_FEE = 0.5e18; uint256 public constant INTEREST_SMEAR = 2 weeks; ESRSlot internal esrSlot; @@ -52,6 +61,10 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn uint256 public totalAllocated; /// @dev Total amount of allocation points across all strategies including the cash reserve. uint256 public totalAllocationPoints; + /// @dev fee rate + uint256 public performanceFee; + /// @dev fee recipient address + address public feeRecipient; /// @dev An array of strategy addresses to withdraw from address[] public withdrawalQueue; @@ -137,6 +150,25 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn _setRoleAdmin(WITHDRAW_QUEUE_REORDERER_ROLE, WITHDRAW_QUEUE_REORDERER_ROLE_ADMIN_ROLE); _setRoleAdmin(STRATEGY_ADDER_ROLE, STRATEGY_ADDER_ROLE_ADMIN_ROLE); _setRoleAdmin(STRATEGY_REMOVER_ROLE, STRATEGY_REMOVER_ROLE_ADMIN_ROLE); + _setRoleAdmin(PERFORMANCE_FEE_MANAGER_ROLE, PERFORMANCE_FEE_MANAGER_ROLE_ADMIN_ROLE); + } + + /// @notice Set performance fee recipient address + /// @notice @param _newFeeRecipient Recipient address + function setFeeRecipient(address _newFeeRecipient) external onlyRole(PERFORMANCE_FEE_MANAGER_ROLE) { + if (_newFeeRecipient == feeRecipient) revert FeeRecipientAlreadySet(); + + feeRecipient = _newFeeRecipient; + } + + /// @notice Set performance fee (1e18 == 100%) + /// @notice @param _newFee Fee rate + function setPerformanceFee(uint256 _newFee) external onlyRole(PERFORMANCE_FEE_MANAGER_ROLE) { + if (_newFee > MAX_PERFORMANCE_FEE) revert MaxPerformanceFeeExceeded(); + if (feeRecipient == address(0)) revert FeeRecipientNotSet(); + if (_newFee == performanceFee) revert PerformanceFeeAlreadySet(); + + performanceFee = _newFee; } /// @notice Enables balance forwarding for sender @@ -426,7 +458,6 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn IERC4626 strategy = IERC4626(withdrawalQueue[i]); _harvest(address(strategy)); - _gulp(); Strategy storage strategyStorage = strategies[address(strategy)]; @@ -447,6 +478,8 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn strategy.withdraw(withdrawAmount, address(this), address(this)); } + _gulp(); + if (assetsRetrieved < assets) { revert NotEnoughAssets(); } @@ -456,6 +489,8 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn function _gulp() internal { ESRSlot memory esrSlotCache = updateInterestAndReturnESRSlotCache(); + + if (totalAssetsDeposited == 0) return; uint256 toGulp = totalAssetsAllocatable() - totalAssetsDeposited - esrSlotCache.interestLeft; uint256 maxGulp = type(uint168).max - esrSlotCache.interestLeft; @@ -550,13 +585,24 @@ contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEn uint256 yield = underlyingBalance - strategyData.allocated; strategies[strategy].allocated = uint120(underlyingBalance); totalAllocated += yield; - // TODO possible performance fee + + _accruePerformanceFee(yield); } else { // TODO handle losses revert NegativeYield(); } } + function _accruePerformanceFee(uint256 _yield) internal { + if (feeRecipient == address(0) || performanceFee == 0) return; + + // `feeAssets` will be rounded down to 0 if `yield * performanceFee < 1e18`. + uint256 feeAssets = Math.mulDiv(_yield, performanceFee, 1e18, Math.Rounding.Down); + uint256 feeShares = _convertToShares(feeAssets, Math.Rounding.Down); + + if (feeShares != 0) _mint(feeRecipient, feeShares); + } + /// @dev Override _afterTokenTransfer hook to call IBalanceTracker.balanceTrackerHook() /// @dev Calling .balanceTrackerHook() passing the address total balance /// @param from Address sending the amount diff --git a/test/common/FourSixTwoSixAggBase.t.sol b/test/common/FourSixTwoSixAggBase.t.sol index c060fcfd..54845492 100644 --- a/test/common/FourSixTwoSixAggBase.t.sol +++ b/test/common/FourSixTwoSixAggBase.t.sol @@ -38,12 +38,14 @@ contract FourSixTwoSixAggBase is EVaultTestBase { fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.WITHDRAW_QUEUE_REORDERER_ROLE_ADMIN_ROLE(), deployer); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.STRATEGY_ADDER_ROLE_ADMIN_ROLE(), deployer); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.STRATEGY_REMOVER_ROLE_ADMIN_ROLE(), deployer); + fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.PERFORMANCE_FEE_MANAGER_ROLE_ADMIN_ROLE(), deployer); // grant roles to manager fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.ALLOCATION_ADJUSTER_ROLE(), manager); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.WITHDRAW_QUEUE_REORDERER_ROLE(), manager); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.STRATEGY_ADDER_ROLE(), manager); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.STRATEGY_REMOVER_ROLE(), manager); + fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.PERFORMANCE_FEE_MANAGER_ROLE(), manager); vm.stopPrank(); } @@ -71,16 +73,22 @@ contract FourSixTwoSixAggBase is EVaultTestBase { fourSixTwoSixAgg.getRoleAdmin(fourSixTwoSixAgg.STRATEGY_REMOVER_ROLE()), fourSixTwoSixAgg.STRATEGY_REMOVER_ROLE_ADMIN_ROLE() ); + assertEq( + fourSixTwoSixAgg.getRoleAdmin(fourSixTwoSixAgg.PERFORMANCE_FEE_MANAGER_ROLE()), + fourSixTwoSixAgg.PERFORMANCE_FEE_MANAGER_ROLE_ADMIN_ROLE() + ); assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.ALLOCATION_ADJUSTER_ROLE_ADMIN_ROLE(), deployer)); assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.WITHDRAW_QUEUE_REORDERER_ROLE_ADMIN_ROLE(), deployer)); assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.STRATEGY_ADDER_ROLE_ADMIN_ROLE(), deployer)); assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.STRATEGY_REMOVER_ROLE_ADMIN_ROLE(), deployer)); + assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.PERFORMANCE_FEE_MANAGER_ROLE_ADMIN_ROLE(), deployer)); assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.ALLOCATION_ADJUSTER_ROLE(), manager)); assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.WITHDRAW_QUEUE_REORDERER_ROLE(), manager)); assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.STRATEGY_ADDER_ROLE(), manager)); assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.STRATEGY_REMOVER_ROLE(), manager)); + assertTrue(fourSixTwoSixAgg.hasRole(fourSixTwoSixAgg.PERFORMANCE_FEE_MANAGER_ROLE(), manager)); } function _addStrategy(address from, address strategy, uint256 allocationPoints) internal { diff --git a/test/e2e/BalanceForwarderE2ETest.t.sol b/test/e2e/BalanceForwarderE2ETest.t.sol index d1597e95..87bbaac8 100644 --- a/test/e2e/BalanceForwarderE2ETest.t.sol +++ b/test/e2e/BalanceForwarderE2ETest.t.sol @@ -39,11 +39,14 @@ contract BalanceForwarderE2ETest is FourSixTwoSixAggBase { fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.WITHDRAW_QUEUE_REORDERER_ROLE_ADMIN_ROLE(), deployer); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.STRATEGY_ADDER_ROLE_ADMIN_ROLE(), deployer); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.STRATEGY_REMOVER_ROLE_ADMIN_ROLE(), deployer); + fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.PERFORMANCE_FEE_MANAGER_ROLE_ADMIN_ROLE(), deployer); + // grant roles to manager fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.ALLOCATION_ADJUSTER_ROLE(), manager); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.WITHDRAW_QUEUE_REORDERER_ROLE(), manager); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.STRATEGY_ADDER_ROLE(), manager); fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.STRATEGY_REMOVER_ROLE(), manager); + fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.PERFORMANCE_FEE_MANAGER_ROLE(), manager); vm.stopPrank(); uint256 initialStrategyAllocationPoints = 500e18; diff --git a/test/e2e/PerformanceFeeE2ETest.t.sol b/test/e2e/PerformanceFeeE2ETest.t.sol new file mode 100644 index 00000000..238dbb93 --- /dev/null +++ b/test/e2e/PerformanceFeeE2ETest.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import { + FourSixTwoSixAggBase, + FourSixTwoSixAgg, + console2, + EVault, + IEVault, + IRMTestDefault, + TestERC20 +} from "../common/FourSixTwoSixAggBase.t.sol"; + +contract PerformanceFeeE2ETest is FourSixTwoSixAggBase { + uint256 user1InitialBalance = 100000e18; + + address feeRecipient; + + function setUp() public virtual override { + super.setUp(); + + uint256 initialStrategyAllocationPoints = 500e18; + _addStrategy(manager, address(eTST), initialStrategyAllocationPoints); + + assetTST.mint(user1, user1InitialBalance); + + feeRecipient = makeAddr("FEE_RECIPIENT"); + } + + function testSetPerformanceFee() public { + assertEq(fourSixTwoSixAgg.performanceFee(), 0); + + uint256 newPerformanceFee = 3e17; + + vm.startPrank(manager); + fourSixTwoSixAgg.setFeeRecipient(feeRecipient); + fourSixTwoSixAgg.setPerformanceFee(newPerformanceFee); + vm.stopPrank(); + + assertEq(fourSixTwoSixAgg.performanceFee(), newPerformanceFee); + assertEq(fourSixTwoSixAgg.feeRecipient(), feeRecipient); + } + + function testHarvestWithFeeEnabled() public { + uint256 newPerformanceFee = 3e17; + + vm.startPrank(manager); + fourSixTwoSixAgg.setFeeRecipient(feeRecipient); + fourSixTwoSixAgg.setPerformanceFee(newPerformanceFee); + vm.stopPrank(); + + uint256 amountToDeposit = 10000e18; + + // 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); + } + + vm.warp(block.timestamp + 86400); + // mock an increase of strategy balance by 10% + 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)); + } + + uint256 expectedPerformanceFee = yield * fourSixTwoSixAgg.performanceFee() / 1e18; + uint256 expectedPerformanceFeeShares = fourSixTwoSixAgg.convertToShares(expectedPerformanceFee); + + // harvest + vm.prank(user1); + fourSixTwoSixAgg.harvest(address(eTST)); + + assertEq(fourSixTwoSixAgg.balanceOf(feeRecipient), expectedPerformanceFeeShares); + + // full withdraw, will have to withdraw from strategy as cash reserve is not enough + { + uint256 amountToWithdraw = fourSixTwoSixAgg.balanceOf(user1); + uint256 totalAssetsDepositedBefore = fourSixTwoSixAgg.totalAssetsDeposited(); + uint256 aggregatorTotalSupplyBefore = fourSixTwoSixAgg.totalSupply(); + uint256 user1AssetTSTBalanceBefore = assetTST.balanceOf(user1); + uint256 expectedAssetTST = fourSixTwoSixAgg.convertToAssets(fourSixTwoSixAgg.balanceOf(user1)); + + vm.prank(user1); + fourSixTwoSixAgg.redeem(amountToWithdraw, user1, user1); + + assertApproxEqAbs(fourSixTwoSixAgg.totalAssetsDeposited(), totalAssetsDepositedBefore - expectedAssetTST, 1); + assertEq(fourSixTwoSixAgg.totalSupply(), aggregatorTotalSupplyBefore - amountToWithdraw); + assertApproxEqAbs(assetTST.balanceOf(user1), user1AssetTSTBalanceBefore + expectedAssetTST, 1); + } + + // full withdraw of recipient fees + { + uint256 totalAssetsDepositedBefore = fourSixTwoSixAgg.totalAssetsDeposited(); + uint256 assetTSTBalanceBefore = assetTST.balanceOf(feeRecipient); + + uint256 feeShares = fourSixTwoSixAgg.balanceOf(feeRecipient); + uint256 expectedAssets = fourSixTwoSixAgg.convertToAssets(feeShares); + vm.prank(feeRecipient); + fourSixTwoSixAgg.redeem(feeShares, feeRecipient, feeRecipient); + + assertApproxEqAbs(fourSixTwoSixAgg.totalAssetsDeposited(), totalAssetsDepositedBefore - expectedAssets, 1); + assertEq(fourSixTwoSixAgg.totalSupply(), 0); + assertApproxEqAbs(assetTST.balanceOf(feeRecipient), assetTSTBalanceBefore + expectedAssets, 1); + } + } +}