Uniswap V2 Router 路由合约详解
目录
- 一、Router 在架构中的位置
- 二、Router01 与 Router02 的区别
- 三、两类交换函数
- 四、ETH 变体与 WETH
- 五、添加流动性
- 六、移除流动性
- 七、Permit 变体:免单独授权
- 八、转账收费代币(Fee-on-Transfer)支持
- 九、关键安全参数:deadline 与滑点
- 十、总结
- 十一、动手练习项目:完整 Router 与前端交互
一、Router 在架构中的位置
Router 属于 periphery(外围) 合约。Pair(核心)只提供裸的 swap、mint、burn,没有任何用户保护。Router 把这些裸操作包装成安全、好用的入口,提供:
- 安全地铸造/销毁 LP 代币
- 代币交换(含多跳)
- ETH 自动包装成 WETH
- 滑点保护
- 转账收费代币支持
- 通过 Permit 实现免单独授权交易
Router 不持有资金(除了交易中转的瞬间),所有保护逻辑都在这里,核心 Pair 因此保持极简。
二、Router01 与 Router02 的区别
Router02 = Router01 + 针对转账收费代币(fee-on-transfer)的额外函数。
Router02 继承 Router01 全部功能,额外加了 ...SupportingFeeOnTransferTokens 系列函数。生产环境用 Router02。
三、两类交换函数
核心区别:你想固定输入还是固定输出。
3.1 swapExactTokensForTokens
固定输入,限定最小输出。用户指定精确的输入数量和能接受的最小输出量。
function swapExactTokensForTokens(
uint amountIn, // 精确输入,比如 25 token0
uint amountOutMin, // 最小可接受输出,比如 49.5 token1(1% 容忍)
address[] calldata path,// [tokenIn, tokenOut] 或多跳 [A, B, C]
address to,
uint deadline
) external returns (uint[] memory amounts);
例:拿 25 个 token0 换,至少要拿到 49.5 个 token1(容忍 1% 滑点),否则 revert。
3.2 swapTokensForExactTokens
固定输出,限定最大输入。用户指定精确想要的输出,和能接受的最大输入。
function swapTokensForExactTokens(
uint amountOut, // 精确输出,比如 50 token1
uint amountInMax, // 最大可接受输入,比如 25.5 token0
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
适合”我必须正好拿到 50 个 token1 去还债”这种需求复杂的合约场景。
3.3 交换的统一工作流
两类函数都遵循同样四步:
- 用 Library 的
getAmountsOut/getAmountsIn算出各跳金额; - 校验安全阈值(输出 < amountOutMin 或 输入 > amountInMax 就 revert);
- 把用户的输入代币
transferFrom到第一个 Pair; - 调用内部
_swap()完成(可能多跳的)交换。
3.4 _swap 内部函数
function _swap(uint[] memory amounts, address[] memory path, address _to) internal {
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];
// 按 token0/token1 顺序确定哪个方向有输出
(uint amount0Out, uint amount1Out) = input == token0
? (uint(0), amountOut) : (amountOut, uint(0));
// 中间跳的接收方是下一个 Pair,最后一跳才是 _to
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));
}
}
精妙之处:中间跳直接把输出打到下一个 Pair,省去 Router 反复中转的转账。每个 Pair 的输入代币正是上一跳 Pair 转过来的(呼应 swap 篇”先转币后调用”)。注意它只传 amountOut,符合 Pair 的 swap 规范(amountIn 由转入推断)。
四、ETH 变体与 WETH
Pair 只认 ERC20,不认原生 ETH。所以涉及 ETH 的交换需要先把 ETH 包装成 WETH(一种 1:1 锚定 ETH 的 ERC20)。Router 提供自动处理的变体:
swapExactETHForTokens/swapTokensForExactETH/swapExactTokensForETH/swapETHForExactTokens- 这些函数自动
WETH.deposit{value: msg.value}()包装,或WETH.withdraw()解包,用户无感。
五、添加流动性
5.1 _addLiquidity 计算最优比例
直接按 desired 数量注资会偏离池子比例(上一篇讲过会损失份额)。_addLiquidity 帮你算出匹配当前比例的最优组合:
参数:amountADesired/BDesired(期望注入)、amountAMin/BMin(最低可接受,滑点保护)。
算法:
- 先假设全额用 amountADesired,用
quote算出需要配多少 B(amountBOptimal); - 若
amountBOptimal ≤ amountBDesired:检查它 ≥ amountBMin,用 (amountADesired, amountBOptimal); - 否则反过来:固定 amountBDesired,算 amountAOptimal,检查 ≥ amountAMin。
数值例子:池子现有 100 token0、300 token1(比例 1:3)。用户 approve 了 21 token0、63 token1,设最低 20 和 60。
- 假设用满 21 token0,需配 21 × 3 = 63 token1 ≤ 63 ✓,用 (21, 63);
- 但如果池子比例在上链前变了,导致最优变成 19.9 token0(< 20 最低),交易 revert——保护用户不会按糟糕比例注资。
5.2 addLiquidity 与 addLiquidityETH
addLiquidity 调 _addLiquidity 算好最优量 → transferFrom 两种代币到 Pair → 调 pair.mint(to)。
addLiquidityETH 先把 msg.value 的 ETH 包装成 WETH,再走同样流程。
六、移除流动性
function removeLiquidity(
address tokenA, address tokenB,
uint liquidity,
uint amountAMin, uint amountBMin, // 滑点保护:取回不得低于此
address to, uint deadline
) external returns (uint amountA, uint amountB);
流程:把用户的 LP 代币 transferFrom 到 Pair → 调 pair.burn(to) → 校验取回的两种代币都 ≥ 各自的 Min。
removeLiquidityETH:以 Router 为接收方调 removeLiquidity,然后把 ERC20 转给用户、把 WETH 解包成 ETH 转给用户。
七、Permit 变体:免单独授权
正常流程要两笔交易:先 approve LP 代币给 Router,再 removeLiquidity。
removeLiquidityWithPermit / removeLiquidityETHWithPermit 接收一个链下签名(EIP-2612 Permit),在同一笔交易里完成授权 + 销毁,省掉单独的 approve 交易,提升体验、省 Gas。
八、转账收费代币(Fee-on-Transfer)支持
有些代币转账时会扣手续费(如某些通缩代币),转 100 实际只到账 99。这会破坏 Router01 的计算(它假设转多少到多少)。
Router02 的解法:不依赖 amountIn / liquidity 这些名义值,而是转账后实测实际到账量。
- 加流动性时无需特殊处理——“用户只按实际转入 Pair 的数量记账”;
- 交换时用专门函数
swapExactTokensForTokensSupportingFeeOnTransferTokens(及 ETH 变体),它在每跳后用balanceOf实测输出再继续。
九、关键安全参数:deadline 与滑点
这是集成 Uniswap 时最容易出致命错误的两个地方。
deadline(交易过期时间)
绝对不要把 deadline 设成
block.timestamp或block.timestamp + 常量。
如果在合约里这样写,deadline 永远等于交易被打包的那一刻,形同虚设。正确做法:deadline 应由用户在链下生成并作为参数传入,合约只负责校验 block.timestamp <= deadline。
攻击场景:恶意的区块构建者(builder)可以把你的交换交易扣留,等到价格对你不利时再打包执行,砸你的单或操纵价格。合理的 deadline 应该是几分钟量级,而不是几小时几天。
滑点保护
绝对不要把 amountMin 设成 0,或 amountMax 设成 type(uint).max。
这等于放弃所有滑点和三明治攻击保护——攻击者可以把你的成交价砸到任意糟糕的程度,你还是会接受。amountOutMin/amountInMax 必须设成合理的容忍区间(比如预期值的 99%)。
十、总结
- Router 是用户友好的外围入口,把核心 Pair 的裸操作包装出滑点保护、多跳、ETH、Permit;
- 两类交换:固定输入(ExactTokensForTokens)vs 固定输出(TokensForExactTokens);
_swap让中间跳直接打款到下一个 Pair,零冗余中转;_addLiquidity自动算最优比例,避免份额损失;- deadline 不能写死、滑点不能设 0——这是集成时的两条铁律。
十一、动手练习项目:完整 Router 与前端交互
项目目标
把前几篇练习的所有合约(Factory、Pair、Library)整合,实现一个支持多跳、ETH、滑点保护的 MiniRouter,部署到 Sepolia,并用一个最简前端(或纯 cast 命令)走通”加流动性 → 交换 → 移除流动性”完整闭环。
合约要求
1. WETH9.sol:直接用经典 WETH 实现(deposit/withdraw/transfer),或部署 Sepolia 上已有的 WETH。
2. MiniRouter.sol
immutable factory、immutable WETH;构造函数注入;modifier ensure(uint deadline):require(deadline >= block.timestamp, Expired())——并在注释里写明”deadline 必须由用户传入,不能在前端写死成 now”;- 实现以下函数(复用 PathQuoter 库):
swapExactTokensForTokens(amountIn, amountOutMin, path, to, deadline):算 amounts → 校验 amounts[last] ≥ amountOutMin(错误InsufficientOutputAmount())→ transferFrom 到首个 Pair →_swap;swapTokensForExactTokens(amountOut, amountInMax, path, to, deadline):getAmountsIn → 校验 amounts[0] ≤ amountInMax(错误ExcessiveInputAmount())→ 执行;swapExactETHForTokens(amountOutMin, path, to, deadline) payable:要求 path[0] == WETH,WETH.deposit{value: msg.value}(),转给首个 Pair,_swap;addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin, to, deadline):实现_addLiquidity最优比例逻辑 → transferFrom →pair.mint;removeLiquidity(...):转 LP →pair.burn→ 校验两个 Min;- 内部
_swap(amounts, path, _to):照本文 3.4 节实现中间跳直达下一个 Pair。
测试要求(Foundry)
test_SwapExactIn_RespectsMinOut:正常成交;把 amountOutMin 设成比实际高 1 wei,断言 revertInsufficientOutputAmount;test_SwapForExactOut_RespectsMaxIn:固定输出成交;amountInMax 设过低应 revert;test_MultiHop_AtoC:通过 [A,B,C] 一笔交易完成两跳,最终 C 到账 == getAmountsOut 预测;test_AddLiquidity_OptimalRatio:复现 5.1 节 100:300 池子 + 21/63 approve 的例子,断言实际注入 (21,63);再构造比例突变使最优 < Min,断言 revert;test_AddLiquidityETH:用 ETH 加流动性,验证 WETH 被正确包装进池;test_RemoveLiquidity_SlippageGuard:amountAMin 设过高应 revert;test_Deadline_Expired:vm.warp到 deadline 之后,任意操作 revertExpired;test_SandwichSimulation:模拟攻击者抢跑推高价格,验证受害者因 amountOutMin 保护而 revert,资金不受损(对照不设保护时的损失)。
Sepolia 部署与验证步骤
- 部署 WETH、三种代币、Factory、Library、MiniRouter;
addLiquidity建 A-B 和 B-C 两个池(也可建一个 WETH-A 池试 ETH 路径);- 用 cast 或前端调
swapExactTokensForTokens([A,B,C]),故意先把 amountOutMin 设过高观察 revert,再设合理值成交; - 试一次
swapExactETHForTokens走 WETH 路径; removeLiquidity退出,核对取回数量。
进阶挑战(可选)
- 接一个最简前端(Scaffold-ETH 2 或 viem + 一个 HTML 页),输入框算实时报价、显示滑点、生成
deadline = now + 20min传给合约——把本文”deadline 由前端生成”的正确做法落地; - 实现
swapExactTokensForTokensSupportingFeeOnTransferTokens,部署一个会扣 1% 转账费的测试代币,验证普通 swap 会因 K 校验/输出不足而失败、而 Supporting 版本成功。