Uniswap V1 Smart Contract Breakdown
On the one hand, you have an introduction to basic concepts and an overview of the Uniswap Protocol.
And, on the other hand, you have a smart contract breakdown along with a graphical guide for understanding the main functions of the Uniswap Protocol. For example, how the price works and how the liquidity pools work.
NOTE: The following article brings together all the notes that I have been collecting from various sources to understand how Uniswap works in its first version. If the article isn’t enough for you, I recommend going down the rabbit hole with the sources mentioned in the footnotes.
What Is Uniswap?
Basic Concepts
Descentralized Exchanges: allow its users to mantain control of their funds, users don’t give up control of their private keys and, their orders are executed on a blockchain.1
Centralized Exchanges: coordinate the funds of its users, users don’t retain the control of their private keys and, their orders are logged on an internal database.1
Traditional Exchanges: use a central limit order book, a list of buy and sell orders for different assets consisting of volumes and prices. And, they directly match up buyers and sellers and announce current market prices based on the last price an asset sells for.2
Automated Market Maker: is a general concept that embraces different market maker algorithms.3
A Constant Product Market Maker is a class of Automated Market Maker.3
Constant Product Market Maker: is a type of decentralized exchange protocol that establish a pre-defined set of prices based on the available quantities of two or more assets based on a constant function and, it allows anyone to provide liquidity to the markets.34
The term constant function refers to the fact that any trade must change the reserves in such a way that the product of those reserves remains unchanged.3
The Uniswap Protocol
The Uniswap Protocol to serve as an alternative to Centralized Exchanges it must have liquidity, because whitout liquidity trades are not possible.5
There are two ways to get liquidity:
- Developers must put their own money or money of their investors to become market makers
- Allow any user to be a market maker
The first option is not practical because they would be the only market makers and this would make the DEX centralized. In adition, they would need to provide liquidity for all pairs, they would need a lot of money.5
The second option is what makes the Uniswap Protocol an Automated Market Maker, because any user can provide their funds as liquidity to a trading pair and benefit from it.5
Unlike Traditional Exchanges, the Uniswap Protocol does not have a centralized matchmaker. In other words, traders trade against a pool of assets, there is no need to have another trader to make a trade.3
In short:
The Uniswap Protocol is a Descentralized Exchange that implements an Automated Market Maker design, which is a Constant Product Market Maker, that allows its users to interact with a smart contract whose liquidity is provided by other users and, satisfies the following constant function:
How Does Uniswap V1 Works?
The Uniswap Protocol: A suite of persistent, non-upgradable smart contracts that together create an automated market maker, a protocol that facilitates peer-to-peer market making and swapping of ERC-20 tokens on the Ethereum blockchain.6
Price Function
A Constant Product Market Maker satisfies the following constant function:
Where x is ETH reserve, y is ERC20 token reserve (or vice versa), and k is a constant.5 Trading any amount of either asset must change the reserves in such a way that their product remains equal to the constant k.3
Every trade increases a reserve of either ETH or ERC20 token and, decreases a reserve of either ETH or ERC20 token.5
Where $\Delta x$ is the amount of ETH or ERC20 tokens we’re trading for $\Delta y$, amount of ERC20 tokens or ETH we’re getting in exchange.5
The pricing function is as follows:
The Uniswap Protocol uses this price function to ensure that after each trade k remains the same.5
This constant product function forms a hyperbola when plotting two assets, which has a desirable property of always having liquidity as prices approach infinity on both sides of the spectrum.3
This makes reserves infinite and, this is the mechanism that protects pools from being drained.5
But this constant product function has a drawback, it causes price slippage, in which an user receives a different trade execution price than intended.7 The bigger the ammount of tokens traded in relative to reserves, the lower the price would be.5
In the other hand, a Constant Sum Market Maker satisfies the following constant function:
This constant sum function forms a straight line, it crosses x and y, which means it allows 0 in anye of them.5
While this function produces zero slippage, it does not provide infinite liquidity and therefore an arbitrageur is likely to drain one of the reserves.
In short, the Uniswap Protocol uses a constant product function, as a price function, to calculate exchange rates.
Overview of Uniswap’s Contracts
There are two different types of contracts:
- Factory: it creates new Exchange contracts, serves as a public registry and is used to look up all token and exchange addresses added to the system.8
- Exchange: it holds reserves of both ETH and its associated ERC20 token and, allows anyone to become a liquidity provider and benefit from it by contributing to its reserves.8
Providing Liquidity
Adding Liquidity
Anyone can become a liquidity provider on an exchange and contribute to its reserves. It requires depositing an equivalent value of both ETH and the relevant ERC20 token.8
Each Exchange contract is used to pool both Ether and a specific ERC20.9
Liquidity Tokens
An internal “pool token” (ERC20) is minted when liquidity is deposited into the system and, can be burned at any time to withdraw a proportional share of the reserves.8
Removing Liquidity
Liquidity Providers can burn their Liquidity Tokens at any time to withdraw their proportional share of ETH and ERC20 tokens from the pools.
ETH and ERC20 tokens are withdrawn at the current exchange rate, not the ratio of their original investment. This means some value can be lost from market fluctuations.10
That is called Impermanent Loss, the divergence in price between the time liquidity is deposited and withdrawn.11
Swaps
Fees
Liquidity Providers have to be incentivized, otherwhise they would not benefit from providing liquidity. In order to incentivize them, there is a 0.3% fee for swapping between ETH and ERC20 tokens (ETH $ \leftrightarrows $ ERC20 Trades).
The fee is split by liquidity providers proportional to their contribution, and are immediately deposited into liquidity reserves whitout minting new Liquidity Tokens.10
ETH $ \leftrightarrows $ ERC20 Trades
There are two possible swaps:
- ETH $ \rightarrow $ ERC20 Token: when trading ETH for an ERC20 token, ETH is sent to the contract’s pool and the ERC20 token is given back to the user.9
- ERC20 Token $ \rightarrow $ ETH: when trading an ERC20 token for ETH, the ERC20 is transferred from the user’s balance to the contract’s balance and ETH is given back to the user.
The amount that is returned from swapping is based on a constant product function which automatically adjusts prices based off the relative sizes of the two reserves and the size ot the incoming trade.12
ERC20 $ \leftrightarrows $ ERC20 Trades
Since ETH is used as a common pair for all ERC20 tokens, it can be used as an intermediary for direct ERC20 to ERC20 swaps.
- ERC20 Token A $ \rightarrow $ ETH, ETH $ \rightarrow $ ERC20 Token B: it is possible to convert ERC20 token A to ETH on one exchange and then from ETH to ERC20 token B on another within a single transaction.10
Since ERC20 to ERC20 trades include both an ERC20 to ETH swap and an ETH to ERC20 swap, the fee is paid on both exchanges.10
Uniswap V1 Smart Contract Breakdown
Although the core contracts of the Uniswap Protocol are written in Vyper, the code below is based on the following articles by Jeiwan:
- Programming DeFi: Uniswap. Part 1
- Programming DeFi: Uniswap. Part 2
- Programming DeFi: Uniswap. Part 3
NOTE: Instead of using the ERC20 contract from OpenZeppelin, here I’m using the ERC20 contract from solmate. And, instead of using Hardhat for contract developing and testing, I’ll be using Foundry.
Factory
The Factory Contract can be used to create Exchange Contracts for any ERC20 token that does not already have one.
- createExchange: create, deploy and register an Exchange Contract by taking an ERC20 token address.
And, it also functions as a registry of ERC20 tokens that have been added to the system, and the Exchange with which they are associated.13
- tokenToExchange: data structure to store exchanges.
- getExchange: find the Exchange address associated with an ERC20 token address.
contract Factory {
mapping(address => address) public tokenToExchange;
function createExchange(address _tokenAddress) public returns (address) {
require(_tokenAddress != address(0), "invalid token address");
require(tokenToExchange[_tokenAddress] == address(0), "exchange already exists");
Exchange exchange = new Exchange(_tokenAddress);
tokenToExchange[_tokenAddress] = address(exchange);
return address(exchange);
}
function getExchange(address _tokenAddress) public view returns (address) {
return tokenToExchange[_tokenAddress];
}
}
Exchange
There is a separate Exchange Contract for every ERC20 token.13
Constructor
It holds reserves of both ETH and its associated ERC20 token.
Any ETH sent or withdrawn is updated to the Exchange contract balance, and any ERC20 token sent or withdrawn is updated to the ERC20 token contract balance.
- tokenAddress: address of the ERC20 token traded on this Exchange Contract.
- getReserve: it returns the ERC20 token balance of an exchange.
contract Exchange {
address public tokenAddress;
constructor(address _token) {
require(_token != address(0), "invalid token address");
tokenAddress = _token;
}
}
function getReserve() public view returns (uint256) {
return IERC20(tokenAddress).balanceOf(address(this));
}
Providing Liquidity
It allows anyone to become a liquidity provider and benefit from it by contributing to its reserves.
Adding Liquidity
It requires depositing an equivalent value of both ETH and the relevant ERC20 token.8
- reserves are empty: the first Liquidity Provider to join a pool sets the initial exchange rate by depositing what they believe to be an equivalent value of ETH and ERC20 tokens.10 And, the amount of Liquidity Tokens issued equals to the amount of ETH deposited.14
function addLiquidity(uint256 _tokenAmount) public payable returns (uint256) {
if (getReserve() == 0) {
IERC20 token = IERC20(tokenAddress);
token.transferFrom(msg.sender, address(this), _tokenAmount);
uint256 liquidity = address(this).balance;
_mint(msg.sender, liquidity);
return liquidity;
} else {
// omitted
}
}
- reserves are not empty: all future Liquidity Providers deposit ETH and ERC20’s using the exchange rate at the moment of their deposit.10 And, the amount of Liquidity Tokens issued is proportional to the amount of ETH deposited.14
function addLiquidity(uint256 _tokenAmount) public payable returns (uint256) {
if (getReserve() == 0) {
// omitted
} else {
uint256 ethReserve = address(this).balance - msg.value;
uint256 tokenReserve = getReserve();
uint256 tokenAmount = (msg.value * tokenReserve) / ethReserve;
require(_tokenAmount >= tokenAmount, "insufficient token amount");
IERC20 token = IERC20(tokenAddress);
token.transferFrom(msg.sender, address(this), tokenAmount);
uint256 liquidity = (IERC20(address(this)).totalSupply() * msg.value) / ethReserve;
_mint(msg.sender, liquidity);
return liquidity;
}
}
Liquidity Providers must deposit at the current exchange rate to satisfy the constant product function.15
- tokenAmount: depositing ETH into reserves requires depositing an equivalent value of ERC20 tokens as well.10 And, this is calculated with the equation:
The exchange rate can change between when a transaction is signed and when it is executed on Ethereum.15
- require(_tokenAmount >= tokenAmount): _tokenAmount is used to bound the amount the exchange rate can fluctuate.
Liquidity tokens are minted to track the relative proportion of total reserves that each liquidity provider has contributed.15
- liquidity: the number of liquidity tokens minted is determined by the amount of ETH sent to the function.10 And, this is calculated with the equation:
Liquidity Tokens
An internal “pool token” (ERC20) is used to track each providers relative contribution.8
- is ERC20: this Exchange Contract is an ERC20 token.
- ERC20(“Uniswap-V1”, “UNI-V1”, 18): create this Exchange Contract as a Liquidity Token.
contract Exchange is ERC20 {
// omitted
constructor(address _token) ERC20("Uniswap-V1", "UNI-V1", 18) {
// omitted
}
}
Removing Liquidity
Liquidity Providers can burn their Liquidity Tokens at any time to withdraw their proportional share of ETH and ERC20 tokens from the pools.
- ethAmount: the amount of ETH withdrawn can be calculated using the equation:
- tokenAmount: the amount of ERC20 tokens withdrawn can be calculated using the equation:
function removeLiquidity(uint256 _amount) public returns (uint256, uint256) {
require(_amount > 0, "invalid amount");
uint256 ethAmount = (address(this).balance * _amount) / IERC20(address(this)).totalSupply();
uint256 tokenAmount = (getReserve() * _amount) / IERC20(address(this)).totalSupply();
_burn(msg.sender, _amount);
payable(msg.sender).transfer(ethAmount);
IERC20(tokenAddress).transfer(msg.sender, tokenAmount);
return (ethAmount, tokenAmount);
}
Swaps
It allows to exchange ETH to and from only one ERC20 token.5
Price Function
The Uniswap Protocol uses a constant product function, as a price function, to calculate exchange rates.
- getAmount: function used in trades and returns the exchange rate. It respects input amount and ensures that after each trade k remains constant.
- inputAmountWithFee: Jeiwan uses 1% as the fee.14
- (inputReserve * 100): Solidity does not support floating point division.
function getAmount(uint256 inputAmount, uint256 inputReserve, uint256 outputReserve)
private
pure
returns (uint256)
{
require(inputReserve > 0 && outputReserve > 0, "invalid reserves");
uint256 inputAmountWithFee = inputAmount * 99;
uint256 numerator = inputAmountWithFee * outputReserve;
uint256 denominator = (inputReserve * 100) + inputAmountWithFee;
return numerator / denominator;
}
ETH $ \leftrightarrows $ ERC20 Trades
When trading ETH for an ERC20 token, ETH is sent to the contract’s pool and the ERC20 token is given back to the user.9
- ethToTokenSwap: payable function that receives ETH and adds it to the contract balance, and calls ethToToken.
- ethToToken: it takes an address, recipient, to whom we want to send tokens. In this case, recipient is equal to msg.sender, the user.
function ethToToken(uint256 _minTokens, address recipient) private {
uint256 tokenReserve = getReserve();
uint256 tokensBought = getAmount(msg.value, address(this).balance - msg.value, tokenReserve);
require(tokensBought >= _minTokens, "insufficient output amount");
IERC20(tokenAddress).transfer(recipient, tokensBought);
}
function ethToTokenSwap(uint256 _minTokens) public payable {
ethToToken(_minTokens, msg.sender);
}
When trading an ERC20 token for ETH, the ERC20 is transferred from the user’s balance to the contract’s balance and ETH is given back to the user.
- tokenToEthSwap: transfers tokens from the user’s balance to the exchange’s balance in exchange of ETH.
function tokenToEthSwap(uint256 _tokensSold, uint256 _minEth) public {
uint256 tokenReserve = getReserve();
uint256 ethBought = getAmount(_tokensSold, tokenReserve, address(this).balance);
require(ethBought >= _minEth, "insufficient output amount");
IERC20(tokenAddress).transferFrom(msg.sender, address(this), _tokensSold);
payable(msg.sender).transfer(ethBought);
}
ERC20 $ \leftrightarrows $ ERC20 Trades
Every Exchange needs to know the address of the Factory to perform ERC20 $ \leftrightarrows $ ERC20 Trades.16
- factoryAddress: address of the Factory Contract that created this Exchange Contract.
contract Exchange is ERC20 {
// omitted
address public factoryAddress;
constructor(address _token) ERC20("Uniswap-V1", "UNI-V1", 18) {
// omitted
factoryAddress = msg.sender;
}
}
It is possible to convert ERC20 token A to ETH on one exchange and then from ETH to ERC20 token B on another within a single transaction.10
- tokenToTokenSwap: this Exchange Contract A finds another Exchange Contract B for the token address, and sends it ETH to swap them to tokens.
- ethToTokenTransfer: payable function that receives ETH and adds it to the contract balance, and calls ethToToken.
- ethToToken: it takes an address, recipient, to whom we want to send tokens. In this case, recipient is equal to msg.sender, the user and not the calling contract, Exchange Contract A.
function ethToTokenTransfer(uint256 _minTokens, address _recipient) public payable {
ethToToken(_minTokens, _recipient);
}
function tokenToTokenSwap(uint256 _tokenSold, uint256 _minTokensBought, address _tokenAddress) public {
address exchangeAddress = IFactory(factoryAddress).getExchange(_tokenAddress);
require(exchangeAddress != address(this) && exchangeAddress != address(0), "invalid exchange address");
uint256 tokenReserve = getReserve();
uint256 ethBought = getAmount(_tokenSold, tokenReserve, address(this).balance);
IERC20(tokenAddress).transferFrom(msg.sender, address(this), _tokenSold);
IExchange(exchangeAddress).ethToTokenTransfer{value: ethBought}(_minTokensBought, msg.sender);
}
Testing Uniswap V1 Smart Contract With Foundry
NOTE: The code of the following tests done with Foundry can be found in this repository. Since most of the Uniswap Protocol logic is in the Exchange Contract, I’ll only explain how Exchange.t.sol is organized.
ExchangeBaseSetup: deploys a Token, Factory, and Exchange Contract and, defines an owner and an user address. It is used as a base setup for the following tests, except for TokenToTokenSwapTest.
DeploymentTest: tests if the deployment was successful.
Providing Liquidity
The Liquidity Provider can face two situations, the reserves are empty or the reserves are not empty.
AddLiquidityWithEmptyReservesTest: tests the if branch of addLiquidity, and whether the Liquidity Tokens are issued when liquidity is added.
AddLiquidityWithExistingReservesTest: tests the else branch of addLiquidity, and whether the Liquidity Tokens are issued when liquidity is added.
RemoveLiquidityTest: tests if the Exchange Contract allows to withdraw the proportional part of ETH and ERC20 tokens, and whether the Liquidity Tokens are burned when liquidity is removed.
Swaps
EthToTokenSwapTest and TokenToEthSwapTest tests if the Exchange Contract allows to trade ETH to and from a single ERC20 token.
TokenToTokenSwapTest: its base setup deploys two Tokens, a Factory, and two Exchanges and, defines an owner and user address, also, it gives them a label to easily identify them. And, it tests if the Exchange Contract allows to directly swap an ERC20 to another ERC20 in a single transaction.
Remarks
The core contracts of the Uniswap Protocol are written in Vyper and, have differences from this version in Solidity. I’ll highlight the most notable differences of the uniswap_exchange.vy:
- The ERC20 Token Standard is implemented directly, because Vyper does not provide class inheritance. However, Vyper provides a specific built-in interface for the ERC20 Token Standard, but it is not imported.
- The deadline parameter is used to set a time after which a transaction can no longer be executed. This limits the “free option” problem, where Ethereum miners can hold signed transactions and execute them based off market movements.15 But it is not used in this version for simplicity.
- Many of the helper functions to get the input and output amounts of ETH or ERC20 tokens have been coded directly or simplified with the getAmount function.
-
https://www.coindesk.com/business/2021/02/04/what-is-uniswap-a-complete-beginners-guide/ ↩︎
-
https://medium.com/bollinger-investment-group/constant-function-market-makers-defis-zero-to-one-innovation-968f77022159 ↩︎
-
https://academy.binance.com/en/articles/what-is-an-automated-market-maker-amm ↩︎
-
https://docs.ethhub.io/guides/graphical-guide-for-understanding-uniswap/ ↩︎
-
https://pintail.medium.com/uniswap-a-good-deal-for-liquidity-providers-104c0b6816f2 ↩︎
-
https://docs.uniswap.org/protocol/V1/guides/connect-to-uniswap ↩︎
-
https://docs.uniswap.org/protocol/V1/guides/pool-liquidity ↩︎