Degen Code

Degen Code

Share this post

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

UniswapV3 — Swap Prediction (Part II)

Multi-Tick Swaps

Oct 11, 2022
∙ Paid
9

Share this post

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

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 called step

  • Store the sqrtPriceX96 at the beginning of the step by pulling that value from state

  • Set the tick number and initialized status for the next tick by querying the tickBitmap using the function nextInitializedTickWithinOneWord() which will return all initialized ticks within one word (32 bytes, or 256 ticks) of the current tick

  • Put guards on the lower and upper boundary of the next tick

  • Get sqrtPriceX96 at the next initialized tick

  • Computes values needed to reach a tick, price limit, or swap a given amount.

  • If swap() was called in exactInput 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:

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