Uniswap from Scratch
4 July 2021You must have heard of Uniswap or even know at a high level how it works.
You read the indepth guide:
It's amazing!! 😲... LPs! constant product.📉 decentralised ⛓️ blockchain ⛓️ AMM and execution engine.
No orderbooks? No servers? NO ORACLE? WTF!!!! 😵 😵 😵
x * y = k, X * Y = K! It's ✨ magic. ✨
You reach the end of the article. A unicorn jumps out of the screen and fist bumps you.
You wake up at your desk, you fell asleep at x * y = k
. That's
ok- It's more straightforward than you might think. Let's implement
it from scratch and understand how it really works.
The purpose of this guide is not to produce a perfect and secure implementation of
Uniswap. It is to demonstrate the mechanics in the most simplified way.
It assumes you have a basic understanding of ethereum, such as smart
contracts, transactions and tokens.
The code for this implementation dubbed "Looneyswap" can be found here.
The Pool
The Uniswap story begins with the pool. The pool is a smart contract that
holds reserves of two tokens token0
and token1
.
contract LooneySwapPool is ERC20 {
address public token0;
address public token1;
// Reserve of token 0
uint public reserve0;
// Reserve of token 1
uint public reserve1;
...
}
Creating a pool is simple: specify the 2 tokens this pool should hold:
constructor(address _token0, address _token1) ERC20("LiquidityProvider", "LP") {
token0 = _token0;
token1 = _token1;
}
After the contract is created, it will keep a balance of how many
token0
and token1
it holds in the state variables
reserve0
and reserve1
respectively.
Notice that the contract also extends ERC20. This is because, in addition to counting the amount of reserves in each token, it is a token itself too! As you may tell by the name, the token represents a balance for liquidity providers.
Adding liquidity
To add liquidity to our pool as a user, we have to call the
add
function specifying the amounts of each token we are
depositing:
function add(uint amount0, uint amount1)
First we transfer the tokens to the pool (itself).
assert(IERC20(token0).transferFrom(msg.sender, address(this), amount0));
assert(IERC20(token1).transferFrom(msg.sender, address(this), amount1));
Since the user no longer has control of these tokens, we need a way to give the them an object that represents their rightful share of the pool. This is where the pool's native token comes in!
We mint new supply of the token in proportion to the user's share of the pool.
If this is the first user providing liquidity, we mint an initial amount, effectively giving them a 100% share.
_mint(msg.sender, INITIAL_SUPPLY);
Otherwise, we calculate the pro rata share and mint the equivalent LP tokens.
uint reserve0After = reserve0 + amount0;
uint reserve1After = reserve1 + amount1;
...
uint currentSupply = totalSupply(); // Current supply of LP tokens
uint newSupplyGivenReserve0Ratio = reserve0After * currentSupply / reserve0;
uint newSupplyGivenReserve1Ratio = reserve1After * currentSupply / reserve1;
uint newSupply = Math.min(newSupplyGivenReserve0Ratio, newSupplyGivenReserve1Ratio);
_mint(msg.sender, newSupply - currentSupply);
Great! Our pool now has a ratio of tokens and we can account for who provided them. We can now begin implementing swapping!
The magic formula
Before we can execute a swap, we need to derive a price.
In the traditional exchange model, liquidity providers explicitly set a price
and size, for example: Sell 2.4 ETH @ $2210/ETH
. This requires
participants to be active in the operations of the exchange and constantly
adjust/replenish. It comes with a host of issues in the context of
blockchains, because each adjustment would require transactions to be
submitted and order books tend to become inefficient. Alternatively,
orderbooks could be provided as a centralised service which create trust
issues.
Instead of explicit pricing, Uniswap derives prices using a formula.
x * y = k
It looks abstract, but with some examples, it's straightforward.
x
is reserve0
y
is reserve1
So the value of k
becomes reserve0 * reserve1
. And
it must remain constant when making a price.
In effect, k
remaining constant means:
-
If you decrease
reserve0
, you must increasereserve1
-
If you decrease
reserve1
, you must increasereserve0
Written in code this would look like:
function getAmountOut (uint amountIn, address fromToken) {
...
uint k = reserve0 * reserve1;
...
newReserve0 = amountIn + reserve0;
newReserve1 = k / newReserve0;
amountOut = reserve1 - newReserve1;
}
Let's look at a concrete example.
Let's assume we have added liquidity to the pool such that it contains
10 ETH (reserve0) + 20000 DAI (reserve1)
. We would like to sell 1
ETH for DAI.
First let's calculate the value of k
:
k = x(reserve0) * y(reserve1)
k = 10 * 20000
k = 200000
To sell 1 ETH to the pool we would be increasing its reserves
reserve0
by 1.
newReserve0 = 10 + 1
newReserve0 = 11
Now that we have reserve0
and k
we can calculate
what reserve1
should become.
newReserve0 * newReserve1 = k
newReserve1 = k / newReserve0
newReserve1 = 200000 / 11
newReserve1 = 18182
The amount of DAI received in return is the difference between what
reserve1
should become and what it is now.
amountOut = reserve1 - newReserve1
amountOut = 20000 - 18182
amountOut = 1818
Ok! So based on the reserves and the formula, a price is enforced. Pretty neat.
Swapping
We have reserves and we know how to calculate the amount of tokens to output. Now let's execute it.
function swap(uint amountIn, uint minAmountOut, address fromToken, address toToken, address to)
First let's derive the output amount and what the reserves should look like using the function from before:
(uint amountOut, uint newReserve0, uint newReserve1) = getAmountOut(amountIn, fromToken);
We could simply send the user amountOut
but there is an issue:
Since your transaction may take some time and other users are also interacting
with the reserves, you may end up with a price wildly different than what you
expect or is reasonable. Therefore, we also want to limit how far the price
can venture from the point in time that the transaction was sent until when it
is successfully executed on chain:
require(amountOut >= minAmountOut, 'Slipped... on a banana');
This concept is called slippage and the minimum output amount is calculate based on the current price when the transaction is sent. Read more about slippage
Now we simply transfer the tokens and update the reserves:
assert(IERC20(fromToken).transferFrom(msg.sender, address(this), amountIn));
assert(IERC20(toToken).transfer(to, amountOut));
reserve0 = newReserve0;
reserve1 = newReserve1;
Removing liquidity
If we no longer wish to provide liquidity to the pool, we can take back our tokens.
function remove(uint liquidity)
First transfer the LP tokens representing our share back to the pool:
assert(transfer(address(this), liquidity));
Calculate the amount of tokens that the share of the pool represents:
uint currentSupply = totalSupply();
uint amount0 = liquidity * reserve0 / currentSupply;
uint amount1 = liquidity * reserve1 / currentSupply;
Burn the LP token supply that was returned:
_burn(address(this), liquidity);
Transfer the user's share of the tokens back to them and update the reserves:
assert(IERC20(token0).transfer(msg.sender, amount0));
assert(IERC20(token1).transfer(msg.sender, amount1));
reserve0 = reserve0 - amount0;
reserve1 = reserve1 - amount1;
Providing liquidity may seem pointless but in reality, the protocol also requires fees (not implemented) that are added to the reserves, creating incentive. Learn more about uniswap fees.
That's all folks!
There it is, Uniswap demonstrated in 100 lines of code. It wasn't complicated. The formula makes sense once you run a few examples through it and I like to think that it's much easier to parse the mechanics of the protocol by implementing it compared to reading an explainer. The source code can be found here and if you're interested in digging deeper, you should have a great foundation now that will help you understand the rest of the intricacies.