Uniswap V2 源码学习第八篇:Router,为什么用户通常不直接调用 Pair?

tomenengr 发布于 2026-05-11 阅读 602

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:
    交易过期时间。

注意这里的 amountADesiredamountBDesired 不是最终一定全部转进去。

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 对应 token0amount1 对应 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:验证和更新状态

相关文章

0 条评论