Skip to main content

Pool Swap

In the previous section, we provided an overview of the entire swap process, but we have not yet analyzed how pool.swap(params) is actually executed. Now, let's dive into src/libraries/Pool.sol to understand how token swaps occur within the contract.

struct SwapParams {
int256 amountSpecified;
int24 tickSpacing;
bool zeroForOne;
uint160 sqrtPriceLimitX96;
uint24 lpFeeOverride;
}

function swap(State storage self, SwapParams memory params)
internal
returns (BalanceDelta swapDelta, uint256 amountToProtocol, uint24 swapFee, SwapResult memory result)
{

As seen in the code above, the swap function's input data structure is SwapParams. Let's analyze the role of each parameter in SwapParams. The amountSpecified parameter represents the exact amount of tokens the user wants to send to or receive from the pool.

tickSpacing determines the intervals at which ticks are searched (for example, 1, 10, or 20). Ticks can only be searched at increments defined by tickSpacing. Smaller tickSpacing values provide greater liquidity to complete the swap, resulting in more accurate transactions and reduced slippage. However, this comes at the cost of higher gas fees due to more extensive tick searches.

It represents a trade-off between lower accuracy and higher slippage with reduced gas costs, or higher accuracy and lower slippage with increased gas costs.

zeroForOne indicates whether token0 is being swapped for token1 or the reverse.

sqrtPriceLimitX96 serves as price range protection, ensuring that swaps occur within a specified limit. This mechanism prevents slippage from exceeding the user's maximum allowable threshold.

lpFeeOverride works in conjunction with hooks to enable dynamic fees for swaps. If the hook overrides the liquidity provider (LP) fee before the swap is executed, the swap uses the overridden fee. Otherwise, the default swap fee is applied.

Now, let's take a closer look at the execution of the swap function.

int256 amountSpecifiedRemaining = params.amountSpecified;
int256 amountCalculated = 0;
// initialize to the current sqrt(price)
result.sqrtPriceX96 = slot0Start.sqrtPriceX96();
// initialize to the current tick
result.tick = slot0Start.tick();
// initialize to the current liquidity
result.liquidity = self.liquidity;

The following code sets up the initial state for the swap. The amountSpecifiedRemaining variable is initialized based on the amount of tokens the user wishes to swap.

Why do we use "remaining" to name this variable? This is because the liquidity at a given tick may not fully satisfy the user's swap request. In such cases, the swap process continues searching for liquidity at the next tick. Therefore, we need a parameter to track the number of tokens that still need to be swapped during this process.

To complement amountSpecifiedRemaining, the amountCalculated variable represents the tokens already obtained from the swap. Its initial value is set to 0.

uint24 lpFee = params.lpFeeOverride.isOverride()
? params.lpFeeOverride.removeOverrideFlagAndValidate()
: slot0Start.lpFee();

swapFee = protocolFee == 0 ? lpFee : uint16(protocolFee).calculateSwapFee(lpFee);

lpFee represents the fee paid to the liquidity provider. As mentioned earlier in the lpFeeOverride parameter explanation, if the hook adjusts this value, the fee provided by the hook is used as the LP fee. Otherwise, the default fee is applied.

swapFee represents the total fee, which combines the protocol fee and the liquidity provider fee for this swap. The calculation of the total fee is as follows:

// protocolFee + lpFee(1_000_000 - protocolFee) / 1_000_000 (rounded up)

From the calculation method, we can see that the final fee is not simply the sum of protocolFee and lpFee. Instead, the swapping tokens are first charged the protocolFee, and the remaining tokens are then subject to the lpFee.

if (zeroForOne) {
if (params.sqrtPriceLimitX96 >= slot0Start.sqrtPriceX96()) {
PriceLimitAlreadyExceeded.selector.revertWith(slot0Start.sqrtPriceX96(), params.sqrtPriceLimitX96);
}
// Swaps can never occur at MIN_TICK, only at MIN_TICK + 1, except at initialization of a pool
// Under certain circumstances outlined below, the tick will preemptively reach MIN_TICK without swapping there
if (params.sqrtPriceLimitX96 <= TickMath.MIN_SQRT_PRICE) {
PriceLimitOutOfBounds.selector.revertWith(params.sqrtPriceLimitX96);
}
} else {
if (params.sqrtPriceLimitX96 <= slot0Start.sqrtPriceX96()) {
PriceLimitAlreadyExceeded.selector.revertWith(slot0Start.sqrtPriceX96(), params.sqrtPriceLimitX96);
}
if (params.sqrtPriceLimitX96 >= TickMath.MAX_SQRT_PRICE) {
PriceLimitOutOfBounds.selector.revertWith(params.sqrtPriceLimitX96);
}
}

If zeroForOne is true, it indicates that the user wants to swap token0 for token1. In this case, the price curve will decline during the swap. However, if params.sqrtPriceLimitX96 >= slot0Start.sqrtPriceX96(), it means the user's minimum swap price limit is higher than the current price. As a result, the swap will not proceed.

Similarly, if zeroForOne is false, the user intends to swap token1 for token0, causing the price curve to rise during the swap. In this scenario, the limit swap price params.sqrtPriceLimitX96 must be higher than the current price; otherwise, the swap will fail.

StepComputations memory step;
step.feeGrowthGlobalX128 = zeroForOne ? self.feeGrowthGlobal0X128 : self.feeGrowthGlobal1X128;

while (!(amountSpecifiedRemaining == 0 || result.sqrtPriceX96 == params.sqrtPriceLimitX96))
{...}

Next, we initialize the variable feeGrowthGlobalX128 to facilitate the accumulation of LP fees. In the following part, we will dive into the while loop to explore how tick liquidity is searched and how the swap is executed.

// Inside the while loop
(step.tickNext, step.initialized) =
self.tickBitmap.nextInitializedTickWithinOneWord(result.tick, params.tickSpacing, zeroForOne);

if (step.tickNext <= TickMath.MIN_TICK) {
step.tickNext = TickMath.MIN_TICK;
}
if (step.tickNext >= TickMath.MAX_TICK) {
step.tickNext = TickMath.MAX_TICK;
}
step.sqrtPriceNextX96 = TickMath.getSqrtPriceAtTick(step.tickNext);

The nextInitializedTickWithinOneWord function returns the tickNext as an int24 value, representing the next initialized tick position (within the range of [TickMath.MIN_TICK, TickMath.MAX_TICK]). It also checks if the tick has liquidity, which is indicated by the initialized variable.

This function searches for ticks within the boundaries of one word in the tick bitmap. For the zeroForOne price direction, it can search up to 256 ticks. However, the search is constrained to the current bitmap. If no liquidity is found within the current bitmap, the function returns either the leftmost or rightmost tick of the bitmap, setting initialized to false to indicate that no liquidity exists at the returned tick.

You might wonder—what happens if tickSpacing exceeds 256? Wouldn't this prevent the function from locating the next tick within one word? This is not an issue. The compress function addresses cases where tickSpacing exceeds 256 by compressing the tick value. This allows efficient tick searches even when the spacing surpasses the 256-tick boundary.

// src/libraries/TickBitmap/nextInitializedTickWithinOneWord
int24 compressed = compress(tick, tickSpacing);

function compress(int24 tick, int24 tickSpacing) internal pure returns (int24 compressed) {
// compressed = tick / tickSpacing;
// if (tick < 0 && tick % tickSpacing != 0) compressed--;
assembly ("memory-safe") {
tick := signextend(2, tick)
tickSpacing := signextend(2, tickSpacing)
compressed :=
sub(
sdiv(tick, tickSpacing),
// if (tick < 0 && tick % tickSpacing != 0) then tick % tickSpacing < 0, vice versa
slt(smod(tick, tickSpacing), 0)
)
}
}

The value stored in the tick bitmap is not the raw tick position but rather the result of dividing the tick by tickSpacing using the formula tick / tickSpacing. This process discards any remainder, retaining only the integer portion of the result.

The compressed structure allows efficient searching within the bitmap. The reason for handling negative compressed values separately is due to Solidity's behavior in integer division. By default, negative division rounds towards zero, but in this context, we need the result to round away from zero. As a result, when tick % tickSpacing != 0, the compressed value is decremented by 1 to account for this behavior.

(result.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
result.sqrtPriceX96, // current price
SwapMath.getSqrtPriceTarget(zeroForOne, step.sqrtPriceNextX96, params.sqrtPriceLimitX96),
result.liquidity, // current liquidity
amountSpecifiedRemaining,
swapFee
);

// sqrtPriceX96: the new price after this swap
// step.amountIn: the amount of input tokens for this step
// step.amountOut: the amount of output tokens for this step
// feeAmount: the fee charged for this step

The role of the SwapMath.computeSwapStep function is to calculate the token amount that can be swapped from the current tick to the next tick. If the liquidity at the current tick is insufficient to complete the swap, the function records the amount of tokens already swapped. The remaining tokens are processed in the next iteration of the while loop.

// if exactOutput
if (params.amountSpecified > 0) {
unchecked {
amountSpecifiedRemaining -= step.amountOut.toInt256();
}
amountCalculated -= (step.amountIn + step.feeAmount).toInt256();
} else {
// safe because we test that amountSpecified > amountIn + feeAmount in SwapMath
unchecked {
amountSpecifiedRemaining += (step.amountIn + step.feeAmount).toInt256();
}
amountCalculated += step.amountOut.toInt256();
}

params.amountSpecified > 0 represents an exact output swap, meaning the user specifies the exact amount of tokens they want to receive. In this scenario, the amount of tokens to be received is fixed, so amountCalculated represents the amount of tokens the user needs to send to the pool. A negative value indicates that the pool receives tokens from the user, which is why we use -=. Conversely, a positive value represents tokens that need to be sent to the user, and amountSpecifiedRemaining tracks the remaining amount that needs to be sent. Each loop iteration reduces amountSpecifiedRemaining by the corresponding output amount, until amountSpecifiedRemaining == 0.

In contrast, when params.amountSpecified < 0, it represents an exact input swap, meaning the user specifies the exact amount of tokens they want to provide to the pool. In this case, amountCalculated represents the amount of tokens the pool will return to the user. A positive value of amountCalculated indicates the tokens the user will receive, which is why we use +=.

amountSpecifiedRemaining tracks the amount of tokens the user still needs to provide to the pool. Since the user is supplying tokens, amountSpecifiedRemaining is negative. Each loop iteration reduces this negative value by adding the input amount, gradually approaching zero until amountSpecifiedRemaining == 0.

To summarize:

  • In exact output swaps (params.amountSpecified > 0), the user specifies the exact amount of tokens they wish to receive, and the pool calculates how many tokens the user must provide.

  • In exact input swaps (params.amountSpecified < 0), the user provides a fixed amount of tokens to the pool, and the pool calculates how many tokens to return.

if (protocolFee > 0) {
unchecked {
uint256 delta = (swapFee == protocolFee)
? step.feeAmount // lp fee is 0, so the entire fee is owed to the protocol instead
: (step.amountIn + step.feeAmount) * protocolFee / ProtocolFeeLibrary.PIPS_DENOMINATOR;
step.feeAmount -= delta;
amountToProtocol += delta;
}
}

From the above code, we can see that when protocolFee is enabled, the protocol fee is calculated as a ratio of the original input tokens. The user's input tokens are split into amountIn and feeAmount, where only amountIn is added to the pool for the swap. The feeAmount is deducted from the input tokens before the swap occurs.

if (result.liquidity > 0) {
unchecked {
// FullMath.mulDiv isn't needed as the numerator can't overflow uint256 since tokens have a max supply of type(uint128).max
step.feeGrowthGlobalX128 +=
UnsafeMath.simpleMulDiv(step.feeAmount, FixedPoint128.Q128, result.liquidity);
}
}

When the current tick has liquidity, the swap fee for this step needs to be added to the global fee amounts. This is done using the formula feeGrowthGlobalX128 += feeAmount * Q128 / liquidity, which accumulates the global fee.

Why do we divide by liquidity instead of simply adding feeAmount? The reason is to normalize the global fee per unit of liquidity. This allows LPs (Liquidity Providers) to calculate their share of fees in the future by simply multiplying their liquidity by the accumulated global fee.

feesOwed = (feeGrowthInside - feeGrowthInsideLastX128) * liquidity / Q128

Another question is whether the user swaps token0 for token1 or token1 for token0. Since the feeAmount could be in either token0 or token1, how do we ensure that the accumulated fees are correctly attributed to the respective token?

This is handled in the previous code snippet, where step.feeGrowthGlobalX128 is assigned using zeroForOne ? self.feeGrowthGlobal0X128 : self.feeGrowthGlobal1X128. This determines whether the global fee corresponds to token0 or token1. Additionally, conditional checks in subsequent code ensure that the data is saved in the correct position.

// update fee growth global
if (!zeroForOne) {
self.feeGrowthGlobal1X128 = step.feeGrowthGlobalX128;
} else {
self.feeGrowthGlobal0X128 = step.feeGrowthGlobalX128;
}

Now, let's take a look at when the tick price float to next tick postion then what will happen.

if (result.sqrtPriceX96 == step.sqrtPriceNextX96) {
// if the tick is initialized, run the tick transition
if (step.initialized) {
(uint256 feeGrowthGlobal0X128, uint256 feeGrowthGlobal1X128) = zeroForOne
? (step.feeGrowthGlobalX128, self.feeGrowthGlobal1X128)
: (self.feeGrowthGlobal0X128, step.feeGrowthGlobalX128);
int128 liquidityNet =
Pool.crossTick(self, step.tickNext, feeGrowthGlobal0X128, feeGrowthGlobal1X128);
// if we're moving leftward, we interpret liquidityNet as the opposite sign
// safe because liquidityNet cannot be type(int128).min
unchecked {
if (zeroForOne) liquidityNet = -liquidityNet;
}

result.liquidity = LiquidityMath.addDelta(result.liquidity, liquidityNet);
}

unchecked {
result.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
}
}

function crossTick(State storage self, int24 tick, uint256 feeGrowthGlobal0X128, uint256 feeGrowthGlobal1X128)
internal
returns (int128 liquidityNet)
{
...
liquidityNet = info.liquidityNet;
}

If zeroForOne is true, it means the user swaps token0 for token1. Before the actual swap occurs, the contract first takes some tokens as a fee. Therefore, we should accumulate and update the token0 fees. The token1 fees won't change, so we don't need to update them. Conversely, the analysis is likely to follow this framework.

If the next tick (boundary tick) has liquidity, the crossTick function is invoked to process the tick transition (crossTick role is to update the new tick's tickInfo because beforehand the global fees have growth). When zeroForOne is true, it indicates that the swap price increases (i.e., the price curve moves from right to left). In this case, crossing a tick means the liquidity associated with the previous tick should be removed.

When using the upperTick method to return liquidityNet , the logic assumes the price is moving from left to right. However, since the price is moving from right to left in this scenario, a negative value is added to liquidityNet to account for the directional difference.

The sign of liquidityNet is just a convention and doesn’t have an actual meaning. When the price moves from left to right, the liquidityNet sign is positive; otherwise, it is negative.

When zeroForOne is true, why do we need to use tickNext - 1? The reason lies in how tick liquidity is applied. For a price range moving from left to right, the active tick range is [tick, tick + 1]. However, when the price moves from right to left, the active tick range becomes [tick - 1, tick]. Therefore, when zeroForOne is true, we adjust the tick to tickNext - 1 to reflect the correct price range.

else if (result.sqrtPriceX96 != step.sqrtPriceStartX96) {
// recompute unless we're on a lower tick boundary (i.e. already transitioned ticks), and haven't moved
result.tick = TickMath.getTickAtSqrtPrice(result.sqrtPriceX96);
}

Another scenario occurs when the price changes but does not reach the next price tick. In this case, the getTickAtSqrtPrice function is used to calculate the current tick corresponding to the updated square root price.

self.slot0 = slot0Start.setTick(result.tick).setSqrtPriceX96(result.sqrtPriceX96);
// update liquidity if it changed
if (self.liquidity != result.liquidity) self.liquidity = result.liquidity;

// update fee growth global
if (!zeroForOne) {
self.feeGrowthGlobal1X128 = step.feeGrowthGlobalX128;
} else {
self.feeGrowthGlobal0X128 = step.feeGrowthGlobalX128;
}

Next, the contract state is updated with the latest tick, price, and liquidity information.

Finally, the final swap result, swapDelta, is calculated as follows:

if (zeroForOne != (params.amountSpecified < 0)) {
swapDelta = toBalanceDelta(
amountCalculated.toInt128(), (params.amountSpecified - amountSpecifiedRemaining).toInt128()
);
} else {
swapDelta = toBalanceDelta(
(params.amountSpecified - amountSpecifiedRemaining).toInt128(), amountCalculated.toInt128()
);
}

There are two cases to consider:

  1. The swap is accurate for token1. This happens when the user swaps token0 for token1 with an exact output (zeroForOne == true && params.amountSpecified > 0), or when the user swaps token1 for token0 with an exact input (zeroForOne == false && params.amountSpecified < 0).

  2. The swap is accurate for token0. The logic here is similar but applies to the opposite direction of the swap.