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.