Core Contracts

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

Incorrect reward checkpoint update in FeeCollector's claimRewards function

Summary

The FeeCollector contract calculates pending rewards for a user based on their voting power relative to the total voting power. However, when a user claims rewards via the claimRewards function, the contract updates the user's reward checkpoint by setting it to the global totalDistributed value rather than their individual accrued share. This misalignment causes subsequent pending reward calculations to be incorrect, potentially resulting in underpayment of rewards.

Vulnerability Details

How the Reward Calculation Works

  • Pending Reward Calculation:
    The pending reward for a user is computed as follows:

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

    Here, share represents the user’s entitled rewards based on their voting power, while userRewards[user] acts as a checkpoint tracking the amount already claimed.

  • Faulty Checkpoint Update:
    In the claimRewards function, after a successful claim, the checkpoint is updated as:

    userRewards[user] = totalDistributed;

    This is incorrect because the checkpoint should reflect the user’s share, not the total global distribution.

The Mathematical Impact

  • Example Scenario:

    • Suppose totalDistributed is 1,000 tokens.

    • Total voting power is 1,000.

    • A particular user has a voting power of 100.

    • Expected user share:

      share = (1,000 * 100) / 1,000 = 100 tokens.
    • If the user has not claimed rewards before (userRewards[user] is 0), the pending reward is correctly 100 tokens.

  • Faulty Update Consequence:

    • The current implementation sets userRewards[user] = totalDistributed, i.e., 1,000.

    • On a subsequent claim (with no additional distributions), the calculated share remains 100 tokens.

    • The pending reward would be computed as:

      pendingReward = 100 - 1,000 = 0 (or an underflow, depending on Solidity version)

    This means that the user would be unable to claim any new rewards until totalDistributed increases sufficiently, leading to an underpayment.

Proof-of-Concept (PoC)

Scenario Setup

  • Initial Conditions:

    • totalDistributed = 1,000 tokens.

    • Total voting power = 1,000.

    • A user’s voting power = 100.

    • The user’s checkpoint (userRewards[user]) initially = 0.

Calculation and Outcome

  1. Expected Calculation:

    • User’s share:

      share = (1,000 * 100) / 1,000 = 100 tokens.
    • Pending reward (if no previous claims):

      pendingReward = 100 - 0 = 100 tokens.
  2. Faulty Update:

    • Current implementation sets userRewards[user] = totalDistributed = 1,000.

    • On the next claim (assuming no new distributions), the computed share remains 100 tokens.

    • New pending reward becomes:

      pendingReward = 100 - 1,000 = 0 tokens.
    • The user is effectively blocked from claiming any further rewards until totalDistributed increases by more than 900 tokens.

Impact

  • Incorrect Reward Accrual:
    Users may receive less than their fair share of rewards because the checkpoint overshoots the user’s actual accrued amount.

Tools Used

Manual review

Recommendations

Update Checkpoint Correctly:
Modify the claimRewards function so that the user's checkpoint is updated to their individual share, not the global totalDistributed. The correct update should be:

uint256 share = (totalDistributed * veRAACToken.getVotingPower(user)) / veRAACToken.getTotalVotingPower();
userRewards[user] = share;

Revised claimRewards Function

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();
// Correctly update user rewards checkpoint based on the user's share
uint256 userVotingPower = veRAACToken.getVotingPower(user);
uint256 totalVotingPower = veRAACToken.getTotalVotingPower();
uint256 share = (totalDistributed * userVotingPower) / totalVotingPower;
userRewards[user] = share;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}

Explanation of Changes

  • Accurate Checkpoint Update:
    By computing the user’s share based on the global totalDistributed and the user’s voting power, and then updating the checkpoint with this share, the contract accurately tracks the rewards already claimed by the user.

  • Future Claims:
    With the checkpoint correctly updated, subsequent reward claims will only compute rewards based on any new distributions beyond what the user has already received.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 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.

Give us feedback!