Uniswap V2 源码学习第八篇:Router,为什么用户通常不直接调用 Pair?
Router合约拆解
前面几篇我们已经把 Pair 的核心逻辑拆完了:
mint()
burn()
swap()
也把 UniswapV2Library 里的报价公式讲清楚了:
getAmountOut()
getAmountIn()
getAmountsOut()
getAmountsIn()
现在问题来了:
既然 Pair 已经能 mint、burn、swap,为什么用户还要通过 Router?
答案很简单:
Pair 负责规则。
Router 负责体验。
Pair 是协议核心,它只关心资产有没有到账、K 值是否满足、LP Token 份额是否正确。
但用户真正想表达的是:
我想添加 1000 DAI 和最多 1000 USDC 的流动性。
我想用 1000 DAI 换尽可能多的 USDC。
我想用 ETH 买 DAI。
我想多跳交易 DAI → WETH → USDC。
我想设置滑点保护和 deadline。
这些都不是 Pair 直接处理的。
这些是 Router 的工作。
1. Router 在 Uniswap V2 里的位置
我们先重新画一下结构:
User
↓
Router
↓
Factory / Library
↓
Pair
↓
ERC20 Token
Router 自己不保存流动性,不维护 reserve,也不执行 AMM 的核心规则。
它主要做这些事:
1. 帮用户计算添加流动性的最佳 token 数量
2. 帮用户 transferFrom token 到 Pair
3. 帮用户调用 Pair.mint / burn / swap
4. 支持多跳交易 path
5. 支持 ETH 和 WETH 的包装/拆包
6. 支持滑点保护 amountMin
7. 支持 deadline 防止交易长期挂起
8. 通过特定函数支持 fee-on-transfer token
所以 Router 是一个“用户友好层”。
Pair 很硬核:
你把 token 转进来,我按规则 mint。
你把 LP 转进来,我按规则 burn。
你指定 amountOut,我最后检查 K。
Router 则更贴近用户:
你告诉我想做什么,我帮你把步骤排好。
2. Router01 和 Router02 有什么区别?
Uniswap V2 periphery 里有两个 Router:
UniswapV2Router01.sol
UniswapV2Router02.sol
实际常用的是:
UniswapV2Router02
Router02 在 Router01 基础上增加了对 fee-on-transfer token 的支持。
所谓 fee-on-transfer token,就是转账时会扣手续费的 token。
比如你转:
100 token
接收方实际可能只收到:
98 token
这会让普通 swap 的预计算 amounts 不准确。
所以 Router02 里有一些特殊函数,比如:
swapExactTokensForTokensSupportingFeeOnTransferTokens()
swapExactETHForTokensSupportingFeeOnTransferTokens()
swapExactTokensForETHSupportingFeeOnTransferTokens()
注意,这些主要是 exact-input 方向的特殊支持函数,不是所有 Router 方法都自动兼容 fee-on-transfer token。
这一篇我们先讲标准 Router 逻辑。
fee-on-transfer 的特殊处理后面单独讲。
3. Router 的两个不可变核心地址
Router 构造函数里通常会设置:
address public immutable factory;
address public immutable WETH;
它们分别表示:
factory:UniswapV2Factory 地址
WETH:Wrapped ETH 地址
这里的 immutable 是 Solidity 0.6+ 版本 Router02 的写法,用来在部署后固定地址,同时比普通 storage 读取更省 gas。
为什么 Router 需要 Factory?
因为 Router 要找到 Pair。
比如用户想交易:
DAI → USDC
Router 需要知道 DAI/USDC Pair 在哪里。
它可以通过:
UniswapV2Library.pairFor(factory, tokenA, tokenB)
计算 Pair 地址。
为什么 Router 需要 WETH?
因为 Pair 只支持 ERC20。
原生 ETH 不是 ERC20,不能直接进入 Pair。
所以 ETH 相关操作都要通过 WETH 包一层:
ETH → WETH → Pair
Pair → WETH → ETH
这就是 addLiquidityETH()、swapExactETHForTokens()、swapExactTokensForETH() 这些函数存在的原因。
4. modifier ensure:deadline 是怎么做的?
Router 里有个很简单的 modifier:
modifier ensure(uint deadline) {
require(deadline >= block.timestamp, 'UniswapV2Router: EXPIRED');
_;
}
很多用户入口函数都会带:
uint deadline
比如:
swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
)
然后函数上会有:
ensure(deadline)
这解决什么问题?
它防止用户签出的交易长期停留在 mempool 里,过了很久才被矿工或验证者打包。
市场价格可能已经变了。
如果没有 deadline,用户可能在很久之后以完全不想要的价格成交。
所以 Router 要求:
当前区块时间必须小于等于用户设置的 deadline。
Pair 不处理 deadline,因为 Pair 不关心用户意图。
Router 处理 deadline,因为 Router 负责用户体验和交易保护。
5. addLiquidity:添加 ERC20/ERC20 流动性
先看常见入口:
function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external ensure(deadline) returns (
uint amountA,
uint amountB,
uint liquidity
)
用户传入的参数可以翻译成:
tokenA / tokenB:
我要给哪个交易对添加流动性。
amountADesired / amountBDesired:
我希望最多投入多少 tokenA/tokenB。
amountAMin / amountBMin:
我能接受的最小投入数量,用于滑点保护。
to:
LP Token 接收地址。
deadline:
交易过期时间。
注意这里的 amountADesired 和 amountBDesired 不是最终一定全部转进去。
Router 会根据池子当前比例,选择合适数量。
6. _addLiquidity:Router 如何计算最佳添加比例?
addLiquidity() 里会先调用内部函数:
(amountA, amountB) = _addLiquidity(
tokenA,
tokenB,
amountADesired,
amountBDesired,
amountAMin,
amountBMin
);
我们看 _addLiquidity() 的核心逻辑:
function _addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin
) internal virtual returns (uint amountA, uint amountB) {
if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) {
IUniswapV2Factory(factory).createPair(tokenA, tokenB);
}
(uint reserveA, uint reserveB) =
UniswapV2Library.getReserves(factory, tokenA, tokenB);
if (reserveA == 0 && reserveB == 0) {
(amountA, amountB) = (amountADesired, amountBDesired);
} else {
uint amountBOptimal =
UniswapV2Library.quote(amountADesired, reserveA, reserveB);
if (amountBOptimal <= amountBDesired) {
require(
amountBOptimal >= amountBMin,
'UniswapV2Router: INSUFFICIENT_B_AMOUNT'
);
(amountA, amountB) = (amountADesired, amountBOptimal);
} else {
uint amountAOptimal =
UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
assert(amountAOptimal <= amountADesired);
require(
amountAOptimal >= amountAMin,
'UniswapV2Router: INSUFFICIENT_A_AMOUNT'
);
(amountA, amountB) = (amountAOptimal, amountBDesired);
}
}
}
这个函数做了三件大事:
1. 如果 Pair 不存在,就创建 Pair
2. 如果是新池子,直接使用用户期望投入的数量
3. 如果池子已有流动性,就按当前 reserve 比例计算最佳投入数量
7. Pair 不存在时,Router 会创建 Pair
第一段:
if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) {
IUniswapV2Factory(factory).createPair(tokenA, tokenB);
}
这意味着用户不需要手动先去 Factory 创建 Pair。
如果添加流动性时发现 Pair 不存在,Router 会帮你创建。
这一步最终会走到我们前面讲过的:
Factory.createPair(tokenA, tokenB)
也就是:
排序 token0/token1
CREATE2 部署 Pair
initialize token0/token1
写入 getPair
emit PairCreated
所以对于用户来说,创建交易对和添加第一笔流动性可以一笔交易完成。
8. 新池子:第一次流动性决定初始价格
接下来读取 reserve:
(uint reserveA, uint reserveB) =
UniswapV2Library.getReserves(factory, tokenA, tokenB);
如果是新池子:
if (reserveA == 0 && reserveB == 0) {
(amountA, amountB) = (amountADesired, amountBDesired);
}
这说明第一次添加流动性时,Router 不会替用户调整比例。
因为池子没有历史价格。
用户传入的比例就是初始价格。
例如用户首次添加:
10 WETH + 30,000 USDC
那初始价格就是:
1 WETH = 3000 USDC
如果用户首次添加:
10 WETH + 20,000 USDC
那初始价格就是:
1 WETH = 2000 USDC
Router 无法知道哪个价格“正确”。
它只执行用户给出的初始比例。
这也是为什么第一次建池要特别小心。价格偏了,套利者会来纠正价格,并赚走价差。
9. 老池子:按 reserve 比例添加
如果池子已有流动性,Router 会计算最佳比例。
核心是:
uint amountBOptimal =
UniswapV2Library.quote(amountADesired, reserveA, reserveB);
quote() 我们上一篇讲过:
amountB = amountA * reserveB / reserveA;
意思是:
如果我投入 amountADesired 个 tokenA,
按当前池子比例,我应该投入多少 tokenB?
比如当前池子:
reserveA = 10,000 DAI
reserveB = 20,000 USDC
比例是:
1 DAI : 2 USDC
用户希望投入:
amountADesired = 100 DAI
amountBDesired = 300 USDC
Router 算:
amountBOptimal = 100 * 20000 / 10000 = 200 USDC
200 USDC 小于用户愿意提供的 300 USDC,所以最终只投入:
100 DAI + 200 USDC
剩下的 100 USDC 不转进 Pair。
这就是 Router 和直接调用 Pair 的区别。
直接调 Pair,转多了就捐给池子了。
通过 Router,它会帮你只转合适比例。
10. amountBOptimal 分支
源码:
if (amountBOptimal <= amountBDesired) {
require(
amountBOptimal >= amountBMin,
'UniswapV2Router: INSUFFICIENT_B_AMOUNT'
);
(amountA, amountB) = (amountADesired, amountBOptimal);
}
这表示:
以 amountADesired 为基准计算出的 tokenB 数量,没有超过用户最多愿意提供的 amountBDesired。
所以可以使用:
amountA = amountADesired
amountB = amountBOptimal
但还要检查:
require(amountBOptimal >= amountBMin)
为什么?
因为用户设置了最小可接受 tokenB 投入。
这不是“越少越好”吗?为什么还要 min?
添加流动性时,投入太少也可能不是用户想要的结果。
比如用户本来以为会添加接近:
100 DAI + 200 USDC
结果价格变化后,只能添加:
100 DAI + 100 USDC
这意味着池子比例已经变了,用户得到的 LP 份额也不一样。
amountBMin 是用户对比例变化的保护。
11. amountAOptimal 分支
如果:
amountBOptimal > amountBDesired
说明按 amountADesired 来算,需要的 tokenB 超过用户最多愿意提供的数量。
那就反过来,以 amountBDesired 为基准,算应该投入多少 tokenA:
uint amountAOptimal =
UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
然后检查:
assert(amountAOptimal <= amountADesired);
require(
amountAOptimal >= amountAMin,
'UniswapV2Router: INSUFFICIENT_A_AMOUNT'
);
最终:
(amountA, amountB) = (amountAOptimal, amountBDesired);
例子:
reserveA = 10,000 DAI
reserveB = 20,000 USDC
比例:
1 DAI : 2 USDC
用户希望投入:
amountADesired = 200 DAI
amountBDesired = 300 USDC
如果用 200 DAI,需要:
400 USDC
但用户只愿意给 300 USDC。
于是 Router 反过来算:
amountAOptimal = 300 * 10000 / 20000 = 150 DAI
最终投入:
150 DAI + 300 USDC
12. addLiquidity 真正做了什么?
_addLiquidity() 算完 amountA/amountB 后,addLiquidity() 会继续:
address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
TransferHelper.safeTransferFrom(
tokenA,
msg.sender,
pair,
amountA
);
TransferHelper.safeTransferFrom(
tokenB,
msg.sender,
pair,
amountB
);
liquidity = IUniswapV2Pair(pair).mint(to);
也就是:
1. 计算 Pair 地址
2. 把 tokenA 从用户转到 Pair
3. 把 tokenB 从用户转到 Pair
4. 调用 Pair.mint(to)
5. Pair 根据 balance - reserve 计算新增流动性
6. Pair mint LP Token 给 to
这正好对应我们前面讲的 Pair.mint()。
Router 并不自己 mint LP Token。
它只是把 token 转进去,再叫 Pair 执行 mint。
13. addLiquidityETH:ETH 是如何进入 Pair 的?
ERC20/ERC20 是 addLiquidity()。
如果用户想添加:
ETH + token
就用:
addLiquidityETH()
因为 Pair 只认识 ERC20,所以 ETH 必须先变成 WETH。
核心流程大概是:
(amountToken, amountETH) = _addLiquidity(
token,
WETH,
amountTokenDesired,
msg.value,
amountTokenMin,
amountETHMin
);
address pair = UniswapV2Library.pairFor(factory, token, WETH);
TransferHelper.safeTransferFrom(
token,
msg.sender,
pair,
amountToken
);
IWETH(WETH).deposit{value: amountETH}();
assert(IWETH(WETH).transfer(pair, amountETH));
liquidity = IUniswapV2Pair(pair).mint(to);
if (msg.value > amountETH) {
TransferHelper.safeTransferETH(msg.sender, msg.value - amountETH);
}
翻译一下:
1. 用户随交易发送 ETH,也就是 msg.value
2. Router 把 ETH 当成 WETH 一侧来计算最佳比例
3. Router 从用户转 token 到 Pair
4. Router 把需要使用的 ETH deposit 成 WETH
5. Router 把 WETH 转到 Pair
6. 调用 Pair.mint(to)
7. 如果用户 ETH 给多了,把多余 ETH 退回
这里有一个很贴心的点:
if (msg.value > amountETH) {
TransferHelper.safeTransferETH(msg.sender, msg.value - amountETH);
}
如果用户发送的 ETH 比最终需要的多,Router 会退还。
这就是 Router 负责体验的体现。
Pair 完全不会帮你退。
14. removeLiquidity:移除 ERC20/ERC20 流动性
移除流动性的入口:
function removeLiquidity(
address tokenA,
address tokenB,
uint liquidity,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) public ensure(deadline) returns (uint amountA, uint amountB)
参数含义:
tokenA / tokenB:
要移除哪个交易对的流动性。
liquidity:
要 burn 多少 LP Token。
amountAMin / amountBMin:
最少要拿回多少 tokenA/tokenB,用于滑点保护。
to:
底层资产接收地址。
deadline:
交易过期时间。
核心代码:
address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
IUniswapV2Pair(pair).transferFrom(
msg.sender,
pair,
liquidity
);
(uint amount0, uint amount1) = IUniswapV2Pair(pair).burn(to);
(address token0,) = UniswapV2Library.sortTokens(tokenA, tokenB);
(amountA, amountB) = tokenA == token0
? (amount0, amount1)
: (amount1, amount0);
require(amountA >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
require(amountB >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
流程是:
1. 计算 Pair 地址
2. 把用户 LP Token 转到 Pair
3. 调用 Pair.burn(to)
4. Pair 按 LP 份额返还 token0/token1
5. Router 把 amount0/amount1 转换成 tokenA/tokenB 顺序
6. 检查最小返还数量
重点还是这句:
IUniswapV2Pair(pair).transferFrom(msg.sender, pair, liquidity);
Pair.burn 不会主动拉 LP Token。
所以 Router 先把 LP Token 转给 Pair。
这和我们前面讲的完全对应。
15. 为什么 removeLiquidity 要重新排序 amount?
Pair 返回的是:
(uint amount0, uint amount1)
其中 amount0 对应 token0,amount1 对应 token1。
但用户传进来的是:
tokenA
tokenB
这两个顺序不一定等于 token0/token1。
所以 Router 要转换:
(address token0,) = UniswapV2Library.sortTokens(tokenA, tokenB);
(amountA, amountB) = tokenA == token0
? (amount0, amount1)
: (amount1, amount0);
这样用户拿到的返回值顺序和自己传参一致。
这个细节很重要。
Router 作为用户层合约,必须对用户友好。
Pair 只按自己的 token0/token1 世界说话。
Router 负责翻译。
16. removeLiquidityETH:WETH 如何变回 ETH?
如果用户移除的是:
token + ETH
Router 实际上处理的是:
token + WETH
核心流程:
1. removeLiquidity(token, WETH, ...)
2. Pair.burn 把 token 和 WETH 发给 Router
3. Router 把 token 转给用户
4. Router 调用 WETH.withdraw(amountETH)
5. Router 把 ETH 转给用户
也就是说,ETH 版本和 ERC20 版本的本质区别是:
Router 在末尾多做一步 WETH → ETH
Pair 从头到尾都不知道 ETH 的存在。
Pair 只知道 ERC20。
这是 WETH 的意义:
把原生 ETH 包装成 ERC20,让它能进入统一的 AMM 逻辑。
17. swapExactTokensForTokens:固定输入换尽可能多输出
接下来进入 swap。
最常用的是:
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external ensure(deadline) returns (uint[] memory amounts)
用户语义是:
我固定输入 amountIn 个 path[0]。
请沿着 path 帮我换成 path[last]。
最终输出不能低于 amountOutMin。
核心代码大概是:
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
require(
amounts[amounts.length - 1] >= amountOutMin,
'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT'
);
TransferHelper.safeTransferFrom(
path[0],
msg.sender,
UniswapV2Library.pairFor(factory, path[0], path[1]),
amounts[0]
);
_swap(amounts, path, to);
流程:
1. 用 getAmountsOut 计算每一跳输出
2. 检查最终输出 >= amountOutMin
3. 把输入 token 转进第一个 Pair
4. 调用 _swap 逐跳执行
这里有个熟悉的点:
Router 只把输入 token 转进第一个 Pair。
后面的中间 token,会由前一个 Pair 直接转给下一个 Pair。
18. _swap:多跳交易的核心
Router 的 _swap() 很关键:
function _swap(
uint[] memory amounts,
address[] memory path,
address _to
) internal virtual {
for (uint i; i < path.length - 1; i++) {
(address input, address output) = (path[i], path[i + 1]);
(address token0,) = UniswapV2Library.sortTokens(input, output);
uint amountOut = amounts[i + 1];
(uint amount0Out, uint amount1Out) = input == token0
? (uint(0), amountOut)
: (amountOut, uint(0));
address to = i < path.length - 2
? UniswapV2Library.pairFor(factory, output, path[i + 2])
: _to;
IUniswapV2Pair(
UniswapV2Library.pairFor(factory, input, output)
).swap(amount0Out, amount1Out, to, new bytes(0));
}
}
这段就是多跳交易的调度器。
它循环 path 中每一段:
path[0] → path[1]
path[1] → path[2]
path[2] → path[3]
...
每一段都找到对应 Pair,调用一次 swap()。
19. _swap 如何决定 amount0Out / amount1Out?
Pair.swap 要求传:
amount0Out
amount1Out
但 Router 当前知道的是:
input token
output token
amountOut
所以它要先判断 output 是 token0 还是 token1。
源码:
(address token0,) = UniswapV2Library.sortTokens(input, output);
(uint amount0Out, uint amount1Out) = input == token0
? (uint(0), amountOut)
: (amountOut, uint(0));
这里有点绕,我们拆一下。
如果:
input == token0
说明用户输入的是 token0,输出应该是 token1。
所以:
amount0Out = 0
amount1Out = amountOut
如果:
input != token0
说明 input 是 token1,output 是 token0。
所以:
amount0Out = amountOut
amount1Out = 0
这就是 Router 把“交易方向”翻译成 Pair 世界里的 amount0Out/amount1Out。
Pair 不知道 path,也不关心 input/output 的名字。
Pair 只认 token0/token1。
20. _swap 如何决定 to?
这行非常精彩:
address to = i < path.length - 2
? UniswapV2Library.pairFor(factory, output, path[i + 2])
: _to;
意思是:
如果当前不是最后一跳,
当前 Pair 的输出 token 直接发给下一个 Pair。
如果当前是最后一跳,
输出 token 发给用户指定的 _to。
假设路径:
DAI → WETH → USDC
第一跳:
input = DAI
output = WETH
这不是最后一跳,所以:
to = WETH/USDC Pair
也就是说,DAI/WETH Pair 输出的 WETH 直接进入 WETH/USDC Pair。
第二跳:
input = WETH
output = USDC
这是最后一跳,所以:
to = 用户地址
这就是多跳交易为什么不需要 Router 中间持有资产。
资产路径是:
用户 → Pair1 → Pair2 → 用户
而不是:
用户 → Router → Pair1 → Router → Pair2 → Router → 用户
这很省 gas,也减少 Router 托管资产的风险。
21. swapTokensForExactTokens:固定输出,最多输入
另一个方向是:
function swapTokensForExactTokens(
uint amountOut,
uint amountInMax,
address[] calldata path,
address to,
uint deadline
) external ensure(deadline) returns (uint[] memory amounts)
用户语义:
我想拿到精确 amountOut 个目标 token。
最多愿意花 amountInMax 个输入 token。
核心逻辑:
amounts = UniswapV2Library.getAmountsIn(factory, amountOut, path);
require(
amounts[0] <= amountInMax,
'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT'
);
TransferHelper.safeTransferFrom(
path[0],
msg.sender,
UniswapV2Library.pairFor(factory, path[0], path[1]),
amounts[0]
);
_swap(amounts, path, to);
和 exact input 的区别是:
exact input:
已知 amounts[0]
用 getAmountsOut 正向算 amounts[last]
exact output:
已知 amounts[last]
用 getAmountsIn 反向算 amounts[0]
执行 _swap() 的方式一样。
22. ETH 版本 swap:本质还是 WETH
Router 还提供 ETH 版本,例如:
swapExactETHForTokens()
swapTokensForExactETH()
swapExactTokensForETH()
swapETHForExactTokens()
它们本质上都是围绕 WETH 做包装。
比如:
swapExactETHForTokens(
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
)
要求:
require(path[0] == WETH, 'UniswapV2Router: INVALID_PATH');
因为用户输入的是 ETH,Router 会先把 ETH deposit 成 WETH。
流程是:
1. 用户发送 ETH
2. Router 把 ETH 包成 WETH
3. Router 把 WETH 转给第一个 Pair
4. 调用 _swap
5. 最终 token 发给用户
反过来,如果用户最终想收到 ETH,比如:
swapExactTokensForETH()
要求:
require(path[path.length - 1] == WETH, 'UniswapV2Router: INVALID_PATH');
流程是:
1. 用户输入 token
2. 最后一跳输出 WETH 给 Router
3. Router withdraw WETH 成 ETH
4. Router 把 ETH 发给用户
这里最后一跳的 _swap 接收地址通常是 Router 自己,因为 Router 需要拿到 WETH 后 unwrap。
23. TransferHelper:Router 如何安全转账?
Router 使用:
TransferHelper.safeTransferFrom(...)
TransferHelper.safeTransfer(...)
TransferHelper.safeTransferETH(...)
而不是直接:
IERC20(token).transferFrom(...)
原因和 Pair 的 _safeTransfer() 类似:
现实世界里有些 ERC20 不规范。
有的返回 bool。
有的不返回。
有的失败时 revert。
TransferHelper 使用低级调用兼容这些情况。
典型逻辑是:
(bool success, bytes memory data) = token.call(
abi.encodeWithSelector(selector, ...)
);
require(
success && (data.length == 0 || abi.decode(data, (bool))),
'TransferHelper: TRANSFER_FROM_FAILED'
);
这在 DeFi 里非常常见。
Router 每次帮用户转 token 时都要尽可能兼容各种 ERC20。
24. Router 为什么不保存资产?
标准 Router 的设计里,资产通常只是“经过”Router,甚至很多时候根本不经过 Router。
例如普通 ERC20 swap:
用户 tokenIn → 第一个 Pair
Pair1 → Pair2 → 用户
Router 只是发起调用和计算路径。
它不会长期保存用户资产。
ETH/WETH 场景里,Router 会短暂持有 ETH 或 WETH,用于包装或拆包,但交易结束后也会转出或退回。
这是一种很好的安全边界:
核心资产放在 Pair。
Router 尽量不沉淀资产。
这样即使未来出现新的 Router,也可以复用旧 Pair 的流动性。
而如果某个 Router 不好用,用户可以换 Router。
25. Pair 和 Router 的风格差异
到这里,我们可以清晰对比一下。
Pair 的风格
低层、硬核、规则导向。
不处理用户意图。
不处理滑点。
不处理 deadline。
不处理 ETH。
不处理路径。
不主动优化比例。
只看余额、reserve、K、LP 份额。
Router 的风格
高层、友好、用户导向。
处理 token 顺序。
处理滑点。
处理 deadline。
处理多跳路径。
处理 ETH/WETH。
处理最佳添加比例。
处理 LP Token transferFrom。
一句话:
Pair 是协议。
Router 是接口。
这也是 Uniswap V2 源码读起来很舒服的原因:边界清楚。
26. 一个完整 addLiquidity 路径
我们用一次完整添加流动性收尾。
用户调用:
addLiquidity(
DAI,
USDC,
1000e18,
1000e6,
990e18,
990e6,
alice,
deadline
)
Router 做:
1. 检查 deadline
2. 如果 DAI/USDC Pair 不存在,创建 Pair
3. 读取 DAI/USDC reserve
4. 用 quote 计算最佳 amountA/amountB
5. 检查 amountAMin/amountBMin
6. 把 DAI 从 Alice 转到 Pair
7. 把 USDC 从 Alice 转到 Pair
8. 调用 Pair.mint(alice)
9. Pair 根据 balance - reserve mint LP Token
10. Alice 收到 LP Token
Pair 只负责第 9 步。
Router 负责把前后流程都安排好。
27. 一个完整 swap 路径
用户调用:
swapExactTokensForTokens(
1000e18,
900e6,
[DAI, USDC],
alice,
deadline
)
Router 做:
1. 检查 deadline
2. 用 getAmountsOut 计算输出
3. 检查最终输出 >= amountOutMin
4. 把 1000 DAI 从 Alice 转到 DAI/USDC Pair
5. 调用 Pair.swap(0, amountOut, alice, "")
6. Pair 转出 USDC 给 Alice
7. Pair 反推 amountIn
8. Pair 做 K 检查
9. Pair 更新 reserve
Router 负责报价、滑点、转账入口。
Pair 负责最终执行和验证。
28. 一个完整多跳路径
用户调用:
swapExactTokensForTokens(
amountIn,
amountOutMin,
[DAI, WETH, USDC],
alice,
deadline
)
执行路径:
1. Router 用 getAmountsOut 算:
amounts[0] = DAI 输入
amounts[1] = WETH 中间输出
amounts[2] = USDC 最终输出
2. Router 把 DAI 转入 DAI/WETH Pair
3. _swap 第一跳:
DAI/WETH Pair 输出 WETH
to = WETH/USDC Pair
4. _swap 第二跳:
WETH/USDC Pair 输出 USDC
to = Alice
5. 每个 Pair 各自做 K 检查和 reserve 更新
整个过程中,Router 不需要中间持有 WETH。
中间资产直接从 Pair 到 Pair。
这就是 _swap() 那行 to 计算的美感。
29. Router 的设计哲学
Router 的设计可以总结成几条。
第一,Router 是可替换的
Router 不保存核心状态。
核心状态都在 Pair:
reserve
token balance
LP totalSupply
price cumulative
kLast
所以可以有 Router01、Router02,也可以有第三方 Router、聚合器 Router。
只要它们按 Pair 的规则交互,就能使用同一批流动性。
第二,Router 负责用户保护
这些都在 Router:
amountOutMin
amountInMax
amountAMin
amountBMin
deadline
Pair 不做这些,因为 Pair 不知道用户心理价位。
第三,Router 是翻译层
Router 把用户语言翻译成 Pair 语言:
用户说:我要用 DAI 换 USDC。
Router 翻译成:
找 DAI/USDC Pair
token0/token1 排序
amount0Out/amount1Out 设置
token 先转入 Pair
调用 swap
第四,Router 不应该沉淀资产
标准路径中,资产要么进 Pair,要么回用户。
Router 只是短暂停靠站,不是金库。
第五,Router 组合 Library 和 Pair
Library 负责计算。
Router 负责流程。
Pair 负责规则。
三者刚好拼在一起。
小结
这一篇我们拆了 Router 的核心职责。
你现在应该能回答:
1. 为什么用户通常不直接调用 Pair?
因为 Pair 只处理底层规则,Router 提供滑点、deadline、路径、ETH/WETH、最佳比例等用户体验。
2. addLiquidity 做了什么?
计算最佳 token 数量,把 token 转进 Pair,然后调用 Pair.mint。
3. _addLiquidity 为什么要用 quote?
为了按当前 reserve 比例添加流动性,避免用户转多一边造成浪费。
4. removeLiquidity 做了什么?
把用户 LP Token 转到 Pair,调用 Pair.burn,再检查最小返还数量。
5. swapExactTokensForTokens 怎么执行?
先用 getAmountsOut 算报价,检查 amountOutMin,把输入 token 转到第一个 Pair,再 _swap。
6. _swap 的核心是什么?
逐跳调用 Pair.swap,并把中间输出直接转给下一个 Pair。
7. ETH 为什么要通过 WETH?
因为 Pair 只支持 ERC20,原生 ETH 需要包装成 WETH。
8. Router 和 Pair 的区别是什么?
Pair 负责协议规则,Router 负责用户入口和流程编排。
到这里,我们已经把 Uniswap V2 的主要交易路径串起来了:
Library:算
Router:调度
Pair:验证和更新状态