Core Contracts

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

DOS in FeeCollector::claimRewards which prevents users from claiming rewards in subsequent periods

Summary

In the RAAC protocol, users with veRAAC tokens are eligible to claim protocol rewards through the FeeCollector::claimRewards function. The protocol distributes rewards over 7-day periods, ensuring fair allocation based on voting power. However, due to an improper state update, users who claimed rewards in a previous period are unable to claim rewards in a new period—even when they are eligible. This occurs because their claim state is incorrectly tied to totalDistributed from the previous period, preventing them from receiving rewards in the next cycle. This issue creates a denial-of-service (DoS) vulnerability, significantly reducing user incentives to hold veRAAC tokens.

Vulnerability Details

Users with veRAAC tokens are eligible to claim protocol rewards via FeeCollector::claimRewards:

/**
* @notice Claims accumulated rewards for a user
* @param user Address of the user claiming rewards
* @return amount Amount of rewards claimed
*/
function claimRewards(address user) external override nonReentrant whenNotPaused returns (uint256) {
if (user == address(0)) revert InvalidAddress();
uint256 pendingReward = _calculatePendingRewards(user);
if (pendingReward == 0) revert InsufficientBalance();
// Reset user rewards before transfer
userRewards[user] = totalDistributed;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}

Protocol fees are distributed in 7 day periods via FeeCollector::distributeCollectedFees which calls FeeCollector::_processDistributions. This is where the periods are set. See below:

/**
* @dev Processes the distribution of collected fees
* @param totalFees Total fees to distribute
* @param shares Distribution shares for different stakeholders
*/
function _processDistributions(uint256 totalFees, uint256[4] memory shares) internal {
uint256 contractBalance = raacToken.balanceOf(address(this));
if (contractBalance < totalFees) revert InsufficientBalance();
if (shares[0] > 0) {
uint256 totalVeRAACSupply = veRAACToken.getTotalVotingPower();
if (totalVeRAACSupply > 0) {
TimeWeightedAverage.createPeriod(
distributionPeriod,
block.timestamp + 1,
7 days,
shares[0],
totalVeRAACSupply
);
totalDistributed += shares[0];
} else {
shares[3] += shares[0]; // Add to treasury if no veRAAC holders
}
}
if (shares[1] > 0) raacToken.burn(shares[1]);
if (shares[2] > 0) raacToken.safeTransfer(repairFund, shares[2]);
if (shares[3] > 0) raacToken.safeTransfer(treasury, shares[3]);
}

The bug occurs due to the following line in FeeCollector::_calculatePendingRewards:

return share > userRewards[user] ? share - userRewards[user] : 0;

This line is designed to prevent user's from repeatedly claiming rewards. In FeeCollector::claimRewards, userRewards[user] is set to totalDistributed. totalDistributed is updated to the amount of shares that veRAAC holders are eligible to claim during the distribution period. The issue is that when a new period starts, userRewards[user] is still set to totalDistributed which means that when rewards have been distributed in the new period, a user who has claimed in the previous period will not be able to claim rewards for the new period which they are eligible for.

Proof Of Code (POC)

This test was run in the FeeCollector.test.js file in the "Fee Collection and Distribution" describe block

it("user cannot claim rewards in new distribution period", async function () {
//c for testing purposes
//c users have already locked tokens in the beforeEach block
//c wait for half of the year so the user's biases change
await time.increase(ONE_YEAR / 2); // users voting power should be reduced as duration has passed
//c get user biases
const user1Bias = await veRAACToken.getVotingPower(user1.address);
const user2Bias = await veRAACToken.getVotingPower(user2.address);
const expectedTotalSupply = user1Bias + user2Bias;
console.log("User 1 Bias: ", user1Bias);
console.log("User 2 Bias: ", user2Bias);
console.log("Expected Total Supply: ", expectedTotalSupply);
//c get actual total supply
const actualTotalSupply = await veRAACToken.totalSupply();
console.log("Actual Total Supply: ", actualTotalSupply);
//c due to improper tracking, expectedtotalsupply will be less than the actual total supply
assert(expectedTotalSupply < actualTotalSupply);
//c get the share of totalfees allocated to veRAAC holders
const tx1 = await feeCollector.connect(owner).distributeCollectedFees();
const tx1Receipt = await tx1.wait();
await feeCollector.connect(user1).claimRewards(user1.address);
// Calculate gross amounts needed including transfer tax but make these fees less than the user1 claimed last time to show that the user cannot claim rewards in a new period
const taxRate = SWAP_TAX_RATE + BURN_TAX_RATE; // 150 basis points (1.5%)
const grossMultiplier =
BigInt(BASIS_POINTS * BASIS_POINTS) /
BigInt(BASIS_POINTS * BASIS_POINTS - taxRate * BASIS_POINTS);
const protocolFeeGross =
(ethers.parseEther("40") * grossMultiplier) / BigInt(10000);
const lendingFeeGross =
(ethers.parseEther("30") * grossMultiplier) / BigInt(10000);
const swapTaxGross =
(ethers.parseEther("10") * grossMultiplier) / BigInt(10000);
// Collect fees
await feeCollector.connect(user1).collectFee(protocolFeeGross, 0);
await feeCollector.connect(user1).collectFee(lendingFeeGross, 1);
await feeCollector.connect(user1).collectFee(swapTaxGross, 6);
//c allow the period to be over so a new period can begin
await time.increase(WEEK + 10);
const tx2 = await feeCollector.connect(owner).distributeCollectedFees();
const tx2Receipt = await tx2.wait();
const eventLogs = tx2Receipt.logs;
let veRAACShare;
for (let log of eventLogs) {
if (log.fragment && log.fragment.name === "FeeDistributed") {
veRAACShare = log.args[0];
break;
}
}
console.log("veRAAC Share: ", veRAACShare);
//c get pending rewards user 1 in new period
const user1PendingRewardsP2 = await feeCollector.getPendingRewards(
user1.address
);
console.log(
"User 1 Pending Rewards in New Period: ",
user1PendingRewardsP2
);
//c user1 pending rewards is 0 even though fees have been distributed and user1 has veRAAC tokens and veRAAC holder shares are not 0
assert(veRAACShare > 0);
assert(user1PendingRewardsP2 == 0);
});

Impact

Users Are Unfairly Denied Rewards: Users who claimed in the previous period lose access to their next period's rewards, even when they still hold veRAAC. This reduces incentives to participate in veRAAC staking.

Breaks Protocol Tokenomics & Governance Incentives: The veRAAC model is designed to encourage long-term participation. If rewards are unreliable, users will unstake, decreasing veRAAC's governance participation.

Tools Used

Manual Review, Hardhat

Recommendations

o fix this issue, the contract should not apply the reward check when a new period starts, ensuring users receive their full shares.

Updated Fix in claimRewards()
Check if the user is in a new period
If yes, reset their rewards and give them their full share
If not, apply the normal check

By implementing this fix, users will always receive their rewards in every eligible period, maintaining protocol trust and incentivizing long-term participation.

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!