Every external call is a handover
Reentrancy is a symptom. The event underneath is control leaving your contract while state is half-written. Count those moments per function and audit the count.
Every Solidity audit checklist has a line for reentrancy, and every tutorial teaches the same fix: checks, effects, interactions, plus a guard modifier. The fix works for the case the tutorial shows and it names the wrong thing. Reentrancy is a symptom. The event underneath it is that control leaves your contract while some of your state is still to be written, and that event has more consequences than the one where the caller comes straight back in. It is also how a token's transfer hook runs your counterparty's code, how another contract reads your half-updated numbers, and how a flash-loan receiver ends up running inside someone else's transaction. Counting those moments is a better habit than pattern-matching for the symptom, and the count has a target, which is zero.
The handover count
For each function, count the points where control leaves the contract, by any external call, with at least one state variable still to be written after that point. That is the handover count. A function with zero handovers cannot be re-entered in any way that matters, because there is nothing half-written for a re-entrant caller to exploit, and nothing stale for an outside reader to see. A function with a non-zero count is not necessarily wrong; it has a reason, and the reason gets written next to the call in the code, and the count and its reasons are reported per function in the audit.
The definition is deliberately about state written after the call, not about whether the callee is trusted. Trust is what the tutorials use, and it is the wrong axis: a trusted callee can be upgraded, can itself call an untrusted contract, or can be a token whose transfer runs a hook you did not know about. The state ordering is a property of your own code, and it is the one you control.
What a flash-loan receiver looks like under the count
The clearest shape I have worked with is a flash-loan receiver. In Aave's pool contract, a receiver calls flashLoan, the pool transfers the borrowed tokens and calls back into the receiver's executeOperation, the receiver does whatever it borrowed the money for, approves the repayment, and returns; the pool then pulls the loan plus premium. The whole design is a handover: your code runs inside the pool's transaction, between the pool's transfer and the pool's repayment check. Inside your executeOperation, every swap on a decentralised exchange is another handover, and every token transfer is a potential one, because a token that follows ERC-777 calls a hook on the recipient during the transfer, and an ERC-721 safe transfer calls the receiver's acceptance function.
Under the count, that function starts at four. The work of reducing it is ordinary: the in-flight flag is set before the first call and cleared at the very end, which is fine as long as the flag is the only thing read by anything re-entrant, and it is written down as the reason; the profit accounting moves to after the last external call, which reduces the number of handovers with unwritten state behind them from three to zero for that variable; the payee is fixed before anything is called. The count that remains, one, with the flag as its reason, is what the audit reports. It is a smaller and more honest number than "uses a reentrancy guard".
Why the symptom is the wrong thing to count
The public loss data explains why the broader framing matters. DefiLlama's hacks dataset classifies incidents by technique, and for the period from January 2025 to mid-September 2026, incidents labelled reentrancy account for six events and about 45 million dollars, which is a small slice of the roughly 4.5 billion lost in the period, most of it to key and signer compromise rather than to contract logic. Among the contract-logic categories, arithmetic errors, donation attacks, rounding errors, price manipulation and access control are each larger than reentrancy.
The numbers are not an argument that reentrancy does not matter; the dataset's largest reentrancy loss in the period, a 42 million dollar exploit of a perpetuals protocol in July 2025, is reason enough to keep the line on the checklist. They are an argument about what to count. A guard modifier defends against the one technique labelled reentrancy. A handover count defends against every technique whose mechanism is "someone else's code ran while my state was inconsistent", and that mechanism shows up under other labels: a donation that lands between a balance read and its use, a price read from a pool that is mid-swap, a share calculation that ran before a callback finished. The label on the incident is decided afterwards. The handover was visible in the code beforehand.
The read-only case the guard misses
The variant that makes the point sharpest is read-only reentrancy, where nobody re-enters the vulnerable contract at all. Contract A is mid-withdrawal: it has sent tokens to the caller but not yet updated the reserves it uses to compute its share price. During the send, the caller's code runs, and it calls contract B, a lender that values A's shares by asking A for its price. A answers from the half-updated reserves, B lends against the inflated value, and the loop closes without A's guard ever firing, because A was never called again. A's handover count on that withdrawal function was one, with the reserves as the unwritten state, and the count would have flagged it.
Running the count
The count is easy to produce by hand for a small contract and easy to automate for a large one: for each function, list external calls in order, and for each call, list the storage writes that follow it in the same function. The audit report carries a table with one row per function: handover count, and for each non-zero entry, the call, the state still unwritten, and the reason given in the code. Reasons that are acceptable are specific: "the flag is the only state read by re-entrant paths and it is set before the call"; "the callee is this contract's own library and makes no external calls". Reasons that are not acceptable are the ones the tutorials teach: "the callee is trusted", "there is a guard".
Automating it is a walk over the syntax tree. For each function, visit the statements in order; an external call is any call expression whose target is an address or an interface rather than an internal function or a library that makes no calls of its own; a state write is any assignment whose left-hand side resolves to storage. Emit one row per call that has at least one state write after it in the same function, including writes inside branches and loops, and include writes that happen in internal functions called after the external call, because those are the ones a manual review skips. The tool does not need to decide what is safe. It needs to produce the list, and the reasons are a human's job.
What the count does not cover deserves saying. Arithmetic and rounding errors, the largest contract-logic category in the data, are not handovers and need their own discipline. Key compromise, the largest category of all, is not a contract property at all. The handover count is a narrow tool for a specific mechanism, and its value is that the mechanism is the one that hides behind several labels. Get every function to zero, write a reason for every one that cannot be, and the reentrancy line on the checklist becomes a consequence rather than a hope.
Get new posts by email
Occasional essays on engineering, AI, and building for the people technology leaves behind.
Subscribe with RSS