Summary
According to the comments that point out how the rewards should be distributed, in case a proposal fails the ETH reward is sent back to the creator and if a proposal succeeds the ETH reward is divided between the For
voters. The problem is the code is not working as intended because the rewardPerVoter
is computed by dividing the totalRewards
to the totalVotes
.
Vulnerability Details
The protocol implements the reward distribution as follows:
uint256 totalRewards = address(this).balance;
if (totalVotesAgainst >= totalVotesFor) {
_sendEth(s_creator, totalRewards);
}
else {
uint256 rewardPerVoter = totalRewards / totalVotes;
for (uint256 i; i < totalVotesFor; ++i) {
...
The rewardPerVoter is wrongly divided by totalVotes, the consequences can be observed in the test attached below.
Copy the following code into a new test file:
pragma solidity ^0.8.23;
import {Test, console} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {VotingBooth} from "../src/VotingBooth.sol";
contract TestVotingBooth is StdInvariant, Test {
uint256 constant AMOUNT = 100e18;
uint256 constant VOTING_ADDRESSES = 7;
address[] voters;
VotingBooth booth;
function setUp() public {
deal(address(this), AMOUNT);
address[] memory participants = new address[](VOTING_ADDRESSES);
address participant;
for (uint160 i = 1; i < VOTING_ADDRESSES + 1;) {
participant = address(i);
participants[i - 1] = participant;
unchecked {
++i;
}
}
for (uint256 j; j < participants.length;) {
targetSender(participants[j]);
unchecked {
++j;
}
}
booth = new VotingBooth{value: AMOUNT}(participants);
targetContract(address(booth));
}
function invariant_dacianTheGreatestFuzzer() public {
if (booth.isActive()) {
assertEq(address(booth).balance, AMOUNT);
} else {
assertEq(address(booth).balance, 0);
}
}
}
Run it with the following command:
forge test --mt invariant_dacianTheGreatestFuzzer -vvv
Yielding the following result (copied only the relevant part):
[22342] TestVotingBooth::invariant_dacianTheGreatestFuzzer()
├─ [2292] VotingBooth::isActive() [staticcall]
│ └─ ← false
├─ emit log(val: "Error: a == b not satisfied [uint]")
├─ emit log_named_uint(key: " Left", val: 25000000000000000000 [2.5e19])
├─ emit log_named_uint(key: " Right", val: 0)
├─ [0] VM::store(VM: [0x7109709ECfa91a80626fF3989D68f67F5b1DD12D], 0x6661696c65640000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000001)
│ └─ ← ()
└─ ← ()
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 11.59s
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/newTest.t.sol:TestVotingBooth
[FAIL. Reason: <no data>]
[Sequence]
sender=0x0000000000000000000000000000000000000007 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[false]
sender=0x0000000000000000000000000000000000000003 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
sender=0x0000000000000000000000000000000000000007 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[false]
sender=0x0000000000000000000000000000000000000002 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
sender=0x0000000000000000000000000000000000000005 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[false]
sender=0x0000000000000000000000000000000000000003 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
sender=0x0000000000000000000000000000000000000001 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[false]
sender=0x0000000000000000000000000000000000000003 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
sender=0x0000000000000000000000000000000000000004 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
invariant_dacianTheGreatestFuzzer() (runs: 10000, calls: 149994, reverts: 115100)
Encountered a total of 1 failing tests, 0 tests succeeded
[22342] TestVotingBooth::invariant_dacianTheGreatestFuzzer()
├─ [2292] VotingBooth::isActive() [staticcall]
│ └─ ← false
├─ emit log(val: "Error: a == b not satisfied [uint]")
├─ emit log_named_uint(key: " Left", val: 25000000000000000000 [2.5e19])
├─ emit log_named_uint(key: " Right", val: 0)
├─ [0] VM::store(VM: [0x7109709ECfa91a80626fF3989D68f67F5b1DD12D], 0x6661696c65640000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000001)
│ └─ ← ()
└─ ← ()
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 11.59s
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/newTest.t.sol:TestVotingBooth
[FAIL. Reason: <no data>]
[Sequence]
sender=0x0000000000000000000000000000000000000007 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[false]
sender=0x0000000000000000000000000000000000000003 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
sender=0x0000000000000000000000000000000000000007 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[false]
sender=0x0000000000000000000000000000000000000002 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
sender=0x0000000000000000000000000000000000000005 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[false]
sender=0x0000000000000000000000000000000000000003 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
sender=0x0000000000000000000000000000000000000001 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[false]
sender=0x0000000000000000000000000000000000000003 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
sender=0x0000000000000000000000000000000000000004 addr=[src/VotingBooth.sol:VotingBooth]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=vote(bool) args=[true]
invariant_dacianTheGreatestFuzzer() (runs: 10000, calls: 149994, reverts: 115100)
Encountered a total of 1 failing tests, 0 tests succeeded
As we can see the contract has a balance of 25e18 ETH which will be permanently stuck.
Impact
Funds stuck in the contract.
For
voters will receive less rewards compared to what they should.
Tools Used
Manual review + forge testing
Recommendations
Amend the code as follows:
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) {