Modern Heather Sidewinder
Medium
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.
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.
Let's take an example scenario:
- A reward notifier starts a new reward period(30 days) with
30 ether
reward tokens. - The first deposit is performed after
10 days
later. - After
20 days
passes, and the depositor earns only20 ether
reward tokens that are corresponding to20 days
. The remaining10 ether
reward tokens remained unallocated. - 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.
Unpaid reward tokens will be permanently locked in the contract.
https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L32
Manual Review
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.