Uniswap V3 - 2.交换机制与流动性管理

本文是 Uniswap V3 深度解析系列的第二篇,聚焦于 V3 最核心的功能:代币交换(Swap)和流动性管理(Mint/Burn)。

通过本文,你将深入理解:

1.Swap 机制:从单 Tick 交换到跨多个 Tick 的复杂情况 2.Factory 合约:如何创建和管理池子,CREATE2 的作用 3.流动性管理:Mint、Burn、Collect 的完整流程 4.Tick Bitmap:如何高效查找下一个初始化的 Tick 5.实战交互:通过真实案例理解合约交互的完整过程

 

核心问题

  • 当用户想用 100 ETH 换 USDC 时,合约具体是如何工作的?
  • 当价格跨越多个 Tick 时,流动性如何变化?
  • LP 如何添加流动性?需要提供多少代币?
  • 手续费如何累积和分配?

让我们开始探索。

 

Swap 机制

核心问题

用户视角(简单):

我有:1 ETH
我想要:多少 USDC?
我能接受的最低价格:1800 USDC/ETH(滑点保护)

这是一个exactInput操作:输入数量固定,输出数量待定。

或者反过来:

我想要:2000 USDC
我愿意付:最多多少 ETH?

这是一个exactOutput操作:输出数量固定,输入数量待定。

 

合约视角(复杂):

  1. 计算输出量或输入量(考虑手续费)
  2. 更新价格状态(√P 和 tick)
  3. 如果跨越 Tick,更新流动性 L
  4. 转账代币
  5. 记录日志和 TWAP

 

V2 vs V3 的差异

Uniswap V2

  1. 读取储备量 (x, y)
  2. 计算输出:Δy = y * Δx / (x + Δx * 0.997)
  3. 更新储备量
  4. 转账

 

Uniswap V3

  1. 读取当前状态 (L, √P, tick)
  2. 计算能在当前 tick 内交换多少
  3. 如果需要,移动到下一个 tick
  4. 重复步骤 2-3 直到完成交换
  5. 更新最终状态和转账

V3 复杂得多,因为流动性不是均匀分布的,可能需要跨越多个 tick,每个 tick 的流动性可能不同。

 

单 Tick Swap

让我们从最简单的情况开始:假设交换不会跨越 tick 边界

场景设定

假设 ETH/USDC 池子: -当前价格 P:2000 USDC/ETH -当前 √P:44.721 -当前 tick:76320 -当前流动性 L:1,000,000 -下一个初始化的 tick:76380(价格约 2006) -手续费:0.3%

用户想用0.1 ETH换 USDC。

 

步骤 1:判断是否会跨越边界

用户提供 0.1 ETH(token0),这会导致价格下降(因为 ETH 变多了)。

注意,这里的 Δx\Delta x扣除手续费后的数量:

Δxactual=0.1×(10.003)=0.0997 ETH\Delta x_{\text{actual}} = 0.1 \times (1 - 0.003) = 0.0997 \text{ ETH}

计算新价格:

Pafter=LPbeforeL+ΔxactualPbefore=1,000,000×44.7211,000,000+0.0997×44.72144.7208\begin{aligned} \sqrt{P_{\text{after}}} &= \frac{L\sqrt{P_{\text{before}}}}{L + \Delta x_{\text{actual}}\sqrt{P_{\text{before}}}} \\ &= \frac{1{,}000{,}000 \times 44.721}{1{,}000{,}000 + 0.0997 \times 44.721} \\ &\approx 44.7208 \end{aligned}

Pafter=44.720821999.95 USDC/ETHP_{\text{after}} = 44.7208^2 \approx 1999.95 \text{ USDC/ETH}

价格从 2000 下降到 1999.95,对应的 tick 约为 76319,没有跨越边界(下一个边界是 76380),所以这笔交换可以在当前流动性内完成。

 

步骤 2:计算输出量

现在我们知道了 Pbefore=44.721\sqrt{P_{\text{before}}} = 44.721Pafter44.7208\sqrt{P_{\text{after}}} \approx 44.7208

使用公式:

Δy=L×(PbeforePafter)=1,000,000×(44.72144.7208)=200 USDC\begin{aligned} \Delta y &= L \times (\sqrt{P_{\text{before}}} - \sqrt{P_{\text{after}}}) \\ &= 1{,}000{,}000 \times (44.721 - 44.7208) \\ &= 200 \text{ USDC} \end{aligned}

所以,用户用 0.1 ETH 可以换到约200 USDC

验证:平均价格 = 200 / 0.1 = 2000 USDC/ETH ✓

 

数学公式

ExactInput: 固定输入量

这是最常见的情况:用户知道要卖出多少,想知道能买到多少。

Token0 → Token1 (zeroForOne = true,交易方向为正)

用户提供 Δx\Delta x(token0),想得到 Δy\Delta y(token1)。

步骤 1:扣除手续费

Δxactual=Δxin×(1fee)\Delta x_{\text{actual}} = \Delta x_{\text{in}} \times (1 - \text{fee})

步骤 2:计算新价格

Pafter=L×PbeforeL+Δxactual×Pbefore\sqrt{P_{\text{after}}} = \frac{L \times \sqrt{P_{\text{before}}}}{L + \Delta x_{\text{actual}} \times \sqrt{P_{\text{before}}}}

步骤 3:计算输出量

Δyout=L×(PbeforePafter)\Delta y_{\text{out}} = L \times (\sqrt{P_{\text{before}}} - \sqrt{P_{\text{after}}})

 

Token1 → Token0 (zeroForOne = false,交易方向为负)

用户提供 Δy\Delta y(token1),想得到 Δx\Delta x(token0)。

步骤 1:扣除手续费

Δyactual=Δyin×(1fee)\Delta y_{\text{actual}} = \Delta y_{\text{in}} \times (1 - \text{fee})

步骤 2:计算新价格

Pafter=Pbefore+ΔyactualL\sqrt{P_{\text{after}}} = \sqrt{P_{\text{before}}} + \frac{\Delta y_{\text{actual}}}{L}

注意:这个公式比 token0 → token1 简单得多(线性关系)!

步骤 3:计算输出量

Δxout=L×(1Pafter1Pbefore)\Delta x_{\text{out}} = L \times \left(\frac{1}{\sqrt{P_{\text{after}}}} - \frac{1}{\sqrt{P_{\text{before}}}}\right)

 

ExactOutput: 固定输出量

用户知道想买多少,需要计算要卖多少。这是反向计算。

Token0 → Token1 (想得到固定的 Δy\Delta y)

从期望输出反推新价格:

Pafter=PbeforeΔydesiredL\sqrt{P_{\text{after}}} = \sqrt{P_{\text{before}}} - \frac{\Delta y_{\text{desired}}}{L}

计算需要的输入量(扣除手续费前):

Δxin=Δxactual1fee\Delta x_{\text{in}} = \frac{\Delta x_{\text{actual}}}{1 - \text{fee}}

其中 Δxactual=L×(1Pafter1Pbefore)\Delta x_{\text{actual}} = L \times \left(\frac{1}{\sqrt{P_{\text{after}}}} - \frac{1}{\sqrt{P_{\text{before}}}}\right)

 

手续费扣除

关键点:手续费在输入时扣除,而不是输出时。

用户输入 1 ETH
↓
扣除 0.3% = 0.003 ETH 作为手续费
↓
实际用于交换的:0.997 ETH
↓
计算输出量(基于 0.997 ETH)

为什么这样设计?

1.简化计算:只需要在一个地方处理手续费 2.符合直觉:用户输入的数量包含手续费 3.防止操纵:输出不受手续费直接影响

手续费不是立即分配给 LP,而是累积在池子中,LP 可以随时 claim。

 

跨 Tick Swap

在 V3 中,由于流动性是集中的,单个 tick 内的流动性可能不足以完成大额交易

####案例分析

假设 ETH/USDC 池子(0.3% 手续费,tickSpacing = 60):

Tick 区间流动性 L价格范围 (USDC/ETH)
76260-76320500,0001994.0 - 2000.0
76320-763801,000,0002000.0 - 2006.0
76380-76440300,0002006.0 - 2012.0

当前价格在 tick 76320(2000 USDC/ETH),流动性 L = 1,000,000。

 

如果用户想用100 ETH换 USDC,用 Part 1 的公式计算:

Pafter=1,000,000×44.7211,000,000+100×44.72144.522\begin{aligned} \sqrt{P_{\text{after}}} &= \frac{1{,}000{,}000 \times 44.721}{1{,}000{,}000 + 100 \times 44.721} \\ &\approx 44.522 \end{aligned}

Pafter1982.2 USDC/ETHP_{\text{after}} \approx 1982.2 \text{ USDC/ETH}

价格降到 1982.2,已经低于tick 76260 的边界(1994),所以会跨越边界

当价格从 tick 76320 下降到 tick 76260 时,会发生两件事:

1.离开tick 76320 的上界 → 可能有 LP 的仓位在这里结束 2.进入tick 76260 的下界 → 可能有 LP 的仓位在这里开始

每个事件都可能导致全局流动性 L 的变化

 

完整流程

[开始] 用户要用 100 ETH 换 USDC
   |
   v
[状态] 当前 tick = 76320, L = 1,000,000, √P = 44.721
   |
   v
[判断] 计算如果流动性不变会到什么价格
       结果:会到 tick ≈ 76140,会跨越 tick 76260
   |
   v
[步骤1] 先在当前 tick 内交换到边界
       - 计算需要多少 ETH 才能从 76320 到 76260
       - 消耗约 29.5 ETH
       - 得到约 67,300 USDC
       - 剩余:70.5 ETH
   |
   v
[更新] 价格到达 tick 76260 边界
       - 穿越 tick 76260,更新流动性:L = 1,000,000 → 500,000
       - 更新 tick:76320 → 76259
   |
   v
[步骤2] 在新 tick 区间继续交换
       - 使用新流动性 L = 500,000
       - 用剩余的 70.5 ETH 继续
       - 可能又会跨越到下一个 tick...
   |
   v
[重复] 直到 100 ETH 全部用完或达到滑点限制
   |
   v
[结束] 返回总共得到的 USDC 数量

 

liquidityNet

当价格跨越一个 tick 边界时,需要更新全局流动性 L。但如何知道 L 应该增加还是减少?增减多少?liquidityNet可以获取这个信息。

Tick 的数据结构里就包含了这个字段

struct Tick {
    uint128 liquidityGross;     // 这个 tick 处的总流动性(绝对值)
    int128 liquidityNet;        // 穿越此 tick 时 L 的变化量(带符号)
    // ... 其他字段
}

 

liquidityNet 的含义

liquidityNet 表示:当价格穿越这个 tick 时,全局流动性 L 应该变化多少

-正数:流动性增加(有 LP 的 tickLower 在这里,仓位开始) -负数:流动性减少(有 LP 的 tickUpper 在这里,仓位结束)

 

例子

Alice 在 [76260, 76380] 添加流动性 1000,Bob 在 [76320, 76440] 添加 500。

合约更新:

ticks[76260].liquidityNet = +1000   // Alice 开始
ticks[76320].liquidityNet = +500    // Bob 开始
ticks[76380].liquidityNet = -1000   // Alice 结束
ticks[76440].liquidityNet = -500    // Bob 结束

价格变化过程:

价格区间激活的 LP全局流动性 L
< 762600
76260-76320Alice1000
76320-76380Alice + Bob1500
76380-76440Bob500
> 764400

 

更新规则

-向上穿越(价格上涨):L+=tick.liquidityNetL \mathrel{+}= \text{tick.liquidityNet} -向下穿越(价格下跌):L=tick.liquidityNetL \mathrel{-}= \text{tick.liquidityNet}

 

验证

价格从 76400 下降到 76300(向下穿越 76380 和 76320):

初始 L=500L = 500(在 76380-76440 区间,只有 Bob)

穿越 tick 76380(向下):

L=liquidityNet=500(1000)=1500L \mathrel{-}= \text{liquidityNet} = 500 - (-1000) = 1500 \quad \checkmark

穿越 tick 76320(向下):

L=liquidityNet=1500500=1000L \mathrel{-}= \text{liquidityNet} = 1500 - 500 = 1000 \quad \checkmark

 

Tick Bitmap

在 V3 中,不是所有 tick 都有流动性。大部分 tick 是未初始化的(liquidityGross = 0)。

如果逐个检查每个 tick,效率会非常低。V3 使用Tick Bitmap来高效查找下一个初始化的 tick。

核心思想:用一个 bit 表示一个 tick 是否初始化。

mapping(int16 => uint256) public tickBitmap;

每个 uint256 有 256 bits,可以表示 256 个 tick 的状态。

查找下一个初始化的 tick 只需要:

  1. 找到当前 tick 所在的 word(256 个 tick 一组)
  2. 在这个 word 内用位运算找最近的 1
  3. 如果 word 内没有,移动到下一个 word

这样,最多只需要**O(1)**的 gas!(详细机制在下面会专门讲解)

 

computeSwapStep

上面的计算在合约中是由 SwapMath.computeSwapStep 函数完成的。

函数签名

function computeSwapStep(
    uint160 sqrtRatioCurrentX96,    // 当前价格
    uint160 sqrtRatioTargetX96,     // 目标价格(下一个 tick)
    uint128 liquidity,              // 当前流动性
    int256 amountRemaining,         // 还需要交换多少
    uint24 feePips                  // 手续费
) internal pure returns (
    uint160 sqrtRatioNextX96,       // 实际到达的价格
    uint256 amountIn,               // 这一步消耗的输入量
    uint256 amountOut,              // 这一步得到的输出量
    uint256 feeAmount               // 这一步的手续费
);

 

核心逻辑

function computeSwapStep(...) internal pure returns (...) {
    bool exactIn = amountRemaining >= 0;

    if (exactIn) {
        // 扣除手续费
        uint256 amountRemainingLessFee =
            uint256(amountRemaining) * (1e6 - feePips) / 1e6;

        // 计算到达目标价格需要的输入量
        amountIn = getAmountDelta(...);

        if (amountRemainingLessFee >= amountIn) {
            // 可以到达目标价格
            sqrtRatioNextX96 = sqrtRatioTargetX96;
        } else {
            // 无法到达目标价格,计算实际能到的价格
            sqrtRatioNextX96 = getNextSqrtPrice(...);
        }
    }

    // 计算实际的 amountIn, amountOut, feeAmount
    return (sqrtRatioNextX96, amountIn, amountOut, feeAmount);
}

关键点

  1. 判断能否到达目标价格
  2. 先扣除手续费,用扣除后的量计算价格变化
  3. 所有计算都对用户不利(向上舍入输入,向下舍入输出)

 

调用示例

让我们看一个完整的实际例子,理解 computeSwapStep 在交换过程中是如何被调用的。

场景:用 1 ETH 换 USDC,这是第一步计算(在当前 tick 内交换)

输入参数

// 1. 当前价格(sqrtPriceX96)
// 当前价格 P = 2000 USDC/ETH
// sqrtPrice = √2000 ≈ 44.721
// sqrtPriceX96 = 44.721 × 2^96 ≈ 3.543 × 10^30
sqrtRatioCurrentX96 = "3543191142285914205922034323" // uint160

// 2. 目标价格(下一个初始化的 tick)
// 目标 tick = 76260,对应价格约 1994 USDC/ETH
// sqrtPrice = √1994 ≈ 44.654
// sqrtPriceX96 = 44.654 × 2^96 ≈ 3.538 × 10^30
sqrtRatioTargetX96 = "3538073524141965917640957498" // uint160

// 3. 当前流动性
liquidity = "1000000000000000000000000" // 1,000,000 (18位小数)

// 4. 剩余待交换数量(exactInput 模式)
// 用户想用 1 ETH 换 USDC
amountRemaining = "1000000000000000000" // 1 ETH (正数 = exactInput)

// 5. 手续费(0.3%)
feePips = 3000 // 3000 = 0.3%

 

执行过程

function computeSwapStep(...) internal pure returns (...) {
    // 步骤 1:判断交易方向
    bool zeroForOne = sqrtRatioCurrentX96 >= sqrtRatioTargetX96;
    // zeroForOne = true (价格下降,token0 -> token1)

    bool exactIn = amountRemaining >= 0;
    // exactIn = true (用户指定输入量)

    // 步骤 2:扣除手续费
    uint256 amountRemainingLessFee = 1000000000000000000 * (1e6 - 3000) / 1e6;
    // amountRemainingLessFee = 1 ETH × 0.997 = 997000000000000000 (0.997 ETH)

    // 步骤 3:计算到达目标价格需要的输入量
    // 使用 SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
    uint256 amountIn = getAmount0Delta(...);
    // 计算公式:Δx = L × (1/√P_target - 1/√P_current)
    // amountIn ≈ 29,500,000,000,000,000 (约 0.0295 ETH)

    // 步骤 4:判断能否到达目标价格
    if (amountRemainingLessFee >= amountIn) {
        // 0.997 ETH >= 0.0295 ETH → true
        // 可以到达目标价格!
        sqrtRatioNextX96 = sqrtRatioTargetX96;
        // sqrtRatioNextX96 = 3538073524141965917640957498

        // 实际消耗的输入量就是到达边界所需的量
        amountIn = 29500000000000000; // 0.0295 ETH

        // 加上手续费
        feeAmount = mulDivRoundingUp(amountIn, feePips, 1e6 - feePips);
        // feeAmount = 0.0295 ETH × 3000 / 997000 ≈ 88847700000000 (约 0.000089 ETH)

        // 计算输出量
        amountOut = getAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, false);
        // 计算公式:Δy = L × (√P_current - √P_target)
        // amountOut ≈ 58,900,000 (约 58.9 USDC,6位小数)
    } else {
        // 如果不能到达目标价格,会计算实际能到的价格
        // 本例中可以到达,所以不执行这个分支
    }

    return (sqrtRatioNextX96, amountIn, amountOut, feeAmount);
}

返回值

{
    sqrtRatioNextX96: "3538073524141965917640957498",  // 到达了目标价格
    amountIn: "29500000000000000",                     // 消耗 0.0295 ETH(不含手续费)
    amountOut: "58900000",                             // 得到 58.9 USDC
    feeAmount: "88847700000000"                        // 手续费 0.000089 ETH
}

 

结果解释

1.成功到达边界:价格从 2000 降到 1994(tick 76260 边界) 2.消耗的 token0:0.0295 + 0.000089 ≈ 0.0296 ETH(含手续费) 3.得到的 token1:58.9 USDC 4.剩余待交换:1 - 0.0296 = 0.9704 ETH 5.下一步:由于还有 0.9704 ETH 没交换完,需要穿越 tick 76260,更新流动性后继续交换

 

完整 Swap 流程中的位置

[Swap 主循环]
  │
  ├─ 第1次调用 computeSwapStep
  │  输入:当前状态 (tick=76320, L=1M, 剩余=1 ETH)
  │  输出:到达 tick 76260,消耗 0.0296 ETH,得到 58.9 USDC  ← 本例
  │
  ├─ 穿越 tick 76260,更新流动性:L = 1M → 500K
  │
  ├─ 第2次调用 computeSwapStep
  │  输入:新状态 (tick=76260, L=500K, 剩余=0.9704 ETH)
  │  输出:继续交换...
  │
  └─ 重复直到剩余=0 或达到限价

这个例子清楚地展示了:

  • computeSwapStep 如何处理单步交换
  • 实际的数值如何计算
  • 为什么需要在循环中多次调用它
  • 每次调用的输入和输出关系

 

Factory 与池子管理

核心职责

如果说 Pool 是 V3 的"心脏"(执行交易),那么 Factory 就是"大脑"(创建和管理所有池子)。

UniswapV3Factory 的主要功能:

1. 创建新池子

function createPool(
    address tokenA,
    address tokenB,
    uint24 fee
) external returns (address pool);

特点:

  • 任何人都可以调用(无需权限)
  • 使用 CREATE2 确定性部署
  • 同一交易对可以创建多个池子(不同 fee)

 

2. 管理手续费档位

mapping(uint24 => int24) public feeAmountTickSpacing;

// 当前启用的档位:
feeAmountTickSpacing[500] = 10;     // 0.05%
feeAmountTickSpacing[3000] = 60;    // 0.3%
feeAmountTickSpacing[10000] = 200;  // 1%
feeAmountTickSpacing[100] = 1;      // 0.01% (后来添加)

 

3. 作为池子注册表

mapping(address => mapping(address => mapping(uint24 => address)))
    public getPool;

// 查询:给定 token0, token1, fee,返回池子地址
address pool = factory.getPool(WETH, USDC, 3000);

 

CREATE2 确定性部署

CREATE2 提供了三重好处:

1. 确定性地址

无需查询链上数据就知道 Pool 地址,前端/SDK 可以直接计算。

// 计算池子地址
const salt = keccak256(encode(token0, token1, fee));
const poolAddress = getCreate2Address(factory, salt, initCodeHash);

2. 防止重复部署

CREATE2 到相同地址会 revert,天然保证一个参数组合只有一个 Pool,无需额外的 require 检查。

3. 节省 Gas

计算地址比查询 mapping 便宜,特别是在跨合约调用时。

 

CREATE2 的地址计算公式

address = keccak256(
    0xff,
    sender,          // Factory 地址
    salt,            // keccak256(abi.encode(token0, token1, fee))
    bytecodeHash     // keccak256(Pool bytecode + constructor args)
)[12:]

 

实际例子

// ETH/USDC 0.3% 池子
const FACTORY = "0x1F98431c8aD98523631AE4a59f267346ea31F984";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const FEE = 3000;

// 1. 排序 token(确保 token0 < token1)
const [token0, token1] = USDC < WETH ? [USDC, WETH] : [WETH, USDC];

// 2. 计算 salt
const salt = keccak256(
    ethers.solidityPacked(
        ['address', 'address', 'uint24'],
        [token0, token1, FEE]
    )
);

// 3. 计算地址
const POOL_INIT_CODE_HASH = "0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54";

const poolAddress = ethers.getCreate2Address(
    FACTORY,
    salt,
    POOL_INIT_CODE_HASH
);

console.log(poolAddress);
// 输出: 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640

 

createPool

function createPool(
    address tokenA,
    address tokenB,
    uint24 fee
) external noDelegateCall returns (address pool) {
    // 1. 参数验证
    require(tokenA != tokenB, 'IDENTICAL_ADDRESSES');

    // 2. 排序 token 地址
    (address token0, address token1) = tokenA < tokenB
        ? (tokenA, tokenB)
        : (tokenB, tokenA);

    require(token0 != address(0), 'ZERO_ADDRESS');

    // 3. 检查手续费档位是否启用
    int24 tickSpacing = feeAmountTickSpacing[fee];
    require(tickSpacing != 0, 'FEE_NOT_ENABLED');

    // 4. 检查是否已存在
    require(getPool[token0][token1][fee] == address(0), 'POOL_EXISTS');

    // 5. 使用 CREATE2 部署池子
    pool = deploy(address(this), token0, token1, fee, tickSpacing);

    // 6. 更新注册表
    getPool[token0][token1][fee] = pool;

    // 7. 触发事件
    emit PoolCreated(token0, token1, fee, tickSpacing, pool);
}

 

为什么要排序?

确保 ETH/USDCUSDC/ETH 映射到同一个池子。

// 例子
createPool(WETH, USDC, 3000)
createPool(USDC, WETH, 3000)

// 两次调用都会得到同一个池子地址,因为内部会排序:
// token0 = USDC (0xA0b8..., 地址更小)
// token1 = WETH (0xC02a..., 地址更大)

 

多费率体系

V3 支持 4 个手续费档位,每个对应不同的 Tick Spacing:

手续费Tick Spacing价格粒度适用场景
0.01%10.01%稳定币对(DAI/USDC)
0.05%100.1%相关资产(ETH/stETH)
0.3%600.6%标准交易对(ETH/USDC)
1%2002%高波动资产

为什么需要多费率?

1.稳定币对:价格稳定,滑点小,LP 竞争激烈,需要低费率吸引交易量 2.波动资产:价格变化大,无常损失风险高,需要高费率补偿 LP

 

为什么不同费率需要不同 Tick Spacing?

低费率 + 小 Tick Spacing

  • 0.01% 费率,Tick Spacing = 1
  • LP 可以精确设置价格区间
  • 适合稳定币这种价格范围窄的场景

高费率 + 大 Tick Spacing

  • 1% 费率,Tick Spacing = 200
  • 强制 LP 提供更宽的流动性
  • 防止流动性过度碎片化
  • 平衡交易者滑点和 LP 收益

 

流动性管理

在深入流动性管理之前,我们需要理解几个关键概念。这些概念贯穿整个 V3 系统,是理解后续操作的基础。

核心概念

####Global Liquidity

定义:全局流动性。在当前价格下,所有激活的 Position 提供的流动性总和。

// Pool 合约的状态变量
uint128 public liquidity;  // 全局流动性

特性

1.动态变化:随价格移动而变化

  • 当价格进入某个 Position 的区间,该 Position 的流动性被"激活"
  • 当价格离开某个 Position 的区间,该 Position 的流动性被"停用"

2.用于交换计算:所有 Swap 公式都使用当前的全局流动性 L

P=LP/(L+ΔxP)这里的L就是全局流动性√P' = L√P / (L + Δx√P) ← 这里的 L 就是全局流动性

 

例子

当前价格:2000 USDC/ETH (tick 76320)

Position A: [1800, 2200],流动性 500
Position B: [1900, 2100],流动性 300
Position C: [2050, 2300],流动性 200

当前全局流动性 = 500 + 300 = 800
(Position C 未激活,因为当前价格 < 2050)

 

liquidityGross

定义:Tick 的总流动性。在某个 Tick 边界处,所有穿过该 Tick 的 Position 的流动性总和(绝对值)。

struct Tick {
    uint128 liquidityGross;  // 总流动性(无符号)
    ...
}

用途:判断 Tick 是否已初始化(liquidityGross > 0

例子

Position A: [76260, 76380],流动性 1000
Position B: [76260, 76440],流动性 500

在 Tick 76260(两个 Position 的下界):
liquidityGross = 1000 + 500 = 1500

在 Tick 76380(只有 Position A 的上界):
liquidityGross = 1000

在 Tick 76440(只有 Position B 的上界):
liquidityGross = 500

 

liquidityNet

定义:Tick 的净流动性变化。当价格穿越某个 Tick 时,全局流动性应该增加或减少的数量(带符号)。

struct Tick {
    int128 liquidityNet;  // 净变化(有符号)
    ...
}

符号规则

-tickLowerliquidityNet > 0(流动性开始,向上穿越时增加) -tickUpperliquidityNet < 0(流动性结束,向上穿越时减少)

更新规则

// 向上穿越(价格上涨)
liquidity += tick.liquidityNet

// 向下穿越(价格下跌)
liquidity -= tick.liquidityNet

完整例子

Alice: [76260, 76380],L = 1000
Bob:   [76320, 76440],L = 500

各 Tick 的数据:

Tick 76260 (Alice 下界):
  liquidityGross = 1000
  liquidityNet = +1000  ← 向上穿越时,+1000 到全局流动性

Tick 76320 (Bob 下界):
  liquidityGross = 500
  liquidityNet = +500   ← 向上穿越时,+500 到全局流动性

Tick 76380 (Alice 上界):
  liquidityGross = 1000
  liquidityNet = -1000  ← 向上穿越时,-1000 到全局流动性

Tick 76440 (Bob 上界):
  liquidityGross = 500
  liquidityNet = -500   ← 向上穿越时,-500 到全局流动性

价格移动时的流动性变化

价格区间激活的 Position计算过程全局流动性
< 76260初始0
76260-76320Alice0 + 10001000
76320-76380Alice + Bob1000 + 5001500
76380-76440Bob1500 - 1000500
> 76440500 - 5000

 

三者关系

全局流动性 (liquidity)
  ↓
  当前价格下所有激活的 Position 的流动性总和
  随价格移动而动态更新

liquidityGross
  ↓
  某个 Tick 处的流动性总量(绝对值的和)
  用于判断 Tick 是否初始化

liquidityNet
  ↓
  穿越 Tick 时,全局流动性的变化量(带符号)
  用于高效更新全局流动性(O(1)操作)

 

为什么需要 liquidityNet?

如果没有 liquidityNet,更新全局流动性需要:

// 朴素方法,需要找到每个 position 的上下界(太慢!)
function updateLiquidity(int24 newTick) {
    liquidity = 0;
    for (每个 Position) {
        if (position.tickLower <= newTick < position.tickUpper) {
            liquidity += position.liquidity;
        }
    }
}
// O(n) 复杂度,n = Position 数量,可能有成千上万个!

使用 liquidityNet:

// V3 方法(高效!)
function crossTick(int24 tick, bool goingUp) {
    if (goingUp) {
        liquidity += ticks[tick].liquidityNet;
    } else {
        liquidity -= ticks[tick].liquidityNet;
    }
}
// O(1) 复杂度,无论有多少 Position!

 

Position

在 V3 中,每个流动性仓位是一个Position,而不是简单的 LP Token 余额。

数据结构

struct Position {
    // 流动性数量
    uint128 liquidity;

    // 手续费相关(上次更新时的累积值)
    uint256 feeGrowthInside0LastX128;
    uint256 feeGrowthInside1LastX128;

    // 待领取的手续费
    uint128 tokensOwed0;
    uint128 tokensOwed1;
}

 

唯一标识

在 Pool 合约中,Position 通过一个 key 唯一标识:

mapping(bytes32 => Position.Info) public positions;

// Position key 的计算
function positionKey(
    address owner,
    int24 tickLower,
    int24 tickUpper
) internal pure returns (bytes32) {
    return keccak256(abi.encodePacked(owner, tickLower, tickUpper));
}

关键点

  • 同一个 owner + tickLower + tickUpper = 同一个 Position
  • 不同的价格区间 = 不同的 Position
  • 即使是同一个 owner,在不同区间的流动性也是分开的

例子

Alice 的操作:

// 第一次:在 [1800, 2200] 添加流动性
mint(alice, 1800, 2200, 1000) -> Position A

// 第二次:在同样区间添加更多
mint(alice, 1800, 2200, 500) -> 更新 Position A (流动性变为 1500)

// 第三次:在不同区间添加
mint(alice, 1900, 2100, 300) -> Position B (新的 Position)

结果:

Position A: key = hash(alice, 1800, 2200), liquidity = 1500
Position B: key = hash(alice, 1900, 2100), liquidity = 300

 

Pool 层

在 Pool 层面,Position 只是一个 mapping 条目。但用户通常通过NonfungiblePositionManager交互,它会铸造一个ERC721 NFT代表这个 Position。

// NonfungiblePositionManager.sol
struct Position {
    uint96 nonce;
    address operator;
    address token0;
    address token1;
    uint24 fee;
    int24 tickLower;
    int24 tickUpper;
    uint128 liquidity;
    uint256 feeGrowthInside0LastX128;
    uint256 feeGrowthInside1LastX128;
    uint128 tokensOwed0;
    uint128 tokensOwed1;
}

NFT 的 tokenId 是自增的,每次 mint 创建新的 NFT。

 

添加流动性

Mint 是 V3 最复杂的操作之一,涉及 Position 创建、Tick 更新、手续费累积等多个机制。

完整实现如下

function mint(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount,  // 要添加的流动性 L
    bytes calldata data
) external returns (uint256 amount0, uint256 amount1) {
    // 1. 检查 tick 有效性
    require(tickLower < tickUpper);
    require(tickLower >= TickMath.MIN_TICK);
    require(tickUpper <= TickMath.MAX_TICK);

    // 2. 更新 Position
    Position.Info storage position = positions.get(
        recipient, tickLower, tickUpper
    );

    // 3. 更新 Tick
    ticks.update(tickLower, amount, ...);
    ticks.update(tickUpper, amount, ...);

    // 4. 如果当前价格在区间内,更新全局流动性
    if (tick >= tickLower && tick < tickUpper) {
        liquidity += amount;
    }

    // 5. 计算所需代币数量
    amount0 = getAmount0Delta(...);
    amount1 = getAmount1Delta(...);

    // 6. 回调前记录余额
    uint256 balance0Before = balance0();
    uint256 balance1Before = balance1();

    // 7. 通过回调收取代币
    IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(
        amount0, amount1, data
    );
    
    // 8. 回调后校验是否已付款
    if (amount0 > 0) require(balance0Before + amount0 <= balance0(), "M0");
    if (amount1 > 0) require(balance1Before + amount1 <= balance1(), "M1");

    emit Mint(recipient, tickLower, tickUpper, amount, amount0, amount1);
}

 

步骤详解

步骤 1:计算所需代币数量

根据第一篇的公式,给定流动性 LL 和价格区间 [Plower,Pupper][P_{\text{lower}}, P_{\text{upper}}],当前价格 PP

  • 如果 P<PlowerP < P_{\text{lower}}:只需要 token0

x=LPlowerLPupper,y=0x = \frac{L}{\sqrt{P_{\text{lower}}}} - \frac{L}{\sqrt{P_{\text{upper}}}}, \quad y = 0

  • 如果 PlowerP<PupperP_{\text{lower}} \leq P < P_{\text{upper}}:需要两种代币

x=LPLPuppery=LPLPlower\begin{aligned} x &= \frac{L}{\sqrt{P}} - \frac{L}{\sqrt{P_{\text{upper}}}} \\ y &= L\sqrt{P} - L\sqrt{P_{\text{lower}}} \end{aligned}

  • 如果 PPupperP \geq P_{\text{upper}}:只需要 token1

x=0,y=LPupperLPlowerx = 0, \quad y = L\sqrt{P_{\text{upper}}} - L\sqrt{P_{\text{lower}}}

 

步骤 2:更新 Tick 数据

对于 tickLower 和 tickUpper,需要更新:

// 如果 Tick 首次初始化
if (!tick.initialized) {
    tick.initialized = true;
    // 在 Bitmap 中标记此 Tick
    tickBitmap.flipTick(tickLower, tickSpacing);
}

// 更新流动性
tick.liquidityGross += liquidity;

// 更新 liquidityNet(带方向)
if (isLowerTick) {
    tick.liquidityNet += int128(liquidity);  // 下界,流动性增加
} else {
    tick.liquidityNet -= int128(liquidity);  // 上界,流动性减少
}

 

步骤 3:更新 Position 数据

position.liquidity += liquidity;

// 更新手续费跟踪(防止领取历史手续费,后面会具体讲手续费算法)
position.feeGrowthInside0LastX128 = feeGrowthInside0;
position.feeGrowthInside1LastX128 = feeGrowthInside1;

 

步骤 4:更新全局流动性

如果当前价格在区间内,全局流动性立即增加:

if (tick >= tickLower && tick < tickUpper) {
    liquidity += amount;
}

 

步骤 5:转账代币

合约使用回调模式转账:

// Pool 调用 caller 的回调函数
IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(
    amount0, amount1, data
);

// Caller(如 NonfungiblePositionManager)在回调中转账
function uniswapV3MintCallback(
    uint256 amount0,
    uint256 amount1,
    bytes calldata data
) external {
    // 验证调用者是 Pool
    require(msg.sender == pool);

    // 转账代币到 Pool
    if (amount0 > 0) token0.safeTransferFrom(payer, msg.sender, amount0);
    if (amount1 > 0) token1.safeTransferFrom(payer, msg.sender, amount1);
}

为什么用回调?

  1. 安全:Pool 先更新状态,再要求转账,防止重入攻击
  2. 灵活:支持闪电贷等高级功能
  3. Gas 优化:避免多次跨合约调用

 

移除流动性

Burn 是 Mint 的反向操作。

function burn(
    int24 tickLower,
    int24 tickUpper,
    uint128 amount  // 要移除的流动性
) external returns (uint256 amount0, uint256 amount1) {
    // 1. 获取 Position
    Position.Info storage position = positions.get(
        msg.sender, tickLower, tickUpper
    );

    require(position.liquidity >= amount);

    // 2. 更新 Position
    position.liquidity -= amount;

    // 3. 更新 Tick
    ticks.update(tickLower, -int128(amount), ...);
    ticks.update(tickUpper, -int128(amount), ...);

    // 4. 如果当前价格在区间内,更新全局流动性
    if (tick >= tickLower && tick < tickUpper) {
        liquidity -= amount;
    }

    // 5. 计算应返回的代币数量
    amount0 = getAmount0Delta(...);
    amount1 = getAmount1Delta(...);

    // 6. 更新 tokensOwed(暂不转账)
    position.tokensOwed0 += uint128(amount0);
    position.tokensOwed1 += uint128(amount1);

    emit Burn(msg.sender, tickLower, tickUpper, amount, amount0, amount1);
}

关键点

  • Burn 只是更新状态,不实际转账
  • 代币累积在 tokensOwed
  • 用户需要调用 collect 领取

为什么分两步?

  1. Gas 优化:可以 burn 多个仓位,一次性 collect
  2. 手续费结算:同时领取手续费和本金

 

收取手续费和本金

function collect(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount0Requested,
    uint128 amount1Requested
) external returns (uint128 amount0, uint128 amount1) {
    // 1. 获取 Position
    Position.Info storage position = positions.get(
        msg.sender, tickLower, tickUpper
    );

    // 2. 计算应得手续费(如果有流动性)
    if (position.liquidity > 0) {
        // 计算 feeGrowthInside(手续费后面讲)
        uint256 feeGrowthInside0 = getFeeGrowthInside0(...);
        uint256 feeGrowthInside1 = getFeeGrowthInside1(...);

        // 计算累积的手续费
        position.tokensOwed0 += uint128(
            position.liquidity *
            (feeGrowthInside0 - position.feeGrowthInside0LastX128) / 2**128
        );
        position.tokensOwed1 += ...;

        // 更新状态
        position.feeGrowthInside0LastX128 = feeGrowthInside0;
        position.feeGrowthInside1LastX128 = feeGrowthInside1;
    }

    // 3. 实际转账(取最小值)
    amount0 = amount0Requested > position.tokensOwed0
        ? position.tokensOwed0
        : amount0Requested;
    amount1 = ...;

    position.tokensOwed0 -= amount0;
    position.tokensOwed1 -= amount1;

    if (amount0 > 0) token0.transfer(recipient, amount0);
    if (amount1 > 0) token1.transfer(recipient, amount1);

    emit Collect(msg.sender, recipient, tickLower, tickUpper, amount0, amount1);
}

 

Tick Bitmap

在跨 Tick 交换时,我们需要快速找到下一个初始化的 Tick

V3 使用Tick Bitmap来高效查找:用一个 bit 表示一个 tick 是否初始化。

mapping(int16 => uint256) public tickBitmap;

每个 uint256 有 256 bits,可以表示 256 个 tick 的状态。

例如:

tickBitmap[300] = 0b...0000100100...
                        ^   ^
                        |   |
                    tick 76380 已初始化
                    tick 76320 已初始化

 

数据结构

映射关系

给定一个 tick,如何找到对应的 bitmap 位置?

// 1. 计算 word position(256 个 tick 一组)
int16 wordPos = int16(tick >> 8);  // tick / 256

// 2. 计算 bit position(在 word 内的位置)
uint8 bitPos = uint8(uint24(tick % 256));

// 3. 读取/设置 bit
uint256 word = tickBitmap[wordPos];
bool initialized = (word & (1 << bitPos)) != 0;

为什么右移 8 位?

因为 28=2562^8 = 256,右移 8 位相当于除以 256。

例子 1:正数 Tick

tick = 76320
wordPos = 76320 >> 8 = 298
bitPos = 76320 % 256 = 64

所以 tick 76320 对应:
tickBitmap[298] 的第 64 位

例子 2:负数 Tick 的处理

负数 tick 的处理需要特别注意,因为负数的右移和取模行为与正数不同。

tick = -500

步骤 1:计算 wordPos
wordPos = -500 >> 8 = -2  (向下取整除法)

验证:-2 × 256 = -512, -1 × 256 = -256
-512 ≤ -500 < -256,所以 wordPos = -2 ✓

步骤 2:计算 bitPos
在 Solidity 中,需要转换为 uint 再取模:
uint24(-500) = 16777216 - 500 = 16776716
bitPos = 16776716 % 256 = 12

验证:-500 在 word -2 中的位置
word -2 范围:[-512, -257]
-500 = -512 + 12
所以 bitPos = 12 ✓

所以 tick -500 对应:
tickBitmap[-2] 的第 12 位

 

设置和清除 bit

// 设置 bit(标记 tick 已初始化)
function flipTick(int24 tick) internal {
    int16 wordPos = int16(tick >> 8);
    uint8 bitPos = uint8(uint24(tick % 256));

    uint256 mask = 1 << bitPos;
    tickBitmap[wordPos] ^= mask;  // 异或操作,翻转 bit
}

 

查找算法

假设现在有一堆 tick:60, 120, 180, 240, 300...(tickSpacing = 60)

当前价格在 tick = 250,需要向左找下一个有流动性的 tick。

如果逐个检查,太慢了!Bitmap 的作用就是用位运算快速定位。

 

第一步:Compress(压缩)

因为 tick 必须是 60 的倍数,我们先把 tick 除以 60,变成连续的整数:

tick 60   → compressed 1
tick 120  → compressed 2
tick 180  → compressed 3
tick 240  → compressed 4
tick 300  → compressed 5

当前 tick 250 → compressed = 250 / 60 = 4(向下取整)

为什么要 compress?

因为 bitmap 的每个 bit 代表一个 tick,如果直接用 tick 值,会浪费大量空间(60 到 120 之间没有 tick,但会占用 60 个 bit)。压缩后,每个 bit 对应一个可能存在的 tick

负数的特殊处理

int24 compressed = tick / tickSpacing;
if (tick < 0 && tick % tickSpacing != 0) compressed--;

例子:

tick = -70, tickSpacing = 60
-70 / 60 = -1(Solidity 向零取整)

但 -70 应该在 [-120, -60) 区间,对应 compressed = -2
所以需要 compressed--

 

第二步:Bitmap 存储

现在 bitmap[0] 用 256 个 bit 存储 compressed tick 0-255 的状态:

compressed:  0   1   2   3   4   5   6  ...
bitmap[0]:  [0] [1] [0] [1] [0] [1] [0] ...
             ↑   ↑   ↑   ↑   ↑   ↑
             无  有  无  有  无  有

假设只有 compressed 1, 3, 5 有流动性(对应 tick 60, 180, 300)

用二进制表示:bitmap[0] = 0b00101010

位:  7  6  5  4  3  2  1  0
值:  0  0  1  0  1  0  1  0
        ↑     ↑     ↑
      tick  tick  tick
      300   180   60

 

第三步:查找算法

当前在 compressed = 4,要向左找(≤ 4)最近的有流动性的 tick。

方法 1:笨办法

检查 bit 4:0(无)
检查 bit 3:1(有!)✓
找到了!compressed 3 → tick 180

如果有成千上万个 tick,这样检查会消耗大量 gas。

方法 2:掩码 + 位运算(V3 的做法)

一次性处理:

当前 bitPos = 4

步骤 1:创建掩码
mask = (1 << 4) - 1 + (1 << 4)
     = 0b00010000 - 1 + 0b00010000
     = 0b00001111 + 0b00010000
     = 0b00011111  (第 0-4 位是 1)

作用:只保留 ≤ 4 的位置

步骤 2:应用掩码
bitmap = 0b00101010  (原始,第 1,3,5 位是 1)
masked = bitmap & mask
       = 0b00101010 & 0b00011111
       = 0b00001010  (只剩第 1,3 位)

第 5 位被清除了(超出范围)

步骤 3:找最高位的 1
mostSignificantBit(0b00001010) = 3
意思是:从右往左数,从 0 开始数,最高位的 1 在第 3 位

步骤 4:计算结果
offset = bitPos - mostSignificantBit(masked)
       = 4 - 3
       = 1

next_compressed = 4 - 1 = 3
next_tick = 3 × 60 = 180 ✓

掩码的作用是**"只看某个范围内的 bit"**,然后用 mostSignificantBit/leastSignificantBit 快速找到目标,避免逐个检查。这也是 Tick Bitmap 的精髓:用空间换时间,让跨 Tick 交换成为可能

 

mostSignificantBit

就是找"从右往左,第一个是 1 的位置":

0b00001010
      ↑
    这是第 3 位(从 0 开始数)

0b01000010
  ↑
这是第 6 位

如何快速找?用二分查找

function mostSignificantBit(uint256 x) internal pure returns (uint8 r) {
    if (x >= 0x10000000000000000) { x >>= 64; r += 64; }
    if (x >= 0x100000000) { x >>= 32; r += 32; }
    if (x >= 0x10000) { x >>= 16; r += 16; }
    if (x >= 0x100) { x >>= 8; r += 8; }
    if (x >= 0x10) { x >>= 4; r += 4; }
    if (x >= 0x4) { x >>= 2; r += 2; }
    if (x >= 0x2) { r += 1; }
}

例子

x = 0b01000010 (十进制 66)

x >= 0x10 (16)? 是(66 >= 16)
  → 最高位至少在第 4 位以上
  → x 右移 4 位:x = 0b0100 (4)
  → r = 4

x >= 0x4 (4)? 是(4 >= 4)
  → 最高位至少在第 2 位以上
  → x 右移 2 位:x = 0b01 (1)
  → r = 6

x >= 0x2 (2)? 否(1 < 2)
  → 停止

结果:最高位在第 6 位 ✓

复杂度:O(log 256) = O(8) = O(1)

 

###小结

完整查找流程可视化

步骤 1: Compress
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
tick 250 ÷ 60 = 4 (compressed)

步骤 2: 定位到 Bitmap
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
wordPos = 0, bitPos = 4

bitmap[0]:
  位置:  7  6  5  4  3  2  1  0
  值:    0  0  1  0  1  0  1  0
                ↑
            当前位置

步骤 3: 创建掩码 (保留 ≤ 4 的位置)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mask:   0  0  0  1  1  1  1  1
        ↑  ↑  ↑  └──保留范围──┘

步骤 4: 应用掩码
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
bitmap: 0  0  1  0  1  0  1  0
AND
mask:   0  0  0  1  1  1  1  1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
masked: 0  0  0  0  1  0  1  0
                    ↑
            最高有效位在这里

步骤 5: 找最高位 (MSB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mostSignificantBit(0b00001010) = 3

步骤 6: 计算结果
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
offset = 4 - 3 = 1
next = (4 - 1) × 60 = 180

✓ 找到下一个 tick: 180

 

流程总结

  1. Compress:tick / 60 变成连续整数

    250 → 4

  2. 计算位置:compressed 4 在 bitmap[0] 的第 4 位

  3. 创建 mask:保留 0-4 位

    mask = 0b00011111

  4. 应用 mask:过滤掉不需要的范围

    masked = 0b00101010 & 0b00011111 = 0b00001010

  5. 找最高位:mostSignificantBit(0b00001010) = 3

  6. 计算结果:4 - 1 = 3,还原为 tick 180

 

性能对比

朴素方法:

  • 逐个检查 tick:O(n)
  • n = 10,000 时,gas > 21,000,000(超出区块 gas limit!)

Bitmap 方法:

  • 单 word 内查找:O(1)
  • 跨 word 查找:O(k),k ≤ 3
  • gas ≈ 2,000 - 6,000

性能提升:1000+ 倍!

 

合约交互

Swap 交互

Alice 想用 1 ETH 换 USDC

步骤 1:选择池子

首先需要确定用哪个池子。ETH/USDC 有多个费率的池子,需要比较哪个能得到更多 USDC。

const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const QUOTER = "0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6";

// 使用 Quoter 估算不同池子的输出
const quoter = new ethers.Contract(QUOTER, QUOTER_ABI, provider);

// 估算 0.05% 池子
const quote5 = await quoter.quoteExactInputSingle.staticCall({
    tokenIn: WETH,
    tokenOut: USDC,
    fee: 500,  // 0.05%
    amountIn: ethers.parseEther("1"),
    sqrtPriceLimitX96: 0
});

// 估算 0.3% 池子
const quote3 = await quoter.quoteExactInputSingle.staticCall({
    tokenIn: WETH,
    tokenOut: USDC,
    fee: 3000,  // 0.3%
    amountIn: ethers.parseEther("1"),
    sqrtPriceLimitX96: 0
});

console.log('0.05% 池子:');
console.log(`  输出: ${ethers.formatUnits(quote5.amountOut, 6)} USDC`);
console.log(`  手续费: 1 ETH × 0.05% = 0.0005 ETH`);
// 输出: 1998.123 USDC

console.log('0.3% 池子:');
console.log(`  输出: ${ethers.formatUnits(quote3.amountOut, 6)} USDC`);
console.log(`  手续费: 1 ETH × 0.3% = 0.003 ETH`);
// 输出: 1994.523 USDC

虽然 0.3% 池子流动性更深,但对于 1 ETH 这样的交易量: -手续费差异:0.3% - 0.05% = 0.25%,约 5 USDC -滑点差异:0.05% 池子滑点稍大,但不足以抵消手续费优势

所以选择使用0.05% 池子,能多得到 3.6 USDC。

决策流程图

graph TD
    A[开始: 1 ETH 换 USDC] --> B[查询可用池子]
    B --> C1[0.05% 池子]
    B --> C2[0.3% 池子]
    B --> C3[1% 池子]

    C1 --> D1[Quoter 估算<br/>输出: 1998.123 USDC<br/>手续费: 0.0005 ETH]
    C2 --> D2[Quoter 估算<br/>输出: 1994.523 USDC<br/>手续费: 0.003 ETH]
    C3 --> D3[Quoter 估算<br/>输出: 1985.123 USDC<br/>手续费: 0.01 ETH]

    D1 --> E[比较输出量]
    D2 --> E
    D3 --> E

    E --> F{选择最优池子}
    F -->|输出最多| G[0.05% 池子 ✓]

    style G fill:#90EE90
    style D1 fill:#E8F5E9

 

步骤 2:设置滑点保护

Alice 使用 0.05% 池子的报价,设置 1% 滑点保护:

const selectedFee = 500;  // 0.05%
const quote = quote5;

const minAmountOut = quote.amountOut * 99n / 100n;  // 99%
console.log(`Min amount out: ${ethers.formatUnits(minAmountOut, 6)} USDC`);
// 输出: Min amount out: 1978.142 USDC

 

步骤 3:执行交易

通过 SwapRouter02 执行:

const SWAP_ROUTER = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45";
const router = new ethers.Contract(SWAP_ROUTER, ROUTER_ABI, signer);

// 构造参数
const swapParams = {
    tokenIn: WETH,
    tokenOut: USDC,
    fee: 500,  // 使用选中的 0.05% 池子
    recipient: alice.address,
    amountIn: ethers.parseEther("1"),
    amountOutMinimum: minAmountOut,
    sqrtPriceLimitX96: 0
};

// 执行交易
const tx = await router.exactInputSingle(swapParams, {
    value: ethers.parseEther("1")  // 如果 tokenIn 是 ETH
});

console.log(`Transaction sent: ${tx.hash}`);
await tx.wait();
console.log("Swap completed!");

 

步骤 4:链上发生了什么?

让我们追踪这笔交易在链上的完整过程:

交易流程图

sequenceDiagram
    participant Alice
    participant SwapRouter
    participant Pool
    participant WETH
    participant USDC

    Alice->>SwapRouter: exactInputSingle(1 ETH -> USDC)
    Note over Alice: 已 approve WETH

    SwapRouter->>Pool: swap(recipient, zeroForOne, amount, limit, data)
    Note over Pool: 初始状态<br/>tick=76320<br/>L=2.5e22

    Pool->>Pool: computeSwapStep<br/>计算价格和数量
    Note over Pool: 扣除手续费<br/>0.997 ETH 用于交换

    Pool->>SwapRouter: uniswapV3SwapCallback(amount0, amount1)
    Note over Pool: amount0 = +1 ETH<br/>amount1 = -1998 USDC

    SwapRouter->>WETH: transferFrom(Alice, Pool, 1 ETH)
    WETH-->>Pool: ✓ 收到 1 ETH

    Pool->>USDC: transfer(Alice, 1998 USDC)
    USDC-->>Alice: ✓ 收到 1998 USDC

    Pool->>Pool: 更新状态<br/>sqrtPrice, tick, feeGrowth

    Pool-->>SwapRouter: 返回 (amount0, amount1)
    SwapRouter-->>Alice: 交易完成

    Note over Alice: 花费: 1 ETH<br/>收到: 1998 USDC

 

最终结果

  • Alice 支付:1 ETH
  • Alice 收到:1994.523 USDC
  • Pool 收取手续费:0.003 ETH
  • 价格变化:2000 → 1999.95 USDC/ETH
  • Gas 消耗:约 120,000 gas

 

流动性管理

Bob 想成为 LP,在 ETH/USDC 池子添加流动性。

步骤 1:选择价格区间

Bob 分析当前市场:

  • 当前价格:2000 USDC/ETH
  • 过去 30 天价格范围:1800 - 2200

Bob 决定在 [1800, 2200] 区间提供流动性。

 

步骤 2:计算所需代币

Bob 想知道如果提供 10 ETH,需要多少 USDC。

const L = 10e18;  // 假设流动性为 10e18
const sqrtP = Math.sqrt(2000);
const sqrtPLower = Math.sqrt(1800);
const sqrtPUpper = Math.sqrt(2200);

// 计算所需代币
const amount0 = L * (1/sqrtP - 1/sqrtPUpper);
const amount1 = L * (sqrtP - sqrtPLower);

console.log(`For L = 10e18:`);
console.log(`Need ${amount0} ETH`);
console.log(`Need ${amount1} USDC`);

// 实际使用 NonfungiblePositionManager 的帮助函数
const positionManager = new ethers.Contract(
    POSITION_MANAGER_ADDRESS,
    POSITION_MANAGER_ABI,
    provider
);

// 更实用的方法:指定想提供的代币数量
const amount0Desired = ethers.parseEther("10");    // 10 ETH
const amount1Desired = ethers.parseUnits("20000", 6);  // 20000 USDC

 

步骤 3:添加流动性(mint)

// 计算 tick
function priceToTick(price) {
    return Math.floor(Math.log(price) / Math.log(1.0001));
}

const tickLower = priceToTick(1800);  // ≈ 75978
const tickUpper = priceToTick(2200);  // ≈ 76662

// 调整到 tickSpacing = 60 的倍数
const tickSpacing = 60;
const adjustedTickLower = Math.floor(tickLower / tickSpacing) * tickSpacing;
const adjustedTickUpper = Math.ceil(tickUpper / tickSpacing) * tickSpacing;

console.log(`Adjusted ticks: [${adjustedTickLower}, ${adjustedTickUpper}]`);
// 输出: Adjusted ticks: [75960, 76680]

// 准备 mint 参数
const mintParams = {
    token0: USDC,  // 注意:USDC < WETH,所以 USDC 是 token0
    token1: WETH,
    fee: 3000,
    tickLower: adjustedTickLower,
    tickUpper: adjustedTickUpper,
    amount0Desired: ethers.parseUnits("20000", 6),
    amount1Desired: ethers.parseEther("10"),
    amount0Min: ethers.parseUnits("19800", 6),  // 1% 滑点
    amount1Min: ethers.parseEther("9.9"),
    recipient: bob.address,
    deadline: Math.floor(Date.now() / 1000) + 3600  // 1小时后过期
};

// 执行 mint
const tx = await positionManager.mint(mintParams);
console.log(`Transaction sent: ${tx.hash}`);

const receipt = await tx.wait();

// 从事件中获取 NFT tokenId
const mintEvent = receipt.logs.find(log =>
    log.eventSignature === "IncreaseLiquidity(uint256,uint128,uint256,uint256)"
);
const tokenId = mintEvent.args.tokenId;
console.log(`NFT minted: tokenId = ${tokenId}`);

 

步骤 4:链上发生了什么?

Mint 流程图

sequenceDiagram
    participant Bob
    participant NFTManager as NonfungiblePositionManager
    participant Pool
    participant USDC
    participant WETH

    Bob->>NFTManager: mint(params)
    Note over Bob: 已 approve 代币

    NFTManager->>NFTManager: 计算流动性 L<br/>L = min(L0, L1)

    NFTManager->>Pool: mint(tickLower, tickUpper, L)

    Pool->>Pool: 更新 Tick 数据<br/>liquidityGross += L<br/>liquidityNet ± L

    Pool->>Pool: 更新 Position<br/>positions[key].liquidity += L

    Pool->>Pool: 更新全局流动性<br/>liquidity += L<br/>(如果价格在区间内)

    Pool->>NFTManager: uniswapV3MintCallback(amount0, amount1)
    Note over Pool: 要求转账代币

    NFTManager->>USDC: transferFrom(Bob, Pool, amount0)
    USDC-->>Pool: ✓ 收到 USDC

    NFTManager->>WETH: transferFrom(Bob, Pool, amount1)
    WETH-->>Pool: ✓ 收到 WETH

    Pool-->>NFTManager: 返回 (amount0, amount1)

    NFTManager->>NFTManager: _mint(Bob, tokenId)<br/>铸造 NFT

    NFTManager-->>Bob: 返回 tokenId

    Note over Bob: 获得 NFT #12345<br/>代表流动性 Position

最终结果

  • Bob 提供:约 10 ETH + 20000 USDC
  • Bob 收到:NFT #12345
  • 流动性:在 [1800, 2200] 区间
  • Gas 消耗:约 300,000 gas(首次 mint 需要初始化 tick)

 

步骤 5:价格变化时的状态

一周后,价格上涨到 2100 USDC/ETH。Bob 想知道他的仓位现在是什么状态。

// 查询当前价格
const slot0 = await pool.slot0();
const currentSqrtPriceX96 = slot0.sqrtPriceX96;
const currentPrice = (Number(currentSqrtPriceX96) / 2**96)**2;
console.log(`Current price: ${currentPrice} USDC/ETH`);
// 输出: Current price: 2100 USDC/ETH

// 查询 Position
const position = await positionManager.positions(tokenId);
console.log(`Liquidity: ${position.liquidity}`);
console.log(`Fee growth inside 0: ${position.feeGrowthInside0LastX128}`);
console.log(`Fee growth inside 1: ${position.feeGrowthInside1LastX128}`);

// 计算当前持有的代币数量
const sqrtP = Math.sqrt(2100);
const sqrtPLower = Math.sqrt(1800);
const sqrtPUpper = Math.sqrt(2200);
const L = Number(position.liquidity);

const currentAmount0 = L * (1/sqrtP - 1/sqrtPUpper);
const currentAmount1 = L * (sqrtP - sqrtPLower);

console.log(`Current holdings:`);
console.log(`  ${currentAmount0} USDC`);
console.log(`  ${currentAmount1} WETH`);

Bob 会发现,由于价格上涨,他的 ETH 减少了,USDC 增加了(被动卖出了一些 ETH)。

 

步骤 6:收取手续费

Bob 想收取累积的手续费:

// 先调用 burn(0) 更新手续费
const burnTx = await positionManager.decreaseLiquidity({
    tokenId: tokenId,
    liquidity: 0,  // 不移除流动性,只更新手续费
    amount0Min: 0,
    amount1Min: 0,
    deadline: Math.floor(Date.now() / 1000) + 3600
});
await burnTx.wait();

// 然后 collect
const collectTx = await positionManager.collect({
    tokenId: tokenId,
    recipient: bob.address,
    amount0Max: ethers.MaxUint128,  // 收取所有 token0
    amount1Max: ethers.MaxUint128   // 收取所有 token1
});
const collectReceipt = await collectTx.wait();

// 从事件中获取实际收取的手续费
const collectEvent = collectReceipt.logs.find(log =>
    log.eventSignature === "Collect(uint256,address,uint256,uint256)"
);
console.log(`Collected fees:`);
console.log(`  ${ethers.formatUnits(collectEvent.args.amount0, 6)} USDC`);
console.log(`  ${ethers.formatEther(collectEvent.args.amount1)} WETH`);
// 输出: Collected fees:
//   125.8 USDC
//   0.063 WETH

 

链上发生了什么?

1.PositionManager 调用 Pool.burn(0)

  • 流动性不变,但触发手续费计算
  • Pool 计算 feeGrowthInside
  • 更新 position.tokensOwed

2.PositionManager 调用 Pool.collect()

  • Pool 转账累积的手续费给 PositionManager
  • PositionManager 再转账给 Bob

Bob 现在收到了手续费!

 

附录

官方资源

社区资源

分析工具

开发工具

合约地址(以太坊主网)

UniswapV3Factory:
0x1F98431c8aD98523631AE4a59f267346ea31F984

SwapRouter02:
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45

NonfungiblePositionManager:
0xC36442b4a4522E871399CD717aBDD847Ab11FE88

Quoter V2:
0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6

示例池子地址(计算方法):
pool = getCreate2Address(
    factory,
    keccak256(abi.encode(token0, token1, fee)),
    POOL_INIT_CODE_HASH
)

POOL_INIT_CODE_HASH:
0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54

作者:加密鲸拓

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