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:
-
The swap is accurate for
token1
. This happens when the user swapstoken0
fortoken1
with an exact output (zeroForOne == true && params.amountSpecified > 0
), or when the user swapstoken1
fortoken0
with an exact input (zeroForOne == false && params.amountSpecified < 0
). -
The swap is accurate for
token0
. The logic here is similar but applies to the opposite direction of the swap.