Core Contracts

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

GaugeController and BaseGauge UpdatePeriod Functions Fail to Update Intermediate Period States: Critical Information Loss

Summary

The GaugeController contract manages gauge reward distribution by tracking time‐weighted metrics using a period state (via the TimeWeightedAverage library). When a new gauge is added (using addGauge), an initial period is created. Later, the updatePeriod function is intended to update intermediate state—adjusting the time‐weighted values continuously—and only roll over to a new period when the current period has truly ended.

However, due to an omission in the implementation, the updatePeriod function in GaugeController reverts with a PeriodNotElapsed error when a call is made within the current period window. This prevents any intermediate updates. A similar issue exists in several functions in the BaseGauge contract (e.g., _updateWeights and its own updatePeriod), where instead of updating the gauge’s current weight during an active period, the functions simply create a new period only after the window expires. This behavior leads to stale gauge state, improper reward calculation, and ultimately may cause a denial-of-service (DoS) for gauge updates.

Vulnerability Details

In GaugeController

  • addGauge Initialization:
    When adding a gauge, the contract creates the initial period using:

    TimeWeightedAverage.createPeriod(
    period,
    block.timestamp, // Start time
    duration,
    periodWeight, // Calculated as: (initialWeight == 0 ? 1 : initialWeight)
    periodWeight
    );

    Note: The period is created with a fixed totalDuration equal to duration (which is a bug in itself), but the focus here is on the update mechanism.

  • updatePeriod Function:
    The current implementation in GaugeController is:

    function updatePeriod(address gauge) external override whenNotPaused {
    Gauge storage g = gauges[gauge];
    if (!g.isActive) revert GaugeNotActive();
    TimeWeightedAverage.Period storage period = gaugePeriods[gauge];
    uint256 duration = g.gaugeType == GaugeType.RWA ? 30 days : 7 days;
    // If this is the first period, initialize it
    if (period.startTime == 0) {
    TimeWeightedAverage.createPeriod(
    period,
    block.timestamp + 1, // add 1 sec to avoid collision
    duration,
    0,
    g.weight
    );
    emit PeriodRolled(gauge, block.timestamp, g.weight);
    return;
    }
    // Check if current period has elapsed
    if (block.timestamp < period.startTime + period.totalDuration) {
    revert PeriodNotElapsed();
    }
    uint256 average = TimeWeightedAverage.calculateAverage(period, block.timestamp);
    // Roll over to new time period if duration has elapsed
    TimeWeightedAverage.createPeriod(
    period,
    block.timestamp + 1, // add 1 sec to avoid collision
    duration,
    average,
    g.weight
    );
    emit PeriodRolled(gauge, block.timestamp, g.weight);
    }

    Issue:
    The function reverts if the current timestamp is less than period.startTime + period.totalDuration. Thus, any attempt to update intermediate state (i.e., before the period ends) fails, leaving gauge state static.

In BaseGauge

BaseGauge also includes functions to update gauge weight and period state:

  • _updateWeights Function:

    function _updateWeights(uint256 newWeight) internal {
    uint256 currentTime = block.timestamp;
    uint256 duration = getPeriodDuration();
    if (weightPeriod.startTime == 0) {
    // For initial period, start from next period boundary
    uint256 nextPeriodStart = ((currentTime / duration) + 1) * duration;
    TimeWeightedAverage.createPeriod(weightPeriod, nextPeriodStart, duration, newWeight, WEIGHT_PRECISION);
    } else {
    // For subsequent periods, force new period creation
    // @danger: this creates a DoS if update weight requests occur within the current period window.
    uint256 nextPeriodStart = ((currentTime / duration) + 1) * duration;
    TimeWeightedAverage.createPeriod(weightPeriod, nextPeriodStart, duration, newWeight, WEIGHT_PRECISION);
    }
    }

    Issue:
    This function always creates a new period rather than updating the current period’s state if an update occurs within the active window, resulting in a denial of service until the current period expires.

  • updatePeriod Function in BaseGauge:

    function updatePeriod() external override onlyController {
    uint256 currentTime = block.timestamp;
    uint256 periodEnd = periodState.periodStartTime + getPeriodDuration();
    // Only creates new period, overwriting current period – DoS if window not expired
    if (currentTime < periodEnd) {
    revert PeriodNotElapsed();
    }
    uint256 periodDuration = getPeriodDuration();
    uint256 avgWeight = periodState.votingPeriod.calculateAverage(periodEnd);
    uint256 nextPeriodStart = ((currentTime / periodDuration) + 2) * periodDuration;
    periodState.distributed = 0;
    periodState.periodStartTime = nextPeriodStart;
    TimeWeightedAverage.createPeriod(
    periodState.votingPeriod, nextPeriodStart, periodDuration, avgWeight, WEIGHT_PRECISION
    );
    }

    Issue:
    Like in GaugeController, this function does not allow for intermediate updates and instead forces a complete rollover only after the period ends, potentially causing DoS if weight update requests come in prematurely.

Proof of Concept

Scenario Walkthrough

  1. Gauge Setup:

    • An admin adds a gauge using addGauge, which creates an initial period.

    • A user (ALICE) casts a vote, triggering gauge state updates.

  2. Intermediate Update Attempt:

    • Before the current period expires (i.e., within the period window), ALICE (or another actor) attempts to update the period by calling updatePeriod.

    • The function checks the condition:

      if (block.timestamp < period.startTime + period.totalDuration) { revert PeriodNotElapsed(); }

      and reverts, blocking the update.

  3. Test Cases:
    The following Foundry test suite demonstrates both the GaugeController and BaseGauge issues.

    // 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;
    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%
    uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%
    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(RWA_GAUGE_OWNER);
    rwaGauge = new RWAGauge(address(rewardToken), address(stakingToken), address(gaugeController));
    vm.stopPrank();
    }
    function raacTokenAllotmentAndAcquireVeRaac() private {
    vm.startPrank(RAAC_OWNER);
    raacToken.setMinter(RAAC_MINTER);
    vm.stopPrank();
    vm.startPrank(RAAC_MINTER);
    raacToken.mint(ALICE, MAX_LOCK_AMOUNT);
    raacToken.mint(BOB, MAX_LOCK_AMOUNT);
    raacToken.mint(CHARLIE, MAX_LOCK_AMOUNT);
    raacToken.mint(DEVIL, MAX_LOCK_AMOUNT);
    raacToken.mint(ALICE, MAX_LOCK_AMOUNT);
    vm.stopPrank();
    }
    function raacTokenLock() private {
    vm.startPrank(ALICE);
    raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
    veToken.lock(MAX_LOCK_AMOUNT, MAX_LOCK_DURATION);
    vm.stopPrank();
    vm.startPrank(BOB);
    raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
    veToken.lock(MAX_LOCK_AMOUNT, MAX_LOCK_DURATION);
    vm.stopPrank();
    vm.startPrank(CHARLIE);
    raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
    veToken.lock(MAX_LOCK_AMOUNT, MAX_LOCK_DURATION);
    vm.stopPrank();
    vm.startPrank(DEVIL);
    raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
    veToken.lock(MAX_LOCK_AMOUNT, MAX_LOCK_DURATION);
    vm.stopPrank();
    }
    function testGaugeControllerUpdatePeriodIsInefficient() public {
    raacTokenAllotmentAndAcquireVeRaac();
    raacTokenLock();
    uint256 initialWeight = 0;
    uint256 weight = 100;
    vm.startPrank(GAUGE_CONTROLLER_OWNER);
    gaugeController.addGauge(address(rwaGauge), IGaugeController.GaugeType.RWA, initialWeight);
    vm.stopPrank();
    vm.startPrank(ALICE);
    gaugeController.vote(address(rwaGauge), weight);
    vm.stopPrank();
    vm.warp(block.timestamp + 30 days - 1);
    // Attempt to update period within current interval; should revert.
    vm.startPrank(ALICE);
    vm.expectRevert(bytes4(keccak256("PeriodNotElapsed()")));
    gaugeController.updatePeriod(address(rwaGauge));
    vm.stopPrank();
    }
    function testGaugeControllerUpdatePeriodIssuesEvenAfterTimeWeightedPeriodTotalDurationFix() public {
    raacTokenAllotmentAndAcquireVeRaac();
    raacTokenLock();
    uint256 initialWeight = 0;
    uint256 weight = 100;
    vm.startPrank(GAUGE_CONTROLLER_OWNER);
    gaugeController.addGauge(address(rwaGauge), IGaugeController.GaugeType.RWA, initialWeight);
    vm.stopPrank();
    vm.startPrank(ALICE);
    gaugeController.vote(address(rwaGauge), weight);
    vm.stopPrank();
    vm.warp(block.timestamp + 1);
    // After fixing the totalDuration bug in createPeriod (setting totalDuration to 0), still a bug exists:
    // GaugeController::updatePeriod allows an update even if the current window is not meant to update.
    vm.startPrank(ALICE);
    gaugeController.updatePeriod(address(rwaGauge));
    vm.stopPrank();
    (
    uint256 startTime,
    uint256 endTime,
    uint256 lastUpdateTime,
    uint256 value,
    uint256 weightedSum,
    uint256 totalDuration,
    uint256 periodWeight
    ) = gaugeController.gaugePeriods(address(rwaGauge));
    console.log("timestamp : ", block.timestamp);
    console.log("startTime : ", startTime);
    console.log("endTime : ", endTime);
    console.log("updateT : ", lastUpdateTime);
    console.log("value : ", value);
    console.log("periodWeight: ", periodWeight);
    assertEq(startTime, block.timestamp + 1);
    assertEq(endTime, block.timestamp + 1 + 30 days);
    assertEq(lastUpdateTime, block.timestamp + 1);
    }
    }

How to Run the Test Suite

  1. Step 1: Create a Foundry project:

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

  3. Step 3: Convert your Hardhat project to Foundry by placing contracts in the src directory.

  4. Step 4: Create a test directory adjacent to src and include all necessary contract files and mocks.

  5. Step 5: In the test directory, create a test file (e.g., GaugeTest.t.sol) and paste the above test suite.

  6. Step 6: Run the tests:

    forge test --mt testGaugeControllerUpdatePeriodIsInefficient -vv
    forge test --mt testGaugeControllerUpdatePeriodIssuesEvenAfterTimeWeightedPeriodTotalDurationFix -vv
  7. Expected Output:
    The first test should revert with PeriodNotElapsed() for intermediate updates, and the second test (after fixing the totalDuration bug in createPeriod) will show that the period is updated (rolled over) even if the update window has not truly ended—demonstrating that the mechanism does not support genuine intermediate updates.

Impact

  • Stale Gauge Data:
    Without intermediate period updates, the gauge’s time-weighted state remains unchanged, causing inaccurate reward distribution and misrepresentation of user voting power.

  • Reward Distribution Errors:
    Inaccurate time-weighted averages lead to misallocation of rewards, distorting incentives.

  • Governance and Incentive Misalignment:
    Outdated gauge state may lead to erroneous governance decisions based on stale data.

  • Potential Exploitation:
    Attackers might exploit the lack of dynamic updates by timing their actions to benefit from outdated gauge information.

  • System Instability:
    Overall protocol trust is undermined when gauge states fail to reflect real-time changes in voting power and participation.

Tools Used

  • Manual Review

  • Foundry

Recommendations

To address these vulnerabilities, modify the updatePeriod functions in both GaugeController and BaseGauge contracts to update intermediate period states rather than reverting if the period has not fully elapsed. The new logic should allow updating the current period’s state (e.g., via TimeWeightedAverage.updateValue) and only roll over to a new period when the period’s end time is reached.

Proposed Diff for GaugeController::updatePeriod

function updatePeriod(address gauge) external override whenNotPaused {
Gauge storage g = gauges[gauge];
if (!g.isActive) revert GaugeNotActive();
TimeWeightedAverage.Period storage period = gaugePeriods[gauge];
uint256 duration = g.gaugeType == GaugeType.RWA ? 30 days : 7 days;
// If no period exists, initialize it
if (period.startTime == 0) {
TimeWeightedAverage.createPeriod(
period,
block.timestamp + 1, // add 1 sec to avoid collision
duration,
0,
g.weight
);
emit PeriodRolled(gauge, block.timestamp, g.weight);
return;
}
- // Check if current period has elapsed and revert if not
- if (block.timestamp < period.startTime + period.totalDuration) {
- revert PeriodNotElapsed();
- }
+ // If current period has not yet ended, update the intermediate state instead
+ if (block.timestamp < period.endTime) {
+ uint256 average = TimeWeightedAverage.calculateAverage(period, block.timestamp);
+ TimeWeightedAverage.updateValue(period, average, block.timestamp);
+ emit PeriodUpdated(gauge, block.timestamp, average);
+ return;
+ }
uint256 average = TimeWeightedAverage.calculateAverage(period, block.timestamp);
// Roll over to new period if the current period has ended
TimeWeightedAverage.createPeriod(
period,
block.timestamp + 1, // add 1 sec to avoid collision
duration,
average,
g.weight
);
emit PeriodRolled(gauge, block.timestamp, g.weight);
}

Proposed Diff for BaseGauge Functions

For the BaseGauge contract functions, update _updateWeights and updatePeriod similarly to allow intermediate state updates rather than forcing a full period rollover:

function _updateWeights(uint256 newWeight) internal {
uint256 currentTime = block.timestamp;
uint256 duration = getPeriodDuration();
if (weightPeriod.startTime == 0) {
uint256 nextPeriodStart = ((currentTime / duration) + 1) * duration;
- TimeWeightedAverage.createPeriod(weightPeriod, nextPeriodStart, duration, newWeight, WEIGHT_PRECISION);
+ // Initialize new period at next boundary
+ TimeWeightedAverage.createPeriod(weightPeriod, nextPeriodStart, duration, newWeight, WEIGHT_PRECISION);
} else {
- uint256 nextPeriodStart = ((currentTime / duration) + 1) * duration;
- TimeWeightedAverage.createPeriod(weightPeriod, nextPeriodStart, duration, newWeight, WEIGHT_PRECISION);
+ // Instead of always creating a new period, update intermediate state if within the current period
+ if (currentTime < weightPeriod.endTime) {
+ uint256 avg = TimeWeightedAverage.calculateAverage(weightPeriod, currentTime);
+ TimeWeightedAverage.updateValue(weightPeriod, avg, currentTime);
+ } else {
+ uint256 nextPeriodStart = ((currentTime / duration) + 1) * duration;
+ TimeWeightedAverage.createPeriod(weightPeriod, nextPeriodStart, duration, newWeight, WEIGHT_PRECISION);
+ }
}
}
function updatePeriod() external override onlyController {
uint256 currentTime = block.timestamp;
uint256 periodEnd = periodState.periodStartTime + getPeriodDuration();
- if (currentTime < periodEnd) {
- revert PeriodNotElapsed();
- }
+ // If within current period, update intermediate state
+ if (currentTime < periodEnd) {
+ uint256 avgWeight = periodState.votingPeriod.calculateAverage(currentTime);
+ TimeWeightedAverage.updateValue(periodState.votingPeriod, avgWeight, currentTime);
+ emit PeriodUpdated(address(this), currentTime, avgWeight);
+ return;
+ }
uint256 periodDuration = getPeriodDuration();
uint256 avgWeight = periodState.votingPeriod.calculateAverage(periodEnd);
uint256 nextPeriodStart = ((currentTime / periodDuration) + 2) * periodDuration;
periodState.distributed = 0;
periodState.periodStartTime = nextPeriodStart;
TimeWeightedAverage.createPeriod(
periodState.votingPeriod, nextPeriodStart, periodDuration, avgWeight, WEIGHT_PRECISION
);
emit PeriodRolled(address(this), currentTime, WEIGHT_PRECISION);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

GaugeController and BaseGauge updatePeriod functions revert with PeriodNotElapsed when called during active periods, preventing intermediate state updates and causing stale gauge data

This submission confuses period rollover with weight updates. Gauge weights update immediately when users vote via _updateGaugeWeight, while updatePeriod only handles period boundaries. The PeriodNotElapsed revert is intentional protection against premature rollover.

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

GaugeController and BaseGauge updatePeriod functions revert with PeriodNotElapsed when called during active periods, preventing intermediate state updates and causing stale gauge data

This submission confuses period rollover with weight updates. Gauge weights update immediately when users vote via _updateGaugeWeight, while updatePeriod only handles period boundaries. The PeriodNotElapsed revert is intentional protection against premature rollover.

Appeal created

theirrationalone Submitter
4 months ago
inallhonesty Lead Judge
3 months ago
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

GaugeController and BaseGauge updatePeriod functions revert with PeriodNotElapsed when called during active periods, preventing intermediate state updates and causing stale gauge data

This submission confuses period rollover with weight updates. Gauge weights update immediately when users vote via _updateGaugeWeight, while updatePeriod only handles period boundaries. The PeriodNotElapsed revert is intentional protection against premature rollover.

Support

FAQs

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