Degen Code

Degen Code

Share this post

Degen Code
Degen Code
UniswapV3 — Cycle Arbitrage Helper (Part I)

UniswapV3 — Cycle Arbitrage Helper (Part I)

Just Cycle My WETH Up Fam

Dec 06, 2022
∙ Paid
8

Share this post

Degen Code
Degen Code
UniswapV3 — Cycle Arbitrage Helper (Part I)
Share

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 profit

  • SciPy 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.

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2025 BowTiedDevil
Privacy ∙ Terms ∙ Collection notice
Start writingGet the app
Substack is the home for great culture

Share