本文是 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操作:输出数量固定,输入数量待定。
合约视角(复杂):
- 计算输出量或输入量(考虑手续费)
- 更新价格状态(√P 和 tick)
- 如果跨越 Tick,更新流动性 L
- 转账代币
- 记录日志和 TWAP
V2 vs V3 的差异
Uniswap V2:
- 读取储备量 (x, y)
- 计算输出:Δy = y * Δx / (x + Δx * 0.997)
- 更新储备量
- 转账
Uniswap V3:
- 读取当前状态 (L, √P, tick)
- 计算能在当前 tick 内交换多少
- 如果需要,移动到下一个 tick
- 重复步骤 2-3 直到完成交换
- 更新最终状态和转账
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 变多了)。
注意,这里的 是扣除手续费后的数量:
计算新价格:
价格从 2000 下降到 1999.95,对应的 tick 约为 76319,没有跨越边界(下一个边界是 76380),所以这笔交换可以在当前流动性内完成。
步骤 2:计算输出量
现在我们知道了 和 。
使用公式:
所以,用户用 0.1 ETH 可以换到约200 USDC。
验证:平均价格 = 200 / 0.1 = 2000 USDC/ETH ✓
数学公式
ExactInput: 固定输入量
这是最常见的情况:用户知道要卖出多少,想知道能买到多少。
Token0 → Token1 (zeroForOne = true,交易方向为正)
用户提供 (token0),想得到 (token1)。
步骤 1:扣除手续费
步骤 2:计算新价格
步骤 3:计算输出量
Token1 → Token0 (zeroForOne = false,交易方向为负)
用户提供 (token1),想得到 (token0)。
步骤 1:扣除手续费
步骤 2:计算新价格
注意:这个公式比 token0 → token1 简单得多(线性关系)!
步骤 3:计算输出量
ExactOutput: 固定输出量
用户知道想买多少,需要计算要卖多少。这是反向计算。
Token0 → Token1 (想得到固定的 )
从期望输出反推新价格:
计算需要的输入量(扣除手续费前):
其中
手续费扣除
关键点:手续费在输入时扣除,而不是输出时。
用户输入 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-76320 | 500,000 | 1994.0 - 2000.0 |
| 76320-76380 | 1,000,000 | 2000.0 - 2006.0 |
| 76380-76440 | 300,000 | 2006.0 - 2012.0 |
当前价格在 tick 76320(2000 USDC/ETH),流动性 L = 1,000,000。
如果用户想用100 ETH换 USDC,用 Part 1 的公式计算:
价格降到 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 |
|---|---|---|
| < 76260 | 无 | 0 |
| 76260-76320 | Alice | 1000 |
| 76320-76380 | Alice + Bob | 1500 |
| 76380-76440 | Bob | 500 |
| > 76440 | 无 | 0 |
更新规则:
-向上穿越(价格上涨): -向下穿越(价格下跌):
验证:
价格从 76400 下降到 76300(向下穿越 76380 和 76320):
初始 (在 76380-76440 区间,只有 Bob)
穿越 tick 76380(向下):
穿越 tick 76320(向下):
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 只需要:
- 找到当前 tick 所在的 word(256 个 tick 一组)
- 在这个 word 内用位运算找最近的 1
- 如果 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); }
关键点:
- 判断能否到达目标价格
- 先扣除手续费,用扣除后的量计算价格变化
- 所有计算都对用户不利(向上舍入输入,向下舍入输出)
调用示例
让我们看一个完整的实际例子,理解 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/USDC 和 USDC/ETH 映射到同一个池子。
// 例子 createPool(WETH, USDC, 3000) createPool(USDC, WETH, 3000) // 两次调用都会得到同一个池子地址,因为内部会排序: // token0 = USDC (0xA0b8..., 地址更小) // token1 = WETH (0xC02a..., 地址更大)
多费率体系
V3 支持 4 个手续费档位,每个对应不同的 Tick Spacing:
| 手续费 | Tick Spacing | 价格粒度 | 适用场景 |
|---|---|---|---|
| 0.01% | 1 | 0.01% | 稳定币对(DAI/USDC) |
| 0.05% | 10 | 0.1% | 相关资产(ETH/stETH) |
| 0.3% | 60 | 0.6% | 标准交易对(ETH/USDC) |
| 1% | 200 | 2% | 高波动资产 |
为什么需要多费率?
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
例子:
当前价格: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; // 净变化(有符号) ... }
符号规则:
-tickLower:liquidityNet > 0(流动性开始,向上穿越时增加)
-tickUpper:liquidityNet < 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-76320 | Alice | 0 + 1000 | 1000 |
| 76320-76380 | Alice + Bob | 1000 + 500 | 1500 |
| 76380-76440 | Bob | 1500 - 1000 | 500 |
| > 76440 | 无 | 500 - 500 | 0 |
三者关系
全局流动性 (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:计算所需代币数量
根据第一篇的公式,给定流动性 和价格区间 ,当前价格 :
-
如果 :只需要 token0
-
如果 :需要两种代币
-
如果 :只需要 token1
步骤 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); }
为什么用回调?
- 安全:Pool 先更新状态,再要求转账,防止重入攻击
- 灵活:支持闪电贷等高级功能
- 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领取
为什么分两步?
- Gas 优化:可以 burn 多个仓位,一次性 collect
- 手续费结算:同时领取手续费和本金
收取手续费和本金
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 位?
因为 ,右移 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
流程总结:
-
Compress:tick / 60 变成连续整数
250 → 4
-
计算位置:compressed 4 在 bitmap[0] 的第 4 位
-
创建 mask:保留 0-4 位
mask = 0b00011111
-
应用 mask:过滤掉不需要的范围
masked = 0b00101010 & 0b00011111 = 0b00001010
-
找最高位:mostSignificantBit(0b00001010) = 3
-
计算结果: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 现在收到了手续费!
附录
官方资源:
社区资源:
分析工具:
- Uniswap Info
- Revert Finance - LP 仓位管理
- Flipside Crypto - 数据分析
- Dune Analytics - 链上数据
开发工具:
合约地址(以太坊主网)
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
作者:加密鲸拓
版权:此文章版权归 加密鲸拓 所有,如有转载,请注明出处!