Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
MAINNET_RPC=
MAINNET_DEPLOYER_NAME=

SEPOLIA_RPC=
SEPOLIA_DEPLOYER_NAME=

BASE_RPC=
ARBITRUM_RPC=
GNOSIS_RPC=
CELO_RPC=
OPTIMISM_RPC=
POLYGON_RPC=

# Deployer key for forge script (hex private key)
PRIVATE_KEY=
# Used by script/DeployDonationHandler.s.sol only (must match PRIVATE_KEY account)
PUBLIC_KEY=

ETHERSCAN_API_KEY=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ out-via-ir

# Config files
.env
.env.local

# Avoid ignoring gitkeep
!/**/.gitkeep
Expand Down
18 changes: 18 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,31 @@ src = 'src/interfaces/'
fuzz = { runs = 5000 }
invariant = { runs = 1000 }

# Use only for implementation deploy script: identical bytecode across chains for CREATE2.
# Run: FOUNDRY_PROFILE=deterministic forge script ...
[profile.deterministic]
bytecode_hash = 'none'
cbor_metadata = false

[fuzz]
runs = 1000

[rpc_endpoints]
mainnet = "${MAINNET_RPC}"
sepolia = "${SEPOLIA_RPC}"
base = "${BASE_RPC}"
arbitrum = "${ARBITRUM_RPC}"
gnosis = "${GNOSIS_RPC}"
celo = "${CELO_RPC}"
optimism = "${OPTIMISM_RPC}"
polygon = "${POLYGON_RPC}"

[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }
sepolia = { key = "${ETHERSCAN_API_KEY}" }
base = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.basescan.org/api' }
arbitrum = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.arbiscan.io/api' }
gnosis = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.gnosisscan.io/api' }
celo = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.celoscan.io/api' }
optimism = { key = "${ETHERSCAN_API_KEY}", url = 'https://api-optimistic.etherscan.io/api' }
polygon = { key = "${ETHERSCAN_API_KEY}", url = 'https://api.polygonscan.com/api' }
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
"build": "forge build",
"build:optimized": "FOUNDRY_PROFILE=optimized forge build",
"coverage": "forge coverage --ir-minimum --report summary --report lcov --match-path 'test/unit/*'",
"deploy:mainnet": "bash -c 'source .env && forge script Deploy --rpc-url $MAINNET_RPC --account $MAINNET_DEPLOYER_NAME --broadcast --verify --chain mainnet -vvvvv'",
"deploy:sepolia": "bash -c 'source .env && forge script Deploy --rpc-url $SEPOLIA_RPC --account $SEPOLIA_DEPLOYER_NAME --broadcast --verify --chain sepolia -vvvvv'",
"deploy:implementation": "./scripts/deploy-implementation.sh",
"deploy:mainnet": "bash -c 'source .env && forge script script/DeployDonationHandler.s.sol:DeployDonationHandler --rpc-url $MAINNET_RPC --broadcast --verify --chain mainnet -vvvvv'",
"deploy:sepolia": "bash -c 'source .env && forge script script/DeployDonationHandler.s.sol:DeployDonationHandler --rpc-url $SEPOLIA_RPC --broadcast --verify --chain sepolia -vvvvv'",
"lint:check": "yarn lint:sol && forge fmt --check",
"lint:fix": "sort-package-json && forge fmt && yarn lint:sol --fix",
"lint:natspec": "npx @defi-wonderland/natspec-smells --config natspec-smells.config.js",
Expand Down
2 changes: 1 addition & 1 deletion script/DeployDonationHandler.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
pragma solidity ^0.8.22;

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

vm.startBroadcast(deployerPrivateKey);

Expand Down
46 changes: 46 additions & 0 deletions script/DeployDonationHandlerImplementation.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

/// @notice Deploy DonationHandler implementation via CreateX CREATE2 so the address matches across chains.
/// @dev Build with `FOUNDRY_PROFILE=deterministic` (see foundry.toml) so init code is identical everywhere.
/// See https://www.getfoundry.sh/guides/deterministic-deployments-using-create2

import {DonationHandler} from '../src/contracts/DonationHandler.sol';
import {Script, console} from 'forge-std/Script.sol';

interface ICreateX {
function deployCreate2(bytes32 salt, bytes memory initCode) external payable returns (address deployed);
function computeCreate2Address(bytes32 salt, bytes32 initCodeHash) external view returns (address computed);
}

contract DeployDonationHandlerImplementation is Script {
/// @dev CreateX factory at the same address on supported chains.
ICreateX internal constant CREATEX = ICreateX(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed);

/// @dev Bump version when starting a new implementation lineage (changes CREATE2 address).
bytes32 internal constant IMPLEMENTATION_SALT = keccak256('donation-handler.implementation.v1');

function run() external {
uint256 deployerPrivateKey = vm.envUint('PRIVATE_KEY');

require(address(CREATEX).code.length > 0, 'CreateX not deployed on this chain');

bytes memory initCode = type(DonationHandler).creationCode;
bytes32 initCodeHash = keccak256(initCode);

address predicted = CREATEX.computeCreate2Address(IMPLEMENTATION_SALT, initCodeHash);

console.log('=== CREATE2 DonationHandler implementation (CreateX) ===');
console.log('Predicted address:', predicted);

vm.startBroadcast(deployerPrivateKey);

address deployed = CREATEX.deployCreate2(IMPLEMENTATION_SALT, initCode);

vm.stopBroadcast();

require(deployed == predicted, 'CREATE2 address mismatch');
console.log('Deployed at:', deployed);
console.log('Upgrade via ProxyAdmin.upgradeAndCall(proxy, implementation, 0x) when ready.');
}
}
49 changes: 49 additions & 0 deletions scripts/deploy-implementation.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -e

# Deploy DonationHandler implementation via CreateX CREATE2 (same address on each chain when init code matches).
# Usage: ./scripts/deploy-implementation.sh <chain>
# Requires .env: PRIVATE_KEY, <CHAIN>_RPC (e.g. BASE_RPC), ETHERSCAN_API_KEY for --verify
#
# Build uses FOUNDRY_PROFILE=deterministic — see foundry.toml (must match manual forge script runs).
# Chain names match foundry.toml [rpc_endpoints] keys (mainnet, base, sepolia, ...).
#
# Before deploying, ensure CreateX exists on the chain, e.g.:
# cast code 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed --rpc-url "$BASE_RPC"
# (non-empty code). The Solidity script also reverts if CreateX is missing.

CHAIN="${1:?Usage: deploy-implementation.sh <chain> (e.g. base, mainnet, sepolia)}"

cd "$(dirname "$0")/.."
source .env

RPC_SUFFIX=$(echo "$CHAIN" | tr '[:lower:]' '[:upper:]' | tr '-' '_')
RPC_VAR="${RPC_SUFFIX}_RPC"

if [[ -z "${!RPC_VAR}" ]]; then
echo "Error: $RPC_VAR is not set in .env"
exit 1
fi

CREATEX_ADDRESS="0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed"
CREATEX_CODE=$(cast code "$CREATEX_ADDRESS" --rpc-url "${!RPC_VAR}" 2>/dev/null || true)
if [[ -z "$CREATEX_CODE" || "$CREATEX_CODE" == "0x" ]]; then
echo "Error: CreateX not deployed at $CREATEX_ADDRESS on this RPC (cast code returned empty)."
exit 1
fi

LEGACY_CHAINS="celo gnosis"
LEGACY_FLAG=""
if [[ " $LEGACY_CHAINS " == *" $CHAIN "* ]]; then
LEGACY_FLAG="--legacy"
fi

export FOUNDRY_PROFILE=deterministic

forge script script/DeployDonationHandlerImplementation.s.sol:DeployDonationHandlerImplementation \
--rpc-url "${!RPC_VAR}" \
--broadcast \
--verify \
--chain "$CHAIN" \
$LEGACY_FLAG \
-vvvv
6 changes: 4 additions & 2 deletions src/contracts/DonationHandler.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol';
import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol';
import '@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol';
import '@openzeppelin/contracts/interfaces/IERC20.sol';
import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';

contract DonationHandler is OwnableUpgradeable, ReentrancyGuardUpgradeable {
using SafeERC20 for IERC20;

address private constant ETH_TOKEN_ADDRESS = address(0);

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

Expand Down
38 changes: 19 additions & 19 deletions test/DonationHandler.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,14 @@ pragma solidity ^0.8.0;

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

import './mocks/FailingMockERC20.sol';
import './mocks/MockERC20.sol';
import './mocks/NoReturnMockERC20.sol';

import '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol';
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';
import 'forge-std/Test.sol';

// Mock ERC20 token for testing
contract MockERC20 is ERC20 {
constructor() ERC20('MockToken', 'MTK') {
_mint(msg.sender, 1_000_000 * 10 ** 18);
}
}

contract FailingMockERC20 is ERC20 {
constructor() ERC20('FailingToken', 'FAIL') {
_mint(msg.sender, 1_000_000 * 10 ** 18);
}

function transferFrom(address, address, uint256) public pure override returns (bool) {
return false;
}
}

contract DonationHandlerTest is Test {
DonationHandler public donationHandler;
MockERC20 public mockToken;
Expand Down Expand Up @@ -262,10 +249,23 @@ contract DonationHandlerTest is Test {
uint256 amount = 100 * 10 ** 18;
failingToken.approve(address(donationHandler), amount);

vm.expectRevert('ERC20 transfer failed');
vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(failingToken)));
donationHandler.donateERC20(address(failingToken), recipient1, amount, data);
}

function test_WhenMakingERC20DonationWithNoReturnToken() external {
NoReturnMockERC20 noReturnToken = new NoReturnMockERC20();
uint256 donationAmount = 100 * (10 ** uint256(noReturnToken.decimals()));
bytes memory data = '';

noReturnToken.approve(address(donationHandler), donationAmount);

_expectDonationEvent(recipient1, donationAmount, address(noReturnToken));
donationHandler.donateERC20(address(noReturnToken), recipient1, donationAmount, data);

assertEq(noReturnToken.balanceOf(recipient1), donationAmount);
}

function test_RevertWhen_InitializingTwice() external {
vm.expectRevert(Initializable.InvalidInitialization.selector);
donationHandler.initialize();
Expand Down
17 changes: 6 additions & 11 deletions test/DonationHandlerBugFix.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@
pragma solidity ^0.8.0;

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

import './mocks/MockERC20.sol';

import '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol';
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import 'forge-std/Test.sol';

// Mock ERC20 token for testing
contract MockERC20 is ERC20 {
constructor() ERC20('MockToken', 'MTK') {
_mint(msg.sender, 1_000_000 * 10 ** 18);
}
}

/// @title DonationHandlerBugFixTest
/// @notice Tests for the bug fix that ensures amounts array sums to totalAmount
/// @dev These tests verify the fix for the vulnerability where mismatched amounts could lock or steal ETH/tokens
Expand Down Expand Up @@ -45,7 +40,7 @@ contract DonationHandlerBugFixTest is Test {
vm.deal(eve, 100 ether);

// Give Alice some tokens
mockToken.transfer(alice, 10000e18);
mockToken.transfer(alice, 10_000e18);
}

/// @notice Test that donateManyETH reverts when amounts sum is less than totalAmount
Expand Down Expand Up @@ -203,14 +198,14 @@ contract DonationHandlerBugFixTest is Test {
to[2] = alice;
to[3] = makeAddr('charlie');
to[4] = makeAddr('dave');

uint256[] memory amts = new uint256[](5);
amts[0] = 1 ether;
amts[1] = 2 ether;
amts[2] = 3 ether;
amts[3] = 2.5 ether;
amts[4] = 1.5 ether; // Total = 10 ETH

bytes[] memory data = new bytes[](5);

vm.prank(alice);
Expand Down
14 changes: 9 additions & 5 deletions test/DonationHandlerMultisigCalls.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ pragma solidity ^0.8.0;

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

import {DonationHandlerSetup, FailingMockERC20, MockERC20} from './DonationHandlerSetup.t.sol';
import './DonationHandlerSetup.t.sol';
import './mocks/FailingMockERC20.sol';
import './mocks/MockERC20.sol';

import '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol';
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import 'forge-std/Test.sol';

// Mock ERC20 token for testing

contract DummyMultisig {
address public owner;
bool public shouldRevert;
Expand All @@ -28,7 +28,11 @@ contract DummyMultisig {
}

// Execute a call to the donation handler
function executeTransaction(address target, uint256 value, bytes memory data) external payable returns (bool success) {
function executeTransaction(
address target,
uint256 value,
bytes memory data
) external payable returns (bool success) {
require(msg.sender == owner, 'Not authorized');

if (shouldRevert) {
Expand Down
21 changes: 3 additions & 18 deletions test/DonationHandlerSetup.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,12 @@ pragma solidity ^0.8.0;

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

import './mocks/FailingMockERC20.sol';
import './mocks/MockERC20.sol';

import '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol';
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import 'forge-std/Test.sol';

// Mock ERC20 token for testing
contract MockERC20 is ERC20 {
constructor() ERC20('MockToken', 'MTK') {
_mint(msg.sender, 1_000_000 * 10 ** 18);
}
}

contract FailingMockERC20 is ERC20 {
constructor() ERC20('FailingToken', 'FAIL') {
_mint(msg.sender, 1_000_000 * 10 ** 18);
}

function transferFrom(address, address, uint256) public pure override returns (bool) {
return false;
}
}

contract DonationHandlerSetup is Test {
DonationHandler public donationHandler;
MockERC20 public mockToken;
Expand Down
Loading
Loading