Uniswap V2 Swap 函数深度解析
目录
- 一、swap 函数的整体设计哲学
- 二、三个执行阶段
- 三、关键洞察:函数里没有”转入”代币的代码
- 四、闪电贷(Flash Swap)机制
- 五、如何测量”转入了多少代币”
- 六、含手续费的 K 不变量校验
- 七、_update 函数
- 八、安全注意事项
- 九、设计哲学总结
- 十、动手练习项目:带闪电贷的 FlashPair
一、swap 函数的整体设计哲学
Uniswap V2 的 swap 函数是整个协议中最精彩的一段代码。它的设计哲学可以用一句话概括:
不规定过程,只验证结果。
函数不关心你的代币从哪来、怎么来、什么时候来——它只在最后检查一件事:池子的 K 值(含手续费调整后)没有变小。只要结果合法,过程随便你怎么折腾。这一个设计同时解锁了三种能力:普通兑换、闪电贷、灵活的多源注资。
二、三个执行阶段
swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) 的执行分三步:
- 立刻把代币转出去——用户要的
amount0Out/amount1Out先无条件转给to地址(这就是闪电贷的入口); - 校验余额——检查当前合约的实际代币余额,确认有足够的代币被转了进来,使得带手续费的 K 不变量成立;
- 更新状态——把新余额写入 reserve 储备变量。
注意顺序:先给钱,后验账。如果第 2 步验不过,整笔交易 revert,第 1 步的转出也会回滚——区块链的原子性保证了”先给钱”不会真的丢钱。
三、关键洞察:函数里没有”转入”代币的代码
仔细读 swap 的源码会发现一个反直觉的事实:整个函数里没有任何一行代码把代币转入池子(没有 transferFrom)。
那输入代币怎么进来?两种方式:
- 普通兑换:调用者(通常是 Router)在调用
swap之前,先把输入代币transfer到 Pair 合约; - 闪电贷:在 swap 转出代币后的回调函数里,由借款人把代币还回来。
swap 只负责在最后”数一数账上多了多少钱”。
四、闪电贷(Flash Swap)机制
4.1 工作流程
闪电贷允许无抵押借币,流程:
- 调用
swap,把想借的数量填进amount0Out/amount1Out,并传入非空的data参数; - Pair 把代币转给
to; - 因为
data非空,Pair 会回调to地址上的uniswapV2Call(sender, amount0, amount1, data)函数(to必须实现IUniswapV2Callee接口); - 借款人在这个回调里随意使用借来的代币(套利、清算、换抵押品……);
- 回调结束前必须归还本金 + 手续费(直接 transfer 回 Pair);
- 回调返回后,swap 继续执行 K 校验——还不够钱?整笔 revert,就像什么都没发生过。
4.2 为什么只有合约能用 swap
文章指出一个重要约束:只有智能合约能直接和 swap 函数交互。因为 EOA(普通钱包地址)无法在一笔交易里”先转 ERC20 给 Pair,再调用 swap”——EOA 一笔交易只能做一个调用。所以普通用户都是通过 Router 合约来间接使用 swap 的。
五、如何测量”转入了多少代币”
5.1 reserve 与 balance 的区别
Pair 合约维护两套数字:
| 概念 | 来源 | 含义 |
|---|---|---|
reserve0 / reserve1 | 存储变量 | 上次 _update 时记录的余额(账面值) |
balance0 / balance1 | balanceOf(address(this)) 实时读取 | 当前真实的代币余额 |
两者的差值,就能推断出”这笔交易期间有人转入了多少代币”。
5.2 amountIn 的计算逻辑与三个数值例子
核心代码(以 token X 为例):
amountXIn = balanceX > _reserveX - amountXOut
? balanceX - (_reserveX - amountXOut)
: 0;
逻辑:_reserveX - amountXOut 是”如果没有人转入任何 X,转出之后应该剩下的余额”。如果实际余额比它大,多出来的部分就是净转入量;如果不大,说明没有净转入,记 0。
三个数值例子(账面 reserve 均为 10):
| 场景 | amountOut | 当前 balance | 计算 | amountIn |
|---|---|---|---|---|
| 纯转入 | 0 | 12 | 12 − (10 − 0) | 2 |
| 纯转出 | 7 | 3 | 3 不大于 (10 − 7) | 0 |
| 闪电贷借 6 还 8 | 6 | 18 | 18 − (10 − 6) | 14?不对,看下文 |
第三个例子要小心理解:闪电贷借出 6 后账面应剩 4,回调里还回 8,实际余额 = 4 + 8 = 12,amountIn = 12 − 4 = 8。amountIn 反映的是”净增量视角下的输入”:
amountIn 在该代币有净流入时等于净流入量加上借出量的归还,在净流出时为 0。
这个 amountIn 接下来只用于一件事:算手续费。
六、含手续费的 K 不变量校验
6.1 K 其实不是恒定的
“恒定乘积”这个名字其实不准确——K 只是不允许变小,完全可以变大:
- 每笔交易的手续费留在池中 → K 变大;
- 有人直接给池子捐赠代币 → K 变大,所有 LP 受益。
合约真正防御的是 K 变小——那意味着 LP 的资产被白白取走。
6.2 手续费公式与整数化技巧
0.3% 手续费只对输入代币(amountIn)收取。校验公式:
(balance0 − 0.003 × amount0In) × (balance1 − 0.003 × amount1In) ≥ reserve0 × reserve1
Solidity 不支持小数,所以两边同乘 1000(左边每个因子乘 1000,右边乘 1000²):
uint balance0Adjusted = balance0 * 1000 - amount0In * 3;
uint balance1Adjusted = balance1 * 1000 - amount1In * 3;
require(
balance0Adjusted * balance1Adjusted >= uint(_reserve0) * _reserve1 * 1000**2,
'UniswapV2: K'
);
含义:把”扣掉手续费之后的余额”的乘积,与旧储备乘积比较。手续费部分不参与”维持 K”的计算,但它实实在在留在了池子里——所以校验通过后真实 K 必然变大。
6.3 手续费数值例子
- 普通兑换:转入 1000 个 token0 换出若干 token1 → 只对 1000 token0 收 0.3% = 3 个 token0 的费,token1 侧 amountIn = 0 不收费;
- 闪电贷:借 1000 个 token0(token1 不动)→ 必须归还 1000 + 3 个 token0。
可以看到:闪电贷和兑换被同一条公式统一处理——你从池子里拿走什么,就按拿走对应的输入侧付 0.3%。这是”只验证结果”设计的又一个红利:不需要为闪电贷写单独的计费逻辑。
七、_update 函数
校验通过后,_update(balance0, balance1, _reserve0, _reserve1) 收尾:
reserve0 = balance0
reserve1 = balance1
把当前真实余额固化为新的账面储备。这个函数里还有一大段 TWAP 预言机的累加逻辑(用到旧的 reserve 值和时间差),将在 TWAP 篇详细讲解;它的核心职责就是同步账面与现实。
八、安全注意事项
swap 本身故意省略了用户保护,文章列出两个直接调用它的风险:
- 多付风险:amountIn 没有被强制为最优值——你转入太多代币,swap 照样通过,多付的部分白送给 LP;
- 不足额 revert 风险:amountOut 是写死的参数,如果由于交易排序(被抢跑)导致池子状态变化、你转入的量不再够支撑这个 amountOut,交易 revert,Gas 白烧。
所以官方注释明确说:这个函数应该由实现了安全检查的上层合约(Router)来调用。滑点保护(amountOutMin / amountInMax)全部在 Router 层实现——又一次体现核心-外围分离。
九、设计哲学总结
- 验证终态而非规定过程 → 一套代码同时支持兑换、闪电贷、多源注资;
- reserve(账面)与 balance(现实)的差分测量 → 不需要 transferFrom 也能知道你付了多少;
- 手续费只看你”拿走了什么对应的输入” → 闪电贷自然纳入统一计费;
- 用户保护下放给外围合约 → 核心代码保持最小攻击面。
十、动手练习项目:带闪电贷的 FlashPair
项目目标
在前两篇练习的池子基础上,实现一个完整复刻 swap 设计哲学的 FlashPair 合约:先转出、回调、事后 K 校验,并写一个闪电贷借款合约实际跑通借还流程,部署到 Sepolia 验证。
合约要求
1. FlashPair.sol
- 状态:
token0、token1、reserve0、reserve1(uint112 不强求,uint256 即可); seed(uint amount0, uint amount1):初始注资并同步 reserve(只许一次);- 核心函数,签名完全模仿 Uniswap:
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
实现步骤(严格按本文三阶段):
- 校验
amount0Out > 0 || amount1Out > 0(错误InsufficientOutput())、输出量小于对应 reserve(错误InsufficientLiquidity())、to不是两个代币地址; - 先转出两种代币给
to; - 若
data.length > 0,回调IFlashCallee(to).flashCall(msg.sender, amount0Out, amount1Out, data); - 读取两个真实
balance,用本文第五节的三元表达式计算amount0In、amount1In; - 校验
amount0In > 0 || amount1In > 0(错误InsufficientInput()); - K 校验(整数化版本):
(balance0*1000 - amount0In*3) * (balance1*1000 - amount1In*3) >= reserve0 * reserve1 * 1000_000,不满足抛KInvariantViolated(); _update():reserve ← balance;- 加最简单的重入锁 modifier(一个
bool locked,模仿 Uniswap 的lock),并发出事件Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to)。
2. FlashBorrower.sol(借款人示范合约)
- 实现
flashCall(address sender, uint amount0, uint amount1, bytes calldata data); - 逻辑:收到借款后,计算应还金额 = 借款 × 1000 / 997 + 1(向上取整覆盖 0.3% 费),从自己预存的代币余额中 transfer 回 Pair;
- 提供
executeFlashLoan(address pair, uint amount0, uint amount1)入口供你在 Etherscan 上触发; - 在回调里校验
msg.sender == 预期的 pair 地址(错误UntrustedCaller())——这是真实闪电贷接入方必须有的安全检查。
3. SwapRouterLite.sol(可选但推荐)
swapExactIn(address pair, address tokenIn, uint amountIn, uint minOut):先transferFrom用户的输入代币到 pair,再按 getAmountOut 公式算出输出、调用pair.swap,体会”Router 先转币、Pair 后验账”的真实调用顺序。
测试要求(Foundry)
test_NormalSwap:通过 Router 路径完成一次兑换,输出量等于公式预测值;test_FlashLoan_Succeeds:FlashBorrower 借 100e18 token0,还 100e18*1000/997+1,swap 不 revert,且交易后 K 变大;test_FlashLoan_RevertWhen_Underpaid:借款人少还 1 wei,必须 revertKInvariantViolated;test_AmountInMeasurement:直接给 pair 转 2e18 再调 swap(0,0,…) 之外——构造本文 5.2 节的三个场景,断言事件里的 amountIn 分别是 2、0、8(注意场景三需在回调中归还);testFuzz_KNeverDecreases。
Sepolia 部署与验证步骤
- 部署 TokenX/TokenY/FlashPair/FlashBorrower(构造传 pair 地址)/Router;
seed(1000e18, 1000e18);- 给 FlashBorrower 转 10e18 token0 作为”手续费储备金”;
- Etherscan 调用
executeFlashLoan(pair, 100e18, 0),在交易日志里观察:Pair→Borrower 转出 100e18,Borrower→Pair 转回 ~100.3e18,Swap事件 amount0In ≈ 100.3e18; - 读 reserves 确认 K 变大了约 0.3e18 × 1000e18。
进阶挑战(可选)
- 给 FlashBorrower 加一个故意”借了不还”的模式开关,在 Sepolia 上发一笔注定失败的交易,观察 revert 后链上状态毫发无损——亲身体会原子性如何让”先给钱”变得安全;
- 思考并写注释回答:如果去掉重入锁,攻击者能否在 flashCall 回调里再次调用 swap 造成危害?