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 oftoken0
→token1
(you withdraw more y for a given x deposit)Decreasing
sqrtPriceX96
results in more favorable swaps oftoken1
→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