Summary
During staking, the user's userRewardPerTokenPaid should be set to rewardData.rewardPerTokenStored. This ensures that the user's reward is not affected by accrued rewards before their staking.
However, the user's userRewardPerTokenPaid is set to zero during staking. As a result, user who stake later can earn more rewards.
Vulnerability Details
modifier updateReward(address _account, uint256 _index) {
{
rewardData.rewardPerTokenStored = uint216(_rewardPerToken());
rewardData.lastUpdateTime = uint40(_lastTimeRewardApplicable(rewardData.periodFinish));
if (_account != address(0)) {
StakeInfo memory _stakeInfo = _stakeInfos[_account][_index];
uint256 vestingRate = _getVestingRate(_stakeInfo);
claimableRewards[_account][_index] = _earned(_stakeInfo, _account, _index);
userRewardPerTokenPaid[_account][_index] = vestingRate * uint256(rewardData.rewardPerTokenStored) / 1e18;
}
}
_;
}
During a user's staking, the updateReward modifier sets userRewardPerTokenPaid[_account][_index] to zero since the vestingRate is zero at this moment. The subsequent stakeFor() does not set the user's userRewardPerTokenPaid. Therefore, the user's userRewardPerTokenPaid remains zero after staking.
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];
}
However, the user's reward is calculated by subtracting userRewardPerTokenPaid from _rewardPerToken() after vesting. Since _rewardPerToken() increases as time passes, the later a user stakes, the more rewards they can receive.
Impact
Later stakers receives more rewards from the TempleGoldStaking contract. Moreover, the reward accounting is broken, there would not be enough rewards in the contract for all the stakers, causing later withdrawers to be unable to claim their rewards.
Proof of Concept
pragma solidity ^0.8.0;
import { console } from "forge-std/Test.sol";
import { TempleGoldStakingTestBase } from "./templegold/TempleGoldStaking.t.sol";
contract PoC is TempleGoldStakingTestBase {
function test_poc() public {
uint256 _period = 16 weeks;
{
_setRewardDuration(_period);
_setVestingPeriod(uint32(_period));
_setVestingFactor(templeGold);
}
uint256 stakeAmount = 100 ether;
deal(address(templeToken), alice, 1000 ether, true);
deal(address(templeToken), bob, 1000 ether, true);
vm.prank(alice);
templeToken.approve(address(staking), type(uint256).max);
vm.prank(bob);
templeToken.approve(address(staking), type(uint256).max);
vm.prank(alice);
staking.stake(stakeAmount);
skip(1 weeks);
staking.distributeRewards();
uint256 snap = vm.snapshot();
{
vm.startPrank(bob);
staking.stake(stakeAmount);
skip(_period);
uint256 earnedAmount = staking.earned(bob, 1);
console.log("Scenario 1: bob staking at the start of period:");
console.log(" earnedAmount %s\n", earnedAmount);
vm.stopPrank();
}
vm.revertTo(snap);
{
skip(_period / 2);
vm.startPrank(bob);
staking.stake(stakeAmount);
skip(_period);
uint256 earnedAmount = staking.earned(bob, 1);
console.log("Scenario 2: bob staking in the middle of period:");
console.log(" earnedAmount %s\n", earnedAmount);
vm.stopPrank();
}
vm.revertTo(snap);
{
skip(_period);
vm.startPrank(bob);
staking.stake(stakeAmount);
skip(_period);
uint256 earnedAmount = staking.earned(bob, 1);
console.log("Scenario 3: bob staking at the end of period:");
console.log(" earnedAmount %s\n", earnedAmount);
vm.stopPrank();
}
}
}
Place the above code in the file test/forge/PoC.t.sol, then run the command forge test --mc PoC -vv. The result is as follows:
[PASS] test_poc() (gas: 1320297)
Logs:
Scenario 1: bob staking at the start of period:
earnedAmount 10499999999999999995699200
Scenario 2: bob staking in the middle of period:
earnedAmount 15749999999999999993548800
Scenario 3: bob staking at the end of period:
earnedAmount 20999999999999999991398400
As seen from the result, the later bob stakes, the more reward he receives.
Tools Used
Manual Review, Foundry
Recommendations
Set the correct userRewardPerTokenPaid during staking.