Precision in DeFi Math: Understanding the Balancer Exploit
A Complete Technical Guide for Engineers
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:
Round Down (divDown): When calculating how many tokens to give OUT → Protects the pool
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:
Upscaling: Multiply by a scaling factor to a common precision (typically 18 decimals) using
mulDownCalculations: Do all math at this unified precision
Downscaling: Divide by the scaling factor using
divDownordivUpdepending 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?
Mathematical Edge Cases: Auditors typically look for logic bugs, not numerical edge cases that only manifest under specific boundary conditions.
Static Analysis Limitations: Traditional audits use static code analysis which doesn't explore the entire state space of how values interact.
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.
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
Always use explicit rounding directions:
Never rely on default Solidity division
Always use
divUp()ordivDown()explicitlyComment why you chose each direction
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 }Use property-based testing:
# Run Foundry with many iterations forge test --fuzz-runs 10000Formal 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
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
Conduct a precision audit: Review all places where you divide and multiply, verify rounding directions
Implement continuous monitoring: Track D monotonicity on-chain with emergency pause mechanisms
Add redundant checks: Calculate values using two independent methods and compare
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 functionpkg/v2-pool-stable/contracts/StableMath.sol- The invariant calculationpkg/v2-vault/contracts/Vault.sol- The batch swap entry point
Verified Packages:
Foundry (for fuzz testing): https://github.com/foundry-rs/foundry
Certora: https://www.certora.com/
On-Chain Evidence
The actual attack transactions are visible on-chain:






