DeFi AMM Accounting Bugs & Virtual Balance Cache Exploitation

Overview

Yearn Finance's yETH pool (Nov 2025) showed that "virtual balance cache exploitation" is often a multi-bug chain, not just a single missing reset. The weighted stableswap pool tracks up to 32 liquid staking derivatives (LSDs), converts them to ETH-equivalent virtual balances (vb_i = balance_i * rate_i / PRECISION), and stores those values in packed_vbs[], while also maintaining solver state such as Sigma, Pi, and an internal equilibrium supply D. The attacker first pushed the solver into a numerically invalid regime so Pi collapsed to zero and LP was over-minted, then drained the pool until a production prev_supply == 0 state became reachable, and finally re-entered the bootstrap branch. At that point the pool trusted stale cached virtual balances and unchecked math, so a 16 wei dust deposit produced roughly 2.35e56 yETH and drove about $9M in losses across the yETH pool and yETH/WETH Curve liquidity.

Key ingredients:

  • Derived-state caching: expensive oracle lookups are avoided by persisting virtual balances and incrementally updating them.
  • Solver divergence under extreme imbalance: highly skewed deposits pushed the fixed-point iteration outside its safe region and let Pi collapse to 0.
  • Dual supply notions: internal invariant supply D and ERC-20 totalSupply could diverge during protocol-owned-liquidity reconciliation.
  • Missing reset when supply == 0: remove_liquidity() proportional decrements left non-zero residues in packed_vbs[] after each withdrawal cycle.
  • Initialization branch trusts the cache: add_liquidity() reads packed_vbs[] when prev_supply == 0, assuming the cache is also zeroed.
  • Bootstrap path remained reachable: a one-time initialization code path could be re-entered in live operation.
  • Unchecked arithmetic in invariant-critical code: once A * Sigma < D * Pi, unsafe_sub turned a bad state into an infinite mint instead of a revert.
  • Flash-loan financed state poisoning: the full chain could be executed without long-lived capital.

Cache design & where it fit in the chain

The vulnerable flow is simplified below:

function remove_liquidity(uint256 burnAmount) external {
    uint256 supplyBefore = totalSupply();
    _burn(msg.sender, burnAmount);

    for (uint256 i; i < tokens.length; ++i) {
        packed_vbs[i] -= packed_vbs[i] * burnAmount / supplyBefore; // truncates to floor
    }

    // BUG: packed_vbs not cleared when supply hits zero
}

function add_liquidity(Amounts calldata amountsIn) external {
    uint256 prevSupply = totalSupply();
    uint256 sumVb = prevSupply == 0 ? _calc_vb_prod_sum() : _calc_adjusted_vb(amountsIn);
    uint256 lpToMint = pricingInvariant(sumVb, prevSupply, amountsIn);
    _mint(msg.sender, lpToMint);
}

function _calc_vb_prod_sum() internal view returns (uint256 sum) {
    for (uint256 i; i < tokens.length; ++i) {
        sum += packed_vbs[i]; // assumes cache == 0 for a pristine pool
    }
}

Because remove_liquidity() only applied proportional decrements, every loop left fixed-point rounding dust. After repeated deposit/withdraw cycles, those residues accumulated into phantom virtual balances while the on-chain token balances were almost empty. Reaching totalSupply == 0 did not clear the cache, priming the protocol for a malformed re-initialization.

The subtle part is that the stale cache was not the initial source of profit. According to Yearn's disclosure, the attacker first abused a solver instability: extremely imbalanced deposits made the initial vb_prod tiny, the Newton iteration diverged, and the stored product term Pi was truncated to 0. A later remove_liquidity(0) recomputed Pi from balances, but the inflated internal supply D survived. Only after that mismatch was used to drain LSTs and reach a live prev_supply == 0 state did the stale packed_vbs[] + bootstrap underflow become reachable.

Exploit playbook (yETH case study)

  1. Flash-loan working capital – Borrow wstETH, rETH, cbETH, ETHx, WETH, etc. from Balancer/Aave to avoid tying up capital while manipulating the pool.
  2. Break the solver first – Feed extremely imbalanced add_liquidity() inputs so the weighted-stableswap solver enters a divergent regime. vb_prod becomes tiny, the Newton step truncates, Pi collapses to 0, and the attacker receives excess LP.
  3. Repair Pi, keep inflated D – Call remove_liquidity(0) to recompute Pi from balances, then trigger rate/supply reconciliation so the protocol burns staking/POL yETH instead of the attacker's oversized position.
  4. Drain real liquidity while leaving cache dust – Repeated withdrawals plus floor division drive the real LST balances down but leave non-zero packed_vbs[] residues behind.
  5. Reach a live zero-supply bootstrap state – The protocol's dual-supply design makes prev_supply == 0 reachable after the drain even though this path should have been "deployment only".
  6. Dust-size re-initialization – Send a total of 16 wei across the supported LSD slots. add_liquidity() sees prev_supply == 0, reads the stale cache, and then evaluates invariant math in a state where A * Sigma < D * Pi. Because the code uses unchecked subtraction, the bootstrap path underflows and mints roughly 2.35e56 yETH.
  7. Cash out & repay – Use the counterfeit yETH to drain the yETH/WETH Curve pool and remaining collateral paths, swap proceeds back to ETH/LSTs, repay flash loans/fees, and route the profit.

Generalized exploitation conditions

You can abuse similar AMMs when all of the following hold:

  • Cached derivatives of balances (virtual balances, TWAP snapshots, invariant helpers) persist between transactions for gas savings.
  • Partial updates truncate results (floor division, fixed-point rounding), letting an attacker accumulate stateful residues via symmetric deposit/withdraw cycles.
  • Iterative solvers can enter degenerate states (Pi == 0, virtual supply near zero, denominator collapse) without reverting, and later be only partially "repaired".
  • Internal accounting can diverge from real balances (e.g., D vs totalSupply, preminted BPT, POL-backed supply, cached rate vs vault balance).
  • Boundary conditions reuse caches or bootstrap code instead of ground-truth recomputation, especially when totalSupply == 0, totalLiquidity == 0, or pool composition resets.
  • Public cache refresh / reconciliation paths exist (update_rates, zero-amount remove/join flows, cache refresh helpers) and can be called after attacker-controlled poisoning.
  • Unsafe arithmetic or missing domain checks turn invalid states into wrapped values instead of reverts.
  • Minting logic lacks ratio sanity checks (e.g., absence of expected_value/actual_value bounds) so a dust deposit can mint essentially the entire historic supply.
  • Cheap capital is available (flash loans or internal credit) to run dozens of state-adjusting operations inside one transaction or tightly choreographed bundle.

Defensive engineering checklist

  • Explicit resets when supply/lpShares hit zero:
    if (totalSupply == 0) {
        for (uint256 i; i < tokens.length; ++i) packed_vbs[i] = 0;
    }
    
    Apply the same treatment to every cached accumulator derived from balances or oracle data.
  • Recompute on initialization branches – When prev_supply == 0, ignore caches entirely and rebuild virtual balances from actual token balances + live oracle rates.
  • Seal bootstrap logic forever – Treat initialization as one-shot. Re-entering prev_supply == 0 on a mature pool should require an explicit governance-controlled migration/shutdown mode, not ordinary user flow.
  • Assert solver domain and convergence – Revert if A * Sigma < D * Pi, if iterations fail to converge, or if Pi == 0 while non-zero balances still exist.
  • Prove zero-supply states are unreachable in production – If the design keeps separate notions of supply (D, ERC-20 shares, POL balances), formally test that an attacker cannot force prev_supply == 0 while economically meaningful state remains.
  • Minting sanity bounds – Revert if lpToMint > depositValue * MAX_INIT_RATIO or if a single transaction mints >X% of historic supply while total deposits are below a minimal threshold.
  • Rounding-residue drains – Aggregate per-token dust into a sink (treasury/burn) so repeated proportional adjustments do not drift caches away from real balances.
  • Differential tests – For every state transition (add/remove/swap), recompute the same invariant off-chain with high-precision math and assert equality within a tight epsilon even after full liquidity drains.

Minimal invariant fuzz targets

Expose a test harness that can read both the cached state and the from-scratch recomputation, then assert boundary properties directly:

function invariant_zero_supply_clears_derived_state() public {
    if (pool.totalSupply() == 0) {
        assertEq(h.cachedVirtualBalanceSum(), 0);
        assertEq(h.recomputedVirtualBalanceSum(), 0);
    }
    if (h.recomputedVirtualBalanceSum() > 0) assertGt(h.cachedPi(), 0);
}

If your design intentionally allows totalSupply == 0 during migrations or POL reconciliation, replace the second assertion with "bootstrap remains disabled unless governance explicitly opens migration mode".

Also fuzz single-wei deposits immediately after: (1) full withdrawals, (2) remove_liquidity(0)-style sync calls, (3) public rate-cache refreshes, and (4) any reconciliation path that can burn or mint protocol-owned liquidity.

Monitoring & response

  • Multi-transaction detection – Track sequences of near-symmetric deposit/withdraw events that leave the pool with low balances but high cached state, followed by supply == 0. Single-transaction anomaly detectors miss these poisoning campaigns.
  • Runtime simulations – Before executing add_liquidity(), recompute virtual balances from scratch and compare with cached sums; revert or pause if deltas exceed a basis-point threshold.
  • Alert on cache refresh after attacker-controlled state changes – Public functions that refresh rates, reconcile supply, or perform zero-amount syncs are high-signal when they appear between imbalance creation and dust deposits.
  • Flash-loan aware alerts – Flag transactions that combine large flash loans, exhaustive pool withdrawals, and a dust-sized final deposit; block or require manual approval.

Related: for swap-hook precision abuse that does not rely on stale persistent AMM state, see defi-amm-hook-precision.md.

References