To understand decentralized exchanges (DEX), we should try and understand how its centralized counterpart (CEX) functions at a high level. CEXs are Order Book based. The price of an instrument at any moment is the outcome of several "match" operations between ask and bid orders executed at high speeds. On one side, all "Ask" a.k.a Sell Orders are matched against all "Bid" a.k.a Buy Orders on the other side. 0 unmatched orders on either side reflects an absence of demand or supply. No trades are possible under such circumstances.
Though CEXs are centralized, the price discovery mechanism is elegant and decentralized. This decentralization is achieved through simple invariants like "Match the highest bid with the lowest ask" and the consequent actions by interested parties. More on invariants later. Actually this mostly is a post about invariants.
Like mentioned before, trades dry up in CEXs under the absence of adequate supply or demand. DEXs try to solve this problem. Is there an alternate way to facilitate trades?
Is there a way in which we may design exchanges without order books? Is there a way to design an exchange where orders could go through even without active demand or supply? Can the exchange itself be designed such that it may be created by anyone, at any time? The answer to all the above is yes. Introducing "Automated Liquidity Pools (ALPs)". ALPs are the cornerstone of DEXs. Let's dive right in and try and understand how ALPs really work.
Just as equity of public companies get traded on CEXs, crypto tokens may be traded on CEXs (example Binance) or on DEXs (example Uniswap).
Lets model the DEX world starting with 2 toy tokens. Our first toy is the Ice Cream token-ICT. 1 token of ICT represents 1 scoop of ice cream. The second toy is the Penny token-PT. 1 token of PT represents 1/100th of a US dollar. Since the value of 1 PT = 0.01 USD, PT is an example of a stable coin.
Say a person named Dexter has some ICTs and PTs in his crypto wallet. He decides to make some money in return for locking these in a Liquidity Pool. This way of making money is also called "Staking". He goes to a DEX (like Uniswap) and sees that there is no liquidity yet for the ICT-PT pair. In his mind, the value of 1 scoop of ice cream is 1 penny, so he goes ahead and locks 100 each of both his tokens into the ALP for the ICT-PT pair. It's customary to specify a base token when creating such pairs. When 1 in the pair is a stable coin, it's a good choice for the ALP's base token.
Lets model Dexter's actions in code. I followed this post for my models. I implement all logic using only basic arithmetic.
Our ALP is the global variable ICT-PT
(defn reset-liquidity! [alp {:keys [q-ict q-pt] :as liquidity}]
(reset! alp {:q-pt q-pt :q-ict q-ict}))
(reset-liquidity! ICT-PT {:q-ict 100 :q-pt 100})
=> {:q-ict 100 :q-pt 100}
Dexter seeds ICT-PT
parting ways with 100 each of his ice cream and penny tokens. Dexter in this case is called the Liquidity Provider (LP). Since he was the first person to add liquidity to the pair, that act of adding liquidity also makes the pool open for "Swaps" business. From this point on, if any other LP or even if Dexter wants to add liquidity concerning ICT and PT, it gets added to ICT-PT
. Als JFYI, methods ending in ! mutate. Functional programming looks down upon such methods as non kosher.
A fun Quiz: Say there are 6 tokens in our world? How many ALPs can exist max?
(defn fact [n]
(reduce * (range 1 (inc n))))
(defn choose [n r]
(fact n) / (* (fact r) (fact (- n r))))
(choose 6 2)
=> 48
Ans: 48 - Because in a universe of 6, there could exist 48 unique pairs.
Actually, the quiz was fun and hopefully drove home the point that the pair comprising ALPs is unordered. That is to say, the ALP ICT-PT
is the same as the ALP PT-ICT
and the 2 may be used interchangeably.
Coming back to ICT-PT
. Once our ALP's liquidity is added or modified, it maintains an invariant. For now, take a leap of faith and take that invariant to be the product of the ALP's individual token quantities. Till the time a LP doesn't pull out or adds liquidity to the ALP, its token product will remain constant. 10,000 in our example. The majority of the pool and the reason for its existence is to facilitate swap operations. These are done by swappers. Swap operations don't change ALP's liquidity. We are saying that as long as there are no liquidity modifying events, for all swap operations on ICT-PT
, the token product will be maintained sacrosanct as 10,000.
(defn get-invariant [alp]
(* (:q-pt @alp) (:q-ict @alp)))
(get-invariant ICT-PT)
=> 10000
(defn get-invariant1 [alp]
(/ (:q-pt @alp) (:q-ict @alp)))
(get-invariant1 ICT-PT)
=> 1
Let's define the ratio of tokens or the "dilution" as another invariant. More this ratio, more ICT is diluted with respect to PTs in the ALP.
For the remainder of this post when I say invariant, I mean the value returned by the function get-invariant
defined above. Invariants such as get-invariant
are facts about a system that remain sacrosanct at all times. Invariants are surprisingly effective in promoting a system's structural integrity. Few (1 or 2) key invariants often dominate data flows inside algorithms. Invariants also help in reasoning about systems and resolving key system trade offs.
Our "product of tokens should be constant" invariant, becomes the basis of all subsequent swaps by swappers. You want to swap PT for your ICT? fine, but the invariant after the transaction should still be 10000. You want ICTs for your PTs?, fine, but maintain the post transaction invariant as 10,000. Just with 10,000 as the invariant, we can ensure that swaps never stop in the ALP. Implementing swap functions is simple once we have decided the invariant.
(defn swap-pt2ict [alp q-pt]
"gives back a quote in terms of q-ict quantity of ICT tokens that will be swapped out
for the q-pt PTs that will be swapped in for the alp. This does not mutate the passed alp as
its just a quote and no actual transaction is performed"
(let [invariant (get-invariant alp)
new-q-pt (+ (:q-pt @alp) q-pt)
new-q-ict (/ invariant new-q-pt)
q-ict (- (:q-ict @alp) new-q-ict)]
q-ict))
The swap-pt2ict
function gives you a swap quote i.e how much ICTs you take out, for the PTs you put in.
((partial swap-pt2ict ICT-PT) 50)
=> 33.333333333333336
You get 33.33 ICT for 50 PT you put in. 33.33 is the right quote because after the transaction the ALP will have 100+50 = 150 PTs and 100-33.33 = 66.67 ICTs.
(* (+ 100 50) (- 100 33.33))
=> 10000.5
The invariant is maintained.
(defn swap-pt2ict! [alp q-pt]
"gives back quantity ict that will be swapped out
for the q-pt that will be swapped in for the alp.
This mutates the alp and commits the transaction.
Returns ALP composition post the mutation"
(let [q-pt-plus q-pt
q-ict-minus (swap-pt2ict alp q-pt)]
(reset! alp {:q-pt (+ (:q-pt @alp) q-pt-plus)
:q-ict (- (:q-ict @alp) q-ict-minus)})))
The above is a version of the quote function swap-pt2ict, but when the quote is actually transacted and new values are committed inside the pool.
If you are curious, what happens when a swap is split into 2 transactions.
(let [x ((partial swap-pt2ict ICT-PT) 25)
_ ((partial swap-pt2ict! ICT-PT) 25)
y ((partial swap-pt2ict ICT-PT) 25)]
(+ x y))
=> 33.333333333333336
This should give you comfort, there is no effect because of split transactions.
The above split transaction experiment mutated the pool, lets reset it back to our original point
(reset-liquidity! ICT-PT {:q-ict 100 :q-pt 100})
=> {:q-ict 100 :q-pt 100}
Plotting ICT swap quotes on a chart
We can observe that no matter what, you will always successfully swap out ICTs for PTs. Trade never dries up in this world. However from the relation between the 2 quantities, you see that as the supply of PTs increase, you get lower and lower diminishing ICT returns for every PT you swap in. Also in CEXs, it's easy to get the price of an instrument. In our world we are one function away from plotting prices. For prices, we need to plot the slope of the curve above in another graph.
(defn get-slope [alp q-pt]
"Returns the slope of the swap plot in terms of q-pt.
By definiton, this slope is ICT's marginal price"
(let [epsilon 0.001
y1 q-pt
y2 (+ epsilon q-pt)
x1 ((partial swap-pt2ict alp) y1)
x2 ((partial swap-pt2ict alp) y2)
slope (/ (- y2 y1) (- x2 x1))]
slope))
(def ict-marginal-price get-slope)
Turns out that slope defined as above is actually the marginal price for ICTs. Now we are ready to plot the marginal price as a function of q-PT.
We can see here that the price steadily increases with the penny supply, at constant liquidity.
When the pool is more diluted, the price function looks like this.
Prices inflate exponentially, if liquidity remains low, and dilution is high. When Dexter began, we could have gotten 1 scoop of ice cream more or less for a penny. But when the demand increases, we have to pay 40 K pennies for the same scoop. This is ridiculous inflation. Should this situation occur and the high ICT price unjustfied, swappers would swap out PTs trading in ICTs.
Enter Alice the swapper. Alice sees these quotes on the ALP and her eyes light up. This pool is price distorted and she spots an arbitrage opportunity. In her mind an ICT is 14 rupees and a PT is only 7 rupees. That is an ICT is 2x PT and not 1x what Dexter thinks it is. To maximize her "rupee" holdings she plots this graph privately.
(defn alice-rupee-profit [alp q-pt]
"How many rupees alice can profit if she supplies q-pt to the apl
we assume 1 PT is 7 rupees and 1 ICT is 14 rupees and shamelessly hardcode
"
(let [rupee-out (* 7 q-pt)
rupee-in (* 14 ((partial swap-pt2ict alp) q-pt))]
(- rupee-in rupee-out)))
The "Alice Plot"
Alice can make good rupee profits for the right amount of PT supplied. If she supplies too little, she won't make as much profit. If she supplies too many, she ends up making losses.
(last (->> (range 1 200)
(map (juxt identity (partial alice-rupee-profit ICT-PT)))
(map (partial zipmap [:penny :rupee-profit]))
(sort-by :rupee-profit)))
=> {:penny 41 :rupee-profit 120.09219858156028}
She makes a maximum profit of Rs 120, at around the 41 PT mark.
(for [s (range 40 44)]
[s ((partial ict-marginal-price ICT-PT) s)])
=> ([40 1.9600139999833779] [41 1.9881140999741231] [42 2.016414199942685] [43 2.0449142999865293])
Here is an interesting thing. You can see by plugging some values to the ICT marginal price function, when the penny quantity is 42 the marginal price matches Alice's perception of 2. The same can be verified in the "ICT marginal price chart" a few plots above.
(swap-pt2ict! ICT-PT 41)
=> {:q-ict 70.92198581560284 :q-pt 141}
And another interesting thing. What happens when Alice wants to commit the transaction and enjoy the proceeds? The pool mutates thus after the arbitrage swap transaction.
(get-invariant1 ICT-PT)
=> 1.9881
The ratio above is again 2.
So after the arbitrage swap, Alice sets the pool's dilution by swapping it to her idea of ICT value. Arbitrage swappers like Alice, play an important role in the DEX system, to regulate and correct the prices. This is also the reason why get-invariant1 can serve as a "Price Oracle" if implemented in a blockchain.
And how would this price chart have looked for Alice and everyone if Dexter had seeded it with 100 K tokens instead of just 100?
(reset-liquidity! ICT-PT {:q-ict 100000 :q-pt 100000})
=> {:q-ict 100000 :q-pt 100000}
(get-invariant ICT-PT)
=> 10000000000
Plotting the marginal price at high liquidity levels in an alternate universe where Dexter seeds the pool with 1000 X more liquidity.
We can see that the pool maintains a stable price because of more liquidity. Alice as a swapper sees stable rates. For her to play the arbitrage role, she needs to swap 1000 X more PTs than she did in our earlier universe.
In summary, the ALP acts in 2 orthogonal dimensions. Liquidity and Dilution. Though mathematically, swaps dont stop, they stop in practice when the prices don't make sense. Actually prices inform the swaps and not the other way round. If you are someone looking at DEXs to convert your tokens to stablecoins, you need to be mindful of the above 2 dimensions at play.
It's fascinating how such a simple invariant can be so effective and have so many ramifications. In fact, because the swap algo is so simple, it is possible to code swap-pt2ict
and swap-pt2ict!
in Ethereum using Solidity or Vyper. We can make the transfers to conform to the ERC20 standard as well. This makes it possible for anyone in the world to set up DEXs and make it usable by anyone in the world as well. This is true Decentralization.
Arbitrage plays a key role in DEXs. The transparency that make arbitrage possible is the same that make price manipulation inevitable. CEXs have regulation and controls like circuit breakers to prevent price manipulation. None of that exists by default in DEXs. Its upto the DEXs governance to enforce fair play. There are controls like "Kernel Smoothers" that need to be baked in if we intend to circumvent price manipulation.
Does this mean that the product of tokens is the only possible and effective invariant? Certainly not, there are other invariants that can drive DEXs. Also there are DEXs that work simultaneously with many tokens, not just two. But these build on the basics that are explained in this post.
I intend to model fees, slippage, incentives for LP behaviour and other concepts like Concentrated Liquidity in later posts.
Please leave your comments, questions as well as suggestions regarding the model. You may contact me by mail or ping me on my social handle.