Reentrancy protection: how to check yield pool safety
The DAO lost $60 million in 2016 because a contract sent funds before updating its internal balance. The pattern was simple. The consequence was not. In 2022, the Fei Protocol and Rari Fuse Pools exploit produced losses exceeding $80 million.

Reentrancy is not an obsolete Solidity mistake. It remains an attack vector in yield pools, lending markets, vaults, and cross-contract accounting systems.
A reentrancy protection check for yield pools should therefore examine more than the presence of nonReentrant. The relevant question is whether the protocol preserves a valid state across every external call, every callback, and every contract that consumes its accounting data. A guard on one function does not establish system-wide safety.
Reentrancy is an accounting failure before it is a coding pattern
A reentrancy attack occurs when a contract makes an external call while its state is temporarily inconsistent. The receiving contract calls back into the original contract before the first execution path has completed. If the original contract still reports an old balance, share count, collateral value, or withdrawal limit, the callback may execute against data that should no longer exist.
The basic vulnerable sequence looks like this:
1. A user requests a withdrawal.
2. The pool calculates the amount and prepares an external transfer.
3. The pool sends assets to a user-controlled contract.
4. The user-controlled contract calls the withdrawal function again.
5. The pool still sees the original balance because its accounting update has not occurred.
6. The second withdrawal succeeds against stale state.
The issue is not limited to a function calling itself. There are several relevant forms.
Single-function reentrancy
This is the classic case. A withdrawal function calls an external address and can be entered again before execution finishes.
A vulnerable pattern resembles:
calculate amount
send tokens
reduce user balanceThe safer ordering is:
calculate amount
reduce user balance
send tokensThe second pattern follows Checks-Effects-Interactions, commonly abbreviated as CEI. Internal checks occur first. State changes follow. External calls happen last.
CEI is useful because it removes the exploitable balance before control leaves the contract. It is not a complete security model. A protocol can follow CEI in one function while another function reads, modifies, or exposes the same state incorrectly.
Cross-contract reentrancy
A yield pool may not call back into the same function. It may call another contract that eventually returns to the pool through a different entry point.
For example, a vault can transfer a receipt token, invoke a strategy, update a share price, or call an oracle adapter. Each external contract adds another path back into the system. A guard attached only to withdraw() may not protect harvest(), depositFor(), flashLoan(), or a strategy callback that can alter the same accounting variables.
The review must map the call graph, not inspect isolated functions. The relevant question is:
Which external calls can regain control, and which state variables remain invalid when that happens?
Read-only reentrancy
Read-Only Reentrancy is more difficult to detect because the callback may enter a view or pure function rather than a state-changing function. A conventional ReentrancyGuard can block recursive state changes while failing to protect a read function that returns temporarily stale data.
Consider a pool that updates its reserves only after an external call. During that interval, another contract asks the pool for its exchange rate, total assets, or share price. The returned value may be inconsistent with the actual assets already transferred. A second protocol can consume that value as if it were authoritative.
This creates a cross-protocol attack surface. The vulnerable contract does not necessarily lose funds through a direct recursive withdrawal. Another contract may use the stale read to issue excessive shares, accept insufficient collateral, or calculate an incorrect liquidation amount.
A smart contract reentrancy check that stops at state-changing functions will miss this class of failure.
Start with the external-call inventory
The first practical step is to identify every point where the yield pool gives control to another contract. Search the source code for low-level calls, token transfers, callbacks, hooks, and interfaces whose implementation is not fully controlled by the pool.
The inventory should include:
call,delegatecall, andstaticcall;- ERC-20 transfers, especially tokens with hooks or non-standard behavior;
- ERC-721 and ERC-1155 safe transfers, which can invoke receiver callbacks;
- interactions with wrapped assets and staking derivatives;
- strategy deposits and withdrawals;
- liquidity pool joins and exits;
- flash-loan callbacks;
- oracle reads through external adapters;
- reward distribution contracts;
- fee recipients and treasury contracts;
- upgradeable proxy calls;
- arbitrary target calls exposed to governance or keepers.
Solidity’s transfer() and send() historically enforced a 2300 gas stipend. That constraint was often treated as a partial reentrancy barrier. It is not a robust basis for modern protocol security. Low-level call() forwards remaining gas unless explicitly limited, and token behavior does not always follow assumptions built around native ETH transfers. A review should treat every external call as a potential control-flow transfer.
The absence of an obvious call() is not conclusive. A token transfer may execute code in the token contract. A safe NFT transfer may invoke a receiver. A strategy adapter can make further calls that are invisible from the pool’s immediate function body.
Verify the Checks-Effects-Interactions ordering
CEI is the core code pattern to verify, but it must be applied to the entire state transition. A superficial review may see a balance decrement and classify the function as safe. That is insufficient if another variable remains stale.
Suppose a pool tracks:
- user shares;
- total shares;
- total assets;
- pending rewards;
- withdrawal fees;
- strategy debt;
- price-per-share;
- user-specific withdrawal limits.
Updating only the user balance before a token transfer may still leave the total asset value or share price inconsistent. A callback can then enter a different function that reads those variables and creates an economic advantage.
For each external call, trace the state before and after it. The review should answer four concrete questions:
1. Which balances, shares, debts, and limits are supposed to change?
2. Are all of those changes committed before the external call?
3. Can another function observe a partially updated state?
4. Does the external call depend on a value that the callback can manipulate?
A secure-looking sequence can still fail if the protocol calculates a transfer amount from a stale rate. The state update must occur before the interaction, but the calculation itself must also be based on values that cannot be altered during execution.
The common failure pattern
A frequent design error is to update user-level accounting while postponing protocol-level accounting. The function burns the user’s shares but leaves the pool’s exchange rate dependent on assets that have already been sent out. A callback then reads the inflated rate.
Another error is to update the pool but not the strategy. The vault marks a withdrawal as complete, calls the strategy for liquidity, and allows the strategy to invoke a callback that re-enters the vault. The vault’s local state may be correct while its external dependency still reports old balances.
The code review must follow the asset and the accounting value through every contract in the path.
Check the guard, then check what the guard does not cover
OpenZeppelin’s ReentrancyGuard uses a status flag. The nonReentrant modifier blocks a function from being entered recursively while the protected execution is active. This is a useful control. It is not a protocol-wide proof.
A meaningful audit of the guard should establish:
- which functions use
nonReentrant; - whether all asset-moving entry points are covered;
- whether administrative or callback functions can reach the same state;
- whether protected functions call one another internally;
- whether external wrappers expose an unprotected equivalent;
- whether read functions return values during an inconsistent state;
- whether the guard is inherited correctly in upgradeable deployments;
- whether proxy initialization establishes the guard’s expected storage state.
A common implementation limitation is that non-reentrant functions cannot directly call one another when both use the same guard. Developers may work around this with private internal functions and external wrappers. The wrapper design must be reviewed carefully. Moving logic into an internal function does not automatically make it safe if an unprotected public entry point can reach it under the wrong conditions.
The guard can also be applied too narrowly. Protecting withdraw() while leaving emergencyWithdraw(), claimRewards(), or harvest() open may preserve a path to the same balances. The correct unit of analysis is the shared state, not the function name.
nonReentrant is a lock on an execution path. It is not a lock on the protocol’s accounting model.Read-only reentrancy requires state-consistency controls
Read functions are often treated as harmless because they do not write storage. That assumption fails when other contracts trust their output.
A pool may expose functions such as:
totalAssets();convertToShares();convertToAssets();getPricePerShare();getReserves();balanceOf();- collateral valuation methods;
- utilization and liquidation calculations.
If these functions can be called while the pool is in a temporary intermediate state, a callback may obtain a value that is technically produced by a view function but economically false.
The protection strategy depends on the architecture. Possible controls include:
- updating all relevant state before external calls;
- adding a reentrancy lock to sensitive read paths;
- returning values from a consistent cached state;
- rejecting dependent operations while the system is mid-transition;
- requiring downstream protocols to validate state through a trusted mechanism;
- avoiding price calculations that rely on balances during external callbacks.
The exact control must match the dependency graph. A read lock added to one contract may not protect another contract that caches or transforms the value. If a lending market consumes a vault’s share price, both sides need to understand when the price can be stale.
This is also where audit reports often become less useful than source-level analysis. An audit may identify a guarded withdrawal function but say little about downstream consumers of view methods. The report’s scope and assumptions matter.
Test the attack vectors, not only the happy path
A yield farming reentrancy protection review should include adversarial tests. Unit tests that deposit, withdraw, and claim rewards from externally owned accounts are not enough. An externally owned account cannot execute a callback. The test suite needs malicious receiver contracts and configurable token mocks.
At minimum, test these cases:
1. Recursive withdrawal.
The receiver calls the same withdrawal function during a token transfer. The second call must fail or observe a zeroed balance.
2. Cross-function entry.
The receiver calls a different public function that touches the same balances, shares, rewards, or limits.
3. Token callback behavior.
A malicious or hook-enabled token calls back during transfer, approval, or balance-related operations.
4. Strategy callback.
A strategy or adapter invokes the vault before the vault has completed its accounting update.
5. Read-only callback.
During an external call, another contract reads the pool’s price, reserves, or conversion rate and uses the value in a state-changing operation.
6. Nested protocol interaction.
The pool calls an external market, which calls a second adapter, which returns to the pool through a different route.
7. Failure and rollback.
The external call reverts after internal state changes. The test must confirm that the transaction reverts atomically and that no partial state survives.
8. Gas and execution variation.
The callback should be tested with sufficient gas. Security must not depend on the 2300 gas stipend or on a particular compiler-era transfer behavior.
Fuzzing is particularly useful for invariants. Instead of checking only expected outputs, define properties that must remain true after arbitrary sequences of deposits, withdrawals, claims, transfers, and callbacks:
- user shares cannot be redeemed twice;
- total liabilities cannot exceed accounted assets without an explicit, bounded condition;
- a user’s balance cannot increase through a failed withdrawal;
- the conversion rate cannot be manipulated by an intermediate callback;
- the sum of user claims remains consistent with total shares;
- a callback cannot alter the recipient, amount, or accounting basis after authorization.
The goal is not to demonstrate that one malicious contract fails. It is to show that the system preserves invariants under unexpected control flow.
Audit reports are evidence, not a security certificate
A yield pool’s audit history is relevant, but the document must be read as a bounded technical assessment. An audit does not guarantee immunity from reentrancy. It may have reviewed an older commit, excluded strategy contracts, assumed trusted tokens, or omitted economic interactions with external protocols.
For a useful smart contract reentrancy check, examine:
| Review item | What it establishes | What it does not establish |
|---|---|---|
| Audit scope | Which contracts and functions were examined | That deployed bytecode matches the reviewed code |
| Finding status | Whether known issues were fixed or accepted | That no new attack vector exists |
| Reentrancy analysis | Whether identified call paths were reviewed | That read-only and cross-contract paths are safe |
| Test coverage | Which scenarios were executed | That arbitrary callback sequences are impossible |
| Upgrade controls | Who can change the implementation | That future upgrades will preserve current protections |
| Bug bounty | Whether researchers have an incentive to report bugs | That undiscovered vulnerabilities do not exist |
| Insurance coverage | Whether a defined loss event may be compensated | That exclusions, limits, or claim conditions will not apply |
The deployed address, chain, implementation bytecode, proxy administrator, and audit commit should be matched. A pool can retain the same brand and interface while pointing to a materially different implementation.
Upgradeability is a separate attack surface. If an administrator, multisig, or governance module can replace the implementation, the reentrancy review describes the current version only. The upgrade authority must be assessed with the same precision as the withdrawal function. A secure implementation under centralized upgrade control can still carry substantial governance risk.
Multisig security also belongs in the analysis. A compromised signer may not need to exploit reentrancy directly. They may upgrade the contract, change the strategy, replace the token, or redirect fees. Reentrancy protection narrows one class of attack. It does not neutralize administrative compromise.
Economic reentrancy can survive technically correct code
Not every exploit requires a direct recursive withdrawal. Some attacks exploit the same underlying weakness: a protocol exposes a value before its state transition is economically complete.
This can occur through:
- inflated share prices;
- stale reserve readings;
- incorrect reward debt;
- collateral values calculated during a temporary imbalance;
- fee accounting that is applied after an external interaction;
- flash-loan-funded state changes;
- adapters that assume an external price remains stable during execution.
The result may be yield compression for honest depositors rather than an immediate full drain. If an attacker mints excess shares, future yield is distributed across a larger claim base. The protocol may continue operating while its accounting becomes progressively less solvent.
That is a form of systemic insolvency: the contract remains callable, but its liabilities no longer correspond to recoverable assets. A narrow reentrancy review that asks only whether a callback can steal tokens in one transaction will miss this failure mode.
The correct assessment combines code safety with balance-sheet logic. We need to know not only whether a callback is blocked, but whether the protocol can prove that every issued share, reward claim, and withdrawal amount remains backed.
In practical terms, inspect:
- how total assets are measured;
- whether assets held in strategies are valued synchronously;
- whether accrued yield is realized or merely assumed;
- whether losses are socialized across depositors;
- whether the share price can increase before funds are actually available;
- whether rewards are minted from an external balance or from accounting variables;
- whether emergency withdrawals bypass normal checks.
A protocol can have a clean nonReentrant implementation and still expose an economically exploitable stale-price path.
A disciplined review sequence
The fastest way to waste time on a yield pool is to begin with the audit badge and stop at the modifier. A better review proceeds in layers.
1. Identify custody and liability contracts
Separate the contracts that hold assets from those that issue claims. A strategy, vault, reward distributor, and oracle adapter may all influence the user’s final redemption even if only one contract stores the tokens.
2. Map every external interaction
Record the target, function, asset, callback possibility, and state variables that are updated before and after the call. Include token operations and receiver hooks. Do not restrict the map to explicit low-level calls.
3. Trace shared state
Mark every function that reads or writes balances, shares, prices, debt, reserves, reward indices, and withdrawal limits. Then compare these functions with the external-call inventory. This reveals cross-function entry points.
4. Verify CEI at the system boundary
Confirm that all accounting changes required for consistency occur before control leaves the contract. Check whether the state is still coherent if the external call invokes a read function, a callback, or a different public entry point.
5. Review guard coverage
Check nonReentrant placement, inheritance, proxy storage, internal wrappers, and callback functions. Treat the guard as one layer. Do not use it as a substitute for state-ordering analysis.
6. Test malicious contracts
Use receiver contracts, hook-enabled token mocks, reverting adapters, recursive callbacks, and nested call sequences. Include read-only reentrancy tests. A test suite without adversarial contracts is not a reentrancy test suite.
7. Inspect the deployment and change controls
Match deployed bytecode to the reviewed source. Identify proxy administrators, multisig signers, timelocks, emergency powers, and upgrade procedures. Security assumptions that depend on a privileged address must be explicit.
8. Evaluate recovery capacity
If an exploit occurs, determine whether the protocol can pause deposits, withdrawals, rewards, or strategy interactions. Examine whether the pause authority itself is protected and whether assets can be isolated without worsening the loss.
Risk assessment in DeFi has the same structure as other safety systems: the main infrastructure is only one layer. In outdoor operations, for example, physical conditioning matters more than trail infrastructure for hiking safety. In a yield pool, the equivalent mistake is to assess the visible interface while ignoring the operator, callbacks, adapters, and recovery controls behind it.
What a credible result looks like
A strong review should produce a concrete conclusion for each relevant path. “Audited” is not a conclusion. “Uses OpenZeppelin” is not a conclusion. The useful result identifies:
- the external call;
- the state exposed during that call;
- the possible callback route;
- the invariant that must hold;
- the control that preserves it;
- the test or formal argument supporting the control;
- the remaining assumptions.
For example, a satisfactory finding might state that a withdrawal burns shares and updates total accounting before invoking the token transfer; the receiver callback cannot re-enter through any public path that accesses those balances; price reads are based on committed state; and malicious receiver tests preserve the share-to-asset invariant.
An unsatisfactory finding would say that the withdrawal function has nonReentrant while leaving the strategy callback, conversion-rate view, and emergency withdrawal path unexamined.
The distinction is material. Security controls must be tied to a known failure mode. Otherwise they are decorative.
Final assessment
A yield pool passes a reentrancy protection check only when its accounting remains valid across external calls, callbacks, read paths, and cross-contract interactions. CEI is the first control. ReentrancyGuard is a useful second layer. Neither replaces call-graph analysis, invariant testing, deployment verification, or review of upgrade authority.
The binary verdict is straightforward:
- If the protocol updates all relevant state before interaction, blocks or safely handles recursive paths, protects sensitive reads, tests malicious callbacks, and matches deployed code to the reviewed implementation, reentrancy risk may be contained.
- If the assessment relies on a single modifier, an old audit, or the assumption that view functions cannot cause damage, the risk-to-reward ratio is unsound.
Yield is variable. A stale accounting state is deterministic.