Dynamic AMM Incident Writeup
By: Ping Chen
Tags: Defi, Ethereum, Blockchain, Writeup

For the basics of Dynamic AMM (creamY), you may read the technical introduction from its original author.

Besides normal AMM, there are three key differences:

It’s a stablecoin-specific AMM There’s a Normalizer It uses the same logic as a swap to deal with add/remove liquidity

Stablecoin-Specific AMMs

If you’re familiar with Curve.fi, it might not be hard to understand Dynamic AMM: Stablecoin-specific bonding curve, ultra-low slippage, high capital efficiency. The only difference is Dynamic AMM is using the curve from BlackHoleSwap due to a license issue.

Normalizer

For stablecoin swaps, an extra step required is to normalize the figures. Take DAI-USDC swap for example: though 1 DAI = 1 USDC, they have different decimals, which is 18 for DAI and 6 for USDC. So the actual exchange rate of the underlying raw digit should be 1000000000000:1.

Also, some interest bearing tokens have their own exchangeRate. Tokens like iDAI and yCrv have an exchangeRate starts from 1 and gradually grows over time.

To enable the swaps between these tokens, a normalizer is introduced. It tells the DAMM DAI = 1, USDC = 1e12, yDAI = 1.05, etc. So a normalizer pretty much works like an onchain oracle, but its price info refers to an admin predetermined method, instead of honest reporters like Chainlink.

Add liquidity as a swap function

Rather than using hard computation in single-sided add liquidity function like Curve.fi and Balancer, Dynamic AMM uses a brutal approach to deal with it. If there are n kinds of tokens in the AMM, it assumes each of them in the pool worths 1/n of the total supply of LP token. Adding liquidity is considered as swapping an underlying token to the LP token.

LP minted by deposit X DAI = getInExactOut( X, DAI reserve, totalSupply() / n )

However, as long as remove liquidity also follows the same logic

DAI withdrawed by burning X LP = getInExactOut( X, totalSupply() / n, DAI reserve )

it causes the liquidity providers nearly never to make any profit.

Usually, LP makes a profit from AMM’s pool size growth. For example, you deposit 1 DAI + 1 sUSD in Uniswap to get 1 UNI-LP token. Since Uniswap is gathering a 0.3% fee from every trade, the ratio of LP-DAI-sUSD may grow from 1:1:1 to 1:1.05:1.05, which means the liquidity provider earns a 5% profit.

However, in Dynamic AMM’s case, LPs don’t make profit proportionally as the pool’s underlying assets grow.

Let’s say virtual price (the ratio of total reserves to total LP) gets doubled; there’s 1M LP token but 2M USD worth underlying in the pool. LP can only get ~1.02x back, which is much less than expected.

getInExactOut(1, 1000000, 2000000) ~= 1.02

Furthermore, because removing liquidity is not in the right proportion, the virtual price (LP-underlying ratio) grows even more.

Within a huge liquidity removal event (probably due to the end of liquidity mining), the virtual price raised dramatically from 1.68 to 13.3.

Fund removed = 2030549

LP burnt = 1985432

virtual price before = (3,529,006 / 2,098,032) = 1.68

virtual price after = (1,498,457 / 112,600) = 13.3

The rescue

As a result, even if all liquidity providers redeem, there will still be a lot of funds remaining in the pool. Even it’s still providing liquidity and generating fee, those funds may be considered permanently lost.

To “rescue” fund, cream.finance’s admin silently deployed a script with a series of operations.

It temporarily changes creamY’s oracle (the Normalizer) to a wrong one (in their words, an evil normalizer), which reports LP token’s value 100000x higher. Then it calls removeLiquidity() function to draw a huge amount of fund to Cream’s multisig with extremely low cost.

By doing so, creamY’s virtual price successfully comes back from $13 to $1.

The problem is, who does the fund extracted by this hack belong to? At this moment, since they haven’t made any announcement, it is hard to speculate the Cream team’s intention. Hope they will make the right decision.