区块链 区块链技术 比特币公众号手机端

读懂 Uniswap V2 的 swap 函数

liumuhui 4小时前 阅读数 1 #区块链

读懂 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 数量,其中一个可以为 0
  • to:接收地址
  • data:非空时回调 touniswapV2Call,用于闪电兑/闪电贷
  • 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 = 1000reserve1 = 1000K = 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 真的烧掉了。

典型场景:被抢跑

三明治攻击时间线:

  1. T0:Router 调用 getAmountIn 算出 amountIn = 112,基于当前 reserve = 1000/1000
  2. T1:套利机器人同一区块内先执行 swap,把池子价格打到 900/1112
  3. T2:你的交易执行,转入 112 个 token1,想取 100 个 token0

校验过程:

  • reserve0 = 900,取出 100 后理论剩 800
  • balance0 = 900 - 100 = 800
  • balance1 = 1112 + 112 = 1224
  • amount0In = 0amount1In = 112
  • balance0Adjusted = 800 * 1000 = 800,000
  • balance1Adjusted = 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,000 revert '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 内部

几个值得注意的实现细节:

  1. 乐观转账 + 回调 + 余额校验 三件套:一个机制覆盖普通兑换、闪电兑、闪电贷
  2. 余额差反推 amountIn:避免显式 transferFrom,兼容异型代币
  3. K 值校验放在最后:任何"取走"都必须由"补回"证明合法,否则整笔交易回滚
  4. 0.3% 手续费用 *1000 - *3 表达:在整数域精确计算,避免浮点
  5. {} 作用域解决 stack too deep:Solidity 0.5 时代常见技巧

理解 swap 的关键在于它的信任模型:

Pair 不算账,只查账;调用方算账,算错就回滚。

延迟信任 + 原子校验,让一个极简合约可以承载复杂的交易场景。

参考资源

  • Uniswap V2 白皮书
  • Uniswap V2 Core 源码
  • Uniswap V2 Periphery 源码
版权声明

本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门