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();
userRewards[user] = totalDistributed;
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];
}
}
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 () {
await time.increase(ONE_YEAR / 2);
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);
const actualTotalSupply = await veRAACToken.totalSupply();
console.log("Actual Total Supply: ", actualTotalSupply);
assert(expectedTotalSupply < actualTotalSupply);
const tx1 = await feeCollector.connect(owner).distributeCollectedFees();
const tx1Receipt = await tx1.wait();
await feeCollector.connect(user1).claimRewards(user1.address);
const taxRate = SWAP_TAX_RATE + BURN_TAX_RATE;
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);
await feeCollector.connect(user1).collectFee(protocolFeeGross, 0);
await feeCollector.connect(user1).collectFee(lendingFeeGross, 1);
await feeCollector.connect(user1).collectFee(swapTaxGross, 6);
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);
const user1PendingRewardsP2 = await feeCollector.getPendingRewards(
user1.address
);
console.log(
"User 1 Pending Rewards in New Period: ",
user1PendingRewardsP2
);
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.