Skip to main content

Overview

In November 2025, an attacker systematically manipulated exchange rates in Balancer V2 stable pools by exploiting accumulated rounding errors in the stable pool invariant calculation. Through a crafted sequence of 84 alternating batch swaps, the attacker biased the pool’s invariant (D) downward by maintaining balances near rounding boundaries. This caused the BPT (Balancer Pool Token) price to change significantly. One pool changed from ~1.027e18 to ~20.189e18 (approximately 20x change). The attacker then extracted value through internal balance withdrawals in a subsequent transaction. Key Insight: While the root cause was complex (involving scaled balance calculations, down-rounding operations, and specific balance configurations), the invariant violation was simple: pool rates should not change drastically within a single transaction. This demonstrates how assertions protect protocols by focusing on what should never happen rather than how it might happen.
This assertion was developed by analyzing the observable effects of the exploit (drastic rate changes) rather than the root cause (rounding error accumulation). While this approach provides broad protection against rate manipulation attacks, it may not cover all edge cases or could potentially trigger false positives on legitimate operations that we have not yet discovered.Use with caution. This assertion should undergo extended testing and monitoring in non-production environments before deployment to production systems. The 3x threshold may need adjustment based on real-world transaction patterns.

The Invariant

In Balancer V2 stable pools, the rate represents the price of BPT relative to underlying assets:
rate = D / totalSupply
Where:
  • D is the stable pool invariant (computed from token balances)
  • totalSupply is the circulating supply of BPT
Normal behavior: During legitimate swaps, rates change minimally (typically < 10%) due to:
  • Trading fees
  • Slight imbalances from large trades
  • Natural price discovery
Exploit behavior: The attacker’s manipulation caused rates to change by 20x in a single batchSwap call, which should never happen for any legitimate operation.

The Exploit

The exploit consisted of two transactions:

Transaction 1: Rate Manipulation

The attacker executed a single batchSwap with 84 swap steps in alternating patterns. These swaps manipulated balances to specific boundaries where StableMath._calculateInvariant() accumulated maximum down-rounding bias, causing the invariant D to be underestimated and reducing BPT price. Observable Effect:
  • osETH/WETH-BPT pool rate: approximately 1.027e18 to 20.189e18 (~19.7x change)
  • wstETH/WETH-BPT pool rate: approximately 1.051e18 to 3.887e18 (~3.7x change)

Transaction 2: Value Extraction

The attacker withdrew accumulated internal balances via manageUserBalance(WITHDRAW_INTERNAL).

Root Cause (Technical Detail)

While not necessary for the assertion, understanding the root cause provides context: Balancer V2’s StableMath._calculateInvariant() uses repeated down-rounding operations on scaled balances:
// Simplified invariant calculation
D_P = Math.divDown(Math.mul(D_P, invariant), Math.mul(balances[j], numTokens));
invariant = Math.divDown(...);  // Multiple divDown operations compound precision loss
Under specific conditions:
  • Mixed token decimals amplify precision loss
  • Balances maintained near rounding boundaries maximize bias
  • Repeated operations in a long batchSwap sequence compound the error
  • The computed invariant D becomes progressively underestimated
The attacker’s math helper contract (0x679b3) searched for inputs that would push denominators toward zero without triggering reverts.

The Assertion Solution

Rather than attempting to detect the specific exploit mechanism, we assert a simple invariant: Pool rates must not change drastically within a single batchSwap call.

Implementation

contract BatchSwapDeltaAssertion is Assertion {
    uint256 constant MAX_RATE_CHANGE_MULTIPLIER = 3e18; // 3.0x
    uint256 constant MIN_RATE_CHANGE_MULTIPLIER = 33e16; // 0.33x

    function assertionBatchSwapRateManipulation() external {
        address vault = ph.getAssertionAdopter();
        PhEvm.CallInputs[] memory batchSwapCalls = ph.getAllCallInputs(
            vault,
            IVault.batchSwap.selector
        );

        for (uint256 i = 0; i < batchSwapCalls.length; i++) {
            // Decode swap parameters to get affected pools
            (, IVault.BatchSwapStep[] memory swaps, , , , ) = abi.decode(
                callInput.input,
                (IVault.SwapKind, IVault.BatchSwapStep[], IAsset[],
                 IVault.FundManagement, int256[], uint256)
            );

            bytes32[] memory uniquePoolIds = _getUniquePoolIds(swaps);

            for (uint256 j = 0; j < uniquePoolIds.length; j++) {
                (address poolAddress, ) = IVault(vault).getPool(uniquePoolIds[j]);

                // Check rate before and after the swap
                ph.forkPreCall(callInput.id);
                uint256 preRate = IRateProvider(poolAddress).getRate();

                ph.forkPostCall(callInput.id);
                uint256 postRate = IRateProvider(poolAddress).getRate();

                uint256 rateChangeMultiplier = (postRate * 1e18) / preRate;

                // Revert if rate changed by more than 3x
                require(
                    rateChangeMultiplier <= MAX_RATE_CHANGE_MULTIPLIER &&
                    rateChangeMultiplier >= MIN_RATE_CHANGE_MULTIPLIER,
                    "BatchSwap: Extreme pool rate manipulation detected"
                );
            }
        }
    }
}

Why This Works

The assertion is root-cause agnostic:
  • Detects the known exploit (20x rate change far exceeds 3x threshold)
  • Would detect any future rate manipulation attack, regardless of technique
  • Doesn’t care about:
    • How the manipulation was achieved (rounding, overflow, logic bugs)
    • Whether it’s 84 swaps or 10 swaps
    • The specific math used in the exploit
    • Helper contracts or preprocessing techniques
The assertion simply enforces: “Pool rates don’t drastically change in one transaction”, a fundamental economic invariant of stable pools.

Threshold Selection

We chose 3x (300%) as the threshold based on the following analysis: Normal rate change behavior:
  • Single swaps: Cause < 10% rate changes due to swap fees (1.05x-1.10x maximum)
  • Proportional joins/exits: Maintain rate constant (1.0x - no change)
  • Imbalanced joins/exits: Similar to swaps, < 10% rate change maximum
  • Multiple operations in a batch: Could accumulate to ~1.5-2.5x in extreme cases with many maximum-sized operations
Exploit behavior:
  • The actual exploit showed 20x rate change (far exceeding any legitimate scenario)
Safety margin:
  • 3x provides a 1.2x-2x buffer above the most extreme legitimate batch swap scenarios
  • Significantly below the 20x change observed in the exploit
Potential edge cases:
  • Rate provider updates: If a pool uses yield-bearing tokens with rate providers, legitimate rates should change gradually over time. A 3x change in a single transaction would indicate either oracle manipulation or a severe underlying issue (slashing event, de-pegging) that warrants investigation.
  • Amplification changes: Limited to 2x per day by protocol, causing minimal per-transaction rate impact
Trade-off: The 3x threshold is aggressive enough to catch exploitation attempts while providing margin for legitimate operations. If false positives occur in production, the threshold can be increased to 5x or higher, though this would reduce sensitivity to smaller manipulation attempts. Disclaimer: This assertion is conceptual. Based on our analysis of the protocol mechanics, we have not discovered any legitimate operations that would cause rate changes exceeding 3x in a single transaction. However, we cannot guarantee that such edge cases do not exist. Thorough testing and analysis of real-world scenarios and edge cases is required before using this assertion in production.

Backtesting Results

We have built a custom backtesting framework that allows for running assertions against real-world historical transactions. This tool is excellent for testing assertions we develop against known exploits and it’s also what we used to verify the assertion’s accuracy. We backtested the assertion against the actual exploit transaction on Ethereum mainnet:
FOUNDRY_PROFILE=assertions pcl test --match-contract BatchSwapBacktest --ffi -vvv

Test Configuration

contract BatchSwapBacktest is CredibleTestWithBacktesting {
    address constant BALANCERV2_VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;
    uint256 constant END_BLOCK = 23717632;  // Mainnet exploit block
    uint256 constant BLOCK_RANGE = 1;

    function testBacktest_Balancer_BatchSwapOperations() public {
        executeBacktest({
            targetContract: BALANCERV2_VAULT,
            endBlock: END_BLOCK,
            blockRange: BLOCK_RANGE,
            assertionCreationCode: type(BatchSwapDeltaAssertion).creationCode,
            assertionSelector: BatchSwapDeltaAssertion.assertionBatchSwapRateManipulation.selector,
            rpcUrl: vm.envString("MAINNET_RPC_URL")
        });
    }
}

Results

==========================================
         BACKTESTING CONFIGURATION
==========================================
Target Contract: 0xba12222222228d8ba445958a75a0704d566bf2c8
Block Range: 23717632 to 23717632
Assertion Selector: 0x5f3ce91c
==========================================

Total transactions found: 3

=== TRANSACTION 1 ===
Hash: 0x3e173ab0ba9183efa8a42caa783bdb5ec75daffcc8505cc1302009d11daf1ccf
Function: 0x60e087db
---
Transaction gas cost: 14297996
Assertion gas cost: 299665

Assertion function reverted: BatchSwap: Extreme pool rate manipulation detected
[ASSERTION_FAIL] VALIDATION FAILED
---

=== TRANSACTION 2 ===
Hash: 0x6341ec5db92cab0cfd8c17bffab7b7194a591e20de8d14e6f5c5f0d338627a35
---
[SKIP] Assertion not triggered on this transaction
---

=== TRANSACTION 3 ===
Hash: 0xac5837a3b4c17893725c0155b6c0ee24590b1e504487400a7aa4bd927606b24c
---
[SKIP] Assertion not triggered on this transaction
---

==========================================
           BACKTESTING SUMMARY
==========================================
Block Range: 23717632 - 23717632
Total Transactions: 3
Processed Transactions: 3
Successful Validations: 2
Failed Validations: 1

=== ERROR BREAKDOWN ===
Protocol Violations (Assertion Failures): 1
Unknown Errors: 0

Success Rate: 66%
!!! PROTOCOL VIOLATIONS DETECTED: 1
================================
Result: The assertion detected the exploit transaction, demonstrating how invariant-based protection can catch complex attacks, without requiring knowledge of the specific exploit mechanism.

Key Takeaways

  1. Invariants > Root Causes: By focusing on what should never happen (drastic rate changes) rather than how it might happen (rounding errors, helper contracts, specific exploit techniques), the assertion protects against both known and unknown attack vectors.
  2. Simplicity is powerful: A simple rate comparison is more effective than trying to detect complex exploit patterns like:
    • Multi-step swap sequences
    • Rounding boundary manipulation
    • Helper contracts
    • Specific balance configurations
  3. Economic invariants are robust: The assertion enforces an economic property (pool rates should be stable) that holds true regardless of the underlying implementation details or future protocol changes.
  4. One assertion, multiple protections: This same assertion would catch:
    • Oracle manipulation attacks on pool rates
    • Future rounding error exploits
    • Integer overflow/underflow affecting rates
    • Any logic bug causing rate distortion

References