Case studySolidity ^0.8.19Unaudited
Fundraise.sol keeps the ETH honest.
Memecoin raises still do this: tweet an EOA, no caps, no refunds, hope the deployer is honest if the thing never launches. Token distribution is whoever screenshotted the explorer first.
Fundraise.sol is a Solidity contract that takes the ETH instead. Rules are constructor arguments. The ledger is contributions[address] plus events. It is unaudited — treat it as a pattern, not a blessed mainnet deploy.
ETH goes to a contract, not an EOA. Contributors can pull out until the soft cap; the owner cannot pull until it is met.
- Reentrancy::attack{value: 0.3 ETH}()gas 86,979
- Fundraise::contribute{value: 0.3 ETH}()emit Contributed(isDeposit: true, amount: 0.3, totalAmount: 0.3)gas 36,900
- Fundraise::userWithdraw(0.01 ETH)gas 18,737
- Reentrancy::receive{value: 0.01 ETH}()getTargetBalance() → 5.29 ETH, so re-entergas 2,525
- Fundraise::userWithdraw(0.01 ETH)revertReentrancyGuard: reentrant callgas 843
- ← receive()revertReentrancyGuard: reentrant call
- ← userWithdraw()revertcannot withdraw
- ← attack()revertcannot withdraw
- Contract before
- 5 ETH
- Contract after
- 5 ETH
- Attacker after
- 0 ETH
The re-entry trips the guard, the send fails, and the whole attack reverts. forge test -vvvv against 409H/fundraise @ 7d5d10e, forge 1.8.6, solc 0.8.37.
- way in: contribute()
- 1
- rules fixed at deploy
- 4
- functions in the ABI
- 13
- repo tests passing
- 16/16
- audits, so far
- 0
Four numbers, set once
Constructor arguments, in wei, with no setter afterwards. The values shown are the ones the repo's tests deploy with.
minContributionWei / maxContributionWei — per wallet, cumulative
fundraiseSoftCapWei — refunds stop and owner withdraw is allowed only after this
fundraiseHardCapWei — no more contribute() once totalRaised would exceed it
In the tests
- min
- 0.3 ETH
- max
- 5 ETH
- soft cap
- 13 ETH
- hard cap
- 100 ETH
Five doors, one way in
Every way to touch the contract. Pick one to see who can call it, what it checks and the code.
selector 0xd7bb99bapayable
contribute()
contribute() is the only payable function. The min and max apply to your running total, not to each transaction, so you can top up in steps as long as you stay inside the window. The hard cap is checked against totalRaised plus what you are sending: one wei over and the whole call reverts.
Your position lives in contributions[msg.sender], and every call emits Contributed with the amount and your new total.
- Who can call it
- Anyone, with ETH attached
- Checks, in order
- min ≤ your new running total ≤ max
Out of bounds contribution - totalRaised + msg.value ≤ hard cap
Out of bounds contribution - hard cap exceeded
- min ≤ your new running total ≤ max
- What changes
- totalRaised += msg.value
- contributions[msg.sender] = new running total
- emit Contributed(sender, true, amount, totalAmount)
- Walletcontribute() + ETHFundraise.sol
- Fundraise.solmin/max, hard capLedger += amount
- Ledger += amountemit Contributed(…, true, …)Indexer
function contribute() public payable { uint256 amt = msg.value; uint256 currentContribution = contributions[msg.sender]; uint256 totalAmt = currentContribution + amt; require(totalAmt >= minContributionWei && totalAmt <= maxContributionWei, "Out of bounds contribution" ); uint256 newTotalRaised = totalRaised + amt; require(fundraiseHardCapWei >= newTotalRaised, "Out of bounds contribution - hard cap exceeded" ); totalRaised += amt; contributions[msg.sender] = totalAmt; emit Contributed(msg.sender, true, amt, totalAmt); }Anatomy of a raise
One run of the contract at 7d5d10e with the repo's test rules (0.3 / 5 / 13 / 100 ETH). Every step's result and state are asserted in a Foundry test; gas is the call gas from the trace.
- 01
Deployed
Owner
new Fundraise(0.3, 5, 13, 100)emit Fundrasing(true)
- totalRaised
- 0 ETH
- balance
- 0 ETH
- 02
Refund window
Wallet A
contribute{5 ETH}Contributed(A, true, 5, 5)
- totalRaised
- 5 ETH
- balance
- 5 ETH
- gas
- 75,064
- 03
Refund window
Wallet B
contribute{5 ETH}Contributed(B, true, 5, 5)
- totalRaised
- 10 ETH
- balance
- 10 ETH
- gas
- 57,964
- 04
Refund window
Wallet A
userWithdraw(2 ETH)Contributed(A, false, 2, 3)
- totalRaised
- 8 ETH
- balance
- 8 ETH
- gas
- 49,156
- 05
Stalled at the cap
Wallet C
contribute{5 ETH}Contributed(C, true, 5, 5)
- totalRaised
- 13 ETH
- balance
- 13 ETH
- gas
- 57,964
- 06
Stalled at the cap
Wallet A
userWithdraw(1 ETH)revertsoft-cap reached, cannot withdraw
- totalRaised
- 13 ETH
- balance
- 13 ETH
- gas
- 33,493
- 07
Stalled at the cap
Owner
ownerWithdraw()revertsoft-cap is not reached
- totalRaised
- 13 ETH
- balance
- 13 ETH
- gas
- 25,852
- 08
Owner window
Wallet D
contribute{0.3 ETH}Contributed(D, true, 0.3, 0.3)
- totalRaised
- 13.3 ETH
- balance
- 13.3 ETH
- gas
- 57,964
- 09
Owner window
Owner
ownerWithdraw()13.3 ETH to the owner, no event
- totalRaised
- 13.3 ETH
- balance
- 0 ETH
- gas
- 32,694
Edges worth knowing
Each of these is checked in a Foundry test against the contract at commit 7d5d10e.
- 01
Exactly at the soft cap, nobody can withdraw
Refunds need
totalRaised < softCap. The owner needsbalance > softCap. Land on the cap exactly and both revert until someone contributes again. - 02
Late money waits
After
ownerWithdraw()the balance is zero buttotalRaisedis not, so refunds stay closed. New contributions sit in the contract until the balance passes the soft cap again. - 03
Soft cap equal to hard cap never unlocks
If both caps are the same number, the balance can never be above the soft cap. Refunds close at the cap and the owner can never withdraw.
- 04
A refund can leave you under the minimum
The minimum is only checked in
contribute(). A partialuserWithdraw()can leave a wallet holding less thanminContributionWei. - 05
The owner has to be cheap to pay
ownerWithdraw()forwards 21,000 gas. An owner contract whosereceive()writes to storage runs out and the call revertscannot withdraw. - 06
The payout is silent
ownerWithdraw()emits no event. Indexers see the balance drop, not a log.
Why bother
If the raise dies under the soft cap, contributors are not waiting on a screenshot of an EOA. If it clears, the owner can take the ETH and the contribution table is still on-chain. That is the whole contract. Audit it before you point real money at it.
