Read-only reentrancy: when a view function tells the truth too early
A contract can be safe from classic reentrancy and still expose an inconsistent state to anyone who reads it mid-call. How the pattern works, and how integrators should defend against it.
- Written by
- Quantix Research, Smart Contract Security
- Published
- Reading time
- 8 minutes
- Category
- Smart Contract Vulnerabilities
Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Contents
Most engineers learn reentrancy through a single picture: a contract sends funds before it updates a balance, and the recipient calls back in to withdraw again. The defence is just as familiar: update state first, or put a reentrancy guard on every function that changes state. Both are correct, and both leave one door open.
Read-only reentrancy does not re-enter a function that writes. It re-enters one that only reads, at a moment when the contract’s state is half updated, and it does so through another protocol that trusts the answer. The vulnerable contract never loses funds directly. The protocol that read from it does.
Reentrancy without writes
A view function promises not to modify state. It makes no promise that the state it reads is consistent. During an external call, a contract’s storage is frozen exactly as it was when control left it. If some variables have been updated and others have not, every view function that combines them will return a value that is true of no real state of the system.
On its own this is harmless: the contract will finish its own accounting when control returns. It becomes a vulnerability when a second protocol reads that value during the window and acts on it, for example by pricing collateral, minting a derivative, or settling a trade.
How the inconsistent read happens
The pattern needs three ingredients:
- A function that hands control to an untrusted address part-way through its accounting. Native-coin transfers, ERC-777 hooks, ERC-721 and ERC-1155 receiver callbacks and arbitrary low-level calls all qualify.
- A public view function whose result depends on two or more values that are updated on different sides of that external call, such as reserves and total supply.
- An integrating protocol that calls the view function and treats the result as authoritative.
The attacker triggers the first function, receives control in the callback, and from inside that callback interacts with the integrator. The integrator reads a price that is briefly wrong, usually inflated or deflated in a direction the attacker chose, and the attacker borrows, liquidates or redeems against it before the original call completes.
The pattern drew wide attention in 2022, when researchers showed that several lending integrations priced liquidity-pool tokens using a pool function that could be read mid-withdrawal. The underlying idea is general, and it appears wherever a protocol exposes derived values for others to consume.
A simplified example
The pool below holds a native coin and an ERC-20 token and issues shares against them. During removeLiquidity it reduces totalShares before sending the native coin, but reduces reserveNative only afterwards. The code is deliberately reduced to the essentials.
// Simplified, illustrative pool. Not production code.contract LiquidityPool is ReentrancyGuard { uint256 public totalShares; uint256 public reserveNative; // native coin held by the pool uint256 public reserveToken; // ERC-20 held by the pool mapping(address => uint256) public shares; /// Price of one share, read by external protocols. function sharePrice() external view returns (uint256) { uint256 value = reserveNative + _tokenValueInNative(reserveToken); return (value * 1e18) / totalShares; } function removeLiquidity(uint256 amount) external nonReentrant { uint256 nativeOut = (reserveNative * amount) / totalShares; uint256 tokenOut = (reserveToken * amount) / totalShares; shares[msg.sender] -= amount; totalShares -= amount; // supply already reduced... reserveToken -= tokenOut; // ...but reserveNative is updated only after the external call. (bool ok, ) = msg.sender.call{value: nativeOut}(""); require(ok, "send failed"); reserveNative -= nativeOut; token.transfer(msg.sender, tokenOut); }}Inside the callback on line 23, totalShares has fallen but reserveNative still holds the coins being withdrawn. sharePrice() divides an unchanged numerator by a smaller denominator and reports a higher price than the pool can actually support.
// A lending market that values pool shares as collateral.function collateralValue(address user) public view returns (uint256) { uint256 price = pool.sharePrice(); // read during the attacker's callback return (poolShares[user] * price) / 1e18;}An attacker who holds pool shares as collateral in the lending market removes a large amount of liquidity, and while the pool is mid-call, borrows against the inflated collateral value. When the callback returns, the pool finishes its accounting, the price falls back, and the attacker’s position is undercollateralised. The lending market holds the loss.
The same shape elsewhere
Liquidity pools are the textbook case, but the shape recurs wherever a contract exposes a derived value and hands control to someone else before that value is consistent again.
- Vault share prices. A vault that transfers assets out, triggering a token hook, before burning the corresponding shares will report an inflated
convertToAssetsduring the hook. Any protocol that accepts the vault’s shares as collateral inherits the problem. - Token hooks. ERC-777 lets a sender’s registered hook run before balances move, and receiver hooks for ERC-721 and ERC-1155 run during transfers and mints. Code that updates supply, ownership counters or reward indexes around those calls must assume the hook can observe every intermediate state.
- Reward and index accounting. Staking contracts that pay rewards before updating a reward-per-share index can expose a stale index to anyone who reads it during the payout, which matters when the index is used by another contract to price a receipt token.
- Governance snapshots. Voting power read from a token whose balances are mid-update can be double counted if a callback lets the same tokens be observed in two places at once.
In each case the question is the same: at the moment control leaves the contract, which public answers are temporarily untrue, and who is listening for them?
Why the usual guard is not enough
A standard reentrancy guard sets a flag on entry to a protected function and reverts if another protected function is entered while the flag is set. View functions are not protected because a modifier that writes the flag cannot run in a view context, and because the conventional threat model treats reads as safe.
The integrator cannot rely on the guard either. Its call to sharePrice() succeeds, because nothing stops a view from being called while the lock is held. From the integrator’s point of view the pool answered normally.
Checks-effects-interactions, applied strictly, removes the problem at its source: if every effect is complete before any interaction, there is no window in which state is inconsistent. In practice, code bases often apply the pattern only to the variables that guard against classic reentrancy, and leave derived values out of step.
Defences on both sides
Both the protocol that exposes the value and the protocol that consumes it can close the gap. Ideally both do.
// Option 1: make the pool's own views refuse to answer mid-call.function sharePrice() external view returns (uint256) { if (_reentrancyGuardEntered()) revert PoolLocked(); uint256 value = reserveNative + _tokenValueInNative(reserveToken); return (value * 1e18) / totalShares;} // Option 2: remove the inconsistency by finishing all accounting first.function removeLiquidity(uint256 amount) external nonReentrant { uint256 nativeOut = (reserveNative * amount) / totalShares; uint256 tokenOut = (reserveToken * amount) / totalShares; shares[msg.sender] -= amount; totalShares -= amount; reserveToken -= tokenOut; reserveNative -= nativeOut; // effects complete before any interaction (bool ok, ) = msg.sender.call{value: nativeOut}(""); require(ok, "send failed"); token.transfer(msg.sender, tokenOut);}OpenZeppelin’s ReentrancyGuard exposes _reentrancyGuardEntered(), an internal view that returns true while a nonReentrant function is on the call stack. A pool can use it to make its own views revert during guarded calls. Integrators that cannot change the pool have fewer options, but they are not powerless.
| Who | Defence | Trade-off |
|---|---|---|
| Value provider | Complete all state updates before external calls (checks-effects-interactions across every variable a view reads). | Requires discipline across the whole contract; easy to regress. |
| Value provider | Make public views revert while the reentrancy lock is held. | Integrators must handle the revert; some may break. |
| Value provider | Document which views are safe to consume on-chain, and when. | Only helps integrators who read the documentation. |
| Integrator | Before reading, call a cheap state-changing function on the provider that is protected by the same lock; if it reverts, the provider is mid-call. | Costs gas, and depends on the provider exposing such a function. |
| Integrator | Price collateral from an independent source and bound the provider’s value against it. | Adds an oracle dependency; needs sensible deviation thresholds. |
For integrators the most robust stance is to treat any value read from another contract as an input that can be wrong for the duration of a transaction, and to ask what happens if it is. If the answer is that collateral can be overvalued, the read needs a second opinion.
What reviewers check
When Quantix reviewers assess a protocol for this class of issue, the questions are concrete:
- Which functions transfer control to an address that is not fully trusted, and at what point in their accounting?
- For each such call, which storage variables have been updated and which have not?
- Which public or external view functions read any of the variables that are out of step?
- Are those views documented, or known, to be consumed by other protocols on-chain?
- For integrations under review, which external views does the protocol read, and can the provider be mid-call when it does?
The last question matters most for lending markets, vaults and derivatives that accept other protocols’ tokens as collateral. The provider may have been audited, and correctly found safe for its own users. The integration still needs its own threat model.
Where a candidate issue is found, reviewers reproduce it before reporting it. A proof of concept for this class usually needs three contracts: the provider in a forked or simulated state, a small attacker contract whose receive hook calls the integrator, and the integrator itself. The test asserts that the value read inside the hook differs from the value read before and after the call. That single assertion turns a plausible concern into a demonstrable finding, and it later becomes the regression test that proves the fix.
Conclusions
Read-only reentrancy turns a harmless-looking view function into an attack surface for every protocol that reads it. The contract that exposes the inconsistent value is often safe for its own users, which is why the issue survives reviews scoped to a single code base.
The provider closes the gap by finishing its accounting before any external call, or by making its views refuse to answer while a guarded function is running. The consumer closes it by treating any value read from another contract as possibly wrong for the duration of the transaction, and bounding it against an independent source.
Recommendations
Value providers
- Apply checks-effects-interactions to every variable that a public view reads, not only to balances.
- Make public views revert while the reentrancy lock is held, or expose the lock status to integrators.
- Document which views are safe to consume on-chain, and under what conditions.
Integrators
- Before reading a derived value, confirm the provider is not mid-call, or bound the value against an independent price.
- Include the provider’s callback paths in your own threat model.
Reviewers
- For every external call, list the variables that are stale at that point and the views that read them.
- Reproduce candidate issues with a test that asserts the view differs inside the callback from before and after.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1Security considerations: reentrancy(opens in a new tab)Solidity documentation
- 2ReentrancyGuard and _reentrancyGuardEntered()(opens in a new tab)OpenZeppelin Contracts 5.x API: Utils
- 3SWC-107: Reentrancy(opens in a new tab)Smart Contract Weakness Classification Registry
- 4EIP-777: Token Standard (hooks)(opens in a new tab)Ethereum Improvement Proposals
Applied on this site
Assessments, case studies and tools that apply this publication.







