Skip to content

Commit f3946df

Browse files
authored
Merge pull request #6 from Giveth/feat/safe-erc20-create2
feat: SafeERC20 + CREATE2 implementation deploy
2 parents 2567ba3 + a3592dd commit f3946df

16 files changed

Lines changed: 250 additions & 66 deletions

.env.example

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
MAINNET_RPC=
2-
MAINNET_DEPLOYER_NAME=
3-
42
SEPOLIA_RPC=
5-
SEPOLIA_DEPLOYER_NAME=
3+
4+
BASE_RPC=
5+
ARBITRUM_RPC=
6+
GNOSIS_RPC=
7+
CELO_RPC=
8+
OPTIMISM_RPC=
9+
POLYGON_RPC=
10+
11+
# Deployer key for forge script (hex private key)
12+
PRIVATE_KEY=
13+
# Used by script/DeployDonationHandler.s.sol only (must match PRIVATE_KEY account)
14+
PUBLIC_KEY=
615

716
ETHERSCAN_API_KEY=

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ out-via-ir
1010

1111
# Config files
1212
.env
13+
.env.local
1314

1415
# Avoid ignoring gitkeep
1516
!/**/.gitkeep

foundry.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,31 @@ src = 'src/interfaces/'
2929
fuzz = { runs = 5000 }
3030
invariant = { runs = 1000 }
3131

32+
# Use only for implementation deploy script: identical bytecode across chains for CREATE2.
33+
# Run: FOUNDRY_PROFILE=deterministic forge script ...
34+
[profile.deterministic]
35+
bytecode_hash = 'none'
36+
cbor_metadata = false
37+
3238
[fuzz]
3339
runs = 1000
3440

3541
[rpc_endpoints]
3642
mainnet = "${MAINNET_RPC}"
3743
sepolia = "${SEPOLIA_RPC}"
44+
base = "${BASE_RPC}"
45+
arbitrum = "${ARBITRUM_RPC}"
46+
gnosis = "${GNOSIS_RPC}"
47+
celo = "${CELO_RPC}"
48+
optimism = "${OPTIMISM_RPC}"
49+
polygon = "${POLYGON_RPC}"
3850

3951
[etherscan]
4052
mainnet = { key = "${ETHERSCAN_API_KEY}" }
4153
sepolia = { key = "${ETHERSCAN_API_KEY}" }
54+
base = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.basescan.org/api' }
55+
arbitrum = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.arbiscan.io/api' }
56+
gnosis = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.gnosisscan.io/api' }
57+
celo = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.celoscan.io/api' }
58+
optimism = { key = "${ETHERSCAN_API_KEY}", url = 'https://api-optimistic.etherscan.io/api' }
59+
polygon = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.polygonscan.com/api' }

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
"build": "forge build",
1414
"build:optimized": "FOUNDRY_PROFILE=optimized forge build",
1515
"coverage": "forge coverage --ir-minimum --report summary --report lcov --match-path 'test/unit/*'",
16-
"deploy:mainnet": "bash -c 'source .env && forge script Deploy --rpc-url $MAINNET_RPC --account $MAINNET_DEPLOYER_NAME --broadcast --verify --chain mainnet -vvvvv'",
17-
"deploy:sepolia": "bash -c 'source .env && forge script Deploy --rpc-url $SEPOLIA_RPC --account $SEPOLIA_DEPLOYER_NAME --broadcast --verify --chain sepolia -vvvvv'",
16+
"deploy:implementation": "./scripts/deploy-implementation.sh",
17+
"deploy:mainnet": "bash -c 'source .env && forge script script/DeployDonationHandler.s.sol:DeployDonationHandler --rpc-url $MAINNET_RPC --broadcast --verify --chain mainnet -vvvvv'",
18+
"deploy:sepolia": "bash -c 'source .env && forge script script/DeployDonationHandler.s.sol:DeployDonationHandler --rpc-url $SEPOLIA_RPC --broadcast --verify --chain sepolia -vvvvv'",
1819
"lint:check": "yarn lint:sol && forge fmt --check",
1920
"lint:fix": "sort-package-json && forge fmt && yarn lint:sol --fix",
2021
"lint:natspec": "npx @defi-wonderland/natspec-smells --config natspec-smells.config.js",

script/DeployDonationHandler.s.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
pragma solidity ^0.8.22;
33

44
import {DonationHandler} from '../src/contracts/DonationHandler.sol';
5-
import {ProxyAdmin} from '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol';
65
import {TransparentUpgradeableProxy} from '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol';
76
import {Script} from 'forge-std/Script.sol';
87
import {console} from 'forge-std/console.sol';
@@ -16,6 +15,7 @@ contract DeployDonationHandler is Script {
1615
function run() external {
1716
address deployer = vm.envAddress('PUBLIC_KEY');
1817
uint256 deployerPrivateKey = vm.envUint('PRIVATE_KEY');
18+
require(vm.addr(deployerPrivateKey) == deployer, 'PUBLIC_KEY must match PRIVATE_KEY');
1919

2020
vm.startBroadcast(deployerPrivateKey);
2121

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.22;
3+
4+
/// @notice Deploy DonationHandler implementation via CreateX CREATE2 so the address matches across chains.
5+
/// @dev Build with `FOUNDRY_PROFILE=deterministic` (see foundry.toml) so init code is identical everywhere.
6+
/// See https://www.getfoundry.sh/guides/deterministic-deployments-using-create2
7+
8+
import {DonationHandler} from '../src/contracts/DonationHandler.sol';
9+
import {Script, console} from 'forge-std/Script.sol';
10+
11+
interface ICreateX {
12+
function deployCreate2(bytes32 salt, bytes memory initCode) external payable returns (address deployed);
13+
function computeCreate2Address(bytes32 salt, bytes32 initCodeHash) external view returns (address computed);
14+
}
15+
16+
contract DeployDonationHandlerImplementation is Script {
17+
/// @dev CreateX factory at the same address on supported chains.
18+
ICreateX internal constant CREATEX = ICreateX(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed);
19+
20+
/// @dev Bump version when starting a new implementation lineage (changes CREATE2 address).
21+
bytes32 internal constant IMPLEMENTATION_SALT = keccak256('donation-handler.implementation.v1');
22+
23+
function run() external {
24+
uint256 deployerPrivateKey = vm.envUint('PRIVATE_KEY');
25+
26+
require(address(CREATEX).code.length > 0, 'CreateX not deployed on this chain');
27+
28+
bytes memory initCode = type(DonationHandler).creationCode;
29+
bytes32 initCodeHash = keccak256(initCode);
30+
31+
address predicted = CREATEX.computeCreate2Address(IMPLEMENTATION_SALT, initCodeHash);
32+
33+
console.log('=== CREATE2 DonationHandler implementation (CreateX) ===');
34+
console.log('Predicted address:', predicted);
35+
36+
vm.startBroadcast(deployerPrivateKey);
37+
38+
address deployed = CREATEX.deployCreate2(IMPLEMENTATION_SALT, initCode);
39+
40+
vm.stopBroadcast();
41+
42+
require(deployed == predicted, 'CREATE2 address mismatch');
43+
console.log('Deployed at:', deployed);
44+
console.log('Upgrade via ProxyAdmin.upgradeAndCall(proxy, implementation, 0x) when ready.');
45+
}
46+
}

scripts/deploy-implementation.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
set -e
3+
4+
# Deploy DonationHandler implementation via CreateX CREATE2 (same address on each chain when init code matches).
5+
# Usage: ./scripts/deploy-implementation.sh <chain>
6+
# Requires .env: PRIVATE_KEY, <CHAIN>_RPC (e.g. BASE_RPC), ETHERSCAN_API_KEY for --verify
7+
#
8+
# Build uses FOUNDRY_PROFILE=deterministic — see foundry.toml (must match manual forge script runs).
9+
# Chain names match foundry.toml [rpc_endpoints] keys (mainnet, base, sepolia, ...).
10+
#
11+
# Before deploying, ensure CreateX exists on the chain, e.g.:
12+
# cast code 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed --rpc-url "$BASE_RPC"
13+
# (non-empty code). The Solidity script also reverts if CreateX is missing.
14+
15+
CHAIN="${1:?Usage: deploy-implementation.sh <chain> (e.g. base, mainnet, sepolia)}"
16+
17+
cd "$(dirname "$0")/.."
18+
source .env
19+
20+
RPC_SUFFIX=$(echo "$CHAIN" | tr '[:lower:]' '[:upper:]' | tr '-' '_')
21+
RPC_VAR="${RPC_SUFFIX}_RPC"
22+
23+
if [[ -z "${!RPC_VAR}" ]]; then
24+
echo "Error: $RPC_VAR is not set in .env"
25+
exit 1
26+
fi
27+
28+
CREATEX_ADDRESS="0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed"
29+
CREATEX_CODE=$(cast code "$CREATEX_ADDRESS" --rpc-url "${!RPC_VAR}" 2>/dev/null || true)
30+
if [[ -z "$CREATEX_CODE" || "$CREATEX_CODE" == "0x" ]]; then
31+
echo "Error: CreateX not deployed at $CREATEX_ADDRESS on this RPC (cast code returned empty)."
32+
exit 1
33+
fi
34+
35+
LEGACY_CHAINS="celo gnosis"
36+
LEGACY_FLAG=""
37+
if [[ " $LEGACY_CHAINS " == *" $CHAIN "* ]]; then
38+
LEGACY_FLAG="--legacy"
39+
fi
40+
41+
export FOUNDRY_PROFILE=deterministic
42+
43+
forge script script/DeployDonationHandlerImplementation.s.sol:DeployDonationHandlerImplementation \
44+
--rpc-url "${!RPC_VAR}" \
45+
--broadcast \
46+
--verify \
47+
--chain "$CHAIN" \
48+
$LEGACY_FLAG \
49+
-vvvv

src/contracts/DonationHandler.sol

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol';
55
import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol';
66
import '@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol';
77
import '@openzeppelin/contracts/interfaces/IERC20.sol';
8+
import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
89

910
contract DonationHandler is OwnableUpgradeable, ReentrancyGuardUpgradeable {
11+
using SafeERC20 for IERC20;
12+
1013
address private constant ETH_TOKEN_ADDRESS = address(0);
1114

1215
/// @notice Event emitted when a donation is made
@@ -185,8 +188,7 @@ contract DonationHandler is OwnableUpgradeable, ReentrancyGuardUpgradeable {
185188
/// @param recipientAddress The address of the recipient of the donation
186189
function _handleERC20(address token, uint256 amount, address recipientAddress, bytes memory data) internal {
187190
if (token == ETH_TOKEN_ADDRESS || recipientAddress == ETH_TOKEN_ADDRESS || amount == 0) revert InvalidInput();
188-
bool success = IERC20(token).transferFrom(msg.sender, recipientAddress, amount);
189-
require(success, 'ERC20 transfer failed');
191+
IERC20(token).safeTransferFrom(msg.sender, recipientAddress, amount);
190192
emit DonationMade(recipientAddress, amount, token, data);
191193
}
192194

test/DonationHandler.t.sol

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,14 @@ pragma solidity ^0.8.0;
33

44
import '../src/contracts/DonationHandler.sol';
55

6+
import './mocks/FailingMockERC20.sol';
7+
import './mocks/MockERC20.sol';
8+
import './mocks/NoReturnMockERC20.sol';
9+
610
import '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol';
7-
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
11+
import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
812
import 'forge-std/Test.sol';
913

10-
// Mock ERC20 token for testing
11-
contract MockERC20 is ERC20 {
12-
constructor() ERC20('MockToken', 'MTK') {
13-
_mint(msg.sender, 1_000_000 * 10 ** 18);
14-
}
15-
}
16-
17-
contract FailingMockERC20 is ERC20 {
18-
constructor() ERC20('FailingToken', 'FAIL') {
19-
_mint(msg.sender, 1_000_000 * 10 ** 18);
20-
}
21-
22-
function transferFrom(address, address, uint256) public pure override returns (bool) {
23-
return false;
24-
}
25-
}
26-
2714
contract DonationHandlerTest is Test {
2815
DonationHandler public donationHandler;
2916
MockERC20 public mockToken;
@@ -262,10 +249,23 @@ contract DonationHandlerTest is Test {
262249
uint256 amount = 100 * 10 ** 18;
263250
failingToken.approve(address(donationHandler), amount);
264251

265-
vm.expectRevert('ERC20 transfer failed');
252+
vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(failingToken)));
266253
donationHandler.donateERC20(address(failingToken), recipient1, amount, data);
267254
}
268255

256+
function test_WhenMakingERC20DonationWithNoReturnToken() external {
257+
NoReturnMockERC20 noReturnToken = new NoReturnMockERC20();
258+
uint256 donationAmount = 100 * (10 ** uint256(noReturnToken.decimals()));
259+
bytes memory data = '';
260+
261+
noReturnToken.approve(address(donationHandler), donationAmount);
262+
263+
_expectDonationEvent(recipient1, donationAmount, address(noReturnToken));
264+
donationHandler.donateERC20(address(noReturnToken), recipient1, donationAmount, data);
265+
266+
assertEq(noReturnToken.balanceOf(recipient1), donationAmount);
267+
}
268+
269269
function test_RevertWhen_InitializingTwice() external {
270270
vm.expectRevert(Initializable.InvalidInitialization.selector);
271271
donationHandler.initialize();

test/DonationHandlerBugFix.t.sol

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,12 @@
22
pragma solidity ^0.8.0;
33

44
import '../src/contracts/DonationHandler.sol';
5+
6+
import './mocks/MockERC20.sol';
7+
58
import '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol';
6-
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
79
import 'forge-std/Test.sol';
810

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-
1611
/// @title DonationHandlerBugFixTest
1712
/// @notice Tests for the bug fix that ensures amounts array sums to totalAmount
1813
/// @dev These tests verify the fix for the vulnerability where mismatched amounts could lock or steal ETH/tokens
@@ -45,7 +40,7 @@ contract DonationHandlerBugFixTest is Test {
4540
vm.deal(eve, 100 ether);
4641

4742
// Give Alice some tokens
48-
mockToken.transfer(alice, 10000e18);
43+
mockToken.transfer(alice, 10_000e18);
4944
}
5045

5146
/// @notice Test that donateManyETH reverts when amounts sum is less than totalAmount
@@ -203,14 +198,14 @@ contract DonationHandlerBugFixTest is Test {
203198
to[2] = alice;
204199
to[3] = makeAddr('charlie');
205200
to[4] = makeAddr('dave');
206-
201+
207202
uint256[] memory amts = new uint256[](5);
208203
amts[0] = 1 ether;
209204
amts[1] = 2 ether;
210205
amts[2] = 3 ether;
211206
amts[3] = 2.5 ether;
212207
amts[4] = 1.5 ether; // Total = 10 ETH
213-
208+
214209
bytes[] memory data = new bytes[](5);
215210

216211
vm.prank(alice);

0 commit comments

Comments
 (0)