Core Contracts

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

[H-1] - Incorrect logic in `FeeCollector::claimRewards` function prevents users from claiming their rewards

Summary

In the FeeCollector::claimRewards function, the contract updates userRewards[user] to the value of totalDistributed. Because totalDistributed is the global total number of tokens that have been distributed historically to all users rather than a particular user’s cumulative claimed balance, subsequent claims will result in pendingReward being calculated as zero. In other words, if a user claims once, they will no longer receive any share for newly added rewards.

Vulnerability Details

Within the claimRewards function, after computing the user’s pro-rata share of totalDistributed, the contract erroneously updates the userRewards[user] mapping and sets it to be equal to totalDistributed instead of the actual number of tokens that the user is entitled to claim (pendingReward).

function claimRewards(address user) external override nonReentrant whenNotPaused returns (uint256) {
//..
//@audit we calculate the share of reward tokens that the user is entitled to get here
uint256 pendingReward = _calculatePendingRewards(user);
if (pendingReward == 0) revert InsufficientBalance();
// Reset user rewards before transfer
//@audit we erroneously set this `= totalDistributed`
userRewards[user] = totalDistributed;
//..
}

This effectively stores the system-wide total instead of tracking the user’s total claimed amount. During the next distribution, the _calculatePendingRewards function subtracts userRewards[user] from the user’s new pro-rata share. Because userRewards[user] is set to a value higher than the user’s actual claimed tokens, the result is zero or a negative difference (which floors to zero). The user is therefore blocked from claiming further distributions.

function _calculatePendingRewards(address user) internal view returns (uint256) {
//..
//@audit after the first claim this will almost always default to zero because `share < userRewards[user]` and the function returns 0
return share > userRewards[user] ? share - userRewards[user] : 0;
}

Impact

Loss of funds for users. Affected users will not be able to claim their future token rewards from subsequent distributions, effectively losing tokens by never receiving newly allocated rewards.

PoC

Put the following test inside the FeeCollector.test.js file.

it.only("should prove flawed claimedRewards logic", async function () {
//setup fees locally for the test
const SWAP_TAX_RATE = 100; // 1%
const BURN_TAX_RATE = 50; // 0.5%
const taxRate = SWAP_TAX_RATE + BURN_TAX_RATE;
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);
//collect some fees
await feeCollector.connect(user1).collectFee(protocolFeeGross, 0);
await feeCollector.connect(user1).collectFee(lendingFeeGross, 1);
await feeCollector.connect(user1).collectFee(swapTaxGross, 6); // Collect fees
await feeCollector.connect(user1).collectFee(protocolFeeGross, 0);
await feeCollector.connect(user1).collectFee(lendingFeeGross, 1);
await feeCollector.connect(user1).collectFee(swapTaxGross, 6);
//distribute the fees
await feeCollector.connect(owner).distributeCollectedFees();
await time.increase(WEEK);
const initialBalance = await raacToken.balanceOf(user1.address);
const userRewardsBefore = await feeCollector.userRewards(user1.address);
console.log("Balance of user before claim = ", initialBalance);
console.log("userRewards before first claim = ", userRewardsBefore);
await feeCollector.connect(user1).claimRewards(user1.address);
const balanceAfterClaim = await raacToken.balanceOf(user1.address);
const userRewardsAfterClaim1 = await feeCollector.userRewards(
user1.address
);
console.log("Balance of user after claim 1 = ", balanceAfterClaim);
console.log("userRewards after first claim = ", userRewardsAfterClaim1);
expect(await raacToken.balanceOf(user1.address)).to.be.gt(initialBalance);
//collect additional fees and distribute them
await feeCollector.connect(user1).collectFee(protocolFeeGross, 0);
await feeCollector.connect(user1).collectFee(lendingFeeGross, 1);
await feeCollector.connect(user1).collectFee(swapTaxGross, 6);
await feeCollector.connect(owner).distributeCollectedFees();
await time.increase(WEEK);
//@audit user is unable to claim anything from the newly accrued fees
await expect(feeCollector.connect(user1).claimRewards(user1.address)).to
.be.reverted;
});
});

Test output

npm run test:unit:collectors
> @raac/core@1.0.0 test:unit:collectors
> npx hardhat test test/unit/core/collectors/*.test.js
FeeCollector
Fee Collection and Distribution
Balance of user before claim = 8999970000000000000000n
userRewards before first claim = 0n
Balance of user after claim 1 = 8999979616438356164390n
userRewards after first claim = 15000000000000000n
✔ should prove flawed claimedRewards logic (65ms)
1 passing (2s)

You can see that userRewards = 15000000000000000 while the actual number of claimed tokens was 9616438356164390.
The difference is an extra ~5383561600000000 that the protocol stores as claimed by the user, although they were not claimed.

Tools Used

Manual review

Recommended Mitigation

Track the cumulative tokens the user has already claimed. The simplest fix is to increment the user’s claimed balance by the newly claimed token amount.

function claimRewards(address user) external override nonReentrant whenNotPaused returns (uint256) {
//..
uint256 pendingReward = _calculatePendingRewards(user);
if (pendingReward == 0) revert InsufficientBalance();
// Reset user rewards before transfer
- userRewards[user] = totalDistributed;
+ userRewards[user] += pendingReward ;
//..
//..
}

Like this, the mapping should properly track the rewards claimed by each user individually.

Updates

Lead Judging Commences

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