Summary
The 'VotingBooth:_distributeRewards' function does not reward the correct amount to 'for' voters under certain conditions where the 'totalAllowedVoters' is either 5, 7, or 9 and there is at least 1 'against' vote.
Vulnerability Details
When voting ends and if the 'for' votes win, _distributeRewards is called. The rewardPerVoter is calculated by taking the (totalRewards / totalVoters). In cases where there are 5, 7, or 9 totalAllowedVoters and there is at least 1 'against' vote, this will result in an incorrect payout and leave money left over in the contract.
For example, if there are 5 totalAllowedVoters and the first two voters vote 'true' and the third votes 'false' this will end the vote. _distributeRewards will be called and the rewardPerVoter will be calculated to 0.33 -> (1 ETH / 3 totalVoters). This will pay out 0.33 to the two 'for' voters and leave 0.33 in the contract. This should payout 0.5 to each of the 'for' voters.
Impact
The below fuzz test fails because the contract balance is not zero after the vote has ended. There is a balance left over.
pragma solidity ^0.8.23;
import {VotingBooth} from "../../src/VotingBooth.sol";
import {Test, console} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
contract OpenInvariantsTest is Test {
uint256 constant ETH_REWARD = 10e18;
address[] voters;
VotingBooth booth;
function setUp() public {
deal(address(this), ETH_REWARD);
voters.push(address(0x1));
voters.push(address(0x2));
voters.push(address(0x3));
voters.push(address(0x4));
voters.push(address(0x5));
for (uint256 i; i < voters.length; ++i) {
targetSender(voters[i]);
}
booth = new VotingBooth{value: ETH_REWARD}(voters);
assert(address(booth).balance == ETH_REWARD);
assert(booth.isActive());
assert(booth.getTotalAllowedVoters() == voters.length);
assert(booth.getCreator() == address(this));
targetContract(address(booth));
}
receive() external payable {}
function invariant_protocolBalanceShouldBeZeroIfVoteEnded() public view {
if (!booth.isActive()) {
console.log("Contract Balance: ", address(booth).balance);
assert(address(booth).balance == 0);
}
}
Tools Used
--Foundry
Recommendations
It is recommended to change the logic in the 'VotingBooth:_distributeRewards' function to calculate the rewardPerVoter using the totalVotesFor instead of totalVotes when the vote passes 'for'.
else {
- uint256 rewardPerVoter = totalRewards / totalVotes;
+ uint256 rewardPerVoter = totalRewards / totalVotesFor;
for (uint256 i; i < totalVotesFor; ++i) {
// proposal creator is trusted when creating allowed list of voters,
// findings related to gas griefing attacks or sending eth
// to an address reverting thereby stopping the reward payouts are
// invalid. Yes pull is to be preferred to push but this
// has not been implemented in this simplified version to
// reduce complexity & help you focus on finding the
// harder to find bug
// if at the last voter round up to avoid leaving dust; this means that
// the last voter can get 1 wei more than the rest - this is not
// a valid finding, it is simply how we deal with imperfect division
if (i == totalVotesFor - 1) {
- rewardPerVoter = Math.mulDiv(totalRewards, 1, totalVotes, Math.Rounding.Ceil);
+ rewardPerVoter = Math.mulDiv(totalRewards, 1, totalVotesFor, Math.Rounding.Ceil);
}
_sendEth(s_votersFor[i], rewardPerVoter);
}
}