diff --git a/src/Bases/Staker/ITokenizedStaker.sol b/src/Bases/Staker/ITokenizedStaker.sol new file mode 100644 index 0000000..94d3f07 --- /dev/null +++ b/src/Bases/Staker/ITokenizedStaker.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.18; + +import {IStrategy} from "@tokenized-strategy/interfaces/IStrategy.sol"; + +interface ITokenizedStaker is IStrategy { + struct Reward { + /// @notice The only address able to top up rewards for a token (aka notifyRewardAmount()). + address rewardsDistributor; + /// @notice The duration of our rewards distribution for staking, default is 7 days. + uint256 rewardsDuration; + /// @notice The end (timestamp) of our current or most recent reward period. + uint256 periodFinish; + /// @notice The distribution rate of reward token per second. + uint256 rewardRate; + /** + * @notice The last time rewards were updated, triggered by updateReward() or notifyRewardAmount(). + * @dev Will be the timestamp of the update or the end of the period, whichever is earlier. + */ + uint256 lastUpdateTime; + /** + * @notice The most recent stored amount for rewardPerToken(). + * @dev Updated every time anyone calls the updateReward() modifier. + */ + uint256 rewardPerTokenStored; + } + + /* ========== EVENTS ========== */ + + event RewardAdded(address rewardToken, uint256 reward); + event RewardPaid(address indexed user, address rewardToken, uint256 reward); + event RewardsDurationUpdated(address rewardToken, uint256 newDuration); + + /* ========== STATE VARIABLES ========== */ + + function rewardTokens(uint256 index) external view returns (address); + + function rewardToken(address) external view returns (address); + + function periodFinish(address) external view returns (uint256); + + function rewardRate(address) external view returns (uint256); + + function rewardsDuration(address) external view returns (uint256); + + function lastUpdateTime(address) external view returns (uint256); + + function rewardPerTokenStored(address) external view returns (uint256); + + function userRewardPerTokenPaid( + address account, + address rewardToken + ) external view returns (uint256); + + function rewards( + address account, + address rewardToken + ) external view returns (uint256); + + /* ========== FUNCTIONS ========== */ + function lastTimeRewardApplicable( + address rewardToken + ) external view returns (uint256); + + function rewardPerToken( + address rewardToken + ) external view returns (uint256); + + function earned( + address account, + address rewardToken + ) external view returns (uint256); + + function getRewardForDuration( + address rewardToken + ) external view returns (uint256); + + function notifyRewardAmount(address rewardToken, uint256 reward) external; + + function getReward() external; + + function exit() external; + + function setRewardsDuration( + address rewardToken, + uint256 _rewardsDuration + ) external; + + function rewardData( + address rewardToken + ) external view returns (Reward memory); + + function addReward( + address rewardToken, + address rewardsDistributor, + uint256 rewardsDuration + ) external; + + function getOneReward(address rewardToken) external; +} diff --git a/src/Bases/Staker/TokenizedStaker.sol b/src/Bases/Staker/TokenizedStaker.sol new file mode 100644 index 0000000..92fafb3 --- /dev/null +++ b/src/Bases/Staker/TokenizedStaker.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.18; + +import {BaseHooks, ERC20} from "../Hooks/BaseHooks.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +abstract contract TokenizedStaker is BaseHooks, ReentrancyGuard { + using SafeERC20 for ERC20; + + struct Reward { + /// @notice The only address able to top up rewards for a token (aka notifyRewardAmount()). + address rewardsDistributor; + /// @notice The duration of our rewards distribution for staking, default is 7 days. + uint256 rewardsDuration; + /// @notice The end (timestamp) of our current or most recent reward period. + uint256 periodFinish; + /// @notice The distribution rate of reward token per second. + uint256 rewardRate; + /** + * @notice The last time rewards were updated, triggered by updateReward() or notifyRewardAmount(). + * @dev Will be the timestamp of the update or the end of the period, whichever is earlier. + */ + uint256 lastUpdateTime; + /** + * @notice The most recent stored amount for rewardPerToken(). + * @dev Updated every time anyone calls the updateReward() modifier. + */ + uint256 rewardPerTokenStored; + } + + /* ========== EVENTS ========== */ + + event RewardAdded(address rewardToken, uint256 reward); + event RewardPaid(address indexed user, address rewardToken, uint256 reward); + event RewardsDurationUpdated(address rewardToken, uint256 newDuration); + + /* ========== MODIFIERS ========== */ + + modifier updateReward(address account) { + _updateReward(account); + _; + } + + function _updateReward(address account) internal virtual { + for (uint256 i = 0; i < rewardTokens.length; i++) { + address rewardToken = rewardTokens[i]; + rewardData[rewardToken].rewardPerTokenStored = rewardPerToken( + rewardToken + ); + rewardData[rewardToken].lastUpdateTime = lastTimeRewardApplicable( + rewardToken + ); + if (account != address(0)) { + rewards[account][rewardToken] = earned(account, rewardToken); + userRewardPerTokenPaid[account][rewardToken] = rewardData[ + rewardToken + ].rewardPerTokenStored; + } + } + } + + /// @notice Array containing the addresses of all of our reward tokens. + address[] public rewardTokens; + + /// @notice The address of our reward token => reward info. + mapping(address => Reward) public rewardData; + + /** + * @notice The amount of rewards allocated to a user per whole token staked. + * @dev Note that this is not the same as amount of rewards claimed. Mapping order is user -> reward token -> amount + */ + mapping(address => mapping(address => uint256)) + public userRewardPerTokenPaid; + + /** + * @notice The amount of unclaimed rewards an account is owed. + * @dev Mapping order is user -> reward token -> amount + */ + mapping(address => mapping(address => uint256)) public rewards; + + constructor(address _asset, string memory _name) BaseHooks(_asset, _name) {} + + function _preDepositHook( + uint256 /* assets */, + uint256 /* shares */, + address receiver + ) internal virtual override { + _updateReward(receiver); + } + + function _preWithdrawHook( + uint256 /* assets */, + uint256 /* shares */, + address /* receiver */, + address owner, + uint256 /* maxLoss */ + ) internal virtual override { + _updateReward(owner); + } + + function _preTransferHook( + address from, + address to, + uint256 /* amount */ + ) internal virtual override { + _updateReward(from); + _updateReward(to); + } + + /// @notice Either the current timestamp or end of the most recent period. + function lastTimeRewardApplicable( + address rewardToken + ) public view virtual returns (uint256) { + return + block.timestamp < rewardData[rewardToken].periodFinish + ? block.timestamp + : rewardData[rewardToken].periodFinish; + } + + /// @notice Reward paid out per whole token. + function rewardPerToken( + address rewardToken + ) public view virtual returns (uint256) { + uint256 _totalSupply = TokenizedStrategy.totalSupply(); + if (_totalSupply == 0) { + return rewardData[rewardToken].rewardPerTokenStored; + } + + if (TokenizedStrategy.isShutdown()) { + return 0; + } + + return + rewardData[rewardToken].rewardPerTokenStored + + (((lastTimeRewardApplicable(rewardToken) - + rewardData[rewardToken].lastUpdateTime) * + rewardData[rewardToken].rewardRate * + 1e18) / _totalSupply); + } + + /// @notice Amount of reward token pending claim by an account. + function earned( + address account, + address rewardToken + ) public view virtual returns (uint256) { + if (TokenizedStrategy.isShutdown()) { + return 0; + } + + return + (TokenizedStrategy.balanceOf(account) * + (rewardPerToken(rewardToken) - + userRewardPerTokenPaid[account][rewardToken])) / + 1e18 + + rewards[account][rewardToken]; + } + + /// @notice Reward tokens emitted over the entire rewardsDuration. + function getRewardForDuration( + address rewardToken + ) external view virtual returns (uint256) { + return + rewardData[rewardToken].rewardRate * + rewardData[rewardToken].rewardsDuration; + } + + /// @notice Notify staking contract that it has more reward to account for. + /// @dev Reward tokens must be sent to contract before notifying. May only be called + /// by rewards distribution role. + /// @param rewardToken Address of the reward token. + /// @param reward Amount of reward tokens to add. + function notifyRewardAmount( + address rewardToken, + uint256 reward + ) external virtual onlyManagement { + _notifyRewardAmount(rewardToken, reward); + } + + /// @notice Notify staking contract that it has more reward to account for. + /// @dev Reward tokens must be sent to contract before notifying. May only be called + /// by rewards distribution role. + /// @param rewardToken Address of the reward token. + /// @param reward Amount of reward tokens to add. + function _notifyRewardAmount( + address rewardToken, + uint256 reward + ) internal virtual updateReward(address(0)) { + if (block.timestamp >= rewardData[rewardToken].periodFinish) { + rewardData[rewardToken].rewardRate = + reward / + rewardData[rewardToken].rewardsDuration; + } else { + uint256 remaining = rewardData[rewardToken].periodFinish - + block.timestamp; + uint256 leftover = remaining * rewardData[rewardToken].rewardRate; + rewardData[rewardToken].rewardRate = + reward + + leftover / + rewardData[rewardToken].rewardsDuration; + } + + rewardData[rewardToken].lastUpdateTime = block.timestamp; + rewardData[rewardToken].periodFinish = + block.timestamp + + rewardData[rewardToken].rewardsDuration; + emit RewardAdded(rewardToken, reward); + } + + /// @notice Claim any earned reward tokens. + /// @dev Can claim rewards even if no tokens still staked. + function getReward() public virtual nonReentrant updateReward(msg.sender) { + for (uint256 i = 0; i < rewardTokens.length; i++) { + address rewardToken = rewardTokens[i]; + uint256 reward = rewards[msg.sender][rewardToken]; + if (reward > 0) { + rewards[msg.sender][rewardToken] = 0; + ERC20(rewardToken).safeTransfer(msg.sender, reward); + emit RewardPaid(msg.sender, rewardToken, reward); + } + } + } + + /** + * @notice Claim any one earned reward token. + * @dev Can claim rewards even if no tokens still staked. + * @param _rewardsToken Address of the rewards token to claim. + */ + function getOneReward( + address _rewardsToken + ) external nonReentrant updateReward(msg.sender) { + uint256 reward = rewards[msg.sender][_rewardsToken]; + if (reward > 0) { + rewards[msg.sender][_rewardsToken] = 0; + ERC20(_rewardsToken).safeTransfer(msg.sender, reward); + emit RewardPaid(msg.sender, _rewardsToken, reward); + } + } + + /// @notice Unstake all of the sender's tokens and claim any outstanding rewards. + function exit() external virtual { + redeem( + TokenizedStrategy.balanceOf(msg.sender), + msg.sender, + msg.sender, + 10_000 + ); + getReward(); + } + + /** + * @notice Add a new reward token to the staking contract. + * @dev May only be called by owner, and can't be set to zero address. Add reward tokens sparingly, as each new one + * will increase gas costs. This must be set before notifyRewardAmount can be used. + * @param _rewardsToken Address of the rewards token. + * @param _rewardsDistributor Address of the rewards distributor. + * @param _rewardsDuration The duration of our rewards distribution for staking in seconds. + */ + function addReward( + address _rewardsToken, + address _rewardsDistributor, + uint256 _rewardsDuration + ) external onlyManagement { + _addReward(_rewardsToken, _rewardsDistributor, _rewardsDuration); + } + + /// @dev Internal function to add a new reward token to the staking contract. + function _addReward( + address _rewardsToken, + address _rewardsDistributor, + uint256 _rewardsDuration + ) internal { + require( + _rewardsToken != address(0) && _rewardsDistributor != address(0), + "No zero address" + ); + require(_rewardsDuration > 0, "Must be >0"); + require( + rewardData[_rewardsToken].rewardsDuration == 0, + "Reward already added" + ); + + rewardTokens.push(_rewardsToken); + rewardData[_rewardsToken].rewardsDistributor = _rewardsDistributor; + rewardData[_rewardsToken].rewardsDuration = _rewardsDuration; + } + + /// @notice Set the duration of our rewards period. + /// @dev May only be called by owner, and must be done after most recent period ends. + /// @param _rewardsDuration New length of period in seconds. + function setRewardsDuration( + address rewardToken, + uint256 _rewardsDuration + ) external virtual onlyManagement { + _setRewardsDuration(rewardToken, _rewardsDuration); + } + + function _setRewardsDuration( + address rewardToken, + uint256 _rewardsDuration + ) internal virtual { + require( + block.timestamp > rewardData[rewardToken].periodFinish, + "Previous rewards period must be complete before changing the duration for the new period" + ); + rewardData[rewardToken].rewardsDuration = _rewardsDuration; + emit RewardsDurationUpdated(rewardToken, _rewardsDuration); + } +} diff --git a/src/test/TokenizedStaker.t.sol b/src/test/TokenizedStaker.t.sol new file mode 100644 index 0000000..4927453 --- /dev/null +++ b/src/test/TokenizedStaker.t.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import {Setup, IStrategy, SafeERC20, ERC20} from "./utils/Setup.sol"; + +import {MockTokenizedStaker, IMockTokenizedStaker} from "./mocks/MockTokenizedStaker.sol"; + +contract TokenizedStakerTest is Setup { + IMockTokenizedStaker public staker; + + ERC20 public rewardToken; + ERC20 public rewardToken2; + uint256 public duration = 10_000; + + function setUp() public override { + super.setUp(); + + rewardToken = ERC20(tokenAddrs["YFI"]); + rewardToken2 = ERC20(tokenAddrs["WETH"]); + + staker = IMockTokenizedStaker( + address( + new MockTokenizedStaker(address(asset), "MockTokenizedStaker") + ) + ); + + staker.setKeeper(keeper); + staker.setPerformanceFeeRecipient(performanceFeeRecipient); + staker.setPendingManagement(management); + // Accept management. + vm.prank(management); + staker.acceptManagement(); + + // Add initial reward token + vm.prank(management); + staker.addReward(address(rewardToken), management, duration); + } + + function test_TokenizedStakerSetup() public { + assertEq(staker.asset(), address(asset)); + assertEq(staker.rewardTokens(0), address(rewardToken)); + assertEq(staker.rewardPerToken(address(rewardToken)), 0); + assertEq(staker.lastTimeRewardApplicable(address(rewardToken)), 0); + assertEq( + staker.userRewardPerTokenPaid(address(0), address(rewardToken)), + 0 + ); + assertEq(staker.userRewardPerTokenPaid(user, address(rewardToken)), 0); + assertEq(staker.rewards(address(0), address(rewardToken)), 0); + assertEq(staker.rewards(user, address(rewardToken)), 0); + assertEq(staker.earned(user, address(rewardToken)), 0); + + IMockTokenizedStaker.Reward memory rewardData = staker.rewardData( + address(rewardToken) + ); + assertEq(rewardData.periodFinish, 0); + assertEq(rewardData.rewardRate, 0); + assertEq(rewardData.rewardsDuration, duration); + assertEq(rewardData.rewardsDistributor, management); + } + + function test_addReward() public { + vm.prank(management); + staker.addReward(address(rewardToken2), management, duration); + + assertEq(staker.rewardTokens(1), address(rewardToken2)); + + IMockTokenizedStaker.Reward memory rewardData = staker.rewardData( + address(rewardToken2) + ); + assertEq(rewardData.rewardsDuration, duration); + assertEq(rewardData.rewardsDistributor, management); + + // Can't add same token twice + vm.expectRevert("Reward already added"); + vm.prank(management); + staker.addReward(address(rewardToken2), management, duration); + + // Can't add zero address + vm.expectRevert("No zero address"); + vm.prank(management); + staker.addReward(address(0), management, duration); + } + + function test_TokenizedStaker_notifyRewardAmount() public { + uint256 amount = 1_000e18; + mintAndDepositIntoStrategy(IStrategy(address(staker)), user, amount); + + assertEq(staker.rewardPerToken(address(rewardToken)), 0); + assertEq(staker.lastTimeRewardApplicable(address(rewardToken)), 0); + + IMockTokenizedStaker.Reward memory rewardData = staker.rewardData( + address(rewardToken) + ); + assertEq(rewardData.periodFinish, 0); + assertEq(rewardData.rewardRate, 0); + assertEq(rewardData.rewardsDuration, duration); + + uint256 rewardAmount = 100e18; + + vm.expectRevert("!management"); + staker.notifyRewardAmount(address(rewardToken), rewardAmount); + + airdrop(rewardToken, address(staker), rewardAmount); + + vm.prank(management); + staker.notifyRewardAmount(address(rewardToken), rewardAmount); + + rewardData = staker.rewardData(address(rewardToken)); + assertEq(rewardData.lastUpdateTime, block.timestamp); + assertEq(rewardData.periodFinish, block.timestamp + duration); + assertEq(rewardData.rewardRate, rewardAmount / duration); + + skip(duration / 2); + + assertEq(staker.earned(user, address(rewardToken)), rewardAmount / 2); + + // Add more rewards mid-period + airdrop(rewardToken, address(staker), rewardAmount); + vm.prank(management); + staker.notifyRewardAmount(address(rewardToken), rewardAmount); + + rewardData = staker.rewardData(address(rewardToken)); + assertEq(rewardData.lastUpdateTime, block.timestamp); + assertEq(rewardData.periodFinish, block.timestamp + duration); + assertEq( + rewardData.rewardRate, + (rewardAmount + (rewardAmount / 2)) / duration + ); + } + + function test_TokenizedStaker_getReward() public { + uint256 amount = 1_000e18; + mintAndDepositIntoStrategy(IStrategy(address(staker)), user, amount); + + // Add multiple reward tokens + vm.prank(management); + staker.addReward(address(rewardToken2), management, duration); + + uint256 rewardAmount = 100e18; + // Add rewards for both tokens + airdrop(rewardToken, address(staker), rewardAmount); + airdrop(rewardToken2, address(staker), rewardAmount); + + vm.startPrank(management); + staker.notifyRewardAmount(address(rewardToken), rewardAmount); + staker.notifyRewardAmount(address(rewardToken2), rewardAmount); + vm.stopPrank(); + + skip(duration / 2); + + assertEq(staker.earned(user, address(rewardToken)), rewardAmount / 2); + assertEq(staker.earned(user, address(rewardToken2)), rewardAmount / 2); + + vm.prank(user); + staker.getReward(); + + assertEq(rewardToken.balanceOf(user), rewardAmount / 2); + assertEq(rewardToken2.balanceOf(user), rewardAmount / 2); + assertEq(staker.rewards(user, address(rewardToken)), 0); + assertEq(staker.rewards(user, address(rewardToken2)), 0); + } + + function test_TokenizedStaker_getOneReward() public { + uint256 amount = 1_000e18; + mintAndDepositIntoStrategy(IStrategy(address(staker)), user, amount); + + // Add multiple reward tokens + vm.prank(management); + staker.addReward(address(rewardToken2), management, duration); + + uint256 rewardAmount = 100e18; + // Add rewards for both tokens + airdrop(rewardToken, address(staker), rewardAmount); + airdrop(rewardToken2, address(staker), rewardAmount); + + vm.startPrank(management); + staker.notifyRewardAmount(address(rewardToken), rewardAmount); + staker.notifyRewardAmount(address(rewardToken2), rewardAmount); + vm.stopPrank(); + + skip(duration / 2); + + vm.prank(user); + staker.getOneReward(address(rewardToken)); + + assertEq(rewardToken.balanceOf(user), rewardAmount / 2); + assertEq(rewardToken2.balanceOf(user), 0); + assertEq(staker.rewards(user, address(rewardToken)), 0); + assertEq(staker.rewards(user, address(rewardToken2)), rewardAmount / 2); + } +} diff --git a/src/test/mocks/MockTokenizedStaker.sol b/src/test/mocks/MockTokenizedStaker.sol new file mode 100644 index 0000000..15abc1f --- /dev/null +++ b/src/test/mocks/MockTokenizedStaker.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import {TokenizedStaker} from "../../Bases/Staker/TokenizedStaker.sol"; +import {ITokenizedStaker} from "../../Bases/Staker/ITokenizedStaker.sol"; + +contract MockTokenizedStaker is TokenizedStaker { + constructor( + address _asset, + string memory _name + ) TokenizedStaker(_asset, _name) {} + + function _deployFunds(uint256) internal override {} + + function _freeFunds(uint256) internal override {} + + function _harvestAndReport() + internal + override + returns (uint256 _totalAssets) + { + _totalAssets = asset.balanceOf(address(this)); + } +} + +interface IMockTokenizedStaker is ITokenizedStaker {}