Degen Code

Degen Code

Share this post

Degen Code
Degen Code
UniswapV3 — Swap Prediction (Part I)
Copy link
Facebook
Email
Notes
More

UniswapV3 — Swap Prediction (Part I)

Single-Tick Swaps

Oct 08, 2022
∙ Paid
17

Share this post

Degen Code
Degen Code
UniswapV3 — Swap Prediction (Part I)
Copy link
Facebook
Email
Notes
More
Share

This lesson will test our understanding of the Pool, Router and Quoter contracts.

It’s all fine and good to understand what the contracts do, but the goal of MEV enjoyooooors is to identify and profit from misaligned markets. Identifying misaligned markets is all about prices, prices, prices!

So we will take our very familiar and comfy DAI/WETH pool, run a local fork, and then beat on this pool until we understand how swaps affect the price, how liquidity and ticks are affected for various trade sizes, and how to properly predict the end state for some fictional trades.

The end goal is to build another set of degenbot helper classes. Again, nice to know how all this works, but even better to figure it out and then abstract away the details.

Lesson Structure

This will be a two-part lesson. The first part covers how to predict and verify swaps within a single tick boundary. The second part covers the same topic, but for swaps that cross tick boundaries.

Console Exploration

This will be a console-heavy lesson, so let’s get ready. Spin up your local fork at block height 15686250.

We will be working with these addresses:

  • Liquidity Pool (DAI/WETH): 0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8

  • Quoter: 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6

  • QuoterV2: 0x61fFE014bA17989E743c5F6cB21bF9697530B21e

  • Router: 0xE592427A0AEce92De3Edee1F18E0157C05861564

  • WETH: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2

  • DAI: 0x6B175474E89094C44Da98b954EedeAC495271d0F

>>> lp = Contract.from_explorer(
    '0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8'
    )

>>> quoter = Contract.from_explorer(
    '0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6'
    )

>>> quoterv2 = Contract.from_explorer(
    '0x61fFE014bA17989E743c5F6cB21bF9697530B21e'
    )

>>> router = Contract.from_explorer(
    '0xE592427A0AEce92De3Edee1F18E0157C05861564'
    )

>>> weth = Contract.from_explorer(
    '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
    )

>>> dai = Contract.from_explorer(
    '0x6B175474E89094C44Da98b954EedeAC495271d0F'
    )

Now let’s get ourselves some fake WETH, grant approval for swapping it via the router, and set a chain snapshot to easily return to “zero”.

>>> weth.deposit({'from':accounts[0],'value':500*10**18})
Transaction sent: 0x06e240acfbd41fed3a1d6fc68e2ce93f369419fa60c5d62e8d6c5850b24463b7
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 6
  Transaction confirmed   Block: 15686252   Gas used: 43738 (0.36%)

<Transaction '0x06e240acfbd41fed3a1d6fc68e2ce93f369419fa60c5d62e8d6c5850b24463b7'>

>>> weth.approve(router.address,500*10**18,{'from':accounts[0]})
Transaction sent: 0xb98a364af99c1e896bcca4ec40f5add4b59f3744a1274d913db47233dbb7e348
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 7
  Transaction confirmed   Block: 15686253   Gas used: 43964 (0.37%)

<Transaction '0xb98a364af99c1e896bcca4ec40f5add4b59f3744a1274d913db47233dbb7e348'>

>>> chain.snapshot()

>>> chain.height
15686253

Now we have 500 WETH, a liquidity pool, and freedom to poke at it until we achieve enlightenment.

A Zen koan for this exercise: what is the sound of one pool swapping?

Same-Tick Swap

The first thing we want to test is swap behavior of the pool inside a single tick. We’ll do it by calculating a WETH swap that will keep us inside the current tick.

First let’s find the current tick, sqrtPriceX96, and liquidity, then get a quote to see the effect:

>>> current_sqrtpricex96, current_tick, *others = lp.slot0()
>>> current_tick
-72269
>>> lp.liquidity()
1548654344361108748118783

Recall again that liquidity is provided inside of a tick range. The lower boundary of the current tick range will be at tickSpacing * (current_tick // tickSpacing) and the upper boundary will be at tickSpacing * (current_tick // tickSpacing + 1).

Here are those values:

>>> lp.tickSpacing() * (current_tick//lp.tickSpacing())
-72300
>>> lp.tickSpacing() * (current_tick//lp.tickSpacing() + 1)
-72240

So the largest possible single-tick swap in the “positive” direction will drive the tick to -72240.

From the white paper, we find this equation:

Δ𝑥 = Δ(1 / √𝑃) · 𝐿 (Eq. 6.16 - token0 (x) swapped out for a given change in sqrtPrice)

And we know that sqrtPrice is a function of ticks:

𝑖𝑐 = log(base=√1.0001)√𝑃 (Eq. 6.8 - tick index as a function of sqrtPrice)

First, verify equation 6.8 against the current pool state:

>>> current_sqrtpricex96
2136349984626364120081779203
>>> import math
>>> math.log(
    current_sqrtpricex96*2**(-96),
    math.sqrt(1.0001)
)
-72268.27446817268

NOTE: the pool contract stores sqrtPriceX96 as a Q64.96 value, so we transform it to sqrtPrice by multiplying by 2^-96.

EVM does floor division by default, so we can modify this a little by surrounding the expression with floor():

>>> math.floor(
        math.log(
            current_sqrtpricex96*2**(-96),
        math.sqrt(1.0001)
    )
)
-72269

So now we’ve verified that the current tick matches the current sqrtPrice. And we know that the a maximum single-tick swap will drive the tick up to -72240. We know the current tick and the final tick, which allows us to solve for the change in sqrtPrice.

The sqrtPrice at tick -72240 is, using equation 6.2 from the white paper:

>>> 1.0001**(-72240/2)
0.02700267315767631

And the current sqrtPrice is:

>>> current_sqrtpricex96*2**(-96)
0.02696452772385997

From the white paper, we know some things:

  • Liquidity 𝐿 does not change inside a tick interval

  • The virtual reserves of token0 (𝑥) and token1 (𝑦) are related to Liquidity, 𝐿 = √𝑥𝑦 (Eq. 6.3)

  • Token swaps inside a single tick are related only to Liquidity and sqrtPrice (Eq. 6.14 and 6.16)

From our results above, we calculate the change in √𝑃:

>>> 1.0001**(-72240/2) - current_sqrtpricex96*2**(-96)
3.8145433816340335e-05

And from this value, calculate the amount of token1 (WETH) needed to drive the sqrtPrice to our target:

>>> (1.0001**(-72240/2)-current_sqrtpricex96*2**(-96)) * lp.liquidity()
5.907409179721461e+19

Which is 59.07 WETH, after correcting for decimals.

So let’s do a test swap and check the new results:

>>> tx = router.exactInputSingle(
    (
        weth.address, 
        dai.address, 
        3000, 
        accounts[0], 
        99999999999999999, 
        59.074*10**18,
        0,
        0
    ),
    {'from':accounts[0]}
)

Transaction sent: 0xde1f9912477b0a0b28db27113f6e83233430a8ad26b184f7952ba3755cfd27cb
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 8
  Transaction confirmed   Block: 15686254   Gas used: 135755 (1.13%)

>>> lp.slot0()
(2139363105995855777310422166, -72241, 622, 1500, 1500, 0, True)

We ended up shy of the target tick by 1. We can confirm we’re still inside the same liquidity range at this new tick:

>>> lp.liquidity()
1548654344361108748118783

Let’s see how close we can get by reverting the chain and trying it again with more WETH:

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