Uniswap V2 源码学习第六篇:swap,最核心的交易函数是怎么工作的?
swap拆解
前面我们已经拆完了两个流动性函数:
mint(address to)
burn(address to)
它们分别对应:
添加流动性
移除流动性
这一篇进入 Uniswap V2 的灵魂函数:
function swap(
uint amount0Out,
uint amount1Out,
address to,
bytes calldata data
) external lock
如果说 mint() 和 burn() 负责“让 LP 进出池子”,那么 swap() 就负责真正的交易。
这一篇会非常关键。
因为 Uniswap V2 最精妙的几个设计都集中在 swap() 里:
1. 用户先拿走输出 token
2. Pair 再检查输入 token 是否足够
3. amountIn 不是参数,而是通过余额反推
4. 手续费通过 K 检查体现
5. flash swap 天然嵌在 swap 里
6. 最终靠恒定乘积约束兜底
一句话概括:
Uniswap V2 的 swap 不是“先收钱再发货”,而是“先发货,最后检查你有没有付够钱”。
这就很 DeFi。胆子大,但规矩硬。
1. 先看完整 swap 源码
UniswapV2Pair.swap() 的核心代码大概是这样:
function swap(
uint amount0Out,
uint amount1Out,
address to,
bytes calldata data
) external lock {
require(
amount0Out > 0 || amount1Out > 0,
'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT'
);
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
require(
amount0Out < _reserve0 && amount1Out < _reserve1,
'UniswapV2: INSUFFICIENT_LIQUIDITY'
);
uint balance0;
uint balance1;
{
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
if (data.length > 0) {
IUniswapV2Callee(to).uniswapV2Call(
msg.sender,
amount0Out,
amount1Out,
data
);
}
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint amount0In = balance0 > _reserve0 - amount0Out
? balance0 - (_reserve0 - amount0Out)
: 0;
uint amount1In = balance1 > _reserve1 - amount1Out
? balance1 - (_reserve1 - amount1Out)
: 0;
require(
amount0In > 0 || amount1In > 0,
'UniswapV2: INSUFFICIENT_INPUT_AMOUNT'
);
{
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(
balance0Adjusted.mul(balance1Adjusted)
>= uint(_reserve0).mul(_reserve1).mul(1000**2),
'UniswapV2: K'
);
}
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(
msg.sender,
amount0In,
amount1In,
amount0Out,
amount1Out,
to
);
}
这段代码第一次看会觉得有点反直觉。
因为你可能会期待一个 swap 函数长这样:
function swap(uint amountIn, uint amountOutMin, address tokenIn, address to)
但 Pair 的 swap() 不是这样。
它只关心:
uint amount0Out
uint amount1Out
address to
bytes data
也就是说,Pair 只让你声明:
你想从池子里拿走多少 token0?
你想从池子里拿走多少 token1?
资产发给谁?
是否触发回调?
它没有 amountIn 参数。
因为输入多少,Pair 自己会算。
2. swap 的整体流程
先把流程压成一张图:
调用 swap(amount0Out, amount1Out, to, data)
↓
检查至少有一个输出金额 > 0
↓
读取旧 reserve
↓
检查池子流动性够不够输出
↓
把 amount0Out / amount1Out 先转给 to
↓
如果 data.length > 0,调用 to.uniswapV2Call(...)
↓
读取 Pair 当前真实余额 balance0/balance1
↓
用 balance 和 reserve 反推 amount0In/amount1In
↓
检查至少有一个输入金额 > 0
↓
扣除 0.3% 手续费后检查 K 不变式
↓
更新 reserve
↓
emit Swap
这个流程最值得注意的是中间这段:
先转出
后检查
这也是 flash swap 能成立的根本。
3. amount0Out / amount1Out:输出金额,而不是输入金额
函数签名:
function swap(
uint amount0Out,
uint amount1Out,
address to,
bytes calldata data
) external lock
这里的 amount0Out 和 amount1Out 是“输出数量”。
如果用户用 token0 换 token1,那么:
amount0Out = 0
amount1Out > 0
如果用户用 token1 换 token0,那么:
amount0Out > 0
amount1Out = 0
理论上两个都可以大于 0,但最终必须通过 K 检查。
正常 swap 里,一般只有一个输出大于 0。
比如 DAI/USDC Pair:
token0 = DAI
token1 = USDC
用户用 DAI 买 USDC,那么 Pair 里发生的是:
用户输入 DAI
Pair 输出 USDC
对应:
amount0Out = 0
amount1Out = USDC 输出数量
Pair 不要求你告诉它输入多少 DAI。
它只要求交易结束后,你真的把足够多的 DAI 付进来了。
4. 第一步:输出金额不能全是 0
开头第一句:
require(
amount0Out > 0 || amount1Out > 0,
'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT'
);
这很好理解。
如果两个输出都是 0,那就不是 swap。
amount0Out = 0
amount1Out = 0
这没有意义,直接 revert。
5. 第二步:读取旧 reserve
接下来:
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
这里读取的是交易开始前 Pair 记录的储备量。
后面会用它做三件事:
1. 检查输出金额是否超过池子流动性
2. 反推输入金额 amountIn
3. 做 K 检查
注意,_reserve0/_reserve1 是旧状态。
后面转出 token、回调、输入 token 到账之后,Pair 会重新读取 balance0/balance1。
这又是熟悉的模式:
reserve:交易前的账本状态
balance:交易后的真实余额
6. 第三步:池子里必须有足够流动性
require(
amount0Out < _reserve0 && amount1Out < _reserve1,
'UniswapV2: INSUFFICIENT_LIQUIDITY'
);
这句要求:
输出的 token0 小于 reserve0
输出的 token1 小于 reserve1
注意是 <,不是 <=。
也就是说,不能把某一边完全取空。
如果池子有:
reserve0 = 1000
reserve1 = 1000
你不能:
amount0Out = 1000
必须小于 1000。
原因很简单:AMM 需要保留正的流动性,否则价格模型、K 检查、后续交易都会失去意义。
池子不能被一次 swap 直接抽干一边。
7. 为什么有一个花括号代码块?
源码里有一段这样的结构:
{
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
if (data.length > 0) {
IUniswapV2Callee(to).uniswapV2Call(...);
}
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
这个额外的 { ... } 不是为了逻辑分支。
它主要是为了避免 Solidity 的:
stack too deep
老版本 Solidity 里,函数内局部变量太多容易超过栈限制。
用一层代码块可以让里面的局部变量作用域提前结束,释放栈空间。
这也是 Uniswap V2 源码里很典型的工程小细节。
8. 缓存 token0/token1
代码块里先缓存:
address _token0 = token0;
address _token1 = token1;
和前面 burn() 一样,这么写主要是:
1. 减少重复读取 storage
2. 省 gas
3. 缓解 stack too deep
老 Solidity 源码里这种写法很多。
9. to 不能是 token0 或 token1
接下来:
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
这句很容易被忽略。
它要求输出 token 的接收地址 to 不能是 token0 或 token1 的合约地址。
为什么?
因为后面 Pair 会调用:
_safeTransfer(_token0, to, amount0Out);
_safeTransfer(_token1, to, amount1Out);
如果 to 是 token 合约本身,就会把 token 发送到 token 合约地址。
这在某些 token 实现里可能造成异常行为,甚至干扰后续的余额判断和回调逻辑。
Uniswap V2 干脆禁止这种情况。
正常交易里,to 应该是:
用户地址
下一个 Pair 地址
执行 flash swap 的合约地址
而不应该是 token 合约地址。
10. 乐观转账:先把输出 token 发出去
现在到了最反直觉的一步:
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
Pair 在还没有确认输入 token 是否到账之前,就先把输出 token 转给了 to。
这叫“乐观转账”。
英文里常说 optimistic transfer。
普通交易视角下,你可能会觉得:
这不危险吗?
用户钱还没付,怎么先发货?
答案是:
不危险,因为函数最后会检查 K。
如果用户没付够,整笔交易 revert。
EVM 里 revert 会回滚整个交易。
也就是说,如果后面 K 检查失败,前面的 _safeTransfer 也会回滚。
所以 Pair 可以大胆先转出,再在最后统一检查。
这就像一个特别严格的收银员:
你可以先把东西拿走,但出门前我会算账。算不平,整单作废。
11. Flash Swap 就藏在这里
乐观转账之后:
if (data.length > 0) {
IUniswapV2Callee(to).uniswapV2Call(
msg.sender,
amount0Out,
amount1Out,
data
);
}
这就是 Uniswap V2 flash swap 的入口。
如果 data.length == 0:
普通 swap
如果 data.length > 0:
flash swap
Pair 会调用 to 地址上的:
uniswapV2Call(
address sender,
uint amount0,
uint amount1,
bytes calldata data
)
也就是说:
Pair 先把 token 转给 to
然后调用 to 的回调函数
to 可以在回调里使用这笔 token 做任意操作
最后必须把足够的 token 还回 Pair
这就是 flash swap。
它和闪电贷的味道很像:
同一笔交易里:
先借出资产
调用外部逻辑
最后检查是否偿还
否则全部 revert
Uniswap V2 没有单独写一个 flashLoan() 函数。
它把 flash swap 自然融合进了 swap()。
这设计非常漂亮。
12. flash swap 和普通 swap 的区别
普通 swap 通常是 Router 先把输入 token 转进 Pair,然后调用 swap():
Router 先转入 tokenIn
↓
Pair.swap 输出 tokenOut
↓
data.length == 0,不触发回调
↓
Pair 检查 K
flash swap 是反过来的:
Pair 先输出 tokenOut 给 to
↓
Pair 调用 to.uniswapV2Call(...)
↓
to 在回调里处理逻辑,并把 token 还回 Pair
↓
Pair 检查 K
所以区别不是 K 检查有没有。
两者最后都靠 K 检查兜底。
区别是:
普通 swap:输入通常在 swap 前到账
flash swap:输入通常在回调中到账
Pair 不关心你是哪种。
它只在最后看余额:
交易结束时,扣费后的 K 约束是否成立?
13. 转账和回调之后,读取真实余额
接下来:
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
此时已经发生过:
1. 输出 token 转给 to
2. 可选 flash swap 回调
3. 回调中可能还回 token
4. 普通 swap 中输入 token 可能早已到账
现在 Pair 查自己的真实余额。
这两个余额是整个 swap 交易的最终状态。
后面所有检查都基于它们。
这仍然是 Uniswap V2 的老原则:
不听你说你付了多少,我看余额。
14. amountIn 是怎么反推出来的?
接下来是 swap 里最经典的一段:
uint amount0In = balance0 > _reserve0 - amount0Out
? balance0 - (_reserve0 - amount0Out)
: 0;
uint amount1In = balance1 > _reserve1 - amount1Out
? balance1 - (_reserve1 - amount1Out)
: 0;
第一次看会有点绕。
我们拆开。
交易开始前,Pair 记录:
reserve0
reserve1
交易中,Pair 可能转出了:
amount0Out
amount1Out
如果没有任何输入,转出之后理论余额应该是:
token0 理论余额 = reserve0 - amount0Out
token1 理论余额 = reserve1 - amount1Out
但实际最终余额是:
balance0
balance1
所以:
如果 balance0 > reserve0 - amount0Out
说明 token0 有输入
输入量 = balance0 - (reserve0 - amount0Out)
如果 balance1 > reserve1 - amount1Out
说明 token1 有输入
输入量 = balance1 - (reserve1 - amount1Out)
这就是 amountIn 的来源。
它不是用户传的。
它是 Pair 自己从最终余额中反推的。
15. 用普通 swap 例子理解 amountIn
假设一个 DAI/USDC 池子:
token0 = DAI
token1 = USDC
reserve0 = 10000 DAI
reserve1 = 10000 USDC
用户用 DAI 换 USDC。
Router 先转入:
1000 DAI
然后调用 Pair.swap:
amount0Out = 0
amount1Out = 906 USDC
Pair 先转出 906 USDC。
交易结束后余额是:
balance0 = 11000 DAI
balance1 = 9094 USDC
计算输入:
amount0In =
balance0 - (reserve0 - amount0Out)
= 11000 - (10000 - 0)
= 1000 DAI
amount1In =
balance1 > reserve1 - amount1Out ?
9094 > 10000 - 906 ?
9094 > 9094 ?
false
amount1In = 0
所以 Pair 反推出:
用户输入了 1000 DAI
用户没有输入 USDC
这正是我们想要的结果。
16. 用 flash swap 例子理解 amountIn
假设还是这个池子:
reserve0 = 10000 DAI
reserve1 = 10000 USDC
某个合约发起 flash swap,先借走:
1000 USDC
调用:
amount0Out = 0
amount1Out = 1000
data.length > 0
Pair 先转出 1000 USDC。
然后调用 to.uniswapV2Call(...)。
在回调里,to 最终还回:
1115 DAI
交易结束后:
balance0 = 11115 DAI
balance1 = 9000 USDC
Pair 计算:
amount0In =
11115 - (10000 - 0)
= 1115 DAI
amount1In =
9000 > (10000 - 1000) ?
9000 > 9000 ?
false
amount1In = 0
所以 Pair 认为:
你拿走了 1000 USDC
你付回来了 1115 DAI
然后用 K 检查判断这是否足够。
17. 至少要有输入
反推出 amountIn 后:
require(
amount0In > 0 || amount1In > 0,
'UniswapV2: INSUFFICIENT_INPUT_AMOUNT'
);
如果两个输入都是 0,说明用户只拿走了 token,没有付回任何东西。
那当然不行。
比如:
amount0Out = 100
amount1Out = 0
但最终:
balance0 = reserve0 - 100
balance1 = reserve1
那么:
amount0In = 0
amount1In = 0
直接 revert。
18. 最核心的 K 检查
接下来进入 swap 的核心:
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(
balance0Adjusted.mul(balance1Adjusted)
>= uint(_reserve0).mul(_reserve1).mul(1000**2),
'UniswapV2: K'
);
这几行同时完成了三件事:
1. 检查恒定乘积公式
2. 收取 0.3% 手续费
3. 支持单边输入、双边输入、flash swap 等各种情况
看起来短,但密度极高。
19. 没有手续费时,K 检查应该是什么?
先假设没有手续费。
Uniswap 的基础模型是:
x * y = k
swap 之后,池子的乘积不能变小。
也就是:
balance0 * balance1 >= reserve0 * reserve1
如果交易后乘积变小了,说明用户拿走了太多资产。
所以最朴素的检查是:
require(
balance0 * balance1 >= reserve0 * reserve1,
'K'
);
但是 V2 有 0.3% 手续费,所以不能这么简单。
20. 手续费怎么体现在 K 检查里?
Uniswap V2 的交易手续费是 0.3%。
换句话说,如果用户输入了:
amountIn
只有:
amountIn * 997 / 1000
参与兑换计算。
另外:
amountIn * 3 / 1000
作为手续费留在池子里。
在 Router 的 getAmountOut() 里,我们会看到:
uint amountInWithFee = amountIn.mul(997);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(1000).add(amountInWithFee);
amountOut = numerator / denominator;
而 Pair 的 swap() 不直接算报价。
它只是检查最终状态是否符合“扣除手续费后 K 不减少”。
于是它写成:
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
这等价于:
balanceAdjusted = balance * 1000 - amountIn * 3
为什么这样就表示扣除手续费?
因为最终余额 balance 里已经包含了全部输入。
比如 token0 输入了 amount0In。
最终余额可以写成:
balance0 = reserve0 - amount0Out + amount0In
但在检查交易是否公平时,真正参与价格计算的输入只有:
amount0In * 997 / 1000
手续费那 0.3% 不应该帮用户换出更多资产。
所以 adjusted balance 要把手续费部分从输入里扣掉。
用放大 1000 倍的整数写法就是:
balance0Adjusted
= balance0 * 1000 - amount0In * 3
也就是:
= (reserve0 - amount0Out + amount0In) * 1000 - amount0In * 3
= (reserve0 - amount0Out) * 1000 + amount0In * 997
这正好是:
旧余额减输出 + 扣完手续费后的有效输入
非常漂亮。
21. 为什么要乘 1000?
因为 Solidity 里没有浮点数。
手续费是:
0.3% = 3 / 1000
有效输入是:
997 / 1000
所以源码把所有东西放大 1000 倍,用整数计算。
这就是:
balance0.mul(1000).sub(amount0In.mul(3))
最后比较时,右边也要乘:
1000**2
因为左边是两个 adjusted balance 相乘,每个都放大了 1000 倍。
所以:
balance0Adjusted.mul(balance1Adjusted)
>= uint(_reserve0).mul(_reserve1).mul(1000**2)
如果不乘 1000**2,量纲就不一致。
22. 用数字走一遍 K 检查
还是这个池子:
reserve0 = 10000 DAI
reserve1 = 10000 USDC
用户输入:
amount0In = 1000 DAI
输出:
amount1Out = 906 USDC
交易后余额:
balance0 = 11000
balance1 = 9094
计算 adjusted balance:
balance0Adjusted
= 11000 * 1000 - 1000 * 3
= 11,000,000 - 3,000
= 10,997,000
balance1Adjusted
= 9094 * 1000 - 0 * 3
= 9,094,000
检查:
balance0Adjusted * balance1Adjusted
>= reserve0 * reserve1 * 1000^2
右边:
10000 * 10000 * 1,000,000
= 100,000,000,000,000
左边:
10,997,000 * 9,094,000
= 100,006,718,000,000
左边略大于右边,所以交易通过。
为什么会略大?
因为整数除法向下取整,Router 算出的 amountOut 通常会保守一点。
23. 如果输出多拿一点会怎样?
还是输入 1000 DAI。
如果用户想拿:
907 USDC
交易后:
balance0 = 11000
balance1 = 9093
adjusted:
balance0Adjusted = 10,997,000
balance1Adjusted = 9,093,000
左边:
10,997,000 * 9,093,000
= 99,995,721,000,000
右边:
100,000,000,000,000
左边小于右边。
所以:
require(..., 'UniswapV2: K');
会失败。
这就是 K 检查的威力:
多拿一点点,都过不了。
24. swap 为什么不自己计算 amountOut?
Pair.swap 不负责报价。
它只负责验证。
报价通常由 Router / Library 完成。
例如:
UniswapV2Library.getAmountOut(
amountIn,
reserveIn,
reserveOut
)
会计算用户输入多少,最多能输出多少。
然后 Router 调用 Pair.swap。
Pair 最后用 K 检查验证:
你这笔交易有没有满足 AMM 规则?
手续费有没有留下?
这是一种很好的职责分离:
Library:负责计算报价
Router:负责用户路径和转账
Pair:负责最终验证和状态更新
Pair 不需要知道用户的意图是什么。
它只需要确保最终状态合法。
25. K 检查天然支持多种输入形式
因为 Pair 只看最终余额,所以它可以支持很多玩法。
单边输入,单边输出
最常见:
输入 token0
输出 token1
或者:
输入 token1
输出 token0
双边输入
理论上用户可以同时输入 token0 和 token1,同时输出某一边或两边。
只要最后 K 检查通过,就可以。
flash swap
先输出,再回调里输入。
也可以通过 K 检查。
多跳交易
中间 Pair 的输出直接转到下一个 Pair,最终每个 Pair 都独立检查自己的 K。
所以 Pair.swap 非常通用。
它没有把交易场景写死,而是用一个最终状态约束统一兜底。
这就是好协议设计的味道。
26. 更新 reserve
K 检查通过后:
_update(balance0, balance1, _reserve0, _reserve1);
这会把 Pair 的 reserve 更新成交易后的真实余额:
reserve0 = balance0
reserve1 = balance1
同时还会更新:
blockTimestampLast
price0CumulativeLast
price1CumulativeLast
所以 swap 之后,池子的价格也随之变化。
比如:
交易前:
reserve0 = 10000
reserve1 = 10000
用户输入 token0,输出 token1 后:
balance0 = 11000
balance1 = 9094
_update 后:
reserve0 = 11000
reserve1 = 9094
价格从:
1 : 1
变成了:
11000 : 9094
也就是 token0 变多,token1 变少,token1 相对更贵。
这就是 AMM 定价的结果。
27. 触发 Swap 事件
最后:
emit Swap(
msg.sender,
amount0In,
amount1In,
amount0Out,
amount1Out,
to
);
事件记录:
msg.sender:谁调用了 swap
amount0In:实际输入多少 token0
amount1In:实际输入多少 token1
amount0Out:输出多少 token0
amount1Out:输出多少 token1
to:输出接收者
如果用户通过 Router swap:
msg.sender = Router
to = 用户地址或下一个 Pair
所以链下解析事件时,要注意 sender 不一定是最终用户。
28. 普通 swap 的完整调用路径
以 swapExactTokensForTokens 为例。
用户想用 1000 DAI 换 USDC。
大致流程:
User
↓
Router.swapExactTokensForTokens(...)
↓
Router 通过 getAmountsOut 算出每一跳输出
↓
Router 把 1000 DAI transferFrom 到第一个 Pair
↓
Router 调用 Pair.swap(0, amountOut, to, new bytes(0))
↓
Pair 转出 USDC
↓
Pair 反推 amountIn
↓
Pair K 检查
↓
Pair 更新 reserve
关键点是:
输入 token 是 swap 前由 Router 转入 Pair 的
输出 token 是 Pair.swap 中转出的
所以 Pair.swap 里没有 transferFrom。
29. 多跳 swap 的完整路径
假设用户做:
DAI → WETH → USDC
路径是:
path = [DAI, WETH, USDC]
Router 会先计算:
amounts[0] = DAI 输入
amounts[1] = WETH 中间输出
amounts[2] = USDC 最终输出
然后:
1. Router 把 DAI 转进 DAI/WETH Pair
2. DAI/WETH Pair 输出 WETH
3. 这个 WETH 不给用户,而是直接给 WETH/USDC Pair
4. WETH/USDC Pair 输出 USDC 给用户
Router 的 _swap() 里有一段很关键:
address to = i < path.length - 2
? UniswapV2Library.pairFor(factory, output, path[i + 2])
: _to;
意思是:
如果不是最后一跳:
输出 token 直接发给下一个 Pair
如果是最后一跳:
输出 token 发给用户
每个 Pair 都只关心自己这一跳的 K 检查。
整个多跳交易就是多个 Pair.swap 串起来。
30. swap 和 Router 的滑点保护
Pair.swap 本身没有:
amountOutMin
deadline
path
这些都是 Router 层的东西。
比如用户调用:
swapExactTokensForTokens(
amountIn,
amountOutMin,
path,
to,
deadline
)
Router 会先算:
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
然后检查:
require(
amounts[amounts.length - 1] >= amountOutMin,
'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT'
);
Pair 不负责用户是否满意。
Pair 只负责:
交易是否满足池子的数学规则
Router 负责:
用户是否接受这个价格
交易是否过期
路径是否正确
这再次体现 core/periphery 分离。
31. swap 的安全边界
swap() 里有几个安全点值得特别记。
lock 防重入
function swap(...) external lock
因为 swap 会:
1. 转 token 给外部地址
2. 可能调用外部合约 uniswapV2Call
这都是重入风险点。
lock 保证同一个 Pair 的关键函数不能被重入。
K 检查兜底
无论你普通 swap、flash swap,还是复杂套利,只要最终 K 不满足,就 revert。
不相信 amountIn 参数
因为根本没有 amountIn 参数。
Pair 自己从余额反推输入。
to 不能是 token 地址
避免输出直接打到 token 合约造成异常边界。
_safeTransfer 兼容非标准 ERC20
转出 token 时使用低级调用,兼容不返回 bool 的 token。
32. swap 的设计哲学
swap() 是 Uniswap V2 最漂亮的函数,因为它用很少的代码统一了很多场景。
它的设计哲学可以总结成几句话。
第一,最终状态约束优于过程约束
Pair 不规定你必须先转入还是后转入。
它只看最后:
扣费后的 K 是否满足?
手续费是否留下?
这让 flash swap 成为自然结果。
第二,不信任调用者,信任余额
Pair 不接受 amountIn。
它自己计算:
amountIn = balance - expectedBalanceAfterOutput
这减少了很多欺骗空间。
第三,手续费不单独转账
手续费通过 adjusted balance 进入 K 检查。
0.3% 留在池子里,成为 LP 的收益。
第四,Pair 只管规则,不管体验
滑点、deadline、多跳路径、ETH 包装都在 Router。
Pair 只负责保证这笔状态变更不破坏池子。
第五,flash swap 不是外挂功能
flash swap 不是独立模块,而是乐观转账 + 回调 + K 检查的自然产物。
这点真的很优雅。
33. swap 完整流程再压缩一遍
最后压成一版:
1. 检查 amount0Out 或 amount1Out 至少一个大于 0
2. 读取旧 reserve
3. 检查输出金额小于 reserve
4. 缓存 token0/token1
5. 检查 to 不能是 token 地址
6. 先把输出 token 转给 to
7. 如果 data 不为空,调用 to.uniswapV2Call
8. 读取 Pair 当前真实 balance
9. 根据 balance 和 reserve 反推 amount0In/amount1In
10. 检查至少有一个输入
11. 对输入扣除 0.3% 手续费,计算 adjusted balance
12. 检查 adjustedBalance0 * adjustedBalance1 >= oldReserve0 * oldReserve1 * 1000^2
13. 更新 reserve
14. emit Swap
一句话版本:
Pair 允许你先拿走 token,但交易结束时,你必须让扣除手续费后的池子乘积不小于交易前。
这就是 Uniswap V2 swap 的核心。
小结
这一篇我们拆完了 Pair.swap()。
现在你应该能回答:
1. swap 为什么没有 amountIn 参数?
因为 Pair 通过最终余额反推真实输入。
2. 为什么先转出 token?
因为最后有 K 检查,失败会回滚;这也支持 flash swap。
3. flash swap 是怎么触发的?
data.length > 0 时,Pair 会调用 to.uniswapV2Call。
4. amount0In / amount1In 怎么算?
用最终 balance 减去“扣除输出后的理论余额”。
5. 0.3% 手续费在哪里实现?
在 balanceAdjusted = balance * 1000 - amountIn * 3 中体现。
6. K 检查检查的是什么?
扣除手续费后的最终余额乘积不能小于旧 reserve 乘积。
7. swap 后为什么要 _update?
把 reserve 同步到交易后的真实余额,并更新价格累计。
8. Pair 为什么不处理滑点?
滑点是用户体验层逻辑,由 Router 负责。
到这里,我们已经读完 Pair 的三大核心动作:
mint:添加流动性
burn:移除流动性
swap:交易