Uniswap V2 Whitepaper 톺아보기

· Blockchain

친구 배가놈과 블록체인 스터디를 시작했다. 뭘 공부해야하나 막막했는데 친구가 다양한 프로토콜이나 유명한 디파이 서비스들을 하나하나 공부해보자고 제안했다.

첫 주제로는 Uniswap. 블록체인을 잘 모르는 나에게는 디파이의 상징과도 같은 느낌을 주는 서비스. 백서와 컨트랙트 코드를 읽고 나름대로 간단하게 정리해보았다.

백서 요약

ERC-20 Pairs

Uniswap V1에서는 ETH와 ERC-20 토큰 간의 페어를 만들어 지원했다. 다시 말해 ERC-20토큰 간의 거래를 하려면 ETH를 한 번 거쳐서 거래를 해야 했다는 뜻이다. 이 방식은 아마도 수수료 문제도 있을 것이고, 거래를 수행하는 사이에 가격 변동이 발생하면 예기치 못한 손실을 볼 위험도 존재했을 것이다.

그래서 V2에서는 ERC-20과 ERC-20간의 페어로 동작하게 변경되었다. 그런데 이렇게 되면 ETH는 사용할 수가 없으니 ETH를 ERC-20으로 Wrapping한 WETH 형태로 만들어 사용한다.

Price Oracle

유니스왑에서 거래되는 토큰의 가격 정보들을 제공할 수 있도록 만든 것이다. 다른 컨트랙트들은 유니스왑의 Price Oracle을 사용할 수 있다. 그런데 유니스왑이 공격 (유동성 풀에 대한 악의적인 조작 등) 받게 되면 앞서 말한 유니스왑 Price Oracle을 사용하는 다른 컨트랙트들에도 영향을 미치게 된다.

그래서 유니스왑의 가격 정보를 바로 Price Oracle로 반영하지 않고, 전체 시간에서 각 가격 별로 그 가격이 유지된 시간만큼을 곱해서 평균을 내 가격을 결정한다. 급격한 가격 변동을 막기 위한 보호 장치인 셈이다.

가격 표현

Solidity는 non-integer(실수)를 표현할 수가 없어서, 256비트 데이터를 자료형으로 활용하는데, 112비트는 정수 부분, 나머지 112비트는 실수 부분을 의미하는 듯하고, 나머지 32비트는 Timestamp로 사용하는 것으로 보인다.

Protocol Fee

유니스왑의 유동성 풀에 유동성을 넣고 뺄 때(deposit, withdraw) 발생하게 된다. Protocol Fee는 LP 토큰 풀의 증가량에 비례해서 부과한다.

식 1

식 2

식 3

식 4

식 5

그리고 이 Protocol Fee는 FactoryContract 안에 있는 feeTo라는 특별한 어드레스로 송금된다. feeTo 어드레스가 setting되어 있지 않다면, fee는 수집되지 않고, 이 Protocol Fee 기능이 켜졌다 꺼졌다 할 수 있는 것 같다. (불확실하다. 원문은 that can be turned on and off이라고 한다)

LP 토큰 공급

일단 LP 토큰이란 유니스왑 페어의 유동성 풀을 유지시키기 위해서 풀에 토큰을 공급해주는 유동성 공급자들에게 “토큰을 예치시킨 것을 인증”해주는 증서 역할을 하는 토큰을 말한다.

그럼 이제 LP 토큰이 얼마나 발행되는지가 중요한데, 발행량을 계산하는 두 가지 공식이 있다. 첫 번째는 이미 해당 페어의 풀이 존재하는 상황일 때, 두 번째는 새롭게 페어가 생성되어 아직 풀이 존재하지 않을 때이다.

페어가 존재할 때의 LP 토큰 발행 공식

첫 번째, 페어의 풀이 존재하는 상황일 때, 새로운 유동성 공급자가 토큰을 Deposit할 때의 연산 공식이다.

페어가 존재하지 않을 때의 LP 토큰 발행 공식

그럼 이제 두 번째, 페어가 새로 생성되어 처음으로 유동성을 공급하는 상황일 때의 연산 공식이다.

그런데 유동성을 최초 공급할 때는 일정 수치만큼 LP 토큰의 양이 소각된다. 이것도 보호장치의 일종이다. LP 토큰의 구분 가능한 최소 수량은 1e-18개다. 이유는 자료형 때문이다. 그런데 처음부터 너무 큰 유동성 공급을 해버리게 되면, 최소 단위인 1e-18 개의 LP 토큰을 받기 위해 너무 큰 예치금이 필요해지게 된다. 이렇게 되면 소규모 유동성 공급자들에 풀에 토큰을 예치하는게 불가능해지는데, 이를 방지하기 위함이다.

따라서 처음으로 민팅된 LP 토큰의 1e-15만큼의 양을 0번 주소에 민팅시킴으로써 LP 토큰을 Burn시키도록 구현했다. LP 토큰의 가치는 100달러로 만들려면, 공격자는 풀에 약 10만 달러를 기부하게 된다고 한다. 그리고 유동성 풀에는 10만 달러어치가 고정적으로 존재하는 효과가 발생한다. (영어로 읽어서 이 문단이 정확한지는 확신이 안든다.)

코드 요약

중요한 부분만 살펴봤다.

UniswapV2Factory.sol

// 토큰A, 토큰B 주소를 받아서 새로운 페어 컨트랙트를 만드는 함수다.
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;
    // token0과 token1의 ABI로 keccak256 해시함수를 돌려서 salt를 생성한다.
    bytes32 salt = keccak256(abi.encodePacked(token0, token1));
    assembly {
        pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
    }
    // create2 함수로 페어 컨트랙트를 생성한다.
    IUniswapV2Pair(pair).initialize(token0, token1);
    // 생성한 토큰 페어 컨트랙트에 initialize 메소드에 token0, token1을 넣고 실행해준다.
    getPair[token0][token1] = pair;
    getPair[token1][token0] = pair; 
    // getPair에 페어 컨트랙트 주소 넣어준다.
    allPairs.push(pair);
    emit PairCreated(token0, token1, pair, allPairs.length);
    // 페어가 생성되었다는 로그를 남긴다.
}

UniswapV2Pair.sol

// 페어 컨트랙트는 각 토큰 컨트랙트로부터 잔고 정보를 받아와 업데이트 해야 한다.
// 실제 잔고 정보는 token0, token1 컨트랙트에 저장되어 있다.
// 이 컨트랙트가 기록하고 있는 토큰의 양보다 각 토큰 컨트랙트의 실제 잔고가 많다면, 돈이 들어온 것으로 본다.
// update reserves and, on the first call per block, price accumulators
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
    require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');
    uint32 blockTimestamp = uint32(block.timestamp % 2**32);
    uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
    // 유니스왑의 PRICE ORACLE 관련 코드다.
    if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
        // * never overflows, and + overflow is desired
        price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
        price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
        // 각 가격이 유지된 시간의 가중치만큼을 곱해서 평균을 내어 가격을 결정한다.
        // price0CumulativeLast, price1CumulativeLast이 Price Oracle에 사용되는 변수이기 때문에 public 변수인 것이다.
    }
    reserve0 = uint112(balance0);
    reserve1 = uint112(balance1);
    // 잔고를 업데이트시킨다.
    blockTimestampLast = blockTimestamp;
    emit Sync(reserve0, reserve1);
}

다음은 LP토큰 mint 관련 코드를 살펴보았다. burn은 대체적으로 mint의 반대로 동작하고 토큰을 돌려주는 코드가 추가되는 정도 이므로 생략했다.

// LP토큰(LP share)를 만들어내는 함수다.
// 페어 컨트랙트가 가지고 있는 자산의 양과 실제로 토큰 컨트랙트들에 기록되는 자산의 양이 다를 수 있다. 이걸 위에서 설명한 업데이트 함수가 토큰 잔액 정보를 가져오게 되는 것이다.
// 토큰 컨트랙트에 있는 자산의 양이 페어 컨트랙트가 기록해놓은 토큰 자산 양보다 더 많으면 mint가 된다.
// 토큰이 deposit된 것이기 때문에 유동성 공급자에게 LP를 mint해주는 것이다.
// 반대로 토큰 컨트랙트에 있는 자산의 양이 더 적으면 withdraw가 일어난 것이므로 burn이 일어난다.
// this low-level function should be called from a contract which performs important safety checks
function mint(address to) external lock returns (uint liquidity) {
    (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
    uint balance0 = IERC20(token0).balanceOf(address(this));
    uint balance1 = IERC20(token1).balanceOf(address(this));
    // 각 토큰 컨트랙트에서 잔고 정보를 받아온다.
    uint amount0 = balance0.sub(_reserve0);
    uint amount1 = balance1.sub(_reserve1);
    // reserve0, reserve1은 현재 페어 컨트랙트가 가지고 있는 잔고 정보다.
    // 즉, 새로운 잔액 정보에서 reserve를 빼주면 deposit된 금액 정보가 나온다.
            
    bool feeOn = _mintFee(_reserve0, _reserve1);
    // mintFee를 계산한다.
    uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
    if (_totalSupply == 0) {
        // 만약 처음 이 컨트랙트에 풀을 공급하는 사람이라면
        // 보호장치로써 LP토큰이 일부 burn된다. (앞에서 설명한 내용)
        liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
        // 처음 풀에 유동성을 공급할 때 일부 burn된다고 했던 양이 MINIMUM_LIQUIDITY만큼이다.
        _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first 
        MINIMUM_LIQUIDITY tokens
        // MINIMUM_LIQUIDITY만큼의 LP토큰은 0번 주소에 mint해서 burn해버린다.
    } else {
        // 이미 풀에 유동성이 존재한다면, 앞에서 설명한 1번 공식대로 LP토큰의 mint 양이 정해진다.
        liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
    }
    require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
    _mint(to, liquidity); // deposit한 유동성 공급자에게 LP토큰을 mint해준다.

    _update(balance0, balance1, _reserve0, _reserve1);
    if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
    // 유동성이 양쪽 토큰에 공급되었으므로, x*y=k의 k값을 새로 업데이트 해준다.
    emit Mint(msg.sender, amount0, amount1);
    // 민트 이벤트 로그를 남긴다.
}

function burn(address to) external lock returns (uint amount0, uint amount1) {
    ... // 생략
    
    // LP 토큰을 burn시킨다.
    _burn(address(this), liquidity);
    // 소각한 LP토큰만큼의 양에 해당하는 유동성을 다시 돌려준다.
    _safeTransfer(_token0, to, amount0);
    _safeTransfer(_token1, to, amount1);
    balance0 = IERC20(_token0).balanceOf(address(this));
    balance1 = IERC20(_token1).balanceOf(address(this));

    // 토큰 잔고를 업데이트한다.
    _update(balance0, balance1, _reserve0, _reserve1);
    if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
    // 유동성 풀의 크기가 변동되었으므로 x*y=k의 k값을 새로 업데이트 해준다.
    emit Burn(msg.sender, amount0, amount1, to);
    // 번 이벤트 로그를 남긴다.
}