From b2138c7b736ee058af443c6b9f7c2fa4ebb53ffa Mon Sep 17 00:00:00 2001 From: keating Date: Thu, 27 Mar 2025 14:28:51 -0400 Subject: [PATCH 1/5] Add modular testing utilites --------- Co-authored-by: Alexander Keating Add modular testing natspec (#150) * Add modular testing natspec --- ...tyOracleEarningPowerCalculatorTestBase.sol | 44 ++ src/test/MintRewardNotifierTestBase.sol | 28 + ...yOracleEarningPowerCalculatorTestSuite.sol | 148 ++++ src/test/StakerTestBase.sol | 227 ++++++ src/test/StandardTestSuite.sol | 747 ++++++++++++++++++ .../TransferFromRewardNotifierTestBase.sol | 37 + src/test/TransferRewardNotifierTestBase.sol | 31 + src/test/helpers/PercentAssertions.sol | 60 ++ src/test/interfaces/IERC20Mintable.sol | 9 + test/fakes/DeployBaseFake.sol | 5 +- ...bilityOracleEarningPowerCalculatorFake.sol | 6 +- test/helpers/PercentAssertions.sol | 3 +- ...eployIdentityEarningPowerCalculator.t.sol} | 0 ...ier.sol => DeployMintRewardNotifier.t.sol} | 0 ...OracleEarningPowerCalculatorTestBase.t.sol | 90 +++ test/test/MintRewardNotifierTestBase.t.sol | 39 + ...OracleEarningPowerCalculatorTestBase.t.sol | 59 ++ test/test/StandardTestSuite.t.sol | 51 ++ .../TransferFromRewardNotifierTestBase.t.sol | 41 + .../test/TransferRewardNotifierTestBase.t.sol | 39 + 20 files changed, 1659 insertions(+), 5 deletions(-) create mode 100644 src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol create mode 100644 src/test/MintRewardNotifierTestBase.sol create mode 100644 src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.sol create mode 100644 src/test/StakerTestBase.sol create mode 100644 src/test/StandardTestSuite.sol create mode 100644 src/test/TransferFromRewardNotifierTestBase.sol create mode 100644 src/test/TransferRewardNotifierTestBase.sol create mode 100644 src/test/helpers/PercentAssertions.sol create mode 100644 src/test/interfaces/IERC20Mintable.sol rename test/script/{DeployIdentityEarningPowerCalculator.sol => DeployIdentityEarningPowerCalculator.t.sol} (100%) rename test/script/{DeployMintRewardNotifier.sol => DeployMintRewardNotifier.t.sol} (100%) create mode 100644 test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol create mode 100644 test/test/MintRewardNotifierTestBase.t.sol create mode 100644 test/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol create mode 100644 test/test/StandardTestSuite.t.sol create mode 100644 test/test/TransferFromRewardNotifierTestBase.t.sol create mode 100644 test/test/TransferRewardNotifierTestBase.t.sol diff --git a/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol b/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol new file mode 100644 index 00000000..88969736 --- /dev/null +++ b/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol @@ -0,0 +1,44 @@ +// 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 when delegatees meet +/// the eligibility threshold. This includes stake management and eligibility threshold testing +/// functionality. +/// @dev This contract requires an initialized instance of +/// `BinaryEligibilityOracleEarningPowerCalculator`. Initialization is typically handled by a +/// deployment script such as +/// `src/script/calculators/DeployBinaryEligibilityOracleEarningPowerCalculator.sol`. +abstract contract BinaryEligibilityOracleEarningPowerCalculatorTestBase is StakerTestBase { + BinaryEligibilityOracleEarningPowerCalculator calculator; + MintRewardNotifier mintRewardNotifier; + + /// @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..5eb94ec2 --- /dev/null +++ b/src/test/MintRewardNotifierTestBase.sol @@ -0,0 +1,28 @@ +// 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 { + MintRewardNotifier mintRewardNotifier; + + /// @notice Sets the reward amount, then calls the `notify` function that triggers token minting + /// and reward distribution. + function _notifyRewardAmount(uint256 _amount) public override { + address _owner = mintRewardNotifier.owner(); + + vm.prank(_owner); + mintRewardNotifier.setRewardAmount(_amount); + + mintRewardNotifier.notify(); + } +} diff --git a/src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.sol b/src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.sol new file mode 100644 index 00000000..a8ca3574 --- /dev/null +++ b/src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// slither-disable-start reentrancy-benign + +pragma solidity ^0.8.23; + +import {BinaryEligibilityOracleEarningPowerCalculator} from + "../calculators/BinaryEligibilityOracleEarningPowerCalculator.sol"; +import {StakerTestBase} from "./StakerTestBase.sol"; +import {Staker} from "../Staker.sol"; +import {BinaryEligibilityOracleEarningPowerCalculatorTestBase} from + "./BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol"; + +/// @title StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite +/// @author [ScopeLift](https://scopelift.co) +/// @notice The base contract for testing BinaryEligibilityOracleEarningPowerCalculator. Contains +/// test setup and helper functions for testing the calculator's behavior when delegatees are below +/// the eligibility threshold. This contract is designed to be used in conjunction with the +/// deployment scripts in +/// `src/script/calculators/DeployBinaryEligibilityOracleEarningPowerCalculator.sol`. +abstract contract StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase is + BinaryEligibilityOracleEarningPowerCalculatorTestBase +{ + /// @notice Helper to set a delegatee's score below threshold. + /// @param delegatee The address of the delegatee whose score will be set below threshold. + function _setDelegateeScoreBelowThreshold(address delegatee) internal { + vm.startPrank(calculator.scoreOracle()); + calculator.updateDelegateeScore(delegatee, calculator.delegateeEligibilityThresholdScore() - 1); + vm.stopPrank(); + } + + /// @notice A test helper that wraps calling the `stake` function and ensures proper earning power + /// adjustment when delegatee scores are below threshold. + /// @param _depositor The address of the depositor. + /// @param _amount The amount to stake. + /// @param _delegatee The address to which the delegation surrogate is delegating voting power. + /// @return _depositId The id of the created deposit. + function _stakeBelowThreshold(address _depositor, uint256 _amount, address _delegatee) + internal + virtual + returns (Staker.DepositIdentifier _depositId) + { + _depositId = StakerTestBase._stake(_depositor, _amount, _delegatee); + _setDelegateeScoreBelowThreshold(_delegatee); + } + + /// @notice Helper to set a delegatee's score above the eligibility threshold. + /// @dev This should be called after a delegatee is known but before checking their earning power. + /// @param delegatee The address of the delegatee whose score will be set above threshold. + function _setDelegateeScoreAboveThreshold(address delegatee) internal { + vm.startPrank(calculator.scoreOracle()); + calculator.updateDelegateeScore(delegatee, calculator.delegateeEligibilityThresholdScore() + 1); + vm.stopPrank(); + } + + /// @notice A test helper that wraps calling the `stake` function and ensures proper earning power + /// adjustment when delegatee scores are above threshold. + /// @param _depositor The address of the depositor. + /// @param _amount The amount to stake. + /// @param _delegatee The address to which the delegation surrogate is delegating voting power. + /// @return _depositId The id of the created deposit. + function _stakeAboveThreshold(address _depositor, uint256 _amount, address _delegatee) + internal + virtual + returns (Staker.DepositIdentifier _depositId) + { + _depositId = StakerTestBase._stake(_depositor, _amount, _delegatee); + + _setDelegateeScoreAboveThreshold(_delegatee); + vm.startPrank(_depositor); + staker.bumpEarningPower(_depositId, _depositor, 0); + vm.stopPrank(); + } +} + +abstract contract StakeBase is StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase { + function testFuzz_StakerEarnsZeroRewardsWhenDelegateeScoreIsBelowThreshold( + address _depositor, + uint96 _amount, + address _delegatee, + uint256 _rewardAmount, + uint256 _percentDuration + ) public { + _assumeNotZeroAddressOrStaker(_depositor); + vm.assume(_delegatee != address(0) && _amount != 0); + + _mintStakeToken(_depositor, _amount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _percentDuration = bound(_percentDuration, 1, 100); + + Staker.DepositIdentifier _depositId = _stakeBelowThreshold(_depositor, _amount, _delegatee); + + _notifyRewardAmount(_rewardAmount); + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + uint256 unclaimedRewards = staker.unclaimedReward(_depositId); + + assertEq(unclaimedRewards, 0); + } +} + +abstract contract WithdrawBase is StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase { + function testFuzz_OnlyStakeWithEligibleEarningPowerClaimsRewardAfterDuration( + address _depositor1, + address _depositor2, + uint96 _amount1, + uint96 _amount2, + address _delegatee1, + address _delegatee2, + uint256 _rewardAmount, + uint256 _percentDuration + ) public { + _assumeNotZeroAddressOrStaker(_depositor1); + _assumeNotZeroAddressOrStaker(_depositor2); + vm.assume(_depositor1 != _depositor2); + vm.assume(_delegatee1 != address(0) && _delegatee2 != address(0) && _delegatee1 != _delegatee2); + + _amount1 = uint96(_boundMintAmount(_amount1)); + _amount2 = uint96(_boundMintAmount(_amount2)); + vm.assume(_amount1 != 0 && _amount2 != 0); + + _mintStakeToken(_depositor1, _amount1); + _mintStakeToken(_depositor2, _amount2); + + Staker.DepositIdentifier _depositId1 = _stakeBelowThreshold(_depositor1, _amount1, _delegatee1); + Staker.DepositIdentifier _depositId2 = _stakeAboveThreshold(_depositor2, _amount2, _delegatee2); + + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _notifyRewardAmount(_rewardAmount); + _percentDuration = bound(_percentDuration, 1, 100); + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + Staker.Deposit memory _deposit2 = _fetchDeposit(_depositId2); + uint256 _calculatedRewards2 = + _calculateEarnedRewards(_deposit2.earningPower, _rewardAmount, _percentDuration); + + _withdraw(_depositor1, _depositId1, _amount1); + _withdraw(_depositor2, _depositId2, _amount2); + + vm.prank(_depositor1); + uint256 _actualReward1 = staker.claimReward(_depositId1); + + vm.prank(_depositor2); + uint256 _actualReward2 = staker.claimReward(_depositId2); + + assertEq(0, _actualReward1); + assertApproxEqAbs(_calculatedRewards2, _actualReward2, 1); + } +} diff --git a/src/test/StakerTestBase.sol b/src/test/StakerTestBase.sol new file mode 100644 index 00000000..7127b9ea --- /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., `StandardTestSuite.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 { + assumeNotZeroAddress(_addr); + vm.assume(_addr != address(staker)); + } +} diff --git a/src/test/StandardTestSuite.sol b/src/test/StandardTestSuite.sol new file mode 100644 index 00000000..ce6fb1a1 --- /dev/null +++ b/src/test/StandardTestSuite.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/TransferFromRewardNotifierTestBase.sol b/src/test/TransferFromRewardNotifierTestBase.sol new file mode 100644 index 00000000..184ee87d --- /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 "src/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/src/test/helpers/PercentAssertions.sol b/src/test/helpers/PercentAssertions.sol new file mode 100644 index 00000000..97bdad73 --- /dev/null +++ b/src/test/helpers/PercentAssertions.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; + +contract PercentAssertions is Test { + // Because there will be (expected) rounding errors in the amount of rewards earned, this helper + // checks that the truncated number is lesser and within 1% of the expected number. + function assertLteWithinOnePercent(uint256 a, uint256 b) public { + if (a > b) { + emit log("Error: a <= b not satisfied"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + + fail(); + } + + uint256 minBound = (b * 9900) / 10_000; + + if (a < minBound) { + emit log("Error: a >= 0.99 * b not satisfied"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + emit log_named_uint(" minBound", minBound); + + fail(); + } + } + + function _percentOf(uint256 _amount, uint256 _percent) public pure returns (uint256) { + // For cases where the percentage is less than 100, we calculate the percentage by + // taking the inverse percentage and subtracting it. This effectively rounds _up_ the + // value by putting the truncation on the opposite side. For example, 92% of 555 is 510.6. + // Calculating it in this way would yield (555 - 44) = 511, instead of 510. + if (_percent < 100) return _amount - ((100 - _percent) * _amount) / 100; + else return (_percent * _amount) / 100; + } + + // This helper is for normal rounding errors, i.e. if the number might be truncated down by 1 + function assertLteWithinOneUnit(uint256 a, uint256 b) public { + if (a > b) { + emit log("Error: a <= b not satisfied"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + + fail(); + } + + uint256 minBound = b; + if (b != 0) minBound = b - 1; + + if (!((a == b) || (a == minBound))) { + emit log("Error: a == b || a == b-1"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + + fail(); + } + } +} 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/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/helpers/PercentAssertions.sol b/test/helpers/PercentAssertions.sol index 79eb63f9..97bdad73 100644 --- a/test/helpers/PercentAssertions.sol +++ b/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/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..f7ee4caf --- /dev/null +++ b/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol @@ -0,0 +1,90 @@ +// 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/StandardTestSuite.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; + + 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..065f76a0 --- /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/StandardTestSuite.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/StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol b/test/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol new file mode 100644 index 00000000..15d7e68a --- /dev/null +++ b/test/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {BinaryEligibilityOracleEarningPowerCalculator} from + "src/calculators/BinaryEligibilityOracleEarningPowerCalculator.sol"; +import {MintRewardNotifier} from "../../src/notifiers/MintRewardNotifier.sol"; +import {IEarningPowerCalculator} from "../../src/interfaces/IEarningPowerCalculator.sol"; +import { + StakeBase, + WithdrawBase, + StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase +} from "../../src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.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 DeployStakedBinaryEligibilityOracleEarningPowerCalculatorTestBase is + StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase +{ + DeployBinaryEligibilityOracleEarningPowerCalculatorFake DEPLOY_SCRIPT; + + 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, DeployStakedBinaryEligibilityOracleEarningPowerCalculatorTestBase {} + +contract Withdraw is + WithdrawBase, + DeployStakedBinaryEligibilityOracleEarningPowerCalculatorTestBase +{} diff --git a/test/test/StandardTestSuite.t.sol b/test/test/StandardTestSuite.t.sol new file mode 100644 index 00000000..a619c1f5 --- /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/StandardTestSuite.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..c5d2dde6 --- /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/StandardTestSuite.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..504872a0 --- /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/StandardTestSuite.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 {} From 000cb873c75b977f450e4b9279569afaa046c65d Mon Sep 17 00:00:00 2001 From: Keating Date: Wed, 4 Jun 2025 15:20:28 -0400 Subject: [PATCH 2/5] Remove extra staking test utility --- ...yOracleEarningPowerCalculatorTestSuite.sol | 148 ------------------ ...OracleEarningPowerCalculatorTestBase.t.sol | 59 ------- 2 files changed, 207 deletions(-) delete mode 100644 src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.sol delete mode 100644 test/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol diff --git a/src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.sol b/src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.sol deleted file mode 100644 index a8ca3574..00000000 --- a/src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.sol +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// slither-disable-start reentrancy-benign - -pragma solidity ^0.8.23; - -import {BinaryEligibilityOracleEarningPowerCalculator} from - "../calculators/BinaryEligibilityOracleEarningPowerCalculator.sol"; -import {StakerTestBase} from "./StakerTestBase.sol"; -import {Staker} from "../Staker.sol"; -import {BinaryEligibilityOracleEarningPowerCalculatorTestBase} from - "./BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol"; - -/// @title StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite -/// @author [ScopeLift](https://scopelift.co) -/// @notice The base contract for testing BinaryEligibilityOracleEarningPowerCalculator. Contains -/// test setup and helper functions for testing the calculator's behavior when delegatees are below -/// the eligibility threshold. This contract is designed to be used in conjunction with the -/// deployment scripts in -/// `src/script/calculators/DeployBinaryEligibilityOracleEarningPowerCalculator.sol`. -abstract contract StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase is - BinaryEligibilityOracleEarningPowerCalculatorTestBase -{ - /// @notice Helper to set a delegatee's score below threshold. - /// @param delegatee The address of the delegatee whose score will be set below threshold. - function _setDelegateeScoreBelowThreshold(address delegatee) internal { - vm.startPrank(calculator.scoreOracle()); - calculator.updateDelegateeScore(delegatee, calculator.delegateeEligibilityThresholdScore() - 1); - vm.stopPrank(); - } - - /// @notice A test helper that wraps calling the `stake` function and ensures proper earning power - /// adjustment when delegatee scores are below threshold. - /// @param _depositor The address of the depositor. - /// @param _amount The amount to stake. - /// @param _delegatee The address to which the delegation surrogate is delegating voting power. - /// @return _depositId The id of the created deposit. - function _stakeBelowThreshold(address _depositor, uint256 _amount, address _delegatee) - internal - virtual - returns (Staker.DepositIdentifier _depositId) - { - _depositId = StakerTestBase._stake(_depositor, _amount, _delegatee); - _setDelegateeScoreBelowThreshold(_delegatee); - } - - /// @notice Helper to set a delegatee's score above the eligibility threshold. - /// @dev This should be called after a delegatee is known but before checking their earning power. - /// @param delegatee The address of the delegatee whose score will be set above threshold. - function _setDelegateeScoreAboveThreshold(address delegatee) internal { - vm.startPrank(calculator.scoreOracle()); - calculator.updateDelegateeScore(delegatee, calculator.delegateeEligibilityThresholdScore() + 1); - vm.stopPrank(); - } - - /// @notice A test helper that wraps calling the `stake` function and ensures proper earning power - /// adjustment when delegatee scores are above threshold. - /// @param _depositor The address of the depositor. - /// @param _amount The amount to stake. - /// @param _delegatee The address to which the delegation surrogate is delegating voting power. - /// @return _depositId The id of the created deposit. - function _stakeAboveThreshold(address _depositor, uint256 _amount, address _delegatee) - internal - virtual - returns (Staker.DepositIdentifier _depositId) - { - _depositId = StakerTestBase._stake(_depositor, _amount, _delegatee); - - _setDelegateeScoreAboveThreshold(_delegatee); - vm.startPrank(_depositor); - staker.bumpEarningPower(_depositId, _depositor, 0); - vm.stopPrank(); - } -} - -abstract contract StakeBase is StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase { - function testFuzz_StakerEarnsZeroRewardsWhenDelegateeScoreIsBelowThreshold( - address _depositor, - uint96 _amount, - address _delegatee, - uint256 _rewardAmount, - uint256 _percentDuration - ) public { - _assumeNotZeroAddressOrStaker(_depositor); - vm.assume(_delegatee != address(0) && _amount != 0); - - _mintStakeToken(_depositor, _amount); - _rewardAmount = _boundToRealisticReward(_rewardAmount); - _percentDuration = bound(_percentDuration, 1, 100); - - Staker.DepositIdentifier _depositId = _stakeBelowThreshold(_depositor, _amount, _delegatee); - - _notifyRewardAmount(_rewardAmount); - _jumpAheadByPercentOfRewardDuration(_percentDuration); - - uint256 unclaimedRewards = staker.unclaimedReward(_depositId); - - assertEq(unclaimedRewards, 0); - } -} - -abstract contract WithdrawBase is StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase { - function testFuzz_OnlyStakeWithEligibleEarningPowerClaimsRewardAfterDuration( - address _depositor1, - address _depositor2, - uint96 _amount1, - uint96 _amount2, - address _delegatee1, - address _delegatee2, - uint256 _rewardAmount, - uint256 _percentDuration - ) public { - _assumeNotZeroAddressOrStaker(_depositor1); - _assumeNotZeroAddressOrStaker(_depositor2); - vm.assume(_depositor1 != _depositor2); - vm.assume(_delegatee1 != address(0) && _delegatee2 != address(0) && _delegatee1 != _delegatee2); - - _amount1 = uint96(_boundMintAmount(_amount1)); - _amount2 = uint96(_boundMintAmount(_amount2)); - vm.assume(_amount1 != 0 && _amount2 != 0); - - _mintStakeToken(_depositor1, _amount1); - _mintStakeToken(_depositor2, _amount2); - - Staker.DepositIdentifier _depositId1 = _stakeBelowThreshold(_depositor1, _amount1, _delegatee1); - Staker.DepositIdentifier _depositId2 = _stakeAboveThreshold(_depositor2, _amount2, _delegatee2); - - _rewardAmount = _boundToRealisticReward(_rewardAmount); - _notifyRewardAmount(_rewardAmount); - _percentDuration = bound(_percentDuration, 1, 100); - _jumpAheadByPercentOfRewardDuration(_percentDuration); - - Staker.Deposit memory _deposit2 = _fetchDeposit(_depositId2); - uint256 _calculatedRewards2 = - _calculateEarnedRewards(_deposit2.earningPower, _rewardAmount, _percentDuration); - - _withdraw(_depositor1, _depositId1, _amount1); - _withdraw(_depositor2, _depositId2, _amount2); - - vm.prank(_depositor1); - uint256 _actualReward1 = staker.claimReward(_depositId1); - - vm.prank(_depositor2); - uint256 _actualReward2 = staker.claimReward(_depositId2); - - assertEq(0, _actualReward1); - assertApproxEqAbs(_calculatedRewards2, _actualReward2, 1); - } -} diff --git a/test/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol b/test/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol deleted file mode 100644 index 15d7e68a..00000000 --- a/test/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.23; - -import {BinaryEligibilityOracleEarningPowerCalculator} from - "src/calculators/BinaryEligibilityOracleEarningPowerCalculator.sol"; -import {MintRewardNotifier} from "../../src/notifiers/MintRewardNotifier.sol"; -import {IEarningPowerCalculator} from "../../src/interfaces/IEarningPowerCalculator.sol"; -import { - StakeBase, - WithdrawBase, - StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase -} from "../../src/test/StakedBinaryEligibilityOracleEarningPowerCalculatorTestSuite.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 DeployStakedBinaryEligibilityOracleEarningPowerCalculatorTestBase is - StakedBinaryEligibilityOracleEarningPowerCalculatorTestBase -{ - DeployBinaryEligibilityOracleEarningPowerCalculatorFake DEPLOY_SCRIPT; - - 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, DeployStakedBinaryEligibilityOracleEarningPowerCalculatorTestBase {} - -contract Withdraw is - WithdrawBase, - DeployStakedBinaryEligibilityOracleEarningPowerCalculatorTestBase -{} From c7b4d3df84a80dfa7e45db7a46a027edc06ff5a7 Mon Sep 17 00:00:00 2001 From: Keating Date: Wed, 4 Jun 2025 15:24:47 -0400 Subject: [PATCH 3/5] Consolidate PercentAssertions --- test/StakerTestBase.sol | 2 +- test/helpers/PercentAssertions.sol | 60 ------------------------------ 2 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 test/helpers/PercentAssertions.sol diff --git a/test/StakerTestBase.sol b/test/StakerTestBase.sol index ff2c5ac6..90889a53 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/helpers/PercentAssertions.sol b/test/helpers/PercentAssertions.sol deleted file mode 100644 index 97bdad73..00000000 --- a/test/helpers/PercentAssertions.sol +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.23; - -import {Test} from "forge-std/Test.sol"; - -contract PercentAssertions is Test { - // Because there will be (expected) rounding errors in the amount of rewards earned, this helper - // checks that the truncated number is lesser and within 1% of the expected number. - function assertLteWithinOnePercent(uint256 a, uint256 b) public { - if (a > b) { - emit log("Error: a <= b not satisfied"); - emit log_named_uint(" Expected", b); - emit log_named_uint(" Actual", a); - - fail(); - } - - uint256 minBound = (b * 9900) / 10_000; - - if (a < minBound) { - emit log("Error: a >= 0.99 * b not satisfied"); - emit log_named_uint(" Expected", b); - emit log_named_uint(" Actual", a); - emit log_named_uint(" minBound", minBound); - - fail(); - } - } - - function _percentOf(uint256 _amount, uint256 _percent) public pure returns (uint256) { - // For cases where the percentage is less than 100, we calculate the percentage by - // taking the inverse percentage and subtracting it. This effectively rounds _up_ the - // value by putting the truncation on the opposite side. For example, 92% of 555 is 510.6. - // Calculating it in this way would yield (555 - 44) = 511, instead of 510. - if (_percent < 100) return _amount - ((100 - _percent) * _amount) / 100; - else return (_percent * _amount) / 100; - } - - // This helper is for normal rounding errors, i.e. if the number might be truncated down by 1 - function assertLteWithinOneUnit(uint256 a, uint256 b) public { - if (a > b) { - emit log("Error: a <= b not satisfied"); - emit log_named_uint(" Expected", b); - emit log_named_uint(" Actual", a); - - fail(); - } - - uint256 minBound = b; - if (b != 0) minBound = b - 1; - - if (!((a == b) || (a == minBound))) { - emit log("Error: a == b || a == b-1"); - emit log_named_uint(" Expected", b); - emit log_named_uint(" Actual", a); - - fail(); - } - } -} From 5c10de9136c5b4a07d9140514593dd1ce071d30d Mon Sep 17 00:00:00 2001 From: Keating Date: Wed, 4 Jun 2025 18:27:18 -0400 Subject: [PATCH 4/5] Some small changes --- src/test/{StandardTestSuite.sol => StakerForkTestSuite.sol} | 0 src/test/StakerTestBase.sol | 4 ++-- ...inaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol | 2 +- test/test/MintRewardNotifierTestBase.t.sol | 2 +- test/test/StandardTestSuite.t.sol | 2 +- test/test/TransferFromRewardNotifierTestBase.t.sol | 2 +- test/test/TransferRewardNotifierTestBase.t.sol | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename src/test/{StandardTestSuite.sol => StakerForkTestSuite.sol} (100%) diff --git a/src/test/StandardTestSuite.sol b/src/test/StakerForkTestSuite.sol similarity index 100% rename from src/test/StandardTestSuite.sol rename to src/test/StakerForkTestSuite.sol diff --git a/src/test/StakerTestBase.sol b/src/test/StakerTestBase.sol index 7127b9ea..2b9c0b2f 100644 --- a/src/test/StakerTestBase.sol +++ b/src/test/StakerTestBase.sol @@ -17,7 +17,7 @@ import {PercentAssertions} from "./helpers/PercentAssertions.sol"; /// 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., `StandardTestSuite.sol`). +/// 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 { @@ -220,7 +220,7 @@ abstract contract StakerTestBase is Test, PercentAssertions { } /// @notice A test helper that assumes an address is neither zero nor the staker contract - function _assumeNotZeroAddressOrStaker(address _addr) internal { + function _assumeNotZeroAddressOrStaker(address _addr) internal view { assumeNotZeroAddress(_addr); vm.assume(_addr != address(staker)); } diff --git a/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol b/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol index f7ee4caf..891eca4e 100644 --- a/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol +++ b/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol @@ -7,7 +7,7 @@ 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/StandardTestSuite.sol"; +import {StakeBase, WithdrawBase} from "../../src/test/StakerForkTestSuite.sol"; import {Staker} from "../../src/Staker.sol"; import {DeployBinaryEligibilityOracleEarningPowerCalculatorFake} from "../fakes/DeployBinaryEligibilityOracleEarningPowerCalculatorFake.sol"; diff --git a/test/test/MintRewardNotifierTestBase.t.sol b/test/test/MintRewardNotifierTestBase.t.sol index 065f76a0..cdd3337b 100644 --- a/test/test/MintRewardNotifierTestBase.t.sol +++ b/test/test/MintRewardNotifierTestBase.t.sol @@ -8,7 +8,7 @@ import { ClaimRewardBase, AlterClaimerBase, AlterDelegateeBase -} from "../../src/test/StandardTestSuite.sol"; +} from "../../src/test/StakerForkTestSuite.sol"; import {Staker} from "../../src/Staker.sol"; import {MintRewardNotifier} from "../../src/notifiers/MintRewardNotifier.sol"; import {DeployBaseFake} from "../fakes/DeployBaseFake.sol"; diff --git a/test/test/StandardTestSuite.t.sol b/test/test/StandardTestSuite.t.sol index a619c1f5..0d084f20 100644 --- a/test/test/StandardTestSuite.t.sol +++ b/test/test/StandardTestSuite.t.sol @@ -8,7 +8,7 @@ import { ClaimRewardBase, AlterClaimerBase, AlterDelegateeBase -} from "../../src/test/StandardTestSuite.sol"; +} from "../../src/test/StakerForkTestSuite.sol"; import {Staker} from "../../src/Staker.sol"; import {MintRewardNotifier} from "../../src/notifiers/MintRewardNotifier.sol"; import {DeployBaseFake} from "../fakes/DeployBaseFake.sol"; diff --git a/test/test/TransferFromRewardNotifierTestBase.t.sol b/test/test/TransferFromRewardNotifierTestBase.t.sol index c5d2dde6..8cd3b23e 100644 --- a/test/test/TransferFromRewardNotifierTestBase.t.sol +++ b/test/test/TransferFromRewardNotifierTestBase.t.sol @@ -9,7 +9,7 @@ import { ClaimRewardBase, AlterClaimerBase, AlterDelegateeBase -} from "../../src/test/StandardTestSuite.sol"; +} from "../../src/test/StakerForkTestSuite.sol"; import {Staker} from "../../src/Staker.sol"; import {TransferFromRewardNotifier} from "../../src/notifiers/TransferFromRewardNotifier.sol"; import {DeployTransferFromRewardNotifierFake} from diff --git a/test/test/TransferRewardNotifierTestBase.t.sol b/test/test/TransferRewardNotifierTestBase.t.sol index 504872a0..a139469b 100644 --- a/test/test/TransferRewardNotifierTestBase.t.sol +++ b/test/test/TransferRewardNotifierTestBase.t.sol @@ -8,7 +8,7 @@ import { ClaimRewardBase, AlterClaimerBase, AlterDelegateeBase -} from "../../src/test/StandardTestSuite.sol"; +} from "../../src/test/StakerForkTestSuite.sol"; import {Staker} from "../../src/Staker.sol"; import {TransferRewardNotifier} from "../../src/notifiers/TransferRewardNotifier.sol"; import {DeployTransferRewardNotifierFake} from "../fakes/DeployTransferRewardNotifierFake.sol"; From 891fc627a2126b6c032de4d8821334b552743479 Mon Sep 17 00:00:00 2001 From: Keating Date: Wed, 4 Jun 2025 20:19:50 -0400 Subject: [PATCH 5/5] Some more small changes --- ...ibilityOracleEarningPowerCalculatorTestBase.sol | 14 +++++--------- src/test/MintRewardNotifierTestBase.sol | 2 ++ src/test/TransferFromRewardNotifierTestBase.sol | 2 +- test/StakerTestBase.sol | 2 +- ...ilityOracleEarningPowerCalculatorTestBase.t.sol | 3 ++- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol b/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol index 88969736..3d77904b 100644 --- a/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol +++ b/src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol @@ -10,17 +10,13 @@ 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 when delegatees meet -/// the eligibility threshold. This includes stake management and eligibility threshold testing -/// functionality. -/// @dev This contract requires an initialized instance of -/// `BinaryEligibilityOracleEarningPowerCalculator`. Initialization is typically handled by a -/// deployment script such as -/// `src/script/calculators/DeployBinaryEligibilityOracleEarningPowerCalculator.sol`. +/// @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; - MintRewardNotifier mintRewardNotifier; /// @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 diff --git a/src/test/MintRewardNotifierTestBase.sol b/src/test/MintRewardNotifierTestBase.sol index 5eb94ec2..594dd088 100644 --- a/src/test/MintRewardNotifierTestBase.sol +++ b/src/test/MintRewardNotifierTestBase.sol @@ -13,10 +13,12 @@ import {StakerTestBase} from "./StakerTestBase.sol"; /// 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(); diff --git a/src/test/TransferFromRewardNotifierTestBase.sol b/src/test/TransferFromRewardNotifierTestBase.sol index 184ee87d..1e7ebdde 100644 --- a/src/test/TransferFromRewardNotifierTestBase.sol +++ b/src/test/TransferFromRewardNotifierTestBase.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.23; import {TransferFromRewardNotifier} from "../notifiers/TransferFromRewardNotifier.sol"; -import {IMintable} from "src/interfaces/IMintable.sol"; +import {IMintable} from "../interfaces/IMintable.sol"; import {StakerTestBase} from "./StakerTestBase.sol"; /// @title TransferFromRewardNotifierTestBase diff --git a/test/StakerTestBase.sol b/test/StakerTestBase.sol index 90889a53..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 "src/test/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/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol b/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol index 891eca4e..2f5408dd 100644 --- a/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol +++ b/test/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.23; import {BinaryEligibilityOracleEarningPowerCalculatorTestBase} from "../../src/test/BinaryEligibilityOracleEarningPowerCalculatorTestBase.sol"; import {BinaryEligibilityOracleEarningPowerCalculator} from - "src/calculators/BinaryEligibilityOracleEarningPowerCalculator.sol"; + "../../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"; @@ -20,6 +20,7 @@ contract DeployBinaryEligibilityOracleEarningPowerCalculatorTestBase is BinaryEligibilityOracleEarningPowerCalculatorTestBase { DeployBinaryEligibilityOracleEarningPowerCalculatorFake DEPLOY_SCRIPT; + MintRewardNotifier mintRewardNotifier; function setUp() public virtual { REWARD_TOKEN = new ERC20Fake();