DeFi AMM Accounting Bugs & Virtual Balance Cache Exploitation

{{#include ../../banners/hacktricks-training.md}}

Overview

Yearn Finance's yETH pool (Nov 2025) exposed how gas-saving caches inside complex AMMs can be weaponized when they are not reconciled during boundary-state transitions. 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 a packed storage array packed_vbs[]. When all LP tokens are burned, totalSupply correctly drops to zero but the cached packed_vbs[i] slots retained huge historic values. The subsequent depositor was treated as the "first" liquidity provider even though the cache still held phantom liquidity, letting an attacker mint ~235 septillion yETH for only 16 wei before draining β‰ˆUSD 9M in LSD collateral.

Key ingredients:

  • Derived-state caching: expensive oracle lookups are avoided by persisting virtual balances and incrementally updating them.
  • 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() calls _calc_vb_prod_sum() and simply reads packed_vbs[] when prev_supply == 0, assuming the cache is also zeroed.
  • Flash-loan financed state poisoning: deposit/withdraw loops amplified rounding residues with no capital lockup, enabling a catastrophic over-mint in the "first deposit" path.

Cache design & missing boundary handling

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 ≳10 deposit/withdraw cycles those residues accumulated into extremely large phantom virtual balances while the on-chain token balances were almost empty. Burning the final LP shares set totalSupply to zero yet caches stayed populated, priming the protocol for a malformed initialization.

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. Poison packed_vbs[] – Loop deposits and withdrawals across eight LSD assets. Each partial withdrawal truncates packed_vbs[i] βˆ’ vb_share, leaving >0 residues per token. Repeating the loop inflates phantom ETH-equivalent balances without raising suspicion because real balances roughly net out.
  3. Force supply == 0 – Burn every remaining LP token so the pool believes it is empty. Implementation oversight leaves the poisoned packed_vbs[] untouched.
  4. Dust-size "first deposit" – Send a total of 16 wei divided across the supported LSD slots. add_liquidity() sees prev_supply == 0, runs _calc_vb_prod_sum(), and reads the stale cache instead of recomputing from actual balances. The mint calculation therefore acts as if trillions of USD entered, emitting ~2.35Γ—10^26 yETH.
  5. Drain & repay – Redeem the inflated LP position for all vaulted LSDs, swap yETHβ†’WETH on Balancer, convert to ETH via Uniswap v3, repay flash loans/fees, and launder the profit (e.g., through Tornado Cash). Net profit β‰ˆUSD 9M while only 16 wei of own funds ever touched the pool.

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.
  • Boundary conditions reuse caches instead of ground-truth recomputation, especially when totalSupply == 0, totalLiquidity == 0, or pool composition resets.
  • 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.
  • 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.

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.
  • 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.

References

{{#include ../../banners/hacktricks-training.md}}