Core Contracts

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

Incorrect reward calculation in `FeeCollector` prevents users from claiming rewards

Summary

The FeeCollector::claimRewards() is responsible for distributing pending rewards to users who have locked their RAAC tokens in the veRAACToken contract based on their voting power. However, due to incorrect updates in the userRewards mapping, a user who has already claimed rewards once may be unable to claim them again, even if they are eligible.

Vulnerability Details

The claimRewards() function calculates rewards using _calculatePendingRewards():

uint256 pendingReward = _calculatePendingRewards(user);

The _calculatePendingRewards() function determines a user's share of rewards based on their voting power and the total supply of veRAACToken:

https://github.com/Cyfrin/2025-02-raac/blob/main/contracts/core/collectors/FeeCollector.sol#L479-L488

function _calculatePendingRewards(address user) internal view returns (uint256) {
uint256 userVotingPower = veRAACToken.getVotingPower(user);
if (userVotingPower == 0) return 0;
uint256 totalVotingPower = veRAACToken.getTotalVotingPower();
if (totalVotingPower == 0) return 0;
uint256 share = (totalDistributed * userVotingPower) / totalVotingPower;
return share > userRewards[user] ? share - userRewards[user] : 0;
}

Before transferring rewards, the contract updates the userRewards mapping for the user:

https://github.com/Cyfrin/2025-02-raac/blob/main/contracts/core/collectors/FeeCollector.sol#L206

// Reset user rewards before transfer
userRewards[user] = totalDistributed; //@audit

This issue does not affect the first reward claim. However, after more rewards are distributed, a user may not be able to claim future rewards due to several issues:

  1. The totalDistributed value increases whenever rewards are distributed but is never reset or decreased.

  2. The reward share calculation is based on the updated totalDistributed value. If the newly calculated share is less than or equal to userRewards[user], the pending reward will be 0, making the user unable to claim rewards.

  3. Since userRewards[user] is set to the previous value of totalDistributed, the user will only receive rewards again if their voting power increases significantly or totalDistributed grows substantially, which may take a long time.

return share > userRewards[user] ? share - userRewards[user] : 0;

Impact

High. Users may be unable to claim rewards until totalDistributed increases significantly. However, totalDistributed grows based on protocol fees, which are distributed among multiple stakeholders (veRAACShare, burnShare, repairShare, treasuryShare). As a result, it could take a long time before a user is able to claim rewards again.

Proof of Concept (PoC)

I have built a below test in Foundry. Paste below code in test folder and run test with following command:

forge test --mt test_audit_ClaimRewards -vvv

import "forge-std/console.sol";
import "forge-std/Test.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/veRAACToken.sol";
import "../contracts/core/collectors/FeeCollector.sol";
import "../contracts/core/collectors/Treasury.sol";
contract FeeCollectorTest is Test {
RAACToken public raacToken;
veRAACToken public veRaacToken;
Treasury public treasury;
FeeCollector public feeCollector;
address public owner = makeAddr("owner");
address public repairFund= makeAddr("repairFund");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
uint256 public tokenAmount;
function setUp() public{
vm.startPrank(owner);
//Deploying contracts
raacToken = new RAACToken(owner, 0, 0);
veRaacToken = new veRAACToken(address(raacToken));
treasury = new Treasury(owner);
feeCollector = new FeeCollector(address(raacToken), address(veRaacToken), address(treasury), repairFund, owner);
//Set address of owner as minter of RAAC token
raacToken.setMinter(owner);
//Mint some RAAC token to Alice and Bob
raacToken.mint(alice, 2_000 * 10 ** raacToken.decimals());
raacToken.mint(bob, 5_000 * 10 ** raacToken.decimals());
vm.stopPrank();
}
function test_audit_ClaimRewards() public {
//Alice locks RAAC token for 2 years
vm.startPrank(alice);
raacToken.approve(address(veRaacToken), 2_000 * 10 ** raacToken.decimals());
veRaacToken.lock(1_000 * 10 ** raacToken.decimals(), 365 days * 2);
vm.stopPrank();
//Skip 1 year
vm.warp(365 days);
//Bob locks RAAC token for 3 years
vm.startPrank(bob);
raacToken.approve(address(veRaacToken), 5_000 * 10 ** raacToken.decimals());
veRaacToken.lock(5_000 * 10 ** raacToken.decimals(), 365 days * 3);
vm.stopPrank();
vm.startPrank(owner);
//Mint and approve some RAAC token to owner for reward distribution
raacToken.mint(owner, 10_000_00 * 10 ** raacToken.decimals());
raacToken.approve(address(feeCollector), 10_000_00 * 10 ** raacToken.decimals());
//Whitelist FeeCollector and veRAAC contract to avoid swap tax
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veRaacToken), true);
//Asserting FeeCollector has not any balance
assertEq(raacToken.balanceOf(address(feeCollector)), 0);
//skip to 90 days
vm.warp(90 days);
//Send fees across all 8 types
for (uint8 i; i<8; ++i) {
feeCollector.collectFee(1_000_00 * 10 ** raacToken.decimals(), i);
}
//Distribute fees to all stake holders
feeCollector.distributeCollectedFees();
//Claiming and Logging rewards for alice and bob
uint256 aliceReward = feeCollector.claimRewards(alice);
uint256 bobReward = feeCollector.claimRewards(bob);
console.log("Claimed Reward of Alice:",aliceReward);
console.log("Claimed Reward of Bob:",bobReward);
//Ensure Alice and Bob does not have any pending rewards. //share = (totalDistributed * userVotingPower) / totalVotingPower;
assertEq((feeCollector.totalDistributed() * veRaacToken.getVotingPower(alice) / veRaacToken.getTotalVotingPower()) - aliceReward, 0);
assertEq((feeCollector.totalDistributed() * veRaacToken.getVotingPower(bob) / veRaacToken.getTotalVotingPower()) - bobReward, 0);
//skip to 180 days
vm.warp(180 days);
//Send fees across all 8 types
for (uint8 i; i<8; ++i) {
feeCollector.collectFee(250_00 * 10 ** raacToken.decimals(), i);
}
//Distribute fees to all stake holders
feeCollector.distributeCollectedFees();
//Logging Expected pending rewards of Alice as per calculation
console.log("Alice pending rewards:", (feeCollector.totalDistributed() * veRaacToken.getVotingPower(alice) / veRaacToken.getTotalVotingPower()) - aliceReward); //share = (totalDistributed * userVotingPower) / totalVotingPower;
//Logging Pending rewards of Alice as per flawed calculation
console.log("alice pending flawed:",feeCollector.getPendingRewards(alice));
//Expect Revert InsufficientBalance()
vm.expectRevert();
feeCollector.claimRewards(alice);
}
}

Test Output

Ran 1 test for test/auditFeeCollector.t.sol:FeeCollectorTest
[PASS] test_audit_ClaimRewards() (gas: 1679340)
Logs:
Claimed Reward of Alice: 41248800101322219085648
Claimed Reward of Bob: 352870588235294117647058
Alice pending rewards: 3061434513646563176494
alice pending flawed: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.21ms (5.82ms CPU time)
  1. Alice and Bob lock their RAAC tokens in the veRAACToken contract at different times.

  2. The owner calls collectFee() in the FeeCollector contract, distributing fees across different categories.

  3. After some time, the owner calls distributeCollectedFees().

  4. Alice and Bob claim their rewards successfully for the first time.

  5. Later, the owner repeats steps 2 and 3.

  6. Alice tries to claim her rewards again but faces an InsufficientBalance() error because her pending rewards are now 0, even though she should be eligible.

Tools Used

Foundry

Recommendations

Update the userRewards mapping to collected rewards of user.

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!