
Preventing Read-Only Reentrancy in Solidity
What read-only reentrancy is
In Solidity, view functions cannot modify state directly. That restriction is useful, but it does not guarantee safety. A view function can still read storage that is temporarily inconsistent because another function has already changed part of the state and has not yet finished restoring invariants.
The typical pattern looks like this:
- A state-changing function updates some storage.
- Before it finishes, it makes an external call.
- The external contract calls back into a
viewfunction on the original contract. - The
viewfunction returns a value based on half-updated state. - The caller uses that value to make a decision.
The danger is not the callback itself, but the fact that the callback can observe a transient state that should never be externally visible.
Why this matters in practice
Read-only reentrancy often shows up in systems that:
- calculate share prices or exchange rates from internal balances,
- expose
viewhelpers used by other contracts for validation, - call external hooks during deposits, withdrawals, swaps, or liquidations,
- rely on “current” values that are derived from multiple storage variables.
A common real-world consequence is price manipulation through stale or inconsistent accounting. For example, a vault may update its asset balance before minting shares, then call an external token or receiver hook. During that hook, another protocol queries totalAssets() or convertToShares(), sees an inflated or deflated value, and executes an unfavorable trade.
This is particularly dangerous because many developers assume view functions are safe by definition. They are not safe if they expose intermediate state.
A vulnerable example
Consider a simplified vault that tracks assets and shares. It updates totalAssets before transferring tokens and before minting shares.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
interface IHook {
function onDeposit(address user, uint256 amount) external;
}
contract Vault {
IERC20 public immutable asset;
uint256 public totalAssets;
uint256 public totalShares;
constructor(IERC20 _asset) {
asset = _asset;
}
function deposit(uint256 amount, address hook) external {
// Effects are incomplete here.
totalAssets += amount;
// External interaction before invariant is fully restored.
if (hook != address(0)) {
IHook(hook).onDeposit(msg.sender, amount);
}
require(asset.transferFrom(msg.sender, address(this), amount), "transfer failed");
totalShares += amount;
}
function convertToShares(uint256 assets) external view returns (uint256) {
if (totalAssets == 0 || totalShares == 0) return assets;
return (assets * totalShares) / totalAssets;
}
}At first glance, this seems reasonable. But convertToShares() can be called during onDeposit() while totalAssets has already increased and totalShares has not. Any external protocol relying on this conversion may receive a distorted result.
How the exploit works
An attacker deploys a hook contract that calls another protocol during onDeposit(). That protocol queries Vault.convertToShares() and uses the returned value for pricing or collateral checks. Because the vault’s state is temporarily inconsistent, the attacker can cause the protocol to overvalue or undervalue the vault position.
The key point is that the vault itself may never directly lose funds during the callback. The loss happens because other contracts trust its view output too early.
Common sources of read-only reentrancy
| Pattern | Why it is risky | Typical symptom |
|---|---|---|
| External calls before restoring invariants | Temporary state becomes visible to callbacks | Incorrect pricing or accounting |
view functions derived from multiple storage variables | Partial updates create inconsistent snapshots | Mismatched ratios or totals |
| Hook-based token standards | Receiver callbacks can query state mid-operation | Unexpected values during deposits or transfers |
| Cross-protocol integrations | Another contract trusts your view output | Bad collateral, swaps, or liquidations |
The issue is not limited to ERC standards. Any contract that exposes derived state and performs external calls can be affected.
Defensive design principles
1. Restore invariants before external calls
The safest rule is simple: do not expose transient state to the outside world. If a function must make an external call, ensure that all storage used by public view functions is already in a consistent post-operation state.
That often means:
- compute all new values locally,
- write the final state before the external call,
- or postpone the external call until after all critical state is finalized.
If you cannot do that, redesign the flow so the external interaction happens in a separate transaction or a separate function.
2. Avoid using live storage for sensitive pricing
If a view function is used for pricing, consider whether it should read directly from mutable storage. In some cases, it is safer to use:
- cached snapshots,
- epoch-based accounting,
- oracle data,
- or explicit settlement steps.
A pricing function that depends on “current” balances is fragile if those balances can be temporarily inconsistent.
3. Treat view functions as part of the attack surface
A view function is not harmless just because it cannot write storage. If another contract uses it during execution, it becomes part of the trust boundary. Audit these functions with the same care you would apply to state-changing logic.
4. Minimize external callbacks
Hook-based designs are convenient but risky. If callbacks are required, keep them narrow and avoid calling untrusted contracts before state is finalized. If possible, use pull-based patterns instead of push-based ones.
A safer implementation
Here is a safer version of the vault that finalizes accounting before any external interaction and avoids exposing inconsistent values.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20Safe {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
interface IHookSafe {
function onDeposit(address user, uint256 amount) external;
}
contract SafeVault {
IERC20Safe public immutable asset;
uint256 public totalAssets;
uint256 public totalShares;
constructor(IERC20Safe _asset) {
asset = _asset;
}
function deposit(uint256 amount, address hook) external {
require(amount > 0, "amount=0");
// Pull tokens first so accounting reflects actual funds.
require(asset.transferFrom(msg.sender, address(this), amount), "transfer failed");
// Finalize state before any external callback.
totalAssets += amount;
totalShares += amount;
// Optional hook happens after invariants are restored.
if (hook != address(0)) {
IHookSafe(hook).onDeposit(msg.sender, amount);
}
}
function convertToShares(uint256 assets) external view returns (uint256) {
uint256 assetsTotal = totalAssets;
uint256 sharesTotal = totalShares;
if (assetsTotal == 0 || sharesTotal == 0) return assets;
return (assets * sharesTotal) / assetsTotal;
}
}This version improves safety in two ways:
- It transfers tokens before updating accounting, so the vault does not advertise assets it does not yet hold.
- It updates both
totalAssetsandtotalSharesbefore any callback, soconvertToShares()always sees a consistent ratio.
The exact order depends on your business logic, but the principle is the same: external observers should never see half-finished state.
Practical patterns for safer contracts
Use explicit settlement phases
For complex protocols, split operations into phases:
- Prepare: validate inputs and compute outcomes.
- Settle: update all critical storage.
- Interact: perform optional external calls.
This structure makes it easier to reason about invariants and reduces the chance of accidental exposure.
Prefer immutable or snapshot-based inputs
If a contract needs a value for a sensitive decision, pass it in explicitly or derive it from a snapshot rather than from live mutable state. For example, a liquidation engine may use an oracle price with a timestamped round instead of querying a vault’s current balance mid-execution.
Keep view helpers pure in intent
A view function should ideally return a value that is stable for the duration of a transaction, or at least one that cannot be weaponized if observed during a callback. If the value is only meaningful after a full operation completes, do not expose it during intermediate steps.
Document callback safety
If your contract supports hooks, document:
- when the hook is called,
- which state is already finalized,
- which functions are safe to call from the hook,
- and which assumptions integrators must not make.
Security issues often arise from integration assumptions, not just from code defects.
Testing for read-only reentrancy
You should test for this issue with adversarial callbacks, not just standard unit tests. A good test harness should:
- call a state-changing function,
- reenter through a hook or callback,
- query public
viewfunctions during the callback, - and assert that the returned values are consistent.
A useful strategy is to create a malicious helper contract that records the values observed during reentry. Then compare those values against the expected post-operation state.
What to look for in tests
- Does a
viewfunction return different values before and after the same transaction? - Can a callback observe a ratio or balance that should never be externally visible?
- Does another protocol make a decision based on that temporary value?
- Are there any external calls before all accounting variables are updated?
If the answer to any of these is yes, the design deserves a closer review.
Review checklist
Use the following checklist during audits:
- No external call occurs before critical accounting is finalized.
viewfunctions do not expose transient or partially updated state.- Hook-based integrations are called only after invariants are restored.
- Pricing and conversion functions are safe to use during callbacks.
- Cross-protocol dependencies are documented and tested.
- Malicious callback contracts are included in the test suite.
This checklist is especially important for vaults, AMMs, staking systems, lending markets, and bridge adapters.
When to redesign instead of patch
Sometimes the best fix is not a local code change but a broader redesign. Consider redesigning if:
- the contract must make external calls during core accounting,
- multiple public
viewfunctions depend on mutable internal ratios, - the protocol is intended to be integrated by third parties in real time,
- or the state model is too complex to guarantee consistency during callbacks.
In those cases, a snapshot-based or epoch-based architecture is often safer than trying to defend every view function individually.
Conclusion
Read-only reentrancy is easy to miss because it does not look like a traditional state corruption bug. The contract may never write unsafe values during the callback, yet it still leaks inconsistent state that other systems can exploit. In modern Solidity development, that is enough to create serious financial risk.
The core defense is straightforward: finalize invariants before external calls, treat view functions as security-sensitive interfaces, and test every callback path with malicious observers. If your contract exposes derived values, assume they can be queried at the worst possible moment.
