Uniswap V3 第4章 兑换:跨 tick 的 swap 算法

讲解 Uniswap V3 的 swap 算法:单区间内的价格移动、跨 tick 时 liquidityNet 如何切换流动性、swap 手续费,以及 SwapRouter 的定输入/定输出。

6 分钟阅读
Uniswap V3 第4章 兑换:跨 tick 的 swap 算法

Uniswap V3 第 4 章:兑换(Swap)

V3 的 swap 比 V2 复杂,因为流动性分布在不同区间,价格移动可能跨越多个 tick,每跨一个 tick 流动性 L 就会变化。这一章讲清单区间内怎么算、跨 tick 时怎么切换 L(liquidityNet)、手续费,以及面向用户的 SwapRouter。


目录


1. swap 的整体思路:一段一段地走

V3 的流动性像一条”高低起伏的地形”:不同价格区间有不同的流动性深度 L。一笔 swap 会让价格移动,可能从当前 tick 一路走过好几个 tick 边界。

算法把整个 swap 拆成若干小段,每段在”两个相邻已初始化 tick 之间”进行:

while 还有输入没用完 且 没到价格限制:
    1. 找到下一个"已初始化的 tick"(流动性边界)
    2. 在当前 L 不变的前提下,计算价格走到那个 tick(或输入用完)能换多少
    3. 扣手续费、累加输出
    4. 如果走到了 tick 边界,跨过它:用 liquidityNet 更新 L

核心:每一小段内 L 是常数,可以用第 3 章的公式精确计算;只有跨 tick 时 L 才跳变。


2. 单区间内的价格移动

在 L 不变的一段内,用第 3 章的增量公式:

卖出 token1(买 token0)→ 价格 P 下跌:
    Δy = L · Δ(√P)            (你投入的 y 决定 √P 下降多少)
    Δx = L · Δ(1/√P)          (你换出的 x)

卖出 token0(买 token1)→ 价格 P 上涨:
    Δx = L · Δ(1/√P)
    Δy = L · Δ(√P)

给定输入量,先算”如果全部用在这一段,√P 会移到哪”;如果那个新 √P 还没越过下一个 tick 边界,这一段就直接结束(输入用完);否则价格停在 tick 边界,剩余输入进入下一段。


3. 案例:单区间内卖出 token1

设当前 √P = 54.77(P=3000),这一段流动性 L = 1,000,000,你投入 Δy = 281,000 个 token1(买 token0,价格会涨)。

Δ(√P) = Δy / L = 281,000 / 1,000,000 = 0.281
新 √P = 54.77 + 0.281 = 55.05  → 新 P ≈ 3030.5
换出的 token0:
    Δx = L · (1/√P_old − 1/√P_new) = 1,000,000 · (1/54.77 − 1/55.05)
       = 1,000,000 · (0.018258 − 0.018165) = 1,000,000 · 0.0000929 ≈ 92.9

你投入 281,000 token1,换出约 92.9 token0,价格从 3000 涨到约 3030.5。只要没跨 tick,这就是精确结果(再扣手续费)。如果新 √P 会越过下一个已初始化 tick,则价格停在那个 tick,余下输入进入下一段。


4. 跨 tick:liquidityNet 切换流动性

每个已初始化的 tick(有 LP 区间在此开始或结束)记录一个值 liquidityNet

liquidityNet = 在这个 tick 上"净增加"的流动性
             = (以此为下界的头寸 L 之和) − (以此为上界的头寸 L 之和)

当 swap 价格向上跨过一个 tick:L += liquidityNet。 当价格向下跨过L -= liquidityNet

直觉:

  • 价格涨过某 tick,意味着进入了一些头寸的区间(它们的下界)、离开了另一些头寸的区间(它们的上界)。
  • liquidityNet 一次性记录了”跨过这个 tick,活跃流动性净变化多少”,跨越时加上即可,无需遍历所有头寸。这是 V3 高效的关键。

反方向跨越要用减法——所以同一个 liquidityNet 在涨/跌方向符号相反。


5. 多区间 swap 的循环

把前面拼起来,一笔大额 swap 的完整循环:

当前价 √P,当前 L
remaining = 输入量
while remaining > 0 且 未达价格限制 sqrtPriceLimit:
    nextTick = 下一个已初始化 tick(用 tick bitmap 高效查找,见第 7 章)
    sqrtPriceNext = getSqrtRatioAtTick(nextTick)
    # 在 [√P, sqrtPriceNext] 这一段,用 L 不变计算能消耗多少输入、产出多少
    (consumed, out, √P_new) = computeSwapStep(√P, sqrtPriceNext, L, remaining, fee)
    累加 out;remaining -= consumed;扣手续费
    if √P_new == sqrtPriceNext:   # 走到了 tick 边界
        L += liquidityNet(nextTick)(方向相应取正负)
        √P = sqrtPriceNext
    else:                          # 输入用完,停在区间内
        √P = √P_new; break
更新 slot0 的 sqrtPriceX96 和 tick

sqrtPriceLimit 是调用者设的价格上/下限(滑点保护),价格触及它就停止。


6. swap 手续费

V3 的手续费率由池子的费率档位决定(0.01% / 0.05% / 0.3% / 1%),收在输入币上:

每一小段:feeAmount = 输入段 · fee / 1e6      (fee 如 3000 表示 0.3%)
实际参与换出的输入 = 输入段 − feeAmount

和 V2 不同的是:V3 的手续费不自动复投进流动性,而是单独累计到全局变量 feeGrowthGlobal,按”流动性在区间内的时间”分配给各 LP,需要 LP 主动 collect(第 8 章详解费用追踪)。


7. SwapRouter:定输入 / 定输出,单池 / 多池

普通用户不直接调 pool.swap(那需要实现回调),而是用 periphery 的 SwapRouter02,四种常用方法:

方法含义
exactInputSingle单池,定输入(给定卖出量,尽量多换)
exactInput多池路由,定输入(path 编码多跳 + 费率)
exactOutputSingle单池,定输出(给定想买到的量,尽量少花)
exactOutput多池路由,定输出

V3 的多跳 path 编码与 V2 不同:它把 token 和费率档位交替编码token0, fee, token1, fee, token2, ...),因为同一对代币有多个费率池,必须指明走哪个。

参数里同样有 amountOutMinimum / amountInMaximum(滑点保护)和 sqrtPriceLimitX96(价格限制)。


8. 本章小结

  1. V3 swap 把价格移动拆成若干小段,每段 L 不变,用第 3 章公式精确计算。
  2. 单段内:Δy = L·Δ√PΔx = L·Δ(1/√P)
  3. 已初始化 tick 时用 liquidityNet 切换 L(涨方向 +、跌方向 ),无需遍历头寸。
  4. 完整 swap 是个循环:算一段 → 扣费累加 → 跨 tick 更新 L → 直到输入用完或触及 sqrtPriceLimit
  5. 手续费按费率档位收在输入币,单独累计到 feeGrowthGlobal,需 LP 主动 collect。
  6. 用户用 SwapRouter 的 exactInput/exactOutput(单池/多池),多跳 path 需编码 token+fee 交替。

9. 动手练习

对应课程的 Swap 练习:用 SwapRouter 做单池/多池、定输入/定输出兑换。

练习:用 SwapRouter02 兑换

主网分叉:

interface ISwapRouter {
    struct ExactInputSingleParams {
        address tokenIn; address tokenOut; uint24 fee; address recipient;
        uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96;
    }
    function exactInputSingle(ExactInputSingleParams calldata) external payable returns (uint256 amountOut);
    function exactInput(bytes calldata path, ...) external payable returns (uint256);
}

思路:

  1. 单池定输入exactInputSingle,用 WETH 换 USDC,fee=500amountIn=1e18amountOutMinimum=1sqrtPriceLimitX96=0(不限)。断言换到 USDC > 0。
  2. 多池定输入:构造 path = abi.encodePacked(WETH, uint24(500), USDC, uint24(100), DAI),调 exactInput,断言换到 DAI > 0。注意 path 里 token 和 fee 交替
  3. 单池定输出exactOutputSingle,精确买到 1000 USDC,amountInMaximum 设上限。断言收到恰好 1000 USDC。
  4. 记得先 approve tokenIn 给 Router。

进阶

  • 用 Quoter 的 quoteExactInputSingle 预估,和实际对比。
  • 故意设一个很紧的 sqrtPriceLimitX96,观察 swap 只部分成交或 revert。

运行

forge test --evm-version cancun --fork-url $FORK_URL \
  --match-path test/UniswapV3Swap.t.sol -vvv

下一章(第 5 章 Factory)讲 V3 工厂:费率档位、tickSpacing,以及同一对代币的多池设计。

💬 评论