3.7.1 Security-Critical Control Flow Patterns
The three patterns in this section — Checks-Effects-Interactions, Reentrancy Guards, and Pull-over-Push — are not optional. Every contract that handles value will use at least one, and most will use all three together. They are the load-bearing structural choices for any function that moves funds or invokes external code.
This section presents them as design patterns: the shape, the reasoning, the trade-offs, and the idiomatic implementation. Section 3.8.2 covers the reentrancy vulnerability in depth — what goes wrong when these patterns are absent. The two sections are complementary; if you are looking for vulnerable contract examples and Foundry attack proofs, that is 3.8.2. Here, the focus is on writing the right code the first time.
Checks-Effects-Interactions (CEI)
CEI is an ordering discipline for function bodies. Every state-changing function gets divided into three regions, executed in this order:
- Checks — validate inputs, permissions, balances, invariants. Revert if anything is wrong.
- Effects — update the contract's own state to reflect what is about to happen.
- Interactions — call external contracts or transfer ETH.
The reasoning is simple: by the time control leaves the contract (the Interactions region), the contract's state is already consistent with the operation having completed. Any re-entry sees a contract that has already accounted for the action — there is nothing left to exploit.
CEI costs nothing. It is a discipline, not a feature. A function written with CEI uses no extra gas, no extra storage, and no extra modifier. The cost is purely in attention during development.
Idiomatic Form
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Escrow {
mapping(address => uint256) public deposits;
function deposit() external payable {
deposits[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
// Checks
uint256 balance = deposits[msg.sender];
require(balance >= amount, "insufficient");
// Effects
deposits[msg.sender] = balance - amount;
// Interactions
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
The order matters even when the consequences of getting it wrong seem minor. A common temptation is to perform the transfer first and "save gas" by skipping the state update if the transfer fails — but the transfer cannot fail in any way that lets execution continue, so this saving is illusory and the inverted order opens the function to reentrancy.
When CEI Alone Is Not Enough
CEI protects the function it is applied to. It does not protect other functions on the same contract that share state with it. If withdraw() is CEI-compliant but transferShare() reads deposits[msg.sender] and can be re-entered during withdraw's external call, the contract is still vulnerable — just through a different door. This is cross-function reentrancy, and it is the reason CEI alone is insufficient; pair it with a reentrancy guard whenever multiple functions touch the same state.
Solidity 0.8+ Note
The introduction of checked arithmetic in 0.8.0 made CEI marginally more important. Pre-0.8 code often used SafeMath, which would revert on overflow during the Effects stage. Post-0.8, the same revert happens automatically — but only if the developer puts the arithmetic in Effects rather than after the interaction. Putting deposits[msg.sender] -= amount after the external call still works arithmetically; it just defeats CEI.
Reentrancy Guards
A reentrancy guard is a runtime lock. A storage slot tracks whether the contract is currently executing a protected function; entering one sets the slot to "locked," and any re-entry into another guarded function reverts.
The pattern is encapsulated in OpenZeppelin's ReentrancyGuard, which exposes the nonReentrant modifier:
import "@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, "insufficient");
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
}
The cost of nonReentrant is a single SSTORE on entry and a single SSTORE on exit, which OpenZeppelin optimizes to ~2,300 gas per call by using the slot toggle pattern (write 2, write back to 1, never write 0 because zero-to-nonzero is more expensive than nonzero-to-nonzero). On chains supporting EIP-1153 (Ethereum mainnet from the Cancun upgrade in March 2024), OpenZeppelin's ReentrancyGuardTransient uses transient storage instead, dropping the cost to roughly 100 gas per call.
When to Apply nonReentrant
The rule of thumb: apply nonReentrant to every external/public function that either (a) transfers ETH, (b) calls an external contract whose code you don't fully trust, or (c) shares state with any function that does either of the above.
The third condition is the one that catches developers off-guard. In the example below, withdraw() looks safe with the modifier, but claimReward() is unprotected and shares the balances mapping:
contract IncompleteGuard is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
// VULNERABLE: missing nonReentrant, reads shared state
function claimReward() external {
uint256 reward = balances[msg.sender] / 100;
(bool ok, ) = msg.sender.call{value: reward}("");
require(ok);
}
}
The lock is contract-wide. Adding nonReentrant to claimReward() closes the cross-function path because both functions compete for the same lock.
Read-Only Functions and Guards
A view function does not modify state, so it cannot itself be re-entered in a way that corrupts state. But if external protocols read a view function as a price feed during one of your state-changing functions, they may read a momentarily inconsistent value. OpenZeppelin v5.0 added _reentrancyGuardEntered() to expose lock status, allowing view functions to revert when called mid-operation:
function getPrice() external view returns (uint256) {
require(!_reentrancyGuardEntered(), "pool mid-operation");
return _calculatePrice();
}
This is read-only reentrancy defense and is treated more fully in Section 3.8.2 and Section 3.11.1 (oracle exposure).
Guard Limitations
A reentrancy guard prevents reentry into the same contract. It does not protect against:
- Cross-contract reentrancy, where the attacker re-enters a sibling contract that reads your contract's state. Each contract has its own lock; there is no global lock by default.
- Cross-chain reentrancy, where the "re-entry" happens on a different chain entirely via a bridge.
- Bugs in the function logic itself — a function with a wrong calculation is wrong whether or not it is guarded.
Defense in depth means combining CEI with a guard, not relying on the guard alone.
Pull-over-Push Payments
The third pattern restructures who initiates the transfer. In a push model, the contract sends funds to the recipient as part of an operation. In a pull model, the contract credits an internal balance and the recipient withdraws on their own initiative.
Push (the Hazard)
contract AuctionPush {
address public highestBidder;
uint256 public highestBid;
function bid() external payable {
require(msg.value > highestBid, "bid too low");
if (highestBidder != address(0)) {
// PUSH: refund the previous bidder immediately
(bool ok, ) = highestBidder.call{value: highestBid}("");
require(ok, "refund failed");
}
highestBidder = msg.sender;
highestBid = msg.value;
}
}
Two failure modes:
- DoS via revert: if
highestBidderis a contract whosereceive()always reverts, no future bid can succeed — the refund will always fail. The contract is permanently broken at the current high bid. - Gas griefing: if
highestBidderis a contract whosereceive()consumes all gas, every subsequent bid pays an enormous gas bill.
The contract has handed control to an untrusted recipient inside a critical state transition.
Pull (the Fix)
contract AuctionPull {
address public highestBidder;
uint256 public highestBid;
mapping(address => uint256) public pendingReturns;
function bid() external payable {
require(msg.value > highestBid, "bid too low");
if (highestBidder != address(0)) {
// Credit the previous bidder; let them pull
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdraw() external {
uint256 amount = pendingReturns[msg.sender];
require(amount > 0, "nothing to withdraw");
pendingReturns[msg.sender] = 0; // CEI applies here
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "withdraw failed");
}
}
Now a misbehaving recipient affects only themselves. If their withdraw() reverts, no other user's funds are blocked. The auction's critical path no longer depends on external code execution.
When Push Is Acceptable
Pull is the safer default, but push has legitimate uses:
- Trusted, known recipients — paying out to your own treasury multisig, for example, where you fully control the receiving contract.
- Atomic settlement requirements — some DeFi flows need the transfer to happen in the same transaction as the state change for downstream protocols to observe a consistent state.
- EOA-only recipients — if you can guarantee the recipient is an externally-owned account (no code), then push is essentially safe, though
force-feedattacks viaselfdestructand similar can still cause issues if your contract tracks balance byaddress(this).balance.
For anything user-facing where the recipient is arbitrary, pull is the right default. OpenZeppelin's PullPayment contract provides a ready-made implementation.
Gas and UX Trade-offs
Pull payments require two transactions per payout (the credit and the withdrawal), which doubles the user's gas cost relative to push. For high-value operations this is negligible; for micro-payouts it can be material. Some protocols hybrid the approaches: pull by default, with a permissioned "push to user" function for protocol operators to batch payouts when desired.
How the Patterns Compose
These three patterns are designed to layer. The minimum-safe shape of a value-handling function uses all of them:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Treasury is ReentrancyGuard {
mapping(address => uint256) public credits;
function deposit() external payable {
credits[msg.sender] += msg.value;
}
function settleAndCredit(address recipient, uint256 amount) external nonReentrant {
// Checks
require(credits[msg.sender] >= amount, "insufficient");
// Effects (pull pattern: credit recipient internally)
credits[msg.sender] -= amount;
credits[recipient] += amount;
// No external interaction at all — recipient withdraws separately
}
function withdraw() external nonReentrant {
// Checks
uint256 amount = credits[msg.sender];
require(amount > 0, "nothing to withdraw");
// Effects
credits[msg.sender] = 0;
// Interactions
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "withdraw failed");
}
}
Read this carefully:
settleAndCredithas no external call at all — the pull pattern eliminated it. Reentrancy is impossible because there is no interaction phase.withdrawdoes have an external call, so it follows CEI and is wrapped innonReentrant.- The two functions share the
creditsmapping, so both carrynonReentrantto close the cross-function path.
A function written this way has no realistic reentrancy attack surface. The remaining vulnerabilities, if any, are logic bugs (wrong math, wrong access control, wrong invariants) rather than reentrancy.
Foundry Test Demonstrating the Composition
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Treasury.sol";
contract MaliciousReceiver {
Treasury public treasury;
uint256 public reentryCount;
constructor(address _treasury) {
treasury = Treasury(_treasury);
}
function fund() external payable {
treasury.deposit{value: msg.value}();
}
function attack() external {
treasury.withdraw();
}
receive() external payable {
reentryCount++;
// try to re-enter — should always revert because of nonReentrant
try treasury.withdraw() {
// should not reach here
} catch {
// expected
}
}
}
contract TreasuryTest is Test {
Treasury treasury;
MaliciousReceiver attacker;
function setUp() public {
treasury = new Treasury();
attacker = new MaliciousReceiver(address(treasury));
vm.deal(address(attacker), 1 ether);
}
function test_withdrawalIsReentrancySafe() public {
attacker.fund{value: 1 ether}();
uint256 startBalance = address(attacker).balance;
attacker.attack();
// The reentrant call inside receive() was caught and reverted,
// so reentryCount should be 1 (entered once, failed once).
assertEq(attacker.reentryCount(), 1);
// Attacker got their original deposit back — no more, no less.
assertEq(address(attacker).balance, startBalance + 1 ether);
// Treasury fully drained of attacker's funds, no residual.
assertEq(address(treasury).balance, 0);
}
}
The test asserts three things: the reentrant call attempt happened (proving the malicious receiver tried), the attempt failed (proving the guard blocked it), and the final accounting is exactly as a non-reentrant withdrawal would produce. A Hardhat translation would use expect(...).to.equal(...) from Chai and ethers.getContractFactory(...) — the test logic is identical.
Quick Reference
| Pattern | Cost | Protects against | Does not protect against |
|---|---|---|---|
| Checks-Effects-Interactions | Zero gas | Single-function reentrancy, inconsistent state during external calls | Cross-function attacks, logic bugs |
| Reentrancy Guard | ~2,300 gas (or ~100 with transient storage) | Single- and cross-function reentrancy within one contract | Cross-contract reentrancy, cross-chain reentrancy |
| Pull-over-Push | ~21,000 extra gas (additional tx) | DoS via reverting recipient, gas griefing | Logic bugs in withdrawal handling |
Cross-References
- Vulnerability deep dive — Section 3.8.2 (Reentrancy Family) covers each reentrancy variant with attack proofs
- State management context — Section 3.7.2 (State & Storage Patterns) covers patterns these interact with
- Defensive patterns — Section 3.7.5 (Defensive Patterns) covers circuit breakers and rate limiting, which compose with these
- Pre-0.8 arithmetic — Section 3.8.3 (Arithmetic & Precision) explains the SafeMath/checked-arithmetic transition referenced above
- Real exploits — Section 3.10.1 (The DAO) shows what happens when CEI is missing in production
- Auditor's heuristics — Section 4.11.8 (Re-entrancy Vulnerabilities) covers how reviewers detect missing applications of these patterns