Degen Code

Degen Code

Share this post

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

UniswapV3 — Swap Prediction (Part III)

Multi-Pool, Multi-Tick Swaps

Oct 28, 2022
∙ Paid
8

Share this post

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

First we learned how to do single-tick, single pool swaps, then we learned how to do multi-tick, single pool swaps.

Now we dive into the most complex kind of trading: multi-tick, multi-pool swaps.

Don’t be scared though. It’s not much harder than working through the previous lesson, and the added complexity is mostly bookkeeping.

Swapping across multiple pools will occur one pool at a time, one tick at a time. If we work carefully and keep track of the internal swap state of the pool as we progress, it will be no problem to correctly predict the output of trades of arbitrary length and complexity.

Console Exploration

We will use the familiar DAI/WETH pool from Part I and Part II. In addition to DAI/WETH, we will use the DAI/WBTC pool at address 0x391e8501b626c623d39474afca6f9e46c2686649.

The exercise is to identify some trade size along the WETH → DAI → WBTC path that drives both pools across a tick boundary.

As I sit down to write, the most recent mainnet block is at height 15836600.

Before you proceed, make sure to clone a copy of my univ3py repo into your working directory. It contains some helper functions for calculating swap values on UniswapV3.

Open the Brownie console, forking at block height 15836600, then load up some contracts:

(.venv) devil@hades:~/bots$ BLOCK=15836600 brownie console --network mainnet-fork-atblock
Brownie v1.19.2 - Python development framework for Ethereum

BotsProject is the active project.

Launching 'ganache-cli --chain.vmErrorsOnRPCResponse true --wallet.totalAccounts 10 --hardfork istanbul --fork.url https://rpc.ankr.com/eth@15836600 --miner.blockGasLimit 12000000 --wallet.mnemonic brownie --server.port 6969'...
Brownie environment is ready.
>>> chain.height
15836601
>>> quoter = Contract.from_explorer(
    '0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6'
    )
>>> quoter2 = Contract.from_explorer(
    '0x61fFE014bA17989E743c5F6cB21bF9697530B21e'
    )
>>> router = Contract.from_explorer(
    '0xE592427A0AEce92De3Edee1F18E0157C05861564'
    )
>>> router2 = Contract.from_explorer(
    '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45'
    )
>>> lens = Contract.from_explorer(
    '0xbfd8137f7d1516D3ea5cA83523914859ec47F573'
    )
>>> lp_weth_dai = Contract.from_explorer(
    '0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8'
    )
>>> lp_dai_wbtc = Contract.from_abi(
    name='',
    address='0x391e8501b626c623d39474afca6f9e46c2686649',   
    abi=lp_weth_dai.abi
    )
>>> weth = Contract.from_explorer(
    '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
    )
>>> dai = Contract.from_explorer(
    '0x6B175474E89094C44Da98b954EedeAC495271d0F'
    )
>>> wbtc = Contract.from_explorer(
    '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599'
    )

That from_abi call is a bit of a curve-ball. Some of the UniswapV3 pools aren’t correctly verified on Etherscan, but they all use the same ABI and are deployed from a factory so I’m just monkey-patching the common ABI from the WETH/DAI pool onto the DAI/WBTC pool.

Now take some helpful actions — exchange ETH for WETH, set unlimited approval for the routers, and set a chain snapshot:

>>> weth.deposit({
    'from':accounts[0],
    'value':web3.toWei(500,"ether")
    })
Transaction sent: 0x06e240acfbd41fed3a1d6fc68e2ce93f369419fa60c5d62e8d6c5850b24463b7
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 6
  Transaction confirmed   Block: 15836602   Gas used: 43738 (0.36%)

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

>>> weth.approve(
    router2.address,
    web3.toWei(500,"ether"),
    {'from':accounts[0]}
)
Transaction sent: 0x79deb1c2ccaddb55bdff93bbdd6fc950980d2b73394752c7b93e11806598e884
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 8
  Transaction confirmed   Block: 15836604   Gas used: 43964 (0.37%)

>>> chain.snapshot()

Check the token0/token1 position for each pool:

>>> lp_weth_dai.token0()
'0x6B175474E89094C44Da98b954EedeAC495271d0F'  # DAI
>>> lp_weth_dai.token1()
'0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'  # WETH

>>> lp_dai_wbtc.token0()
'0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599'  # WBTC
>>> lp_dai_wbtc.token1()
'0x6B175474E89094C44Da98b954EedeAC495271d0F'  # DAI

Now check the current tick and tick spacing for each pool:

>>> lp_weth_dai.slot0()
(2017920967356726570412668415, -73409, 1260, 1500, 1500, 0, True)
>>> lp_weth_dai.tickSpacing()
60

>>> lp_dai_wbtc.slot0()
(1139629936288146190992705199224087266, 329649, 12, 30, 30, 0, True)
>>> lp_dai_wbtc.tickSpacing()
60

Calculate the lower and upper tick boundaries for both pools:

# lower tick boundary - WETH/DAI
>>> (lp_weth_dai.slot0()[1] // lp_weth_dai.tickSpacing()) * lp_weth_dai.tickSpacing()
-73440

# upper tick boundary - WETH/DAI
>>> (1 + lp_weth_dai.slot0()[1] // lp_weth_dai.tickSpacing()) * lp_weth_dai.tickSpacing()
-73380

# lower tick boundary - DAI/WBTC
>>> (lp_dai_wbtc.slot0()[1] // lp_dai_wbtc.tickSpacing()) * lp_dai_wbtc.tickSpacing()
329640

# upper tick boundary - DAI/WBTC
>>> (1 + lp_dai_wbtc.slot0()[1] // lp_dai_wbtc.tickSpacing()) * lp_dai_wbtc.tickSpacing()
329700

Now we need to find some WETH swap that will drive both pools out of these intervals:

  • WETH/DAI: [-73440, -73380]

  • DAI/WBTC: [329640, 329700]

Hand-encoding the arguments to exactInput is cumbersome, so we’ll use eth_abi as shown in the Router lesson:

>>> import eth_abi.packed
>>> path = eth_abi.packed.encode_abi_packed(
    ['address','uint24','address','uint24','address'],   
    [weth.address,3000,dai.address,3000,wbtc.address]
)
  
>>> tx = router2.exactInput(
    (
        path,
        accounts[0],
        web3.toWei(150,"ether"),
        0
    ),
    {'from':accounts[0]}
)
Transaction sent: Transaction sent: 0xb02a712969c8316bb5734155040fd83e0c563e12323976d63ef079bd0bc0c1ac
  Gas price: 0.0 gwei   Gas limit: 12000000   Nonce: 9
  Transaction confirmed   Block: 15836605   Gas used: 324883 (2.71%)

Note that I’ve specified amountOutMinimum = 0 here as a simplification, since I’m just playing around on a fork and not worried about being sandwiched on the live chain. Don’t do this in production!

Now check the pool states:

>>> lp_weth_dai.slot0()
(2026509061891660702364955273, -73325, 1261, 1500, 1500, 0, True)

>>> lp_dai_wbtc.slot0()
(1461446703485210103287273052203988822378723970341, 887271, 13, 30, 30, 0, True)

WETH/DAI has moved out of the tick range to -73325, and DAI/WBTC has moved out of the tick range to 887271. We did it!

Offline Prediction

Now that we’ve demonstrated that the swap example is feasible, let’s run through the exercise of calculating an exact result from inspecting the pool state at block height 15836600.

Pool 1 — WETH/DAI

We will begin with the WETH/DAI pool and calculate how much WETH is required to consume all liquidity in the current range.

Revert the chain to the snapshot state and retrieve the current sqrtPriceX96, liquidity, and tick:

>>> chain.revert()
15836604

>>> lp_weth_dai.liquidity()
1387463260978121804736539

>>> lp_weth_dai.slot0()[0]
2017920967356726570412668415

>>> lp_weth_dai.slot0()[1]
-73409

Now get the sqrtPriceX96 value at the upper tick from univ3py:

>>> univ3py.getSqrtRatioAtTick(-73380)
2020844029816253023383672425

Now run these through the computeSwapStep function from univ3py, choosing an arbitrarily high value for the 4th argument (amountRemaining), which ensures that the tick is reached before the input amount is completely used:

>>> univ3py.computeSwapStep(
    2017920967356726570412668415,
    2020844029816253023383672425, 
    1387463260978121804736539, 
    100*10**18, 
    3000
)
(2020844029816253023383672425, 51189395833926084053, 78795646416896221904576, 154030278336788619)

This function returns the following:

  • sqrtRatioNextX96 = 2020844029816253023383672425

  • amountIn = 51189395833926084053

  • amountOut = 78795646416896221904576

  • feeAmount = 154030278336788619

Since amountIn < amountRemaining, I know that the target sqrtPriceX96 was reached (i.e. there is a quantity of tokens that have not been swapped).

A brief note about token0 and token1: the price that the pool reports is a ratio of token1 / token0 for swaps executed at the current state. The white paper defines √𝑃 = √(𝑦 / 𝑥) (Eq. 6.4). From the perspective of the pool:

  • Increasing sqrtPriceX96 results in more favorable swaps of token0 → token1 (you withdraw more y for a given x deposit)

  • Decreasing sqrtPriceX96 results in more favorable swaps of token1 → token0 (you withdraw more x for a given y deposit)

You can also think of sqrtPriceX96 as the overall “balance” of token0 and token1 trading across the pool. UniswapV2 does not allow you to completely drain a pool of one token (the last token becomes infinitely expensive), however UniswapV3 does. Assuming all liquidity is provided on a continuous range, swapping could consume all of the tick ranges in one direction before stopping at the min or max tick.

At the bottom end, the pool will only have token1 for swaps. At the top end, the pool will only have token0. This excludes the captured trading fees, which are accounted for separately but otherwise still held in the pool. A significant arbitrage would be available if you found a pool like this, since returns would be extreme as you restored it to “balance”.

The other return values from computeSwapStep tell me that to drive the sqrtPriceX96 to 2020844029816253023383672425 (at tick -73380), I need to send amountIn + feeAmount of token1 to the pool.

All together, that is:

>>> (51189395833926084053 + 154030278336788619) / (10**18)
51.343426112262875

Or approximately 51.34 WETH.

Pool 2 — DAI/WBTC

We repeat the exercise above to discover how much DAI (token1) must be deposited to drive the sqrtPriceX96 to the next tick.

Pull some relevant values:

>>> lp_dai_wbtc.liquidity()
50299928023873643

>>> lp_dai_wbtc.slot0()[0]  # sqrtPriceX96
1139629936288146190992705199224087266

>>> lp_dai_wbtc.slot0()[1]  # tick
329649

>>> univ3py.getSqrtRatioAtTick(329700)
1142526217395415776674293755130690850

Now use the computeSwapStep helper to investigate the required DAI input to reach this price:

>>> univ3py.computeSwapStep(
    1139629936288146190992705199224087266,   
    1142526217395415776674293755130690850, 
    50299928023873643, 
    2_000*10**18, 
    3000
)
(1142526217395415776674293755130690850, 1838774579763052263041, 8864569, 5532922506809585546)

This function returns the following:

  • sqrtRatioNextX96 = 1142526217395415776674293755130690850

  • amountIn = 1838774579763052263041

  • amountOut = 8864569

  • feeAmount = 5532922506809585546

Since amountOut < amountIn, I know that the target sqrtPriceX96 was reached.

You may be curious where the amountIn value (2,000) came from. I just guessed, increasing as needed until I reached my target. This pool will reach tick 329700 with a swap of roughly 1,839 DAI.

This happens because WETH/DAI liquidity is much deeper than DAI/WBTC liquidity, so it takes a lot more to push the WETH/DAI pool around (51.34 WETH).

Pool 1 → Pool 2

Now we know that 51.34 WETH is enough to push WETH/DAI across a tick, and 1,839 DAI is enough to push DAI/WBTC across a tick. Obviously 51.34 WETH will give us way more DAI than needed to meet our 1,839 minimum.

Now as an exercise, let’s see exactly what happens inside Pool 2 if we try to trade all the DAI from Pool 1.

First, look up in the Pool 1 section at the results from computeSwapStep. The 3rd return value (amountOut) tells us that we receive 78795646416896221904576 DAI, which is roughly 78,796 after offsetting for decimals.

So let’s take this value and work through the various liquidity ranges, checking with computeSwapStep and then verifying the behavior on the console.

First, retrieve the initialized ticks for the first 256 words (32-byte chunks) using the TickLens contract:

>>> for i in range(256): lens.getPopulatedTicksInWord(lp_dai_wbtc.address, i)
...
()
()
[...]
()
((337380, -480120721769272, 480120721769272), (336240, -15768504585847335, 15768504585847335), (333360, 296093732156900, 296093732156900), (331740, -34051589239524304, 34051589239524304), (330060, -263675138255, 263675138255), (328020, 263675138255, 263675138255), (326460, 15767954387441812, 15767954387441812), (325200, 34051589239524304, 34051589239524304), (323520, 480120721769272, 480120721769272))
((341820, -295543533751377, 295543533751377), (341100, -153358018329670, 153358018329670), (340320, 37290035734704, 37290035734704), (339960, -26976167517698, 26976167517698), (339660, 26976167517698, 26976167517698), (338460, 116067982594966, 116067982594966))
()
()
[...]
()
()

We find that there are huge gaps in the provided liquidity. Remember that each line represents initialized liquidity values for 256 bits, with each bit representing a tick.

TickLens shows us the following tick/liquidity values (manually re-ordered for clarity):

  • (323520, 480120721769272, 480120721769272)

  • (325200, 34051589239524304, 34051589239524304)

  • (326460, 15767954387441812, 15767954387441812)

  • (328020, 263675138255, 263675138255)

  • (330060, -263675138255, 263675138255)

  • (331740, -34051589239524304, 34051589239524304)

  • (333360, 296093732156900, 296093732156900)

  • (336240, -15768504585847335, 15768504585847335)

  • (337380, -480120721769272, 480120721769272)

  • (338460, 116067982594966, 116067982594966)

  • (339660, 26976167517698, 26976167517698)

  • (339960, -26976167517698, 26976167517698)

  • (340320, 37290035734704, 37290035734704)

  • (341100, -153358018329670, 153358018329670)

  • (341820, -295543533751377, 295543533751377)

Reviewing the analytics page for this pool, we see a visual of the various ranges.

We are currently at tick 329649, in tick interval [329640, 329700]. But the liquidity values above show that there is no initialized tick at either 329640 or 329700.

We’re not inside a tick range with initialized liquidity, but we do get a value from the function call to liquidity() — why?

The answer lies inside the swap function of the Pool contract. On line 722, the following code runs:

state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);

However it’s surrounded by some if clauses:

// shift tick if we reached the next price
if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
    // if the tick is initialized, run the tick transition
    if (step.initialized) {

        [...]

    }

This is very interesting! The pool will only update the state.liquidity value if a swap drives the tick to an initialized value. If the tick is not initialized, it simply skips that code block and keeps going.

I can’t find this documented anywhere, but I suspect that it allows users to continue trading in the pool even during “weird” price conditions that run into areas where nobody has deposited liquidity.

Since we are in an uninitialized area, the liquidity value is “sticky” and remains at the last-crossed initialized value. It will remain there until normal swapping volume drives it back into the range of an initialized tick, where it will reset to the liquidity value of that tick.

Unintuitive to say the least, but that’s the nature of UniswapV3.

To demonstrate this behavior, check the last 5 swaps that used this pool prior to block height 15836600:

  • 0x02b63fcf684d87cd2bc4460b00e088e7d8ba2ee18c4ba08cc03848aea9f79636

  • 0x35597dcd87b6b2c62b87759ba76bac794f83ae142c473b54adeb36a986e59662

  • 0xf774c120278196fd6d4b708705d833a9be4c17ecd0aa0f7fca28a0c60bcac3bb

  • 0x11b4cebb4de6ffcff99bf379f8fc3e50bf7f2a81520fce0a7f3a2d087ec01377

  • 0xebcf5e1b66a84c0b6317bb685143d9c61fc041a8bca0ec0a7f2f93b116b4e6b8

To view these swap events, view the Logs tab and find the Swap event associated with address 0x391e8501b626c623d39474afca6f9e46c2686649:

Then change the 4th result from Hex to Num:

This 4th result is the new liquidity value after the swap has completed.

If you do the same for the other transactions, you’ll find that the liquidity value is reported as 50299928023873643 for each of these swaps.

This is the same value we find when we call liquidity() on the pool:

>>> lp_dai_wbtc.liquidity()
50299928023873643

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