Beanstalk Basin Wells getReserves() Read-Only Reentrancy Near Miss (2023-05-31)
- Name: Beanstalk Basin Wells
getReserves()read-only reentrancy near miss - Date: 2023-05-31
- Protocol: Basin / Beanstalk
- Chain: Ethereum
- Category: read-only reentrancy near miss / transient reserve desynchronization / callback-window oracle hazard / AMM integration risk
- Loss: no reported onchain loss; the issue was fixed before any public exploit was preserved
- Summary: Cyfrin found that Basin’s generic
WellAMM implementation exposedgetReserves()as a readablevieweven whileremoveLiquidity()and related state-changing flows were still mid-execution undernonReentrant. In the vulnerable sequencing, pumps could update before outbound token transfers, but the Well’s stored reserves were only committed after those external transfers completed. If a Well used a callback-capable asset such as an ERC-777-style token, a re-entrant callback could callgetReserves()during that window and observe stale reserve state. Basin’s own write paths were still protected from ordinary state-mutating reentry, but downstream pumps, price functions, or integrators that trustedgetReserves()inside the callback frame could turn that transient mismatch into false oracle or solvency truth. The preserved healthy-side lesson is unusually clean: Beanstalk fixed the issue not by asking integrators to remember folklore, but by makinggetReserves()itself fail closed under active lock state viarequire(!_reentrancyGuardEntered(), "ReentrancyGuard: reentrant call"). - Trigger / entry point:
- The vulnerable surface was Basin’s shared
Well.solimplementation, especially liquidity-removal flows that performed external token transfers before final reserve commitment. removeLiquidity()was the audit’s main example, though the broader issue was thatgetReserves()stayed callable while anonReentrantWell function was still in flight.- Callback-capable assets, especially ERC-777-like tokens or malicious tokens with transfer hooks, could regain control during the outbound transfer.
- During that callback, attacker-controlled code or an integrating protocol could call
IWell(well).getReserves()and receive the pre-settlement reserve snapshot.
- The vulnerable surface was Basin’s shared
- Root cause:
- Basin treated
viewaccessibility as harmless even thoughgetReserves()represented security-critical accounting for pumps and downstream integrators. - The relevant Well flows did not fully follow Checks-Effects-Interactions ordering for reserve publication: reserves were not committed before the external transfer completed.
nonReentrantblocked direct state-mutating reentry into the same write functions, but it did not stop third parties from monetizing a staleviewanswer during the same callback frame.- That left a gap between
write paths are lockedandread paths are safe, which is exactly the read-only-reentrancy seam later exploited repeatedly across Curve, Balancer, and SyncSwap integrations.
- Basin treated
- Exploit path:
- The attacker deploys or targets a Basin Well that includes a callback-capable token surface, such as an ERC-777-like token.
- They build a position in the Well and enter a callback-capable path such as
removeLiquidity(). - The Well updates pump-related state and begins transferring tokens out, but has not yet committed the new reserve values to storage.
- The token transfer hands execution back to attacker-controlled callback code.
- Inside that callback window, the attacker or a downstream consumer calls
getReserves()on the Well. getReserves()returns a stale reserve vector that looks like ordinary finalized state even though the Well is mid-transition.- Any pump, oracle consumer, or integrating protocol that trusts that read can convert the transient reserve mismatch into a false price, false LP value, or other incorrect solvency signal.
- After control returns, the Well finishes and stored reserves catch up, but any downstream protocol that already acted on the stale read may have released real value against a state that only existed inside the callback frame.
- What the attacker needed to be true:
- The relevant Well needed a callback-capable token or other external control-transfer edge during settlement.
- A downstream consumer needed to trust
getReserves()during the same transaction instead of requiring a settled post-callback state. - The transient reserve mismatch needed to feed a monetizable path such as an oracle update, collateral valuation, routing decision, or liquidation check.
- The attacker needed enough capital or downstream liquidity for the false read to matter economically.
- Technical anatomy worth remembering:
- This was not a classical
reenter and mutate the same storage twicebug. The sharper lesson is that aviewlikegetReserves()can itself be the dangerous publication surface when other protocols will spend against it immediately. - The Basin case is valuable because it preserves the upstream-protocol side of the read-only-reentrancy family. Many incident notes in this corpus show downstream lenders and vaults trusting unsafe reads from Curve or Balancer; Basin is the clean example of an AMM team recognizing that the upstream protocol should make those reads fail while the lock is active.
- Cyfrin explicitly noted that the immediate Well implementation was not necessarily insolvent on its own because mutating reentry was blocked, but custom pumps and external integrations remained exposed. That distinction matters: the loss surface can sit one protocol hop away from the accounting bug.
- The mitigation was correspondingly narrow and instructive. Beanstalk did not need a full redesign to close this seam; it simply made the sensitive
viewrevert under active reentrancy, turning a misleading read surface into a fail-closed one. - That makes this incident a strong positive-design exemplar, not just a near miss:
unsafe publication is itself the bug, and a protocol-authored read surface can often fix it locally before downstream lenders, routers, or keepers are forced to improvise their own lock-probing rules.
- This was not a classical
- Representative contracts and artifacts:
- Basin
Well.solshared implementation:src/Well.sol - Cyfrin audit issue:
Read-only reentrancyin the 2023-06-16 Beanstalk Wells report - Fix pull request: Basin PR
#85(Read Only Reentrancy Protection) - Fix commit:
fcbf04a99b00807891fb2a9791ba18ed425479ab(fail on getReserves reentry)
- Basin
- Response / mitigation:
- Cyfrin documented the issue in its June 2023 Beanstalk Wells report and rated it High risk.
- Beanstalk had already merged a mitigation on 2023-05-31 through Basin PR
#85. - The fix added
require(!_reentrancyGuardEntered(), "ReentrancyGuard: reentrant call")togetReserves(), preventing reserve reads while the Well is already inside a guarded state-changing function. - Cyfrin acknowledged the mitigation in the published report.
- Key transaction hashes:
- No public exploit transaction hashes are known because this issue was preserved as an audit-disclosed near miss rather than a realized drain.
- The key preservation artifacts are the Cyfrin report, Basin PR
#85, and commitfcbf04a99b00807891fb2a9791ba18ed425479ab.
Pattern tags
unsafe reserve publication during guarded settlementview read became the integration hazard, not a write-path reentryupstream AMM patched the read surface instead of pushing folklore onto integratorscallback-capable asset support widened the reserve-truth boundary
Three safe-read authority promotions this incident collapsed
- Transient reserve artifact → canonical reserve truth: a stale reserve vector existed only because the Well was mid-settlement, but
getReserves()still published that callback-window artifact as if it were ordinary finalized pool state. - Canonical reserve truth → downstream pricing / solvency truth: once a pump, oracle, router, or lender trusted that answer, the stale vector stopped being a local implementation artifact and started counting as canonical reserve truth for price, LP-value, routing, or liquidation decisions.
- Downstream pricing / solvency truth → live value-moving authority: the accepted reserve story could then inherit real economic authority, letting downstream systems mint, liquidate, lend, or release assets against a state that only existed inside one callback frame.
Why the exploit worked
- Basin’s write-side reentrancy guard only protected part of the real security object. The Well still published reserve truth through
getReserves()while a guarded settlement path could be halfway through changing what those reserves meant economically. - Because reserve commitment happened after outbound token transfers, a callback-capable asset could reopen control flow at exactly the moment when the stored reserve vector was stale but still externally readable. That made
viewpublication, not double-write mutation, the exploitable boundary. - Any downstream pump, oracle, router, or lender willing to trust that stale answer could have spent real value against a reserve state that disappeared once the original Well call finished. The near miss is valuable precisely because it shows how an AMM can endanger neighbors even when its own state-mutating reentry is blocked.
- The fix proved the clean design answer: if reserve truth is unsafe mid-frame, the protocol should stop answering the reserve query during that frame instead of expecting every integrator to remember the timing caveat.
Detection and audit pivots
- Treat
getReserves(),totalAssets(),exchangeRate(),get_virtual_price(), and similarviewhelpers as security-critical publication surfaces whenever downstream protocols will spend, liquidate, or mint against them in the same transaction. - Ask whether any guarded state-changing path can transfer control outward before the contract commits the reserve/accounting values later returned by its views. If yes, assume the view can publish transient falsehood unless it explicitly fails closed under the active lock.
- Audit callback-capable token support as part of the read-safety boundary, not just the write-safety boundary. ERC-777-style hooks, bespoke transfer callbacks, native-asset sends, and adapter hooks all create places where third parties may query state mid-transition.
- Prefer upstream machine-checkable defenses over integrator folklore. A single
require(!_reentrancyGuardEntered())or public lock/context probe on the reserve view is often more robust than asking every downstream lender, router, or oracle to remember when not to call it. - Review every pump, oracle, routing, and liquidation integration that consumes the reserve view and ask the blunt question: if the protocol is already inside a guarded write path, should this read answer at all?
Why this incident matters
- It preserves one of the cleanest examples of
view surface must fail closed under callbackas an upstream AMM design rule rather than a downstream integrator patch. - It helps connect later losses back to a simple architectural question: if reserve truth is unsafe mid-callback, why is the protocol still willing to answer it?
- It also shows that reentrancy hardening is not complete when only write paths are locked. If downstream protocols monetize
viewanswers, those reads are part of the security boundary too. - Just as importantly, it preserves the healthier implementation move in compact form: publish one machine-checkable safe-read boundary in code, so integrators do not have to guess whether the current reserve vector is quiescent or only temporarily true inside the same stack frame.
Failure-prevention checklist
- Treat reserve-publishing
viewfunctions as part of the settlement surface whenever downstream pumps, routers, lenders, or liquidators can spend against them immediately.nonReentranton writes is not enough ifgetReserves()still exports half-finished truth. - Commit reserve/accounting state before any outbound transfer when the design allows it. If external delivery must happen before final commitment, make the affected read surfaces fail closed until settlement is finished.
- Expose one canonical machine-checkable safe-read boundary in code: a reverting
getReserves()under active lock, or an explicit context probe every integration can call before trusting live reserve state. - Keep callback-capable token admission tied to reserve-publication review. ERC-777-style hooks, bespoke transfer callbacks, and any adapter-mediated external send expand the read-only-reentrancy boundary even when write-side reentry is blocked.
- Document which views are safe only in quiescent state and make unsafe ones hard to misuse. A buried audit note or folklore warning is weaker than a reverting interface.
- Test pumps, oracle consumers, and integrators against callback-window reserve reads, not just direct state-mutating reentry. The question is whether any downstream system can still monetize the stale read.
- Prefer delayed settled snapshots when no trustworthy same-transaction reserve surface can exist. Some integrations should consume only post-settlement state rather than raw live reserves.
Audit prompts
- Which
viewfunctions export balances, reserves, supply, or ratios that downstream code may monetize inside the same transaction? - Can any guarded state-changing path transfer control outward before the values returned by those views are fully committed to storage?
- If a callback fires during
removeLiquidity(),removeLiquidityOneToken(), or a similar exit path, what exact reserve story doesgetReserves()publish before settlement completes? - Does the protocol expose one canonical lock/context probe or reverting safe-read boundary, or are integrators expected to remember unsafe timing rules from docs and audits?
- Which admitted token standards or adapter paths can regain control during settlement, and have they been reviewed as reserve-publication hazards rather than only write-path reentrancy hazards?
- Can any downstream pump, oracle, router, or lender turn a stale reserve vector into immediate spendable value before the original frame unwinds?
- If a consumer must read reserves during live execution, what proves the answer is settled economic truth rather than a transient implementation artifact?
Lessons that should already be standard
Viewis not a safety property. If another protocol can borrow, liquidate, or mint against the answer right now, the read surface is already part of the exploit boundary.- Reentrancy defense is incomplete when it protects writes but still publishes stale accounting during callback windows.
- Upstream protocols should not outsource read safety to downstream folklore. If a reserve answer is unsafe mid-frame, the protocol should either revert or expose an explicit safe-read primitive.
- Callback-capable asset support changes more than transfer semantics; it changes when reserve truth can safely be published.
- A small fail-closed interface change can be more valuable than a large redesign. Basin’s preserved lesson is that sometimes the healthiest fix is simply refusing to answer a dangerous question during an unsafe frame.
Internal linkages
- Core recurring pattern: read-only-reentrancy.
- Positive-design companion: safe-read oracle interfaces.
- Adjacent parent pattern: reentrancy.
- Early downstream negative exemplar: rari-capital-ethereum-pool-ibeth-totaleth-work-read-only-reentrancy-drain-2021-05-08 — useful when the question is how a wrapper exchange-rate read, not just an LP reserve read, can become false solvency truth inside another protocol’s callback frame.
- Useful downstream comparison: mai-finance-steth-curve-vaults-get-virtual-price-read-only-reentrancy-near-miss-2022-10-18 — a later whitehat near miss where an integrating protocol would have treated callback-window LP overvaluation as immediate collateral truth.
- Useful downstream comparison: eralend-syncswap-eth-usdc-lp-oracle-read-only-reentrancy-2023-07-25 — another AMM-side reserve / supply sequencing problem that became a live lending exploit once an external market trusted the callback-window read.
Control surface
-
The decisive leverage sat in
Well::removeLiquidity()-style external settlement sequencing,getReserves()remaining callable during that in-flight frame, and downstream protocols’ willingness to spend against reserve data that was only temporarily true inside the callback window. -
The bug was not
reentrancy guard missing. The bug was that the protocol still published a trusted reserve answer while the guard said the system was mid-transition. -
Sources:
- https://raw.githubusercontent.com/solodit/solodit_content/main/reports/Cyfrin/2023-06-16-Beanstalk%20wells.md
- https://github.com/BeanstalkFarms/Basin/pull/85
- https://github.com/BeanstalkFarms/Basin/commit/fcbf04a99b00807891fb2a9791ba18ed425479ab
- https://patch-diff.githubusercontent.com/raw/BeanstalkFarms/Basin/pull/85.patch
-
Last reviewed: 2026-08-04 UTC