Guarding Against Reentrancy: Using Solidity’s Checks‑Effects‑Interactions Pattern
Reentrancy attacks can drain a contract’s funds. The Checks‑Effects‑Interactions (CEI) pattern is a tried‑and‑true way to stop them. This post walks through a concrete withdraw example, shows how CEI works, and explains its limits and when to add a mutex.
05 Jun 2026, 04:59 UTC

Problem: Reentrancy in Solidity
When a contract sends Ether to an address that is a contract itself, the recipient’s fallback function runs before the original function finishes. If the fallback calls back into the original function, the state may be in an inconsistent or incomplete state, allowing the attacker to repeat the operation and drain funds. This classic vulnerability was exposed by the DAO hack in 2016.
The Checks‑Effects‑Interactions (CEI) Pattern
CEI is a design pattern that orders the three logical steps of a function:
- Checks: Verify all pre‑conditions (e.g., sufficient balance, correct caller).
- Effects: Update the contract’s internal state (e.g., set the user’s balance to zero).
- Interactions: Make external calls or transfer Ether.
By moving the state update before any external call, a re‑entrant call will hit the Checks step and fail, because the state has already been changed.
Why It Matters
A malicious contract can only succeed if it reaches the Checks step again before the state is finalized. CEI guarantees that the state is finalized, so the re‑entrant call will fail immediately.
Worked Example: A Safe Withdraw Function
Below is a minimal ERC‑20‑style contract that lets users deposit and withdraw Ether. The withdraw function follows CEI. The example also includes a malicious contract that attempts to re‑enter the withdraw call via its receive fallback.
Safe Contract (CEI)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeVault {
mapping(address => uint256) public balances;
// Deposit funds
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// Withdraw funds following CEI
function withdraw() external {
// Checks
uint256 userBalance = balances[msg.sender];
require(userBalance > 0, "Nothing to withdraw");
// Effects
balances[msg.sender] = 0;
// Interactions
(bool sent, ) = msg.sender.call{value: userBalance}(
""
);
require(sent, "Transfer failed");
}
}
Malicious Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVault {
function withdraw() external;
}
contract Attack {
IVault public target;
bool public firstCall = true;
constructor(address _target) {
target = IVault(_target);
}
// Receive Ether and re‑enter
receive() external payable {
if (firstCall) {
firstCall = false;
target.withdraw(); // Re‑enter
}
}
function startAttack() external payable {
target.deposit{value: msg.value}();
target.withdraw();
}
}
Testing the Difference
Deploy SafeVault and Attack on a local Hardhat or Foundry network. Call Attack.startAttack() with 1 ETH. Observe that the attack fails because the CEI implementation sets the balance to zero before the external call, causing the re‑entered withdraw to hit the require(userBalance > 0) check and revert.
For comparison, if you swap the state update to after the external call, the attack succeeds: the malicious contract re‑enters, finds a non‑zero balance, and drains the vault.
Trade‑offs & Limitations
- Not a silver bullet: CEI protects against simple re‑entrancy, but complex interactions (e.g., multiple external calls, callbacks that modify other state) may still leak vulnerabilities. In such cases, a mutex such as OpenZeppelin’s
ReentrancyGuardis recommended. - Low‑level calls: The pattern is most relevant for
.call(),.transfer(), and.send(). Using high‑level calls likeaddress payable’stransferis still safe, but if you switch to.call()for gas‑re‑entrancy reasons, double‑check CEI ordering. - State update order matters: Even a single misplaced line can break the pattern. Keep
balances[msg.sender] = 0;immediately after the checks, before any external call or low‑level call. - Gas limits: External calls consume gas. If the callee re‑enters before the state update, the attacker may deplete the caller’s gas, causing a revert that could be exploited for denial‑of‑service. CEI mitigates this by ensuring the state is finalized before the external call.
Actionable Takeaways
- Always order your functions as Checks → Effects → Interactions. If you’re unsure, write a comment or use a linter rule to enforce the order.
- When a function makes multiple external calls or complex state changes, wrap the entire operation in a mutex like
ReentrancyGuardto add an extra safety layer. - Use
call{value: amount}('')only when you need to forward all available gas; otherwise, prefertransferorsendfor simplicity. - Test your contract against a re‑entrancy script like the one above. Deploy to a local test network, run the attack, and verify that the function reverts on re‑entry.
- Document the CEI ordering in your code comments and developer onboarding to reduce future bugs.
By adopting the CEI pattern, you get a first line of defense against the most common re‑entrancy exploits. Pair it with other best practices—like using the latest compiler version, avoiding delegatecall when not necessary, and applying a mutex for complex interactions—to build contracts that are both functional and secure.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.