Solidity Reentrancy Diagnostic Guide: State-Change-After-External-Call
A diagnostic guide for Solidity reentrancy caused by state-change-after-external-call. Map calls, check ordering, apply Checks-Effects-Interactions and reentrancy guards, with checks and escalation criteria.
19 Aug 2026, 23:39 UTC

The problem you can spot in code review
A withdraw or payout function sends Ether to a user before it updates the contract's own accounting. The external call hands control to the recipient's fallback or receive function. That recipient can call back into the same function before the first execution finishes, draining funds multiple times with a single deposit.
Useful takeaway: reentrancy is an ordering problem, not a missing feature. If state is read, then an external call happens, then state is written, the function is vulnerable.
Condition, cause and quick diagnostic table
| Signal | Why it matters | Typical code smell |
|---|---|---|
| External call before state write | Call transfers control; state is still stale for re-entry | call/transfer/send to msg.sender before balances[msg.sender] -= amount |
| Shared mutable state across functions | Guard on one function does not protect another | withdraw and emergencyWithdraw both modify balances |
| Low-level call without reentrancy control | call returns success but does not prevent re-entry | (bool ok,) = recipient.call{value: amount}("") |
Ordered checks
1. Map external calls
List every call, transfer or send that reaches an untrusted address. Untrusted means msg.sender or a user-supplied address.
grep -R ".call{" contracts/ || trueWhere to run: project root. Permission: read access to source. Expected check: a list of files and lines where external calls occur. Risk: grep misses assembly or library wrappers.
2. Map state reads and writes for the same function
For each function containing an external call, note which state variables are read before the call and written after it. Focus on balances, allowances, nonces, and flags.
Expected check: a sequence like read balances[msg.sender], external call, then balances[msg.sender] -= amount. That ordering is vulnerable.
3. Check cross-function state sharing
Identify state variables modified by more than one external function. A mutex on one function does not protect the other.
Expected check: shared storage variable touched by multiple entry points.
4. Static analysis
Run a reentrancy detector on the compiled sources.
slither . --detect reentrancy-eth,reentrancy-no-ethWhere to run: project root with Slither installed. Permission: read. Expected check: a report listing functions with potential reentrancy patterns. Risk: false positives and missed cross-function cases. Slither results should be reviewed manually.
5. Manual re-entry test harness
Write a malicious contract with a fallback that calls the target withdraw method again while the first call is still active.
// Attacker.sol sketch
contract Attacker {
Target target;
constructor(Target t) { target = t; }
receive() external payable {
if (address(target).balance >= 1 ether) {
target.withdraw(1 ether);
}
}
function attack() external payable {
target.deposit{value: 1 ether}();
target.withdraw(1 ether);
}
}Deploy to a local testnet such as Hardhat or Foundry. Permission: ability to spin a local node. Expected check: the ability to withdraw more than the initial deposit indicates the vulnerability. Do not claim the test was run; this is the verification shape.
Fixes tied to findings
Finding: state written after external call
Apply Checks-Effects-Interactions. Update all relevant state before any external call.
function withdraw(uint amount) external {
uint bal = balances[msg.sender];
require(bal >= amount, "insufficient");
balances[msg.sender] = bal - amount; // Effects
(bool ok,) = msg.sender.call{value: amount}(""); // Interaction
require(ok, "send failed");
}Limitation: this pattern does not prevent cross-function reentrancy on shared state.
Finding: external call to untrusted address with shared state
Add a reentrancy guard mutex. OpenZeppelin's ReentrancyGuard provides a nonReentrant modifier that sets a boolean flag on entry and clears it on exit.
Gas cost: additional SLOAD and SSTORE per protected call.
Limitation: guards are per-contract, not per-function state. Cross-function reentrancy can still occur if two guarded functions share state and are called via a different entry point.
Finding: reliance on transfer/send for safety
Avoid relying solely on transfer or send with a fixed 2300 gas stipend. It may break with proxy contracts or contracts with complex receive logic, and it does not guarantee safety.
Preferred approach: use call with explicit Checks-Effects-Interactions and, if needed, a guard.
Escalation criteria
- Reentrancy involves multiple functions sharing state. Escalate to architecture review.
- Contract is already deployed and holds funds. Escalate to incident response and consider upgrade path or pause.
- Static analysis flags reentrancy-no-eth with no obvious external call. Escalate for manual audit of delegatecall or library usage.
- Fix requires changing public interface or storage layout. Escalate for upgrade planning and testing.
Version assumptions: Solidity ^0.8.x where arithmetic overflow reverts by default. Behavior of call and gas stipend is version dependent; verify against the compiler version used in the project.
Practical way to check result: re-run the static analysis and the manual re-entry harness shape after changes. Confirm state updates precede interactions and that shared state is either isolated or protected by a consistent guard.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.