Core Contracts

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

Missing Claim Delay Check and Last Claim Timestamp Update in FeeCollector::claimRewards: Denial of Service in Reward Claims

Summary

The claimRewards function in the FeeCollector contract is designed to allow users to claim their accumulated rewards based on their voting power. However, the function contains two critical oversights:

  1. It fails to update the last claim timestamp via the helper function _updateLastClaimTime, which is necessary for enforcing a minimum claim delay.

  2. It does not check that the minimum claim delay (e.g., MIN_CLAIM_INTERVAL) has passed since the last claim.

Without these checks, users can claim rewards too frequently. This not only violates the intended reward claim interval—potentially leading to abuse or misalignment in reward distribution—but also may cause a denial of service for users who have previously claimed rewards, as their recorded rewards are set to totalDistributed, preventing subsequent proper reward calculation.

Vulnerability Details

Flow of the Vulnerability

  • Reward Calculation:
    The function calculates the pending rewards using _calculatePendingRewards(user). If no rewards are pending, it reverts.

  • Faulty Reset of Reward State:
    Instead of updating the user's reward record to their proportional share, the function resets userRewards[user] to totalDistributed. This incorrect update can cause subsequent reward calculations to yield zero, particularly if the user's rewards have already been claimed.

  • Missing Claim Delay Enforcement:
    The contract maintains a state variable lastClaimTime and a helper function _updateLastClaimTime to record the timestamp of the last reward claim. However, claimRewards does not check whether the required minimum delay between claims has passed before allowing another claim. This oversight permits users to claim rewards repeatedly in rapid succession.

Code Visuals

Faulty 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 reset: sets userRewards to totalDistributed
userRewards[user] = totalDistributed;
// Transfer rewards to the user
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}

Missing Claim Delay Check:
There is no check like:

if (block.timestamp < lastClaimTime[user] + MIN_CLAIM_INTERVAL) revert ClaimTooFrequent();

nor is _updateLastClaimTime(user) called to update the claim timestamp after a reward claim.

Proof of Concept

Scenario Walkthrough

  1. Initial Claim:

    • User Alice claims her rewards for the first time. The function calculates her pending reward and successfully transfers tokens to her.

    • However, the function resets userRewards[ALICE] to totalDistributed rather than her proportional share, and it does not update lastClaimTime[ALICE].

  2. Subsequent Claim Attempt:

    • Shortly after the initial claim, Alice attempts to claim rewards again.

    • Due to the improper update, her pending reward is calculated as zero (or less than expected), or the system may prevent further accumulation, leading to a denial of service where subsequent claims always return 0.

  3. Test Suite Example:

Below is a Foundry test case demonstrating the vulnerability:

// 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;
uint256 initialRaacBurnTaxRateInBps = 150;
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");
// Additional addresses omitted for brevity.
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);
// Additional minting omitted for brevity.
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
}
function testClaimRewardsFailsOnSubsequentCallsDueToIncorrectReset() public {
// Simulate fee collection and distribution
for (uint8 i = 0; i < 8; i++) {
vm.startPrank(FEES_PAYER);
feeCollector.collectFee(1_000_000e18, i);
vm.stopPrank();
}
vm.startPrank(FEE_COLLECTOR_ADMIN);
feeCollector.distributeCollectedFees();
vm.stopPrank();
// ALICE claims rewards successfully the first time.
vm.startPrank(ALICE);
uint256 firstClaim = feeCollector.claimRewards(ALICE);
vm.stopPrank();
console.log("First claim reward: ", firstClaim);
// ALICE increases her voting power.
uint256 additionalLock = 10_000_000e18 - 1_000_000e18;
vm.startPrank(RAAC_MINTER);
raacToken.mint(ALICE, additionalLock);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veToken), additionalLock);
veToken.increase(additionalLock);
vm.stopPrank();
// ALICE attempts a second reward claim.
vm.startPrank(ALICE);
vm.expectRevert(bytes4(keccak256("InsufficientBalance()")));
feeCollector.claimRewards(ALICE);
vm.stopPrank();
}
}

How to Run the Test

  1. Create a Foundry Project:

    forge init my-foundry-project
  2. Place Contract Files:
    Ensure that FeeCollector.sol, RAACToken.sol, veRAACToken.sol, and dependencies are in the src directory.

  3. Create Test Directory:
    Create a test directory adjacent to src and add the above test file (e.g., FeeCollectorTest.t.sol).

  4. Run the Test:

    forge test --mt testClaimRewardsFailsOnSubsequentCallsDueToIncorrectReset -vv
  5. Expected Output:
    The first claim should succeed, but after increasing voting power, the second claim should revert with an InsufficientBalance error, demonstrating that the resetting of userRewards is incorrect and that no update is made to the last claim timestamp.

Impact

  • Denial of Service in Reward Claims:
    Users who claim rewards once will be locked out of future claims since userRewards is reset to totalDistributed, preventing accumulation of additional rewards.

  • Economic Distortion:
    This error leads to inaccurate reward distribution, whereby users may never receive rewards proportional to their increased voting power, thus misaligning incentives.

  • Governance Implications:
    Since rewards are tied to governance participation, an inability to claim rewards accurately undermines the incentive structure that ensures fair decision-making.

  • Long-Term Trust Erosion:
    Persistent reward claim failures can diminish user confidence in the protocol, discouraging participation and destabilizing the overall ecosystem.

Tools Used

  • Manual Review

  • Foundry

Recommendations

To resolve this vulnerability, the claimRewards function must be modified to:

  1. Correctly update userRewards[user] to reflect the user’s share of totalDistributed based on their voting power.

  2. Update the last claim timestamp by invoking _updateLastClaimTime(user) to enforce the minimum claim delay.

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();
- userRewards[user] = totalDistributed;
+ // Correctly update userRewards to the user's current share
+ userRewards[user] = pendingReward;
+ _updateLastClaimTime(user); // Update the last claim timestamp to enforce claim delay
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}

Additional Recommendations

  • Implement and Enforce a Minimum Claim Delay:
    Ensure that a check is in place to verify that the minimum claim interval has elapsed before allowing a new claim, e.g.:

    if (block.timestamp < lastClaimTime[user] + MIN_CLAIM_INTERVAL) revert ClaimTooFrequent();
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

_updateLastClaimTime not properly used to track rewards claim time

Support

FAQs

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

Give us feedback!