Expanding on our understanding from Part I (single-tick swaps), this lesson will cover the behavior of the pool contract in detail. Particularly interesting is what happens when a swap is large enough to cross a tick boundary.
Console Exploration
This will take the same format as Part I. Get ready and spin up your local fork at block height 15686250 again.
Here are the 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 return to the same state as we left in Part I (500 WETH, full approval, chain snapshot set at height 15686253 after 2 successful transactions).
>>> 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
In the previous part, we pushed our DAI/WETH pool from tick -72269 to -72240 by swapping 60 WETH. Then we observed that the virtual liquidity changed after crossing into the new tick.
Instead of trying to touch the bottom tick of the next liquidity range, now we will try to shoot through it.
We know that a 90 WETH swap will cross a tick boundary at -72240, but how far into the next tick range will the swap get? Will it cross that range as well?
Let’s see!
I used the formulae from the white paper for the previous exercise, but now I’ll introduce some code from the actual UniswapV3 contracts.
I’ll skip some steps here, but the short summary is that when we call exactInputSingle()
, the router does some checks, identifies the pool we want, and calls swap()
on it directly.
Pool Contract
Here is the full function definition for the swap()
function. Feel free to skim it, but open the contract source in another tab to save lots of scrolling up and down.
function swap(
address recipient,
bool zeroForOne,
int256 amountSpecified,
uint160 sqrtPriceLimitX96,
bytes calldata data
) external override noDelegateCall returns (
int256 amount0, int256 amount1
) {
require(amountSpecified != 0, 'AS');
Slot0 memory slot0Start = slot0;
require(slot0Start.unlocked, 'LOK');
require(
zeroForOne
? sqrtPriceLimitX96 < slot0Start.sqrtPriceX96
&& sqrtPriceLimitX96 > TickMath.MIN_SQRT_RATIO
: sqrtPriceLimitX96 > slot0Start.sqrtPriceX96
&& sqrtPriceLimitX96 < TickMath.MAX_SQRT_RATIO,
'SPL'
);
slot0.unlocked = false;
SwapCache memory cache =
SwapCache({
liquidityStart: liquidity,
blockTimestamp: _blockTimestamp(),
feeProtocol:
zeroForOne
? (slot0Start.feeProtocol % 16)
: (slot0Start.feeProtocol >> 4),
secondsPerLiquidityCumulativeX128: 0,
tickCumulative: 0,
computedLatestObservation: false
});
bool exactInput = amountSpecified > 0;
SwapState memory state =
SwapState({
amountSpecifiedRemaining: amountSpecified,
amountCalculated: 0,
sqrtPriceX96: slot0Start.sqrtPriceX96,
tick: slot0Start.tick,
feeGrowthGlobalX128:
zeroForOne
? feeGrowthGlobal0X128
: feeGrowthGlobal1X128,
protocolFee: 0,
liquidity: cache.liquidityStart
});
// continue swapping as long as we haven't used the
// entire input/output and haven't reached the price limit
while (state.amountSpecifiedRemaining != 0
&& state.sqrtPriceX96 != sqrtPriceLimitX96) {
StepComputations memory step;
step.sqrtPriceStartX96 = state.sqrtPriceX96;
(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
state.tick,
tickSpacing,
zeroForOne
);
// ensure that we do not overshoot the min/max tick,
// as the tick bitmap is not aware of these bounds
if (step.tickNext < TickMath.MIN_TICK) {
step.tickNext = TickMath.MIN_TICK;
} else if (step.tickNext > TickMath.MAX_TICK) {
step.tickNext = TickMath.MAX_TICK;
}
// get the price for the next tick
step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(
step.tickNext
);
// compute values to swap to the target tick, price limit,
// or point where input/output amount is exhausted
(
state.sqrtPriceX96,
step.amountIn,
step.amountOut,
step.feeAmount
) = SwapMath.computeSwapStep(
state.sqrtPriceX96,
(zeroForOne
? step.sqrtPriceNextX96 < sqrtPriceLimitX96
: step.sqrtPriceNextX96 > sqrtPriceLimitX96
)
? sqrtPriceLimitX96
: step.sqrtPriceNextX96,
state.liquidity,
state.amountSpecifiedRemaining,
fee
);
if (exactInput) {
state.amountSpecifiedRemaining -= (
step.amountIn + step.feeAmount
).toInt256();
state.amountCalculated = state.amountCalculated.sub(
step.amountOut.toInt256()
);
} else {
state.amountSpecifiedRemaining += step.amountOut.toInt256();
state.amountCalculated = state.amountCalculated.add(
(step.amountIn + step.feeAmount).toInt256()
);
}
// if the protocol fee is on, calculate how much is owed,
// decrement feeAmount, and increment protocolFee
if (cache.feeProtocol > 0) {
uint256 delta = step.feeAmount / cache.feeProtocol;
step.feeAmount -= delta;
state.protocolFee += uint128(delta);
}
// update global fee tracker
if (state.liquidity > 0)
state.feeGrowthGlobalX128 += FullMath.mulDiv(
step.feeAmount,
FixedPoint128.Q128,
state.liquidity
);
// 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) {
// check for the placeholder value, which we
// replace with the actual value the first time the
// swap crosses an initialized tick
if (!cache.computedLatestObservation)
{
(
cache.tickCumulative,
cache.secondsPerLiquidityCumulativeX128
) = observations.observeSingle(
cache.blockTimestamp,
0,
slot0Start.tick,
slot0Start.observationIndex,
cache.liquidityStart,
slot0Start.observationCardinality);
cache.computedLatestObservation = true;
}
int128 liquidityNet =
ticks.cross(
step.tickNext,
(zeroForOne
? state.feeGrowthGlobalX128
: feeGrowthGlobal0X128),
(zeroForOne
? feeGrowthGlobal1X128
: state.feeGrowthGlobalX128),
cache.secondsPerLiquidityCumulativeX128,
cache.tickCumulative,
cache.blockTimestamp
);
// if we're moving leftward, we interpret
// liquidityNet as the opposite sign
// safe because liquidityNet cannot be type(int128).min
if (zeroForOne) liquidityNet = -liquidityNet;
state.liquidity = LiquidityMath.addDelta(
state.liquidity,
liquidityNet
);
}
state.tick = zeroForOne
? step.tickNext - 1
: step.tickNext;
} else if (state.sqrtPriceX96 != step.sqrtPriceStartX96) {
// recompute unless we're on a lower tick boundary
// (i.e. already transitioned ticks), and haven't moved
state.tick = TickMath.getTickAtSqrtRatio(
state.sqrtPriceX96
);
}
}
// update tick and write an oracle entry if the tick change
if (state.tick != slot0Start.tick) {
(uint16 observationIndex, uint16 observationCardinality) =
observations.write(
slot0Start.observationIndex,
cache.blockTimestamp,
slot0Start.tick,
cache.liquidityStart,
slot0Start.observationCardinality,
slot0Start.observationCardinalityNext
);
(
slot0.sqrtPriceX96,
slot0.tick,
slot0.observationIndex,
slot0.observationCardinality
) = (
state.sqrtPriceX96,
state.tick,
observationIndex,
observationCardinality
);
} else {
// otherwise just update the price
slot0.sqrtPriceX96 = state.sqrtPriceX96;
}
// update liquidity if it changed
if (
cache.liquidityStart != state.liquidity
) liquidity = state.liquidity;
// update fee growth global and, if necessary, protocol fees
// overflow is acceptable, protocol has to withdraw before
// it hits type(uint128).max fees
if (zeroForOne) {
feeGrowthGlobal0X128 = state.feeGrowthGlobalX128;
if (state.protocolFee > 0) protocolFees.token0 += state.protocolFee;
} else {
feeGrowthGlobal1X128 = state.feeGrowthGlobalX128;
if (state.protocolFee > 0) protocolFees.token1 += state.protocolFee;
}
(amount0, amount1) = zeroForOne == exactInput
? (
amountSpecified - state.amountSpecifiedRemaining,
state.amountCalculated
)
: (
state.amountCalculated,
amountSpecified - state.amountSpecifiedRemaining
);
// do the transfers and collect payment
if (zeroForOne) {
if (amount1 < 0) TransferHelper.safeTransfer(
token1,
recipient,
uint256(-amount1)
);
uint256 balance0Before = balance0();
IUniswapV3SwapCallback(
msg.sender
).uniswapV3SwapCallback(amount0, amount1, data);
require(
balance0Before.add(uint256(amount0)) <= balance0(),
'IIA'
);
} else {
if (amount0 < 0) TransferHelper.safeTransfer(
token0,
recipient,
uint256(-amount0)
);
uint256 balance1Before = balance1();
IUniswapV3SwapCallback(
msg.sender
).uniswapV3SwapCallback(amount0, amount1, data);
require(
balance1Before.add(uint256(amount1)) <= balance1(),
'IIA'
);
}
emit Swap(
msg.sender,
recipient,
amount0,
amount1,
state.sqrtPriceX96,
state.liquidity,
state.tick
);
slot0.unlocked = true;
}
This is where all the good stuff is, so we will work through it in steps.
1 - Track Initial State
Before taking any state-changing actions, the function takes a snapshot of the current state of slot0
as a memory variable called slot0Start
.
Then it “locks” the struct with slot0.unlocked = false;
which prevents any modifications to slot0
until the full impact of the swap can be evaluated.
Then it takes a snapshot of type SwapCache
called cache
, which is a struct storing these initial states:
liquidityStart
blockTimestamp
feeProtocol
secondsPerLiquidityCumulativeX128
tickCumulative
computedLatestObservation
Many of these are only relevant for the liquidity providers and the price oracle, so we can mostly ignore it. The values we care about are liquidityStart
and tickCumulative
.
Then it determines if the mode of the swap is of type exactInput
by comparing amountSpecified > 0
.
Then it initializes a struct of type SwapState
called state
, which it will use to track the changes along the swap path for these values:
amountSpecifiedRemaining
amountCalculated
sqrtPriceX96
tick
feeGrowthGlobalX128
protocolFee
liquidity
2 - Iterate Until amountSpecified is Satisfied
The pool then executes a while
loop that continually compares two values:
state.amountSpecifiedRemaining != 0
state.sqrtPriceX96 != sqrtPriceLimitX96
The first value checks that amountSpecifiedRemaining
has not been satisfied.
The second value checks that the maximum price limit sqrtPriceLimitX96
has not been reached.
As long as both comparisons are true (there are outstanding tokens to be swapped, and the price limit has not been reached), the loop will continue to execute.
The loop itself perform the following actions:
Create a new struct in memory of type
StepComputations
calledstep
Store the
sqrtPriceX96
at the beginning of the step by pulling that value fromstate
Set the tick number and initialized status for the next tick by querying the
tickBitmap
using the functionnextInitializedTickWithinOneWord()
which will return all initialized ticks within one word (32 bytes, or 256 ticks) of the current tickPut guards on the lower and upper boundary of the next tick
Get
sqrtPriceX96
at the next initialized tickComputes values needed to reach a tick, price limit, or swap a given amount.
If
swap()
was called inexactInput
mode:Subtract the fee and the input token sent into this step from
amountSpecifiedRemaining
Subtract the output amount from the pool reserves
Otherwise do the reverse (
exactOutput
was specified)Account for fees to the protocol (we don’t care)
If we have reached the end of a price range, adjust the tick value stored in
state
Otherwise recompute the tick from the current
sqrtPriceX96
3 - Update the Pool Values, Transfer Tokens, Emit Event
With the swap satisfied, the pool retrieves the relevant values from state
(tick
, sqrtPriceX96
, and some others we don’t care about), sets them inside contract storage (inside slot0
), transfers the appropriate tokens to the relevant addresses, emits a Swap event, and unlocks slot0
so other swaps can proceed.
Relevant Contract Sections
The highest value portion of this function (for our use at least) begins on line 662: