Solidity Reentrancy: CEI Ordering and nonReentrant Guards for Payouts
Use CEI ordering and nonReentrant guards to prevent reentrancy in Solidity payout functions that send ETH to untrusted recipients.
06 Dec 2025, 14:48 UTC

The problem: reentrancy during payouts
A Solidity contract that sends ETH or tokens to an address supplied by a caller can be re-entered before its first execution finishes. If the recipient is a malicious contract, its fallback or receive function can call back into the payout function. If state has not been updated yet, the contract pays out twice for the same request.
Useful takeaway: validate preconditions, mutate state, and only then make the external call. Add a nonReentrant guard to any state-changing external function that touches shared state and interacts with untrusted contracts. This combines the Checks-Effects-Interactions (CEI) pattern with a reentrancy lock.
Requirements and version assumptions
This guide assumes Solidity 0.8.x, which includes built-in integer overflow checks. It applies to external or public functions that transfer native ETH or ERC-20 tokens to an untrusted address. It does not apply to view-only functions or internal logic that never makes an external call.
Smallest suitable design
Start with a mapping that tracks user credits, a function to withdraw them, and OpenZeppelin's ReentrancyGuard. The guard stores a status flag in storage and reverts if a function is re-entered before the first invocation completes.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract PayoutVault is ReentrancyGuard {
mapping(address => uint256) public credits;
function deposit() external payable {
credits[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
if (amount == 0 || credits[msg.sender] < amount) revert InsufficientBalance();
// Effect: mutate state before any interaction.
credits[msg.sender] -= amount;
// Interaction: forward remaining gas and require success.
(bool sent, ) = payable(msg.sender).call{value: amount}("");
if (!sent) revert TransferFailed();
}
}
The order above is check, then effect, then interaction. The balance mapping is reduced before the ETH transfer, so a re-entrant call observes the updated balance and cannot repeat the withdrawal.
Trust and data boundaries
- The caller is untrusted, and the recipient address is controlled by the caller.
- The external call crosses the trust boundary. Treat the recipient as potentially malicious.
- Shared state is the
creditsmapping. Any function that reads or writes it while a payout is in flight is part of the same reentrancy surface.
Operational checks
- Run
solc --versionto confirm the compiler target, for example 0.8.x, before compiling. - Always check the boolean returned by
call; an unchecked return silently ignores a failed ETH transfer. - Prefer
call{value: amount}("")overtransferorsend. The latter two forward only 2,300 gas and can fail when the recipient contract runs expensive fallback logic. - Apply
nonReentrantto every external function that modifies shared state and then calls out, not only to the withdrawal function.
Command risks: solc --version and slither . are read-only. forge test or hardhat test deploys to a local network and carries no mainnet financial risk, but a bad test can produce false confidence.
Failure modes the pattern does not stop
- Cross-function reentrancy: if
withdrawand another function both touchcreditsbut only one hasnonReentrant, the unguarded function can re-enter through shared state. Scope one guard to the shared state rather than adding independent guards per function. delegatecallto an untrusted library can bypass storage protections entirely.- View functions invoked while a transaction is interrupted can read inconsistent state.
- CEI alone does not protect against cross-function reentrancy where two functions share vulnerable state.
Conditions that change the design
- Fee-on-transfer tokens: the amount received may differ from the amount sent. Use pull-over-push or reconcile balances after the transfer instead of assuming a fixed payout.
- High-frequency payouts: the storage read and write cost of
nonReentrantadds modest gas overhead. Apply it only where external calls to untrusted contracts occur. - If the recipient is always a known contract you control, a guard may be unnecessary, but CEI ordering still applies.
Practical verification
Write a Foundry or Hardhat test that deploys the vault and a malicious attacker contract whose fallback function calls withdraw again. Assert that the credits mapping is decremented before the external call and that the re-entrant invocation reverts.
# Run in the project root. Requires Foundry and a local Anvil node.
forge test --match-path test/reentrancy.t.sol -vv
Expected check: the reentrancy test reverts with the guard error and the mapping shows the post-withdrawal balance. Risk: a poorly written test can pass even if CEI order is wrong; review the test logic, not only the output.
# Static analysis, read-only.
slither .
Expected check: Slither flags state-changing functions that perform external calls before state updates. Confirm manually; static analyzers produce false positives.
Limitations
Reentrancy guards add storage reads and writes, increasing gas cost. They do not prevent every variant, especially cross-contract attacks through delegatecall. A guard on one function does not protect another function that shares the same state. Review compiled bytecode or run Slither or Mythril to confirm vulnerable call ordering before relying on the contract.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.