Uniswap V2 Swap 函数深度解析

逐段拆解 swap 函数:先转出后验证的设计、闪电贷机制、含手续费的 K 不变量校验

9 分钟阅读
Uniswap V2 Swap 函数深度解析

Uniswap V2 Swap 函数深度解析

目录


一、swap 函数的整体设计哲学

Uniswap V2 的 swap 函数是整个协议中最精彩的一段代码。它的设计哲学可以用一句话概括:

不规定过程,只验证结果。

函数不关心你的代币从哪来、怎么来、什么时候来——它只在最后检查一件事:池子的 K 值(含手续费调整后)没有变小。只要结果合法,过程随便你怎么折腾。这一个设计同时解锁了三种能力:普通兑换、闪电贷、灵活的多源注资。

二、三个执行阶段

swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) 的执行分三步:

  1. 立刻把代币转出去——用户要的 amount0Out/amount1Out 先无条件转给 to 地址(这就是闪电贷的入口);
  2. 校验余额——检查当前合约的实际代币余额,确认有足够的代币被转了进来,使得带手续费的 K 不变量成立;
  3. 更新状态——把新余额写入 reserve 储备变量。

注意顺序:先给钱,后验账。如果第 2 步验不过,整笔交易 revert,第 1 步的转出也会回滚——区块链的原子性保证了”先给钱”不会真的丢钱。

三、关键洞察:函数里没有”转入”代币的代码

仔细读 swap 的源码会发现一个反直觉的事实:整个函数里没有任何一行代码把代币转入池子(没有 transferFrom)。

那输入代币怎么进来?两种方式:

  • 普通兑换:调用者(通常是 Router)在调用 swap 之前,先把输入代币 transfer 到 Pair 合约;
  • 闪电贷:在 swap 转出代币后的回调函数里,由借款人把代币还回来。

swap 只负责在最后”数一数账上多了多少钱”。

四、闪电贷(Flash Swap)机制

4.1 工作流程

闪电贷允许无抵押借币,流程:

  1. 调用 swap,把想借的数量填进 amount0Out/amount1Out,并传入非空的 data 参数;
  2. Pair 把代币转给 to
  3. 因为 data 非空,Pair 会回调 to 地址上的 uniswapV2Call(sender, amount0, amount1, data) 函数(to 必须实现 IUniswapV2Callee 接口);
  4. 借款人在这个回调里随意使用借来的代币(套利、清算、换抵押品……);
  5. 回调结束前必须归还本金 + 手续费(直接 transfer 回 Pair);
  6. 回调返回后,swap 继续执行 K 校验——还不够钱?整笔 revert,就像什么都没发生过。

4.2 为什么只有合约能用 swap

文章指出一个重要约束:只有智能合约能直接和 swap 函数交互。因为 EOA(普通钱包地址)无法在一笔交易里”先转 ERC20 给 Pair,再调用 swap”——EOA 一笔交易只能做一个调用。所以普通用户都是通过 Router 合约来间接使用 swap 的。

五、如何测量”转入了多少代币”

5.1 reserve 与 balance 的区别

Pair 合约维护两套数字:

概念来源含义
reserve0 / reserve1存储变量上次 _update 时记录的余额(账面值)
balance0 / balance1balanceOf(address(this)) 实时读取当前真实的代币余额

两者的差值,就能推断出”这笔交易期间有人转入了多少代币”。

5.2 amountIn 的计算逻辑与三个数值例子

核心代码(以 token X 为例):

amountXIn = balanceX > _reserveX - amountXOut
    ? balanceX - (_reserveX - amountXOut)
    : 0;

逻辑:_reserveX - amountXOut 是”如果没有人转入任何 X,转出之后应该剩下的余额”。如果实际余额比它大,多出来的部分就是净转入量;如果不大,说明没有净转入,记 0。

三个数值例子(账面 reserve 均为 10):

场景amountOut当前 balance计算amountIn
纯转入01212 − (10 − 0)2
纯转出733 不大于 (10 − 7)0
闪电贷借 6 还 861818 − (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 本身故意省略了用户保护,文章列出两个直接调用它的风险:

  1. 多付风险:amountIn 没有被强制为最优值——你转入太多代币,swap 照样通过,多付的部分白送给 LP;
  2. 不足额 revert 风险:amountOut 是写死的参数,如果由于交易排序(被抢跑)导致池子状态变化、你转入的量不再够支撑这个 amountOut,交易 revert,Gas 白烧。

所以官方注释明确说:这个函数应该由实现了安全检查的上层合约(Router)来调用。滑点保护(amountOutMin / amountInMax)全部在 Router 层实现——又一次体现核心-外围分离。

九、设计哲学总结

  • 验证终态而非规定过程 → 一套代码同时支持兑换、闪电贷、多源注资;
  • reserve(账面)与 balance(现实)的差分测量 → 不需要 transferFrom 也能知道你付了多少;
  • 手续费只看你”拿走了什么对应的输入” → 闪电贷自然纳入统一计费;
  • 用户保护下放给外围合约 → 核心代码保持最小攻击面。

十、动手练习项目:带闪电贷的 FlashPair

项目目标

在前两篇练习的池子基础上,实现一个完整复刻 swap 设计哲学FlashPair 合约:先转出、回调、事后 K 校验,并写一个闪电贷借款合约实际跑通借还流程,部署到 Sepolia 验证。

合约要求

1. FlashPair.sol

  • 状态:token0token1reserve0reserve1(uint112 不强求,uint256 即可);
  • seed(uint amount0, uint amount1):初始注资并同步 reserve(只许一次);
  • 核心函数,签名完全模仿 Uniswap:
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;

实现步骤(严格按本文三阶段):

  1. 校验 amount0Out > 0 || amount1Out > 0(错误 InsufficientOutput())、输出量小于对应 reserve(错误 InsufficientLiquidity())、to 不是两个代币地址;
  2. 先转出两种代币给 to
  3. data.length > 0,回调 IFlashCallee(to).flashCall(msg.sender, amount0Out, amount1Out, data)
  4. 读取两个真实 balance,用本文第五节的三元表达式计算 amount0Inamount1In
  5. 校验 amount0In > 0 || amount1In > 0(错误 InsufficientInput());
  6. K 校验(整数化版本):(balance0*1000 - amount0In*3) * (balance1*1000 - amount1In*3) >= reserve0 * reserve1 * 1000_000,不满足抛 KInvariantViolated()
  7. _update():reserve ← balance;
  8. 加最简单的重入锁 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)

  1. test_NormalSwap:通过 Router 路径完成一次兑换,输出量等于公式预测值;
  2. test_FlashLoan_Succeeds:FlashBorrower 借 100e18 token0,还 100e18*1000/997+1,swap 不 revert,且交易后 K 变大;
  3. test_FlashLoan_RevertWhen_Underpaid:借款人少还 1 wei,必须 revert KInvariantViolated
  4. test_AmountInMeasurement:直接给 pair 转 2e18 再调 swap(0,0,…) 之外——构造本文 5.2 节的三个场景,断言事件里的 amountIn 分别是 2、0、8(注意场景三需在回调中归还);
  5. testFuzz_KNeverDecreases

Sepolia 部署与验证步骤

  1. 部署 TokenX/TokenY/FlashPair/FlashBorrower(构造传 pair 地址)/Router;
  2. seed(1000e18, 1000e18)
  3. 给 FlashBorrower 转 10e18 token0 作为”手续费储备金”;
  4. Etherscan 调用 executeFlashLoan(pair, 100e18, 0),在交易日志里观察:Pair→Borrower 转出 100e18,Borrower→Pair 转回 ~100.3e18,Swap 事件 amount0In ≈ 100.3e18;
  5. 读 reserves 确认 K 变大了约 0.3e18 × 1000e18。

进阶挑战(可选)

  • 给 FlashBorrower 加一个故意”借了不还”的模式开关,在 Sepolia 上发一笔注定失败的交易,观察 revert 后链上状态毫发无损——亲身体会原子性如何让”先给钱”变得安全;
  • 思考并写注释回答:如果去掉重入锁,攻击者能否在 flashCall 回调里再次调用 swap 造成危害?

💬 评论