Core Contracts

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

Fee rewards are permanently lost for users who claim early due to incorrect tracking of claimed rewards

Summary

In the FeeCollector.sol contract, users' claimed rewards are incorrectly tracked by setting userRewards[user] to the total amount distributed to all users (totalDistributed) rather than incrementing it by the amount actually claimed. This causes users who claim early to lose access to future rewards permanently, while late claimers receive disproportionately higher rewards.

Vulnerability Details

The issue occurs in the claimRewards function where userRewards[user] is set to totalDistributed after a claim:

function claimRewards(address user) external override nonReentrant whenNotPaused returns (uint256) {
uint256 pendingReward = _calculatePendingRewards(user);
// Reset user rewards before transfer
@> userRewards[user] = totalDistributed; // @audit-issue Should increment by claimed amount
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
}

When calculating pending rewards:

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

Consider two users with equal voting power (250e18 each) when 800 RAAC tokens are distributed:

  1. User A claims early:

    • Gets 400 RAAC (their 50% share)

    • userRewards[A] is set to 800 (total distributed)

  2. Another 800 RAAC distribution occurs:

    • totalDistributed is now 1600

    • User A's share calculation: (1600 * 250/500) = 800

    • Pending rewards: 800 - 800 = 0 (no rewards despite being entitled to them)

  3. User B claims for the first time:

    • Share calculation: (1600 * 250/500) = 800

    • Pending rewards: 800 - 0 = 800 (gets full amount because userRewards[B] was 0)

Impact

High - Directly causes loss of rewards that users are entitled to and creates systemic unfairness in reward distribution.

  • Users who claim rewards early permanently lose access to future rewards

  • Late claimers receive disproportionately higher rewards

  • Creates a "last to claim" game theory that undermines the protocol's intended reward distribution

Likelihood

High - Comes into fruition after first round of rewards.

Proof of Code

  1. Convert the project into a foundry project, ensuring test in foundry.toml points to a designated test directory.

  2. Comment out the forking object from the hardhat.congif.cjs file:

networks: {
hardhat: {
mining: {
auto: true,
interval: 0
},
//forking: {
// url: process.env.BASE_RPC_URL,
//},
  1. Add the following code into the test folder:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
//Tokens
import "contracts/core/tokens/RToken.sol";
import "contracts/core/tokens/RAACToken.sol";
import "contracts/core/tokens/veRAACToken.sol";
//Governance
import "contracts/core/collectors/FeeCollector.sol";
import "contracts/core/governance/boost/BoostController.sol";
import "contracts/core/governance/gauges/BaseGauge.sol";
import "contracts/core/governance/gauges/GaugeController.sol";
import "contracts/core/governance/gauges/RAACGauge.sol";
import "contracts/core/governance/gauges/RWAGauge.sol";
import "contracts/core/governance/proposals/Governance.sol";
import "contracts/core/governance/proposals/TimelockController.sol";
//3rd Party
import {Test, console} from "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
contract MasterGovernanceTest is Test {
RToken public rToken;
RAACToken public raacToken;
veRAACToken public veRaacToken;
// FeeCollector
FeeCollector public feeCollector;
address public treasury;
address public repairFund;
// Mock token
MockERC20 public mockCrvUSD;
// Admin actors
address public protocolOwner = makeAddr("ProtocolOwner");
address public rTokenMinter = makeAddr("rTokenMinter");
address public rTokenBurner = makeAddr("rTokenBurner");
address public raacTokenMinter = makeAddr("RAACTokenMinter");
address public veRaacTokenMinter = makeAddr("veRaacTokenMinter");
// Users
address public Alice = makeAddr("Alice");
address public Bob = makeAddr("Bob");
// Constants
uint256 constant MIN_LOCK_DURATION = 365 days;
uint256 constant MAX_LOCK_DURATION = 1460 days; // 4 years
uint256 constant LOCK_AMOUNT = 1000 ether;
uint256 constant ADDITIONAL_LOCK_AMOUNT = 100 ether;
// SetUp
function setUp() public {
vm.startPrank(protocolOwner);
// Set up RToken
mockCrvUSD = new MockERC20();
rToken = new RToken(
"RToken",
"RTKN",
protocolOwner,
address(mockCrvUSD)
);
rToken.setMinter(rTokenMinter);
rToken.setBurner(rTokenBurner);
// Set up RAACToken
raacToken = new RAACToken(
protocolOwner, // initialOwner
100, // initialSwapTaxRate - 1%
50 // initialBurnTaxRate - 0.5%
);
raacToken.setMinter(raacTokenMinter);
// Set up veRAACToken
veRaacToken = new veRAACToken(address(raacToken));
veRaacToken.setMinter(veRaacTokenMinter);
// Whitelist veRAACToken address so that fees are not issued on transfers
raacToken.manageWhitelist(address(veRaacToken), true);
// FeeCollector setup
treasury = makeAddr("treasury");
repairFund = makeAddr("repairFund");
feeCollector = new FeeCollector(
address(raacToken),
address(veRaacToken),
treasury,
repairFund,
protocolOwner
);
vm.stopPrank();
// Mint RAAC tokens to Alice for testing
vm.startPrank(raacTokenMinter);
raacToken.mint(Alice, LOCK_AMOUNT);
raacToken.mint(Bob, LOCK_AMOUNT);
vm.stopPrank();
}
function test_RewardsDistributionBug() public {
// Alice locks tokens to get voting power
vm.startPrank(Alice);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, MIN_LOCK_DURATION);
vm.stopPrank();
// Bob locks tokens to get voting power
vm.startPrank(Bob);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, MIN_LOCK_DURATION);
vm.stopPrank();
// Mint RAAC to a fee payer
address feePayer = makeAddr("feePayer");
vm.startPrank(raacTokenMinter);
raacToken.mint(feePayer, 1000 ether);
vm.stopPrank();
// Collect fees
vm.startPrank(feePayer);
raacToken.approve(address(feeCollector), 1000 ether);
feeCollector.collectFee(1000 ether, 0); // Using protocolFees type
vm.stopPrank();
// Simulate fee collection
uint256 feeAmount = 1000 ether;
vm.startPrank(raacTokenMinter);
raacToken.mint(address(feeCollector), feeAmount);
vm.stopPrank();
// First distribution
vm.startPrank(protocolOwner);
feeCollector.distributeCollectedFees();
vm.stopPrank();
// Check Alice's initial rewards
uint256 initialAliceRewards = feeCollector.getPendingRewards(Alice);
console.log("Alice rewards after first round (claims): ", initialAliceRewards);
// Alice claims rewards
vm.startPrank(Alice);
feeCollector.claimRewards(Alice);
vm.stopPrank();
// Check Bob's initial rewards
uint256 initialBobRewards = feeCollector.getPendingRewards(Bob);
console.log("Bob rewards after first round (Does not claim): ", initialBobRewards);
vm.warp(block.timestamp + 7 days + 1); // Move past the period duration
address feePayer2 = makeAddr("feePayer2");
vm.startPrank(raacTokenMinter);
raacToken.mint(feePayer2, 1000 ether);
vm.stopPrank();
vm.startPrank(feePayer2);
raacToken.approve(address(feeCollector), 1000 ether);
feeCollector.collectFee(1000 ether, 0);
vm.stopPrank();
// Second fee collection and distribution
vm.startPrank(raacTokenMinter);
raacToken.mint(address(feeCollector), feeAmount);
vm.stopPrank();
vm.startPrank(protocolOwner);
feeCollector.distributeCollectedFees();
vm.stopPrank();
// Check Alice's rewards after second distribution
uint256 secondAliceRewards = feeCollector.getPendingRewards(Alice);
console.log("Alice rewards after second round: ", secondAliceRewards);
// Check Bob's rewards after second distribution
uint256 secondBobRewards = feeCollector.getPendingRewards(Bob);
console.log("Bob rewards after second round: ", secondBobRewards);
// Check Bobs rewards
vm.startPrank(Bob);
feeCollector.claimRewards(Bob);
vm.stopPrank();
console.log("Bob RAAC balance after claiming (Some lost to burning): ", IERC20(address(raacToken)).balanceOf(Bob));
}
}
  1. Run forge test -vvvv

  2. Output:

Logs:
Alice rewards after first round (claims): 400000000000000000000
Bob rewards after first round (Does not claim): 400000000000000000000
Alice rewards after second round: 0
Bob rewards after second round: 784657508878742039228
Bob RAAC balance after claiming (Some lost to burning): 772887646245560908640

Recommendations

  • Update the reward tracking to increment by claimed amount.

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!