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: