$8.4M Gone in 3 Steps: How a Rounding Error Became Bunni’s Nightmare
9. October 2025
On September 2nd, an attacker turned Bunni's innovative math against itself, draining two pools across Ethereum and Unichain and stealing $8.4M in under 5 minutes. No fancy vulnerabilities, just a price manipulation and 44 carefully crafted micro withdrawals that exploited a precision bug everyone missed, even after three audits found 125+ other issues.
This is the story of how a protocol ignored explicit warnings to "stop scaling" and paid the ultimate price. It's also a wake up call for every developer who's ever shipped code they were 99% sure about.
Timeline
- [2025-Sep-02, 4:35AM UTC] Attacker steals $6M on Unichain
- [2025-Sep-02, 4:38AM UTC] Attacker steals $2.4M on Ethereum
- [2025-Sep-02, 5:05AM UTC] BlockSec's Phalcon alerts CT about a suspicious transaction
- [2025-Sep-02, 6:43AM UTC] Hacken alerts that funds have been bridged to Ethereum
- [2025-Sep-02, 6:53AM UTC] KyberSwap’s CEO Victor Tran provides the first deeper insight
- [2025-Sep-02, 7:04AM UTC] Bunni pauses the protocol
- [2025-Sep-02, 9:28AM UTC] William Li spots the root cause
- [2025-Sep-03, 11:45PM UTC] Cyfrin’s Giovanni Di Sienna provides a deep analysis
- [2025-Sep-04, 9:37PM UTC] Bunni unpauses withdrawals and publishes the post-mortem
The Audit Situation
Bunni wasn't some fly-by-night protocol. They'd been audited three times by top tier firms:
- Pashov, twice August and October 2024, resulting in combined 66 issues, including 9 criticals and 8 highs
- Trail of Bits in January 2025, pointing out the rounding errors and the overall arithmetic complexity of the system, making it hard to verify
- Finally, Cyfrin in June 2025
- "Cyfrin's June 2025 audit found 50+ issues and warned explicitly: 'Considering the number of issues identified, it is statistically likely that there are more complex bugs still present... it is recommended that a follow-up audit and development of a more complex stateful fuzz test suite be undertaken prior to continuing to deploy significant monetary capital to production.'"
Cyfrin essentially told Bunni to not scale until they’ve done more security work. The protocol had already deployed, but the recommendation was clear, additional audits before continuing to deploy significant monetary capital.
But, Bunni chose speed over security.
Understanding Bunni's Dual Balance Architecture
Bunni is a Uniswap V4 hook that introduces Liquidity Density Functions (LDFs), a novel approach to liquidity management. Unlike standard AMMs, Bunni separates pool balances into two types:
- Active Balance: Used for swaps, provides liquidity
- Idle Balance: Not used for swaps, can be rehypothecated (reused) to lending protocols
The total liquidity is computed using the active balances and their corresponding density values from the LDF.
// QueryLDF.sol
uint256 balance0 = totalBalance0 - idleBalance0; // active balance only!
uint256 balance1 = totalBalance1 - idleBalance1;
...
totalLiquidityEstimate0 = balance0 * Q96 / totalDensity0X96;
totalLiquidityEstimate1 = balance1 * Q96 / totalDensity1X96;
totalLiquidity = min(estimate0, estimate1); // "Conservative" approach, underestimating is deemed as fine
The Vulnerability: Rounding in the Wrong Direction
The core issue was in BunniHubLogic::withdraw()
:
// decrease idle balance proportionally to the amount removed
{
(uint256 balance, bool isToken0) = IdleBalanceLibrary.fromIdleBalance(state.idleBalance);
>>> uint256 newBalance = balance - balance.mulDiv(shares, currentTotalSupply);
if (newBalance != balance) {
s.idleBalance[poolId] = newBalance.toIdleBalance(isToken0);
}
}
This line updates the idle balance when users withdraw. The mulDiv
function rounds down, which was intentional and assumed to be a correct rounding direction, but it has created a critical flaw when combined with Bunni's liquidity calculation logic.
Attack Breakdown with Real Numbers
Step 1: Price Manipulation through a Flashloan
We will be using the attacker’s Ethereum transaction in this example ($2.4M stolen.
The attacker borrowed 3M USDT and swapped those aggressively to drain the USDC side down to just 28 wei
.
// ===== Before flashloan swaps =====
USDC balance: 1,500,000e6 (active)
USDT balance: 1,500,000e6 (active)
-> Pool tick: -276 (1 USDC ≈ 1 USDT)
Total liquidity: 5.83e16
// ===== After flashloan swaps =====
USDC balance: 28 wei (active) // <--- dust!
USDT balance: ~4,500,000e6 (active)
-> Pool tick: 5000 (1 USDC = 1.68 USDT)
Total liquidity: Still ~5.83e16
Step 2: The Precision Attack
The attacker made 44 tiny withdrawals, decreasing the active balance even further, but keeping idle balances intact due to the rounding error.
newIdleBalance = idleBalance - idleBalance.mulDiv(shares, totalSupply);
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Rounds DOWN to 0 for small withdrawals
Over the 44 withdrawals:
- USDC’s active balance went from
28 wei
down to4 wei
(86% decrease) - USDC’s idle balance didn’t change significantly, due to rounding errors
- Total liquidity went from
5.83e16
down to9.114e15
(84% decrease)
Even though idle balance wasn’t used directly in swaps, it still participates in the liquidity calculations, and because it wasn’t updated properly, it created an accounting error.
The pool now thinks it has much less liquidity than it actually does (because the idle balance is intact and the total liquidity depends only on the active liquidity).
Step 3: The Sandwich Attack
- Swap 1: With liquidity artificially reduced, the attacker executed a large
USDT → USDC
swap, and because pool thinks it has low liquidity, the price impact was massive, and the exchange rate became1 USDC = 2.77e36 USDT
. - The pool now prefers to use the other token for computing liquidity, so it switches to
token1
(USDT) side, which “restores” thetotalLiquidity
back to1.06e16
. - The attacker then sandwiched the new liquidity increase by performing another swap in the opposite direction, so
USDC → USDT
, with less of a price impact. - The two swaps yielded ~$2.3M ($1.3M USDC + $1M USDT) after repaying the flashloan.
// QueryLDF.sol
uint256 balance0 = totalBalance0 - idleBalance0;
uint256 balance1 = totalBalance1 - idleBalance1;
...
totalLiquidityEstimate0 = balance0 * Q96 / totalDensity0X96;
totalLiquidityEstimate1 = balance1 * Q96 / totalDensity1X96;
>>> totalLiquidity = min(estimate0, estimate1); // <--- switches to estimate1
Why the Idle Balance Matters
- Withdrawals should decrease both balances proportionally.
- The bug meant that idle balance stayed high while active balance plummeted. Since only active balance determines liquidity, the attacker gained precise control over the pool’s perceived liquidity, and therefore its pricing.
One Liner Fix
The entire $8.4M exploit could have been prevented by changing a single line:
- newIdleBalance = idleBalance - idleBalance.mulDiv(shares, totalSupply);
+ newIdleBalance = idleBalance - idleBalance.mulDivUp(shares, totalSupply);
By rounding up the amount to subtract, every withdrawal, no matter how tiny, reduces the idle balance by at least 1 wei
, keeping the active/idle
ratio honest.
Closing Thoughts
Bunni's story isn't just another hack; it's a masterclass in ignored warnings becoming prophecies. Cyfrin literally spelled it out: "Considering the number of issues identified, it is statistically likely that there are more complex bugs still present." They didn't find this specific bug, but they predicted its inevitability.
Your protocol might be next. Not because you're careless, but because you're confident. Every unaudited line of code, every skipped fuzz test, every "we'll fix it in v2" is a ticking time bomb.
Even though Bunni has recently put a 108 ETH bounty on the attacker, it could take them a while before they are able to rescue any stolen funds.
Math doesn't care about your roadmap. Rounding errors don't respect your TVL.