DEV Community

ohmygod
ohmygod

Posted on

Death by a Thousand Rounds: How Balancer V2 Lost $128M to a Rounding Error

TL;DR

On November 3, 2025, an attacker exploited a rounding direction error in Balancer V2's Composable Stable Pools to drain $128 million across nine chains — Ethereum, Polygon, Base, Arbitrum, and more. No flash loans. No oracle manipulation. Just arithmetic that rounded in the wrong direction, compounded across thousands of micro-transactions.

This article dissects the exploit mechanism, explains why precision bugs are the emerging dominant attack class in DeFi, and provides a concrete defense playbook for protocol developers.


The Attack: How 1 Wei Becomes $128 Million

The Vulnerable Code Pattern

Balancer V2's batchSwap function scales token amounts between internal high-precision representations and external token decimals. The critical flaw was in the _upscale function:

// Simplified illustration of the vulnerable pattern
function _upscale(uint256 amount, uint256 scalingFactor) internal pure returns (uint256) {
    // Always rounds DOWN — this is the bug
    return FixedPoint.mulDown(amount, scalingFactor);
}
Enter fullscreen mode Exit fullscreen mode

The problem: upscaling should round up (favoring the protocol), but it rounded down (favoring the trader). For normal-sized swaps, the difference is 1 wei — negligible. But for carefully crafted micro-swaps in low-liquidity pools, that 1 wei difference becomes exploitable.

The Exploit Flow

1. Attacker identifies Composable Stable Pool with low liquidity
2. Executes thousands of micro-swaps via batchSwap()
3. Each swap extracts 1 wei more than it should (rounding in attacker's favor)
4. At micro-scale, 1 wei of rounding error ≈ significant % of swap amount
5. Compound across thousands of txs → drain the pool
6. Repeat across 9 chains where Balancer V2 pools existed
Enter fullscreen mode Exit fullscreen mode

The key insight: the profitability of rounding exploits is inversely proportional to pool liquidity. In a $100M pool, 1 wei of rounding error is meaningless. In a pool with $1000 of liquidity, that same 1 wei could represent 0.1% of the swap — and 10,000 swaps later, you've extracted the entire pool.

Why It Took 4 Years to Exploit

Trail of Bits flagged this rounding direction issue in their October 2021 audit (finding TOB-BALANCER-004). At the time, it was marked "undetermined severity" because:

  1. The bug required specific pool configurations to be exploitable
  2. Rounding exploits weren't considered a serious attack vector in 2021
  3. The profitability threshold wasn't clear — how low does liquidity need to be?

Between 2021 and 2025, three things changed:

  • Gas costs dropped (L2s made thousands of micro-txs cheap)
  • More pools appeared with low liquidity (long-tail tokens)
  • Attackers got sophisticated (the easy access control bugs were patched)

This is the evolution of DeFi security: each layer of defense pushes attackers deeper into the math.


Why Precision Bugs Are the New Dominant Attack Class

The Balancer hack is part of a clear trend:

Year Top Attack Vector Example
2021 Access control, key compromise Poly Network ($611M)
2022 Bridge vulnerabilities Wormhole ($320M), Ronin ($624M)
2023 Reentrancy, oracle manipulation Curve ($41M), Euler ($197M)
2024 First-depositor/rounding Sonne Finance ($20M), PenPie ($27M)
2025 Precision/scaling Balancer V2 ($128M)

The trend is clear: as protocols patch higher-level vulnerabilities, the remaining attack surface concentrates in mathematical edge cases. Rounding errors, precision loss, and scaling inconsistencies are the new frontier.

Why These Bugs Are Hard to Catch

  1. They're conditional — only exploitable under specific parameter combinations (low liquidity, extreme token decimal differences, specific pool configurations)
  2. They're tiny — 1 wei per operation doesn't trigger alerts or look anomalous
  3. They compound — the exploit is profitable only in aggregate
  4. They're boring — auditors focus on flashy vulnerabilities (reentrancy, access control) and treat arithmetic as "just math"

The Rounding Rules: A Complete Reference

Every DeFi protocol that handles token math needs a consistent rounding strategy. Here are the rules:

The Golden Rule: Round Against the User, in Favor of the Protocol

Token IN  (user deposits/swaps in):   Round UP    → user pays slightly more
Token OUT (user withdraws/swaps out): Round DOWN  → user receives slightly less
Share price calculation:               Round DOWN  → shares are slightly cheaper
Share redemption calculation:          Round UP    → redeeming costs slightly more shares
Enter fullscreen mode Exit fullscreen mode

In Solidity: mulUp vs mulDown

library SafeMath {
    uint256 internal constant ONE = 1e18;

    // Round DOWN: result ≤ true value
    function mulDown(uint256 a, uint256 b) internal pure returns (uint256) {
        return (a * b) / ONE;
    }

    // Round UP: result ≥ true value
    function mulUp(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 product = a * b;
        if (product == 0) return 0;
        return ((product - 1) / ONE) + 1;
    }

    // Round DOWN
    function divDown(uint256 a, uint256 b) internal pure returns (uint256) {
        return (a * ONE) / b;
    }

    // Round UP
    function divUp(uint256 a, uint256 b) internal pure returns (uint256) {
        if (a == 0) return 0;
        return ((a * ONE - 1) / b) + 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

The Scaling Trap (What Killed Balancer)

Token scaling is where rounding bugs hide most often:

// Converting between token decimals and internal precision
// Example: USDC (6 decimals) to 18-decimal internal representation

// DANGEROUS: Rounds in the same direction for both up and down scaling
function upscale(uint256 amount, uint256 factor) internal pure returns (uint256) {
    return amount * factor; // Exact for upscale, but...
}
function downscale(uint256 amount, uint256 factor) internal pure returns (uint256) {
    return amount / factor; // Rounds DOWN always — is this correct?
}

// CORRECT: Rounding direction depends on context
function upscaleTokenIn(uint256 amount, uint256 factor) internal pure returns (uint256) {
    // User is sending tokens IN — round UP (charge slightly more)
    return divUp(amount, factor);
}
function downscaleTokenOut(uint256 amount, uint256 factor) internal pure returns (uint256) {
    // User is receiving tokens OUT — round DOWN (pay slightly less)
    return amount / factor; // Division naturally rounds down
}
Enter fullscreen mode Exit fullscreen mode

Defense Playbook: How to Make Your Protocol Precision-Safe

1. Rounding Direction Audit (Manual)

Create a rounding direction map for every arithmetic operation in your protocol:

Function              | Operation      | Current | Correct | Status
--------------------- | -------------- | ------- | ------- | ------
deposit()             | shares = f(x)  | DOWN    | DOWN    | ✅
withdraw()            | assets = f(x)  | DOWN    | DOWN    | ✅
swap() - tokenIn      | upscale        | DOWN    | UP      | ❌ BUG
swap() - tokenOut     | downscale      | DOWN    | DOWN    | ✅
getRate()             | price calc     | UP      | DOWN    | ❌ BUG
Enter fullscreen mode Exit fullscreen mode

Trail of Bits open-sourced roundme, a tool for human-assisted rounding direction analysis. Use it.

2. Invariant Fuzzing with Echidna/Medusa

Write property tests that catch rounding violations:

// Invariant: No single swap should increase the trader's net position
// beyond the pool's quoted rate
function echidna_no_free_value() public returns (bool) {
    uint256 balanceBefore = token.balanceOf(address(pool));

    // Execute a round-trip: swap A→B then B→A
    uint256 amountOut = pool.swap(tokenA, tokenB, 1e18);
    uint256 amountBack = pool.swap(tokenB, tokenA, amountOut);

    // After a round-trip, user should have LESS than they started with
    // (due to fees + rounding in protocol's favor)
    return amountBack <= 1e18;
}

// Invariant: Pool reserves should never decrease from swaps alone
function echidna_reserves_monotonic() public returns (bool) {
    uint256 reserveA_before = pool.getReserve(tokenA);
    uint256 reserveB_before = pool.getReserve(tokenB);

    // Random swap
    pool.swap(tokenA, tokenB, amount);

    uint256 reserveA_after = pool.getReserve(tokenA);
    uint256 reserveB_after = pool.getReserve(tokenB);

    // Total value should not decrease (in a fee-taking pool)
    return (reserveA_after + reserveB_after) >= (reserveA_before + reserveB_before);
}
Enter fullscreen mode Exit fullscreen mode

3. Formal Verification of Arithmetic Properties

Using Halmos or Certora, prove rounding direction holds for ALL inputs:

// Halmos: Prove upscale always rounds in protocol's favor
function check_upscale_rounds_up(uint256 amount, uint256 factor) public {
    vm.assume(amount > 0 && amount < type(uint128).max);
    vm.assume(factor > 0 && factor < type(uint128).max);

    uint256 scaled = upscaleTokenIn(amount, factor);
    uint256 unscaled = downscaleTokenOut(scaled, factor);

    // After upscale→downscale round-trip, result should be >= original
    // (protocol kept the rounding difference)
    assert(unscaled >= amount);
}
Enter fullscreen mode Exit fullscreen mode

4. Minimum Liquidity and Swap Size Guards

Even with correct rounding, extremely low liquidity makes precision issues dangerous:

// Enforce minimum pool liquidity
modifier ensureMinLiquidity() {
    _;
    require(
        totalLiquidity() >= MIN_LIQUIDITY,
        "Pool liquidity below safety threshold"
    );
}

// Enforce minimum swap size to make rounding exploitation unprofitable
modifier ensureMinSwapSize(uint256 amount) {
    require(
        amount >= MIN_SWAP_SIZE,
        "Swap below minimum size"
    );
    _;
}
Enter fullscreen mode Exit fullscreen mode

5. On-Chain Monitoring for Rounding Exploitation Patterns

Set up alerts for:

  • High-frequency micro-swaps from a single address
  • Pool reserves decreasing without corresponding LP withdrawals
  • Swap sizes near the minimum (1 wei patterns)
  • Repeated round-trip swaps (A→B→A cycles)
# Monitoring pseudocode
def check_rounding_exploitation(pool, timewindow="1h"):
    swaps = get_recent_swaps(pool, timewindow)

    # Flag: >100 swaps from same address in 1 hour
    per_address = group_by(swaps, "sender")
    for addr, addr_swaps in per_address.items():
        if len(addr_swaps) > 100:
            alert(f"Potential rounding exploit: {addr} made {len(addr_swaps)} swaps")

    # Flag: average swap size < 0.01% of pool liquidity
    avg_size = mean([s.amount for s in swaps])
    if avg_size < pool.liquidity * 0.0001:
        alert(f"Micro-swap pattern detected on {pool.address}")
Enter fullscreen mode Exit fullscreen mode

Lessons for the Solana Ecosystem

Solana programs face analogous precision risks, especially in:

SPL Token Decimal Handling

// DANGEROUS: Implicit rounding when converting between token decimals
pub fn convert_decimals(amount: u64, from_decimals: u8, to_decimals: u8) -> u64 {
    if to_decimals > from_decimals {
        amount * 10u64.pow((to_decimals - from_decimals) as u32)
    } else {
        amount / 10u64.pow((from_decimals - to_decimals) as u32)
        // Always rounds DOWN — is this correct for this context?
    }
}

// CORRECT: Direction-aware decimal conversion
pub fn convert_decimals_up(amount: u64, from_decimals: u8, to_decimals: u8) -> u64 {
    if to_decimals >= from_decimals {
        amount.checked_mul(10u64.pow((to_decimals - from_decimals) as u32))
            .expect("overflow")
    } else {
        let divisor = 10u64.pow((from_decimals - to_decimals) as u32);
        // Round UP: (amount + divisor - 1) / divisor
        amount.checked_add(divisor - 1)
            .expect("overflow")
            .checked_div(divisor)
            .expect("div by zero")
    }
}
Enter fullscreen mode Exit fullscreen mode

Anchor Program Fixed-Point Math

use anchor_lang::prelude::*;

/// Safe fixed-point multiplication with explicit rounding direction
pub fn mul_round_up(a: u64, b: u64, decimals: u32) -> Result<u64> {
    let scale = 10u128.pow(decimals);
    let product = (a as u128).checked_mul(b as u128)
        .ok_or(ErrorCode::MathOverflow)?;

    // Round UP: (product + scale - 1) / scale
    let result = product.checked_add(scale - 1)
        .ok_or(ErrorCode::MathOverflow)?
        .checked_div(scale)
        .ok_or(ErrorCode::MathOverflow)?;

    u64::try_from(result).map_err(|_| ErrorCode::MathOverflow.into())
}

pub fn mul_round_down(a: u64, b: u64, decimals: u32) -> Result<u64> {
    let scale = 10u128.pow(decimals);
    let product = (a as u128).checked_mul(b as u128)
        .ok_or(ErrorCode::MathOverflow)?;

    let result = product.checked_div(scale)
        .ok_or(ErrorCode::MathOverflow)?;

    u64::try_from(result).map_err(|_| ErrorCode::MathOverflow.into())
}
Enter fullscreen mode Exit fullscreen mode

The Precision Audit Checklist

For auditors and developers reviewing DeFi math:

□ Map every arithmetic operation to its rounding direction
□ Verify rounding always favors the protocol (never the user)
□ Check scaling/descaling operations for consistency
□ Test with extreme values: 1 wei, MAX_UINT, 0, near-overflow
□ Test with extreme pool states: 1 wei liquidity, massive imbalance
□ Verify round-trip operations don't leak value (swap A→B→A ≤ initial)
□ Run Echidna/Medusa fuzzing with value-leak invariants
□ Use roundme tool for systematic rounding direction analysis
□ Check for inconsistent rounding between view functions and state functions
□ Verify cross-chain deployments use identical math libraries
□ Enforce minimum liquidity and minimum swap size
□ Set up monitoring for micro-transaction patterns
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. 1 wei × 10,000 transactions × 9 chains = $128 million. Rounding errors are not "negligible."

  2. The bug was flagged in 2021 and exploited in 2025. Undetermined-severity arithmetic findings deserve re-evaluation as the threat landscape evolves.

  3. L2s changed the economics of rounding exploits. Cheap transactions make micro-swap grinding profitable where it wasn't before.

  4. Rounding direction must be a first-class security property, documented and verified with the same rigor as access control and reentrancy protection.

  5. Invariant fuzzing catches precision bugs that manual review misses. If your protocol handles token math and you're not running Echidna or Medusa, you're flying blind.

The DeFi security landscape has matured past the era of obvious bugs. The remaining attack surface is in the math — and the math doesn't forgive.


DreamWork Security researches DeFi vulnerabilities with a focus on precision analysis and protocol invariants. Follow for weekly deep dives into smart contract security.

Top comments (0)