Core Contracts

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

Missing Distribution Cap Check in notifyRewardAmount: Unrestricted Reward Distribution Risk

Summary

The notifyRewardAmount function in the BaseGauge contract is responsible for updating the reward rate and tracking the total distributed rewards for a given period. When the function is called, it verifies that the new reward amount does not exceed the period’s maximum emission value. However, it lacks a critical check to ensure that the cumulative rewards distributed (i.e., periodState.distributed plus the new amount) do not exceed a predefined distributionCap.

Without this additional check, reward distribution can inadvertently exceed the intended cap, potentially depleting the reward pool or distorting the economic incentives of the protocol. This oversight may lead to a scenario where more rewards are allocated than intended, undermining the reward distribution mechanism, causing economic imbalance, and ultimately affecting governance and participant incentives.

Vulnerability Details

Function Analysis

notifyRewardAmount Function

The function is defined as follows:

function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
// @info: missing distributionCap limit exceed check
// here should have a check like:
// if (periodState.distributed + amount > distributionCap) revert RewardDistributionCapExceeded(distributionCap);
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);
}

Key Points:

  • The function first checks that the incoming reward amount does not exceed periodState.emission.

  • Missing Check:
    There is no condition verifying that periodState.distributed + amount does not exceed a pre-defined distributionCap. The inline comment indicates that such a check should be in place:

    // if (periodState.distributed + amount > distributionCap) revert RewardDistributionCapExceeded(distributionCap);
  • After calculating the reward rate and updating periodState.distributed, the function ensures that the computed reward rate multiplied by the period duration does not exceed the contract’s reward token balance.

notifyReward Function

This helper function also performs reward cap validations:

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;
}

While it checks that amount + state.distributed does not exceed state.emission, it does not enforce an external distribution cap (distributionCap), which may be designed as an upper limit on rewards distributed over a period.

Consequences

  • Over-Distribution Risk:
    Without a check against distributionCap, the contract might distribute rewards beyond the intended maximum for a given period, potentially depleting the reward token pool.

  • Economic Imbalance:
    Excessive reward distribution can disrupt the tokenomics of the protocol, leading to unintended inflation or misaligned incentives.

  • Governance Impact:
    Since reward rates can influence participation in governance decisions, an incorrect reward allocation mechanism could distort voting power and governance outcomes.

Proof of Concept

Scenario Walkthrough

  1. Intended Behavior:
    The protocol should ensure that in any given period, the total distributed rewards (stored in periodState.distributed) plus the new reward amount does not exceed a predefined distributionCap.

  2. Exploit Scenario:
    Suppose the protocol’s intended distribution cap is 300,000 tokens per period. If periodState.distributed is 0 tokens and an admin(controller) calls notifyRewardAmount with an amount of 400,000 tokens, the expected behavior is to the should pass due to bug (missing conditional check)

  3. Test Case Example:

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.19;
    import {Test, console} from "forge-std/Test.sol";
    import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    import {BaseGauge} from "../src/core/governance/gauges/BaseGauge.sol";
    import {GaugeController} from "../src/core/governance/gauges/GaugeController.sol";
    import {RAACGauge} from "../src/core/governance/gauges/RAACGauge.sol";
    import {RWAGauge} from "../src/core/governance/gauges/RWAGauge.sol";
    import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
    import {RAACToken} from "../src/core/tokens/RAACToken.sol";
    import {RewardTokenMock} from "./mocks/RewardTokenMock.m.sol";
    import {StakingTokenMock} from "./mocks/StakingTokenMock.m.sol";
    import {IGaugeController} from "../src/interfaces/core/governance/gauges/IGaugeController.sol";
    import {IGauge} from "../src/interfaces/core/governance/gauges/IGauge.sol";
    contract GaugeTest is Test {
    GaugeController gaugeController;
    BaseGauge baseGauge;
    RAACGauge raacGauge;
    RWAGauge rwaGauge;
    veRAACToken veToken;
    RAACToken raacToken;
    RewardTokenMock rewardToken;
    StakingTokenMock stakingToken;
    address GAUGE_CONTROLLER_OWNER = makeAddr("GAUGE_CONTROLLER_OWNER");
    address RAAC_GAUGE_OWNER = makeAddr("RAAC_GAUGE_OWNER");
    address RWA_GAUGE_OWNER = makeAddr("RWA_GAUGE_OWNER");
    address RAAC_OWNER = makeAddr("RAAC_OWNER");
    address RAAC_MINTER = makeAddr("RAAC_MINTER");
    uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
    uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
    uint256 MAX_LOCK_AMOUNT = 10_000_000e18;
    uint256 MAX_LOCK_DURATION = 1460 days;
    address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
    address ALICE = makeAddr("ALICE");
    address BOB = makeAddr("BOB");
    address CHARLIE = makeAddr("CHARLIE");
    address DEVIL = makeAddr("DEVIL");
    function setUp() public {
    vm.startPrank(RAAC_OWNER);
    raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
    vm.stopPrank();
    vm.startPrank(VE_RAAC_OWNER);
    veToken = new veRAACToken(address(raacToken));
    vm.stopPrank();
    rewardToken = new RewardTokenMock();
    stakingToken = new StakingTokenMock();
    vm.startPrank(GAUGE_CONTROLLER_OWNER);
    gaugeController = new GaugeController(address(veToken));
    vm.stopPrank();
    vm.startPrank(RAAC_GAUGE_OWNER);
    raacGauge = new RAACGauge(address(rewardToken), address(stakingToken), address(gaugeController));
    vm.stopPrank();
    vm.startPrank(RWA_GAUGE_OWNER);
    rwaGauge = new RWAGauge(address(rewardToken), address(stakingToken), address(gaugeController));
    vm.stopPrank();
    }
    function testNotifyRewardAmountExceedsDistributionCap() public {
    address FEE_ADMIN_ADDRESS = makeAddr("FEE_ADMIN_ADDRESS");
    vm.startPrank(RWA_GAUGE_OWNER);
    rwaGauge.grantRole(rwaGauge.FEE_ADMIN(), FEE_ADMIN_ADDRESS);
    vm.stopPrank();
    // Set up period state for testing (simulate already distributed rewards)
    // Assume periodState.emission is set high enough and periodState.distributed is near cap.
    vm.startPrank(FEE_ADMIN_ADDRESS);
    rwaGauge.setDistributionCap(300_000e18);
    vm.stopPrank();
    vm.startPrank(GAUGE_CONTROLLER_OWNER);
    gaugeController.addGauge(address(rwaGauge), IGaugeController.GaugeType.RWA, 0);
    vm.stopPrank();
    rewardToken.mint(address(rwaGauge), 500_000e18);
    // Attempt to notify reward amount that exceeds the distributionCap.
    uint256 rewardAmount = 400_000e18;
    vm.startPrank(address(gaugeController));
    rwaGauge.notifyRewardAmount(rewardAmount);
    vm.stopPrank();
    }
    }

How to Run the Test

  1. Step 1: Create a Foundry project:

    forge init my-foundry-project
  2. Step 2: Remove unnecessary files.

  3. Step 3: Place all relevant contract files (including BaseGauge, RewardTokenMock, etc.) in the src directory.

  4. Step 4: Create a test directory adjacent to src and add the test file (e.g., GaugeRewardTest.t.sol).

  5. Step 5: Run the test:

    forge test --mt testNotifyRewardAmountExceedsDistributionCap -vv
  6. Expected Outcome:
    The test should pass when the cumulative reward distribution would exceed distributionCap.

Impact

  • Reward Distribution Errors:
    Without a proper distribution cap check, rewards can be over-distributed beyond the intended limit, leading to depletion of the reward pool and unfair tokenomics.

  • Economic Imbalance:
    Excessive rewards may cause unanticipated inflation, misaligning incentives and destabilizing the protocol's economic model.

  • Governance and Participation Distortion:
    Since rewards and governance power are intertwined, excessive rewards could distort voting power and influence governance outcomes.

  • System Integrity:
    Over time, these issues can erode user trust, reduce participation, and make the protocol vulnerable to economic attacks or manipulation.

Tools Used

  • Manual Review

  • Foundry

Recommendations

To remediate this vulnerability, update the notifyRewardAmount function to include an additional check that prevents the cumulative distributed rewards from exceeding a defined distributionCap.

Proposed Diff for notifyRewardAmount

function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
+ // Check that adding the new reward amount does not exceed the distribution cap
+ if (periodState.distributed + amount > distributionCap) {
+ revert RewardDistributionCapExceeded(distributionCap);
+ }
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);
}
Updates

Lead Judging Commences

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

BaseGauge lacks enforcement of both distributionCap and MAX_REWARD_RATE limits

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

BaseGauge lacks enforcement of both distributionCap and MAX_REWARD_RATE limits

Support

FAQs

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

Give us feedback!