Field notes
Reentrancy is a question about state, not about calls
A method signal on one of the oldest documented smart-contract bug classes, and what we read for when we look for it.
Reentrancy is the first entry most people learn. It is catalogued as SWC-107 in the Smart Contract Weakness Classification registry, mapped to CWE-841, Improper Enforcement of Behavioral Workflow. The registry notes that its content is no longer actively maintained, but the description still holds. Because the class is so familiar, it is easy to treat it as solved by a modifier. We treat it as a question about state.
What the primary sources say
The Solidity documentation’s Security Considerations page describes the core issue: any interaction from contract A with contract B, including a plain Ether transfer, hands control to B. B can then call back into A before that interaction completes. The documentation recommends the Checks-Effects-Interactions pattern: validate inputs, update your own state, and only then interact with other contracts.
// Vulnerable: interaction before effect
function withdraw() external {
uint256 amt = balance[msg.sender];
(bool ok, ) = msg.sender.call{value: amt}("");
require(ok);
balance[msg.sender] = 0;
}
// Checks-Effects-Interactions
function withdraw() external {
uint256 amt = balance[msg.sender];
balance[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amt}("");
require(ok);
}
OpenZeppelin’s ReentrancyGuard provides a nonReentrant modifier. Its documentation also states a limit: because there is a single guard, functions marked nonReentrant may not call one another.
What we read for
The guard closes the obvious path. The questions below are the ones we ask on every external call, guarded or not.
- Where does control leave? Ether transfers, token transfers with hooks, callbacks such as flash-loan receivers, and calls to addresses a user can choose. Each one is a point where foreign code runs.
- What state is half-written at that point? List every storage variable that the function changes after the call. Any of them that another function reads is a candidate.
- Which other functions read that state? Reentry does not have to target the same function. A guard on
withdrawdoes not protect an unguardeddepositthat reads the same balances. - Which view functions report it? A view that returns a price or share value mid-update can mislead another contract that trusts it, even though the view itself changes nothing.
- Does it span contracts? Two contracts that share accounting can be re-entered through each other, and a per-contract guard will not see it.
How we test it
Reading gives the candidates. For each one we write a malicious receiver in a Foundry test that re-enters at the callback and asserts an invariant: for example, that total recorded balances never exceed assets held. Stateful fuzzing then varies the order of calls. A candidate that cannot break an invariant is noted and closed. One that can becomes a finding with a runnable PoC.
Status of this note
The descriptions above come from the cited sources. The list of questions is our method, not a claim about how often the class appears in practice. We make no frequency claim here.