TempleGold

TempleDAO
Foundry
25,000 USDC
View results
Submission Details
Severity: medium
Valid

updating vestingPeriod can allow users to get more rewards than expected or prevent users from withdrawing staking tokens in TempleGoldStaking

Summary

In TempleGoldStakingcontract, vestingPeriod can be updated by calling setVestingPeriod. However, updating vestingPeriod affects the vestingRate of stakers whose rewards are still not vested fully. This can also result claiming of more rewards by the users or revert of withdraw, withdrawAll and getReward function.

Vulnerability Details

vestingRateof stakerfor an epochis calculated using function _getVestingRate. The function uses _stakeInfo.fullyVestedAtto determine if rewards are fully vested. If rewards are not fully vested, the vestingRateis calculated using timeDifferenceand vestingPeriod. However, vestingPeriodused may be different from when userdid the staking if vestingPeriodis updated later.

https://github.com/Cyfrin/2024-07-templegold/blob/main/protocol/contracts/templegold/TempleGoldStaking.sol#L484-L493

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;
}
}

https://github.com/Cyfrin/2024-07-templegold/blob/main/protocol/contracts/templegold/TempleGoldStaking.sol#L463-L482

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
// UNDERFLOW HAPPENS HERE
(_stakeInfo.amount * (_perTokenReward - userRewardPerTokenPaid[_account][_index])) / 1e18 +
claimableRewards[_account][_index];
}

There can be following 2 vulnerabilities due to this:

Vulnerability 1

If the new vestingPeriodis less than old vestingPeriod, rewards will be fully vested before _stakeInfo.fullyVestedAt. The issue with this is vestingRateof users in this case can exceed1e18 which can result in more amounts of rewards to be claimed by the user than their earned amount.

Steps to reporduce:

Initial vestingPeriod= 5 days = 5 * 24 * 3600 seconds

reward.periodFinish= 24 hours = 24 * 3600 seconds

rewardRate= 5

1) User calls stakeat time = 0seconds with amount = 1 ether. _stakeInfoswill be set as following:

_stakeInfos[user][1] = StakeInfo(0, 5 * 24 * 3600, 1 ether);

rewardDataand other params will be set as following:

rewardData.rewardPerTokenStored = 0
rewardData.lastUpdateTime = 0
claimableRewards[user][1] = 0
userRewardPerTokenPaid[user][1] = 0

After 24 hours, at time = 24 * 3600. reward period is finished

2) User calls getReward with index = 1 at time = 24 hours = 24 * 3600.

rewardDataand other params will be updated as following:

rewardData.rewardPerTokenStored = 24 * 3600 * 5 * 1e18/1e18 = 432000
rewardData.lastUpdateTime = 24 * 3600
vesting rate of user = 0.20e18
userRewardPerTokenPaid[user][1] = 432000 * 0.20e18 = 86400e18
claimableRewards[user][1] = 0

3) ownerupdates vestingPeriodto 1 days.

3) At time = 4 days and 23 hours = 119 * 3600 seconds. User calls getRewardwith index = 1.

// not changed because no new distribution started
rewardData.rewardPerTokenStored = 24 * 3600 * 5 * 1e18/1e18 = 432000
rewardData.lastUpdateTime = 24 * 3600
vesting rate of user = (119 * 3600 - 0) * 1e18 / 1 days = 4.958e18
// this is more than rewardPerTokenStored which can cause users to claim the rewards which is not theirs
claimableRewards[user][1] = 432000 * 4.958e18 - 86400e18 = 2055456e18

Thus, claimableRewards of user is more than expected due to increased vestingPeriod.

Vulnerability 2

If new vestingPeriodis more than old vestingPeriod, the vestingRatewill be less than expected while rewards will again be fully vested at _stakeInfo.fullyVestedAt. This type of vesting doesn't make sense for users because it won't be linear vesting. In this case, functions like withdraw, withdrawAll and getReward may revert for some stakers due to underflowin _earned function.

Let's see how can underflow happen in _earnedfunction in this case.

Some points to note:

rewardData.rewardPerTokenStored will keep on increasing as time passes because rewardswill be accumulated. It will increase in linear proportion till reward duration ends if there are no additional staking or withdrawals.

userRewardPerTokenPaid[_account][_index]stores vestingRate * rewardData.rewardPerTokenStoredwhen any user action is done(eg. staking, withdrawor claim of rewards.)

Steps to reporduce:

Initial vestingPeriod= 1 days = 24 * 3600 seconds

reward.periodFinish= 18 hours = 18 * 3600 seconds

rewardRate= 5

1) User calls stakeat time = 15seconds with amount = 1 ether. _stakeInfoswill be set as following:

_stakeInfos[user][1] = StakeInfo(15, 15 + 24 * 3600, 1 ether);

rewardDataand other params will be set as following:

rewardData.rewardPerTokenStored = 0
rewardData.lastUpdateTime = 15
claimableRewards[user][1] = 0
userRewardPerTokenPaid[user][1] = 0

2) User calls getReward with index = 1 at time = 17 hours and 15 seconds = 17 * 3600 + 15 seconds .

rewardDataand other params will be updated as following:

rewardData.rewardPerTokenStored = 0 + (17 * 3600 + 15 - 15) * 5e18 * 1e18 /1e18 = 17 * 3600 * 5e18 = 306000e18
rewardData.lastUpdateTime = 17 * 3600 + 15
vestingRate of user = (17 * 3600 + 15 - 15) * 1e18 / 1 days = 0.708e18
claimableRewards[user][1] = 0
userRewardPerTokenPaid[user][1] = vestingrate * rewardData.rewardPerTokenStored / 1e18 = 216648e18

3) after 1 hour at time = 18 hours and 15 seconds = 18 * 3600 + 15 seconds, reward's period finishes. ownerupdates vestingPeriodto 2 days.

New vesting Period = 2 days = 2 * 24 * 3600 seconds

after 15 secondsat time = 18 * 3600 + 15 + 15 seconds, user calls withdrawwhich will revert due to underflow.

3) User calls withdrawAllwith index = 1 at time = 18 * 3600 + 30 seconds.

calling updateReward for the user and index will revert. rewardDataand other params will be updated as following:

NOTE: vestingRateof user decreased due to new vesting period

rewardData.rewardPerTokenStored = 306000e18 + (18 * 3600 - (17 * 3600 + 15)) * 5e18 * 1e18/1e18 = 323925e18
rewardData.lastUpdateTime = 18 * 3600
// due to update
vestingRate of user = (18 * 3600 + 30 - 15) * 1e18/ 2 days = 0.3750e18
// in `_earned` function
_perTokenReward = 323925e18 * 0.3750e18 / 1e18 = 121471e18
// from previous
userRewardPerTokenPaid[user][1] = 216648e18
// In `_earned` function
// due to this, _perTokenReward is less than userRewardPerTokenPaid[user][1].
// Due to this following line in `_earned` function will revert,
(_stakeInfo.amount * (_perTokenReward - userRewardPerTokenPaid[_account][_index])) / 1e18 +
claimableRewards[_account][_index];

Impact

update in vestingPeriodwill affect previously opened positions. Users can claim more funds than their expected rewards if new vestingPeriodis less.

If new vestingPeriodis more than previous vestingPeriod, functions like withdraw, withdrawAll and getRewardmay revert due to underflow.

Tools Used

Manual review and calculation on pen-paper.

Recommendations

Add the vestingPeriodin stakeInfostruct and do not use the updated vestingPeriodfor the previously opened positions.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Changes to vesting period is not handled inside `_getVestingRate`

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.