Reentrancy, checks-effects-interactions and pull payments
How the reentrancy attack that hacked The DAO works and how to defend against it: operation order, ReentrancyGuard and deferred payments, using escrow.
In this article
If smart contracts had a "favourite disease" it would be reentrancy. The most famous losses in blockchain history began with it, and it still regularly surfaces in new projects. The idea is simple and nasty: when a contract sends money it hands control to outside code, and that code can call it back at a moment when state is not yet updated.
The attack by example
Imagine a naive withdrawal contract:
mapping(address => uint256) public balance;
function withdraw() external {
uint256 amount = balance[msg.sender];
require(amount > 0, "nothing");
(bool ok, ) = msg.sender.call{value: amount}(""); // 1. send the money
require(ok, "failed");
balance[msg.sender] = 0; // 2. zero the balance
}What is wrong: the balance is zeroed after the money is sent. If the recipient is not a person but a contract, receiving the ETH triggers its receive() function. Inside it the attacker calls withdraw() again. The balance is not zeroed yet - the check passes - the money goes out a second time, a third… until the contract is empty.
contract Attacker {
Victim victim;
receive() external payable {
if (address(victim).balance >= 1 ether) victim.withdraw(); // re-entry
}
function attack() external payable {
victim.deposit{value: 1 ether}();
victim.withdraw();
}
}This is how The DAO - a project holding tens of millions of dollars in ETH - was hacked in 2016. The consequences were so serious that the Ethereum community split and the Ethereum Classic network appeared.
Defence 1: checks-effects-interactions
The main rule is the order of operations inside a function:
- Checks - validate: status, rights, amounts.
- Effects - update your own state.
- Interactions - only now call external contracts and send money.
The fixed version:
function withdraw() external {
uint256 amount = balance[msg.sender]; // check
require(amount > 0, "nothing");
balance[msg.sender] = 0; // effect: zero first
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction: send after
require(ok, "failed");
}Now re-entry is useless: on the second call the balance is already zero and the check rejects it. In our escrow every payout works this way: the _settle function first moves the deal to Settled and writes the event, and only then sends the money.
Defence 2: ReentrancyGuard
Order discipline is good, but humans make mistakes. So a lock is added: the nonReentrant modifier sets an "inside a call" flag and rejects any re-entry until it is cleared.
modifier nonReentrant() {
require(_status != ENTERED, "reentrant call");
_status = ENTERED;
_;
_status = NOT_ENTERED;
}In our contract nonReentrant sits on every function that moves money: createOffer, acceptOffer, cancelOffer, confirmDelivery, setCancelIntent, claimExpired, openDispute, resolveDispute, claimArbiterTimeout, withdrawCredit. A proven implementation from the OpenZeppelin library is used, not a home-made one.
Reentrancy can be trickier
There are subtler kinds too: cross-function (re-entering a different function that uses the same state) and read-only reentrancy (an outside contract reads your state at a moment when it is temporarily incorrect). The general principle of defence is the same: do not leave state "half-updated" at the moment of an external call and treat any external call as potentially hostile.
The second problem: a recipient can block a payout
Suppose that at deal completion the contract sends money to the seller, and the seller is a contract that always reverts on receiving money. If the payout is done with require(ok) the whole transaction reverts and the deal can never be settled. One dishonest participant freezes everyone else's money.
A similar situation: the recipient's address is blocked by the token issuer (this happens with USDT and USDC). Any transfer attempt reverts.
Defence 3: pull payments
The solution is to flip the model. Instead of "push" (the contract sends money to the recipient) use "pull" (the recipient comes for the money). If a payout fails, the contract does not revert the transaction but records a debt:
function _payout(address token, address to, uint256 amount) private {
if (_trySend(token, to, amount)) {
reserved[token] -= amount;
emit Paid(token, to, amount);
} else {
credits[token][to] += amount; // the obligation stays in the contract
emit PaymentDeferred(token, to, amount);
}
}The deal closes either way, while the problematic recipient gets an entry "you are owed this much" and can collect the money by hand:
function withdrawCredit(address token) external nonReentrant {
uint256 amount = credits[token][msg.sender];
if (amount == 0) revert NothingToWithdraw();
credits[token][msg.sender] = 0; // zero first (CEI)
reserved[token] -= amount;
_sendOrRevert(token, msg.sender, amount);
emit CreditWithdrawn(token, msg.sender, amount);
}With a manual withdrawal gas is not capped - the recipient initiates the call and pays for it themselves. And if the withdrawal fails again, the transaction reverts and the entries stay untouched.
Defence 4: a gas cap on sending
One more trick: when automatically sending the native coin the contract gives the recipient limited gas (in our contract - 50,000). A recipient with a "greedy" receive() that burns all gas cannot hold up the deal's closing or squeeze anything extra out of it: their payout simply becomes deferred.
(bool ok, ) = to.call{value: amount, gas: PUSH_GAS_LIMIT}("");Accounting of obligations
So that "debts" are not lost, the contract keeps a reserved[token] counter - the sum of everything it owes to anyone: the money of live deals, unclaimed credits and accrued fees. The main solvency invariant:
balanceOf(contract) >= reserved[token]The owner can withdraw only the difference - "extra" money accidentally sent to the address. Other people's money is out of reach. How such an invariant is checked - in the article on testing.
Summary
- An external call hands control to foreign code: treat it as hostile.
- The order "checks → effects → interactions".
- A
nonReentrantlock as a second line of defence. - Pull payments so one recipient cannot block the rest.
- A gas cap on automatic sending.
- Strict accounting of obligations and a solvency invariant.
What to read next
ERC-20 tokens are external contracts too, and they come in all kinds. Which "surprises" they bring and how to cope: non-standard tokens. And a full overview of typical mistakes is in the catalogue of vulnerabilities.
Keep reading
- 3 · Under the hood
A catalogue of smart contract vulnerabilities: 12 classic mistakes and defences
An overview of frequent contract vulnerabilities: reentrancy, access control, overflow, oracles, signature replay, DoS. With examples and the escrow's defences.
5 min read - 3 · Under the hood
Non-standard tokens: USDT without a return value, fee-on-transfer and blacklists
What surprises ERC-20 tokens hide - USDT with no return value, transfer fees, address blocking, different decimals - and how an escrow contract defends itself.
5 min read
Mentioned in
- Glossary - blockchain, wallets, smart contracts and escrow from A to Z
- Multisig and timelock - managing a contract without a single point of failure
- The EVM under the hood: stack, memory, storage, calldata and the cost of gas
- A tour of the Solidity language - types, functions, errors and events by example
- Writing escrow in Solidity: the state machine and a real contract walkthrough
- Access-control patterns in smart contracts - owner, roles and deal parties
- Non-standard tokens: USDT without a return value, fee-on-transfer and blacklists
- A catalogue of smart contract vulnerabilities: 12 classic mistakes and defences
- Gas optimization in Solidity - 12 techniques and when they apply
- Account abstraction (ERC-4337) - smart wallets, gas sponsorship and access recovery
- Testing smart contracts: unit, fuzz, invariants and static analysis
Ready to try it?
Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.

