Core Contracts

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

Incorrect Reward Claim Logic in FeeCollector::claimRewards Causes Denial of Service

Summary

The claimRewards function in the FeeCollector contract is designed to allow users to claim their accumulated rewards based on their voting power. The rewards are calculated using the formula:

pendingReward = share - userRewards[user]

where:

share = (totalDistributed * userVotingPower) / totalVotingPower

However, the function mistakenly resets the user's reward record by setting userRewards[user] equal to totalDistributed rather than updating it to the user's current proportional share. This error means that once a user claims rewards, their recorded reward balance is set too high, so subsequent calculations yield a pending reward of zero. As a result, users who have claimed previously may never be able to claim additional rewards even if their voting power increases, thus causing a denial of service for reward claims.

Vulnerability Details

How It Begins

  1. Reward Calculation Flow:
    The reward claim process involves:

    • Calculating the user's share:

      share = (totalDistributed * veRAACToken.getVotingPower(user)) / veRAACToken.getTotalVotingPower()

    • Determining the pending reward as:

      pendingReward = share - userRewards[user]

  2. Faulty Reset Logic in claimRewards:
    In the claimRewards function, after calculating pendingReward, the function resets userRewards[user] to totalDistributed:

    userRewards[user] = totalDistributed;

    This is incorrect. Instead, userRewards[user] should be updated to the user's share value, not the total distributed amount. Resetting it to totalDistributed overstates the user's cumulative claimed rewards. Therefore, subsequent reward calculations will yield:

    pendingReward = share - totalDistributed

    which is zero or negative, effectively blocking further claims.

  3. Impact of the Bug:
    As users claim rewards, their userRewards value becomes too high, and future claims return 0 even when their voting power increases. This results in a denial of service (DoS) for reward claims, undermining both reward distribution and user trust in the protocol.

Code Excerpts Highlighting the Issue

claimRewards Function:

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();
// Incorrect: resetting user rewards to totalDistributed causes subsequent pendingReward to be zero
userRewards[user] = totalDistributed;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}

_calculatePendingRewards Function:

function _calculatePendingRewards(address user) internal view returns (uint256) {
uint256 userVotingPower = veRAACToken.getVotingPower(user);
if (userVotingPower == 0) return 0;
uint256 totalVotingPower = veRAACToken.getTotalVotingPower();
if (totalVotingPower == 0) return 0;
uint256 share = (totalDistributed * userVotingPower) / totalVotingPower;
@> return share > userRewards[user] ? share - userRewards[user] : 0;
}

The error in claimRewards prevents future rewards from accumulating, as userRewards is set to an excessive value.

Proof of Concept

Scenario Walkthrough

  1. Initial Reward Claim:

    • A user (e.g., Alice) locks tokens and accumulates a certain voting power.

    • At time T1, totalDistributed is, say, 3,859,228e18.

    • The user's share is calculated as:

      share = (3,859,228e18 * AliceVotingPower) / TotalVotingPower

    • Suppose Alice’s pending reward is computed as 964,807e18.

    • When she claims, the function resets her reward record to totalDistributed (3,859,228e18).

  2. Subsequent Reward Claims:

    • At time T2, if Alice's voting power increases, her new share is recalculated. However, since userRewards[ALICE] is already set to a very high value (totalDistributed), the pending reward becomes:

      pendingReward = newShare - totalDistributed; approx 0

    • As a result, Alice cannot claim any further rewards, causing a denial of service.

Test Suite Example

Below is the test suite demonstrating the issue:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {FeeCollector} from "../src/core/collectors/FeeCollector.sol";
import {Treasury} from "../src/core/collectors/Treasury.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {IFeeCollector} from "../src/interfaces/core/collectors/IFeeCollector.sol";
import {Test, console} from "forge-std/Test.sol";
contract FeeCollectorTest is Test {
FeeCollector feeCollector;
Treasury treasury;
RAACToken raacToken;
veRAACToken veToken;
address RAAC_OWNER = makeAddr("RAAC_OWNER");
address RAAC_MINTER = makeAddr("RAAC_MINTER");
uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
address TREASURY_ADMIN = makeAddr("TREASURY_ADMIN");
address FEE_COLLECTOR_ADMIN = makeAddr("FEE_COLLECTOR_ADMIN");
address REPAIR_FUND_ADDRESS = makeAddr("REPAIR_FUND_ADDRESS");
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
address FEES_PAYER = makeAddr("FEES_PAYER");
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veToken = new veRAACToken(address(raacToken));
vm.stopPrank();
vm.startPrank(TREASURY_ADMIN);
treasury = new Treasury(TREASURY_ADMIN);
vm.stopPrank();
vm.startPrank(FEE_COLLECTOR_ADMIN);
feeCollector = new FeeCollector(
address(raacToken), address(veToken), address(treasury), REPAIR_FUND_ADDRESS, FEE_COLLECTOR_ADMIN
);
vm.stopPrank();
getveRaacTokenForProposer();
}
function getveRaacTokenForProposer() private {
uint256 LOCK_AMOUNT = 1_000_000e18;
uint256 LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(ALICE, LOCK_AMOUNT);
raacToken.mint(BOB, LOCK_AMOUNT);
raacToken.mint(CHARLIE, LOCK_AMOUNT);
raacToken.mint(DEVIL, LOCK_AMOUNT);
raacToken.mint(FEES_PAYER, 100_000_000e18);
vm.stopPrank();
vm.startPrank(FEES_PAYER);
// kinda max
raacToken.approve(address(feeCollector), 100_000_000e18);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BOB);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(CHARLIE);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(DEVIL);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
}
function testClaimFunctionTotalDistributionMisalignmentCausesDoS() public {
console.log("\n Before fee collection...");
console.log("totalDistributed : ", feeCollector.totalDistributed());
console.log("Alice raac token balance: ", raacToken.balanceOf(ALICE));
console.log("Alice calimed rewards : ", feeCollector.userRewards(ALICE));
for (uint8 i = 0; i < 8; i++) {
vm.startPrank(FEES_PAYER);
feeCollector.collectFee(1_000_000e18, i);
vm.stopPrank();
}
console.log("\n Before Distribution...");
console.log("totalDistributed : ", feeCollector.totalDistributed());
console.log("Alice raac token balance: ", raacToken.balanceOf(ALICE));
console.log("Alice calimed rewards : ", feeCollector.userRewards(ALICE));
vm.startPrank(FEE_COLLECTOR_ADMIN);
feeCollector.distributeCollectedFees();
vm.stopPrank();
console.log("\n after Distribution...");
console.log("totalDistributed : ", feeCollector.totalDistributed());
console.log("Alice raac token balance: ", raacToken.balanceOf(ALICE));
console.log("Alice calimed rewards : ", feeCollector.userRewards(ALICE));
// totalDistributed: 3859228000000000000000000 or 3_859_228e18
vm.startPrank(ALICE);
feeCollector.claimRewards(ALICE);
vm.stopPrank();
console.log("\n After Distribution claim...");
console.log("totalDistributed : ", feeCollector.totalDistributed());
console.log("Alice raac token balance: ", raacToken.balanceOf(ALICE));
console.log("Alice calimed rewards : ", feeCollector.userRewards(ALICE));
// totalDistributed: 3859228000000000000000000 or 3_859_228e18
// Alice's rewards : 3859228000000000000000000 or 3_859_228e18
// assertEq(feeCollector.totalDistributed(), feeCollector.userRewards(ALICE));
console.log("votingpower: ", veToken.getVotingPower(ALICE, block.timestamp));
console.log("totalVoting: ", veToken.getTotalVotingPower());
// correct calc... this is true only for fresh claimers
// alice voting power: 250000000000000000000000
// total voting power: 1000000000000000000000000
// totaldistriuted: 3859228000000000000000000
// alice claimed rewards: 0
// alice share: (3859228000000000000000000 * 250000000000000000000000) / 1000000000000000000000000
// alice share: 964807000000000000000000
// 964807000000000000000000 > 0 => 964807000000000000000000 - 0 = 964807000000000000000000
// alice claimed rewards = 964807000000000000000000
uint256 newLockAmount = 10_000_000e18 - 1_000_000e18;
vm.startPrank(RAAC_MINTER);
raacToken.mint(ALICE, newLockAmount);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veToken), newLockAmount);
veToken.increase(newLockAmount);
vm.stopPrank();
console.log("votingpower: ", veToken.getVotingPower(ALICE, block.timestamp));
console.log("totalVoting: ", veToken.getTotalVotingPower());
// alice voting power has increased means rewards claim might also increased
vm.startPrank(ALICE);
// dos because totaldistriuted has been assigned to userRewards mistakenly.
vm.expectRevert(bytes4(keccak256("InsufficientBalance()")));
feeCollector.claimRewards(ALICE);
vm.stopPrank();
// below calc not effected due to the revert above because of wrong userRewards assignment.
// correct calculation...
// alice recorded reward balance after claim: 964807000000000000000000 or 964_807e18
// alice voting power: 4750000000000000000000000 or 4_750_000e18
// total voting power: 5500000000000000000000000 or 5_500_000e18
// totaldistriuted: 3859228000000000000000000 or 3_859_228e18
// alice share: (3859228000000000000000000 * 4750000000000000000000000) / 5500000000000000000000000
// alice share: 3332969636363636363636363
// 3332969636363636363636363 > 964807000000000000000000 == true
// 3332969636363636363636363 - 964807000000000000000000 = 2368162636363636363636363
// alice claimed rewards = 2368162636363636363636363
console.log("\n After second claim...");
console.log("totalDistributed : ", feeCollector.totalDistributed());
console.log("Alice raac token balance: ", raacToken.balanceOf(ALICE));
console.log("Alice calimed rewards : ", feeCollector.userRewards(ALICE));
}
}

Observations from Test Output

  • Initial Claim:
    ALICE is able to claim an initial reward based on her voting power.

  • After Voting Power Increase:
    When ALICE increases her voting power, the subsequent call to claimRewards reverts because userRewards[ALICE] is incorrectly set to totalDistributed from the first claim, which is far higher than her proportional share.

  • Resulting DoS:
    ALICE (and similarly other users) are effectively locked out of future reward claims.

Impact

  • Denial of Service for Reward Claims:
    Users who have previously claimed rewards cannot claim further rewards, even if their voting power increases, as their recorded rewards are set too high. This leads to a persistent denial of service for reward claims.

  • Economic Distortion:
    The miscalculation undermines the fairness of reward distribution. Users receive less than their due share, which can discourage participation and damage trust in the protocol.

  • Governance Implications:
    Reduced rewards affect the overall incentive structure and may distort governance, as rewards are a critical component of stakeholder influence.

Tools Used

  • Manual Review

  • Foundry

Recommendations

To fix this vulnerability, update the claimRewards function so that it correctly updates userRewards[user] to reflect the user’s proportional share of totalDistributed rather than resetting it to totalDistributed. The correct approach is to set userRewards[user] equal to:

userRewards[user] = pendingReward;

Proposed Diff for claimRewards Function

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();
// Incorrect: setting userRewards to totalDistributed causes future claims to yield zero
- userRewards[user] = totalDistributed;
+ // Correctly update userRewards to the user's current share of totalDistributed
+ userRewards[user] = pendingReward;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}
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!