TempleGold

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

Staking algorithm is incompatible with vesting mechanism

Summary

The introduction of vesting periods in the staking algorithm can lead to unallocated and undistributed reward tokens that will be stuck in the contract and cause loss of protocol funds.

Vulnerability Details

The TempleGoldStaking contract follows the classic staking algorithm, popularized by Synthetix's implementation. In the algorithm, an accumulator named rewardPerTokenStored tracks the amount of reward tokens corresponding to a single unit of the staking token. This is given by the _rewardPerToken() function.

502: function _rewardPerToken() internal view returns (uint256) {
503: if (totalSupply == 0) {
504: return rewardData.rewardPerTokenStored;
505: }
506:
507: return
508: rewardData.rewardPerTokenStored +
509: (((_lastTimeRewardApplicable(rewardData.periodFinish) -
510: rewardData.lastUpdateTime) *
511: rewardData.rewardRate * 1e18)
512: / totalSupply);
513: }

Basically this is the delta in time since last updated, multiplied by rate per second, and divided by the total supply. Important to note here that total supply is the total amount of staking tokens deposited, without any notion of vesting.

Users then have another accumulator in the userRewardPerTokenPaid mapping. Pending rewards are then calculated as the difference between rewardPerTokenStored and userRewardPerTokenPaid.

463: function _earned(
464: StakeInfo memory _stakeInfo,
465: address _account,
466: uint256 _index
467: ) internal view returns (uint256) {
468: uint256 vestingRate = _getVestingRate(_stakeInfo);
469: if (vestingRate == 0) {
470: return 0;
471: }
472: uint256 _perTokenReward;
473: if (vestingRate == 1e18) {
474: _perTokenReward = _rewardPerToken();
475: } else {
476: _perTokenReward = _rewardPerToken() * vestingRate / 1e18;
477: }
478:
479: return
480: (_stakeInfo.amount * (_perTokenReward - userRewardPerTokenPaid[_account][_index])) / 1e18 +
481: claimableRewards[_account][_index];
482: }

A fundamental difference from the classic algorithm is the vesting rate. The user's reward per token here is multiplied by the rate. When the stake is fully matured, the algorithm behaves the same. However, when the rate is less than 1e18 the reward per token will be a percentage of the expected reward per token at that specific point in time.

Recall from before that rewardPerTokenStored is calculated using the total stake supply. This means that if a user doesn't wait until their stake is fully matured, there will be a portion of the allocated reward tokens that won't be distributed to anyone.

Of course this isn't a problem in the classic version since there is no notion of vestion. There, whenever a user unstakes, the rewardPerTokenStored variable will be adjusted accordingly to distribute the pending rewards to the other stakers. The key difference in the Temple implementation, and the core of the problem, is that while the global userRewardPerTokenPaid is calculated using the totalSupply, the same doesn't always apply to each specific user's userRewardPerTokenPaid.

Proof of Concept

The following test illustrates the issue. While Alice has her stake fully matured along the reward period, Bob withdraws at the middle of the vesting period. The end result is a portion of the rewards are left unallocated and locked in the staking contract.

Place this test in the TempleGoldStaking.t.sol file.

function test_RewardConflictVesting() public {
// set period to have 0 dust, simplifies demostration
uint256 _period = 1_500_000;
{
_setRewardDuration(_period);
_setVestingPeriod(uint32(_period));
_setVestingFactor(templeGold);
}
{
skip(1 days);
}
// seed accounts and stake
{
deal(address(templeToken), alice, 1 ether, true);
deal(address(templeToken), bob, 1 ether, true);
vm.startPrank(alice);
_approve(address(templeToken), address(staking), type(uint).max);
staking.stake(1 ether);
vm.stopPrank();
vm.startPrank(bob);
_approve(address(templeToken), address(staking), type(uint).max);
staking.stake(1 ether);
vm.stopPrank();
}
// trigger distribution
{
staking.distributeRewards();
}
uint256 rewardBalance;
{
rewardBalance = templeGold.balanceOf(address(staking));
console.log("Reward balance:", rewardBalance);
}
// at half of the period (and half of the vestion rate) bob unstakes
skip(_period / 2);
{
vm.prank(bob);
staking.withdrawAll(1, true);
}
// skip to the complete period and claim rewards
skip(_period / 2);
{
staking.getReward(alice, 1);
staking.getReward(bob, 1);
}
uint256 aliceRewards = templeGold.balanceOf(alice);
uint256 bobRewards = templeGold.balanceOf(bob);
console.log("Alice TGLD balance:", aliceRewards);
console.log("Bob TGLD balance:", bobRewards);
// At this point alice + bob is less than the full rewards
assertLt(aliceRewards + bobRewards, rewardBalance);
// A portion of the reward tokens are left undistributed in the staking
assertGt(templeGold.balanceOf(address(staking)), 0);
}

Impact

Reward tokens associated with unrealized stakes are not redistributed to other stakers when users exit their position before their vesting period is fully matured. Undistributed reward tokens will be left stuck in the contract, unallocated and unrecoverable, and cause loss of protocol funds.

Note that the issue becomes critical since these undistributed rewards not only are left unallocated, but cannot be eventually re-claimed by the user. As stakes are dependent on a unique index determined at deposit time, the user cannot re-stake under the same index again.

Tools Used

None.

Recommendations

When a user withdraws their stake before it is fully matured, the implementation should distribute the rewards associated with the inflicted penalty (the 1e18 - vestingRate percentage) to other stakers. This could be potentially done by re-adjusting the reward rate to account for the undistributed tokens.

Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months 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.