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
S

FullStack / ETL Developer. Algo-Trader at NSE. Blockchain Enthusiast.

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: