체인의정석

UniswapV2 정리 - UniswapV2Pair & UniswapV2ERC20 컨트렉트 본문

블록체인/디파이

UniswapV2 정리 - UniswapV2Pair & UniswapV2ERC20 컨트렉트

체인의정석 2022. 10. 24. 19:07
728x90
반응형

https://github.com/Uniswap/v2-core/tree/master/contracts

 

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

🎛 Core smart contracts of Uniswap V2. Contribute to Uniswap/v2-core development by creating an account on GitHub.

github.com

참고) 수수료 관련해서는 현재 기준 LP 풀이 0.3%를 전부 가져간다고 한다.

트위터에서  피드백을 주신분이 있어서 알 수 있었다. (감사합니다!)
https://twitter.com/Ingtellect/status/1585277179276845058

1. UniswapV2ERC20.sol

 

트위터에서 즐기는 ingtellect🐞🌑

“@stone_chain 현재 유니스왑 v2는 0.3%를 모두 lp가 가져갑니다. https://t.co/Uq9TuQQEPT feeTo 가 0인것을 확인 하실 수 있습니다.”

twitter.com

ERC20에 permit을 추가한 것.

ERC20 과 다른점은 approve가 무한 일때  allwoance를 차감하지 않는 다는 점

    function transferFrom(address from, address to, uint value) external returns (bool) {
        if (allowance[from][msg.sender] != uint(-1)) {
            allowance[from][msg.sender] = allowance[from][msg.sender].sub(value);
        }
        _transfer(from, to, value);
        return true;
    }

allowance 차감의 조건에 uint(-1)를 하는것을 통해 볼 수 있다.

 

그리고 Permit을 썼다는 점이다.

    bytes32 public DOMAIN_SEPARATOR;
    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
    mapping(address => uint) public nonces;
    
    
    .
    .
    .
    
    constructor() public {
        uint chainId;
        assembly {
            chainId := chainid
        }
        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
                keccak256(bytes(name)),
                keccak256(bytes('1')),
                chainId,
                address(this)
            )
        );
    }

일단 EIP712 서명을 미리 받아 놨다가 permit을 실행시키는 로직이 들어가기 때문에 EIP721에 관련된 부분이 정의된 것을 확인할 수 있었다.

 

    function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
        require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
        bytes32 digest = keccak256(
            abi.encodePacked(
                '\x19\x01',
                DOMAIN_SEPARATOR,
                keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
            )
        );
        address recoveredAddress = ecrecover(digest, v, r, s);
        require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
        _approve(owner, spender, value);
    }

지정해 둔 서명 정보 및 데드라인 등의 값을 입력받은 후에 digest를 써서 EIP712 서명을 한번 생성하고 이를 통하여 원래의 지갑 주소를 ecrecover를 통해 도출해 낸다. 

 

그래서 만약 도출해낸 지갑 주소가 owner라면 (여기서 owner는 approve를 해주는 즉 토큰의 소유주의 지갑 주소를 의미한다.) approve를 해주는 것이다. 이렇게 permit을 했을 때 달라지는 점은 원래는 유저가 직접 approve를 해준 후 transferFrom에 서명을 하는 2번의 서명이 필요했지만 이제는 permit을 통하여 유저가 approve서명을 하지 않아도 다른 주체가 직접 서명할  수 있다는 점이다. 대신 유저가 미리 eip712 서명을 해둔 내용에 대해서만 서비스가 approve를 할 수 있어 안정성은 보장되는 경우이다.

 

2. Uniswap V2 Pair.sol

uniswapV2ERC20을 상속받아서 사용하는 컨트렉트로 토큰 0 와 토큰 1의 관계를 통하여 예치, 출금, 스왑 등을 하는 함수이다. 소위 말하는 LP 토큰이 여기에 해당된다.

 

먼저 

    uint public constant MINIMUM_LIQUIDITY = 10**3;

이 부분은 반올림 과정에서 나오는 오차를 없애기 위해서 소각시키는 값으로 10**18 wei를 놓고 보면 매우 작은 차이만 있기 때문에 위와 같은 작은 값은 버려도 큰 문제는 나오지 않는다고 한다. 그냥 솔리디티에서는 소수점 표시가 안되니 이런식으로 값을 버리는게 계산에 맞아서 이렇게 하는 것 같다.

 

    bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)')));

function selector 부분인데 추후 다른 컨트렉트로 인터널 트랜잭션을 보낼 때 사용된다.

 

    address public factory;
    address public token0;
    address public token1;

컨트렉트를 생성한 팩토리의 주소 그리고 유동성 풀을 차지하는 각 토큰 2개의 주소가 위의 주소 값들이다.

 

    uint112 private reserve0;           // uses single storage slot, accessible via getReserves
    uint112 private reserve1;           // uses single storage slot, accessible via getReserves
    uint32  private blockTimestampLast; // uses single storage slot, accessible via getReserves
    
    .
    .
    .
    .
    
    function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }

위의 reserve0,1 은 이전에 업데이트 된 각 토큰의 잔고 이며, 업데이트 된 시점이 blockTimestampLast 이다.

여기서 재밌는 점은 3개의 변수를 합치면 256 바이트가 나온다는 점인데 이렇게 하면 한번에 uint256까지 다뤄서 호출이 되게 되므로 호출 시에 하나의 스토리지 슬롯을 사용함으로서 가스비를 절감할 수 있게 된다.

 

    uint public price0CumulativeLast;
    uint public price1CumulativeLast;
    uint public kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event

public으로 선언된 값들은 가격 정보로 사용하기 위한 값으로 선언 됩니다. 또한 kLast는 x*y=k 에서 k값을 의미합니다. 가장 최근에 발생한 유동성에 따른 k 값이 표시됩니다.

 

    uint private unlocked = 1;
    modifier lock() {
        require(unlocked == 1, 'UniswapV2: LOCKED');
        unlocked = 0;
        _;
        unlocked = 1;
    }

요부분은 Reentrancy Guard와 같은 맥락으로 사용되는 부분이므로 패스

 

    function _safeTransfer(address token, address to, uint value) private {
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(SELECTOR, to, value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), 'UniswapV2: TRANSFER_FAILED');
    }

아까 위에서 function Selector를 정의해서 Transfe가 되도록 만든 부분이 있는데 이걸 사용하는 부분이 이것.

call을 사용하며 응답값이 없거나 true가 오지 않으면 에러가 발생하도록 만든다. 이 부분때문에 재진입 가드를 넣은 것 같다.

 

 

    constructor() public {
        factory = msg.sender;
    }
    
    // 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;
    }

그 다음 이벤트를 정의한 부분을 지나고 나면 생성자가 나오는데 이 컨트렉트는 다음에 살펴볼 Factory에서 만들어 준다고 한다.

따라서 msg.sender가 Factory 컨트렉트의 주소가 된다. Factory의 경우 Initialize를 사용하여 토큰 주소값을 받아와 초기변수 설정을 해주기 때문에 initialize도 필요하다.

2-1.  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);
    }

update의 경우 각 토큰에 대한 잔고와 reserve를 업데이트 하는 함수로서 여기서는 가스비를 절약하기 위하여 112 바이트로 reserve를 다룹니다. 112 바이트로 오버플로우가 발생하는지 먼저 체크를 합니다. 그 후 timeElapsed를 통해서 경과된 시간을 체크한다.

 

만약 시간이 경과하여서 reserve가 발생했을 경우 위에서 선언했던 price0CumulativeLast 와 1을 각각 계산한다.

이 부분을 이해하기 위해

https://docs.uniswap.org/protocol/V2/concepts/advanced-topics/understanding-returns

 

Understanding Returns | Uniswap

Uniswap incentivizes users to add liquidity to trading pools by rewarding providers with the fees generated when other users trade with those pools. Market making, in general, is a complex activity. There is a risk of losing money during large and sustaine

docs.uniswap.org

 

위의 링크를 살펴본 결과

eth_price = token_liquidity_pool / eth_liquidity_pool

요런 식을 확인할 수 있었는데 이를 통해서 각각 이더리움(token0)와 토큰(token1)의 가격을 각각 구해주는 것 같다.

그리고 타입스탬프를 최신화 시킨 후 이벤트 로그를 남기는 것을 볼 수 있었다.

2-2.  mint fee

다음 함수로 mint fee를 살펴볼 수 있는데

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

이부분은 Factory에서 지정한 feeTo를 가져와서 적용하는데 여기는 수식이 좀 들어간 부분이다. 근데 문제는 수수료를 각 토큰으로 걷는 것이 아니라 LP 토큰으로 지불한다고 한다. 그래서 수식이 들어간 부분은 수수료로 지불할 LP 토큰을 구하는 부분이다.

유니스왑이 걷어가는 0.3%의 수수료 중 0.25%는 유동성 공급자들이 가져가고, 프로토콜이 받아가는 수수료의 비중은 0.05% 이므로 

1/6이 되며, 수수료를 계산하는 식은  백서에 따르면

 

https://uniswap.org/whitepaper.pdf

 

위와 같이 표현 될 수 있다. 일단 전체 K 상수가 k1에서 k2로 인상됨에 따라서 총 수수료는 증가율인 f1,2와 같이 나타낼 수 있다.

여기서 저 동그라미에 막대기가 꽂힌게 1/6을 의미한다고 보면 수수료 증가율의 1/6이 프로토콜이 가져가는 수수료이며 이는 증가 전 전체 LP토큰 총량인 Sm에 증가 후 LP 토큰 총량인 Sm + S1을 더한 값이다.

 

요걸 다시 풀어서 계산하면 위와 같이 정리가 된다고 한다.

그리고 1/6을 적용하면 위와 같이 되기 때문에 이 부분을 수치화 한 값이라고 한다.

 

                    uint numerator = totalSupply.mul(rootK.sub(rootKLast));
                    uint denominator = rootK.mul(5).add(rootKLast);
                    uint liquidity = numerator / denominator;

요거랑 수식이  같다.

 

마지막 단계는 지금 종이와 팬이 없어서 전개가 안되는데 나중에 팬이 생기면 한번 수식으로 풀어봐야겠다.

 

아무튼 이부분은 프로토콜 수수료를받아가는 부분이다.

 

 

2-3.  mint

다음은 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 토큰에 있는 token 0, 1의 잔고를 각각 정의한 후 reserve 만큼을 뺀 만큼을 amount 0,1로 정의한다 이 부분은 증가량을 계산한 것이다.

그리고 mint Fee를 써서 프로토콜 수수료를 받아오고 가스를 절약하기 위하여 스토리지에서 변수를 빼줄 때 모두 변수로 담아 메모리로 담은 후에 메모리에서 불러와서 사용하는 최적화 방식을 사용한다.

 

만약 풀에 totalSupply가 0이라면 루트K값에서 최소 유동성 값 만큼을 차감한다. 그리고 1000만큼의 값은 0번 주소로 mint해 버린다. 이렇게 해야 소수점 처리에서 오는 문제가 없어진다고 한다.

 

그게 아니라면 두 토큰 중 변동량이 더 작은 토큰을 기준으로 유동성을 산정한 후 mint를 하게 된다.

(두 증가식은 사실상 비슷한 값이 나오는 것을 가정하기 때문에 두 값 중 더 작은 값을 선택한다고 한다.)

유동성을 공급할때 반반 넣는 형태를 취하기 때문인것 같다.

 

해당 작업이 끝나면 이후에는 증가한 유동성 만큼 lp 토큰을 mint 해주면서 update를 통해서 한번에 변수들을 업데이트 해준다.

또한 수수료가 있을 경우 KLast값을 업데이트 시켜준다.

 

2-4.  burn

burn 함수는 LP 토큰을 소각하고 유동성을 제공할때의 자산을 다시 돌려받는 역할을 한다.

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

앞부분은 모두 비슷한 과정이다.

 

balance를 가져와서 각 토큰의 잔고를 계산한 후

컨트렉트에 있는 총 토큰의 양을 liquidity로 지정한다.

만약 fee가 있는 상황이라면 fee 만큼을 민트 하고 프로토콜이 가져간다.

 

그 이후 각 토큰에 대해서 증가했던 양은 유동성 * (잔고 / 총공급량)으로 각각 계산 하여 보내주며 유동성 수치만큼은 소각시킨다.

 

그리고 마지막으로 결과 값을 업데이트 해준다.

 

 

2-5.  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);
    }

swap은 out에 대한 부분만 다루지만 코드에 amount In이 들어가 있음을 전제로 하고 검산을 진행한다.

 

일단 토큰을 인출해주는 주는 로직은 (amount Out)부분으로 입력값이 들어간 만큼 해당 주소에게 각 토큰을 전송해 준다.

 

이후 토큰을 조정해주는 amountIn 에 대한 부분이 있는데 이 부분에서는 잔액의 변화를 체크한다. 만약 swap 전에 amount in이 있었다면 해당하는 자산에는 그 amount만큼을 들어오게 하고 없었다면 오류가 난다. (INSUFFICIENT_INPUT_AMOUNT)

무조건 한쪽에서는 값이 들어와야 다른쪽으로 값을 보내주는 로직이 작동하는 것이다.

 

adjusted 부분은 3/1000 즉 0.03%의 수수료를 통해서 수수료를 계산한 후에

새로 계산한 값이 과거의 K 값 이상인지 마지막으로 점검을 한다.

 

그런 후 update를 진행한다.

 

 

2-6.  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));
    }

만약 잔액과 reserve 정보에 미묘한 오차가 발생할 시 잔액이 더 많은 경우에 reserve오차 만큼을 빼서 값을 맞춰주는 함수이다.

 

2-7.  Sync

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

reserve가 잔액 정보를 따라가지 못하고 있는 경우 reserve가 blance에 맞도록 값을 업데이트 하는 것은 sync이다.

 

Factory는 다음 글에서 살펴보도록하겠다.

728x90
반응형
Comments