Read-Only Reentrancy: Protecting Curve and Balancer Reads

Quick answer. Read-only reentrancy is when your protocol reads a Curve or Balancer price mid-callback, while pool supply and balances disagree. Before every read, check the pool's lock state, for example with Balancer's ensureNotInVaultContext, and revert if it is active.
Video transcript
Your protocol can be drained without ever being re-entered. Read-only reentrancy feeds you a Curve or Balancer price mid-operation.
A view function cannot write storage, but it can return a snapshot taken halfway through another contract's update.
In Curve, LP price is the invariant D over supply. During remove liquidity, supply falls before balances update. An ETH callback then calls your oracle, which reads a stale price.
In early 2023, Midas Capital, dForce and Sentiment were exploited this way, with Jarvis's assets drained on Midas. dForce got its funds back, and Sentiment's attacker returned ninety percent.
Your non-reentrant modifier guards your storage, not the dependency's half-finished state. The fix: ask the dependency if it is mid-operation, and revert.
On Balancer V2, call ensure not in Vault context in every BPT price function. On V3, review the deployed Vault, which exposes is unlocked. Newer Curve stableswap pools lock get virtual price, but not get p.
In Guardian's GMX GLV finding M zero eight, a native token transfer ran before supply was burned, so integrators could liquidate on a wrong value.
Then inventory every LP token, BPT and vault share you price, and map each dependency's callbacks. Check the lock state on every read, then fuzz your oracle inside joins and exits.
Guardian's smart contract audit puts two independent teams on one scope, with invariant fuzzing of edge states. Read the full checklist, or talk to Guardian at guardian.security.
Why integrators get caught
Read-only reentrancy hurts the protocol that reads a price, not the pool that holds the funds. Your lending market or vault can be drained without ever being re-entered. ChainSecurity, Balancer
In early 2023, Midas Capital (draining Jarvis's jFIAT assets), dForce and Sentiment were exploited through Curve and Balancer LP prices; dForce later got its funds back. Sentiment, Aave Governance, CertiK, dForce, rekt.news
The pattern still appears in audits. Guardian findings from 2024 and 2025 flag the same stale-state window in vault and liquidity code. Guardian
This guide is for teams that price LP tokens, BPT or vault shares. It ends with a checklist to run against your own oracle.
Why view functions are not safe
A view function cannot write storage. It can still return a snapshot taken halfway through another contract's state change. Solidity Foundation
Solidity's security guidance treats every external call as a possible handoff of control. It recommends checks-effects-interactions as the defense.
Curve's get_virtual_price divides the pool invariant D by LP total supply. ChainSecurity
During remove_liquidity, supply can fall before every balance is updated. A native ETH transfer hands control to the receiver inside that gap.
The receiver then calls your protocol. Your oracle reads get_virtual_price and gets a value that will not exist once the transaction ends.
Balancer's February 2023 advisory described the same class. The Vault and its pools were each non-reentrant, but their guards were not synchronized. Balancer
During joins and exits, pool supply and Vault balances could diverge. Balancer listed stale rates, excess fee payments and bad trades as outcomes.
Nested Composable Stable pools were the highest-risk class in that advisory.
Your own nonReentrant modifier does not help. It guards your storage, not the dependency's half-finished state.
For classic, cross-function and cross-contract variants, see reentrancy attacks explained. This article covers only the integrator's side.
Sentiment, Jarvis and dForce
Midas Capital on Polygon was exploited on 15 January 2023. Aave governance first estimated about 368,000 jEUR uncollateralized, revised to 257k unbacked on 18 January. rekt.news, Aave Governance
AIP-143 froze jEUR on Aave Polygon v3, executed 28 January 2023.
Midas accepted a WMATIC-stMATIC Curve LP token as collateral. rekt.news reports its value depended on D and total supply, and D was updated after an unexpected callback.
Numen traced the reentrant path through Curve remove_liquidity on Polygon and puts the loss at 663,101 MATIC, a token count, not a USD figure. Numen Cyber
Jarvis said the attacker took 368k jEUR, 237k jCHF and 45k jGBP, leaving those assets unbacked. Its January 2023 recovery plan was a stated plan, not confirmed completion. Jarvis Network
dForce's Curve-based oracle was read while a withdrawal left pool accounting inconsistent. The inflated collateral value let the attacker borrow, CertiK reports. CertiK
dForce said on 13 February 2023 that the attacker returned all funds to its Arbitrum and Optimism multisigs. It offered a bounty and dropped its investigation and law enforcement actions. dForce, Crypto.news
Sentiment's own recovery plan says the attacker manipulated the oracle price of a Balancer LP token. Sentiment
Its postmortem names view-only reentrancy in Balancer pools during an ETH liquidity removal. BPT was burned before Vault balances updated, so Sentiment's oracle read an inflated price.
The exploiter returned 90% of misappropriated funds, 465.75 ETH, and insurers committed $65,701.03, part of it pending at the time. Bad debt and recovered assets are separate measures.
Each lending protocol, Midas, dForce and Sentiment, priced an LP token from live pool state without asking whether the pool was mid-operation.
| Incident | Date | Price source | Reported impact |
|---|---|---|---|
| Jarvis via Midas, Polygon | 15 Jan 2023 | WMATIC-stMATIC Curve LP token | 368k jEUR, 237k jCHF, 45k jGBP taken (Jarvis) |
| dForce | 9 Feb 2023 | Curve-based oracle | About $3.7M taken (CertiK); all returned 13 Feb 2023 (dForce) |
| Sentiment | 4 Apr 2023 | Balancer LP token price | About $1.09M bad debt; attacker returned 90% of taken funds |
Checking an external lock state
The durable fix is to ask the dependency whether it is mid-operation, and revert if it is.
Balancer V2 publishes this check as VaultReentrancyLib.ensureNotInVaultContext. It staticcalls the Vault's manageUserBalance with an empty operation list and a 10,000 gas limit. Balancer Labs GitHub
Outside a Vault operation, the guard tries to write storage, so the staticcall fails with empty revert data. That result means the read can proceed.
Inside an operation, the guard reverts with BAL#400. The library then reverts, and so does your price read.
Because the probe is a staticcall, it works inside view functions. Put it at the top of every function that prices BPT or reads pool rates.
Balancer V3 moves BPT accounting into the Vault, so balances and BPT supply update together. During a swap, its Vault still holds balances in memory, and marks _swap nonReentrant to guard against read-only reentrancy. Balancer Docs
The V2 library does not apply to V3, whose Vault exposes isUnlocked(), true while it is executing an operation. Balancer's V3 BPT oracle contracts offer shouldRevertIfVaultUnlocked, which reverts TVL calculations while the Vault is unlocked.
Outside those oracles, rejecting BPT price reads while isUnlocked() is true is a conservative check, not a documented Balancer requirement. It also blocks reads in legitimate same-session calls, so confirm the behavior against the deployed Vault.
Stableswap-ng pools lock get_virtual_price and price_oracle, but not get_p. For older pools, ChainSecurity recommended calling the pool's withdraw_admin_fees function to trigger its reentrancy lock. Curve Finance GitHub, ChainSecurity
Do not copy that workaround blindly, since withdraw_admin_fees still moves accrued admin fees. Confirm the deployed pool source, its Vyper version and lock behavior, and that your probe cannot move fees or funds.
Guardian's Gamma Limit Order Manager finding L-14: Gamma read Algebra's globalState directly, bypassing the pool's lock, which exposed Gamma to read-only reentrancy. Guardian
It was rated Low and resolved. The finding recommended reading through safelyGetStateOfAMM, which respects the lock, or first checking the pool's isUnlocked() before any direct read.
In Guardian's reports
Guardian's GMX GLV finding M-08 shows the window in LP code. A native token transfer ran before GLV supply was burned. Guardian
A receiver callback could see assets reduced but supply unchanged. Integrators using GLV as collateral could then liquidate on a wrong value.
Guardian's Gamma Position Managers finding L-17 linked token transfers made before state writes to the same risk.
Integrator checklist
The review rule: whenever a callback can run, every price input you consume must be final, or rejected as in progress.
Encode that rule as an invariant, with handlers that call your oracle from inside joins, exits and withdrawals. Our Foundry invariant testing guide shows how to build handlers.
Run each check below against every LP token, BPT and vault share you accept.
| Check | Evidence to ask for | Red flag |
|---|---|---|
| Inventory every LP, BPT and vault share priced | Oracle map naming the source contract per asset | Price taken from live pool state, undocumented |
| Find callbacks in each dependency | Native ETH, token hook and pool hook paths listed | View-only status offered as the safety argument |
| Balancer V2 reads check Vault context | ensureNotInVaultContext called inside the price function | BPT price or rate read with no Vault check |
| Balancer V3 reads match the deployed Vault | Deployed Vault source reviewed; any isUnlocked() check documented | V2 assumptions applied to V3 or forks |
| Curve reads match the pool generation | Pool source shows which views carry the lock | Unguarded view such as get_p, or old pool unchecked |
| Lock check cannot change state | Staticcall or audited non-mutating probe | Workaround that can move fees or funds |
| Your own code follows checks-effects-interactions | Transfers placed after all state writes | Tokens or ETH sent before supply updates |
| Fork tests read prices mid-withdrawal | Test calling your oracle from a callback | Only end-of-transaction prices tested |
| Invariant suite covers callback states | Handlers that re-enter during joins and exits | Fuzzing only at transaction boundaries |
Frequently Asked Questions
Is read-only reentrancy the same as oracle manipulation?
No. Classic manipulation moves a price with a swap, often flash-loan funded. Read-only reentrancy needs no price move: the read lands inside a half-finished withdrawal or join. Our guide to oracle manipulation attacks covers the swap-driven kind. ChainSecurity
Should the pool or the integrator fix read-only reentrancy?
Both. The root cause is a dependency calling out before its state is final, which checks-effects-interactions prevents. Deployed pools rarely change, so Balancer's advisory also set out mitigations for integrators. Solidity Foundation, Balancer
Why does Balancer's lock probe use only 10,000 gas?
The probe is expected to revert in both cases. Balancer's library caps the call so a revert caused by the guard's storage write does not waste gas. Review the library source before changing the cap. Balancer Labs GitHub
Related reading
- reentrancy attacks explained
- external call attacks
- price oracle manipulation
- lending protocol audit checklist
- erc4626 security checklist
Sources
- Solidity Foundation: Solidity Security Considerations
- ChainSecurity: Curve LP Oracle Manipulation: Post Mortem (October 11, 2022)
- Balancer: Reentrancy Vulnerability Scope Expanded
- Balancer Labs GitHub: VaultReentrancyLib.sol
- Balancer Labs GitHub: Balancer V3 Vault.sol
- Sentiment: Sentiment Exploit: Recovery Plan
- Aave Governance: jEUR incident. Plan of action
- Numen Cyber: Jarvis Network Flash Loan and Re-Entrancy Attack Analysis
- CertiK: Curve Conundrum: The dForce Attack via a Read-Only Reentrancy Vector Exploit (January 8, 2024)
- Guardian: Gamma Limit Order Manager L-14: Exposure To Read-Only Reentrancy
- Guardian: Gamma Position Managers L-17: CEI Pattern Not Followed
- Guardian: Abracadabra PurrSwap ve33 L-12
- Guardian: Abracadabra PurrSwap Balancer Aggregator C-06
- Guardian: GMX GLV M-08: GLV Read-only Reentrancy Risk
- Guardian: Abracadabra MIMSwap L-10
- Guardian: PariFi ORDM-23
- Guardian: Poolshark Limit BCALL-2
- Guardian: GMX Synthetics V2 ORDU-1
- Guardian: Poolshark Cover CP-2
- Jarvis Network: Removing, and looking past the bad debt
- rekt.news: Midas Capital - REKT
- dForce: dForce incident recovery thread
- Crypto.news: DForce confirms the return of exploited $3.65m to their vaults
- Sentiment: Incident Postmortem: Apr-04-2023
- Curve Finance GitHub: CurveStableSwapNG.vy
- Balancer Labs GitHub: Balancer V3 IVaultExtension.sol
- Balancer Docs: Valuing BPT
- Balancer Docs: Balancer Pool Tokens (BPT)
- Balancer Docs: BPT Oracle contracts
- Balancer Docs: Price Oracles for BPT as Collateral
- Balancer Docs: Transient accounting
Test your LP pricing against callback states
Guardian's smart contract audit puts two independent teams on one scope, with an invariant suite fuzzing accounting, solvency and edge states, then reviews your fixes. Bring the oracle and vault code that reads Curve or Balancer.