ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Defi] Uniswap V2 Contract 코드 분석 1 - Factory
    블록체인 2021. 2. 8. 02:52

     오랜만에 오픈소스 분석이나 해볼겸 요즘 관심갖는 Defi의 가장 유명한 탈중앙화 거래소인 유니스왑 코드를 분석해보기로 했다. 어디까지나 개인 공부용이니 포스트를 보면서 사실과 다른점이 있다면 얼마든지 댓글을 남겨주세요 ㅠ_ㅠ

     유니스왑의 컨트랙트는 크게 Core(Factory, Pairs), Periphery(Library, Router)로 구성돼있는것 같다.(https://uniswap.org/docs/v2/protocol-overview/smart-contracts/)

     오늘은 먼저 Core 코드를 분석해보자.
    Core는 크게 Factory와 Pairs로 나뉜다. Factory 컨트랙트는 pool을 만드는 컨트랙트라고 한다. 또한 하나의 token pair마다 하나의 컨트랙트가 할당되는 것으로 보아, UNI-ETH pair에 유니크한 컨트랙트가 하나 할당됐고, 다른 pair에도 각자 유니크한 컨트랙트가 할당됐다고 보면 될것같다.

     Pairs 컨트랙트는 AAM(Automated Market Maker)를 제공하고, pool에 존재한믄 토큰의 비율을 추적하는 역할을 한다.

     

    UniswapV2Factory.sol

     먼저 Factory 컨트랙트부터 살펴보자.
    코드는 https://github.com/Uniswap/uniswap-v2-core/blob/master/contracts/UniswapV2Factory.sol 에서 확인할 수 있다.

    interface IUniswapV2Factory {
        event PairCreated(address indexed token0, address indexed token1, address pair, uint);
    
        function feeTo() external view returns (address);
        function feeToSetter() external view returns (address);
    
        function getPair(address tokenA, address tokenB) external view returns (address pair);
        function allPairs(uint) external view returns (address pair);
        function allPairsLength() external view returns (uint);
    
        function createPair(address tokenA, address tokenB) external returns (address pair);
    
        function setFeeTo(address) external;
        function setFeeToSetter(address) external;
    }

    Uniswap의 Factory interface를 정의한 IUniswapV2Factory 의 내용이다. 하나씩 살펴보자.(나도 공부하는 중이라 정확하지 않을 수 있다 ㅠ_ㅠ)

    • feeTo : 아마 이 pool을 이용하는 사람들이 수수료를 지불하는 지갑 또는 컨트랙트 주소
    • feeToSetter : 위의 feeTo를 설정할 수 있는 주소(아마도 관리자)
    • getPair : 파라미터로 들어온 두 토큰(erc20) pair pool의 컨트랙트 주소
    • allPairs : 모든 토큰 pool의 컨트랙트 주소, 이름은 allPairs인데 파라미터로 들어온 인덱스에 해당하는 컨트랙트를 반환하는 것 같다.
    • allPairsLength : 전체 pool의 개수
    • createPair : 파라미터로 들어온 두 토큰 pair로 이루어진 pool을 만든다.
    • setFeeTo : feeTo를 설정하는 함수
    • setFeeToSetter : feeToSetter를 설정하는 함수

     그리고 맨 위에 PairCreated라는 이벤트가 있는데, 이더리움 스마트 컨트랙트에서 이벤트란 로그를 뜻하며, 블록체인에 기록된다. 사실 찾는 방법이 좀 복잡하긴 한데, 함수 안에서 이벤트를 발생시키면(emit한다고 표현) 나중에 어떤 블록안에 이벤트 발생 기록이 남게되어 추적할 수 있다. 그리고 이 이벤트는 새로운 Pair가 만들어졌다는 뜻이므로, createPair함수 안에서 불릴거라고 생각한다.

     먼저 인터페이스를 통해 각 함수가 어떤 역할을 하는지 생각해봤으니, 이제 실제 구현부를 살펴보자.

     

    1. 변수

     뭔가 Java 프로그래밍 기본 강의를 듣는 기분이지만, 먼저 컨트랙트에 있는 변수들부터 살펴보면 좋을 것 같다.

    contract UniswapV2Factory is IUniswapV2Factory {
        address public feeTo;
        address public feeToSetter;
    
        mapping(address => mapping(address => address)) public getPair;
        address[] public allPairs;
        
        // ...

    (티스토리 기본 코드블럭에 Solidity가 없네 ㅠ_ㅠ)

     먼저 address 타입의 feeTo, feeToSetter가 존재한다. 근데 컨트랙트 전체를 살펴보면 알겠지만, feeTo함수는 구현이 안돼있다. 주변에 물어보니 아직 구현이 안됐다는 답변을 받았다. 유니스왑에서 거래 수수료의 일부를 자기쪽으로 보낼 것이라고 하는데, 검색해봤는데 Protocol Charge Calculation 이라고한다.
     여기도 아직 구현이 안됐다고 언급하고 있고, 나중에 0.05%의 프로토콜 단위의 수수료를 도입할 것이라고 한다. 그러나 이 수수료는 스왑 프로토콜을 이용하는 트레이더들에게 부과되는 것이 아닌, 유동성을 공급하거나 해제할때만 부과할 것이라고 한다. (트레이드 할 때 가스비 더 들지 않기 위해서라고...)

     즉, 정리하자면 현재 구현이 안돼있지만 언젠가 Uniswap에 LP들이 받는 수익에서 0.05%를 feeTo 컨트랙트로 보내는 것이다. 그리고 그 feeTo를 설정할 수 있는 주소(관리자)가 feeToSetter이다.

     getPair 라는 변수는 이더리움 address를 key, map(address => address)를 value로 갖는 map이다. (map은 키-밸류로 이루어진 튜플 형식의 변수를 뜻한다. 간단히 표를 생각하면 편하다.) 중첩된 map 변수인데, 처음 봤을때는 뭐하는 변수인지 잘 몰랐지만 함수에서 쓰임새를 살펴보니 erc20 토큰 두 개를 key로 순서대로 입력하면 pair 컨트랙트 주소가 나오는 변수였다. (즉 token A address => (token B address => pair address) 모양이라고 생각하면 될 것 같다. 글로 설명하려니 어렵다...)

    이렇게 2중 표 형태의 변수라고 생각하면 편할 것 같다.

     

     allPairs 변수는 address의 배열로, 모든 pair contract 주소의 모음이라고 생각하면 될 것 같다.

    요런 느낌이라고 생각하면 편할 듯...

     

    2. 함수

     그 다음 Factory 컨트랙트의 함수를 한번 살펴보자. 우선 feeTo는 미구현이므로 컨트랙트에 존재하지 않는다.

        function allPairsLength() external view returns (uint) {
            return allPairs.length;
        }

     allPairsLength는 변수에서 살펴본 allPairs 리스트의 길이를 나타낸다. 즉, Uniswap에서 지원하는 모든 pair 컨트랙트의 개수를 뜻한다.

     

    function createPair(address tokenA, address tokenB) external returns (address pair) {
            require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
            (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
            require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
            require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient
            bytes memory bytecode = type(UniswapV2Pair).creationCode;
            bytes32 salt = keccak256(abi.encodePacked(token0, token1));
            assembly {
                pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
            }
            IUniswapV2Pair(pair).initialize(token0, token1);
            getPair[token0][token1] = pair;
            getPair[token1][token0] = pair; // populate mapping in the reverse direction
            allPairs.push(pair);
            emit PairCreated(token0, token1, pair, allPairs.length);
        }

     createPair는 이 컨트랙트의 꽃과 같은 함수인데, 이름만 봐도 알 수 있듯이 새로운 토큰 Pair를 생성하는 함수이다.
    로직을 살펴보면 다음과 같이 작동한다는 것을 알 수 있다.

    • line 1, 첫 번째 require문 : tokenA의 주소와 tokenB가 다른지 여부를 체크한다. (같은 토큰이면 실패)

    • line 2, address 형태의 token0, token1를 생성하고 tokenA와 tokenB를 작은 것 부터 대입한다.
      예를 들어 tokenA의 주소가 0, tokenB의 주소가 1이면 token0 = tokenA, token1 = tokenB가 된다.

    • line 3, 두 번째 require문 : token0가 zero address가 아닌지 체크한다. (zero address면 실패)

    • line 4, getPair 변수에 tokenA, tokenB를 key로 하여 value가 존재한다면, 이미 Uniswap에 존재하는 토큰 페어로 인식하여 실패한다. 즉, 처음으로 만들어지는 토큰 페어여야만 한다.

    • line 5, UniSwapV2Pair의 byte code를 가져온다. UniSwapV2Pair는 Core에 존재하는 또 다른 컨트랙트이며, 혹시 byte code가 뭔지 모르겠다면 Solidity로 만든 컨트랙트를 이더리움 네트워크가 인식할 수 있게 번역한(컴파일 생각하면 됨) 언어다.

    • line 6 - line 10, 두 주소 token 0, token 1를 encodePacked(정확히 어떤 연산인지는 더 알아봐야겠으나, solidity docs를 살펴보니 여러 데이터를 압축하는 기법)하여 압축한 뒤 keccak256 함수를 이용해 해시한다.
    •  line 7, 오우,, 무슨 인라인 어셈블리 함수가 있는데 아마 이더리움 op code중 하나인 create2 코드를 솔리디티 단에서 사용하기 위해 쓴 코드인 듯 하다. op code에 대해서는 거의 모르는 수준이라 검색을 해보니, create op code는 컨트랙트를 배포하는 코드이고, 매개 변수와 주소를 생성하는 방법이 다른 듯 하다.

       op code 내용까지는 솔직히 생략해도 상관없는데 궁금해서 좀 더 찾아봤다. 기존에 새로 컨트랙트를 배포할 떄는 create opcode 를 사용했는데, 여기에 매개변수로 nonce 값이 들어간다. nonce란 모든 트랜잭션에 할당된 유니크한 번호인데, 블록에 들어오는 순서대로 할당되기 때문에 컨트랙트에 들어갈 nonce값을 예측할 수가 없다. 즉, 새로 만들어질 컨트랙트의 주소를 예측할 수 없다는 뜻인 것 같다.
       반면에 create2에는 nonce가 파라미터로 들어가지 않는다. 함수에서 사용된 예제만 보더라도 4가지 parameter를 모두 직접 계산해서, 새로 만들어질 컨트랙트의 주소를 예측할 수 있다.

       이 방법을 이용해 inline-assembly로 새로운 컨트랙트를 생성하고, 그 주소를 직접 계산한 뒤 initialize 함수까지 실행해주는 것 같다. create를 사용했을 때 어떻게 구현될지 모르겠어서 직접적인 비교는 어렵지만 최소한 한 함수안에서 이 동작들을 atomic하게 실행하는 것은 장점일지도 모르겠다. (혹시 create2의 장점에 대해 더 자세히 아시는 분은 댓글 남겨주세요..)

    • line 11 - 12 : getPair 맵에 token0 - token1, token1 - token 0를 key, 새로 만들어진 pair 컨트랙트 주소를 value로 대입한다. 굳이 키를 바꿔가면서 하는 이유는 뭘 먼저 대입해도 값이 나오도록 하기 위해...

    • line 13 : allPairs 리스트에 새로 만들어진 주소를 넣는다.

    • line 14 : PairCreated 이벤트를 emit한다.

     한번 싹 정리하니까 create2함수를 사용한 이유가 좀 더 명확해진것 같다. 새로 만들어지는 컨트랙트의 주소를 구할 수 있으니, getPair나 allPairs변수에도 즉시 컨트랙트 주소를 넣을 수 있다. 만약 주소를 예측할 수 없는 create를 썼다면, 내가 배포한 컨트랙트 주소를 받아오기 전(트랜잭션이 컨펌돼야 하니까 아마 한참 후?)까지는 이 연산을 할 수 없을 것 같다.

     나머지 함수들은 평범한 setter들이니 굳이 정리할 필요는 없을 것 같다. 즉석에서 컨트랙트 찾아보고 모르는 것은 검색하면서 정리하느라 두서가 너무 없지만, 나도 공부하면서 정리하려고 블로그를 하는거라 ㅠ_ㅠ 혹시 틀린 내용이나 추가적인 설명은 댓글 환영합니다 :)

    댓글

Designed by Tistory.