Uniswap V2 源码学习第十一篇:Flash Swap,为什么 V2 可以先借钱后还钱?

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

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 的大部分核心机制已经讲完了。

相关文章

0 条评论