Core Contracts

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

Incorrect reward rate calculation in BaseGauge leads to permanent loss of undistributed rewards

Description

The BaseGauge::notifyReward function calculates new reward rates without accounting for remaining rewards from previous periods. This results in permanent loss of any undistributed rewards when new rewards are added, as the contract overrides the previous rate without carrying forward residual amounts.

Morever, in GaugeController::distributeRewards, anyone can call the function to distribute rewards to the gauges, making this scenario even more likely to happen.

Proof of Concept

  1. Admin notifies 1000 tokens for 7-day distribution (142.85 tokens/day rate)

  2. After 3 days: 428.55 tokens distributed, 571.45 remain in contract

  3. Admin notifies another 1000 tokens

  4. New rate becomes (1000 / 7) = 142.85 tokens/day, ignoring 571.45 remaining tokens

  5. Total distributable becomes 1000 new + 571.45 old = 1571.45, but actual distribution will only be 1000 tokens

Add this test to BaseGauge.test.js:

it("loses leftover rewards when notifying new amount", async () => {
// Initial setup
await baseGauge.setEmission(ethers.parseEther("2000"));
// First reward notification
await baseGauge.notifyRewardAmount(ethers.parseEther("1000"));
const initialRate = await baseGauge.rewardRate();
// Simulate 3 days of distribution
await time.increase(3 * DAY);
// Second reward notification - should carry over remaining 1000 - (142.85 * 3) = 571.45
await baseGauge.notifyRewardAmount(ethers.parseEther("1000"));
// Verify new rate doesn't include leftover
const newRate = await baseGauge.rewardRate();
const expectedRate = ethers.parseEther("1000") / (7n * BigInt(DAY));
expect(newRate).to.equal(expectedRate); // Fails as leftover not included
// Total distributed would be 1000 + 1000 = 2000, but 571.45 remains locked
});

Impact

High severity - Direct loss of protocol-controlled value. Undistributed rewards become permanently stuck in the contract, violating core protocol functionality and reward distribution guarantees.

Recommendation

  • Carry Forward Residual Rewards:

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) {
+ uint256 residual = (periodDuration - (block.timestamp - state.periodStartTime)) * rewardRate;
+ uint256 totalAmount = amount + residual;
+ if (totalAmount + state.distributed > state.emission) {
revert RewardCapExceeded();
}
- uint256 rewardRate = amount / periodDuration;
+ uint256 rewardRate = totalAmount / periodDuration;
if (rewardRate == 0) revert ZeroRewardRate();
return rewardRate;
}
  • Sweep Functionality:
    Implement emergency function to recover stuck rewards after sufficient time:

function recoverResidualRewards() external onlyRole(DEFAULT_ADMIN_ROLE) {
if (block.timestamp < periodState.periodStartTime + 2 * getPeriodDuration())
revert TooEarly();
rewardToken.safeTransfer(msg.sender, rewardToken.balanceOf(address(this)));
}
Updates

Lead Judging Commences

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

Give us feedback!