Core Contracts

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

Lost Rewards Due to Incorrect Reward Claimed traking in `feeCollector` contract

Summary

  • The FeeCollector contract's reward distribution uses a flawed cumulative tracking system that prevents users from claiming new rewards from new distributions after their initial claim. This occurs in the claimRewards function due to absolute total tracking instead of per-distribution accounting. The key risk is protocol funds becoming permanently unclaimable despite being distributed

Vulnerability Details

  • The reward calculation uses a single totalDistributed accumulator and stores absolute claimed amounts rather than tracking per-distribution rewards.when ever a user who have a voting power claims rewards , it stores his userRewards to the current totalDistributed value :

function claimRewards(address user) external override nonReentrant whenNotPaused returns (uint256) {
// prev code ..
>> userRewards[user] = totalDistributed;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}

The flaw originates from how future reward calculations then subtract this stored value, effectively “locking in” the cumulative amount from the first claim and preventing any additional rewards from being claimed by that user when his not the only one in the system .

  • The protocol continuously collects fees, so totalDistributed increases over time (for example: 100 → 200 → 300 RAAC).

  • Rewards pending for a user are calculated using the formula:

```solidity
 pendingRewards = (currentTotalDistributed * userPower) / totalPower - userRewards[user];
```
  • Here, userRewards[user] is updated to the current totalDistributed whenever the user claims rewards.

  • To simplify the issue consider the following example:

    • Initial State:

      • totalDistributed = 100

      • User A has voting power of 50 out of a total of 100.

      • By formula: User A’s share = (100 × 50/100) = 50 tokens.

      • When User A claims rewards, userRewards[A] is set to 100.

    • After New Distribution:

      • The protocol adds 100 more tokens, so totalDistributed becomes 200.

      • User A’s share (if calculated freshly) would be: (200 × 50/100) = 100 tokens.

      • However, since userRewards[A] is still 100, the new pending rewards become: 100 − 100 = 0.

    • Result: Despite User A maintaining the same voting power, they cannot claim any additional rewards from the new distribution.

  • Non-Cumulative Claims: Once a user claims rewards, their userRewards value locks them out of receiving any future rewards, even though new funds are added to the pool.

  • Lost Rewards: Newly distributed fees become unclaimable by existing users because the claim calculation incorrectly assumes that the user’s share has been fully compensated.

  • Root Cause: The system uses an absolute cumulative total (totalDistributed) and updates userRewards[user] to match it at claim time rather than tracking each distribution period separately. This means that after the first claim, any increases in totalDistributed are effectively ignored for that user.

Note: If a user is the sole holder of voting power, the calculation works as expected, since the entire distribution is attributed to that user.

POC

Foundry Envirement Setup
  • i'm using foundry for test , to integrate foundry :
    run :

    npm install --save-dev @nomicfoundation/hardhat-foundry

    add this to hardhat.config.cjs :

    require("@nomicfoundation/hardhat-foundry");

    run :

    npx hardhat init-foundry
  • comment the test/unit/libraries/ReserveLibraryMock.sol as it's causing compiling errors

  • inside test folder , create new dir foundry and inside it , create new file baseTest.sol , and copy/paste this there :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
import "../../contracts/core/tokens/DebtToken.sol";
import "../../contracts/core/tokens/RAACToken.sol";
import "../../contracts/core/tokens/veRAACToken.sol";
import "../../contracts/core/tokens/RToken.sol";
import "../../contracts/core/tokens/DEToken.sol";
import "../../contracts/core/pools/LendingPool/LendingPool.sol";
import "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../../contracts/core/tokens/RAACNFT.sol";
import "../../contracts/core/primitives/RAACHousePrices.sol";
import "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import "../../contracts/core/collectors/FeeCollector.sol";
import "../../contracts/core/collectors/Treasury.sol";
import "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
contract baseTest is Test {
// Protocol contracts
crvUSDToken public crvUSD;
RAACToken public raacToken;
veRAACToken public veToken;
RAACHousePrices public housePrices;
RAACNFT public raacNFT;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
LendingPool public lendingPool;
StabilityPool public stabilityPool;
Treasury public treasury;
Treasury public repairFund;
FeeCollector public feeCollector;
RAACMinter public minter;
RAACReleaseOrchestrator public releaseOrchestrator;
// Test accounts
address public admin = makeAddr("admin");
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
// Constants
uint256 public constant INITIAL_MINT = 1000 ether;
uint256 public constant HOUSE_PRICE = 100 ether;
uint256 public constant INITIAL_PRIME_RATE = 0.1e27; // 10% in RAY
uint256 public constant TAX_RATE = 200; // 2% in basis points
uint256 public constant BURN_RATE = 50; // 0.5% in basis points
function setUp() public virtual {
vm.startPrank(admin);
// Deploy base tokens
crvUSD = new crvUSDToken(admin);
crvUSD.setMinter(admin);
raacToken = new RAACToken(admin, TAX_RATE, BURN_RATE);
veToken = new veRAACToken(address(raacToken));
releaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
// Deploy mock oracle
housePrices = new RAACHousePrices(admin);
housePrices.setOracle(admin);
// Deploy NFT
raacNFT = new RAACNFT(address(crvUSD), address(housePrices), admin);
// Deploy pool tokens
rToken = new RToken("RToken", "RT", admin, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", admin);
deToken = new DEToken("DEToken", "DEToken", admin, address(rToken));
// Deploy core components
treasury = new Treasury(admin);
repairFund = new Treasury(admin);
feeCollector =
new FeeCollector(address(raacToken), address(veToken), address(treasury), address(repairFund), admin);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(housePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(admin);
minter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(treasury));
// Initialize contracts
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veToken), true);
raacToken.manageWhitelist(admin, true);
raacToken.setMinter(admin);
raacToken.mint(user2, INITIAL_MINT);
raacToken.mint(user3, INITIAL_MINT);
raacToken.setMinter(address(minter));
bytes32 FEE_MANAGER_ROLE = feeCollector.FEE_MANAGER_ROLE();
bytes32 EMERGENCY_ROLE = feeCollector.EMERGENCY_ROLE();
bytes32 DISTRIBUTOR_ROLE = feeCollector.DISTRIBUTOR_ROLE();
feeCollector.grantRole(FEE_MANAGER_ROLE, admin);
feeCollector.grantRole(EMERGENCY_ROLE, admin);
feeCollector.grantRole(DISTRIBUTOR_ROLE, admin);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
raacToken.transferOwnership(address(minter));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(minter),
address(crvUSD),
address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
// Setup test environment
crvUSD.mint(user1, INITIAL_MINT);
crvUSD.mint(user2, INITIAL_MINT);
crvUSD.mint(user3, INITIAL_MINT);
housePrices.setHousePrice(1, HOUSE_PRICE);
vm.stopPrank();
}
// Helper functions
function mintCrvUSD(address to, uint256 amount) public {
vm.prank(admin);
crvUSD.mint(to, amount);
}
function setHousePrice(uint256 tokenId, uint256 price) public {
vm.prank(admin);
housePrices.setHousePrice(tokenId, price);
}
function calculateInterest(uint256 principal, uint256 rate, uint256 time) public pure returns (uint256) {
return principal * rate * time / 365 days / 1e27;
}
function warpAndAccrue(uint256 time) public {
vm.warp(block.timestamp + time);
lendingPool.updateState();
}
}
  • now create a pocs.sol inside test/foundry , and copy/paste this there :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "./baseTest.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockCurveVault is ERC4626 {
constructor(address _asset) ERC4626(ERC20(_asset)) ERC20("Mock Curve Vault", "mcrvUSD") {}
}
contract pocs is baseTest {
//poc here
}
  • the following PoC shows how user1 and user2 wasn't able to claim their rewards after the first distribution cycle due to the flowed formula :

function test_poc08() public {
uint256 duration = 365 days;
// Step 1: User1 and user2 deposits RAAC tokens to veRAACToken
vm.startPrank(address(minter));
raacToken.mint(user1, 1000 ether);
raacToken.mint(user2, 1000 ether);
raacToken.mint(admin, 10000 ether);
vm.stopPrank();
vm.startPrank(user1);
raacToken.approve(address(veToken), 1000 ether);
veToken.lock(1000 ether, duration);
vm.stopPrank();
vm.startPrank(user2);
raacToken.approve(address(veToken), 10000 ether);
veToken.lock(1000 ether, duration);
vm.stopPrank();
vm.startPrank(admin);
raacToken.approve(address(feeCollector), type(uint256).max);
// Step 3: Collect fees (80% goes to veRAACToken holders as per _initializeFeeTypes)
uint256 feeAmount = 1000 ether;
feeCollector.collectFee(feeAmount, 0);
feeCollector.distributeCollectedFees();
vm.stopPrank();
// Step 5: User1 claims their rewards (should get all ether since they're the only holder)
vm.prank(user1);
uint256 user1Rewards = feeCollector.claimRewards(user1);
vm.prank(user2);
uint256 user2Rewards = feeCollector.claimRewards(user2);
skip(10 days);
// distribute again :
vm.startPrank(admin);
feeCollector.collectFee(feeAmount, 0);
feeCollector.distributeCollectedFees();
vm.stopPrank();
// user1 and user2 try to claim again , and it reverts :
vm.prank(user1);
vm.expectRevert();
user1Rewards = feeCollector.claimRewards(user1);
vm.prank(user2);
vm.expectRevert();
user2Rewards = feeCollector.claimRewards(user2);
}

Impact

  • Rewards become unclaimable after first distribution cycle

  • Protocol accumulates dead funds with each distribution
    High severity: Direct fund loss + protocol functionality failure

Tools Used

foundry , manual review

Recommendations

Implementing checkpoint-based tracking—for example, by recording each distribution separately and allowing users to claim their share of each epoch—would preserve historical distribution data and enable correct cumulative claims.

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.