diff --git a/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol b/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol new file mode 100644 index 00000000..3d77904b --- /dev/null +++ b/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.23; + +import {BinaryEligibilityOracleEarningPowerCalculator} from + "src/calculators/BinaryEligibilityOracleEarningPowerCalculator.sol"; +import {MintRewardNotifier} from "../notifiers/MintRewardNotifier.sol"; +import {StakerTestBase} from "./StakerTestBase.sol"; +import {Staker} from "../Staker.sol"; + +/// @title BinaryEligibilityOracleEarningPowerCalculatorTestBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice The base contract for testing `BinaryEligibilityOracleEarningPowerCalculator`. Contains +/// test setup and helper functions for testing the calculator's behavior. This conrtract is meant +/// to be inherited in a concrete implementation with the necessary setUp and virtual methods +/// implemented. +abstract contract BinaryEligibilityOracleEarningPowerCalculatorTestBase is StakerTestBase { + /// @notice The earning power calculator to be tested. + BinaryEligibilityOracleEarningPowerCalculator calculator; + + /// @notice A helper function that updates the delegatee score for a given deposit to a random + /// value between 0 and twice the eligibility threshold, facilitating tests for both eligible and + /// ineligible delegatee scenarios. + /// @param _depositId The identifier of the deposit whose delegatee's score is to be updated. + function _updateEarningPower(Staker.DepositIdentifier _depositId) internal virtual override { + uint256 _delegateeeScore = vm.randomUint(0, calculator.delegateeEligibilityThresholdScore() * 2); + Staker.Deposit memory _deposit = _fetchDeposit(_depositId); + vm.startPrank(calculator.scoreOracle()); + calculator.updateDelegateeScore(_deposit.delegatee, _delegateeeScore); + vm.stopPrank(); + } + + /// @notice Bound the mint amount to a realistic value. + /// @dev Override of the base contract's function to set appropriate bounds for this calculator. + /// @param _amount The unbounded mint amount. + /// @return The bounded mint amount. + function _boundMintAmount(uint256 _amount) internal pure virtual override returns (uint256) { + return bound(_amount, 1, 100_000_000e18); + } +} diff --git a/src/test/MintRewardNotifierTestBase.sol b/src/test/MintRewardNotifierTestBase.sol new file mode 100644 index 00000000..594dd088 --- /dev/null +++ b/src/test/MintRewardNotifierTestBase.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.23; + +import {MintRewardNotifier} from "../notifiers/MintRewardNotifier.sol"; +import {StakerTestBase} from "./StakerTestBase.sol"; + +/// @title MintRewardNotifierTestBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice Base contract for testing a staker contract with a single `MintRewardNotifier`. Extends +/// `StakerTestBase` and implements the reward notification logic. +/// @dev This contract requires an initialized instance of `MintRewardNotifier`. Initialization is +/// typically handled by a deployment script such as +/// `src/script/notifiers/DeployMintRewardNotifier.sol` +abstract contract MintRewardNotifierTestBase is StakerTestBase { + /// @notice The mint reward notifier to test. + MintRewardNotifier mintRewardNotifier; + + /// @notice Sets the reward amount, then calls the `notify` function that triggers token minting + /// and reward distribution. + /// @param _amount The amount of reward to notify the staker contract. + function _notifyRewardAmount(uint256 _amount) public override { + address _owner = mintRewardNotifier.owner(); + + vm.prank(_owner); + mintRewardNotifier.setRewardAmount(_amount); + + mintRewardNotifier.notify(); + } +} diff --git a/src/test/StakerForkTestSuite.sol b/src/test/StakerForkTestSuite.sol new file mode 100644 index 00000000..ce6fb1a1 --- /dev/null +++ b/src/test/StakerForkTestSuite.sol @@ -0,0 +1,747 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// slither-disable-start reentrancy-benign + +pragma solidity ^0.8.23; + +import {Staker} from "../Staker.sol"; +import {StakerTestBase} from "./StakerTestBase.sol"; + +/// @title StakeBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice Provides a standard suite of tests for core staking functionality. +/// This includes tests for single and multiple depositors staking and earning rewards +/// over various timeframes and reward periods. +/// @dev Inherit this contract to test the fundamental staking and reward accrual logic of a Staker +/// implementation, covering scenarios where there are single and multiple depositors, as well as +/// single and multiple reward periods. +abstract contract StakeBase is StakerTestBase { + /// @notice Tests that a single depositor correctly stakes and earns rewards after a specified + /// duration within a single reward period. Asserts that the unclaimed rewards are consistent with + /// calculated earned rewards. + /// @param _depositor The address of the staker. + /// @param _amount The amount of stake tokens to deposit. + /// @param _delegatee The address with the stake's voting power. + /// @param _rewardAmount The total reward amount for the period. + /// @param _percentDuration The percentage of the reward duration to advance time by (1-100). + function testForkFuzz_CorrectlyStakeAndEarnRewardsAfterDuration( + address _depositor, + uint96 _amount, + address _delegatee, + uint256 _rewardAmount, + uint256 _percentDuration + ) public virtual { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume(_delegatee != address(0) && _amount != 0); + + _mintStakeToken(_depositor, _amount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _percentDuration = bound(_percentDuration, 1, 100); + + Staker.DepositIdentifier _depositId = _stake(_depositor, _amount, _delegatee); + _updateEarningPower(_depositId); + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(bound(_percentDuration, 0, 100)); + + uint256 unclaimedRewards = staker.unclaimedReward(_depositId); + Staker.Deposit memory _deposit = _fetchDeposit(_depositId); + + uint256 _earnedRewards = + _calculateEarnedRewards(_deposit.earningPower, _rewardAmount, _percentDuration); + + assertLteWithinOneUnit(unclaimedRewards, _earnedRewards); + } + + /// @notice Tests that two distinct depositors correctly earn rewards over a single reward period, + /// with stakes made at the same time. Asserts that unclaimed rewards for both depositors are + /// consistent with calculated earned rewards. + /// @param _depositor1 The address of the first staker. + /// @param _depositor2 The address of the second staker. + /// @param _amount The amount of stake tokens each depositor deposits. + /// @param _delegatee The address to delegate voting power to for both deposits. + /// @param _rewardAmount The total reward amount for the period. + /// @param _percentDuration1 The first percentage of the reward duration to advance time by. + /// @param _percentDuration2 The second percentage of the reward duration to advance time by, + /// after the first. + function testForkFuzz_TwoDepositorsEarnRewardsOverSinglePeriod( + address _depositor1, + address _depositor2, + uint96 _amount, + address _delegatee, + uint256 _rewardAmount, + uint256 _percentDuration1, + uint256 _percentDuration2 + ) public { + _assumeNotZeroAddressOrStaker(_depositor1); + _assumeNotZeroAddressOrStaker(_depositor2); + vm.assume(_depositor1 != _depositor2 && _delegatee != address(0) && _amount != 0); + + _mintStakeToken(_depositor1, _amount); + _mintStakeToken(_depositor2, _amount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _percentDuration1 = bound(_percentDuration1, 1, 100); + _percentDuration2 = bound(_percentDuration2, 0, 100 - _percentDuration1); + + Staker.DepositIdentifier _depositId1 = _stake(_depositor1, _amount, _delegatee); + _updateEarningPower(_depositId1); + Staker.DepositIdentifier _depositId2 = _stake(_depositor2, _amount, _delegatee); + _updateEarningPower(_depositId2); + _notifyRewardAmount(_rewardAmount); + + _jumpAheadByPercentOfRewardDuration(_percentDuration1); + uint256 unclaimedRewards1 = staker.unclaimedReward(_depositId1); + Staker.Deposit memory _deposit1 = _fetchDeposit(_depositId1); + uint256 _earnedRewards1 = + _calculateEarnedRewards(_deposit1.earningPower, _rewardAmount, _percentDuration1); + + _jumpAheadByPercentOfRewardDuration(_percentDuration2); + uint256 unclaimedRewards2 = staker.unclaimedReward(_depositId2); + Staker.Deposit memory _deposit2 = _fetchDeposit(_depositId2); + uint256 _earnedRewards2 = _calculateEarnedRewards( + _deposit2.earningPower, _rewardAmount, _percentDuration1 + _percentDuration2 + ); + + assertLteWithinOneUnit(unclaimedRewards1, _earnedRewards1); + assertLteWithinOneUnit(unclaimedRewards2, _earnedRewards2); + } + + /// @notice Tests that two distinct depositors correctly earn rewards across multiple reward + /// periods. Asserts that unclaimed rewards for both depositors are consistent with calculated + /// earned rewards accumulated over both periods. + /// @param _depositor1 The address of the first staker. + /// @param _depositor2 The address of the second staker. + /// @param _amount The amount of stake tokens each depositor deposits. + /// @param _delegatee The address to delegate voting power to for both deposits. + /// @param _rewardAmount The total reward amount for each period. + /// @param _percentDuration1 The percentage of the first reward duration to advance time by. + /// @param _percentDuration2 The percentage of the second reward duration to advance time by. + function testForkFuzz_TwoDepositorsEarnRewardsOverMultiplePeriods( + address _depositor1, + address _depositor2, + uint96 _amount, + address _delegatee, + uint256 _rewardAmount, + uint256 _percentDuration1, + uint256 _percentDuration2 + ) public { + _assumeNotZeroAddressOrStaker(_depositor1); + _assumeNotZeroAddressOrStaker(_depositor2); + vm.assume(_depositor1 != _depositor2 && _delegatee != address(0) && _amount != 0); + + _mintStakeToken(_depositor1, _amount); + _mintStakeToken(_depositor2, _amount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _percentDuration1 = bound(_percentDuration1, 1, 100); + _percentDuration2 = bound(_percentDuration2, 1, 100); + + Staker.DepositIdentifier _depositId1 = _stake(_depositor1, _amount, _delegatee); + _updateEarningPower(_depositId1); + Staker.DepositIdentifier _depositId2 = _stake(_depositor2, _amount, _delegatee); + _updateEarningPower(_depositId2); + + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration1); + + // intentionally warp till the end of current reward period + _jumpAheadByPercentOfRewardDuration(100 - _percentDuration1); + + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration2); + + uint256 unclaimedRewards1 = staker.unclaimedReward(_depositId1); + uint256 unclaimedRewards2 = staker.unclaimedReward(_depositId2); + Staker.Deposit memory _deposit1 = _fetchDeposit(_depositId1); + Staker.Deposit memory _deposit2 = _fetchDeposit(_depositId2); + uint256 _earnedRewards1 = _calculateEarnedRewards(_deposit1.earningPower, _rewardAmount, 100) + + _calculateEarnedRewards(_deposit1.earningPower, _rewardAmount, _percentDuration2); + uint256 _earnedRewards2 = _calculateEarnedRewards(_deposit2.earningPower, _rewardAmount, 100) + + _calculateEarnedRewards(_deposit2.earningPower, _rewardAmount, _percentDuration2); + + assertLteWithinOneUnit(unclaimedRewards1, _earnedRewards1); + assertLteWithinOneUnit(unclaimedRewards2, _earnedRewards2); + } +} + +/// @title WithdrawBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice Provides a standard suite of tests for withdrawal functionality. +/// This includes tests for unstaking after reward accrual, withdrawals by multiple users, +/// and interactions between claiming rewards and withdrawing stake. +/// @dev Inherit this contract to test the withdrawal mechanisms of a Staker implementation, +/// including scenarios with single and multiple users unstaking, and interactions +/// between reward claims and withdrawals. +abstract contract WithdrawBase is StakerTestBase { + /// @notice Tests that a depositor can correctly unstake a specified amount after rewards have + /// accrued over a duration. Asserts that the staker's token balance reflects the withdrawal and + /// unclaimed rewards are handled consistently. + /// @param _depositor The address of the staker. + /// @param _amount The initial amount of stake tokens deposited. + /// @param _delegatee The address with the stake's voting power. + /// @param _rewardAmount The total reward amount for the period. + /// @param _withdrawAmount The amount of stake tokens to withdraw. + /// @param _percentDuration The percentage of the reward duration to advance time by before + /// withdrawal. + function testForkFuzz_CorrectlyUnstakeAfterDuration( + address _depositor, + uint96 _amount, + address _delegatee, + uint256 _rewardAmount, + uint256 _withdrawAmount, + uint256 _percentDuration + ) public { + vm.assume(_depositor != address(0) && _delegatee != address(0)); + vm.assume(_depositor != address(staker)); + + _amount = uint96(_boundMintAmount(_amount)); + vm.assume(_amount != 0); + _mintStakeToken(_depositor, _amount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _percentDuration = bound(_percentDuration, 1, 100); + + Staker.DepositIdentifier _depositId = _stake(_depositor, _amount, _delegatee); + _updateEarningPower(_depositId); + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + uint256 initialRewards = staker.unclaimedReward(_depositId); + + _withdrawAmount = bound(_withdrawAmount, 0, _amount); + _withdraw(_depositor, _depositId, _withdrawAmount); + + uint256 _balance = STAKE_TOKEN.balanceOf(_depositor); + + // If we have rewards accrued, check that they're consistent after withdrawal + uint256 currentRewards = staker.unclaimedReward(_depositId); + assertLteWithinOneUnit(currentRewards, initialRewards); + assertEq(_balance, _withdrawAmount); + } + + /// @notice Tests withdrawal functionality for two distinct users after rewards have accrued over + /// different partial durations. Asserts that each user's token balance reflects their withdrawal + /// and unclaimed rewards are consistent. + /// @param _depositor1 The address of the first staker. + /// @param _depositor2 The address of the second staker. + /// @param _amount The initial amount of stake tokens deposited by each user. + /// @param _delegatee The address to delegate voting power to for both deposits. + /// @param _rewardAmount The total reward amount for the period. + /// @param _withdrawAmount1 The amount for the first depositor to withdraw. + /// @param _withdrawAmount2 The amount for the second depositor to withdraw. + /// @param _percentDuration1 The first percentage of the reward duration to advance time by before + /// the first withdrawal. + /// @param _percentDuration2 The second percentage of the reward duration to advance time by + /// before the second withdrawal. + function testFuzz_WithdrawTwoUsersAfterDuration( + address _depositor1, + address _depositor2, + uint96 _amount, + address _delegatee, + uint256 _rewardAmount, + uint256 _withdrawAmount1, + uint256 _withdrawAmount2, + uint256 _percentDuration1, + uint256 _percentDuration2 + ) public { + _assumeNotZeroAddressOrStaker(_depositor1); + _assumeNotZeroAddressOrStaker(_depositor2); + vm.assume(_depositor1 != _depositor2 && _delegatee != address(0)); + + _amount = uint96(_boundMintAmount(_amount)); + vm.assume(_amount != 0); + + _mintStakeToken(_depositor1, _amount); + _mintStakeToken(_depositor2, _amount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _percentDuration1 = bound(_percentDuration1, 1, 100); + _percentDuration2 = bound(_percentDuration2, 0, 100 - _percentDuration1); + + Staker.DepositIdentifier _depositId1 = _stake(_depositor1, _amount, _delegatee); + _updateEarningPower(_depositId1); + Staker.DepositIdentifier _depositId2 = _stake(_depositor2, _amount, _delegatee); + _updateEarningPower(_depositId2); + + _notifyRewardAmount(_rewardAmount); + + _jumpAheadByPercentOfRewardDuration(_percentDuration1); + uint256 initialRewards1 = staker.unclaimedReward(_depositId1); + _withdrawAmount1 = bound(_withdrawAmount1, 0, _amount); + _withdraw(_depositor1, _depositId1, _withdrawAmount1); + assertLteWithinOneUnit(staker.unclaimedReward(_depositId1), initialRewards1); + + _jumpAheadByPercentOfRewardDuration(_percentDuration2); + uint256 initialRewards2 = staker.unclaimedReward(_depositId2); + _withdrawAmount2 = bound(_withdrawAmount2, 0, _amount); + _withdraw(_depositor2, _depositId2, _withdrawAmount2); + assertLteWithinOneUnit(staker.unclaimedReward(_depositId2), initialRewards2); + + assertEq(STAKE_TOKEN.balanceOf(_depositor1), _withdrawAmount1); + assertEq(STAKE_TOKEN.balanceOf(_depositor2), _withdrawAmount2); + } + + /// @notice Tests that a depositor can claim their accrued rewards and then withdraw parts or all + /// of their stake. Asserts that rewards are correctly transferred upon claim, unclaimed rewards + /// become zero, and stake is correctly withdrawn. + /// @param _depositor The address of the staker. + /// @param _amount The initial amount of stake tokens deposited. + /// @param _delegatee The address with the stake's voting power. + /// @param _rewardAmount The total reward amount for the period. + /// @param _withdrawAmount The amount of stake tokens to withdraw. + /// @param _percentDuration The percentage of the reward duration to advance time by before + /// claiming and withdrawing. + function testForkFuzz_ClaimRewardAndWithdrawAfterDuration( + address _depositor, + uint96 _amount, + address _delegatee, + uint256 _rewardAmount, + uint256 _withdrawAmount, + uint256 _percentDuration + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume(_delegatee != address(0)); + + _amount = uint96(_boundMintAmount(_amount)); + _mintStakeToken(_depositor, _amount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _percentDuration = bound(_percentDuration, 1, 100); + + Staker.DepositIdentifier _depositId = _stake(_depositor, _amount, _delegatee); + _updateEarningPower(_depositId); + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + uint256 initialRewards = staker.unclaimedReward(_depositId); + uint256 initialRewardBalance = REWARD_TOKEN.balanceOf(_depositor); + + vm.prank(_depositor); + staker.claimReward(_depositId); + + uint256 rewardsReceived = REWARD_TOKEN.balanceOf(_depositor) - initialRewardBalance; + assertEq(staker.unclaimedReward(_depositId), 0); + assertEq(rewardsReceived, initialRewards); + + _withdrawAmount = bound(_withdrawAmount, 0, _amount); + _withdraw(_depositor, _depositId, _withdrawAmount); + + uint256 _balance = STAKE_TOKEN.balanceOf(_depositor); + assertEq(_balance, _withdrawAmount); + } +} + +/// @title ClaimRewardBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice Provides a standard suite of tests for reward claiming functionality. +/// This includes tests for claiming rewards over single and multiple periods, handling of +/// zero-deposits, and scenarios involving staking, claiming, and re-staking. +/// @dev Inherit this contract to test the reward claiming mechanisms of a Staker implementation, +/// covering scenarios such as claims within single and across multiple reward periods, zero-deposit +/// handling, and sequences of staking, claiming, and re-staking. +abstract contract ClaimRewardBase is StakerTestBase { + /// @notice Tests that a depositor can correctly claim all earned rewards within a single reward + /// period. Asserts that the claimed reward amount matches the unclaimed rewards and that + /// unclaimed rewards become zero post-claim. + /// @param _depositor The address of the staker. + /// @param _delegatee The address with the stake's voting power. + /// @param _depositAmount The amount of stake tokens deposited. + /// @param _rewardAmount The total reward amount for the period. + /// @param _percentDuration The percentage of the reward duration to advance time by before + /// claiming. + function testFuzz_DepositorClaimsEarnedRewardsWithinASinglePeriod( + address _depositor, + address _delegatee, + uint96 _depositAmount, + uint256 _rewardAmount, + uint256 _percentDuration + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume(_delegatee != address(0)); + + _mintStakeToken(_depositor, _depositAmount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _percentDuration = bound(_percentDuration, 1, 100); + + Staker.DepositIdentifier _depositId = _stake(_depositor, _depositAmount, _delegatee); + _updateEarningPower(_depositId); + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + uint256 _unclaimedReward = staker.unclaimedReward(_depositId); + + vm.prank(_depositor); + staker.claimReward(_depositId); + + uint256 _claimedReward = REWARD_TOKEN.balanceOf(_depositor); + + assertEq(_claimedReward, _unclaimedReward); + assertEq(staker.unclaimedReward(_depositId), 0); + } + + /// @notice Tests that a depositor can correctly claim rewards accumulated over multiple reward + /// periods. Asserts that the total claimed reward matches the total unclaimed rewards from all + /// periods. + /// @param _depositor The address of the staker. + /// @param _delegatee The address with the stake's voting power. + /// @param _depositAmount The amount of stake tokens deposited. + /// @param _rewardAmount1 The reward amount for the first period. + /// @param _rewardAmount2 The reward amount for the second period. + /// @param _percentDuration The percentage of the second reward duration to advance time by before + /// claiming. + function testFuzz_DepositorClaimsEarnedRewardsAfterMultiplePeriods( + address _depositor, + address _delegatee, + uint96 _depositAmount, + uint256 _rewardAmount1, + uint256 _rewardAmount2, + uint256 _percentDuration + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume(_delegatee != address(0) && _depositAmount != 0); + + _percentDuration = bound(_percentDuration, 1, 100); + _rewardAmount1 = _boundToRealisticReward(_rewardAmount1); + _rewardAmount2 = _boundToRealisticReward(_rewardAmount2); + + _mintStakeToken(_depositor, _depositAmount); + + Staker.DepositIdentifier _depositId = _stake(_depositor, _depositAmount, _delegatee); + _updateEarningPower(_depositId); + + _notifyRewardAmount(_rewardAmount1); + _jumpAheadByPercentOfRewardDuration(100); + + _rewardAmount2 = _boundToRealisticReward(_rewardAmount2); + _notifyRewardAmount(_rewardAmount2); + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + uint256 _unclaimedReward = staker.unclaimedReward(_depositId); + + vm.prank(_depositor); + staker.claimReward(_depositId); + + uint256 _claimedReward = REWARD_TOKEN.balanceOf(_depositor); + + assertEq(_claimedReward, _unclaimedReward); + assertLteWithinOneUnit(_claimedReward, _rewardAmount1 + _rewardAmount2 * _percentDuration / 100); + assertEq(staker.unclaimedReward(_depositId), 0); + } + + /// @notice Tests that a deposit of zero tokens correctly yields zero rewards. Asserts that both + /// unclaimed and claimed rewards are zero when the deposit amount is zero. + /// @param _depositor The address of the staker. + /// @param _delegatee The address with the stake's voting power. + /// @param _rewardAmount The total reward amount for the period. + /// @param _percentDuration The percentage of the reward duration to advance time by. + function testFuzz_ZeroDepositYieldsZeroReward( + address _depositor, + address _delegatee, + uint256 _rewardAmount, + uint256 _percentDuration + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume(_delegatee != address(0)); + + _percentDuration = bound(_percentDuration, 0, 100); + + _rewardAmount = _boundToRealisticReward(_rewardAmount); + + _mintStakeToken(_depositor, 0); + Staker.DepositIdentifier _depositId = _stake(_depositor, 0, _delegatee); + _updateEarningPower(_depositId); + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + uint256 _unclaimedReward = staker.unclaimedReward(_depositId); + + vm.prank(_depositor); + staker.claimReward(_depositId); + + uint256 _claimedReward = REWARD_TOKEN.balanceOf(_depositor); + + assertEq(_claimedReward, _unclaimedReward); + assertEq(_claimedReward, 0); + assertEq(staker.unclaimedReward(_depositId), 0); + } + + /// @notice Tests a scenario where a depositor stakes, claims rewards, waits, stakes again (new + /// deposit), and then claims all rewards within a single reward period. Asserts that the total + /// claimed rewards correctly reflect earnings from both deposits across the various time + /// segments. + /// @param _depositor The address of the staker. + /// @param _delegatee The address with the stake's voting power. + /// @param _depositAmount The amount for each of the two stake operations. + /// @param _rewardAmount The total reward amount for the period. + /// @param _percentDuration1 Percentage of duration before first claim. + /// @param _percentDuration2 Percentage of duration to wait after first claim before second stake. + /// @param _percentDuration3 Percentage of duration after second stake before final claims. + function testFuzz_DepositorClaimsRewardsWaitsAndStakesAgainWithinASinglePeriod( + address _depositor, + address _delegatee, + uint96 _depositAmount, + uint256 _rewardAmount, + uint256 _percentDuration1, + uint256 _percentDuration2, + uint256 _percentDuration3 + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume(_delegatee != address(0)); + + _depositAmount = uint96(_boundToRealisticStake(_depositAmount)); + + _percentDuration1 = bound(_percentDuration1, 0, 100); + _percentDuration2 = bound(_percentDuration2, 0, 100 - _percentDuration1); + _percentDuration3 = bound(_percentDuration3, 0, 100 - _percentDuration1 - _percentDuration2); + + _rewardAmount = _boundToRealisticReward(_rewardAmount); + + _mintStakeToken(_depositor, _depositAmount * 2); + Staker.DepositIdentifier _depositId1 = _stake(_depositor, _depositAmount, _delegatee); + _updateEarningPower(_depositId1); + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration1); + + uint256 _unclaimedReward1 = staker.unclaimedReward(_depositId1); + + vm.prank(_depositor); + staker.claimReward(_depositId1); + + _jumpAheadByPercentOfRewardDuration(_percentDuration2); + + Staker.DepositIdentifier _depositId2 = _stake(_depositor, _depositAmount, _delegatee); + _updateEarningPower(_depositId2); + _jumpAheadByPercentOfRewardDuration(_percentDuration3); + + _unclaimedReward1 += staker.unclaimedReward(_depositId1); + uint256 _unclaimedReward2 = staker.unclaimedReward(_depositId2); + + vm.startPrank(_depositor); + staker.claimReward(_depositId1); + staker.claimReward(_depositId2); + vm.stopPrank(); + + uint256 _claimedReward = REWARD_TOKEN.balanceOf(_depositor); + + assertEq(_claimedReward, _unclaimedReward1 + _unclaimedReward2); + // because we summed 3 time periods, the rounding error can be as much as 2 units + assertApproxEqAbs( + _claimedReward, + _rewardAmount * (_percentDuration1 + _percentDuration2 + _percentDuration3) / 100, + 2 + ); + assertEq(staker.unclaimedReward(_depositId1), 0); + assertEq(staker.unclaimedReward(_depositId2), 0); + } + + /// @notice Tests a scenario where a depositor stakes, claims rewards, waits until a new reward + /// period, stakes again (new deposit), and then claims all rewards. Asserts that total claimed + /// rewards correctly reflect earnings from both deposits across both reward periods. + /// @param _depositor The address of the staker. + /// @param _delegatee The address with the stake's voting power. + /// @param _depositAmount1 The amount for the first stake. + /// @param _depositAmount2 The amount for the second stake. + /// @param _rewardAmount1 The reward amount for the first period. + /// @param _rewardAmount2 The reward amount for the second period. + /// @param _percentDuration1 Percentage of the first reward duration before the first claim. + /// @param _percentDuration2 Percentage of the second reward duration after the second stake + /// before final claims. + function testFuzz_DepositorClaimsRewardsWaitsAndStakesAgainAfterMultiplePeriods( + address _depositor, + address _delegatee, + uint96 _depositAmount1, + uint96 _depositAmount2, + uint256 _rewardAmount1, + uint256 _rewardAmount2, + uint256 _percentDuration1, + uint256 _percentDuration2 + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume(_delegatee != address(0)); + _depositAmount1 = uint96(_boundToRealisticStake(_depositAmount1)); + _depositAmount2 = uint96(_boundToRealisticStake(_depositAmount2)); + + _percentDuration1 = bound(_percentDuration1, 0, 100); + _percentDuration2 = bound(_percentDuration2, 0, 100); + + _rewardAmount1 = _boundToRealisticReward(_rewardAmount1); + _rewardAmount2 = _boundToRealisticReward(_rewardAmount2); + + _mintStakeToken(_depositor, _depositAmount1 + _depositAmount2); + Staker.DepositIdentifier _depositId1 = _stake(_depositor, _depositAmount1, _delegatee); + _updateEarningPower(_depositId1); + _notifyRewardAmount(_rewardAmount1); + _jumpAheadByPercentOfRewardDuration(_percentDuration1); + + uint256 _unclaimedReward1 = staker.unclaimedReward(_depositId1); + + vm.prank(_depositor); + staker.claimReward(_depositId1); + // intentionally warp till the end of current reward period + _jumpAheadByPercentOfRewardDuration(100 - _percentDuration1); + + Staker.DepositIdentifier _depositId2 = _stake(_depositor, _depositAmount2, _delegatee); + _updateEarningPower(_depositId2); + _notifyRewardAmount(_rewardAmount2); + _jumpAheadByPercentOfRewardDuration(_percentDuration2); + + _unclaimedReward1 += staker.unclaimedReward(_depositId1); + uint256 _unclaimedReward2 = staker.unclaimedReward(_depositId2); + + vm.startPrank(_depositor); + staker.claimReward(_depositId1); + staker.claimReward(_depositId2); + vm.stopPrank(); + + uint256 _claimedReward = REWARD_TOKEN.balanceOf(_depositor); + + assertEq(_claimedReward, _unclaimedReward1 + _unclaimedReward2); + // because we summed 2 amounts, the rounding error can be as much as 2 units + assertApproxEqAbs(_claimedReward, _rewardAmount1 + _percentDuration2 * _rewardAmount2 / 100, 2); + assertEq(staker.unclaimedReward(_depositId1), 0); + assertEq(staker.unclaimedReward(_depositId2), 0); + } +} + +/// @title AlterClaimerBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice Provides a standard suite of tests for the `alterClaimer` functionality. +/// This includes tests for updating the claimer both before and after rewards have +/// accrued. +/// @dev Inherit this contract to test the claimer alteration mechanism of a Staker implementation, +/// including updates to the claimer both before and after rewards have accrued. +abstract contract AlterClaimerBase is StakerTestBase { + /// @notice Tests that a depositor can successfully update the claimer for their deposit before + /// any rewards have accrued. Verifies that the `ClaimerAltered` event is emitted with correct + /// parameters and the deposit's claimer is updated. No rewards should be present. + /// @param _depositor The address of the staker. + /// @param _depositAmount The amount of stake tokens deposited. + /// @param _delegatee The initial delegatee address. + /// @param _firstClaimer The initial claimer address. + /// @param _newClaimer The new claimer address to be set. + function testFuzz_DepositorCanUpdateClaimerBeforeAccruingRewards( + address _depositor, + uint96 _depositAmount, + address _delegatee, + address _firstClaimer, + address _newClaimer + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume( + _firstClaimer != address(0) && _newClaimer != address(0) && _newClaimer != _firstClaimer + ); + + _depositAmount = uint96(_boundMintAmount(_depositAmount)); + _mintStakeToken(_depositor, _depositAmount); + Staker.DepositIdentifier _depositId = + _stake(_depositor, _depositAmount, _delegatee, _firstClaimer); + _updateEarningPower(_depositId); + + Staker.Deposit memory _deposit = _fetchDeposit(_depositId); + + vm.expectEmit(); + emit Staker.ClaimerAltered(_depositId, _firstClaimer, _newClaimer, _deposit.earningPower); + + vm.prank(_depositor); + staker.alterClaimer(_depositId, _newClaimer); + + _deposit = _fetchDeposit(_depositId); + + assertEq(staker.unclaimedReward(_depositId), 0); + assertEq(_deposit.claimer, _newClaimer); + } + + /// @notice Tests that a depositor can successfully update the claimer for their deposit after + /// rewards have accrued. Verifies that `ClaimerAltered` event is emitted, the deposit's claimer + /// is updated, and existing unclaimed rewards are maintained for the new claimer. + /// @param _depositor The address of the staker. + /// @param _depositAmount The amount of stake tokens deposited. + /// @param _delegatee The initial delegatee address. + /// @param _rewardAmount The total reward amount for the period. + /// @param _percentDuration The percentage of the reward duration to advance time by before + /// altering the claimer. + /// @param _firstClaimer The initial claimer address. + /// @param _newClaimer The new claimer address to be set. + function testFuzz_DepositorCanUpdateClaimerAfterAccruingRewards( + address _depositor, + uint96 _depositAmount, + address _delegatee, + uint256 _rewardAmount, + uint256 _percentDuration, + address _firstClaimer, + address _newClaimer + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume( + _firstClaimer != address(0) && _newClaimer != address(0) && _newClaimer != _firstClaimer + ); + _percentDuration = bound(_percentDuration, 1, 100); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _depositAmount = uint96(_boundMintAmount(_depositAmount)); + // We assume the following to guarantee positive unclaimed reward. + vm.assume(_depositAmount != 0 && _rewardAmount != 0); + + _mintStakeToken(_depositor, _depositAmount); + Staker.DepositIdentifier _depositId = + _stake(_depositor, _depositAmount, _delegatee, _firstClaimer); + _updateEarningPower(_depositId); + + Staker.Deposit memory _deposit = _fetchDeposit(_depositId); + + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + vm.expectEmit(); + emit Staker.ClaimerAltered(_depositId, _firstClaimer, _newClaimer, _deposit.earningPower); + + vm.prank(_depositor); + staker.alterClaimer(_depositId, _newClaimer); + + _deposit = _fetchDeposit(_depositId); + + assertGt(staker.unclaimedReward(_depositId), 0); + assertEq(_deposit.claimer, _newClaimer); + } +} + +/// @title AlterDelegateeBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice Provides a standard suite of tests for the `alterDelegatee` functionality. +/// This includes tests for updating the delegatee and verifying the correct transfer +/// of delegated stake/voting power. +/// @dev Inherit this contract to test the delegatee alteration mechanism of a Staker +/// implementation. +abstract contract AlterDelegateeBase is StakerTestBase { + /// @notice Tests that a depositor can successfully update the delegatee for their deposit. + /// Verifies that `DelegateeAltered` event is emitted, the deposit's delegatee is updated, and + /// stake token balances of corresponding surrogates are correctly adjusted. + /// @param _depositor The address of the staker. + /// @param _depositAmount The amount of stake tokens deposited. + /// @param _firstDelegatee The initial delegatee address. + /// @param _newDelegatee The new delegatee address to be set. + function testFuzz_DepositorCanUpdateDelegatee( + address _depositor, + uint96 _depositAmount, + address _firstDelegatee, + address _newDelegatee + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume( + _firstDelegatee != address(0) && _newDelegatee != address(0) + && _newDelegatee != _firstDelegatee + ); + + _depositAmount = uint96(_boundMintAmount(_depositAmount)); + _mintStakeToken(_depositor, _depositAmount); + Staker.DepositIdentifier _depositId = _stake(_depositor, _depositAmount, _firstDelegatee); + _updateEarningPower(_depositId); + address _firstSurrogate = address(staker.surrogates(_firstDelegatee)); + + vm.expectEmit(); + emit Staker.DelegateeAltered(_depositId, _firstDelegatee, _newDelegatee, _depositAmount); + + vm.prank(_depositor); + staker.alterDelegatee(_depositId, _newDelegatee); + + Staker.Deposit memory _deposit = _fetchDeposit(_depositId); + address _newSurrogate = address(staker.surrogates(_deposit.delegatee)); + + assertEq(_deposit.delegatee, _newDelegatee); + assertEq(STAKE_TOKEN.balanceOf(_newSurrogate), _depositAmount); + assertEq(STAKE_TOKEN.balanceOf(_firstSurrogate), 0); + } +} diff --git a/src/test/StakerTestBase.sol b/src/test/StakerTestBase.sol new file mode 100644 index 00000000..2b9c0b2f --- /dev/null +++ b/src/test/StakerTestBase.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {Staker} from "../Staker.sol"; +import {DelegationSurrogate} from "../DelegationSurrogate.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Mintable} from "./interfaces/IERC20Mintable.sol"; +import {PercentAssertions} from "./helpers/PercentAssertions.sol"; + +/// @title StakerTestBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice This abstract contract provides a common foundation and essential helper functions for +/// constructing modular integration tests. It is designed to be inherited by test suites that +/// verify the functionality of Staker contracts, various reward notifiers and earning power +/// calculators. For example, a test base for `MintRewardNotifier` (like +/// `MintRewardNotifierTestBase`) will inherit `StakerTestBase` to implement `_notifyRewardAmount`, +/// by calling `setAmount` and `notify` on the `MintRewardNotifier`. These specific notifier +/// behaviors can then be tested through a common suite (e.g., `StakerForkTestSuite.sol`). +/// @dev Integrators looking to develop a bespoke reward notifier or earning power calculator should +/// consider extending this contract. +abstract contract StakerTestBase is Test, PercentAssertions { + Staker staker; + IERC20 STAKE_TOKEN; + IERC20 REWARD_TOKEN; + + mapping(DelegationSurrogate surrogate => bool isKnown) isKnownSurrogate; + mapping(address depositor => bool isKnown) isKnownDepositor; + + /// @notice A function to move time forward. + /// @param _seconds The time to jump forward in seconds. + function _jumpAhead(uint256 _seconds) public virtual { + vm.warp(block.timestamp + _seconds); + } + + /// @notice A function that will move time forward by a percentage of the underlying Stake's + /// reward duration. + /// @param _percent The percent of the Staker's reward duration to move forward. + function _jumpAheadByPercentOfRewardDuration(uint256 _percent) public virtual { + uint256 _seconds = (_percent * staker.REWARD_DURATION()) / 100; + _jumpAhead(_seconds); + } + + /// @notice Bound the reward amount to a realistic number. + /// @param _rewardAmount The unbounded reward amount. + /// @return _boundedRewardAmount The bounded reward amount. + function _boundToRealisticReward(uint256 _rewardAmount) + public + view + virtual + returns (uint256 _boundedRewardAmount) + { + _boundedRewardAmount = bound(_rewardAmount, 200e6, 10_000_000e18); + } + + /// @notice Bound the stake to a realistic amount. + /// @param _stakeAmount The stake amount to bound. + /// @return _boundedStakeAmount The bounded stake amount. + function _boundToRealisticStake(uint256 _stakeAmount) + public + view + virtual + returns (uint256 _boundedStakeAmount) + { + _boundedStakeAmount = bound(_stakeAmount, 0.1e18, 25_000_000e18); + } + + /// @notice Bound the mint amount to a realistic value. + /// @param _amount The unbounded mint amount. + /// @return The bounded mint amount. + function _boundMintAmount(uint256 _amount) internal pure virtual returns (uint256) { + return bound(_amount, 0, 100_000_000e18); + } + + /// @notice A function to mint a specified amount of stake token to an address. + /// @param _to The address for where to mint tokens. + /// @param _amount The amount of tokens to be minted. + function _mintStakeToken(address _to, uint96 _amount) internal virtual { + vm.assume(_to != address(0)); + IERC20Mintable(address(STAKE_TOKEN)).mint(_to, _amount); + } + + /// @notice A function to mint and stake tokens with a bounded amount. + /// @param _depositor The address staking the minted tokens. + /// @param _amount The amount of tokens to mint and stake. + /// @param _delegatee The address that receives the stake's voting power. + function _boundMintAndStake(address _depositor, uint256 _amount, address _delegatee) + internal + virtual + returns (uint256 _boundedAmount, Staker.DepositIdentifier _depositId) + { + _boundedAmount = _boundMintAmount(_amount); + _mintStakeToken(_depositor, uint96(_boundedAmount)); + _depositId = _stake(_depositor, _boundedAmount, _delegatee); + } + + /// @notice Test helper that handles token approvals, pranking, and safety checks for staking. + /// Ensures consistent setup across all staking tests to reduce code duplication. + /// @param _depositor The address of the depositor. + /// @param _amount The amount to stake. + /// @param _delegatee The address that will receive the voting power of the stake. + /// @return _depositId The id of the created deposit. + function _stake(address _depositor, uint256 _amount, address _delegatee) + internal + virtual + returns (Staker.DepositIdentifier _depositId) + { + vm.assume(_delegatee != address(0)); + + vm.startPrank(_depositor); + STAKE_TOKEN.approve(address(staker), _amount); + _depositId = staker.stake(_amount, _delegatee); + vm.stopPrank(); + _updateEarningPower(_depositId); + + // Called after the stake so the surrogate will exist + _assumeSafeDepositorAndSurrogate(_depositor, _delegatee); + } + + /// @notice Extension of the base stake helper that supports custom reward claimers + /// for `AlterClaimer` tests. Provides the same setup and safety checks. + /// @param _depositor The address of the depositor. + /// @param _amount The amount to stake. + /// @param _delegatee The address that will receive the voting power of the stake. + /// @param _claimer Address that will have the right to claim rewards for this stake. + /// @return _depositId The id of the created deposit. + function _stake(address _depositor, uint256 _amount, address _delegatee, address _claimer) + internal + virtual + returns (Staker.DepositIdentifier _depositId) + { + vm.assume(_delegatee != address(0)); + + vm.startPrank(_depositor); + STAKE_TOKEN.approve(address(staker), _amount); + _depositId = staker.stake(_amount, _delegatee, _claimer); + vm.stopPrank(); + + // Called after the stake so the surrogate will exist + _assumeSafeDepositorAndSurrogate(_depositor, _delegatee); + } + + /// @notice A test helper hook that updates a deposit's earning power. + function _updateEarningPower(Staker.DepositIdentifier) internal virtual {} + + /// @notice A test helper that wraps calling `withdraw` on the underlying Staker contract. + /// @param _depositor The depositor that is withdrawing their stake. + /// @param _depositId The deposit id to withdraw stake from. + /// @param _amount The amount of stake to withdraw. + function _withdraw(address _depositor, Staker.DepositIdentifier _depositId, uint256 _amount) + internal + virtual + { + vm.prank(_depositor); + staker.withdraw(_depositId, _amount); + } + + /// @notice A helper function to that returns a deposit struct given a deposit ID. + /// @param _depositId The id of the deposit to fetch. + /// @return A struct of deposit information. + function _fetchDeposit(Staker.DepositIdentifier _depositId) + internal + view + virtual + returns (Staker.Deposit memory) + { + ( + uint96 _balance, + address _owner, + uint96 _earningPower, + address _delegatee, + address _claimer, + uint256 _rewardPerTokenCheckpoint, + uint256 _scaledUnclaimedRewardCheckpoint + ) = staker.deposits(_depositId); + return Staker.Deposit({ + balance: _balance, + owner: _owner, + delegatee: _delegatee, + claimer: _claimer, + earningPower: _earningPower, + rewardPerTokenCheckpoint: _rewardPerTokenCheckpoint, + scaledUnclaimedRewardCheckpoint: _scaledUnclaimedRewardCheckpoint + }); + } + + /// @notice A test helper that calls `notifyRewardAmount`. + /// @param _amount The amount of tokens to send to the Staker contract. + function _notifyRewardAmount(uint256 _amount) public virtual; + + /// @notice A function to help calculate the earned rewards over a given period of the reward + /// duration. + /// @param _earningPower The earning power during the reward duration. + /// @param _rewardAmount The total amount of reward staker's split. + /// @param _percentDuration The total duration of the reward period that has passed. + /// @return The total rewards the given earning power has earned. + function _calculateEarnedRewards( + uint256 _earningPower, + uint256 _rewardAmount, + uint256 _percentDuration + ) internal virtual returns (uint256) { + uint256 _totalEarningPower = staker.totalEarningPower(); + return _totalEarningPower == 0 + ? 0 + : _percentOf((_earningPower * _rewardAmount) / _totalEarningPower, _percentDuration); + } + + /// @notice A test helper to prevent address collisions with depositors and delegate surrogate + /// contracts. + function _assumeSafeDepositorAndSurrogate(address _depositor, address _delegatee) internal { + DelegationSurrogate _surrogate = staker.surrogates(_delegatee); + isKnownDepositor[_depositor] = true; + isKnownSurrogate[_surrogate] = true; + + vm.assume( + (!isKnownSurrogate[DelegationSurrogate(_depositor)]) + && (!isKnownDepositor[address(_surrogate)]) + ); + } + + /// @notice A test helper that assumes an address is neither zero nor the staker contract + function _assumeNotZeroAddressOrStaker(address _addr) internal view { + assumeNotZeroAddress(_addr); + vm.assume(_addr != address(staker)); + } +} diff --git a/src/test/TransferFromRewardNotifierTestBase.sol b/src/test/TransferFromRewardNotifierTestBase.sol new file mode 100644 index 00000000..1e7ebdde --- /dev/null +++ b/src/test/TransferFromRewardNotifierTestBase.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.23; + +import {TransferFromRewardNotifier} from "../notifiers/TransferFromRewardNotifier.sol"; +import {IMintable} from "../interfaces/IMintable.sol"; +import {StakerTestBase} from "./StakerTestBase.sol"; + +/// @title TransferFromRewardNotifierTestBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice Base contract for testing a staker contract with a single `TransferFromRewardNotifier`. +/// Extends `StakerTestBase` and implements the reward notification logic. +/// @dev This contract requires an initialized instance of `TransferFromRewardNotifier`. +/// Initialization is typically handled by a deployment script such as +/// `src/script/notifiers/DeployTransferFromRewardNotifier.sol` +abstract contract TransferFromRewardNotifierTestBase is StakerTestBase { + TransferFromRewardNotifier transferFromRewardNotifier; + + /// @notice Sets the reward amount, then prepares for and executes `notify` on + /// `TransferFromRewardNotifier`. Preparation involves minting tokens to `rewardSource` and + /// `rewardSource` approving them for `transferFromRewardNotifier`. The `notify` execution then + /// triggers the token transfer and reward distribution. + function _notifyRewardAmount(uint256 _amount) public override { + address _owner = transferFromRewardNotifier.owner(); + address _source = transferFromRewardNotifier.rewardSource(); + + vm.prank(_owner); + transferFromRewardNotifier.setRewardAmount(_amount); + + vm.startPrank(_source); + IMintable(address(REWARD_TOKEN)).mint(_source, _amount); + REWARD_TOKEN.approve(address(transferFromRewardNotifier), _amount); + vm.stopPrank(); + + transferFromRewardNotifier.notify(); + } +} diff --git a/src/test/TransferRewardNotifierTestBase.sol b/src/test/TransferRewardNotifierTestBase.sol new file mode 100644 index 00000000..1042928c --- /dev/null +++ b/src/test/TransferRewardNotifierTestBase.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.23; + +import {TransferRewardNotifier} from "../notifiers/TransferRewardNotifier.sol"; +import {IMintable} from "src/interfaces/IMintable.sol"; +import {StakerTestBase} from "./StakerTestBase.sol"; + +/// @title TransferRewardNotifierTestBase +/// @author [ScopeLift](https://scopelift.co) +/// @notice Base contract for testing a staker contract with a single `TransferRewardNotifier`. +/// Extends `StakerTestBase` and implements the reward notification logic. +/// @dev This contract requires an initialized instance of `TransferRewardNotifier`. Initialization +/// is typically handled by a deployment script such as +/// `src/script/notifiers/DeployTransferRewardNotifier.sol` +abstract contract TransferRewardNotifierTestBase is StakerTestBase { + TransferRewardNotifier transferRewardNotifier; + + /// @notice Sets the reward amount, then prepares for and executes `notify` on + /// `TransferRewardNotifier`. Preparation involves minting tokens to `transferRewardNotifier`. The + /// `notify` execution then triggers the token transfer and reward distribution. + function _notifyRewardAmount(uint256 _amount) public override { + address _owner = transferRewardNotifier.owner(); + + vm.prank(_owner); + transferRewardNotifier.setRewardAmount(_amount); + + IMintable(address(REWARD_TOKEN)).mint(address(transferRewardNotifier), _amount); + transferRewardNotifier.notify(); + } +} diff --git a/test/helpers/PercentAssertions.sol b/src/test/helpers/PercentAssertions.sol similarity index 97% rename from test/helpers/PercentAssertions.sol rename to src/test/helpers/PercentAssertions.sol index 79eb63f9..97bdad73 100644 --- a/test/helpers/PercentAssertions.sol +++ b/src/test/helpers/PercentAssertions.sol @@ -46,7 +46,8 @@ contract PercentAssertions is Test { fail(); } - uint256 minBound = b - 1; + uint256 minBound = b; + if (b != 0) minBound = b - 1; if (!((a == b) || (a == minBound))) { emit log("Error: a == b || a == b-1"); diff --git a/src/test/interfaces/IERC20Mintable.sol b/src/test/interfaces/IERC20Mintable.sol new file mode 100644 index 00000000..9e988790 --- /dev/null +++ b/src/test/interfaces/IERC20Mintable.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity ^0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IERC20Mintable is IERC20 { + function mint(address _account, uint256 _value) external; +} diff --git a/test/StakerTestBase.sol b/test/StakerTestBase.sol index ff2c5ac6..b78152ef 100644 --- a/test/StakerTestBase.sol +++ b/test/StakerTestBase.sol @@ -7,7 +7,7 @@ import {DelegationSurrogate} from "../src/DelegationSurrogate.sol"; import {ERC20VotesMock} from "./mocks/MockERC20Votes.sol"; import {ERC20Fake} from "./fakes/ERC20Fake.sol"; import {MockFullEarningPowerCalculator} from "./mocks/MockFullEarningPowerCalculator.sol"; -import {PercentAssertions} from "./helpers/PercentAssertions.sol"; +import {PercentAssertions} from "../src/test/helpers/PercentAssertions.sol"; // Base utilities that can be used for testing concrete Staker implementations. Steps: // 1. Create a test file that inherits from StakerTestBase diff --git a/test/fakes/DeployBaseFake.sol b/test/fakes/DeployBaseFake.sol index 8a10187f..dcb1dbcd 100644 --- a/test/fakes/DeployBaseFake.sol +++ b/test/fakes/DeployBaseFake.sol @@ -7,7 +7,7 @@ import {DeployMintRewardNotifier} from "../../src/script/notifiers/DeployMintRew import {DeployIdentityEarningPowerCalculator} from "../../src/script/calculators/DeployIdentityEarningPowerCalculator.sol"; import {IMintable} from "../../src/interfaces/IMintable.sol"; - +import {FakeMinter} from "./FakeMinter.sol"; import {IEarningPowerCalculator} from "../../src/interfaces/IEarningPowerCalculator.sol"; import {Staker} from "../../src/Staker.sol"; import {StakerHarness} from "../harnesses/StakerHarness.sol"; @@ -23,7 +23,7 @@ contract DeployBaseFake is address public admin = makeAddr("Staker admin"); address public notifierReceiver = makeAddr("Notifier receiver"); address public notifierOwner = makeAddr("Notifier owner"); - address public notifierMinter = makeAddr("Notifier minter"); + address public notifierMinter; uint256 public initialRewardAmount = 10e18; uint256 public initialRewardInterval = 30 days; uint256 public maxBumpTip = 1e18; @@ -34,6 +34,7 @@ contract DeployBaseFake is constructor(IERC20 _rewardToken, IERC20 _stakeToken) { rewardToken = _rewardToken; stakeToken = _stakeToken; + notifierMinter = address(new FakeMinter(IMintable(address(_rewardToken)))); } function _baseConfiguration() internal virtual override returns (BaseConfiguration memory) { diff --git a/test/fakes/DeployBinaryEligibilityOracleEarningPowerCalculatorFake.sol b/test/fakes/DeployBinaryEligibilityOracleEarningPowerCalculatorFake.sol index cd55cfaa..1f47624a 100644 --- a/test/fakes/DeployBinaryEligibilityOracleEarningPowerCalculatorFake.sol +++ b/test/fakes/DeployBinaryEligibilityOracleEarningPowerCalculatorFake.sol @@ -12,6 +12,7 @@ import {Staker} from "../../src/Staker.sol"; import {StakerHarness} from "../harnesses/StakerHarness.sol"; import {IERC20Staking} from "../../src/interfaces/IERC20Staking.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {FakeMinter} from "./FakeMinter.sol"; contract DeployBinaryEligibilityOracleEarningPowerCalculatorFake is DeployBase, @@ -22,7 +23,7 @@ contract DeployBinaryEligibilityOracleEarningPowerCalculatorFake is address public admin = makeAddr("Staker admin"); address public owner = makeAddr("owner"); address public notifierOwner = makeAddr("Notifier owner"); - address public notifierMinter = makeAddr("Notifier minter"); + address public notifierMinter = makeAddr("Notifier Minter"); address public scoreOracle = makeAddr("scoreOracle"); address public oraclePauseGuardian = makeAddr("oraclePauseGuardian"); uint256 public initialRewardAmount = 10e18; @@ -39,6 +40,7 @@ contract DeployBinaryEligibilityOracleEarningPowerCalculatorFake is constructor(IERC20 _rewardToken, IERC20 _stakeToken) { rewardToken = _rewardToken; stakeToken = _stakeToken; + notifierMinter = address(new FakeMinter(IMintable(address(_rewardToken)))); } function _baseConfiguration() internal virtual override returns (BaseConfiguration memory) { @@ -52,7 +54,7 @@ contract DeployBinaryEligibilityOracleEarningPowerCalculatorFake is returns (MintRewardNotifierConfiguration memory) { return MintRewardNotifierConfiguration({ - initialRewardAmount: initialRewardInterval, + initialRewardAmount: initialRewardAmount, initialRewardInterval: initialRewardInterval, initialOwner: notifierOwner, minter: IMintable(notifierMinter) diff --git a/test/script/DeployIdentityEarningPowerCalculator.sol b/test/script/DeployIdentityEarningPowerCalculator.t.sol similarity index 100% rename from test/script/DeployIdentityEarningPowerCalculator.sol rename to test/script/DeployIdentityEarningPowerCalculator.t.sol diff --git a/test/script/DeployMintRewardNotifier.sol b/test/script/DeployMintRewardNotifier.t.sol similarity index 100% rename from test/script/DeployMintRewardNotifier.sol rename to test/script/DeployMintRewardNotifier.t.sol diff --git a/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol b/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol new file mode 100644 index 00000000..2f5408dd --- /dev/null +++ b/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {BinaryEligibilityOracleEarningPowerCalculatorTestBase} from + "../../src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol"; +import {BinaryEligibilityOracleEarningPowerCalculator} from + "../../src/calculators/BinaryEligibilityOracleEarningPowerCalculator.sol"; +import {MintRewardNotifier} from "../../src/notifiers/MintRewardNotifier.sol"; +import {IEarningPowerCalculator} from "../../src/interfaces/IEarningPowerCalculator.sol"; +import {StakeBase, WithdrawBase} from "../../src/test/StakerForkTestSuite.sol"; +import {Staker} from "../../src/Staker.sol"; +import {DeployBinaryEligibilityOracleEarningPowerCalculatorFake} from + "../fakes/DeployBinaryEligibilityOracleEarningPowerCalculatorFake.sol"; +import {ERC20Fake} from "../fakes/ERC20Fake.sol"; +import {ERC20VotesMock} from "../mocks/MockERC20Votes.sol"; +import {StakerTestBase} from "../../src/test/StakerTestBase.sol"; +import {IERC20Mintable} from "../../src/test/interfaces/IERC20Mintable.sol"; + +contract DeployBinaryEligibilityOracleEarningPowerCalculatorTestBase is + BinaryEligibilityOracleEarningPowerCalculatorTestBase +{ + DeployBinaryEligibilityOracleEarningPowerCalculatorFake DEPLOY_SCRIPT; + MintRewardNotifier mintRewardNotifier; + + function setUp() public virtual { + REWARD_TOKEN = new ERC20Fake(); + STAKE_TOKEN = new ERC20VotesMock(); + DEPLOY_SCRIPT = + new DeployBinaryEligibilityOracleEarningPowerCalculatorFake(REWARD_TOKEN, STAKE_TOKEN); + ( + IEarningPowerCalculator _earningPowerCalculator, + Staker _staker, + address[] memory _rewardNotifiers + ) = DEPLOY_SCRIPT.run(); + mintRewardNotifier = MintRewardNotifier(_rewardNotifiers[0]); + calculator = BinaryEligibilityOracleEarningPowerCalculator(address(_earningPowerCalculator)); + staker = _staker; + } + + /// @notice Test helper to notify rewards using the mint reward notifier. + /// @param _amount The amount of rewards to notify. + function _notifyRewardAmount(uint256 _amount) public virtual override { + vm.assume(address(mintRewardNotifier) != address(0)); + IERC20Mintable(address(REWARD_TOKEN)).mint(address(mintRewardNotifier), _amount); + + vm.startPrank(address(mintRewardNotifier)); + REWARD_TOKEN.transfer(address(staker), _amount); + staker.notifyRewardAmount(_amount); + vm.stopPrank(); + } +} + +contract Stake is StakeBase, DeployBinaryEligibilityOracleEarningPowerCalculatorTestBase { + function _boundMintAmount(uint256 _amount) + internal + pure + virtual + override(StakerTestBase, BinaryEligibilityOracleEarningPowerCalculatorTestBase) + returns (uint256) + { + return BinaryEligibilityOracleEarningPowerCalculatorTestBase._boundMintAmount(_amount); + } + + function _updateEarningPower(Staker.DepositIdentifier _depositId) + internal + virtual + override(StakerTestBase, BinaryEligibilityOracleEarningPowerCalculatorTestBase) + { + return BinaryEligibilityOracleEarningPowerCalculatorTestBase._updateEarningPower(_depositId); + } +} + +contract Withdraw is WithdrawBase, DeployBinaryEligibilityOracleEarningPowerCalculatorTestBase { + function _boundMintAmount(uint256 _amount) + internal + pure + virtual + override(StakerTestBase, BinaryEligibilityOracleEarningPowerCalculatorTestBase) + returns (uint256) + { + return BinaryEligibilityOracleEarningPowerCalculatorTestBase._boundMintAmount(_amount); + } + + function _updateEarningPower(Staker.DepositIdentifier _depositId) + internal + virtual + override(StakerTestBase, BinaryEligibilityOracleEarningPowerCalculatorTestBase) + { + return BinaryEligibilityOracleEarningPowerCalculatorTestBase._updateEarningPower(_depositId); + } +} diff --git a/test/test/MintRewardNotifierTestBase.t.sol b/test/test/MintRewardNotifierTestBase.t.sol new file mode 100644 index 00000000..cdd3337b --- /dev/null +++ b/test/test/MintRewardNotifierTestBase.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {MintRewardNotifierTestBase} from "../../src/test/MintRewardNotifierTestBase.sol"; +import { + StakeBase, + WithdrawBase, + ClaimRewardBase, + AlterClaimerBase, + AlterDelegateeBase +} from "../../src/test/StakerForkTestSuite.sol"; +import {Staker} from "../../src/Staker.sol"; +import {MintRewardNotifier} from "../../src/notifiers/MintRewardNotifier.sol"; +import {DeployBaseFake} from "../fakes/DeployBaseFake.sol"; +import {ERC20Fake} from "../fakes/ERC20Fake.sol"; +import {ERC20VotesMock} from "../mocks/MockERC20Votes.sol"; + +contract DeployMintRewardNotifierTestBase is MintRewardNotifierTestBase { + DeployBaseFake DEPLOY_SCRIPT; + + function setUp() public virtual { + REWARD_TOKEN = new ERC20Fake(); + STAKE_TOKEN = new ERC20VotesMock(); + DEPLOY_SCRIPT = new DeployBaseFake(REWARD_TOKEN, STAKE_TOKEN); + (, Staker _staker, address[] memory _rewardNotifiers) = DEPLOY_SCRIPT.run(); + mintRewardNotifier = MintRewardNotifier(_rewardNotifiers[0]); + staker = _staker; + } +} + +contract Stake is StakeBase, DeployMintRewardNotifierTestBase {} + +contract Withdraw is WithdrawBase, DeployMintRewardNotifierTestBase {} + +contract ClaimReward is ClaimRewardBase, DeployMintRewardNotifierTestBase {} + +contract AlterClaimer is AlterClaimerBase, DeployMintRewardNotifierTestBase {} + +contract AlterDelegatee is AlterDelegateeBase, DeployMintRewardNotifierTestBase {} diff --git a/test/test/StandardTestSuite.t.sol b/test/test/StandardTestSuite.t.sol new file mode 100644 index 00000000..0d084f20 --- /dev/null +++ b/test/test/StandardTestSuite.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {StakerTestBase, IERC20Mintable} from "../../src/test/StakerTestBase.sol"; +import { + StakeBase, + WithdrawBase, + ClaimRewardBase, + AlterClaimerBase, + AlterDelegateeBase +} from "../../src/test/StakerForkTestSuite.sol"; +import {Staker} from "../../src/Staker.sol"; +import {MintRewardNotifier} from "../../src/notifiers/MintRewardNotifier.sol"; +import {DeployBaseFake} from "../fakes/DeployBaseFake.sol"; +import {ERC20Fake} from "../fakes/ERC20Fake.sol"; +import {ERC20VotesMock} from "../mocks/MockERC20Votes.sol"; +import {IERC20Mintable} from "../../src/test/interfaces/IERC20Mintable.sol"; + +contract DeployBaseHarnessTestBase is StakerTestBase { + DeployBaseFake immutable DEPLOY_SCRIPT; + MintRewardNotifier immutable REWARD_NOTIFIER; + + constructor() { + REWARD_TOKEN = new ERC20Fake(); + STAKE_TOKEN = new ERC20VotesMock(); + DEPLOY_SCRIPT = new DeployBaseFake(REWARD_TOKEN, STAKE_TOKEN); + (, Staker _staker, address[] memory _rewardNotifiers) = DEPLOY_SCRIPT.run(); + REWARD_NOTIFIER = MintRewardNotifier(_rewardNotifiers[0]); + staker = _staker; + } + + function _notifyRewardAmount(uint256 _amount) public virtual override { + vm.assume(address(REWARD_NOTIFIER) != address(0)); + IERC20Mintable(address(REWARD_TOKEN)).mint(address(REWARD_NOTIFIER), _amount); + + vm.startPrank(address(REWARD_NOTIFIER)); + REWARD_TOKEN.transfer(address(staker), _amount); + staker.notifyRewardAmount(_amount); + vm.stopPrank(); + } +} + +contract Stake is StakeBase, DeployBaseHarnessTestBase {} + +contract Withdraw is WithdrawBase, DeployBaseHarnessTestBase {} + +contract ClaimReward is ClaimRewardBase, DeployBaseHarnessTestBase {} + +contract AlterClaimer is AlterClaimerBase, DeployBaseHarnessTestBase {} + +contract AlterDelegatee is AlterDelegateeBase, DeployBaseHarnessTestBase {} diff --git a/test/test/TransferFromRewardNotifierTestBase.t.sol b/test/test/TransferFromRewardNotifierTestBase.t.sol new file mode 100644 index 00000000..8cd3b23e --- /dev/null +++ b/test/test/TransferFromRewardNotifierTestBase.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {TransferFromRewardNotifierTestBase} from + "../../src/test/TransferFromRewardNotifierTestBase.sol"; +import { + StakeBase, + WithdrawBase, + ClaimRewardBase, + AlterClaimerBase, + AlterDelegateeBase +} from "../../src/test/StakerForkTestSuite.sol"; +import {Staker} from "../../src/Staker.sol"; +import {TransferFromRewardNotifier} from "../../src/notifiers/TransferFromRewardNotifier.sol"; +import {DeployTransferFromRewardNotifierFake} from + "../fakes/DeployTransferFromRewardNotifierFake.sol"; +import {ERC20Fake} from "../fakes/ERC20Fake.sol"; +import {ERC20VotesMock} from "../mocks/MockERC20Votes.sol"; + +contract DeployTransferFromRewardNotifierTestBase is TransferFromRewardNotifierTestBase { + DeployTransferFromRewardNotifierFake DEPLOY_SCRIPT; + + function setUp() public virtual { + REWARD_TOKEN = new ERC20Fake(); + STAKE_TOKEN = new ERC20VotesMock(); + DEPLOY_SCRIPT = new DeployTransferFromRewardNotifierFake(REWARD_TOKEN, STAKE_TOKEN); + (, Staker _staker, address[] memory _rewardNotifiers) = DEPLOY_SCRIPT.run(); + transferFromRewardNotifier = TransferFromRewardNotifier(_rewardNotifiers[0]); + staker = _staker; + } +} + +contract Stake is StakeBase, DeployTransferFromRewardNotifierTestBase {} + +contract Withdraw is WithdrawBase, DeployTransferFromRewardNotifierTestBase {} + +contract ClaimReward is ClaimRewardBase, DeployTransferFromRewardNotifierTestBase {} + +contract AlterClaimer is AlterClaimerBase, DeployTransferFromRewardNotifierTestBase {} + +contract AlterDelegatee is AlterDelegateeBase, DeployTransferFromRewardNotifierTestBase {} diff --git a/test/test/TransferRewardNotifierTestBase.t.sol b/test/test/TransferRewardNotifierTestBase.t.sol new file mode 100644 index 00000000..a139469b --- /dev/null +++ b/test/test/TransferRewardNotifierTestBase.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {TransferRewardNotifierTestBase} from "../../src/test/TransferRewardNotifierTestBase.sol"; +import { + StakeBase, + WithdrawBase, + ClaimRewardBase, + AlterClaimerBase, + AlterDelegateeBase +} from "../../src/test/StakerForkTestSuite.sol"; +import {Staker} from "../../src/Staker.sol"; +import {TransferRewardNotifier} from "../../src/notifiers/TransferRewardNotifier.sol"; +import {DeployTransferRewardNotifierFake} from "../fakes/DeployTransferRewardNotifierFake.sol"; +import {ERC20Fake} from "../fakes/ERC20Fake.sol"; +import {ERC20VotesMock} from "../mocks/MockERC20Votes.sol"; + +contract DeployTransferRewardNotifierTestBase is TransferRewardNotifierTestBase { + DeployTransferRewardNotifierFake DEPLOY_SCRIPT; + + function setUp() public virtual { + REWARD_TOKEN = new ERC20Fake(); + STAKE_TOKEN = new ERC20VotesMock(); + DEPLOY_SCRIPT = new DeployTransferRewardNotifierFake(REWARD_TOKEN, STAKE_TOKEN); + (, Staker _staker, address[] memory _rewardNotifiers) = DEPLOY_SCRIPT.run(); + transferRewardNotifier = TransferRewardNotifier(_rewardNotifiers[0]); + staker = _staker; + } +} + +contract Stake is StakeBase, DeployTransferRewardNotifierTestBase {} + +contract Withdraw is WithdrawBase, DeployTransferRewardNotifierTestBase {} + +contract ClaimReward is ClaimRewardBase, DeployTransferRewardNotifierTestBase {} + +contract AlterClaimer is AlterClaimerBase, DeployTransferRewardNotifierTestBase {} + +contract AlterDelegatee is AlterDelegateeBase, DeployTransferRewardNotifierTestBase {}