读懂 Uniswap V2 的 swap 函数
读懂 Uniswap V2 的 swap 函数
Uniswap V2 的核心合约 UniswapV2Pair.sol 中,swap 函数只有 29 行,却同时支持了普通兑换、闪电兑和闪电贷三种场景,且不做任何 transferFrom 也能保证池子不被白嫖。本文拆解这段代码的实现思路,并讨论滑点与 MEV 防护。
一、源码
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);
}
整体逻辑可以拆成三段:乐观转出 → 余额反推 → K 值校验。
二、函数签名与基础校验
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock
amount0Out/amount1Out:要从池子取出的 token 数量,其中一个可以为 0to:接收地址data:非空时回调to的uniswapV2Call,用于闪电兑/闪电贷lock:重入锁
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
取出量必须严格小于储备量(不能掏空池子)。_reserve0/1 是把 storage 读到栈上做 gas 优化。
注意一个关键点:swap 不做 transferFrom 收款,只负责"打钱出去 + 余额校验"。对价代币必须由调用方在调用前(或回调中)转入池子。
三、乐观转账与回调
{
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));
}
花括号作用域 是为了规避 Solidity 0.5 时代的 stack too deep 编译错误:局部变量在出作用域时弹出栈,腾出空间。
乐观转账 是 Uniswap 的核心设计:先把代币打给 to,等回调结束后再校验是否被补齐。如果最后没补够,整笔交易 revert,代币自动退回。
uniswapV2Call 回调让 swap 同时覆盖三种用法:
- 普通兑换:Router 在 swap 前
transferFrom收款,data为空 - 闪电兑:先收到 token,在回调中完成套利或操作,再把欠款转回池子
- 闪电贷:完全借用,回调中归还本金 + 0.3% 手续费
回调结束后读取池子最新余额 balance0/1,此时已包含转出和转入两部分。
四、从余额差反推 amountIn
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');
逻辑:
- swap 前池子余额是
_reserve0 - 转走
amount0Out后,理论上应剩_reserve0 - amount0Out - 若实际余额
balance0大于这个理论值,差额就是amount0In(有人转回池子的部分) - 否则
amount0In = 0
这种"事后反推"避免了显式 transferFrom,能尽量兼容含税币、rebasing 等异型代币。Pair 不关心谁转的、何时转的,只看最终余额。
五、K 值校验与 0.3% 手续费
{
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');
}
恒定乘积公式 x * y = k 含 0.3% 手续费后的实现。
balance * 1000:余额放大 1000 倍- amountIn * 3:转入量中抽走 3/1000 作为手续费,等价于实际参与做市的转入量 =amountIn * 997/1000
校验条件两边都乘 1000² 避免浮点,等价于:
新 x * 新 y >= 旧 x * 旧 y (含手续费)
直观说:扣掉 0.3% 手续费后,池子的 x*y 必须不减少。
数值例子
池子 reserve0 = 1000,reserve1 = 1000,K = 1,000,000。用户想取 amount0Out = 100 个 token0,应该转入多少 token1?
设转入量为 x,扣除手续费后参与做市的量为 0.997x:
(1000 - 100) * (1000 + 0.997x) >= 1000 * 1000
900 * (1000 + 0.997x) >= 1,000,000
0.997x >= 111.111
x >= 111.445
至少需要转入约 111.445 个 token1。
六、对价代币计算:Router 的职责
Pair 不算账,只查账。对价代币数量由调用方(通常是 Router)在调用前算好。
恒定乘积公式(含手续费)
(x + Δx·(1-fee)) · (y - Δy) = x · y
其中 fee = 0.3% = 3/1000,即 1 - fee = 997/1000。
已知 amountOut 反求 amountIn
Δx = (x · Δy · 1000) / ((y - Δy) · 997)
向上取整避免误差:
amountIn = (reserveIn · amountOut · 1000) / ((reserveOut - amountOut) · 997) + 1
UniswapV2Library.getAmountIn 实现:
function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut) internal pure returns (uint amountIn) {
require(amountOut > 0, 'INSUFFICIENT_OUTPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'INSUFFICIENT_LIQUIDITY');
uint numerator = reserveIn.mul(amountOut).mul(1000);
uint denominator = reserveOut.sub(amountOut).mul(997);
amountIn = (numerator / denominator).add(1);
}
+1 是因为 Solidity 整数除法向下取整。如果直接用 numerator / denominator,结果会偏小,可能让 K 值略微下降导致校验失败。多收 1 个最小单位是保守做法。
已知 amountIn 求 amountOut
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
require(amountIn > 0, 'INSUFFICIENT_INPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'INSUFFICIENT_LIQUIDITY');
uint amountInWithFee = amountIn.mul(997);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(1000).add(amountInWithFee);
amountOut = numerator / denominator;
}
公式:
Δy = (Δx · 997 · y) / (x · 1000 + Δx · 997)
Router 调用流程
UniswapV2Router02 的 _swap 只负责把 amountOut 沿路径传递给每个 Pair:
function _swap(uint[] memory amounts, address[] memory path, address _to) internal {
for (uint i = 0; i < path.length - 1; i++) {
(address input, address output) = (path[i], path[i + 1]);
(address token0,) = UniswapV2Library.sortTokens(input, output);
uint amountOut = amounts[i + 1];
(uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOut) : (amountOut, uint(0));
address to = i < path.length - 2 ? UniswapV2Library.pairFor(factory, output, path[i + 2]) : _to;
IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output)).swap(
amount0Out, amount1Out, to, bytes("")
);
}
}
调用 _swap 之前,Router 先把计算好的 amounts[0] 通过 transferFrom 转入第一个 Pair:
TransferHelper.safeTransferFrom(
path[0], msg.sender,
UniswapV2Library.pairFor(factory, path[0], path[1]),
amounts[0]
);
七、滑点与 MEV:amountIn 不够会怎样
实战中最关键的问题。简短回答:交易 revert,报错 UniswapV2: K。
Pair 如何发现 amountIn 不够
swap 的 amountIn 不是入参,而是从余额差反推的。转少了,amountIn 就是那个较小的真实值,K 校验把它挡下。
revert 之后
由于乐观转账和 K 校验在同一笔交易内,EVM 原子性保证:
| 状态 | 结果 |
|---|---|
| 已转出的 amountOut | 退回 Pair |
| 已转入的对价代币 | 退回调用方 |
| reserve0/reserve1 | 不变 |
| 用户损失 | 仅 gas,且 gas 消耗到 revert 那一步为止,反而更贵 |
池子层面安全,但用户的 gas 真的烧掉了。
典型场景:被抢跑
三明治攻击时间线:
- T0:Router 调用
getAmountIn算出amountIn = 112,基于当前reserve = 1000/1000 - T1:套利机器人同一区块内先执行 swap,把池子价格打到
900/1112 - T2:你的交易执行,转入 112 个 token1,想取 100 个 token0
校验过程:
reserve0 = 900,取出 100 后理论剩 800balance0 = 900 - 100 = 800balance1 = 1112 + 112 = 1224amount0In = 0,amount1In = 112balance0Adjusted = 800 * 1000 = 800,000balance1Adjusted = 1224 * 1000 - 112 * 3 = 1,223,664- 乘积 =
978,931,200,000 - K =
900 * 1112 * 1,000,000 = 1,000,800,000,000 978,931,200,000 < 1,000,800,000,000revert 'UniswapV2: K'
同一个 amountOut 在新价格下需要的 amountIn 变大了,你转的钱不够。
八、三层防御
Pair 不防御滑点,只防白嫖。滑点防御责任在 Router。
Router 所有 swap 函数都要求用户传 amountOutMin:
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin, // 用户能接受的最少输出
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
Router 在调用 pair.swap 之前先校验:
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, 'INSUFFICIENT_OUTPUT_AMOUNT');
两种校验对比:
| 策略 | 触发方 | 错误信息 | Gas 消耗 |
|---|---|---|---|
| Router amountOutMin | Router | INSUFFICIENT_OUTPUT_AMOUNT | 少(swap 前 revert) |
| Pair K 校验 | Pair | UniswapV2: K | 多(已执行乐观转账、回调、余额读取) |
实践应优先靠 amountOutMin 拦截,K 校验是兜底。前端通常把 amountOutMin 设为预期输出的 99.5%,即容忍 0.5% 滑点。
K 校验作为唯一防线的场景
- 闪电兑/闪电贷:调用方在
uniswapV2Call回调中转入对价,Router 无法在 swap 前用amountOutMin拦截 - 自定义套利合约:绕过 Router 直接调 Pair
这两种情况下完全依赖 Pair 的 K 校验。
九、设计要点
| 角色 | 防御手段 | 时机 |
|---|---|---|
| 用户 | 设置 amountOutMin | 提交交易时 |
| Router | swap 前比对 amounts[last] >= amountOutMin | 链上、swap 之前 |
| Pair | K 值恒定校验 | 链上、swap 内部 |
几个值得注意的实现细节:
- 乐观转账 + 回调 + 余额校验 三件套:一个机制覆盖普通兑换、闪电兑、闪电贷
- 余额差反推 amountIn:避免显式
transferFrom,兼容异型代币 - K 值校验放在最后:任何"取走"都必须由"补回"证明合法,否则整笔交易回滚
- 0.3% 手续费用
*1000 - *3表达:在整数域精确计算,避免浮点 {}作用域解决 stack too deep:Solidity 0.5 时代常见技巧
理解 swap 的关键在于它的信任模型:
Pair 不算账,只查账;调用方算账,算错就回滚。
延迟信任 + 原子校验,让一个极简合约可以承载复杂的交易场景。
参考资源
- Uniswap V2 白皮书
- Uniswap V2 Core 源码
- Uniswap V2 Periphery 源码
版权声明
本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。
鸿途知科网
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。