Stopping the Loop: Implementing Checks-Effects-Interactions in Solidity
Learn how to prevent reentrancy attacks in Solidity using the Checks-Effects-Interactions (CEI) pattern to ensure contract state is updated before external calls.
14 Mar 2026, 23:04 UTC

The Reentrancy Trap
A common failure in smart contract engineering is the "reentrancy attack." This happens when a contract sends funds to an external address before updating its own internal ledger. Because sending Ether can trigger a fallback or receive function in the recipient's contract, a malicious actor can force your contract to execute the withdrawal logic repeatedly before the first transaction ever finishes updating the balance.
The takeaway is simple: Never trust an external call to return control to your contract in a predictable state. To solve this, we use the Checks-Effects-Interactions (CEI) pattern.
The CEI Framework
CEI is a structural discipline that ensures a contract's state is finalized before it interacts with the outside world. By rearranging the order of operations, you remove the window of opportunity for a recursive call to exploit stale data.
1. Checks
First, validate all preconditions. This includes require statements that check if the caller has sufficient permissions, if the contract is paused, or if the requested amount is available. If a check fails, the transaction reverts immediately, and no state is changed.
2. Effects
Next, update the contract's internal state. This means subtracting balances, updating mapping values, or changing status flags. By performing "effects" before "interactions," you ensure that if the contract is called again recursively, the second call will hit the updated state (e.g., a zero balance) and fail the "Checks" phase.
3. Interactions
Finally, perform the external call. This includes call, transfer, or calling a function on another contract. Since the state is already updated, the external entity cannot manipulate the contract's logic to double-spend or bypass limits.
Worked Example: Secure Withdrawal
Below is a comparison of a vulnerable withdrawal function and one secured with the CEI pattern. This example assumes Solidity ^0.8.0.
Vulnerable Implementation
// RISK: Reentrancy possible
function withdraw() public {
uint amount = balances[msg.sender];
require(amount > 0, "Insufficient balance");
// Interaction happens BEFORE Effect
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0; // State updated too late
}
Secure CEI Implementation
// SECURE: State updated before external call
function withdraw() public {
// 1. Checks
uint amount = balances[msg.sender];
require(amount > 0, "Insufficient balance");
// 2. Effects
balances[msg.sender] = 0;
// 3. Interactions
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Adding a Mutex Guard
While CEI is the primary defense, engineers often add a ReentrancyGuard. This is a "mutex" (mutual exclusion) lock—a state variable that tracks whether a function is currently executing. If a recursive call attempts to enter the function while the lock is active, the transaction reverts.
To implement this, you can use OpenZeppelin's nonReentrant modifier. This provides a second layer of defense if the CEI pattern is accidentally bypassed during complex refactoring.
Trade-offs and Limitations
CEI is highly effective for single-function reentrancy, but it has limitations:
- Cross-Function Reentrancy: If Function A updates state and calls an external contract, and that contract calls Function B (which relies on the same state), CEI in Function A alone won't save you. You must apply the pattern across all functions sharing that state.
- Gas Costs: Adding a
ReentrancyGuardincreases gas costs because it requires writing to storage (updating the lock) at the start and end of every call. - Complexity: In highly complex protocols with multiple interacting contracts, maintaining a strict CEI order across the entire ecosystem requires rigorous auditing.
Verification and Testing
To verify your implementation, you can use a static analysis tool like Slither. Run it against your contract to detect potential reentrancy paths. For manual testing, write a "Attacker" contract with a receive() function that calls the target's withdraw() function recursively. If the target uses CEI, the second call will fail the balance check and the attack will stop.
Rollback: If you are refactoring a live contract to implement CEI, ensure you have a snapshot of the current state. Since this change alters the sequence of operations, test it on a fork of the mainnet to ensure that existing integrations (like third-party vaults) do not rely on the previous, incorrect execution order.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.