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

Incorrect calculation for rewardPerVoter

Summary

Incorrect calculation of rewardPerVoter inside _distributeRewards() results in lower rewards to be awarded to for voters and also considerable leftover amount in the contract is stuck which can't be withdrawn now or transferred.


ROOT CAUSE: Usage of totalVotes instead of totalVotesFor while calculating rewardPerVoter which gives an incorrect result when totalVotesFor totalVotes for a passed proposal.

Vulnerability Details

VotingBooth.sol#L192 calculates rewardPerVoter as:

uint256 rewardPerVoter = totalRewards / totalVotes;

This causes problems when totalVotesFor totalVotes and the proposal has passed. Consider the following scenario:

  • Suppose s_totalAllowedVoters = 5

  • Votes needed to reach quorum = 3 votes (Since )

  • Let us have totalRewards = 10 ether

  • voter votes AGAINST the proposal.

  • voter votes FOR the proposal.

  • voter votes FOR the proposal.

  • Hence, totalVotes = 3 and totalVotesFor = 2.

  • rewardPerVoter is calculated as per L192 which says:
    . This equals 3333333333333333333 wei. This is incorrect & should be:

    . Which equals 5000000000000000000 wei.



    Also, the current reward distribution looks like this:

  • voter receives 3333333333333333333 wei

  • voter receives 3333333333333333334 wei

  • Dust left in the contract = 10 ether - 3333333333333333333 wei - 3333333333333333334 wei = 3333333333333333333 wei.

This leftover amount is now stuck in the contract since there is no withdraw or transfer or selfDestruct function supported by the protocol.

PoC-1

Add this test and run via forge test --mt testIncorrectReward -vv to see it fail.

diff --git a/test/VotingBoothTest.t.sol b/test/VotingBoothTest2.t.sol
index 6cbc119..0c53ed6 100644
--- a/test/VotingBoothTest.t.sol
+++ b/test/VotingBoothTest2.t.sol
@@ -46,6 +46,22 @@ contract VotingBoothTest is Test {
// required to receive refund if proposal fails
receive() external payable {}
+ function testIncorrectReward() public {
+ vm.prank(address(0x1));
+ booth.vote(false);
+
+ vm.prank(address(0x2));
+ booth.vote(true);
+
+ vm.prank(address(0x3));
+ booth.vote(true);
+
+ assert(!booth.isActive());
+ assertEq(address(0x2).balance, 5_000_000_000_000_000_000); // @audit : should have been 5000000000000000000 wei = 5 ether
+ assertEq(address(0x3).balance, 5_000_000_000_000_000_000); // @audit : should have been 5000000000000000000 wei = 5 ether
+ assertEq(address(booth).balance, 0, "dust remains"); // @audit : should have been 0, but is 3333333333333333333
+ }
+
function testVotePassesAndMoneyIsSent() public {
vm.prank(address(0x1));
booth.vote(true);

Output:

[FAIL. Reason: assertion failed] testIncorrectReward() (gas: 282888)
Logs:
Error: a == b not satisfied [uint]
Left: 3333333333333333333
Right: 5000000000000000000
Error: a == b not satisfied [uint]
Left: 3333333333333333334
Right: 5000000000000000000
Error: dust remains
Error: a == b not satisfied [uint]
Left: 3333333333333333333
Right: 0

PoC-2 (Fuzz Test - SIMPLER approach)

We can also uncover the bug using fuzzing. Let's modify the existing test testVotePassesAndMoneyIsSent() into a fuzz-style one which checks different vote (for/against) combinations for 3 voters.

Add this test and run via forge test --mt testFuzz_IncorrectReward -vv to see the counterexample which makes it fail.

function testFuzz_IncorrectReward(bool vote1, bool vote2, bool vote3) public {
vm.prank(address(0x1));
booth.vote(vote1);
vm.prank(address(0x2));
booth.vote(vote2);
vm.prank(address(0x3));
booth.vote(vote3);
assert(!booth.isActive() && address(booth).balance == 0);
}

Output:

Running 1 test for test/VotingBoothTest2.t.sol:VotingBoothTest
[FAIL. Reason: panic: assertion failed (0x01); counterexample: calldata=0xe6502645000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001 args=[false, true, true]] testFuzz_IncorrectReward(bool,bool,bool) (runs: 5, μ: 192560, ~: 196542)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 15.09ms

PoC-3 (Fuzz style - LONGER Approach)

We'll design a fuzz test which tries out different combination of not only the votes (true/false) but also the number of total eligible voters. This will help us see various counterexamples of failures.

Params being fuzzed:

  • numberOfVoters (total voters elgible to vote)

  • votes polled (for/against)

Save the following code inside a new file test/VotingBoothTestFuzzer2.t.sol and run via forge test --mt testFuzz_Style2_IncorrectReward -vv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import {VotingBooth} from "../src/VotingBooth.sol";
import {Test} from "forge-std/Test.sol";
contract VotingBoothTestFuzzer2 is Test {
uint256 constant ETH_REWARD = 10e18;
uint256 constant MIN_QUORUM = 51;
// allowed voters
address[] voters;
// contracts required for test
VotingBooth booth;
receive() external payable {}
function testFuzz_Style2_IncorrectReward(uint256 eligibleVotersCount, bool[] memory voted) public {
// @audit : we make sure that fuzzed inputs are within allowed limits
eligibleVotersCount = bound(eligibleVotersCount, 3, 9);
vm.assume(eligibleVotersCount % 2 == 1); // @audit : only odd values allowed
vm.assume(voted.length == eligibleVotersCount); // @audit : ensure same size of arrays
deal(address(this), ETH_REWARD);
// setup the allowed list of voters
for (uint256 n = 1; n <= eligibleVotersCount; ++n) {
voters.push(address(uint160(n)));
}
// setup contract to be tested
booth = new VotingBooth{value: ETH_REWARD}(voters);
// verify setup
//
// proposal has rewards
assert(address(booth).balance == ETH_REWARD);
// proposal is active
assert(booth.isActive());
// proposal has correct number of allowed voters
assert(booth.getTotalAllowedVoters() == voters.length);
// this contract is the creator
assert(booth.getCreator() == address(this));
for (uint256 i = 1; i <= eligibleVotersCount; ++i) {
vm.prank(voters[i - 1]);
booth.vote(voted[i - 1]);
if (i * 100 / eligibleVotersCount >= MIN_QUORUM) {
break;
}
}
assertEq(booth.isActive(), false, "still active");
assertEq(address(booth).balance, 0, "balance not zero");
}

We'll run the fuzz test a couple of times so that we can see multiple counterexamples :

Output of run:

[FAIL. Reason: assertion failed; counterexample: calldata=0x800ff8430000000000000000000000000000000000000000000000000000000000001aae0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000 args=[6830, [false, true, true, true, false]]] testFuzz_Style2_IncorrectReward(uint256,bool[]) (runs: 2, μ: 908225, ~: 908225)
Logs:
Bound Result 5
Error: balance not zero
Error: a == b not satisfied [uint]
Left: 3333333333333333333
Right: 0

Output of run:

[FAIL. Reason: assertion failed; counterexample: calldata=0x800ff84300000000000000000000000000000000000000000000000000000000000008b9000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001 args=[2233, [true, true, false, true, true, true, true]]] testFuzz_Style2_IncorrectReward(uint256,bool[]) (runs: 3, μ: 849984, ~: 850117)
Logs:
Bound Result 7
Error: balance not zero
Error: a == b not satisfied [uint]
Left: 2500000000000000000
Right: 0

Impact

  • Lower rewards distributed to for voters.

  • Significant amount leftover in the contract which can not be salvaged.

Tools Used

Foundry, manual inspection.

Recommendations

Make the following changes:

VotingBooth.sol#L182-L208

// 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); // @audit: This fix can be improved. See note below.
}

Note: The above fix should be implemented in conjunction with the recommendation mentioned in another bug (raised separately) titled: Dust can still remain after _distributeRewards(). This will make sure that no dust amount remains at the end. Otherwise for certain combinations of totalRewards and totalVotesFor, there are chances that dust still remains in the contract in spite of:

VotingBooth.sol#L207

- rewardPerVoter = Math.mulDiv(totalRewards, 1, totalVotes, Math.Rounding.Ceil);
+ rewardPerVoter = Math.mulDiv(totalRewards, 1, totalVotesFor, Math.Rounding.Ceil); // @audit: This fix can be improved. See note below.

The bug report Dust can still remain after _distributeRewards() explains how to fix this. It basically says that we need to change this to:

- rewardPerVoter = Math.mulDiv(totalRewards, 1, totalVotes, Math.Rounding.Ceil);
+ rewardPerVoter = address(this).balance; // @audit: No dust will remain now
Updates

Lead Judging Commences

0xnevi Lead Judge
over 1 year ago
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.