티스토리 뷰

⛓️ 시리즈

2024.01.30 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2ERC20

2024.01.31 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2Factory


🦄 IUniswapV2Pair.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

// 유동성 풀의 토큰 쌍을 나타내는 인터페이스
interface IUniswapV2Pair {
    /*

    // solidity v0.8.0 이상에서는 상속 문제로 인해 UniswapV2ERC20와 충돌이 발생하므로 주석 처리

    event Approval(address indexed owner, address indexed spender, uint value); // Approval 이벤트 (owner가 spender에게 value만큼의 토큰을 인출할 수 있도록 허가)
    event Transfer(address indexed from, address indexed to, uint value); // Transfer 이벤트 (from에서 to로 value만큼의 토큰을 전송)

    function name() external pure returns (string memory); // 토큰 이름 (getter)

    function symbol() external pure returns (string memory); // 토큰 심볼 (getter)

    function decimals() external pure returns (uint8); // 토큰 소수점 자리수 / ether의 경우 18 (getter)

    function totalSupply() external view returns (uint); // 토큰 총 발행량 (getter)

    function balanceOf(address owner) external view returns (uint); // owner의 토큰 잔액 (getter)

    function allowance(
        address owner,
        address spender
    ) external view returns (uint); // owner가 spender에게 인출을 허가한 토큰의 잔액 (getter)

    function approve(address spender, uint value) external returns (bool); // spender에게 value만큼의 토큰을 인출할 수 있도록 허가

    function transfer(address to, uint value) external returns (bool); // to에게 value만큼의 토큰을 전송

    function transferFrom(
        address from,
        address to,
        uint value
    ) external returns (bool); // from에서 to에게 value만큼의 토큰을 전송

    function DOMAIN_SEPARATOR() external view returns (bytes32); // EIP-2612 permit()을 위한 도메인 구분자

    function PERMIT_TYPEHASH() external pure returns (bytes32); // EIP-2612 permit()을 위한 타입 해시

    function nonces(address owner) external view returns (uint); // EIP-2612 permit()을 위한 nonce

    function permit(
        address owner,
        address spender,
        uint value,
        uint deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external; // EIP-2612 permit()을 통한 토큰 인출 허가
    */

    event Mint(address indexed sender, uint amount0, uint amount1); // Mint 이벤트 (sender가 amount0만큼의 token0과 amount1만큼의 token1을 유동성 풀에 추가)
    event Burn(
        address indexed sender,
        uint amount0,
        uint amount1,
        address indexed to
    ); // Burn 이벤트 (sender가 amount0만큼의 token0과 amount1만큼의 token1을 유동성 풀에서 인출)
    event Swap(
        address indexed sender,
        uint amount0In,
        uint amount1In,
        uint amount0Out,
        uint amount1Out,
        address indexed to
    ); // Swap 이벤트 (sender가 amount0In만큼의 token0과 amount1In만큼의 token1을 유동성 풀에 추가하고, amount0Out만큼의 token0과 amount1Out만큼의 token1을 유동성 풀에서 인출)
    event Sync(uint112 reserve0, uint112 reserve1); // Sync 이벤트 (reserve0와 reserve1을 동기화)

    function MINIMUM_LIQUIDITY() external pure returns (uint); // 최소 유동성

    function factory() external view returns (address); // 팩토리 주소 (getter)

    function token0() external view returns (address); // 토큰0 주소 (getter)

    function token1() external view returns (address); // 토큰1 주소 (getter)

    function getReserves()
        external
        view
        returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); // 토큰0과 토큰1의 잔액과 마지막 업데이트 시간 (getter)

    function price0CumulativeLast() external view returns (uint); // 토큰0의 누적 가격 (getter)

    function price1CumulativeLast() external view returns (uint); // 토큰1의 누적 가격 (getter)

    function kLast() external view returns (uint); // 상수 k의 마지막 값 (getter)

    function mint(address to) external returns (uint liquidity); // to에게 토큰을 발행

    function burn(address to) external returns (uint amount0, uint amount1); // to에게 토큰을 인출

    function swap(
        uint amount0Out,
        uint amount1Out,
        address to,
        bytes calldata data
    ) external; // to에게 amount0Out만큼의 token0과 amount1Out만큼의 token1을 일정한 비율로 스왑 (data는 IUniswapV2Callee.uniswapV2Call()을 호출하기 위한 데이터)

    function skim(address to) external; // to에게 토큰을 인출

    function sync() external; // 토큰0과 토큰1의 잔액과 마지막 업데이트 시간을 동기화

    function initialize(address, address) external; // 유동성 풀을 초기화
}

 Uniswap Core에서도 핵심 중의 핵심인지라 내용이 상당히 많습니다. 윗부분에 ERC-20, ERC-2612 관련된 부분을 주석으로 가린 이유는 UniswapV2Pair 컨트랙트에서 IUniswapV2Pair와 UniswapV2ERC20를 같이 상속을 하는 바람에 UniswapV2ERC20에서 이미 구현되어 있는 부분과 충돌이 발생해서입니다.

 

 인터페이스만 봐서는 이게 어떻게 구현되어야 할지 알 수가 없기 때문(개발진이 주석을 안 달아놔서)에 이번에도 인터페이스를 구현한 컨트랙트를 보면서 차근차근 뜯어서 살펴보도록 하겠습니다.


🦄 UniswapV2Pair.sol

👇 전체 코드

더보기
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "./interfaces/IUniswapV2Pair.sol";
import "./UniswapV2ERC20.sol";
import "./libraries/Math.sol";
import "./libraries/UQ112x112.sol";
import "./interfaces/IERC20.sol";
import "./interfaces/IUniswapV2Factory.sol";
import "./interfaces/IUniswapV2Callee.sol";

contract UniswapV2Pair is IUniswapV2Pair, UniswapV2ERC20 {
    using SafeMath for uint;
    using UQ112x112 for uint224;

    uint public constant MINIMUM_LIQUIDITY = 10 ** 3; // 최소 유동성 (1,000)
    bytes4 private constant SELECTOR =
        bytes4(keccak256(bytes("transfer(address,uint256)"))); // transfer 함수의 selector (transfer.selector를 사용할 수도 있음)

    address public factory; // 팩토리 컨트랙트 주소
    address public token0; // 토큰0 주소
    address public token1; // 토큰1 주소

    // 세 개의 상태 변수가 단일 스토리지 슬롯을 사용 (112 + 112 + 32 = 256 bits)
    uint112 private reserve0; // 토큰0의 보유량 (추적된 토큰0의 보유량)
    uint112 private reserve1; // 토큰1의 보유량 (추적된 토큰1의 보유량)
    uint32 private blockTimestampLast; // 마지막 업데이트 시간

    uint public price0CumulativeLast; // 토큰0의 누적 가격
    uint public price1CumulativeLast; // 토큰1의 누적 가격
    uint public kLast; // 마지막으로 유동성 풀이 업데이트된 이후의 상수 k

    // Reentrancy Guard
    uint private unlocked = 1;
    modifier lock() {
        require(unlocked == 1, "UniswapV2: LOCKED");
        unlocked = 0;
        _;
        unlocked = 1;
    }

    // 보유량과 마지막 업데이트 시간을 반환
    function getReserves()
        public
        view
        returns (
            uint112 _reserve0,
            uint112 _reserve1,
            uint32 _blockTimestampLast
        )
    {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }

    // 현 컨트랙트에서 to로 value만큼의 토큰을 안전하게 전송
    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"
        );
    }

    // 생성자
    constructor() UniswapV2ERC20() {
        factory = msg.sender; // 팩토리 컨트랙트 주소를 저장
    }

    // 팩토리 컨트랙트에서 초기화를 위해 한 번만 호출
    function initialize(address _token0, address _token1) external {
        require(msg.sender == factory, "UniswapV2: FORBIDDEN"); // 팩토리 컨트랙트만 호출 가능
        token0 = _token0; // 토큰0 주소를 저장
        token1 = _token1; // 토큰1 주소를 저장
    }

    // 토큰0과 토큰1의 보유량을 업데이트, 블록당 첫 호출 시 가격 누적값도 업데이트
    function _update(
        uint balance0, // 새로운 토큰0의 보유량
        uint balance1, // 새로운 토큰1의 보유량
        uint112 _reserve0, // 기존의 토큰0의 보유량
        uint112 _reserve1 // 기존의 토큰1의 보유량
    ) private {
        require(
            balance0 <= type(uint112).max && balance1 <= type(uint112).max,
            "UniswapV2: OVERFLOW"
        ); // uint112 타입의 최댓값보다 보유량이 크면 에러
        uint32 blockTimestamp = uint32(block.timestamp % 2 ** 32); // unix timestamp를 32비트로 변환
        uint32 timeElapsed = blockTimestamp - blockTimestampLast; // 마지막 업데이트 시간과 현재 블록의 시간 차이
        if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
            // 마지막 업데이트 시간과 현재 블록의 시간 차이가 0보다 크다 = 서로 다른 블록에서의 업데이트
            // 즉, 새로운 블록에서 첫 번째 업데이트인 경우,
            // 토큰0과 토큰1의 보유량이 0이 아니면 가격 누적값을 업데이트 (현 블록에서 처음 호출된 경우)

            // * never overflows, and + overflow is desired (derised? 오히려 좋다는 뜻?)
            price0CumulativeLast +=
                uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) *
                timeElapsed; // price0 = reserve1 / reserve0, price0CumulativeLast += price0 * timeElapsed
            price1CumulativeLast +=
                uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) *
                timeElapsed; // price1 = reserve0 / reserve1, price1CumulativeLast += price1 * timeElapsed
        }
        reserve0 = uint112(balance0); // 토큰0의 보유량을 업데이트
        reserve1 = uint112(balance1); // 토큰1의 보유량을 업데이트
        blockTimestampLast = blockTimestamp; // 현재 블록에서의 업데이트 시간을 업데이트
        emit Sync(reserve0, reserve1); // Sync 이벤트 발생
    }

    // 수수료가 적용되면, 상수 k의 sqrt(k)의 증가량의 1/6에 해당하는 수량의 UniswapV2ERC20 토큰을 수수료 수취인 주소로 전송
    function _mintFee(
        uint112 _reserve0, // 기존의 토큰0의 보유량
        uint112 _reserve1 // 기존의 토큰1의 보유량
    ) private returns (bool feeOn) {
        address feeTo = IUniswapV2Factory(factory).feeTo(); // 수수료 수취인 주소
        feeOn = feeTo != address(0); // 수수료 수취인 주소가 0이 아니면 수수료가 적용되는 것
        uint _kLast = kLast; // kLast를 불러옴
        if (feeOn) {
            // 수수료 수취인 주소가 설정되어 있는 경우에만 수행
            if (_kLast != 0) {
                // 지난번 유동성 변경 이후의 상수 k가 0이 아니면
                uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1)); // rootK = sqrt(reserve0 * reserve1)
                uint rootKLast = Math.sqrt(_kLast); // rootKLast = sqrt(kLast)
                if (rootK > rootKLast) {
                    uint numerator = totalSupply.mul(rootK.sub(rootKLast)); // numerator = totalSupply * (sqrt(reserve0 * reserve1) - sqrt(kLast))
                    uint denominator = rootK.mul(5).add(rootKLast); // denominator = 5 * sqrt(reserve0 * reserve1) + sqrt(kLast)
                    uint liquidity = numerator / denominator; // liquidity = numerator / denominator = totalSupply * (sqrt(reserve0 * reserve1) - sqrt(kLast)) / (5 * sqrt(reserve0 * reserve1) + sqrt(kLast))
                    if (liquidity > 0) _mint(feeTo, liquidity); // 수수료 수취인 주소로 liquidity만큼의 LP토큰을 전송
                }
            }
        } else if (_kLast != 0) {
            // 수수료 수취인 주소가 설정되어 있지 않고, 지난번 유동성 변경 이후의 상수 k가 0이 아니면
            kLast = 0; // 상수 k를 0으로 업데이트
        }
    }

    // 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(); // 추적된 토큰0과 토큰1의 보유량
        uint balance0 = IERC20(token0).balanceOf(address(this)); // 현재 토큰0의 보유량 (새롭게 유동성 풀에 공급된 토큰0의 보유량이 포함될 수 있음)
        uint balance1 = IERC20(token1).balanceOf(address(this)); // 현재 토큰1의 보유량 (새롭게 유동성 풀에 공급된 토큰1의 보유량이 포함될 수 있음)
        uint amount0 = balance0.sub(_reserve0); // 새롭게 유동성 풀에 공급된 토큰0의 보유량
        uint amount1 = balance1.sub(_reserve1); // 새롭게 유동성 풀에 공급된 토큰1의 보유량

        bool feeOn = _mintFee(_reserve0, _reserve1); // 수수료가 적용되는지 확인
        uint _totalSupply = totalSupply; // _mintFee 함수에서 totalSupply를 업데이트할 수 있으므로, 호출하고 난 후에 불러와야 함
        if (_totalSupply == 0) {
            // 유동성 공급이 처음인 경우 (처음 LP 토큰을 발행하는 경우)
            liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); // liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
            _mint(address(0), MINIMUM_LIQUIDITY); // MINIMUM_LIQUIDITY만큼의 UniswapV2ERC20 토큰을 0번 주소로 전송 (영구적으로 잠금)
        } else {
            liquidity = Math.min(
                amount0.mul(_totalSupply) / _reserve0,
                amount1.mul(_totalSupply) / _reserve1
            ); // liquidity = min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1)
        }
        require(liquidity > 0, "UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED"); // 유동성 공급량이 0이면 에러 반환
        _mint(to, liquidity); // to로 liquidity만큼의 LP 토큰을 전송

        _update(balance0, balance1, _reserve0, _reserve1); // 토큰0과 토큰1의 보유량을 업데이트
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // 수수료가 적용되면, 최근 상수 k를 업데이트
        emit Mint(msg.sender, amount0, amount1); // Mint 이벤트 발생
    }

    // 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(); // 추적된 토큰0과 토큰1의 보유량
        address _token0 = token0; // gas savings (토큰0 주소)
        address _token1 = token1; // gas savings (토큰1 주소)
        uint balance0 = IERC20(_token0).balanceOf(address(this)); // 현재 토큰0의 보유량 (추적되지 않은 보유량이 포함될 수 있음)
        uint balance1 = IERC20(_token1).balanceOf(address(this)); // 현재 토큰1의 보유량 (추적되지 않은 보유량이 포함될 수 있음)
        uint liquidity = balanceOf[address(this)]; // 현재 컨트랙트의 LP 토큰 보유량 (= to가 반환한 LP 토큰의 수량)

        bool feeOn = _mintFee(_reserve0, _reserve1); // 수수료가 적용되는지 확인
        uint _totalSupply = totalSupply; // _mintFee 함수에서 수수료가 발행되면 totalSupply가 업데이트될 수 있으므로, 호출하고 난 후에 불러와야 함
        amount0 = liquidity.mul(balance0) / _totalSupply; // 유동성 풀에서 인출할 토큰0의 수량 (pro-rata distribution)
        amount1 = liquidity.mul(balance1) / _totalSupply; // 유동성 풀에서 인출할 토큰1의 수량 (pro-rata distribution)
        require(
            amount0 > 0 && amount1 > 0,
            "UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED"
        ); // 어느 하나라도 0이면 에러
        _burn(address(this), liquidity); // 반환된 LP 토큰을 소각
        _safeTransfer(_token0, to, amount0); // to로 amount0만큼의 토큰0을 전송
        _safeTransfer(_token1, to, amount1); // to로 amount1만큼의 토큰1을 전송
        balance0 = IERC20(_token0).balanceOf(address(this)); // 현재 토큰0의 보유량 (추적되지 않은 보유량이 포함될 수 있음)
        balance1 = IERC20(_token1).balanceOf(address(this)); // 현재 토큰1의 보유량 (추적되지 않은 보유량이 포함될 수 있음)

        _update(balance0, balance1, _reserve0, _reserve1); // 토큰0과 토큰1의 보유량을 업데이트
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0와 reserve1이 업데이트되었으므로, 최근 상수 k를 업데이트
        emit Burn(msg.sender, amount0, amount1, to); // Burn 이벤트 발생
    }

    // this low-level function should be called from a contract which performs important safety checks
    function swap(
        uint amount0Out, // 풀에서 빠져나갈 토큰0의 수량
        uint amount1Out, // 풀에서 빠져나갈 토큰1의 수량
        address to, // 토큰을 전송할 주소
        bytes calldata data // IUniswapV2Callee.uniswapV2Call()을 호출하기 위한 데이터
    ) external lock {
        require(
            amount0Out > 0 || amount1Out > 0,
            "UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT"
        ); // 토큰0과 토큰1의 수량이 모두 0이면 에러
        (uint112 _reserve0, uint112 _reserve1, ) = getReserves(); // 추적된 토큰0과 토큰1의 보유량
        require(
            amount0Out < _reserve0 && amount1Out < _reserve1,
            "UniswapV2: INSUFFICIENT_LIQUIDITY"
        ); // 토큰0과 토큰1의 보유량보다 많은 수량을 빼려고 하면 에러

        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"); // to가 토큰0이나 토큰1의 주소면 에러
            if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // 낙관적으로 토큰0을 to로 전송
            if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // 낙관적으로 토큰1을 to로 전송
            if (data.length > 0)
                IUniswapV2Callee(to).uniswapV2Call(
                    msg.sender,
                    amount0Out,
                    amount1Out,
                    data
                ); // to가 IUniswapV2Callee 인터페이스를 구현한 컨트랙트인 경우, uniswapV2Call() 함수를 호출
            balance0 = IERC20(_token0).balanceOf(address(this)); // 현재 토큰0의 보유량 (swap() 함수 호출로 인해 토큰0의 보유량에 변동이 있을 수 있음)
            balance1 = IERC20(_token1).balanceOf(address(this)); // 현재 토큰1의 보유량 (swap() 함수 호출로 인해 토큰1의 보유량에 변동이 있을 수 있음)
        }
        uint amount0In = balance0 > _reserve0 - amount0Out
            ? balance0 - (_reserve0 - amount0Out)
            : 0; // swap을 통해 유동성 풀에 추가된 토큰0의 수량
        uint amount1In = balance1 > _reserve1 - amount1Out
            ? balance1 - (_reserve1 - amount1Out)
            : 0; // swap을 통해 유동성 풀에 추가된 토큰1의 수량
        require(
            amount0In > 0 || amount1In > 0,
            "UniswapV2: INSUFFICIENT_INPUT_AMOUNT"
        ); // swap을 통해 유동성 풀에 추가된 토큰0과 토큰1의 수량이 하나라도 0이면 에러 반환
        {
            // scope for reserve{0,1}Adjusted, avoids stack too deep errors
            uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3)); // balance0Adjusted = balance0 * 1000 - amount0In * 3
            uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3)); // balance1Adjusted = balance1 * 1000 - amount1In * 3
            require(
                balance0Adjusted.mul(balance1Adjusted) >=
                    uint(_reserve0).mul(_reserve1).mul(1000 ** 2), // balance0Adjusted * balance1Adjusted = (balance0 * 1000 - amount0In * 3) * (balance1 * 1000 - amount1In * 3) >= reserve0 * reserve1 * 1000^2
                "UniswapV2: K"
            ); // 상수 k가 변하지 않도록 체크
        }

        _update(balance0, balance1, _reserve0, _reserve1); // 토큰0과 토큰1의 보유량을 업데이트 (k는 변하지 않음)
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to); // Swap 이벤트 발생
    }

    // 실제 토큰0과 토큰1의 보유량이 추적된 토큰0과 토큰1의 보유량과 일치하도록 남은 토큰을 to로 전송
    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)
        );
    }

    // 추적된 토큰0과 토큰1의 보유량을  토큰0과 토큰1의 보유량으로 업데이트 (추적되지 않은 보유량이 포함될 수 있음)
    function sync() external lock {
        _update(
            IERC20(token0).balanceOf(address(this)),
            IERC20(token1).balanceOf(address(this)),
            reserve0,
            reserve1
        );
    }
}

상수와 상태 변수

uint public constant MINIMUM_LIQUIDITY = 10 ** 3; // 최소 유동성 (1,000)
bytes4 private constant SELECTOR =
    bytes4(keccak256(bytes("transfer(address,uint256)"))); // transfer 함수의 selector (transfer.selector를 사용할 수도 있음)

address public factory; // 팩토리 컨트랙트 주소
address public token0; // 토큰0 주소
address public token1; // 토큰1 주소

// 세 개의 상태 변수가 단일 스토리지 슬롯을 사용 (112 + 112 + 32 = 256 bits)
uint112 private reserve0; // 토큰0의 보유량 (추적된 토큰0의 보유량)
uint112 private reserve1; // 토큰1의 보유량 (추적된 토큰1의 보유량)
uint32 private blockTimestampLast; // 마지막 업데이트 시간

uint public price0CumulativeLast; // 토큰0의 누적 가격
uint public price1CumulativeLast; // 토큰1의 누적 가격
uint public kLast; // 마지막으로 유동성 풀이 업데이트된 이후의 상수 k
  • MINIMUM_LIQUIDITY: 최소 유동성, 자세한 용도는 이후에 살펴보겠습니다.
  • SELECTOR: call 함수에서 ERC20 토큰 컨트랙트의 transfer 함수를 호출하기 위해 필요한 선택자입니다.
  • factory: UniswapV2Pair 인스턴스를 생성한 팩토리 컨트랙트의 주소입니다.
  • token0: 풀에서 관리하는 토큰 0의 주소입니다.
  • token1: 풀에서 관리하는 토큰 1의 주소입니다.
  • reserve0: 풀에서 추적하고 있는 토큰 0의 보유량입니다.
  • reserve1: 풀에서 추적하고 있는 토큰 1의 보유량입니다.
  • blockTimestampLast: swap이 발생한 마지막 업데이트 시간을 기록합니다.
  • price0CumulativeLast: 토큰 0의 블록 단위 누적 가격을 기록합니다.
  • price1CumulativeLast: 토큰 1의 블록 단위 누적 가격을 기록합니다.
  • kLast: 마지막으로 풀이 업데이트된 이후의 reserve0과 reserve1의 곱입니다.

왜 토큰 보유량을 추적하는가?

 왜 reserve0, reserve1을 사용해 보유량을 추적할까요? 단순히 'token0.balanceOf(address(this))'를 사용해서 보유량을 확인할 수 있는데 말입니다.

 

 그 이유는 balanceOf로는 출처를 알 수 없는 토큰이 얼마나 존재하는지 알 수 없기 때문입니다. 출처를 알 수 없는 토큰이란, UniswapV2Pair 컨트랙트의 외부에서 모종의 방법으로 컨트랙트로 주입되는 토큰들을 의미합니다.

 

 아니, 토큰이 많아지면 유동성이 늘어나서 좋은 것 아닌가요? 그렇지만은 않습니다. 한 토큰의 보유량이 갑자기 늘어나게 되면, 쌍으로 묶인 토큰의 가격이 크게 변동될 수 있습니다. 이는 유니 스왑 서비스 그 자체뿐만 아니라 이에 의존하는 굉장히 많은 서비스들에 지대한 영향을 미칠 수 있습니다.

그렇다면 토큰 가격은 어떻게 계산되는가?

 유니 스왑은 CPMM(Constant Product Market Maker)라고 하는 가격 결정 알고리즘을 사용합니다. Constant Product 공식은 다음과 같습니다.

x * y = k
  • x, y: 풀이 보유하고 있는 토큰 0과 토큰 1의 수량 (reserve0, reserve1)
  • k: 각 토큰의 초기 유동성 공급의 곱

 CPMM의 핵심은 이 공식에서 k값이 항상 일정해야 한다는 것입니다.

 

 예를 들어, Alice가 토큰 0 10개를 사용해 토큰 1과 스왑 하고자 합니다. 그렇다면 (x + 10) * y' = k를 만족하는 y'을 구하여 y에서 y'을 뺀 만큼을 Alice에게 전송하면 됩니다.

 

 더 구체적인 예를 들어보겠습니다. (제가 이해가 안 돼서)

 

 1. 유동성 풀에 50개의 시바이누 토큰과 100개의 페페 토큰(코인?)이 들어있습니다.

2. Alice가 10개의 시바이누 토큰을 페페 코인으로 스왑 하려고 합니다.

3. 풀에서는 시바이누 토큰을 10개 받고 상수 k를 유지하기 위해 Alice에게 전송해야 할 페페 코인의 개수 x를 계산합니다. 남겨야 하는 페페 코인의 개수가 83.3333인데 소수점은 올려서 84로 계산하면 x는 16이 됩니다. 따라서 Alice는 10개의 시바이누 토큰으로 16개의 페페 코인을 교환할 수 있습니다.

4. Alice가 다시 한번 10개의 시바이누 토큰을 페페 코인으로 교환하려고 합니다.

5. 그런데 이번에는 페페 코인을 12개를 받았습니다. 지난번에 스왑 할 때는 분명 16개였는데 12개로 줄어들어 버리다니... 나머지 4개의 페페 코인은 어디로 가버린 것일까요?

k 그는 가격의 신인가?

 앞서 사라진 4개의 페페 코인은 사실 사라진 것이 아닙니다. 풀 안에서는 k를 고정시켜 놓고 토큰 쌍의 비율을 저울질합니다. 이 과정에서 이전 스왑을 통해 수량이 늘어나 상대적으로 가치가 떨어진 시바이누 토큰으로 상대적으로 가치가 높아진 페페 코인을 교환하려 했기 때문에 더 적은 양의 페페 코인을 받게 된 것입니다. 이렇듯 수요와 공급에 의해 풀에서의 토큰 가격이 결정됩니다.

출처:&nbsp;https://docs.uniswap.org/contracts/v2/concepts/protocol-overview/how-uniswap-works

 

 그리고 이 유동성 풀은 세상과 격리되어 있는 것처럼 보이지만, 가격의 차이가 발생한다는 것은 결국 그 빈틈을 노리고 차익을 실현하고자 하는 백만의 군세가 존재한다는 뜻입니다. 이로 인해 결과적으로 유동성 풀의 토큰 가격은 시장(거래소 또는 다른 DEX 등)과 균형을 이루게 됩니다.

 

 이것이 바로 수학 알고리즘에 의해 자동으로 시장이 형성된다, Automated.Market.Maker(AMM)!

유동성 풀이란?

 유동성 풀은 스마트 컨트랙트에 모인 자금을 의미합니다. 유동성이라는 단어의 의미를 통해 알 수 있듯이, 유동성 풀을 통해 얼마나 자금이 쉽고 빠르게 오고 갈 수 있는지가 생존 요건입니다.

 만약 시바이누 토큰을 페페 코인으로 스왑 하고 싶은데 충분한 페페 코인이 유동성 풀에 유치되어 있지 않다면 어떨까요?

 사용자들은 뒤도 돌아보지 않고 유동성이 더 큰 풀을 찾아 떠날 것입니다. 즉, 유동성 풀은 유치된 자금이 많을수록, 그리고 사용 규모가 클수록 인기가 많습니다.

유동성은 왜 중요한가?

 앞서 단적인 예로 토큰을 스왑 하려는데 유치된 자금이 부족해서 사용자가 떠나는 경우도 있지만, 충분한 자금이 있음에도 사용자들은 규모가 더 큰 유동성 풀을 선호할 수 있습니다.

 

 이는 거래 위험 요소 중 하나인 '슬리피지' 때문입니다. 슬리피지는 기대 수익과 실현된 수익 사이의 오차를 의미합니다. 슬리피지는 유동성 풀의 규모가 커질수록 줄어듭니다.

 

 예를 들어, 시장에서 1 시바이누 토큰이 2 페페 코인과 동일한 가치를 가진다고 생각해 봅시다.

 

 먼저 X 유동성 풀에 50개의 시바이누 토큰과 100개의 페페 코인이 들어있는 풀입니다.

 

 1. 이번에도 Alice가 10개의 시바이누 토큰을 페페 코인으로 교환하려 합니다. (소수점 아래가 있는 경우 올림)

 2. Alice는 시장에서의 두 토큰의 비율이 1:2이기 때문에 20개의 페페 코인을 받을 것으로 기대합니다.

  3. 그러나 Alice가 받은 것은 16개의 페페 코인입니다. 기대했던 수익에서 4개의 손실이 발생한 것입니다.

 이번에는 Y 유동성 풀에 시바이누 토큰 5000개와 페페 코인 10000개가 들어있는 풀에서 동일한 작업을 실행해 보겠습니다.

 

 1. Alice가 10개의 시바이누 토큰을 페페 코인으로 교환하려 합니다.

 2. 이번에는 페페 코인을 19개를 받았습니다. 기대했던 수익에서 1개만큼의 손해가 발생했지만, 앞서 유동성 풀의 크기가 작았던 경우와 비교하면 그래도 남는 장사입니다.

 

 정리하자면, 유동성 풀의 규모와 슬리피지는 반비례합니다. 규모가 커질수록 슬리피지가 줄어들기 때문에 사용자들은 규모가 큰 풀을 선호할 수밖에 없습니다. 따라서 DEX 서비스들이 최우선으로 고려해야 하는 것이 유동성을 확보하는 것입니다.

유동성은 누가 제공하나요?

 

 자금을 가지고 있다면 누구나 유동성 제공자(Liquidity Provider)가 될 수 있습니다. 그러나 누가 '너 유동성 공급 안 하면 죽어'하고 협박할 수 있는 것도 아니고 유동성 제공자들이 자발적으로 행동에 나서야 하는 이유가 없지 않을까요?

 

 유동성을 공급하면 공급량에 비례한 유동성 토큰(LP Token)을 보상으로 받을 수 있습니다. 유동성 토큰을 보유하고 있으면  지분에 따라 달라지는 수수료 수익을 얻을 수 있습니다. 또한 유동성 토큰을 사용해서 또 다른 투자 수익을 올릴 수도 있고 지분을 파는 등 여러 방법으로 활용할 수 있습니다. 물론 자금을 빼고 싶을 때는 비례하는 유동성 토큰을 반납하고 자금을 인출할 수도 있습니다. 이렇듯 유동성 풀을 관리하는 여러 DEX 서비스에서는 여러 방면으로 활용할 수 있는 유동성 토큰을 유동성 제공자에게 지급하여 활발한 참여를 유도하고 있습니다.

유동성 공급의 위험성은?

 유동성 풀에 공급한 토큰의 가격이 시장 가격과 다름으로 인해 비영구적인 손실(Impermanent Loss)이 발생할 수 있습니다. 손실이 비영구적인 이유는 토큰의 가격이 시시각각 변동하여 손실 또한 변하기 때문입니다.

 

 유동성 풀의 규모는 상수 k로 고정되어 있습니다. x는 토큰 0의 보유량이고 y는 토큰 1의 보유량입니다.

x * y = k

 토큰의 가격은 비율로 계산됩니다. 예를 들어, 시바이누 토큰 50개와 페페 코인 100개가 들어있는 유동성 풀에서는 시바이누 토큰의 가격은 시바이누 토큰 1개당 2 페페 코인이 되는 것이고, 페페 코인 1개의 가격은 0.5 시바이누 토큰이 되는 것입니다.

price_x = y / x
price_y = x / y

 위의 공식들을 사용해서 주어진 토큰의 가격에 대해 토큰의 유동성 공급량을 구하는 공식을 다음과 같이 구할 수 있습니다.

x = k / y = price_y * y
y * y = k / price_y
y = sqrt(k * price_y)

y = k / x = price_x * x
x * x = k / price_x
x = sqrt(x * price_x)

 비영구적 손실이 어떻게 발생하는지 이해하기 위해 예시를 들어보겠습니다. Charlie가 유동성 풀에 500개의 시바이누 토큰과 1000개의 페페 코인을 공급하여 유동성 풀에는 5000개의 시바이누 토큰과 10000개의 페페 코인이 들어있습니다. Charlie는 유동성 풀에 대한 10%의 지분을 가지게 되며, 이에 상응하는 LP 토큰을 지급받습니다.

 몇 차례 거래가 발생하고 나서 유동성 풀에서의 토큰 가격이 1 시바이누 토큰당 3.125 페페 코인으로 변동됩니다.

 이 경우 Charlie의 지분에 따른 유동성 풀에서의 실제 보유량은 400개의 시바이누 토큰과 1250개의 페페 코인으로 변동됩니다.

 손실이 얼마나 발생했는지 한눈에 알아볼 수 있게 시바이누 토큰을 페페 코인으로 치환하여 비교해 보겠습니다. 만약 Charlie가 유동성을 공급하지 않고 토큰을 들고 있었다면 500 * 3.125 + 1000 = 2562.5개의 페페 코인을 가지고 있어야 합니다.

 그런데 유동성을 공급한 경우의 페페 코인의 개수는 400 * 3.125 + 12500 = 2500개로, 62.5개의 차이가 발생합니다. 결과론적인 이야기지만, 손에 들고 있었다면 발생하지 않았을 2.44%의 손실이 발생한 것입니다.

 

 비영구적 손실은 다음 공식을 통해 계산됩니다.

impermanent_loss = 2 * sqrt(price_ratio) / (1 + price_ratio) - 1

출처:&nbsp;https://docs.uniswap.org/contracts/v2/concepts/advanced-topics/understanding-returns

 앞서 예제에서 시바이누 토큰의 가격이 1.5625배 올랐으므로 이를 공식에 대입해 보면,

2 * sqrt(1.5625) / (1 + 1.5625) - 1 = -0.02439

 앞서 계산된 비영구적 손실률과 동일한 결과를 얻을 수 있습니다.

 

 비영구적 손실이 발생함에도 불구하고, 유동성 공급자들은 거래 수수료를 통해 손실을 메꿀 수 있습니다. 거래가 활발한 유동성 풀 같은 경우는 오히려 수수료로 더 큰 이윤을 남길 수도 있습니다. 또한 거래소에 따라 유동성 공급에 대한 이자를 지급하는 곳도 있으므로, 모든 것은 투자자 개인의 현명한 선택에 따를지니...


🦄 다시 UniswapV2Pair.sol

 글이 너무 길어지기 때문에 꼬리에 꼬리를 무는 질문에서 빠져나와 다시 코드를 살펴보겠습니다.

Reentrancy Guard

 유니 스왑 V2는 재진입 공격을 방지하기 위해 주요 함수들에서 reentrancy guard를 사용하였습니다.

// Reentrancy Guard
uint private unlocked = 1;
modifier lock() {
    require(unlocked == 1, "UniswapV2: LOCKED");
    unlocked = 0;
    _;
    unlocked = 1;
}

2023.10.04 - [Solidity] - [Solidity] 재진입 공격 예방 기법

생성자와 initialize 함수

 UniswapV2Factory 컨트랙트에서 createPair 함수 호출 시에 UniswapV2Pair 컨트랙트를 생성합니다. 따라서 생성자에서 초기화되는 factory 변수의 msg.sender는 UniswapV2Factory 컨트랙트의 주소입니다. 또한 컨트랙트를 생성하고 initialize 함수를 불러 토큰 쌍의 주소를 초기화합니다. msg.sender가 factory로 초기화되어 있으므로 initialize 함수는 문제없이 실행됩니다.

 

 한 가지 또 중요한 것이 있는데, 생성자에 'UniswapV2ERC20()'가 붙어 있는 것을 확인할 수 있습니다. 제가 solidity 0.8.19 버전으로 수정하면서 추가한 것인데, 이는 이 컨트랙트가 그 자체로 UniswapV2ERC20 컨트랙트임을 의미합니다. 즉, 토큰 컨트랙트라는 것입니다. 토큰이 필요한 이유는 앞서 다룬 것처럼 유동성을 공급하는 사용자들에게 일정한 비율의 LP 토큰을 지급하기 위함입니다.

// 생성자
constructor() UniswapV2ERC20() {
    factory = msg.sender; // 팩토리 컨트랙트 주소를 저장
}

// 팩토리 컨트랙트에서 초기화를 위해 한 번만 호출
function initialize(address _token0, address _token1) external {
    require(msg.sender == factory, "UniswapV2: FORBIDDEN"); // 팩토리 컨트랙트만 호출 가능
    token0 = _token0; // 토큰0 주소를 저장
    token1 = _token1; // 토큰1 주소를 저장
}

mint 함수 (LP 토큰 발행)

// 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(); // 추적된 토큰0과 토큰1의 보유량
    uint balance0 = IERC20(token0).balanceOf(address(this)); // 현재 토큰0의 보유량 (새롭게 유동성 풀에 공급된 토큰0의 보유량이 포함될 수 있음)
    uint balance1 = IERC20(token1).balanceOf(address(this)); // 현재 토큰1의 보유량 (새롭게 유동성 풀에 공급된 토큰1의 보유량이 포함될 수 있음)
    uint amount0 = balance0.sub(_reserve0); // 새롭게 유동성 풀에 공급된 토큰0의 보유량
    uint amount1 = balance1.sub(_reserve1); // 새롭게 유동성 풀에 공급된 토큰1의 보유량

    bool feeOn = _mintFee(_reserve0, _reserve1); // 수수료가 적용되는지 확인
    uint _totalSupply = totalSupply; // _mintFee 함수에서 totalSupply를 업데이트할 수 있으므로, 호출하고 난 후에 불러와야 함
    if (_totalSupply == 0) {
        // 유동성 공급이 처음인 경우 (처음 LP 토큰을 발행하는 경우)
        liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); // liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
        _mint(address(0), MINIMUM_LIQUIDITY); // MINIMUM_LIQUIDITY만큼의 UniswapV2ERC20 토큰을 0번 주소로 전송 (영구적으로 잠금)
    } else {
        liquidity = Math.min(
            amount0.mul(_totalSupply) / _reserve0,
            amount1.mul(_totalSupply) / _reserve1
        ); // liquidity = min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1)
    }
    require(liquidity > 0, "UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED"); // 유동성 공급량이 0이면 에러 반환
    _mint(to, liquidity); // to로 liquidity만큼의 LP 토큰을 전송

    _update(balance0, balance1, _reserve0, _reserve1); // 토큰0과 토큰1의 보유량을 업데이트
    if (feeOn) kLast = uint(reserve0).mul(reserve1); // 수수료가 적용되면, 최근 상수 k를 업데이트
    emit Mint(msg.sender, amount0, amount1); // Mint 이벤트 발생
}

 mint 함수를 to에게 유동성 변경과 관련해 일정 비율의 LP 토큰을 발행해 주는 함수입니다. 아무래도 다른 컨트랙트에서 유동성 풀로 토큰을 전송한 다음에 호출하는 함수인 것 같습니다.

 

 조금 이해가 안 되는 부분은 지급할 토큰의 양을 결정하는 부분입니다. 발행한 LP 토큰의 양이 0이라는 것은 결국 처음으로 유동성이 공급되었다는 뜻인데, 이 때는 liquidity를 계산하는 방식도 다르고 심지어 MINIMUM_LIQUIDITY만큼의 토큰을 address(0)으로 보내서 잠가버립니다. 백서를 한 번 읽어봐야 하지 않을까 싶습니다.

if (_totalSupply == 0)
    liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
    _mint(address(0), MINIMUM_LIQUIDITY);
else
   liquidity = min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1);

내부 _mintFee 함수

// 수수료가 적용되면, 상수 k의 sqrt(k)의 증가량의 1/6에 해당하는 수량의 UniswapV2ERC20 토큰을 수수료 수취인 주소로 전송
function _mintFee(
    uint112 _reserve0, // 기존의 토큰0의 보유량
    uint112 _reserve1 // 기존의 토큰1의 보유량
) private returns (bool feeOn) {
    address feeTo = IUniswapV2Factory(factory).feeTo(); // 수수료 수취인 주소
    feeOn = feeTo != address(0); // 수수료 수취인 주소가 0이 아니면 수수료가 적용되는 것
    uint _kLast = kLast; // kLast를 불러옴
    if (feeOn) {
        // 수수료 수취인 주소가 설정되어 있는 경우에만 수행
        if (_kLast != 0) {
            // 지난번 유동성 변경 이후의 상수 k가 0이 아니면
            uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1)); // rootK = sqrt(reserve0 * reserve1)
            uint rootKLast = Math.sqrt(_kLast); // rootKLast = sqrt(kLast)
            if (rootK > rootKLast) {
                uint numerator = totalSupply.mul(rootK.sub(rootKLast)); // numerator = totalSupply * (sqrt(reserve0 * reserve1) - sqrt(kLast))
                uint denominator = rootK.mul(5).add(rootKLast); // denominator = 5 * sqrt(reserve0 * reserve1) + sqrt(kLast)
                uint liquidity = numerator / denominator; // liquidity = numerator / denominator = totalSupply * (sqrt(reserve0 * reserve1) - sqrt(kLast)) / (5 * sqrt(reserve0 * reserve1) + sqrt(kLast))
                if (liquidity > 0) _mint(feeTo, liquidity); // 수수료 수취인 주소로 liquidity만큼의 LP토큰을 전송
            }
        }
    } else if (_kLast != 0) {
        // 수수료 수취인 주소가 설정되어 있지 않고, 지난번 유동성 변경 이후의 상수 k가 0이 아니면
        kLast = 0; // 상수 k를 0으로 업데이트
    }
}

 수수료를 중간에서 떼가는 것이 아니라 새로 발행해서 totalSupply로 집계하는 것 같습니다. UniswapV2Factory 컨트랙트의 feeTo를 설정한 경우에만 수수료가 전달됩니다.

 

 수수료와 관련된 내용은 Core 부분만 보고서는 이해하기가 어려운 것 같습니다. 아무래도 스왑 할 때 떼가는 수수료가 보유량에 포함된 상태에서 호출이 되는 거 같은데, 이는 라우터와 같이 전체적인 그림을 봐야 이해가 갈 것 같습니다.

내부 _update 함수

// 토큰0과 토큰1의 보유량을 업데이트, 블록당 첫 호출 시 가격 누적값도 업데이트
function _update(
    uint balance0, // 새로운 토큰0의 보유량
    uint balance1, // 새로운 토큰1의 보유량
    uint112 _reserve0, // 기존의 토큰0의 보유량
    uint112 _reserve1 // 기존의 토큰1의 보유량
) private {
    require(
        balance0 <= type(uint112).max && balance1 <= type(uint112).max,
        "UniswapV2: OVERFLOW"
    ); // uint112 타입의 최댓값보다 보유량이 크면 에러
    uint32 blockTimestamp = uint32(block.timestamp % 2 ** 32); // unix timestamp를 32비트로 변환
    uint32 timeElapsed = blockTimestamp - blockTimestampLast; // 마지막 업데이트 시간과 현재 블록의 시간 차이
    if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
        // 마지막 업데이트 시간과 현재 블록의 시간 차이가 0보다 크다 = 서로 다른 블록에서의 업데이트
        // 즉, 새로운 블록에서 첫 번째 업데이트인 경우,
        // 토큰0과 토큰1의 보유량이 0이 아니면 가격 누적값을 업데이트 (현 블록에서 처음 호출된 경우)

        // * never overflows, and + overflow is desired (derised? 오히려 좋다는 뜻?)
        price0CumulativeLast +=
            uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) *
            timeElapsed; // price0 = reserve1 / reserve0, price0CumulativeLast += price0 * timeElapsed
        price1CumulativeLast +=
            uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) *
            timeElapsed; // price1 = reserve0 / reserve1, price1CumulativeLast += price1 * timeElapsed
    }
    reserve0 = uint112(balance0); // 토큰0의 보유량을 업데이트
    reserve1 = uint112(balance1); // 토큰1의 보유량을 업데이트
    blockTimestampLast = blockTimestamp; // 현재 블록에서의 업데이트 시간을 업데이트
    emit Sync(reserve0, reserve1); // Sync 이벤트 발생
}

 _update는 유동성 풀에 토큰이 공급되거나 인출되었을 때 호출되는 내부 함수로 보입니다. 기존의 토큰 보유량을 새로운 보유량으로 업데이트하기 전에, 지난번에 _update가 호출된 시간과 현재 시간의 차이인 timeElapsed를 구합니다. 이 값이 양수라면, 지난번에 업데이트가 발생한 트랜잭션이 포함된 블록과 현재 실행되는 트랜잭션이 포함된 블록이 서로 다르다는 것을 의미합니다. 이 경우에는 토큰 가격의 누적값을 계산합니다.

 

 즉, 모든 블록에서 발생한 첫 번째 트랜잭션에서 토큰 가격의 누적값을 계산합니다. 이때 가격 계산에 사용되는 보유량은 새로운 토큰 보유량이 아닌 이전에 업데이트된 보유량으로, 이전 블록(직전 블록이 아닐 수도 있음)에서의 가격으로 새로운 블록까지의 시간 가중치를 적용한 가격을 구합니다.

출처:&nbsp;https://docs.uniswap.org/contracts/v2/concepts/core-concepts/oracles

 이렇게 누적된 가격으로 특정 시간대의 평균 가격을 구할 수 있는데, 이를 TWAP(Time Weighted Average Price)라고 합니다. TWAP을 사용하는 이유는 공격자가 단기간에 토큰의 가격을 조작하는 것을 방지하여, 신뢰할 수 있는 가격 오라클을 제공하기 위함입니다. 공격자가 가격을 조작하기 위해서는 블록을 연속해서 생성해야 하기 때문에 사실상 불가능에 가깝다고 보시면 됩니다.

출처:&nbsp;https://docs.uniswap.org/contracts/v2/concepts/core-concepts/oracles

 TWAP은 일정한 구간을 선정하고 구간 사이의 누적 가격을 누적 시간으로 나누어서 구할 수 있습니다. 구간이 길어질수록 공격자가 가격을 조작하기 더 어려워지지만, 최신 가격과는 거리가 멀어진다는 단점이 있습니다.

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(); // 추적된 토큰0과 토큰1의 보유량
    address _token0 = token0; // gas savings (토큰0 주소)
    address _token1 = token1; // gas savings (토큰1 주소)
    uint balance0 = IERC20(_token0).balanceOf(address(this)); // 현재 토큰0의 보유량 (추적되지 않은 보유량이 포함될 수 있음)
    uint balance1 = IERC20(_token1).balanceOf(address(this)); // 현재 토큰1의 보유량 (추적되지 않은 보유량이 포함될 수 있음)
    uint liquidity = balanceOf[address(this)]; // 현재 컨트랙트의 LP 토큰 보유량 (= to가 반환한 LP 토큰의 수량)

    bool feeOn = _mintFee(_reserve0, _reserve1); // 수수료가 적용되는지 확인
    uint _totalSupply = totalSupply; // _mintFee 함수에서 수수료가 발행되면 totalSupply가 업데이트될 수 있으므로, 호출하고 난 후에 불러와야 함
    amount0 = liquidity.mul(balance0) / _totalSupply; // 유동성 풀에서 인출할 토큰0의 수량 (pro-rata distribution)
    amount1 = liquidity.mul(balance1) / _totalSupply; // 유동성 풀에서 인출할 토큰1의 수량 (pro-rata distribution)
    require(
        amount0 > 0 && amount1 > 0,
        "UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED"
    ); // 어느 하나라도 0이면 에러
    _burn(address(this), liquidity); // 반환된 LP 토큰을 소각
    _safeTransfer(_token0, to, amount0); // to로 amount0만큼의 토큰0을 전송
    _safeTransfer(_token1, to, amount1); // to로 amount1만큼의 토큰1을 전송
    balance0 = IERC20(_token0).balanceOf(address(this)); // 현재 토큰0의 보유량 (추적되지 않은 보유량이 포함될 수 있음)
    balance1 = IERC20(_token1).balanceOf(address(this)); // 현재 토큰1의 보유량 (추적되지 않은 보유량이 포함될 수 있음)

    _update(balance0, balance1, _reserve0, _reserve1); // 토큰0과 토큰1의 보유량을 업데이트
    if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0와 reserve1이 업데이트되었으므로, 최근 상수 k를 업데이트
    emit Burn(msg.sender, amount0, amount1, to); // Burn 이벤트 발생
}

 burn 함수는 반납된 LP 토큰에 비례하는 토큰 0과 토큰 1의 수량을 역산하여 to에게 반환하고 LP 토큰을 소각합니다. mint 함수와 마찬가지로 다른 컨트랙트에서 유동성 풀로 LP 토큰을 반납한 후에 호출되는 함수인 것 같습니다.

swap 함수 (토큰 쌍 교환)

// this low-level function should be called from a contract which performs important safety checks
function swap(
    uint amount0Out, // 풀에서 빠져나갈 토큰0의 수량
    uint amount1Out, // 풀에서 빠져나갈 토큰1의 수량
    address to, // 토큰을 전송할 주소
    bytes calldata data // IUniswapV2Callee.uniswapV2Call()을 호출하기 위한 데이터
) external lock {
    require(
        amount0Out > 0 || amount1Out > 0,
        "UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT"
    ); // 토큰0과 토큰1의 수량이 모두 0이면 에러
    (uint112 _reserve0, uint112 _reserve1, ) = getReserves(); // 추적된 토큰0과 토큰1의 보유량
    require(
        amount0Out < _reserve0 && amount1Out < _reserve1,
        "UniswapV2: INSUFFICIENT_LIQUIDITY"
    ); // 토큰0과 토큰1의 보유량보다 많은 수량을 빼려고 하면 에러

    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"); // to가 토큰0이나 토큰1의 주소면 에러
        if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // 낙관적으로 토큰0을 to로 전송
        if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // 낙관적으로 토큰1을 to로 전송
        if (data.length > 0)
            IUniswapV2Callee(to).uniswapV2Call(
                msg.sender,
                amount0Out,
                amount1Out,
                data
            ); // to가 IUniswapV2Callee 인터페이스를 구현한 컨트랙트인 경우, uniswapV2Call() 함수를 호출
        balance0 = IERC20(_token0).balanceOf(address(this)); // 현재 토큰0의 보유량 (swap() 함수 호출로 인해 토큰0의 보유량에 변동이 있을 수 있음)
        balance1 = IERC20(_token1).balanceOf(address(this)); // 현재 토큰1의 보유량 (swap() 함수 호출로 인해 토큰1의 보유량에 변동이 있을 수 있음)
    }
    uint amount0In = balance0 > _reserve0 - amount0Out
        ? balance0 - (_reserve0 - amount0Out)
        : 0; // swap을 통해 유동성 풀에 추가된 토큰0의 수량
    uint amount1In = balance1 > _reserve1 - amount1Out
        ? balance1 - (_reserve1 - amount1Out)
        : 0; // swap을 통해 유동성 풀에 추가된 토큰1의 수량
    require(
        amount0In > 0 || amount1In > 0,
        "UniswapV2: INSUFFICIENT_INPUT_AMOUNT"
    ); // swap을 통해 유동성 풀에 추가된 토큰0과 토큰1의 수량이 하나라도 0이면 에러 반환
    {
        // scope for reserve{0,1}Adjusted, avoids stack too deep errors
        uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3)); // balance0Adjusted = balance0 * 1000 - amount0In * 3
        uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3)); // balance1Adjusted = balance1 * 1000 - amount1In * 3
        require(
            balance0Adjusted.mul(balance1Adjusted) >=
                uint(_reserve0).mul(_reserve1).mul(1000 ** 2), // balance0Adjusted * balance1Adjusted = (balance0 * 1000 - amount0In * 3) * (balance1 * 1000 - amount1In * 3) >= reserve0 * reserve1 * 1000^2
            "UniswapV2: K"
        ); // 상수 k가 변하지 않도록 체크
    }

    _update(balance0, balance1, _reserve0, _reserve1); // 토큰0과 토큰1의 보유량을 업데이트 (k는 변하지 않음)
    emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to); // Swap 이벤트 발생
}

 swap도 mint나 burn과 마찬가지로 다른 컨트랙트에서 유동성 풀로 스왑 할 토큰을 전송한 다음에 쌍 토큰의 계산된 수량을 to에게 전송하는 함수입니다. 여기서는 마땅히 어려운 부분은 없는 것 같습니다. 눈에 띄는 것은 파라미터로 받은 data의 길이가 0보다 길 때 to를 IUniswapV2Callee로 감싸서 uniswapV2Call 함수를 호출하는 것인데, 이 부분은 플래시론과 연관이 있다고 하니 나중에 다뤄보겠습니다.

 

 그 외에는 상수 k가 불변인지 유효성을 체크하는 부분인데, 식이 조금 복잡해 보이는 것은 아무래도 소수점 이하 자리 때문에 정밀도 체크를 위한 연산이 추가되어서 그런 것 같습니다.

skim 함수 (추적되지 않은 토큰 제거)

// 실제 토큰0과 토큰1의 보유량이 추적된 토큰0과 토큰1의 보유량과 일치하도록 남은 토큰을 to로 전송
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)
    );
}

 여분의 토큰 보유량을 유동성 풀로부터 제거하는 함수입니다. 여분은 to에게 돌아가는데 중요한 것은 이 함수를 아무나 호출할 수 있기 때문에 괜스레 유동성 풀에 장난으로 토큰을 전송하는 것은 삼가는 것이 좋을 것 같네요.

sync 함수 (보유량 동기화)

// 추적된 토큰0과 토큰1의 보유량을 실제 토큰0과 토큰1의 보유량으로 업데이트 (추적되지 않은 보유량이 포함될 수 있음)
function sync() external lock {
    _update(
        IERC20(token0).balanceOf(address(this)),
        IERC20(token1).balanceOf(address(this)),
        reserve0,
        reserve1
    );
}

 보유량을 동기화하는 함수입니다. 유동성 풀로 직접 토큰을 전송하고 이 함수를 호출하면 보낸 토큰이 보유량에 포함되게 만들 수 있어 보입니다.


🔍 테스트로 동작 이해하기

SETUP

 각 테스트를 실행할 때마다 두 개의 ERC20 토큰 컨트랙트, 팩토리 컨트랙트 그리고 페어 컨트랙트를 생성합니다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "../src/UniswapV2Factory.sol";
import "../src/UniswapV2Pair.sol";
import "../src/libraries/UQ112x112.sol";
import {ERC20} from "../src/test/ERC20.sol";
import "forge-std/Test.sol";

contract UniswapV2ERC20Test is Test {
	using SafeMath for uint;

    event Approval(address indexed owner, address indexed spender, uint value);
    event Transfer(address indexed from, address indexed to, uint value);
    event Mint(address indexed sender, uint amount0, uint amount1);
    event Burn(
        address indexed sender,
        uint amount0,
        uint amount1,
        address indexed to
    );
    event Swap(
        address indexed sender,
        uint amount0In,
        uint amount1In,
        uint amount0Out,
        uint amount1Out,
        address indexed to
    );
    event Sync(uint112 reserve0, uint112 reserve1);

    UniswapV2Factory public factory;
    ERC20 public token0;
    ERC20 public token1;
    UniswapV2Pair public pair;

    uint256 ownerPrivateKey;

    address owner;

    function setUp() public {
        ownerPrivateKey = 0x1234567890123456789012345678901234567890123456789012345678901234;
        owner = vm.addr(ownerPrivateKey);

        vm.startPrank(owner);

        factory = new UniswapV2Factory(owner);
        token0 = new ERC20(100000e18);
        token1 = new ERC20(100000e18);

        if (address(token0) > address(token1)) {
            (token0, token1) = (token1, token0);
        }

        address pairAddress = factory.createPair(
            address(token0),
            address(token1)
        );

        pair = UniswapV2Pair(pairAddress);

        vm.stopPrank();
    }
    
    ...
}

 ERC20 토큰은 생성자에서 msg.sender에게 _totalSupply만큼의 토큰을 발행해 주도록 설정하였습니다.

contract ERC20 is UniswapV2ERC20WithMint {
    constructor(uint _totalSupply) {
        _mint(msg.sender, _totalSupply);
    }
}

 그리고 유틸리티 함수로 유동성 풀에 유동성을 추가하는 addLiquidity 함수와 토큰 보유량으로 112비트 고정소수점 수로 가격을 변환하는 encodePrice 함수를 정의하였습니다. 

function addLiquidity(
    uint amount0,
    uint amount1
) public returns (uint liquidity) {
    vm.startPrank(owner);
    token0.transfer(address(pair), amount0);
    token1.transfer(address(pair), amount1);

    liquidity = pair.mint(owner);

    vm.stopPrank();
}

function encodePrice(
    uint112 reserve0,
    uint112 reserve1
) public pure returns (uint224, uint224) {
    return (
        UQ112x112.uqdiv(UQ112x112.encode(reserve1), reserve0),
        UQ112x112.uqdiv(UQ112x112.encode(reserve0), reserve1)
    );
}

CASE 1: 유동성 풀에 처음 유동성을 공급하는 경우

function test_Mint() public {
    uint256 token0Amount = 1e19;
    uint256 token1Amount = 4e19;

    vm.startPrank(owner);
    token0.transfer(address(pair), token0Amount);
    token1.transfer(address(pair), token1Amount);

    uint256 minimumLiquidity = pair.MINIMUM_LIQUIDITY();
    uint256 expectedLiquidity = 2e19 - minimumLiquidity;

    vm.expectEmit(true, true, true, true);
    emit Transfer(address(0), address(0), minimumLiquidity);
    vm.expectEmit(true, true, true, true);
    emit Transfer(address(0), owner, expectedLiquidity);
    vm.expectEmit(true, true, true, true);
    emit Sync(uint112(token0Amount), uint112(token1Amount));
    vm.expectEmit(true, true, true, true);
    emit Mint(owner, token0Amount, token1Amount);

    uint256 liquidity = pair.mint(owner);

    vm.stopPrank();

    assertEq(expectedLiquidity, liquidity);
    assertEq(pair.totalSupply(), expectedLiquidity + minimumLiquidity);
    assertEq(pair.balanceOf(owner), expectedLiquidity);
    assertEq(token0.balanceOf(address(pair)), token0Amount);
    assertEq(token1.balanceOf(address(pair)), token1Amount);

    (uint112 reserve0, uint112 reserve1, ) = pair.getReserves();
    assertEq(reserve0, token0Amount);
    assertEq(reserve1, token1Amount);
}
  1. token0Amount만큼의 토큰 0과 token1Amount만큼의 토큰 1을 유동성 풀에 공급합니다.
  2. 유동성 풀에 처음 유동성을 공급하는 것이므로, owner의 liquidity는 sqrt(1e19 * 4e19) - MINIMUM_LIQUIDITY가 됩니다.
  3. 유동성 공급에 대한 LP 토큰 보상을 지급받기 위해 mint 함수를 호출합니다.
  4. owner에게는 liquidity만큼의 LP 토큰이 지급되고, 유동성 풀에서 추적하는 보유량이 갱신됩니다. 이때 수수료가 설정되어 있지 않으므로, kLast는 갱신되지 않습니다.
$ forge test -vvv --mt test_Mint
[⠒] Compiling...
[⠢] Compiling 1 files with 0.8.23
[⠆] Solc 0.8.23 finished in 2.62s
Compiler run successful!

Running 1 test for test/UniswapV2Pair.t.sol:UniswapV2ERC20Test
[PASS] test_Mint() (gas: 230395)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.73ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

CASE 2: 활성화된 유동성 풀에 유동성을 공급하는 경우

function test_MintToActivePool() public {
    uint256 token0Amount = 1e19;
    uint256 token1Amount = 4e19;

    uint256 liquidityBefore = addLiquidity(token0Amount, token1Amount);

    // second mint
    vm.startPrank(owner);
    token0.transfer(address(pair), token0Amount);
    token1.transfer(address(pair), token1Amount);

    uint256 totalSupply = pair.totalSupply();

    uint expectedLiquidity = Math.min(
        token0Amount.mul(totalSupply) / token0Amount,
        token1Amount.mul(totalSupply) / token1Amount
    );

    vm.expectEmit(true, true, true, true);
    emit Transfer(address(0), owner, expectedLiquidity);
    vm.expectEmit(true, true, true, true);
    emit Sync(
        uint112(token0Amount + token0Amount),
        uint112(token1Amount + token1Amount)
    );
    vm.expectEmit(true, true, true, true);
    emit Mint(owner, token0Amount, token1Amount);

    uint256 liquidity = pair.mint(owner);

    vm.stopPrank();

    assertEq(liquidity, expectedLiquidity);
    assertEq(pair.totalSupply(), totalSupply + expectedLiquidity);
    assertEq(pair.balanceOf(owner), liquidityBefore + expectedLiquidity);
    assertEq(token0.balanceOf(address(pair)), token0Amount + token0Amount);
    assertEq(token1.balanceOf(address(pair)), token1Amount + token1Amount);

    (uint112 reserve0, uint112 reserve1, ) = pair.getReserves();

    assertEq(reserve0, token0Amount + token0Amount);
    assertEq(reserve1, token1Amount + token1Amount);
}
  • 활성화되어 있는 유동성 풀에 유동성을 공급하는 경우, 보상으로 받는 LP 토큰의 양은 min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1)이 됩니다.
$ forge test -vvv --mt test_MintToActivePool
[⠒] Compiling...
No files changed, compilation skipped

Running 1 test for test/UniswapV2Pair.t.sol:UniswapV2ERC20Test
[PASS] test_MintToActivePool() (gas: 250536)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.99ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

CASE 3: 토큰 0을 토큰 1로 스왑 하는 경우

function test_SwapToken0() public {
    uint256 token0Amount = 5e19;
    uint256 token1Amount = 10e19;

    addLiquidity(token0Amount, token1Amount);

    uint256 swapAmount = 1e19;
    uint256 expectedOutputAmount = 16524979156244789060;

    vm.startPrank(owner);

    token0.transfer(address(pair), swapAmount);

    vm.expectEmit(true, true, true, true);
    emit Transfer(address(pair), owner, expectedOutputAmount);
    vm.expectEmit(true, true, true, true);
    emit Sync(
        uint112(token0Amount + swapAmount),
        uint112(token1Amount - expectedOutputAmount)
    );
    vm.expectEmit(true, true, true, true);
    emit Swap(owner, swapAmount, 0, 0, expectedOutputAmount, owner);

    pair.swap(0, expectedOutputAmount, owner, new bytes(0));

    vm.stopPrank();

    (uint112 reserve0, uint112 reserve1, ) = pair.getReserves();

    assertEq(reserve0, token0Amount + swapAmount);
    assertEq(reserve1, token1Amount - expectedOutputAmount);
    assertEq(token0.balanceOf(address(pair)), token0Amount + swapAmount);
    assertEq(
        token1.balanceOf(address(pair)),
        token1Amount - expectedOutputAmount
    );

    uint256 totalSupplyToken0 = token0.totalSupply();
    uint256 totalSupplyToken1 = token1.totalSupply();

    assertEq(
        token0.balanceOf(owner),
        totalSupplyToken0 - token0Amount - swapAmount
    );
    assertEq(
        token1.balanceOf(owner),
        totalSupplyToken1 - token1Amount + expectedOutputAmount
    );
}
  1. 5e19와 10e19를 곱하면 k는 50e38입니다.
  2. 1개의 토큰 0을 토큰 1과 교환하기 위해 pair 컨트랙트에게 전송합니다. 
  3. (5e19 + 1e19) * (10e19 - x) >= k를 만족하는 토큰 1의 개수 x는 (1.66 * 1e19) 이하여야 합니다.
  4. 정확한 계산은 라우터를 살펴볼 때 다룰 것이므로 (1.66 * 1e19) 이하의 임의의 개수로 스왑을 시도할 경우 정상적으로 처리가 되는지를 확인합니다.
$ forge test -vvv --mt test_SwapToken0
[⠒] Compiling...
No files changed, compilation skipped

Running 1 test for test/UniswapV2Pair.t.sol:UniswapV2ERC20Test
[PASS] test_SwapToken0() (gas: 256293)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.79ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

CASE 4: 토큰 1을 토큰 0으로 스왑 하는 경우

function test_SwapToken1() public {
    uint256 token0Amount = 5e19;
    uint256 token1Amount = 10e19;

    addLiquidity(token0Amount, token1Amount);

    uint256 swapAmount = 1e19;
    uint256 expectedOutputAmount = 453305446940074565;

    vm.startPrank(owner);

    token1.transfer(address(pair), swapAmount);

    vm.expectEmit(true, true, true, true);
    emit Transfer(address(pair), owner, expectedOutputAmount);
    vm.expectEmit(true, true, true, true);
    emit Sync(
        uint112(token0Amount - expectedOutputAmount),
        uint112(token1Amount + swapAmount)
    );
    vm.expectEmit(true, true, true, true);
    emit Swap(owner, 0, swapAmount, expectedOutputAmount, 0, owner);

    pair.swap(expectedOutputAmount, 0, owner, new bytes(0));

    vm.stopPrank();

    (uint112 reserve0, uint112 reserve1, ) = pair.getReserves();

    assertEq(reserve0, token0Amount - expectedOutputAmount);
    assertEq(reserve1, token1Amount + swapAmount);
    assertEq(
        token0.balanceOf(address(pair)),
        token0Amount - expectedOutputAmount
    );
    assertEq(token1.balanceOf(address(pair)), token1Amount + swapAmount);

    uint256 totalSupplyToken0 = token0.totalSupply();
    uint256 totalSupplyToken1 = token1.totalSupply();

    assertEq(
        token0.balanceOf(owner),
        totalSupplyToken0 - token0Amount + expectedOutputAmount
    );
    assertEq(
        token1.balanceOf(owner),
        totalSupplyToken1 - token1Amount - swapAmount
    );
}
  1. 5e19와 10e19를 곱하면 k는 50e38입니다.
  2. 1개의 토큰 1을 토큰 0과 교환하기 위해 pair 컨트랙트에게 전송합니다. 
  3. (5e19 - x) * (10e19 + 1e19) >= k를 만족하는 토큰 0의 개수 x는 ( 0.45  * 1e19) 이하여야 합니다.
  4. 앞선 테스트와 마찬가지 (0.45 * 1e19) 이하의 임의의 개수로 스왑을 시도할 경우 정상적으로 처리가 되는지를 확인합니다.
$ forge test -vvv --mt test_SwapToken1
[⠒] Compiling...
[⠆] Compiling 1 files with 0.8.23
[⠰] Solc 0.8.23 finished in 2.78s
Compiler run successful!

Running 1 test for test/UniswapV2Pair.t.sol:UniswapV2ERC20Test
[PASS] test_SwapToken1() (gas: 256317)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.77ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

CASE 5: 유동성 풀에서 자금을 회수하는 경우

function test_Burn() public {
    uint256 token0Amount = 3e19;
    uint256 token1Amount = 3e19;

    uint liquidity = addLiquidity(token0Amount, token1Amount);

    vm.startPrank(owner);

    pair.transfer(address(pair), liquidity);

    vm.expectEmit(true, true, true, true);
    emit Transfer(address(pair), address(0), liquidity);
    vm.expectEmit(true, true, true, true);
    emit Transfer(address(pair), owner, token0Amount - 1000);
    vm.expectEmit(true, true, true, true);
    emit Transfer(address(pair), owner, token1Amount - 1000);
    vm.expectEmit(true, true, true, true);
    emit Sync(1000, 1000);
    vm.expectEmit(true, true, true, true);
    emit Burn(owner, token0Amount - 1000, token1Amount - 1000, owner);

    (uint return0Amount, uint return1Amount) = pair.burn(owner);

    vm.stopPrank();

    assertEq(pair.totalSupply(), pair.MINIMUM_LIQUIDITY());
    assertEq(pair.balanceOf(owner), 0);
    assertEq(token0.balanceOf(address(pair)), 1000);
    assertEq(token1.balanceOf(address(pair)), 1000);
    assertEq(return0Amount, token0Amount - 1000);
    assertEq(return1Amount, token1Amount - 1000);
}
  1. owner가 유동성 풀에 최초로 유동성을 공급합니다.
  2. 유동성 공급에 대한 보상으로 받은 3e19 - MINIMUM_LIQUIDITY 만큼의 LP 토큰을 받습니다.
  3. owner가 유동성 풀에 공급한 토큰을 인출하기 위해 가지고 있는 모든 LP 토큰을 반납합니다.
  4. 각 토큰 보유량에서 MINIMUM_LIQUIDITY를 제외한 토큰이 owner에게 반환됩니다.
$ forge test -vvv --mt test_Burn
[⠒] Compiling...
[⠘] Compiling 1 files with 0.8.23
[⠃] Solc 0.8.23 finished in 3.20s
Compiler run successful!

Running 1 test for test/UniswapV2Pair.t.sol:UniswapV2ERC20Test
[PASS] test_Burn() (gas: 242596)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.12ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

CASE 6: 누적 가격 계산

function test_CumulativeLast() public {
    uint256 token0Amount = 3e19;
    uint256 token1Amount = 3e19;

    addLiquidity(token0Amount, token1Amount);

    (, , uint32 blockTimestamp) = pair.getReserves();

    // pretend to mine a block
    vm.roll(block.number + 1);
    vm.warp(blockTimestamp + 1);

    pair.sync(); // update price0CumulativeLast and price1CumulativeLast

    (uint224 initialPrice0, uint224 initialPrice1) = encodePrice(
        uint112(token0Amount),
        uint112(token1Amount)
    );

    assertEq(pair.price0CumulativeLast(), initialPrice0);
    assertEq(pair.price1CumulativeLast(), initialPrice1);

    (, , uint32 blockTimestampLast) = pair.getReserves();

    assertEq(blockTimestampLast, blockTimestamp + 1);

    uint256 swapAmount = 30e18;

    vm.startPrank(owner);
    token0.transfer(address(pair), swapAmount);

    // pretend to mine a block
    vm.roll(block.number + 1);
    vm.warp(blockTimestamp + 10);

    pair.swap(0, 10e18, owner, new bytes(0));

    vm.stopPrank();

    assertEq(pair.price0CumulativeLast(), initialPrice0 * 10);
    assertEq(pair.price1CumulativeLast(), initialPrice1 * 10);

    (, , blockTimestampLast) = pair.getReserves();

    assertEq(blockTimestampLast, blockTimestamp + 10);

    // pretend to mine a block
    vm.roll(block.number + 1);
    vm.warp(blockTimestamp + 20);

    pair.sync(); // update price0CumulativeLast and price1CumulativeLast

    (uint224 newPrice0, uint224 newPrice1) = encodePrice(60e18, 20e18);

    assertEq(pair.price0CumulativeLast(), initialPrice0 * 10 + newPrice0 * 10);
    assertEq(pair.price1CumulativeLast(), initialPrice1 * 10 + newPrice1 * 10);

    (, , blockTimestampLast) = pair.getReserves();

    assertEq(blockTimestampLast, blockTimestamp + 20);
}
  1. owner가 유동성 풀에 유동성을 공급합니다. 토큰의 누적 가격은 아직 기록되지 않았습니다.
  2. 현재 블록을 첫 번째 블록이라고 가정하고, 블록의 타임스탬프를 가져옵니다.
  3. 첫 번째 블록에서 1밀리 초가 지나 두 번째 블록이 생성되고, 이 블록의 첫 번째 트랜잭션에서 sync 함수를 호출함으로써 토큰의 누적 가격을 갱신합니다. 토큰의 누적 가격은 1밀리초 * 첫 번째 블록의 마지막 가격입니다.
  4. 첫 번째 블록에서 10밀리 초가 지나 세 번째 블록이 생성되고, 이 블록의 첫 번째 트랜잭션에서 swap 함수를 호출하여 토큰 0을 토큰 1로 교환합니다. 토큰의 가격이 변하지만, 새로운 가격이 아닌 기존의 가격이 누적됩니다. 토큰의 누적 가격은 10밀리초 * 첫 번째 블록의 마지막 가격입니다.
  5. 첫 번째 블록에서 20밀리 초가 지나 네 번째 블록이 생성되고, 이 블록의 첫 번째 트랜잭션에서 sync 함수를 호출함으로써 토큰의 누적 가격을 갱신합니다. 토큰의 누적 가격은 10밀리 초 * 첫 번째 블록의 마지막 가격 + 10 * 두 번째 블록의 마지막 가격입니다.
$ forge test -vvv --mt test_CumulativeLast
[⠒] Compiling...
No files changed, compilation skipped

Running 1 test for test/UniswapV2Pair.t.sol:UniswapV2ERC20Test
[PASS] test_CumulativeLast() (gas: 312567)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.71ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

CASE 7: 수수료가 설정되어 있는 경우

function test_FeeToOn() public {
    address feeTo = vm.addr(1);

    vm.prank(owner);
    factory.setFeeTo(feeTo);

    uint256 token0Amount = 1000e19;
    uint256 token1Amount = 1000e19;

    addLiquidity(token0Amount, token1Amount);

    uint256 swapAmount = 1e19;
    uint256 expectedOutputAmount = 996006981039903216;

    vm.startPrank(owner);

    token1.transfer(address(pair), swapAmount);
    pair.swap(expectedOutputAmount, 0, owner, new bytes(0));

    uint256 minimumLiquidity = pair.MINIMUM_LIQUIDITY();
    uint256 expectedLiquidity = 1000e19 - minimumLiquidity;

    pair.transfer(address(pair), expectedLiquidity);
    (uint return0Amount, uint return1Amount) = pair.burn(owner);

    vm.stopPrank();

    uint256 expectedLiquidityOnFee = 749799759298893433;

    assertEq(pair.totalSupply(), minimumLiquidity + expectedLiquidityOnFee);
    assertEq(pair.balanceOf(feeTo), expectedLiquidityOnFee);
    assertEq(
        token0.balanceOf(address(pair)),
        token0Amount - expectedOutputAmount - return0Amount
    );
    assertEq(
        token1.balanceOf(address(pair)),
        token1Amount + swapAmount - return1Amount
    );
}
  1. owner가 setFeeTo 함수를 호출하여 feeTo가 수수료를 받을 수 있도록 설정합니다.
  2. 1000e19와 1000e19를 곱하면 k는 1000000e38입니다. 유동성을 공급하고 1000e19 - MINIMUM_LIQUIDITY 만큼의 LP 토큰을 받습니다.
  3. 1개의 토큰 1을 토큰 0과 교환하기 위해 pair 컨트랙트에게 전송합니다. 
  4. (1000e19 - x) * (1000e19 + 1e19) >= k를 만족하는 토큰 0의 개수 x는 ( 0.999  * 1e19) 이하여야 합니다.
  5. (0.999  * 1e19) 이하의 임의의 개수로 스왑을 시도합니다. 여기 서는 996006981039903216을 사용하였습니다.
  6. owner가 자신이 소유한 모든 LP 토큰을 반납하고 자금을 인출하고자 합니다.
  7. 이때 수수료가 먼저 적용됩니다. 실제로 보유한 토큰으로 √k'을 계산해 보면, 10004500485787373410936로 고정된 k의 √k의 값이 10000000000000000000000인 것과 비교해 4500485787373410936이 차이가 납니다.
  8. √k' - √k가 0보다 크므로, 이를 totalSupply * (sqrt(k') - sqrt(k)) / (5 * sqrt(k') + sqrt(k)) 공식을 통해 수수료를 계산합니다. 그리고 계산된 수수료만큼의 LP 토큰을 feeTo에게 발행합니다.
  9. LP 토큰이 발행되었기 때문에 LP 토큰의 totalSupply가 늘어나고, 그 비율만큼 owner가 인출할 자금의 액수가 줄어들게 됩니다. 이런 식으로 수수료를 부과하는 것 같습니다.
$ forge test -vvv --mt test_FeeToOn
[⠒] Compiling...
No files changed, compilation skipped

Running 1 test for test/UniswapV2Pair.t.sol:UniswapV2ERC20Test
[PASS] test_FeeToOn() (gas: 365054)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.34ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

😎 마무리

 문득 이런 생각이 듭니다. 뛰어난 연구개발진이 심혈을 기울여 만들어낸 시스템 또는 서비스를 단번에 너무 쉽게 이해해 버리면 그것은 그 나름대로 상당히 무례한 행동이 아닐까. Uniswap V2 프리파라로 돌아오겠습니다. 그전에 백서도 한 번 읽어봐야 할 것 같습니다.


🥷 전체 코드

 

GitHub - piatoss3612/dig-defi

Contribute to piatoss3612/dig-defi development by creating an account on GitHub.

github.com


📖 참고

 

Overview | Uniswap

Welcome to the Uniswap protocol V2 docs.

docs.uniswap.org

 

Uniswap: A Good Deal for Liquidity Providers?

What is Uniswap?

pintail.medium.com

 

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

 

'Solidity > DeFi' 카테고리의 다른 글

[Uniswap] V2 Oracle 예제  (0) 2024.02.21
[Uniswap] V2 Router  (0) 2024.02.20
[Uniswap] V2 Core 보충 자료 - 백서 읽기  (0) 2024.02.05
[Uniswap] V2 Core - UniswapV2Factory  (1) 2024.01.31
[Uniswap] V2 Core - UniswapV2ERC20  (1) 2024.01.30
최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday
글 보관함