Summary
After one distribution period ends, some stakers can earn more rewards, this is unexpected.
Vulnerability Details
When we start one distribute reward period, we will set the distribution period's periodFinish
. When the distribution period finishes, there will be no any reward for this stake period.
Stakers' rewards will be calculated via _earned()
. In function _earned()
, we will calculate the current rewards based on _rewardPerToken()
and vestingRate
. The _rewardPerToken()
value will stop increasing when we reach periodFinish
, but vestingRate
can still increase even if we reach periodFinish
. This will cause the final earned rewards will increase even if the distribution period ends.
function _notifyReward(uint256 amount) private {
......
rewardData.lastUpdateTime = uint40(block.timestamp);
rewardData.periodFinish = uint40(block.timestamp + rewardDuration);
}
function _earned(
StakeInfo memory _stakeInfo,
address _account,
uint256 _index
) internal view returns (uint256) {
uint256 vestingRate = _getVestingRate(_stakeInfo);
if (vestingRate == 0) {
return 0;
}
uint256 _perTokenReward;
if (vestingRate == 1e18) {
_perTokenReward = _rewardPerToken();
} else {
_perTokenReward = _rewardPerToken() * vestingRate / 1e18;
}
return
(_stakeInfo.amount * (_perTokenReward - userRewardPerTokenPaid[_account][_index])) / 1e18 +
claimableRewards[_account][_index];
}
function _rewardPerToken() internal view returns (uint256) {
if (totalSupply == 0) {
return rewardData.rewardPerTokenStored;
}
return
rewardData.rewardPerTokenStored +
(((_lastTimeRewardApplicable(rewardData.periodFinish) -
rewardData.lastUpdateTime) *
rewardData.rewardRate * 1e18)
/ totalSupply);
}
function _getVestingRate(StakeInfo memory _stakeInfo) internal view returns (uint256 vestingRate) {
if (_stakeInfo.stakeTime == 0) {
return 0;
}
if (block.timestamp > _stakeInfo.fullyVestedAt) {
vestingRate = 1e18;
} else {
vestingRate = (block.timestamp - _stakeInfo.stakeTime) * 1e18 / vestingPeriod;
}
}
Poc
Add this test case into TempleGoldStaking.t.tsol.
When the distribution period ends, wait for another 2 weeks, the staker Bob can earn more rewards.
function test_Poc2_tgldStaking_single_stake_single_account() public {
skip(4 weeks);
uint32 _rewardDuration = 4 weeks;
_setVestingPeriod(_rewardDuration);
_setRewardDuration(_rewardDuration);
_setVestingFactor(templeGold);
vm.startPrank(alice);
deal(address(templeToken), alice, 1000 ether, true);
deal(address(templeToken), bob, 1000 ether, true);
_approve(address(templeToken), address(staking), type(uint).max);
uint256 stakeAmount = 100 ether;
staking.stake(stakeAmount);
uint256 stakeTime = block.timestamp;
uint256 goldBalanceBefore = templeGold.balanceOf(address(staking));
staking.distributeRewards();
uint256 goldRewardsAmount = templeGold.balanceOf(address(staking)) - goldBalanceBefore;
skip(2 weeks);
vm.stopPrank();
vm.startPrank(bob);
_approve(address(templeToken), address(staking), type(uint).max);
staking.stake(stakeAmount);
vm.stopPrank();
skip(2 weeks);
ITempleGoldStaking.Reward memory rewardData = staking.getRewardData();
uint256 earned = staking.earned(alice, 1);
console.log("Distribution period ends, Alice reward", earned);
earned = staking.earned(bob, 1);
console.log("Distribution period ends, Bob reward: ", earned);
skip(2 weeks);
earned = staking.earned(bob, 1);
console.log("Distribution period ends, Bob increased reward: ", earned);
}
The test case output is as below:
Logs:
Whole reward amount: 84000000000000000000000000
Distribution period ends, Alice reward 62999999999999999999596800
Distribution period ends, Bob reward: 31499999999999999999798400
Distribution period ends, Bob increased reward: 62999999999999999999596800
Impact
Stakers may get more rewards after the distribution period ends.
Tools Used
Manual
Recommendations
Revisit _getVestingRate() logic, if block.timestamp is larger than the distribution periodFinish
, we cannot increase vestingRate.