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
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.
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
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.
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()
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?
