Core Contracts

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

Cumulative Rewards Tracking Bug Causes Users to Miss Future Rewards Due to Wrong Update of `userRewards[user]`

Summary

The claimRewards() function incorrectly resets the user's reward tracking state to totalDistributed in FeeCollector.sol, causing all future calls to claimRewards() to return 0 pendingReward. As a result, users are unable to claim any newly distributed rewards, leading to a complete lockout from further earnings.

This bug arises due to the incorrect update of the userRewards[user] state variable, which tracks the user's claimed rewards improperly and prevents correct calculation of pending rewards.

Vulnerability Details

In the claimRewards() function of the FeeCollector contract:

FeeCollector.sol#L199-L213

function claimRewards(address user) external override nonReentrant whenNotPaused returns (uint256) {
if (user == address(0)) revert InvalidAddress();
uint256 pendingReward = _calculatePendingRewards(user);
if (pendingReward == 0) revert InsufficientBalance();
// Reset user rewards before transfer
userRewards[user] = totalDistributed;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}

The issue is triggered by this line:

FeeCollector.sol#L206

userRewards[user] = totalDistributed;

And, before hand, the _calculatePendingRewards() function computes the user’s pending reward as:

FeeCollector.sol#L486-L487

uint256 share = (totalDistributed * userVotingPower) / totalVotingPower;
return share > userRewards[user] ? share - userRewards[user] : 0;

On the first claim, userRewards[user] is 0, and the user correctly receives their initial pending rewards. However, after the first claim, userRewards[user] == totalDistributed. This causes future calls to claimRewards() to almost always return 0 pendingReward because share < userRewards[user].

Example Walkthrough:

Step 1. Initial State

  • totalDistributed = 1000 tokens

  • The user’s voting power entitles them to 200 tokens.

Step 2. First Call to claimRewards()

  • _calculatePendingRewards() correctly calculates:
    pendingReward = (1000 * userVotingPower) / totalVotingPower = 200 tokens

  • The user receives 200 tokens, and userRewards[user] is updated:
    userRewards[user] = totalDistributed; // 1000

Step 3. Future Calls (After More Rewards Distributed and Assuming the Same Ratio of userVotingPower / totalVotingPower)

  • Suppose totalDistributed = 1500, but _calculatePendingRewards() calculates:
    share = (1500 * userVotingPower) / totalVotingPower = 300 tokens

  • Since userRewards[user] == 1000, 300 < 1000, making the returned pendingReward == 0.

  • The user is locked out of future rewards despite new distributions.

Impact

  1. Complete Lockout from Future Rewards:
    Users will be unable to claim any additional rewards after their first claim, resulting in financial loss for stakeholders and a breakdown of the rewards distribution system.

  2. Stakeholder Trust and Reputation Risk:
    As rewards remain locked, users may lose confidence in the protocol, and the protocol could face reputational damage if the issue is not quickly addressed.

  3. Potential Accumulation of Unclaimed Rewards:
    Since users cannot claim new rewards, unclaimed rewards will accumulate within the contract, potentially leading to large protocol-wide imbalances and manual interventions.

Tools Used

Manual

Recommendations

Consider the following refactoring:

FeeCollector.sol#L206

- userRewards[user] = totalDistributed;
+ userRewards[user] += pendingReward;
Updates

Lead Judging Commences

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

FeeCollector::claimRewards sets `userRewards[user]` to `totalDistributed` seriously grieving users from rewards

Support

FAQs

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