What reentrancy is and why it matters

Reentrancy happens when a contract makes an external call and that callee re-enters the original contract before the first execution completes. In Solidity, this is especially dangerous when you:

  • transfer ETH or tokens before updating internal balances
  • call arbitrary external contracts
  • rely on state that can change during a callback

A classic vulnerable pattern looks like this:

  1. User requests a withdrawal.
  2. Contract sends ETH to the user.
  3. User’s fallback function runs and calls withdraw() again.
  4. The contract still thinks the user has funds, so it pays again.

The fix is not just “use transfer” or “use send.” Those approaches are brittle and do not solve the general problem. Modern Solidity development favors explicit state updates before external calls, plus a guard for defense in depth.


A practical example: a simple deposit vault

Let’s build a minimal vault that accepts deposits and lets users withdraw their own balance. This is a common pattern in payment systems, reward contracts, and escrow-like flows.

Vulnerable version

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");

        // Vulnerable: external call before state update
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

        balances[msg.sender] -= amount;
    }
}

The issue is subtle but severe. If msg.sender is a contract, its fallback or receive function can call withdraw() again before balances[msg.sender] is reduced.

Why this is unsafe

The contract performs the interaction first, then the effects. That violates the checks-effects-interactions pattern. If the recipient is malicious, the contract’s state is still unchanged during the callback, so the same balance can be withdrawn multiple times.


The checks-effects-interactions pattern

The most important defense is to reorder logic:

  1. Checks: validate inputs and permissions.
  2. Effects: update internal state.
  3. Interactions: call external contracts last.

Here is the safer version:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SafeVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");

        // Effects first
        balances[msg.sender] -= amount;

        // Interaction last
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

This alone blocks the most straightforward reentrancy attack because the balance is already reduced before the external call occurs. If the recipient re-enters, the second call sees the updated balance.

Why this pattern is still not enough by itself

Checks-effects-interactions is necessary, but not always sufficient. Complex contracts may have:

  • multiple state variables that must remain consistent
  • cross-function reentrancy paths
  • external calls hidden inside helper functions
  • token callbacks such as ERC777 hooks
  • interactions with untrusted contracts in loops

For those cases, add a reentrancy guard.


Adding a reentrancy guard

A reentrancy guard prevents a function from being entered again while it is already executing. OpenZeppelin’s ReentrancyGuard is the standard choice, but it is useful to understand the mechanism.

Guarded implementation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

abstract contract ReentrancyGuard {
    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;
    uint256 private _status;

    constructor() {
        _status = NOT_ENTERED;
    }

    modifier nonReentrant() {
        require(_status != ENTERED, "reentrant call");
        _status = ENTERED;
        _;
        _status = NOT_ENTERED;
    }
}

contract GuardedVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "insufficient balance");

        balances[msg.sender] -= amount;

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

The modifier blocks nested entry into withdraw(). Even if a malicious recipient tries to call back, the second call fails immediately.

When to use a guard

Use a guard when:

  • the function makes external calls
  • the contract has multiple state transitions
  • you want defense in depth
  • the contract may evolve and gain new call paths later

Use it especially for public or external functions that transfer ETH or call arbitrary contracts.


Choosing the right transfer method

Solidity gives you several ways to send ETH. They are not equivalent.

MethodGas behaviorReentrancy riskNotes
transferFixed 2300 gasLower, but not a complete solutionCan break if recipient needs more gas
sendFixed 2300 gas, returns boolLower, but awkwardMust check return value manually
call{value: amount}("")Forwards all remaining gas by defaultHigher if misusedPreferred for compatibility, but requires careful design

Modern Solidity generally favors call because transfer and send can fail unexpectedly as gas costs change. The correct response is not to avoid call, but to use it safely with proper state ordering and guards.


Handling failed withdrawals cleanly

A withdrawal function should not silently lose funds if the external transfer fails. There are two common approaches:

  1. Revert the whole transaction if sending ETH fails.
  2. Record a pending withdrawal and let the user claim later.

For a simple vault, reverting is fine. For more robust systems, a pull-based claim flow can isolate external transfer failures.

Example: pending withdrawal fallback

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract ClaimableVault {
    mapping(address => uint256) public balances;
    mapping(address => uint256) public pendingWithdrawals;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function requestWithdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");
        balances[msg.sender] -= amount;
        pendingWithdrawals[msg.sender] += amount;
    }

    function claim() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "nothing to claim");

        pendingWithdrawals[msg.sender] = 0;

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "claim failed");
    }
}

This design separates accounting from transfer. If the claim fails, the user can retry without corrupting the main balance ledger.


Real-world best practices for withdrawal flows

A secure withdrawal pattern is more than a single modifier. Keep these practices in mind:

1. Minimize external calls

Every external call increases risk. Avoid calling untrusted contracts unless necessary. If you must, keep the call surface small and predictable.

2. Update all relevant state before interaction

If a withdrawal affects multiple variables, update all of them before sending ETH. Partial updates can still be exploitable.

3. Prefer pull over push

Let users withdraw their own funds rather than pushing payments automatically in loops. Push-based mass payouts are harder to secure and can fail due to one bad recipient.

4. Avoid loops with external calls

A loop that sends ETH to many recipients can be blocked by one reverting recipient and can also create complex reentrancy paths. If you need batch payouts, consider chunked processing or claim-based distribution.

5. Treat token transfers as external calls

ERC20 transfers can still be risky if the token is malicious or non-standard. ERC777 and some fee-on-transfer tokens can trigger unexpected behavior. Apply the same discipline.

6. Keep invariants explicit

Write down what must always be true, such as:

  • total recorded balances must not exceed contract ETH balance
  • a user’s balance cannot go negative
  • withdrawals cannot exceed deposits

These invariants are useful both in code review and in tests.


Testing a reentrancy attack

A secure pattern should be verified with an attack-style test. Even a simple malicious contract can reveal whether your withdrawal flow is safe.

Malicious attacker contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IVault {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract ReentrancyAttacker {
    IVault public vault;
    uint256 public attackAmount;
    bool public attacking;

    constructor(address vaultAddress) {
        vault = IVault(vaultAddress);
    }

    function attack() external payable {
        require(msg.value > 0, "need ETH");
        attackAmount = msg.value;
        vault.deposit{value: msg.value}();
        attacking = true;
        vault.withdraw(attackAmount);
        attacking = false;
    }

    receive() external payable {
        if (attacking && address(vault).balance >= attackAmount) {
            vault.withdraw(attackAmount);
        }
    }
}

If the vault is vulnerable, the attacker can drain more than their deposit. If the vault uses checks-effects-interactions and a reentrancy guard, the second call fails.

What to assert in tests

In a proper test suite, verify that:

  • the attacker cannot withdraw more than their balance
  • the vault balance decreases only by the legitimate amount
  • reentrant calls revert
  • normal users can still withdraw successfully

Use a framework such as Foundry or Hardhat to simulate the attack contract and inspect balances before and after the transaction.


Common mistakes to avoid

MistakeWhy it is dangerousSafer alternative
Sending ETH before updating balancesEnables repeated withdrawalsUpdate state first
Relying only on transferBreaks with gas changes and is not a general fixUse call with safe ordering
Mixing accounting and payout logicIncreases complexity and attack surfaceSeparate request and claim steps
Forgetting cross-function reentrancyGuarding one function may not protect othersProtect all relevant entry points
Ignoring token callbacksNon-ETH assets can still re-enterTreat token transfers as external interactions

A production-ready withdrawal checklist

Before deploying a contract that sends value, review the following:

  • Does every withdrawal update state before external calls?
  • Are all public/external payout functions protected by nonReentrant where appropriate?
  • Are there any loops that call untrusted addresses?
  • Can a malicious recipient block the entire system?
  • Are failed transfers handled explicitly?
  • Are invariants covered by tests?
  • Have you reviewed indirect calls through helper functions and token hooks?

If the answer to any of these is unclear, the contract needs more review.


Conclusion

Reentrancy is one of the most important security concerns in Solidity because it exploits normal contract behavior: external calls. The safest withdrawal design combines three layers:

  • checks-effects-interactions
  • a reentrancy guard
  • disciplined testing with malicious contracts

For simple vaults, reordering state updates may be enough. For production systems, add a guard and keep the payout surface as small as possible. The result is a contract that is easier to reason about, easier to audit, and much harder to exploit.

Learn more with useful resources