{"content":{"title":"剖析DeFi交易产品之UniswapV3：交易路由合约","body":"本文首发于公众号：[Keegan小钢](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494672&idx=1&sn=4ef80f9b3791d608e3ab4893cf62a2c2&chksm=8b685140bc1fd856b1920ba98a8e0a1e899955ad7dd6938460106e9b0715009a5324072125b9&token=1750626373&lang=zh_CN#rd)\r\n***\r\n**SwapRouter** 合约封装了面向用户的交易接口，但不再像 **UniswapV2Router** 一样根据不同交易场景拆分为了那么多函数，UniswapV3 的 SwapRouter 核心就只有 4 个交易函数：\r\n\r\n* `exactInputSingle`：指定输入数量的单池内交易\r\n* `exactOutputSingle`：指定输出数量的单池内交易\r\n* `exactInput`：指定输入数量和交易路径的交易\r\n* `exactOutput`：指定输出数量和交易路径的交易\r\n\r\n带 `Single` 的只支持单池内的交易，而不带 `Single` 的则支持跨不同池子的互换交易。\r\n\r\n### exactInputSingle\r\n\r\n先来看简单的单池交易，以 `exactInputSingle` 为始，其代码实现如下：\r\n\r\n```solidity\r\nstruct ExactInputSingleParams {\r\n    address tokenIn;   //输入token\r\n    address tokenOut;  //输出token\r\n    uint24 fee;        //手续费率\r\n    address recipient; //收款地址\r\n    uint256 deadline;  //过期时间\r\n    uint256 amountIn;  //指定的输入token数量\r\n    uint256 amountOutMinimum;  //输出token的最小数量\r\n    uint160 sqrtPriceLimitX96; //限定的价格\r\n}\r\n\r\nfunction exactInputSingle(ExactInputSingleParams calldata params)\r\n    external\r\n    payable\r\n    override\r\n    checkDeadline(params.deadline)\r\n    returns (uint256 amountOut)\r\n{\r\n    amountOut = exactInputInternal(\r\n        params.amountIn,\r\n        params.recipient,\r\n        params.sqrtPriceLimitX96,\r\n        SwapCallbackData({path: abi.encodePacked(params.tokenIn, params.fee, params.tokenOut), payer: msg.sender})\r\n    );\r\n    require(amountOut >= params.amountOutMinimum, 'Too little received');\r\n}\r\n```\r\n\r\n其入参有 9 个参数，返回值就一个 `amountOut`，即输出的 token 数量。\r\n\r\n从代码上可看出，实际的逻辑实现是在内部函数 `exactInputInternal`。查看该内部函数之前，我们先来了解下 `SwapCallbackData`。我们从上面代码可以看到，调用 `exactInputInternal` 时，最后一个传入的参数就是 `SwapCallbackData`，这其实是一个结构体，定义了两个属性：\r\n\r\n```solidity\r\nstruct SwapCallbackData {\r\n    bytes path;\r\n    address payer;\r\n}\r\n```\r\n\r\n`path` 表示交易路径，在以上代码中，就是由 `tokenIn`、`fee`、`tokenOut` 这三个变量拼接而成。`payer` 表示支付输入 token 的地址，上面的就是 `msg.sender`。\r\n\r\n接着，来看看内部函数 `exactInputInternal` 的代码实现：\r\n\r\n```solidity\r\nfunction exactInputInternal(\r\n    uint256 amountIn,\r\n    address recipient,\r\n    uint160 sqrtPriceLimitX96,\r\n    SwapCallbackData memory data\r\n) private returns (uint256 amountOut) {\r\n    // allow swapping to the router address with address 0\r\n    if (recipient == address(0)) recipient = address(this);\r\n\t\t//从路径中解码出第一个池子\r\n    (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();\r\n\t\t//当tokenIn<tokenOUt时，则说明tokenIn为token0，所以是要将token0兑换成token1\r\n    bool zeroForOne = tokenIn < tokenOut;\r\n\t\t//调用底层池子的swap函数执行交易\r\n    (int256 amount0, int256 amount1) =\r\n        getPool(tokenIn, tokenOut, fee).swap(\r\n            recipient,\r\n            zeroForOne,\r\n            amountIn.toInt256(),\r\n            sqrtPriceLimitX96 == 0\r\n                ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1)\r\n                : sqrtPriceLimitX96,\r\n            abi.encode(data)\r\n        );\r\n\t\t//返回amountOut\r\n    return uint256(-(zeroForOne ? amount1 : amount0));\r\n}\r\n```\r\n\r\n首先，如果 recipient 地址为零地址的话，那会把 recipient 重置为当前合约地址。\r\n\r\n接着，通过 `data.path.decodeFirstPool()` 从路径中解码得出 `tokenIn`、`tokenOut` 和 `fee`。`decodeFirstPool` 函数是在库合约 **Path** 里实现的。\r\n\r\n布尔类型的 `zeroForOne` 表示底层 `token0` 和 `token1` 的兑换方向，为 `true` 表示用 `token0` 兑换 `token1`，`false` 则反之。因为底层的 `token0` 是小于 `token1` 的，所以，当 `tokenIn` 也小于 `tokenOut` 的时候，说明 `tokenIn == token0`，所以 `zeroForOne` 为 `true`。\r\n\r\n然后，通过 `getPool` 函数可得到池子地址，再调用底层池子的 `swap` 函数来执行实际的交易逻辑。\r\n\r\n最后，我们要得到的是 `amountOut`，这是 amount0 和 amount1 中的其中一个。我们已经知道，`zeroForOne` 为 `true` 的时候，`tokenIn` 等于 `token0`，所以 `tokenOut` 就是 `token1`，因此 `amountOut` 就是 `amount1`。另外，对底层池子来说，属于输出的时候，返回的数值是负数，即 `amount1` 其实是一个负数，因此需要再加个负号转为正数的 `uint256` 类型。\r\n\r\n在这个函数里，我们可以看出并没有支付 token 的功能，但前面讲解 **UniswapV3Pool** 时已经了解到，支付是在回调函数 `uniswapV3SwapCallback` 里完成的。因为这个回调函数会涉及到所有 4 种交易类型，所以我们留到最后再来讲解。\r\n\r\n### exactOutputSingle\r\n\r\n接着，来看 `exactOutputSingle` 函数的实现，其代码如下：\r\n\r\n```solidity\r\nstruct ExactOutputSingleParams {\r\n    address tokenIn;   //输入token\r\n    address tokenOut;  //输出token\r\n    uint24 fee;        //手续费率\r\n    address recipient; //收款地址\r\n    uint256 deadline;  //过期时间\r\n    uint256 amountOut; //指定的输出token数量\r\n    uint256 amountInMaximum;   //输入token的最大数量\r\n    uint160 sqrtPriceLimitX96; //限定的价格\r\n}\r\n\r\nfunction exactOutputSingle(ExactOutputSingleParams calldata params)\r\n    external\r\n    payable\r\n    override\r\n    checkDeadline(params.deadline)\r\n    returns (uint256 amountIn)\r\n{\r\n    // avoid an SLOAD by using the swap return data\r\n    amountIn = exactOutputInternal(\r\n        params.amountOut,\r\n        params.recipient,\r\n        params.sqrtPriceLimitX96,\r\n        SwapCallbackData({path: abi.encodePacked(params.tokenOut, params.fee, params.tokenIn), payer: msg.sender})\r\n    );\r\n\r\n    require(amountIn <= params.amountInMaximum, 'Too much requested');\r\n    // has to be reset even though we don't use it in the single hop case\r\n    amountInCached = DEFAULT_AMOUNT_IN_CACHED;\r\n}\r\n```\r\n\r\n可看出，`exactOutputSingle` 函数的实现与 `exactInputSingle` 函数大同小异。首先，参数上，只有两个不同，`exactInputSingle` 函数指定的是 `amountIn` 和 `amountOutMinimum`；而 `exactOutputSingle` 函数改为了 `amountOut` 和 `amountInMaximum`，即输出是指定的，而输入则限制了最大值。其次，实际逻辑封装在了 `exactOutputInternal` 内部函数，而且传给该内部函数的最后一个参数的 `path` 组装顺序也不一样了，排在第一位的是 `tokenOut`。\r\n\r\n核心实现还是在 `exactOutputInternal` 内部函数，其代码实现如下：\r\n\r\n```solidity\r\nfunction exactOutputInternal(\r\n    uint256 amountOut,\r\n    address recipient,\r\n    uint160 sqrtPriceLimitX96,\r\n    SwapCallbackData memory data\r\n) private returns (uint256 amountIn) {\r\n    // allow swapping to the router address with address 0\r\n    if (recipient == address(0)) recipient = address(this);\r\n\t\t//从路径中解码出第一个池子\r\n    (address tokenOut, address tokenIn, uint24 fee) = data.path.decodeFirstPool();\r\n\t\t//是否token0兑换token1\r\n    bool zeroForOne = tokenIn < tokenOut;\r\n\t\t//调用底层池子的swap函数执行交易\r\n    (int256 amount0Delta, int256 amount1Delta) =\r\n        getPool(tokenIn, tokenOut, fee).swap(\r\n            recipient,\r\n            zeroForOne,\r\n            -amountOut.toInt256(), //指定输出需转为负数\r\n            sqrtPriceLimitX96 == 0\r\n                ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1)\r\n                : sqrtPriceLimitX96,\r\n            abi.encode(data)\r\n        );\r\n\t\t\r\n\t\t//确定amountIn和amountOut\r\n    uint256 amountOutReceived;\r\n    (amountIn, amountOutReceived) = zeroForOne\r\n        ? (uint256(amount0Delta), uint256(-amount1Delta))\r\n        : (uint256(amount1Delta), uint256(-amount0Delta));\r\n    // it's technically possible to not receive the full output amount,\r\n    // so if no price limit has been specified, require this possibility away\r\n    if (sqrtPriceLimitX96 == 0) require(amountOutReceived == amountOut);\r\n}\r\n```\r\n\r\n可见和 `exactInputInternal` 的实现也是大同小异。不过，有一个细节需要补充一下。因为是指定的输出数额，所以调用底层的 swap 函数时，第三个传参转为了负数，这也是前面讲解 UniswapV3Pool 的 swap 函数时讲过的，当指定的交易数额是输出的数额时，则需传负数。\r\n\r\n和 `exactInputInternal` 一样，在当前函数里没有支付 token 的逻辑，也是统一在 `uniswapV3SwapCallback` 回调函数里去完成支付。\r\n\r\n### exactInput\r\n\r\n`exactInput` 函数则用于处理跨多个池子的指定输入数量的交易，相比单池交易会复杂一些，而且这里面的逻辑还有点绕，我们来进行一一剖析。其实现代码如下：\r\n\r\n```solidity\r\nstruct ExactInputParams {\r\n    bytes path;         //交易路径\r\n    address recipient;  //收款地址\r\n    uint256 deadline;   //过期时间\r\n    uint256 amountIn;   //指定输入token数量\r\n    uint256 amountOutMinimum; //输出token的最小数量\r\n}\r\n\r\nfunction exactInput(ExactInputParams memory params)\r\n    external\r\n    payable\r\n    override\r\n    checkDeadline(params.deadline)\r\n    returns (uint256 amountOut)\r\n{\r\n\t\t//调用者需支付路径中的第一个代币\r\n    address payer = msg.sender;\r\n    //遍历路径\r\n    while (true) {\r\n    \t\t//路径中是否还存在多个池子\r\n        bool hasMultiplePools = params.path.hasMultiplePools();\r\n        //先前交换的输出成为后续交换的输入\r\n        params.amountIn = exactInputInternal(\r\n            params.amountIn,\r\n            hasMultiplePools ? address(this) : params.recipient,\r\n            0,\r\n            SwapCallbackData({\r\n                path: params.path.getFirstPool(), // 只需要路径里的第一个池子\r\n                payer: payer\r\n            })\r\n        );\r\n        //当路径依然由多个池子组成时，则继续循环，否则退出循环\r\n        if (hasMultiplePools) {\r\n            payer = address(this);\r\n            //跳过第一个token，作为下一轮的路径\r\n            params.path = params.path.skipToken();\r\n        } else {\r\n        \t\t//最后一次兑换，把前面设为了amountIn的重新赋值给amountOut\r\n            amountOut = params.amountIn;\r\n            break;\r\n        }\r\n    }\r\n\r\n    require(amountOut >= params.amountOutMinimum, 'Too little received');\r\n}\r\n```\r\n\r\n其中，需要跨多个池子的路径编码方式如下图：\r\n\r\n![uniswapV3-path.webp](https://img.learnblockchain.cn/attachments/2023/11/ce9E8Ajg655089566b4db.webp)\r\n\r\n和 UniswapV2 一样，这个路径是由前端计算出来再传给合约的。寻找最优路径的算法也是和 UniswapV2 一样的思路。\r\n\r\n`exactInput` 函数的核心实现逻辑是，循环处理路径中的每一个配对池，每处理完一个池子的交易，就从路径中移除第一个 token 和 fee，直到路径只剩下最后一个池子就结束循环。期间，每一次执行 `exactInputInternal` 后，将返回的 `amounOut` 作为下一轮的 `amountIn`。第一轮兑换时，`payer` 是合约的调用者，即 `msg.sender`，而输出代币的 `recipient` 则是当前合约地址。中间的每一次兑换，`payer` 和 `recipient` 都是当前合约地址。到最后一次兑换时，`recipient` 才转为用户传入的地址。\r\n\r\n### exactOutput\r\n\r\n剩下最后一个函数 `exactOutput` 了，也是用于处理跨多个池子的的交易，而指定的是输出的数量。以下是其代码实现：\r\n\r\n```solidity\r\nstruct ExactOutputParams {\r\n    bytes path;        //交易路径\r\n    address recipient; //收款地址\r\n    uint256 deadline;  //过期时间\r\n    uint256 amountOut; //指定输出token数量\r\n    uint256 amountInMaximum; //输入token的最大数量\r\n}\r\n\r\nfunction exactOutput(ExactOutputParams calldata params)\r\n    external\r\n    payable\r\n    override\r\n    checkDeadline(params.deadline)\r\n    returns (uint256 amountIn)\r\n{\r\n    // it's okay that the payer is fixed to msg.sender here, as they're only paying for the \"final\" exact output\r\n    // swap, which happens first, and subsequent swaps are paid for within nested callback frames\r\n    exactOutputInternal(\r\n        params.amountOut,\r\n        params.recipient,\r\n        0,\r\n        SwapCallbackData({path: params.path, payer: msg.sender})\r\n    );\r\n\r\n    amountIn = amountInCached;\r\n    require(amountIn <= params.amountInMaximum, 'Too much requested');\r\n    amountInCached = DEFAULT_AMOUNT_IN_CACHED;\r\n}\r\n```\r\n\r\n可看到其逻辑就直接调用内部函数 `exactOutputInternal` 完成交易，并没有像 `exactInput` 一样的循环处理。但在整个流程中，其实还是进行了遍历路径的多次交易的，只是这个流程完成得比较隐晦。其关键其实是在 `uniswapV3SwapCallback` 回调函数里，后面我们会说到。\r\n\r\n### uniswapV3SwapCallback\r\n\r\n以下就是回调函数的实现：\r\n\r\n```solidity\r\nfunction uniswapV3SwapCallback(\r\n    int256 amount0Delta,\r\n    int256 amount1Delta,\r\n    bytes calldata _data\r\n) external override {\r\n    require(amount0Delta > 0 || amount1Delta > 0);\r\n    //解码出_data数据\r\n    SwapCallbackData memory data = abi.decode(_data, (SwapCallbackData));\r\n    //解码出路径的第一个池子\r\n    (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();\r\n    //校验callback的调用者\r\n    CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee);\r\n    //用于判断当前需要支付的代币\r\n    (bool isExactInput, uint256 amountToPay) =\r\n        amount0Delta > 0\r\n            ? (tokenIn < tokenOut, uint256(amount0Delta))\r\n            : (tokenOut < tokenIn, uint256(amount1Delta));\r\n    if (isExactInput) { //指定金额的是输入，直接执行支付\r\n        pay(tokenIn, data.payer, msg.sender, amountToPay);\r\n    } else { //指定金额的是输出\r\n        // either initiate the next swap or pay\r\n        if (data.path.hasMultiplePools()) {\r\n            // 路径里有多个池子时，则跳过路径的第一个token，使用下一个配对的池子进行交易\r\n            data.path = data.path.skipToken();\r\n            exactOutputInternal(amountToPay, msg.sender, 0, data);\r\n        } else { //只剩下一个池子，执行支付\r\n            amountInCached = amountToPay;\r\n            tokenIn = tokenOut; // swap in/out because exact output swaps are reversed\r\n            pay(tokenIn, data.payer, msg.sender, amountToPay);\r\n        }\r\n    }\r\n}\r\n```\r\n\r\n另外，这个是 swap 时的回调函数。而之前的文章我们还讲了另一个回调函数 `uniswapV3MintCallback` 是添加流动性时的回调函数，两者是不同的，不要搞混了。\r\n\r\n其逻辑实现并不复杂。首先，先把 `_data` 解码成 `SwapCallbackData` 结构体类型数据。接着，解码出路径的第一个池子。然后，通过 `verifyCallback` 校验调用当前回调函数的是否为底层 pool 合约，非底层 pool 合约是不允许调起回调函数的。\r\n\r\n`isExactInput` 和 `amountToPay` 的赋值需要拆解一下才好理解。首先需知道，`amount0Delta` 和 `amount1Delta` 其实是一正一负的，正数是输入的，负数是输出的。因此，`amount0Delta` 大于 0 的话则 `amountToPay` 就是 `amount0Delta`，否则就是 `amount1Delta` 了。 `amount0Delta` 大于 0 也说明了输入的是 `token0`，因此，当 `tokenIn < tokenOut` 的时候，说明 `tokenIn` 就是 `token0`，也即是说用户指定的是输入数量，所以这时候的 `isExactInput` 即为 `true`。\r\n\r\n当指定金额为输出的时候，也就是处理 `exactOutput` 和 `exactOutputSingle` 函数的时候。我们前面看到  `exactOutput` 的代码逻辑里并没有对路径进行遍历处理，这个遍历其实就是在这个回调函数里完成的。仔细看这段代码：\r\n\r\n```solidity\r\nif (data.path.hasMultiplePools()) {\r\n    // 路径里有多个池子时，则跳过路径的第一个token，使用下一个配对的池子进行交易\r\n    data.path = data.path.skipToken();\r\n    exactOutputInternal(amountToPay, msg.sender, 0, data);\r\n}\r\n```\r\n\r\n这不就是遍历路径多次执行 `exactOutputInternal` 了吗。\r\n\r\n至此，SwapRouter 合约也讲解完了。"},"author":{"user":"https://learnblockchain.cn/people/96","address":null},"history":null,"timestamp":1699776921,"version":1}