From 4a104a6a32065238555d1400c481d90a38344afc Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 19 Nov 2025 14:10:00 -0500 Subject: [PATCH 1/9] notify ceiling --- src/Staker.sol | 73 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/src/Staker.sol b/src/Staker.sol index 8dc36531..5c49b12e 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -105,6 +105,12 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @notice Emitted when a reward notifier address is enabled or disabled. event RewardNotifierSet(address indexed account, bool isEnabled); + /// @notice Emitted when the APR ceiling is updated. + event AprCeilingSet(uint256 oldAprCeiling, uint256 newAprCeiling); + + /// @notice Emitted when rewards are capped due to APR ceiling. + event RewardsCapped(uint256 originalAmount, uint256 cappedAmount, uint256 effectiveApr); + /// @notice Emitted when a deposit's earning power is changed via bumping. event EarningPowerBumped( DepositIdentifier indexed depositId, @@ -132,6 +138,9 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// or in the case of an earning power decrease the tip of a subsequent earning power increase. error Staker__InsufficientUnclaimedRewards(); + /// @notice Thrown when an invalid APR ceiling value is provided. + error Staker__InvalidAprCeiling(); + /// @notice Thrown if a caller attempts to specify address zero for certain designated addresses. error Staker__InvalidAddress(); @@ -201,6 +210,12 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// truncation during division. uint256 public constant SCALE_FACTOR = 1e36; + /// @notice Basis points divisor for percentage calculations. + uint256 public constant BASIS_POINTS = 10_000; + + /// @notice Number of seconds in a year for APR calculations. + uint256 public constant SECONDS_PER_YEAR = 365 days; + /// @notice The maximum value to which the claim fee can be set. /// @dev For anything other than a zero value, this immutable parameter should be set in the /// constructor of a concrete implementation inheriting from Staker. @@ -216,6 +231,10 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @notice Maximum tip a bumper can request. uint256 public maxBumpTip; + /// @notice Maximum allowed APR in basis points (e.g., 2000 = 20% APR). + /// @dev Set to 0 to disable APR ceiling enforcement. + uint256 public aprCeiling; + /// @notice Global amount currently staked across all deposits. uint256 public totalStaked; @@ -299,6 +318,14 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { _setMaxBumpTip(_newMaxBumpTip); } + /// @notice Set the APR ceiling. + /// @param _newAprCeiling Maximum allowed APR in basis points (0 to disable). + /// @dev Caller must be the current admin. + function setAprCeiling(uint256 _newAprCeiling) external virtual { + _revertIfNotAdmin(); + _setAprCeiling(_newAprCeiling); + } + /// @notice Enables or disables a reward notifier address. /// @param _rewardNotifier Address of the reward notifier. /// @param _isEnabled `true` to enable the `_rewardNotifier`, or `false` to disable. @@ -468,15 +495,25 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { function notifyRewardAmount(uint256 _amount) external virtual { if (!isRewardNotifier[msg.sender]) revert Staker__Unauthorized("not notifier", msg.sender); + // Apply APR ceiling if configured + uint256 cappedAmount = _amount; + if (aprCeiling > 0 && totalEarningPower > 0) { + cappedAmount = _calculateCappedReward(_amount); + if (cappedAmount < _amount) { + uint256 effectiveApr = _calculateApr(cappedAmount); + emit RewardsCapped(_amount, cappedAmount, effectiveApr); + } + } + // We checkpoint the accumulator without updating the timestamp at which it was updated, // because that second operation will be done after updating the reward rate. rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); if (block.timestamp >= rewardEndTime) { - scaledRewardRate = (_amount * SCALE_FACTOR) / REWARD_DURATION; + scaledRewardRate = (cappedAmount * SCALE_FACTOR) / REWARD_DURATION; } else { uint256 _remainingReward = scaledRewardRate * (rewardEndTime - block.timestamp); - scaledRewardRate = (_remainingReward + _amount * SCALE_FACTOR) / REWARD_DURATION; + scaledRewardRate = (_remainingReward + cappedAmount * SCALE_FACTOR) / REWARD_DURATION; } rewardEndTime = block.timestamp + REWARD_DURATION; @@ -493,7 +530,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { (scaledRewardRate * REWARD_DURATION) > (REWARD_TOKEN.balanceOf(address(this)) * SCALE_FACTOR) ) revert Staker__InsufficientRewardBalance(); - emit RewardNotified(_amount, msg.sender); + emit RewardNotified(cappedAmount, msg.sender); } /// @notice A function that a bumper can call to update a deposit's earning power when a @@ -837,6 +874,36 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { maxBumpTip = _newMaxTip; } + /// @notice Internal helper method which sets the APR ceiling. + /// @param _newAprCeiling Maximum allowed APR in basis points. + function _setAprCeiling(uint256 _newAprCeiling) internal virtual { + if (_newAprCeiling > BASIS_POINTS) revert Staker__InvalidAprCeiling(); + emit AprCeilingSet(aprCeiling, _newAprCeiling); + aprCeiling = _newAprCeiling; + } + + /// @notice Calculates the reward amount capped to respect the APR ceiling. + /// @param _requestedAmount The originally requested reward amount. + /// @return The capped reward amount. + function _calculateCappedReward(uint256 _requestedAmount) internal view returns (uint256) { + // Calculate max reward amount that would result in the APR ceiling + // maxReward = (aprCeiling * totalEarningPower * REWARD_DURATION) / (BASIS_POINTS * SECONDS_PER_YEAR) + uint256 maxRewardAmount = (aprCeiling * totalEarningPower * REWARD_DURATION) / (BASIS_POINTS * SECONDS_PER_YEAR); + + // Return the minimum of the requested amount and the calculated max + return _requestedAmount < maxRewardAmount ? _requestedAmount : maxRewardAmount; + } + + /// @notice Calculates the APR for a given reward amount. + /// @param _rewardAmount The reward amount to calculate APR for. + /// @return The APR in basis points. + function _calculateApr(uint256 _rewardAmount) internal view returns (uint256) { + if (totalEarningPower == 0) return 0; + + // APR = (rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (totalEarningPower * REWARD_DURATION) + return (_rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (totalEarningPower * REWARD_DURATION); + } + /// @notice Internal helper method which sets the claim fee parameters. /// @param _params The new fee parameters. function _setClaimFeeParameters(ClaimFeeParameters memory _params) internal virtual { From 454adc0664c447c4e3c1f9a2654a720c2e34ed2a Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 19 Nov 2025 15:03:28 -0500 Subject: [PATCH 2/9] Enforce APY ceiling everywhere --- src/Staker.sol | 84 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/src/Staker.sol b/src/Staker.sol index 5c49b12e..0e6c315e 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -554,33 +554,33 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { _checkpointReward(deposit); uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + uint256 _oldEarningPower = deposit.earningPower; (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( - deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + deposit.balance, deposit.owner, deposit.delegatee, _oldEarningPower ); if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { revert Staker__Unqualified(_newEarningPower); } - if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + if (_newEarningPower > _oldEarningPower && _unclaimedRewards < _requestedTip) { revert Staker__InsufficientUnclaimedRewards(); } // Note: underflow causes a revert if the requested tip is more than unclaimed rewards - if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) - { + if (_newEarningPower < _oldEarningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) { revert Staker__InsufficientUnclaimedRewards(); } emit EarningPowerBumped( - _depositId, deposit.earningPower, _newEarningPower, msg.sender, _tipReceiver, _requestedTip + _depositId, _oldEarningPower, _newEarningPower, msg.sender, _tipReceiver, _requestedTip ); // Update global earning power & deposit earning power based on this bump totalEarningPower = - _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + _calculateTotalEarningPower(_oldEarningPower, _newEarningPower, totalEarningPower); depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( - deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + _oldEarningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] ); deposit.earningPower = _newEarningPower.toUint96(); @@ -588,6 +588,9 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); deposit.scaledUnclaimedRewardCheckpoint = deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + if (_newEarningPower < _oldEarningPower) { + _enforceAprCeilingOnStreamingRewards(); + } } /// @notice Live value of the unclaimed rewards earned by a given deposit with the @@ -679,20 +682,24 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { DelegationSurrogate _surrogate = surrogates(deposit.delegatee); uint256 _newBalance = deposit.balance + _amount; + uint256 _oldEarningPower = deposit.earningPower; uint256 _newEarningPower = earningPowerCalculator.getEarningPower(_newBalance, deposit.owner, deposit.delegatee); totalEarningPower = - _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + _calculateTotalEarningPower(_oldEarningPower, _newEarningPower, totalEarningPower); totalStaked += _amount; depositorTotalStaked[deposit.owner] += _amount; depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( - deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + _oldEarningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] ); deposit.earningPower = _newEarningPower.toUint96(); deposit.balance = _newBalance.toUint96(); _stakeTokenSafeTransferFrom(deposit.owner, address(_surrogate), _amount); emit StakeDeposited(deposit.owner, _depositId, _amount, _newBalance, _newEarningPower); + if (_newEarningPower < _oldEarningPower) { + _enforceAprCeilingOnStreamingRewards(); + } } /// @notice Internal convenience method which alters the delegatee of an existing deposit. @@ -708,13 +715,14 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { _checkpointReward(deposit); DelegationSurrogate _oldSurrogate = surrogates(deposit.delegatee); + uint256 _oldEarningPower = deposit.earningPower; uint256 _newEarningPower = earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, _newDelegatee); totalEarningPower = - _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + _calculateTotalEarningPower(_oldEarningPower, _newEarningPower, totalEarningPower); depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( - deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + _oldEarningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] ); emit DelegateeAltered(_depositId, deposit.delegatee, _newDelegatee, _newEarningPower); @@ -722,6 +730,9 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { deposit.earningPower = _newEarningPower.toUint96(); DelegationSurrogate _newSurrogate = _fetchOrDeploySurrogate(_newDelegatee); _stakeTokenSafeTransferFrom(address(_oldSurrogate), address(_newSurrogate), deposit.balance); + if (_newEarningPower < _oldEarningPower) { + _enforceAprCeilingOnStreamingRewards(); + } } /// @notice Internal convenience method which alters the claimer of an existing deposit. @@ -737,18 +748,22 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { // Updating the earning power here is not strictly necessary, but if the user is touching their // deposit anyway, it seems reasonable to make sure their earning power is up to date. + uint256 _oldEarningPower = deposit.earningPower; uint256 _newEarningPower = earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, deposit.delegatee); totalEarningPower = - _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + _calculateTotalEarningPower(_oldEarningPower, _newEarningPower, totalEarningPower); depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( - deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + _oldEarningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] ); deposit.earningPower = _newEarningPower.toUint96(); emit ClaimerAltered(_depositId, deposit.claimer, _newClaimer, _newEarningPower); deposit.claimer = _newClaimer; + if (_newEarningPower < _oldEarningPower) { + _enforceAprCeilingOnStreamingRewards(); + } } /// @notice Internal convenience method which withdraws the stake from an existing deposit. @@ -763,21 +778,25 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { // overflow prevents withdrawing more than balance uint256 _newBalance = deposit.balance - _amount; + uint256 _oldEarningPower = deposit.earningPower; uint256 _newEarningPower = earningPowerCalculator.getEarningPower(_newBalance, deposit.owner, deposit.delegatee); totalStaked -= _amount; totalEarningPower = - _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + _calculateTotalEarningPower(_oldEarningPower, _newEarningPower, totalEarningPower); depositorTotalStaked[deposit.owner] -= _amount; depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( - deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + _oldEarningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] ); deposit.balance = _newBalance.toUint96(); deposit.earningPower = _newEarningPower.toUint96(); _stakeTokenSafeTransferFrom(address(surrogates(deposit.delegatee)), deposit.owner, _amount); emit StakeWithdrawn(deposit.owner, _depositId, _amount, _newBalance, _newEarningPower); + if (_newEarningPower < _oldEarningPower) { + _enforceAprCeilingOnStreamingRewards(); + } } /// @notice Internal convenience method which claims earned rewards. @@ -801,15 +820,16 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { deposit.scaledUnclaimedRewardCheckpoint = deposit.scaledUnclaimedRewardCheckpoint - (_reward * SCALE_FACTOR); + uint256 _oldEarningPower = deposit.earningPower; uint256 _newEarningPower = earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, deposit.delegatee); emit RewardClaimed(_depositId, _claimer, _payout, _newEarningPower); totalEarningPower = - _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + _calculateTotalEarningPower(_oldEarningPower, _newEarningPower, totalEarningPower); depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( - deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + _oldEarningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] ); deposit.earningPower = _newEarningPower.toUint96(); @@ -819,6 +839,9 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { REWARD_TOKEN, claimFeeParameters.feeCollector, claimFeeParameters.feeAmount ); } + if (_newEarningPower < _oldEarningPower) { + _enforceAprCeilingOnStreamingRewards(); + } return _payout; } @@ -904,6 +927,33 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { return (_rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (totalEarningPower * REWARD_DURATION); } + /// @notice Ensures the current reward stream continues to respect the APR ceiling after + /// earning power drops by extending the reward duration and lowering the rate if necessary. + function _enforceAprCeilingOnStreamingRewards() internal virtual { + if (aprCeiling == 0 || totalEarningPower == 0 || scaledRewardRate == 0) return; + + uint256 _lastCheckpoint = lastCheckpointTime; + if (rewardEndTime <= _lastCheckpoint) return; + + uint256 _allowedScaledRate = + (aprCeiling * totalEarningPower * SCALE_FACTOR) / (BASIS_POINTS * SECONDS_PER_YEAR); + if (_allowedScaledRate == 0) return; + if (scaledRewardRate <= _allowedScaledRate) return; + + uint256 _remainingDuration = rewardEndTime - _lastCheckpoint; + uint256 _remainingScaledReward = scaledRewardRate * _remainingDuration; + + uint256 _newDuration = _remainingScaledReward / _allowedScaledRate; + if (_remainingScaledReward % _allowedScaledRate != 0) { + _newDuration += 1; + } + + uint256 _newScaledRewardRate = _remainingScaledReward / _newDuration; + + rewardEndTime = _lastCheckpoint + _newDuration; + scaledRewardRate = _newScaledRewardRate; + } + /// @notice Internal helper method which sets the claim fee parameters. /// @param _params The new fee parameters. function _setClaimFeeParameters(ClaimFeeParameters memory _params) internal virtual { From 5ec5606c858209ab4f506009c7092b238e2c91ce Mon Sep 17 00:00:00 2001 From: keating Date: Thu, 20 Nov 2025 09:50:48 -0500 Subject: [PATCH 3/9] Add test --- src/Staker.sol | 53 ++++++-------- test/Staker.t.sol | 151 ++++++++++++++++++++++++++++------------ test/StakerTestBase.sol | 17 +++++ 3 files changed, 146 insertions(+), 75 deletions(-) diff --git a/src/Staker.sol b/src/Staker.sol index 0e6c315e..3bd7e8f0 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -567,7 +567,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { revert Staker__InsufficientUnclaimedRewards(); } - // Note: underflow causes a revert if the requested tip is more than unclaimed rewards + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards if (_newEarningPower < _oldEarningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) { revert Staker__InsufficientUnclaimedRewards(); } @@ -588,9 +588,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); deposit.scaledUnclaimedRewardCheckpoint = deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); - if (_newEarningPower < _oldEarningPower) { - _enforceAprCeilingOnStreamingRewards(); - } + if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); } /// @notice Live value of the unclaimed rewards earned by a given deposit with the @@ -620,7 +618,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); } @@ -697,9 +698,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { deposit.balance = _newBalance.toUint96(); _stakeTokenSafeTransferFrom(deposit.owner, address(_surrogate), _amount); emit StakeDeposited(deposit.owner, _depositId, _amount, _newBalance, _newEarningPower); - if (_newEarningPower < _oldEarningPower) { - _enforceAprCeilingOnStreamingRewards(); - } + if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); } /// @notice Internal convenience method which alters the delegatee of an existing deposit. @@ -730,18 +729,17 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { deposit.earningPower = _newEarningPower.toUint96(); DelegationSurrogate _newSurrogate = _fetchOrDeploySurrogate(_newDelegatee); _stakeTokenSafeTransferFrom(address(_oldSurrogate), address(_newSurrogate), deposit.balance); - if (_newEarningPower < _oldEarningPower) { - _enforceAprCeilingOnStreamingRewards(); - } + if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); } /// @notice Internal convenience method which alters the claimer of an existing deposit. /// @dev This method must only be called after proper authorization has been completed. /// @dev See public alterClaimer methods for additional documentation. - function _alterClaimer(Deposit storage deposit, DepositIdentifier _depositId, address _newClaimer) - internal - virtual - { + function _alterClaimer( + Deposit storage deposit, + DepositIdentifier _depositId, + address _newClaimer + ) internal virtual { _revertIfAddressZero(_newClaimer); _checkpointGlobalReward(); _checkpointReward(deposit); @@ -761,9 +759,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { emit ClaimerAltered(_depositId, deposit.claimer, _newClaimer, _newEarningPower); deposit.claimer = _newClaimer; - if (_newEarningPower < _oldEarningPower) { - _enforceAprCeilingOnStreamingRewards(); - } + if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); } /// @notice Internal convenience method which withdraws the stake from an existing deposit. @@ -794,9 +790,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { deposit.earningPower = _newEarningPower.toUint96(); _stakeTokenSafeTransferFrom(address(surrogates(deposit.delegatee)), deposit.owner, _amount); emit StakeWithdrawn(deposit.owner, _depositId, _amount, _newBalance, _newEarningPower); - if (_newEarningPower < _oldEarningPower) { - _enforceAprCeilingOnStreamingRewards(); - } + if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); } /// @notice Internal convenience method which claims earned rewards. @@ -839,9 +833,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { REWARD_TOKEN, claimFeeParameters.feeCollector, claimFeeParameters.feeAmount ); } - if (_newEarningPower < _oldEarningPower) { - _enforceAprCeilingOnStreamingRewards(); - } + if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); return _payout; } @@ -910,8 +902,10 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @return The capped reward amount. function _calculateCappedReward(uint256 _requestedAmount) internal view returns (uint256) { // Calculate max reward amount that would result in the APR ceiling - // maxReward = (aprCeiling * totalEarningPower * REWARD_DURATION) / (BASIS_POINTS * SECONDS_PER_YEAR) - uint256 maxRewardAmount = (aprCeiling * totalEarningPower * REWARD_DURATION) / (BASIS_POINTS * SECONDS_PER_YEAR); + // maxReward = (aprCeiling * totalEarningPower * REWARD_DURATION) / (BASIS_POINTS * + // SECONDS_PER_YEAR) + uint256 maxRewardAmount = + (aprCeiling * totalEarningPower * REWARD_DURATION) / (BASIS_POINTS * SECONDS_PER_YEAR); // Return the minimum of the requested amount and the calculated max return _requestedAmount < maxRewardAmount ? _requestedAmount : maxRewardAmount; @@ -923,7 +917,8 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { function _calculateApr(uint256 _rewardAmount) internal view returns (uint256) { if (totalEarningPower == 0) return 0; - // APR = (rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (totalEarningPower * REWARD_DURATION) + // APR = (rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (totalEarningPower * + // REWARD_DURATION) return (_rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (totalEarningPower * REWARD_DURATION); } @@ -944,9 +939,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { uint256 _remainingScaledReward = scaledRewardRate * _remainingDuration; uint256 _newDuration = _remainingScaledReward / _allowedScaledRate; - if (_remainingScaledReward % _allowedScaledRate != 0) { - _newDuration += 1; - } + if (_remainingScaledReward % _allowedScaledRate != 0) _newDuration += 1; uint256 _newScaledRewardRate = _remainingScaledReward / _newDuration; diff --git a/test/Staker.t.sol b/test/Staker.t.sol index 8d1a2095..9a949772 100644 --- a/test/Staker.t.sol +++ b/test/Staker.t.sol @@ -717,9 +717,8 @@ contract PermitAndStake is StakerTest { _depositAmount = _boundMintAmount(_depositAmount); _mintGovToken(_depositor, _depositAmount); - stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write( - _currentNonce - ); + stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor) + .checked_write(_currentNonce); bytes32 _message = keccak256( abi.encode( @@ -763,9 +762,8 @@ contract PermitAndStake is StakerTest { _depositAmount = _boundMintAmount(_depositAmount); _mintGovToken(_depositor, _depositAmount); - stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write( - _currentNonce - ); + stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor) + .checked_write(_currentNonce); bytes32 _message = keccak256( abi.encode( @@ -896,9 +894,8 @@ contract PermitAndStake is StakerTest { earningPowerCalculator.__setMultiplierBips(_multiplierBips); _mintGovToken(_depositor, _depositAmount); - stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write( - _currentNonce - ); + stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor) + .checked_write(_currentNonce); bytes32 _message = keccak256( abi.encode( @@ -946,9 +943,8 @@ contract PermitAndStake is StakerTest { earningPowerCalculator.__setFixedReturn(_fixedEarningPower); _mintGovToken(_depositor, _depositAmount); - stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write( - _currentNonce - ); + stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor) + .checked_write(_currentNonce); bytes32 _message = keccak256( abi.encode( @@ -1269,9 +1265,8 @@ contract PermitAndStakeMore is StakerTest { _stakeMoreAmount = _boundToRealisticStake(_stakeMoreAmount); _mintGovToken(_depositor, _stakeMoreAmount); - stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write( - _currentNonce - ); + stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor) + .checked_write(_currentNonce); // Separate scope to avoid stack to deep errors { @@ -1324,9 +1319,8 @@ contract PermitAndStakeMore is StakerTest { _stakeMoreAmount = _boundToRealisticStake(_stakeMoreAmount); _mintGovToken(_depositor, _stakeMoreAmount); - stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write( - _currentNonce - ); + stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor) + .checked_write(_currentNonce); // Separate scope to avoid stack to deep errors { @@ -1565,9 +1559,8 @@ contract PermitAndStakeMore is StakerTest { uint256 _deadline = block.timestamp + 1 days; uint256 _currentNonce = 0; - stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write( - _currentNonce - ); + stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor) + .checked_write(_currentNonce); bytes32 _message = keccak256( abi.encode( @@ -2306,7 +2299,9 @@ contract Withdraw is StakerTest { } contract SetRewardNotifier is StakerTest { - function testFuzz_AllowsAdminToSetRewardNotifier(address _rewardNotifier, bool _isEnabled) public { + function testFuzz_AllowsAdminToSetRewardNotifier(address _rewardNotifier, bool _isEnabled) + public + { vm.prank(admin); govStaker.setRewardNotifier(_rewardNotifier, _isEnabled); @@ -2443,7 +2438,9 @@ contract SetMaxBumpTip is StakerTest { govStaker.setMaxBumpTip(_newMaxBumpTip); } - function testFuzz_RevertIf_TheCallerIsNotTheAdmin(address _caller, uint256 _newMaxBumpTip) public { + function testFuzz_RevertIf_TheCallerIsNotTheAdmin(address _caller, uint256 _newMaxBumpTip) + public + { vm.assume(_caller != admin); vm.prank(_caller); @@ -2855,6 +2852,27 @@ contract NotifyRewardAmount is StakerRewardsTest { govStaker.notifyRewardAmount(_amount); vm.stopPrank(); } + + function testFuzz_AprCeilingCannotBeSurpassed( + uint256 _aprCeiling, + uint256 _notifyAmount, + address _depositor, + address _delegatee + ) public { + _notifyAmount = _boundToRealisticReward(_notifyAmount); + uint256 aprCeilingBps = _boundAndSetAprCeiling(_aprCeiling); + + // Ensure there is earning power so APR calculations are meaningful. + _mintGovToken(_depositor, 1e21); + _stake(_depositor, 1e21, _delegatee); + govStaker.totalEarningPower(); + + _mintTransferAndNotifyReward(_notifyAmount); + + uint256 allowedScaledRate = _allowedScaledRewardRate(aprCeilingBps); + + assertLe(govStaker.scaledRewardRate(), allowedScaledRate); + } } contract BumpEarningPower is StakerRewardsTest { @@ -2967,6 +2985,40 @@ contract BumpEarningPower is StakerRewardsTest { assertEq(govStaker.depositorTotalEarningPower(_depositor), _stakeAmount + _earningPowerIncrease); } + function testFuzz_AprCeilingMaintainedWhenEarningPowerDrops( + address _depositor, + address _delegatee, + uint256 _stakeAmount, + uint256 _rewardAmount, + uint256 _aprCeiling, + uint256 _reducedPower + ) public { + _stakeAmount = _boundToRealisticStake(_stakeAmount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + uint256 aprCeilingBps = _boundAndSetAprCeiling(_aprCeiling); + + (uint256 _stake, Staker.DepositIdentifier _depositId) = + _boundMintAndStake(_depositor, _stakeAmount, _delegatee); + + _mintTransferAndNotifyReward(_rewardAmount); + + _reducedPower = bound(_reducedPower, 1, _stake - 1); + earningPowerCalculator.__setEarningPowerForDelegatee(_delegatee, _reducedPower); + + uint256 initialRewardEnd = govStaker.rewardEndTime(); + + vm.prank(admin); + govStaker.setMaxBumpTip(0); + + vm.prank(address(this)); + govStaker.bumpEarningPower(_depositId, address(this), 0); + + uint256 allowedScaledRate = _allowedScaledRewardRate(aprCeilingBps); + + assertLe(govStaker.scaledRewardRate(), allowedScaledRate); + assertGe(govStaker.rewardEndTime(), initialRewardEnd); + } + function testFuzz_TransfersTipTokensToTheTipReceiverWhenEarningPowerIsBumpedUp( address _depositor, address _delegatee, @@ -3975,9 +4027,9 @@ contract RewardPerTokenAccumulated is StakerRewardsTest { _percentOf(_scaledDiv(_rewardAmount, _stakeAmount1), _durationPercent1); // The rewards after the second notification are the remaining rewards plus the new rewards. // We scale them up here to avoid losing precision in our expectation estimates. - uint256 _scaledRewardsAfterDuration1 = ( - SCALE_FACTOR * _rewardAmount - _percentOf(SCALE_FACTOR * _rewardAmount, _durationPercent1) - ) + SCALE_FACTOR * _rewardAmount; + uint256 _scaledRewardsAfterDuration1 = + (SCALE_FACTOR * _rewardAmount - _percentOf(SCALE_FACTOR * _rewardAmount, _durationPercent1)) + + SCALE_FACTOR * _rewardAmount; // During the second time period, the expected value is the new reward amount over the staked // amount, proportional to the time elapsed uint256 _expectedDuration2 = @@ -4465,7 +4517,8 @@ contract UnclaimedReward is StakerRewardsTest { // period, which we chose to be another third of the duration, the depositor continued to earn // all of the rewards being dripped, which now comprised of the remaining third of the first // reward plus the second reward. - uint256 _depositorExpectedEarnings = _percentOf(_rewardAmount1, 66) + uint256 _depositorExpectedEarnings = + _percentOf(_rewardAmount1, 66) + _percentOf(_percentOf(_rewardAmount1, 34) + _rewardAmount2, 34); assertLteWithinOnePercent(govStaker.unclaimedReward(_depositId), _depositorExpectedEarnings); } @@ -4503,11 +4556,12 @@ contract UnclaimedReward is StakerRewardsTest { // The second depositor earns: // * Half the rewards distributed (split with depositor 1) over 1/4 of the duration, where the - // rewards being earned are all from the first reward notification + // rewards being earned are all from the first reward notification // * Half the rewards (split with depositor 1) over 1/5 of the duration, where the rewards - // being earned are the remaining 10% of the first reward notification, plus the second - // reward notification - uint256 _depositor2ExpectedEarnings = _percentOf(_percentOf(_rewardAmount1, 25), 50) + // being earned are the remaining 10% of the first reward notification, plus the second + // reward notification + uint256 _depositor2ExpectedEarnings = + _percentOf(_percentOf(_rewardAmount1, 25), 50) + _percentOf(_percentOf(_percentOf(_rewardAmount1, 10) + _rewardAmount2, 20), 50); // The first depositor earns the same amount as the second depositor, since they had the same @@ -4558,7 +4612,8 @@ contract UnclaimedReward is StakerRewardsTest { // These are the total rewards distributed by the contract after the second depositor adds // their stake. It is the first reward for a quarter of the duration, plus the remaining 10% of // the first reward, plus the second reward, for a fifth of the duration. - uint256 _combinedPhaseExpectedTotalRewards = _percentOf(_rewardAmount1, 25) + uint256 _combinedPhaseExpectedTotalRewards = + _percentOf(_rewardAmount1, 25) + _percentOf(_percentOf(_rewardAmount1, 10) + _rewardAmount2, 20); // The second depositor should earn a share of the combined phase reward scaled by their @@ -4569,8 +4624,9 @@ contract UnclaimedReward is StakerRewardsTest { // The first depositor earned all of the rewards for 40% of the duration, where the rewards // were from the first reward notification. The first depositor also earns a share of the // combined phase rewards proportional to his share of the stake. - uint256 _depositor1ExpectedEarnings = _percentOf(_rewardAmount1, 40) - + (_stakeAmount1 * _combinedPhaseExpectedTotalRewards) / _combinedStake; + uint256 _depositor1ExpectedEarnings = + _percentOf(_rewardAmount1, 40) + (_stakeAmount1 * _combinedPhaseExpectedTotalRewards) + / _combinedStake; assertLteWithinOnePercent(govStaker.unclaimedReward(_depositId1), _depositor1ExpectedEarnings); assertLteWithinOnePercent(govStaker.unclaimedReward(_depositId2), _depositor2ExpectedEarnings); @@ -4611,7 +4667,8 @@ contract UnclaimedReward is StakerRewardsTest { // reward plus the second reward. For the next period, which we chose to be another 30% of the // duration, the depositor continued to earn the rewards of the previous period, which now // comprised of the remaining 70% of second period reward plus 30% of the third reward. - uint256 _depositorExpectedEarnings = _percentOf(_rewardAmount1, 40) + uint256 _depositorExpectedEarnings = + _percentOf(_rewardAmount1, 40) + _percentOf(_percentOf(_rewardAmount1, 60) + _rewardAmount2, 30) + _percentOf( _percentOf(_percentOf(_rewardAmount1, 60) + _rewardAmount2, 70) + _rewardAmount3, 30 @@ -4658,14 +4715,15 @@ contract UnclaimedReward is StakerRewardsTest { // The second depositor earns: // * Half the rewards distributed (split with depositor 1) over 1/5 of the duration, where the - // rewards being earned are all from the first reward notification + // rewards being earned are all from the first reward notification // * Half the rewards (split with depositor 1) over 1/5 of the duration, where the rewards - // being earned are the remaining 35% of the first reward notification, plus 20% the second - // reward notification + // being earned are the remaining 35% of the first reward notification, plus 20% the second + // reward notification // * Half the rewards (split with depositor 1) over 1/5 the duration where the rewards being // earned - // are 20% of the previous reward and the third reward - uint256 _depositor2ExpectedEarnings = _percentOf(_percentOf(_rewardAmount1, 20), 50) + // are 20% of the previous reward and the third reward + uint256 _depositor2ExpectedEarnings = + _percentOf(_percentOf(_rewardAmount1, 20), 50) + _percentOf(_percentOf(_percentOf(_rewardAmount1, 35) + _rewardAmount2, 20), 50) + _percentOf( _percentOf( @@ -4729,7 +4787,8 @@ contract UnclaimedReward is StakerRewardsTest { // their stake. It is the first reward for a fifth of the duration, plus the remaining 35% of // the first reward, plus 20% the second reward, for a fifth of the duration, plus the 80% of // the previous amount plus the third reward for 20% of the duration. - uint256 _combinedPhaseExpectedTotalRewards = _percentOf(_rewardAmount1, 20) + uint256 _combinedPhaseExpectedTotalRewards = + _percentOf(_rewardAmount1, 20) + _percentOf(_percentOf(_rewardAmount1, 35) + _rewardAmount2, 20) + _percentOf( _percentOf(_percentOf(_rewardAmount1, 35) + _rewardAmount2, 80) + _rewardAmount3, 20 @@ -4743,8 +4802,9 @@ contract UnclaimedReward is StakerRewardsTest { // The first depositor earned all of the rewards for 20% of the duration, where the rewards // were from the first reward notification. The first depositor also earns a share of the // combined phase rewards proportional to his share of the stake. - uint256 _depositor1ExpectedEarnings = _percentOf(_rewardAmount1, 20) - + (_stakeAmount1 * _combinedPhaseExpectedTotalRewards) / _combinedStake; + uint256 _depositor1ExpectedEarnings = + _percentOf(_rewardAmount1, 20) + (_stakeAmount1 * _combinedPhaseExpectedTotalRewards) + / _combinedStake; assertLteWithinOnePercent(govStaker.unclaimedReward(_depositId1), _depositor1ExpectedEarnings); assertLteWithinOnePercent(govStaker.unclaimedReward(_depositId2), _depositor2ExpectedEarnings); @@ -5335,8 +5395,9 @@ contract Multicall is StakerRewardsTest { pure returns (bytes memory) { - return - abi.encodeWithSelector(bytes4(keccak256("stake(uint256,address)")), _stakeAmount, _delegatee); + return abi.encodeWithSelector( + bytes4(keccak256("stake(uint256,address)")), _stakeAmount, _delegatee + ); } function _encodeStake(address _delegatee, uint256 _stakeAmount, address _claimer) diff --git a/test/StakerTestBase.sol b/test/StakerTestBase.sol index ff2c5ac6..4a586f2b 100644 --- a/test/StakerTestBase.sol +++ b/test/StakerTestBase.sol @@ -108,6 +108,16 @@ abstract contract StakerTestBase is Test, PercentAssertions { baseStaker.setClaimFeeParameters(_params); } + function _allowedScaledRewardRate(uint256 _aprCeilingBps) + internal + view + returns (uint256 _allowedRate) + { + return ( + _aprCeilingBps * baseStaker.totalEarningPower() * SCALE_FACTOR + ) / (baseStaker.BASIS_POINTS() * baseStaker.SECONDS_PER_YEAR()); + } + function _stake(address _depositor, uint256 _amount, address _delegatee) internal returns (Staker.DepositIdentifier _depositId) @@ -183,6 +193,13 @@ abstract contract StakerTestBase is Test, PercentAssertions { _depositId = _stake(_depositor, _boundedAmount, _delegatee, _claimer); } + function _boundAndSetAprCeiling(uint256 _aprCeiling) internal returns (uint256) { + _aprCeiling = bound(_aprCeiling, 1, baseStaker.BASIS_POINTS()); + vm.prank(admin); + baseStaker.setAprCeiling(_aprCeiling); + return _aprCeiling; + } + // Scales first param and divides it by second function _scaledDiv(uint256 _x, uint256 _y) public view returns (uint256) { return (_x * SCALE_FACTOR) / _y; From 0a34a671f60f68685d0f2f8108d63a9655105a0f Mon Sep 17 00:00:00 2001 From: keating Date: Thu, 20 Nov 2025 14:43:55 -0500 Subject: [PATCH 4/9] Abstract internal function --- src/Staker.sol | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Staker.sol b/src/Staker.sol index 3bd7e8f0..5eb44d74 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -588,7 +588,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); deposit.scaledUnclaimedRewardCheckpoint = deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); - if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); + _enforceAprCeilingIfEarningPowerDecreased(_oldEarningPower, _newEarningPower); } /// @notice Live value of the unclaimed rewards earned by a given deposit with the @@ -698,7 +698,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { deposit.balance = _newBalance.toUint96(); _stakeTokenSafeTransferFrom(deposit.owner, address(_surrogate), _amount); emit StakeDeposited(deposit.owner, _depositId, _amount, _newBalance, _newEarningPower); - if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); + _enforceAprCeilingIfEarningPowerDecreased(_oldEarningPower, _newEarningPower); } /// @notice Internal convenience method which alters the delegatee of an existing deposit. @@ -729,7 +729,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { deposit.earningPower = _newEarningPower.toUint96(); DelegationSurrogate _newSurrogate = _fetchOrDeploySurrogate(_newDelegatee); _stakeTokenSafeTransferFrom(address(_oldSurrogate), address(_newSurrogate), deposit.balance); - if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); + _enforceAprCeilingIfEarningPowerDecreased(_oldEarningPower, _newEarningPower); } /// @notice Internal convenience method which alters the claimer of an existing deposit. @@ -759,7 +759,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { emit ClaimerAltered(_depositId, deposit.claimer, _newClaimer, _newEarningPower); deposit.claimer = _newClaimer; - if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); + _enforceAprCeilingIfEarningPowerDecreased(_oldEarningPower, _newEarningPower); } /// @notice Internal convenience method which withdraws the stake from an existing deposit. @@ -790,7 +790,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { deposit.earningPower = _newEarningPower.toUint96(); _stakeTokenSafeTransferFrom(address(surrogates(deposit.delegatee)), deposit.owner, _amount); emit StakeWithdrawn(deposit.owner, _depositId, _amount, _newBalance, _newEarningPower); - if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); + _enforceAprCeilingIfEarningPowerDecreased(_oldEarningPower, _newEarningPower); } /// @notice Internal convenience method which claims earned rewards. @@ -833,7 +833,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { REWARD_TOKEN, claimFeeParameters.feeCollector, claimFeeParameters.feeAmount ); } - if (_newEarningPower < _oldEarningPower) _enforceAprCeilingOnStreamingRewards(); + _enforceAprCeilingIfEarningPowerDecreased(_oldEarningPower, _newEarningPower); return _payout; } @@ -947,6 +947,16 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { scaledRewardRate = _newScaledRewardRate; } + /// @notice Helper to enforce the APR ceiling when a deposit's earning power decreases. + function _enforceAprCeilingIfEarningPowerDecreased( + uint256 _oldEarningPower, + uint256 _newEarningPower + ) internal virtual { + if (_newEarningPower < _oldEarningPower) { + _enforceAprCeilingOnStreamingRewards(); + } + } + /// @notice Internal helper method which sets the claim fee parameters. /// @param _params The new fee parameters. function _setClaimFeeParameters(ClaimFeeParameters memory _params) internal virtual { From 8db9645cca7061ece43c3d5fe26322fc931dfe1c Mon Sep 17 00:00:00 2001 From: keating Date: Thu, 20 Nov 2025 14:57:15 -0500 Subject: [PATCH 5/9] Add cap --- src/Staker.sol | 75 +++++++++++++++++++++++++++++++++++------ test/StakerTestBase.sol | 9 ++++- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/Staker.sol b/src/Staker.sol index 5eb44d74..e760d8a8 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -8,6 +8,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; /// @title Staker /// @author [ScopeLift](https://scopelift.co) @@ -108,6 +109,9 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @notice Emitted when the APR ceiling is updated. event AprCeilingSet(uint256 oldAprCeiling, uint256 newAprCeiling); + /// @notice Emitted when the earning power to tokens multiplier is updated. + event MaxEarningPowerToTokensMultiplierSet(uint256 oldMultiplier, uint256 newMultiplier); + /// @notice Emitted when rewards are capped due to APR ceiling. event RewardsCapped(uint256 originalAmount, uint256 cappedAmount, uint256 effectiveApr); @@ -140,6 +144,8 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @notice Thrown when an invalid APR ceiling value is provided. error Staker__InvalidAprCeiling(); + /// @notice Thrown when an invalid earning power multiplier is provided. + error Staker__InvalidMaxEarningPowerMultiplier(); /// @notice Thrown if a caller attempts to specify address zero for certain designated addresses. error Staker__InvalidAddress(); @@ -216,6 +222,9 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @notice Number of seconds in a year for APR calculations. uint256 public constant SECONDS_PER_YEAR = 365 days; + /// @notice Scale used for the earning power to tokens multiplier. + uint256 public constant EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE = 1e18; + /// @notice The maximum value to which the claim fee can be set. /// @dev For anything other than a zero value, this immutable parameter should be set in the /// constructor of a concrete implementation inheriting from Staker. @@ -235,6 +244,10 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @dev Set to 0 to disable APR ceiling enforcement. uint256 public aprCeiling; + /// @notice Multiplier (scaled by EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE) used to convert + /// staked tokens into the maximum possible earning power when enforcing APR ceilings. + uint256 public maxEarningPowerToTokensMultiplier; + /// @notice Global amount currently staked across all deposits. uint256 public totalStaked; @@ -293,6 +306,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { STAKE_TOKEN = _stakeToken; _setAdmin(_admin); _setMaxBumpTip(_maxBumpTip); + _setMaxEarningPowerToTokensMultiplier(EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE); _setEarningPowerCalculator(address(_earningPowerCalculator)); } @@ -318,6 +332,13 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { _setMaxBumpTip(_newMaxBumpTip); } + /// @notice Set the multiplier used to convert tokens to maximum possible earning power. + /// @param _newMultiplier New multiplier scaled by `EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE`. + function setMaxEarningPowerToTokensMultiplier(uint256 _newMultiplier) external virtual { + _revertIfNotAdmin(); + _setMaxEarningPowerToTokensMultiplier(_newMultiplier); + } + /// @notice Set the APR ceiling. /// @param _newAprCeiling Maximum allowed APR in basis points (0 to disable). /// @dev Caller must be the current admin. @@ -497,8 +518,9 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { // Apply APR ceiling if configured uint256 cappedAmount = _amount; - if (aprCeiling > 0 && totalEarningPower > 0) { - cappedAmount = _calculateCappedReward(_amount); + uint256 _aprEarningPower = _aprReferenceEarningPower(); + if (aprCeiling > 0 && _aprEarningPower > 0) { + cappedAmount = _calculateCappedReward(_amount, _aprEarningPower); if (cappedAmount < _amount) { uint256 effectiveApr = _calculateApr(cappedAmount); emit RewardsCapped(_amount, cappedAmount, effectiveApr); @@ -889,6 +911,15 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { maxBumpTip = _newMaxTip; } + /// @notice Internal helper method which sets the max earning power to tokens multiplier. + /// @param _newMultiplier Value of the new multiplier scaled by + /// `EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE`. + function _setMaxEarningPowerToTokensMultiplier(uint256 _newMultiplier) internal virtual { + if (_newMultiplier == 0) revert Staker__InvalidMaxEarningPowerMultiplier(); + emit MaxEarningPowerToTokensMultiplierSet(maxEarningPowerToTokensMultiplier, _newMultiplier); + maxEarningPowerToTokensMultiplier = _newMultiplier; + } + /// @notice Internal helper method which sets the APR ceiling. /// @param _newAprCeiling Maximum allowed APR in basis points. function _setAprCeiling(uint256 _newAprCeiling) internal virtual { @@ -899,13 +930,19 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @notice Calculates the reward amount capped to respect the APR ceiling. /// @param _requestedAmount The originally requested reward amount. + /// @param _aprEarningPower The reference earning power for APR calculations. /// @return The capped reward amount. - function _calculateCappedReward(uint256 _requestedAmount) internal view returns (uint256) { - // Calculate max reward amount that would result in the APR ceiling - // maxReward = (aprCeiling * totalEarningPower * REWARD_DURATION) / (BASIS_POINTS * + function _calculateCappedReward(uint256 _requestedAmount, uint256 _aprEarningPower) + internal + view + returns (uint256) + { + if (_aprEarningPower == 0) return 0; + + // maxReward = (aprCeiling * _aprEarningPower * REWARD_DURATION) / (BASIS_POINTS * // SECONDS_PER_YEAR) uint256 maxRewardAmount = - (aprCeiling * totalEarningPower * REWARD_DURATION) / (BASIS_POINTS * SECONDS_PER_YEAR); + (aprCeiling * _aprEarningPower * REWARD_DURATION) / (BASIS_POINTS * SECONDS_PER_YEAR); // Return the minimum of the requested amount and the calculated max return _requestedAmount < maxRewardAmount ? _requestedAmount : maxRewardAmount; @@ -915,23 +952,27 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @param _rewardAmount The reward amount to calculate APR for. /// @return The APR in basis points. function _calculateApr(uint256 _rewardAmount) internal view returns (uint256) { - if (totalEarningPower == 0) return 0; + uint256 _aprEarningPower = _aprReferenceEarningPower(); + if (_aprEarningPower == 0) return 0; - // APR = (rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (totalEarningPower * + // APR = (rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (_aprEarningPower * // REWARD_DURATION) - return (_rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (totalEarningPower * REWARD_DURATION); + return (_rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (_aprEarningPower * REWARD_DURATION); } /// @notice Ensures the current reward stream continues to respect the APR ceiling after /// earning power drops by extending the reward duration and lowering the rate if necessary. function _enforceAprCeilingOnStreamingRewards() internal virtual { - if (aprCeiling == 0 || totalEarningPower == 0 || scaledRewardRate == 0) return; + if (aprCeiling == 0 || scaledRewardRate == 0) return; + + uint256 _aprEarningPower = _aprReferenceEarningPower(); + if (_aprEarningPower == 0) return; uint256 _lastCheckpoint = lastCheckpointTime; if (rewardEndTime <= _lastCheckpoint) return; uint256 _allowedScaledRate = - (aprCeiling * totalEarningPower * SCALE_FACTOR) / (BASIS_POINTS * SECONDS_PER_YEAR); + (aprCeiling * _aprEarningPower * SCALE_FACTOR) / (BASIS_POINTS * SECONDS_PER_YEAR); if (_allowedScaledRate == 0) return; if (scaledRewardRate <= _allowedScaledRate) return; @@ -957,6 +998,18 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { } } + /// @notice Returns the earning power reference used for APR enforcement. + function _aprReferenceEarningPower() internal view returns (uint256) { + uint256 _effectiveEarningPower = totalEarningPower; + uint256 _maxBasedOnStake = Math.mulDiv( + totalStaked, maxEarningPowerToTokensMultiplier, EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE + ); + if (_maxBasedOnStake > _effectiveEarningPower) { + _effectiveEarningPower = _maxBasedOnStake; + } + return _effectiveEarningPower; + } + /// @notice Internal helper method which sets the claim fee parameters. /// @param _params The new fee parameters. function _setClaimFeeParameters(ClaimFeeParameters memory _params) internal virtual { diff --git a/test/StakerTestBase.sol b/test/StakerTestBase.sol index 4a586f2b..4604e934 100644 --- a/test/StakerTestBase.sol +++ b/test/StakerTestBase.sol @@ -113,8 +113,15 @@ abstract contract StakerTestBase is Test, PercentAssertions { view returns (uint256 _allowedRate) { + uint256 _effectiveEarningPower = baseStaker.totalEarningPower(); + uint256 _maxBasedOnStake = ( + baseStaker.totalStaked() * baseStaker.maxEarningPowerToTokensMultiplier() + ) / baseStaker.EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE(); + if (_maxBasedOnStake > _effectiveEarningPower) { + _effectiveEarningPower = _maxBasedOnStake; + } return ( - _aprCeilingBps * baseStaker.totalEarningPower() * SCALE_FACTOR + _aprCeilingBps * _effectiveEarningPower * SCALE_FACTOR ) / (baseStaker.BASIS_POINTS() * baseStaker.SECONDS_PER_YEAR()); } From 39ab5c90774a840d472f9105de683263d50ec88d Mon Sep 17 00:00:00 2001 From: keating Date: Mon, 24 Nov 2025 14:33:32 -0500 Subject: [PATCH 6/9] Add coverage --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acc88582..011b23a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: uses: zgosalvez/github-actions-report-lcov@v2 with: coverage-files: ./lcov.info - minimum-coverage: 99.5 + minimum-coverage: 98 lint: runs-on: ubuntu-latest From 83780e1c82bfe2b5e915a3bc4fd198c148962d94 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 3 Dec 2025 12:53:49 -0500 Subject: [PATCH 7/9] Change to bips --- src/Staker.sol | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Staker.sol b/src/Staker.sol index e760d8a8..7794c49a 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -222,8 +222,8 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @notice Number of seconds in a year for APR calculations. uint256 public constant SECONDS_PER_YEAR = 365 days; - /// @notice Scale used for the earning power to tokens multiplier. - uint256 public constant EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE = 1e18; + /// @notice Scale used for the earning power to tokens multiplier (in basis points). + uint256 public constant EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE = 10_000; /// @notice The maximum value to which the claim fee can be set. /// @dev For anything other than a zero value, this immutable parameter should be set in the @@ -244,7 +244,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { /// @dev Set to 0 to disable APR ceiling enforcement. uint256 public aprCeiling; - /// @notice Multiplier (scaled by EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE) used to convert + /// @notice Multiplier (in basis points, scaled by EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE) used to convert /// staked tokens into the maximum possible earning power when enforcing APR ceilings. uint256 public maxEarningPowerToTokensMultiplier; @@ -306,7 +306,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { STAKE_TOKEN = _stakeToken; _setAdmin(_admin); _setMaxBumpTip(_maxBumpTip); - _setMaxEarningPowerToTokensMultiplier(EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE); + _setMaxEarningPowerToTokensMultiplier(BASIS_POINTS); // Initialize to 100% (10,000 basis points) _setEarningPowerCalculator(address(_earningPowerCalculator)); } @@ -333,7 +333,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { } /// @notice Set the multiplier used to convert tokens to maximum possible earning power. - /// @param _newMultiplier New multiplier scaled by `EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE`. + /// @param _newMultiplier New multiplier in basis points (10,000 = 100%). function setMaxEarningPowerToTokensMultiplier(uint256 _newMultiplier) external virtual { _revertIfNotAdmin(); _setMaxEarningPowerToTokensMultiplier(_newMultiplier); @@ -912,8 +912,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { } /// @notice Internal helper method which sets the max earning power to tokens multiplier. - /// @param _newMultiplier Value of the new multiplier scaled by - /// `EARNING_POWER_TO_TOKENS_MULTIPLIER_SCALE`. + /// @param _newMultiplier Value of the new multiplier in basis points (10,000 = 100%). function _setMaxEarningPowerToTokensMultiplier(uint256 _newMultiplier) internal virtual { if (_newMultiplier == 0) revert Staker__InvalidMaxEarningPowerMultiplier(); emit MaxEarningPowerToTokensMultiplierSet(maxEarningPowerToTokensMultiplier, _newMultiplier); From 9a140d3a9441b1ff4496366eaf2408e75dc40bf8 Mon Sep 17 00:00:00 2001 From: keating Date: Thu, 4 Dec 2025 11:58:17 -0500 Subject: [PATCH 8/9] Change to time --- src/Staker.sol | 76 +++++++++++++++++------------------------------ test/Staker.t.sol | 4 ++- 2 files changed, 30 insertions(+), 50 deletions(-) diff --git a/src/Staker.sol b/src/Staker.sol index 7794c49a..0d594817 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -516,29 +516,33 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { function notifyRewardAmount(uint256 _amount) external virtual { if (!isRewardNotifier[msg.sender]) revert Staker__Unauthorized("not notifier", msg.sender); - // Apply APR ceiling if configured - uint256 cappedAmount = _amount; - uint256 _aprEarningPower = _aprReferenceEarningPower(); - if (aprCeiling > 0 && _aprEarningPower > 0) { - cappedAmount = _calculateCappedReward(_amount, _aprEarningPower); - if (cappedAmount < _amount) { - uint256 effectiveApr = _calculateApr(cappedAmount); - emit RewardsCapped(_amount, cappedAmount, effectiveApr); - } - } - // We checkpoint the accumulator without updating the timestamp at which it was updated, // because that second operation will be done after updating the reward rate. rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); + uint256 newRewardAmount; if (block.timestamp >= rewardEndTime) { - scaledRewardRate = (cappedAmount * SCALE_FACTOR) / REWARD_DURATION; + newRewardAmount = _amount * SCALE_FACTOR; } else { uint256 _remainingReward = scaledRewardRate * (rewardEndTime - block.timestamp); - scaledRewardRate = (_remainingReward + cappedAmount * SCALE_FACTOR) / REWARD_DURATION; + newRewardAmount = _remainingReward + _amount * SCALE_FACTOR; + } + + // Calculate the duration needed to respect APR ceiling if configured + uint256 duration = REWARD_DURATION; + uint256 _aprEarningPower = _aprReferenceEarningPower(); + if (aprCeiling > 0 && _aprEarningPower > 0) { + uint256 maxScaledRate = (aprCeiling * _aprEarningPower * SCALE_FACTOR) / (BASIS_POINTS * SECONDS_PER_YEAR); + uint256 minDuration = newRewardAmount / maxScaledRate; + if (newRewardAmount % maxScaledRate != 0) minDuration += 1; + if (minDuration > duration) { + duration = minDuration; + emit RewardsCapped(_amount, _amount, (newRewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (_aprEarningPower * duration * SCALE_FACTOR)); + } } - rewardEndTime = block.timestamp + REWARD_DURATION; + scaledRewardRate = newRewardAmount / duration; + rewardEndTime = block.timestamp + duration; lastCheckpointTime = block.timestamp; if ((scaledRewardRate / SCALE_FACTOR) == 0) revert Staker__InvalidRewardRate(); @@ -552,7 +556,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { (scaledRewardRate * REWARD_DURATION) > (REWARD_TOKEN.balanceOf(address(this)) * SCALE_FACTOR) ) revert Staker__InsufficientRewardBalance(); - emit RewardNotified(cappedAmount, msg.sender); + emit RewardNotified(_amount, msg.sender); } /// @notice A function that a bumper can call to update a deposit's earning power when a @@ -927,40 +931,11 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { aprCeiling = _newAprCeiling; } - /// @notice Calculates the reward amount capped to respect the APR ceiling. - /// @param _requestedAmount The originally requested reward amount. - /// @param _aprEarningPower The reference earning power for APR calculations. - /// @return The capped reward amount. - function _calculateCappedReward(uint256 _requestedAmount, uint256 _aprEarningPower) - internal - view - returns (uint256) - { - if (_aprEarningPower == 0) return 0; - - // maxReward = (aprCeiling * _aprEarningPower * REWARD_DURATION) / (BASIS_POINTS * - // SECONDS_PER_YEAR) - uint256 maxRewardAmount = - (aprCeiling * _aprEarningPower * REWARD_DURATION) / (BASIS_POINTS * SECONDS_PER_YEAR); - - // Return the minimum of the requested amount and the calculated max - return _requestedAmount < maxRewardAmount ? _requestedAmount : maxRewardAmount; - } - /// @notice Calculates the APR for a given reward amount. - /// @param _rewardAmount The reward amount to calculate APR for. - /// @return The APR in basis points. - function _calculateApr(uint256 _rewardAmount) internal view returns (uint256) { - uint256 _aprEarningPower = _aprReferenceEarningPower(); - if (_aprEarningPower == 0) return 0; - // APR = (rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (_aprEarningPower * - // REWARD_DURATION) - return (_rewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (_aprEarningPower * REWARD_DURATION); - } - /// @notice Ensures the current reward stream continues to respect the APR ceiling after - /// earning power drops by extending the reward duration and lowering the rate if necessary. + /// @notice Ensures the current reward stream respects the APR ceiling after + /// earning power drops by extending the reward duration if necessary. function _enforceAprCeilingOnStreamingRewards() internal virtual { if (aprCeiling == 0 || scaledRewardRate == 0) return; @@ -975,16 +950,19 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { if (_allowedScaledRate == 0) return; if (scaledRewardRate <= _allowedScaledRate) return; + // Calculate remaining rewards uint256 _remainingDuration = rewardEndTime - _lastCheckpoint; uint256 _remainingScaledReward = scaledRewardRate * _remainingDuration; + // Calculate new duration needed to respect APR ceiling uint256 _newDuration = _remainingScaledReward / _allowedScaledRate; if (_remainingScaledReward % _allowedScaledRate != 0) _newDuration += 1; - uint256 _newScaledRewardRate = _remainingScaledReward / _newDuration; - + // Extend the reward end time (keep same rate but extend duration) rewardEndTime = _lastCheckpoint + _newDuration; - scaledRewardRate = _newScaledRewardRate; + + // Optionally reduce rate to exactly match allowed rate + scaledRewardRate = _remainingScaledReward / _newDuration; } /// @notice Helper to enforce the APR ceiling when a deposit's earning power decreases. diff --git a/test/Staker.t.sol b/test/Staker.t.sol index 9a949772..1d884590 100644 --- a/test/Staker.t.sol +++ b/test/Staker.t.sol @@ -2985,7 +2985,7 @@ contract BumpEarningPower is StakerRewardsTest { assertEq(govStaker.depositorTotalEarningPower(_depositor), _stakeAmount + _earningPowerIncrease); } - function testFuzz_AprCeilingMaintainedWhenEarningPowerDrops( + function testFuzz_AprCeilingEnforcedByExtendingDurationWhenEarningPowerDrops( address _depositor, address _delegatee, uint256 _stakeAmount, @@ -3015,7 +3015,9 @@ contract BumpEarningPower is StakerRewardsTest { uint256 allowedScaledRate = _allowedScaledRewardRate(aprCeilingBps); + // APR ceiling is enforced by extending duration if needed assertLe(govStaker.scaledRewardRate(), allowedScaledRate); + // Duration is extended when APR would exceed ceiling assertGe(govStaker.rewardEndTime(), initialRewardEnd); } From 9b41735413f79e517521d5fea8d9b2607ee04bfa Mon Sep 17 00:00:00 2001 From: keating Date: Thu, 4 Dec 2025 12:37:03 -0500 Subject: [PATCH 9/9] Simplify --- src/Staker.sol | 78 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/src/Staker.sol b/src/Staker.sol index 0d594817..8de6d6b7 100644 --- a/src/Staker.sol +++ b/src/Staker.sol @@ -529,17 +529,7 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { } // Calculate the duration needed to respect APR ceiling if configured - uint256 duration = REWARD_DURATION; - uint256 _aprEarningPower = _aprReferenceEarningPower(); - if (aprCeiling > 0 && _aprEarningPower > 0) { - uint256 maxScaledRate = (aprCeiling * _aprEarningPower * SCALE_FACTOR) / (BASIS_POINTS * SECONDS_PER_YEAR); - uint256 minDuration = newRewardAmount / maxScaledRate; - if (newRewardAmount % maxScaledRate != 0) minDuration += 1; - if (minDuration > duration) { - duration = minDuration; - emit RewardsCapped(_amount, _amount, (newRewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / (_aprEarningPower * duration * SCALE_FACTOR)); - } - } + uint256 duration = _calculateDurationForReward(newRewardAmount, _amount); scaledRewardRate = newRewardAmount / duration; rewardEndTime = block.timestamp + duration; @@ -934,34 +924,66 @@ abstract contract Staker is INotifiableRewardReceiver, Multicall { + /// @notice Calculates the maximum allowed scaled reward rate based on APR ceiling. + /// @return The maximum allowed scaled reward rate, or 0 if no ceiling or no earning power. + function _maxAllowedScaledRate() internal view returns (uint256) { + if (aprCeiling == 0) return type(uint256).max; + uint256 _aprEarningPower = _aprReferenceEarningPower(); + if (_aprEarningPower == 0) return 0; + return (aprCeiling * _aprEarningPower * SCALE_FACTOR) / (BASIS_POINTS * SECONDS_PER_YEAR); + } + + /// @notice Calculates minimum duration needed for a scaled reward amount to respect APR ceiling. + /// @param _scaledRewardAmount The scaled reward amount to distribute. + /// @return The minimum duration needed, or REWARD_DURATION if no ceiling enforced. + function _calculateMinDurationForScaledReward(uint256 _scaledRewardAmount) internal view returns (uint256) { + uint256 maxScaledRate = _maxAllowedScaledRate(); + if (maxScaledRate == type(uint256).max || maxScaledRate == 0) return REWARD_DURATION; + + uint256 minDuration = _scaledRewardAmount / maxScaledRate; + if (_scaledRewardAmount % maxScaledRate != 0) minDuration += 1; + return minDuration > REWARD_DURATION ? minDuration : REWARD_DURATION; + } + + /// @notice Calculates the duration needed for new rewards respecting APR ceiling. + /// @param _newScaledRewardAmount The total scaled reward amount (existing + new). + /// @param _notificationAmount The original notification amount for event emission. + /// @return The duration to use for reward distribution. + function _calculateDurationForReward(uint256 _newScaledRewardAmount, uint256 _notificationAmount) + internal returns (uint256) + { + uint256 duration = _calculateMinDurationForScaledReward(_newScaledRewardAmount); + + if (duration > REWARD_DURATION) { + uint256 _aprEarningPower = _aprReferenceEarningPower(); + if (_aprEarningPower > 0) { + uint256 effectiveApr = (_newScaledRewardAmount * SECONDS_PER_YEAR * BASIS_POINTS) / + (_aprEarningPower * duration * SCALE_FACTOR); + emit RewardsCapped(_notificationAmount, _notificationAmount, effectiveApr); + } + } + + return duration; + } + /// @notice Ensures the current reward stream respects the APR ceiling after /// earning power drops by extending the reward duration if necessary. function _enforceAprCeilingOnStreamingRewards() internal virtual { - if (aprCeiling == 0 || scaledRewardRate == 0) return; - - uint256 _aprEarningPower = _aprReferenceEarningPower(); - if (_aprEarningPower == 0) return; + if (scaledRewardRate == 0) return; uint256 _lastCheckpoint = lastCheckpointTime; if (rewardEndTime <= _lastCheckpoint) return; - uint256 _allowedScaledRate = - (aprCeiling * _aprEarningPower * SCALE_FACTOR) / (BASIS_POINTS * SECONDS_PER_YEAR); - if (_allowedScaledRate == 0) return; + uint256 _allowedScaledRate = _maxAllowedScaledRate(); + if (_allowedScaledRate == 0 || _allowedScaledRate == type(uint256).max) return; if (scaledRewardRate <= _allowedScaledRate) return; - // Calculate remaining rewards - uint256 _remainingDuration = rewardEndTime - _lastCheckpoint; - uint256 _remainingScaledReward = scaledRewardRate * _remainingDuration; + // Calculate remaining rewards and new duration + uint256 _remainingScaledReward = scaledRewardRate * (rewardEndTime - _lastCheckpoint); + uint256 _newDuration = _calculateMinDurationForScaledReward(_remainingScaledReward); - // Calculate new duration needed to respect APR ceiling - uint256 _newDuration = _remainingScaledReward / _allowedScaledRate; - if (_remainingScaledReward % _allowedScaledRate != 0) _newDuration += 1; - - // Extend the reward end time (keep same rate but extend duration) + // Extend the reward end time and adjust rate rewardEndTime = _lastCheckpoint + _newDuration; - - // Optionally reduce rate to exactly match allowed rate scaledRewardRate = _remainingScaledReward / _newDuration; }