Incident report

Balancer's EURe/sDAI pool on GnosisChain

Balancer's EURe/sDAI pool on GnosisChain has suffered heavy losses, caused by a misconfigured price cache, an issue compounded by overly concentrated liquidity and transaction fees that were too low.

The Balancer EURe/sDAI pool, created on GnosisChain on October 3, 2023, uses Composable Stable Pools version 2. This pool naturally follows the StableSwap formula popularized by Curve to manage slippage, but with a key difference: an oracle determines the asset ratio. Two oracles are utilized: the first, an off-chain oracle Chainlink, converts EURe to dollars, while the second, an on-chain oracle, retrieves the DAI to sDAI ratio.

Using an oracle is expected to allow liquidity providers to capture the divergence between two assets. This can only work if the assets in question have very low volatility between price updates. This mechanism might allow, for example, capturing a portion of the yield offered by sDAI in a DAI/sDAI pool, because the price of sDAI will slowly diverge from DAI. If the underlying asset's volatility is too high, the pool will offer takers a stale price. This discrepancy between the actual market price and the price offered by the pool can then be arbitraged by takers at the expense of liquidity providers. As we'll see, this is the main cause of losses generated by the pool.

In our case, we have two oracles. The DAI/sDAI one is fine; it yields around 10% annually, so its daily divergence is under 0.03%. That's no big deal since GnosisChain produces a block every 5 seconds. The EUR/USD oracle, though, can move by several percent in a matter of minutes. But is that actually an issue? Surely the price wouldn't shift that much in just 5 seconds?

Price divergence spot vs pool

To understand the problem clearly, it's important to grasp two main points:

First, how ChainLink's EUR/USD oracle functions: it only releases a new price if the market price has shifted by at least 0.1% from the previous one, and updates only occur during Forex market opening hours. This means the price discrepancy is always at least 0.1%. Furthermore, on weekends, the price isn't updated at all. Yet, even with the Forex market closed on weekends, EUR/USD still has a price, whether through traditional financial instruments or on (de)centralized cryptocurrency exchanges.

Second, Balancer's Composable Stable Pools use a caching mechanism so they don't have to retrieve a new price every block. The pool operator can configure the duration of this cache. The issue was that the pool operator had set this duration to 3 hours, and this was the setting up until April 7, 2025. Naturally, this caused significant divergences between the pool's price and the actual EUR/USD price. (The cache duration has now been set to 1 second, effectively meaning there's no cache.)

Loading...

As we can see on this graph, the pool price isn't actually updated over the weekend, leading to a significant difference between the pool price and the actual EUR/USD price. You can also observe the impact of the 3-hour cache on price divergence by looking at the weekday prices (feel free to explore the graph). If you look at the period after April 7, 2025, once the cache was disabled, you'll also be able to see the impact of ChainLink alone on price divergence.

Loading...

The graph illustrates the pool's cumulative profit or loss per basis point. This figure excludes fees paid to the pool and any potential slippage. This allows us to see the impact of the misconfigured cache by comparing realized losses before and after the correction. Even though the price cache correction drastically cut down on losses, a small loss still lingers. This is because ChainLink doesn't update the price for deviations of less than 0.1%, and updates are also paused on weekends. However, this remaining loss might be partially offset by fees and/or slippage.

Estimated total loss

Now that we've figured out why the pool lost money, it's time to calculate the total loss caused by this misconfiguration.

Loading...

The total loss amounts to $700,000. This figure, however, does not account for transaction fees and slippage collected by the pool. Once these are factored in, the net loss comes down to $370,000. It's also worth noting the cumulative P&L after deactivating the cache; the pool then incurs fewer losses from price divergence, though significant losses are still noticeable on weekends.

Recommendations

Deprecate EURe/sDAI pool. We've observed that even with the cache disabled, the pool keeps bleeding money over weekends because ChainLink's price feeds aren't updating. On top of that, GnosisChain's 5-second block time allows for quick and low-cost price arbitrage. Therefore, I recommend publicly addressing this issue and urging users to migrate their liquidity to an oracle-less solution.

Reduce the amplification factor. If the pool is not to be deprecated, I had recommended in the note I shared with kpk on April 9, 2025, reducing the amplification factor by a factor of 2 to 4 (from 1,000 down to 250-500) to mitigate the impact of arbitrage. The amplification factor has since been lowered to 500, and fees have been increased from 0.05% to 0.25%.

Compensate for the loss. GnosisPay, and by extension GnosisChain, has a community that, while admittedly modest in size, is notably loyal, as evidenced by its retention rates. This loss, although relatively small, has the potential to severely erode the trust this community places in Gnosis's products. I believe it would be in Gnosis's best interest to compensate the liquidity providers for all or part of this loss. Even though the pool was managed by Balancer, it was crucial to the smooth operation of GnosisPay. I am available to assist should you wish to implement a reimbursement strategy. I also want to disclose that I am one of the liquidity providers.

Appendix

To calculate the loss resulting from the price divergence between the pool and the spot price, I needed to obtain data for all swaps involving the pool.

Balancer vault swap event

I was unable to use the data provided by Dune, as it did not offer a complete picture of all transactions. Indeed, their data was limited to Swap events—specifically, EURe<=>sDAI swaps—thereby omitting liquidity provisions and withdrawals.

If liquidity is added or removed using both assets in proportion to their existing ratio in the pool, no swap occurs, and the operation is considered neutral. However, it's also possible to enter or exit the pool with a single asset, or even with both assets but in proportions that differ from the pool's ratio. Such an operation is effectively a swap.

Swap sDAI => BPT => EURe

For instance, consider this transaction: the taker added sDAI to the pool and withdrew EURe. In doing so, they performed an sDAI to EURe swap without an sDAI => EURe swap event being emitted.

To calculate swaps due to liquidity deposits and withdrawals, I had to determine the ratio between the Balancer Pool Token (BPT) and the two assets involved (sDAI and EURe). This ratio can typically be obtained via getters functions. However, a problem emerged: a single transaction can perform multiple operations with the pool. This made it impossible to simply call the functions before the transaction, as the values could change significantly within that same transaction.

Pseudocode to obtain swap value(sDAI => EURe) from onJoinPool() with sDAI only

To overcome this limitation, I first needed to retrieve the onSwap(), onJoinPool(), and onExitPool() calls using trace_filter. Then, it was necessary to use trace_replayTransaction to get the SLOAD and SSTORE operations for that subtrace. This allowed me to manually calculate the ratio and, from there, determine the exact proportions of sDAI and EURe for each BPT sent or received.