MONOKH

نخبه break things
Github @monokh Twitter @mo_nokh

Uniswap from Scratch

You 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:

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.