Core Contracts

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

veRAAC token holders will be unable to claim reward in the fee collector

Summary

veRAAC token holders will be unable to claim their reward in the fee collector as it will be stuck.

Vulnerability Details

In FeeCollector.sol , the claim function can be called by veRAAC token holders to get a share of the distributed protocol revenue.

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

The _calculatePendingRewards(user) at the part marked @>>1 calculates pending user rewards to be claimed which is defined as :

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;
//@audit-info : if the computed user userRewards[user] > share return 0
return share > userRewards[user] ? share - userRewards[user] : 0;
}

After calculating the pending reward, it resets the user rewards by assigning
userRewards[user] = totalDistributed before transferring the reward to the user.

The issue is that, after the first claim, users will no longer be able to claim any future reward distributed since userRewards[user] will be greater than the computed share which will always return 0

Proof Of Concept

See how to integrate foundry to hardhat project
. Create a new file POC.t.sol in project /test/ folder. Paste the poc and run forge test --mt test_POC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "contracts/core/tokens/RAACToken.sol";
import "contracts/core/tokens/veRAACToken.sol";
import "contracts/core/collectors/FeeCollector.sol";
contract FeeCollectorTest is Test {
RAACToken public raacToken;
veRAACToken public veRToken;
FeeCollector public feeCollector;
address public owner;
address public user1;
address public user2;
address public treasury;
address public newTreasury;
address public repairFund;
address public emergencyAdmin;
uint256 constant BASIS_POINTS = 10000;
uint256 constant WEEK = 7 * 24 * 3600;
uint256 constant ONE_YEAR = 365 * 24 * 3600;
uint256 constant INITIAL_MINT = 10000 ether;
uint256 constant SWAP_TAX_RATE = 100; // 1%
uint256 constant BURN_TAX_RATE = 50; // 0.5%
struct FeeType {
uint256 veRAACShare;
uint256 burnShare;
uint256 repairShare;
uint256 treasuryShare;
}
FeeType public defaultFeeType;
function setUp() public {
owner = makeAddr("owner");
user1 = makeAddr("alice");
user2 = makeAddr("bob");
treasury = makeAddr("treasury");
newTreasury = makeAddr("newTreasury");
repairFund = makeAddr("repairFund");
emergencyAdmin = makeAddr("emergencyAdmin");
vm.startPrank(owner);
raacToken = new RAACToken(owner, SWAP_TAX_RATE, BURN_TAX_RATE);
veRToken = new veRAACToken(address(raacToken));
feeCollector = new FeeCollector(
address(raacToken),
address(veRToken),
treasury,
repairFund,
owner
);
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veRToken), true);
raacToken.setMinter(owner);
veRToken.setMinter(owner);
feeCollector.grantRole(feeCollector.FEE_MANAGER_ROLE(), owner);
feeCollector.grantRole(feeCollector.EMERGENCY_ROLE(), emergencyAdmin);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), owner);
vm.stopPrank();
}
function test_POC() public {
uint256 protocolFeeGross = 50 ; // 40
uint256 lendingFeeGross = 30 ; // 21
uint256 swapTaxGross = 20 ;
vm.startPrank(owner);
raacToken.mint(address(user1), 100 );
raacToken.mint(address(user2), 100 );
raacToken.mint(owner, 200);
raacToken.approve(address(feeCollector), 200 );
feeCollector.collectFee(protocolFeeGross, 0);
feeCollector.collectFee(lendingFeeGross, 1);
feeCollector.collectFee(swapTaxGross, 6);
vm.stopPrank();
// user1 locks tokens
vm.startPrank(user1);
raacToken.approve(address(veRToken), 100);
veRToken.lock(100, ONE_YEAR);
vm.stopPrank();
// user2 locks tokens
vm.startPrank(user2);
raacToken.approve(address(veRToken), 100);
veRToken.lock(100, ONE_YEAR);
vm.stopPrank();
// First distribution period
vm.prank(owner);
feeCollector.distributeCollectedFees();
// User1 claims first time
vm.prank(user1);
assertEq(feeCollector.claimRewards(user1), 31);
//@audit-info
// user 2 did not claim during this period
vm.warp(block.timestamp + WEEK);
// second distribution
vm.startPrank(owner);
raacToken.mint(owner, 200);
raacToken.approve(address(feeCollector), 200 );
feeCollector.collectFee(50, 0);
feeCollector.distributeCollectedFees();
vm.stopPrank();
// forward time
vm.warp(block.timestamp + WEEK);
vm.prank(user1);
vm.expectRevert(abi.encodeWithSignature("InsufficientBalance()"));
feeCollector.claimRewards(user1);
// user2 got full reward at second distribution
vm.prank(user2);
assertEq(feeCollector.claimRewards(user2), 51);
}
}

Impact

New rewards distributed after their first claim are unclaimable leading to loss of reward for token holders

Tools Used

Manual Review

Recommendations

Modify the function to ensure that users can claim their reward when distributions are made

Updates

Lead Judging Commences

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