Skip to content

Commit 2567ba3

Browse files
authored
Merge pull request #4 from Giveth/fixTotalAmountBug
add missing part related to the total amount validation
2 parents 37f10c9 + b5ac23f commit 2567ba3

File tree

8 files changed

+283
-2
lines changed

8 files changed

+283
-2
lines changed

.github/workflows/tests.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
name: CI
22

3-
on: [push]
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- master
8+
pull_request:
9+
branches:
10+
- main
11+
- master
412

513
concurrency:
614
group: ${{github.workflow}}-${{github.ref}}
@@ -15,6 +23,8 @@ jobs:
1523
runs-on: ubuntu-latest
1624
steps:
1725
- uses: actions/checkout@v4
26+
with:
27+
submodules: recursive
1828

1929
- name: Install Foundry
2030
uses: foundry-rs/foundry-toolchain@v1

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[submodule "lib/openzeppelin-contracts-upgradeable"]
22
path = lib/openzeppelin-contracts-upgradeable
33
url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
4+
[submodule "lib/forge-std"]
5+
path = lib/forge-std
6+
url = https://github.com/foundry-rs/forge-std

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# DonationHandler Smart Contract
22

3+
[![Tests](https://github.com/Giveth/donation-handler-foundry/actions/workflows/tests.yml/badge.svg)](https://github.com/Giveth/donation-handler-foundry/actions/workflows/tests.yml)
4+
35
A flexible and secure smart contract system for handling both ETH and ERC20 token donations with support for single and batch transactions.
46

57
## Overview
@@ -47,6 +49,9 @@ The contract includes comprehensive test coverage for:
4749
- Multiple ERC20 token donations
4850
- Error cases and edge conditions
4951
- Direct ETH transfer prevention
52+
- Amount validation and sum verification (security fix)
53+
54+
All tests are automatically run on pull requests via GitHub Actions CI.
5055

5156
### Events
5257

foundry.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ out = 'out-via-ir'
2525
[profile.docs]
2626
src = 'src/interfaces/'
2727

28+
[profile.ci]
29+
fuzz = { runs = 5000 }
30+
invariant = { runs = 1000 }
31+
2832
[fuzz]
2933
runs = 1000
3034

lib/forge-std

Submodule forge-std added at 7117c90

remappings.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
forge-std/=node_modules/forge-std/src
1+
forge-std/=lib/forge-std/src
22
halmos-cheatcodes=node_modules/halmos-cheatcodes
33

44
contracts/=src/contracts

src/contracts/DonationHandler.sol

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,16 @@ contract DonationHandler is OwnableUpgradeable, ReentrancyGuardUpgradeable {
9898
require(allocations.tokenAddress == ETH_TOKEN_ADDRESS, 'Invalid token address for ETH');
9999
require(msg.value == allocations.totalAmount, 'Incorrect ETH amount sent');
100100

101+
uint256 sum;
101102
uint256 length = recipientAddresses.length;
103+
for (uint256 i = 0; i < length;) {
104+
sum += allocations.amounts[i];
105+
unchecked {
106+
++i;
107+
}
108+
}
109+
require(sum == allocations.totalAmount, 'Amounts do not match total');
110+
102111
for (uint256 i = 0; i < length;) {
103112
_handleETH(allocations.amounts[i], allocations.recipientAddresses[i], allocations.data[i]);
104113
unchecked {
@@ -139,7 +148,16 @@ contract DonationHandler is OwnableUpgradeable, ReentrancyGuardUpgradeable {
139148
Allocations memory allocations = Allocations(tokenAddress, totalAmount, recipientAddresses, amounts, data);
140149
if (allocations.tokenAddress == ETH_TOKEN_ADDRESS) revert InvalidInput();
141150

151+
uint256 sum;
142152
uint256 length = recipientAddresses.length;
153+
for (uint256 i = 0; i < length;) {
154+
sum += allocations.amounts[i];
155+
unchecked {
156+
++i;
157+
}
158+
}
159+
require(sum == allocations.totalAmount, 'Amounts do not match total');
160+
143161
for (uint256 i = 0; i < length;) {
144162
_handleERC20(
145163
allocations.tokenAddress, allocations.amounts[i], allocations.recipientAddresses[i], allocations.data[i]

test/DonationHandlerBugFix.t.sol

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.0;
3+
4+
import '../src/contracts/DonationHandler.sol';
5+
import '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol';
6+
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
7+
import 'forge-std/Test.sol';
8+
9+
// Mock ERC20 token for testing
10+
contract MockERC20 is ERC20 {
11+
constructor() ERC20('MockToken', 'MTK') {
12+
_mint(msg.sender, 1_000_000 * 10 ** 18);
13+
}
14+
}
15+
16+
/// @title DonationHandlerBugFixTest
17+
/// @notice Tests for the bug fix that ensures amounts array sums to totalAmount
18+
/// @dev These tests verify the fix for the vulnerability where mismatched amounts could lock or steal ETH/tokens
19+
contract DonationHandlerBugFixTest is Test {
20+
DonationHandler public handler;
21+
MockERC20 public mockToken;
22+
23+
address public alice;
24+
address public bob;
25+
address public eve;
26+
27+
event DonationMade(address indexed recipientAddress, uint256 amount, address indexed tokenAddress, bytes data);
28+
29+
function setUp() public {
30+
// Deploy contracts
31+
handler = new DonationHandler();
32+
handler = DonationHandler(
33+
payable(address(new ERC1967Proxy(address(handler), abi.encodeCall(DonationHandler.initialize, ()))))
34+
);
35+
mockToken = new MockERC20();
36+
37+
// Setup accounts
38+
alice = makeAddr('alice');
39+
bob = makeAddr('bob');
40+
eve = makeAddr('eve');
41+
42+
// Fund accounts
43+
vm.deal(alice, 100 ether);
44+
vm.deal(bob, 100 ether);
45+
vm.deal(eve, 100 ether);
46+
47+
// Give Alice some tokens
48+
mockToken.transfer(alice, 10000e18);
49+
}
50+
51+
/// @notice Test that donateManyETH reverts when amounts sum is less than totalAmount
52+
/// @dev This prevents ETH from getting stuck in the contract
53+
function test_RevertWhen_ETHAmountsSumLessThanTotal() public {
54+
address[] memory to = new address[](1);
55+
to[0] = bob;
56+
uint256[] memory amts = new uint256[](1);
57+
amts[0] = 8 ether; // Only 8 ETH allocated
58+
bytes[] memory data = new bytes[](1);
59+
60+
vm.prank(alice);
61+
vm.expectRevert('Amounts do not match total');
62+
handler.donateManyETH{value: 10 ether}(10 ether, to, amts, data); // But sending 10 ETH
63+
}
64+
65+
/// @notice Test that donateManyETH reverts when amounts sum is greater than totalAmount
66+
/// @dev This prevents draining of previously stuck funds
67+
function test_RevertWhen_ETHAmountsSumGreaterThanTotal() public {
68+
address[] memory to = new address[](1);
69+
to[0] = bob;
70+
uint256[] memory amts = new uint256[](1);
71+
amts[0] = 12 ether; // 12 ETH allocated
72+
bytes[] memory data = new bytes[](1);
73+
74+
vm.prank(alice);
75+
vm.expectRevert('Amounts do not match total');
76+
handler.donateManyETH{value: 10 ether}(10 ether, to, amts, data); // But only sending 10 ETH
77+
}
78+
79+
/// @notice Test that donateManyETH reverts with empty arrays
80+
/// @dev This prevents locking all sent ETH with no recipients
81+
function test_RevertWhen_ETHEmptyArraysWithValue() public {
82+
address[] memory to = new address[](0);
83+
uint256[] memory amts = new uint256[](0);
84+
bytes[] memory data = new bytes[](0);
85+
86+
vm.prank(alice);
87+
vm.expectRevert('Amounts do not match total');
88+
handler.donateManyETH{value: 10 ether}(10 ether, to, amts, data);
89+
}
90+
91+
/// @notice Test that donateManyETH succeeds when amounts sum exactly equals totalAmount
92+
/// @dev This is the correct behavior
93+
function test_SuccessWhen_ETHAmountsSumEqualsTotal() public {
94+
address[] memory to = new address[](2);
95+
to[0] = bob;
96+
to[1] = eve;
97+
uint256[] memory amts = new uint256[](2);
98+
amts[0] = 6 ether;
99+
amts[1] = 4 ether; // Total = 10 ETH
100+
bytes[] memory data = new bytes[](2);
101+
102+
uint256 bobBefore = bob.balance;
103+
uint256 eveBefore = eve.balance;
104+
105+
vm.prank(alice);
106+
handler.donateManyETH{value: 10 ether}(10 ether, to, amts, data);
107+
108+
assertEq(bob.balance, bobBefore + 6 ether);
109+
assertEq(eve.balance, eveBefore + 4 ether);
110+
assertEq(address(handler).balance, 0); // No ETH stuck in contract
111+
}
112+
113+
/// @notice Test that the contract balance remains zero after multiple correct donations
114+
/// @dev This ensures no ETH accumulates in the contract
115+
function test_ContractBalanceRemainsZeroAfterMultipleDonations() public {
116+
address[] memory to = new address[](1);
117+
to[0] = bob;
118+
uint256[] memory amts = new uint256[](1);
119+
amts[0] = 5 ether;
120+
bytes[] memory data = new bytes[](1);
121+
122+
// First donation
123+
vm.prank(alice);
124+
handler.donateManyETH{value: 5 ether}(5 ether, to, amts, data);
125+
assertEq(address(handler).balance, 0);
126+
127+
// Second donation
128+
vm.prank(eve);
129+
handler.donateManyETH{value: 5 ether}(5 ether, to, amts, data);
130+
assertEq(address(handler).balance, 0);
131+
132+
// Third donation
133+
vm.prank(alice);
134+
handler.donateManyETH{value: 5 ether}(5 ether, to, amts, data);
135+
assertEq(address(handler).balance, 0);
136+
}
137+
138+
/// @notice Test that donateManyERC20 reverts when amounts sum is less than totalAmount
139+
/// @dev This prevents inconsistent token transfers
140+
function test_RevertWhen_ERC20AmountsSumLessThanTotal() public {
141+
address[] memory to = new address[](1);
142+
to[0] = bob;
143+
uint256[] memory amts = new uint256[](1);
144+
amts[0] = 800e18; // Only 800 tokens allocated
145+
bytes[] memory data = new bytes[](1);
146+
147+
vm.startPrank(alice);
148+
mockToken.approve(address(handler), 1000e18);
149+
150+
vm.expectRevert('Amounts do not match total');
151+
handler.donateManyERC20(address(mockToken), 1000e18, to, amts, data); // But claiming 1000 total
152+
vm.stopPrank();
153+
}
154+
155+
/// @notice Test that donateManyERC20 reverts when amounts sum is greater than totalAmount
156+
/// @dev This prevents over-transfers
157+
function test_RevertWhen_ERC20AmountsSumGreaterThanTotal() public {
158+
address[] memory to = new address[](1);
159+
to[0] = bob;
160+
uint256[] memory amts = new uint256[](1);
161+
amts[0] = 1200e18; // 1200 tokens allocated
162+
bytes[] memory data = new bytes[](1);
163+
164+
vm.startPrank(alice);
165+
mockToken.approve(address(handler), 1000e18);
166+
167+
vm.expectRevert('Amounts do not match total');
168+
handler.donateManyERC20(address(mockToken), 1000e18, to, amts, data); // But only 1000 approved
169+
vm.stopPrank();
170+
}
171+
172+
/// @notice Test that donateManyERC20 succeeds when amounts sum exactly equals totalAmount
173+
/// @dev This is the correct behavior
174+
function test_SuccessWhen_ERC20AmountsSumEqualsTotal() public {
175+
address[] memory to = new address[](2);
176+
to[0] = bob;
177+
to[1] = eve;
178+
uint256[] memory amts = new uint256[](2);
179+
amts[0] = 600e18;
180+
amts[1] = 400e18; // Total = 1000 tokens
181+
bytes[] memory data = new bytes[](2);
182+
183+
uint256 aliceBefore = mockToken.balanceOf(alice);
184+
uint256 bobBefore = mockToken.balanceOf(bob);
185+
uint256 eveBefore = mockToken.balanceOf(eve);
186+
187+
vm.startPrank(alice);
188+
mockToken.approve(address(handler), 1000e18);
189+
handler.donateManyERC20(address(mockToken), 1000e18, to, amts, data);
190+
vm.stopPrank();
191+
192+
assertEq(mockToken.balanceOf(alice), aliceBefore - 1000e18);
193+
assertEq(mockToken.balanceOf(bob), bobBefore + 600e18);
194+
assertEq(mockToken.balanceOf(eve), eveBefore + 400e18);
195+
}
196+
197+
/// @notice Test multiple recipients with matching sum
198+
/// @dev Ensures the fix works with more complex scenarios
199+
function test_SuccessWhen_MultipleRecipientsWithMatchingSum() public {
200+
address[] memory to = new address[](5);
201+
to[0] = bob;
202+
to[1] = eve;
203+
to[2] = alice;
204+
to[3] = makeAddr('charlie');
205+
to[4] = makeAddr('dave');
206+
207+
uint256[] memory amts = new uint256[](5);
208+
amts[0] = 1 ether;
209+
amts[1] = 2 ether;
210+
amts[2] = 3 ether;
211+
amts[3] = 2.5 ether;
212+
amts[4] = 1.5 ether; // Total = 10 ETH
213+
214+
bytes[] memory data = new bytes[](5);
215+
216+
vm.prank(alice);
217+
handler.donateManyETH{value: 10 ether}(10 ether, to, amts, data);
218+
219+
assertEq(address(handler).balance, 0);
220+
}
221+
222+
/// @notice Test that amounts array with overflow protection still works
223+
/// @dev Ensures unchecked increment doesn't break the sum validation
224+
function test_SuccessWhen_LargeNumberOfRecipients() public {
225+
uint256 numRecipients = 50;
226+
address[] memory to = new address[](numRecipients);
227+
uint256[] memory amts = new uint256[](numRecipients);
228+
bytes[] memory data = new bytes[](numRecipients);
229+
230+
for (uint256 i = 0; i < numRecipients; i++) {
231+
to[i] = makeAddr(string(abi.encodePacked('recipient', vm.toString(i))));
232+
amts[i] = 0.1 ether; // Each gets 0.1 ETH
233+
}
234+
235+
vm.prank(alice);
236+
handler.donateManyETH{value: 5 ether}(5 ether, to, amts, data); // 50 * 0.1 = 5 ETH
237+
238+
assertEq(address(handler).balance, 0);
239+
}
240+
}

0 commit comments

Comments
 (0)