TempleGold

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

Later stakers receive more rewards due to the setting of users' `userRewardPerTokenPaid` to zero in staking

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) {
{
// stack too deep
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

// SPDX-License-Identifier: Unlicense
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.

Updates

Lead Judging Commences

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

Future stakers are paid with rewards that have been accrued from the past due to miscalculation in userRewardPerTokenPaid and _perTokenReward

Support

FAQs

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