A deep dive inside Uniswap V2
Welcome to the in Depth DeFi series!
This series will be composed of several posts, each one will consist of the explanation of an individual protocol. The main purpose of this series is to understand from a first-principles approach how the top DeFi protocols work under the hood.
We will review them from a theoretical and practical level. This means that we will understand the theory behind the protocol, but we are also going to dive deep into the actual implementation (code).
In order to follow along, it is recommendable to have the following knowledge:
The post is divided into two main parts. The first one “System’s Components Overview” explains the theory and practice behind the most important components that make the system work.
The second part “System’s CORE Implementation” goes through the actual architectural implementation.
I believe that to truly understand a system in depth, you should be able to re-write it from scratch. Hopefully by the end of this post, you will be able to do that.
In order to start learning the system in a structured manner, it is important to ask ourselves some questions that are fundamental to the system:
Along the way, all the fundamental questions should be answered by understanding the protocol deeply.
Before starting, let’s quickly go to what Uniswap is:
Uniswap is a decentralized automated market maker protocol that allows anyone to swap token A for token B. The way automated market makers work is different from a traditional order book model. The core principle that differentiates an automated market maker (Uniswap) from a traditional centralized order book, is that the former runs permissionless code on the blockchain allowing anyone to participate.
Before moving on, there are a couple of things I want to say.
Let’s start !
To start getting a feeling of how the protocol works, we need to ask ourselves what is really happening when you are doing a simple swap. Of course, the purpose of the entire post is to explain this.
But at a basic level, you are interacting with a smart contract (pool) that “holds” reserves of two different tokens. For example, if you are swapping some WETH for USDC, you are interacting with the WETH/USDC smart contract pair (we will go into detail as to how this works later on). If you are interested, here is the WETH/USDC smart contract pair on Etherscan.
So if you are buying USDC with WETH, you are increasing the supply of WETH in the pool and reducing the supply of USDC, therefore, increasing the relative price of USDC vs WETH.
In the following sections, we will understand how the pool works (math behind it).
One important thing to keep in mind, is that UniswapV1’s pools were always traded against Eth. In other words, every pool was ETH/xToken.
In Uniswap V2, every pool is ERC-20/ERC-20. This later approach provides more flexibility to liquidity providers as they don’t need to rely 100% on ETH. So every time you are swapping ETH for another token, in reality, your ETH gets converted to WETH first.
Uniswap works by providing incentives to different participants to work with the system in order for them to profit. The main participants of the system are:
Before diving into the smart contracts, let’s understand the core concepts.
There are 3 basic components that we really need to understand in order to have a deep knowledge of Uniswap (and most of the DeFi protocols), they are the constant product formula, arbitrage and impermanent loss.
UniswapV2 charges a flat 0.3% fee per trade, we will see how this is calculated in future sections. The fee goes to the liquidity providers, this is to reward people for their liquidity. The protocol can also trigger a change that would give 0.05% to the Uniswap team, that part of the fee would be discounted from the LP’s instead of the traders.
A lot of the automated market makers work thanks to the constant product formula. It is a very simple yet powerful algorithm.
The constant product formula is the automated market algorithm that powers the Uniswap protocol (and a lot more AMM’s).
This formula simply states that the invariant k must remain unchanged regarding of the outflow / inflow of x and y.
In other words, you can change the value of x and y to whatever you want, as long as k remains the same.
What are x and y ?
x and y are the reserves of the tokens in the pool. For example, if you are swapping DAI for WETH, you are interacting with the DAI/WETH smart contract pool. The total amount of DAI that the contract holds would be x and the total amount of WETH would be y.
Simple enough, this is the algorithm that powers a lot of AMM’s.
In the actual implementation, the formula is a bit different.
Let’s see the real formula that Uniswap V2 uses.
Here you can find the full “swap” function implementation. But we are particularly interested in the following lines of code:
This piece of code is the real implementation of the constant product formula. Everything else you have read or seen, is just the theory. These lines of code are the bare metal implementation of the constant product formula.
There are 4 variables inside the require statement:
Of course, balance0Adjusted and balance1Adjusted can be treated inversely.
Let’s do an example to understand this better:
As you can see, the trader sent 1 WETH to the pool in exchange of 1500 DAI. He could have gotten more DAI for his WETH ! but the point here is to illustrate two things:
Here is actual code implementation of the previous example:
As you can see, things are multiplied by 1000. This is because there is no floating point numbers in Solidity, so this is necessary to represent the 0.3% fee. That is why the amount0In or amount1in are multiplied by 3 (to represent the 0.3%).
A very important thing to understand, is the maximum amount of tokens that you can get for a given input. NOTE: In Uniswap V2 Periphery you have all the helper functions to implement this math.
We have a DAI /WETH pool with the following reserves:
The maximum amount of DAI that we would get for 1 WETH (for this pool) is 4748.29.
The math we just did can also be represented in code (this function is from V2 Periphery):
It is very important to always get this math right in order to avoid getting less output tokens.
As a final note on this topic, you maybe wondering, what determines the variable k?
K is determined when the pool is first created, and then it grows every time there is a new trade. If we go to our past example, if a trader executes a swap just after the one we did, the k for his trade is going to be the newK of the prior trade.
Arbitrage is one of the most important concepts to understand how Uniswap works. Although, this concept is not unique to Uniswap, it applies to almost all of the DeFi projects. In order to understand this concept better, the first thing we need to ask ourselves is, how does Uniswap knows the price of a given token ?
For example, when you are swapping WETH for DAI in Uniswap, how does the protocol know that the price of WETH relative to DAI is x ?
The short answer is that Uniswap has no clue what the real price is. In reality, the price matches the outside world due to incentives.
The only thing that the protocol really enforces in order to make a successful swap, is the “constant product formula”.
Let’s go through an example.
Suppose there is a DAI/WETH pool in Uniswap. The price of WETH in Uniswap is trading at around 3000 DAI. We can get the price by dividing the amount of reserves of DAI by the amount of reserves of ETH (this is not the precise amount that you would get, we calculated that in the previous section).
Now, here comes the opportunity !
The price of WETH is trading at $3,200 at Coinbase. There is a difference of $200 usd between the WETH price of Uniswap and Coinbase. This will create an arbitrage opportunity, and immediately an arbitrageur (bot software) will buy WETH in Uniswap and sell it in Coinbase until the price matches.
This is how the prices in Uniswap are correlated with the outside world, by arbitrageurs that are constantly searching for an arbitrage opportunity. There are different types of arbitrage (we will not go into detail). But for the example shown, it is not an atomic arbitrage. In other words, the risks are higher because there is a probability that when the arbitrageur tries to earn a profit in Coinbase, the prices are already at par (because another arbitrageur did it first). That is why the safest arbitrage is done on-chain, because transactions are atomic, meaning, if something fails, the whole transaction reverts (minus gas fees).
So in conclusion, Uniswap does not know about the outside world prices, the prices are almost identical to the outside world thanks to the arbitrageurs.
Impermanent loss for liquidity providers is the change in dollar terms of their total stake in a given pool at a given time vs just holding the assets.**
**Let’s create an example to understand this better.
Imagine that Alice has some tokens in her wallet, so she wants to become a liquidity provider to earn some yield. For our example, she is going to create a new DAI/WETH pool in Uniswap. The current market price of WETH is $3,000, so to avoid arbitrage, she will need to initiate the pool by putting 3000 DAI for every WETH.
She will initiate the pool by putting 10 WETH and 30,000 DAI. With a total usd value of $60,000 (we need to keep this number in mind).
*To make it simple, we will not take into consideration the transaction fees.
Great, so now Alice became a LP of this DAI/WETH Uniswap pool. Now, let’s imagine that the price of WETH goes up to $4,687. As you should know by now, this will create a huge arbitrage opportunity, so immediately an arbitrageur will buy ETH in Alice’s pool until the price is at par with the outside market.
So after some arbitrage, Alice’s pool will look something like this (again, we are not considering the transaction fees):
As we can see, after some arbitrage now the pool has 8 WETH instead of 10 and 37,500 DAI instead of 30,000 (what Alice initially supplied). The total amount of the pool is now $75,000 ((8ETH * $4,687.5) + 37,500)).
Here comes the problem, if Alice would have just held the assets, she would have more money. Remember that initially she supplied the pool with 10 WETH and 30,000 DAI. So at the end, she had an impermanent loss of $1,875:
For this example we did not contemplate the profit from transaction fees (0.3%), but the concept remains unchanged.
The reason why it is called “impermanent” is because the loss only applies if the LPs sell at the present time, if the assets fluctuate, then that loss can be mitigated.
Hopefully the concept is clear by now! If still in doubts, I highly recommend watching this video.
Big amounts of liquidity is what makes Uniswap an attractive system to use. Without enough liquidity, the system becomes inefficient particularly for big trades relative to the total liquidity of the pool.
Side Note*: For big trades (relative to the pool), it is better to use an aggregator to decrease slippage as much as possible.*
Ok! So the first thing we need to understand is, how are LP tokens minted and burned?
When a liquidity provider provides liquidity to a pool, it receives the pool’s LP tokens proportionally to the amount of liquidity he provided.
Let’s unpack this.
A Uniswap pool is just a smart contract that “holds” certain amount of reserves (token x and y), these reserves are provided by the liquidity providers. But, this pool also has an in-house token called “LP token”. This token is unique to each pool, and the main purpose is to keep track of the liquidity each liquidity provider has injected. You can think of this token as a certificate of your liquidity. This token also accrues the 0.3% fee that Uniswap charges.
The smart contract pool imports “UniswapV2ERC20.sol” (you can find it here), which is basically a contract that has basic ERC20 functionality (minting, burning, transfer, etc..).
The LP tokens are “stored” inside of this UniswapV2ERC20.sol. Every time a liquidity provider provides liquidity, some amount of tokens are minted to his address. Inversely, every time the liquidity provider takes liquidity out, the LP tokens are burned.
So, how does this work ?
If we think about it from a high-level, a liquidity provider can a) provide liquidity and b) take out the liquidity. Uniswap implements this mechanism in two functions: mint() and burn().
Let’s first go with the mint function, you can find the full function implementation here.
In order to calculate the LP tokens that a liquidity provider receives, we first need to know if there is liquidity, or if it is the first time someone is providing liquidity.
Here is the piece of code (inside of the mint function) that we are interested in:
The MINIMUM_LIQUIDITY is a constant variable equal to 1000:
To understand this better, we are going to go through an example.
NOTE: In Ethereum, the currency for computation is WEI (ETH * 10 **18), this also applies to most of the ERC-20 tokens. For simplicity, we will write the numbers without the 18 zeroes, but every time there is a hardcoded number like MAXIMUM_LIQUIDITY = 1000, we will just accommodate that number instead.
Let’s suppose that Alice (a liquidity provider) just created a new DAI/WETH pool.
Before Alice, the total supply of the pool was 0, so the first thing that is going to happen is mint 1000 tokens to the address 0 (this is done to maintain a minimum liquidity):
Note: Every time there is a mint, the totalSupply variable gets updated:
We then get the amount of LP tokens that are going to be minted to the liquidity provider (Alice), for our example, the result was 141.42..
Again, this is the formula to determine that:
Great, so now our pool consists of the following:
Total supply means the total amount of LP tokens there are in circulation (do not confuse it with the x and y reserves).
Now, let’s imagine that Alice wants to withdraw every DAI and WETH she putted into the pool. For the sake of simplicity, let’s suppose that the reserves remain the same.
In order for Alice to remove her liquidity, she needs to call the burn function, but prior to that, she needs to transfer all her LP tokens to the pool’s address. Let’s understand how this works, here is the complete burn function implementation.
If we see line 187, the variable liquidity is the amount of LP tokens that the contract’s address hold. Hence, that is why I mentioned that Alice needs to transfer the LP tokens prior to the function call. Then, in line 191 and 192 are the amounts of token x and y to be transfered to Alice.
Following our example, let’s unpack this:
As you can see, amount0 = 10000 and amount1 = 2, that is the initial amount that Alice supplied !
After the amounts are calculated, they are transferred and then burned.
This is the magic behind LP tokens !
As a final note on this topic, if we remember, the protocol charges a 0.3% trading fee. This fee goes to the liquidity providers. The way the fee is technically accrued, is by increasing the reserves of x and y in every trade. This will positively impact the amount of LP tokens each liquidity provider holds (proportionally to their holdings).
If we go back to the burn function, these 2 lines of code are what calculate the amount of token x and y to send to the liquidity provider when he wants to exchange his LP tokens for the x and y tokens.
Every time a trader executes a swap, it needs to send a buffer of 0.3% relative to the size of the trade, that 0.3% increments the reserves of the pool. Therefore, increasing the value of balance0 and balance1 in the burn function.
Now that we have a solid understanding of the individual components, let’s go to the actual implementation.
This part will be much faster, as the core topics were already covered.
In simpler terms, V2-Core is the part of the protocol that implements the core features (swapping, minting, burning etc..). In contrastV2-Periphery is one layer up. It is a set of contracts and libraries that make the integration easier for the developers.
The Uniswap team also architected this modular codebase approach to reduce security critical vulnerabilities by having the bare minimum in V2-Core. In other words, they only implemented what is necessary in V2-Core so there is less code to audit (therefore more secure). All the helper functions were thrown to V2-Periphery.
We will only focus on V2-Core. The simple reason, is that if you know how V2-Core works, by default you also know how V2-Periphery works (but not the other way around).
Again, you can find the V2-Core repo here.
There are 3 main contracts:
Again, the factory contract is mainly responsible for creating new contract pairs (UniswapV2Pair.sol), here is the V2 factory contract on Etherscan.
In order to concentrate liquidity, there can only be one smart contract per pair. In other words, if there is a WETH/UNI pair contract already, the factory won’t allow to create the same pair. Of course, you can bypass that (by deploying the pair contract directly), but the core principle here is to concentrate liquidity as much as possible to avoid price slippage and have more liquidity.
Here is the function that creates pairs in UniswapV2Factory:
One thing that probably was still in question is, what determines the order of x and y ?
The pairs are grouped by hexadecimal order as you can see in line 25:
In line 27 it checks that the pair is unique:
In line 30-32 it creates the pair (UniswapV2Pair.sol) using create2:
Then it initializes the contract with the address of each token and adds them to an array and mapping.
Another important point is that the factory can turn on a fee to charge a percentage per swap(you can check that yourself, it is pretty trivial).
That’s it for the factory. It is a very simple and straight-forward factory implementation, nothing complicated.
UniswapV2Pair.sol is the foundation of UniswapV2, here is the contract.
This contract is responsible for handling unique pools (each pool = one contract).
The basic functionality of this contract is to swap, mint and burn. We already went in detail as to how this components work, so we will just give them one quick brush.
Swap(): The swap function is the star of Uniswap. It is the function that gets called every time you want to trade one token for another. You can see the full function implementation here. The basic task of this function is to enforce that newK is greater than or equal to k:
Mint(): The mint function is responsible for minting LP tokens every time a liquidity provider provides liquidity, you can find it here.
Burn(): The burn function is responsible of burning LP tokens every time a liquidity provider wants to take his assets out of the pool, you can find it here.
You made it!
You just learned how the engine behind most of the automated market makers work !
Flash loans are a very important topic that we did not cover in this post. The reason why is because we are going to cover it in depth in the next post about AAVE.
Hopefully you enjoyed it !
Any comments are kindly welcomed.