Uniswap V3 第6章 流动性:NFT 头寸的增删与领费

讲解 Uniswap V3 如何通过 NonfungiblePositionManager 铸造 NFT 头寸、增减流动性、领取手续费,以及 pool 的 mint/burn/collect。

5 分钟阅读
Uniswap V3 第6章 流动性:NFT 头寸的增删与领费

Uniswap V3 第 6 章:流动性(Liquidity)

这一章讲 LP 怎么实操:用 NonfungiblePositionManager 铸造一个 NFT 头寸、增加/减少流动性、领取累积的手续费。底层对应 Pool 的 mint / burn / collect


目录


1. 头寸的生命周期

一个 V3 流动性头寸的一生:

mint(铸造 NFT,存入初始流动性)

   ├─ increaseLiquidity(往同一个头寸追加)
   ├─ decreaseLiquidity(抽出部分流动性 → 变成"欠你的代币")
   ├─ collect(领取手续费 + decrease 出来的代币)

   └─ burn(流动性清零后,销毁这个 NFT)

NonfungiblePositionManager(简称 NPM)是 LP 的主要入口,它把这些操作包装好,并用一个 NFT(tokenId)代表你的整个头寸。


2. mint:铸造一个 NFT 头寸

manager.mint(MintParams({
    token0: DAI, token1: WETH, fee: 3000,
    tickLower: ..., tickUpper: ...,        // 你选的价格区间
    amount0Desired: 1000e18, amount1Desired: 1e18,  // 想投入的量
    amount0Min: 0, amount1Min: 0,          // 滑点保护
    recipient: address(this),               // NFT 给谁
    deadline: block.timestamp
}));
// 返回 (tokenId, liquidity, amount0, amount1)
  • 你给 amount0Desired / amount1Desired,NPM 按你选的区间和当前价格,算出实际能用多少(回忆第 3 章:区间内两种币的比例是固定的,多的那种不会全用上)。
  • 返回 tokenId(你的 NFT 编号)、实际投入的 amount0/amount1、以及获得的流动性 liquidity(L)。
  • 区间用 tickLower / tickUpper 表示。想做”全区间”(类似 V2)就用最大/最小 tick。

3. tick 必须是 tickSpacing 的整数倍

tickLowertickUpper 必须能被池子的 tickSpacing 整除,否则 revert。

例如 0.3% 池的 tickSpacing=60,要做”全区间”头寸,不能直接用 MIN_TICK = -887272(不是 60 的倍数),而要对齐:

int24 tickLower = MIN_TICK / TICK_SPACING * TICK_SPACING;  // 向 0 取整到 60 的倍数
int24 tickUpper = MAX_TICK / TICK_SPACING * TICK_SPACING;

这是练习里最容易踩的坑——任何区间端点都要先对齐到 tickSpacing


4. 底层三件套:pool.mint / burn / collect

NPM 最终调用 Pool 的三个函数:

Pool 函数作用
mint(recipient, tickLower, tickUpper, amount, data)在区间增加流动性 L,通过回调收取两种代币
burn(tickLower, tickUpper, amount)减少流动性 L,把对应代币 + 已赚手续费记为”欠你的”(tokensOwed),但不转账
collect(recipient, tickLower, tickUpper, amount0Req, amount1Req)把”欠你的”代币真正转给你

mint 用回调模式(类似 flash):Pool 先记账,再回调要求调用者把代币转进来,检查到账才算数。


5. 关键:burn 不退币,collect 才退

这是 V3 一个反直觉但重要的设计:

burn 不会把代币转给你,它只是把”该退的本金 + 已赚的手续费”累加到你头寸的 tokensOwed0 / tokensOwed1。要真正拿到币,必须再调 collect

为什么分两步?

  • 让”减少流动性”和”提取代币”解耦,更灵活(你可以多次 burn 再一次性 collect)。
  • 手续费的领取也走 collect:哪怕你不 burn,只要调 collect,就能把累积的手续费取走(手续费在每次 swap 经过你区间时累计到 tokensOwed,详见第 8 章)。

所以”领手续费” = collect;“撤本金” = decreaseLiquidity(底层 burn) + collect


6. increaseLiquidity / decreaseLiquidity

对一个已存在的头寸(tokenId):

manager.increaseLiquidity(IncreaseLiquidityParams({
    tokenId: id, amount0Desired: ..., amount1Desired: ...,
    amount0Min: ..., amount1Min: ..., deadline: ...
}));  // 往同一区间追加流动性

manager.decreaseLiquidity(DecreaseLiquidityParams({
    tokenId: id, liquidity: 减少多少L,
    amount0Min: ..., amount1Min: ..., deadline: ...
}));  // 抽出部分流动性 → 记为 tokensOwed(还没到手)

manager.collect(CollectParams({
    tokenId: id, recipient: 你, amount0Max: max, amount1Max: max
}));  // 把 tokensOwed(本金+手续费)真正取出来

完全撤出并销毁 NFT 的顺序:decreaseLiquidity(全部L)collect(全部)burn(tokenId)(NPM 的 burn,要求流动性和 owed 都已清零)。


7. 案例:完整的 LP 流程

小明给 DAI/WETH 0.3% 池做 LP:

1. mint:选区间 [tickLower, tickUpper](对齐到 60),投入 1000 DAI + 0.5 WETH
   → 得到 NFT #123,流动性 L,实际用了 1000 DAI + 0.33 WETH(多的 WETH 退回)
2. 一段时间内,交易者经过他的区间 swap,手续费累积
3. collect(#123):领取累积的手续费(比如 5 DAI + 0.002 WETH)
4. 想退出:
   a. decreaseLiquidity(#123, 全部 L) → 本金记为 tokensOwed
   b. collect(#123) → 拿回本金 + 剩余手续费
   c. burn(#123) → 销毁 NFT

8. 本章小结

  1. V3 头寸是 NFT,由 NonfungiblePositionManager 管理;生命周期:mint → increase/decrease/collect → burn。
  2. mint 选区间 [tickLower, tickUpper] 和投入量,返回 tokenId、实际投入、流动性 L。
  3. 区间端点必须是 tickSpacing 的整数倍(要先对齐,否则 revert)。
  4. 底层 Pool 三件套:mint(加 L,回调收币)、burn(减 L,记 tokensOwed,不转账)、collect(真正转账)。
  5. 领手续费 = collect;撤本金 = decreaseLiquidity + collect;销毁 = burn(NFT)。

9. 动手练习

对应课程的 Liquidity 练习:mint / increase / decrease / collect 全流程。

练习:NFT 头寸的完整操作

主网分叉,NPM = 0xC36442b4a4522E871399CD717aBDD847Ab11FE88,DAI/WETH 0.3% 池:

interface INonfungiblePositionManager {
    function mint(MintParams calldata) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);
    function increaseLiquidity(...) external payable returns (...);
    function decreaseLiquidity(...) external payable returns (uint256 amount0, uint256 amount1);
    function collect(...) external payable returns (uint256 amount0, uint256 amount1);
}

思路:

  1. setUpdeal 给自己 3000 DAI + 3 WETH,approve 给 NPM。
  2. mint:tickLower/tickUpper 用 MIN_TICK/60*60MAX_TICK/60*60(全区间,已对齐),amount0Desired=1000e18amount1Desired=1e18。断言返回 liquidity > 0,拿到 tokenId
  3. increaseLiquidity:对同一 tokenId 再追加一些,断言流动性增加。
  4. swap 制造手续费(可选):用 Router 做几笔 DAI↔WETH 兑换,让你的区间累积手续费。
  5. decreaseLiquidity:减少全部 L。
  6. collectamount0Max/amount1Max = type(uint128).max,断言收回了本金 + 手续费(两种币余额增加)。

运行

forge test --evm-version cancun --fork-url $FORK_URL \
  --match-path test/UniswapV3Liquidity.t.sol -vvv

下一章(第 7 章 Tick Bitmap)讲:swap 跨 tick 时,如何用位图高效找到”下一个有流动性的 tick”。

💬 评论