Uniswap V3 —— Periphery 解析

Uniswap V3 Periphery 提供了用户交互接口,最大的特点是使用 NFT 来表示流动性头寸,每个头寸都是独一无二的。

1. 概述

V3 Periphery 与 V2 的主要区别:

特性 V2 V3
流动性凭证 ERC-20 LP Token ERC-721 NFT
价格范围 全范围 (0, ∞) 自定义范围 [tickLower, tickUpper]
头寸管理 同质化,可合并 非同质化,每个独立
合约 功能
SwapRouter 交换路由,支持单池和多池路径交换
NonfungiblePositionManager NFT 头寸管理器,创建/管理流动性头寸
Quoter 报价器,模拟交换计算输出数量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌─────────────────────────────────────────────────────────┐
│ 用户 │
└─────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────┐
│ SwapRouter │ │ NonfungiblePositionManager │
│ (交换路由) │ │ (NFT 头寸管理) │
└─────────────────────┘ └─────────────────────────────┘
│ │
└──────────────┬───────────────┘

┌─────────────────────────────────────────────────────────┐
│ UniswapV3Pool │
│ (Core - 核心逻辑层) │
└─────────────────────────────────────────────────────────┘

2. SwapRouter 合约解析

SwapRouter 封装了 Pool 的 swap 函数,提供更友好的交换接口。

2.1 单池交换

exactInputSingle

指定输入数量,单池交换(Exact Input)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ExactInputSingleParams {
address tokenIn; // 输入代币
address tokenOut; // 输出代币
uint24 fee; // 池子费率(500/3000/10000)
address recipient; // 接收地址
uint256 deadline; // 截止时间
uint256 amountIn; // 输入数量
uint256 amountOutMinimum; // 最小输出(滑点保护)
uint160 sqrtPriceLimitX96;// 价格限制(0 表示不限制)
}

function exactInputSingle(
ExactInputSingleParams calldata params
) external payable returns (uint256 amountOut);

示例:用 1 ETH 换 USDC

1
2
3
4
5
6
tokenIn = WETH
tokenOut = USDC
fee = 3000 # 0.3% 费率池
amountIn = 1 ETH
amountOutMinimum = 1900 USDC # 至少获得 1900 USDC
sqrtPriceLimitX96 = 0 # 不限制价格

exactOutputSingle

指定输出数量,单池交换(Exact Output)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ExactOutputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 deadline;
uint256 amountOut; // 期望输出数量
uint256 amountInMaximum; // 最大输入(滑点保护)
uint160 sqrtPriceLimitX96;
}

function exactOutputSingle(
ExactOutputSingleParams calldata params
) external payable returns (uint256 amountIn);

示例:想获得正好 2000 USDC

1
2
3
4
5
tokenIn = WETH
tokenOut = USDC
fee = 3000
amountOut = 2000 USDC
amountInMaximum = 1.1 ETH # 最多支付 1.1 ETH

2.2 多池路径交换

V3 的路径编码格式:tokenIn + fee + tokenOut + fee + tokenOut + ...

1
2
路径示例:USDC → ETH → DAI
编码:USDC(20bytes) + 3000(3bytes) + WETH(20bytes) + 500(3bytes) + DAI(20bytes)

exactInput

指定输入,多池路径交换。

1
2
3
4
5
6
7
8
9
10
11
struct ExactInputParams {
bytes path; // 编码后的路径
address recipient;
uint256 deadline;
uint256 amountIn;
uint256 amountOutMinimum;
}

function exactInput(
ExactInputParams calldata params
) external payable returns (uint256 amountOut);

示例:100 USDC → WETH → DAI

1
2
3
4
5
6
7
8
9
10
11
12
13
ExactInputParams({
path: abi.encodePacked(
USDC, // tokenIn
uint24(3000), // USDC-WETH 池费率
WETH, // 中间代币
uint24(500), // WETH-DAI 池费率
DAI // tokenOut
),
recipient: msg.sender,
deadline: block.timestamp + 300,
amountIn: 100e6, // 100 USDC
amountOutMinimum: 99e18 // 至少 99 DAI
})

exactOutput

指定输出,多池路径交换。

1
2
3
4
5
6
7
8
9
10
11
struct ExactOutputParams {
bytes path; // 路径(注意:从 tokenOut 到 tokenIn 反向编码)
address recipient;
uint256 deadline;
uint256 amountOut;
uint256 amountInMaximum;
}

function exactOutput(
ExactOutputParams calldata params
) external payable returns (uint256 amountIn);

注意:exactOutput 的 path 是反向的,从输出代币到输入代币。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 想获得 100 DAI,路径 USDC → WETH → DAI
// path 编码为:DAI + fee + WETH + fee + USDC(反向)
ExactOutputParams({
path: abi.encodePacked(
DAI, // 从 tokenOut 开始
uint24(500),
WETH,
uint24(3000),
USDC // 到 tokenIn 结束
),
recipient: msg.sender,
deadline: block.timestamp + 300,
amountOut: 100e18, // 100 DAI
amountInMaximum: 102e6 // 最多 102 USDC
})

3. NonfungiblePositionManager 合约解析

NonfungiblePositionManager 将流动性头寸封装为 ERC-721 NFT,每个 NFT 代表一个独立的流动性头寸。

3.1 头寸管理

mint - 创建头寸

创建新的流动性头寸,铸造 NFT。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct MintParams {
address token0; // 代币0(地址较小)
address token1; // 代币1(地址较大)
uint24 fee; // 费率
int24 tickLower; // 价格下界 tick
int24 tickUpper; // 价格上界 tick
uint256 amount0Desired; // 期望存入的 token0
uint256 amount1Desired; // 期望存入的 token1
uint256 amount0Min; // 最小 token0(滑点保护)
uint256 amount1Min; // 最小 token1(滑点保护)
address recipient; // NFT 接收地址
uint256 deadline;
}

function mint(
MintParams calldata params
) external payable returns (
uint256 tokenId, // NFT ID
uint128 liquidity, // 流动性数量
uint256 amount0, // 实际存入的 token0
uint256 amount1 // 实际存入的 token1
);

示例:在 ETH/USDC 池创建头寸,价格范围 1500-2500 USDC/ETH

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 首先计算 tick 范围
// tick = log1.0001(price)
// tickLower ≈ 73136 (对应价格 ~1500)
// tickUpper ≈ 78245 (对应价格 ~2500)

MintParams({
token0: USDC, // 地址较小
token1: WETH, // 地址较大
fee: 3000,
tickLower: 73136,
tickUpper: 78245,
amount0Desired: 3000e6, // 3000 USDC
amount1Desired: 1e18, // 1 ETH
amount0Min: 2970e6,
amount1Min: 0.99e18,
recipient: msg.sender,
deadline: block.timestamp + 300
})

increaseLiquidity

向已有头寸添加流动性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct IncreaseLiquidityParams {
uint256 tokenId; // NFT ID
uint256 amount0Desired;
uint256 amount1Desired;
uint256 amount0Min;
uint256 amount1Min;
uint256 deadline;
}

function increaseLiquidity(
IncreaseLiquidityParams calldata params
) external payable returns (
uint128 liquidity,
uint256 amount0,
uint256 amount1
);

decreaseLiquidity

从头寸移除流动性(不提取代币,只减少流动性)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct DecreaseLiquidityParams {
uint256 tokenId;
uint128 liquidity; // 要移除的流动性数量
uint256 amount0Min;
uint256 amount1Min;
uint256 deadline;
}

function decreaseLiquidity(
DecreaseLiquidityParams calldata params
) external payable returns (
uint256 amount0, // 可提取的 token0
uint256 amount1 // 可提取的 token1
);

注意decreaseLiquidity 只是将流动性转换为待提取的代币,实际提取需要调用 collect

3.2 收益提取

collect

提取头寸的代币(包括移除的流动性 + 累积的手续费)。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct CollectParams {
uint256 tokenId;
address recipient;
uint128 amount0Max; // 最大提取 token0(type(uint128).max 提取全部)
uint128 amount1Max; // 最大提取 token1
}

function collect(
CollectParams calldata params
) external payable returns (
uint256 amount0,
uint256 amount1
);

完整退出流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 移除全部流动性
decreaseLiquidity({
tokenId: 12345,
liquidity: position.liquidity, // 全部流动性
amount0Min: 0,
amount1Min: 0,
deadline: block.timestamp + 300
});

// 2. 提取代币 + 手续费
collect({
tokenId: 12345,
recipient: msg.sender,
amount0Max: type(uint128).max, // 提取全部
amount1Max: type(uint128).max
});

// 3. 销毁 NFT(可选)
burn(12345);

4. Quoter 合约解析

Quoter 用于模拟交换,返回预期输出数量,常用于前端显示报价。

4.1 交换报价

quoteExactInputSingle

单池交换报价。

1
2
3
4
5
6
7
function quoteExactInputSingle(
address tokenIn,
address tokenOut,
uint24 fee,
uint256 amountIn,
uint160 sqrtPriceLimitX96
) external returns (uint256 amountOut);

注意:Quoter 函数会 revert 并在 revert data 中返回结果,调用时需要用 try/catchstaticcall 捕获。

1
2
3
4
5
6
7
8
// 前端调用示例(ethers.js)
const quotedAmountOut = await quoter.callStatic.quoteExactInputSingle(
WETH_ADDRESS,
USDC_ADDRESS,
3000,
ethers.utils.parseEther("1"),
0
);

quoteExactInput

多池路径交换报价。

1
2
3
4
function quoteExactInput(
bytes memory path,
uint256 amountIn
) external returns (uint256 amountOut);

quoteExactOutputSingle / quoteExactOutput

反向报价,计算达到指定输出需要的输入数量。

1
2
3
4
5
6
7
8
9
10
11
12
function quoteExactOutputSingle(
address tokenIn,
address tokenOut,
uint24 fee,
uint256 amountOut,
uint160 sqrtPriceLimitX96
) external returns (uint256 amountIn);

function quoteExactOutput(
bytes memory path, // 反向路径
uint256 amountOut
) external returns (uint256 amountIn);

5. 常用操作流程

5.1 添加流动性

1
2
3
4
5
1. 选择代币对和费率
2. 设置价格范围(tickLower, tickUpper)
3. approve 代币给 NonfungiblePositionManager
4. 调用 mint() 创建头寸
5. 获得 NFT(tokenId)

5.2 移除流动性

1
2
3
1. 调用 decreaseLiquidity() 减少流动性
2. 调用 collect() 提取代币
3. (可选)调用 burn() 销毁空头寸 NFT

5.3 交换代币

1
2
3
4
1. 使用 Quoter 获取报价
2. approve 代币给 SwapRouter
3. 调用 exactInputSingle() 或 exactInput()
4. 获得输出代币