本文是 Uniswap V3 深度解析系列的第三篇,探讨手续费分配、价格预言机、闪电交换等高级特性。
在前两篇文章中,我们理解了 V3 的核心创新(集中流动性)、数学基础(Tick 机制)、以及交换和流动性管理的具体实现。
但还有几个关键问题没有解答:
- 手续费如何分配? 成千上万个 Position 在不同价格区间提供流动性,每次交换只在部分区间发生,手续费怎么精确分配给每个 LP?
- 如何获取可靠的价格数据? DeFi 协议需要价格预言机,直接用池子的即时价格会被闪电贷攻击,V3 提供了什么解决方案?
- 什么是 Flash Swaps? 为什么可以"先借后还",这背后有什么安全机制?
- NFT Position Manager 是什么? 为什么要把流动性变成 NFT?
手续费分配
在 Uniswap V2 中,手续费分配很简单:所有 LP 共享同一条曲线,按流动性占比分配手续费。
但在 V3 中,情况完全不同:
假设一个 ETH/USDC 池子:
- Alice 在 [1800, 2200] 价格区间提供了 100 ETH 的流动性
- Bob 在 [1950, 2050] 价格区间提供了 50 ETH 的流动性
- Charlie 在 [2000, 2500] 价格区间提供了 80 ETH 的流动性
当价格在 2000 时,三个人的流动性都在活跃,所以这个区间的手续费应该按 100:50:80 的比例分配。
但当价格移动到 2100 时,只有 Alice 和 Charlie 的流动性活跃(Bob 的区间是 [1950, 2050],已经不包含 2100),所以应该按 100:80 分配。
更复杂的是:一个热门池子可能有 几千甚至上万个 Position,每次价格移动都可能进入或离开某些 Position 的区间。
最暴力的方案:每次交换时,遍历所有 Position,判断是否在活跃区间,计算应得手续费。
时间复杂度:O(n),其中 n 是 Position 数量。
在以太坊的 Gas 费用模型下,这完全不可行。一个包含 1000 个 Position 的池子,每次交换可能需要几百万 Gas。
V3 的解决方案:通过三个巧妙的变量,将复杂度降低到 O(1)——无论有多少个 Position,每次交换的计算量都是常数。
延迟计算 + 全局累积
V3 的手续费算法基于一个反直觉的思想:不在交换时分配手续费,而是记录"累积信息",等 LP 需要时再计算。
类比:想象一个银行账户,你的余额随时间增长(利息)。
暴力方案:银行每秒钟给所有账户加一次利息。
聪明方案:银行只记录"每秒的利率",当你查询余额时,根据利率和时间计算出应得利息。
V3 的手续费算法就是后者。
feeGrowthGlobal 全局累积器
定义:从池子创建到现在,单位流动性累积获得的手续费总量。
公式:
每次交换产生手续费 fee,当前活跃流动性是 liquidity,那么 feeGrowthGlobal 增加:
举例:
假设池子刚创建,feeGrowthGlobal = 0。
- 第 1 笔交换:产生了 3 ETH 手续费,当时 liquidity = 1000,所以 feeGrowthGlobal += 3/1000 = 0.003,即此时每单位流动性可以获得 0.003 的手续费
- 第 2 笔交换:产生了 5 ETH 手续费,当时 liquidity = 1200,所以 feeGrowthGlobal += 5/1200 ≈ 0.0042,也就是说 feeGrowthGlobal = 0.003 + 0.0042 = 0.0072,即此时每单位流动性可以获得 0.0072 的手续费
如果你从池子创建时就提供了 1 单位流动性,那么到现在能获得 0.0072 ETH 手续费。
feeGrowthOutside 边界外的累积
现在我们知道全局累积了多少手续费,但还需要知道某个价格区间内累积了多少。
问题:给定一个区间 [tickLower, tickUpper],如何计算区间内的累积?
核心思路:如果我们知道区间外面累积了多少,用全局减去外面,就是里面。
V3 在每个被使用的 tick 上记录 feeGrowthOutside,用于计算"该 tick 某一侧的手续费累积"。
初始化规则
当一个 tick 第一次被 LP 使用时(有人在这个 tick 创建了 Position 边界),需要初始化 feeGrowthOutside。
规则很简单:
- 如果当前价格 ≥ 这个 tick:
feeGrowthOutside = feeGrowthGlobal(把历史手续费都算在"下方") - 如果当前价格 < 这个 tick:
feeGrowthOutside = 0(历史手续费都算在"上方",等于 Global - 0)
为什么这样初始化?
因为在这个 tick 被使用之前,根本没有 Position 在这个边界上活跃过,所以无所谓手续费怎么分。初始化的目的是让后续的计算正确。
举个例子:
池子刚创建,当前价格在 tick = 100 feeGrowthGlobal = 0 ------- 发生了一些交换 ------- feeGrowthGlobal = 0.05(累积了 0.05 的单位手续费) 当前价格还是 tick = 100 ------- Alice 创建 Position [80, 120] ------- 初始化 tick 80:当前价格 100 ≥ 80,所以 feeGrowthOutside[80] = 0.05 初始化 tick 120:当前价格 100 < 120,所以 feeGrowthOutside[120] = 0
关键理解:初始化时把之前的历史手续费"归类"到某一侧,这样新加入的 LP 从 0 开始计算。
feeGrowthOutside 记录的是"哪一侧"?
这是最容易混淆的地方。规则是:
feeGrowthOutside 始终记录"当前价格所在方向的对侧"的累积
换句话说:
- 当前价格在 tick 上方(tick < currentTick):feeGrowthOutside 记录的是 tick 下方的累积
- 当前价格在 tick 下方(tick ≥ currentTick):feeGrowthOutside 记录的是 tick 上方的累积
为什么这么绕? 因为当价格跨越 tick 时,会翻转 feeGrowthOutside,保持这个语义。
核心机制:翻转
当价格穿越一个 tick 时,feeGrowthOutside 需要翻转。
数值例子:
初始状态: - feeGrowthGlobal = 0.10 - 当前价格 tick = 120(在 tick 100 上方) - feeGrowthOutside[100] = 0.03(记录的是下方的累积) 这意味着: - tick < 100 的区域累积了 0.03 - tick ≥ 100 的区域累积了 0.10 - 0.03 = 0.07 ------- 价格从 120 跌到 80(穿过 tick 100)------- 穿越 tick 100 时,发生翻转: feeGrowthOutside[100]_new = feeGrowthGlobal - feeGrowthOutside[100]_old = 0.10 - 0.03 = 0.07 现在 feeGrowthOutside[100] = 0.07 这意味着: - 因为当前价格现在在 tick 100 下方 - feeGrowthOutside[100] 记录的是 tick 100 上方的累积 = 0.07 ✅ - tick 100 下方的累积 = 0.10 - 0.07 = 0.03 ✅ 翻转后,数值的含义变了,但"下方"和"上方"的实际累积量没变!
翻转公式的本质:
因为"上方累积 + 下方累积 = 全局累积",所以翻转就是"全局 - 原值 = 另一侧的值"。
feeGrowthInside 区间内的累积
有了全局累积和边界外累积,就能计算区间内的累积。
核心公式:
对于上面 Alice 创建 Position [80, 120]的例子,计算此时的 feeGrowthInside[80, 120]:
- 下方(< 80):feeGrowthOutside[80] = 0.05
- 上方(> 120):feeGrowthOutside[120] = 0
- 区间内 = 0.05 - 0.05 - 0 = 0 ✅
Alice 刚加入,区间内手续费是 0,符合预期!
手续费计算
现在我们有了区间内的累积 feeGrowthInside,LP 的手续费怎么算?
每个 Position 记录两个变量:
struct Position { uint128 liquidity; uint256 feeGrowthInside0LastX128; // 上次更新时的 feeGrowthInside uint256 feeGrowthInside1LastX128; uint128 tokensOwed0; // 待领取的手续费 uint128 tokensOwed1; }
计算公式:
当 LP 更新 Position(增加/减少流动性,或收取手续费)时,计算自上次更新以来的手续费增量:
- 是当前区间内的累积
- 是上次更新时的累积
- 两者相减得到增量
- 乘以 Position 的流动性,就是应得手续费
- 除以 是因为 feeGrowthInside 是 Q128.128 格式
举例:
Alice 在 [1900, 2100] 提供了 1000 流动性。
- 添加时,feeGrowthInside = 0.005,记录 feeGrowthInside0Last = 0.005
- 一段时间后,feeGrowthInside = 0.008
- Alice 收取手续费:tokensOwed = (0.008 - 0.005) × 1000 = 3 ETH
关键优势:
无论池子里有多少个 Position,每次交换只需要:
- 更新 feeGrowthGlobal(1 次计算)
- 如果跨越 tick,翻转 feeGrowthOutside(最多几次)
LP 收取手续费时,只需要:
- 计算当前 feeGrowthInside(几次读取 + 减法)
- 乘以自己的流动性
时间复杂度:O(1),与 Position 数量无关!
完整案例
让我们通过一个详细的案例,完整追踪手续费是如何累积和分配的。
初始状态:
ETH/USDC 池,当前价格 2000 USDC/ETH(tick = 76320),feeGrowthGlobal = 0。
三个 LP 依次创建 Position:
| Position | Tick 区间 | 价格区间 (USDC/ETH) | 流动性 |
|---|---|---|---|
| Alice | [76000, 76640] | [1800, 2200] | 1000 |
| Bob | [76200, 76440] | [1950, 2050] | 500 |
| Charlie | [76320, 76800] | [2000, 2500] | 800 |
feeGrowthOutside 初始化(当前 tick = 76320):
根据初始化规则:当前价格 ≥ tick 时初始化为 feeGrowthGlobal,否则为 0。
| tick | 与当前价格关系 | feeGrowthOutside 初始值 | 记录的是哪一侧 |
|---|---|---|---|
| 76000 | 76320 ≥ 76000 | 0 (= feeGrowthGlobal) | 下方 |
| 76200 | 76320 ≥ 76200 | 0 | 下方 |
| 76320 | 76320 ≥ 76320 | 0 | 下方 |
| 76440 | 76320 < 76440 | 0 | 上方 |
| 76640 | 76320 < 76640 | 0 | 上方 |
| 76800 | 76320 < 76800 | 0 | 上方 |
(因为 feeGrowthGlobal = 0,所以碰巧都是 0)
事件 1:第一笔交换(价格 = 2000)
- 用户用 100 USDC 换 ETH,产生手续费 0.3 USDC
- 活跃流动性:1000 + 500 + 800 = 2300
事件 2:价格上涨穿过 tick 76440(Bob 的上界)
- 价格从 2000 涨到 2080,穿过 tick 76440
- Bob 的 Position 变为不活跃
翻转 feeGrowthOutside[76440]:
翻转前:feeGrowthOutside[76440] = 0,记录的是 tick 上方的累积(因为之前价格在 76440 下方)
翻转后:feeGrowthOutside[76440] = 0.0001304,记录的是 tick 下方的累积(因为现在价格在 76440 上方)
活跃流动性变为:1000 + 800 = 1800
事件 3:第二笔交换(价格 = 2080)
- 用户用 200 USDC 换 ETH,产生手续费 0.6 USDC
- 活跃流动性:1800
事件 4:价格继续上涨穿过 tick 76640(Alice 的上界)
- 价格从 2080 涨到 2250,穿过 tick 76640
- Alice 的 Position 变为不活跃
翻转 feeGrowthOutside[76640]:
翻转后记录的是 tick 下方的累积。
活跃流动性变为:800
事件 5:第三笔交换(价格 = 2250)
- 用户用 150 USDC 换 ETH,产生手续费 0.45 USDC
- 活跃流动性:800
最终状态汇总
feeGrowthGlobal = 0.0010262 当前价格 tick ≈ 76710 feeGrowthOutside: [76000] = 0 (记录下方,从未翻转) [76200] = 0 (记录下方,从未翻转) [76320] = 0 (记录下方,从未翻转) [76440] = 0.0001304 (记录下方,事件2翻转) [76640] = 0.0004637 (记录下方,事件4翻转) [76800] = 0 (记录上方,从未翻转)
现在三个 LP 分别收取手续费
Alice 收取手续费(区间 [76000, 76640])
当前价格 tick ≈ 76710,在 Alice 区间上方。
Step 1: 计算 tickLower=76000 以下的累积 currentTick(76710) >= 76000 ✓ → feeGrowthOutside[76000] 记录的是「下方」,直接用 → feeGrowth_below = 0 Step 2: 计算 tickUpper=76640 以上的累积 currentTick(76710) >= 76640 → 需要翻转! → feeGrowthOutside[76640] 记录的是「下方」,我们要「上方」 → feeGrowth_above = feeGrowthGlobal - fo[76640] = 0.0010262 - 0.0004637 = 0.0005625 Step 3: 计算区间内 feeGrowthInside = feeGrowthGlobal - feeGrowth_below - feeGrowth_above = 0.0010262 - 0 - 0.0005625 = 0.0004637
Alice 手续费 = 0.0004637 × 1000 = 0.4637 USDC
验证:事件1 () + 事件3 () = 0.1304 + 0.3333 = 0.4637 ✓
Bob 收取手续费(区间 [76200, 76440])
当前价格 tick ≈ 76710,在 Bob 区间上方。
Step 1: 计算 tickLower=76200 以下的累积 currentTick(76710) >= 76200 ✓ → feeGrowthOutside[76200] 记录的是「下方」,直接用 → feeGrowth_below = 0 Step 2: 计算 tickUpper=76440 以上的累积 currentTick(76710) >= 76440 → 需要翻转! → feeGrowthOutside[76440] 记录的是「下方」,我们要「上方」 → feeGrowth_above = feeGrowthGlobal - fo[76440] = 0.0010262 - 0.0001304 = 0.0008958 Step 3: 计算区间内 feeGrowthInside = 0.0010262 - 0 - 0.0008958 = 0.0001304
Bob 手续费 = 0.0001304 × 500 = 0.0652 USDC
验证:Bob 只在事件1活跃, = 0.0652 ✓
Charlie 收取手续费(区间 [76320, 76800])
当前价格 tick ≈ 76710,在 Charlie 区间内(76320 ≤ 76710 < 76800)。
Step 1: 计算 tickLower=76320 以下的累积 currentTick(76710) >= 76320 ✓ → feeGrowthOutside[76320] 记录的是「下方」,直接用 → feeGrowth_below = 0 Step 2: 计算 tickUpper=76800 以上的累积 currentTick(76710) < 76800 ✓ 不需要翻转 → feeGrowthOutside[76800] 记录的是「上方」,直接用 → feeGrowth_above = 0 Step 3: 计算区间内 feeGrowthInside = 0.0010262 - 0 - 0 = 0.0010262
Charlie 手续费 = 0.0010262 × 800 = 0.8210 USDC
验证:事件1 + 事件3 + 事件5 = = 0.1043 + 0.2667 + 0.45 = 0.8210 ✓
验证总和
| LP | 手续费 (USDC) |
|---|---|
| Alice | 0.4637 |
| Bob | 0.0652 |
| Charlie | 0.8210 |
| 总计 | 1.3499 ≈ 1.35 |
总手续费 = 0.3 + 0.6 + 0.45 = 1.35 USDC ✓
三个 LP 的手续费之和精确等于总手续费,验证了 feeGrowthInside 算法的正确性!
核心要点:
- 无需遍历 Position:每次交换只更新 feeGrowthGlobal 和必要的 feeGrowthOutside
- 延迟计算:手续费在 LP 主动收取时才计算,不是在交换时分配
- O(1) 复杂度:无论池子里有 3 个还是 3000 个 Position,计算量都一样
- 翻转是关键:计算 feeGrowthInside 时,必须根据当前价格位置判断是否需要翻转
TWAP 预言机
即时价格的弊端
想象一个借贷协议,用 Uniswap 的即时价格(slot0.sqrtPriceX96)来判断抵押品价值和清算线。
为什么即时价格危险?
Uniswap 的即时价格就是当前池子的 ,它会随着每一笔 swap 实时变化。问题是:在同一笔交易内,攻击者可以先操纵价格,再利用被操纵的价格。
攻击场景(单笔交易内完成):
假设有一个借贷协议 VulnerableLending,它这样读取 ETH 价格:
// 危险!直接读取即时价格 function getETHPrice() public view returns (uint256) { (uint160 sqrtPriceX96, , , , , , ) = uniswapPool.slot0(); return uint256(sqrtPriceX96) ** 2 / (2 ** 192); // 转换为价格 } function borrow(uint256 ethCollateral, uint256 usdcAmount) external { uint256 ethPrice = getETHPrice(); // 读取当前价格 uint256 collateralValue = ethCollateral * ethPrice; require(collateralValue >= usdcAmount * 150 / 100, "Undercollateralized"); // ... 执行借款 }
攻击者部署一个恶意合约,在单笔交易内执行:
function attack() external { // 1. 闪电贷借 2000 万 USDC aave.flashLoan(20_000_000 * 1e6, address(this)); } function executeOperation(uint256 amount, ...) external { // 2. 用 2000 万 USDC 在 Uniswap 买入 ETH // 价格从 2000 被推高到 4000 USDC/ETH uniswapRouter.swap(USDC, ETH, 20_000_000 * 1e6); // 3. 此时 Uniswap 的即时价格是 4000 // 用 100 ETH 作为抵押(攻击者自己的) // 按 4000 价格计算,价值 40 万 USDC // 可以借出 40万 / 1.5 = 26.6 万 USDC vulnerableLending.borrow(100 ether, 266_000 * 1e6); // 4. 把 ETH 卖回 USDC,价格恢复到 2000 uniswapRouter.swap(ETH, USDC, ethBalance); // 5. 归还闪电贷 2000 万 + 利息 IERC20(USDC).transfer(aave, 20_000_000 * 1e6 + fee); // 结果: // - 攻击者用 100 ETH(真实价值 20 万 USDC)借出了 26.6 万 USDC // - 借贷协议损失 6.6 万 USDC // - 整个过程在一笔交易内完成,无法被阻止 }
为什么能在一笔交易内完成?
- 闪电贷 → 2. 操纵价格 → 3. 借款(读取被操纵的价格)→ 4. 恢复价格 → 5. 还闪电贷
这 5 步都是合约调用,在同一个 EVM 执行上下文中顺序执行。步骤 3 调用 getETHPrice() 时,读取的就是步骤 2 操纵后的价格。
核心问题:即时价格可以在单笔交易内被操纵,而依赖即时价格的协议会在同一笔交易中读取到错误的价格。
解决方案:使用 TWAP(Time-Weighted Average Price),即时间加权平均价格。TWAP 计算的是过去一段时间的平均价格,单笔交易的操纵无法影响历史数据。
V2 的 TWAP 实现
Uniswap V2 就提供了 TWAP 功能,但比较简陋。
核心思想:累积"价格 × 时间"。
V2 维护一个变量 price0CumulativeLast,每次有人交互(swap/mint/burn)时更新:
其中 是当前价格, 是距离上次更新的时间。
外部合约如何使用:
需要自己记录两个时间点的累积值,计算 TWAP:
V2 的缺点:
- 算术平均:价格是相对变化(百分比),算术平均不适合
- 单点存储:只存储一个累积值,外部合约需要自己存储历史点
- 精度有限:使用 UQ112x112 格式
V3 的改进
改进 1:几何平均替代算术平均
先搞懂什么是算术平均和几何平均
假设 ETH 价格在两天内这样变化:
- 第 1 天:1000 USDC
- 第 2 天:2000 USDC
算术平均(就是普通平均):
几何平均(把数字相乘,再开根号):
单看这两个数字,你可能觉得 1500 也挺合理的。但用在价格上,1500 有严重问题。
为什么价格要用几何平均?
用一个投资场景来理解
假设你在这两天分别用 相同金额 买入 ETH:
- 第 1 天:花 1000 USDC,价格 1000 USDC/ETH,买到 1 ETH
- 第 2 天:花 1000 USDC,价格 2000 USDC/ETH,买到 0.5 ETH
总共花了 2000 USDC,得到 1.5 ETH。
你的实际平均买入价格是多少?
不是 1500!
如果你用 1500 作为"平均价格"来估算成本,你会以为 1.5 ETH 花了 2250 USDC,比实际多算了 250 USDC。
因为价格高的时候,同样的钱买到的数量少;价格低的时候,买到的数量多。算术平均没有考虑这个"数量权重",所以价格会不准。几何平均 1414 更接近真实的 1333,而算术平均 1500 偏高了。
另一个角度:正反价格不一致
假设 ETH/USDC 的价格在两个时间段分别是 1000 和 2000。
从 ETH/USDC 角度算算术平均:
从 USDC/ETH 角度(取倒数)算算术平均:
如果 ETH/USDC = 1500,那么 USDC/ETH 应该 = 1/1500 ≈ 0.000667
但我们算出来的是 0.00075,对应 ETH/USDC = 1/0.00075 ≈ 1333
1500 ≠ 1333!算术平均从两个方向算出了不同的结果!
几何平均没有这个问题:正反价格互为倒数
V3 如何实现几何平均?
直接累积价格再开根号在链上很难算。V3 用了一个巧妙的数学转换:几何平均 = 对数的算术平均,再取指数
举例:计算 1000 和 2000 的几何平均
方法 1(直接算):
方法 2(用对数):
- 取对数:,
- 算算术平均:
- 取指数还原:
两种方法结果一样!
V3 的实现:
还记得 tick 的定义吗?,也就是
tick 本身就是价格的对数!所以 V3 只需要:
1. 累积 tick × 时间
tickCumulative 是一个只增不减的累积值,每次有交易时更新:
其中 是距离上次更新经过的秒数。
具体例子:
时间线(用时间戳表示,单位:秒) t=1000: 池子创建,tick=100,tickCumulative=0 │ │ 经过 100 秒,tick 一直是 100 ▼ t=1100: 有人 swap,tick 变成 120 更新:tickCumulative = 0 + 100 × 100 = 10,000 │ │ 经过 200 秒,tick 一直是 120 ▼ t=1300: 又有人 swap,tick 变成 80 更新:tickCumulative = 10,000 + 120 × 200 = 34,000 │ │ 经过 150 秒,tick 一直是 80 ▼ t=1450: 查询 TWAP 更新:tickCumulative = 34,000 + 80 × 150 = 46,000
2. 计算平均 tick
假设你想计算 t=1000 到 t=1450 这 450 秒的 TWAP:
就是时间戳的差值,单位是秒。这里 1450 - 1000 = 450 秒。
3. 还原为价格(取指数)
改进 2:observation 数组
V2 只存储一个累积值,外部合约要自己记录历史。
V3 直接在合约内存储一个 observation 数组,每个元素记录一个时间点的:
- blockTimestamp
- tickCumulative
- secondsPerLiquidityCumulative(流动性的倒数累积,用于计算流动性深度的 TWAP)
struct Observation { uint32 blockTimestamp; int56 tickCumulative; uint160 secondsPerLiquidityCumulativeX128; bool initialized; } Observation[65535] public observations;
环形缓冲区:
数组是环形的,当写满后会覆盖最旧的数据。
有两个重要参数:
observationCardinality:当前数组的有效长度observationCardinalityNext:下次扩展的目标长度
池子可以通过 increaseObservationCardinalityNext() 增加容量(需要消耗 gas)。
好处:
外部合约可以直接调用 observe([1800, 0])(查询 30 分钟前和现在的数据),不需要自己存储历史。
改进 3:插值查询
observation 数组不是每秒都更新的,只有在有交易时才更新。
那如果你想查询的时间点正好没有 observation 怎么办?
V3 的解决方案:线性插值
假设你想查询 t=1000 的 tickCumulative,但数组中只有:
- t=900: tickCumulative = 50000
- t=1200: tickCumulative = 52000
V3 会线性插值:
这样就能查询任意历史时间点的数据。
计算案例
让我们通过一个具体案例,理解 tickCumulative 如何累积,以及如何计算 TWAP。
场景:ETH/USDC 池,观察 1 小时内的价格变化。
关键理解:tickCumulative 累积的是上一个时间段的 tick × 时间,不是当前时刻的。
| 时间 | 当前 tick | 价格 (USDC/ETH) | tickCumulative | 说明 |
|---|---|---|---|---|
| 0:00 | 76320 | 2000 | 0 | 池子刚创建,初始值 |
| 0:30 | 76520 | 2050 | 0 + 76320 × 1800 = 137,376,000 | 过去 30 分钟 tick=76320 |
| 0:45 | 76120 | 1950 | 137,376,000 + 76520 × 900 = 206,244,000 | 过去 15 分钟 tick=76520 |
| 1:00 | 76120 | 1950 | 206,244,000 + 76120 × 900 = 274,752,000 | 过去 15 分钟 tick=76120 |
计算 0:00 到 1:00 的 TWAP:
平均 tick:
平均价格:
TWAP 的用法
最简单的用法:
// 查询过去 30 分钟的 TWAP uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 1800; // 30 分钟前 secondsAgos[1] = 0; // 现在 (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos); int56 tickCumulativeDelta = tickCumulatives[1] - tickCumulatives[0]; int24 averageTick = int24(tickCumulativeDelta / 1800); // tick 转换为价格 uint256 avgPrice = 1.0001 ** averageTick;
为什么 30 分钟?
时间窗口的选择是权衡:
- 太短(< 5 分钟):容易被操纵(攻击者可以持续推高价格几分钟)
- 太长(> 2 小时):反应迟钝,无法及时响应真实价格变化
推荐:15-30 分钟对大多数场景是合理的。
多重验证:
更安全的做法是同时查询多个时间窗口:
uint32[] memory secondsAgos = new uint32[](4); secondsAgos[0] = 1800; // 30 分钟 secondsAgos[1] = 3600; // 1 小时 secondsAgos[2] = 7200; // 2 小时 secondsAgos[3] = 0; (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos); // 计算三个 TWAP int24 twap30min = (tickCumulatives[3] - tickCumulatives[0]) / 1800; int24 twap1hour = (tickCumulatives[3] - tickCumulatives[1]) / 3600; int24 twap2hour = (tickCumulatives[3] - tickCumulatives[2]) / 7200; // 检查一致性:短期 TWAP 不应偏离长期 TWAP 太多 require(abs(twap30min - twap2hour) < 100, "Price manipulation detected");
局限性
TWAP 不是万能的:
- 可以被操纵,只是成本很高:攻击者需要持续推高价格 30 分钟,需要锁定大量资金且承担价格波动风险
- 延迟性:TWAP 反映的是过去的平均价格,不是当前价格
- 需要足够的 observation 容量:如果 cardinality 太小(如只有 1),无法查询长时间窗口
最佳实践:
- 结合其他 Oracle(如 Chainlink)使用
- 多池验证(如同时检查 0.05% 和 0.3% fee tier 的池子)
- 异常检测(短期 TWAP 不应偏离长期 TWAP 太多)
Flash Swaps
普通 swap 的流程:先付钱,再拿货
你 ---(转入 USDC)---> Pool ---(转出 ETH)---> 你
Flash Swap 的流程:先拿货,再付钱(在同一笔交易内)
Pool ---(先转出 ETH)---> 你 ---(做点事)---> 你 ---(再转入 USDC)---> Pool
核心特性:
- Pool 先把代币转给你,你还没付钱
- Pool 回调你的合约,你可以用这些代币做任何事(套利、清算等)
- 回调结束前,你必须把欠的代币转回给 Pool
- 如果你没还够,整个交易回滚,就像什么都没发生
安全性:这一切在同一个交易内完成,EVM 的原子性保证了要么全部成功,要么全部回滚。Pool 不会真的损失资金。
与 Flash Loan 的区别
Flash Swap 和 Flash Loan 很像,但有一个本质区别:
Flash Loan(如 Aave):
- 借 100 USDC → 必须还 100 USDC + 手续费
- 借什么还什么,币种不能变
Flash Swap(Uniswap):
- 借 1 ETH → 可以还 2000 USDC(按当前价格 + 手续费)
- 本质是一笔延迟支付的 swap,不是借贷
案例说明:
假设 Uniswap ETH/USDC 池的价格是 2000 USDC/ETH。
Flash Loan 思维:
借 1 ETH → 用 1 ETH 做点事 → 还 1 ETH + 0.0009 ETH 手续费
你必须在最后凑出 ETH 来还。
Flash Swap 思维:
先拿 1 ETH → 用 1 ETH 做点事 → 还 2000 USDC + 手续费
这其实就是一笔普通的 swap(用 USDC 买 ETH),只不过付款延迟到了回调结束时。
为什么这个区别重要?
假设你想套利:Uniswap ETH 价格 2000,SushiSwap ETH 价格 2050。
用 Flash Loan:
- 借 2000 USDC
- 在 Uniswap 用 2000 USDC 买 1 ETH
- 在 SushiSwap 卖 1 ETH 得 2050 USDC
- 还 2000 USDC + 手续费
- 利润:~48 USDC
用 Flash Swap:
- 从 Uniswap Flash Swap 拿 1 ETH(欠 2000 USDC)
- 在 SushiSwap 卖 1 ETH 得 2050 USDC
- 还 Uniswap 2000 USDC + 手续费
- 利润:~48 USDC
Flash Swap 少了一步(不需要先买 ETH),更省 Gas!
| 特性 | Flash Swap (Uniswap) | Flash Loan (Aave) |
|---|---|---|
| 本质 | 延迟支付的 swap | 无抵押借贷 |
| 借 A 还 B | ✅ 可以(这就是 swap) | ❌ 必须还 A |
| 手续费 | swap fee(0.05%-1%) | 固定 0.09% |
| Gas 效率 | 更高(少一次 swap) | 需要额外 swap |
工作流程
具体步骤:
假设你想套利:Uniswap ETH 价格 2000,SushiSwap ETH 价格 2010。 策略:从 Uniswap Flash Swap 拿 ETH,在 SushiSwap 卖出,用 USDC 还 Uniswap。
假设池子 token0=WETH, token1=USDC。
1. 调用 swap() 发起 Flash Swap:
// 我想得到 1 ETH(token0),稍后用 USDC(token1)还 pool.swap( address(this), // recipient: 代币发给我 false, // zeroForOne=false: 我要得到 token0(ETH) -1 ether, // 负数 = exactOutput: 我想精确得到 1 ETH type(uint160).max, // 价格限制(设最大,不限制) abi.encode(data) // 传给回调的数据 );
参数解释:
zeroForOne=false:表示"用 token1 换 token0",即用 USDC 买 ETHamountSpecified=-1 ether:负数表示 exactOutput,我想得到 1 ETH- Pool 会计算出我需要付多少 USDC
2. Pool 先把 ETH 转给你:
Pool ---(转出 1 ETH)---> 你的合约 此时你还没付 USDC!
3. Pool 回调你的合约:
// Pool 调用你的回调函数 uniswapV3SwapCallback( -1 ether, // amount0Delta: 负数表示 Pool 转出(你收到) 2001 * 1e6, // amount1Delta: 正数表示你需要转给 Pool data );
amountDelta 的符号规则:
- 负数:Pool 转出,你收到
- 正数:你需要转给 Pool
4. 你在回调中执行套利:
function uniswapV3SwapCallback( int256 amount0Delta, // -1 ETH(你收到) int256 amount1Delta, // +2001 USDC(你需要还) bytes calldata data ) external override { require(msg.sender == address(pool), "Unauthorized"); // 此时你已经有 1 ETH 了! // 在 SushiSwap 卖出 1 ETH,得到 2010 USDC sushiRouter.swapExactTokensForTokens(1 ether, ...); // 归还 Uniswap:需要 2001 USDC(含手续费) uint256 amountOwed = uint256(amount1Delta); IERC20(usdc).transfer(msg.sender, amountOwed); // 利润:2010 - 2001 = 9 USDC }
5. Pool 验证余额:
回调返回后,Pool 检查自己的 USDC 余额是否增加了 amount1Delta。如果不够,整个交易 revert。
安全机制:
-
回调验证:必须验证
msg.sender == pool,否则任何人都可以调用你的回调骗走资金为什么危险? 上面的代码恰好写死了调用 SushiSwap,所以攻击者伪造调用时,如果合约没有 ETH,SushiSwap 那步会失败。但如果回调写得更"灵活"(比如根据 amount0Delta 决定卖多少),或者合约恰好有 ETH 余额,攻击者就能得逞。验证 msg.sender 是通用的安全原则——永远不要相信外部传入的参数,除非你确认调用者是可信的。
-
重入锁:Pool 在 swap 期间上锁,防止回调中再次调用 swap
什么是重入? EVM 确实是串行的,但在同一笔交易内,合约 A 可以调用合约 B,B 再回调 A。如果没有锁,你在回调里可以再次调用
pool.swap(),形成递归调用。虽然 Uniswap 的设计让重入很难获利,但加锁是防御性编程的好习惯。 -
余额检查:回调结束后验证余额,确保你真的还了钱
应用场景
跨 DEX 套利
市场状况:
- Uniswap V3 ETH/USDC 池:价格 = 2000 USDC/ETH
- SushiSwap ETH/USDC 池:价格 = 2010 USDC/ETH
- 手续费:Uniswap 0.05%,SushiSwap 0.3%
套利步骤:
-
Flash Swap 借 10 ETH:
- 从 Uniswap 借出 10 ETH
- 需要归还: USDC(包含 0.05% 手续费)
-
在 SushiSwap 卖出 10 ETH:
- 得到: USDC(扣除 0.3% 手续费)
-
归还 Uniswap:
- 归还 20,010 USDC
-
利润:
- USDC
无需本金:整个过程不需要你预先持有 ETH 或 USDC。
风险:
- 价格滑点:如果你的交易量过大,会推动价格,减少利润
- Gas 费用:需要确保利润 > Gas 费用
- MEV 竞争:其他套利者可能抢先交易(frontrun)
清算
背景:某借贷协议(如 Aave)中,用户抵押 1 ETH 借出 1500 DAI。ETH 价格下跌,健康因子 < 1,可被清算。
清算规则:清算者归还部分债务(如 750 DAI),获得等值抵押品 + 清算奖励(如 5%),即获得价值 787.5 DAI 的 ETH。
问题:清算者需要先有 750 DAI 才能执行清算,但可能没有。
用 Flash Swap 解决:
-
Flash Swap 从 Uniswap DAI/ETH 池拿 750 DAI
(此时欠 Uniswap 一些 ETH,具体数量取决于价格)
-
用 750 DAI 清算借贷协议的仓位
获得价值 787.5 DAI 的 ETH(含 5% 奖励)
-
把获得的 ETH 转给 Uniswap,偿还 Flash Swap 的债务
-
利润 = 获得的 ETH - 欠 Uniswap 的 ETH = 清算奖励 - 手续费
关键点:Flash Swap 让你可以"借 DAI 还 ETH",正好符合清算场景(需要 DAI,获得 ETH)。如果用 Flash Loan,你借 DAI 必须还 DAI,还需要额外一步把 ETH 换成 DAI。
限制
不是无限资金:
借出数量受池子流动性限制。如果池子只有 100 ETH 流动性,你最多借 100 ETH。
手续费成本:
Flash Swap 不是免费的,需要支付 swap 手续费(0.05%-1%)。
套利竞争:
Flash Swap 让套利变得无门槛(无需本金),导致套利机会瞬间被抢走,MEV 竞争激烈。
NFT Position Manager
概览
在第二篇中我们学习了 Pool 合约的流动性管理(mint/burn/collect)。但直接和 Pool 交互有几个问题:
问题 1:Position 无法转让
Pool 层的 Position 由 (owner, tickLower, tickUpper) 标识:
// Pool 合约中 mapping(bytes32 => Position.Info) public positions; // key = keccak256(owner, tickLower, tickUpper)
owner 是 Position 的一部分,无法改变。如果 Alice 想把 Position 卖给 Bob,做不到。
问题 2:接口不友好
Pool.mint() 需要你自己:
- 把价格转换成 tick
- 计算流动性数量 L(需要用公式算)
- 处理回调函数
问题 3:同一区间只能有一个 Position
如果 Alice 在 [1800, 2200] 区间已有 Position,她再 mint 同样区间,只会增加同一个 Position 的流动性,而不是创建新的。
NFT Manager 的解决方案
NonfungiblePositionManager 是一个包装合约,它:
- 持有所有 Position:NFT Manager 自己作为 owner 去调用 Pool.mint()
- 铸造 NFT 给用户:每个 Position 对应一个 ERC721 NFT(有唯一 tokenId)
- 用 tokenId 追踪:内部维护
tokenId => Position信息的映射
用户视角: 实际存储: Alice 持有 NFT #1234 → NFT Manager 是 Pool Position 的 owner NFT Manager 记录 #1234 属于 Alice
这样就解决了上面的问题:
- 可转让:Alice 把 NFT #1234 转给 Bob,Bob 就拥有了这个 Position
- 接口简单:用户只需指定"我想提供多少代币",NFT Manager 自动计算流动性
- 同一区间可以有多个 Position:每次 mint 都创建新的 NFT,即使区间相同
NFT Manager 和 Pool 的调用关系
┌─────────────────────────────────────────────────────────┐ │ 用户 │ └─────────────────────┬───────────────────────────────────┘ │ 调用 NFT Manager ▼ ┌─────────────────────────────────────────────────────────┐ │ NonfungiblePositionManager │ │ │ │ - 接收用户的"代币数量",计算流动性 L │ │ - 调用 Pool.mint/burn/collect │ │ - 铸造/销毁 NFT │ │ - 维护 tokenId => Position 的映射 │ └─────────────────────┬───────────────────────────────────┘ │ 调用 Pool ▼ ┌─────────────────────────────────────────────────────────┐ │ Pool 合约 │ │ │ │ - 真正存储流动性 │ │ - 更新 tick、计算手续费 │ │ - 执行 swap │ └─────────────────────────────────────────────────────────┘
关键理解:NFT Manager 不存储流动性,它只是一个"代理"。真正的流动性在 Pool 里。
核心操作
NFT Manager 提供的接口比 Pool 简单得多:
| 操作 | NFT Manager 函数 | 底层调用 |
|---|---|---|
| 创建 Position | mint() | Pool.mint() + 铸造 NFT |
| 追加流动性 | increaseLiquidity() | Pool.mint() |
| 移除流动性 | decreaseLiquidity() | Pool.burn() |
| 提取代币 | collect() | Pool.collect() |
| 销毁 NFT | burn() | 只销毁 NFT(需先移除流动性) |
用户接口 vs Pool 接口的区别:
// Pool.mint() - 需要自己算流动性 pool.mint(recipient, tickLower, tickUpper, liquidityAmount, data); // NFT Manager.mint() - 只需指定代币数量 positionManager.mint({ token0: WETH, token1: USDC, fee: 3000, tickLower: 76000, tickUpper: 76640, amount0Desired: 10 ether, // 我想提供 10 ETH amount1Desired: 20000 * 1e6, // 我想提供 20000 USDC amount0Min: 9.5 ether, // 滑点保护 amount1Min: 19000 * 1e6, recipient: msg.sender, deadline: block.timestamp + 300 });
NFT Manager 会自动根据当前价格和你提供的代币数量,计算出最合适的流动性 L。
批量操作
NFT Manager 提供 multicall() 函数,可以在一笔交易内执行多个操作。
为什么需要?
假设你想关闭一个 Position,需要三步:
decreaseLiquidity()- 移除流动性collect()- 提取代币burn()- 销毁 NFT
如果分三笔交易,要付三次 Gas。用 multicall 打包成一笔交易,更省 Gas 且保证原子性。
// 一次性完成:移除流动性 + 提取代币 + 销毁 NFT positionManager.multicall([ abi.encodeCall(positionManager.decreaseLiquidity, (...)), abi.encodeCall(positionManager.collect, (...)), abi.encodeCall(positionManager.burn, (tokenId)) ]);
Position 生命周期
创建 ──────────────────────────────────────────────────→ 销毁 mint() increaseLiquidity() decreaseLiquidity() burn() │ │ │ │ ▼ ▼ ▼ ▼ 铸造 NFT 追加流动性 移除流动性 销毁 NFT │ ▼ collect() 提取代币
注意:
- 随时可以 collect() 提取已赚取的手续费
- burn() 前必须:liquidity = 0,tokensOwed = 0
- NFT 可以随时在 OpenSea 等市场交易
总结
从 V2 的简单恒定乘积,到 V3 的集中流动性、Tick 机制、手续费算法、TWAP 预言机、Flash Swaps,Uniswap 在不断推动 DEX 的边界。
V3 的核心思想:
- 权衡取舍:用复杂度换效率(集中流动性)
- 延迟计算:不在事件发生时处理,而是记录累积信息(手续费、TWAP)
- 组合性:提供基础设施(TWAP、Flash Swaps),让其他协议组合使用
V3 不是完美的(Gas 高、LP 需要主动管理、价格移出区间不赚手续费),但它代表了 AMM 设计的一个高峰。
V4 的 Hooks 系统将进一步释放创新空间——开发者可以在不修改核心协议的情况下,实现各种定制化功能。
附录
合约地址(以太坊主网)
- UniswapV3Factory:
0x1F98431c8aD98523631AE4a59f267346ea31F984 - NonfungiblePositionManager:
0xC36442b4a4522E871399CD717aBDD847Ab11FE88 - SwapRouter:
0xE592427A0AEce92De3Edee1F18E0157C05861564
作者:加密鲸拓
版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!