유니스왑V3 3번째- provide liquidity

Justin Gee
21 min readApr 3, 2023

--

Uniswap Labs, 트위터 캡처화면

유니스왑V3 1번째 — deploy pool
유니스왑V3 2번째 — initialize pool + FeeProtocol

유니스왑V3 2번째에 이어서 3번째(provide liquidity) 해볼 것이다.

테스트케이스는 https://uniswapv3book.com/ 을 참고했으며, 내 나름대로 만들어봤다.

테스트 케이스

현재 가격은 5000 USDC/ETH인 상태다.

5000USDC/1ETH 쌍을 5개의 price range로 유동성을 제공해 볼 것이다. 이때 자기가 설정한 price range에 따라서, 5000USDC와 1ETH를 그대로 제공을 못할 수 있고, 제공하는 유동성이 매번 달라진다.

어떻게 달라지는지 코드를 보면서 살펴 볼 것이다.

먼저 유니스왑에서는 유동성을 제공하는 함수명을 mint로 통일하고 있다. https://github.com/Uniswap/v3-periphery/blob/main/contracts/NonfungiblePositionManager.sol 에 mint 함수를 통해서 유동성을 제공할 수 있다. 먼저 mint함수의 파라미터를 보자. 코드에 설명을 바로 적었다.

/// @inheritdoc INonfungiblePositionManager
function mint(
MintParams calldata params
)
external
payable
override
checkDeadline(params.deadline)
returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)
{
....

==>
struct MintParams {
address token0; //eth address
address token1; //usdc address
uint24 fee; // pool fee (eg. 3000)
int24 tickLower; //제공하는 price range(eg. 4545)
int24 tickUpper; //제공하는 price range(eg. 5500)
uint256 amount0Desired; //제공하려는 eth의 amount(eg. 1 eth)
uint256 amount1Desired; //제공하려는 usdc의 amount(eg. 5000 usdc)
uint256 amount0Min; // 최소 제공 amount 설정
uint256 amount1Min; // 최소 제공 amount 설정
address recipient; //내 주소
uint256 deadline; //제공이 이루어지는 최대 시간 설정 (시간이 흐름에 따라서 current Price가 바뀔 수 있고 제공될 수 있는 유동성의 양이 달라질 수 있으니 deadline을 설정하는 것이다.)
}

여기서 4545~5500의 price range에 유동성을 제공하려면 tick을 계산해야 하는데, v3-core/contracts/libraries/tickMath.sol에 getTickAtSqrtRatio함수가 구현되어 있다. 다음 코드와 같이 tickLower과 tickUpper를 구할 수 있다.

commonParams.tickLower =
Math.round(
(await tickMath.getTickAtSqrtRatio(encodePriceSqrt(1, 4545))) / tickSpacing
) * tickSpacing
commonParams.tickUpper =
Math.round(
(await tickMath.getTickAtSqrtRatio(encodePriceSqrt(1, 5500))) / tickSpacing
) * tickSpacing

tickSpacing은 전 시리즈에서 설명했으며, 현재 eth/usdc 3000 fee pool에는 tickspacing이 60으로 설정되어있다. 60을 나누고 곱해줌으로써 60의 배수로 맞출 수 있다.

#공통

amount0Desired = 1 ETH, amount1Desired = 5000 USDC, currentPrice = 5000 (tick: 85176, sqrtPriceX96 : 5602277097478613991873193822745)

똑같은 토큰의 양을 유동성을 제공해볼 것이고, price range에 따라서 어떻게 제공할 수 있는 유동성이 달라지는 지를 볼 것이다.

#1

4545(tick: 84240, sqrtPriceX96: 5346092701810166522520541901099) ~ 5500(tick: 86100, sqrtPriceX96: 5867104893150104774564485477116)

자 이제 이 price range에 따라서 유동성을 계산해볼 것 이다. https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/LiquidityAmounts.sol

아래 함수를 통해서 유동성이 계산된다.

function getLiquidityForAmounts(
uint160 sqrtRatioX96,
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint256 amount0,
uint256 amount1
) internal pure returns (uint128 liquidity) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);

if (sqrtRatioX96 <= sqrtRatioAX96) {
liquidity = getLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0);
} else if (sqrtRatioX96 < sqrtRatioBX96) {
uint128 liquidity0 = getLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0);
uint128 liquidity1 = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1);

liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1;
} else {
liquidity = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1);
}
}
getLiquidityForAmount0 함수 식
getLiquidityForAmount1 함수 식

sqrt(P_c)는 현재 currentPrice를 나타낸다. sqrt(P_a)는 제공하는 price range에서 lowerPrice를 의미하고, sqrt(P_b)는 제공하는 price range에서 upperPrice를 의미한다.

자 위의 코드를 보면, 두번 째 if문(else if (sqrtRatioX96 < sqrtRatioBX96))은 현재 price range가 current price를 포함할 때를 뜻하며, 현재 4545~5500의 범위에서 유동성을 제공하므로 이 if문에 해당한다. 그리고 코드와 함수식을 보면, amount0과 amount1을 통해서 계산하고 더 적은 liquidity를 선택한다. 이유는 유동성을 price range 내에서 고르게 주기 위해서다. 이를 추상화해서 그림으로 표현해보겠다.

실행해보면, liquidity0 = 1566553709122544960775, liquidity1 = 1546311247949719370862로 계산된다.

여기서 만약에 큰 liquidity를 고른다면, 파란색 선 위에 있는 보라색 점선 만큼 liquidity를 더 제공해야 한다. 따라서 고르게 주기 위해서는 더 작은 것을 선택한다.

만약에 제공하려는 유동성의 price range가 currentPrice를 포함하지 않고 currentPrice보다 더 높은 price range를 제공하면 어떻게 될까? 그때는 ETH로만 유동성을 제공할 수 있게 된다. 이를 추상화해서 그림으로 표현해보겠다.

자 currentPrice가 5000 USDC/ETH 상태에서 5500원으로 상승하려면 어떻게 해야할까. 누군가 △USDC만큼 USDC를 팔고 △ETH만큼을 사면 된다. 그럼 pool 입장에서 스왑이 가능하려면, △ETH만큼의 유동성만 가지고 있으면 된다. 따라서, currentPrice보다 가격이 상승하는 스왑이 일어날때는 pool에 ETH만 있으면 된다. 따라서 5500~6250의 price range에서 유동성을 제공하게 되면, ETH로만 유동성을 제공하게 된다.

상승 후, currentPrice가 5500이고 5000원으로 내려갈때를 보자. 누군가 △ETH를 팔고 △USDC만큼을 사면 된다. 그럼 pool 입장에서는 아까 처음에 받은 △USDC를 주면 된다. 여기서 5000원에서 4545원으로 또 가격이 내려갈때는 어떻게 될까? 그럼 pool에 또 ETH가 들어올것이고 그만큼 USDC는 풀에서 나가게 된다. 따라서, 현재 가격인 5000원보다 낮은 price range에 유동성을 제공하게 되면 USDC로만 유동성을 제공하게 된다.

다시 테스트케이스로 돌아가자. 결국 liquidity1인 1546311247949719370862을 유동성으로 제공할 수 있게 된다. 그리고 v3-core/contracts/UniswapV3Pool.sol의 mint 함수에 tickLower, tickUpper, liquidity 등을 인자로 넘겨주게 된다.

pool은 들어오는 유동성을 맵핑 tick에 관리한다. 맵핑의 값으로는 struct가 오고 liquidityGross와 liquidityNet을 관리한다. liquidityGross는 lowerTick과 upperTick의 관계없이 해당 틱에 liquidity를 무조건 더한다. liquidityNet은 lowerTick인 경우에 liquidity를 더하고, upperTick인 경우에 liquidity를 뺀다. 아래 코드를 보면 알 수 있다. liquidityNet은 swap할 때 price range가 변하고, 해당 price range에 유동성을 계산하기 위해서 사용된다.

struct Info {
// the total position liquidity that references this tick
uint128 liquidityGross;
// amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left),
int128 liquidityNet;
...생략
}
...
function update(
mapping(int24 => Tick.Info) storage self,
int24 tick,
int24 tickCurrent,
int128 liquidityDelta,
...
) internal returns (bool flipped) {
...

info.liquidityNet = upper
? int256(info.liquidityNet).sub(liquidityDelta).toInt128()
: int256(info.liquidityNet).add(liquidityDelta).toInt128();
}

따라서

ticks[84240].liquidityGross = 1546311247949719370862, ticks[84240].liquidityNet= 1546311247949719370862

ticks[86100].liquidityGross = 1546311247949719370862
ticks[86100].liquidityNet = -1546311247949719370862

이 된다.

그리고 tickBitmap 변수에 flipTick을 통해서 해당 tick이 initiailized 됐다는 것을 표시한다. 이는 swap시에 다음에 initialized된 tick을 찾는데 쓰인다. 아래코드에서 tick이 어떻게 initialized되는지 확인하자.

function flipTick(
mapping(int16 => uint256) storage self,
int24 tick,
int24 tickSpacing
) internal {
require(tick % tickSpacing == 0); // ensure that the tick is spaced
(int16 wordPos, uint8 bitPos) = position(tick / tickSpacing);
uint256 mask = 1 << bitPos;
self[wordPos] ^= mask;
}
...
function position(int24 tick) private pure returns (int16 wordPos, uint8 bitPos) {
wordPos = int16(tick >> 8);
bitPos = uint8(tick % 256);
}

여기서 require문은 통과한다. 왜냐하면 처음에 /tickSpacing*tickSpacing을 통해서 tickSpacing의 배수로 맞췄다.

자 먼저 추상화된 그림을 먼저 보자.

해당 tick은 word와 bit로 나눠서 관리할 수 있다. 그리고 initialized 됐는지는 0과 1의 값으로, 0이면 false 1이면 true다.

그리고 84240 / 60 = 1,404이다. 1404로 wordPos와 bitPos를 구할 수 있다. wordPos는 1404 >> 8, 즉 1404/256을 통해서 찾는다. 그리고 % 256을 통해서 해당 틱의 bitPos를 계산한다. 그리고 1 << bitPos를 통해서 해당 포지션만 1인 mask를 구하고, tickBitmap에 XOR연산을 통해서 해당 틱을 뒤집는다. XOR은 값이 같은 경우 0이고, 값이 다른 경우 1로 바꾼다. 즉 mask에 해당 bitPos는 1이기 때문에 0이면 1이되고, 1이면 0이 된다. 결국 뒤집는 것이 된다.

자 이제 마지막으로 아까 구한 liquidity로 실제로 유동성으로 제공되는 amount0과 amount1을 다시 계산한다. 여기서 1 ETH와 5000U USDC가 아니게 된다.

v3-core/contracts/UniswapV3Pool.sol의 _updatePosition()

amount0 = SqrtPriceMath.getAmount0Delta(
_slot0.sqrtPriceX96,
TickMath.getSqrtRatioAtTick(params.tickUpper),
params.liquidityDelta
);
amount1 = SqrtPriceMath.getAmount1Delta(
TickMath.getSqrtRatioAtTick(params.tickLower),
_slot0.sqrtPriceX96,
params.liquidityDelta
);

getAmount0Delta와 getAmount1Delta를 함수식으로 표현하면 다음과 같다.

그래서 결국 amount0 : 987078348444137445
amount1 : 4999999999999999999999로

0.987… ETH와 4999.99.. USDC를 유동성으로 제공하게 된다.

이제 다음 테스트케이스는 변화되는 값만 살펴 보겠다.

#2 5500~6250 (lowerTick : 86100, upperTick : 87420)

currentPrice보다 높은 priceRange에 유동성을 제공하므로,

amount0 : 1000000000000000000
amount1 : 0

만큼만 유동성을 제공하게 된다. liquidity는 1159509296178761268990를 제공하게 된다.

ticks[86100].liquidityGross = 1546311247949719370862 + 1159509296178761268990 = 2705820544128480639852
ticks[86100].liquidityNet = -1546311247949719370862 + 1159509296178761268990 = -386801951770958101872 가 된다. (lowerTick이므로 +)

ticks[87420].liquidityGross = 1159509296178761268990
ticks[87420].liquidityNet= -1159509296178761268990

#3 5500~6250 (lowerTick : 86100, upperTick : 87420)

currentPrice보다 높은 priceRange에 유동성을 제공하므로,

amount0 : 1000000000000000000
amount1 : 0

만큼만 유동성을 제공하게 된다. liquidity는 1159509296178761268990를 제공하게 된다.

ticks[86100].liquidityGross = 2705820544128480639852 + 1159509296178761268990 = 3865329840307241908842
ticks[86100].liquidityNet = -386801951770958101872+ 1159509296178761268990 = 772707344407803167118가 된다. (lowerTick이므로 +)

ticks[87420].liquidityGross = 1159509296178761268990 + 1159509296178761268990 = 2319018592357522537980
ticks[87420].liquidityNet = -1159509296178761268990 -1159509296178761268990 = -2319018592357522537980

#4 5000~6000 (lowerTick : 85200 , upperTick: 87000)

currentPrice보다 같고 높은 priceRange에 유동성을 제공하므로,

amount0 : 1000000000000000000 (1 ether)
amount1 : 0

만큼만 유동성을 제공하게 된다. liquidity는 822577684515349976875를 제공하게 된다.

ticks[85200].liquidityGross = 822577684515349976875
ticks[85200].liquidityNet = 822577684515349976875

ticks[87000].liquidityGross = 822577684515349976875
ticks[87000].liquidityNet = -822577684515349976875

#5 4980~6000 (lowerTick : 85140, upperTick : 87000)

자 여기서는 currentPrice가 5000인데 4980~6000에 유동성을 제공하고 있다. 이 케이스 또한 liquidity를 price range에 고르게 분배하여 제공한다.

current price인 5000보다 작은 부분은, 5000–4980 = 20인데 20만큼의 price range에는 5000USDC이 아니라 103.7150… USDC만 제공되게 된다.

amount0 : 1000000000000000000 (1 ETH)
amount1 : 103715064499732485845 (103.7150… USDC)

만큼만 유동성을 제공하게 된다. liquidity는 811348695493091218667를 제공하게 된다.

ticks[85140].liquidityGross = 811348695493091218667
ticks[85140].liquidityNet = 811348695493091218667

ticks[87000].liquidityGross = 822577684515349976875 + 811348695493091218667 = 1633926380008441195542
ticks[87000].liquidityNet = -822577684515349976875 -811348695493091218667 = -1633926380008441195542

#6 5001~6250 (lowerTick : 85200, upperTick : 87420)

currentPrice보다 같고 높은 priceRange에 유동성을 제공하므로,

amount0 : 1000000000000000000 (1 ether)
amount1 : 0

만큼만 유동성을 제공하게 된다. liquidity는 673875950313623875342를 제공하게 된다.

ticks[85200].liquidityGross = 822577684515349976875 + 673875950313623875342 = 1496453634828973852217
ticks[85200].liquidityNet = 822577684515349976875 + 673875950313623875342 = 1496453634828973852217

ticks[87420].liquidityGross = 2319018592357522537980 + 673875950313623875342 = 2992894542671146413322
ticks[87420].liquidityNet = -2319018592357522537980 -673875950313623875342 = -2992894542671146413322

그리고 storage state인 liquidity는 제공할때 price range에 currentPrice가 포함될때만 liquidity를 업데이트한다. 그러므로 #1과 #5의 유동성을 더하면, 1546311247949719370862+811348695493091218667 = 2357659943442810589529 이다.

최종상태

이제 swap할때 liquidityGross와 liquidityNet가 어떻게 사용되는지, tick을 사용해서 current price가 어떻게 바뀌는지 다음 시리즈에서 볼 것이다.

--

--