Applying the Checks‑Effects‑Interactions Pattern in Solidity to Block Reentrancy
A concise architecture note that outlines requirements, minimal design, trust boundaries, operational checks, and failure modes for using the checks‑effects‑interactions pattern to prevent reentrancy in Solidity contracts.
28 Jan 2026, 09:09 UTC

Requirements
Identify every function that either transfers Ether or calls an address that is not under your control. For each such function you must guarantee that any change to the contract’s state happens before the external call. This eliminates the window where a malicious caller could re‑enter the function and observe inconsistent state.
Smallest Suitable Design
The minimal pattern consists of three ordered sections inside the function:
- Validate inputs – use
requirestatements to check caller permissions, amounts, and any pre‑conditions. - Update state – modify storage variables (e.g., balances, approvals) that reflect the intended effect of the call.
- Perform external call – use
callortransferto send Ether or invoke an untrusted contract, and check the return value.
To enforce the ordering across all functions, add a private nonReentrant modifier (or rely on OpenZeppelin’s ReentrancyGuard) that sets a boolean lock before the external call and clears it afterward.
Example Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Withdrawal is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
// 1) Validate inputs
require(amount > 0, "Amount must be positive");
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2) Update state
balances[msg.sender] -= amount;
// 3) Perform external call
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Ether transfer failed");
}
}
The example shows the required order: validation, state update, then the low‑level call. The nonReentrant modifier from OpenZeppelin ensures that re‑entrancy via the same function is blocked.
Trust/Data Boundaries
Before the external call, only the contract’s own storage variables are considered trusted. Any data returned from the external call or any state changes made by the called contract must be treated as untrusted. Therefore, all logic that depends on the contract’s internal invariants must be completed in the “effects” step.
Operational Checks
- Use
requirefor every pre‑condition (input validation, balance checks). - After the low‑level call, explicitly test the returned boolean; revert on failure.
- If the call returns data that you intend to use, validate it before relying on it.
- Consider using
callwith a gas stipend (e.g.,call{value: amount, gas: 5000}) to limit the caller’s ability to consume all remaining gas.
Failure Modes and Design Changes
If the checks‑effects‑interactions ordering is violated, a reentrancy attack can drain funds by repeatedly invoking the withdrawal function before the balance is updated. Adding a mutex lock (the nonReentrant modifier) changes the design from a pure pattern‑based approach to a guard‑based approach, which adds a small storage overhead but protects against cross‑function reentrancy as well.
Other conditions that would prompt a redesign include:
- The introduction of asynchronous calls in future EVM versions (e.g.,
STATICCALLwith delayed execution) – the pattern assumes synchronous execution. - Need to support read‑only reentrancy views; in that case, a simple mutex is insufficient and you may need to apply the pattern to view functions or use
ReentrancyGuardwithnonReentrantView. - When gas costs become a critical concern, you might replace the modifier with a custom lock that uses a cheaper storage pattern (e.g., a
uint256status flag) after measuring the impact.
Verification
To confirm that your implementation follows the pattern:
- Compile with Solidity ^0.8.0:
solc --version(requires Solidity installed). - Run a unit test that deploys a malicious contract which attempts to re‑enter the
withdrawfunction. The test should assert that the attacker’s balance does not increase and that the contract’s balance remains unchanged after the failed attempt. - Use static analysis: run
slither . --checks reentrancyormythx analyzeto detect any missing checks‑effects‑interactions violations. - Manually inspect each function: ensure every state modification precedes any external call and that every external call is guarded by a
requireon its success.
Limitations: The pattern alone does not protect against reentrancy via read‑only calls or cross‑function reentrancy if state is not properly locked. Gas costs increase slightly due to extra require statements and the state updates before the external call. Always verify with both automated tests and static analysis.
Practical Way to Check the Result
After deploying the contract to a testnet, invoke the withdraw function from a normal account and record the balance. Then, from a separate account, deploy the malicious re‑entrancy tester and call withdraw. If the pattern is correct, the transaction will revert (or the attacker’s balance will stay unchanged) and the contract’s total Ether will match the expected amount. Use a block explorer or eth_getBalance RPC call to verify the balances.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.