Skip to content
Guide · Security

The reentrancy checklist

Seven patterns every Solidity developer must internalize before mainnet — CEI, ReentrancyGuard, pull payments, Pausable, and cross-function traps.

Security
12 min read

Why reentrancy still tops the charts

Reentrancy is not a historical footnote — it remains one of the most exploited vulnerability classes in smart contracts. The attack is simple: your contract calls an external address (sending ETH or invoking a token hook) before it finishes updating its own state. The receiver calls back into your contract and exploits stale balances. The fix is structural: reorder operations and use OpenZeppelin guards.

  • Classic withdraw — send ETH before zeroing balance
  • ERC777/ERC721 hooks — tokensToSend or onERC721Received reenter
  • Cross-function — guard on withdraw() does not protect transfer() reading same balance
  • Cross-contract — read-only reentrancy via oracle or LP token pricing

Pattern 1 — Checks-Effects-Interactions (CEI)

CEI is the default discipline. Checks: validate inputs and permissions. Effects: write all state changes. Interactions: call external contracts or transfer value. If you zero a balance before sending ETH, a reentrant call sees the updated balance and cannot withdraw again.

  • Apply CEI to every function that reads then writes shared state before an external call
  • CEI alone does not fix cross-function reentrancy — pair with ReentrancyGuard
  • Solidity 0.8+ reverts on underflow, but logic bugs still happen with wrong order
cei-withdraw.solsolidity
function withdraw(uint256 amount) external {
    // Checks
    require(balances[msg.sender] >= amount, "insufficient");

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

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

Pattern 2 — ReentrancyGuard

OpenZeppelin's ReentrancyGuard maintains a _status flag. The nonReentrant modifier sets it on entry and clears on exit; a reentrant call hits _nonReentrantBefore and reverts. Add it to every external function that performs external calls or transfers value.

Vault.solsolidity
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Vault 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);
        balances[msg.sender] -= amount;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok);
    }
}

Pattern 3 — Pull over push payments

Push payments send ETH or tokens to recipients inside a loop or settlement function — one bad receiver bricks the whole batch. Pull payments record what is owed and let recipients call claim(). The failure surface is isolated to the claimant's own transaction.

  • OpenZeppelin PaymentSplitter uses pull semantics internally
  • Crowdfunding refunds should let contributors pull, not push in a loop
  • Merkle airdrops — claim() is naturally pull-based
pull-payments.solsolidity
mapping(address => uint256) public pendingWithdrawals;

function withdraw(uint256 amount) external nonReentrant {
    pendingWithdrawals[msg.sender] += amount;
    // ... deduct internal balance
}

function claim() external nonReentrant {
    uint256 amount = pendingWithdrawals[msg.sender];
    pendingWithdrawals[msg.sender] = 0;
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok);
}

Pattern 4 — Pausable emergency stop

When you detect an active exploit, pausing stops state-changing functions while you diagnose. OpenZeppelin's Pausable adds whenNotPaused to sensitive entrypoints. The owner (ideally a multisig or Timelock) calls pause(); users can still withdraw if you exempt view-only and exit functions.

ProtectedVault.solsolidity
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract ProtectedVault is Pausable, Ownable {
    function stake(uint256 amount) external whenNotPaused {
        // ...
    }

    function pause() external onlyOwner {
        _pause();
    }

    function unpause() external onlyOwner {
        _unpause();
    }
}

Pattern 5 — Use SafeERC20

Not every ERC20 returns true from transfer. Some revert, some return nothing. Calling transfer directly and checking a boolean that was never returned gives a false sense of security. OpenZeppelin SafeERC20 reverts on failure uniformly.

  • safeTransfer, safeTransferFrom, safeApprove — use all three
  • Never assume transfer succeeded because the call did not revert with raw transfer()
TokenVault.solsolidity
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract TokenVault {
    using SafeERC20 for IERC20;

    function deposit(IERC20 token, uint256 amount) external {
        token.safeTransferFrom(msg.sender, address(this), amount);
    }
}

Pre-mainnet audit checklist

Walk this list before every deployment. Ethereum Toolset templates ship with ReentrancyGuard and SafeERC20 wired in — custom logic you add on top needs the same scrutiny.

  • Every external/public function that transfers value — CEI order verified?
  • nonReentrant on all value-transfer entrypoints, including cross-function sets?
  • No push payments in loops — pull or merkle claim instead?
  • Pausable on deposit/stake, withdraw always available?
  • SafeERC20 for every token transfer?
  • Read-only reentrancy considered for oracle/LP price reads?
  • Tested with a malicious receiver contract on Sepolia?