The security pattern: Check-Effects-Interactions
Why order matters
Solidity executes statements strictly from top to bottom. If a function contacts the outside world—by sending ether or invoking another contract—before its own state is final, it creates a dangerous window of time. During that window a malicious contract can call back into the half‑finished function and observe or even change state that should have been locked. The results range from balance inflation to a complete drain of funds.
Early Solidity tutorials often placed business logic first, then transferred ether as a final “return value”. Many security incidents proved that approach backwards. In the EVM, external calls are the most expensive and least predictable operations; they deserve to happen last.
Vulnerable withdrawal function
Below is a minimal example that compiles and looks sensible at first glance. Unfortunately, it breaks the cardinal rule of ordering.
pragma solidity ^0.8.24;
contract Vault {
mapping(address => uint256) public balances;
// Anyone can deposit ether.
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
// 1. Send ether to the caller.
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
// 2. Only *after* the transfer, check and mutate balance.
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
}
}
Exploitation
Assume msg.sender
is not a normal wallet but a contract whose receive()
function immediately calls withdraw()
again. Because the balance has not yet been reduced, the second call also passes the require
check and receives ether. The process repeats until the vault is empty.
contract Attacker {
Vault public target;
constructor(address _target) {
target = Vault(_target);
}
// Fallback triggers on receiving ether.
receive() external payable {
if (address(target).balance >= 1 ether) {
target.withdraw(1 ether);
}
}
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 ETH");
target.deposit{value: 1 ether}();
target.withdraw(1 ether);
}
}
Because each re‑entrant call runs before the previous call finishes, the attacker effectively clones the same balance over and over until nothing remains.
Broader risks of poor ordering
- Re‑entrancy – An external contract regains control and executes arbitrary logic while your state is inconsistent.
- State inconsistency – Balances or ownership variables record the wrong value if a later check fails and the function reverts.
- Unexpected reverts or gas griefing – If the callee runs out of gas or deliberately reverts, your function may roll back large, expensive changes you already made.
Check‑Effects‑Interactions pattern
At its heart, the Check‑Effects‑Interactions (CEI) pattern is a straightforward rule of thumb: first check that everything is in order, then record your internal changes, and only after that interact with the outside world. Put differently, you confirm you have permission, update your own books, and only then send money or call another contract. Following this order keeps your contract from ever sending value while its storage still shows the old numbers.
Experienced teams therefore write every mutating function in three clearly separated phases:
- Checks – Validate all inputs and conditions up front. If anything is wrong, revert immediately and spare the user needless gas.
- Effects – Make the contract’s own storage reflect the new reality. After this phase, the internal state must be self‑consistent.
- Interactions – Perform ether transfers, interface calls, or any other operation that hands control to an external address.
Following this sequence closes the re‑entrancy window because the external contract arrives after your storage is final. Even if it calls back, the critical invariants already hold.
Seasoned Solidity developers treat CEI the way systems engineers treat a mutex: you first secure the right to act, then make your changes while no one else can interfere, and only afterward open the door to the outside world. By imposing an obligatory three‑part choreography on every state‑changing function you ensure that contract storage is never observed in a half‑written state. The first part is like validating pre‑conditions on a critical section; the second is the in‑memory update that must be atomic; the third is equivalent to releasing the lock and making a system call. In short, CEI turns each function into a tiny state machine with exactly one safe transition order.
Fixed withdrawal function
The quickest remedy is simply to re‑order the logic so it follows the Checks → Effects → Interactions flow. In the snippet below we will validate the caller’s balance first, commit the updated state second, and only then hand control to the outside world. Line by line, you will see how this shuts the re‑entrancy window that the previous version left wide open.
function withdraw(uint256 amount) external {
// 1. CHECKS – fail fast.
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2. EFFECTS – update state.
balances[msg.sender] -= amount;
// 3. INTERACTIONS – now it is safe to send ether.
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
If the external call at step three tries to re‑enter, the balance is already reduced to zero, so the first require
fails at the very start of the second call.
A bit of history: The DAO hack
In June 2016 the largest crowdfunded project on Ethereum, The DAO, held roughly 14 % of all ether in circulation. Its purpose was to act as a decentralized venture fund, but the contract code contained a subtle ordering flaw that ruined the experiment.
The development team actually knew about the "recursive send" vulnerability and had patched a different part of the code. While they were publishing a victory blog post, an adversary had already prepared an exploit for another path: the splitDAO function that let investors leave with their share of funds.
Below is an excerpt of the original DAO.sol
.
function splitDAO(
uint _proposalID,
address _newCurator
) noEther onlyTokenholders returns (bool _success) {
// INTERACTIONS
// Move ether and assign new Tokens. Notice how this is done first!
uint fundsToBeMoved =
(balances[msg.sender] * p.splitData[0].splitBalance) /
p.splitData[0].totalSupply;
if (p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == false) // This is the line the attacker wants to run more than once
throw;
// INTERACTIONS
// Burn DAO Tokens
Transfer(msg.sender, 0, balances[msg.sender]);
withdrawRewardFor(msg.sender); // be nice, and get his rewards
// Notice the preceding line is critically before the next few
// EFFECTS
totalSupply -= balances[msg.sender];
balances[msg.sender] = 0;
paidOut[msg.sender] = 0;
return true;
}
Because createTokenProxy is an external call that can trigger code in the receiver, the attacker passed in a malicious contract whose fallback recursively invoked splitDAO again, before the balances were reduced. Each iteration transferred the same proportional share of ether into the attacker‑controlled child DAO. Roughly 3.6 million ETH—worth about USD 50 million at the time—was stolen in a single day and forced the Ethereum community to hard‑fork the chain to recover the funds.
CEI as solution
If the developers had followed the Check‑Effects‑Interactions template, the exploit would have failed on the second call. The fix is conceptually trivial: move the balance‑changing statements before the external call.
function splitDAO(uint _proposalID, address _newCurator) ... {
// CHECKS omitted for brevity...
// EFFECTS – lock the user’s balance first
uint senderBalance = balances[msg.sender];
totalSupply -= senderBalance;
balances[msg.sender] = 0;
// INTERACTIONS – safe to interact with the new DAO
uint fundsToBeMoved = (senderBalance * p.splitData[0].splitBalance) / p.splitData[0].totalSupply;
bool ok = p.splitData[0].newDAO.createTokenProxy{value: fundsToBeMoved}(msg.sender);
require(ok, "Token proxy failed");
// Remaining logic...
}
With balances already set to zero, any recursive attempt to run splitDAO again would hit the first line of the function, see a zero balance, and revert. The simple act of re‑ordering the statements would have preserved millions of dollars and likely avoided Ethereum’s first hard fork.
Other vulnerabilities CEI mitigates
Failing to enforce the Checks‑Effects‑Interactions order leaves contracts open to several repeat‑offender bugs:
- Same‑function re‑entrancy — drain funds in a single transaction (The DAO, 2016).
- Cross‑function re‑entrancy — jump into a second method that relies on stale state (Parity multisig freeze, 2017).
- State inconsistency — send value before debiting storage, enabling double‑spends (early Compound v1 report).
- Flash‑loan callback abuse — manipulate collateral or oracle data during the lender’s callback (Hundred Finance, 2024).
Each issue disappears—or becomes dramatically harder to exploit—once a function validates inputs, locks its own storage, and only then interacts with external contracts.
Gas‑efficiency advantages of CEI
Security is the headline benefit of the pattern, but it also saves users gas by failing early. Gas is spent for every opcode that executes — even if the transaction later reverts, so running an expensive external call before discovering that an input is invalid burns ether for nothing.
Consider the following function that violates CEI:
function example() external {
sendA(); // external call #1
callB(); // external call #2
checkX(); // may revert
checkY(); // may revert
updateM(); // state change
}
If checkX()
fails, the EVM must roll back both external calls. You still pay for the gas consumed by sendA()
and callB()
plus the cost of storing the revert data.
Now reorder the logic to follow CEI:
function example() external {
// CHECKS – fail fast
checkX();
checkY();
// EFFECTS – commit internal state
updateM();
// INTERACTIONS – cheapest to run last
sendA();
callB();
}
In this layout a failing checkX()
or checkY()
halts execution immediately, refunding most of the remaining gas under EIP‑150 rules. Only when the function is certain to succeed does it pay the price of sendA()
and callB()
.
A Foundry benchmark (run with forge test -vvvv
) on comparable functions shows a 30–35% reduction in average gas used for reverted transactions when CEI ordering is applied.