Bunni V2 Liquidity Density Function Idle-Balance Rounding / Liquidity-Rebound Sandwich Drain (2025-09-02)

  • Name: Bunni V2 Liquidity Density Function idle-balance rounding / liquidity-rebound sandwich drain
  • Date: 2025-09-02
  • Protocol: Bunni V2
  • Chain: Ethereum + Unichain
  • Category: arithmetic-solvency failure / directional rounding bias / low-liquidity amplification / custom-AMM liquidity-estimation failure / sandwich drain
  • Loss: about $8.4M across two pools, with the official postmortem naming USDC/USDT on Ethereum and weETH/ETH on Unichain as the affected pools
  • Summary: Bunni V2’s custom Liquidity Density Function AMM split pool balances into active liquidity and idle balance, then estimated total liquidity from the smaller of a token0-side and token1-side liquidity estimate. In BunniHubLogic::withdraw(), the protocol reduced idle balance with balance.mulDiv(shares, currentTotalSupply), intentionally rounded down under the assumption that slightly underestimating active liquidity was pool-safe. The attacker first used flash-loaned swaps to push the pool into a dust-balance state where the active USDC side fell to 28 wei, then made 44 tiny withdrawals whose floor rounding cut that active balance to 4 wei and collapsed the chosen liquidity estimate from 5.83e16 to 9.114e15. A subsequent large swap pushed price far enough that Bunni stopped using the token0-side estimate and switched back to the token1-side estimate, partially restoring total liquidity to 1.065e16. That rebound let the attacker sandwich the temporary liquidity increase and exit with more assets than they started with.
  • Trigger / entry point:
    • The decisive accounting path sat in BunniHubLogic::withdraw(), specifically the idle-balance update balance - balance.mulDiv(shares, currentTotalSupply).
    • Bunni treated each pool as the combination of active swap liquidity plus idle balance not currently deployed into swaps.
    • QueryLDF estimated pool liquidity from both token sides and then used the smaller estimate, assuming the lower figure was the safer one.
    • The attacker targeted a state where token0 active balance was already near zero, so repeated tiny withdrawals could move the chosen estimate disproportionately.
  • Root cause:
    • The protocol reasoned locally about one rounding direction instead of globally about a multi-operation state machine.
    • Rounding the idle-balance decrease down did indeed understate active liquidity, which looked conservative in isolation.
    • But Bunni’s liquidity engine later recomputed total liquidity from the minimum of two token-side estimates, so the attacker could first force the token0-side estimate artificially low and then switch the protocol back to the token1-side estimate with a later swap.
    • In other words, underestimate liquidity now was not actually safe because the system exposed a later path that could partially reverse the underestimate and let the attacker trade through the rebound.
  • Exploit path:
    1. The attacker flash-borrowed about 3M USDT and repeatedly swapped USDT -> USDC into the Ethereum USDC/USDT Bunni pool.
    2. Those swaps pushed the pool tick to about 5000 (1 USDC = 1.68 USDT) and, more importantly, drove the pool’s active USDC balance down to only 28 wei.
    3. The attacker then executed 44 tiny withdrawals.
    4. Each withdrawal burned a small amount of LP shares, and withdraw() reduced idle balance using floor-rounded proportional math.
    5. Because total pool balance was conserved while idle balance was updated with the wrong economic rounding direction, the active USDC balance shrank from 28 wei to 4 wei, an 85.7% decrease out of proportion to the shares actually burned.
    6. That dust-state error pushed Bunni’s chosen total-liquidity estimate down from about 5.83e16 to 9.114e15.
    7. With liquidity now understated, the attacker made a large USDT -> USDC swap. The lower quoted liquidity let the price move much farther than it should have, up to tick 839189 (1 USDC = 2.77e36 USDT).
    8. At that extreme state, totalDensity0X96 collapsed to 1, making the token0-side liquidity estimate enormous rather than tiny. Bunni therefore stopped using the token0-side estimate and switched to the token1-side estimate instead.
    9. That estimator switch raised total liquidity from 9.114e15 to about 1.065e16 inside the attacker-controlled sequence.
    10. The attacker then swapped back USDC -> USDT against this rebound state, extracting value from a pool whose pricing and liquidity accounting had become path-dependent.
    11. After repaying the flash loan, the attacker kept roughly 1.33M USDC plus ~1M USDT of profit on the Ethereum leg; the same core mechanism was also used against the weETH/ETH pool on Unichain.
  • What the attacker needed to be true:
    • The attacker needed enough external flash-loan liquidity to push one side of the pool to a dust active balance before withdrawals began.
    • Tiny withdrawals had to remain cheap and repeatable enough that dozens of burns could walk the dust balance downward.
    • Bunni had to keep deriving total liquidity from min(token0-side estimate, token1-side estimate) so a later price move could swap which estimate was authoritative.
    • The pool needed a later trading path that still honored the rebound state economically instead of freezing or reconciling after pathological balance changes.
    • On Unichain, the exploit only worked on the affected pool sizes that could be pushed into the required edge state with available flash liquidity; Bunni’s own postmortem notes that the larger Unichain USDC/USD₮0 pool likely escaped only because available flash liquidity was insufficient.
  • Technical anatomy worth remembering:
    • This was not primarily a rebalancing or shifting exploit even though early public takes focused there. Bunni’s own postmortem says those features did not trigger during the exploit path.
    • The dangerous state variable was not total pool TVL in the abstract, but the split between active and idle balances plus the rule for selecting which side’s liquidity estimate counted as truth.
    • The exploit is a strong example of locally conservative rounding can still be globally unsafe. The protocol deliberately chose a direction that seemed to favor the pool, but the attacker found a later state transition that monetized the understatement.
    • The exploit also preserves a useful AMM-design lesson: if liquidity is derived from two candidate estimates and the protocol is free to switch between them as state changes, then a rounding bug on one side can become a state-toggle primitive rather than just a static pricing error.
    • The larger Unichain pool not being exploitable because flash-loan size was insufficient is a reminder that some AMM failures are bounded in practice by outside market depth, not by onchain permissioning.
  • Representative transactions and contracts:
    • Ethereum exploit transaction: 0x1c27c4d625429acfc0f97e466eda725fd09ebdc77550e529ba4cbdbc33beb97b
    • Unichain exploit transaction: 0x4776f31156501dd456664cd3c91662ac8acc78358b9d4fd79337211eb6a1d451
    • Vulnerable module: BunniHubLogic::withdraw() idle-balance update logic
    • Liquidity-estimation module: QueryLDF
    • Code location highlighted by Bunni’s postmortem: src/lib/BunniHubLogic.sol#L478 in commit ad8acfc30249da5a63450adf4a92c946c260e9de
  • Response / mitigation:
    • Bunni paused smart-contract functions across all networks immediately after the exploit.
    • The team later unpaused withdrawals only, while leaving deposits and swaps paused.
    • Bunni publicly offered the attacker 10% of the stolen funds for the return of the remainder, alerted centralized exchanges, and involved law enforcement.
    • The team’s proposed immediate code fix was changing the idle-balance update from mulDiv to mulDivUp, while also acknowledging that broader invariant and fuzz-test coverage was needed before the protocol could safely resume normal operations.
  • Key transaction hashes:
    • Ethereum exploit: 0x1c27c4d625429acfc0f97e466eda725fd09ebdc77550e529ba4cbdbc33beb97b
    • Unichain exploit: 0x4776f31156501dd456664cd3c91662ac8acc78358b9d4fd79337211eb6a1d451

Pattern tags

Why the exploit worked

  • Bunni collapsed three different steps into one ambient pool state changed story when they needed separate scrutiny: inventory still existed in the pool, Bunni had published one active-versus-idle split plus one authoritative liquidity estimate locally, and the swap path was still allowed to spend against that freshly published split immediately.
  • The floor-rounded idle-balance update in BunniHubLogic::withdraw() looked locally conservative because it understated active liquidity, but the protocol did not keep that understatement fenced off from later monetization. Repeated micro-withdrawals could therefore turn a tiny accounting bias into a deliberate active-balance rewrite.
  • QueryLDF then made the distorted token0-side estimate authoritative because Bunni chose the smaller of the two liquidity estimates. That meant the attacker did not just create a bookkeeping error; they changed which liquidity story the AMM was willing to quote as real executable depth.
  • A later large swap was still allowed to push the selector back onto the token1-side estimate after price moved far enough. The protocol therefore exposed an attacker-owned understate liquidity now, then partially restore it through a selector flip path.
  • Because swap pricing remained live through that branch transition, the rebound in quoted liquidity became a spendable state change rather than an internal inconsistency that got quarantined or reconciled first.

Detection and audit pivots

  • Re-run the exact reversible path, not just isolated operations: flash-loan setup -> dust active token0 balance -> repeated tiny withdrawals -> selector uses token0-side estimate -> large swap flips authority back toward token1-side estimate -> reverse swap. If profit only appears in the composed sequence, the review target is the whole state machine rather than one line of rounding math.
  • Track three objects separately in tests and monitoring: total inventory, published active/idle split, and the estimator currently authoritative for quoted liquidity. The exploit paid when the second and third objects drifted even though the first still looked conserved.
  • Fuzz repeated micro-withdrawals once one active-side balance is already in dust. Bunni’s preserved edge state was not large LP burn but 44 tiny burns walking 28 wei of active USDC down to 4 wei.
  • Instrument selector transitions explicitly. A sudden move from useLiquidityEstimate0 = true to false after a path that first crushed one side’s estimate should be treated as a solvency event, not as routine estimator behavior.
  • Ask whether any locally conservative bucket update can later be reversed into spendable liquidity, collateral, or reserve authority by another path that recomputes from a different branch or denominator.
  • Monitor for impossible lifecycle shapes in production: withdrawals making one side’s active balance collapse, a later swap increasing quoted total liquidity without new capital entering, or estimator disagreement widening exactly when a profitable reverse trade becomes possible.

Failure-prevention checklist

  • Treat active balance + idle balance + authoritative liquidity estimate as one solvency object. Withdraw logic should not be allowed to conserve gross inventory while silently changing which portion of that inventory counts as executable liquidity without the same invariant proof covering both pieces.
  • Prove whole-path monotonicity for withdraw -> swap -> reverse swap, not just local safety for withdraw() in isolation. If a rounding choice is only safe until a later estimator recomputation or selector flip occurs, it is not actually safe.
  • Make selector-switch behavior monotone. If the protocol derives liquidity from multiple candidate estimates, an attacker must not be able to push one estimate artificially low, trigger a later switch to a different estimate, and monetize the rebound as fresh tradable depth.
  • Fuzz and invariant-test dust active-balance states explicitly. Bunni’s exploit depended on walking token0 active balance from 28 wei to 4 wei through 44 tiny burns; those edge states belong in core coverage, not tail-case coverage.
  • Differential-test mulDiv versus mulDivUp style accounting branches against a high-precision model that tracks how share burns should affect idle balance, active balance, and total-liquidity estimates across repeated micro-withdrawals.
  • Bound or quarantine pools whose exploitability depends on extreme external setup cost. Not enough flash liquidity today is not a protocol defense; it is a temporary market condition that can change with one new lender or one deeper venue.
  • Alert on impossible lifecycle shapes such as withdrawals reduced one side's active balance sharply, no deposits repaired the pool, and the next swap increased total estimated liquidity. That combination should be treated as a solvency alarm, not as routine state evolution.
  • Require launch-blocking review for any AMM that splits balances into active and idle buckets while also deriving tradable liquidity from multiple estimators. That architecture is already a composed accounting system, not a simple pool.

Audit prompts

  • If a liquidity provider burns a tiny amount of shares repeatedly, what invariant proves the active balance cannot shrink far more than the redeemed share fraction would justify?
  • When idle balance updates round in the protocol’s favor locally, what later path could reverse that conservative error and turn it into a spendable liquidity increase?
  • Can an attacker depress one token-side liquidity estimate to near zero while keeping the other estimate healthy, then force the protocol to switch which estimate is authoritative during the same transaction?
  • What tests cover dust active balance + repeated tiny withdrawals + large follow-up swap + reverse swap, and do they prove no profit is created before fees?
  • Does the protocol distinguish gross inventory still exists from that inventory is still executable swap liquidity, or can internal bucket moves quietly rewrite swap power without a deposit or a legitimate liquidity activation event?
  • Are pathological pool states bounded only by current flash-loan depth on outside venues? If deeper external liquidity appeared tomorrow, would the same state machine become fully drainable?
  • What monitoring or runtime checks would catch total liquidity went down because of withdrawals, then up again because of estimator switching before the reverse leg cashes out?
  • If a postmortem fix changes one rounding direction and makes the exploit unprofitable, what proof shows the broader active/idle and selector-switch lifecycle is now monotone rather than only this exact recipe being patched?

Lessons that should already be standard

  • Conservative in one function is not a real defense when later state transitions can reclassify or partially reverse the same underestimate into tradable depth.
  • Active-versus-idle bucket splits are balance-sheet truth, not UI detail. If the bucket split drifts, executable liquidity truth drifts with it.
  • Use the smaller estimate is not automatically safe. A minimum-of-two selector becomes an attack surface if the attacker can first corrupt one estimate and later force the protocol back onto the other one.
  • Repeated dust-state burns deserve the same security attention as large swaps. Many arithmetic exploits are really tiny state edits that change the slope seen by the next large trade.
  • External flash liquidity often decides whether a bug is exploitable at current pool sizes, but it does not decide whether the accounting is sound.
  • Novel AMM wrappers inherit review obligations for the accounting semantics they add on top of familiar swap rails. The dangerous bug may live in the wrapper’s liquidity ledger rather than in the underlying swap primitive.
  • If a system can create an attacker-controlled liquidity rebound without any honest new capital entering, the protocol has already let accounting state masquerade as real market depth.

Why this incident matters

  • It preserves a modern example of a custom AMM where the bug was neither a simple invariant typo nor a generic oracle manipulation, but a multi-step interaction between rounding direction, dust-state liquidity, and estimator switching.
  • It is a strong cautionary note for teams that build novel AMM math above established settlement rails: the compositional bug may live in the wrapper’s liquidity-accounting semantics even when the underlying swap engine is familiar.
  • It is also the clean negative exemplar for active-liquidity and idle-balance ledger coherence discipline: total pool inventory stayed present, but the active-versus-idle split stopped being a trustworthy statement about executable liquidity.
  • It also sharpens the recurring lesson that safe in isolation is not a real security property for arithmetic choices when the protocol exposes enough state transitions to reverse or amplify the local bias.

Internal linkages

Control surface

  • The leverage sat in Bunni’s custom split between active and idle balances, the rounding rule inside withdraw(), and the rule that total liquidity was whichever token-side estimate looked smaller at that moment.
  • Put differently, the vulnerable security object was not total pool balance alone. It was total balance + active/idle bucket split + authoritative estimator, which is exactly the healthier boundary preserved in active-liquidity and idle-balance ledger coherence discipline.
  • The exploit did not need governance capture, external oracle corruption, or compromised keys. It only needed permissionless access to the protocol’s own accounting transitions plus enough external liquidity to force the vulnerable edge state.

Sources

Last reviewed

  • 2026-07-28 UTC