The V2 cycle arb helper is fairly complex. It supports overriding reserves, internal and external updates, and the ability to process paths of arbitrary length.
This post has a simple goal: build a V3-compatible helper, and largely skip all the complicated stuff.
Not that we can’t implement the same level of sophistication with the V3 helper, it’s just that it will get extremely messy if I try to bolt on too many features at once.
Instead, this lesson will implement a more narrow scope of features. Recall that “cycle arbitrage” is just a made-up name that I gave to executing an arbitrage with some starting token, then ending with a gain of the same token.
On Ethereum, the most common cycle token is WETH. A 2-pool cycle arbitrage might look like WETH → WETH/DAI (Sushiswap) → DAI → WETH/DAI (Uniswap) → WETH.
We will implement a basic arb helper that will perform cycle arbitrage of WETH between V2 <> V3 and V3 <> V3 pools.
Base and Derived Class
To begin, we take a lesson from the LP Helper (Part I) and begin with a base class. Going forward, I am going to derive my arbitrage helper classes from a common base class called Arbitrage
. So in the degenbot code directory arbitrage
, I define the base class:
base.py
from abc import ABC
class Arbitrage(ABC):
pass
And then begin to build my V3 helper, which I’ll call V3LpSwap
, derived from Arbitrage
:
v3_lp_swap.py
from typing import List, Tuple, Union
from scipy import optimize
from degenbot.arbitrage import Arbitrage
from degenbot.exceptions import (
ArbCalculationError,
InvalidSwapPathError,
DegenbotError
)
from degenbot.token import Erc20Token
from degenbot.uniswap.v2 import LiquidityPool
from degenbot.uniswap.v3 import V3LiquidityPool
class V3LpSwap(Arbitrage):
def __init__(
self,
input_token: Erc20Token,
swap_pools: List[Union[LiquidityPool, V3LiquidityPool]],
name: str = "",
max_input: int = None,
id: str = None,
):
if id:
self.id = id
if name:
self.name = name
self.input_token = input_token
self.swap_pools = swap_pools
self.max_input = max_input
So far it’s quite simple. It requires an Erc20Token
object for the input token (WETH in this example), a list of swap pools (LiquidityPool
or V3LiquidityPool
), and some optional inputs (name
, max_input
, and id
).
Alternative Constructor
One thing I mentioned in the LP Helper lesson is providing alternative constructors. Under the V2 paradigm, I provided many optional keyword arguments, including the ability to build a helper from a list of objects or a list of addresses (but not both).
Here, I am going to demonstrate how to build an alternative constructor that will build a helper from addresses instead of from pre-build helper objects.
I will call it from_addresses
, and I will implement it as a class method. A class method is a method that is implemented at the class level, and is not expected to be called from an already-instantiated object.
In this case the from_addresses
method will accept the list of addresses, generate the required inputs for the normal constructor, call it, then return the generated object. This has the effect of building the helper from addresses without having to clutter up my default constructor.
Add it like so:
@classmethod
def from_addresses(
cls,
input_token_address: str,
swap_pool_addresses: List[Tuple[str, str]],
name: str = "",
max_input: int = None,
id: str = None,
) -> "V3LpSwap":
"""
Create a new `V3LpSwap` object from token and pool addresses.
Arguments
---------
input_token_address : str
A address for the input_token
swap_pool_addresses : List[str]
An ordered list of tuples, representing the address for
each pool in the swap path, and a string specifying the
Uniswap version for that pool (either "V2" or "V3")
e.g. swap_pool_addresses = [
("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD","V3"),
("0xbb2b8038a1640196fbe3e38816f3e67cba72d940","V2")
]
name : str, optional
The display name for the helper
max_input: int, optional
The maximum input for the cycle token in question
(typically limited by the balance of the deployed contract
or operating EOA)
id: str, optional
A unique identifier for bookkeeping purposes
(not used internally, the attribute is provided for
operator convenience)
"""
# create the token object
try:
token = Erc20Token(input_token_address)
except:
raise
# create the pool objects
pool_objects = []
for pool_address, pool_type in swap_pool_addresses:
# determine if the pool is a V2 or V3 type
if pool_type == "V2":
pool_objects.append(LiquidityPool(address=pool_address))
elif pool_type == "V3":
pool_objects.append(V3LiquidityPool(address=pool_address))
else:
raise DegenbotError(
f"Pool type not understood! Expected 'V2' or 'V3', got {pool_type}"
)
return cls(
input_token=token,
swap_pools=pool_objects,
name=name,
max_input=max_input,
id=id,
)
You’ll notice that this method returns an object, as opposed to the typical constructor that does not explicitly return anything.
Input Optimization
If you have not worked through the lesson on using SciPy to calculate the optimal borrow amount, please review it.
If you don’t understand it, this might help. The purpose of using SciPy’s minimize_scalar
method is to iteratively identify an optimal input, x
, that minimizes the output of some function func
at requested tolerance and boundary parameters.
A scalar function is one that returns a single value for a single input. The profit function for a cycle arbitrage is one such scalar function.
Let’s say that I want to cycle WETH through two pools, poolA and poolB, which both hold WETH and some arbitrary other token (TKN).
I will only consider a single direction, poolA → poolB. My profit function arb_profit
might look like this:
arb_profit = WETH_received_from_poolB - WETH_sent_to_poolA
But what is WETH_received_from_poolB
?
That can be determined from our LP helper, using the familiar interface function calculate_tokens_out_from_tokens_in
.
So using lpB
as a variable to represent our helper for poolB, WETH_received_from_poolB = lpB.calculate_tokens_out_from_tokens_in(token_in=TKN,token_in_quantity=…)
And what is the value of token_in_quantity
? That’s how much TKN we receive from poolA after swapping in x
WETH.
So token_in_quantity
for poolB equals lpA.calculate_tokens_out_from_tokens_in(token_in=WETH,token_in_quantity=x)
We can rewrite the expression for as:
WETH_received_from_poolB = lpB.calculate_tokens_out_from_tokens_in(
token_in=TKN,
token_in_quantity = lpA.calculate_tokens_out_from_tokens_in(
token_in=WETH,
token_in_quantity=x
)
)
And our familiar profit function can be rewritten as a function of that input x
:
arb_profit = lpB.calculate_tokens_out_from_tokens_in(
token_in=TKN,
token_in_quantity=lpA.calculate_tokens_out_from_tokens_in(
token_in=WETH,
token_in_quantity=x
)
) - x
The SciPy-powered optimizer can now be written to optimize the WETH amount sent into the poolA → poolB arb, the profit of which is found by calculating arb_profit
.
There are two wrinkles:
minimize_scalar
attempts to find a minimum value, while we want to maximize the profitSciPy works using float values, while EVM and our helpers use integers
We work around the first issue by simply negating our profit function. Now when we find the minimum, it’s just the negative of the max profit.
We work around the float/int issue by converting all x
values to integers before sending into the helpers, and transform the integer result to a float so SciPy is satisfied.