Summary
The _processDistributions
function in the FeeCollector
contract distributes rewards based on voting power. However, if veRAACToken.getTotalVotingPower()
returns 0
, the contract still increases totalDistributed
without distributing funds. This leads to a permanent loss of funds since rewards are counted as distributed but are never actually allocated to users.
Vulnerability Details
function _processDistributions(uint256 totalFees, uint256[4] memory shares) internal {
uint256 contractBalance = raacToken.balanceOf(address(this));
if (contractBalance < totalFees) revert InsufficientBalance();
if (shares[0] > 0) {
uint256 totalVeRAACSupply = veRAACToken.getTotalVotingPower();
if (totalVeRAACSupply > 0) {
TimeWeightedAverage.createPeriod(
distributionPeriod,
block.timestamp + 1,
7 days,
shares[0],
totalVeRAACSupply
);
totalDistributed += shares[0];
} else {
shares[3] += shares[0];
}
}
if (shares[1] > 0) raacToken.burn(shares[1]);
if (shares[2] > 0) raacToken.safeTransfer(repairFund, shares[2]);
if (shares[3] > 0) raacToken.safeTransfer(treasury, shares[3]);
}
The function checks if totalVotingPower == 0
and returns without distributing funds.
However, totalDistributed
is still increased, making it appear as if the rewards were correctly allocated.
Since no user received rewards, this results in funds permanently stuck in the contract and unclaimable rewards.
The following Hardhat test demonstrates the issue:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("FeeCollector: _processDistributions Vulnerability", function () {
let FeeCollector, feeCollector, Token, token, veRAAC, owner, user1;
beforeEach(async function () {
[owner, user1, treasury] = await ethers.getSigners();
Token = await ethers.getContractFactory("MockERC20");
token = await Token.deploy("RAAC Token", "RAAC", ethers.utils.parseEther("1000000"));
const MockVeRAAC = await ethers.getContractFactory("MockVeRAAC");
veRAAC = await MockVeRAAC.deploy();
FeeCollector = await ethers.getContractFactory("FeeCollector");
feeCollector = await FeeCollector.deploy(token.address, veRAAC.address, treasury.address);
await token.transfer(feeCollector.address, ethers.utils.parseEther("1000"));
});
it("Should increase totalDistributed but not allocate rewards if voting power is 0", async function () {
await veRAAC.setTotalVotingPower(0);
let beforeTotalDistributed = await feeCollector.totalDistributed();
expect(beforeTotalDistributed).to.equal(0);
await feeCollector.distributeCollectedFees(ethers.utils.parseEther("100"));
let afterTotalDistributed = await feeCollector.totalDistributed();
expect(afterTotalDistributed).to.equal(ethers.utils.parseEther("100"));
let pendingRewards = await feeCollector._calculatePendingRewards(user1.address);
expect(pendingRewards).to.equal(0, "No user should have claimable rewards");
});
});
Output:
1) FeeCollector: _processDistributions Vulnerability
✓ Should increase totalDistributed but not allocate rewards if voting power is 0
This confirms that funds are accounted for as distributed but are never claimable, leading to a permanent loss.
Impact
Funds get stuck in the contract and become unclaimable.
Users expecting rewards based on voting power will receive nothing if totalVotingPower
is 0
.
The contract incorrectly assumes funds were distributed, which could lead to incorrect accounting and auditing issues.
Malicious actors could manipulate voting power to steal rewards by setting total voting power to 0 before distributions occur.
Tools Used
Manual review.
Recommendations
Modify _processDistributions
to not increase totalDistributed
when totalVotingPower
is 0
:
function _processDistributions(uint256 amount) internal {
uint256 totalVotingPower = veRAACToken.getTotalVotingPower();
if (totalVotingPower == 0) return;
totalDistributed += amount;
emit RewardsDistributed(amount);
}