Core Contracts

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

Zero Reward for users with Low Voting Power

Summary

When veRAAC token holders lock differing amounts, the fee distribution calculation may round down the reward share of users with relatively low voting power to zero. As a consequence, users who lock only a small number of tokens receive no claimable rewards even when new fees have been distributed while users with large locks receive almost all of the rewards.

Vulnerability Details

FeeCollector’s reward claim logic depends on the pending reward,
computed as follows:

pendingReward = share – userRewards[user]

where share = (totalDistributed * userVotingPower) / totalVotingPower
as shown below

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

Take for instance the following scenario:

  1. Alice (user1) locks 1 ether for 365 days which gives her
    a voting power of 2.5*10^17

  2. Bob (user2) locks 20000 wei for 365 days which gives him
    a voting power of 5000

  3. Total Voting Power is computed as:
    totalVotingPower =

  4. When rewards are distributed, FeeCollector calculates shares as follows:

    • For Alice :
      share_alice =

Because her voting power is almost the entirety of the total, this share comes out nearly equal to totalDistributed.

  • For Bob:
    share_bob =
    Even if totalDistributed is a significant number, multiplying by 5000 and then divided by approximately 2.5*10^17, using integer division which truncates decimals results in share_bob rounding down to 0.

Alice, with her large voting power, receives close to the entire proportion of the distributed rewards.
As a result, when Bob calls claimRewards, his pending reward is calculated as zero. Even if rewards are later distributed, the proportional amount due to his lock remains too small,
and thus he receives nothing.

Proof Of Concept

See how to integrate foundry to hardhat project
. Create a new file POC.t.sol in project /test/ folder . Paste the poc and run forge test --mt test_POC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "contracts/core/tokens/RAACToken.sol";
import "contracts/core/tokens/veRAACToken.sol";
import "contracts/core/collectors/FeeCollector.sol";
contract FeeCollectorTest is Test {
RAACToken public raacToken;
veRAACToken public veRToken;
FeeCollector public feeCollector;
address public owner;
address public user1;
address public user2;
address public treasury;
address public newTreasury;
address public repairFund;
address public emergencyAdmin;
uint256 constant BASIS_POINTS = 10000;
uint256 constant WEEK = 7 * 24 * 3600;
uint256 constant ONE_YEAR = 365 * 24 * 3600;
uint256 constant INITIAL_MINT = 10000 ether;
uint256 constant SWAP_TAX_RATE = 100; // 1%
uint256 constant BURN_TAX_RATE = 50; // 0.5%
struct FeeType {
uint256 veRAACShare;
uint256 burnShare;
uint256 repairShare;
uint256 treasuryShare;
}
FeeType public defaultFeeType;
function setUp() public {
owner = makeAddr("owner");
user1 = makeAddr("alice");
user2 = makeAddr("bob");
treasury = makeAddr("treasury");
newTreasury = makeAddr("newTreasury");
repairFund = makeAddr("repairFund");
emergencyAdmin = makeAddr("emergencyAdmin");
vm.startPrank(owner);
raacToken = new RAACToken(owner, SWAP_TAX_RATE, BURN_TAX_RATE);
veRToken = new veRAACToken(address(raacToken));
feeCollector = new FeeCollector(
address(raacToken),
address(veRToken),
treasury,
repairFund,
owner
);
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veRToken), true);
raacToken.setMinter(owner);
veRToken.setMinter(owner);
feeCollector.grantRole(feeCollector.FEE_MANAGER_ROLE(), owner);
feeCollector.grantRole(feeCollector.EMERGENCY_ROLE(), emergencyAdmin);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), owner);
vm.stopPrank();
}
function test_POC() public {
uint256 protocolFeeGross = 50 ;
uint256 lendingFeeGross = 30 ;
uint256 swapTaxGross = 20 ;
vm.startPrank(owner);
raacToken.mint(address(user1), 1 ether );
raacToken.mint(address(user2), 20000 );
raacToken.mint(owner, 200);
raacToken.approve(address(feeCollector), 200 );
feeCollector.collectFee(protocolFeeGross, 0);
feeCollector.collectFee(lendingFeeGross, 1);
feeCollector.collectFee(swapTaxGross, 6);
vm.stopPrank();
// user1 locks tokens
vm.startPrank(user1);
raacToken.approve(address(veRToken), 1 ether);
veRToken.lock(1 ether, ONE_YEAR);
vm.stopPrank();
// user2 locks tokens
vm.startPrank(user2);
raacToken.approve(address(veRToken), 20000);
veRToken.lock(20000, ONE_YEAR);
vm.stopPrank();
// First distribution period
vm.prank(owner);
feeCollector.distributeCollectedFees();
// User1 claims first time
vm.prank(user1);
feeCollector.claimRewards(user1);
// no rewards for user 2
vm.prank(user2);
vm.expectRevert(abi.encodeWithSignature("InsufficientBalance()"));
feeCollector.claimRewards(user2);
}
}

Impact

Users with small veRAAC balances will see their computed share rounded to zero, meaning they will never be able to claim a reward when distributed

Tools Used

Manual Review

Recommendations

Consider normalizing the voting power values so that even low-value voters have a meaningful contribution in the division.

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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