Core Contracts

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

Incorrect rewardRate management in BaseGauge

Summary

The BaseGauge contract operates on a period-based distribution where rewards accrue linearly. However, there is a mismanagement issue with how rewards are calculated and distributed within a given period, which can lead to inconsistent reward emissions.

Issue Details

Period-Based Emission

The contract defines a period with a start timestamp and an end time, during which emissions occur linearly. When rewards are sent using notifyRewardAmount, they are subject to an emission cap:

function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
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);
}

The function notifyReward is used to calculate the reward rate:

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;
if (rewardRate == 0) revert ZeroRewardRate();
return rewardRate;
}

The periodDuration function

function getPeriodDuration() public view virtual returns (uint256) {
return 7 days; // Default period duration, can be overridden by child contracts
}

Incorrect Reward Rate Calculation

The contract assumes that every reward distribution spans the entire periodDuration (typically 7 days). This creates an issue when additional rewards are added mid-period:

Scenario:

  1. Assume the period has an emission cap of 20,000.

  2. The controller calls notifyRewardAmount(10,000), setting the rewardRate as:

    rewardRate = 10,000 / 7 days

  3. After 3 days, the controller adds another reward of 10,000, assuming it will be distributed over the remaining 4 days.

  4. However, the rewardRate is recalculated as:

    rewardRate = 10,000 / 7 days, instead of 10,000 / 4 days.

  5. This leads to a misalignment between the intended and actual reward distributions.

The period is set to end in 4 days, but the newly added reward is incorrectly scheduled to be distributed over 7 days. As a result, the portion of the reward intended for the last 3 days (10,000 / 3) will not be properly allocated.

Poc

run in BaseGauge.test.js

In the second case where reward are split user get less than the first case

To clearly see the issue we should fix another issue in the BaseGauge contract

function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
// rewardRate is increment instead of overwrite
rewardRate += notifyReward(periodState, amount, periodState.emission, getPeriodDuration());
periodState.distributed += amount;
uint256 balance = rewardToken.balanceOf(address(this));
if (rewardRate * getPeriodDuration() > balance) { // should also be corrected
revert InsufficientRewardBalance();
}
lastUpdateTime = block.timestamp;
emit RewardNotified(amount);
}
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

In the second test, the user receives less reward than in the first test because the rewardRate is incorrectly calculated and split over 7 days in both calls to notifyReward. Here's a more explicit explanation:

  1. First call to notifyReward with 500:

    • The rewardRate is calculated as 500 / 7, which means the reward is distributed evenly over 7 days.

    • So, rewardRate = 500 / 7 ≈ 71.428 per day.

  2. Second call to notifyReward with 500:

    • The rewardRate is incorrectly updated by adding 500 / 7 again, instead of considering the remaining duration (which is now 3 days).

    • So, rewardRate += 500 / 7 ≈ 71.428, resulting in a total rewardRate ≈ 142.856 per day.

  3. Accumulated reward calculation:

    • For the first 4 days, the user earns 4 * (500 / 7) = 284.

    • For the next 3 days, the user earns 3 * (500 / 7) + 3 * (500 / 7)≈ 426.

    • The total reward becomes 500 + 426 ≈ 710, which is less than the expected amount.

Correct Approach:

The rewardRate should be adjusted based on the remaining duration when the second reward is added. Here's how it should work:

  1. First call to notifyReward with 500:

    • rewardRate = 500 / 7 ≈ 71.428 per day.

  2. Second call to notifyReward with 500:

    • The remaining duration is 3 days, so the rewardRate for the new reward should be 500 / 3 ≈ 166.666 per day.

    • The total rewardRate should now be 71.428 + 166.666 ≈ 238.094 per day.

  3. Accumulated reward calculation:

    • For the first 7 days, the user earns 4 * (500 / 7) = 284.

    • For the next 3 days, the user earns 3 * (500 / 3) + 3 * (500 / 7)≈ 713.

    • The total reward becomes 284 + 713 ≈ 997, which is the correct amount.

Impact

Unfair Reward Distribution: claimers may receive less rewards.

Recommendation

Modify the notifyReward function to correctly account for the remaining period duration instead of assuming a fixed periodDuration.

Fix: Use Remaining Period Duration

Instead of using getPeriodDuration(), compute the actual remaining period dynamically:

uint256 remainingTime = periodState.endTime - block.timestamp;
uint256 rewardRate = amount / remainingTime;

This ensures that rewards added mid-period are distributed over the correct duration, avoiding misalignment.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

BaseGauge::notifyRewardAmount increments periodState.distributed with full amount regardless of actual distribution timing, causing reward accounting discrepancies

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

BaseGauge::notifyRewardAmount increments periodState.distributed with full amount regardless of actual distribution timing, causing reward accounting discrepancies

Support

FAQs

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