공대생 정리노트

유니스왑 - Concentrated Liquidity 소스 훑어보기 (상세X) 본문

블록체인/오픈소스

유니스왑 - Concentrated Liquidity 소스 훑어보기 (상세X)

woojinger 2022. 3. 3. 23:58

Reference

- 유니스왑 v3 백서

- https://github.com/Uniswap

- https://starli.medium.com/uniswap-deep-dive-into-v3s-source-code-b141c1754bae


유니스왑 v3의 라이선스는 오픈소스는 아니다.

스시스왑의 뱀파이어 공격 이후 유사 공격을 막기 위해 비즈니스 라이선스를 도입을 한 상태이다.

https://starli.medium.com/uniswap-deep-dive-into-v3s-source-code-b141c1754bae

유니스왑 v3의 코드는 크게 core와 periphery로 나눌 수 있다. 

Periphery 부분은 다시 크게 Position management 부분과 swap router management 부분으로 나뉜다.

 

이번 글에서는 Position management 부분과 core 로직을 집중적으로 살펴 볼 것이다.

 

 Concentrated Liquidity란?

유니스왑 백서
Figure 2의 식

유니스왑 v2에서는 유동성을 공급하면 가격 변동폭이 크지 않은 이상 일부의 유동성만 사용이 된다는 단점이 있었다.

어떤 한 토큰이 풀에서 끝까지 소모가 되지 않는 이상 항상 쓰이지 않는 토큰들이 풀에 남게 된다.

 

v3에서는 유동성 공급 시 구간을 설정해 유동성을 공급할 수 있게 하였다. 만약 가격이 해당 가격 범위를 넘어서게 되면, 설정한 구간의 유동성은 사용되지 않는다.

위 그림을 보면 v3에서는 virtual reserves를 평행 이동한 것이 실제 reserve인 것을 확인할 수 있다. 만약 토큰의 가격 변동이 a~b구간에서만 움직인다면 적은 유동성으로 똑같은 효과를 낼 수 있게 된다.

 

Concentrated Liquidity 구현 살펴보기

1. Pool의 구성

/// @title NFT positions
/// @notice Wraps Uniswap V3 positions in the ERC721 non-fungible token interface
contract NonfungiblePositionManager is
    INonfungiblePositionManager,
    Multicall,
    ERC721Permit,
    PeripheryImmutableState,
    PoolInitializer,
    LiquidityManagement,
    PeripheryValidation,
    SelfPermit
{
...
}

periphery 부분의 NonfungiblePositionManager 부분을 보자.

v3는 v2와 다르게 각 포지션이 두 개의 틱으로 이루어져 있기 때문에 같은 풀이어도 포지션이 달라 ERC721 토큰으로 포지션을 wrap을 한다.

NonfungiblePositionManager가 상속한 PoolInitializer 코드를 보자.

/// @title Creates and initializes V3 Pools
abstract contract PoolInitializer is IPoolInitializer, PeripheryImmutableState {
    /// @inheritdoc IPoolInitializer
    function createAndInitializePoolIfNecessary(
        address token0,
        address token1,
        uint24 fee,
        uint160 sqrtPriceX96
    ) external payable override returns (address pool) {
        require(token0 < token1);
        pool = IUniswapV3Factory(factory).getPool(token0, token1, fee);

        if (pool == address(0)) {
            pool = IUniswapV3Factory(factory).createPool(token0, token1, fee);
            IUniswapV3Pool(pool).initialize(sqrtPriceX96);
        } else {
            (uint160 sqrtPriceX96Existing, , , , , , ) = IUniswapV3Pool(pool).slot0();
            if (sqrtPriceX96Existing == 0) {
                IUniswapV3Pool(pool).initialize(sqrtPriceX96);
            }
        }
    }
}

Pool을 만드는 함수를 가지고 있다.

여기서 sqrtPriceX96은 Pool에서 Price의 제곱근을 의미한다.

풀에 사용될 두 개의 토큰과 fee, 그리고 Price를 받아 새로운 pool을 생성한다.

 

유니스왑 백서

유니스왑 v3는 pool의 virtual reserve인 x, y를 각각 트래킹을 하는 대신 liquidity(L)과 sqrtPrice를 트래킹한다.

x, y는 liquidity와 sqrtPrice를 통해 계산이 가능하다.

sqrtPrice는 swap을 할 때 변하고, liquidity는 민팅을 하거나 liquidity를 burn할 때 변경되므로 동시에 같이 변하는 일이 없다.

이는 변수를 관리하기 편하게 해준다.

contract UniswapV3Pool is IUniswapV3Pool, NoDelegateCall {
   ...
   
    /// @inheritdoc IUniswapV3PoolImmutables
    address public immutable override factory;
    /// @inheritdoc IUniswapV3PoolImmutables
    address public immutable override token0;
    /// @inheritdoc IUniswapV3PoolImmutables
    address public immutable override token1;
    /// @inheritdoc IUniswapV3PoolImmutables
    uint24 public immutable override fee;

    /// @inheritdoc IUniswapV3PoolImmutables
    int24 public immutable override tickSpacing;
    
   ...
   
   struct Slot0 {
        // the current price
        uint160 sqrtPriceX96;
        // the current tick
        int24 tick;
        // the most-recently updated index of the observations array
        uint16 observationIndex;
        // the current maximum number of observations that are being stored
        uint16 observationCardinality;
        // the next maximum number of observations to store, triggered in observations.write
        uint16 observationCardinalityNext;
        // the current protocol fee as a percentage of the swap fee taken on withdrawal
        // represented as an integer denominator (1/x)%
        uint8 feeProtocol;
        // whether the pool is locked
        bool unlocked;
    }
    /// @inheritdoc IUniswapV3PoolState
    Slot0 public override slot0;
    
    /// @inheritdoc IUniswapV3PoolState
    uint256 public override feeGrowthGlobal0X128;
    /// @inheritdoc IUniswapV3PoolState
    uint256 public override feeGrowthGlobal1X128;

    // accumulated protocol fees in token0/token1 units
    struct ProtocolFees {
        uint128 token0;
        uint128 token1;
    }
    /// @inheritdoc IUniswapV3PoolState
    ProtocolFees public override protocolFees;
    
    uint128 public override liquidity
    ...
 }

core에 들어있는 UniswapV3Pool contract에서 sqrtPrice, tick, feeProtocol 등을 담고 있는 slot0와 liquidity를 가지고 있는 것을 확인할 수 있다.

tickSpacing은 tick이 설정될 수 있는 간격을 의미한다. 즉 모든 tick은 tickSpacing으로 나누어져야 한다.

tickSpacing이 작으면 tick이 촘촘하게 생성이 될 수 있으나 tick을 생성할 때 gas가 들어가므로 gas를 좀 더 많이 소모할 가능성이 높다.

fee는 swapper들이 내야하는 수수료를 나타내고, feeProtocol은 liquidity provider에게 가지 않고 프로토콜에게 가는 fee의 비율을 나타낸다. (default는 0이나 UNI 가버넌스에 의해 변경될 수 있다)

feeGrowthGlobal0X128feeGrowthGlobal1X128은 단위 L당 벌어들인 총 fee를 의미한다. 이때 protocol Fee는 제외한다.

ProtocolFees는 아직 가져오지 못한 토큰별 프로토콜 fee를 의미한다. 이 protocol fee들은 UNI 가버넌스가 collectProtocol 함수를 통해 가져올 수 있다.

fg1은 feeGrowthGlobal, fp1은 protocolFee를 의미

위 식은 싱글 틱에서 스왑이 일어날 때 증가되는 fee의 양이다.

감마는 수수료율, 파이는 프로토콜 fee에 배정되는 비율을 의미한다.

2.  Swap 코드 살펴보기 - 싱글 틱

스왑되는 양이 많지 않을 때 가격이 다음 tick을 넘어가지 않고 스왑할 수 있다. 이 상황을 싱글 틱에서 스왑이라고 하겠다. 이때는 x*y = k가 성립한다.

yin을 token1이 보내진 양이라고 하면

y의 증가량

y토큰의 증가한 양은 수수료를 빼서 위 식과 같이 계산이 된다.

따라서 x도 위 식처럼 계산이 된다.

스왑시에는 L이 일정하고, 이때  price와 x, y와의 관계식을 사용하면 x및 y변화량을 L과 P로 나타낼 수 있다.

 

이를 코드에서 확인해보자

// v3-periphery SwapRouter.sol

/// @inheritdoc ISwapRouter
    function exactInputSingle(ExactInputSingleParams calldata params)
        external
        payable
        override
        checkDeadline(params.deadline)
        returns (uint256 amountOut)
    {
        amountOut = exactInputInternal(
            params.amountIn,
            params.recipient,
            params.sqrtPriceLimitX96,
            SwapCallbackData({path: abi.encodePacked(params.tokenIn, params.fee, params.tokenOut), payer: msg.sender})
        );
        require(amountOut >= params.amountOutMinimum, 'Too little received');
    }
    
    /// @dev Performs a single exact input swap
    function exactInputInternal(
        uint256 amountIn,
        address recipient,
        uint160 sqrtPriceLimitX96,
        SwapCallbackData memory data
    ) private returns (uint256 amountOut) {
        // allow swapping to the router address with address 0
        if (recipient == address(0)) recipient = address(this);

        (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();

        bool zeroForOne = tokenIn < tokenOut;

        (int256 amount0, int256 amount1) =
            getPool(tokenIn, tokenOut, fee).swap(
                recipient,
                zeroForOne,
                amountIn.toInt256(),
                sqrtPriceLimitX96 == 0
                    ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1)
                    : sqrtPriceLimitX96,
                abi.encode(data)
            );

        return uint256(-(zeroForOne ? amount1 : amount0));
    }
// v3 core UniswapV3Pool.sol

function swap(
    address recipient,
    bool zeroForOne,
    int256 amountSpecified,
    uint160 sqrtPriceLimitX96,
    bytes calldata data
) external override noDelegateCall returns (int256 amount0, int256 amount1) {
...
}

recipient : swap을 시도한 from address

zeroForOne : Token0이 Token1로 바뀌는지

amountSpecified : convert될 양

sqrtPriceLimitX96 : price의 upper limit 

 

swap function의 body에 다음 부분을 확인할 수 있다

// compute values to swap to the target tick, price limit, or point where input/output amount is exhausted
            (state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
                state.sqrtPriceX96,
                (zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
                    ? sqrtPriceLimitX96
                    : step.sqrtPriceNextX96,
                state.liquidity,
                state.amountSpecifiedRemaining,
                fee
            );

computeSwapStep 함수를 보자

    /// @dev The fee, plus the amount in, will never exceed the amount remaining if the swap's `amountSpecified` is positive
    /// @param sqrtRatioCurrentX96 The current sqrt price of the pool
    /// @param sqrtRatioTargetX96 The price that cannot be exceeded, from which the direction of the swap is inferred
    /// @param liquidity The usable liquidity
    /// @param amountRemaining How much input or output amount is remaining to be swapped in/out
    /// @param feePips The fee taken from the input amount, expressed in hundredths of a bip
    /// @return sqrtRatioNextX96 The price after swapping the amount in/out, not to exceed the price target
    /// @return amountIn The amount to be swapped in, of either token0 or token1, based on the direction of the swap
    /// @return amountOut The amount to be received, of either token0 or token1, based on the direction of the swap
    /// @return feeAmount The amount of input that will be taken as a fee
function computeSwapStep(
        uint160 sqrtRatioCurrentX96,
        uint160 sqrtRatioTargetX96,
        uint128 liquidity,
        int256 amountRemaining,
        uint24 feePips
    )
        internal
        pure
        returns (
            uint160 sqrtRatioNextX96,
            uint256 amountIn,
            uint256 amountOut,
            uint256 feeAmount
        )
    {
    ...

computeSwapStep에서는 다시 SqrtPriceMath의 getAmount0Delta 함수와 getNextSqrtPriceFromOutput 함수 등을 호출한다.

    /// @notice Gets the amount0 delta between two prices
    /// @dev Calculates liquidity / sqrt(lower) - liquidity / sqrt(upper),
    /// i.e. liquidity * (sqrt(upper) - sqrt(lower)) / (sqrt(upper) * sqrt(lower))
    /// @param sqrtRatioAX96 A sqrt price
    /// @param sqrtRatioBX96 Another sqrt price
    /// @param liquidity The amount of usable liquidity
    /// @param roundUp Whether to round the amount up or down
    /// @return amount0 Amount of token0 required to cover a position of size liquidity between the two passed prices
    function getAmount0Delta(
        uint160 sqrtRatioAX96,
        uint160 sqrtRatioBX96,
        uint128 liquidity,
        bool roundUp
    ) internal pure returns (uint256 amount0) {
    ...
    }

 

getAmount0Delta 함수의 주석 부분을 보면 liquidity / sqrt(lower) - liquidity / sqrt(upper) 부분을 볼 수 있다.

이는 백서에서 나온 식과 같다.

(그 이후까지 들어가면 너무 복잡해져서 생략..)

3. Position

position은 lowerTick과  upperTick으로 구성된다.

/// @inheritdoc INonfungiblePositionManager
    function mint(MintParams calldata params)
        external
        payable
        override
        checkDeadline(params.deadline)
        returns (
            uint256 tokenId,
            uint128 liquidity,
            uint256 amount0,
            uint256 amount1
        )
    {
        IUniswapV3Pool pool;
        (liquidity, amount0, amount1, pool) = addLiquidity(
            AddLiquidityParams({
                token0: params.token0,
                token1: params.token1,
                fee: params.fee,
                recipient: address(this),
                tickLower: params.tickLower,
                tickUpper: params.tickUpper,
                amount0Desired: params.amount0Desired,
                amount1Desired: params.amount1Desired,
                amount0Min: params.amount0Min,
                amount1Min: params.amount1Min
            })
        );
        
        _mint(params.recipient, (tokenId = _nextId++));

        bytes32 positionKey = PositionKey.compute(address(this), params.tickLower, params.tickUpper);
        (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);

        // idempotent set
        uint80 poolId =
            cachePoolKey(
                address(pool),
                PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee})
            );

        _positions[tokenId] = Position({
            nonce: 0,
            operator: address(0),
            poolId: poolId,
            tickLower: params.tickLower,
            tickUpper: params.tickUpper,
            liquidity: liquidity,
            feeGrowthInside0LastX128: feeGrowthInside0LastX128,
            feeGrowthInside1LastX128: feeGrowthInside1LastX128,
            tokensOwed0: 0,
            tokensOwed1: 0
        });

        emit IncreaseLiquidity(tokenId, liquidity, amount0, amount1);
    }

peripheryNonfungiblePositionManager의 mint 부분을 보면 포지션을 민팅하는 함수가 있다.

addliquidity 함수를 보면 core 로직의 pool의 민트 함수를 호출한다.

민트 함수는 _modifyPosition 함수를 호출한다.

    /// @dev Effect some changes to a position
    /// @param params the position details and the change to the position's liquidity to effect
    /// @return position a storage pointer referencing the position with the given owner and tick range
    /// @return amount0 the amount of token0 owed to the pool, negative if the pool should pay the recipient
    /// @return amount1 the amount of token1 owed to the pool, negative if the pool should pay the recipient
    function _modifyPosition(ModifyPositionParams memory params)
        private
        noDelegateCall
        returns (
            Position.Info storage position,
            int256 amount0,
            int256 amount1
        )
    {
    ...
    position = _updatePosition(
            params.owner,
            params.tickLower,
            params.tickUpper,
            params.liquidityDelta,
            _slot0.tick
        );
    ...
    if (params.liquidityDelta != 0) {
            if (_slot0.tick < params.tickLower) {
                // current tick is below the passed range; liquidity can only become in range by crossing from left to
                // right, when we'll need _more_ token0 (it's becoming more valuable) so user must provide it
                ...
             } else if (_slot0.tick < params.tickUpper) {
             ...
             liquidity = LiquidityMath.addDelta(liquidityBefore, params.liquidityDelta);
             }
      ...
      }
   }

포지션을 업데이트하고 token0, token1 및 liquidity를 업데이트 한다.

현재의 틱이 파라미터의 lower tick과  upper tick 사이인지, 아니인지에 따라 계산식이 달라진다.

4. Swap 코드 살펴보기 - Tick-Indexed State

 

// v3-core/contracts/interface/poo/IUniswapV3PoolState.sol

interface IUniswapV3PoolState {
   ...
   function ticks(int24 tick)
        external
        view
        returns (
            uint128 liquidityGross,
            int128 liquidityNet,
            uint256 feeGrowthOutside0X128,
            uint256 feeGrowthOutside1X128,
            int56 tickCumulativeOutside,
            uint160 secondsPerLiquidityOutsideX128,
            uint32 secondsOutside,
            bool initialized
        );
        ...

}

 

// v3-core/contracts/UniswapV3Pool.sol

contract UniswapV3Pool is IUniswapV3Pool, NoDelegateCall {
  ...
  /// @inheritdoc IUniswapV3PoolState
  mapping(int24 => Tick.Info) public override ticks;
  /// @inheritdoc IUniswapV3PoolState
  mapping(int16 => uint256) public override tickBitmap;
  /// @inheritdoc IUniswapV3PoolState
  mapping(bytes32 => Position.Info) public override positions;
  /// @inheritdoc IUniswapV3PoolState
  Oracle.Observation[65535] public override observations;
  ...
}

Uniswap의 pool은 interface인 IUniswapV3PoolState을 implement하고 있다.

UniswapV3Pool이 int24 tick을 받아 Tick.Info를 반환하는 mapping을 가지고 있음을 알 수 있다.

각 tick마다 7개의 tick에 관련된 변수를 트래킹하고 있는 것이다.

 

- LiquidityNet : 틱을 지나갈 때 출입할 유동성

- LiquidityGross : 누적 유동성

- feeGrowthOutside : 주어진 구간에 누적된 fee -> 단위 유동성당 얻은 fee를 계산 가능 (자세한 식은 백서에)

- secondOutside, tickCumulativeOutside, secondsPerLiquidityOutsideX128 : 컨트랙트 내부에서 쓰는 것 아님. 외부 함수를 위해 제공

Comments