DEV Community

ohmygod
ohmygod

Posted on

The Balancer V2 Rounding Error: How 65 Micro-Swaps Drained $128M and Killed a Protocol

The Balancer V2 Rounding Error: How 65 Micro-Swaps Drained $128M and Killed a Protocol

Balancer Labs announced its shutdown on March 24, 2026. The $128M rounding error exploit that forced the closure is a masterclass in how tiny arithmetic imprecisions compound into catastrophic drain.


The Timeline That Ended Balancer

On November 3, 2025, an attacker deployed a single smart contract that drained $128.64 million from Balancer V2's ComposableStablePool contracts across six blockchains in under 30 minutes. Four months later — yesterday — Balancer Labs announced it would shut down its corporate entity. The protocol will limp on under community governance, but the company behind one of DeFi's foundational AMMs is gone.

The root cause? Integer division rounding down by a few wei.

How Solidity's Integer Division Becomes a Weapon

Balancer V2's ComposableStablePools use Curve's StableSwap invariant to maintain price stability between correlated assets (like wstETH/WETH). The invariant D represents total pool value. BPT price is simply D / totalSupply.

The vulnerability lives in _upscaleArray, which scales raw token balances before invariant calculation:

function _upscaleArray(
    uint256[] memory amounts,
    uint256[] memory scalingFactors
) private pure returns (uint256[] memory) {
    for (uint256 i = 0; i < amounts.length; i++) {
        amounts[i] = FixedPoint.mulDown(amounts[i], scalingFactors[i]);
    }
    return amounts;
}
Enter fullscreen mode Exit fullscreen mode

mulDown performs integer division that rounds down. Under normal conditions — pool balances in the millions — this is negligible. The relative error on a $10M balance rounding down by 1 wei is approximately 0.0000000000000001%.

But what happens when you push a token balance to 8–9 wei?

The relative precision loss jumps to ~10% per operation. And since this error propagates directly into the invariant D, it artificially suppresses BPT price — creating an arbitrage gap the attacker can extract.

The Three-Phase Attack Loop

The attacker weaponized this precision loss through a repeating cycle inside a single batchSwap transaction:

Phase 1: Push to Rounding Boundary

Swap large amounts of BPT for underlying tokens, driving one token's raw balance down to the critical 8–9 wei range where mulDown introduces maximum relative error.

Phase 2: Trigger Precision Cascade

Execute small swaps involving the boundary-positioned token. Each swap forces _upscaleArray to round down, underestimating the invariant D and artificially depressing BPT price.

Phase 3: Extract the Spread

Mint or purchase BPT at the suppressed price, then immediately redeem at full value. The price discrepancy is pure profit.

This cycle repeated 65 times within a single atomic transaction. No frontrunning possible. No intervention window. The constructor of the exploit contract executed the entire attack during deployment — by the time the contract existed on-chain, the theft was complete.

The Numbers

The exploit contract's internal balance increased by:

  • Pool 1 (osETH/wETH-BPT): +4,623 WETH, +6,851 osETH
  • Pool 2 (wstETH-WETH-BPT): +1,963 WETH, +4,259 wstETH
  • Combined: 6,586 WETH + 6,851 osETH + 4,259 wstETH ≈ $128.64M

A second transaction called function 0x8a4f75d6 to withdraw the accumulated internal balances to the final recipient address.

The attack spread across Ethereum, Arbitrum, Base, Avalanche, Sonic, and Polygon. Ethereum was hit hardest. Balancer forks like Beets Finance were also drained.

Why Audits Missed It

This vulnerability passed multiple security audits. Here's why:

1. Individual operations were correct. Each mulDown call behaves exactly as specified. The rounding is deterministic and documented. No single swap produces anomalous results.

2. Audits test correctness, not composability. Traditional audits verify that function A does what it claims. They rarely model what happens when function A is called 65 times in sequence with adversarially chosen parameters.

3. The exploit requires specific preconditions. The rounding error only becomes exploitable when pool balances reach the 8–9 wei range — a state that doesn't occur in normal operation. Fuzzing with random inputs won't find it. You need targeted adversarial modeling.

4. A related bug was found — and fixed — in 2023. A security researcher disclosed a rounding error in Balancer's ERC4626 Linear Pools via Immunefi. Balancer fixed it. But the same vulnerability class recurred in a different pool type (ComposableStablePools). The fix was point-specific, not systemic.

The Systemic Lesson: Vulnerability Classes Recur

This is the real takeaway. Balancer patched the 2023 rounding bug in Linear Pools. The 2025 exploit hit Composable Stable Pools. Same vulnerability class, different pool type. The Balancer team acknowledged the pattern similarity but noted the affected code was distinct.

This is a universal problem in DeFi: point fixes don't protect against class recurrence. When you find a rounding error in one pool design, the correct response isn't just patching that pool — it's auditing every code path where mulDown or mulUp operates on potentially small values.

Detection Pattern for Protocol Teams

// DANGEROUS: scaling small raw balances
// If rawBalance can reach < 100 wei, precision loss may be exploitable
uint256 scaled = FixedPoint.mulDown(rawBalance, scalingFactor);

// SAFER: enforce minimum balance thresholds
require(rawBalance >= MIN_BALANCE_THRESHOLD, "Balance too low for safe scaling");
uint256 scaled = FixedPoint.mulDown(rawBalance, scalingFactor);
Enter fullscreen mode Exit fullscreen mode

Any protocol using fixed-point arithmetic with scaling factors should ask: "What happens when the input approaches zero?"

Defense Patterns That Would Have Helped

1. Invariant Change Validation

After every swap, verify that the pool invariant hasn't decreased beyond an acceptable tolerance:

uint256 invariantBefore = _calculateInvariant(balancesBefore);
// ... execute swap ...
uint256 invariantAfter = _calculateInvariant(balancesAfter);
require(
    invariantAfter >= invariantBefore - ACCEPTABLE_LOSS,
    "Invariant decreased beyond tolerance"
);
Enter fullscreen mode Exit fullscreen mode

This single check would have caught the attack. Each micro-swap decreased D slightly through rounding — 65 iterations compounded into a massive invariant reduction that a threshold check would flag.

2. Minimum Balance Enforcement

Prevent any token balance from dropping below a safe threshold:

uint256 constant MIN_POOL_BALANCE = 1e6; // 1M wei minimum

function _validateBalances(uint256[] memory balances) internal pure {
    for (uint256 i = 0; i < balances.length; i++) {
        require(balances[i] >= MIN_POOL_BALANCE, "Balance below minimum");
    }
}
Enter fullscreen mode Exit fullscreen mode

The attack only works when balances reach 8–9 wei. A minimum balance of even 1,000 wei would eliminate the exploitable precision loss.

3. Batch Operation Limits

Cap the number of operations in a single batchSwap:

require(swaps.length <= MAX_BATCH_SIZE, "Batch too large");
Enter fullscreen mode Exit fullscreen mode

The 65-operation batch was anomalous. While this alone wouldn't prevent the bug, it limits the compounding effect that makes it profitable.

4. Economic Invariant Fuzzing

Standard fuzzing with random inputs won't find this. You need adversarial economic modeling:

# Foundry invariant test concept
def invariant_no_value_extraction():
    """No sequence of swaps should allow extracting more
    value than was deposited, minus fees."""
    total_deposited = sum(all_deposits)
    total_withdrawn = sum(all_withdrawals)
    total_fees = sum(all_fees_collected)
    assert total_withdrawn <= total_deposited + total_fees + DUST_THRESHOLD
Enter fullscreen mode Exit fullscreen mode

The Solana Parallel: CPI and Precision

Solana programs face analogous risks. Cross-Program Invocations (CPI) that pass scaled values between programs can introduce similar precision loss, especially when converting between tokens with different decimal places.

// Rust/Anchor: Same class of bug
// If input_amount is very small and decimals differ significantly,
// the division can round to zero
let scaled = input_amount
    .checked_mul(10u64.pow(target_decimals as u32))
    .unwrap()
    .checked_div(10u64.pow(source_decimals as u32))
    .unwrap();

// Fix: enforce minimum amounts
require!(input_amount >= MIN_SWAP_AMOUNT, ErrorCode::AmountTooSmall);
Enter fullscreen mode Exit fullscreen mode

SPL Token-2022's transfer hooks add another surface — any hook that performs arithmetic on small balances should validate minimum thresholds.

The Bigger Picture

Balancer's shutdown isn't just the end of a protocol. It's a warning about the compound risk of arithmetic precision in DeFi:

  • $128M lost from a rounding error that produces <1 wei of imprecision per operation
  • Six chains drained in 30 minutes because shared Vault architecture meant one bug affected everything
  • A known bug class that was previously found and fixed in a different pool type, then recurred
  • Multiple audits passed because the vulnerability only manifests under adversarial batch conditions

The next $100M+ exploit won't be a novel attack vector. It will be a known vulnerability class — reentrancy, oracle manipulation, precision loss — recurring in a new context that existing tests don't cover.

Audit for classes, not instances. Fuzz for economic invariants, not just code correctness. And when you find a precision bug in one component, assume it exists in every component that shares the same mathematical pattern.


This analysis is based on Check Point Research's technical breakdown, Immunefi's post-incident report, and on-chain transaction data. The exploit transaction hashes and contract addresses are publicly available for independent verification.

Top comments (0)