V2 코드 분석

Uniswap V2의 코드를 분석해보자!

uniswap-v2-core

GitHub - Uniswap/v2-core: 🎛 Core smart contracts of Uniswap V2

contrancts 폴더에 interfaces, libraries, test 폴더는 볼 필요 없다. 밑에 3가지 solidity 코드만 보면 되는데 UniswapV2ERC20.sol 파일은 UNI 토큰에 대한 것이다. 그래서 UniswapV2Factory.sol, UniswapV2Pair.sol 2가지 파일만 보면 된다.

UniswapV2Factory.sol

새로운 pair를 거래하는 컨트랙트를 생성해내고 관리하는 게이트웨이.

createPair

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);
}

새로운 pair 컨트랙트를 만들어내는 함수.

tokenA와 tokenB의 주소를 argument로 받고 있다.

line 1

  • 두 토큰의 주소가 같으면 안 된다.

line 2

  • 두 토큰 주소를 비교해서 A가 크면 B-A pair를 만들고 B가 크면 A-B pair를 만든다.

  • A-B pair와 B-A pair는 같은 것인데 이것을 토큰 주소의 크기로 비교하여 정의한 것이다.

line 3

  • token0의 주소가 0인지 검사한다.

  • 주소가 0인 컨트랙트는 없다.

  • token1은 token0보다 크기 때문에 token1은 검사를 하지 않아도 된다.

line 4

  • 두 토큰 페어에 해당하는 컨트랙트가 이미 있는지 검사한다.

  • factory가 토큰 페어를 관리하는 자료구조를 담고 있는 변수 이름이 getPair이다.

  • 만약 존재한다면 revert시킨다. 이미 존재하는 토큰 페어를 또 만들면 안 되기 때문이다.

line 5

  • UniswapV2Pair의 tokenPair 컨트랙트 코드를 바이트코드로 가져온다.

line 6

  • salt를 하나 만든다.

  • token0, token1의 abi를 keccak256이라고 하는 hash 함수를 돌려서 만들어낸다.

line 7-9

  • create2함수를 가지고 pair 컨트랙트를 만든다.

  • 어셈블리어로 선언이 되어 있다.

line 10

  • token0과 token1을 초기화 시켜준다.

  • 이제부터 이 pair는 token0와 token1을 거래할 수 있는 풀이 된다.

line 11-12

  • getPair는 token exchange contract의 주소를 담고 있는 자료구조이다.

  • token0, token1에 해당하는 요소에 pair를 넣어준다.

  • 안정성을 위해 모든 조합에 대해 다 넣어주는 것 같다.

line 13

  • allPairs에 새로 생성한 pair를 푸시해준다.

line 14

  • 새로 pair를 만들었다는 로그를 남겨준다.

V2에서 새로 생긴 것이 fee 중에 0.05%를 가져오는 것이다. 그것을 위해서 feeTo 라고 하는 address 변수를 넣어놨다. V2 백서에서 봤던 공식에 의해서 이 변수에게 토큰 컨트랙트에서 거래되는 수수료의 일부가 가게 된다.

UniswapV2Pair.sol

실제로 두 토큰을 거래하는 풀이자 거래소.

위의 UniswapV2Factory.sol 에서 생성한 V2Pair를 알아보자.

constructor

constructor() public {
    factory = msg.sender;
}

constructor에 factory 주소를 넣어준다.

msg.sender는 트랜잭션을 보낸 address를 나타낸다.

factory가 v2pair를 생성했기 때문에 msg.sender가 factory 주소를 받아온다.

initialize

// called once by the factory at time of deployment
function initialize(address _token0, address _token1) external {
    require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
    token0 = _token0;
    token1 = _token1;
}

Facotry에서 호출했다.

token0와 token1에 각각의 argument를 대입해준다.

_update

// 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
    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;
    }
    reserve0 = uint112(balance0);
    reserve1 = uint112(balance1);
    blockTimestampLast = blockTimestamp;
    emit Sync(reserve0, reserve1);
}

컨트랙트는 자기가 얼마를 들고 있는지 계속해서 추적한다. 하지만 그 추적이 항상 맞지는 않다.

실제 잔고는 token0와 token1 컨트랙트에 있고 그 잔고에 대한 정보는 이 컨트랙트에 가야 볼 수 있다. 내가 지금 기록하고 있는 토큰의 수량보다 토큰 컨트랙트에 있는 수량이 더 많다면 내가 돈을 받았다는 것을 알 수 있다. (이 토큰 풀에 토큰이 들어왔다는 것을 알 수 있다. - 입금)

그 정보를 보고 업데이트 해주는 것이 _update 함수이다.

line 2 - 8

  • blockTimestamp, timeElapsed, price0CumulativeLast, price1CumulativeLast 변수들을 모종의 로직으로 바꾸어주고 있다.

  • 이것이 Uniswap을 price oracle로 쓰기 위한 로직이다.

  • 각각의 price들이 유지된 시간의 가중치만큼 더해서 평균을 낸 후 현재 가격을 결정한다.

  • price0CumulativeLast, price1CumulativeLast가 public으로 선언되어 있기 때문에 Uniswap을 price oracle로 사용하고 싶다면 저 두 변수를 보면 된다.

_mintfee

// if fee is on, mint liquidity equivalent to 1/6th of the growth in sqrt(k)
function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {
    address feeTo = IUniswapV2Factory(factory).feeTo();
    feeOn = feeTo != address(0);
    uint _kLast = kLast; // gas savings
    if (feeOn) {
        if (_kLast != 0) {
            uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1));
            uint rootKLast = Math.sqrt(_kLast);
            if (rootK > rootKLast) {
                uint numerator = totalSupply.mul(rootK.sub(rootKLast));
                uint denominator = rootK.mul(5).add(rootKLast);
                uint liquidity = numerator / denominator;
                if (liquidity > 0) _mint(feeTo, liquidity);
            }
        }
    } else if (_kLast != 0) {
        kLast = 0;
    }
}

프로토콜 fee를 계산하는 함수이다.

아래 함수에서 ϕ를 1/6을 대입한 공식이다.

mint

// 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);

    bool feeOn = _mintFee(_reserve0, _reserve1);
    uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
    if (_totalSupply == 0) {
        liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
       _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
    } else {
        liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
    }
    require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
    _mint(to, liquidity);

    _update(balance0, balance1, _reserve0, _reserve1);
    if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
    emit Mint(msg.sender, amount0, amount1);
}

LP 토큰을 만들어내는 함수이다.

pair contract에서 기록하고 있는 자산의 양과 해당 token contract에서 기록하고 있는 자산의 양이 다를 수 있다.

만약 토큰 컨트랙트에 있는 내 자산의 양이 더 많으면 mint시켜줘야 한다. 반대로 더 적다면 burn을 시켜줘야 한다.

line 1

  • 내 pair contract에서 기록하고 있는 잔고

line 2 - 3

  • token0, token1 컨트랙트에서 잔고를 받아옴.

line 4 - 5

  • 받아온 잔고에서 기록하고 있는 잔고를 빼서 내가 받은 양(amount)를 계산한다.

line 7

  • 수수료를 계산한다.

line 8 - 14

  • totalSupply가 0이라는 것은 처음 이 컨트랙트에 pair를 넣는 사람이라는 것이다. 이때에는 LP share의 최소 단위 즉 1LP 토큰의 천배에 해당하는 돈이 burn된다. address 0 에게 생성하지만 address 0 는 존재하지 않기 때문에 이 돈은 그냥 묶이게 된다.(burn) 이전 백서에서 봤던 공식에 따라 liquidity가 정해진다.

  • totalSupply가 0이 아니면 인플레이션을 시키게 된다. 이전 백서에서 봤던 공식에 따라 정해진다.

line 16

  • 지금 돈을 넣은 사람(to 주소)에게 liquidity(LP token)을 준다.

line 18 - 19

  • 값들을 업데이트 해준다.

line 20

  • 민트했다고 로그를 남긴다.

burn

// this low-level function should be called from a contract which performs important safety checks
function burn(address to) external lock returns (uint amount0, uint amount1) {
    (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
    address _token0 = token0;                                // gas savings
    address _token1 = token1;                                // gas savings
    uint balance0 = IERC20(_token0).balanceOf(address(this));
    uint balance1 = IERC20(_token1).balanceOf(address(this));
    uint liquidity = balanceOf[address(this)];

    bool feeOn = _mintFee(_reserve0, _reserve1);
    uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
    amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
    amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
    require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED');
    _burn(address(this), liquidity);
    _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
    emit Burn(msg.sender, amount0, amount1, to);
}

line 1 - 6

  • mint와 마찬가지로 잔고 정보를 가져온다.

line 10 - 11

  • 이 사람이 지금 태우는 liquidity의 비율에 해당하는 각 토큰의 amount 만큼을 태우는 사람이 받게 된다.

line 14 - 15

  • LP token을 전송해준다.

line 16 - 20

  • 변경된 값들이 업데이트 된다.

swap

// this low-level function should be called from a contract which performs important safety checks
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
    require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
    (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
    require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');

    uint balance0;
    uint balance1;
    { // scope for _token{0,1}, avoids stack too deep errors
    address _token0 = token0;
    address _token1 = token1;
    require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
    if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
    if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
    if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
    balance0 = IERC20(_token0).balanceOf(address(this));
    balance1 = IERC20(_token1).balanceOf(address(this));
    }
    uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
    uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
    require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
    { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
    uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
    uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
    require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
    }

    _update(balance0, balance1, _reserve0, _reserve1);
    emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}

두 토큰을 교환하는 함수이다.

보통 교환을 생각하면 지불하는 토큰과 받는 토큰이 있으면 된다고 생각하는데 이 함수는 넣는 돈(in)은 없고 out만 2개가 있다.

flash loan이라고 해서 암호화폐 대출 서비스인 Aave나 Compound에서 토큰을 먼저 빌린 다음에 그 토큰을 swap하고 swap한 토큰을 다른 곳에 집어 넣어 가지고 차액을 얻은 다음 다시 Aave나 Compound에 반납하는 그런 식의 작업을 한 트랜잭션 내에서 atomic하게 수행하는 경우가 있다.

Uniswap에서도 flash loan을 쓸 수 있도록 구현해놓은 것이다.

in은 이 스왑함수를 실행시키기 전에 pair 컨트랙트의 token 주소로 먼저 교환하고자 하는 토큰을 집어넣어야 한다. 그 후에 이 swap 함수를 부른다.

백서상으로 보면 in에 해당하는 argument가 없으니까 돈을 누가 얼마나 보냈는지를 알기 힘들어서 프라이버시를 지킬 수 있다고 한다.

line 1 - 10

  • 값들을 받아오고 검사한다.

line 11 - 12

  • 토큰0, 1을 amount0, 1만큼 보낸다.

line 13

  • 뭔가 데이터가 있다면 UniswapV2Call이라는 함수를 이용해서 콜 해준다.

  • Uniswap을 실행한 후에 다른 무언가를 실행하게 만들 수가 있다. 그럴 때 저 데이터에 값을 넣어서 이 함수로 부를 수가 있다.

  • 예를 들면 Aave나 Compound에 대출을 상환하는 코드가 될 수 있다.

line 14 - 15

  • 이 사람이 돈을 넣었는지 확인하기 위해 balance를 받아온다.

line 17 - 18

  • in을 계산한다.

  • 새로운 balance보다 원래 가지고 있던 reserve에서 내가 다른데 보낸 돈(amountOut)을 뺀 것이 작으면 balance를 조정해준다.

  • 그 반대이면 amount가 잘못된 것이다. 보낼 수 있는 양보다 더 크게 된 것이다. 그런 경우 0을 대입한다.

line 19

  • amountIn이 둘 중 하나라도 0이면 ‘입금액이 부족함’이라고 해서 취소된다.

line 21 - 22

  • balance를 조정한다.

line 23

  • 0.3% 수수료를 낼 수 있는지 확인한다.

skim

// force balances to match reserves
function skim(address to) external lock {
    address _token0 = token0; // gas savings
    address _token1 = token1; // gas savings
    _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
    _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}

만약 입금된 토큰이 이 pair 컨트랙트에서 담을 수 있는 토큰의 양을 넘었을 때 남는 여유분을 회수할 수 있게 만들어주는 함수이다.

회수를 하지 않으면 K값이 max로 고정되어 있으니까 로직이 망가진다.

sync

// force reserves to match balances
function sync() external lock {
    _update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1);
}

skim과 반대로 돈이 너무 적은데 토큰간에 imbalance가 생겼을 때 그것을 다시 맞춰주기 위해서 부르는 함수이다.

Routing

Uniswap은 pair로 토큰 풀을 만들고 만약에 교환하고 싶은 토큰이 있으면 그 풀이 있어야 된다. 그래서 Uniswap에는 그 풀의 양이 굉장히 많을 것이다. 그 모든 풀을 만드는 것은 굉장히 비효율적이다.

페어가 만약 존재하지 않으면 직접적인 pair가 없다고 하더라도 그 pair를 만들어 낼 수 있는 루트를 찾는다. 그것을 routing이라고 한다.

예를 들어 SUSHI와 YFI 토큰을 교환하고 싶은데 SUSHI-YFI pair 컨트랙트가 없다면 다른것을 사용한다. SUSHI-WETH pair와 WETH-YFI pair를 이용하여 atomic하게 SUSHI → WETH → YFI 로 교환한다.

수수료는 조금 더 내겠지만 빠르게 교환할 수 있다.

Reference

https://www.youtube.com/watch?v=JfLB3DRwz0I&t=2s

GitHub - Uniswap/v2-core: 🎛 Core smart contracts of Uniswap V2

탈중앙 금융에서 플래시 론이란 무엇인가요? | Binance Academy

플래시 론 공격 (Flash Loan Attack)원리 및 방법 분석

Last updated