유니스왑V3 4번째- swap

Justin Gee
22 min readApr 10, 2023

--

Uniswap Labs, 트위터 캡처화면

유니스왑V3 1번째 — deploy pool
유니스왑V3 2번째 — initialize pool + FeeProtocol
유니스왑V3 3번째- provide liquidity

유니스왑V3 3번째에 이어서 4번째(swap) 해볼 것이다.

아래는 3번째 시리즈에서 유동성을 제공한 최종 결과다. 현재 current price는 5000 USDC/ETH이며, tick으로는 85176이다. 이 상태에서 5000usdc를 eth로 swap해볼 것이다.

현재 상태

먼저 swap시에 그림으로 추상화해서 위 그림이 어떻게 변하는지 보겠다.

before swap

swap하기 전 상태다. 빨간색으로 usdc amount를 표현했고, 파란색으로 eth amount를 표현했다. Current Price는 tick으로 85176이고 현재 liquidity는 storage state variable로 2357659943442810589529의 값이다. 이 현재 liquidity는 해당 현재 price range내의 liquidity다. 현재 price range란, current price가 포함되어 있는 lower initialized ticks와 upper initialized ticks 사이를 뜻한다(85140~85200).

이 상태에서 5000 usdc를 swap하면 다음 그림과 같이 상태가 바뀐다.

여기서 변하지 않는 것은, 저 그래프가 이루는 도형의 면적이다. 이 면적은 총 liquidity를 의미한다. 총 liquidity는 바뀌지 않으며, 총 liquidity를 이루고 있는 usdc와 eth의 비율이 바뀌는 것 뿐이다.

Current Tick인 85176은 Next Tick인 85547로 바뀌게 된다. 그리고 85176~85547은 usdc로 채워지게 된다. 그리고 85200이 current price를 기준으로 nextInitializedtick이기 때문에 price range가 85200~86100으로 바뀌었다. 그럼 해당 price range의 liquiditys는 어떻게 구할까? 바로 storage state variable인 liquidity에 85200tick의 liquidityNet을 더하면 된다.

2357659943442810589529 + 1496453634828973852217 = 3854113578271784441746 이 값이 liquidity값이 된다.

이제 코드를 살펴보겠다. swap은 https://github.com/Uniswap/v3-periphery/blob/main/contracts/SwapRouter.sol 에서 할 수 있다.

exactInputSingle : 스왑하는 amountIn을 기준으로 amountOut이 계산된다.
exactInput : 멀티홉 스왑하는 amountIn을 기준으로 amountOut이 계산된다.

exactOutputSingle : 스왑하는 amountOut을 기준으로 amountIn이 계산된다.
exactOutput : 멀티홉 스왑하는 스왑하는 amountOut을 기준으로 amountIn이 계산된다.

이 4개중에서 exactInputSingle 함수를 통해서, 5000 usdc를 amountIn 값으로 넣어서 eth를 받아 볼 것이다.

function exactInputSingle(
ExactInputSingleParams calldata params
) external payable override checkDeadline(params.deadline) returns (uint256 amountOut) {
amountOut = exactInputInternal(
params.amountIn,
params.recipient,
params.sqrtPriceLimitX96,
SwapCallbackData({
path: abi.encodePacked(params.tokenIn, params.fee, params.tokenOut),
payer: msg.sender
})
);
require(amountOut >= params.amountOutMinimum, "Too little received");
}

여기서 checkDeadline modifier는 스왑이 되는 deadline을 지정하여, 채굴자가 악의적으로 스왑을 보류하여 다른 가격에 스왑할 수 없도록 보장한다. 그리고 마지막에 require문에서, 처음에 지정한 slippage에서 계산된 amountOutMinimum보다 amountOut이 작다면 revert 된다. 이제 exactInputInternal을 통해서 v3-core/contracts/UniswapV3Pool.sol의 swap function을 호출한다.

function swap(
address recipient,
bool zeroForOne,
int256 amountSpecified,
uint160 sqrtPriceLimitX96,
bytes calldata data
) ... {
...
SwapState memory state = SwapState({
amountSpecifiedRemaining: amountSpecified,
amountCalculated: 0,
sqrtPriceX96: slot0Start.sqrtPriceX96,
tick: slot0Start.tick,
feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128,
protocolFee: 0,
liquidity: cache.liquidityStart
});
while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
...
...
}

swap 함수 안에서 while문을 돌면서, amountOut을 계산해 나간다.
while문안에 들어가기전에, state 변수를 초기화 시켜준다.
amountSpecifiedRemaining : amountIn (5000 usdc)
amountCalculated : 계산될 amountOut
sqrtPriceX96 : 처음에 current price였다가, 나중에 swap할 price로 바뀐다.
tick : 처음에 current tick였다가, 나중에 swap할 tick으로 바뀜.
feeGrowthGlobalX128 : 여기서는 usdc(token1)를 amountIn으로 받기때문에, feeGrowthGlobal1X128다. 이 컨트랙트에서는 token0과 token1에 쌓인 수수료를 feeGrowthGlobal0X128과 feeGrowthGlobal1X128변수로 트랙한다.
liquidity : 현재 liquidity다.
이제 while문을 보겠다.

//WHILE
step.sqrtPriceStartX96 = state.sqrtPriceX96;
(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
state.tick,
tickSpacing,
zeroForOne
);
...

tickBitmap.nextInitializedTickWithinOneWord 함수를 통해서 스왑하는 방향으로 제일 가까운 tick을 찾는다. 아래 추상화된 그림을 다시 보자.

before swap

현재 tick이 85176이다. 여기서 유저는 usdc를 팔고 eth를 사가기 떄문에 eth의 가격은 상승한다. 그러므로 오른쪽에서 next tick을 찾아야 한다. 위 그림을 보면 오른쪽에서 가장 가까운 틱은 85200인 것을 알 수 있다. tickBitmap 컨트랙트에서 이를 어떻게 구현했는지 보자.

function nextInitializedTickWithinOneWord(
mapping(int16 => uint256) storage self, //storage 변수 ticks
int24 tick, // 현재 tick
int24 tickSpacing, // 60
bool lte // false (오른쪽 방향)
) internal view returns (int24 next, bool initialized) {
int24 compressed = tick / tickSpacing;

여기서는 compressed 변수를 통해서 next tick을 찾는다. 즉 tick에서 60을 나누고 계산을 진행한다. 이렇게 하는 이유는 가스비를 줄이기 위함이고, 대신에 precision이 낮아진다. 예를 들어, 85140 ~ 85199 범위는 같은 compressed 값을 가진다. 85140의 오른쪽 다음 tick은 85200이 된다.

이 함수는 lte를 기준으로 if문을 통해 두 가지 방법으로 나뉜다. 우리는 현재 eth의 가격이 상승하는 swap이므로, 오른쪽에서 tick을 찾는 경우의 코드를 봐야 한다. 코드를 보기 전에 좀 추상화된 그림을 보자.

캡처화면

유니스왑에서는 tick을 word와 bit으로 표현하고, initialized 됐는지 안됐는지는 0 (false)또는 1(true)의 값을 통해서 알 수 있다. 즉, 하나의 이진 숫자가 하나의 tick에 해당한다. word는 tick에서 256을 나눈 값이고, word안에서의 위치인 bitPos는 모듈 256을 통해서 구할 수 있다.

이 추상화된 그림과, 코드에서 차이가 있다. 만약에 next tick을 오른쪽에서 찾는다고 하면, word 단위로는 오른쪽으로 가는 것이 맞다. 하지만, word안에 bit에서는 왼쪽으로 가야 값이 커지므로, 왼쪽에서 찾아야 한다. 이것을 헷갈리지 말고 코드를 보자.

else {
// start from the word of the next tick, since the current tick state doesn't matter
(int16 wordPos, uint8 bitPos) = position(compressed + 1);
// all the 1s at or to the left of the bitPos
uint256 mask = ~((1 << bitPos) - 1);
uint256 masked = self[wordPos] & mask;

// if there are no initialized ticks to the left of the current tick, return leftmost in the word
initialized = masked != 0;
// overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick
next = initialized
? (compressed + 1 + int24(BitMath.leastSignificantBit(masked) - bitPos)) *
tickSpacing
: (compressed + 1 + int24(type(uint8).max - bitPos)) * tickSpacing;
}

여기서 wordPos와 bitPos를 /256과 %256을 통해서 구한다. compressed에 1을 더하는 이유는, 현재 틱을 제외하기 위해서다. 위의 ‘before swap’그림을 보면, 현재 tick이 85176을 기준으로 왼쪽은 usdc amount, 오른쪽은 eth amount인 것을 볼수있는데, 정확히는 85176까지는 usdc amount이고, 85176 + 1 부터 eth amount이다. 그러므로 1을 더하는 것이다.

mask는 ~((1 << bitPos) — 1) 수식을 통해서 구한다. 이것은 실제 예를 들어서 보자. 현재 tick이 85176이기 때문에 compressed의 값은 1419.
wordPos = 1419 + 1 // 256 = 5
bitPos = 1419 + 1 % 256 = 140 이다.
00..001 << bitPos => 00..100.. (0 140개)
00..100.. - 1 => 00..011… (1 139개)
~(00..011..) NOT 연산자 => 11..100.. (0 139개)

이렇게 mask의 값은 11..100.. 이 된다. 이 값으로 어떻게 다음 tick을 찾을까. 다음 masked 값을 보자.
masked는 self[wordPos] & 11..100.. 의 수식을 통해서 구한다. &연산자는 두개의 숫자에서 둘다 1이여야 1인 연산자이다. 즉, 140번째 bitPos에서부터 왼쪽으로 1인(initialized된) 모든 tick을 구하는 것이다.

intialized = maksed != 0 을 통해서 0이 아닌 1이 존재한다면 initialized된 tick이 존재한다는 것이다. 그리고 BitMath.leastSignificantBit(masked)을 통해서 왼쪽에서 가장 가까운 tick을 찾을 수 있다. BitMath.leastSignificantBit(masked)이 140이므로, next tick은 (1419 + 1 + (140–140)) * 60 = 85200이 된다.

다시 swap 함수의 while문으로 돌아가자.

//WHILE 문
step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);
(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
);

computeSwapStep 함수를 통해서, state.sqrtPriceX96, amountIn, amountOut, feeAmount를 구한다. 인자로는 currnet Price, next Price, 현재 price range의 liquidity, amountSpecifiedRemaining(5000 usdc)와 fee(3000)을 넘겨준다.

function computeSwapStep(
uint160 sqrtRatioCurrentX96,
uint160 sqrtRatioTargetX96,
uint128 liquidity,
int256 amountRemaining,
uint24 feePips
)
internal
view
returns (
uint160 sqrtRatioNextX96,
uint256 amountIn,
uint256 amountOut,
uint256 feeAmount
)
{
...
uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6);
amountIn = zeroForOne
? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
: SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true);
if (amountRemainingLessFee >= amountIn) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput(
sqrtRatioCurrentX96,
liquidity,
amountRemainingLessFee,
zeroForOne
);
bool max = sqrtRatioTargetX96 == sqrtRatioNextX96;

if (exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96) {
// we didn't reach the target, so take the remainder of the maximum input as fee
feeAmount = uint256(amountRemaining) - amountIn;
} else {
feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 1e6 - feePips);
}

먼저 amountRemainingLessFee는 amountRemaining에서 fee를 뺀 amount이다. 5000usdc를 0.3% fee pool에서 스왑한다면, 5000 usdc * 0.997 = 4985 usdc가 된다.

amountIn이 usdc(token1)이기 때문에, getAmount1Delta을 통해서 구한다.

getAmount1Delta

위의 수식에서, △y가 usdc amount, P_c가 upper Price, P_a가 current Price가 된다.

그리고 if문을 통해 amountRemainingLessFee >= amountIn을 체크한다. 이것이 true면, 해당 price range 내에서 liquidity가 부족해서, sqrtRatioNextX96에 그냥 sqrtRatioTargetX96을 대입한다. 즉 아까전에 구한 nextInitalizedTick이 되는 것이다. 만약에 false라면, 해당 price range내에서 liquidity가 충분하여, 현재 tick과 nextInitializedTick 사이에 Next Price를 계산한다. 이를 수식으로 표현하면 다음과 같다.

이렇게 current Tick과 nextInitalizedTick 사이의 target Price를 구할 수 있다.

다음에 max가 true라면, current price range내에 liquidity가 부족해서, nextInitalizedTick을 기준으로 amountIn을 그대로 쓰고, amountOut을 구한다. 만약에 false라면, current price range내에 liquidity가 충분하여, 아까처럼 getAmount1Delta함수를 통해 구해준다. 그리고 amountOut은 getAmount0Delta함수를 통해서 구할 수 있다. 수식으로 표현하면 다음과 같다.

getAmount0Delta

마지막 if문(exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96)은, true라면 liquidity가 충분하다는 뜻이고 amountRemaining — amountIn을 하면 feeAmount가 바로 계산된다. 왜냐하면 여기서 구한 amountIn은 amountRemainingLessFee을 사용해 계산된 값이기 때문이다.

만약에 false라면 liquidity가 부족하여, 계산된 amountIn에서 feeAmount를 계산한다. 수식으로 표현하면, feeAmount = amountIn * fee / (1e6 — fee) 이다. eg. 2000 * (3/997)

이제 다시 while문으로 돌아온다.

//WHILE 문
state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256();
state.amountCalculated = state.amountCalculated.sub(step.amountOut.toInt256());

state.feeGrowthGlobalX128 += FullMath.mulDiv(
step.feeAmount,
FixedPoint128.Q128,
state.liquidity
);

amountSpecifiedRemaining은, 5000 - (계산된 amountIn - 계산된 feeAmount)로 구할 수 있다.

그리고 amountCalculated는 0 - (계산된 amountOut)이다.

그리고 feeGrowthGlobalX128에 해당 feeAmount를 더해준다. 여기서 liquidity를 나누는 이유는, 이 변수의 값 자체가 feeAmount per liquidity이기 때문이다. 이어서 While문을 보겠다.

//WHILE문
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

...

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);
}

if가 true라면, liquidity가 부족하여, tick을 cross해서 다음 price range를 찾아야한다. cross함수에서는, 오라클 관련 변수들과 fee 관련 변수들을 업데이트 해준다. 그리고 liquidityNet을 반환한다. 그리고 storage 변수 liquidity에 liquidityNet을 더하여 업데이트 한다. 그리고 다시 While문을 돈다.

if가 false라면, 다음 tick을 계산한다. liquidity가 충분하여, 모든 amountIn과 amountOut을 계산하면 while문이 끝난다.

그리고 slot0과 liquidity를 업데이트 해준다. 그리고 transfer가 일어나고 swap은 끝이 난다. 다음 시리즈는 Fee에 대해서 더 자세히 다루겠다.

--

--