Summary
Due to an incorrect assigmnet of the userRewards[user] in the claimRewards function of the FeeCollector contract for user to the totalDistributed could lead the user to be unable to claim rewards in future distribution periods after his/her initial claim.
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();
userRewards[user] = totalDistributed;
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}
Vulnerability Details
Since we are assiging the totalDistributed to the user rewards(userRewards[user]) after we complete the initial claim of rewards this value would be higher than the actual shares that the user claimed. In future claims for a different distribution period of fees we have the following logic inside of the _calculatePendingRewards function:
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;
}
Since user shares could have grown by a smaller amount than the totalDistributed value for the previous distribution the call to the claimRewards function results in a InsufficientBalance revert as rewards to claim is incorrectly returned as 0.
Impact
User unable to claim RAACToken rewards from FeeCollector contract if new shares are less than the value of the total distributed rewards from the previous period.
Tools Used
PoC
Place the test inside of the FeeCollector.test.js file inside of the describe("Fee Collection and Distribution") test suite group
it("PoC user can't claim rewards due to incorrect distributed user rewards snapshot", async function () {
await raacToken.connect(user1).burn(await raacToken.balanceOf(user1.address));
await feeCollector.connect(owner).distributeCollectedFees();
let distributedRewards = await feeCollector.totalDistributed();
console.log("Total distributed rewards after first fee collection: ", distributedRewards);
expect(distributedRewards).to.equal(5000000000000000);
await time.increase(WEEK);
const initialBalance = await raacToken.balanceOf(user1.address);
console.log("Initial rewards token balance of user: ", initialBalance.toString());
expect(initialBalance).to.equal(0);
const collectedReward = await feeCollector.connect(user1).claimRewards(user1.address);
const user1Balance = await raacToken.balanceOf(user1.address);
console.log("User1 rewards token balance after claiming: ", user1Balance.toString());
expect(user1Balance.toString()).to.equal("3205479452054796");
await feeCollector.connect(user2).collectFee(5000000000000000, 0);
await feeCollector.connect(owner).distributeCollectedFees();
distributedRewards = await feeCollector.totalDistributed();
console.log("Total distributed rewards after second fee collection: ", distributedRewards);
expect(distributedRewards).to.equal(7500000000000000n);
await time.increase(WEEK);
await expect(feeCollector.connect(user1).claimRewards(user1.address)).to.be.revertedWithCustomError(feeCollector, "InsufficientBalance")
});
Console log output from test:
Total distributed rewards: 5000000000000000n
Initial rewards token balance of user: 0
User1 rewards token balance after claiming: 3205479452054796
Total distributed rewards: 7500000000000000n
Recommendations
Assigned the user claimed shares to the userRewards mapping instead of the totalDistributed:
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();
- userRewards[user] = totalDistributed;
+ userRewards[user] = pendingReward;
// Transfer rewards
raacToken.safeTransfer(user, pendingReward);
emit RewardClaimed(user, pendingReward);
return pendingReward;
}
Updated PoC after changes
Console log output:
Total distributed rewards after first fee collection: 5000000000000000n
Initial rewards token balance of user: 0
User1 rewards token balance after first claiming: 3205479452054796
Total distributed rewards after second fee collection: 7500000000000000n
User1 rewards token balance after second claiming: 4712328767123292
it("PoC user claims rewards after multiple distribution periods", async function () {
await raacToken.connect(user1).burn(await raacToken.balanceOf(user1.address));
await feeCollector.connect(owner).distributeCollectedFees();
let distributedRewards = await feeCollector.totalDistributed();
console.log("Total distributed rewards after first fee collection: ", distributedRewards);
expect(distributedRewards).to.equal(5000000000000000);
await time.increase(WEEK);
const initialBalance = await raacToken.balanceOf(user1.address);
console.log("Initial rewards token balance of user: ", initialBalance.toString());
expect(initialBalance).to.equal(0);
const collectedReward = await feeCollector.connect(user1).claimRewards(user1.address);
const user1Balance = await raacToken.balanceOf(user1.address);
console.log("User1 rewards token balance after first claiming: ", user1Balance.toString());
expect(user1Balance.toString()).to.equal("3205479452054796");
await feeCollector.connect(user2).collectFee(5000000000000000, 0);
await feeCollector.connect(owner).distributeCollectedFees();
distributedRewards = await feeCollector.totalDistributed();
console.log("Total distributed rewards after second fee collection: ", distributedRewards);
expect(distributedRewards).to.equal(7500000000000000n);
await time.increase(WEEK);
await feeCollector.connect(user1).claimRewards(user1.address);
const user1Balance2 = await raacToken.balanceOf(user1.address);
console.log("User1 rewards token balance after second claiming: ", user1Balance2.toString());
expect(user1Balance2.toString()).to.equal("4712328767123292");
});