- Published on
Inflation Attacks in Defi Protocols
When first depositors turn into attackers
- Authors
- Name
- 🐰 r4bbit
- @0x_r4bbit
After exploring some of the more basic smart contract attacks such as Single-Function Reentrancy and Force-Feeding, I started to look a bit more under the hood of defi protocols. Reading how Uniswap V2 is implemented, I've learned about a possible attack vector, where first depositors of a liquidity pool are be able to make it harder for other liquidity providers to enter the pool by inflating the token shares. This is called an inflation attack and it's not the only scenario in which first depositors can exploit similar protocols. In this post we'll look at some variations of inflation attacks and how to reduce the likelyhood of them happening.
Some Uniswap fundamentals
Inflation attacks are typically a concern in protocols that implement vaults, such as EIP4626. More generally however, pretty much any protocol that allows accounts to deposit funds and have them get some sort of share or proof of deposit in return, is potentially vulnerable. This includes protocols like Uniswap, which purely rely on accounts providing liquidity to enable trades for other accounts. Let's start off with that.
As you might be know, Uniswap is a decentralized exchange and automated market maker ("AMM"). Users create liquidity pools of token pairs, provide these pools with liquidity, and in return they get shares, or in this case, liquidity pool tokens (also known as "LP tokens"). These shares can later be redeemed to get the underlying liquidity out of the protocol. The amount of liquidity returned by the protocol is proportional to the amount of shares, relative to the total amount of shares created by the protocol.
So to keep things simple, if you own 50% of all shares for a pool, then you're entitled to get 50% of total liquidity that lives in the pool by redeeming your shares. There are also trading fees that accrue over time which are added to liquidity pools and in fact one of the incentives why one would provide liquidity in the first place, however that's a detail we'll ignore for a second.
Ultimately, the pool is going to generate these shares and this is where things get interesting. There's two cases that need to be covered:
- The pool is empty and was just created by a user who provided some liquidity. In this case, the user should get 100% of shares, because she owns 100% of the liquidity.
- The pool already has some liquidity and a user provides additional liquidity. In this case, the amount of shares needs to be calculated based on the total shares in circulation and the amount of liquidity that's being provided.
Below is an excerpt of a simplified function that does that:
if (totalSupply == 0) {
shares = Math.sqrt(amount0 * amount1)
} else {
shares = Math.min(
(amount0 * totalSupply) / reserve0,
(amount1 * totalSupply) / reserve1
);
}
A few things to note here:
totalSupply
is the total supply of the liquidity token shares, if it's0
it means the pool has not generated any shares yetshares
is the amount of shares that will be minted for the liquidity provideramount0
andamount1
are the amounts for both tokens of this pool (e.g USDC and DAI) provided by the userreserve0
andreserve1
are the amounts of these tokens that already live in the pool
Let's run through both cases. If the pool is empty, the liquidity provider gets Math.sqrt(amount0 * amount1)
shares. We're taking the square root of the product to reduce the chances of imbalanced liquidity ratios for initial deposits.
So if Alice provides 10 units of token A and 10 units of token B, then Alice gets 10 units of liquidity shares:
shares = Math.sqrt(amount0 * amount1) = Math.sqrt(100) = 10
And just to proof that this is correct, if we reverse this, we can then also calculate how many tokens Alice will get when she redeems her shares:
tokenA = (shares * reserve0) / totalSupply = (10 * 10) / 10 = 10
tokenB = (shares * reserve1) / totalSupply = (10 * 10) / 10 = 10
Okay cool. Now for the second case, where the liquidity pool already has liquidity, the shares are calculated differently.
shares = Math.min(
(amount0 * totalSupply) / reserve0,
(amount1 * totalSupply) / reserve1
);
Assuming there are already 200 units of token A (reserve0
) and 200 units of token B (reserve1
), which results in a totalSupply
of 20 shares, if Alice then provides 100 units for each token respectively, the calculation would be as follows:
shares = (100 * 20) / 200 = 10
This would increase the totalSupply
to 30 of which 20 belong to other liquidity providers and 10 belong to Alice. Great, now we know how Uniswap fundamentally works.
How can this be exploited?
Understanding Inflation Attacks
The idea of an inflation attack is that an attacker is able to artificially inflate a token value and then weaponizes the price impact of that token. In the case of the Uniswap scenario above, without further protection, an attacker could create a liquidity pool with very low quantities of tokens, resulting in very low quantities of returned shares. Then, by sending a large amount of tokens to the pool directly, the value of a share would be heavily inflated, making it harder for others to provide liquidity as well. Let's play this through.
- Say we have a pool of token A and token B and the pool does not have any liquidity yet.
- Attacker adds liquidity to the pool with the smallest amount possible (1 wei). According to our formula mentioned earlier, that would result in 1 LP token.
- Attacker then sends large amount of tokens to the pool contract (say
1000e18
units) and then updates the reserves of the pool
Here's a super simplified version of what such a token pool contract could look like:
contract TokenPair is ERC20 {
address tokenA; // these are underlying tokens in the pool
address tokenB;
uint256 reserve0; // these are the amounts of tokens this pool has
uint256 reserve1;
...
}
Again, super simplified, but the fundamental idea is that a TokenPair
knows about the two token addresses that live in the pool. The reserves keep track of how many tokens of each underlying token live in the pool.
Also, notice how TokenPair
implements ERC20
. This is because the contract itself represents the shares token that is handed out to liquidity providers when they supply the pool with funds.
Speaking of which, when a user provides the pool with liquidity, the contract has to generate these shares based on the amount of liquidity that goes in, proportionally to the total amount of liquidity that exists in the pool.
Below is a mint()
function that pretty much does what we've discussed earlier. Again, simplified for the purpose of this post:
contract TokenPair is ERC20 {
...
function mint() public {
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));
uint256 amount0 = balance0 - reserve0;
uint256 amount1 = balance1 - reserve1;
uint256 shares;
if (totalSupply == 0) {
shares = Math.sqrt(amount0 * amount1);
} else {
shares = Math.min(
(amount0 * totalSupply) / _reserve0,
(amount1 * totalSupply) / _reserve1
);
}
if (shares <= 0) revert InsufficientLiquidityMinted();
_mint(msg.sender, shares);
_reserve0 = balance0;
_reserve1 = balance1;
}
}
There are a few things happening here:
- The
mint()
function expects that the liquidity has already been sent in a transaction earlier. So it calculates the new liquidity (amount0
,amount1
) by getting the difference between what the pool owns (balanceOf(address(this))
) versus what's tracked in the reserves. - It then calculates the amount of
shares
using the formula we've discussed earlier. - Lastly, it mints the shares to the account and updates the internal reserve tracking according to the new liquidity.
In addition, the contract has a function to sync the underlying token balances with the reserves:
function sync() external {
_reserve0 = IERC20(token0).balanceOf(address(this));
_reserve1 = IERC20(token1).balanceOf(address(this));
}
Next, let's imagine an attacker adds liquidity to the pool with the smallest amount possible and the pool is empty. In the case of the underlying pool tokens, the smallest amount is 1 wei
.
Following our formula above, knowing that totalSupply = 0
, the attacker gets exactly one share:
shares = Math.sqrt(amount0, amount1) = Math.sqrt(1, 1) = 1
At this point, the reserves are set to 1
as well, as this is the balance that the pool owns for each underlying token.
To complete the attack, the attacker sends a larger amount of tokens to the pool, followed by a call to sync()
, which inflates the value of a single share in the contract. If the attacker sends, say, 1000e18
units to each token, the value of a single share is then 1000e18 + 1
.
This is now a problem for other accounts that want to enter the pool as a liquidity provider, because they have to add 1000e18 + 1
units to each underlying token to get a single share. If you wonder why 1000e18 + 1
and not just 1000e18
, it's because their share will then be rounded down to 0
, which would cause the transaction to revert.
In other words, the pool is now significantly more expensive for everyone else, possibly even unusable depending on what'st the value of the underlying tokens.
How to minimize the risk
What we've seen so far is just one example of an inflation attack. As mentioned earlier, there are other similar attacks that can be performed on vault-like protocols, especially protocols that make use of ERC-4626 vaults.
There are a few mitigation strategies that can at least reduce the risk of such an attack. One strategy that can be applied to vault-like contracts but also liquidity pools, is the usage of dead shares.
Remember that the core of the attack lies in the fact that it's possible to get a single share at a very low deposit (1 wei
). If getting a single share would be more expensive, inflating the value of a share would become significantly more expensive for the attacker as well.
So how do we ensure a single share is more expensive? By burning some amount of the first depositor's shares!
Recall, here's how the first depositor's shares are calculated:
if (totalSupply == 0) {
shares = Math.sqrt(amount0 * amount1);
}
With dead shares the first depositor has to sacrifice some amount of their shares. Let's say that amount is 10**3
.
uint public constant MINIMUM_LIQUIDITY = 10**3;
...
if (totalSupply == 0) {
shares = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
_mint(address(0), MINIMUM_LIQUIDITY); // burn 1000 shares
}
...
if (shares <= 0) revert InsufficientLiquidityMinted();
_mint(msg.sender, shares);
Notice what's happening here. 1000
shares will be burned on first deposit and enough liquidity has to be provided so that shares > 0
.
To reiterate on the earlier example, instead of depositing 1 wei
to each underlying token, the attacker now has to deposit 10**3 + 1 wei
to each token to get a single share. A single share just got 1000x more expensive, making a possible inflation attack 1000x more expensive as well!
Another approach to this is to have the protocol/project take the loss by having an initialization function that ensures the minimum amount of shares are burned accordingly.
When you read this and you think this doesn't actually prevent anyone from performing this attack, then you're right. It just makes it significantly harder and less feasible as it becomes more expensive to do. So it's much less likely to happen.
The usage of dead shares is the mitigation path that Uniswap V2 and some others have chosen as well. Here's a discussion around some other possible mitigation paths with their pros and cons.
Below are a few more resources for further digging and research.