Core Contracts

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

FeeCollector claimRewards() is not properly implemented

Summary

FeeCollector's claimRewards() is not properly implemented, some user may claim much more than expected while others may not be able to claim at all.

Vulnerability Details

veRAAC token holders calls claimRewards() to claim accumulated rewards, the pending rewards for a user is calculated based on totalDistributed and the user's voting power ratio.

FeeCollector.sol::_calculatePendingRewards()

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

Then userRewards[user] is set to totalDistributed and rewards are transferred to the user.

FeeCollector.sol::claimRewards()

uint256 pendingReward = _calculatePendingRewards(user);
if (pendingReward == 0) revert InsufficientBalance();
// Reset user rewards before transfer
@> userRewards[user] = totalDistributed;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);

The problem is that when new distribution comes in:

  1. User's voting power ratio may decrease dramatically, results in share less than userRewards[user], then user won't be able to claim any share of the new distribution;

  2. When new user comes to claim, totalDistributed is used to calculate the user's pending rewards, but the claimed amount is not deducted from totalDistributed, this leads to user can claim much more than expected.

Consider the following scenario:

  1. Alice is the only one who locks RAAC tokens into veRAACToken, and she holds 100 veRAAC tokens;

  2. 800 RAAC token rewards are distributed, totalDistributed becomes 800, Alice claims all of the 800 RAAC tokens and userRewards[Alice] is updated to 800;

  3. Then Bob locks into veRAACToken and mints 100 veRAAC tokens;

  4. In the next period, another 800 RAAC token rewards are distributed to veRAAC token holders, totalDistributed is updated to 1600;

  5. Currently Alice's voting power ratio is around 50%, however, she is not able to claim any of the rewards because her rewards share is calculated as share ~= totalDistributed * 50% ~= 800, no larger than userRewards[Alice] which is 800;

  6. Bob's voting power ratio is the same as Alice, when he claims, the claimed reward amount by Alice is not deducted from totalDistributed, so Bob's share is around 800, larger than userRewards[Bob] which is default to 0, as a result, Bob is able to claim around 800 RAAC tokens, much more than expected.

Impact

User claims incorrect amount of rewards.

POC

Please run forge test --mt testAudit_RewardsAreWronglyClaimed.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console, stdError} from "forge-std/Test.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/DeToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/tokens/veRAACToken.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/core/collectors/FeeCollector.sol";
import "../contracts/core/collectors/Treasury.sol";
contract Audit is Test {
using WadRayMath for uint256;
using SafeCast for uint256;
address owner = makeAddr("Owner");
address repairFund = makeAddr("RepairFund");
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACHousePrices raacHousePrices;
crvUSDToken crvUSD;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
RAACToken raacToken;
RAACNFT raacNft;
veRAACToken veRaacToken;
RAACMinter raacMinter;
FeeCollector feeCollector;
Treasury treasury;
function setUp() public {
vm.warp(1 days);
raacHousePrices = new RAACHousePrices(owner);
// Deploy tokens
raacToken = new RAACToken(owner, 100, 50);
veRaacToken = new veRAACToken(address(raacToken));
crvUSD = new crvUSDToken(owner);
rToken = new RToken("RToken", "RToken", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
raacNft = new RAACNFT(address(crvUSD), address(raacHousePrices), owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
// Deploy Treasury and FeeCollector
treasury = new Treasury(owner);
feeCollector = new FeeCollector(
address(raacToken),
address(veRaacToken),
address(treasury),
repairFund,
owner
);
// Deploy LendingPool
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNft),
address(raacHousePrices),
0.1e27
);
lendingPool.transferOwnership(owner);
// Deploy stabilityPool Proxy
bytes memory data = abi.encodeWithSelector(
StabilityPool.initialize.selector,
address(rToken),
address(deToken),
address(raacToken),
address(owner),
address(crvUSD),
address(lendingPool)
);
address stabilityPoolProxy = address(
new TransparentUpgradeableProxy(
address(new StabilityPool(owner)),
owner,
data
)
);
stabilityPool = StabilityPool(stabilityPoolProxy);
// RAACMinter
raacMinter = new RAACMinter(
address(raacToken),
address(stabilityPool),
address(lendingPool),
owner
);
// Initialization
vm.startPrank(owner);
raacHousePrices.setOracle(owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
stabilityPool.setRAACMinter(address(raacMinter));
lendingPool.setStabilityPool(address(stabilityPool));
raacToken.setMinter(address(raacMinter));
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(raacMinter), true);
raacToken.manageWhitelist(address(stabilityPool), true);
vm.stopPrank();
vm.label(address(crvUSD), "crvUSD");
vm.label(address(rToken), "RToken");
vm.label(address(debtToken), "DebtToken");
vm.label(address(deToken), "DEToken");
vm.label(address(raacToken), "RAACToken");
vm.label(address(raacNft), "RAAC NFT");
vm.label(address(lendingPool), "LendingPool");
vm.label(address(stabilityPool), "StabilityPool");
vm.label(address(raacMinter), "RAACMinter");
vm.label(address(veRaacToken), "veRAAC");
vm.label(address(feeCollector), "FeeCollector");
vm.label(address(treasury), "Treasury");
vm.label(repairFund, "RepairFund");
}
function testAudit_RewardsAreWronglyClaimed() public {
address alice = makeAddr("Alice");
deal(address(raacToken), alice, 100e18);
vm.startPrank(alice);
raacToken.approve(address(veRaacToken), 100e18);
veRaacToken.lock(100e18, 365 days);
vm.stopPrank();
uint256 distributeAmount = 1000e18;
address feeDistributor = makeAddr("FeeDistributor");
vm.startPrank(owner);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), feeDistributor);
raacToken.manageWhitelist(feeDistributor, true);
vm.stopPrank();
// Distribute 1000 RAAC tokens
deal(address(raacToken), feeDistributor, distributeAmount);
vm.startPrank(feeDistributor);
raacToken.approve(address(feeCollector), distributeAmount);
feeCollector.collectFee(distributeAmount, 0);
feeCollector.distributeCollectedFees();
vm.stopPrank();
// Alice claims 800 RAAC tokens
feeCollector.claimRewards(alice);
assertEq(raacToken.balanceOf(alice), 800e18);
// Bob locks into veRAAC
address bob = makeAddr("Bob");
deal(address(raacToken), bob, 100e18);
vm.startPrank(bob);
raacToken.approve(address(veRaacToken), 100e18);
veRaacToken.lock(100e18, 365 days);
vm.stopPrank();
vm.warp(block.timestamp + 7 days);
// Distribute another 1000 RAAC tokens
deal(address(raacToken), feeDistributor, distributeAmount);
vm.startPrank(feeDistributor);
raacToken.approve(address(feeCollector), distributeAmount);
feeCollector.collectFee(distributeAmount, 0);
feeCollector.distributeCollectedFees();
vm.stopPrank();
// Alice won't be able to claim
vm.expectRevert(IFeeCollector.InsufficientBalance.selector);
feeCollector.claimRewards(alice);
// Bob can claim more than half of the distributed amount (1000)
feeCollector.claimRewards(bob);
assertApproxEqAbs(raacToken.balanceOf(bob), 785e18, 1e18);
}
}

Tools Used

Manual Review

Recommendations

When calculates user's pending rewards, it is necessary to record the claimed reward amount and deduct it from totalDistributed.

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!