Core Contracts

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

FeeCollector pending rewards wrong share calculation

Summary

The FeeCollector::_calculatePendingRewards function uses wrong formula to calculate user share. This can lead to loss of funds for the user.

Vulnerability Details

We have to keep in mind that the userRewards is the amount of total reward that the user has already claimed, not only user's share of the total reward.

/contracts/core/collectors/FeeCollector.sol

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;
}

Then if voting power are not changed and second distribution have less or equal amount of fee to distribute than previous, the calculation will leave user with zero reward.

Asuming that the first and the second distribution was 1000 each. And we have 2 users with same voting power. After first distribution user have claimed their reward. So after second distributeCollectedFees call and before claimRewards call state will be:

totalVotingPower = 2;
userVotingPower = 1;
totalDistributed = 2000; // 1000 + 1000 for each distribution
userRewards[user] = 1000; // user has already claimed 500, but 1000 is stored

Then user's share will be calculated as:

share = (2000 * 1) / 2 = 1000;
return share > userRewards[user] ? share - userRewards[user] : 0;
return 1000 > 1000 ? 1000 - 1000 : 0;
return 0;

/contracts/core/collectors/FeeCollector.sol

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;
}

PoC (foundry)

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
import {RAACToken} from "src/core/tokens/RAACToken.sol";
import {veRAACToken} from "src/core/tokens/veRAACToken.sol";
import {FeeCollector} from "src/core/collectors/FeeCollector.sol";
contract FeeCollectorTestShareCalculation is Test {
RAACToken public raacToken;
veRAACToken public veToken;
FeeCollector public feeCollector;
function setUp() public {
raacToken = new RAACToken(address(this), 0, 0);
raacToken.setMinter(address(this));
veToken = new veRAACToken(address(raacToken));
feeCollector = new FeeCollector(
address(raacToken),
address(veToken),
makeAddr("treasury"),
makeAddr("repairFund"),
address(this));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veToken), true);
raacToken.manageWhitelist(address(this), true);
}
function test_shareCalculation() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
raacToken.mint(address(this), 2000); // will be used to collect fees
raacToken.mint(alice, 1000); // will be used to lock ve
raacToken.mint(bob, 1000); // will be used to lock ve
// set 2 users as ve holders
vm.startPrank(alice);
raacToken.approve(address(veToken), 1000);
veToken.lock(1000, 365 days);
vm.stopPrank();
vm.startPrank(bob);
raacToken.approve(address(veToken), 1000);
veToken.lock(1000, 365 days);
vm.stopPrank();
// collect fees
raacToken.approve(address(feeCollector), 2000);
feeCollector.collectFee(1000, 0);
// distribute fees
feeCollector.distributeCollectedFees();
// claim alice rewards
vm.prank(alice);
feeCollector.claimRewards(alice);
// claim bob rewards
vm.prank(bob);
feeCollector.claimRewards(bob);
skip(8 days);
// collect fees
feeCollector.collectFee(1000, 0);
// distribute fees
feeCollector.distributeCollectedFees();
// claim alice rewards
vm.prank(alice);
feeCollector.claimRewards(alice); // it will fail
}
}

Impact

User can lose rewards.

Recommendations

Share must be calculated from the user's unpaid rewards, not from the total rewards.

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;
+ return ((totalDistributed - userRewards[user]) * userVotingPower) / totalVotingPower;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge about 2 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.