Root Cause
Balancer V1's fixed-point arithmetic library uses 18-decimal integer math throughout. Two functions — joinswapPoolAmountOut and calcSingleInGivenPoolOut — compute the minimum token input required for a desired BPT (Balancer Pool Token) output. When the pool's token reserve is driven to an extremely small number (near zero), the division produces a quotient that rounds down to zero, allowing the caller to mint non-trivial BPT for effectively free. Three safeguards that would have prevented this were absent: a minimum effective input check, a minimum pool balance check, and a relative-error validation on the output/input ratio.
Simplified pseudo-code of the vulnerable path:
// calcSingleInGivenPoolOut — Balancer V1
// When tokenBalanceIn approaches 0, the power term rounds to 1 and
// tokenAmountIn rounds down to 0 (caller mints BPT for free)
function calcSingleInGivenPoolOut(
uint tokenBalanceIn, // attacker drove this to ~1 satoshi
uint tokenWeightIn,
uint poolSupply,
uint totalWeight,
uint poolAmountOut,
uint swapFee
) public pure returns (uint tokenAmountIn) {
// fixed-point exponentiation: (1 + poolAmountOut/poolSupply)^(totalWeight/tokenWeightIn)
// when tokenBalanceIn ≈ 1, ratio ≈ 1, exponent rounds to 1
// result: tokenAmountIn = tokenBalanceIn * (ratio - 1) ≈ 0
return tokenAmountIn; // 0 returned — BPT minted at zero cost
}
// MIN_BALANCE was only enforced during bind()/rebind(), not here
SlowMist's post-mortem confirmed the absence of: (1) minimum effective input, (2) minimum pool balance during swaps, and (3) relative-error validation. The pool's MIN_BALANCE constant existed but was only enforced in the bind() and rebind() initialization paths, not during ordinary swaps or single-asset joins.
Attack Steps
| # | Step | Detail |
|---|---|---|
| 1 | Nested flash loans | Attacker borrowed WBTC from Aave, Spark, Morpho, and Uniswap V3 in a nested flash-loan structure |
| 2 | Drain reserve to dust | Used flash-loaned WBTC to remove liquidity from the WBTC Balancer V1 pool, driving the WBTC reserve to ≈ 1 satoshi |
| 3 | Trigger rounding bug | Called joinswapPoolAmountOut: with reserve ≈ 1 satoshi, math rounded token cost to 0; minted 4,408.8 BPT for 1 satoshi input |
| 4 | Redeem BPT | Redeemed 4,408.8 BPT for a proportional share of all pool assets — extracting ~$234K |
| 5 | Repay flash loans | Returned borrowed WBTC to all four lending platforms; retained net profit |
Impact
- Amount lost: ~$234K from a WBTC Balancer V1 pool on Ethereum
- Chain: Ethereum
- Protocol: Balancer V1 (legacy; Balancer Labs dissolved March 2026 following the $116M V2 drain in November 2025)
- Code status: Immutable — no patch possible; contracts are non-upgradeable
- Downstream risk: All live forks of Balancer V1 code share the same vulnerable functions; SlowMist explicitly warned that unpatched forks remain exposed
Lessons for Auditors
-
Test AMM math at degenerate reserve values. Fixed-point arithmetic that is correct under normal liquidity conditions can produce zero-cost mints when reserves approach dust. Fuzz with reserve values of
1,MIN_BALANCE, and extreme ranges. -
Enforce minimum-balance invariants on every state-changing path, not just initialization. A
MIN_BALANCEguard inbind()/rebind()does nothing to protectjoinswap*orexitswap*code paths — apply the check at the beginning of every function that reads the pool balance. -
Add relative-error validation on all join/exit math. Verify that the effective cost per BPT stays above a floor (e.g.,
require(tokenAmountIn * 1e18 / poolAmountOut >= MIN_COST_PER_BPT)); revert if the ratio collapses. -
Flag all V1 Balancer forks in audits. Any protocol that forked or copied Balancer V1 pool contracts before the $116M fix — or that has not independently patched
calcSingleInGivenPoolOutandjoinswapPoolAmountOut— should be treated as carrying this vulnerability. -
Treat immutable, unmaintained contracts as permanent attack surface. When a protocol's team dissolves, active LPs should be warned and liquidity migrated. Do not assume legacy contracts are 'battle-tested' when the team that would patch them no longer exists.