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.