Core Contracts

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

Incorrect reward calculation in BaseGauge::_updateReward leads to reward dilution and/or a complete loss of rewards

Summary

The getRewardPerToken function in the reward distribution mechanism calculates rewards based on the time elapsed since the last update (lastTimeRewardApplicable() - lastUpdateTime). If no time has passed (e.g., block.timestamp is equal to lastUpdateTime), the rewards calculated will be zero. This results in no rewards being distributed to users, even if they have staked tokens and are eligible for rewards.

This also allows users who claim rewards earlier to receive disproportionately more rewards, even if they have staked fewer tokens. This occurs due to the way rewardPerTokenStored is updated when a user calls getReward(). As a result, users who claim rewards later receive fewer rewards, leading to an unfair distribution of rewards.

Vulnerability Details

The issue arises because the getRewardPerToken function relies on the time elapsed since the last update to calculate rewards. Specifically:

function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return
rewardPerTokenStored +
(((lastTimeRewardApplicable() - lastUpdateTime) *
rewardRate *
1e18) / totalSupply());
}

lastTimeRewardApplicable(): Returns the current timestamp (block.timestamp) if it is less than the periodFinish time, otherwise returns periodFinish.

lastUpdateTime: The timestamp of the last reward update.

If block.timestamp is equal to lastUpdateTime (i.e., no time has passed since the last update), the term (lastTimeRewardApplicable() - lastUpdateTime) becomes zero. As a result, the reward calculation returns rewardPerTokenStored, and no additional rewards are accrued. This is exactly what happens when raacGauge::notifyRewardAmount is called. The lastUpdateTime is updated to the current timestamp.

Another issue arises because the getReward() function updates the rewardPerTokenStored value for the calling user, but this update affects the reward calculations for all users. Specifically:

getReward() Function:

When a user calls getReward(), the _updateReward function is invoked, which updates the rewardPerTokenStored value.
This update is based on the current time and the rewardRate, and it affects the reward calculations for all users. As a result, if user1 calls getReward() before user2, the rewardPerTokenStored value is updated to reflect the rewards accrued up to that point.

When user2 later calls getReward(), the rewardPerTokenStored value has already been updated, and user2 receives fewer rewards than they should, even if they have staked more tokens.

Example Scenario:

user1 stakes 100 tokens.
user2 stakes 200 tokens.

Rewards are notified, and time passes.
user1 calls getReward() first, updating rewardPerTokenStored.
user2 calls getReward() later and receives fewer rewards than they should, even though they have staked more tokens.

Proof Of Code (POC)

These tests were run in the RAACGauge.test.js file in the "Reward Distribution" describe block

describe("Reward Distribution", () => {
beforeEach(async () => {
await veRAACToken
.connect(user1)
.approve(raacGauge.getAddress(), ethers.MaxUint256);
await veRAACToken
.connect(user2)
.approve(raacGauge.getAddress(), ethers.MaxUint256);
await raacGauge.connect(user1).stake(ethers.parseEther("100"));
await raacGauge.connect(user2).stake(ethers.parseEther("200"));
await raacGauge.notifyRewardAmount(ethers.parseEther("1000"));
await raacGauge.connect(user1).voteEmissionDirection(5000);
});
it("if rewards are claimed in same block as notifyRewardAmount, users get no rewards even though they have staked amounts and are eligible", async () => {
//c for testing purposes
//await time.increase(WEEK / 2);
const balanceprerewarddistribution = await rewardToken.balanceOf(
user1.address
);
console.log(
"balance of user1 before reward distribution",
balanceprerewarddistribution.toString()
);
const balanceprerewarddistribution2 = await rewardToken.balanceOf(
user2.address
);
console.log(
"balance of user2 before reward distribution",
balanceprerewarddistribution2.toString()
);
const earnedUser1 = await raacGauge.earned(user1.address);
console.log(earnedUser1.toString());
const earnedUser2 = await raacGauge.earned(user2.address);
console.log(earnedUser2.toString());
await raacGauge.connect(user1).getReward();
await raacGauge.connect(user2).getReward();
const balance = await rewardToken.balanceOf(user1.address);
console.log("balance of user1", balance.toString());
const balance2 = await rewardToken.balanceOf(user2.address);
console.log("balance of user2", balance2.toString());
//c confirm that there are rewards to be distributed
const weeklyState = await raacGauge.periodState();
const distributed = weeklyState.distributed;
console.log("distributed", distributed.toString());
assert(distributed > 0);
//c there are actually no rewards distributed because the lastUpdateTime and lastTimeRewardApplicable are the same which shouldnt matter but it does in this protocol
assert(balanceprerewarddistribution == balance);
assert(balanceprerewarddistribution2 == balance2);
assert(earnedUser1 == 0);
assert(earnedUser2 == 0);
});
it("user1 gets more rewards than user2 simply based on the claim time even when user2 has a higher staked balance", async () => {
//c for testing purposes
await time.increase(WEEK / 2);
const earnedUser1 = await raacGauge.earned(user1.address);
console.log(earnedUser1.toString());
const earnedUser2 = await raacGauge.earned(user2.address);
console.log(earnedUser2.toString());
await raacGauge.connect(user1).getReward();
await raacGauge.connect(user2).getReward();
const balance = await rewardToken.balanceOf(user1.address);
console.log("balance of user1", balance.toString());
const balance2 = await rewardToken.balanceOf(user2.address);
console.log("balance of user2", balance2.toString());
//c confirm that there are rewards to be distributed
const weeklyState = await raacGauge.periodState();
const distributed = weeklyState.distributed;
console.log("distributed", distributed.toString());
assert(distributed > 0);
//c user1 gets more rewards than user2 simply based on the claim time even when user2 has a higher staked balance
assert(balance > balance2);
});

These are the logs from each test:

RAACGauge
Reward Distribution
balance of user1 before reward distribution 1000000000000000000000
balance of user2 before reward distribution 1000000000000000000000
0
0
balance of user1 1000000000000000000000
balance of user2 1000000000000000000000
distributed 1000000000000000000000
if rewards are claimed in same block as notifyRewardAmount, users get no rewards even though they have staked amounts and are eligible (170ms)
1 passing (12s)
RAACGauge
Reward Distribution
31666666
21666666
balance of user1 1000000000000031666666
balance of user2 1000000000000021666666
distributed 1000000000000000000000
✔ user1 gets more rewards than user2 simply based on the claim time even when user2 has a higher staked balance (208ms)
1 passing (12s)

Impact

Unfair Reward Distribution: Users who claim rewards earlier receive disproportionately more rewards, even if they have staked fewer tokens. This undermines the fairness of the protocol and discourages users from participating. It also creates incentives to game the system and can create gas wars to be first to call raacGauge::getReward after an amount of time has elapsed. A user can also unfairly dilute another user's rewards by front running their raacGauge::getReward call.

Economic Inefficiency: The protocol's reward distribution mechanism becomes inefficient, as it does not accurately reflect the contributions of users based on their staked amounts.

Tools Used

Manual Review, Hardhat

Recommendations

To fix this issue, the reward distribution mechanism should be modified to ensure that rewards are distributed fairly based on the staked amounts of users, regardless of when they claim their rewards. This can be achieved by:

Separate Reward Tracking

Track rewards for each user separately, without updating the global rewardPerTokenStored value when a user claims rewards.
This ensures that the reward calculations for one user do not affect the reward calculations for other users.

Ensuring that the earned function calculates rewards based on the user's staked amount and the time elapsed since the last update, without being affected by other users' actions.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BaseGauge reward system can be gamed through repeated stake/withdraw cycles without minimum staking periods, allowing users to earn disproportionate rewards vs long-term stakers

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BaseGauge reward system can be gamed through repeated stake/withdraw cycles without minimum staking periods, allowing users to earn disproportionate rewards vs long-term stakers

Support

FAQs

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

Give us feedback!