diff --git a/src/Staker.sol b/src/Staker.sol index 8dc36531..6b3b4648 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -531,9 +531,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { // Note: underflow causes a revert if the requested tip is more than unclaimed rewards if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) - { - revert Staker__InsufficientUnclaimedRewards(); - } + revert Staker__InsufficientUnclaimedRewards(); emit EarningPowerBumped( _depositId, deposit.earningPower, _newEarningPower, msg.sender, _tipReceiver, _requestedTip @@ -580,7 +578,10 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @param _from Source account from which stake token is to be transferred. /// @param _to Destination account of the stake token which is to be transferred. /// @param _value Quantity of stake token which is to be transferred. - function _stakeTokenSafeTransferFrom(address _from, address _to, uint256 _value) internal virtual { + function _stakeTokenSafeTransferFrom(address _from, address _to, uint256 _value) + internal + virtual + { SafeERC20.safeTransferFrom(STAKE_TOKEN, _from, _to, _value); } diff --git a/src/notifiers/APRRewardNotifier.sol b/src/notifiers/APRRewardNotifier.sol new file mode 100644 index 00000000..711b9219 --- /dev/null +++ b/src/notifiers/APRRewardNotifier.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {Vm, Test, stdStorage, StdStorage, console2, console, stdError} from "forge-std/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Staker} from "../Staker.sol"; + +/// @notice A reward notifier that will extend rewards over the +/// reward duration if rewards exceed a target APR. If rewards +/// are constantly below a fixed APR then we revert to a fixed +/// notification schedule. +/// +/// @dev `RewardNotifierBase` expects a fixed reward interval and +/// a fixed amount. +contract APRRewardNotifier is Ownable { + using SafeERC20 for IERC20; + + /// @notice Emitted when the target APR is changed. + event TargetAPRSet(uint16 oldTargetAPR, uint16 newTargetAPR); + + /// @notice Emitted when the reward amount is changed. + event RewardAmountSet(uint256 oldAmount, uint256 newAmount); + + /// @notice Emitted when the reward interval is changed. + event RewardIntervalSet(uint256 oldInterval, uint256 newInterval); + + /// @notice Emitted when the max earning power token multiplier is changed. + event MaxEarningPowerTokenMultiplierSet(uint16 oldMultiplier, uint16 newMultiplier); + + /// @notice Emitted when rewards are notified to the receiver. + event Notified(uint256 amount, uint256 currentAPR, uint256 nextRewardTime); + + /// @notice Thrown when trying to notify before the interval has elapsed and APR is below + /// target. + error APRRewardNotifier__RewardIntervalNotElapsed(); + + /// @notice Thrown when an invalid parameter is provided. + error APRRewardNotifier__InvalidParameter(); + + /// @notice Thrown when there is insufficient balance to notify rewards. + error APRRewardNotifier__InsufficientBalance(); + + /// @notice The contract that will receive reward notifications. Typically an instance of Staker. + Staker public immutable RECEIVER; + + /// @notice The ERC20 token in which rewards are denominated. + IERC20 public immutable TOKEN; + + /// @notice Seconds in a year for APR calculations. + uint256 public constant SECONDS_PER_YEAR = 31_556_952; + + /// @notice Denominator for basis points calculations. + uint16 public constant BIPS_DENOMINATOR = 10_000; + + /// @notice Minimum reward interval (1 day). + uint256 public constant MIN_REWARD_INTERVAL = 1 days; + + /// @notice Maximum reward interval (365 days). + uint256 public constant MAX_REWARD_INTERVAL = 365 days; + + /// @notice APR threshold in basis points. + uint16 public targetAPR; + + /// @notice Max earning power to token multiplier in basis points. + uint16 public maxEarningPowerTokenMultiplier; + + /// @notice The amount of reward tokens to be distributed at fixed intervals. + uint256 public rewardAmount; + + /// @notice The interval between reward notifications. + uint256 public rewardInterval; + + /// @notice The timestamp after which the next reward notification can be sent. + uint256 public nextRewardTime; + + constructor( + Staker _receiver, + address _owner, + uint16 _maxEarningPowerTokenMultiplier, + uint16 _targetAPR, + uint256 _rewardAmount, + uint256 _rewardInterval + ) Ownable(_owner) { + if (address(_receiver) == address(0)) { + revert APRRewardNotifier__InvalidParameter(); + } + + RECEIVER = _receiver; + TOKEN = _receiver.REWARD_TOKEN(); + _setMaxEarningPowerTokenMultiplier(_maxEarningPowerTokenMultiplier); + _setTargetAPR(_targetAPR); + _setRewardAmount(_rewardAmount); + _setRewardInterval(_rewardInterval); + } + + /// @notice Calls `notifyRewardAmount` on `RECEIVER`. This function can be called anytime the + /// APR has exceeded its target or when a fixed interval has occurred. When notifying the + /// amount notified should not cause the APR to exceed the target. In the case the APR + /// is above the target the amount can be as low as 0. This does not guarantee the APR is + /// below the target. If it isn't then another notify can follow immediately after until + /// it is. If the fixed interval has occurred and the APR is below the target, then the + /// notifier can notify at most up to the target. + function notify() external virtual { + uint256 currentAPR = _calculateCurrentScaledAPR(); + uint256 amountToNotify = 0; + + if (currentAPR <= targetAPR && block.timestamp < nextRewardTime) { + revert APRRewardNotifier__RewardIntervalNotElapsed(); + } + + // If APR is above target, we can notify immediately with 0 or minimal amount + if (currentAPR > targetAPR) { + // Notify with 0 to extend the reward duration without adding more rewards + amountToNotify = _calculateRewardAmountForAPRTarget(); + } + + // If the fixed interval has elapsed + if (block.timestamp >= nextRewardTime) { + // Calculate the maximum amount we can notify without exceeding the target + uint256 maxAllowableAmount = _calculateMaxNotifyAmount(currentAPR); + amountToNotify = rewardAmount > maxAllowableAmount ? maxAllowableAmount : rewardAmount; + nextRewardTime = block.timestamp + rewardInterval; + } + console2.logUint(amountToNotify); + console2.logUint(TOKEN.balanceOf(address(this))); + if (TOKEN.balanceOf(address(this)) < amountToNotify) { + revert APRRewardNotifier__InsufficientBalance(); + } + + // Avoids revert in underlying staker if scaled reward rate is 0 + if (amountToNotify == 0 && (RECEIVER.scaledRewardRate() / RECEIVER.SCALE_FACTOR()) == 0) { + return; + } + TOKEN.safeTransfer(address(RECEIVER), amountToNotify); + + // Notify the receiver + RECEIVER.notifyRewardAmount(amountToNotify); + emit Notified(amountToNotify, currentAPR, nextRewardTime); + } + + /// @notice Set the target APR. This target should be denominated in BIPS. This is + /// only callable by the owner. + /// @param _targetAPR The new target APR in basis points. + function setTargetAPR(uint16 _targetAPR) external virtual { + _checkOwner(); + _setTargetAPR(_targetAPR); + } + + /// @notice The interval between reward notifications outside of notifications to move + /// the APR below the target. + /// @param _rewardInterval The new reward interval in seconds. + function setRewardInterval(uint256 _rewardInterval) external { + _checkOwner(); + _setRewardInterval(_rewardInterval); + } + + /// @notice The amount that can be notified at fixed intervals. If APR is always below the + /// target then the max amount that can be notified over some period is the amount times + /// the reward interval. + /// @param _rewardAmount The new reward amount. + function setRewardAmount(uint256 _rewardAmount) external { + _checkOwner(); + _setRewardAmount(_rewardAmount); + } + + /// @notice The owner can set a token multiple in bips. + /// @param _multiple The new max earning power token multiplier in basis points. + function setMaxEarningPowerTokenMultiplier(uint16 _multiple) external { + _checkOwner(); + _setMaxEarningPowerTokenMultiplier(_multiple); + } + + /// @notice Approve an address to transferFrom tokens held by this contract. + /// @param _spender The address to approve for token spending. + /// @param _amount The amount of tokens to approve. + /// @dev This enables tokens to be clawed back to end rewards as desired. + function approve(address _spender, uint256 _amount) external { + _checkOwner(); + TOKEN.safeIncreaseAllowance(_spender, _amount); + } + + /// @notice Get the current APR in basis points. + /// @return The current APR in basis points. + function getCurrentAPR() external view returns (uint256) { + return _calculateCurrentScaledAPR(); + } + + /// @notice Calculate the maximum amount that can be notified without exceeding the target APR. + /// @param _currentAPR The current APR in basis points. + /// @return The maximum amount that can be notified. + function _calculateMaxNotifyAmount(uint256 _currentAPR) internal view returns (uint256) { + if (_currentAPR >= targetAPR) return 0; + + uint256 totalEarningPower = RECEIVER.totalEarningPower(); + if (totalEarningPower == 0) return rewardAmount; + + uint256 targetScaledRate = _targetScaledRewardRate(totalEarningPower); + uint256 maxAmount = + (targetScaledRate * RECEIVER.REWARD_DURATION()) / RECEIVER.SCALE_FACTOR(); + + uint256 remainingRewards = + _remainingScaledReward() / RECEIVER.SCALE_FACTOR(); + if (maxAmount > remainingRewards) return maxAmount - remainingRewards; + return 0; + } + + /// @notice How to calculate the current APR in basis points. + /// @dev The APR = (scaledRewardRate / totalEarningPower / SCALE_FACTOR) * maxEarningPowerToTokensMultiplier * + /// SECONDS_PER_YEAR / BIPS_DENOMINATOR + function _calculateCurrentScaledAPR() internal view returns (uint256) { + uint256 _totalEarningPower = RECEIVER.totalEarningPower(); + if (_totalEarningPower == 0) return 0; + + return ((RECEIVER.scaledRewardRate() + * uint256(maxEarningPowerTokenMultiplier) + * SECONDS_PER_YEAR) / (_totalEarningPower * BIPS_DENOMINATOR * RECEIVER.SCALE_FACTOR())); + } + + function _calculateRewardAmountForAPRTarget() internal view returns (uint256) { + uint256 _totalEarningPower = RECEIVER.totalEarningPower(); + if (_totalEarningPower == 0) return 0; + + uint256 targetScaledRewardRate = _targetScaledRewardRate(_totalEarningPower); + uint256 targetScaledReward = + targetScaledRewardRate * RECEIVER.REWARD_DURATION(); + uint256 remainingReward = _remainingScaledReward(); + if (targetScaledReward <= remainingReward) return 0; + + return (targetScaledReward - remainingReward) / RECEIVER.SCALE_FACTOR(); + } + + /// @notice Internal helper method which sets the target APR. + /// @param _targetAPR The new target APR in basis points. + function _setTargetAPR(uint16 _targetAPR) internal { + if (_targetAPR == 0) revert APRRewardNotifier__InvalidParameter(); + emit TargetAPRSet(targetAPR, _targetAPR); + targetAPR = _targetAPR; + } + + /// @notice Internal helper method which sets the reward interval. + /// @param _rewardInterval The new reward interval in seconds. + function _setRewardInterval(uint256 _rewardInterval) internal { + if (_rewardInterval < MIN_REWARD_INTERVAL || _rewardInterval > MAX_REWARD_INTERVAL) { + revert APRRewardNotifier__InvalidParameter(); + } + emit RewardIntervalSet(rewardInterval, _rewardInterval); + rewardInterval = _rewardInterval; + } + + /// @notice Internal helper method which sets the reward amount. + /// @param _rewardAmount The new reward amount. + function _setRewardAmount(uint256 _rewardAmount) internal { + if (_rewardAmount == 0) revert APRRewardNotifier__InvalidParameter(); + emit RewardAmountSet(rewardAmount, _rewardAmount); + rewardAmount = _rewardAmount; + } + + /// @notice Internal helper method which sets the max earning power token multiplier. + /// @param _multiple The new max earning power token multiplier in basis points. + function _setMaxEarningPowerTokenMultiplier(uint16 _multiple) internal { + if (_multiple == 0) revert APRRewardNotifier__InvalidParameter(); + emit MaxEarningPowerTokenMultiplierSet(maxEarningPowerTokenMultiplier, _multiple); + maxEarningPowerTokenMultiplier = _multiple; + } + + function _targetScaledRewardRate(uint256 _totalEarningPower) internal view returns (uint256) { + return (uint256(targetAPR) * _totalEarningPower * uint256(maxEarningPowerTokenMultiplier) * RECEIVER.SCALE_FACTOR()) + / (BIPS_DENOMINATOR * SECONDS_PER_YEAR); + } + + function _remainingScaledReward() internal view returns (uint256) { + uint256 rewardEndTime = RECEIVER.rewardEndTime(); + if (block.timestamp >= rewardEndTime) return 0; + return RECEIVER.scaledRewardRate() * (rewardEndTime - block.timestamp); + } +} diff --git a/test/APRRewardNotifier.t.sol b/test/APRRewardNotifier.t.sol new file mode 100644 index 00000000..49126d87 --- /dev/null +++ b/test/APRRewardNotifier.t.sol @@ -0,0 +1,660 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {Vm, Test, stdStorage, StdStorage, console2, console, stdError} from "forge-std/Test.sol"; +import {APRRewardNotifier} from "../src/notifiers/APRRewardNotifier.sol"; +import {INotifiableRewardReceiver} from "../src/interfaces/INotifiableRewardReceiver.sol"; +import {ERC20VotesMock} from "./mocks/MockERC20Votes.sol"; +import {TestHelpers} from "./helpers/TestHelpers.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Staker} from "../src/Staker.sol"; +import {StakerHarness} from "./harnesses/StakerHarness.sol"; +import { + IdentityEarningPowerCalculator +} from "../src/calculators/IdentityEarningPowerCalculator.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Staking} from "../src/interfaces/IERC20Staking.sol"; +import {IEarningPowerCalculator} from "../src/interfaces/IEarningPowerCalculator.sol"; + +contract APRRewardNotifierTest is Test, TestHelpers { + ERC20VotesMock rewardToken; + ERC20VotesMock stakeToken; + StakerHarness receiver; + IdentityEarningPowerCalculator earningPowerCalculator; + APRRewardNotifier notifier; + address admin = makeAddr("Admin"); + address owner = makeAddr("Notifier Owner"); + address alice = makeAddr("Alice"); + address bob = makeAddr("Bob"); + + uint16 initialTargetAPR = 1000; // 10% + uint16 initialMaxMultiplier = 10_000; // 100% + uint256 initialRewardAmount = 10_000e18; + uint256 initialRewardInterval = 7 days; + uint256 maxBumpTip = 1e18; + + uint256 MIN_REWARD_INTERVAL; + uint256 MAX_REWARD_INTERVAL; + uint256 SECONDS_PER_YEAR; + uint16 BIPS_DENOMINATOR; + + function setUp() public virtual { + rewardToken = new ERC20VotesMock(); + stakeToken = new ERC20VotesMock(); + earningPowerCalculator = new IdentityEarningPowerCalculator(); + receiver = new StakerHarness( + IERC20(rewardToken), + IERC20Staking(stakeToken), + IEarningPowerCalculator(earningPowerCalculator), + maxBumpTip, + admin, + "Test Staker" + ); + + notifier = new APRRewardNotifier( + receiver, + owner, + initialMaxMultiplier, + initialTargetAPR, + initialRewardAmount, + initialRewardInterval + ); + + // Set the notifier as a reward notifier on the receiver + vm.prank(admin); + receiver.setRewardNotifier(address(notifier), true); + + MIN_REWARD_INTERVAL = notifier.MIN_REWARD_INTERVAL(); + MAX_REWARD_INTERVAL = notifier.MAX_REWARD_INTERVAL(); + SECONDS_PER_YEAR = notifier.SECONDS_PER_YEAR(); + BIPS_DENOMINATOR = notifier.BIPS_DENOMINATOR(); + vm.warp(block.timestamp + 10); + } + + function _mintAndStake(address _staker, uint256 _amount) internal { + stakeToken.mint(_staker, _amount); + vm.startPrank(_staker); + stakeToken.approve(address(receiver), _amount); + receiver.stake(_amount, _staker); + vm.stopPrank(); + } + + function _assumeSafeOwner(address _owner) public pure { + vm.assume(_owner != address(0)); + } + + function _expectedCurrentAPR() internal view returns (uint256) { + uint256 totalEarningPower = receiver.totalEarningPower(); + if (totalEarningPower == 0) return 0; + + return (receiver.scaledRewardRate() + * uint256(notifier.maxEarningPowerTokenMultiplier()) + * SECONDS_PER_YEAR) / (totalEarningPower * BIPS_DENOMINATOR * receiver.SCALE_FACTOR()); + } + + function _assertCurrentAPRMatchesExpectation() internal view returns (uint256) { + uint256 currentAPR = notifier.getCurrentAPR(); + assertEq(currentAPR, _expectedCurrentAPR()); + return currentAPR; + } + + function _startExternalRewardStream(uint256 _amount) internal { + vm.prank(admin); + receiver.setRewardNotifier(address(this), true); + + rewardToken.mint(address(this), _amount); + rewardToken.transfer(address(receiver), _amount); + receiver.notifyRewardAmount(_amount); + + vm.prank(admin); + receiver.setRewardNotifier(address(this), false); + } +} + +contract Constructor is APRRewardNotifierTest { + function test_SetsInitializationParameters() public view { + assertEq(address(notifier.RECEIVER()), address(receiver)); + assertEq(address(notifier.TOKEN()), address(rewardToken)); + assertEq(notifier.targetAPR(), initialTargetAPR); + assertEq(notifier.maxEarningPowerTokenMultiplier(), initialMaxMultiplier); + assertEq(notifier.rewardAmount(), initialRewardAmount); + assertEq(notifier.rewardInterval(), initialRewardInterval); + assertEq(notifier.owner(), owner); + } + + function testFuzz_SetsInitializationParametersToArbitraryValues( + address _owner, + uint16 _targetAPR, + uint16 _maxMultiplier, + uint256 _rewardAmount, + uint256 _rewardInterval + ) public { + _assumeSafeOwner(_owner); + _targetAPR = uint16(bound(_targetAPR, 1, type(uint16).max)); + _maxMultiplier = uint16(bound(_maxMultiplier, 1, type(uint16).max)); + // Ensure reward results in valid scaledRewardRate + _rewardAmount = bound(_rewardAmount, 1e15, initialRewardAmount); // Bound by notifier's configured amount + _rewardInterval = bound(_rewardInterval, MIN_REWARD_INTERVAL, MAX_REWARD_INTERVAL); + + APRRewardNotifier _notifier = new APRRewardNotifier( + receiver, _owner, _maxMultiplier, _targetAPR, _rewardAmount, _rewardInterval + ); + + assertEq(address(_notifier.RECEIVER()), address(receiver)); + assertEq(_notifier.targetAPR(), _targetAPR); + assertEq(_notifier.maxEarningPowerTokenMultiplier(), _maxMultiplier); + assertEq(_notifier.rewardAmount(), _rewardAmount); + assertEq(_notifier.rewardInterval(), _rewardInterval); + assertEq(_notifier.owner(), _owner); + } + + function test_RevertIf_ReceiverIsZeroAddress() public { + vm.expectRevert(APRRewardNotifier.APRRewardNotifier__InvalidParameter.selector); + new APRRewardNotifier( + Staker(address(0)), + owner, + initialMaxMultiplier, + initialTargetAPR, + initialRewardAmount, + initialRewardInterval + ); + } + + function testFuzz_EmitsEventsOnInitialization( + uint16 _targetAPR, + uint16 _maxMultiplier, + uint256 _rewardAmount, + uint256 _rewardInterval + ) public { + _targetAPR = uint16(bound(_targetAPR, 1, type(uint16).max)); + _maxMultiplier = uint16(bound(_maxMultiplier, 1, type(uint16).max)); + // Ensure reward results in valid scaledRewardRate + _rewardAmount = bound(_rewardAmount, 1e15, initialRewardAmount); // Bound by notifier's configured amount + _rewardInterval = bound(_rewardInterval, MIN_REWARD_INTERVAL, MAX_REWARD_INTERVAL); + + vm.expectEmit(); + emit APRRewardNotifier.MaxEarningPowerTokenMultiplierSet(0, _maxMultiplier); + vm.expectEmit(); + emit APRRewardNotifier.TargetAPRSet(0, _targetAPR); + vm.expectEmit(); + emit APRRewardNotifier.RewardAmountSet(0, _rewardAmount); + vm.expectEmit(); + emit APRRewardNotifier.RewardIntervalSet(0, _rewardInterval); + + new APRRewardNotifier( + receiver, owner, _maxMultiplier, _targetAPR, _rewardAmount, _rewardInterval + ); + } +} + +contract SetTargetAPR is APRRewardNotifierTest { + function testFuzz_UpdatesTargetAPR(uint16 _newTargetAPR) public { + _newTargetAPR = uint16(bound(_newTargetAPR, 1, type(uint16).max)); + + vm.prank(owner); + notifier.setTargetAPR(_newTargetAPR); + + assertEq(notifier.targetAPR(), _newTargetAPR); + } + + function testFuzz_EmitsEventOnUpdate(uint16 _newTargetAPR) public { + _newTargetAPR = uint16(bound(_newTargetAPR, 1, type(uint16).max)); + + vm.expectEmit(); + emit APRRewardNotifier.TargetAPRSet(initialTargetAPR, _newTargetAPR); + vm.prank(owner); + notifier.setTargetAPR(_newTargetAPR); + } + + function test_RevertIf_TargetAPRIsZero() public { + vm.expectRevert(APRRewardNotifier.APRRewardNotifier__InvalidParameter.selector); + vm.prank(owner); + notifier.setTargetAPR(0); + } + + function testFuzz_RevertIf_CallerIsNotOwner(address _notOwner, uint16 _targetAPR) public { + vm.assume(_notOwner != owner); + _targetAPR = uint16(bound(_targetAPR, 1, type(uint16).max)); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _notOwner)); + vm.prank(_notOwner); + notifier.setTargetAPR(_targetAPR); + } +} + +contract SetRewardInterval is APRRewardNotifierTest { + function testFuzz_UpdatesRewardInterval(uint256 _newInterval) public { + _newInterval = bound(_newInterval, MIN_REWARD_INTERVAL, MAX_REWARD_INTERVAL); + + vm.prank(owner); + notifier.setRewardInterval(_newInterval); + + assertEq(notifier.rewardInterval(), _newInterval); + } + + function testFuzz_EmitsEventOnUpdate(uint256 _newInterval) public { + _newInterval = bound(_newInterval, MIN_REWARD_INTERVAL, MAX_REWARD_INTERVAL); + + vm.expectEmit(); + emit APRRewardNotifier.RewardIntervalSet(initialRewardInterval, _newInterval); + vm.prank(owner); + notifier.setRewardInterval(_newInterval); + } + + function testFuzz_RevertIf_IntervalTooShort(uint256 _interval) public { + vm.assume(_interval < MIN_REWARD_INTERVAL); + + vm.expectRevert(APRRewardNotifier.APRRewardNotifier__InvalidParameter.selector); + vm.prank(owner); + notifier.setRewardInterval(_interval); + } + + function testFuzz_RevertIf_IntervalTooLong(uint256 _interval) public { + vm.assume(_interval > MAX_REWARD_INTERVAL); + + vm.expectRevert(APRRewardNotifier.APRRewardNotifier__InvalidParameter.selector); + vm.prank(owner); + notifier.setRewardInterval(_interval); + } + + function testFuzz_RevertIf_CallerIsNotOwner(address _notOwner, uint256 _interval) public { + vm.assume(_notOwner != owner); + _interval = bound(_interval, MIN_REWARD_INTERVAL, MAX_REWARD_INTERVAL); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _notOwner)); + vm.prank(_notOwner); + notifier.setRewardInterval(_interval); + } +} + +contract SetRewardAmount is APRRewardNotifierTest { + function testFuzz_UpdatesRewardAmount(uint256 _newAmount) public { + _newAmount = bound(_newAmount, 1, type(uint256).max); + + vm.prank(owner); + notifier.setRewardAmount(_newAmount); + + assertEq(notifier.rewardAmount(), _newAmount); + } + + function testFuzz_EmitsEventOnUpdate(uint256 _newAmount) public { + _newAmount = bound(_newAmount, 1, type(uint256).max); + + vm.expectEmit(); + emit APRRewardNotifier.RewardAmountSet(initialRewardAmount, _newAmount); + vm.prank(owner); + notifier.setRewardAmount(_newAmount); + } + + function test_RevertIf_AmountIsZero() public { + vm.expectRevert(APRRewardNotifier.APRRewardNotifier__InvalidParameter.selector); + vm.prank(owner); + notifier.setRewardAmount(0); + } + + function testFuzz_RevertIf_CallerIsNotOwner(address _notOwner, uint256 _amount) public { + vm.assume(_notOwner != owner); + _amount = bound(_amount, 1, type(uint256).max); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _notOwner)); + vm.prank(_notOwner); + notifier.setRewardAmount(_amount); + } +} + +contract SetMaxEarningPowerTokenMultiplier is APRRewardNotifierTest { + function testFuzz_UpdatesMultiplier(uint16 _newMultiplier) public { + _newMultiplier = uint16(bound(_newMultiplier, 1, type(uint16).max)); + + vm.prank(owner); + notifier.setMaxEarningPowerTokenMultiplier(_newMultiplier); + + assertEq(notifier.maxEarningPowerTokenMultiplier(), _newMultiplier); + } + + function testFuzz_EmitsEventOnUpdate(uint16 _newMultiplier) public { + _newMultiplier = uint16(bound(_newMultiplier, 1, type(uint16).max)); + + vm.expectEmit(); + emit APRRewardNotifier.MaxEarningPowerTokenMultiplierSet(initialMaxMultiplier, _newMultiplier); + vm.prank(owner); + notifier.setMaxEarningPowerTokenMultiplier(_newMultiplier); + } + + function test_RevertIf_MultiplierIsZero() public { + vm.expectRevert(APRRewardNotifier.APRRewardNotifier__InvalidParameter.selector); + vm.prank(owner); + notifier.setMaxEarningPowerTokenMultiplier(0); + } + + function testFuzz_RevertIf_CallerIsNotOwner(address _notOwner, uint16 _multiplier) public { + vm.assume(_notOwner != owner); + _multiplier = uint16(bound(_multiplier, 1, type(uint16).max)); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _notOwner)); + vm.prank(_notOwner); + notifier.setMaxEarningPowerTokenMultiplier(_multiplier); + } +} + +contract GetCurrentAPR is APRRewardNotifierTest { + function test_ReturnsZeroWhenNoEarningPower() public view { + assertEq(notifier.getCurrentAPR(), 0); + } + + function testFuzz_CalculatesAPRWithVariousScenarios( + uint256 _stakeAmount, + uint256 _rewardAmount, + uint16 _multiplier + ) public { + _stakeAmount = bound(_stakeAmount, 1e18, 100_000e18); + _rewardAmount = bound(_rewardAmount, 1e15, initialRewardAmount); + _multiplier = uint16(bound(_multiplier, 1000, 10_000)); // 10% to 100% + + _mintAndStake(alice, _stakeAmount); + + rewardToken.mint(address(notifier), _rewardAmount * 10); + + vm.prank(owner); + notifier.setMaxEarningPowerTokenMultiplier(_multiplier); + + // Set reward amount to ensure it's valid + vm.prank(owner); + notifier.setRewardAmount(_rewardAmount); + + vm.warp(block.timestamp + initialRewardInterval); + notifier.notify(); + + uint256 currentAPR = notifier.getCurrentAPR(); + + uint256 expectedAPR = (receiver.scaledRewardRate() * uint256(_multiplier) * SECONDS_PER_YEAR) + / (_stakeAmount * BIPS_DENOMINATOR * receiver.SCALE_FACTOR()); + + assertEq(currentAPR, expectedAPR); + } +} + +contract Notify is APRRewardNotifierTest { + function test_NotifySucceedsAfterIntervalElapsed() public { + _mintAndStake(alice, 1000e18); + + // Use moderate reward amount + uint256 rewardAmount = 1e16; // 0.01 ether + rewardToken.mint(address(notifier), rewardAmount * 100); + + vm.prank(owner); + notifier.setRewardAmount(rewardAmount); + + // First notify + vm.warp(block.timestamp + initialRewardInterval); + notifier.notify(); + + // Second notify after interval + vm.warp(block.timestamp + initialRewardInterval); + notifier.notify(); + + // Should succeed without reverting and stay within target APR + uint256 currentAPR = _assertCurrentAPRMatchesExpectation(); + assertLe(currentAPR, initialTargetAPR); + } + + function testFuzz_NotifyWhenIntervalElapsed( + uint256 _stakeAmount, + uint256 _timeElapsed + ) public { + _stakeAmount = bound(_stakeAmount, 1e18, 100_000e18); + _timeElapsed = bound(_timeElapsed, initialRewardInterval, initialRewardInterval * 10); + + _mintAndStake(alice, _stakeAmount); + rewardToken.mint(address(notifier), notifier.rewardAmount()); + + vm.warp(block.timestamp + _timeElapsed); + + uint256 balanceBefore = rewardToken.balanceOf(address(receiver)); + notifier.notify(); + uint256 balanceAfter = rewardToken.balanceOf(address(receiver)); + + assertGe(balanceAfter, balanceBefore); + _assertCurrentAPRMatchesExpectation(); + assertEq(notifier.nextRewardTime(), block.timestamp + initialRewardInterval); + } + + function testFuzz_NotifyWhenAPRAboveTarget(uint256 _externalReward, uint16 _lowTargetAPR) public { + _externalReward = bound(_externalReward, 1e20, 1e24); + _lowTargetAPR = uint16(bound(_lowTargetAPR, 10, 200)); // 0.1% to 2% + + _mintAndStake(alice, 1_000e18); + _startExternalRewardStream(_externalReward); + + vm.prank(owner); + notifier.setTargetAPR(_lowTargetAPR); + rewardToken.mint(address(notifier), initialRewardAmount); + + vm.warp(block.timestamp + initialRewardInterval); + + uint256 aprBefore = _assertCurrentAPRMatchesExpectation(); + vm.assume(aprBefore > _lowTargetAPR); + + notifier.notify(); + + uint256 aprAfter = _assertCurrentAPRMatchesExpectation(); + assertLt(aprAfter, aprBefore); + } + + + function testFuzz_MultipleNotificationsOverTime( + uint256 _stakeAmount, + uint256 _rewardAmount, + uint8 _numNotifications + ) public { + _stakeAmount = bound(_stakeAmount, 100e18, 10_000e18); + _rewardAmount = bound(_rewardAmount, 1e16, initialRewardAmount); + _numNotifications = uint8(bound(_numNotifications, 2, 5)); + + _mintAndStake(alice, _stakeAmount); + rewardToken.mint(address(notifier), _rewardAmount * 10); + + // Set reward amount explicitly + vm.prank(owner); + notifier.setRewardAmount(_rewardAmount); + + for (uint8 i = 0; i < _numNotifications; i++) { + vm.warp(block.timestamp + initialRewardInterval); + notifier.notify(); + + _assertCurrentAPRMatchesExpectation(); + } + } + + function test_RevertIf_IntervalNotElapsedAndAPRBelowTarget() public { + _mintAndStake(alice, 1000e18); + + // Use smaller reward amount to ensure APR stays below target + uint256 smallReward = 1e16; // 0.01 ether + rewardToken.mint(address(notifier), smallReward * 100); + + vm.prank(owner); + notifier.setRewardAmount(smallReward); + + // First notify needs to happen after interval + vm.warp(block.timestamp + initialRewardInterval); + notifier.notify(); + + // Try to notify again immediately - should revert because APR is below target + vm.expectRevert(APRRewardNotifier.APRRewardNotifier__RewardIntervalNotElapsed.selector); + notifier.notify(); + } + + +} + +contract Approve is APRRewardNotifierTest { + function testFuzz_ApprovesSpender(address _spender, uint256 _amount) public { + vm.assume(_spender != address(0)); + _amount = bound(_amount, 1, type(uint256).max); + + vm.prank(owner); + notifier.approve(_spender, _amount); + + assertEq(rewardToken.allowance(address(notifier), _spender), _amount); + } + + function testFuzz_RevertIf_CallerIsNotOwner(address _notOwner, address _spender, uint256 _amount) + public + { + vm.assume(_notOwner != owner); + vm.assume(_spender != address(0)); + _amount = bound(_amount, 1, type(uint256).max); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _notOwner)); + vm.prank(_notOwner); + notifier.approve(_spender, _amount); + } +} + +contract CalculateRewardAmountForAPRTarget is APRRewardNotifierTest { + function testFuzz_CalculatesCorrectAmountToReachTarget( + uint256 _externalReward, + uint256 _rewardAmount, + uint16 _targetAPR, + uint16 _warpAhead + ) public { + // _warpAhead = uint16(bound(_warpAhead, 1000, type(uint16).max)); + _externalReward = bound(_externalReward, 1e20, 1e24); + _rewardAmount = bound(_rewardAmount, 1e15, initialRewardAmount); + _targetAPR = uint16(bound(_targetAPR, 50, 500)); // 0.5% to 5% + _warpAhead = uint16(bound(_warpAhead, 1, type(uint16).max)); // 0.5% to 5% + + _mintAndStake(alice, 2_000e18); + _startExternalRewardStream(_externalReward); + + vm.startPrank(owner); + notifier.setTargetAPR(_targetAPR); + notifier.setRewardAmount(_rewardAmount); + vm.stopPrank(); + + rewardToken.mint(address(notifier), initialRewardAmount * 2); + + uint256 balanceBefore = rewardToken.balanceOf(address(receiver)); + uint256 aprBefore = _assertCurrentAPRMatchesExpectation(); + // APR isn't high enough + vm.assume(aprBefore > _targetAPR); + + vm.warp(block.timestamp + _warpAhead); + notifier.notify(); + + uint256 balanceAfter = rewardToken.balanceOf(address(receiver)); + uint256 aprAfter = _assertCurrentAPRMatchesExpectation(); + + assertGe(balanceAfter, balanceBefore); + assertLe(aprAfter, aprBefore); + } +} + +contract APRCalculationAccuracy is APRRewardNotifierTest { + function testFuzz_VerifiesCalculationAccuracyThroughResultingAPR( + uint256 _stakeAmount, + uint256 _rewardAmount, + uint16 _multiplier, + uint16 _targetAPR + ) public { + _stakeAmount = bound(_stakeAmount, 1e18, 100_000e18); + _rewardAmount = bound(_rewardAmount, 100e18, initialRewardAmount); + _multiplier = uint16(bound(_multiplier, 1000, 10_000)); // 10% to 100% + _targetAPR = uint16(bound(_targetAPR, 100, 2000)); // 1% to 20% + + _mintAndStake(alice, _stakeAmount); + + vm.startPrank(owner); + notifier.setMaxEarningPowerTokenMultiplier(_multiplier); + notifier.setTargetAPR(_targetAPR); + notifier.setRewardAmount(_rewardAmount); + vm.stopPrank(); + + rewardToken.mint(address(notifier), _rewardAmount * 10); + + vm.warp(block.timestamp + initialRewardInterval); + notifier.notify(); + + uint256 resultingAPR = _assertCurrentAPRMatchesExpectation(); + + uint256 scaledRewardRate = receiver.scaledRewardRate(); + uint256 totalEarningPower = receiver.totalEarningPower(); + assertGt(totalEarningPower, 0); + + uint256 calculatedAPR = (scaledRewardRate * uint256(_multiplier) * SECONDS_PER_YEAR) + / (totalEarningPower * BIPS_DENOMINATOR * receiver.SCALE_FACTOR()); + + assertEq(resultingAPR, calculatedAPR); + } +} + +contract EdgeCases is APRRewardNotifierTest { + function test_HandlesZeroTotalEarningPower() public { + rewardToken.mint(address(notifier), initialRewardAmount); + + vm.warp(block.timestamp + initialRewardInterval); + notifier.notify(); + + assertEq(notifier.getCurrentAPR(), 0); + } + + function testFuzz_HandlesLargeValues(uint256 _largeStake, uint256 _largeReward) public { + _largeStake = bound(_largeStake, 10_000e18, 1_000_000e18); + _largeReward = bound(_largeReward, 1e17, initialRewardAmount); + + _mintAndStake(alice, _largeStake); + + // Set the reward amount to ensure it's valid + vm.prank(owner); + notifier.setRewardAmount(_largeReward); + + rewardToken.mint(address(notifier), _largeReward); + + vm.warp(block.timestamp + initialRewardInterval); + + notifier.notify(); + + _assertCurrentAPRMatchesExpectation(); + } + + function test_HandlesStakingAndUnstakingDuringRewardPeriod() public { + uint256 stakeAmount = 1000e18; + uint256 rewardAmount = 100e18; // 0.1 ether + + // Start with a simple scenario - stake first + _mintAndStake(alice, stakeAmount); + _mintAndStake(bob, stakeAmount); + + // Give notifier tokens and set smaller reward amount + rewardToken.mint(address(notifier), rewardAmount * 100); + vm.prank(owner); + notifier.setRewardAmount(rewardAmount); + + // First notification to establish rewards + vm.warp(block.timestamp + initialRewardInterval); + notifier.notify(); + + uint256 aprBefore = _assertCurrentAPRMatchesExpectation(); + + // Bob stakes more, should decrease APR + _mintAndStake(bob, stakeAmount); + + uint256 aprAfterStake = _assertCurrentAPRMatchesExpectation(); + assertLe(aprAfterStake, aprBefore); + + // Get Alice's deposit ID for withdrawal + stakeToken.mint(alice, stakeAmount); + vm.startPrank(alice); + stakeToken.approve(address(receiver), stakeAmount); + Staker.DepositIdentifier newDepositId = receiver.stake(stakeAmount, alice); + vm.stopPrank(); + + // Alice withdraws, should increase APR + vm.prank(alice); + receiver.withdraw(newDepositId, stakeAmount / 2); + + uint256 aprAfterWithdraw = _assertCurrentAPRMatchesExpectation(); + assertGe(aprAfterWithdraw, aprAfterStake); + } +}