剖析DeFi交易产品之UniswapV3:交易路由合约

2023-11-13 17:19:38 浏览数 (2)

SwapRouter 合约封装了面向用户的交易接口,但不再像 UniswapV2Router 一样根据不同交易场景拆分为了那么多函数,UniswapV3 的 SwapRouter 核心就只有 4 个交易函数:

  • exactInputSingle:指定输入数量的单池内交易
  • exactOutputSingle:指定输出数量的单池内交易
  • exactInput:指定输入数量和交易路径的交易
  • exactOutput:指定输出数量和交易路径的交易

Single 的只支持单池内的交易,而不带 Single 的则支持跨不同池子的互换交易。

exactInputSingle

先来看简单的单池交易,以 exactInputSingle 为始,其代码实现如下:

代码语言:javascript复制
struct ExactInputSingleParams {
    address tokenIn;   //输入token
    address tokenOut;  //输出token
    uint24 fee;        //手续费率
    address recipient; //收款地址
    uint256 deadline;  //过期时间
    uint256 amountIn;  //指定的输入token数量
    uint256 amountOutMinimum;  //输出token的最小数量
    uint160 sqrtPriceLimitX96; //限定的价格
}

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

其入参有 9 个参数,返回值就一个 amountOut,即输出的 token 数量。

从代码上可看出,实际的逻辑实现是在内部函数 exactInputInternal。查看该内部函数之前,我们先来了解下 SwapCallbackData。我们从上面代码可以看到,调用 exactInputInternal 时,最后一个传入的参数就是 SwapCallbackData,这其实是一个结构体,定义了两个属性:

代码语言:javascript复制
struct SwapCallbackData {
    bytes path;
    address payer;
}

path 表示交易路径,在以上代码中,就是由 tokenInfeetokenOut 这三个变量拼接而成。payer 表示支付输入 token 的地址,上面的就是 msg.sender

接着,来看看内部函数 exactInputInternal 的代码实现:

代码语言:javascript复制
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();
    //当tokenIn<tokenOUt时,则说明tokenIn为token0,所以是要将token0兑换成token1
    bool zeroForOne = tokenIn < tokenOut;
    //调用底层池子的swap函数执行交易
    (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)
        );
    //返回amountOut
    return uint256(-(zeroForOne ? amount1 : amount0));
}

首先,如果 recipient 地址为零地址的话,那会把 recipient 重置为当前合约地址。

接着,通过 data.path.decodeFirstPool() 从路径中解码得出 tokenIntokenOutfeedecodeFirstPool 函数是在库合约 Path 里实现的。

布尔类型的 zeroForOne 表示底层 token0token1 的兑换方向,为 true 表示用 token0 兑换 token1false 则反之。因为底层的 token0 是小于 token1 的,所以,当 tokenIn 也小于 tokenOut 的时候,说明 tokenIn == token0,所以 zeroForOnetrue

然后,通过 getPool 函数可得到池子地址,再调用底层池子的 swap 函数来执行实际的交易逻辑。

最后,我们要得到的是 amountOut,这是 amount0 和 amount1 中的其中一个。我们已经知道,zeroForOnetrue 的时候,tokenIn 等于 token0,所以 tokenOut 就是 token1,因此 amountOut 就是 amount1。另外,对底层池子来说,属于输出的时候,返回的数值是负数,即 amount1 其实是一个负数,因此需要再加个负号转为正数的 uint256 类型。

在这个函数里,我们可以看出并没有支付 token 的功能,但前面讲解 UniswapV3Pool 时已经了解到,支付是在回调函数 uniswapV3SwapCallback 里完成的。因为这个回调函数会涉及到所有 4 种交易类型,所以我们留到最后再来讲解。

exactOutputSingle

接着,来看 exactOutputSingle 函数的实现,其代码如下:

代码语言:javascript复制
struct ExactOutputSingleParams {
    address tokenIn;   //输入token
    address tokenOut;  //输出token
    uint24 fee;        //手续费率
    address recipient; //收款地址
    uint256 deadline;  //过期时间
    uint256 amountOut; //指定的输出token数量
    uint256 amountInMaximum;   //输入token的最大数量
    uint160 sqrtPriceLimitX96; //限定的价格
}

function exactOutputSingle(ExactOutputSingleParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (uint256 amountIn)
{
    // avoid an SLOAD by using the swap return data
    amountIn = exactOutputInternal(
        params.amountOut,
        params.recipient,
        params.sqrtPriceLimitX96,
        SwapCallbackData({path: abi.encodePacked(params.tokenOut, params.fee, params.tokenIn), payer: msg.sender})
    );

    require(amountIn <= params.amountInMaximum, 'Too much requested');
    // has to be reset even though we don't use it in the single hop case
    amountInCached = DEFAULT_AMOUNT_IN_CACHED;
}

可看出,exactOutputSingle 函数的实现与 exactInputSingle 函数大同小异。首先,参数上,只有两个不同,exactInputSingle 函数指定的是 amountInamountOutMinimum;而 exactOutputSingle 函数改为了 amountOutamountInMaximum,即输出是指定的,而输入则限制了最大值。其次,实际逻辑封装在了 exactOutputInternal 内部函数,而且传给该内部函数的最后一个参数的 path 组装顺序也不一样了,排在第一位的是 tokenOut

核心实现还是在 exactOutputInternal 内部函数,其代码实现如下:

代码语言:javascript复制
function exactOutputInternal(
    uint256 amountOut,
    address recipient,
    uint160 sqrtPriceLimitX96,
    SwapCallbackData memory data
) private returns (uint256 amountIn) {
    // allow swapping to the router address with address 0
    if (recipient == address(0)) recipient = address(this);
    //从路径中解码出第一个池子
    (address tokenOut, address tokenIn, uint24 fee) = data.path.decodeFirstPool();
    //是否token0兑换token1
    bool zeroForOne = tokenIn < tokenOut;
    //调用底层池子的swap函数执行交易
    (int256 amount0Delta, int256 amount1Delta) =
        getPool(tokenIn, tokenOut, fee).swap(
            recipient,
            zeroForOne,
            -amountOut.toInt256(), //指定输出需转为负数
            sqrtPriceLimitX96 == 0
                ? (zeroForOne ? TickMath.MIN_SQRT_RATIO   1 : TickMath.MAX_SQRT_RATIO - 1)
                : sqrtPriceLimitX96,
            abi.encode(data)
        );
  
    //确定amountIn和amountOut
    uint256 amountOutReceived;
    (amountIn, amountOutReceived) = zeroForOne
        ? (uint256(amount0Delta), uint256(-amount1Delta))
        : (uint256(amount1Delta), uint256(-amount0Delta));
    // it's technically possible to not receive the full output amount,
    // so if no price limit has been specified, require this possibility away
    if (sqrtPriceLimitX96 == 0) require(amountOutReceived == amountOut);
}

可见和 exactInputInternal 的实现也是大同小异。不过,有一个细节需要补充一下。因为是指定的输出数额,所以调用底层的 swap 函数时,第三个传参转为了负数,这也是前面讲解 UniswapV3Pool 的 swap 函数时讲过的,当指定的交易数额是输出的数额时,则需传负数。

exactInputInternal 一样,在当前函数里没有支付 token 的逻辑,也是统一在 uniswapV3SwapCallback 回调函数里去完成支付。

exactInput

exactInput 函数则用于处理跨多个池子的指定输入数量的交易,相比单池交易会复杂一些,而且这里面的逻辑还有点绕,我们来进行一一剖析。其实现代码如下:

代码语言:javascript复制
struct ExactInputParams {
    bytes path;         //交易路径
    address recipient;  //收款地址
    uint256 deadline;   //过期时间
    uint256 amountIn;   //指定输入token数量
    uint256 amountOutMinimum; //输出token的最小数量
}

function exactInput(ExactInputParams memory params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (uint256 amountOut)
{
    //调用者需支付路径中的第一个代币
    address payer = msg.sender;
    //遍历路径
    while (true) {
        //路径中是否还存在多个池子
        bool hasMultiplePools = params.path.hasMultiplePools();
        //先前交换的输出成为后续交换的输入
        params.amountIn = exactInputInternal(
            params.amountIn,
            hasMultiplePools ? address(this) : params.recipient,
            0,
            SwapCallbackData({
                path: params.path.getFirstPool(), // 只需要路径里的第一个池子
                payer: payer
            })
        );
        //当路径依然由多个池子组成时,则继续循环,否则退出循环
        if (hasMultiplePools) {
            payer = address(this);
            //跳过第一个token,作为下一轮的路径
            params.path = params.path.skipToken();
        } else {
            //最后一次兑换,把前面设为了amountIn的重新赋值给amountOut
            amountOut = params.amountIn;
            break;
        }
    }

    require(amountOut >= params.amountOutMinimum, 'Too little received');
}

其中,需要跨多个池子的路径编码方式如下图:

和 UniswapV2 一样,这个路径是由前端计算出来再传给合约的。寻找最优路径的算法也是和 UniswapV2 一样的思路。

exactInput 函数的核心实现逻辑是,循环处理路径中的每一个配对池,每处理完一个池子的交易,就从路径中移除第一个 token 和 fee,直到路径只剩下最后一个池子就结束循环。期间,每一次执行 exactInputInternal 后,将返回的 amounOut 作为下一轮的 amountIn。第一轮兑换时,payer 是合约的调用者,即 msg.sender,而输出代币的 recipient 则是当前合约地址。中间的每一次兑换,payerrecipient 都是当前合约地址。到最后一次兑换时,recipient 才转为用户传入的地址。

exactOutput

剩下最后一个函数 exactOutput 了,也是用于处理跨多个池子的的交易,而指定的是输出的数量。以下是其代码实现:

代码语言:javascript复制
struct ExactOutputParams {
    bytes path;        //交易路径
    address recipient; //收款地址
    uint256 deadline;  //过期时间
    uint256 amountOut; //指定输出token数量
    uint256 amountInMaximum; //输入token的最大数量
}

function exactOutput(ExactOutputParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline)
    returns (uint256 amountIn)
{
    // it's okay that the payer is fixed to msg.sender here, as they're only paying for the "final" exact output
    // swap, which happens first, and subsequent swaps are paid for within nested callback frames
    exactOutputInternal(
        params.amountOut,
        params.recipient,
        0,
        SwapCallbackData({path: params.path, payer: msg.sender})
    );

    amountIn = amountInCached;
    require(amountIn <= params.amountInMaximum, 'Too much requested');
    amountInCached = DEFAULT_AMOUNT_IN_CACHED;
}

可看到其逻辑就直接调用内部函数 exactOutputInternal 完成交易,并没有像 exactInput 一样的循环处理。但在整个流程中,其实还是进行了遍历路径的多次交易的,只是这个流程完成得比较隐晦。其关键其实是在 uniswapV3SwapCallback 回调函数里,后面我们会说到。

uniswapV3SwapCallback

以下就是回调函数的实现:

代码语言:javascript复制
function uniswapV3SwapCallback(
    int256 amount0Delta,
    int256 amount1Delta,
    bytes calldata _data
) external override {
    require(amount0Delta > 0 || amount1Delta > 0);
    //解码出_data数据
    SwapCallbackData memory data = abi.decode(_data, (SwapCallbackData));
    //解码出路径的第一个池子
    (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
    //校验callback的调用者
    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()) {
            // 路径里有多个池子时,则跳过路径的第一个token,使用下一个配对的池子进行交易
            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);
        }
    }
}

另外,这个是 swap 时的回调函数。而之前的文章我们还讲了另一个回调函数 uniswapV3MintCallback 是添加流动性时的回调函数,两者是不同的,不要搞混了。

其逻辑实现并不复杂。首先,先把 _data 解码成 SwapCallbackData 结构体类型数据。接着,解码出路径的第一个池子。然后,通过 verifyCallback 校验调用当前回调函数的是否为底层 pool 合约,非底层 pool 合约是不允许调起回调函数的。

isExactInputamountToPay 的赋值需要拆解一下才好理解。首先需知道,amount0Deltaamount1Delta 其实是一正一负的,正数是输入的,负数是输出的。因此,amount0Delta 大于 0 的话则 amountToPay 就是 amount0Delta,否则就是 amount1Delta 了。 amount0Delta 大于 0 也说明了输入的是 token0,因此,当 tokenIn < tokenOut 的时候,说明 tokenIn 就是 token0,也即是说用户指定的是输入数量,所以这时候的 isExactInput 即为 true

当指定金额为输出的时候,也就是处理 exactOutputexactOutputSingle 函数的时候。我们前面看到 exactOutput 的代码逻辑里并没有对路径进行遍历处理,这个遍历其实就是在这个回调函数里完成的。仔细看这段代码:

代码语言:javascript复制
if (data.path.hasMultiplePools()) {
    // 路径里有多个池子时,则跳过路径的第一个token,使用下一个配对的池子进行交易
    data.path = data.path.skipToken();
    exactOutputInternal(amountToPay, msg.sender, 0, data);
}

这不就是遍历路径多次执行 exactOutputInternal 了吗。

至此,SwapRouter 合约也讲解完了。

0 人点赞