Degen Code

Degen Code

Share this post

Degen Code
Degen Code
UniswapV3 — LP Helper (Part I)
Copy link
Facebook
Email
Notes
More

UniswapV3 — LP Helper (Part I)

Help Me LP? 🥹

Nov 19, 2022
∙ Paid
13

Share this post

Degen Code
Degen Code
UniswapV3 — LP Helper (Part I)
Copy link
Facebook
Email
Notes
More
9
Share

When I assembled the V2 helpers I just worked in isolation for a while and then dumped them onto github. It’s time to get the V3 helpers built, so I’m going to write and code as I go, talking through my design.

I’ve learned a lot since I started slinging Python last year, so with luck these V3 helpers will be more robust and be easier to maintain.

I’m a self-taught coder after all so if you see dumb stuff here please comment, send me a pull request, or DM on Twitter or Discord . I don’t have an ego about this, I find and fix bugs all the time and don’t care about looking dumb (open source reveals all). I just want good code, so help me out!

Helper Philosophy

The point of a object-oriented programming is to enable abstract structures that can hold data and instructions together. I built V2 helpers for liquidity pools, tokens, routers, Chainlink oracles, and abstract arbitrage path helpers that automatically built inputs and outputs.

I will build the V3 helpers in a similar way, but will “begin with the end in mind” to reduce the spaghetti nature of the V2 codebase.

Lessons Learned

Interfaces

An important thing I didn’t do with V2 helpers was to define an interface early. I started with the data first, then built methods to pull data out of them as needed. The method names I chose were OK, and I’ll likely reuse or provide similar ones here. But given that we understand the DeFi ecosystem better now, we can come up with some decent “evergreen” method names, attribute names, internal data structures, etc. that apply to tokens, liquidity pools, routers, arbitrage pathways, and generated calldata in general.

Inheritance

Because I did not understand inheritance well enough, I created several similar-but-different arbitrage helpers. Some use the LP directly, some use the router, some support overriding reserves, some support transfer swaps, some support flash borrows. It’s all a mess and there’s a lot of code repeated inside each class.

For the V3 helpers, I am going to start by defining an abstract base class for each type, then derive the necessary classes off of that. In this way, improvements and bug fixes can be fixed “at the top” and all derived classes benefit downstream.

An abstract base class is designed to be inherited, not instantiated directly. We will work with the inherited classes from here, but if you look in a future version of the codebase and see V3 helpers built from a minimal base class full of exceptions, this is why.

THIS is an introductory lesson demonstrating how to build an abstract base class and derived classes from it. THIS is a much more in-depth lesson discussing object-oriented programming in general, including working with inheriteance.

The reason for starting with an abstract base class is to force myself to properly define an interface for each helper. An interface is a set of standard method names and attributes that other related classeswill use to send and receive data from the class.

Alternative Constructors

The V2 helpers have a lot of keyword arguments that change the behavior of the constructor. This leads to flexible classes, but a lot of assertions and argument checking inside the constructor to make sure that conflicting arguments weren’t provided.

Instead, I will provide alternative constructors that change the behavior of the class at instantiation. If you’re used to using Brownie, you know that the Contract() constructor will build a contract object from an address or alias that has already been fetched. You can also use the Contract.from_explorer() constructor to do the same thing, but first retrieving the source code from a block explorer. The Contract.from_explorer() method is known as an alternative constructor, which allows you to create the same object with a different process.

If you want to learn more, THIS is a nice lesson covering alternative constructors.

Exceptions

I will also define some specifically-named exceptions that allow a user to catch errors related to the V3 helper that are separate from other exceptions that may be raised downstream in a Python module.

For example, if I provide an base exception named DegenbotError, you can build your error handling like this:

try:
    some_helper.some_method()
except DegenbotError:
    do_something()
except Exception:
    do_something_else()

This allows you to separate issues in the degenbot code base from issues that may occur in the websocket, web3, scipy, asyncio, etc. modules.

Better Docstrings

I add a lot of comments in my helpers, but do a pretty bad job of defining the behavior of the overall class and its methods.

A docstring is a long description of the function that appears as the first item at the top of the class or method. It explains its purpose, inputs and outputs, dependencies, the exceptions generated, and other relevant info.

I will provide a docstring at the top of each class.

Better Organization

As I build out helpers for more ecosystems, organization becomes more important. I will create a dedicated folder for each ecosystem, sub-folders as needed for various versions (v2 and v3, for examples).

I will also reorganize the V2 helpers appropriately, so don’t be surprised if you look on github soon and see files in new places.

Building The LP Helper

So without further ado, let’s build a V3 LP helper!

From our exploration of the V3 Liquidity Pool Contract, we know there are several immutable properties that we care about:

  • Factory address

  • Pool Address

  • ABI

  • Fee

  • Tick spacing

  • Token0 Address

  • Token1 Address

These are the mutable properties that we care about:

  • Current tick

  • Current liquidity

  • Current square root price

  • Initialized ticks

  • Liquidity changes across tick boundaries

All of these properties will be attributes of the class. We always care about them, so they will be defined in the abstract base class with names that make sense.

Base Class Definition

Let’s build that abstract base class now in a file called v3_liquidity_pool.py:

from abc import ABC


class UniswapV3LiquidityPool(ABC):
    def __init__(self, address):
        self.address = address

If I load the Python interpreter I can check that I can create a new UniswapV3LiquidityPool object, and I have access to the address attribute.

>>> import v3_liquidity_pool
>>> obj = v3_liquidity_pool.BaseV3LiquidityPool('0x420420')
>>> obj.address
'0x420420'

Easy enough. All V3 LPs will have an address, so I don’t need do apply any special protection to methods that use that attribute. Let’s write another method called get_address:

from abc import ABC


class BaseV3LiquidityPool(ABC):
    def __init__(self, address):
        self.address = address

    def get_address(self):
        return self.address

Now test it:

>>> import v3_liquidity_pool
>>> obj = v3_liquidity_pool.BaseV3LiquidityPool('0x420420')
>>> obj.address
'0x420420'
>>> obj.get_address()
'0x420420'

Kind of a dumb example but it will be a bit more clear when I derive a new class from this base class:

from abc import ABC


class BaseV3LiquidityPool(ABC):
    def __init__(self, address):
        self.address = address

    def get_address(self):
        return self.address


class V3LiquidityPool(BaseV3LiquidityPool):
    pass

To derive a class, define it with the class keyword, the new name, and the parent class as the argument.

Now open the console and create two objects:

>>> import v3_liquidity_pool
>>> obj_base = v3_liquidity_pool.BaseV3LiquidityPool('0x420420')
>>> obj_base.address
'0x420420'
>>> obj_base.get_address()
'0x420420'

>>> obj_derived = v3_liquidity_pool.V3LiquidityPool('0x696969')
>>> obj_derived.address
'0x696969'
>>> obj_derived.get_address()
'0x696969'

The derived class has access to both the __init__ and get_address methods, as well as the attributes defined in the constructor. I didn’t need to re-define that. Cool! Even though both the interface (external methods and attributes) and the code (implementation) are defined in the base class, the derived classes have automatic access to it.

Now, what if I decide that V3 LPs should behave differently based on some property? I can define an abstract method in the base class which will cause instantiation to fail whenever that method is not overridden. This does two interesting things:

  • I can no longer instantiate the base class directly.

  • I am forced to redefine the method in the derived class.

Why bother? This ensures that classes that interact with the base class can have a “fixed” interface with method names and attributes that do not change. It forces all derived classes to redefine the abstract methods in a way that is suitable for their differences.

Most importantly, it keeps me from reusing code and makes me think more about what I’m doing! Which is good because I’m a dummy sometimes.

Let’s define an abstract method called get_tick_spacing and see how it affects the classes:

from abc import ABC, abstractmethod


class BaseV3LiquidityPool(ABC):
    def __init__(self, address):
        self.address = address
    
    def get_address(self):
        return self.address

    @abstractmethod
    def get_tick_spacing(self):
        pass


class V3LiquidityPool(BaseV3LiquidityPool):
    pass

On the console:

>>> import v3_liquidity_pool

>>> obj_base = v3_liquidity_pool.BaseV3LiquidityPool('0x420420')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class BaseV3LiquidityPool with abstract method get_tick_spacing

Right away, Python fails to instantiate the base class since it finds the abstract method.

We find the same issue with the derived class:

>>> obj_derived = v3_liquidity_pool.V3LiquidityPool('0x696969')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class V3LiquidityPool with abstract method get_tick_spacing

Now that I’ve turned the base class into one with abstract methods, I know that I’m now responsible for defining this in the derived classes. So let’s do that, returning some arbitrary data:

from abc import ABC, abstractmethod


class BaseV3LiquidityPool(ABC):
    def __init__(self, address):
        self.address = address

    def get_address(self):
        return self.address

    @abstractmethod
    def get_tick_spacing(self):
        pass


class V3LiquidityPool(BaseV3LiquidityPool):
    def get_tick_spacing(self):
        return 420

On the console:

>>> import v3_liquidity_pool
>>> obj_derived = v3_liquidity_pool.V3LiquidityPool('0x696969')
>>> obj_derived.get_tick_spacing()
420

I have no idea whether get_tick_spacing should be an abstract method or not, it’s just a silly example for demonstration. The intent is to show you that I will build my classes this way, deriving from some base class with shared methods and attributes, and overriding functions that require special behavior in the first appropriate derived class.

I don’t see a huge need for this feature in the LP helper, but the arbitrage helpers are likely candidates for this approach. They are set up for different methods depending on whether they are cycle, 2pool, triangle, flash borrow, etc. I’ll define some BaseArbitrage class with a stable interface (so your bot can interact with it), then override the behavior of the various calculations depending on what kind of arbitrage it is.

Initial LP Helper

With all that theoretical stuff out of the way, let’s build a preliminary version of the LP helper. The helper will be capable of interacting with the blockchain, so it needs access to a web3 object (either web3py or Brownie).

The constructor will pull the relevant data from the blockchain through the web3 object, then store the results internally.

We will make this helper more complicated as we progress, but now are building the skeleton. At startup, the LP helper should create a Brownie object from the provided address, then retrieve the relevant data from the LP contract:

from abc import ABC, abstractmethod
from brownie import Contract
from brownie.convert import to_address


class BaseV3LiquidityPool(ABC):
    def __init__(self, address):
        self.address = to_address(address)

        try:
            self._brownie_contract = Contract(address=address)
        except:
            try:
                self._brownie_contract = Contract.from_explorer(
                    address=address,
                    silent=True
                )
            except:
                raise

        try:
            self.token0 = self._brownie_contract.token0()
            self.token1 = self._brownie_contract.token1()
            self.fee = self._brownie_contract.fee()
            self.slot0 = self._brownie_contract.slot0()
            self.liquidity = self._brownie_contract.liquidity()
            self.tick_spacing = self._brownie_contract.tickSpacing()
        except:
            raise


class V3LiquidityPool(BaseV3LiquidityPool):
    pass

This constructor will attempt to create a Brownie contract from storage, then from the explorer. If both fail, it will raise an exception and quit. If the contract creation succeeds, it will fetch token0, token1, fee, slot0, liquidity, and tickSpacing.

Connect to an Ethereum network via Brownie and let’s try it out:

(.venv) [devil@dev bots]$ brownie console --network mainnet-local
Brownie v1.19.2 - Python development framework for Ethereum

BotsProject is the active project.
Brownie environment is ready.

>>> import v3_liquidity_pool
>>> lp = v3_liquidity_pool.V3LiquidityPool(
    '0xCBCdF9626bC03E24f779434178A73a0B4bad62eD'
)

>>> lp.token0
'0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599'
>>> lp.token1
'0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
>>> lp.slot0
(29333211683603067842387080126642042, 256450, 62, 200, 200, 0, True)
>>> lp.liquidity
1991177688360852910
>>> lp.fee
3000
>>> lp.tick_spacing
60
>>> lp.sqrt_price_x96
29333201934790359784279402125234824
>>> lp.tick
256450

Looks good, now let’s add a method called update that will refresh these mutable state values. Instead of silently updating them, it will return a tuple with True/False to indicate whether any new values were found, as well as a dictionary of the new values. This helps us display updates inside a running bot, instead of making separate calls to the helper to retrieve and parse the values again. If we care about the values, we can use them. If not, they can be ignored:

from abc import ABC, abstractmethod
from brownie import Contract
from brownie.convert import to_address


class BaseV3LiquidityPool(ABC):
    def __init__(self, address):
        self.address = to_address(address)

        try:
            self._brownie_contract = Contract(address=address)
        except:
            try:
                self._brownie_contract = Contract.from_explorer(
                    address=address,
                    silent=True
                )
            except:
                raise

        try:
            self.token0 = self._brownie_contract.token0()
            self.token1 = self._brownie_contract.token1()
            self.fee = self._brownie_contract.fee()
            self.slot0 = self._brownie_contract.slot0()
            self.liquidity = self._brownie_contract.liquidity()
            self.tick_spacing = self._brownie_contract.tickSpacing()
            self.sqrt_price_x96 = self.slot0[0]
            self.tick = self.slot0[1]
        except:
            raise

    def update(self):
        updates = False
        try:
            if (slot0 := self._brownie_contract.slot0()) != self.slot0:
                updates = True
                self.slot0 = slot0
                self.sqrt_price_x96 = self.slot0[0]
                self.tick = self.slot0[1]
            if (
                liquidity := self._brownie_contract.liquidity()
            ) != self.liquidity:
                updates = True
                self.liquidity = liquidity

        except:
            raise
        else:
            return updates, {
                "slot0": self.slot0,
                "liquidity": self.liquidity,
                "sqrt_price_x96": self.sqrt_price_x96,
                "tick": self.tick,
            }


class V3LiquidityPool(BaseV3LiquidityPool):
    pass

Now try it out:

>>> import v3_liquidity_pool
>>> lp = v3_liquidity_pool.V3LiquidityPool(
    '0xCBCdF9626bC03E24f779434178A73a0B4bad62eD'
)

# ... several minutes later

>> lp.update()
(True, {'slot0': (29333186564956269021547502108362541, 256450, 62, 200, 200, 0, True), 'liquidity': 1991177688360852910, 'sqrt_price_x96': 29333186564956269021547502108362541, 'tick': 256450})

You may recall that some V3 LPs do not have their source code verified on Etherscan, so we need a way to work around this. We can store a copy of the generic ABI inside the class, and use if the primary methods fail.

Create a new file called v3_lp_abi.py with the following single definition:

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

Copy link
Facebook
Email
Notes
More