Part I in the Conditional Actions series covered the basic idea of conditional actions, partial functions, and callables. It wrapped up with a simple demonstration of watching the Ether price from a Chainlink price feed contract.
Now let’s extend the concept to include the typical use case: ERC-20 tokens in a pool.
To demonstrate, we will monitor the reserves of a pair of high-activity pools on Uniswap, create some conditions and actions, then observe the results with a simple watcher.
We will watch these pools:
I chose these because meme coins have been getting a lot of attention recently, so trading activity is frequent.
I have no PEPE holdings and don’t care about its current price, but perhaps you do!
A Refresher on Pool Price
A liquidity pool has no concept of the “correct” price. It only provides a mechanism to perform token swaps between two or more assets. It typically makes no effort to enforce a certain price, and even the famed Curve V1 StableSwap style pools will allow the price to drift away from the nominal 1:1 ratio when the swapping activity drives it there.
Price is therefore a downstream effect of the swaps that have occurred within its liquidity reserves.
Uniswap V2
A V2 pool is quite simple. The current price is the ratio of the two token reserves (token0
and token1
).
The price of one token is expressed as a ratio of that token’s reserves against the reserves of the other:
Check out this deep dive if you want to learn a lot more about the V2 pool and its construction:
Uniswap V3
A V3 pool is more complex, but only because of the odd naming. The current price is recorded as the square root of the price ratio raised to the 96th power of 2. The pool contract expresses this value as sqrtPriceX96
. You can explore the Uniswap V3 Pool Contract in great detail in the classic lesson below.
If you’re already familiar with the concept, all you need is a formula to convert the pool’s price to a nominal value. Uniswap published a nice primer on V3 math, which I recommend!
The key formula from that article is:
or
That price is always expressed in absolute units of token1/token0
. So the price for the other token is the inverse.
Rewriting as standalone equations for both tokens:
Decimal-Corrected Prices
Finally, consider that ERC-20 token contracts implement a decimals
value that must be accounted for when we’re considering nominal prices instead of absolute prices. The pool contracts typically don’t factor in the decimals value, but we should still be aware of them when considering prices.
For example, if you were looking to express a price for WETH in terms of USDC, you would need to account for decimals. WETH has 18 decimals, and USDC has 6. So when expressing the WETH price in terms of USDC, the value must be corrected.
The format for this correction is expressed as a ratio of two powers of 10.
The price for a V2 pool is expressed in absolute numbers, without any decimal correction.
And the nominal price is corrected by multiplying by the inverse ratio of decimal-exponentiated power of 10 for each token:
Price-Aware Pool Helpers
Currently the LiquidityPool
and V3LiquidityPool
classes do not directly expose the current price, but it’s simple to do add this.
In LiquidityPool
, add the following methods:
def get_absolute_price(self, token) -> Fraction:
"""
Get the absolute price for the given token, expressed as a ratio
of the two pool tokens.
"""
if token == self.token0:
return (
Fraction(self.reserves_token0)
/ Fraction(self.reserves_token1)
)
elif token == self.token1:
return (
Fraction(self.reserves_token1)
/ Fraction(self.reserves_token0)
)
else:
raise ValueError(f"Unknown token {token}")
def get_nominal_price(self, token) -> Fraction:
"""
Get the nominal price for the given token, expressed as a ratio
of the two pool tokens, corrected for decimal place values.
"""
if token == self.token0:
return (
Fraction(self.reserves_token0)
/ Fraction(self.reserves_token1)
* Fraction(10**self.token1.decimals)
/ Fraction(10**self.token0.decimals)
)
elif token == self.token1:
return (
Fraction(self.reserves_token1)
/ Fraction(self.reserves_token0)
* Fraction(10**self.token0.decimals)
/ Fraction(10**self.token1.decimals)
)
else:
raise ValueError(f"Unknown token {token}")
And in V3LiquidityPool
, add a similar pair of methods:
def get_absolute_price(self, token) -> Fraction:
"""
Get the absolute price for the given token, expressed as a ratio
of the two pool tokens.
"""
if token == self.token0:
return Fraction(2**192) / Fraction(self.sqrt_price_x96**2)
elif token == self.token1:
return Fraction(self.sqrt_price_x96**2) / Fraction(2**192)
else:
raise ValueError(f"Unknown token {token}")
def get_nominal_price(self, token) -> Fraction:
"""
Get the nominal price for the given token, expressed as a ratio
of the two pool tokens, corrected for decimal place values.
"""
if token == self.token0:
return (
Fraction(2**192)
/ Fraction(self.sqrt_price_x96**2)
* Fraction(10**self.token1.decimals)
/ Fraction(10**self.token0.decimals)
)
elif token == self.token1:
return (
Fraction(self.sqrt_price_x96**2)
/ Fraction(2**192)
* Fraction(10**self.token0.decimals)
/ Fraction(10**self.token1.decimals)
)
else:
raise ValueError(f"Unknown token {token}")
This allows us to ask the pool for the current absolute and nominal prices for either token.
I’ve committed these methods to the main degenbot repo under the conditional_action branch.
A Final Note About Floating Point Accuracy
I have formatted the price values using the Fraction class, which allows us to work with complicated fractional values without loss of precision. Whenever possible, you should avoid converting an integer expression to a float until the last possible moment.
PEPE Watching
With the necessary price methods in place, write a watcher that builds a container to hold the last known price, builds the PEPE token and the pools, sets up conditional actions that will execute on price changes, and then run:
pepe_price_watcher.py