Core Contracts

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

Fee Collection System Undermines Voter Incentives Due to Supply/Power Mismatch in Reward Distribution

Summary

The FeeCollector.sol contract's reward distribution mechanism has a critical flaw in how it calculates user reward shares. While a user's voting power naturally decays over their lock period, this decaying power is compared against the non-decaying total supply of veRAAC tokens when determining reward shares. This mismatch causes users to receive disproportionately smaller rewards as their locks ages, effectively punishing long-term holders and resulting in protocol fees becoming permanently locked in the contract.

Vulnerability Details

The vulnerability stems from a mismatch between how voting power decays and how rewards are calculated in the FeeCollector's distribution mechanism. Let's examine how fees flow through the system and where they get trapped:

The distribution process follows these steps:

  1. Protocol fees accumulate in the CollectedFees struct

  2. When distributeCollectedFees() is called, it:

  • Calculates the total fees to distribute

  • Allocates 80% to veRAACToken holders based on voting power

  • Records these reward shares in the contract state

  • Deletes all collected fees with delete collectedFees

The critical issue emerges from how rewards are calculated versus how they're tracked:

share = (totalDistributed * userVotingPower) / totalVotingPower;

Where:

  • userVotingPower = User's voting power that decays linearly over the lock period

  • totalVotingPower = Total supply of veRAAC tokens which does not decay

Consider a user who locks 1000 RAAC tokens for 2 years:

  1. At lock creation (t=0):

  • Voting Power = 500 (scaled based on lock duration)

  • veRAAC Total Supply = 1000

  • Reward Share = 500/1000 = 50% of rewards

  1. Near lock expiry (t=700 days):

  • Voting Power ≈ 15.75 (due to linear decay)

  • veRAAC Total Supply = still 1000 (no decay)

  • Reward Share = 15.75/1000 = 1.575% of rewards

Because distributeCollectedFees() deletes all fee records after calculating these diminished rewards:

delete collectedFees;

Each distribution cycle permanently locks away an increasing portion of protocol fees as locks age. This creates a compounding loss of value that should have been distributed to protocol participants.

Impact

High:

  • Severely penalizes long-term token holders by drastically reducing their rewards as their lock ages

  • Results in protocol fees becoming permanently trapped in the contract

  • Creates misaligned incentives that work against the protocol's goal of encouraging long-term holding

Likelihood

High - This issue will affect every user who locks tokens and every fee distribution event. The impact becomes more severe as locks age and will be particularly pronounced for longer lock durations.

Proof of Concept

  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. Copy 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;
// Add 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);
// Add 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 and Bob for testing
vm.startPrank(raacTokenMinter);
raacToken.mint(Alice, LOCK_AMOUNT + ADDITIONAL_LOCK_AMOUNT);
raacToken.mint(Bob, LOCK_AMOUNT + ADDITIONAL_LOCK_AMOUNT);
vm.stopPrank();
}
function test_RewardsDistributionAsTimePassesBug() public {
uint256 amountToCollectAndDistribute = 1000 ether;
uint256 feeAmount = 1000 ether;
// Alice locks tokens to get voting power
vm.startPrank(Alice);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, MIN_LOCK_DURATION * 2);
vm.stopPrank();
console.log("Alice initial Power: ", veRaacToken.getVotingPower(Alice));
// Bob locks tokens to get voting power
vm.startPrank(Bob);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, MIN_LOCK_DURATION * 2);
vm.stopPrank();
console.log("Bob initial Power: ", veRaacToken.getVotingPower(Bob));
// Mint RAAC to a fee payer
address feePayer = makeAddr("feePayer");
vm.startPrank(raacTokenMinter);
raacToken.mint(feePayer, amountToCollectAndDistribute);
vm.stopPrank();
// Collect fees
vm.startPrank(feePayer);
raacToken.approve(address(feeCollector), amountToCollectAndDistribute);
feeCollector.collectFee(amountToCollectAndDistribute, 0); // Using protocolFees type
vm.stopPrank();
// Simulate fee collection
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: ", initialAliceRewards);
// Check Bob's initial rewards
uint256 initialBobRewards = feeCollector.getPendingRewards(Bob);
console.log("Bob rewards after first round: ", initialBobRewards);
// Warp to towards end of lock
vm.warp(block.timestamp + 700 days);
// Collect more fees
address feePayer2 = makeAddr("feePayer2");
vm.startPrank(raacTokenMinter);
raacToken.mint(feePayer2, amountToCollectAndDistribute);
vm.stopPrank();
vm.startPrank(feePayer2);
raacToken.approve(address(feeCollector), amountToCollectAndDistribute);
feeCollector.collectFee(amountToCollectAndDistribute, 0);
vm.stopPrank();
// Check powers when lock is at 700/730 days
console.log("Alice power towards end of lock: ", veRaacToken.getVotingPower(Alice));
console.log("Bob power towards end of lock: ", veRaacToken.getVotingPower(Bob));
// 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 distribution
uint256 secondAliceRewards = feeCollector.getPendingRewards(Alice);
console.log("Alice rewards for second distribution: ", secondAliceRewards);
// Check Bob's rewards after distribution
uint256 secondBobRewards = feeCollector.getPendingRewards(Bob);
console.log("Bob rewards for second distribution: ", secondBobRewards);
// Assert the amount Alice and Bob received is less than the fees accumalated
assertGt(amountToCollectAndDistribute, (secondAliceRewards + secondBobRewards));
// Assert No fees remain after distribution :: effectively losing fees forever
// NOTE: must make FeeCollector.sol::_calculateTotalFees() visibility public so that this function can be accessed
assert(feeCollector._calculateTotalFees() == 0);
// Log amount lost
console.log("Fees lost: ", amountToCollectAndDistribute - (secondAliceRewards + secondBobRewards));
}
}
  1. Run forge test -vv

  2. Logs:

Logs:
Alice initial Power: 500000000000000000000
Bob initial Power: 500000000000000000000
Alice rewards after first round: 400000000000000000000
Bob rewards after first round: 400000000000000000000
Alice power towards end of lock: 20547945205488320000
Bob power towards end of lock: 20547945205488320000
Alice rewards for second distribution: 32876712328781312000
Bob rewards for second distribution: 32876712328781312000
Fees lost: 934246575342437376000

Recommendations

Compare against total actual voting power rather than total supply:

function _calculatePendingRewards(address user) internal view returns (uint256) {
uint256 totalActualVotingPower = _calculateTotalActualVotingPower(); // <- needs implementing
if (totalActualVotingPower == 0) return 0;
uint256 userVotingPower = veRAACToken.getVotingPower(user);
return (totalDistributed * userVotingPower) / totalActualVotingPower;
}
Updates

Lead Judging Commences

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

veRAACToken::getTotalVotingPower returns non-decaying totalSupply while individual voting powers decay, causing impossible governance quorums and stuck rewards in FeeCollector

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

veRAACToken::getTotalVotingPower returns non-decaying totalSupply while individual voting powers decay, causing impossible governance quorums and stuck rewards in FeeCollector

Support

FAQs

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

Give us feedback!