Skip to content

Latest commit

 

History

History
156 lines (111 loc) · 5.45 KB

016.md

File metadata and controls

156 lines (111 loc) · 5.45 KB

Modern Heather Sidewinder

Medium

Unearned reward tokens can be locked in the GovernanceStaker contract

Summary

Since depositors are unable to earn reward tokens when totalEarningPower is zero, some reward tokens may remain unallocated even after the reward duration has expired.

And also due to rounding down, a dust of reward tokens may be accrued in the contract.

By the way, there is no way to withdraw such reward tokens, and they are permanently locked within the contract.

Vulnerability Detail

Reward tokens are not awarded to depositors when totalEarningPower is 0.

GovernanceStaker.sol#L303-L308

  function rewardPerTokenAccumulated() public view virtual returns (uint256) {
@>  if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint;

    return rewardPerTokenAccumulatedCheckpoint
      + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower;
  }

Being 0 totalEarningPower is fairly possible:

  • when no depositors exists - OR -
  • when each depositor's earning power is 0

Consequently, while totalEarningPower is 0, no reward tokens are awarded and remain in the contract.

Proof-Of-Concept

Let's take an example scenario:

  1. A reward notifier starts a new reward period(30 days) with 30 ether reward tokens.
  2. The first deposit is performed after 10 days later.
  3. After 20 days passes, and the depositor earns only 20 ether reward tokens that are corresponding to 20 days. The remaining 10 ether reward tokens remained unallocated.
  4. When next reward period begins, this 10 ether is not added to the new reward amount because the duration of the old reward period has expired.

Here is a test case to demonstrate the locked reward tokens due to 0 totalEarningPower.

contract GovernanceStakerPocTest is GovernanceStakerRewardsTest {

  function _logDetails(GovernanceStaker.DepositIdentifier _depositId) internal {
    GovernanceStaker.Deposit memory _deposit = _fetchDeposit(_depositId);
    console2.log("Deposit Balance", _deposit.balance);
    console2.log("Deposit Earning Power", _deposit.earningPower);
    console2.log("Unclaimed Reward", _deposit.scaledUnclaimedRewardCheckpoint / 1e36);
  }

  function test_LockedRewardTokens() public {
    address depositor0 = makeAddr("depositor0");
    address delegatee0 = makeAddr("delegatee0");

    uint256 rewardAmount = 30 ether;

    {
      console2.log("----------------- day 0 -----------------");
      _mintTransferAndNotifyReward(rewardAmount);
      console2.log("Reward Balances:", rewardToken.balanceOf(address(govStaker)));
    }

    GovernanceStaker.DepositIdentifier _depositId;
    GovernanceStaker.Deposit memory _deposit;
    uint256 stakeAmount = 1 ether;

    _jumpAhead(10 days);
    {
      console2.log("");
      console2.log("----------------- day 10 -----------------");
      _mintGovToken(depositor0, stakeAmount * 2);

      vm.startPrank(depositor0);
      govToken.approve(address(govStaker), stakeAmount);
      _depositId = govStaker.stake(stakeAmount, delegatee0, depositor0);
      vm.stopPrank();

      _logDetails(_depositId);
    }

    _jumpAhead(20 days);
    {
      console2.log("");
      console2.log("----------------- day 30 -----------------");

      vm.startPrank(depositor0);
      govToken.approve(address(govStaker), stakeAmount / 10);
      govStaker.stakeMore(_depositId, stakeAmount / 10);
      vm.stopPrank();

      _logDetails(_depositId);

      vm.prank(depositor0);
      govStaker.claimReward(_depositId);
      console2.log("Remaining Reward Balances", rewardToken.balanceOf(address(govStaker)));
    }

    {
      console2.log("");
      console2.log(">> Starting new reward duaration ...");
      _mintTransferAndNotifyReward(rewardAmount);
      console2.log("Reward amount to be applied in the new duration", 
        govStaker.scaledRewardRate() * govStaker.REWARD_DURATION() / 1e36);
    }
  }
}

Here are the logs:

Ran 1 test for test/_poc/GovernanceStaker.poc.t.sol:GovernanceStakerPocTest
[PASS] test_LockedRewardTokens() (gas: 712934)
Logs:
  ----------------- day 0 -----------------
  Reward Balances: 30000000000000000000

  ----------------- day 10 -----------------
  Deposit Balance 1000000000000000000
  Deposit Earning Power 1000000000000000000
  Unclaimed Reward 0

  ----------------- day 30 -----------------
  Deposit Balance 1100000000000000000
  Deposit Earning Power 1100000000000000000
  Unclaimed Reward 19999999999999999999
  Remaining Reward Balances 10000000000000000001

  >> Starting new reward duration...
  Reward amount to be applied in the new duration 29999999999999999999

As can be seen from the logs, reward tokens are not distributed for the first 10 days. Therefore 10 ether tokens are locked in the contract and not even added to the next reward amount.

Impact

Unpaid reward tokens will be permanently locked in the contract.

Code Snippet

https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L32

Tool used

Manual Review

Recommendation

To address such case, Synthetix adopted an owner-permissioned emergency function called recoverERC20(). (StakingRewards.sol#L135-L139)

I suggest adding such a function to the GovernanceStaker contract to withdraw locked tokens.