Router 合约提供了一个面向用户的智能合约,用于:
- 安全地铸造和销毁 LP token(添加和移除流动性)
- 安全地交换交易对代币
- 它们通过与包装以太坊 (WETH) ERC20 合约集成,增加了交换 Ether 的功能。
- 它们添加了核心合约中省略的与滑点相关的安全检查。
- 它们增加了对转账扣除手续费代币 (fee on transfer tokens) 的支持。
Router02 包含 Router01 的所有功能,并增加了对转账扣除手续费代币的支持
当我们第一次打开 periphery 仓库中的 contracts 文件夹时,我们会看到三个合约:

Router02 是在 Router01 的基础上增加了处理转账扣除手续费代币功能的版本。当我们查看 Router02 的接口时,可以看到它继承了 Router01(红框)(这意味着它实现了其所有功能),并具有以下附加功能,这些功能都是为了在支持转账扣除手续费代币的情况下执行操作(黄色高亮)。

swapExactTokensForTokens 与 swapTokensForExactTokens
让我们从 Router 用于交换代币的函数开始。有两个函数可以实现此目的(绿色高亮)。

这些函数名称的差异如下:
- 在
swapExactTokensForTokens中,“第一个代币是确定的 (first token is exact)”意味着你正在交换的输入代币数量是一个固定值。 - 在
swapTokensForExactTokens中,“第二个代币是确定的 (second token is exact)”表示你希望接收的输出代币数量是一个固定值。
如果用户仅交换两种代币,那么他们将向这些函数提供一个 address[] calldata path 数组(蓝色高亮) [address(tokenIn), address(tokenOut)]。如果他们跨越多个流动性池进行交换,则需指定 [address(tokenIn), address(intermediateToken), …, address(tokenOut)]。
swapExactTokensForTokens
在 swapExactTokensForTokens 的情况下,用户指定了他们将要存入的第一个代币的确切数量,以及他们可以接受的输出代币的最小数量。
例如,假设我们想用 25 个 token0 交换 50 个 token1。如果这是当前状态下的确切价格,这将对我们的交易被确认之前的价格变化没有任何容忍度,从而导致交易回滚。因此,我们会将最小输出指定为 49.5 个 token1,隐式留出了 1% 的容错空间。
swapTokensForExactTokens
在这种情况下,我们指定希望获得确切的 50 个 token1,但我们愿意最多支付 25.5 个 token0 来换取它。
该使用哪个 swap 函数?
大多数使用 EOA 的用户可能会选择使用确定输入的函数,因为他们需要执行授权步骤,如果他们需要输入的金额超过他们授权的金额,交易就会失败。通过确定输入,他们可以授权确切的数量。然而,与 Uniswap 集成的智能合约可能会有更复杂的需求,因此 router 为它们提供了这两种选项。
swap 如何工作
当输入是确定的 (swapExactTokensForTokens) 时,函数会预测在单次交换或一系列交换中预期的输出量。如果最终输出低于用户指定的数量,函数将回滚。确定输出的情况则反之亦然:它计算所需的输入量,如果高于用户指定的阈值,则回滚。
然后,这两个函数都将用户的代币转移到 pair 合约(记住,Uniswap V2 Pair 要求在调用 pair 合约的 swap() 函数之前将代币发送到合约中)。最后,它们都调用接下来将要讨论的内部 _swap() 函数。

_swap() 函数
在底层,所有名称中带有 swap() 的公开函数都会调用如下所示的内部 _swap() 函数。
回想一下,核心 swap 函数 的函数签名中指定了两种代币的 amountOut,而 amountIn 则是由调用函数前转入的金额隐含确定的。

_addLiquidity
还记得添加流动性的安全检查吗?具体来说,我们要确保我们存入的两种代币的比例与当前 pair 的比例完全相同,否则我们铸造的 LP token 数量将取决于我们提供的金额与 pair 余额之间较差的那个比例。然而,在流动性提供者尝试添加流动性到交易确认之间,该比例可能会发生变化。
为了防范这种情况,流动性提供者必须提供(作为参数)他们期望存入的 token0 和 token1 的最小余额(UniswapV2 将它们称为 amountAMin 和 amountBMin)。然后他们转入高于这些最小值的金额(UniswapV2 将它们称为 amountADesired 和 amountBDesired)。如果 pair 比例发生偏移导致无法满足这些最小值要求,则交易将回滚。
_addLiquidity 将接受 amountADesired 并计算符合该比例的 tokenB 的正确数量。如果这个数量高于 amountBDesired(流动性提供者发送的 B 代币数量),那么它将从 amountBDesired 开始并计算出最佳的 B 数量。逻辑如下所示。请注意,如果该 pair 合约尚不存在,添加流动性可能会创建一个新的 pair 合约。

例如,假设当前 pair 余额为 100 个 token0 和 300 个 token1。我们想分别添加 20 和 60 个 token0 和 token1,但 pair 比例可能会改变。所以相反,我们向 router 授权 21 个 token0 和 63 个 token1,同时表明我们想要存入的最低额度是 20 和 60。如果比例发生偏移,使得要存入的最佳 token0 数量变为 19.9,则交易就会回滚。
回想一下,我们曾说过 quote 不应被用作预言机,这依然是正确的。然而,出于添加流动性的目的,我们不关注过去价格的平均值,而是关注现在的当前价格(资金池比例),因为流动性提供者必须遵守它。
addLiquidity() 与 addLiquidityEth()
这些函数应该是不言自明的。它们首先使用上文的 _addLiquidity 计算最佳比例,然后将资产转移到 pair,接着在 pair 上调用 mint。唯一的区别在于 addLiquidityEth 函数会先将 Ether 包装成 WETH。

移除流动性
移除流动性会调用 burn,但使用了参数 amountAMin 和 amountBMin(红色高亮)作为安全检查,以确保流动性提供者拿回他们预期数量的代币。如果在流动性代币被销毁之前代币的比例发生了剧烈变化,那么销毁代币的用户将无法取回他们预期数量的 token A 或 B。
函数 removeLiquidityEth 调用了 removeLiquidity(绿色高亮),但将 router 设置为代币的接收者。常规的 ERC20 代币随后被转移给流动性提供者,而 WETH 被解包成 ETH,然后再发送回流动性提供者。

removeLiquidityWithPermit() 与 removeLiquidityETHWithPermit()
在上述文件的第 109 行,有一条灰色注释 send liquidity to pair,此步骤假定 pair 合约已获得从流动性提供者那里转移 LP token 以销毁它们的授权。这意味着销毁 LP token 需要首先对 pair 进行授权。如果使用 permit(),可以跳过此步骤,因为 Uniswap V2 的 LP token 是一种 ERC20 Permit Token。函数 removeLiquidityWithPermit() 接收一个签名,以在一次交易中完成授权和销毁。如果其中一种代币是 WETH,则流动性提供者将使用 removeLiquidityETHWithPermit()。
Router02:支持转账扣除手续费代币
为了处理转账扣除手续费代币,router 不能直接在参数上进行计算诸如 amountIn()(用于交换)或 liquidity()(用于移除流动性)。添加流动性不受转账扣除手续费代币的影响,因为用户只记录他们实际转入 pair 的金额。


UniswapV2Library 的封装器 (Wrappers)
Router 库中的其余函数是针对 UniswapV2Library 函数的封装器,如下所示。
function quote(uint amountA, uint reserveA, uint reserveB) public pure override returns (uint amountB) {
return UniswapV2Library.quote(amountA, reserveA, reserveB);
}
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) public pure override returns (uint amountOut) {
return UniswapV2Library.getAmountOut(amountIn, reserveIn, reserveOut);
}
function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut) public pure override returns (uint amountIn) {
return UniswapV2Library.getAmountOut(amountOut, reserveIn, reserveOut);
}
function getAmountsOut(uint amountIn, address[] memory path) public view override returns (uint[] memory amounts) {
return UniswapV2Library.getAmountsOut(factory, amountIn, path);
}
function getAmountsIn(uint amountOut, address[] memory path) public view override returns (uint[] memory amounts) {
return UniswapV2Library.getAmountsIn(factory, amountOut, path);
}
}
deadline 参数
在 Uniswap V2 Router 中,所有的公共函数都有一个 deadline(截止时间)参数。当你在 Uniswap 上立刻 (right now) 发起一笔交易时,意味着你想以当前价格进行交易。
在编写与 Uniswap 集成的智能合约时,不要将 deadline 设置为 block.timestamp 或 block.timestamp 加一个常数。
你的智能合约需要独立确保用户提交的交易没有超时。这意味着你的合约需要接受来自用户的 deadline 参数并将其转发给 Uniswap,或者如果 block.timestamp > deadline 则回滚。
如何利用过期的交易
恶意的区块构建者可以“扣留 (hold on)”交换交易,并在此类交易有助于操纵价格,或以不利价格向用户倾销代币时才执行它们。deadline 参数限制了攻击者执行此类漏洞的时间窗口。deadline 应当设定在足够远的未来,以便即使在网络拥堵期间也有时间执行交易,但也不应过长。这通常意味着 deadline 应该是自交易签署以来的几分钟级别。
然而,如果一个智能合约没有包含 deadline,或者通过忽略 deadline 并将当前的 block.timestamp 转发给 Uniswap 使该参数变得毫无用处,那么用户就得不到保护。
永远不要将 amountMin 设置为 0 或将 amountMax 设置为 type(uint).max
另一个非常常见的错误是将 amountMin 设置为 0,或将 amountMax 设置为一个极高的值。这会破坏对价格滑点和三明治攻击的保护。
总结
Router 合约提供了一种面向用户的机制,用于在具有滑点保护的情况下交换代币,也可以跨多个流动性池进行交换,并增加了对 ETH 和转账扣除手续费代币(在 Router02 中)的交易支持。存入流动性不需要考虑转账扣除手续费代币,因为 Uniswap 仅根据实际转入资金池的数量来记账。
存入流动性功能确保用户仅按资金池的确切比例进行存入。移除流动性可以很简单,只需将 LP token 转移到 router 然后销毁即可,也可以包括解包 WETH 并提取转账扣除手续费代币。
此外,还包含了通过 ERC20 Permit 支持免 Gas 授权的功能。
与 Uniswap 集成的智能合约决不能禁用对延迟交换和价格滑点的保护机制。
原载于 2023 年 11 月 10 日