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.
The algorithm used by Uniswap V1 is deceptively simple:
x * y = k
y are the two assets being traded, and the algorithm says that the
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
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.
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:
- 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.
- 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.