Uniswap V2 源码学习第十一篇:Flash Swap,为什么 V2 可以先借钱后还钱?
Flash Swap拆解
上一篇我们讲了 Uniswap V2 的 TWAP Oracle。
这一篇进入一个非常酷、也非常能体现 V2 设计风格的功能:
Flash Swap
也就是闪电兑换。
它的核心效果是:
你可以先从 Pair 里拿走 token,然后在同一笔交易结束前,把足够的 token 还回来。
听起来像闪电贷,但 Uniswap V2 没有单独写一个 flashLoan() 函数。
它只是把 flash swap 藏在了 swap() 里。
关键代码就这一小段:
if (data.length > 0) {
IUniswapV2Callee(to).uniswapV2Call(
msg.sender,
amount0Out,
amount1Out,
data
);
}
看起来轻描淡写,实际非常精妙。
1. 先回忆普通 swap 的流程
普通 swap 大概是这样:
用户想用 token0 换 token1
1. Router 先把 token0 转进 Pair
2. Router 调用 Pair.swap()
3. Pair 把 token1 转给用户
4. Pair 检查 K 是否满足
5. 更新 reserve
也就是:
先输入,再输出
比如:
Alice 用 1000 DAI 换 USDC
流程是:
Alice / Router
↓ 转入 1000 DAI
DAI-USDC Pair
↓ 输出 906 USDC
Alice
这种很好理解。
但上一篇 swap() 源码里我们看到,Pair 实际执行顺序不是“先收钱再发货”。
Pair 的真实逻辑更像:
1. 你告诉我要输出多少 token
2. Pair 先把 token 转给 to
3. 如果 data 不为空,Pair 调用 to 的回调
4. 回调结束后,Pair 检查自己最终余额
5. 如果 K 检查不通过,整笔交易 revert
也就是:
先输出,后验账
这个“先输出,后验账”就是 flash swap 的基础。
2. 普通 swap 和 flash swap 的差别
从 Pair 角度看,两者其实差别很小。
普通 swap
输入 token 通常在调用 swap 之前已经转进 Pair
data.length == 0
不会触发回调
swap 最后检查 K
流程:
Router 先转 tokenIn 到 Pair
↓
Pair.swap()
↓
Pair 转出 tokenOut
↓
Pair 检查 K
Flash swap
输入 token 通常在回调函数里才还回来
data.length > 0
触发 uniswapV2Call
swap 最后检查 K
流程:
Pair.swap()
↓
Pair 先转出 tokenOut 给 to
↓
Pair 调用 to.uniswapV2Call()
↓
to 在回调里使用这笔资金
↓
to 把足够 token 还给 Pair
↓
Pair 检查 K
也就是说:
普通 swap:钱通常提前到账
flash swap:钱通常回调中到账
但最终都靠同一个东西兜底:
K 检查
这就是 Uniswap V2 的优雅之处。
它没有给 flash swap 设计一套独立机制,而是复用了 swap 的最终状态检查。
3. swap 里的 flash swap 代码在哪里?
我们把 swap() 里的相关片段单独拿出来:
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));
顺序很关键:
1. 先把 token 转给 to
2. 如果 data 不为空,调用 to 的回调函数
3. 回调完成后,读取 Pair 当前余额
这意味着,在 uniswapV2Call() 执行期间,to 合约已经拿到了 Pair 转出的 token。
它可以用这些 token 做任何事情:
1. 去别的 DEX 套利
2. 偿还债务
3. 清算头寸
4. 搬运抵押品
5. 执行复杂组合交易
但有一个前提:
回调结束后,Pair 的 K 检查必须通过。
否则整笔交易回滚,所有操作都当没发生。
4. data.length 是 flash swap 的开关
Pair 判断是否触发回调,只看:
if (data.length > 0)
所以:
data 为空:普通 swap
data 不为空:flash swap
这就是 V2 的 flash swap 开关。
注意,不是看:
amount0Out 是否大于 0
amount1Out 是否大于 0
to 是否是合约
msg.sender 是不是 Router
只看 data.length。
这意味着如果你想触发 flash swap,即使没有什么复杂参数,也要传入至少一个字节。
比如:
bytes memory data = abi.encode(...);
或者最小地传:
bytes memory data = new bytes(1);
如果传空:
new bytes(0)
就不会触发回调。
Router 普通 swap 里就是传:
new bytes(0)
所以普通交易不会进入 uniswapV2Call()。
5. 回调接口 IUniswapV2Callee
flash swap 接收合约必须实现这个接口:
interface IUniswapV2Callee {
function uniswapV2Call(
address sender,
uint amount0,
uint amount1,
bytes calldata data
) external;
}
当 Pair 触发回调时,会调用:
IUniswapV2Callee(to).uniswapV2Call(
msg.sender,
amount0Out,
amount1Out,
data
);
参数分别是:
sender:
调用 Pair.swap 的地址。
amount0:
Pair 转出的 token0 数量。
amount1:
Pair 转出的 token1 数量。
data:
调用 swap 时传入的自定义数据。
这里有一个细节:
sender 不是 Pair 地址。
sender 是调用 swap 的 msg.sender。
而在回调函数内部:
msg.sender
才是 Pair 地址。
所以在 uniswapV2Call() 里,通常需要检查:
assert(msg.sender == UniswapV2Library.pairFor(factory, token0, token1));
也就是确认调用回调的确实是合法 Pair。
这是安全上非常重要的一步。
6. 最小 flash swap 调用长什么样?
假设我们想从某个 Pair 里先拿走 amount1Out 个 token1。
调用大概是:
pair.swap(
0,
amount1Out,
address(this),
abi.encode(someData)
);
这里:
amount0Out = 0
amount1Out = 想先拿走的 token1 数量
to = address(this),也就是当前合约
data = 非空,触发回调
Pair 会:
1. 把 amount1Out 个 token1 转给当前合约
2. 调用当前合约的 uniswapV2Call
3. 当前合约在回调中使用 token1
4. 当前合约把足够的 token 还给 Pair
5. Pair 检查 K
如果第 4 步没还够,第 5 步失败,整笔交易 revert。
7. Flash swap 必须还同一种 token 吗?
不一定。
这是 Uniswap V2 flash swap 很有意思的一点。
你可以:
拿走 token0,还 token0
拿走 token1,还 token1
拿走 token0,还 token1
拿走 token1,还 token0
甚至两个都拿走,然后两个都还一部分
Pair 不关心你“名义上借了什么,还了什么”。
Pair 只关心交易结束后:
扣除手续费后的 balance0 * balance1
是否不小于交易前 reserve0 * reserve1
也就是这段:
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'
);
这意味着 flash swap 本质上不是“借 A 还 A”。
它更像是:
你可以先拿走池子里的资产,但最终必须让池子满足 AMM 规则和手续费要求。
这比传统 flash loan 更灵活。
8. 如果借出 token0,再还 token0,手续费是多少?
假设你从 Pair 里 flash swap 拿走 token0,并且最后也用 token0 还。
直觉上手续费是 0.3%。
但这里有一个小坑。
如果你借出 amount0Out,最后还同一种 token0,那么需要还的数量略大于:
amount0Out * 1000 / 997
因为 Pair 的手续费模型是:
输入 amountIn,只有 amountIn * 997 / 1000 作为有效输入
如果你拿走了 token0,又还 token0,本质上相当于:
输出 token0
输入 token0
为了让扣费后的有效输入覆盖输出,你需要:
amountIn * 997 / 1000 >= amountOut
所以:
amountIn >= amountOut * 1000 / 997
实际代码里要向上取整。
比如借出:
1000 token0
需要还大约:
1000 * 1000 / 997
≈ 1003.009
也就是至少 1004 个最小整数单位,取决于 decimals 和精度。
这个等效费率略高于 0.3%,约是:
0.3009027%
因为手续费是对“还入数量”扣的,而不是对“借出数量”直接加的。
9. 如果借 token0,还 token1,本质就是 swap
如果你拿走 token0,最后还 token1,那就更像普通 swap:
你从池子买走 token0
用 token1 支付
只不过支付发生在回调中。
这时需要还多少 token1,可以用 getAmountIn() 计算。
比如:
uint amountRequired = UniswapV2Library.getAmountIn(
amount0Out,
reserve1,
reserve0
);
注意方向:
你想输出 token0
输入 token1
所以 reserveIn 是 token1 的 reserve
reserveOut 是 token0 的 reserve
这就和普通 exact output swap 一样。
所以 flash swap 可以理解成:
把普通 swap 的支付时机,从 swap 前移动到了回调中。
10. 为什么 flash swap 可以做套利?
最典型场景是两个交易市场价格不一致。
假设:
Uniswap 池子里:
1 ETH = 3000 USDC
另一个 DEX 上:
1 ETH = 3050 USDC
你可以做:
1. 从 Uniswap flash swap 借出 1 ETH
2. 去另一个 DEX 卖掉 1 ETH,拿到 3050 USDC
3. 用其中一部分 USDC 还给 Uniswap
4. 剩下 USDC 是利润
整个过程在一笔交易里完成。
如果最终没有利润,或者还不够 Uniswap,交易 revert。
这就是 flash swap 很适合套利的原因:
不需要自己先有本金
只需要交易最终能闭环
当然现实里还要考虑:
gas
滑点
MEV
交易排序
其他套利者竞争
链上套利不是简单的无风险捡钱,真实环境里竞争很激烈。
11. Flash swap 和 flash loan 的区别
它们很像,但不完全一样。
Flash loan
典型闪电贷逻辑是:
借出某个资产
回调
要求归还同一个资产 + fee
比如借 DAI,就还 DAI。
Flash swap
Uniswap V2 flash swap 更通用:
先输出 token0/token1
回调
最终只要 K 检查通过
你可以同币种还,也可以跨币种还。
所以 flash swap 更贴近 AMM 的交易模型。
它不是独立贷款模块。
它是 swap() 的自然扩展。
一句话:
flash loan 关心“借什么还什么”。
flash swap 关心“最终池子状态是否合法”。
12. 写一个最小 flash swap 合约骨架
下面是一个非常简化的结构,只展示流程,不建议直接上生产。
pragma solidity =0.6.6;
import './interfaces/IUniswapV2Pair.sol';
import './interfaces/IUniswapV2Callee.sol';
import './interfaces/IERC20.sol';
contract SimpleFlashSwap is IUniswapV2Callee {
address public factory;
constructor(address _factory) public {
factory = _factory;
}
function startFlashSwap(
address pair,
uint amount0Out,
uint amount1Out,
bytes calldata data
) external {
IUniswapV2Pair(pair).swap(
amount0Out,
amount1Out,
address(this),
data
);
}
function uniswapV2Call(
address sender,
uint amount0,
uint amount1,
bytes calldata data
) external override {
// 1. 校验 msg.sender 确实是合法 Pair
// 2. 解析 data
// 3. 使用借出的 token 执行套利/清算/其他逻辑
// 4. 计算需要归还的 token 数量
// 5. 把足够 token 转回 msg.sender,也就是 Pair
}
}
真正的合约里,最重要的是第 1 步:
校验回调来源
否则任何人都可以直接调用你的 uniswapV2Call(),诱导你的合约执行危险逻辑。
13. 回调里应该校验什么?
一个比较标准的校验包括:
address token0 = IUniswapV2Pair(msg.sender).token0();
address token1 = IUniswapV2Pair(msg.sender).token1();
assert(msg.sender == UniswapV2Library.pairFor(factory, token0, token1));
含义是:
1. 当前调用者 msg.sender 声称自己是 Pair
2. 我读取它的 token0/token1
3. 用官方 factory + token0/token1 重新计算合法 Pair 地址
4. 要求 msg.sender 等于这个合法 Pair
这能防止假 Pair 调用你的回调。
还可以检查:
require(sender == address(this), "INVALID_SENDER");
具体取决于你的调用设计。
如果你的合约只允许自己发起 flash swap,那么 sender 应该是当前合约地址。
14. 回调里的 data 通常放什么?
data 是自定义参数,Pair 不理解,也不处理。
它只是原样传给回调。
你可以把它用来编码:
1. 借出的 token 用途
2. 目标 DEX 地址
3. 最小利润
4. 中间交易路径
5. 还款 token
6. 调用发起者
7. 风控参数
比如:
bytes memory data = abi.encode(
tokenBorrow,
amountBorrow,
tokenRepay,
minProfit,
caller
);
回调中:
(
address tokenBorrow,
uint amountBorrow,
address tokenRepay,
uint minProfit,
address caller
) = abi.decode(data, (address, uint, address, uint, address));
这就是 flash swap 的灵活性来源。
Pair 不关心你怎么用这笔资金。
它只负责最后验账。
15. 回调里怎么还款?
回调结束前,必须把足够 token 转回 Pair。
在 uniswapV2Call() 里:
address pair = msg.sender;
因为是 Pair 调用你的回调。
如果要还 token0:
IERC20(token0).transfer(pair, amountRequired);
如果要还 token1:
IERC20(token1).transfer(pair, amountRequired);
还款不是调用 Pair 的某个 repay() 函数。
只是普通 ERC20 转账。
这和前面所有设计一致:
Pair 不主动拉 token。
Pair 最后看余额。
你只要在回调结束前把 token 转回 Pair,后面的 K 检查自然会识别到 amountIn。
16. Pair 如何知道你还了多少?
回调结束后,Pair 会执行:
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;
所以它并不需要你告诉它:
我还了多少
它自己看:
最终余额比“扣除输出后的理论余额”多多少
这就是 amountIn。
如果你没还够,amountIn 不足,后面的 K 检查失败。
如果你多还了,Pair 也不会退。
多还部分会留在池子里,便宜 LP。
Pair 只按最终余额验账,不负责退还多余输入。
17. Flash swap 的失败会怎样?
如果回调里没有还够,最后这里会失败:
require(
balance0Adjusted.mul(balance1Adjusted)
>= uint(_reserve0).mul(_reserve1).mul(1000**2),
'UniswapV2: K'
);
一旦失败,整个交易 revert。
这包括:
1. Pair 转给你的 token 会回滚
2. 你在回调里做的外部 DEX 交易会回滚
3. 你的中间转账会回滚
4. 所有状态变化都回滚
这就是为什么 flash swap 能“无抵押”。
不是因为 Pair 信任你。
而是因为 EVM 交易原子性保证:
要么全部成功,要么全部失败。
18. Flash swap 是否有重入风险?
有,所以 swap() 带了:
lock
也就是:
modifier lock() {
require(unlocked == 1, 'UniswapV2: LOCKED');
unlocked = 0;
_;
unlocked = 1;
}
flash swap 中,Pair 会外部调用:
to.uniswapV2Call(...)
这天然是重入风险点。
如果没有 lock,回调合约可以尝试再次进入同一个 Pair 的:
mint
burn
swap
skim
sync
lock 会阻止同一个 Pair 的重入。
不过要注意:
它只锁当前 Pair。
回调中仍然可以调用其他 Pair。
这也是套利、多跳、组合交易能成立的原因。
19. Flash swap 可以调用其他 Pair 吗?
可以。
lock 锁的是当前 Pair 合约的状态,不是整个 Uniswap 协议。
所以你从 Pair A flash swap 后,在回调里可以:
1. 调用 Pair B swap
2. 调用另一个 DEX
3. 调用 lending protocol
4. 调用清算合约
5. 调用 token 合约
只要最后能把 Pair A 还够即可。
这也是 flash swap 的组合性。
20. 一个套利型 flash swap 流程
假设两个市场价格不同:
Pair A:1 ETH = 3000 USDC
Pair B:1 ETH = 3100 USDC
你可以从 Pair A 借出 ETH,到 Pair B 卖掉。
流程:
1. 调用 Pair A.swap(amountETHOut, 0, address(this), data)
2. Pair A 转 ETH/WETH 给你的合约
3. Pair A 调用 uniswapV2Call
4. 你的合约把 ETH/WETH 拿到 Pair B 卖成 USDC
5. 用 USDC 中的一部分还给 Pair A
6. 剩下 USDC 作为利润
7. Pair A 做 K 检查
8. 交易成功
如果 Pair B 的价格不够好,导致卖出 USDC 后还不够 Pair A,交易失败回滚。
所以套利合约通常会在回调里检查:
require(profit >= minProfit, "INSUFFICIENT_PROFIT");
否则宁可 revert,不做亏本买卖。
21. 清算型 flash swap 流程
另一个常见用途是借贷协议清算。
假设某个借贷协议里,用户抵押 ETH 借 DAI,价格下跌后可被清算。
清算人需要先拿 DAI 去还债,换得折价 ETH 抵押品。
如果清算人没有 DAI,可以用 flash swap:
1. 从 Uniswap flash swap 借出 DAI
2. 拿 DAI 去借贷协议清算某个仓位
3. 获得折价 ETH
4. 把一部分 ETH 换成 DAI,或直接按 Pair 需求还款
5. 还 Uniswap
6. 剩余 ETH/DAI 是利润
这类操作很常见。
它的核心依然是:
一笔交易里先借资产,完成套利/清算,再还款。
22. Flash swap 和 Router 的关系
普通 Router swap 通常不会触发 flash swap,因为 _swap() 调用 Pair 时传的是:
new bytes(0)
例如:
pair.swap(amount0Out, amount1Out, to, new bytes(0));
data.length == 0,所以不会回调。
flash swap 一般由自定义合约直接调用 Pair.swap,并传入非空 data。
也就是说:
普通用户交易:Router
flash swap:自定义合约直接调 Pair
当然你也可以写一个专门支持 flash swap 的 Router,但 V2 标准 Router 不负责这个。
23. 为什么 V2 不需要单独 flashLoan 函数?
因为 swap() 已经具备 flash loan 需要的三个条件:
1. 能先把资产转出去
2. 能调用接收方回调
3. 能在最后检查资产是否足额回来
这三个条件在 swap() 里分别对应:
_safeTransfer(_token0, to, amount0Out);
_safeTransfer(_token1, to, amount1Out);
IUniswapV2Callee(to).uniswapV2Call(...);
require(balance0Adjusted * balance1Adjusted >= oldK);
所以 flash swap 不是额外功能。
它是 swap 机制自然长出来的一种用法。
这也是 Uniswap V2 源码很迷人的地方:
好的抽象不是把每个功能写成一个新函数,而是让一个核心规则自然覆盖更多场景。
24. Flash swap 的安全注意事项
如果你写 flash swap 合约,几个点非常重要。
第一,必须校验回调来源
不要让任何人都能调用你的 uniswapV2Call()。
至少检查:
msg.sender 是合法 Pair
sender 是你预期的发起者
否则可能被伪造回调攻击。
第二,要考虑滑点
你在回调里去其他 DEX 交易,也会遇到滑点。
必须设置最小输出或最大输入。
第三,要考虑利润检查
套利合约一定要检查最终利润:
profit >= minProfit
否则可能成功执行一笔亏损交易。
第四,要考虑 MEV
flash swap 套利交易很容易被夹、被抢跑、被复制。
真实环境里交易发送方式很重要。
第五,不要把 token 永久留在合约里
回调后剩余利润应该按预期转给调用者或策略地址。
闲置资产留在合约里,容易被后续逻辑误用或被攻击。
25. Flash swap 的源码直觉
我们把 flash swap 的源码直觉压缩一下:
Pair 先把 token 发出去。
如果 data 不为空,Pair 给接收方一次回调机会。
接收方在回调里可以任意操作。
回调结束后,Pair 看自己的最终余额。
只要 K 检查通过,交易成功。
否则全部回滚。
更短:
先拿钱,后验账;验不过,整单撤销。
这背后靠的是:
1. EVM 原子性
2. Pair 的乐观转账
3. uniswapV2Call 回调
4. 余额反推 amountIn
5. adjusted K 检查
6. lock 防重入
26. Flash swap 和前面章节的连接
到这里你会发现,flash swap 不是一个孤立知识点。
它几乎串起了前面所有内容:
swap 的乐观转账:
让先借资产成为可能。
amountIn 反推:
让 Pair 不需要知道你怎么还款。
K 检查:
让 Pair 能验证最终状态是否合法。
0.3% fee:
通过 adjusted balance 自动收取。
Router / Pair 分离:
flash swap 不依赖标准 Router。
lock:
防止回调重入同一个 Pair。
所以如果你真正理解了 swap(),flash swap 就不神秘。
它只是 swap() 的一个特殊使用方式。
小结
这一篇我们拆了 Uniswap V2 的 Flash Swap。
你现在应该能回答:
1. Flash swap 是怎么触发的?
调用 Pair.swap 时传入非空 data。
2. 回调函数叫什么?
uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data)。
3. Pair 为什么敢先转 token?
因为最后会做 K 检查,失败则整笔交易 revert。
4. flash swap 必须还同一种 token 吗?
不必须。只要最终 K 检查通过,可以还 token0、token1,或者组合还款。
5. 普通 swap 和 flash swap 的区别是什么?
普通 swap 通常输入提前到账;flash swap 通常在回调中还款。
6. 为什么 V2 不需要单独 flashLoan 函数?
因为 swap 的乐观转账 + 回调 + K 检查已经覆盖了 flash loan 语义。
7. 回调中最重要的安全检查是什么?
校验 msg.sender 是合法 Pair,sender 是预期调用者。
8. 如果回调后没还够会怎样?
K 检查失败,整笔交易 revert。
9. lock 在 flash swap 里有什么作用?
防止回调过程中重入同一个 Pair 的关键函数。
10. flash swap 常见用途是什么?
套利、清算、资金搬运、复杂组合交易。
到这里,Pair 的大部分核心机制已经讲完了。