Uniswap V3 - 3. 高级特性与实战应用

本文是 Uniswap V3 深度解析系列的第三篇,探讨手续费分配、价格预言机、闪电交换等高级特性。

在前两篇文章中,我们理解了 V3 的核心创新(集中流动性)、数学基础(Tick 机制)、以及交换和流动性管理的具体实现。

但还有几个关键问题没有解答:

  1. 手续费如何分配? 成千上万个 Position 在不同价格区间提供流动性,每次交换只在部分区间发生,手续费怎么精确分配给每个 LP?
  2. 如何获取可靠的价格数据? DeFi 协议需要价格预言机,直接用池子的即时价格会被闪电贷攻击,V3 提供了什么解决方案?
  3. 什么是 Flash Swaps? 为什么可以"先借后还",这背后有什么安全机制?
  4. 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 全局累积器

定义:从池子创建到现在,单位流动性累积获得的手续费总量。

公式

feeGrowthGlobal=feeliquidity\text{feeGrowthGlobal} = \sum \frac{\text{fee}}{\text{liquidity}}

每次交换产生手续费 fee,当前活跃流动性是 liquidity,那么 feeGrowthGlobal 增加:

feeGrowthGlobalnew=feeGrowthGlobalold+feeliquidity\text{feeGrowthGlobal}_{\text{new}} = \text{feeGrowthGlobal}_{\text{old}} + \frac{\text{fee}}{\text{liquidity}}

举例

假设池子刚创建,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],如何计算区间内的累积?

核心思路:如果我们知道区间外面累积了多少,用全局减去外面,就是里面。

feeGrowthInside=feeGrowthGlobalfeeGrowthOutside下方feeGrowthOutside上方\text{feeGrowthInside} = \text{feeGrowthGlobal} - \text{feeGrowthOutside}_{\text{下方}} - \text{feeGrowthOutside}_{\text{上方}}

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 ✅

翻转后,数值的含义变了,但"下方"和"上方"的实际累积量没变!

翻转公式的本质

feeGrowthOutsidenew=feeGrowthGlobalfeeGrowthOutsideold\text{feeGrowthOutside}_{\text{new}} = \text{feeGrowthGlobal} - \text{feeGrowthOutside}_{\text{old}}

因为"上方累积 + 下方累积 = 全局累积",所以翻转就是"全局 - 原值 = 另一侧的值"。

 

feeGrowthInside 区间内的累积

有了全局累积和边界外累积,就能计算区间内的累积。

核心公式

feeGrowthInside=feeGrowthGlobalfeeGrowth下方feeGrowth上方\text{feeGrowthInside} = \text{feeGrowthGlobal} - \text{feeGrowth}_{\text{下方}} - \text{feeGrowth}_{\text{上方}}

对于上面 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(增加/减少流动性,或收取手续费)时,计算自上次更新以来的手续费增量:

tokensOwed=(feeGrowthInsidenowfeeGrowthInsidelast)×liquidity2128\text{tokensOwed} = \frac{(\text{feeGrowthInside}_{\text{now}} - \text{feeGrowthInside}_{\text{last}}) \times \text{liquidity}}{2^{128}}
  • feeGrowthInsidenow\text{feeGrowthInside}_{\text{now}} 是当前区间内的累积
  • feeGrowthInsidelast\text{feeGrowthInside}_{\text{last}} 是上次更新时的累积
  • 两者相减得到增量
  • 乘以 Position 的流动性,就是应得手续费
  • 除以 21282^{128} 是因为 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,每次交换只需要:

  1. 更新 feeGrowthGlobal(1 次计算)
  2. 如果跨越 tick,翻转 feeGrowthOutside(最多几次)

LP 收取手续费时,只需要:

  1. 计算当前 feeGrowthInside(几次读取 + 减法)
  2. 乘以自己的流动性

时间复杂度:O(1),与 Position 数量无关!

 

完整案例

让我们通过一个详细的案例,完整追踪手续费是如何累积和分配的。

初始状态

ETH/USDC 池,当前价格 2000 USDC/ETH(tick = 76320),feeGrowthGlobal = 0。

三个 LP 依次创建 Position:

PositionTick 区间价格区间 (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 初始值记录的是哪一侧
7600076320 ≥ 760000 (= feeGrowthGlobal)下方
7620076320 ≥ 762000下方
7632076320 ≥ 763200下方
7644076320 < 764400上方
7664076320 < 766400上方
7680076320 < 768000上方

(因为 feeGrowthGlobal = 0,所以碰巧都是 0)

 

事件 1:第一笔交换(价格 = 2000)

  • 用户用 100 USDC 换 ETH,产生手续费 0.3 USDC
  • 活跃流动性:1000 + 500 + 800 = 2300
feeGrowthGlobal=0+0.32300=0.0001304\text{feeGrowthGlobal} = 0 + \frac{0.3}{2300} = 0.0001304

 

事件 2:价格上涨穿过 tick 76440(Bob 的上界)

  • 价格从 2000 涨到 2080,穿过 tick 76440
  • Bob 的 Position 变为不活跃

翻转 feeGrowthOutside[76440]

翻转前:feeGrowthOutside[76440] = 0,记录的是 tick 上方的累积(因为之前价格在 76440 下方)

feeGrowthOutside[76440]new=0.00013040=0.0001304\text{feeGrowthOutside}[76440]_{\text{new}} = 0.0001304 - 0 = 0.0001304

翻转后:feeGrowthOutside[76440] = 0.0001304,记录的是 tick 下方的累积(因为现在价格在 76440 上方)

活跃流动性变为:1000 + 800 = 1800

 

事件 3:第二笔交换(价格 = 2080)

  • 用户用 200 USDC 换 ETH,产生手续费 0.6 USDC
  • 活跃流动性:1800
feeGrowthGlobal=0.0001304+0.61800=0.0004637\text{feeGrowthGlobal} = 0.0001304 + \frac{0.6}{1800} = 0.0004637

 

事件 4:价格继续上涨穿过 tick 76640(Alice 的上界)

  • 价格从 2080 涨到 2250,穿过 tick 76640
  • Alice 的 Position 变为不活跃

翻转 feeGrowthOutside[76640]

feeGrowthOutside[76640]new=0.00046370=0.0004637\text{feeGrowthOutside}[76640]_{\text{new}} = 0.0004637 - 0 = 0.0004637

翻转后记录的是 tick 下方的累积。

活跃流动性变为:800

 

事件 5:第三笔交换(价格 = 2250)

  • 用户用 150 USDC 换 ETH,产生手续费 0.45 USDC
  • 活跃流动性:800
feeGrowthGlobal=0.0004637+0.45800=0.0010262\text{feeGrowthGlobal} = 0.0004637 + \frac{0.45}{800} = 0.0010262

 

最终状态汇总

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 (0.3×100023000.3 \times \frac{1000}{2300}) + 事件3 (0.6×100018000.6 \times \frac{1000}{1800}) = 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.3×50023000.3 \times \frac{500}{2300} = 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.3×8002300+0.6×8001800+0.45×8008000.3 \times \frac{800}{2300} + 0.6 \times \frac{800}{1800} + 0.45 \times \frac{800}{800} = 0.1043 + 0.2667 + 0.45 = 0.8210


验证总和

LP手续费 (USDC)
Alice0.4637
Bob0.0652
Charlie0.8210
总计1.3499 ≈ 1.35

总手续费 = 0.3 + 0.6 + 0.45 = 1.35 USDC

三个 LP 的手续费之和精确等于总手续费,验证了 feeGrowthInside 算法的正确性!

核心要点

  1. 无需遍历 Position:每次交换只更新 feeGrowthGlobal 和必要的 feeGrowthOutside
  2. 延迟计算:手续费在 LP 主动收取时才计算,不是在交换时分配
  3. O(1) 复杂度:无论池子里有 3 个还是 3000 个 Position,计算量都一样
  4. 翻转是关键:计算 feeGrowthInside 时,必须根据当前价格位置判断是否需要翻转

 

TWAP 预言机

即时价格的弊端

想象一个借贷协议,用 Uniswap 的即时价格(slot0.sqrtPriceX96)来判断抵押品价值和清算线。

为什么即时价格危险?

Uniswap 的即时价格就是当前池子的 P\sqrt{P},它会随着每一笔 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
    // - 整个过程在一笔交易内完成,无法被阻止
}

为什么能在一笔交易内完成?

  1. 闪电贷 → 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)时更新:

price0Cumulativenew=price0Cumulativeold+P×Δt\text{price0Cumulative}_{\text{new}} = \text{price0Cumulative}_{\text{old}} + P \times \Delta t

其中 P=y/xP = y/x 是当前价格,Δt\Delta t 是距离上次更新的时间。

外部合约如何使用

需要自己记录两个时间点的累积值,计算 TWAP:

TWAP=price0Cumulativet2price0Cumulativet1t2t1\text{TWAP} = \frac{\text{price0Cumulative}_{t_2} - \text{price0Cumulative}_{t_1}}{t_2 - t_1}

V2 的缺点

  1. 算术平均:价格是相对变化(百分比),算术平均不适合
  2. 单点存储:只存储一个累积值,外部合约需要自己存储历史点
  3. 精度有限:使用 UQ112x112 格式

 

V3 的改进

改进 1:几何平均替代算术平均

先搞懂什么是算术平均和几何平均

假设 ETH 价格在两天内这样变化:

  • 第 1 天:1000 USDC
  • 第 2 天:2000 USDC

算术平均(就是普通平均):

1000+20002=1500\frac{1000 + 2000}{2} = 1500

几何平均(把数字相乘,再开根号):

1000×2000=2,000,0001414\sqrt{1000 \times 2000} = \sqrt{2,000,000} \approx 1414

单看这两个数字,你可能觉得 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。

你的实际平均买入价格是多少?

实际平均价格=2000 USDC1.5 ETH=1333 USDC/ETH\text{实际平均价格} = \frac{2000 \text{ USDC}}{1.5 \text{ ETH}} = 1333 \text{ USDC/ETH}

不是 1500!

如果你用 1500 作为"平均价格"来估算成本,你会以为 1.5 ETH 花了 2250 USDC,比实际多算了 250 USDC。

因为价格高的时候,同样的钱买到的数量少;价格低的时候,买到的数量多。算术平均没有考虑这个"数量权重",所以价格会不准。几何平均 1414 更接近真实的 1333,而算术平均 1500 偏高了。

 

另一个角度:正反价格不一致

假设 ETH/USDC 的价格在两个时间段分别是 1000 和 2000。

ETH/USDC 角度算算术平均:

1000+20002=1500 USDC/ETH\frac{1000 + 2000}{2} = 1500 \text{ USDC/ETH}

USDC/ETH 角度(取倒数)算算术平均:

1/1000+1/20002=0.001+0.00052=0.00075 ETH/USDC\frac{1/1000 + 1/2000}{2} = \frac{0.001 + 0.0005}{2} = 0.00075 \text{ ETH/USDC}

如果 ETH/USDC = 1500,那么 USDC/ETH 应该 = 1/1500 ≈ 0.000667

但我们算出来的是 0.00075,对应 ETH/USDC = 1/0.00075 ≈ 1333

1500 ≠ 1333!算术平均从两个方向算出了不同的结果!

几何平均没有这个问题:正反价格互为倒数

1000×2000=1414\sqrt{1000 \times 2000} = 1414 1/1000×1/2000=1/(1000×2000)=1/14140.000707\sqrt{1/1000 \times 1/2000} = \sqrt{1/(1000 \times 2000)} = 1/1414 \approx 0.000707 1414×0.000707=11414 \times 0.000707 = 1 \quad ✓

 

V3 如何实现几何平均?

直接累积价格再开根号在链上很难算。V3 用了一个巧妙的数学转换:几何平均 = 对数的算术平均,再取指数

举例:计算 1000 和 2000 的几何平均

方法 1(直接算):1000×2000=1414\sqrt{1000 \times 2000} = 1414

方法 2(用对数):

  1. 取对数:log(1000)6.9\log(1000) \approx 6.9log(2000)7.6\log(2000) \approx 7.6
  2. 算算术平均:(6.9+7.6)/2=7.25(6.9 + 7.6) / 2 = 7.25
  3. 取指数还原:e7.251414e^{7.25} \approx 1414

两种方法结果一样!

 

V3 的实现

还记得 tick 的定义吗?P=1.0001tickP = 1.0001^{\text{tick}},也就是 tick=log1.0001(P)\text{tick} = \log_{1.0001}(P)

tick 本身就是价格的对数!所以 V3 只需要:

1. 累积 tick × 时间

tickCumulative 是一个只增不减的累积值,每次有交易时更新:

tickCumulativenew=tickCumulativeold+tick×Δt\text{tickCumulative}_{\text{new}} = \text{tickCumulative}_{\text{old}} + \text{tick} \times \Delta t

其中 Δt\Delta t 是距离上次更新经过的秒数

具体例子

时间线(用时间戳表示,单位:秒)

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:

tickavg=tickCumulativet=1450tickCumulativet=100014501000=460000450102.2\text{tick}_{\text{avg}} = \frac{\text{tickCumulative}_{t=1450} - \text{tickCumulative}_{t=1000}}{1450 - 1000} = \frac{46000 - 0}{450} \approx 102.2

t2t1t_2 - t_1 就是时间戳的差值,单位是秒。这里 1450 - 1000 = 450 秒。

3. 还原为价格(取指数)

Pavg=1.0001102.21.0103P_{\text{avg}} = 1.0001^{102.2} \approx 1.0103

 

改进 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 会线性插值:

tickCumulativet=1000=50000+52000500001200900×(1000900)=50000+2000300×10050667\text{tickCumulative}_{t=1000} = 50000 + \frac{52000 - 50000}{1200 - 900} \times (1000 - 900) = 50000 + \frac{2000}{300} \times 100 ≈ 50667

这样就能查询任意历史时间点的数据。

 

计算案例

让我们通过一个具体案例,理解 tickCumulative 如何累积,以及如何计算 TWAP。

场景:ETH/USDC 池,观察 1 小时内的价格变化。

关键理解:tickCumulative 累积的是上一个时间段的 tick × 时间,不是当前时刻的。

时间当前 tick价格 (USDC/ETH)tickCumulative说明
0:007632020000池子刚创建,初始值
0:307652020500 + 76320 × 1800 = 137,376,000过去 30 分钟 tick=76320
0:45761201950137,376,000 + 76520 × 900 = 206,244,000过去 15 分钟 tick=76520
1:00761201950206,244,000 + 76120 × 900 = 274,752,000过去 15 分钟 tick=76120

计算 0:00 到 1:00 的 TWAP

平均 tick:

tickavg=tickCumulative1:00tickCumulative0:003600\text{tick}_{\text{avg}} = \frac{\text{tickCumulative}_{1:00} - \text{tickCumulative}_{0:00}}{3600} =274,752,00003600=76,320= \frac{274,752,000 - 0}{3600} = 76,320

平均价格:

Pavg=1.0001763202000 USDC/ETHP_{\text{avg}} = 1.0001^{76320} \approx 2000 \text{ USDC/ETH}

 

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 不是万能的

  1. 可以被操纵,只是成本很高:攻击者需要持续推高价格 30 分钟,需要锁定大量资金且承担价格波动风险
  2. 延迟性:TWAP 反映的是过去的平均价格,不是当前价格
  3. 需要足够的 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

核心特性

  1. Pool 把代币转给你,你还没付钱
  2. Pool 回调你的合约,你可以用这些代币做任何事(套利、清算等)
  3. 回调结束前,你必须把欠的代币转回给 Pool
  4. 如果你没还够,整个交易回滚,就像什么都没发生

安全性:这一切在同一个交易内完成,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

  1. 借 2000 USDC
  2. 在 Uniswap 用 2000 USDC 买 1 ETH
  3. 在 SushiSwap 卖 1 ETH 得 2050 USDC
  4. 还 2000 USDC + 手续费
  5. 利润:~48 USDC

Flash Swap

  1. 从 Uniswap Flash Swap 拿 1 ETH(欠 2000 USDC)
  2. 在 SushiSwap 卖 1 ETH 得 2050 USDC
  3. 还 Uniswap 2000 USDC + 手续费
  4. 利润:~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 买 ETH
  • amountSpecified=-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%

套利步骤

  1. Flash Swap 借 10 ETH

    • 从 Uniswap 借出 10 ETH
    • 需要归还:10×2000×1.0005=20,01010 \times 2000 \times 1.0005 = 20,010 USDC(包含 0.05% 手续费)
  2. 在 SushiSwap 卖出 10 ETH

    • 得到:10×2010×0.997=20,039.710 \times 2010 \times 0.997 = 20,039.7 USDC(扣除 0.3% 手续费)
  3. 归还 Uniswap

    • 归还 20,010 USDC
  4. 利润

    • 20,039.720,010=29.720,039.7 - 20,010 = 29.7 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 解决

  1. Flash Swap 从 Uniswap DAI/ETH 池拿 750 DAI

    (此时欠 Uniswap 一些 ETH,具体数量取决于价格)

  2. 用 750 DAI 清算借贷协议的仓位

    获得价值 787.5 DAI 的 ETH(含 5% 奖励)

  3. 把获得的 ETH 转给 Uniswap,偿还 Flash Swap 的债务

  4. 利润 = 获得的 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 是一个包装合约,它:

  1. 持有所有 Position:NFT Manager 自己作为 owner 去调用 Pool.mint()
  2. 铸造 NFT 给用户:每个 Position 对应一个 ERC721 NFT(有唯一 tokenId)
  3. 用 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 函数底层调用
创建 Positionmint()Pool.mint() + 铸造 NFT
追加流动性increaseLiquidity()Pool.mint()
移除流动性decreaseLiquidity()Pool.burn()
提取代币collect()Pool.collect()
销毁 NFTburn()只销毁 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,需要三步:

  1. decreaseLiquidity() - 移除流动性
  2. collect() - 提取代币
  3. 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

作者:加密鲸拓

版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!