diff --git a/.gitmodules b/.gitmodules index 1d3068ab..ae5ee093 100644 --- a/.gitmodules +++ b/.gitmodules @@ -8,3 +8,6 @@ path = lib/euler-vault-kit url = https://github.com/euler-xyz/euler-vault-kit +[submodule "lib/reward-streams"] + path = lib/reward-streams + url = https://github.com/euler-xyz/reward-streams diff --git a/foundry.toml b/foundry.toml index e199dc18..da8c522e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,7 +5,7 @@ libs = ["lib"] test = 'test' optimizer = true optimizer_runs = 20_000 -solc = "0.8.23" +# solc = "0.8.0" gas_reports = ["*"] fs_permissions = [{ access = "read", path = "./"}] diff --git a/lib/reward-streams b/lib/reward-streams new file mode 160000 index 00000000..66aafcd3 --- /dev/null +++ b/lib/reward-streams @@ -0,0 +1 @@ +Subproject commit 66aafcd3b00d01b180648f597218ec3c6c67e34a diff --git a/remappings.txt b/remappings.txt index f65498dc..1ad598ca 100644 --- a/remappings.txt +++ b/remappings.txt @@ -2,6 +2,7 @@ ds-test/=lib/ethereum-vault-connector/lib/forge-std/lib/ds-test/src/ erc4626-tests/=lib/ethereum-vault-connector/lib/openzeppelin-contracts/lib/erc4626-tests/ ethereum-vault-connector/=lib/ethereum-vault-connector/src/ forge-std/=lib/forge-std/src/ -openzeppelin-contracts/=lib/ethereum-vault-connector/lib/openzeppelin-contracts/contracts/ -openzeppelin/=lib/ethereum-vault-connector/lib/openzeppelin-contracts/contracts/ evk/=lib/euler-vault-kit/ +reward-streams=lib/reward-streams/src +openzeppelin-contracts/=lib/reward-streams/lib/openzeppelin-contracts/contracts +@openzeppelin/=lib/ethereum-vault-connector/lib/openzeppelin-contracts/contracts/ diff --git a/src/BalanceForwarder.sol b/src/BalanceForwarder.sol new file mode 100644 index 00000000..e0e67622 --- /dev/null +++ b/src/BalanceForwarder.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {IBalanceForwarder} from "./interface/IBalanceForwarder.sol"; +import {IBalanceTracker} from "./interface/IBalanceTracker.sol"; + +/// @title BalanceForwarderModule +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice A generic contract to integrate with https://github.com/euler-xyz/reward-streams +abstract contract BalanceForwarder is IBalanceForwarder { + error NotSupported(); + + IBalanceTracker public immutable balanceTracker; + + mapping(address => bool) internal isBalanceForwarderEnabled; + + event EnableBalanceForwarder(address indexed _user); + event DisableBalanceForwarder(address indexed _user); + + constructor(address _balanceTracker) { + balanceTracker = IBalanceTracker(_balanceTracker); + } + + /// @notice Enables balance forwarding for the authenticated account + /// @dev Only the authenticated account can enable balance forwarding for itself + /// @dev Should call the IBalanceTracker hook with the current account's balance + function enableBalanceForwarder() external virtual; + + /// @notice Disables balance forwarding for the authenticated account + /// @dev Only the authenticated account can disable balance forwarding for itself + /// @dev Should call the IBalanceTracker hook with the account's balance of 0 + function disableBalanceForwarder() external virtual; + + /// @notice Retrieve the address of rewards contract, tracking changes in account's balances + /// @return The balance tracker address + function balanceTrackerAddress() external view returns (address) { + return address(balanceTracker); + } + + /// @notice Retrieves boolean indicating if the account opted in to forward balance changes to the rewards contract + /// @param _account Address to query + /// @return True if balance forwarder is enabled + function balanceForwarderEnabled(address _account) external view returns (bool) { + return isBalanceForwarderEnabled[_account]; + } + + function _enableBalanceForwarder(address _sender, uint256 _senderBalance) internal { + if (address(balanceTracker) == address(0)) revert NotSupported(); + + isBalanceForwarderEnabled[_sender] = true; + IBalanceTracker(balanceTracker).balanceTrackerHook(_sender, _senderBalance, false); + + emit EnableBalanceForwarder(_sender); + } + + /// @notice Disables balance forwarding for the authenticated account + /// @dev Only the authenticated account can disable balance forwarding for itself + /// @dev Should call the IBalanceTracker hook with the account's balance of 0 + function _disableBalanceForwarder(address _sender) internal { + if (address(balanceTracker) == address(0)) revert NotSupported(); + + isBalanceForwarderEnabled[_sender] = false; + IBalanceTracker(balanceTracker).balanceTrackerHook(_sender, 0, false); + + emit DisableBalanceForwarder(_sender); + } +} diff --git a/src/FourSixTwoSixAgg.sol b/src/FourSixTwoSixAgg.sol index 6ebeac5a..eea68b59 100644 --- a/src/FourSixTwoSixAgg.sol +++ b/src/FourSixTwoSixAgg.sol @@ -1,12 +1,13 @@ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.0; -import {Context} from "openzeppelin-contracts/utils/Context.sol"; -import {ERC20, IERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; -import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; -import {ERC4626, IERC4626} from "openzeppelin-contracts/token/ERC20/extensions/ERC4626.sol"; +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 {AccessControlEnumerable} from "@openzeppelin/access/AccessControlEnumerable.sol"; import {EVCUtil, IEVC} from "ethereum-vault-connector/utils/EVCUtil.sol"; -import {AccessControlEnumerable} from "openzeppelin-contracts/access/AccessControlEnumerable.sol"; +import {BalanceForwarder} from "./BalanceForwarder.sol"; // @note Do NOT use with fee on transfer tokens // @note Do NOT use with rebasing tokens @@ -14,7 +15,7 @@ import {AccessControlEnumerable} from "openzeppelin-contracts/access/AccessContr // @note expired by Yearn v3 ❤️ // TODO addons for reward stream support // TODO custom withdraw queue support -contract FourSixTwoSixAgg is EVCUtil, ERC4626, AccessControlEnumerable { +contract FourSixTwoSixAgg is BalanceForwarder, EVCUtil, ERC4626, AccessControlEnumerable { using SafeERC20 for IERC20; error Reentrancy(); @@ -106,13 +107,14 @@ contract FourSixTwoSixAgg is EVCUtil, ERC4626, AccessControlEnumerable { /// @param _initialStrategiesAllocationPoints An array of initial strategies allocation points constructor( IEVC _evc, + address _balanceTracker, address _asset, string memory _name, string memory _symbol, uint256 _initialCashAllocationPoints, address[] memory _initialStrategies, uint256[] memory _initialStrategiesAllocationPoints - ) EVCUtil(address(_evc)) ERC4626(IERC20(_asset)) ERC20(_name, _symbol) { + ) BalanceForwarder(_balanceTracker) EVCUtil(address(_evc)) ERC4626(IERC20(_asset)) ERC20(_name, _symbol) { esrSlot.locked = REENTRANCYLOCK__UNLOCKED; if (_initialStrategies.length != _initialStrategiesAllocationPoints.length) revert ArrayLengthMismatch(); @@ -139,6 +141,21 @@ contract FourSixTwoSixAgg is EVCUtil, ERC4626, AccessControlEnumerable { _setRoleAdmin(STRATEGY_REMOVER_ROLE, STRATEGY_REMOVER_ROLE_ADMIN_ROLE); } + /// @notice Enables balance forwarding for sender + /// @dev Should call the IBalanceTracker hook with the current user's balance + function enableBalanceForwarder() external override nonReentrant { + address user = _msgSender(); + uint256 userBalance = this.balanceOf(user); + + _enableBalanceForwarder(user, userBalance); + } + + /// @notice Disables balance forwarding for the sender + /// @dev Should call the IBalanceTracker hook with the account's balance of 0 + function disableBalanceForwarder() external override nonReentrant { + _disableBalanceForwarder(_msgSender()); + } + /// @notice Rebalance strategy allocation. /// @dev This function will first harvest yield, gulps and update interest. /// @dev If current allocation is greater than target allocation, the aggregator will withdraw the excess assets. @@ -391,6 +408,7 @@ contract FourSixTwoSixAgg is EVCUtil, ERC4626, AccessControlEnumerable { /// @dev See {IERC4626-_deposit}. function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { totalAssetsDeposited += assets; + super._deposit(caller, receiver, assets, shares); } @@ -537,6 +555,20 @@ contract FourSixTwoSixAgg is EVCUtil, ERC4626, AccessControlEnumerable { } } + /// @dev Override _afterTokenTransfer hook to call IBalanceTracker.balanceTrackerHook() + /// @dev Calling .balanceTrackerHook() passing the address total balance + /// @param from Address sending the amount + /// @param to Address receiving the amount + function _afterTokenTransfer(address from, address to, uint256 /*amount*/ ) internal override { + if ((from != address(0)) && (isBalanceForwarderEnabled[from])) { + balanceTracker.balanceTrackerHook(from, super.balanceOf(from), false); + } + + if ((to != address(0)) && (isBalanceForwarderEnabled[to])) { + balanceTracker.balanceTrackerHook(to, super.balanceOf(to), false); + } + } + /// @dev Get accrued interest without updating it. /// @param esrSlotCache Cached esrSlot /// @return uint256 accrued interest diff --git a/src/interface/IBalanceForwarder.sol b/src/interface/IBalanceForwarder.sol new file mode 100644 index 00000000..e88cdae1 --- /dev/null +++ b/src/interface/IBalanceForwarder.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +interface IBalanceForwarder { + function balanceTrackerAddress() external view returns (address); + + function balanceForwarderEnabled(address account) external view returns (bool); + + function enableBalanceForwarder() external; + + function disableBalanceForwarder() external; +} diff --git a/src/interface/IBalanceTracker.sol b/src/interface/IBalanceTracker.sol new file mode 100644 index 00000000..82a47d7b --- /dev/null +++ b/src/interface/IBalanceTracker.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.0; + +/// @title IBalanceTracker +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice Provides an interface for tracking the balance of accounts. +interface IBalanceTracker { + /// @notice Executes the balance tracking hook for an account. + /// @dev This function is called by the Balance Forwarder contract which was enabled for the account. This function + /// must be called with the current balance of the account when enabling the balance forwarding for it. This + /// function must be called with 0 balance of the account when disabling the balance forwarding for it. This + /// function allows to be called on zero balance transfers, when the newAccountBalance is the same as the previous + /// one. To prevent DOS attacks, forfeitRecentReward should be used appropriately. + /// @param account The account address to execute the hook for. + /// @param newAccountBalance The new balance of the account. + /// @param forfeitRecentReward Whether to forfeit the most recent reward and not update the accumulator. + function balanceTrackerHook(address account, uint256 newAccountBalance, bool forfeitRecentReward) external; +} diff --git a/test/common/FourSixTwoSixAggBase.t.sol b/test/common/FourSixTwoSixAggBase.t.sol index bf3298d2..9730a538 100644 --- a/test/common/FourSixTwoSixAggBase.t.sol +++ b/test/common/FourSixTwoSixAggBase.t.sol @@ -24,6 +24,7 @@ contract FourSixTwoSixAggBase is EVaultTestBase { vm.startPrank(deployer); fourSixTwoSixAgg = new FourSixTwoSixAgg( evc, + address(0), address(assetTST), "assetTST_Agg", "assetTST_Agg", diff --git a/test/e2e/BalanceForwarderE2ETest.t.sol b/test/e2e/BalanceForwarderE2ETest.t.sol new file mode 100644 index 00000000..d1597e95 --- /dev/null +++ b/test/e2e/BalanceForwarderE2ETest.t.sol @@ -0,0 +1,177 @@ +// 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"; +import {TrackingRewardStreams} from "reward-streams/TrackingRewardStreams.sol"; + +contract BalanceForwarderE2ETest is FourSixTwoSixAggBase { + uint256 user1InitialBalance = 100000e18; + + address trackingReward; + + function setUp() public virtual override { + super.setUp(); + + vm.startPrank(deployer); + trackingReward = address(new TrackingRewardStreams(address(evc), 2 weeks)); + + fourSixTwoSixAgg = new FourSixTwoSixAgg( + evc, + trackingReward, + address(assetTST), + "assetTST_Agg", + "assetTST_Agg", + CASH_RESERVE_ALLOCATION_POINTS, + new address[](0), + new uint256[](0) + ); + + // grant admin roles to deployer + fourSixTwoSixAgg.grantRole(fourSixTwoSixAgg.ALLOCATION_ADJUSTER_ROLE_ADMIN_ROLE(), deployer); + 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); + // 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); + vm.stopPrank(); + + uint256 initialStrategyAllocationPoints = 500e18; + _addStrategy(manager, address(eTST), initialStrategyAllocationPoints); + assetTST.mint(user1, user1InitialBalance); + + // deposit into aggregator + uint256 amountToDeposit = 10000e18; + { + 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); + } + } + + function testBalanceForwarderrAddress_Integrity() public view { + assertEq(address(fourSixTwoSixAgg.balanceTracker()), trackingReward); + } + + function testEnableBalanceForwarder() public { + vm.prank(user1); + fourSixTwoSixAgg.enableBalanceForwarder(); + + assertTrue(fourSixTwoSixAgg.balanceForwarderEnabled(user1)); + assertEq( + TrackingRewardStreams(trackingReward).balanceOf(user1, address(fourSixTwoSixAgg)), + fourSixTwoSixAgg.balanceOf(user1) + ); + } + + function testDisableBalanceForwarder() public { + vm.prank(user1); + fourSixTwoSixAgg.enableBalanceForwarder(); + + assertTrue(fourSixTwoSixAgg.balanceForwarderEnabled(user1)); + + vm.prank(user1); + fourSixTwoSixAgg.disableBalanceForwarder(); + + assertFalse(fourSixTwoSixAgg.balanceForwarderEnabled(user1)); + assertEq(TrackingRewardStreams(trackingReward).balanceOf(user1, address(fourSixTwoSixAgg)), 0); + } + + function testHookWhenReceiverEnabled() public { + vm.prank(user1); + fourSixTwoSixAgg.enableBalanceForwarder(); + + // deposit into aggregator + uint256 amountToDeposit = 10000e18; + { + 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); + + assertEq( + TrackingRewardStreams(trackingReward).balanceOf(user1, address(fourSixTwoSixAgg)), + fourSixTwoSixAgg.balanceOf(user1) + ); + } + } + + function testHookWhenSenderEnabled() public { + vm.prank(user1); + fourSixTwoSixAgg.enableBalanceForwarder(); + + // deposit into aggregator + uint256 amountToDeposit = 10000e18; + { + 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); + + assertEq( + TrackingRewardStreams(trackingReward).balanceOf(user1, address(fourSixTwoSixAgg)), + fourSixTwoSixAgg.balanceOf(user1) + ); + } + + { + uint256 amountToWithdraw = fourSixTwoSixAgg.balanceOf(user1); + uint256 totalAssetsDepositedBefore = fourSixTwoSixAgg.totalAssetsDeposited(); + uint256 aggregatorTotalSupplyBefore = fourSixTwoSixAgg.totalSupply(); + uint256 user1AssetTSTBalanceBefore = assetTST.balanceOf(user1); + + vm.prank(user1); + fourSixTwoSixAgg.redeem(amountToWithdraw, user1, user1); + + assertEq(eTST.balanceOf(address(fourSixTwoSixAgg)), 0); + assertEq(fourSixTwoSixAgg.totalAssetsDeposited(), totalAssetsDepositedBefore - amountToWithdraw); + assertEq(fourSixTwoSixAgg.totalSupply(), aggregatorTotalSupplyBefore - amountToWithdraw); + assertEq( + assetTST.balanceOf(user1), + user1AssetTSTBalanceBefore + fourSixTwoSixAgg.convertToAssets(amountToWithdraw) + ); + assertEq(TrackingRewardStreams(trackingReward).balanceOf(user1, address(fourSixTwoSixAgg)), 0); + } + } +}