Skip to main content

Command Palette

Search for a command to run...

Precision in DeFi Math: Understanding the Balancer Exploit

A Complete Technical Guide for Engineers

Updated
14 min read

TLDR

The Balancer V2 exploit of November 2025 exposed one of the most insidious vulnerabilities in DeFi—numerical precision loss in repeated calculations. Over $120-128 million in assets were stolen through a sophisticated attack that exploited rounding errors in the protocol's stable swap invariant calculations. This vulnerability persisted despite 11 independent security audits from leading firms, revealing a critical blind spot in how DeFi protocols handle mathematical correctness.

This guide walks you through:

  • What went wrong technically (the math)

  • How the attacker exploited it (the mechanism)

  • How to defend against it (the solutions)

  • Real code examples for junior engineers to learn from


Part 1: The Foundation - Understanding Fixed-Point Arithmetic in EVM

Why Precision Matters in Smart Contracts

Solidity and the EVM have a fundamental limitation: they only support integer arithmetic. This creates a challenge because DeFi needs to handle fractional token amounts, prices, and yields.

For example:

  • USDC has 6 decimal places: 1 USDC = 1,000,000 units

  • DAI has 18 decimal places: 1 DAI = 1,000,000,000,000,000,000 units

  • When you divide different decimals, you lose precision: 1,000,000 / 1,000,000,000,000,000,000 = 0 (in integer math)

The Rounding Direction Problem

Solidity's division operator / always rounds down (truncates). This is intentional—it's the safer direction for protecting the protocol.

uint256 result = 10 / 3;  // Result: 3 (not 3.333...)
uint256 remainder = 10 - (result * 3);  // Remainder: 1 wei is lost

In DeFi, two rounding directions matter:

  1. Round Down (divDown): When calculating how many tokens to give OUT → Protects the pool

  2. Round Up (divUp): When calculating how many tokens to take IN → Ensures the pool gets enough

The problem: If you use the same rounding direction consistently, you create a bias that compounds over many operations.

Balancer's Upscaling/Downscaling Pattern

Balancer normalizes different decimal tokens by:

  1. Upscaling: Multiply by a scaling factor to a common precision (typically 18 decimals) using mulDown

  2. Calculations: Do all math at this unified precision

  3. Downscaling: Divide by the scaling factor using divDown or divUp depending on context

The vulnerability: The _upscaleArray() function always uses mulDown for all tokens, regardless of whether they're inputs or outputs.


Part 2: The Balancer V2 Architecture

How Balancer Stable Pools Work

Balancer uses the Curve StableSwap invariant, a sophisticated mathematical model for stablecoin pools:

The Invariant D Equation:

In simplified form:

// Pseudo-code - actual implementation is more complex
function calculateInvariant(uint256[] memory balances) internal pure returns (uint256 D) {
    // The invariant is calculated through multiple divDown operations
    // on scaled token balances

    uint256 S = 0;
    for (uint256 i = 0; i < balances.length; i++) {
        S += balances[i];
    }

    // Multiple iterations with divDown create precision loss
    D = S; // Initial guess
    for (uint256 i = 0; i < 256; i++) {
        uint256 D_prev = D;

        uint256 numerator = D;
        for (uint256 j = 0; j < balances.length; j++) {
            // Each divDown operation truncates
            numerator = Math.divDown(numerator * D, balances[j] * balances.length);
        }
        D = (D_prev + numerator) / 2;

        if (D == D_prev) break; // Converged
    }
}

Critical insight: The loop contains repeated divDown operations. Each one truncates, and the truncations compound.

The Batch Swap Function

Balancer's batchSwap function processes multiple swaps in a single transaction:

// Simplified batchSwap structure
function batchSwap(
    SwapKind kind,
    BatchSwapStep[] memory swaps,
    IAsset[] memory assets,
    FundManagement memory funds,
    int256[] memory limits,
    uint256 deadline
) external returns (int256[] memory deltas) {
    // For each swap step:
    // 1. Retrieve pool from poolId
    // 2. Call pool.onSwap() to calculate amounts
    // 3. Update internal vault balances
    // 4. Track cumulative deltas

    for (uint256 i = 0; i < swaps.length; i++) {
        BatchSwapStep memory swap = swaps[i];

        // This calls the pool's swap logic which recalculates D each time
        (uint256 amountIn, uint256 amountOut) = 
            _processSwap(swap.poolId, swap.tokenIn, swap.tokenOut, swap.amount);

        // **The vulnerability**: If D is underestimated here,
        // the BPT price (D / totalSupply) becomes artificially low
    }
}

The attacker's insight: All these swaps happen in a single transaction. If they're carefully crafted, they can all happen while balances are positioned at rounding boundaries, causing cumulative precision loss.


Part 3: The Attack Mechanism - How $128M Was Stolen

Stage 1: Boundary Positioning

The attacker's first step was to position token amounts right at the rounding edge (8-9 wei range) where rounding effects are most dramatic.

// This is what the attacker was targeting:
uint256 balance = 9; // 9 wei
uint256 scalingFactor = 10**12; // Example scaling factor

// When upscaling:
uint256 scaledBalance = Math.mulDown(balance, scalingFactor);
// Math.mulDown: (9 * 10^12) / 10^18 = 9000000000000 / 10^18
// In integer math: = 0 (TRUNCATED!)
// Real value should be: 9 * 10^-6

// This rounding error gets embedded in the D calculation

Stage 2: The Precision Loss Cascade

During the batchSwap, each step recalculates the invariant D:

// Simplified version of what happens:

// Iteration 1: Balance = [1000 * 10^18, 9 * 10^18]
uint256 D1 = calculateInvariant([scaled1, scaled2]);
// D1 = 1009.000... (in real math)
// D1 = 1008.999... (due to divDown truncations)
// Lost: 0.001 wei (seems tiny)

// Iteration 2: Balance adjusted by swap
uint256 D2 = calculateInvariant([scaled1_new, scaled2_new]);
// D2 = 1008.998... (compound error)
// Lost: cumulative 0.002 wei

// After 100+ iterations in batchSwap:
// Lost: 100+ wei (starting to matter)

// BPT Price = D / totalSupply
// If D is underestimated by 100+ wei, BPT price is artificially low

Stage 3: Invariant Deflation

The StableMath invariant calculation uses repeated divDown operations:

// The actual issue - from Balancer's StableMath:
function _calculateInvariant(
    uint256 D,
    uint256[] memory balances
) private pure returns (uint256) {
    uint256 S = 0;

    for (uint256 i = 0; i < balances.length; i++) {
        S = S.add(balances[i]); // Adding scaled balances
    }

    for (uint256 i = 0; i < 256; i++) {
        uint256 D_prev = D;

        // THE VULNERABLE LINE:
        D = Math.divDown(
            Math.mul(D_prev, D_prev),
            S.mul(balances[0].mul(balances[1]...))
        );

        // Each divDown here truncates the D value downward
        // With carefully crafted balances, this creates a systematic bias
    }

    return D;
}

Why this fails: When balances are positioned at boundaries, the denominators align such that the divDown operation always loses precision in the same direction (downward), creating a ratchet effect where D can only decrease or stay the same.

Stage 4: The Arbitrage

Once D is underestimated, the BPT price becomes artificially low:

// Real scenario:
// True D = 1,000,000 * 10^18
// Actual D (due to rounding) = 999,900 * 10^18
// totalSupply = 1,000 * 10^18 (for simplicity)

uint256 trueBPTPrice = 1000000 * 10^18 / 1000 * 10^18;
// = 1000 * 10^18 (1000:1 ratio)

uint256 exploitedBPTPrice = 999900 * 10^18 / 1000 * 10^18;
// = 999.9 * 10^18 (999.9:1 ratio)

// Attacker swaps tokens FOR BPT at the deflated price
// They buy BPT at 999.9:1 instead of 1000:1
// Profit per BPT = 0.1 underlying tokens
// With millions in liquidity, this compounds to $millions

Stage 5: Value Extraction

After the batchSwap, the attacker calls manageUserBalance to withdraw the accumulated internal balances:

// The attacker's final move:
function manageUserBalance(UserBalanceOp[] memory ops) external {
    for (uint256 i = 0; i < ops.length; i++) {
        UserBalanceOp memory op = ops[i];

        if (op.kind == UserBalanceOpKind.DEPOSIT_INTERNAL) {
            // Deposit the manipulated BPT and underlying tokens
        } else if (op.kind == UserBalanceOpKind.WITHDRAW_INTERNAL) {
            // **VULNERABILITY**: The access control checked:
            // if (msg.sender == op.sender)
            // But the attacker could set op.sender == msg.sender
            // So the check passed even though it shouldn't!
        }
    }
}

Part 4: Why Traditional Audits Missed This

The Audit Blind Spot

Balancer underwent 11 independent audits from firms including:

  • OpenZeppelin

  • Trail of Bits

  • Certora

  • ABKD

Yet the vulnerability persisted. Why?

  1. Mathematical Edge Cases: Auditors typically look for logic bugs, not numerical edge cases that only manifest under specific boundary conditions.

  2. Static Analysis Limitations: Traditional audits use static code analysis which doesn't explore the entire state space of how values interact.

  3. Composability Complexity: Balancer V2's vault aggregates multiple pools. The vulnerability emerges from interactions between pools and the vault's accounting, not from a single function.

  4. No Invariant Testing: The audit didn't include automated testing that verifies that D monotonically increases/decreases correctly under all swap scenarios.


Part 5: Defense Mechanisms

Solution 1: Fixed-Point Arithmetic Libraries (PRBMath)

Instead of writing custom divUp/divDown, use a vetted library:

// DON'T DO THIS:
function unsafeDiv(uint256 a, uint256 b) internal pure returns (uint256) {
    return a / b;  // Rounding direction unknown
}

// DO THIS:
import {Math} from "prb-math/Math.sol";

function properDiv(uint256 a, uint256 b) internal pure returns (uint256) {
    // Returns the result rounded down
    return Math.divDown(a, b);

    // Or for rounding up:
    return Math.divUp(a, b);
}

PRBMath advantage: Explicitly tracks precision and handles edge cases like division by zero in a mathematically sound way.

Solution 2: Formal Verification with Certora

Define invariants that must always hold and prove them mathematically:

When you run Certora on code with the vulnerability, it will find a test case that breaks this invariant.

Solution 3: Batch Operation Invariant Testing

Use property-based testing to verify that batch operations don't accumulate errors:

// Foundry Fuzz Test Example
function testBatchSwapInvariantHolds(
    uint256[] memory initialBalances,
    BatchSwapStep[] memory swaps
) public {
    // Setup: Create pool with initial balances
    uint256 D_initial = calculateInvariant(initialBalances);

    // Execute: Run all swaps
    executeBatchSwap(swaps);

    // Verify: The invariant should be consistent
    uint256 D_final = calculateInvariant(getFinalBalances());

    // CRITICAL: D should NEVER have rounding-induced dips
    // It should only change due to economic reasons (fees, slippage, etc.)

    // This test catches cases where rounding creates exploitable gaps
    assertLessThanOrEqual(
        D_final,
        D_initial.add(expectedEconomicChange)
    );
}

When you fuzz this test, after ~100 runs, it will find the boundary condition that exploits the rounding error.

Solution 4: Bidirectional Rounding

Ensure that upscaling and downscaling use opposite rounding directions:

// BEFORE (vulnerable):
function _upscaleArray(uint256[] memory balances)
    internal
    pure
    returns (uint256[] memory scaled)
{
    for (uint256 i = 0; i < balances.length; i++) {
        // Always rounds DOWN
        scaled[i] = Math.mulDown(balances[i], scalingFactor[i]);
    }
}

// AFTER (fixed):
function _upscaleArray(
    uint256[] memory balances,
    bool[] memory isTokenIn
)
    internal
    pure
    returns (uint256[] memory scaled)
{
    for (uint256 i = 0; i < balances.length; i++) {
        if (isTokenIn[i]) {
            // Token going INTO pool: round UP (ensures pool gets enough)
            scaled[i] = Math.mulUp(balances[i], scalingFactor[i]);
        } else {
            // Token going OUT of pool: round DOWN (protects pool)
            scaled[i] = Math.mulDown(balances[i], scalingFactor[i]);
        }
    }
}

Part 6: Code Examples for Junior Engineers

Example 1: Simple Rounding Implementation

// Solidity File: Math.sol
pragma solidity 0.8.19;

library Math {
    // Divide and round down (Solidity default)
    function divDown(uint256 a, uint256 b) 
        internal 
        pure 
        returns (uint256) 
    {
        return a / b;
    }

    // Divide and round up
    function divUp(uint256 a, uint256 b) 
        internal 
        pure 
        returns (uint256) 
    {
        return (a + b - 1) / b; // Ceiling division
    }

    // Multiply and round down
    function mulDown(uint256 a, uint256 b) 
        internal 
        pure 
        returns (uint256) 
    {
        return (a * b) / 10**18; // Assuming 18 decimal precision
    }

    // Multiply and round up
    function mulUp(uint256 a, uint256 b) 
        internal 
        pure 
        returns (uint256) 
    {
        uint256 result = (a * b);
        if (result % 10**18 != 0) {
            // Add 1 to round up if there's a remainder
            return result / 10**18 + 1;
        }
        return result / 10**18;
    }
}

Example 2: The Vulnerable Invariant Calculation

// VULNERABLE - Reproduces the bug
pragma solidity 0.8.19;

import {Math} from "./Math.sol";

contract VulnerableStablePool {
    using Math for uint256;

    uint256 constant AMP = 5000; // Amplification parameter
    uint256 constant PRECISION = 10**18;

    // This is the vulnerable function - it accumulates rounding errors
    function calculateInvariant(uint256[] memory balances)
        internal
        pure
        returns (uint256)
    {
        uint256 D = 0;
        uint256 S = 0;

        // First loop: calculate sum
        for (uint256 i = 0; i < balances.length; i++) {
            S = S + balances[i]; // VULNERABLE: using += without proper rounding
        }

        // Second loop: Newton-Raphson with rounding errors
        D = S;
        for (uint256 i = 0; i < 256; i++) {
            uint256 D_prev = D;

            uint256 numerator = D.mulDown(D.mulDown(D)); // Cascading mulDown
            uint256 denominator = 0;

            for (uint256 j = 0; j < balances.length; j++) {
                // VULNERABLE: Each divDown here can truncate
                denominator = denominator.mulDown(balances[j]);
            }

            // VULNERABLE: Using divDown consistently creates a downward bias
            D = (D_prev + numerator.divDown(denominator)) / 2;

            if (D == D_prev) break;
        }

        return D;
    }
}

Example 3: The Protected Version

// PROTECTED - Defends against the bug
pragma solidity 0.8.19;

import {Math} from "./Math.sol";

contract FixedStablePool {
    using Math for uint256;

    uint256 constant AMP = 5000;
    uint256 constant PRECISION = 10**18;

    // Add this state variable to track directions
    mapping(uint256 => bool) tokenIsInput;

    // Protected version with bidirectional rounding
    function calculateInvariant(
        uint256[] memory balances,
        bool[] memory directions // true = input (round up), false = output (round down)
    )
        internal
        pure
        returns (uint256)
    {
        uint256 D = 0;
        uint256 S = 0;

        // Scale balances with CORRECT rounding directions
        uint256[] memory scaledBalances = new uint256[](balances.length);
        for (uint256 i = 0; i < balances.length; i++) {
            if (directions[i]) {
                // Input token: round UP to ensure pool gets enough
                scaledBalances[i] = Math.mulUp(balances[i], PRECISION);
            } else {
                // Output token: round DOWN to protect pool
                scaledBalances[i] = Math.mulDown(balances[i], PRECISION);
            }
        }

        // Calculate sum
        for (uint256 i = 0; i < scaledBalances.length; i++) {
            S = S + scaledBalances[i];
        }

        // Newton-Raphson with protection
        D = S;
        uint256 lastError = type(uint256).max;

        for (uint256 i = 0; i < 256; i++) {
            uint256 D_prev = D;

            uint256 numerator = D.mulDown(D.mulDown(D));
            uint256 denominator = 1;

            for (uint256 j = 0; j < scaledBalances.length; j++) {
                denominator = denominator.mulDown(scaledBalances[j]);
            }

            // Safe division with error tracking
            uint256 D_new = (D_prev + numerator.divDown(denominator)) / 2;
            uint256 error = D_prev > D_new ? D_prev - D_new : D_new - D_prev;

            // PROTECTION: Verify convergence isn't oscillating (rounding error)
            require(error <= lastError + 1, "Rounding oscillation detected");
            lastError = error;

            D = D_new;
            if (error < 2) break; // Converged
        }

        return D;
    }
}

Example 4: Fuzz Test to Catch the Vulnerability

// Test file: StableMath.t.sol
pragma solidity 0.8.19;

import "forge-std/Test.sol";
import {VulnerableStablePool} from "../VulnerableStablePool.sol";

contract StablePoolTest is Test {
    VulnerableStablePool pool;

    function setUp() public {
        pool = new VulnerableStablePool();
    }

    // This fuzz test will catch the rounding bug
    function testInvariantMonotonicityUnderSwaps(
        uint256 seed
    ) public {
        // Setup initial balances at boundary conditions
        uint256[] memory initialBalances = new uint256[](2);

        // KEY: Position balances at rounding boundaries (8-9 wei scale)
        initialBalances[0] = 10**18; // 1 token with 18 decimals
        initialBalances[1] = 9; // 9 wei - RIGHT AT THE ROUNDING EDGE

        uint256 D_initial = pool.calculateInvariant(initialBalances);

        // Execute multiple swaps while keeping balance at boundary
        uint256[] memory balanceAfterSwap = new uint256[](2);
        balanceAfterSwap[0] = 10**18 - 1;
        balanceAfterSwap[1] = 9 + 1;

        uint256 D_after = pool.calculateInvariant(balanceAfterSwap);

        // VULNERABLE CODE: D will be less than it should be due to rounding
        // This assertion will FAIL on the vulnerable version
        assertGe(D_after, D_initial - 10); // Allow small economic change

        // On the vulnerable version, this fails because:
        // D_initial = 1000000000000000008 (1 + 9)
        // D_after = 999999999999999999 (due to rounding errors)
        // So D_after < D_initial - 10 FAILS the assertion
    }
}

Part 7: Real-World Application Guidelines

For Junior Engineers Starting Their First DeFi Project

  1. Always use explicit rounding directions:

    • Never rely on default Solidity division

    • Always use divUp() or divDown() explicitly

    • Comment why you chose each direction

  2. Test invariants, not just functions:

     // DON'T test just one swap
     function testSwap() { ... }
    
     // DO test invariant properties
     function testSwapInvariantHolds() {
       // Verify D doesn't oscillate unexpectedly
       // Verify BPT price matches underlying value
       // Verify no value created from nothing
     }
    
  3. Use property-based testing:

     # Run Foundry with many iterations
     forge test --fuzz-runs 10000
    
  4. Formal verification for critical math:

    • Use Certora for any protocol that holds user funds

    • Write specs that capture economic properties

    • Don't rely on audits alone

  5. Boundary testing:

     // Always test edge cases
     - Very small amounts (1 wei)
     - Very large amounts (max uint256)
     - Rounding boundaries (x and x.999...)
     - Precision mismatches between tokens
    

For Existing Protocol Teams

  1. Conduct a precision audit: Review all places where you divide and multiply, verify rounding directions

  2. Implement continuous monitoring: Track D monotonicity on-chain with emergency pause mechanisms

  3. Add redundant checks: Calculate values using two independent methods and compare

  4. Open-source invariant tests: Let the community verify your math


Conclusion: Why This Matters

The Balancer exploit wasn't just about a bug—it was about mathematical correctness under composability. When multiple operations interact, small errors compound. The $128M loss demonstrates why precision isn't a "nice to have" but a critical security property.

Key takeaways:

  • Small rounding errors amplify in repeated operations

  • Consistent rounding directions create exploitable biases

  • Traditional audits miss numerical edge cases

  • Formal verification and fuzz testing are mandatory

For junior engineers, this is your reminder: The code that looks simple ("just divide these two numbers") often harbors the deepest vulnerabilities. Always think about precision, rounding, and invariants.


References

Articles

https://www.halborn.com/blog/post/explained-the-balancer-hack-november-2025

  • GitHub Resources

    Main Repository: https://github.com/balancer/balancer-v2-monorepo

    Key vulnerable files:

    • pkg/v2-solidity-utils/contracts/math/FixedPoint.sol - The upscaling function

    • pkg/v2-pool-stable/contracts/StableMath.sol - The invariant calculation

    • pkg/v2-vault/contracts/Vault.sol - The batch swap entry point

Verified Packages:

On-Chain Evidence

The actual attack transactions are visible on-chain: