Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Future Stakers Gains More Rewards from Already Accumulated `rewardPerTokenStored` Causing Unfair Reward Distribution

Summary:

The BaseGauge contract's reward calculation allows any futures staker to be rewarded with rewardPerTokenStored that have been accumulated since creation of contract. That means a user who hasn't been staking from the beginning will be rewarded the same as a user who has been staking since the beginning. Or in other words the contract assumes that every user staked when the rewardPerTokenStored is 0.

Vulnerability Details:

  1. rewardPerToken Accumulation: The rewardPerToken value increases over time as rewards are distributed and staking occurs. This value persists even if no new rewards are being distributed.

  2. Instant Benefit for New Stakers: When a new user stakes tokens, their rewardPerTokenPaid is always set after calculating the earned rewards. Thus new stakers instantly benefit from this accumulated rewardPerToken, even if no new rewards have been distributed since the previous user staked.

  3. Not Accounting Already Accumulated rewardPerTokenStored: The rewardPerTokenStored should be updated before calculating the rewards for new stakers. This would ensure that new stakers only receive rewards based on the rewardPerTokenStored value at the time of staking, rather than benefiting from previously accumulated rewards.

function _updateReward(address account) internal {
rewardPerTokenStored = getRewardPerToken();
lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
UserState storage state = userStates[account];
state.rewards = earned(account);
// @audit should be updated before calculating the rewards
state.rewardPerTokenPaid = rewardPerTokenStored; // <---- Wrong place to update rewardPerTokenPaid
state.lastUpdateTime = block.timestamp;
emit RewardUpdated(account, state.rewards);
}
}

Impact:

  • Unfair Reward Distribution: Future stakers receives rewards based on the already accumulated rewardPerToken, while earlier stakers are penalized for their early participation. This creates an unfair and inequitable reward system.

  • Insufficient rewards for early stakers: Early stakers receive fewer rewards than they should, as the rewards are distributed based on the accumulated rewardPerTokenStored value, rather than the value at the time of staking. This causes a loss of rewards for early participants, also no incentive for early staking.

Proof of Concept (PoC):

Scenario:

  1. Alice stakes 100 tokens in the BaseGauge contract when rewardPerTokenStored is 0. She earns some rewards over time.

  2. Bluedragon waits until rewardPerTokenStored has increased to 1000 before staking 100 tokens.

  3. Bluedragon's rewardPerTokenPaid is set to 0 when they stake, and they instantly benefit from the accumulated rewards based on the rewardPerTokenStored value of 1000.

  4. Alice, who has been staking since the beginning, receives rewards based on the rewardPerTokenStored value of 1000, but she has been staking since the rewardPerTokenStored was 0.

  5. Bluedragon gains more rewards than Alice instantly, even though Alice has been staking longer.

Proof Of Code:

  1. Use this guide to intergrate foundry into your project: foundry

  2. Create a new file FortisAudits.t.sol in the test directory.

  3. Add the following gist code to the file: Gist Code

  4. Run the test using forge test --mt test_FortisAudits_FutureStakersEarnsMoreThanEarlyStakers -vvvv.

function test_FortisAudits_FutureStakersEarnsMoreThanEarlyStakers() public {
address bluedragon = makeAddr("bluedragon");
vm.startPrank(initialOwner);
raacToken.setMinter(initialOwner);
raacToken.mint(address(raacGauge), type(uint128).max);
raacToken.mint(anon, 1000e18);
raacToken.mint(bluedragon, 1000e18);
gaugeController.addGauge(address(raacGauge), IGaugeController.GaugeType.RAAC, 1000e18);
vm.stopPrank();
stakingToken.mint(anon, 100e18);
stakingToken.mint(bluedragon, 100e18);
vm.warp(1739686038); // (Feb 16 2025)
vm.startPrank(bluedragon);
raacToken.approve(address(veraacToken), 1000e18);
veraacToken.lock(400e18, 365 days); // 100e18 veRAACToken is minted for 400e18 RAACToken
console.log("Bluedragon has %d veRAACToken", veraacToken.balanceOf(bluedragon)/1e18);
vm.stopPrank();
vm.startPrank(anon);
raacToken.approve(address(veraacToken), 1000e18);
veraacToken.lock(400e18, 365 days); // 100e18 veRAACToken is minted for 400e18 RAACToken
gaugeController.distributeRewards(address(raacGauge));
console.log("Alice has %d veRAACToken", veraacToken.balanceOf(anon)/1e18);
vm.stopPrank();
skip(1 days);
vm.startPrank(anon);
stakingToken.approve(address(raacGauge), 100e18);
console.log("Alice has %d stakingToken", stakingToken.balanceOf(anon)/1e18);
raacGauge.stake(100e18);
console.log("Alice stakes %d stakingToken", raacGauge.balanceOf(anon)/1e18);
skip(1 days);
raacGauge.rewardRate();
// rewardPerTokenStored updated
uint256 earned = raacGauge.earned(anon); // 312_499e18
raacGauge.getReward(); // 312_499e18
console.log("After staking for 1 day, Alice has earned %d of reward tokens ", earned/1e18);
vm.stopPrank();
skip(7 days);
uint256 rewardPerToken = raacGauge.getRewardPerToken();
console.log("After 7 days, reward per token is increased to %d", rewardPerToken/1e18);
vm.startPrank(anon);
uint256 earned_a = raacGauge.earned(anon);
(uint256 time_a,,) = raacGauge.userStates(anon);
raacGauge.getReward(); // 2_187_499e18
uint256 timeDelta_a = block.timestamp - time_a;
console.log("Now, Alice gets %d of rewards with time delta being %d (7 days)", earned_a/1e18, timeDelta_a); // 2_187_499e18
vm.stopPrank();
vm.startPrank(bluedragon);
stakingToken.approve(address(raacGauge), 100e18);
console.log("Bluedragon has %d stakingToken", stakingToken.balanceOf(bluedragon)/1e18);
raacGauge.stake(100e18);
console.log("Alice stakes %d stakingToken", raacGauge.balanceOf(bluedragon)/1e18);
uint256 earned_b = raacGauge.earned(bluedragon);
(uint256 time_b,,) = raacGauge.userStates(bluedragon);
raacGauge.getReward();
uint256 timeDelta_b = block.timestamp - time_b;
console.log("Now, Bluedragon instantly gets %d of rewards instantly with time delta being %d", earned_b/1e18, timeDelta_b);
assertGt(earned_b, earned_a);
vm.stopPrank();
}
[PASS] test_FortisAudits_FutureStakersEarnsMoreThanEarlyStakers() (gas: 1912690)
Logs:
Bluedragon has 100 veRAACToken
Alice has 100 veRAACToken
Alice has 100 stakingToken
Alice stakes 100 stakingToken
After staking for 1 day, Alice has earned 312499 of reward tokens
After 7 days, reward per token is increased to 1428
Now, Alice gets 2187499 of rewards with time delta being 604800 (7 days)
Bluedragon has 100 stakingToken
Alice stakes 100 stakingToken
Now, Bluedragon instantly gets 2499999 of rewards instantly with time delta being 0

Tools Used:

  • Manual code review

Recommended Mitigation:

To address this vulnerability, the rewardPerTokenPaid value should be updated to the current rewardPerTokenStored value when a user stakes tokens. This ensures that new stakers only receive rewards based on the current rewardPerTokenStored value, rather than benefiting from previously accumulated rewards.

function _updateReward(address account) internal {
// other code...
if (account != address(0)) {
UserState storage state = userStates[account];
// @Fix ⬇️
+ state.rewardPerTokenPaid = rewardPerTokenStored;
state.rewards = earned(account);
- state.rewardPerTokenPaid = rewardPerTokenStored;
state.lastUpdateTime = block.timestamp;
emit RewardUpdated(account, state.rewards);
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

bluedragon Submitter
3 months ago
bluedragon Submitter
3 months ago
bluedragon Submitter
3 months ago
inallhonesty Lead Judge
2 months ago
inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BaseGauge::_updateReward calculates rewards before updating rewardPerTokenPaid, allowing new stakers to instantly claim accumulated rewards as if they had staked since contract deployment

Support

FAQs

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