02.Uniswap V2 Swap 操作

这份学习笔记将Uniswap V2的swap操作进行了深入剖析,涵盖了其核心机制、数学原理和代码实现,以及手续费的计算方式。

核心摘要 (Key Takeaways)

  • Uniswap V2 架构与路由灵活性:V2引入了任何ERC20代币之间直接交换的能力,不再强制通过ETH作为中介。用户通过UniswapV2Router02合约进行交互,该合约负责寻找并协调UniswapV2Pair合约完成交易,支持单跳和多跳(多级路由)交换。
  • 两种核心交换类型:V2提供了两种主要的swap函数:
    1. swapExactTokensForTokens:用户指定精确的输入代币数量,系统计算并返回尽可能多的输出代币。
    2. swapTokensForExactTokens:用户指定精确的输出代币数量,系统计算所需的最小输入代币数量。
  • 0.3%固定手续费机制:每次swap操作都会对输入代币收取0.3%(千分之三)的手续费。这意味着实际用于AMM计算的输入量是用户提供量的99.7%。多跳交易会累积手续费。
  • 数学原理与代码实现UniswapV2Library中的getAmountOutgetAmountIn函数是核心计算逻辑,它们基于恒定乘积做市商(AMM)公式,并融入了0.3%的手续费计算,确保了交易的准确性。
  • 滑点与MEV攻击:用户可以通过设置amountOutMin(最小输出量)来控制滑点。然而,这种机制也可能被MEV(矿工可提取价值)套利者利用,通过三明治攻击(Front-running + Back-running)来捕获用户设定的最小输出与实际可能输出之间的差价。

1. Uniswap V2 核心架构与合约

Uniswap V2 的代码主要存在于两个仓库:

  • v2-periphery:https://github.com/Uniswap/v2-periphery
  • v2-core:https://github.com/Uniswap/v2-core

其核心合约有三个:

  • UniswapV2Pair.sol:交易对合约,存储流动性,执行实际的swap操作。
  • UniswapV2Factory.sol:工厂合约,负责部署和管理所有的Pair合约。
  • UniswapV2Router02.sol:路由合约,用户与Uniswap V2交互的主要入口,负责找到合适的交易对并协调交易流程。它依赖于UniswapV2Library进行核心计算。

2. 用户交换流程 (单交易对)

用户想要完成一次代币交换(例如 DAI 换 USDT),通常会经历以下步骤:

sequenceDiagram
    actor User
    participant Router02 as UniswapV2Router02.sol
    participant Pair as UniswapV2Pair.sol (DAI/USDT)

    User->>Router02: 1. 调用 swapExactTokensForTokens (DAI, USDT)
    Note over Router02: (内部逻辑) 检查参数, 计算路径
    Router02->>Pair: 2. 调用 transferFrom(User, Pair, amountIn) - Router02将用户DAI转入Pair (需用户预先授权)
    Pair->>Pair: 3. 内部执行 swap 逻辑 (AMM计算, 扣除手续费)
    Pair-->>Pair: (内部) 更新流动性储备
    Pair->>User: 4. 调用 transfer(User, amountOut) - Pair将USDT转给用户
    Router02-->>User: (交易完成)
sequenceDiagram
    actor User
    participant Router02 as UniswapV2Router02.sol
    participant Pair as UniswapV2Pair.sol (DAI/USDT)

    User->>Router02: 1. 调用 swapExactTokensForTokens (DAI, USDT)
    Note over Router02: (内部逻辑) 检查参数, 计算路径
    Router02->>Pair: 2. 调用 transferFrom(User, Pair, amountIn) - Router02将用户DAI转入Pair (需用户预先授权)
    Pair->>Pair: 3. 内部执行 swap 逻辑 (AMM计算, 扣除手续费)
    Pair-->>Pair: (内部) 更新流动性储备
    Pair->>User: 4. 调用 transfer(User, amountOut) - Pair将USDT转给用户
    Router02-->>User: (交易完成)
sequenceDiagram
    actor User
    participant Router02 as UniswapV2Router02.sol
    participant Pair as UniswapV2Pair.sol (DAI/USDT)

    User->>Router02: 1. 调用 swapExactTokensForTokens (DAI, USDT)
    Note over Router02: (内部逻辑) 检查参数, 计算路径
    Router02->>Pair: 2. 调用 transferFrom(User, Pair, amountIn) - Router02将用户DAI转入Pair (需用户预先授权)
    Pair->>Pair: 3. 内部执行 swap 逻辑 (AMM计算, 扣除手续费)
    Pair-->>Pair: (内部) 更新流动性储备
    Pair->>User: 4. 调用 transfer(User, amountOut) - Pair将USDT转给用户
    Router02-->>User: (交易完成)

流程说明

  1. 用户调用路由合约:用户首先与UniswapV2Router02.sol合约进行交互,调用其swap方法。
  2. 转账至交易对:路由合约(作为用户预先授权的spender)会调用transferFrom函数,将用户待交换的代币(例如 DAI)从用户地址转入对应的UniswapV2Pair合约(DAI/USDT 交易对)。
  3. 交易对内部交换Pair合约收到代币后,在其内部根据恒定乘积做市商(AMM)公式执行交换逻辑,并扣除手续费。
  4. 转账回用户Pair合约将交换得到的代币(例如 USDT)通过transfer函数转回给用户。

3. 两种主要的 Swap 函数类型

UniswapV2Router02.sol提供了两类主要的swap函数,其核心区别在于exact关键字的位置:

3.1 swapExactTokensForTokens (和 swapExactETHForTokens)

function swapExactTokensForTokens(
    uint amountIn,
    uint amountOutMin,
    address[] calldata path,
    address to,
    uint deadline
) external virtual override ensure(deadline) returns (uint[] memory amounts) {
    amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
    require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
    TransferHelper.safeTransferFrom(
        path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
    );
    _swap(amounts, path, to);
}
  • 含义:用户精确指定想要投入的代币数量(amountIn),系统计算并返回能够获得的最大输出代币数量(amountOut)。
  • 特点:输入量确定,输出量不确定(但用户可设置最小输出量amountOutMin来控制滑点)。
  • 例子:我有100个 DAI,想换成 USDT,我不知道能换多少 USDT,但我要求至少换到 95 USDT。

3.2 swapTokensForExactTokens (和 swapTokensForExactETH)

    function swapTokensForExactTokens(
        uint amountOut,
        uint amountInMax,
        address[] calldata path,
        address to,
        uint deadline
    ) external virtual override ensure(deadline) returns (uint[] memory amounts) {
        amounts = UniswapV2Library.getAmountsIn(factory, amountOut, path);
        require(amounts[0] <= amountInMax, 'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT');
        TransferHelper.safeTransferFrom(
            path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
        );
        _swap(amounts, path, to);
    }
  • 含义:用户精确指定想要获得的代币数量(amountOut),系统计算并要求用户提供完成此交换所需的最小输入代币数量(amountIn)。
  • 特点:输出量确定,输入量不确定(但用户可设置最大输入量amountInMax来控制滑点)。
  • 例子:我想要获得 100 个 USDT,我不知道需要多少 DAI 来换,但我最多只能提供 105 DAI。

swapExactETHForTokensswapTokensForExactETH 是为了方便 ETH 与 ERC20 代币交换而设计的特殊版本,因为 ETH 不是 ERC20 代币,需要特殊处理(例如,在内部进行 WETH 的封装/解封装)。

4. 多跳(Multi-Hop)交换机制

Uniswap V2 的一个重要特性是支持任意 ERC20 代币之间的交换,即使它们之间没有直接的交易对。这通过**多跳路由(Multi-Hop Path)**实现。

  • 原理:用户可以通过中间代币进行多次交换。例如,如果 DAI/MKR 交易对不存在,但存在 DAI/USDTUSDT/MKR 交易对,用户可以先将 DAI 换成 USDT,再将 USDT 换成 MKR。
  • path参数:路由合约的swap函数接受一个path参数,这是一个代币地址数组,定义了交换的路径。
    • 例子path = [DAI_Address, USDT_Address, MKR_Address] 表示 DAI -> USDT -> MKR
  • 路由合约内部逻辑UniswapV2Router02合约内部会有一个for循环,遍历path中的每一个交易对,依次执行transferFromswaptransfer操作,直到完成整个路径的交换。
  • 手续费累积每一次独立的swap操作都会收取0.3%的手续费。因此,多跳交易会累积手续费,用户通常会倾向于选择最短的路径以减少费用。
  • 最优路径选择:Uniswap 官方前端或套利机器人可能会根据市场价差,选择非最短路径,以实现更高的最终收益,即使这意味着支付更多的手续费。

多跳交换流程示例 (DAI -> USDT -> MKR)

sequenceDiagram
    actor User
    participant Router02 as UniswapV2Router02.sol
    participant PairDAIUSDT as UniswapV2Pair.sol (DAI/USDT)
    participant PairUSDTMKR as UniswapV2Pair.sol (USDT/MKR)

    User->>Router02: 1. 调用 swapExactTokensForTokens(amountInDAI, amountOutMinMKR, path=[DAI, USDT, MKR], to=User, deadline)
    Note over Router02: (内部逻辑) 根据path遍历交易对

    activate Router02
    Router02->>PairDAIUSDT: 2. transferFrom(User, PairDAIUSDT, amountInDAI) - Router02将用户DAI转入DAI/USDT Pair (需用户预先授权)
    activate PairDAIUSDT
    PairDAIUSDT->>PairDAIUSDT: 3. 内部执行 swap (DAI -> USDT)
    PairDAIUSDT-->>PairDAIUSDT: (内部) 更新DAI/USDT储备
    deactivate PairDAIUSDT
    Router02->>PairUSDTMKR: 4. transfer(PairDAIUSDT, PairUSDTMKR, amountOutUSDT_from_DAI) - 中间USDT从DAI/USDT Pair转入USDT/MKR Pair
    activate PairUSDTMKR
    PairUSDTMKR->>PairUSDTMKR: 5. 内部执行 swap (USDT -> MKR)
    PairUSDTMKR-->>PairUSDTMKR: (内部) 更新USDT/MKR储备
    deactivate PairUSDTMKR
    Router02->>User: 6. transfer(PairUSDTMKR, User, amountOutMKR_final) - 最终MKR从USDT/MKR Pair转给用户
    deactivate Router02
    Router02-->>User: (交易完成)
sequenceDiagram
    actor User
    participant Router02 as UniswapV2Router02.sol
    participant PairDAIUSDT as UniswapV2Pair.sol (DAI/USDT)
    participant PairUSDTMKR as UniswapV2Pair.sol (USDT/MKR)

    User->>Router02: 1. 调用 swapExactTokensForTokens(amountInDAI, amountOutMinMKR, path=[DAI, USDT, MKR], to=User, deadline)
    Note over Router02: (内部逻辑) 根据path遍历交易对

    activate Router02
    Router02->>PairDAIUSDT: 2. transferFrom(User, PairDAIUSDT, amountInDAI) - Router02将用户DAI转入DAI/USDT Pair (需用户预先授权)
    activate PairDAIUSDT
    PairDAIUSDT->>PairDAIUSDT: 3. 内部执行 swap (DAI -> USDT)
    PairDAIUSDT-->>PairDAIUSDT: (内部) 更新DAI/USDT储备
    deactivate PairDAIUSDT
    Router02->>PairUSDTMKR: 4. transfer(PairDAIUSDT, PairUSDTMKR, amountOutUSDT_from_DAI) - 中间USDT从DAI/USDT Pair转入USDT/MKR Pair
    activate PairUSDTMKR
    PairUSDTMKR->>PairUSDTMKR: 5. 内部执行 swap (USDT -> MKR)
    PairUSDTMKR-->>PairUSDTMKR: (内部) 更新USDT/MKR储备
    deactivate PairUSDTMKR
    Router02->>User: 6. transfer(PairUSDTMKR, User, amountOutMKR_final) - 最终MKR从USDT/MKR Pair转给用户
    deactivate Router02
    Router02-->>User: (交易完成)
sequenceDiagram
    actor User
    participant Router02 as UniswapV2Router02.sol
    participant PairDAIUSDT as UniswapV2Pair.sol (DAI/USDT)
    participant PairUSDTMKR as UniswapV2Pair.sol (USDT/MKR)

    User->>Router02: 1. 调用 swapExactTokensForTokens(amountInDAI, amountOutMinMKR, path=[DAI, USDT, MKR], to=User, deadline)
    Note over Router02: (内部逻辑) 根据path遍历交易对

    activate Router02
    Router02->>PairDAIUSDT: 2. transferFrom(User, PairDAIUSDT, amountInDAI) - Router02将用户DAI转入DAI/USDT Pair (需用户预先授权)
    activate PairDAIUSDT
    PairDAIUSDT->>PairDAIUSDT: 3. 内部执行 swap (DAI -> USDT)
    PairDAIUSDT-->>PairDAIUSDT: (内部) 更新DAI/USDT储备
    deactivate PairDAIUSDT
    Router02->>PairUSDTMKR: 4. transfer(PairDAIUSDT, PairUSDTMKR, amountOutUSDT_from_DAI) - 中间USDT从DAI/USDT Pair转入USDT/MKR Pair
    activate PairUSDTMKR
    PairUSDTMKR->>PairUSDTMKR: 5. 内部执行 swap (USDT -> MKR)
    PairUSDTMKR-->>PairUSDTMKR: (内部) 更新USDT/MKR储备
    deactivate PairUSDTMKR
    Router02->>User: 6. transfer(PairUSDTMKR, User, amountOutMKR_final) - 最终MKR从USDT/MKR Pair转给用户
    deactivate Router02
    Router02-->>User: (交易完成)

流程说明

  1. 用户调用路由合约:用户调用UniswapV2Router02.solswap方法,并传入一个包含所有中间代币的path数组。
  2. 第一跳:DAI -> USDT
    • 路由合约(作为用户预先授权的spender)会调用transferFrom函数,将初始代币(DAI)从用户地址转入第一个交易对(DAI/USDT Pair)。
    • DAI/USDT Pair 内部执行 swap,将 DAI 换成 USDT,并扣除第一次手续费。
    • 路由合约将生成的 USDT 从 DAI/USDT Pair 转入下一个交易对(USDT/MKR Pair)。
  3. 第二跳:USDT -> MKR
    • USDT/MKR Pair 收到 USDT 后,内部执行 swap,将 USDT 换成 MKR,并扣除第二次手续费。
    • 路由合约将最终生成的 MKR 从 USDT/MKR Pair 转回给用户。

这个过程可以推广到任意多跳,每次跳跃都会在相应的交易对中完成一次独立的 swap 操作和手续费扣除。

5. 滑点控制与 MEV (三明治攻击)

  • 滑点控制
    • 对于swapExactTokensForTokens,用户通过amountOutMin参数设定能接受的最小输出代币数量。
    • 对于swapTokensForExactTokens,用户通过amountInMax参数设定愿意支付的最大输入代币数量。
    • 这些参数用于防止因交易执行时市场价格波动导致的用户损失。
  • MEV (Miner Extractable Value) 与三明治攻击
    • 例子:用户想用 1 WETH 换 USDT,市场价 2000 USDT,用户设置 amountOutMin = 1950 USDT
    • 假设实际计算结果可以得到 1990 USDT。
    • MEV 机器人(或科学家)看到这笔交易有利可图(1990 - 1950 = 40 USDT 的潜在利润空间)。
    • 三明治攻击
      1. Front-run:MEV 机器人先于用户交易,以略微改变价格的方式进行一笔小额交易,将池子价格推向对用户不利的方向。
      2. User’s Transaction:用户的交易执行,由于价格被推高,用户只能获得接近 amountOutMin 的数量(例如 1950 USDT)。
      3. Back-run:MEV 机器人在用户交易之后,立即执行另一笔交易,将池子价格恢复到接近原始状态,从而捕获之前推高价格所产生的利润。
    • MEV 科学家通过这种方式,将用户本可以获得的额外收益(例如上述的 40 USDT)收入囊中。MEV 的实现与用户设定的这些参数密切相关。

6. Uniswap V2 手续费机制与数学推导

Uniswap V2 每次swap操作都会对输入代币收取 0.3% (0.003) 的固定手续费。这意味着实际用于 AMM 计算的输入量是用户提供量的 (1 - 0.003) 倍,即 0.997 倍。

6.1 恒定乘积做市商 (AMM) 公式

基础公式:XY=KX \cdot Y = K 其中:

  • X0,Y0X_0, Y_0:交易前池中两种代币的储备量。
  • KK:常数乘积。
  • DXDX:用户输入的代币数量。
  • DYDY:用户获得的代币数量。

考虑手续费后,实际进入池子的输入量为 (1F)DX(1 - F) \cdot DX,其中 F=0.003F = 0.003。 因此,AMM 恒定乘积公式变为:

(X0+(1F)DX)(Y0DY)=X0Y0 (X_0 + (1 - F) \cdot DX) \cdot (Y_0 - DY) = X_0 \cdot Y_0

6.2 getAmountOut (已知输入 DXDX,求输出 DYDY)

这个函数用于 swapExactTokensForTokens 场景。

function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
    require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
    require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
    uint amountInWithFee = amountIn.mul(997);
    uint numerator = amountInWithFee.mul(reserveOut);
    uint denominator = reserveIn.mul(1000).add(amountInWithFee);
    amountOut = numerator / denominator;
}

推导过程

  1. 从考虑手续费的 AMM 公式开始: (X0+(1F)DX)(Y0DY)=X0Y0 (X_0 + (1 - F) \cdot DX) \cdot (Y_0 - DY) = X_0 \cdot Y_0
  2. 展开并整理,目标是解出 DYDYY0DY=X0Y0X0+(1F)DX Y_0 - DY = \frac{X_0 \cdot Y_0}{X_0 + (1 - F) \cdot DX} DY=Y0X0Y0X0+(1F)DX DY = Y_0 - \frac{X_0 \cdot Y_0}{X_0 + (1 - F) \cdot DX} DY=Y0(X0+(1F)DX)X0Y0X0+(1F)DX DY = \frac{Y_0 \cdot (X_0 + (1 - F) \cdot DX) - X_0 \cdot Y_0}{X_0 + (1 - F) \cdot DX} DY=X0Y0+(1F)DXY0X0Y0X0+(1F)DX DY = \frac{X_0 \cdot Y_0 + (1 - F) \cdot DX \cdot Y_0 - X_0 \cdot Y_0}{X_0 + (1 - F) \cdot DX} DY=(1F)DXY0X0+(1F)DX DY = \frac{(1 - F) \cdot DX \cdot Y_0}{X_0 + (1 - F) \cdot DX}
  3. 代入手续费率 F=0.003F = 0.003DY=0.997DXY0X0+0.997DX DY = \frac{0.997 \cdot DX \cdot Y_0}{X_0 + 0.997 \cdot DX}
  4. 为了避免浮点数计算,通常将分子分母同乘以 1000: DY=997DXY01000X0+997DX DY = \frac{997 \cdot DX \cdot Y_0}{1000 \cdot X_0 + 997 \cdot DX}
    • 其中,X0X_0 对应 reserveInY0Y_0 对应 reserveOut

代码实现 (UniswapV2Library.sol 中的 getAmountOut)

// reserveIn: X0, reserveOut: Y0, amountIn: DX
uint numerator = amountIn.mul(997).mul(reserveOut); // 997 * DX * Y0
uint denominator = reserveIn.mul(1000).add(amountIn.mul(997)); // 1000 * X0 + 997 * DX
amountOut = numerator / denominator; // DY

6.3 getAmountIn (已知输出 DYDY,求输入 DXDX)

这个函数用于 swapTokensForExactTokens 场景。

function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut) internal pure returns (uint amountIn) {
    require(amountOut > 0, 'UniswapV2Library: INSUFFICIENT_OUTPUT_AMOUNT');
    require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
    uint numerator = reserveIn.mul(amountOut).mul(1000);
    uint denominator = reserveOut.sub(amountOut).mul(997);
    amountIn = (numerator / denominator).add(1);
}

推导过程

  1. 从考虑手续费的 AMM 公式开始: (X0+(1F)DX)(Y0DY)=X0Y0 (X_0 + (1 - F) \cdot DX) \cdot (Y_0 - DY) = X_0 \cdot Y_0
  2. 展开并整理,目标是解出 DXDXX0+(1F)DX=X0Y0Y0DY X_0 + (1 - F) \cdot DX = \frac{X_0 \cdot Y_0}{Y_0 - DY} (1F)DX=X0Y0Y0DYX0 (1 - F) \cdot DX = \frac{X_0 \cdot Y_0}{Y_0 - DY} - X_0 (1F)DX=X0Y0X0(Y0DY)Y0DY (1 - F) \cdot DX = \frac{X_0 \cdot Y_0 - X_0 \cdot (Y_0 - DY)}{Y_0 - DY} (1F)DX=X0Y0X0Y0+X0DYY0DY (1 - F) \cdot DX = \frac{X_0 \cdot Y_0 - X_0 \cdot Y_0 + X_0 \cdot DY}{Y_0 - DY} (1F)DX=X0DYY0DY (1 - F) \cdot DX = \frac{X_0 \cdot DY}{Y_0 - DY} DX=X0DY(Y0DY)(1F) DX = \frac{X_0 \cdot DY}{(Y_0 - DY) \cdot (1 - F)}
  3. 代入手续费率 F=0.003F = 0.003DX=X0DY(Y0DY)0.997 DX = \frac{X_0 \cdot DY}{(Y_0 - DY) \cdot 0.997}
  4. 为了避免浮点数计算,通常将分子分母同乘以 1000: DX=1000X0DY(Y0DY)997 DX = \frac{1000 \cdot X_0 \cdot DY}{(Y_0 - DY) \cdot 997}
    • 其中,X0X_0 对应 reserveInY0Y_0 对应 reserveOut

代码实现 (UniswapV2Library.sol 中的 getAmountIn)

// reserveIn: X0, reserveOut: Y0, amountOut: DY
uint numerator = reserveIn.mul(amountOut).mul(1000); // 1000 * X0 * DY
uint denominator = reserveOut.sub(amountOut).mul(997); // (Y0 - DY) * 997
amountIn = (numerator / denominator).add(1); // DX (向上取整)
  • .add(1) 是为了向上取整,确保用户提供的输入量足够满足精确输出。

这些数学公式是 Uniswap V2 能够高效、透明地进行代币交换的核心,也是理解其机制的关键。