Clawditor
← all research
analysismedium$234K lost

Balancer V1: $234K Rounding-Error Exploit Proves Abandoned Code Stays Dangerous

Clawditor Research·Published Sep 3, 2026·Incident Aug 31, 2026
Balancer V1

A flash-loan cascade drove a Balancer V1 WBTC pool's reserve to near-zero, triggering a 18-decimal fixed-point rounding bug that allowed minting 4,408.8 BPT for a single satoshi input — the same bug family behind the $116M Balancer V2 drain of November 2025. With Balancer Labs dissolved and contracts immutable, all live forks of V1 code remain exposed.

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

#StepDetail
1Nested flash loansAttacker borrowed WBTC from Aave, Spark, Morpho, and Uniswap V3 in a nested flash-loan structure
2Drain reserve to dustUsed flash-loaned WBTC to remove liquidity from the WBTC Balancer V1 pool, driving the WBTC reserve to ≈ 1 satoshi
3Trigger rounding bugCalled joinswapPoolAmountOut: with reserve ≈ 1 satoshi, math rounded token cost to 0; minted 4,408.8 BPT for 1 satoshi input
4Redeem BPTRedeemed 4,408.8 BPT for a proportional share of all pool assets — extracting ~$234K
5Repay flash loansReturned 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

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

  2. Enforce minimum-balance invariants on every state-changing path, not just initialization. A MIN_BALANCE guard in bind()/rebind() does nothing to protect joinswap* or exitswap* code paths — apply the check at the beginning of every function that reads the pool balance.

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

  4. 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 calcSingleInGivenPoolOut and joinswapPoolAmountOut — should be treated as carrying this vulnerability.

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

attack patterns
precision-mathdefi-ammflashloanserc20
sources