« 上一篇下一篇 »

UniswapV3简析(二)

UniswapV3Pool流动池合约实现了Uniswap协议最核心的功能, 它并不与链下直接进行交互, 而只是与外围合约比如NonfungiblePositionManager, SwapRouter进行交互.

Uniswap本质上是由N多个独立的流动池构成, 每个流动池由(token0,token1,fee)三个参数唯一进行标识, 通过这三个参数可以唯一检索出对应的Pool地址, 参考代码如下:

PoolAddress.PoolKey memory poolKey = PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee});
pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));

这里有两个细节需要强调:

第一, 在部署Pool合约的时候会通过 keccak256(abi.encode(token0, token1, fee) 将 token0, token1, fee 作为输入, 得到一个哈希值, 并将其作为 salt 来创建合约. 因为指定了 salt, solidity 会使用 EVM 的 CREATE2 指令来创建合约, 使用 CREATE2 指令的好处是, 只要合约的 bytecode 及 salt 不变, 那么创建出来的地址也将不变. 这样我们就可以直接在链下计算出已经创建的流动池的地址, 而不需要进行链上查询.

第二, 在部署Pool合约的时候会先将token0和token1按其合约地址按从小到大进行排序, 换句话说流动池中的token0合约地址肯定小于token1合约地址.

之前说过, 每一个流动池实际上是一个流动性聚合器, 可以同时添加多个流动性仓位, 每个流动性仓位由(owner,tickLower,tickUpper)三个参数唯一标识, 这三个参数的含义分别为:

owner: 流动性仓位的拥有者地址(实际上就是NonfungiblePositionManager合约)
tickLower: 流动性仓位做市区间最低价的tick索引
tickUpper流动性仓位做市区间最高价的tick索引

我们可以通过上面三个参数唯一检索出对应的流动性仓位, 参考代码如下:

library Position {
    /// @notice Returns the Info struct of a position, given an owner and position boundaries
    /// @param self The mapping containing all user positions
    /// @param owner The address of the position owner
    /// @param tickLower The lower tick boundary of the position
    /// @param tickUpper The upper tick boundary of the position
    /// @return position The position info struct of the given owners' position
    function get(
        mapping(bytes32 => Info) storage self,
        address owner,
        int24 tickLower,
        int24 tickUpper
    ) internal view returns (Position.Info storage position) {
        position = self[keccak256(abi.encodePacked(owner, tickLower, tickUpper))];
    }

    ......

}

注意, 这里的仓位拥有者都是NonfungiblePositionManager合约, 而不是实际的用户地址, 只所以这样做, 一是可以让不同用户添加的价格区间一样的流动性合并管理, 二是为了让流动性与实际用户进行解耦, 这样在外围合约NonfungiblePositionManager中可以独立管理流动性, 每个流动性仓位分配一个NFT给到用户, 由NFT来代表流动性仓位的所有权, 用户可以通过转移NFT来进行流动性仓位所有权的转移

每一个流动性仓位由以下数据结构所表示:

library Position {
    // info stored for each user's position
    struct Info {
        // the amount of liquidity owned by this position
        uint128 liquidity;
        // fee growth per unit of liquidity as of the last update to liquidity or fees owed
        uint256 feeGrowthInside0LastX128;
        uint256 feeGrowthInside1LastX128;
        // the fees owed to the position owner in token0/token1
        uint128 tokensOwed0;
        uint128 tokensOwed1;
    }

    ......

}

因为每个流动池都包含多个流动性仓位, 交易过程中会经常跨越多个流动性仓位的价格边界, 为了算法需要, 每个流动性仓位都在价格区间的两端分别对应一个tick结构, 里面包含了这个价格点位上的若干元数据, 其数据结构如下:

library Tick {

    // info stored for each initialized individual tick
    struct Info {
        // the total position liquidity that references this tick
        uint128 liquidityGross;
        // amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left),
        int128 liquidityNet;
        // fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick)
        // only has relative meaning, not absolute — the value depends on when the tick is initialized
        uint256 feeGrowthOutside0X128;
        uint256 feeGrowthOutside1X128;
        // the cumulative tick value on the other side of the tick
        int56 tickCumulativeOutside;
        // the seconds per unit of liquidity on the _other_ side of this tick (relative to the current tick)
        // only has relative meaning, not absolute — the value depends on when the tick is initialized
        uint160 secondsPerLiquidityOutsideX128;
        // the seconds spent on the other side of the tick (relative to the current tick)
        // only has relative meaning, not absolute — the value depends on when the tick is initialized
        uint32 secondsOutside;
        // true iff the tick is initialized, i.e. the value is exactly equivalent to the expression liquidityGross != 0
        // these 8 bits are set to prevent fresh sstores when crossing newly initialized ticks
        bool initialized;
    }

    ......

}

这里简单总结一下, 每一个流动池包含一个交易对, 其由(token0,token1,fee)唯一标识, 而每个流动池又是一个流动性聚合器其同时包含多个流动性仓位, 而每个流动性仓位又可以由(owner,tickLower,tickUpper)唯一进行标识, 最后每个流动性仓位做市价格区间一高一低两个价格点位上都分别对应一个tick数据结构用于包含价格元数据, 整体结构如下图所示:

每一个流动池内部都有一个Slot0结构, 里面保存了当前价格:

struct Slot0 {
        // the current price
        uint160 sqrtPriceX96;
        // the current tick
        int24 tick;
                ......
    }


添加流动性

function mint(
        address recipient,
        int24 tickLower,
        int24 tickUpper,
        uint128 amount,
        bytes calldata data
    ) external override lock returns (uint256 amount0, uint256 amount1)

参数说明:
recipient - 流动性仓位的拥有者地址
tickLower - 流动性仓位做市价格区间最低点位
tickUpper - 流动性仓位做市价格区间最高点位
amount - 要添加的流动性大小, 也就是L的值
data - 回调函数参数

此函数会调用调用内部函数_modifyPosition, 然后_modifyPosition会调用_updatePosition, 在 _updatePosition函数中会更新position, 及其对应的tick等数据结构, 然后_modifyPosition会依据做市价格区间及其流动L的大小计算需要充值的token0,token1的数量, 假设当前流动池价格为Pc, 用户添加流动性仓位的价格区间的 [Pa, Pb] (Pa < Pb), 实际会分为三种情况:

1.做市价格区间大于当前实际价格, 只能添加x token, 如下图:

流动性L的计算公式为:

2.做市价格区间小于当前实际价格, 只能添加y token, 如下图:

流动性L的计算公式为:

3.做市价格区间包含当前实际价格, 按一定比例分别添加x token和y token, 如下图:

流动性L的计算公式为:

具体的代码实现在contracts\libraries\SqrtPriceMath.sol中, 参考代码:
amount0 = SqrtPriceMath.getAmount0Delta(
                    _slot0.sqrtPriceX96,
                    TickMath.getSqrtRatioAtTick(params.tickUpper),
                    params.liquidityDelta
                );
amount1 = SqrtPriceMath.getAmount1Delta(
                    TickMath.getSqrtRatioAtTick(params.tickLower),
                    _slot0.sqrtPriceX96,
                    params.liquidityDelta
                );

当计算好实际要充值的x token数量与y token数量以后, 就会回调 NonfungiblePositionManager合约的uniswapV3MintCallback函数, 在这个函数里面完成真正的转账充值工作, 然后会检查是否成功收到转账, 如果出现问题系统会进行回滚, 参考代码如下:

if (amount0 > 0) balance0Before = balance0();
if (amount1 > 0) balance1Before = balance1();
IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(amount0, amount1, data);
if (amount0 > 0) require(balance0Before.add(amount0) <= balance0(), 'M0');
if (amount1 > 0) require(balance1Before.add(amount1) <= balance1(), 'M1');

大致流程如下图所示:
1.用户发起添加流动性的操作
2.NonfungiblePositionManager合约调用UniswapV3Pool合约的mint函数
3.UniswapV3Pool合约在mint函数中回调NonfungiblePositionManager合约的uniswapV3MintCallback函数
4.在uniswapV3MintCallback函数中会分别调用池中两个代币对应的ERC20代币合约的transferFrom函数将用户的token转账给Pool


减少流动性

function burn(
        int24 tickLower,
        int24 tickUpper,
        uint128 amount
    ) external override lock returns (uint256 amount0, uint256 amount1)

参数说明:
tickLower - 做市价格区间最低点位, 用于检索对应流动性仓位
tickUpper - 做市价格区间最高点位, 用于检索对应流动性仓位
amount - 要减少的流动性数量, 也就是L值

此函数逻辑整体较为简单, 内部调用_modifyPosition修改对应流动性仓位的相关数据, 它通过(owner,tickLower,tickUpper)三个参数唯一检索出需要修改的流动性仓位. 因为减少了流动性, 所以必然会空闲出相应数量的token, 程序会把空闲出来的token数量保存到对应仓位的position.tokensOwed0,position.tokensOwed1成员变量中, 参考代码如下:

if (amount0 > 0 || amount1 > 0) {
    (position.tokensOwed0, position.tokensOwed1) = (
        position.tokensOwed0 + uint128(amount0),
        position.tokensOwed1 + uint128(amount1)
    );
}

需要强调的是, 这里仅仅只是记录空闲出来的token数量, 而并不会把这些token返还给客户.


提取闲置token及其手续费

function collect(
        address recipient,
        int24 tickLower,
        int24 tickUpper,
        uint128 amount0Requested,
        uint128 amount1Requested
    ) external override lock returns (uint128 amount0, uint128 amount1)

参数说明:
recipient - 接收返还的用户地址
tickLower - 做市价格区间最低点位, 用于检索对应流动性仓位
tickUpper - 做市价格区间最高点位, 用于检索对应流动性仓位
amount0Requested - 想要提取的token0数量
amount1Requested - 想要提取token1数量

当用户进行减少流动性的操作后, 空闲出来的token数量会被保存到对应仓位的position.tokensOwed0,position.tokensOwed1成员变量中, 另外就是用户充当LP赚取的手续费也会保存在这俩成员变量中, 本函数会真正将相应数量的token返还给用户, 代码很少, 一看便知, 参考代码如下:

Position.Info storage position = positions.get(msg.sender, tickLower, tickUpper); #通过(owner,tickLower,tickUpper)找到对应流动性仓位
amount0 = amount0Requested > position.tokensOwed0 ? position.tokensOwed0 : amount0Requested; #获取实际可提取数量
amount1 = amount1Requested > position.tokensOwed1 ? position.tokensOwed1 : amount1Requested; #获取实际可提取数量
if (amount0 > 0) {
    position.tokensOwed0 -= amount0; #更新数量
    TransferHelper.safeTransfer(token0, recipient, amount0); #真正转账
}
if (amount1 > 0) {
    position.tokensOwed1 -= amount1; #更新数量
    TransferHelper.safeTransfer(token1, recipient, amount1); #真正转账
}


交易兑换

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

参数说明:

recipient - 接收兑换输出的地址
zeroForOne - 兑换方向, 当true时兑换方向为:token0->token1, 当false时兑换方向为:token1到token0
amountSpecified - 兑换数量, 当数值为正数代表是输入token的数量, 当数值为负数代表想达到的输出token数量
sqrtPriceLimitX96 - 价格限制, 根据方向最终价格不能大于或者小于该值
data - 回调函数参数

本函数提供token交易兑换功能, 算是较为复杂的部分, 因为交易过程中可能会跨越多个流动性仓位, 算法有一定的复杂性, 具体细节网上存在大量说明, 就不在赘述, 基本逻辑就是:
1.已知输入token的数量, 计算出可以输出多少数量的token, 及其兑换结束后价格移动到什么点位
2.已知输出token的数量, 计算出需要多少数量的输入token, 及其兑换结束后价格移动到什么点位

分为四种情况处理:

(zeroForOne:true, amountSpecified:正数) ==>> 我用amountSpecified数量的token0, 最多可以兑换多少数量的token1?
(zeroForOne:true, amountSpecified:负数) ==>> 我需要兑换abs(amountSpecified)数量的token1, 最少需要准备多少数量的token0?
(zeroForOne:false, amountSpecified:正数) ==>> 我用amountSpecified数量的token1, 最多可以兑换多少数量的token0?
(zeroForOne:falseamountSpecified:负数) ==>> 兑换abs(amountSpecified)数量的token0, 最少需要准备多少数量的token1?

真正在token交易过程中, 可能会涉及路径选择问题, 比如ETH->DAI, 可以直接在ETH/DAI流动池兑换, 也可以利用ETH->USDC->DAI这个路径, 通过ETH/USDC, USDC/DAI两个流动池接力完成兑换
至于采取哪个路径, 由链下前端决定, 链下前端会根据情况计算出最优路径, 关于程序中路径的编码如下图所示:

当最终计算好要兑换的输入输出token数量以后, swap函数会首先将用户需求的token发送给用户, 然后会回调位于SwapRouter合约中的回调函数uniswapV3SwapCallback, 在uniswapV3SwapCallback函数中最终会用ERC20合约的transferFrom方法将用户的token转帐到流动池合约中, 至此完成兑换过程.

swap函数的最后会首先将token转账给用户地址, 然后回调uniswapV3SwapCallback函数, 参考代码如下: 
if (zeroForOne) {
    if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
    uint256 balance0Before = balance0();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
} else {
    if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));
    uint256 balance1Before = balance1();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA');
}

然后存在于SwapRouter合约中的回调函数uniswapV3SwapCallback会将用户的token转帐到流动池合约中, 最终完成兑换过程, 参考代码如下:
function uniswapV3SwapCallback(
    int256 amount0Delta,
    int256 amount1Delta,
    bytes calldata _data
) external override {
    require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported
    SwapCallbackData memory data = abi.decode(_data, (SwapCallbackData));
    (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
    CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee);
    (bool isExactInput, uint256 amountToPay) =
        amount0Delta > 0
            ? (tokenIn < tokenOut, uint256(amount0Delta))
            : (tokenOut < tokenIn, uint256(amount1Delta));
    if (isExactInput) {
        pay(tokenIn, data.payer, msg.sender, amountToPay);
    } else {
        // either initiate the next swap or pay
        if (data.path.hasMultiplePools()) {
            data.path = data.path.skipToken();
            exactOutputInternal(amountToPay, msg.sender, 0, data);
        } else {
            amountInCached = amountToPay;
            tokenIn = tokenOut; // swap in/out because exact output swaps are reversed
            pay(tokenIn, data.payer, msg.sender, amountToPay);
        }
    }
}

因为采用了回调函数, 所以这里会引出一个非常有意思的概念就是闪电交易(flash swap), 看代码流程就会发现, 流动池合约首先会将用户需求的token发送给用户, 然后再调用回调函数, 流动池合约期望在回调函数中把用户相应token转账回流动池合约来完成一次兑换, 那么这里就会有一个很有意思的现象, 在回调函数将相应token转账回流动池合约之前那一小段时间, 是不是相当于我们向流动池借用了相应token!!!, 如果我们利用自己开发的合约实现此回调函数, 就可以充分利用区块链的特性实现一些非常有意思的功能, 利用这个思路还可以实现闪电贷(flash loan).

最后总结下兑换流程, 如下图所示(以token0兑换token1为例):

1.用户发起兑换交易
2.SwapRouter合约调用UniswapV3Pool合约的swap函数
3.在swap函数中会调用token1合约的transfer方法向用户转账相应数量的token1
4.转账完成后会回调存在于SwapRouter合约中的uniswapV3SwapCallback函数
5.uniswapV3SwapCallback函数会利用token0合约的transferFrom方法将用户相应数量的token0转帐给流动池合约

至此用户在SwapRouter合约的帮助下完成交易兑换过程 (用户EOA地址 <<==>> 流动池合约地址)


闪电贷

function flash(
        address recipient,
        uint256 amount0,
        uint256 amount1,
        bytes calldata data
    ) external override lock noDelegateCall

参数说明:

recipient - 想借token的用户地址,比如可以是用户自己开发的合约的地址
amount0 - 用户想借入的token0数量
amount1 - 用户想借入的token1数量
data - 回调函数参数

本函数实现了闪电贷(flash loan), 基本原理前文已经有所说明, 基本的借贷要素如下:
借入方: 用户(需要自己开发智能合约)
借出方: 某流动池
借贷标的: 该流动池所拥有的两种token

代码不多, 这里直接讲重点, 参考代码如下: 
function flash(
    address recipient,
    uint256 amount0,
    uint256 amount1,
    bytes calldata data
) external override lock noDelegateCall {
    ...

    uint256 fee0 = FullMath.mulDivRoundingUp(amount0, fee, 1e6); #用户需要支付的借贷手续费
    uint256 fee1 = FullMath.mulDivRoundingUp(amount1, fee, 1e6); #用户需要支付的借贷手续费
    uint256 balance0Before = balance0(); #借贷前的金额
    uint256 balance1Before = balance1(); #借贷前的金额

    if (amount0 > 0) TransferHelper.safeTransfer(token0, recipient, amount0); #借出token给用户
    if (amount1 > 0) TransferHelper.safeTransfer(token1, recipient, amount1); #借出token给用户

    IUniswapV3FlashCallback(msg.sender).uniswapV3FlashCallback(fee0, fee1, data); #用户应该自己开发合约并实现此回调函数,注意参数是需要支付的手续费数量

    uint256 balance0After = balance0(); #用户已经连本带利归还后的余额
    uint256 balance1After = balance1(); #用户已经连本带利归还后的余额

    require(balance0Before.add(fee0) <= balance0After, 'F0'); #检查用户是不是已经连本带利足额还款, 没还就回滚整个交易
    require(balance1Before.add(fee1) <= balance1After, 'F1'); #检查用户是不是已经连本带利足额还款, 没还就回滚整个交易

    ...
}

简要总结下流程:
1.用户需要自己开发智能合约并按接口规范实现相应回调函数, 并和相应流动池建立连接, 然后调用流动池合约的flash函数
2.flash函数中会将用户需要的token转账到用户指定的地址, 然后回调用户合约的回调函数
3.用户在自己的回调函数中按需完成自己的业务逻辑, 然后连本带利将相应token返还给流动池
4.流动池合约检查用户是不是已经连本带利足额还款, 没还就回滚整个交易

闪电贷这种创新让资金借贷效率产生了一个巨大的飞跃, 可以说是DeFi特有的精髓.