hd.harry denley

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 · call tracetestReentrancyAttackReverts ↗
  1. Reentrancy::attack{value: 0.3 ETH}()gas 86,979
  2. Fundraise::contribute{value: 0.3 ETH}()emit Contributed(isDeposit: true, amount: 0.3, totalAmount: 0.3)gas 36,900
  3. Fundraise::userWithdraw(0.01 ETH)gas 18,737
  4. Reentrancy::receive{value: 0.01 ETH}()getTargetBalance() → 5.29 ETH, so re-entergas 2,525
  5. Fundraise::userWithdraw(0.01 ETH)revertReentrancyGuard: reentrant callgas 843
  6. ← receive()revertReentrancyGuard: reentrant call
  7. ← userWithdraw()revertcannot withdraw
  8. ← 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 ≤ maxOut of bounds contribution
  • totalRaised + msg.value ≤ hard capOut of bounds contribution - hard cap exceeded
What changes
  • totalRaised += msg.value
  • contributions[msg.sender] = new running total
  • emit Contributed(sender, true, amount, totalAmount)
  1. Walletcontribute() + ETHFundraise.sol
  2. Fundraise.solmin/max, hard capLedger += amount
  3. Ledger += amountemit Contributed(…, true, …)Indexer
The codeFundraise.sol L91–109 ↗
    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.

  1. 01

    Deployed

    Ownernew Fundraise(0.3, 5, 13, 100)

    emit Fundrasing(true)

    totalRaised
    0 ETH
    balance
    0 ETH
  2. 02

    Refund window

    Wallet Acontribute{5 ETH}

    Contributed(A, true, 5, 5)

    totalRaised
    5 ETH
    balance
    5 ETH
    gas
    75,064
  3. 03

    Refund window

    Wallet Bcontribute{5 ETH}

    Contributed(B, true, 5, 5)

    totalRaised
    10 ETH
    balance
    10 ETH
    gas
    57,964
  4. 04

    Refund window

    Wallet AuserWithdraw(2 ETH)

    Contributed(A, false, 2, 3)

    totalRaised
    8 ETH
    balance
    8 ETH
    gas
    49,156
  5. 05

    Stalled at the cap

    Wallet Ccontribute{5 ETH}

    Contributed(C, true, 5, 5)

    totalRaised
    13 ETH
    balance
    13 ETH
    gas
    57,964
  6. 06

    Stalled at the cap

    Wallet AuserWithdraw(1 ETH)

    revertsoft-cap reached, cannot withdraw

    totalRaised
    13 ETH
    balance
    13 ETH
    gas
    33,493
  7. 07

    Stalled at the cap

    OwnerownerWithdraw()

    revertsoft-cap is not reached

    totalRaised
    13 ETH
    balance
    13 ETH
    gas
    25,852
  8. 08

    Owner window

    Wallet Dcontribute{0.3 ETH}

    Contributed(D, true, 0.3, 0.3)

    totalRaised
    13.3 ETH
    balance
    13.3 ETH
    gas
    57,964
  9. 09

    Owner window

    OwnerownerWithdraw()

    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 needs balance > softCap. Land on the cap exactly and both revert until someone contributes again.

  • 02

    Late money waits

    After ownerWithdraw() the balance is zero but totalRaised is 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 partial userWithdraw() can leave a wallet holding less than minContributionWei.

  • 05

    The owner has to be cheap to pay

    ownerWithdraw() forwards 21,000 gas. An owner contract whose receive() writes to storage runs out and the call reverts cannot 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.

Next case studyWHG_1 When a user's private key is compromised, they may have unstolen assets