Core Contracts

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

Multiple calls to `BaseGauge::notifyRewardAmount()` override existing reward rate, causing loss of rewards for stakers

Summary

The BaseGauge::notifyRewardAmount() function can be called multiple times through GaugeController.distributeRewards(), which overrides the existing reward rate. This resets the reward distribution, causing loss of unclaimed rewards that were being distributed at the previous rate.

Vulnerability Details

The notifyRewardAmount() function simply overwrites the rewardRate state variable without accounting for existing undistributed rewards:

function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
// Overwrites existing rewardRate without accounting for undistributed rewards
@> rewardRate = notifyReward(periodState, amount, periodState.emission, getPeriodDuration());
periodState.distributed += amount;
uint256 balance = rewardToken.balanceOf(address(this));
if (rewardRate * getPeriodDuration() > balance) {
revert InsufficientRewardBalance();
}
lastUpdateTime = block.timestamp;
emit RewardNotified(amount);
}
function notifyReward(
PeriodState storage state,
uint256 amount,
uint256 maxEmission,
uint256 periodDuration
) internal view returns (uint256) {
if (amount > maxEmission) revert RewardCapExceeded();
if (amount + state.distributed > state.emission) {
revert RewardCapExceeded();
}
@> uint256 rewardRate = amount / periodDuration; // New reward rate is calculated based on the new amount and period duration
if (rewardRate == 0) revert ZeroRewardRate();
@> return rewardRate;
}

When called multiple times:

  1. The existing rewardRate is overwritten with the new rate

  2. lastUpdateTime is reset to current timestamp

  3. Previous unclaimed rewards that were being distributed at the old rate are effectively lost

This means that if rewards were being distributed at rate X and a new call sets rate Y, users who haven't claimed their rewards from rate X period will lose them.

Impact

Users lose unclaimed rewards when the reward rate is overridden by subsequent calls to notifyRewardAmount(). This directly leads to loss of funds for stakers who haven't claimed their rewards yet.

Tools Used

Manual review

Proof of Concept

First a bug in the BaseGauge::constructor() function need to be fixed, as this cause overflow in the calculations due to a incorrect assignment of boostState.minBoost:

- boostState.minBoost = 1e18;
+ boostState.minBoost = 10000;

Add the following test case to the test/unit/core/governance/gauges/GaugeController.test.js file:

it("loss of rewards on multiple notify calls", async () => {
// mint veRAAC token to user to be able to vote
await veRAACToken.mint(user1.address, ethers.parseEther("2000"));
await veRAACToken.connect(user1).approve(rwaGauge.target, ethers.parseEther("1000"));
await rwaGauge.connect(user1).stake(ethers.parseEther("1000"));
// vote for rwa gauge
await gaugeController.connect(user1).vote(rwaGauge.target, 5000);
const initialPeriodState = await rwaGauge.periodState();
// No reward distribution yet
expect(initialPeriodState.distributed).to.be.eq(0);
// mint reward token to gauge to be able to distribute rewards
const rewardsToDistribute = ethers.parseEther("500000");
await rewardToken.mint(rwaGauge.target, rewardsToDistribute);
// First distribution
await gaugeController.distributeRewards(rwaGauge.target);
const firstPeriodState = await rwaGauge.periodState();
const generatedRewards = firstPeriodState.distributed;
expect(generatedRewards).to.be.equal(rewardsToDistribute);
const rewardRate = await rwaGauge.rewardRate();
// advance time one week
await time.increase(WEEK);
const earned = await rwaGauge.earned(user1.address);
// Second distribution
// Set a lower weight for the gauge
await gaugeController.connect(user1).vote(rwaGauge.target, 10);
await gaugeController.connect(user1).vote(raacGauge.target, 5000);
await gaugeController.distributeRewards(rwaGauge.target);
const newRewardRate = await rwaGauge.rewardRate();
const newEarned = await rwaGauge.earned(user1.address);
// User lost unclaimed rewards
expect(newEarned).to.be.lt(earned);
expect(newRewardRate).to.be.lt(rewardRate);
});

Recommendations

Track and account for existing undistributed rewards when updating the reward rate:

function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
+ uint256 remainingRewards = 0;
+ if (block.timestamp < periodFinish()) {
+ remainingRewards = periodFinish() - block.timestamp * rewardRate;
+ }
+ // Add remaining rewards to new amount
+ uint256 totalRewards = amount + remainingRewards;
- rewardRate = notifyReward(periodState, amount, periodState.emission, getPeriodDuration());
+ rewardRate = notifyReward(periodState, totalRewards, periodState.emission, getPeriodDuration());
periodState.distributed += amount;
uint256 balance = rewardToken.balanceOf(address(this));
if (rewardRate * getPeriodDuration() > balance) {
revert InsufficientRewardBalance();
}
lastUpdateTime = block.timestamp;
emit RewardNotified(amount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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

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