Joshua Sells
Crypto arbitrage system

The math and the papers

The bot's central problem is small to state. Given a cycle of pools, how much of the starting token should we put in so that the profit is as large as it can be? This page collects the reading that led to the answer and the derivation. It follows the answer from my own first attempt, which was in the right ballpark, to the answer in the cyclic arbitrage paper (Cyclic Arbitrage in Decentralized Exchanges, listed below), which made it exact, and then from Uniswap V2 to V3.

Reading list

  • Cyclic Arbitrage in Decentralized Exchanges

    Y. Wang, Y. Chen, H. Wu et al. · 2021 (arXiv 2105.02784, v3 Jan 2022)

    Studied closely. It is the source of the idea that a chain of pools can be collapsed into one virtual pool with a closed-form optimal trade. The bot's `Path` class cites it.

  • Uniswap v3 Core

    H. Adams, N. Zinsmeister, M. Salem, R. Keefer, D. Robinson · 2021

    With the v3-core contracts, the source for the off-chain V3 swap reimplementation.

  • Liquidity Math in Uniswap V3

    A. Elsts · 2021

    Companion reference for V3 liquidity and tick math.

  • An Analysis of Uniswap Markets

    G. Angeris, H.-T. Kao, R. Chiang, C. Noyes, T. Chitra · 2019 (arXiv 1911.03380)

    Background on constant-product market behavior.

Reference code and documentation:

The two-pool case: my first answer

Before I found the cyclic arbitrage paper, I worked the two-pool problem out on paper myself. The idea is simple. Two pools quote the same pair at different prices, so I buy the token where it is priced low and sell it into the other pool, where it is priced high. Each trade moves the price against me, so the profit peaks at the trade size where the two pools quote the same price again.

Setting it up

Call the token I start with, and get back at the end, AA. Call the token in the middle BB. Two pools trade this pair:

  • The first pool holds i1i_1 of AA and o1o_1 of BB.
  • The second pool holds i2i_2 of BB and o2o_2 of AA.

The names come from my notes. The letter ii is what goes into a pool when I trade, and oo is what comes out. I will carry one example through every step: the first pool holds 250 AA and 1,000 BB, and the second holds 500 BB and 2,000 AA.

Step 1: the price of BB. The price of one BB in a pool is the amount of AA in the pool divided by the amount of BB. In the first pool that is i1/o1=250/1,000=0.25i_1 / o_1 = 250 / 1{,}000 = 0.25. In the second pool it is o2/i2=2,000/500=4o_2 / i_2 = 2{,}000 / 500 = 4. So BB is priced low in the first pool and high in the second. I buy it in the first pool and sell it in the second.

Step 2: the rule a pool follows. When I trade, a pool's two reserves change, but the two reserves multiplied together stay the same. Ignoring the fee for now, call that number kk:

k1=i1⋅o1=250⋅1,000=250,000,k2=i2⋅o2=500⋅2,000=1,000,000k_1 = i_1 \cdot o_1 = 250 \cdot 1{,}000 = 250{,}000, \qquad k_2 = i_2 \cdot o_2 = 500 \cdot 2{,}000 = 1{,}000{,}000

Step 3: buy xx of BB from the first pool. The pool now has o1−xo_1 - x of BB. Its two reserves must still multiply to k1k_1, so:

(A in the pool)⋅(o1−x)=k1(\text{A in the pool}) \cdot (o_1 - x) = k_1

Divide both sides by (o1−x)(o_1 - x) to get the amount of AA the pool holds afterwards:

A in the pool=k1o1−x\text{A in the pool} = \frac{k_1}{o_1 - x}

In the example, buying x=500x = 500 leaves 500 BB in the pool, so it holds 250,000/500=500250{,}000 / 500 = 500 AA. It started with 250, so I paid in 250.

Step 4: sell the same xx into the second pool. That pool now has i2+xi_2 + x of BB, and by the same reasoning it holds k2/(i2+x)k_2 / (i_2 + x) of AA. In the example it has 1,000 BB and 1,000,000/1,000=1,0001{,}000{,}000 / 1{,}000 = 1{,}000 AA. It started with 2,000, so I took out 1,000. I paid in 250 and got back 1,000.

Step 5: the new prices. The price is AA divided by BB again. Dividing a fraction by something puts that something in the bottom a second time, so:

first pool: k1/(o1−x)o1−x=k1(o1−x)2,second pool: k2/(i2+x)i2+x=k2(i2+x)2\text{first pool: } \frac{k_1 / (o_1 - x)}{o_1 - x} = \frac{k_1}{(o_1 - x)^2}, \qquad \text{second pool: } \frac{k_2 / (i_2 + x)}{i_2 + x} = \frac{k_2}{(i_2 + x)^2}

In the example both come out to 1 (500 divided by 500, and 1,000 divided by 1,000).

Step 6: the best trade is where the two prices meet. While BB is still priced lower in the first pool, buying a little more still makes money. If it has become priced lower in the second pool, I bought too much. So the best xx makes the two prices equal:

k1(o1−x)2=k2(i2+x)2\frac{k_1}{(o_1 - x)^2} = \frac{k_2}{(i_2 + x)^2}

Solving for x

Step 7: take the square root of both sides. The square root of a fraction is the square root of the top over the square root of the bottom, and the square root of something squared is the something itself. So:

k1o1−x=k2i2+x\frac{\sqrt{k_1}}{o_1 - x} = \frac{\sqrt{k_2}}{i_2 + x}

Step 8: multiply across. Multiply both sides by (o1−x)(o_1 - x) and by (i2+x)(i_2 + x):

k1 (i2+x)=k2 (o1−x)\sqrt{k_1}\,(i_2 + x) = \sqrt{k_2}\,(o_1 - x)

Step 9: open the brackets.

k1 i2+k1 x=k2 o1−k2 x\sqrt{k_1}\,i_2 + \sqrt{k_1}\,x = \sqrt{k_2}\,o_1 - \sqrt{k_2}\,x

Step 10: gather the xx terms on the left. Add k2 x\sqrt{k_2}\,x to both sides and subtract k1 i2\sqrt{k_1}\,i_2 from both sides:

k1 x+k2 x=k2 o1−k1 i2\sqrt{k_1}\,x + \sqrt{k_2}\,x = \sqrt{k_2}\,o_1 - \sqrt{k_1}\,i_2

Step 11: pull out the common xx.

x (k1+k2)=k2 o1−k1 i2x\,(\sqrt{k_1} + \sqrt{k_2}) = \sqrt{k_2}\,o_1 - \sqrt{k_1}\,i_2

Step 12: divide both sides by the bracket.

x=k2 o1−k1 i2k1+k2x = \frac{\sqrt{k_2}\,o_1 - \sqrt{k_1}\,i_2}{\sqrt{k_1} + \sqrt{k_2}}

Check it with the example. The square roots are k1=500\sqrt{k_1} = 500 and k2=1,000\sqrt{k_2} = 1{,}000, so x=(1,000⋅1,000−500⋅500)/(500+1,000)=750,000/1,500=500x = (1{,}000 \cdot 1{,}000 - 500 \cdot 500) / (500 + 1{,}000) = 750{,}000 / 1{,}500 = 500. That is the trade from step 3.

What I pay in

Step 13: what I pay in. From step 3, what I pay in is the change in the first pool's AA reserve:

δ1=k1o1−x−i1\delta_1 = \frac{k_1}{o_1 - x} - i_1

To get this in terms of the reserves alone, I need to replace xx. I do it in three parts: simplify o1−xo_1 - x, use it to simplify the fraction, then subtract i1i_1.

Step 14: simplify o1−xo_1 - x. From step 12, xx is a fraction. Call its top NN and its bottom DD:

N=k2 o1−k1 i2,D=k1+k2,x=NDN = \sqrt{k_2}\,o_1 - \sqrt{k_1}\,i_2, \qquad D = \sqrt{k_1} + \sqrt{k_2}, \qquad x = \frac{N}{D}

So I want o1−N/Do_1 - N/D. You cannot subtract a fraction from a whole number directly. It works like 3−1/23 - 1/2: write the 3 as 6/26/2 first, and then 6/2−1/2=5/26/2 - 1/2 = 5/2.

14a. Give o1o_1 the same bottom. Multiplying the top and bottom by DD changes nothing, because D/D=1D / D = 1:

o1=o1 DDo_1 = \frac{o_1\, D}{D}

14b. Subtract. The bottoms match now, so only the tops combine:

o1−x=o1D−NDo_1 - x = \frac{o_1 D - N}{D}

14c. Write out the top. First, o1D=o1k1+o1k2o_1 D = o_1\sqrt{k_1} + o_1\sqrt{k_2}. Then subtract all of NN. The minus sign applies to both parts of NN, so both signs flip:

o1k1+o1k2−k2 o1+k1 i2o_1\sqrt{k_1} + o_1\sqrt{k_2} - \sqrt{k_2}\,o_1 + \sqrt{k_1}\,i_2

14d. Cancel. o1k2o_1\sqrt{k_2} and −k2 o1-\sqrt{k_2}\,o_1 are the same amount with opposite signs, so they cancel and leave:

o1k1+k1 i2o_1\sqrt{k_1} + \sqrt{k_1}\,i_2

14e. Factor. Both parts contain k1\sqrt{k_1}, so pull it out:

o1−x=k1 (o1+i2)k1+k2o_1 - x = \frac{\sqrt{k_1}\,(o_1 + i_2)}{\sqrt{k_1} + \sqrt{k_2}}

Check it with the example, where k1=500\sqrt{k_1} = 500, k2=1,000\sqrt{k_2} = 1{,}000 and D=1,500D = 1{,}500. The bottom is 1,500. The top is 500⋅(1,000+500)=750,000500 \cdot (1{,}000 + 500) = 750{,}000. The fraction is 750,000/1,500=500750{,}000 / 1{,}500 = 500, which matches o1−x=1,000−500o_1 - x = 1{,}000 - 500.

Step 15: use it in the fraction. Now divide k1k_1 by that result. Dividing by a fraction means multiplying by its flip:

k1o1−x=k1⋅k1+k2k1 (o1+i2)\frac{k_1}{o_1 - x} = k_1 \cdot \frac{\sqrt{k_1} + \sqrt{k_2}}{\sqrt{k_1}\,(o_1 + i_2)}

15a. Cancel one k1\sqrt{k_1}. Write k1=k1⋅k1k_1 = \sqrt{k_1} \cdot \sqrt{k_1}. One of them cancels with the k1\sqrt{k_1} on the bottom:

k1o1−x=k1 (k1+k2)o1+i2\frac{k_1}{o_1 - x} = \frac{\sqrt{k_1}\,(\sqrt{k_1} + \sqrt{k_2})}{o_1 + i_2}

15b. Open the bracket. k1⋅k1=k1\sqrt{k_1} \cdot \sqrt{k_1} = k_1 and k1⋅k2=k1k2\sqrt{k_1} \cdot \sqrt{k_2} = \sqrt{k_1 k_2}:

k1o1−x=k1+k1k2o1+i2\frac{k_1}{o_1 - x} = \frac{k_1 + \sqrt{k_1 k_2}}{o_1 + i_2}

In the example this is (250,000+500,000)/1,500=500(250{,}000 + 500{,}000) / 1{,}500 = 500, the AA the pool holds afterwards, as in step 3.

Step 16: subtract i1i_1. This is another fraction minus a whole number. Give i1i_1 the bottom (o1+i2)(o_1 + i_2):

δ1=k1+k1k2o1+i2−i1 (o1+i2)o1+i2=k1+k1k2−i1o1−i1i2o1+i2\delta_1 = \frac{k_1 + \sqrt{k_1 k_2}}{o_1 + i_2} - \frac{i_1\,(o_1 + i_2)}{o_1 + i_2} = \frac{k_1 + \sqrt{k_1 k_2} - i_1 o_1 - i_1 i_2}{o_1 + i_2}

Step 17: cancel and finish. Since k1=i1o1k_1 = i_1 o_1, the terms k1k_1 and −i1o1-i_1 o_1 cancel. Also k1k2=i1o1i2o2\sqrt{k_1 k_2} = \sqrt{i_1 o_1 i_2 o_2}. That leaves:

δ1=i1o1i2o2−i1i2i2+o1\delta_1 = \frac{\sqrt{i_1 o_1 i_2 o_2} - i_1 i_2}{i_2 + o_1}

In the example that is (500,000−125,000)/1,500=250(500{,}000 - 125{,}000) / 1{,}500 = 250, the 250 I paid in at step 3.

What my notes had

The steps above are the tidy version, worked out afterwards. My scratch notes were messier. I set up the equal-price condition with the fee in it, boxed the equation, and pencilled the optimal amount in below it. With r1r_1 and r2r_2 the fee factors (0.997) on the input of each pool, the pencilled line reads:

δ1=i1o1i2o2−i1i2r1 (i2+o1r2)\delta_1 = \frac{\sqrt{i_1 o_1 i_2 o_2} - i_1 i_2}{r_1\,(i_2 + o_1 r_2)}
Handwritten notes defining the reserves and fees of two pools, the condition that the price ratios are equal after the trade, and a boxed equation solved for the optimal trading amount.
My notes: the equal-price condition with fees, solved for the trade size.
Handwritten notes working the same example with numbers, 250 and 1,000 against 500 and 2,000, with a 0.997 fee, to check the two price ratios.
The same example worked with numbers, with the fee included.

It is close, but not exact. For the example it gives 251.25, and the true best input is 250.25. The profit differs only in the fourth decimal place, 747.7432 against 747.7462, so the trade was nearly as good. With a fee, the profit peaks where the marginal prices, fee included, balance, and that is not quite where the reserve ratios are equal.

What the first bot did

The notes were not what ran. The function that turned them into code is calculateAmountToBuy in zzz-the-josh-bot, and it was a rougher implementation. It did not take the square root. It multiplied the equal-price condition out instead, which gives a quadratic:

(k2−k1) x2−2 (k1i2+k2o1) x+(k2o12−k1i22)=0(k_2 - k_1)\,x^2 - 2\,(k_1 i_2 + k_2 o_1)\,x + (k_2 o_1^2 - k_1 i_2^2) = 0

The function then worked out both of its roots and kept whichever was in range:

x=k1i2+k2o1±k1k2 (o1+i2)k2−k1x = \frac{k_1 i_2 + k_2 o_1 \pm \sqrt{k_1 k_2}\,(o_1 + i_2)}{k_2 - k_1}

This is where the edge cases came from. Squaring adds a second, false root, the one where the two prices are equal and opposite. So the code had to handle every combination: both roots negative, both in range, both zero, both too large. It also divides by k2−k1k_2 - k_1, so two pools with the same constant broke it, and the code stopped there with a TODO. The square-root form from the steps above has one root and no such division. On 20,000 random pairs of pools it matches one of the code's two roots every time, and it still works when k1=k2k_1 = k_2.

The fee was not in this formula at all. The router's getAmountIn added it afterwards, when the trade was priced. So the first bot was in the right ballpark, and no more than that. The cyclic arbitrage paper is what made it exact.

What the cyclic arbitrage paper added: a virtual pool

The paper closes both gaps: the fee goes inside the formula, and the answer is exact for any number of pools, not only two.

Wang et al. set the problem up with their own notation, which I use from here. ai,ja_{i,j} is the reserve of token AiA_i in the pool that pairs it with AjA_j. The fee factors are r1r_1 on a pool's input and r2r_2 on its output, so on Uniswap V2 r1=0.997r_1 = 0.997 and r2=1r_2 = 1. A swap of δa\delta_a of AA for δb\delta_b of BB in a pool with reserves aa and bb obeys ab=(a+δar1)(b−δb/r2)ab = (a + \delta_a r_1)(b - \delta_b / r_2), which solves to:

δb=r1r2 b δaa+r1δa\delta_b = \frac{r_1 r_2\, b\, \delta_a}{a + r_1 \delta_a}

Their insight is that a whole chain of pools can be replaced by one virtual pool. For a cycle A1→A2→⋯→An→A1A_1 \to A_2 \to \dots \to A_n \to A_1, fold the first n−1n - 1 pools into a virtual pool with reserves a1,n′a'_{1,n} and an,1′a'_{n,1}. This is their Algorithm 1. Start with a1,n′=a1,2a'_{1,n} = a_{1,2} and an,1′=a2,1a'_{n,1} = a_{2,1}, then for each next pool i=2,…,n−1i = 2, \dots, n - 1:

a1,n′←a1,n′ ai,i+1ai,i+1+r1r2 an,1′,an,1′←r1r2 an,1′ ai+1,iai,i+1+r1r2 an,1′a'_{1,n} \leftarrow \frac{a'_{1,n}\, a_{i,i+1}}{a_{i,i+1} + r_1 r_2\, a'_{n,1}}, \qquad a'_{n,1} \leftarrow \frac{r_1 r_2\, a'_{n,1}\, a_{i+1,i}}{a_{i,i+1} + r_1 r_2\, a'_{n,1}}

The closing pool, between AnA_n and A1A_1, then merges with the virtual pool into a single pool with reserves:

a=a1,n′ an,1an,1+r1r2 an,1′,a′=r1r2 an,1′ a1,nan,1+r1r2 an,1′a = \frac{a'_{1,n}\, a_{n,1}}{a_{n,1} + r_1 r_2\, a'_{n,1}}, \qquad a' = \frac{r_1 r_2\, a'_{n,1}\, a_{1,n}}{a_{n,1} + r_1 r_2\, a'_{n,1}}

The whole cycle now behaves like one trade through a pool with reserves aa and a′a'. Its output is r1r2 a′ δ/(a+r1δ)r_1 r_2\, a'\, \delta / (a + r_1 \delta), so the profit is that minus δ\delta. It starts at zero, climbs, peaks and falls, and at the peak its slope is zero, which gives the optimal input:

δaop=r1r2 a a′−ar1\delta_a^{op} = \frac{\sqrt{r_1 r_2\, a\, a'} - a}{r_1}

A profit exists only when the slope at δ=0\delta = 0 is positive, which is the same as the product of the exchange rates around the cycle, net of fees, being greater than one. That is the test isValueExtractable runs before any real work.

For two pools, n=2n = 2 and the virtual pool is just the first pool. My symbols map onto the cyclic arbitrage paper's as i1=a1,2′i_1 = a'_{1,2}, o1=a2,1′o_1 = a'_{2,1}, i2=a2,1i_2 = a_{2,1} and o2=a1,2o_2 = a_{1,2}, and the paper's answer becomes:

δaop=r1r2i1o1i2o2−i1i2r1 (i2+r1r2 o1)\delta_a^{op} = \frac{r_1 r_2 \sqrt{i_1 o_1 i_2 o_2} - i_1 i_2}{r_1\,(i_2 + r_1 r_2\, o_1)}

Without a fee this is identical to what I found on my own. With the fee it has the same denominator, and one extra factor of r1r2r_1 r_2 on the square root. That factor is the part my equal-price shortcut missed.

The example from my notes, a first pool of 250 and 1,000 and a second pool of 500 and 2,000, comes out as follows. With no fee, aa is 83.33 and a′a' is 1,333.33, so the best input is 111,111−83.33=250\sqrt{111{,}111} - 83.33 = 250, for a profit of 750. With the 0.3% fee, aa is 83.50 and a′a' is 1,331.997, and the best input moves to 250.25, for a profit of 747.75. A brute-force search over every input size lands on the same numbers.

Handwritten notes giving the virtual input and virtual output formulas, with the example of 125,000 over 1,500 equals 83.3, 1,333.3 for the output, and an optimal input of 250.
My notes after reading the cyclic arbitrage paper: the virtual pool built from the example, giving an optimal input of 250.
A page of dotted graph paper with the virtual pool worked with the 0.997 fee, showing a square root and a result of about 250.2497.
The same example with the 0.3% fee, which gives 250.2497.

For a path of more pools, fold in one pool at a time with the two update rules above. A three-pool example agrees with brute force too, to the hundredth.

How the code followed

The three bots show the same arc as the notes, from a close approximation to the exact answer.

BotWhereHow it sized the tradeResult
First bot, 2022zzz-the-josh-bot, Arb.jsSolved the fee-free equal-price condition as a quadratic. The fee was not in the formula, and the router added it afterwards.In the right ballpark, not exact.
Second bot, 2022zzz-do-not-go-gentle, util.jsBuilt the cyclic arbitrage paper's virtual pool and used its closed form, with the fee inside the formula.Exact.
JamBot, 2023jam-bot, Path.calculateOptimalTradingVolumeThe same fold and formula, rewritten in Java and extended to Uniswap V3 by stepping through ticks.Exact within each V3 range.

The Java implementation of this step is Path.calculateOptimalTradingVolume in jam-bot, and the method that decides whether a path is worth evaluating at all is isValueExtractable in the same class.

Pool 1

250 → 1,000

Pool 2

500 → 2,000

Virtual pool

a = 250.00a′ = 1,000.00

Start with pool 1: its reserves become a and a′.

Input

0.00

Output

0.00

Profit

0.00

break even05001,0001,500inputbest input 250.00
input 0.0
Figure M1. The cyclic arbitrage paper's fold, run on real numbers. Each pool is the reserve going in and the reserve coming out. The pools merge into one virtual pool with reserves a and a′, and the profit of the whole cycle is then a single curve with one peak. The first example is the one from my notes: 250 at no fee and 250.25 with the 0.3% fee. The second is a three-pool cycle I checked against a brute-force search.

Uniswap V3: concentrated liquidity

V3 changes the curve. Liquidity is provided inside price ranges, so within one range a pool behaves like a V2 pool with larger virtual reserves, and the moment the price leaves the range the behavior changes. The optimizer therefore works in steps: solve as if the current range lasts forever, check whether the answer crosses the next initialized tick, and if it does, swap exactly up to that tick, update the pool's state, and solve again. Repeat until the answer stays inside a range.

The off-chain code has to match the contract exactly, because the contract is the final judge of every trade. The V3 swap math was therefore ported to integer arithmetic in Java, about 1,100 lines written in five commits between May 17th and June 3rd, 2023, the day the V3 executor went on chain. Pseudo-code for the swap loop (the SwapState and StepComputations structures) was transcribed by hand before any of it was written as code.

You spend

$0

ETH received

0.00

Price now

$2,040

Ticks crossed

0

liquidity by price18001900200021002200230024002000220024000k60k120k180kUSDC spenttick
$0
Figure M2. A concentrated-liquidity pool, simplified. Each bar is the liquidity available in a price range, and the lines between bars are initialized ticks. Buying ETH pushes the price up through the ranges. Inside a range the pool behaves like a V2 pool, so the price follows a smooth curve, and at a tick the liquidity changes and so does the slope. The dashed line is what the price would do if the starting range's liquidity went on forever. This is why the bot swaps up to each tick, updates the pool, and solves again.

What we did not get to

The multi-pool version of the problem: instead of one path at a time, look at every pool of a token pair at once and find the single price at which all of them are in balance. It is a convex optimization problem, and the scratch notes call it "a graphable line". We worked out the idea and never built it.

These notes sketch it as a graph. Price levels run up a vertical axis, swaps from the bottom are stacked against swaps from the top, and the trade that joins the two lines is the answer.

A page with a vertical axis and two horizontal lines, headed by a note about finding a graphable line for swapping from the bottom up and vice versa, and the trade amount that would join the two lines.
The idea in my own words: find a graphable line for swapping from the bottom up and from the top down, and calculate the trade that joins the two.
A price axis from 5 to 15 USDC per WETH, with swaps from WETH to USDC hanging from the top line and swaps from USDC to WETH rising from the bottom line.
The same idea drawn out, with swaps WETH to USDC from the top and USDC to WETH from the bottom.

The research that formalises it is where we would start if we picked the project up again: