Clawditor
← all research
post-mortemhigh$234K lost

Balancer V1 Drained $234K via WBTC Reserve Compression and BPT Minting Rounding Bug

Clawditor Research·Published Sep 2, 2026·Incident Aug 31, 2026
Balancer

An attacker used nested flash loans to compress a Balancer V1 WBTC pool balance to near-zero, exploiting an 18-decimal fixed-point rounding error to mint 4,408.8 BPT for a single satoshi. The same bug family caused the $116M Balancer V2 exploit in November 2025, and all unmaintained V1 forks remain exposed.

Root Cause

Balancer V1's joinswapPoolAmountOut function allows a caller to specify the exact amount of Balancer Pool Tokens (BPT) they want to receive, delegating input calculation to calcSingleInGivenPoolOut. That subroutine performs 18-decimal fixed-point arithmetic (bmul/bdiv) on the pool's token balances.

WBTC uses only 8 decimals. When the WBTC reserve is driven near zero — to a single satoshi (10⁻⁸ WBTC) — the fixed-point division produces a value that rounds down to 1 satoshi regardless of the BPT amount requested. Three safeguards were absent in V1:

  1. No minimum effective deposit amount check (only guards against exactly zero).
  2. No minimum pool reserve balance floor.
  3. No relative-error validation comparing the computed input to the expected economic value.

This is the same bug family that caused the ~$116M Balancer V2 exploit in November 2025; V1 never received the patches applied to V2 because it is unmaintained legacy code.

// Balancer V1 BPool.sol — joinswapPoolAmountOut (vulnerable, simplified)
function joinswapPoolAmountOut(
    address tokenIn,          // WBTC
    uint poolAmountOut,       // attacker requests 4,408.8 BPT (4408.8e18)
    uint maxAmountIn
) external _lock_ returns (uint tokenAmountIn) {
    Record storage inRecord = _records[tokenIn];

    // calcSingleInGivenPoolOut uses 18-decimal bmul/bdiv.
    // When inRecord.balance (8-dec WBTC) ≈ 1 satoshi,
    // the division rounds DOWN → tokenAmountIn = 1 satoshi.
    tokenAmountIn = calcSingleInGivenPoolOut(
        inRecord.balance,   // ≈ 1 satoshi after compression
        inRecord.denorm,
        _totalSupply,
        _totalWeight,
        poolAmountOut,      // 4,408.8 BPT requested
        _swapFee
    );

    // ❌ Rejects only exact zero — does NOT reject 1 satoshi for 4,408 BPT
    require(tokenAmountIn != 0, ERR_MATH_APPROX);

    // ❌ No minimum balance floor on inRecord.balance
    // ❌ No relative error check: expectedIn vs. computed tokenAmountIn

    require(tokenAmountIn <= maxAmountIn, ERR_LIMIT_IN);
    _pullUnderlying(tokenIn, msg.sender, tokenAmountIn);
    uint poolTotal = _totalSupply;
    _mintPoolShare(poolAmountOut);
    _pushPoolShare(msg.sender, poolAmountOut);
}

Attack Steps

#StepDetail
1Flash loansBorrowed large amounts of WBTC and stablecoins via nested flash loans from Aave, Spark, Morpho, and Uniswap V3 in a single transaction.
2WBTC reserve compressionExecuted targeted swaps and one-sided exits to systematically drain WBTC from the target pool (DPI / USDC / WETH / WBTC), reducing the WBTC balance to approximately 1 satoshi.
3BPT minting exploitCalled joinswapPoolAmountOut requesting 4,408.8 BPT with tokenIn = WBTC. Due to the rounding error, calcSingleInGivenPoolOut returned 1 satoshi as the required input.
4Deposit 1 satoshiTransferred a single satoshi of WBTC to the pool and received 4,408.8 BPT — an outsized share of total pool supply.
5Proportional exitCalled exitPool, redeeming 4,408.8 BPT proportionally for all pool assets: DPI, USDC, WETH, and WBTC, draining ~$234,000.
6Repay flash loansRepaid all flash loan principals + fees, netting the ~$234K profit.

Impact

  • Direct theft: ~$234,000 in DPI, USDC, WETH, and WBTC from the affected V1 pool.
  • LP losses: All liquidity providers in the targeted pool lost their remaining assets.
  • Fork exposure: Balancer V1 is unmaintained; any protocol that forked V1's BPool.sol without patching joinswapPoolAmountOut / calcSingleInGivenPoolOut remains vulnerable to the identical attack. Balancer urged all V1 LPs to withdraw immediately.
  • Historical pattern: Same bug family as the November 2025 Balancer V2 $116M exploit, confirming that precision/rounding errors in AMM invariant math are a persistent, high-impact vulnerability class.

Lessons for Auditors

  1. Test AMM math at boundary conditions. Fuzz pool functions with balances compressed to 1 wei (or 1 satoshi for WBTC). Confirm that the computed tokenAmountIn is economically proportional to the requested poolAmountOut.

  2. Implement minimum reserve floors. AMM contracts should revert any join/swap when any token balance falls below a protocol-defined minimum (e.g., 1e4 satoshi ≈ 0.0001 WBTC). This prevents the extreme-compression attack vector.

  3. Add relative-error validation after invariant math. After computing tokenAmountIn, compare it to a naive estimate: expected = poolAmountOut × balance / totalSupply. If |computed − expected| / expected > ε (e.g., 1%), revert with a precision error.

  4. Non-standard token decimals demand explicit overflow/rounding guards. Contracts mixing 18-decimal math with 6-decimal (USDC) or 8-decimal (WBTC) tokens must document and test all boundary conditions. A single satoshi deposit for thousands of BPT is a red flag that invariant math diverges from economic reality.

  5. Nested flash loans expand the attack surface. Multiple simultaneous flash loans from different providers in one transaction can achieve extreme pool imbalances that no single provider's capacity allows. Audit with nested-flash-loan attack scenarios explicitly in scope.

  6. Unmaintained code must carry clear withdrawal warnings. If a protocol deploys legacy code it no longer maintains, it should provide on-chain and off-chain signals for LPs to exit. Balancer V1 pools should have had prominent deprecation warnings years before this exploit.

attack patterns
precision-mathdefi-ammflashloans
sources