Core Contracts

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

Incorrect Reward Calculation Due to Unchecked `block.timestamp` in `_updateReward`

Description

The _updateReward function in BaseGauge.sol sets state.lastUpdateTime to block.timestamp without ensuring that block.timestamp is less than or equal to periodFinish(). This oversight could lead to incorrect reward calculations, particularly if block.timestamp exceeds periodFinish(). As a result, users may receive more rewards than they are entitled to, or rewards may be calculated based on an incorrect time frame.

Affected Code

The issue is located in the _updateReward function in BaseGauge.sol:

https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/BaseGauge.sol#L162-L178

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; //@audit-issue setting it to block.timestamp when they just instantiate lastUpdateTime
emit RewardUpdated(account, state.rewards);
}
}

Vulnerability Details

The vulnerability arises because state.lastUpdateTime is set directly to block.timestamp without using the lastTimeRewardApplicable() function, which ensures that the timestamp used for reward calculations does not exceed periodFinish(). This inconsistency could lead to the following issues:

  1. Reward Calculation Errors: If state.lastUpdateTime is set to a time after periodFinish(), reward calculations could be incorrect, potentially allowing users to claim more rewards than they are entitled to.

  2. Exploitation: An attacker could manipulate the timing of transactions to maximize their rewards by exploiting this inconsistency.

The lastTimeRewardApplicable() function is defined as follows:

function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish() ? block.timestamp : periodFinish();
}

This function ensures that the timestamp used for reward calculations does not exceed periodFinish(). However, it is not used when setting state.lastUpdateTime in _updateReward.

Impact

The incorrect setting of state.lastUpdateTime in _updateReward has a significant impact on the contract's functionality. Since the updateReward modifier is used in 6 critical functions, the mistake propagates across the entire reward distribution mechanism. Below is an analysis of how each function is affected, along with the corresponding code:

1. stake Function

  • Purpose: Allows users to stake tokens and start earning rewards.

  • Impact: If state.lastUpdateTime is set incorrectly, the user's rewards will be calculated based on an incorrect time frame, potentially overpaying or underpaying rewards.

  • Code:

    function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
    if (amount == 0) revert InvalidAmount();
    _totalSupply += amount;
    _balances[msg.sender] += amount;
    stakingToken.safeTransferFrom(msg.sender, address(this), amount);
    emit Staked(msg.sender, amount);
    }

2. withdraw Function

  • Purpose: Allows users to withdraw staked tokens and claim rewards.

  • Impact: Incorrect state.lastUpdateTime could lead to overpayment of rewards during withdrawal, as the time elapsed calculation would include periods beyond periodFinish().

  • Code:

    function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
    if (amount == 0) revert InvalidAmount();
    if (_balances[msg.sender] < amount) revert InsufficientBalance();
    _totalSupply -= amount;
    _balances[msg.sender] -= amount;
    stakingToken.safeTransfer(msg.sender, amount);
    emit Withdrawn(msg.sender, amount);
    }

3. getReward Function

  • Purpose: Allows users to claim their accumulated rewards.

  • Impact: If state.lastUpdateTime is set incorrectly, users could claim rewards for a period when no rewards should be distributed, leading to overpayment.

  • Code:

    function getReward() external virtual nonReentrant whenNotPaused updateReward(msg.sender) {
    if (block.timestamp - lastClaimTime[msg.sender] < MIN_CLAIM_INTERVAL) {
    revert ClaimTooFrequent();
    }
    lastClaimTime[msg.sender] = block.timestamp;
    UserState storage state = userStates[msg.sender];
    uint256 reward = state.rewards;
    if (reward > 0) {
    state.rewards = 0;
    uint256 balance = rewardToken.balanceOf(address(this));
    if (reward > balance) {
    revert InsufficientBalance();
    }
    rewardToken.safeTransfer(msg.sender, reward);
    emit RewardPaid(msg.sender, reward);
    }
    }

4. notifyRewardAmountFunction

  • Purpose: Updates the reward rate and distribution period.

  • Impact: If state.lastUpdateTime is set incorrectly, it could affect the reward calculations for all users, leading to systemic overpayment or underpayment of rewards.

  • Code:

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

5. voteDirection Function

  • Purpose: Allows users to vote on the direction of rewards.

  • Impact: If state.lastUpdateTime is set incorrectly, it could affect the reward calculations for the voter, leading to unfair distribution of rewards.

  • Code:

    function voteDirection(uint256 direction) public whenNotPaused updateReward(msg.sender) {
    if (direction > 10000) revert InvalidWeight();
    uint256 votingPower = IERC20(IGaugeController(controller).veRAACToken()).balanceOf(msg.sender);
    if (votingPower == 0) revert NoVotingPower();
    totalVotes = processVote(
    userVotes[msg.sender],
    direction,
    votingPower,
    totalVotes
    );
    emit DirectionVoted(msg.sender, direction, votingPower);
    }

6. checkpoint Function

  • Purpose: Creates a checkpoint for reward calculations.

  • Impact: If state.lastUpdateTime is set incorrectly, the checkpoint will be based on an incorrect time frame, leading to inaccurate reward calculations.

  • Code:

    function checkpoint() external updateReward(msg.sender) {
    emit Checkpoint(msg.sender, block.timestamp);
    }

PoC

  • Scenario: Suppose periodFinish() is set to 1000, and block.timestamp is 1200.

  • Current Behavior: state.lastUpdateTime is set to 1200 (which is after periodFinish()).

  • Expected Behavior: state.lastUpdateTime should be set to 1000 (the value returned by lastTimeRewardApplicable()).

  • Consequence: When rewards are calculated, the time elapsed will be based on 1200 instead of 1000, leading to incorrect rewards.


Tools Used

  • Manual Review

Recommended Mitigation Steps

To mitigate this issue, state.lastUpdateTime should be set using lastTimeRewardApplicable() instead of block.timestamp. This ensures that state.lastUpdateTime does not exceed periodFinish().

Updated _updateReward Function:

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 = lastTimeRewardApplicable(); // Use lastTimeRewardApplicable() instead of block.timestamp
emit RewardUpdated(account, state.rewards);
}
}
Updates

Lead Judging Commences

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

BaseGauge sets user's lastUpdateTime to uncapped block.timestamp while global lastUpdateTime uses capped lastTimeRewardApplicable(), generating reward calc inconsistencies after period ends

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

BaseGauge sets user's lastUpdateTime to uncapped block.timestamp while global lastUpdateTime uses capped lastTimeRewardApplicable(), generating reward calc inconsistencies after period ends

Support

FAQs

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

Give us feedback!