Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

Uneven distribution of funds in `VotingBooth::_distributeRewards()` results in funds being stuck in the contract forever.

Summary

The vulnerability is related to the calculation of rewards for voters in favor, for, of a proposal. The current implementation calculates the reward based on the total votes cast, including votes for and against, leading to an uneven distribution of funds when a proposal passes. Moreover, there is no other way to withdraw remaining funds from the contract, resulting in funds being stuck in the contract forever.

Vulnerability Details

If the contract distributes the amount to those who favor the proposal, it should be calculated based on the total votes cast in favor of the proposal. If it is calculated based on the total votes cast, then the reward is not distributed evenly.

For example:

Let's assume there are 5 voters in the proposal, and the reward is 1 ETH. Out of five, 2 voted in favor of the proposal and 1 against the proposal. When the quorum is reached, the system should distribute 0.5 ETH to those who favor the proposal. However, with the current system, rewards are distributed as follows:

Logs:
totalRewards : 10000000000000000000
totalVotesFor : 2
totalVotesAgainst : 1
Rewards for voter 0 : 3333333333333333333
Rewards for voter 1 : 3333333333333333334
balance in contract : 3333333333333333333
​

Technically, the balance in the contract should be 0 ETH when the rewards are distributed, but here it keeps 0.33 ETH.

The vulnerability is located in the following portion of the code:

else {
​
//@audit Rewards are calculated based on the total votes (both in favor and against), resulting in rewards not being distributed evenly.
šŸ‘‰ uint256 rewardPerVoter = totalRewards / totalVotes;
​
for (uint256 i; i < totalVotesFor; ++i) {
if (i == totalVotesFor - 1) {
rewardPerVoter = Math.mulDiv(
totalRewards,
1,
//@audit rewards are calculated based on total votes to avoid leaving dust
šŸ‘‰ totalVotes,
Math.Rounding.Ceil
);
}
}
}

Impact

The impact of this vulnerability is that when a proposal passes, the funds are not distributed evenly among voters in favor. The current calculation method, based on total votes, may result in an uneven distribution, potentially leading to unfair rewards for voters. Hence, this results in funds not being distributed and being lost forever, as there is no function to withdraw funds from the contract.

POC

Here, three voters participate in the voting process. Out of the three, two vote in favor of the proposal and one votes against it. After the proposal passes, the funds are distributed but not evenly among the voters. This results in funds that are not distributed being stuck in the contract forever.

Test case with current logic
forge test --match-test test_vote_passes_2_vs_1_and_money_is_not_sent_evenly -vvv
function test_vote_passes_2_vs_1_and_money_is_not_sent_evenly() public {
// Votes in favor of the proposal
vm.prank(address(0x1));
booth.vote(true);
​
// Votes in favor of the proposal
vm.prank(address(0x2));
booth.vote(true);
​
// Votes against the proposal
vm.prank(address(0x3));
booth.vote(false);
​
assert(!booth.isActive() && address(booth).balance > 0);
console.log("Funds in the contract after rewards distribution:", address(booth).balance);
}
​
Logs:
​
Funds in the contract after rewards distribution: 3333333333333333333
Test case with fixed logic
forge test --match-test test_vote_passes_2_vs_1_and_money_is_sent_evenly
function test_vote_passes_2_vs_1_and_money_is_sent_evenly() public {
// Votes in favor of the proposal
vm.prank(address(0x1));
booth.vote(true);
​
// Votes in favor of the proposal
vm.prank(address(0x2));
booth.vote(true);
​
// Votes against the proposal
vm.prank(address(0x3));
booth.vote(false);
​
assert(!booth.isActive() && address(booth).balance == 0);
console.log("Funds in the contract after rewards distribution:", address(booth).balance);
}
​
Logs:
​
Funds in the contract after rewards distribution: 0

Tools Used

The vulnerability was identified through manual code review.

Recommendations

When rewards are distributed, they should be calculated based on the number of voters who support the proposal (totalVotesFor). This will distribute the funds evenly and final balance of the contract will be 0.

function _distributeRewards() private {
// get number of voters for & against
uint256 totalVotesFor = s_votersFor.length;
uint256 totalVotesAgainst = s_votersAgainst.length;
-- uint256 totalVotes = totalVotesFor + totalVotesAgainst;
​
// rewards to distribute or refund. This is guaranteed to be
// greater or equal to the minimum funding amount by a check
// in the constructor, and there is intentionally by design
// no way to decrease or increase this amount. Any findings
// related to not being able to increase/decrease the total
// reward amount are invalid
uint256 totalRewards = address(this).balance;
​
// if the proposal was defeated refund reward back to the creator
// for the proposal to be successful it must have had more `For` votes
// than `Against` votes
if (totalVotesAgainst >= totalVotesFor) {
// proposal creator is trusted to create a proposal from an address
// that can receive ETH. See comment before declaration of `s_creator`
_sendEth(s_creator, totalRewards);
}
// otherwise the proposal passed so distribute rewards to the `For` voters
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);
}
}
}
​
Updates

Lead Judging Commences

0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

VotingBooth._distributeRewards(): Incorrect computation of rewardPerVoter

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.