Uniswap V3 第 4 章:兑换(Swap)
V3 的 swap 比 V2 复杂,因为流动性分布在不同区间,价格移动可能跨越多个 tick,每跨一个 tick 流动性 L 就会变化。这一章讲清单区间内怎么算、跨 tick 时怎么切换 L(liquidityNet)、手续费,以及面向用户的 SwapRouter。
目录
- 1. swap 的整体思路:一段一段地走
- 2. 单区间内的价格移动
- 3. 案例:单区间内卖出 token1
- 4. 跨 tick:liquidityNet 切换流动性
- 5. 多区间 swap 的循环
- 6. swap 手续费
- 7. SwapRouter:定输入 / 定输出,单池 / 多池
- 8. 本章小结
- 9. 动手练习
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. 本章小结
- V3 swap 把价格移动拆成若干小段,每段 L 不变,用第 3 章公式精确计算。
- 单段内:
Δy = L·Δ√P,Δx = L·Δ(1/√P)。 - 跨已初始化 tick 时用
liquidityNet切换 L(涨方向+、跌方向−),无需遍历头寸。 - 完整 swap 是个循环:算一段 → 扣费累加 → 跨 tick 更新 L → 直到输入用完或触及
sqrtPriceLimit。 - 手续费按费率档位收在输入币,单独累计到 feeGrowthGlobal,需 LP 主动 collect。
- 用户用 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);
}
思路:
- 单池定输入:
exactInputSingle,用 WETH 换 USDC,fee=500,amountIn=1e18,amountOutMinimum=1,sqrtPriceLimitX96=0(不限)。断言换到 USDC > 0。 - 多池定输入:构造
path = abi.encodePacked(WETH, uint24(500), USDC, uint24(100), DAI),调exactInput,断言换到 DAI > 0。注意 path 里 token 和 fee 交替。 - 单池定输出:
exactOutputSingle,精确买到 1000 USDC,amountInMaximum设上限。断言收到恰好 1000 USDC。 - 记得先
approvetokenIn 给 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,以及同一对代币的多池设计。