Uniswap V2 的 swap 函数设计得非常巧妙,但许多开发者初次接触时会觉得其逻辑有些反直觉。本文将深入解析它的工作原理。
下面是该函数的代码复现:

诚然,这是一大段代码,但让我们逐步拆解它。
-
在第 170-171 行(用 黄框 标出),该函数直接转出了交易者在函数参数中请求的代币数量。在函数内部没有任何地方将代币转入。 浏览一遍代码,看看你能否找到代币转入的地方,根本不存在。但这并不意味着我们可以随便调用
swap然后抽干我们想要的所有代币! -
我们可以立即提取代币的原因是为了实现闪电贷(flash loans)。当然,第 182 行的
require语句(橙色箭头)会要求我们连本带利地偿还闪电贷。 -
在函数的顶部,有一段注释说明该函数应该从另一个实现了重要安全检查的智能合约中调用。这意味着 这个函数本身缺少安全检查(红线标出)。我们需要弄清楚这些安全检查到底是什么。
-
变量
_reserve0和_reserve1(蓝线标出)在第 161、176-177 和 182 行被读取,但它们 并没有在此函数中被写入。 -
第 182 行(橙色箭头)并没有严格检查 X × Y = K。它检查的是
balance1Adjusted×balance2Adjusted≥ K。这是唯一一个执行了“有趣”操作的require语句。 其他的require语句仅检查值不为零,或者你没有将代币发送到它们自己的合约地址。 -
balance0和balance1是使用 ERC20 的balanceOf直接从 pair 合约的实际余额中读取的。 -
第 172 行(在 黄框 下方)只有在 data 非空时才会执行,否则不执行。
结合这些观察结果,我们将逐个功能点来理清这个函数的逻辑。
闪电借款
用户不必为了交易代币才使用 swap 函数,它也可以纯粹作为闪电贷来使用。

借款合约只需请求他们希望借出的代币数量 (A),无需任何抵押,这些代币就会被转移到该合约中 (B)。
伴随函数调用应提供的数据作为函数参数传入 (C),随后它会被传递给一个实现了
IUniswapV2Callee 的函数。函数 uniswapV2Call 必须连本带手续费一起偿还闪电贷,否则交易将会回滚(revert)。
Swap 需要使用智能合约
如果不使用闪电贷,转入的代币必须在调用 swap 函数的过程中被发送。
显而易见,只有智能合约才能与 swap 函数进行交互,因为如果没有另一个智能合约的辅助,外部拥有账户(EOA)无法在同一笔交易中同时发送转入的 ERC20 代币并调用 swap。
计量转入的代币数量
Uniswap V2 “计量”转入代币数量的方式是在第 176 行和 177 行实现的,如下方的 黄框 所示。

请记住,_reserve0 和 _reserve1 没有在这个函数内部更新。它们反映的是在作为 swap 过程一部分的新代币被转入之前合约的余额。
交易对中的这两种代币,每一种都会发生以下两种情况之一:
-
资金池中某种代币的数量出现了净增加。
-
资金池中某种代币的数量出现了净减少(或没有变化)。
代码使用以下逻辑来确定发生了哪种情况:
currentContractbalanceX > _reserveX - _amountXOut
// alternatively
currentContractBalanceX > previousContractBalanceX - _amountXOut
如果计量结果是净减少,三元运算符将返回零,否则它将计量转入代币的净增加量。
amountXIn = balanceX - (_reserveX - amountXOut)
由于第 162 行的 require 语句,_reserveX > amountXOut 这个条件总是成立的。

一些例子:
-
假设我们之前的余额是 10,
amountOut是 0,而当前余额是 12。这意味着用户存入了 2 个代币。amountXIn将会是 2。 -
假设我们之前的余额是 10,
amountOut是 7,而当前余额是 3。amountXIn将会是 0。 -
假设我们之前的余额是 10,
amountOut是 7,而当前余额是 2。amountXIn仍然会是 0,而不是 -1。虽然资金池确实净损失了 8 个代币,但amountXIn不能为负数。 -
假设我们之前的余额是 10,而
amountOut是 6。如果当前余额是 18,那么用户“借走”了 6 个代币,但偿还了 8 个代币。
结论:如果该代币出现了净增加,amount0In 和 amount1In 将反映这个净增加量;如果该代币出现了净减少,它们将为零。
平衡 XY = K
既然我们已经知道用户转入了多少代币,让我们看看如何强制执行 XY = K。
对应的代码还是这段:

Uniswap V2 每次 swap 会收取硬编码的 0.3% 的手续费,这就是为什么我们会在代码中看到 1000 和 3 参与计算。但让我们进行简化,假设 Uniswap V2 不收取手续费。这意味着我们可以去掉 .sub(amountXIn.mul(3)) 这个部分,并且不需要在第 180 到 181 行乘以 1000,也不需要在第 182 行乘以 1000**2。
新代码将变成:
require(balance0 * balance1 >= reserve0 * reserve1, "K");
它的意思是:
K 并不是真正的常数
说“K 保持不变”是有些误导人的,尽管 AMM 公式有时被称为“恒定乘积公式”。
你可以这样想,如果有人向资金池捐赠代币并改变了 K 值,我们肯定不想阻止他们,因为这让我们流动性提供者变得更富有了,不是吗?
Uniswap V2 不会阻止你“支付过多”,即在 swap 过程中转入过多的代币(这与其中的一个安全检查有关,我们稍后会讲到)。
如果资金池出现净损失,我们才会感到不爽,这正是 require 语句要检查的情况。如果 K 变大,意味着资金池变大了,作为流动性提供者,这正是我们想要的。
计算手续费
但我们不仅希望 K 变大,我们还希望它至少增加一个能强制执行 0.3% 手续费的数量。
具体来说,0.3% 的手续费适用于我们的交易规模,而不是资金池的规模。它只适用于转入的代币,而不适用于转出的代币。一些例子:
-
假设我们转入了 1000 个
token0并取出了 1000 个token1。我们需要支付 3 个token0作为手续费,而token1不需要交手续费。 -
假设我们借出了 1000 个
token0,并且没有借出token1。我们必须要还回 1000 个token0,并且必须在此基础上支付 0.3% 的手续费——也就是 3 个token0。
请注意,如果我们闪电借款(flash borrow)了某一种代币,它所产生的手续费与交换相同数量的该代币的手续费是一样的。你需要为转入的代币支付手续费,而不是转出的代币。但如果你不转入代币,你就无法进行借款或 swap。
请记住,reserve0 和 reserve1 代表的是旧余额,而 balance0 和 balance1 代表的是更新后的余额。
牢记这一点后,让我们看看下面的代码,它应该是不言自明的。乘以 1000 和 3 只是为了完成“分数”乘法,因为它在最后会被抵消掉。

该代码正在实现以下公式:
也就是说,新余额必须增加转入数量的 0.3%。在代码中,该公式通过将各项乘以 1,000 进行了放大缩放,因为 Solidity 不支持浮点数,但这个数学公式展示了代码试图完成的目标。
更新储备量
现在交易已完成,那么“之前的余额”必须被替换为当前余额。这一操作发生在 swap() 末尾调用 _update() 函数时。

_update() 函数

这里有很多用于处理 TWAP oracle 的逻辑,但我们目前只关心第 82 行和第 83 行,这两行更新了存储变量 reserve0 和 reserve1 以反映更改后的余额。参数 _reserve0 和 _reserve1 仅被用于更新预言机,并没有被存储下来。
安全检查
有两件事情可能会出现问题:
-
没有强制要求
amountIn必须是最优值,因此用户可能会为这次swap支付过多的代币。 -
amountOut作为参数传入,没有任何灵活性。如果相对于amountOut来说amountIn不足,交易将会回滚(revert)并且浪费 gas。
如果有人(有意或无意地)抢跑交易(frontrun)并将资金池中的资产比例改变到不利的方向,这些情况就有可能发生。
在 RareSkills 了解更多
本文是我们高级 Solidity Bootcamp 的一部分。请查看课程大纲以了解更多信息。
最初发布于 2023 年 10 月 28 日