Introduction

Precision attacks are hard to discover but devastating when found. On November 3, 2025, a $125 million exploit hit Balancer V2’s Composable Stable Pools. The root cause wasn’t a flashy reentrancy or a misconfigured access control. It was a single rounding function, operating exactly as written, that bled the protocol dry one wei at a time.

This post dissects the mathematics of the attack. We’ll trace how the attacker used Balancer’s internal balance system to drain a pool to near-zero liquidity, then weaponized arithmetic precision loss to collapse the pool’s invariant, and finally bought back billions of pool tokens for essentially nothing.

Important References:

Brief overview of Balancer V2

Balancer describes itself as a non-custodial portfolio manager and liquidity provider. While standard AMMs like Uniswap V2 rely on a 50/50 value split, Balancer allows for arbitrary weights (e.g., 80/20 pools) and multi-asset baskets.

The specific victim in this attack was the Composable Stable Pool. These pools are designed for assets that trade near parity, such as stablecoins or Liquid Staking Tokens. They use a specific invariant called Stable Math, derived from Curve’s logic, to facilitate low-slippage swaps.

Crucially, every pool has a Balancer Pool Token (BPT), representing a user’s share of the liquidity. The price of this BPT is derived from the pool’s total value, represented mathematically by the invariant DD.

Math behind Balancer

To understand the exploit, we have to look at how Balancer handles token amounts mathematically versus how it handles them in Solidity storage.

The Invariant (D)

For stable pools, Balancer uses an invariant DD which represents the total virtual supply of the pool. Determining DD is done iteratively using the Stable Math equation:

Annxi+D=ADnn+Dn+1nnxiA \cdot n^n \cdot \sum{x_i} +D = A \cdot D \cdot n^n + { \frac{D^{n+1}}{{n}^{n}\cdot \prod{x_i} } }

Where:

  • nn is the number of tokens
  • xix_i is the balance of token ii
  • AA is the amplification parameter

BPT Valuation Models

The valuation of a Balancer Pool Token (BPT) plays a central role in on-chain behavior. For Composable Stable Pools, the on-chain price is typically derived via pool.getRate(). This function returns the exchange rate of a BPT to the underlying base asset. It is fundamentally tied to the Invariant DD.

PBPTDTotalSupplyP_{BPT} \approx \frac{D}{TotalSupply}

This reliance on DD for on-chain pricing is exactly why manipulating the invariant causes the BPT price to collapse. Even if the balances (used in the informational formula) technically remain in the pool until the final withdrawal, the protocol believes the value is gone because DD has collapsed.

Scaling Factors

Tokens have different decimals (e.g., USDC has 6, WETH has 18). To perform math on them, Balancer normalizes everything to 18 decimals.

  • Upscaling: Converting native amounts to 18 decimals (Multiplication).
  • Downscaling: Converting 18 decimals back to native amounts (Division).

This is where the vulnerability lies.

The Core Vulnerability

The root cause is a violation of the Error in Favor of the Protocol principle. In DeFi, whenever there is rounding:

  1. If the user gives assets, you round down (count less).
  2. If the user takes assets, you round up (charge more).

This ensures the protocol never bleeds dust.

The Rounding Mismatch

In Balancer’s upscale function (used when reading input amounts), the protocol used unidirectional rounding (rounding down).

When a user requests a swap of type GIVEN_OUT (I want to buy exactly XX tokens), the protocol calculates how many input tokens (YY) are required.

Amountin=calcInGivenOut(Amountout)\begin{aligned} Amount_{in} = \text{calcInGivenOut}(Amount_{out}) \end{aligned}

Inside the calculation, AmountoutAmount_{out} is upscaled. Because upscale uses mulDown (multiply and round down), the protocol essentially forgets the tiny fractional value at the end of the amount.

This causes the calculated AmountinAmount_{in} to be slightly underestimated. The attacker pays less than they mathematically should to extract the desired output.

Deriving the TrickAmt

The attacker needs to calculate the exact swap amount that maximizes precision loss. Let’s derive this from first principles.

The loss from a single upscale operation can be expressed as:

LossxsfyLoss \approx x \cdot sf - y'

Where xx is the token amount, sfsf is the scaling factor, and yy' is the integer result after rounding down.

Expanding the true value:

xsf=x(1+Premium)=x+(xPremium)x \cdot sf = x \cdot (1 + Premium) = x + (x \cdot Premium)

The protocol floors this result. To maximize the loss, we want the accumulated premium to be as close to 1 as possible without reaching it (so it gets completely truncated):

(xPremium)1(x \cdot Premium) \approx 1

Solving for xx:

x=1Premium=1sf1x = \frac{1}{Premium} = \frac{1}{sf - 1}

In Solidity, this becomes:

TrickAmt=C(sf1018)C1018TrickAmt = \frac{C}{\frac{(sf - 10^{18}) \cdot C}{10^{18}}}

Where CC is a scaling constant (e.g., 10,000) used to maintain precision in integer arithmetic. The 101810^{18} terms normalize the scaling factor back to its decimal form. This simplifies to:

TrickAmt=1018sf1018TrickAmt = \frac{10^{18}}{sf - 10^{18}}

This formula gives the precise amount that causes maximum precision loss (approaching 1 wei) per swap. The attacker repeats this across multiple rounds to compound the effect.

The Attack Phases

The attacker crafted a specific swap path to exploit this precision loss repeatedly.

Off-chain Calculation Compute initBalance & TrickAmt (Maximize rounding impact) Phase 1: Pool Setup Borrow BPT, swap assets Set pool to computed balances Phase 2: Precision Loss Swap TrickAmt in low liquidity 1 wei rounding ⇒ massive impact Invariant D collapses Phase 3: Arbitrage Buy cheap BPT Repay borrowed BPT Phase 4: Withdraw Exit pool and extract value

Phase 1: Draining to Setup

The attacker first uses an internal flash mint mechanism within Balancer’s batchSwap. By specifying fromInternalBalance: true and toInternalBalance: true, the attacker can go negative on BPT during Phase 1, and only needs to settle at the end of the entire transaction.

This allows the attacker to extract almost all tokens from the pool:

=== PHASE 1 START ===
Target initBalance: 67000

Initial pool balances:
  osETH:  4,922,356,564,867,078,856,521  (~4,922 ETH)
  wETH:   6,851,581,236,039,298,760,900  (~6,851 ETH)

After 22 swap steps...

=== PHASE 1 COMPLETE ===
Final pool balances:
  osETH:  67,000 wei  (0.000000000000067 ETH)
  wETH:   67,000 wei  (0.000000000000067 ETH)

Total tokens extracted: ~11,773 ETH worth

The pool has been drained from ~11,773 ETH to just 134,000 wei total. This creates the low liquidity environment needed for Phase 2.

Phase 2: The Precision Attack

This is where the real damage happens. By draining the pool to 67,000 wei, the attacker ensures that tiny rounding errors constitute a massive percentage of the total pool value. The attacker then executes 30 rounds of Trick Swaps to bleed the invariant.

Each round consists of:

  1. Prime swap: Moves the pool to a precise, ultra-low balance (e.g., 18 wei) to hit a rounding boundary.
  2. Trick swap (trickAmt): Requests an output that, when upscaled, results in a fractional remainder (e.g., 17.98 wei) which the protocol floors to 17 via mulDown.
  3. Reverse swap: Resets the balances to the prime state for the next round.

The core observation: Each trick swap leaks nearly 1 wei of virtual value. While 1 wei is usually dust, losing 1 unit of value when the balance is only 18 wei represents a ~5.5% loss per swap. Because the invariant DD is derived from the product of balances, these percentage-based leaks compound exponentially.

Phase 2 Initial Invariant: 137,893

Round 0  → Invariant: 113,097 | Drop: 17.9%
Round 5  → Invariant:  40,892 | Drop: 70.3%
Round 10 → Invariant:  15,955 | Drop: 88.4%
Round 15 → Invariant:   7,033 | Drop: 94.9%
Round 20 → Invariant:   3,807 | Drop: 97.2%
Round 25 → Invariant:   2,649 | Drop: 98.1%
Round 29 → Invariant:   2,445 | Drop: 98.2%

The invariant collapsed from ~138,000 to ~2,445—a 98% drop. Although the physical tokens are still present, the protocol’s mathematical valuation of the pool has vanished, causing the BPT price to collapse by the same percentage.

Phase 3: Buying the Dip

Now the BPT is essentially worthless on-chain. The attacker buys massive amounts of BPT for almost nothing:

=== PHASE 3 START ===
Initial State:
  BPT Total Supply: 11,847,097,352,927,601,082,261
  Invariant: 2,445 (collapsed!)
  BPT Price: ~0 (collapsed!)

Step 0: Buy 10,000 BPT         → Cost: 598 wei
Step 1: Buy 10,000,000 BPT     → Cost: 656 wei
Step 2: Buy 10,000,000,000 BPT → Cost: 685 wei
...
Step 6: Buy 10^22 BPT          → Cost: 5,991 wei

=== ATTACK STEPS (FINAL EXTRACTION) ===
Attack Step 0: Buy 941,319,322,493,191,942,754 BPT → Cost: 1,217 wei
Attack Step 1: Buy 941,319,322,493,191,942,754 BPT → Cost: 1,437 wei

Total BPT Acquired: ~11.89 × 10^21 BPT
Total Cost: ~10,345 wei

The attacker just bought ~11.89 quintillion BPT for about 10,000 wei (~$0.00003).

The Net Profit Calculation

The attack uses an internal balance mechanism where:

  • Phase 1: Attacker goes negative ~11.85 × 10²¹ BPT (the flash mint)
  • Phase 3: Attacker gains positive ~11.89 × 10²¹ BPT (buying cheap)
  • Net: The small difference is pure profit
=== PRECISE NET BPT SIMULATION ===
Phase 1 BPT Spent (simulated): 11,847,097,352,927,600,948,040
Phase 3 BPT Received:          11,892,648,654,996,393,895,508
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NET BPT GAIN:                      45,551,302,068,792,947,468

Actual result after execution:     44,154,666,372,672,521,145

The simulation is ~97% accurate. The ~44e18 BPT gained represents ~45 ETH of pure profit at pre-attack prices.


Under the Hood

To verify these claims, I ran a complete simulation of the attack. Here is the cryptographic evidence showing exactly how the invariant collapsed during Phase 2.

Pool State Before and After

=== EXPLOIT EXECUTION START ===
Pool: 0xDACf5Fa19b1f720111609043ac67A9818262850c

BEFORE ATTACK:
  Actual Supply:     11,847,097,352,927,601,082,261
  Invariant D:       12,171,087,849,008,052,087,141
  BPT Price:         1.027347668920808516 ETH
  
AFTER ATTACK:
  Actual Supply:     11,893,097,110,063,641,448,681
  Invariant D:          240,115,638,684,764,457,005
  BPT Price:         0.020189496181073356 ETH
  
DAMAGE:
  Price Collapse:     98%
  Invariant Decrease: 11,930,972,210,323,287,630,136 (98%)

The Precision Loss in Action

During Phase 2, each swap pair demonstrates the rounding error. Here’s a single round showing the precision loss:

=== SWAP (Round 0, Step 1 - The Trick Swap) ===
Token Index In: 0 (osETH)
Token Index Out: 1 (wETH)

Input balances[0]: 374,353 wei
Input balances[1]: 18 wei

tokenAmountOut: 17 wei
amountOutScaled: 17 (after upscale with mulDown)
Scaling Factor:  1,058,132,398,695,929,516
Precision Loss Ratio: 1,000,000,000,000,000,000

Invariant BEFORE: 138,956
Invariant AFTER:  112,405
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Invariant Delta:   26,551 ← VALUE LEAKED!

Notice how the Precision Loss Ratio of 1e18 (no scaling compensation) combined with the tiny amounts causes an Invariant Delta of 26,551. This is value escaping the pool.

The Cumulative Invariant Collapse

The following table shows the invariant at the end of each round of Phase 2:

RoundInvariantDrop %Cumulative Loss
0113,09717%24,796
540,89270%97,001
1015,95588%121,938
157,03394%130,860
203,80797%134,086
252,64998%135,244
292,44598%135,448

Each row represents the state after that round’s 3 swaps complete. The invariant drops monotonically due to accumulated precision loss.

Cheap Token Costs

The collapsed invariant means BPT is essentially free:

StepBPT RequestedToken CostNew Supply
010,000598 wei11.847…e21
110,000,000656 wei11.847…e21
210^10685 wei11.847…e21
310^13710 wei11.847…e21
410^16745 wei11.847…e21
510^19753 wei11.857…e21
610^225,991 wei21.857…e21
Attack 09.41×10^201,217 wei22.798…e21
Attack 19.41×10^201,437 wei23.739…e21

Total cost to acquire ~11.89×10²¹ BPT: ~10,345 wei

Conclusion

This exploit is a harsh reminder that in Solidity, order of operations and rounding direction are non-negotiable. The inconsistency between mulDown (upscale) and divUp (downscale) created a window for precision loss.

The attack demonstrates several key lessons:

  1. Rounding must always favor the protocol in AMM calculations
  2. Low liquidity amplifies precision attacks exponentially
  3. Invariant-based pricing is vulnerable when the invariant can be manipulated

If there is one takeaway: Always round against the user. If they are specifying output, round the required input UP. If they are specifying input, round the resulting output down. Consistency is key.


Breaking the Balancer V2 Invariant

https://github.com/0xvrka/
Author

0xVrka

Publish Date

01 - 20 - 2026

License

Unlicensed

Avatar
0xVrka

Security Sheperd 𖤠

Categories
Tags