
Building a Reentrancy-Safe Withdrawal Pattern in Solidity
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:
- User requests a withdrawal.
- Contract sends ETH to the user.
- User’s fallback function runs and calls
withdraw()again. - 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:
- Checks: validate inputs and permissions.
- Effects: update internal state.
- 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.
| Method | Gas behavior | Reentrancy risk | Notes |
|---|---|---|---|
transfer | Fixed 2300 gas | Lower, but not a complete solution | Can break if recipient needs more gas |
send | Fixed 2300 gas, returns bool | Lower, but awkward | Must check return value manually |
call{value: amount}("") | Forwards all remaining gas by default | Higher if misused | Preferred 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:
- Revert the whole transaction if sending ETH fails.
- 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
| Mistake | Why it is dangerous | Safer alternative |
|---|---|---|
| Sending ETH before updating balances | Enables repeated withdrawals | Update state first |
Relying only on transfer | Breaks with gas changes and is not a general fix | Use call with safe ordering |
| Mixing accounting and payout logic | Increases complexity and attack surface | Separate request and claim steps |
| Forgetting cross-function reentrancy | Guarding one function may not protect others | Protect all relevant entry points |
| Ignoring token callbacks | Non-ETH assets can still re-enter | Treat 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
nonReentrantwhere 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.
