Implementing Checks‑Effects‑Interactions in Solidity: A Practical Guide to Reentrancy Prevention
Implement the Checks‑Effects‑Interactions pattern in Solidity to stop reentrancy attacks. A step‑by‑step example, verification steps with Hardhat and Slither, and a discussion of trade‑offs and limitations.
15 Sept 2025, 04:34 UTC

Reentrancy: The Core Problem
When a contract performs an external call before updating its own state, the called contract can re‑enter the original function. If the state change that should prevent the second entry occurs after the call, the attacker can repeatedly execute the function and drain funds. This is the classic reentrancy attack that broke early DeFi protocols.
Why the Checks‑Effects‑Interactions Pattern Works
The pattern enforces a strict order for any function that interacts with external addresses:
- Checks – validate all pre‑conditions (balances, permissions, etc.).
- Effects – mutate the contract’s own storage.
- Interactions – make external calls (e.g.,
call,transfer,delegatecall).
By moving state updates before external calls, a recursive entry will fail the first require in the subsequent call, breaking the loop.
Vulnerable Example
Below is a minimal withdrawal function that illustrates the flaw. It is intentionally insecure for demonstration purposes.
pragma solidity ^0.8.0;
contract V1 {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// External call before state change – vulnerable
(bool sent, ) = msg.sender.call{value: amount}('');
require(sent, "Transfer failed");
// State change after external call
balances[msg.sender] = 0;
}
}
In this contract, a malicious contract can call withdraw() and, inside its receive() fallback, call withdraw() again before the balance is zeroed.
Safe Refactor Using Checks‑Effects‑Interactions
Reordering the operations protects the function. The state is updated before any external call.
pragma solidity ^0.8.0;
contract V2 {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// State change first
balances[msg.sender] = 0;
// External call after state change
(bool sent, ) = msg.sender.call{value: amount}('');
require(sent, "Transfer failed");
}
}
Now, if the attacker attempts to re‑enter withdraw(), the require(amount > 0) will revert because the balance is already zero.
Demonstrating the Attack
To see the vulnerability in action, deploy V1 and an attacker contract that re‑enters during the receive() fallback. A simple attacker looks like this:
pragma solidity ^0.8.0;
interface IV1 {
function withdraw() external;
}
contract Attacker {
IV1 public target;
address public owner;
constructor(address _target) {
target = IV1(_target);
owner = msg.sender;
}
// Fallback that re‑enters the target contract
receive() external payable {
if (address(target).balance > 0) {
target.withdraw();
}
}
function attack() external payable {
require(msg.value > 0, "Pay to start attack");
target.withdraw();
}
}
After funding V1 and the attacker, calling attack() will drain the contract’s balance in a single transaction if the vulnerable pattern is used.
Verification Steps
- Set up a local test environment. Use Hardhat or Foundry; the following assumes Hardhat.
- Compile the contracts with
solc 0.8.20or later. - Deploy
V1and fund it with a few ether from a test account. - Deploy the
Attackercontract with the address ofV1. - Invoke
attack()from the attacker account. - Observe that the attacker’s balance increases to the full amount left in
V1.
- Compile the contracts with
- Repeat with the safe contract.
- Deploy
V2and fund it similarly. - Deploy a fresh attacker instance pointing to
V2. - Call
attack(). - The transaction will revert on the second call, leaving the original withdrawal successful but preventing a drain.
- Deploy
- Static analysis with Slither. Run:
slither V1.sol --detect reentrancy slither V2.sol --detect reentrancySlither should flag
V1as vulnerable and report no reentrancy issues inV2.
Trade‑Offs and Limitations
- Cross‑function reentrancy. The pattern protects a single function. If multiple functions read or write the same storage (e.g., a shared
balancesmapping), an attacker could re‑enter via a different function before the guard is hit. In such cases, combine with a mutex or OpenZeppelin’sReentrancyGuard. - Gas cost. Updating storage before making an external call adds an extra write operation. In high‑throughput contracts, this can increase gas usage by a few hundred wei per call. The security benefit usually outweighs the cost.
- Complex interactions. When a function performs many external calls, carefully group checks, effects, and interactions to avoid accidental re‑entrancy. Unit tests that simulate re‑entrancy are essential.
Actionable Takeaway
For every function that transfers ether or calls another contract:
- Perform all checks first – validate balances, permissions, and any other pre‑conditions.
- Apply all effects – update your own storage immediately after the checks.
- Make the interactions last – external calls should only occur after the state is consistent.
- Integrate
Slitheror a similar static analyzer into your CI pipeline to catch reentrancy patterns early. - When functions share mutable state across multiple entry points, add a mutex or use
ReentrancyGuardfrom OpenZeppelin.
By following the Checks‑Effects‑Interactions order, you eliminate the most common reentrancy vector and significantly harden your contract against recursive exploits.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.