Core Contracts

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

Reward Rate Overwritten on Multiple Notifications, Leading to Incorrect Distribution

Summary

The notifyRewardAmount function in the BaseGauge contract overwrites the rewardRate when called multiple times within the same period. This results in a loss of previously allocated rewards, leading to incorrect reward distribution.

Vulnerability Details

Issue: Reward Rate Overwrite

The function notifyRewardAmount calculates rewardRate by dividing the notified amount by the period duration. However, each call to notifyRewardAmount replaces the previous rewardRate instead of accumulating it. Consequently, when notifyRewardAmount is called multiple times within the same period, the last call effectively erases any previous rewards that were set.

Code Reference:

rewardRate = notifyReward(periodState, amount, periodState.emission, getPeriodDuration());

This line of code updates rewardRate with the newly calculated value without considering any prior reward rates.

Example Scenario:

  1. Call notifyRewardAmount(500 ether) at the beginning of the period.

  2. Call notifyRewardAmount(500 ether) after three days.

  3. The second call overwrites rewardRate, leading to only 500 ether being distributed instead of the expected 1000 ether.

If the contract instead accumulates the reward rate instead of overwriting it, the full 1000 ether reward would be distributed correctly.

Poc

run in BaseGauge.test.js

  • poc

it(" should distribute full reward", async () => {
let nextPeriodStart = await baseGauge.getCurrentPeriodStart();
let t = await time.latest();
await time.increase(nextPeriodStart- BigInt(t));
await baseGauge.setEmission(ethers.parseEther("1000"));
// Setup rewards and voting
await gaugeController.connect(user1).vote(await baseGauge.getAddress(), 5000);
// Stake some tokens to gauge to be eligible for rewards
await rewardToken.mint(user1.address, ethers.parseEther("1000"));
await rewardToken.connect(user1).approve(await baseGauge.getAddress(), ethers.parseEther("1000"));
await baseGauge.connect(user1).stake(ethers.parseEther("1000"));
await baseGauge.notifyRewardAmount(ethers.parseEther("1000"));
// Wait for rewards to accrue for 7 Days
await time.increase(DAY * 7);
let before = await rewardToken.balanceOf(user1.address);
// user claim reward for the whole period
await baseGauge.connect(user1).getReward();
let after = await rewardToken.balanceOf(user1.address);
const FirstPeriodReward = after-before;
console.log('Reward get in the first period : ',FirstPeriodReward);
});
it(" should distribute less reward", async () => {
let nextPeriodStart = await baseGauge.getCurrentPeriodStart();
let t = await time.latest();
await time.increase(nextPeriodStart- BigInt(t));
await baseGauge.setEmission(ethers.parseEther("1000"));
// Setup rewards and voting
await gaugeController.connect(user1).vote(await baseGauge.getAddress(), 5000);
// Stake some tokens to gauge to be eligible for rewards
await rewardToken.mint(user1.address, ethers.parseEther("1000"));
await rewardToken.connect(user1).approve(await baseGauge.getAddress(), ethers.parseEther("1000"));
await baseGauge.connect(user1).stake(ethers.parseEther("1000"));
// first 500 rewards are notified and will be distibuted over 7 days
await baseGauge.notifyRewardAmount(ethers.parseEther("500"));
await time.increase(DAY * 4);
// 500 rewards are notified and should be distibuted over 3 days but will be distributed in 7 days
await baseGauge.notifyRewardAmount(ethers.parseEther("500"));
// Wait for rewards to accrue for 7 Days
await time.increase(DAY * 3);
let before = await rewardToken.balanceOf(user1.address);
// user claim reward for the whole period
await baseGauge.connect(user1).getReward();
let after = await rewardToken.balanceOf(user1.address);
const FirstPeriodReward = after-before;
console.log('Reward get in the first period : ',FirstPeriodReward);
});

Explanation of output

The first test will log that user clain 9999 reward while the second test will log that the user claim 4999

In the second test user get the half of the first test because rewardRate are overwrite

first call to notifyReward with 500 : rewardRate = 500 / 7
second call to notifyReward with 500 : rewardRate = 500 / 7
so for the second period the protocol consider only 500 rerward
normally :
- first call => rewardRate = 500 / 7
- second call => rewardRate = 500 / 7 + 500 / 7 = 1000 / 7

Impact

  • Users receive fewer rewards than expected when notifyRewardAmount is called multiple times within the same period.

  • Inconsistent behavior where users receive the full reward if notified once, but only partial rewards if notified multiple times.

  • Potential financial loss for users due to incorrect reward distribution.

Tools Used

  • Manual code review

  • Hardhat testing framework

  • Ethers.js for interaction and simulation

Recommendations

Solution: Accumulate rewardRate

Instead of overwriting rewardRate, it should be incremented to account for multiple reward notifications within the same period.

Proposed Fix:

Modify the notifyRewardAmount function to accumulate the reward rate:

rewardRate += notifyReward(periodState, amount, periodState.emission, getPeriodDuration());

This ensures that rewards are correctly distributed based on the sum of all notifyRewardAmount calls within the same period, preventing reward loss and ensuring fair distribution.

Updates

Lead Judging Commences

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

BaseGauge's notifyRewardAmount overwrites reward rates without accounting for undistributed rewards, allowing attackers to reset admin-distributed rewards

Support

FAQs

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