All posts

Incident analysis· 8 min read

Rounding errors, amplified: how three 2025 exploits turned a few wei into $146M

Balancer V2, zkLend and Bunni were drained in 2025 by integer rounding that lost at most a unit or two per operation. Attackers shrank pools to a few wei, repeated the lossy step dozens of times in one transaction, and walked off with about $146M. Here is how, and how to design against it.

By Huynh Phan Gia Bao

Smart contracts have no decimals. Every price, share and interest rate is an integer, usually scaled by 10^18, and every division throws away a remainder. Most of the time that remainder is worth less than a millionth of a cent, so it is easy to treat as noise. In 2025, three protocols on three different chains learned that it is not noise when an attacker can choose the numbers, borrow unlimited capital for one transaction, and repeat the same step as many times as they like.

The OWASP Smart Contract Top 10 added Arithmetic Errors (rounding and precision) as a new category in its 2026 edition because of incidents like these. This post looks at the three largest, what they share, and what a protocol designer can do about it.

2-one-transaction

The pattern in one paragraph

Every rounding operation has a winner. If a vault rounds a user's shares down, the vault wins; if it rounds them up, the user wins. A single wrong choice is worth almost nothing, because the error is at most one unit. The attacker's job is to make that unit large compared with the balance (by squeezing a pool until a balance is a few wei, where one unit is 10% or more of it), and then to make the step repeatable, so the small edge compounds. Flash loans, or transient balances inside a batch, provide the capital to move a pool that far for a single transaction at no cost.

Case 1: Balancer V2, about $128M (3 November 2025)

Balancer V2's composable stable pools hold assets that should trade close to each other, such as ETH and liquid-staking tokens like osETH or wstETH. Because those tokens accrue value, the pool scales each balance by the token's exchange rate before running its stable-swap math. The helper that does this, _upscale, multiplies with mulDown, so it always rounds down, whichever direction the swap goes. A code comment assumed this was harmless because scaling factors would only ever be whole decimal adjustments. The exchange-rate override, added later in 2021, broke that assumption.

OpenZeppelin's write-up gives a clean example: with an amount of 17 and a scaling factor of about 1.058, the exact result is about 17.99, but rounding down returns 17, so the rate disappears completely. At a normal pool size this is invisible. At a balance of a few wei it means the pool charges the wrong amount for a swap.

The attacker used the Vault's batchSwap, which only settles net balances at the end, so it could effectively borrow pool tokens within the batch. According to Check Point Research, it first swapped balances down to the 8–9 wei range and then ran a three-step cycle (prime the pool, run the swap that loses to rounding, reset) 65 times in one batch. Each cycle pushed the pool's invariant, and therefore the price of its LP token (BPT), a little lower. The attacker bought BPT at the depressed price and redeemed it for the underlying assets at full value. In total about $128M left Balancer and its forks across several chains in under half an hour.

One detail makes this case useful for teaching. OpenZeppelin notes that its 2021 audits covered the original stable and weighted pools, which did not have the exchange-rate override, while the vulnerable pool types were added after that audit's scope. Four firms had reviewed Balancer V2, each covering different parts. The bug lived in the gap between a documented assumption and a later change that silently broke it.

Case 2: zkLend, about $9.5M (February 2025)

zkLend was a lending market on Starknet. Like most lending protocols it tracks deposits with an accumulator: your balance in "raw" units times the accumulator equals your claim on the pool. When you withdraw, the protocol burns amount / accumulator raw units, and integer division rounds that down.

The attacker started with a wstETH market that was empty. By donating to it and cycling deposits and withdrawals, they pushed the accumulator to an enormous value (about 4.07 × 10^18). Now one raw unit was worth a lot of wstETH, and the rounding mattered. In Halborn's walk-through, a withdrawal that should have burned 1.5 raw units burned only 1, leaving the attacker with 3 units instead of 2.5. Repeated, their raw balance grew to 1,724, which was then used to borrow and drain other markets in the protocol.

Two conditions made it possible: a market with almost no liquidity, which let the accumulator be pumped cheaply, and rounding on withdrawal that favoured the user instead of the protocol.

Case 3: Bunni v2, about $8.4M (2 September 2025)

Bunni is a liquidity manager built on Uniswap v4 hooks. Its pools split each token into an active balance used for swaps and an idle balance. When a user withdraws, Bunni reduces the idle balance with mulDiv, which rounds down. The developers had reasoned that rounding this way leaves the active balance slightly too low, and that lower liquidity means more price impact, which favours the pool. Taken alone, that reasoning is correct.

The attacker flash-borrowed 3M USDT and swapped it into the USDC/USDT pool on Ethereum until the active USDC balance was just 28 wei. Then came 44 tiny withdrawals. Each one rounded in the "safe" direction, but together they cut the active USDC balance from 28 to 4 wei, an 85.7% drop, while the pool's liquidity only fell 84.4%. Bunni estimates liquidity from both token balances and takes the smaller estimate. The withdrawals made the USDC-based estimate artificially small; a large swap then pushed the price so far that the estimate switched to the USDT side, and total liquidity jumped back up. The attacker sandwiched that jump, repaid the flash loan and kept roughly 1.33M USDC and 1M USDT from this pool. A second pool on Unichain was also hit.

Bunni's post-mortem sums it up in one line that every protocol designer should remember: all of the rounding directions involved were safe in isolation. The danger was in the sequence. Interestingly, the same attack did not work on a USDC/USD₮0 pool on Unichain, because there was not enough flash-loan liquidity on that chain to move the price far enough.

2-three-roundings

What the three cases share

Balancer V2zkLendBunni v2
ChainEthereum and othersStarknetEthereum, Unichain
Lossy stepScaling a balance by a rateBurning shares on withdrawReducing idle balance on withdraw
How the pool was squeezedSwaps down to 8–9 weiEmpty market plus donationsFlash-loan swap to 28 wei
Repetitions65 cycles in one batchMany deposit/withdraw cycles44 withdrawals
Loss~$128M~$9.5M~$8.4M

Four ingredients appear every time:

  1. A tiny balance. Rounding error is bounded in absolute terms, so it only becomes large relative to a balance near zero. Empty markets and squeezed pools are where it bites.
  2. A repeatable step. One rounding error is worthless; 44 or 65 of them are not.
  3. Free capital. A flash loan or a batch with deferred settlement lets an attacker move a pool to an extreme state and back within one transaction. This is why OWASP now describes flash loans (SC04) as a force multiplier for logic, oracle and arithmetic bugs rather than a bug of their own.
  4. A local argument for safety. In each case someone had a reason to believe the rounding was fine, and that reason was true for one operation in a normal state.

Designing against it

Round against the caller, per operation. For every division, ask who benefits from the remainder, and make it the protocol. Shares minted to a depositor round down; shares burned from a withdrawer round up; amounts the pool pays out round down; amounts the pool receives round up. Write this rule down next to the code, so later changes are checked against it.

// Withdraw: burn shares rounded UP so the remainder stays in the vault
uint256 shares = Math.mulDiv(assets, totalShares, totalAssets, Math.Rounding.Ceil);

// Deposit: mint shares rounded DOWN for the same reason
uint256 minted = Math.mulDiv(assets, totalShares, totalAssets, Math.Rounding.Floor);

Never let a pool get tiny cheaply. Burn a minimum amount of liquidity on the first deposit, or use virtual shares and assets in the exchange-rate formula, as OpenZeppelin's ERC-4626 implementation does against the related "inflation attack". Refuse operations that would leave a balance below a floor.

Check the invariant after every operation, not just at the end. Check Point's analysis of Balancer points to the lack of invariant-change validation. If a swap or withdrawal ever lowers the value per LP share, revert.

Test sequences, not calls. Bunni had unit tests and fuzz tests; the bug needed a specific sequence of dozens of calls after a large swap. Stateful invariant fuzzing, where the fuzzer chooses sequences of actions and an invariant such as "value per share never decreases" must hold after each one, is the tool that finds this class of bug.

Treat assumptions as part of the audit scope. Balancer's comment documented exactly the assumption that later failed. When an override, library port or new pool type touches math, the old assumptions need to be re-checked, and new contracts need to be inside some audit's scope.

Limit what one transaction can do. Caps on how far a single transaction can move balances, per-block limits on sensitive actions and monitoring that pauses a pool when its invariant moves sharply all reduce the damage when everything else fails.

Takeaway

None of these protocols had a classic bug like reentrancy or a missing access check. Each made one small, defensible choice about which way to round, and an attacker found a state where that choice was worth millions. For students, these incidents are also good exercises: each can be reproduced in a few hundred lines of Foundry tests, and doing so is the best way to see why "it's only one wei" is never a safety argument on-chain.

Sources