Post

Dive into Uniswap V3 (1) - sqrtPriceX96

Uniswap V3를 공부하면서, 처음에 가장 이해하기 어려웠던 것이 바로 sqrtPriceX96이라는 변수였다. 그냥 price도 아니고, 왜 $\sqrt{p}$를 사용하는지 부터, 또 왜 160bit 크기의 자료형을 사용하는지 까지. 이제와 다시 생각해봐도 처음 Uniswap V3의 codebase를 접하는 사람이라면 직면하게 될 의문이라고 생각하기 때문에 이렇게 글로 작성한다.

우선 왜 sqrtPrice를 사용하는지 먼저 알아보자.

Uniswap V2에서는 reserve0, reserve1이라는 두 변수를 이용해 token0 , token1 의 잔액을 관리하고, 이를 통해 price와 liquidity를 계산한다.

하지만 Uniswap V3의 가장 큰 특징 중 하나인 Tick으로 인해, Uniswap V3에서는 이러한 방법을 사용하기가 매우 까다로워진다. 각 Tick별로 reserve가 다르기 때문에, 이를 모두 상태변수에 저장하고 그때그때 로드하여 price와 liquidity를 계산하는 것은 별로 가스 효율적인 방법이 아니기 때문이다.

때문에 Uniswap V3에서는 각 토큰의 잔액을 저장하여 관리하는 것이 아니라, 애초에 price $p$와 liquidity $L$을 이용해 reserve를 구하는, 반대로 된 방식을 사용한다.

V2에서와 마찬가지로, 아래와 같은 liquidity를 구하는 공식을 알고 있을 것이다.

\[L^2=x\cdot y\]

그리고 AMM모델에서 가격은 풀 내에 있는 X토큰과 Y토큰의 상대적 비율을 나타내므로, 서로의 가격은 각각 역수의 관계를 가지고 있다. 따라서, X토큰의 가격만 추적해도, 이에 역수를 취하면 Y토큰의 가격을 알 수 있기 때문에 X토큰의 가격만을 저장한다. 그리고, 우리는 X토큰의 가격을 구하는 공식을 알고 있다.

\[p_x=\frac{y}{x}\]

그런데, V3에서는 아래와 같이 sqrtPrice를 사용한다고 했다.

\[\sqrt{p_x}=\sqrt{\frac{y}{x}}\]

대체 왜 이렇게 사용하는 것일까?

이는 V3 개발자들이 극한으로 코드를 최적화시킨 결과이다. 이를 사용하는 케이스를 살펴보자.

reserve_x를 구할 때: $\frac{L}{\sqrt{p_x}}=\frac{\sqrt{xy}}{\sqrt{\frac{y}{x}}}=x$

reserve_y를 구할 때: $L\cdot\sqrt{p_x}=\sqrt{xy}\cdot\sqrt{\frac{y}{x}}=y$

이처럼, 일반적인 price $p$가 아니라, $\sqrt{p}$를 사용하는 것이 계산 효율적이라는 것을 알 수 있다.

사실 이 효능은 조금 더 있는데, 아래 부분에서 다시 이야기하도록 하겠다.

이제 sqrtPrice를 사용하는 이유를 알았으니, sqrtPriceX96을 사용하는 이유를 알아보도록 하자.

하지만 sqrtPriceX96을 이해하기 전에 Q number format에 대해 이해할 필요가 있어, 짚으면서 진행해보도록 하자.

많이들 아는 것과 같이, EVM에서는 storage와 memory의 한 slot이 32바이트이기 때문에, 소숫점을 표현하는 것이 매우 어렵다. 때문에 solidity에서는 애초에 소수 자료형을 지원하지 않는다.

하지만, 현존하는 DApp의 대부분이 Defi와 관련있는 현재 상황에서, 소수 자료형이 없다는 것은 매우 치명적이라 할 수 있다.

때문에 개발자들은 소수점 연산을 해야하는 상황에서 Q number format 즉, 고정소숫점 방식을 이용하게 된다.

sqrtPriceX96은 Q64.96으로 표현되는 숫자이다. 즉, 총 160비트를 사용할 때, 정수부에 사용되는 부분이 64비트, 그리고 소수부에 사용되는 부분이 96비트라는 말이다.

하지만 그렇다고 하더라도 결국 solidity 코드에서는 정수 형태로 저장될 뿐이다. 그렇기 때문에 이를 소수로 취급하기 위해 취해줘야 하는 연산이 있는데, 바로 .. 소수부에 속하는 비트 수인 96을 2의 지수로 하여 이로 하여금 sqrtPriceX96을 나누도록 하는 것이다.

아래를 참고해보자. (길이 문제로, 16진수로 표현하였다)

1
2
3
4
0x0000000000000000000000000000000000000000: 160bit (20bytes)
divide with $2^{96}$ (12 bytes)
0x0000000000000000.000000000000000000000000
     정수부                             소수부

이렇게 하면, 꽤 높은 정밀도로 고정소숫점 연산을 해낼 수 있다.

그리고 Solidity에서도, 똑같이 연산을 해주면 된다.

\[\sqrt{p}=\frac{sqrtPriceX96}{2^{96}}\] \[sqrtPriceX96=\sqrt{p}\cdot 2^{96}\]

그런데, 위에서 이야기했던 $\sqrt{p}$를 사용하는 것이 효율적이라는 근거가 여기에서 하나 더 나오게 된다. 이렇게 $\sqrt{p}$를 사용하게 되면, Q64.96 format을 사용하니 square를 취해주면 정수부분이 128bit가 되어 실제로는 최대 128bit크기의 가격까지 저장을 할 수 있게 되는 것이다. 어느정도라고 생각해도 되냐면, 가격 차이가 $10^{38}$배 차이나는 자산 간의 swap까지도 지원을 할 수 있다는 이야기가 된다. 이 정도 가격 차이가 나는 자산은… 쉽게 생기기 어려울 듯 싶다.

Q number format을 어떻게 연산하는지와 같은 더욱 자세한 내용이 필요하다면, 아래 링크를 참고하자. rareskills: Q number format

이제, sqrtPriceX96이 왜 사용되고, 어떻게 사용하는지까지 알았는데, 왜 160 bit 자료형을 사용하는지는 여전히 의문이다.

또다시 Uniswap 개발자들의 최적화 집착이 빛나는 순간이다.

Slot0이라는 구조체를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Slot0 {
    // the current price
    uint160 sqrtPriceX96;
    // the current tick
    int24 tick;
    // the most-recently updated index of the observations array
    uint16 observationIndex;
    // the current maximum number of observations that are being stored
    uint16 observationCardinality;
    // the next maximum number of observations to store, triggered in observations.write
    uint16 observationCardinalityNext;
    // the current protocol fee as a percentage of the swap fee taken on withdrawal
    // represented as an integer denominator (1/x)%
    uint8 feeProtocol;
    // whether the pool is locked
    bool unlocked;
}

이 안의 모든 변수의 크기를 합쳐보았다.

1
20bytes + 3bytes + 2bytes + 2bytes + 2bytes + 1bytes + 1bytes = 31bytes

즉, 160bit 크기의 sqrtPriceX96을 사용하는 이유는, V3에서 사용하는 상태변수들이 쓰고 남은 칸이 160bit 크기의 자료형이 들어갈 크기이기 때문이다~라고 생각해도 좋을 듯 하다.

이러한 방식으로, Uniswap V3에서는 상태변수들의 집합인 Slot0의 모든 변수들을 수정한다고 해도, sstore, sload opcode를 단 한 번 씩만 호출해도 되기 때문에, 극한의 메모리 최적화를 거쳤다고 볼 수 있을 것이다.

This post is licensed under CC BY 4.0 by the author.