유니스왑V3 1번째- deploy pool

Justin Gee
4 min readMar 12, 2023

--

Uniswap Labs, 트위터 캡처화면

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

유니스왑V3를 분석한 것을 글로 작성해 보려고 한다.

개요나 여러 가지 용어에 대한 정의들은 대부분 skip할 예정이다. 중요한 수식이나 개념들은 짚고 넘어갈 것이다. 양이 너무 많아서 시리즈로 작성할 것이다.

첫 번째로 풀을 생성할 것이다. 아래 링크의 코드를 통해서 풀을 생성할 수 있다.

3~5 : 인자로 tokenA, tokenB, 그리고 fee를 받는다.

인자로 tokenA, tokenB, 그리고 fee를 받는다.

  • 모든 풀은 tokenA, tokenB, 그리고 fee로 구분된다. 즉 같은 tokenA와 tokenB의 풀이 있어도 fee가 다른 풀이 존재한다. 셋 다 똑같은 값을 가지는 풀은 존재하지 않는다.
  • 토큰 fee에는 0.05%, 0.3%, 1.0%가 존재한다. 5 BPS, 30 BPS, 100 BPS라고도 한다. BPS는 Basis Point다. 기존 금융에서 똑같이 사용하는 수수료 단위다.
  • 그럼 무조건 1.0%로 설정하여 유동성을 제공하면 수수료를 많이 가져갈 수 있을까? 아니다. 트레이더가 내고 싶어 하는 수수료와 유동성 제공자가 받고 싶어 하는 수수료의 밸런스를 맞춰야 한다. 트레이더는 무조건 낮은 수수료를 내고 스왑하고 싶겠지만, 유동성 제공자는 가격 변동성이 큰 풀에서 impermanent loss를 피하고 싶기 때문에 높은 수수료를 받고 싶어 한다. 그래서 유동성 제공자들은 가격 변동성에 따라서 수수료를 설정한다. 트레이더 입장에서도 무조건 수수료가 낮은 풀에서 트레이드하는 것이 이득이 아니다. 수수료가 낮은 풀에 유동성이 충분하지 않다면 슬리피지가 더 발생할 것이다.(물론 슬리피지를 설정할 수 있긴 하다. 유동성이 모자라면 부분 스왑 된다.)

8 : 토큰의 주소를 대소 비교를 한다. 숫자가 더 적은 것이 tokenA가 된다.

  • 풀에서 토큰의 순서는 중요하다. 토큰 순서에 따라 가격이 계산된다.
    가격 = sqrt(tokenB 리저브 / tokenA 리저브)

10 : int24 tickspacing = feeAmountTickSpacing[fee];

constructor() {
owner = msg.sender;
emit OwnerChanged(address(0), msg.sender);

feeAmountTickSpacing[500] = 10;
emit FeeAmountEnabled(500, 10);
feeAmountTickSpacing[3000] = 60;
emit FeeAmountEnabled(3000, 60);
feeAmountTickSpacing[10000] = 200;
emit FeeAmountEnabled(10000, 200);
}
  • 이 코드를 보면 fee가 500, 3000, 10000으로 되어있다. 500 -> 5 BPS -> 0.05%, 3000 -> 30 BPS -> 0.3%, 10000 -> 100BPS -> 1.0%라고 보면 된다.
  • 위에 constructor를 보면, fee에 비례해서 tickspacing이 달라진다. tick은 price range의 경계선을 나타내는데 사용된다. tickspacing은 스왑할 때 price range가 바뀌면서 반복문을 도는데 거기서 가스비가 덜 나오게 하려고 만든 변수다. 이름 그대로 tick 간의 거리다. tickspacing이 클수록 더욱 gas efficient하고 precision이 떨어진다. tickspacing이 작을수록 gas efficient하지 않고 precision이 올라간다. tickspacing은 fee와 같이 가격의 변동성을 고려해서 10 ,60 아니면 200으로 설정된다. 스테이블 코인 쌍의 풀에서는 스왑할 때 가격의 움직임이 적고 price range를 잘 벗어나지 않는다. 그리고 반복문을 적게 도므로 precision이 높고 gas efficient하지 않는 10을 선택하는 것이 좋다.

13 : pool = deploy(address(this), token0, token1, fee, tickSpacing);을 호출한다. deploy는 UniswapV3PoolDeployer.sol에 있다.

15 : 이 컨트랙트의 public 변수인 paramemter에 값을 저장한다. 그리고 17 : 에서 바로 delete해준다. 가스 소비를 줄이기 위함이다. 이 paremeter 변수는 createPool() 함수 동안에만 값이 있는 임시 변수다.

16 :

pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());

위에 코드를 보면 salt에 값을 주고 uniswapV3Pool을 만들고 있다. 이건 create2다. create2는 nonce 대신에 salt 값을 지정해 줌으로써 주소를 오프체인에서도 계산할 수 있게 된다. 어떤 식으로 계산하는지는 v3-peripehry에 libraries폴더에 PoolAddress.sol 파일이 있다.

그리고 computeAddress함수는 타입스크립트로 구현된 v3-sdk에 유틸리티폴더에도 있다.

솔리디티 코드를 보겠다.

13 : hex ‘ff’는 prefix로 create인지 create2인지 구분해준다.

14 : 여기는 deployer주소가 들어간다. factory 컨트랙트 주소를 넣으면 된다.

15 : 이 부분이 salt다.

16 : Init code hash 또는 creation code hash라고 부른다.

import "../v3-core/UniswapV3Pool.sol";

contract CalHash {
function getInitHash() public pure returns (bytes32) {
bytes memory bytecode = type(UniswapV3Pool).creationCode;
return keccak256(abi.encodePacked(bytecode));
}
}

이렇게 init code hash를 구할 수 있다.

UniswapV3Pool의 constructor다. 아까 create2를 할 때 아무 인자도 넘겨주지 않았다. 그리고 여기서 아까 설정한 public 변수인 parameters를 호출하여 값을 가져온다. 왜 이렇게 파라미터를 넘겨주지 않고 이런 식으로 값을 저장할까?

init code 혹은 creationcode 에는 constructor 인자들이 포함된다. 그래서 저렇게 인자들이 없다면, 모든 풀은 모두 같은 init code를 갖게 될 것이다. 그래서 온체인이나 오프체인에서의 계산을 쉽게 만든다. query하지 않아도 풀의 주소를 계산할 수 있다.

이 과정을 간단하게 테스트 코드를 작성해 봤다. (https://github.com/usgeeus/uniswapv3-hardhat-test)

커밋 : https://github.com/usgeeus/uniswapv3-hardhat-test/commit/54a74ae881a85064464e5491b50ee6c81ea752f6

v3-core의 코드 중간중간에 값들을 console log를 해보고 싶어서 직접 하드햇을 셋팅하고 로컬에 배포하고 테스트를 진행했다. 그런데 컴파일러나 optimizer 셋팅이 다른지 UniswapV3Pool의 init code hash가 다르게 나왔다. 그래서 그 값만 바꾸고 테스트를 진행했다.

이 코드는 모든 경우와 코드를 커버하지 않지만, 흐름을 파악한다. 풀을 생성하고 이벤트에 찍힌 그 풀의 주소와 계산된 주소를 비교한다. 그리고 그 풀에 값이 제대로 초기화되었는지와 parameters가 delete되었는지 확인하는 코드다.

이렇게 새롭게 환경을 구축하고 컴파일하고 테스트할 때 주의해야할 부분이 있다. v3-peripehry에 libraries폴더에 PoolAddress.sol 파일에 bytes32 internal constant POOL_INIT_CODE_HASH 변수다. 아까 address는 Create2를 통해서 결정된다고 설명했다. 그런데 하드햇 settings, optimizer 셋팅 그리고 컴파일러 셋팅에 의해서 컨트랙트의 init code가 달라지는 것 같다. 그래서 contracts/test/CalHash.sol(따로 만든 파일)을 통해서 init code를 계산하고 변수를 바꾸어줬다.

이렇게 Pool을 만들면 바로 initialize함수를 호출해 준다. 다음 글에는 initialize를 하겠다.

--

--