I’ve been getting interested in Decentralized Finance (DeFi) recently, and wanted to understand some of the core ideas better.

I find the best way for me to properly explore an idea is to implement it in code so that I have something I can poke with a stick, to see how it reacts.

In this post, I’m going to do that with the simplest possible Automated Market Maker (AMM) – implement the core algorithm in ruby, and see what I can learn from it.

Traditional exchanges use an order book model, where you put buyers and sellers together, and trades happen when a buyer is willing to pay a seller’s asking price for an asset. If sellers and buyers cannot agree on a price, the market stalls.

AMMs use a different approach. The exchange itself holds the assets being traded (e.g. Ethereum (ETH) <-> tokens), and sets the price of each one according to an algorithm.

Uniswap was one of the first AMMs to get a lot of traction, so I looked at the source code of the Uniswap Version 1 exchange smart contract on GitHub to try and figure out how it works.

Algorithm

The algorithm used by Uniswap V1 is deceptively simple:

x * y = k

x and y are the two assets being traded, and the algorithm says that the value of x * y must always equal k. So, how does that work in practice?

Ruby automated market maker

This is scratch code designed to help me explore an idea. It’s not meant to be concise, robust or clever.

Our AMM is a ruby class that we instantiate with two assets that we’ll call "ether" and "tokens".

class Amm
  attr_accessor :ether_reserve, :token_reserve

  def initialize(params)
    @ether_reserve = params.fetch(:ether_reserve).to_f
    @token_reserve = params.fetch(:token_reserve).to_f
  end
end

Since we’re implementing x * y = k we can set our k in the initialize method:

  def initialize(params)
    @ether_reserve = params.fetch(:ether_reserve).to_f
    @token_reserve = params.fetch(:token_reserve).to_f

    @konst = @ether_reserve * @token_reserve
  end
end

Buying tokens for Ether

Let’s implement our buy method, using the algorithm:

  # Counterparty pays `amount` eth for some tokens
  def buy(amount)
    ether = amount.to_f

    @ether_reserve += ether
    new_token_reserve = @konst / @ether_reserve

    tokens_bought = @token_reserve - new_token_reserve
    @token_reserve = new_token_reserve

    return tokens_bought
  end

We add the new ether amount to @ether_reserve, then calculate how many tokens we should have according to the x * y = k rule:

new_token_reserve = @konst / @ether_reserve

This number must be lower than the number of tokens we already have, because maths. So, however many tokens we have above the number we "need" for x * y = k is the number of tokens our buyer receives in exchange for their ether.

Our sell method is the same as buy, but with tokens and ether swapped around. You can see the AMM with sell implemented here, plus a bit of logging so we can see what’s happening.

Poke it with a stick

Now that we’ve got a simple AMM, let’s create a harness that lets us use it.

First, we need to create a new Amm object, with some ether and some tokens:

#!/usr/bin/env ruby

require "./lib/amm"

ether, tokens = ARGV
ether ||= 10
tokens ||= 1000

amm = Amm.new(ether_reserve: ether.to_f, token_reserve: tokens.to_f)

Here is a simple loop reading input from the user, and executing instructions depending on what she enters:

  • q – quit the program
  • buy X – buy some tokens for X eth
  • sell Y – sell Y tokens for some eth
  • anything else – print "???" and wait for more input

Here’s the code:

string = ""

loop do
  print "> "
  string = STDIN.gets

  case string.chomp
  when /buy (.*)/
    amm.buy($1)
  when /sell (.*)/
    amm.sell($1)
  when "q"
    break
  else
    puts "???"
  end
end

puts "bye."

You can see the full code here.

Let’s take it for a spin:

$ bin/amm.rb
Pool eth: 10.0, tokens: 1000.0
> buy 1
You get 90.90909090909088 tokens for 1.0 ether, price 0.0110
Pool eth: 11.0, tokens: 909.0909090909091
> buy 1
You get 75.75757575757575 tokens for 1.0 ether, price 0.0132
Pool eth: 12.0, tokens: 833.3333333333334
> q
bye.

This shows us a couple of things:

  1. You don’t pay the nominal price for tokens

For the first purchase, there were 10 ETH and 1000 tokens in the AMM, so the price for a token is 0.01 ETH. But we didn’t receive 100 tokens for our 1 ETH. Instead we got just over 90.9

One side-effect of this is that it’s impossible for the AMM to ever completely run out of either asset:

$ bin/amm.rb
Pool eth: 10.0, tokens: 1000.0
> buy 1000000
You get 999.990000099999 tokens for 1000000.0 ether, price 1000.0100
Pool eth: 1000010.0, tokens: 0.00999990000099999

Here, even though we spend 1M ETH to try and buy all the tokens, there’s still a fraction of a token left in the AMM.

  1. Buying tokens affects the token price

We received fewer tokens for the second purchase. The first purchase changed the amounts of Ether and tokens in the AMM, so the price of each token was different when the second purchase happened.

This phenomenon is called slippage, and it’s why you want your AMM’s reserves to be as large as possible. Here’s what happens if the reserves are 100 times larger:

$ bin/amm.rb 1000 100000
Pool eth: 1000.0, tokens: 100000.0
> buy 1
You get 99.90009990010003 tokens for 1.0 ether, price 0.0100
Pool eth: 1001.0, tokens: 99900.0999000999
> buy 1
You get 99.70069850308937 tokens for 1.0 ether, price 0.0100
Pool eth: 1002.0, tokens: 99800.39920159681

This time, the difference between the two purchases is much smaller.

In the next post, we’ll refactor our AMM.

5 thoughts on “Ruby Automated Market Maker

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s