Uniswap V2 Router 路由合约详解

面向用户的外围合约:滑点保护、deadline、多跳交换、ETH 与转账收费代币支持、Permit 免授权

8 分钟阅读
Uniswap V2 Router 路由合约详解

Uniswap V2 Router 路由合约详解

目录


一、Router 在架构中的位置

Router 属于 periphery(外围) 合约。Pair(核心)只提供裸的 swapmintburn,没有任何用户保护。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 交换的统一工作流

两类函数都遵循同样四步:

  1. 用 Library 的 getAmountsOut / getAmountsIn 算出各跳金额;
  2. 校验安全阈值(输出 < amountOutMin 或 输入 > amountInMax 就 revert);
  3. 把用户的输入代币 transferFrom第一个 Pair;
  4. 调用内部 _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(最低可接受,滑点保护)。

算法:

  1. 先假设全额用 amountADesired,用 quote 算出需要配多少 B(amountBOptimal);
  2. amountBOptimal ≤ amountBDesired:检查它 ≥ amountBMin,用 (amountADesired, amountBOptimal);
  3. 否则反过来:固定 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.timestampblock.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 factoryimmutable 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)

  1. test_SwapExactIn_RespectsMinOut:正常成交;把 amountOutMin 设成比实际高 1 wei,断言 revert InsufficientOutputAmount
  2. test_SwapForExactOut_RespectsMaxIn:固定输出成交;amountInMax 设过低应 revert;
  3. test_MultiHop_AtoC:通过 [A,B,C] 一笔交易完成两跳,最终 C 到账 == getAmountsOut 预测;
  4. test_AddLiquidity_OptimalRatio:复现 5.1 节 100:300 池子 + 21/63 approve 的例子,断言实际注入 (21,63);再构造比例突变使最优 < Min,断言 revert;
  5. test_AddLiquidityETH:用 ETH 加流动性,验证 WETH 被正确包装进池;
  6. test_RemoveLiquidity_SlippageGuard:amountAMin 设过高应 revert;
  7. test_Deadline_Expiredvm.warp 到 deadline 之后,任意操作 revert Expired
  8. test_SandwichSimulation:模拟攻击者抢跑推高价格,验证受害者因 amountOutMin 保护而 revert,资金不受损(对照不设保护时的损失)。

Sepolia 部署与验证步骤

  1. 部署 WETH、三种代币、Factory、Library、MiniRouter;
  2. addLiquidity 建 A-B 和 B-C 两个池(也可建一个 WETH-A 池试 ETH 路径);
  3. 用 cast 或前端调 swapExactTokensForTokens([A,B,C]),故意先把 amountOutMin 设过高观察 revert,再设合理值成交;
  4. 试一次 swapExactETHForTokens 走 WETH 路径;
  5. removeLiquidity 退出,核对取回数量。

进阶挑战(可选)

  • 接一个最简前端(Scaffold-ETH 2 或 viem + 一个 HTML 页),输入框算实时报价、显示滑点、生成 deadline = now + 20min 传给合约——把本文”deadline 由前端生成”的正确做法落地;
  • 实现 swapExactTokensForTokensSupportingFeeOnTransferTokens,部署一个会扣 1% 转账费的测试代币,验证普通 swap 会因 K 校验/输出不足而失败、而 Supporting 版本成功。

💬 评论